From f90290c4ecd7549f23daa11178db76f85e007658 Mon Sep 17 00:00:00 2001 From: Koen Date: Mon, 4 Dec 2023 10:57:11 +0100 Subject: [PATCH] Added support for connecting to FCast via QR code. --- app/src/main/AndroidManifest.xml | 8 + .../java/com/futo/platformplayer/UIDialogs.kt | 5 + .../platformplayer/activities/MainActivity.kt | 210 ++++++++++++------ .../platformplayer/casting/StateCasting.kt | 45 ++++ .../dialogs/ConnectCastingDialog.kt | 22 ++ .../res/layout/dialog_casting_connect.xml | 22 +- 6 files changed, 234 insertions(+), 78 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ba3fcc35..ed7a2988 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -61,6 +61,14 @@ + + + + + + + + diff --git a/app/src/main/java/com/futo/platformplayer/UIDialogs.kt b/app/src/main/java/com/futo/platformplayer/UIDialogs.kt index f9dd9185..86d73e28 100644 --- a/app/src/main/java/com/futo/platformplayer/UIDialogs.kt +++ b/app/src/main/java/com/futo/platformplayer/UIDialogs.kt @@ -1,5 +1,6 @@ package com.futo.platformplayer +import android.app.Activity import android.app.AlertDialog import android.content.Context import android.content.Intent @@ -337,6 +338,10 @@ class UIDialogs { } else { val dialog = ConnectCastingDialog(context); registerDialogOpened(dialog); + val c = context + if (c is Activity) { + dialog.setOwnerActivity(c); + } dialog.setOnDismissListener { registerDialogClosed(dialog) }; dialog.show(); } diff --git a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt index a385170a..84dbb104 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt @@ -7,7 +7,6 @@ import android.content.pm.ActivityInfo import android.content.res.Configuration import android.net.Uri import android.os.Bundle -import android.preference.PreferenceManager import android.util.Log import android.util.TypedValue import android.view.View @@ -25,11 +24,9 @@ import androidx.fragment.app.FragmentContainerView import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import com.futo.platformplayer.* -import com.futo.platformplayer.api.media.PlatformID -import com.futo.platformplayer.api.media.models.channels.SerializedChannel import com.futo.platformplayer.casting.StateCasting import com.futo.platformplayer.constructs.Event1 -import com.futo.platformplayer.constructs.Event3 +import com.futo.platformplayer.dialogs.ConnectCastingDialog import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment import com.futo.platformplayer.fragment.mainactivity.main.* import com.futo.platformplayer.fragment.mainactivity.topbar.AddTopBarFragment @@ -45,6 +42,7 @@ import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.SubscriptionStorage import com.futo.platformplayer.stores.v2.ManagedStore import com.google.gson.JsonParser +import com.google.zxing.integration.android.IntentIntegrator import kotlinx.coroutines.* import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json @@ -124,6 +122,24 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { private var _isVisible = true; private var _wasStopped = false; + private val _urlQrCodeResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + val scanResult = IntentIntegrator.parseActivityResult(result.resultCode, result.data) + scanResult?.let { + val content = it.contents + if (content == null) { + UIDialogs.toast(this, getString(R.string.failed_to_scan_qr_code)) + return@let + } + + try { + handleUrlAll(content) + } catch (e: Throwable) { + Logger.i(TAG, "Failed to handle URL.", e) + UIDialogs.toast(this, "Failed to handle URL: ${e.message}") + } + } + } + constructor() : super() { Thread.setDefaultUncaughtExceptionHandler { _, throwable -> val writer = StringWriter(); @@ -409,6 +425,23 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { UIDialogs.toast(this, "No external file permissions\nExporting and auto backups will not work"); }*/ + fun showUrlQrCodeScanner() { + try { + val integrator = IntentIntegrator(this) + integrator.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE) + integrator.setPrompt(getString(R.string.scan_a_qr_code)) + integrator.setOrientationLocked(true); + integrator.setCameraId(0) + integrator.setBeepEnabled(false) + integrator.setBarcodeImageEnabled(true) + integrator.captureActivity = QRCaptureActivity::class.java + _urlQrCodeResultLauncher.launch(integrator.createScanIntent()) + } catch (e: Throwable) { + Logger.i(TAG, "Failed to handle show QR scanner.", e) + UIDialogs.toast(this, "Failed to show QR scanner: ${e.message}") + } + } + override fun onResume() { super.onResume(); Logger.v(TAG, "onResume") @@ -496,76 +529,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { try { if (targetData != null) { - when(intent.scheme) { - "grayjay" -> { - if(targetData.startsWith("grayjay://license/")) { - if(StatePayment.instance.setPaymentLicenseUrl(targetData)) - { - UIDialogs.showDialogOk(this, R.drawable.ic_check, getString(R.string.your_license_key_has_been_set_an_app_restart_might_be_required)); - - if(fragCurrent is BuyFragment) - closeSegment(fragCurrent); - } - else - UIDialogs.toast(getString(R.string.invalid_license_format)); - - } - else if(targetData.startsWith("grayjay://plugin/")) { - val intent = Intent(this, AddSourceActivity::class.java).apply { - data = Uri.parse(targetData.substring("grayjay://plugin/".length)); - }; - startActivity(intent); - } - else if(targetData.startsWith("grayjay://video/")) { - val videoUrl = targetData.substring("grayjay://video/".length); - navigate(_fragVideoDetail, videoUrl); - } - else if(targetData.startsWith("grayjay://channel/")) { - val channelUrl = targetData.substring("grayjay://channel/".length); - navigate(_fragMainChannel, channelUrl); - } - } - "content" -> { - if(!handleContent(targetData, intent.type)) { - UIDialogs.showSingleButtonDialog( - this, - R.drawable.ic_play, - getString(R.string.unknown_content_format) + " [${targetData}]", - "Ok", - { }); - } - } - "file" -> { - if(!handleFile(targetData)) { - UIDialogs.showSingleButtonDialog( - this, - R.drawable.ic_play, - getString(R.string.unknown_file_format) + " [${targetData}]", - "Ok", - { }); - } - } - "polycentric" -> { - if(!handlePolycentric(targetData)) { - UIDialogs.showSingleButtonDialog( - this, - R.drawable.ic_play, - getString(R.string.unknown_polycentric_format) + " [${targetData}]", - "Ok", - { }); - } - } - else -> { - if (!handleUrl(targetData)) { - UIDialogs.showSingleButtonDialog( - this, - R.drawable.ic_play, - getString(R.string.unknown_url_format) + " [${targetData}]", - "Ok", - { }); - } - } - } + handleUrlAll(targetData) } } catch(ex: Throwable) { @@ -573,6 +537,90 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { } } + fun handleUrlAll(url: String) { + val uri = Uri.parse(url) + when (uri.scheme) { + "grayjay" -> { + if(url.startsWith("grayjay://license/")) { + if(StatePayment.instance.setPaymentLicenseUrl(url)) + { + UIDialogs.showDialogOk(this, R.drawable.ic_check, getString(R.string.your_license_key_has_been_set_an_app_restart_might_be_required)); + + if(fragCurrent is BuyFragment) + closeSegment(fragCurrent); + } + else + UIDialogs.toast(getString(R.string.invalid_license_format)); + + } + else if(url.startsWith("grayjay://plugin/")) { + val intent = Intent(this, AddSourceActivity::class.java).apply { + data = Uri.parse(url.substring("grayjay://plugin/".length)); + }; + startActivity(intent); + } + else if(url.startsWith("grayjay://video/")) { + val videoUrl = url.substring("grayjay://video/".length); + navigate(_fragVideoDetail, videoUrl); + } + else if(url.startsWith("grayjay://channel/")) { + val channelUrl = url.substring("grayjay://channel/".length); + navigate(_fragMainChannel, channelUrl); + } + } + "content" -> { + if(!handleContent(url, intent.type)) { + UIDialogs.showSingleButtonDialog( + this, + R.drawable.ic_play, + getString(R.string.unknown_content_format) + " [${url}]", + "Ok", + { }); + } + } + "file" -> { + if(!handleFile(url)) { + UIDialogs.showSingleButtonDialog( + this, + R.drawable.ic_play, + getString(R.string.unknown_file_format) + " [${url}]", + "Ok", + { }); + } + } + "polycentric" -> { + if(!handlePolycentric(url)) { + UIDialogs.showSingleButtonDialog( + this, + R.drawable.ic_play, + getString(R.string.unknown_polycentric_format) + " [${url}]", + "Ok", + { }); + } + } + "fcast" -> { + if(!handleFCast(url)) { + UIDialogs.showSingleButtonDialog( + this, + R.drawable.ic_cast, + "Unknown FCast format [${url}]", + "Ok", + { }); + } + } + else -> { + if (!handleUrl(url)) { + UIDialogs.showSingleButtonDialog( + this, + R.drawable.ic_play, + getString(R.string.unknown_url_format) + " [${url}]", + "Ok", + { }); + } + } + } + } + fun handleUrl(url: String): Boolean { Logger.i(TAG, "handleUrl(url=$url)") @@ -719,6 +767,20 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { startActivity(Intent(this, PolycentricImportProfileActivity::class.java).apply { putExtra("url", url) }) return true; } + + fun handleFCast(url: String): Boolean { + Logger.i(TAG, "handleFCast"); + + try { + StateCasting.instance.handleUrl(this, url) + return true; + } catch (e: Throwable) { + Log.e(TAG, "Failed to parse FCast URL '${url}'.", e) + } + + return false + } + private fun readSharedContent(contentPath: String): ByteArray { return contentResolver.openInputStream(Uri.parse(contentPath))?.use { return it.readBytes(); diff --git a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt index 399c3817..f59b55ad 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt @@ -2,8 +2,11 @@ package com.futo.platformplayer.casting import android.content.ContentResolver import android.content.Context +import android.net.Uri import android.os.Looper +import android.util.Base64 import com.futo.platformplayer.* +import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.http.server.ManagedHttpServer import com.futo.platformplayer.api.http.server.handlers.* @@ -27,6 +30,9 @@ import javax.jmdns.ServiceListener import kotlin.collections.HashMap import com.futo.platformplayer.stores.CastingDeviceInfoStorage import com.futo.platformplayer.stores.FragmentedStorage +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json import javax.jmdns.ServiceTypeListener class StateCasting { @@ -147,6 +153,32 @@ class StateCasting { } } + fun handleUrl(context: Context, url: String) { + val uri = Uri.parse(url) + if (uri.scheme != "fcast") { + throw Exception("Expected scheme to be FCast") + } + + val type = uri.host + if (type != "r") { + throw Exception("Expected type r") + } + + val connectionInfo = uri.pathSegments[0] + val json = Base64.decode(connectionInfo, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP).toString(Charsets.UTF_8) + val networkConfig = Json.decodeFromString(json) + val tcpService = networkConfig.services.first { v -> v.type == 0 } + + addRememberedDevice(CastingDeviceInfo( + name = networkConfig.name, + type = CastProtocolType.FCAST, + addresses = networkConfig.addresses.toTypedArray(), + port = tcpService.port + )) + + UIDialogs.toast(context,"FCast device '${networkConfig.name}' added") + } + fun onStop() { val ad = activeDevice ?: return; Logger.i(TAG, "Stopping active device because of onStop."); @@ -1167,6 +1199,19 @@ class StateCasting { } } + @Serializable + private data class FCastNetworkConfig( + val name: String, + val addresses: List, + val services: List + ) + + @Serializable + private data class FCastService( + val port: Int, + val type: Int + ) + companion object { val instance: StateCasting = StateCasting(); diff --git a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt index dca38091..8f13545c 100644 --- a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt +++ b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt @@ -1,24 +1,33 @@ package com.futo.platformplayer.dialogs +import android.app.Activity import android.app.AlertDialog import android.content.Context +import android.content.Intent import android.graphics.drawable.Animatable +import android.net.Uri import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.widget.Button import android.widget.ImageView import android.widget.TextView +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.R import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.activities.AddSourceActivity +import com.futo.platformplayer.activities.MainActivity +import com.futo.platformplayer.activities.QRCaptureActivity import com.futo.platformplayer.casting.CastConnectionState import com.futo.platformplayer.casting.CastingDevice import com.futo.platformplayer.casting.StateCasting import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.views.adapters.DeviceAdapter +import com.google.zxing.integration.android.IntentIntegrator import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch @@ -28,6 +37,7 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) { private lateinit var _imageLoader: ImageView; private lateinit var _buttonClose: Button; private lateinit var _buttonAdd: Button; + private lateinit var _buttonScanQR: Button; private lateinit var _textNoDevicesFound: TextView; private lateinit var _textNoDevicesRemembered: TextView; private lateinit var _recyclerDevices: RecyclerView; @@ -44,6 +54,7 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) { _imageLoader = findViewById(R.id.image_loader); _buttonClose = findViewById(R.id.button_close); _buttonAdd = findViewById(R.id.button_add); + _buttonScanQR = findViewById(R.id.button_scan_qr); _recyclerDevices = findViewById(R.id.recycler_devices); _recyclerRememberedDevices = findViewById(R.id.recycler_remembered_devices); _textNoDevicesFound = findViewById(R.id.text_no_devices_found); @@ -77,6 +88,17 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) { UIDialogs.showCastingAddDialog(context); dismiss(); }; + + val c = ownerActivity + if (c is MainActivity) { + _buttonScanQR.visibility = View.VISIBLE + _buttonScanQR.setOnClickListener { + c.showUrlQrCodeScanner() + dismiss() + }; + } else { + _buttonScanQR.visibility = View.GONE + } } override fun show() { diff --git a/app/src/main/res/layout/dialog_casting_connect.xml b/app/src/main/res/layout/dialog_casting_connect.xml index ecadd61f..5cb43c35 100644 --- a/app/src/main/res/layout/dialog_casting_connect.xml +++ b/app/src/main/res/layout/dialog_casting_connect.xml @@ -89,18 +89,32 @@ android:textColor="@color/white" android:fontFamily="@font/inter_regular" /> - +