diff --git a/.idea/misc.xml b/.idea/misc.xml index 85fda62..5197594 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -42,6 +42,15 @@ + + + + + + + + + diff --git a/app/build.gradle b/app/build.gradle index 72cbc1f..ce3ae8a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -4,6 +4,8 @@ plugins { } android { + + compileSdk 31 defaultConfig { @@ -56,6 +58,10 @@ android { kotlinCompilerVersion '1.5.21' } packagingOptions { + exclude 'META-INF/*' + // Due to https://github.com/Kotlin/kotlinx.coroutines/issues/2023 + exclude 'META-INF/licenses/*' + exclude '**/attach_hotspot_windows.dll' resources { excludes += '/META-INF/{AL2.0,LGPL2.1}' } @@ -82,6 +88,10 @@ dependencies { implementation 'com.github.yausername.youtubedl-android:library:0.13.+' implementation 'com.github.yausername.youtubedl-android:ffmpeg:0.13.+' implementation "androidx.media:media:1.4.3" + + implementation 'io.ktor:ktor-server-core:1.6.2' + implementation 'io.ktor:ktor-server-jetty:1.6.2' + implementation 'io.ktor:ktor-websockets:1.6.2' testImplementation 'junit:junit:4.+' androidTestImplementation 'androidx.test.ext:junit:1.1.3' androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7b87462..95a1393 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -27,6 +27,16 @@ + + + + + + + + + + + interf.inetAddresses.toList().forEach { inetAddress -> + if (!inetAddress.isLoopbackAddress && inetAddress.hostAddress.indexOf(':') < 0) { + return inetAddress + } + } + } + return null + } } \ No newline at end of file diff --git a/app/src/main/java/eu/toldi/balazs/caster/ChromecastManagerActivity.kt b/app/src/main/java/eu/toldi/balazs/caster/ChromecastManagerActivity.kt index 03fe86b..2299682 100644 --- a/app/src/main/java/eu/toldi/balazs/caster/ChromecastManagerActivity.kt +++ b/app/src/main/java/eu/toldi/balazs/caster/ChromecastManagerActivity.kt @@ -1,12 +1,19 @@ package eu.toldi.balazs.caster +import android.annotation.SuppressLint import android.content.Intent +import android.database.Cursor +import android.net.Uri import android.os.Build import android.os.Bundle +import android.provider.OpenableColumns import android.util.Log import android.view.KeyEvent +import android.webkit.MimeTypeMap import androidx.activity.ComponentActivity +import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.RequiresApi import androidx.compose.foundation.Image import androidx.compose.foundation.layout.* @@ -25,12 +32,16 @@ import coil.compose.rememberImagePainter import com.yausername.ffmpeg.FFmpeg import com.yausername.youtubedl_android.YoutubeDL import com.yausername.youtubedl_android.YoutubeDLException +import eu.toldi.balazs.caster.helpers.FileCacheHelper import eu.toldi.balazs.caster.model.ChromecastManageViewmodel import eu.toldi.balazs.caster.services.ChromecastManagerService import eu.toldi.balazs.caster.ui.theme.CasterTheme import su.litvak.chromecast.api.v2.ChromeCast import su.litvak.chromecast.api.v2.Media import su.litvak.chromecast.api.v2.MediaStatus +import java.io.BufferedInputStream +import java.io.BufferedOutputStream +import java.io.FileOutputStream class ChromecastManagerActivity : ComponentActivity() { @@ -152,37 +163,50 @@ class ChromecastManagerActivity : ComponentActivity() { modifier = Modifier.fillMaxWidth() ) } - Row( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - text = String.format( - "%02d:%02d", - ((mediaStatus.currentTime % 3600) / 60).toInt(), - (mediaStatus.currentTime % 60).toInt() + if(mediaStatus.media.contentType.startsWith("video/")) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = String.format( + "%02d:%02d", + ((mediaStatus.currentTime % 3600) / 60).toInt(), + (mediaStatus.currentTime % 60).toInt() + ) ) - ) - Text( - text = String.format( - "%02d:%02d", - ((mediaStatus.media.duration % 3600) / 60).toInt(), - (mediaStatus.media.duration % 60).toInt() + Text( + text = String.format( + "%02d:%02d", + ((mediaStatus.media.duration % 3600) / 60).toInt(), + (mediaStatus.media.duration % 60).toInt() + ) ) - ) + } + Slider(value = sliderPosition, onValueChange = { + sliderPosition = it + sliderMoving = true + }, onValueChangeFinished = { + viewModel.seek(sliderPosition.toDouble()) + sliderMoving = false + }) } - Slider(value = sliderPosition, onValueChange = { - sliderPosition = it - sliderMoving = true - }, onValueChangeFinished = { - viewModel.seek(sliderPosition.toDouble()) - sliderMoving = false - }) } } + @Composable fun MenuBar() { + val pickPictureLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.OpenDocument() + ) { fileUri -> + if (fileUri != null) { + val cacheHelper = FileCacheHelper(applicationContext) + cacheHelper.cacheThis(listOf(fileUri)) + viewModel.castFromCache(cacheHelper.tryFileName(fileUri)) + + } + } TopAppBar( title = { Text("Caster") @@ -197,7 +221,14 @@ class ChromecastManagerActivity : ComponentActivity() { }, actions = { Row { - + IconButton(onClick = { + pickPictureLauncher.launch(arrayOf("image/*", "video/*")) + }) { + Icon( + Icons.Filled.Folder, + contentDescription = stringResource(id = R.string.back) + ) + } } } ) diff --git a/app/src/main/java/eu/toldi/balazs/caster/MainActivity.kt b/app/src/main/java/eu/toldi/balazs/caster/MainActivity.kt index 812cdc4..0f072f8 100644 --- a/app/src/main/java/eu/toldi/balazs/caster/MainActivity.kt +++ b/app/src/main/java/eu/toldi/balazs/caster/MainActivity.kt @@ -32,11 +32,13 @@ import eu.toldi.balazs.caster.ui.theme.CasterTheme import su.litvak.chromecast.api.v2.ChromeCast -class MainActivity : ComponentActivity() { +open class MainActivity : ComponentActivity() { - lateinit var viewModel: ChromeCastViewModel - private var multicastLock: MulticastLock? = null + protected lateinit var viewModel: ChromeCastViewModel + protected var multicastLock: MulticastLock? = null + + protected fun isViewModelInitialised() = ::viewModel.isInitialized override fun onStart() { super.onStart() val wifi = applicationContext.getSystemService(WIFI_SERVICE) as WifiManager @@ -184,7 +186,11 @@ class MainActivity : ComponentActivity() { @Composable - fun showChromeCastButton(chromeCast: ChromeCast) { + fun showChromeCastButton(chromeCast: ChromeCast,buttonCallBack: () -> Unit = { + ChromecastManagerActivity.chromeCast_ = chromeCast + val intent = Intent(applicationContext, ChromecastManagerActivity::class.java) + startActivity(intent) + }) { Column( Modifier .fillMaxWidth() @@ -193,11 +199,9 @@ class MainActivity : ComponentActivity() { Card(modifier = Modifier .fillMaxWidth() .clickable { - ChromecastManagerActivity.chromeCast_ = chromeCast - val intent = Intent(applicationContext, ChromecastManagerActivity::class.java) - startActivity(intent) + buttonCallBack() }) { - Row() { + Row { val image_id = when (chromeCast.model) { "Chromecast Ultra" -> R.drawable.chromecastultra else -> R.drawable.chromecastv1 @@ -217,7 +221,8 @@ class MainActivity : ComponentActivity() { ) } - Column() { + Column(modifier = Modifier.fillMaxWidth().height(80.dp), + verticalArrangement = Arrangement.Center) { Text( text = "Name: " + when { chromeCast.title != null -> chromeCast.title @@ -267,7 +272,7 @@ class MainActivity : ComponentActivity() { @Preview(showBackground = true) @Composable - fun DefaultPreview() { + open fun DefaultPreview() { CasterTheme { Column { MenuBar({}, {}) diff --git a/app/src/main/java/eu/toldi/balazs/caster/ShareRecieverActivity.kt b/app/src/main/java/eu/toldi/balazs/caster/ShareRecieverActivity.kt index b12a767..dcac3d6 100644 --- a/app/src/main/java/eu/toldi/balazs/caster/ShareRecieverActivity.kt +++ b/app/src/main/java/eu/toldi/balazs/caster/ShareRecieverActivity.kt @@ -2,8 +2,10 @@ package eu.toldi.balazs.caster import android.annotation.SuppressLint import android.content.Intent +import android.net.Uri import android.net.wifi.WifiManager import android.os.Bundle +import android.os.Parcelable import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent @@ -19,49 +21,47 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.lifecycleScope import com.yausername.youtubedl_android.YoutubeDL import com.yausername.youtubedl_android.YoutubeDLException +import eu.toldi.balazs.caster.helpers.FileCacheHelper import eu.toldi.balazs.caster.model.ChromeCastViewModel +import eu.toldi.balazs.caster.services.ChromecastManagerService import eu.toldi.balazs.caster.ui.theme.CasterTheme import kotlinx.coroutines.launch import su.litvak.chromecast.api.v2.ChromeCast -class ShareRecieverActivity : ComponentActivity() { - - lateinit var viewModel: ChromeCastViewModel - private var multicastLock: WifiManager.MulticastLock? = null - - override fun onStart() { - super.onStart() - val wifi = applicationContext.getSystemService(WIFI_SERVICE) as WifiManager - // get the device ip address - multicastLock = wifi.createMulticastLock(javaClass.name) - multicastLock!!.setReferenceCounted(true) - multicastLock!!.acquire() - if (!this::viewModel.isInitialized) - viewModel = ViewModelProvider(this).get(ChromeCastViewModel::class.java) - viewModel.startScanning() - } - - override fun onStop() { - super.onStop() - if (multicastLock != null) { - Log.i("Caster", "Releasing Mutlicast Lock...") - multicastLock!!.release() - multicastLock = null - } - viewModel.startScanning() - } +class ShareRecieverActivity : MainActivity() { @SuppressLint("CoroutineCreationDuringComposition") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - if (intent?.action != Intent.ACTION_SEND || intent.type != "text/plain") { + if (intent?.action != Intent.ACTION_SEND ) { finish() } - val link = intent.getStringExtra(Intent.EXTRA_TEXT) as String + Log.d(null, intent.type.toString()) + val link = when { + intent.type == "text/plain" -> intent.getStringExtra(Intent.EXTRA_TEXT) as String + intent.type?.startsWith("video/") == true || + intent.type?.startsWith("image/") == true -> { + val uri = (intent.getParcelableExtra(Intent.EXTRA_STREAM) as? Uri) + if(uri is Uri) { + val fileCache = FileCacheHelper(applicationContext) + fileCache.cacheThis(listOf(uri)) + "http://" + ChromeCastHelper.getIPv4Address()?.hostAddress + ":" + ChromecastManagerService.PORT + "/assets/" + fileCache.tryFileName( + uri + ) + }else "" + + + } + else -> { + "" + } + } + if (link == "") finish() try { YoutubeDL.getInstance().init(application) } catch (e: YoutubeDLException) { @@ -69,7 +69,7 @@ class ShareRecieverActivity : ComponentActivity() { } setContent { CasterTheme { - if (!this::viewModel.isInitialized) + if (!isViewModelInitialised()) viewModel = ViewModelProvider(this).get(ChromeCastViewModel::class.java) viewModel.startScanning() val chromeCastState = viewModel.chromeCasts.observeAsState(emptyList()) @@ -110,8 +110,8 @@ class ShareRecieverActivity : ComponentActivity() { link = link ) } - item{ - if(enabled.not()){ + item { + if (enabled.not()) { CircularProgressIndicator() } } @@ -130,25 +130,23 @@ class ShareRecieverActivity : ComponentActivity() { chromeCast: ChromeCast, link: String ) { - Button( - onClick = { + showChromeCastButton(chromeCast = chromeCast, buttonCallBack = { + if (enabled) { onEnableChanged(false) + Intent(this, ChromecastManagerService::class.java).also { + it.action = ChromecastManagerService.ACTION_INIT + it.putExtra("CHROMECAST_ADDRESS", chromeCast.address) + it.putExtra("CHROMECAST_NAME", chromeCast.title) + ContextCompat.startForegroundService(this, it) + } lifecycleScope.launch { ChromeCastHelper.chromeCast = chromeCast ChromeCastHelper.castLink(link) { - finish() + //finish() } } - }, - modifier = Modifier.padding(5.dp), - enabled = enabled - ) { - when { - chromeCast.title != null -> Text(text = chromeCast.title) - chromeCast.name != null -> Text(text = chromeCast.name) - else -> Text(text = chromeCast.address) } - } + }) } @Composable @@ -173,7 +171,7 @@ class ShareRecieverActivity : ComponentActivity() { @Preview(showBackground = true) @Composable - fun DefaultPreview() { + override fun DefaultPreview() { CasterTheme { MenuBar {} } diff --git a/app/src/main/java/eu/toldi/balazs/caster/helpers/FileCacheHelper.kt b/app/src/main/java/eu/toldi/balazs/caster/helpers/FileCacheHelper.kt new file mode 100644 index 0000000..ebacf70 --- /dev/null +++ b/app/src/main/java/eu/toldi/balazs/caster/helpers/FileCacheHelper.kt @@ -0,0 +1,129 @@ +package eu.toldi.balazs.caster.helpers + +import android.annotation.SuppressLint +import android.content.Context +import android.database.Cursor +import android.net.Uri +import android.provider.OpenableColumns +import android.util.Log +import android.webkit.MimeTypeMap +import java.io.BufferedInputStream +import java.io.BufferedOutputStream +import java.io.File +import java.io.FileOutputStream +import java.util.concurrent.Executors + +class FileCacheHelper( +private val mContext: Context +) { + + // content resolver + private val contentResolver = mContext.contentResolver + + // to get the type of file + private val mimeTypeMap = MimeTypeMap.getSingleton() + + private val mCacheLocation = mContext.cacheDir + + fun cacheThis(uris: List) { + executor.submit { + uris.forEach { uri -> copyFromSource(uri) } + } + } + + fun tryFileName(uri: Uri): String{ + val fileExtension: String = getFileExtension(uri) ?: kotlin.run { + throw RuntimeException("Extension is null for $uri") + } + return queryName(uri) ?: getFileName(fileExtension) + } + + /** + * Copies the actual data from provided content provider. + */ + private fun copyFromSource(uri: Uri) { + + val fileExtension: String = getFileExtension(uri) ?: kotlin.run { + throw RuntimeException("Extension is null for $uri") + } + val fileName = queryName(uri) ?: getFileName(fileExtension) + + val inputStream = contentResolver.openInputStream(uri) ?: kotlin.run { + throw RuntimeException("Cannot open for reading $uri") + } + val bufferedInputStream = BufferedInputStream(inputStream) + + // the file which will be the new cached file + val outputFile = File(mCacheLocation, fileName) + val bufferedOutputStream = BufferedOutputStream(FileOutputStream(outputFile)) + + // this will hold the content for each iteration + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + + var readBytes = 0 // will be -1 if reached the end of file + + while (true) { + readBytes = bufferedInputStream.read(buffer) + + // check if the read was failure + if (readBytes == -1) { + bufferedOutputStream.flush() + break + } + + bufferedOutputStream.write(buffer) + bufferedOutputStream.flush() + } + Log.i("FileCache",outputFile.absoluteFile.toString()) + // close everything + inputStream.close() + bufferedInputStream.close() + bufferedOutputStream.close() + + } + + private fun getFileExtension(uri: Uri): String? { + return mimeTypeMap.getExtensionFromMimeType(contentResolver.getType(uri)) + } + + /** + * Tries to get actual name of the file being copied. + * This might be required in some of the cases where you might want to know the file name too. + * + * @param uri + * + */ + @SuppressLint("Recycle") + private fun queryName(uri: Uri): String? { + val returnCursor: Cursor = contentResolver.query(uri, null, null, null, null) ?: return null + val nameIndex: Int = returnCursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + returnCursor.moveToFirst() + val name: String = returnCursor.getString(nameIndex) + returnCursor.close() + return name + } + + private fun getFileName(fileExtension: String): String { + return "${System.currentTimeMillis().toString()}.$fileExtension" + } + + /** + * Remove everything that we have cached. + * You might want to invoke this method before quiting the application. + */ + fun removeAll() { + mContext.cacheDir.deleteRecursively() + } + + companion object { + + // base buffer size + private const val BASE_BUFFER_SIZE = 1024 + + // if you want to modify size use binary multiplier 2, 4, 6, 8 + private const val DEFAULT_BUFFER_SIZE = BASE_BUFFER_SIZE * 4 + + private val executor = Executors.newSingleThreadExecutor() + + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/toldi/balazs/caster/model/ChromecastManageViewmodel.kt b/app/src/main/java/eu/toldi/balazs/caster/model/ChromecastManageViewmodel.kt index 469ad9a..2850bb1 100644 --- a/app/src/main/java/eu/toldi/balazs/caster/model/ChromecastManageViewmodel.kt +++ b/app/src/main/java/eu/toldi/balazs/caster/model/ChromecastManageViewmodel.kt @@ -1,15 +1,19 @@ package eu.toldi.balazs.caster.model +import android.net.Uri import android.util.Log import androidx.compose.runtime.State import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import eu.toldi.balazs.caster.ChromeCastHelper +import eu.toldi.balazs.caster.services.ChromecastManagerService import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.launch import su.litvak.chromecast.api.v2.ChromeCast import su.litvak.chromecast.api.v2.MediaStatus +import java.net.InetAddress +import java.net.NetworkInterface class ChromecastManageViewmodel : ViewModel() { @@ -60,6 +64,13 @@ class ChromecastManageViewmodel : ViewModel() { } } + fun castFromCache(fileName: String,callBack: () -> Unit = {}){ + //Log.i("Caster","http://"+getIPv4Address()?.hostAddress+":"+ChromecastManagerService.PORT+"/assets/"+fileName) + viewModelScope.launch(IO) { + ChromeCastHelper.castLink("http://"+getIPv4Address()?.hostAddress+":"+ChromecastManagerService.PORT+"/assets/"+fileName,callBack) + } + } + fun stopApp() { viewModelScope.launch(IO) { ChromeCastHelper.stopApp() @@ -95,4 +106,15 @@ class ChromecastManageViewmodel : ViewModel() { setVolume(chromeCast.status.volume.level - 0.05f) } } + + fun getIPv4Address(): InetAddress? { + NetworkInterface.getNetworkInterfaces().toList().forEach { interf -> + interf.inetAddresses.toList().forEach { inetAddress -> + if (!inetAddress.isLoopbackAddress && inetAddress.hostAddress.indexOf(':') < 0) { + return inetAddress + } + } + } + return null + } } \ No newline at end of file diff --git a/app/src/main/java/eu/toldi/balazs/caster/services/ChromecastManagerService.kt b/app/src/main/java/eu/toldi/balazs/caster/services/ChromecastManagerService.kt index ba69fcf..90db981 100644 --- a/app/src/main/java/eu/toldi/balazs/caster/services/ChromecastManagerService.kt +++ b/app/src/main/java/eu/toldi/balazs/caster/services/ChromecastManagerService.kt @@ -16,14 +16,21 @@ import androidx.core.app.NotificationCompat import eu.toldi.balazs.caster.App.Companion.CHANNEL_ID import eu.toldi.balazs.caster.ChromeCastHelper import eu.toldi.balazs.caster.R -import kotlinx.coroutines.Dispatchers +import io.ktor.application.* +import io.ktor.features.* +import io.ktor.http.* +import io.ktor.http.content.* +import io.ktor.response.* +import io.ktor.routing.* +import io.ktor.server.engine.* +import io.ktor.server.jetty.* +import io.ktor.websocket.* +import kotlinx.coroutines.* import kotlinx.coroutines.Dispatchers.IO -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import su.litvak.chromecast.api.v2.ChromeCast import su.litvak.chromecast.api.v2.Media import su.litvak.chromecast.api.v2.MediaStatus +import java.io.File class ChromecastManagerService : Service() { @@ -36,6 +43,9 @@ class ChromecastManagerService : Service() { const val ACTION_NEXT = "action_next" const val ACTION_PREVIOUS = "action_previous" const val ACTION_STOP = "action_stop" + const val ACTION_SETFILE = "action_file" + + const val PORT = 3080 } private val mMediaPlayer = MediaPlayer() @@ -43,10 +53,33 @@ class ChromecastManagerService : Service() { private lateinit var mController: MediaControllerCompat private lateinit var pendingIntent : PendingIntent private var mediaStatus: MediaStatus? = null - + private var file : File? = null override fun onBind(p0: Intent?): IBinder? = null private lateinit var chromeCast: ChromeCast + private val server by lazy { + embeddedServer(Jetty, PORT, watchPaths = emptyList()) { + install(WebSockets) + install(CallLogging) + routing { + get("/") { + if(file == null) { + call.respondText( + text = "Hello!! You are here in ${Build.MODEL}", + contentType = ContentType.Text.Plain + ) + }else{ + + call.respondFile(file!!) + } + } + static("assets") { + staticRootFolder = applicationContext.cacheDir + files(".") + } + } + } + } @RequiresApi(Build.VERSION_CODES.M) override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { @@ -81,8 +114,11 @@ class ChromecastManagerService : Service() { .setContentIntent(pendingIntent).build() startForeground(1, notification) + CoroutineScope(IO).launch { + server.start(wait = true) + } + CoroutineScope(IO).launch { - GlobalScope.launch(IO) { while (true) { try { ChromeCastHelper.chromeCast = chromeCast @@ -116,6 +152,11 @@ class ChromecastManagerService : Service() { } + override fun onDestroy() { + server.stop(1_000, 2_000) + super.onDestroy() + } + private suspend fun buildNotification() { var status = chromeCast.status Log.d(null, status.applications.toString())