diff --git a/.idea/markdown.xml b/.idea/markdown.xml new file mode 100644 index 00000000..c61ea334 --- /dev/null +++ b/.idea/markdown.xml @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/NOTICE b/NOTICE new file mode 100644 index 00000000..2eceb106 --- /dev/null +++ b/NOTICE @@ -0,0 +1,5 @@ +Socialite includes the following third-party software/code: + +NanoHTTPD +Copyright (c) 2012-2013 by Paul S. Hawke, 2001, 2005-2013 by Jarno Elonen, 2010 by Konstantinos Togias +License: Modified BSD License (see licenses/NANOHTTPD_LICENSE) diff --git a/README.md b/README.md index ea20af25..0e2f258c 100644 --- a/README.md +++ b/README.md @@ -107,3 +107,8 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ``` + +--- +**Third-Party Licenses** + +SociaLite includes the [NanoHTTPD](https://github.com/NanoHttpd/nanohttpd) library, which is licensed under a Modified BSD License. See the [NOTICE](NOTICE) file for more details. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6ec7784a..1ee4f639 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -43,7 +43,7 @@ android { defaultConfig { applicationId = "com.google.android.samples.socialite" - minSdk = 21 + minSdk = 23 targetSdk = 35 versionCode = 1 versionName = "1.0" @@ -166,6 +166,8 @@ dependencies { implementation(libs.media3.transformer) implementation(libs.media3.ui) implementation(libs.media3.ui.compose) + implementation(libs.media3.cast) + implementation(libs.nanohttpd) implementation(libs.hilt.android) implementation(libs.hilt.navigation.compose) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index cc3b47a4..4baeab60 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -49,6 +49,12 @@ android:theme="@style/Theme.Social" tools:targetApi="34"> + + + + { @@ -62,6 +63,7 @@ fun Timeline( player = player, mediaItems = mediaItems, videoRatio = videoRatio, + isRemote = isRemote, onChangePlayerItem = viewModel::changePlayerItem, onInitializePlayer = viewModel::initializePlayer, onReleasePlayer = viewModel::releasePlayer, @@ -77,6 +79,7 @@ fun Timeline( mediaItems: List, player: Player?, videoRatio: Float?, + isRemote: Boolean, modifier: Modifier = Modifier, format: TimelineFormat = rememberTimelineFormat(), onChangePlayerItem: (uri: Uri?, page: Int) -> Unit = { uri: Uri?, i: Int -> }, @@ -109,6 +112,7 @@ fun Timeline( mediaItems = mediaItems, player = player, videoRatio = videoRatio, + isRemote = isRemote, onChangePlayerItem = onChangePlayerItem, modifier = Modifier .fillMaxSize() diff --git a/app/src/main/java/com/google/android/samples/socialite/ui/home/timeline/TimelineViewModel.kt b/app/src/main/java/com/google/android/samples/socialite/ui/home/timeline/TimelineViewModel.kt index 658cf228..b051d843 100644 --- a/app/src/main/java/com/google/android/samples/socialite/ui/home/timeline/TimelineViewModel.kt +++ b/app/src/main/java/com/google/android/samples/socialite/ui/home/timeline/TimelineViewModel.kt @@ -16,6 +16,7 @@ package com.google.android.samples.socialite.ui.home.timeline +import android.content.ContentResolver import android.content.Context import android.net.Uri import android.os.HandlerThread @@ -26,7 +27,10 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.media3.cast.CastPlayer import androidx.media3.common.C +import androidx.media3.common.DeviceInfo import androidx.media3.common.MediaItem import androidx.media3.common.Player import androidx.media3.common.VideoSize @@ -36,25 +40,41 @@ import androidx.media3.exoplayer.ExoPlayer import com.google.android.samples.socialite.model.ChatDetail import com.google.android.samples.socialite.repository.ChatRepository import com.google.android.samples.socialite.ui.player.preloadmanager.PreloadManagerWrapper +import com.google.android.samples.socialite.utils.LocalMediaServer import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import java.io.IOException import javax.inject.Inject +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flattenConcat import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch + +private const val TAG = "TimelineViewModel" @UnstableApi @HiltViewModel class TimelineViewModel @Inject constructor( @ApplicationContext private val application: Context, private val repository: ChatRepository, + private val localMediaServer: LocalMediaServer, ) : ViewModel() { // Single player instance - in the future, we can implement a pool of players to improve // latency and allow for concurrent playback - var player by mutableStateOf(null) + var player by mutableStateOf(null) + + // Cast player instance + var castPlayer by mutableStateOf(null) + + // Local ExoPlayer instance + private var localPlayer: ExoPlayer? = null + + // Keeps track if the current playback location is remote or local + var isRemote by mutableStateOf(false) // Width/Height ratio of the current media item, used to properly size the Surface var videoRatio by mutableStateOf(null) @@ -68,6 +88,26 @@ class TimelineViewModel @Inject constructor( var playbackStartTimeMs = C.TIME_UNSET + private var currentVideoUri: Uri? = null + + init { + viewModelScope.launch(Dispatchers.IO) { + try { + localMediaServer.start() + } catch (e: IOException) { + Log.e(TAG, "Failed to start local media server", e) + } + } + } + + override fun onCleared() { + super.onCleared() + viewModelScope.launch(Dispatchers.IO) { + localMediaServer.stop() + } + releasePlayer() + } + private val videoSizeListener = object : Player.Listener { override fun onVideoSizeChanged(videoSize: VideoSize) { videoRatio = if (videoSize.height > 0 && videoSize.width > 0) { @@ -87,6 +127,53 @@ class TimelineViewModel @Inject constructor( } } + /** + * Listens for MediaRoute connection events. + * When a Cast device connects or disconnects, this listener seamlessly transfers + * the currently playing video to the new playback destination. + */ + private val castPlayerListener = object : Player.Listener { + override fun onDeviceInfoChanged(deviceInfo: DeviceInfo) { + isRemote = deviceInfo.playbackType == DeviceInfo.PLAYBACK_TYPE_REMOTE + currentVideoUri?.let { uri -> + val mediaItem = getMediaItemForUri(uri) + val typeStr = if (isRemote) "REMOTE" else "LOCAL" + Log.i(TAG, "Serving content on $typeStr: ${mediaItem.localConfiguration?.uri}") + player?.apply { + setMediaItem(mediaItem) + prepare() + play() + } + } + } + } + + /** + * Determines the appropriate [MediaItem] source structure for a given URI. + * + * If the session is casting to a remote device AND the URI points to a local file + * (content:// or file://), the Cast receiver cannot securely access that file path. + * To solve this, we proxy the file stream over an HTTP URL backed by our own [LocalMediaServer]. + * + * @param uri The original location of the file to play. + * @return A [MediaItem] that ExoPlayer/CastPlayer can natively decode. + */ + private fun getMediaItemForUri(uri: Uri): MediaItem { + val isLocal = uri.scheme in listOf( + ContentResolver.SCHEME_FILE, + ContentResolver.SCHEME_CONTENT, + ) || uri.path.orEmpty().startsWith("/storage/") + if (!isRemote || !isLocal) return MediaItem.fromUri(uri) + + val ip = localMediaServer.getLocalIpAddress() ?: return MediaItem.fromUri(uri) + val port = localMediaServer.listeningPort.takeIf { it > 0 } ?: return MediaItem.fromUri(uri) + + val proxyPath = if (uri.scheme == ContentResolver.SCHEME_CONTENT) "/$uri" else uri.path + localMediaServer.currentSharedUri = proxyPath?.removePrefix("/") + + return MediaItem.fromUri("http://$ip:$port$proxyPath") + } + @kotlin.OptIn(ExperimentalCoroutinesApi::class) val media = repository.getChats() .map { chats -> @@ -140,7 +227,7 @@ class TimelineViewModel @Inject constructor( .setPrioritizeTimeOverSizeThresholds(true).build() val thread = initPlayerThread() - val newPlayer = ExoPlayer + localPlayer = ExoPlayer .Builder(application.applicationContext) .setLoadControl(loadControl) .setPlaybackLooper(thread.looper) @@ -151,9 +238,12 @@ class TimelineViewModel @Inject constructor( it.addListener(videoSizeListener) it.addListener(firstFrameListener) } + castPlayer = + CastPlayer.Builder(application.applicationContext).setLocalPlayer(localPlayer!!).build() + castPlayer?.addListener(castPlayerListener) videoRatio = null - player = newPlayer + player = castPlayer if (enablePreloadManager) { initPreloadManager(loadControl, thread) @@ -185,6 +275,7 @@ class TimelineViewModel @Inject constructor( if (enablePreloadManager) { preloadManager.release() } + castPlayer?.removeListener(castPlayerListener) player?.apply { removeListener(videoSizeListener) removeListener(firstFrameListener) @@ -193,6 +284,7 @@ class TimelineViewModel @Inject constructor( playerThread?.quitSafely() playerThread = null videoRatio = null + localPlayer = null player = null } @@ -203,18 +295,19 @@ class TimelineViewModel @Inject constructor( stop() videoRatio = null if (uri != null) { + currentVideoUri = uri // Set the right source to play - val mediaItem = MediaItem.fromUri(uri) + val mediaItem = getMediaItemForUri(uri) + Log.d(TAG, "Media item changed: ${mediaItem.localConfiguration?.uri}") if (enablePreloadManager) { val mediaSource = preloadManager.getMediaSource(mediaItem) Log.d("PreloadManager", "Mediasource $mediaSource ") - if (mediaSource == null) { - setMediaItem(mediaItem) + if (!isRemote && mediaSource != null && localPlayer != null) { + localPlayer?.setMediaSource(mediaSource) } else { - // Use the preloaded media source - setMediaSource(mediaSource) + setMediaItem(mediaItem) } preloadManager.setCurrentPlayingIndex(currentPlayingIndex) } else { diff --git a/app/src/main/java/com/google/android/samples/socialite/ui/home/timeline/component/TimelineVerticalPager.kt b/app/src/main/java/com/google/android/samples/socialite/ui/home/timeline/component/TimelineVerticalPager.kt index c67c09fe..5b837b2d 100644 --- a/app/src/main/java/com/google/android/samples/socialite/ui/home/timeline/component/TimelineVerticalPager.kt +++ b/app/src/main/java/com/google/android/samples/socialite/ui/home/timeline/component/TimelineVerticalPager.kt @@ -19,7 +19,9 @@ package com.google.android.samples.socialite.ui.home.timeline.component import android.net.Uri import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.pager.PagerState @@ -42,8 +44,8 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.unit.dp import androidx.compose.ui.util.lerp -import androidx.compose.ui.zIndex import androidx.core.net.toUri +import androidx.media3.cast.MediaRouteButton import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.ui.compose.PlayerSurface @@ -60,6 +62,7 @@ internal fun TimelineVerticalPager( mediaItems: List, player: Player?, videoRatio: Float?, + isRemote: Boolean, modifier: Modifier = Modifier, onChangePlayerItem: (uri: Uri?, page: Int) -> Unit = { uri: Uri?, i: Int -> }, onInspectClicked: (uri: String) -> Unit = {}, @@ -80,6 +83,7 @@ internal fun TimelineVerticalPager( TimelineVerticalPager( mediaItems = mediaItems, player = player, + isRemote = isRemote, pagerState = pagerState, videoRatio = videoRatio, modifier = modifier, @@ -92,6 +96,7 @@ internal fun TimelineVerticalPager( private fun TimelineVerticalPager( mediaItems: List, player: Player, + isRemote: Boolean, pagerState: PagerState, videoRatio: Float?, modifier: Modifier = Modifier, @@ -106,6 +111,7 @@ private fun TimelineVerticalPager( TimelinePage( mediaItem = mediaItem, player = player, + isRemote = isRemote, page = page, pagerState = pagerState, videoRatio = videoRatio, @@ -139,6 +145,7 @@ private fun TimelineVerticalPager( private fun TimelinePage( mediaItem: TimelineMediaItem, player: Player, + isRemote: Boolean, page: Int, pagerState: PagerState, videoRatio: Float?, @@ -157,6 +164,7 @@ private fun TimelinePage( .draggableMediaItem(mediaItem), media = mediaItem, player = player, + isRemote = isRemote, page, pagerState, videoRatio, @@ -177,6 +185,7 @@ private fun MediaItem( modifier: Modifier = Modifier, media: TimelineMediaItem, player: Player, + isRemote: Boolean, page: Int, state: PagerState, videoRatio: Float?, @@ -192,28 +201,20 @@ private fun MediaItem( } else -> { - // Display an info button to inspect the video's metadata. This is overlaid on - // top left of the video preview. - IconButton( - onClick = { - onInspectClicked(media.uri) - }, - modifier = Modifier - .padding(8.dp) - .zIndex(1f), - // Ensure the button is on top of other content - ) { - Icon( - imageVector = Icons.Filled.Info, - contentDescription = "Inspect video metadata", + // Use the "key" composable to force the recreation of the PlayerSurface + // when the playback switch between local and remote + androidx.compose.runtime.key(isRemote) { + PlayerSurface( + player = player, + modifier = modifier.resizeWithContentScale( + ContentScale.Fit, + null, + ), ) } - PlayerSurface( - player = player, - modifier = modifier.resizeWithContentScale( - ContentScale.Fit, - null, - ), + PlayerControls( + uri = media.uri, + onInspectClicked = onInspectClicked, ) } } @@ -230,3 +231,32 @@ private fun MediaItem( } } } + +private val MediaRouteButtonIntrinsicPadding = 15.dp + +@Composable +private fun PlayerControls( + uri: String, + onInspectClicked: (uri: String) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.padding(8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(-MediaRouteButtonIntrinsicPadding), + ) { + // Display an info button to inspect the video's metadata. + IconButton( + onClick = { + onInspectClicked(uri) + }, + ) { + Icon( + imageVector = Icons.Filled.Info, + contentDescription = "Inspect video metadata", + ) + } + // Display a Cast button + MediaRouteButton() + } +} diff --git a/app/src/main/java/com/google/android/samples/socialite/ui/videoedit/VideoEditScreen.kt b/app/src/main/java/com/google/android/samples/socialite/ui/videoedit/VideoEditScreen.kt index 257a0e18..365fd98e 100644 --- a/app/src/main/java/com/google/android/samples/socialite/ui/videoedit/VideoEditScreen.kt +++ b/app/src/main/java/com/google/android/samples/socialite/ui/videoedit/VideoEditScreen.kt @@ -555,7 +555,7 @@ private fun VideoMessagePreview( modifier = Modifier .width(250.dp) .height(450.dp), - ) + ) LaunchedEffect(previewConfig) { // Release the previous player instance if it exists diff --git a/app/src/main/java/com/google/android/samples/socialite/ui/videoedit/VideoEditScreenViewModel.kt b/app/src/main/java/com/google/android/samples/socialite/ui/videoedit/VideoEditScreenViewModel.kt index 5bd7eced..3387ca94 100644 --- a/app/src/main/java/com/google/android/samples/socialite/ui/videoedit/VideoEditScreenViewModel.kt +++ b/app/src/main/java/com/google/android/samples/socialite/ui/videoedit/VideoEditScreenViewModel.kt @@ -31,6 +31,8 @@ import androidx.annotation.OptIn import androidx.core.net.toUri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import androidx.media3.common.C.TRACK_TYPE_AUDIO +import androidx.media3.common.C.TRACK_TYPE_VIDEO import androidx.media3.common.Effect import androidx.media3.common.MediaItem import androidx.media3.common.MimeTypes @@ -208,7 +210,9 @@ class VideoEditScreenViewModel @Inject constructor( val editedMediaItem = EditedMediaItem.Builder(mediaItem) .setRemoveAudio(removeAudio).setDurationUs(durationUs).build() - val videoImageSequence = EditedMediaItemSequence(editedMediaItem) + val videoImageSequence = + EditedMediaItemSequence.Builder(mutableSetOf(TRACK_TYPE_VIDEO, TRACK_TYPE_AUDIO)) + .addItem(editedMediaItem).build() val compositionBuilder = Composition.Builder(videoImageSequence) // Tone-map to SDR if style transfer is selected since it can only be applied for SDR videos diff --git a/app/src/main/java/com/google/android/samples/socialite/utils/LocalMediaServer.kt b/app/src/main/java/com/google/android/samples/socialite/utils/LocalMediaServer.kt new file mode 100644 index 00000000..8bc9ae9f --- /dev/null +++ b/app/src/main/java/com/google/android/samples/socialite/utils/LocalMediaServer.kt @@ -0,0 +1,123 @@ +package com.google.android.samples.socialite.utils + +import android.content.ContentResolver +import android.content.Context +import android.net.Uri +import android.util.Log +import android.webkit.MimeTypeMap +import dagger.hilt.android.qualifiers.ApplicationContext +import fi.iki.elonen.NanoHTTPD +import java.io.File +import java.io.FileNotFoundException +import java.io.IOException +import java.net.Inet4Address +import java.net.NetworkInterface +import java.net.SocketException +import javax.inject.Inject +import javax.inject.Singleton + +private const val TAG = "LocalMediaServer" + +/** + * Serves local media (content:// or file://) to the Google Cast receiver. + * The Cast receiver operates as a web app and cannot read Android device paths securely. + * This local proxy streams bytes seamlessly over HTTP to the receiver. + */ +@Singleton +class LocalMediaServer @Inject constructor( + @ApplicationContext private val context: Context, +) : NanoHTTPD(0) { + + /** + * The single URI that is currently authorized to be streamed. + * Prevents arbitrary path traversal and unauthorized data access on the local network. + */ + @Volatile + var currentSharedUri: String? = null + + /** + * Handles incoming HTTP requests from the Cast receiver. + * + * Verifies the requested URI matches the authorized [currentSharedUri], then + * seamlessly resolves the local file or content stream and chunks it over HTTP. + * + * @param session The inbound HTTP request session. + * @return The HTTP response containing the chunked media stream or an error status. + */ + override fun serve(session: IHTTPSession): Response { + val uriString = session.uri.substringAfter("/") + Log.d(TAG, "Request: $uriString") + + if (uriString != currentSharedUri) { + Log.w(TAG, "Blocked unauthorized request: $uriString") + return newFixedLengthResponse(Response.Status.FORBIDDEN, MIME_PLAINTEXT, "Forbidden") + } + + return try { + val isContentUri = uriString.startsWith("${ContentResolver.SCHEME_CONTENT}://") + val androidUri = if (isContentUri) Uri.parse(uriString) else null + + val inputStream = if (isContentUri) { + androidUri?.let { context.contentResolver.openInputStream(it) } + } else { + File("/$uriString").takeIf { it.isFile }?.inputStream() + } + + if (inputStream != null) { + val mime = if (isContentUri) { + androidUri?.let { context.contentResolver.getType(it) } ?: getMimeType(uriString) + } else { + getMimeType(uriString) + } + newChunkedResponse(Response.Status.OK, mime, inputStream) + } else { + Log.w(TAG, "Content not found: $uriString") + newFixedLengthResponse(Response.Status.NOT_FOUND, MIME_PLAINTEXT, "Content not found") + } + } catch (e: SecurityException) { + Log.e(TAG, "Security violation accessing file", e) + newFixedLengthResponse(Response.Status.FORBIDDEN, MIME_PLAINTEXT, "Forbidden") + } catch (e: FileNotFoundException) { + Log.e(TAG, "File not found", e) + newFixedLengthResponse(Response.Status.NOT_FOUND, MIME_PLAINTEXT, "File not found") + } catch (e: IOException) { + Log.e(TAG, "IO error serving file", e) + newFixedLengthResponse(Response.Status.INTERNAL_ERROR, MIME_PLAINTEXT, "Internal Error") + } catch (e: Exception) { + Log.e(TAG, "Unexpected error serving file", e) + newFixedLengthResponse(Response.Status.INTERNAL_ERROR, MIME_PLAINTEXT, "Internal Error") + } + } + + /** + * Determines the most appropriate local IPv4 address of the device to bind the HTTP server. + * Filters out loopbacks, virtual interfaces, and inactive connections. + * + * @return The local IPv4 address as a string, or null if no valid address is found. + */ + fun getLocalIpAddress(): String? { + return try { + NetworkInterface.getNetworkInterfaces().asSequence() + .filter { !it.isLoopback && it.isUp && !it.isVirtual } + .flatMap { it.inetAddresses.asSequence() } + .firstOrNull { !it.isLoopbackAddress && it is Inet4Address } + ?.hostAddress + } catch (ex: SocketException) { + Log.e(TAG, "Error getting IP address", ex) + null + } + } + + companion object { + /** + * Infers the MIME type of a file based on its extension using the native Android [MimeTypeMap]. + * + * @param url The file path or URI string. + * @return The inferred MIME type (e.g. "video/mp4"), defaulting to "text/plain". + */ + private fun getMimeType(url: String): String { + val extension = MimeTypeMap.getFileExtensionFromUrl(url) + return MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) ?: MIME_PLAINTEXT + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8dc36b82..62363e0e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -46,7 +46,7 @@ android-material3 = "1.13.0-alpha13" material3 = "1.4.0-alpha15" material3-adaptive-navigation-suite = "1.4.0-alpha15" material3-adaptive-navigation3 = "1.0.0-SNAPSHOT" -media3 = "1.8.0" +media3 = "1.9.0" media3-ui-compose = "1.7.1" navigation3 = "1.0.0-alpha01" profileinstaller = "1.4.1" @@ -126,6 +126,8 @@ media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", versi media3-transformer = { group = "androidx.media3", name = "media3-transformer", version.ref = "media3" } media3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3" } media3-ui-compose = { group = "androidx.media3", name = "media3-ui-compose", version.ref = "media3-ui-compose" } +media3-cast = { group = "androidx.media3", name = "media3-cast", version.ref = "media3" } +nanohttpd = { group = "org.nanohttpd", name = "nanohttpd", version = "2.3.1" } navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "navigation3" } navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "navigation3" } profileinstaller = { group = "androidx.profileinstaller", name = "profileinstaller", version.ref = "profileinstaller" } diff --git a/licenses/NANOHTTPD_LICENSE b/licenses/NANOHTTPD_LICENSE new file mode 100644 index 00000000..d8ed9fcb --- /dev/null +++ b/licenses/NANOHTTPD_LICENSE @@ -0,0 +1,11 @@ +Copyright (c) 2012-2013 by Paul S. Hawke, 2001,2005-2013 by Jarno Elonen, 2010 by Konstantinos Togias All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +Neither the name of the NanoHttpd organization nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.