diff --git a/android/app/src/main/kotlin/com/bluebubbles/messaging/services/facetime/CachedWebview.kt b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/facetime/CachedWebview.kt index 028ebdc68c..66230dd16d 100644 --- a/android/app/src/main/kotlin/com/bluebubbles/messaging/services/facetime/CachedWebview.kt +++ b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/facetime/CachedWebview.kt @@ -10,7 +10,9 @@ import android.util.Log import android.view.View import android.webkit.JavascriptInterface import android.webkit.PermissionRequest +import android.webkit.ConsoleMessage import android.webkit.WebChromeClient +import android.webkit.WebResourceError import android.webkit.WebResourceRequest import android.webkit.WebResourceResponse import android.webkit.WebView @@ -24,7 +26,12 @@ import java.io.File @SuppressLint("SetJavaScriptEnabled") class CachedWebview(context: Context, name: String?, desc: String, url: String) { + companion object { + private const val diagnosticTag = "FaceTimeDiag" + } + val webView = WebView(context) + private val applicationContext = context.applicationContext var mirrorReady = false var mirrorReadyCall: (() -> Unit)? = null @@ -36,8 +43,12 @@ class CachedWebview(context: Context, name: String?, desc: String, url: String) val deferredRequests = arrayListOf() var deferredRequestsUpdated: () -> Unit = {} + private fun diagnosticsEnabled(): Boolean = FaceTimeDiagnostics.isEnabled(applicationContext) + + private fun safeResourceLabel(requestUrl: String?): String = FaceTimeDiagnostics.safeResourceLabel(requestUrl) + fun getScriptData(request: WebResourceRequest, client: OkHttpClient, name: String?, desc: String): String { - Log.i("FT", "Getting script") + if (diagnosticsEnabled()) Log.i(diagnosticTag, "getting main.js") // OKHTTP should handle caching for us val okhttp = Request.Builder() .method(request.method, null) @@ -85,7 +96,13 @@ class CachedWebview(context: Context, name: String?, desc: String, url: String) if (!request.url.toString().endsWith("main.js")) return null // intercept and patch request - val scriptData = getScriptData(request, client, name, desc) + if (diagnosticsEnabled()) Log.i(diagnosticTag, "intercepting ${safeResourceLabel(request.url.toString())}") + val scriptData = try { + getScriptData(request, client, name, desc) + } catch (error: Exception) { + if (diagnosticsEnabled()) Log.e(diagnosticTag, "main.js interception failed: ${error.javaClass.simpleName}") + throw error + } return WebResourceResponse( "application/javascript", @@ -93,6 +110,22 @@ class CachedWebview(context: Context, name: String?, desc: String, url: String) ByteArrayInputStream(scriptData.encodeToByteArray()) ) } + + override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { + if (diagnosticsEnabled()) Log.i(diagnosticTag, "page started ${safeResourceLabel(url)}") + } + + override fun onPageFinished(view: WebView?, url: String?) { + if (diagnosticsEnabled()) Log.i(diagnosticTag, "page finished ${safeResourceLabel(url)} mirrorReady=$mirrorReady") + } + + override fun onReceivedError(view: WebView?, request: WebResourceRequest?, error: WebResourceError?) { + if (diagnosticsEnabled()) Log.w(diagnosticTag, "resource error mainFrame=${request?.isForMainFrame} code=${error?.errorCode} resource=${safeResourceLabel(request?.url?.toString())}") + } + + override fun onReceivedHttpError(view: WebView?, request: WebResourceRequest?, errorResponse: WebResourceResponse?) { + if (diagnosticsEnabled()) Log.w(diagnosticTag, "http error mainFrame=${request?.isForMainFrame} status=${errorResponse?.statusCode} resource=${safeResourceLabel(request?.url?.toString())}") + } } webView.setBackgroundColor(Color.BLACK) @@ -110,17 +143,26 @@ class CachedWebview(context: Context, name: String?, desc: String, url: String) it() } }, 250) - Log.i("Got Mirror", "") + if (diagnosticsEnabled()) Log.i(diagnosticTag, "Native.mirrored received; mirrorReady scheduled") } }, "Native") webView.webChromeClient = object : WebChromeClient() { override fun onPermissionRequest(request: PermissionRequest?) { if (request == null) return + if (diagnosticsEnabled()) Log.i(diagnosticTag, "WebView permission request resources=${request.resources.sorted().joinToString()}") deferredRequests.add(request) deferredRequestsUpdated() } + override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean { + if (!diagnosticsEnabled() || consoleMessage == null) return false + if (consoleMessage.messageLevel() == ConsoleMessage.MessageLevel.ERROR || consoleMessage.messageLevel() == ConsoleMessage.MessageLevel.WARNING) { + Log.w(diagnosticTag, "console ${consoleMessage.messageLevel()} line=${consoleMessage.lineNumber()} source=${safeResourceLabel(consoleMessage.sourceId())} message=") + } + return false + } + override fun getDefaultVideoPoster(): Bitmap { return Bitmap.createBitmap(1, 1, Bitmap.Config.RGB_565) } @@ -129,4 +171,4 @@ class CachedWebview(context: Context, name: String?, desc: String, url: String) webView.loadUrl(url) } -} \ No newline at end of file +} diff --git a/android/app/src/main/kotlin/com/bluebubbles/messaging/services/facetime/FaceTimeActivity.kt b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/facetime/FaceTimeActivity.kt index ad4ea24429..c54dd0550b 100644 --- a/android/app/src/main/kotlin/com/bluebubbles/messaging/services/facetime/FaceTimeActivity.kt +++ b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/facetime/FaceTimeActivity.kt @@ -60,6 +60,8 @@ class FaceTimeActivity : Activity() { private lateinit var webView: WebView private var initialMediaVolume: Int? = null; + private fun diagnosticsEnabled(): Boolean = FaceTimeDiagnostics.isEnabled(this) + companion object { var activeFaceTimeActivity: FaceTimeActivity? = null var cachedWebview: CachedWebview? = null @@ -165,6 +167,8 @@ class FaceTimeActivity : Activity() { private fun answerCall() { answered = true + if (diagnosticsEnabled()) Log.i("FaceTimeDiag", "answer requested mirrorReady=$mirrorReady deferredPermissions=${cached.deferredRequests.size}") + handlePermissionRequests() if (notificationId != 0) { @@ -281,6 +285,7 @@ class FaceTimeActivity : Activity() { fun handlePermissionRequest(request: PermissionRequest) { val permissions = request.resources.flatMap { i -> permissionMap[i] ?: listOf() } + if (diagnosticsEnabled()) Log.i("FaceTimeDiag", "handling WebView permission resources=${request.resources.sorted().joinToString()} androidPermissions=${permissions.joinToString()} alreadyGranted=${permissions.all { checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED }}") if (permissions.all { checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED }) { request.grant(request.resources) startService() @@ -325,6 +330,10 @@ class FaceTimeActivity : Activity() { grantResults: IntArray ) { if (requestCode != 1) return + if (diagnosticsEnabled()) { + val permissionResults = permissions.zip(grantResults.toTypedArray()).joinToString { (permission, result) -> "$permission=${result == PackageManager.PERMISSION_GRANTED}" } + Log.i("FaceTimeDiag", "Android permission result $permissionResults") + } for (request in permissionRequests) { request.grant(request.resources.filter { i -> (permissionMap[i] ?: listOf()).all { @@ -338,6 +347,7 @@ class FaceTimeActivity : Activity() { } private fun connecting() { + if (diagnosticsEnabled()) Log.i("FaceTimeDiag", "waiting for mirrorReady") binding.acceptButtons.visibility = View.GONE binding.loadingBanner.text = "Connecting..." Handler(Looper.getMainLooper()).postDelayed({ @@ -368,6 +378,7 @@ class FaceTimeActivity : Activity() { mirrorReady = cached.mirrorReady cached.mirrorReadyCall = { mirrorReady = true + if (diagnosticsEnabled()) Log.i("FaceTimeDiag", "mirrorReady callback answered=$answered") if (answered) { binding.mainFrame.visibility = View.VISIBLE binding.splashLayout.visibility = View.GONE @@ -389,7 +400,7 @@ class FaceTimeActivity : Activity() { binding.avatarView.setImageBitmap(bitmap) } - Log.i("FaceTime", "started activity for call $callUuid") + if (diagnosticsEnabled()) Log.i("FaceTimeDiag", "started activity hasCallUuid=${callUuid != null}") val poster = extras.getString("poster") if (poster != null) { @@ -419,4 +430,4 @@ class FaceTimeActivity : Activity() { handlePermissionRequests() } } -} \ No newline at end of file +} diff --git a/android/app/src/main/kotlin/com/bluebubbles/messaging/services/facetime/FaceTimeDiagnostics.kt b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/facetime/FaceTimeDiagnostics.kt new file mode 100644 index 0000000000..4698c1ec8b --- /dev/null +++ b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/facetime/FaceTimeDiagnostics.kt @@ -0,0 +1,39 @@ +package com.bluebubbles.messaging.services.facetime + +import android.content.Context +import android.net.Uri + +internal object FaceTimeDiagnostics { + private const val preferencesName = "FlutterSharedPreferences" + private const val developerModeKey = "flutter.developerEnabled" + private const val diagnosticsKey = "flutter.faceTimeDiagnosticsEnabled" + + fun isEnabled(context: Context): Boolean { + val preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE) + return shouldEnable( + developerModeEnabled = preferences.getBoolean(developerModeKey, false), + diagnosticsEnabled = preferences.getBoolean(diagnosticsKey, false), + ) + } + + internal fun shouldEnable( + developerModeEnabled: Boolean, + diagnosticsEnabled: Boolean, + ): Boolean = developerModeEnabled && diagnosticsEnabled + + internal fun safeResourceLabel(requestUrl: String?): String { + if (requestUrl == null) return "unknown" + return try { + val uri = Uri.parse(requestUrl) + val segment = uri.lastPathSegment.orEmpty() + val resource = when { + segment.endsWith(".js", ignoreCase = true) -> segment.substringAfterLast('/') + segment.endsWith(".css", ignoreCase = true) -> segment.substringAfterLast('/') + else -> "page-or-media" + } + "${uri.host ?: "unknown"}/$resource" + } catch (_: Exception) { + "unparseable" + } + } +} diff --git a/android/app/src/test/kotlin/com/bluebubbles/messaging/services/facetime/FaceTimeDiagnosticsTest.kt b/android/app/src/test/kotlin/com/bluebubbles/messaging/services/facetime/FaceTimeDiagnosticsTest.kt new file mode 100644 index 0000000000..0c790b5316 --- /dev/null +++ b/android/app/src/test/kotlin/com/bluebubbles/messaging/services/facetime/FaceTimeDiagnosticsTest.kt @@ -0,0 +1,22 @@ +package com.bluebubbles.messaging.services.facetime + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class FaceTimeDiagnosticsTest { + @Test + fun diagnosticsRequireDeveloperModeAndExplicitOptIn() { + assertFalse(FaceTimeDiagnostics.shouldEnable(false, false)) + assertFalse(FaceTimeDiagnostics.shouldEnable(false, true)) + assertFalse(FaceTimeDiagnostics.shouldEnable(true, false)) + assertTrue(FaceTimeDiagnostics.shouldEnable(true, true)) + } + + @Test + fun resourceLabelsOmitQueryAndPageDetails() { + assertEquals("example.com/main.js", FaceTimeDiagnostics.safeResourceLabel("https://example.com/assets/main.js?token=secret#fragment")) + assertEquals("example.com/page-or-media", FaceTimeDiagnostics.safeResourceLabel("https://example.com/call/private-room?token=secret")) + } +} diff --git a/lib/app/layouts/settings/pages/misc/troubleshoot_panel.dart b/lib/app/layouts/settings/pages/misc/troubleshoot_panel.dart index 437c901aff..86e7e650b9 100644 --- a/lib/app/layouts/settings/pages/misc/troubleshoot_panel.dart +++ b/lib/app/layouts/settings/pages/misc/troubleshoot_panel.dart @@ -556,6 +556,33 @@ class _TroubleshootPanelState extends OptimizedState { )) : Icon(Icons.check, color: context.theme.colorScheme.outline)) ) ]), + if (usingRustPush && Platform.isAndroid) + Obx(() => ss.settings.developerEnabled.value + ? SettingsHeader( + iosSubtitle: iosSubtitle, + materialSubtitle: materialSubtitle, + text: "FaceTime Diagnostics", + ) + : const SizedBox.shrink()), + if (usingRustPush && Platform.isAndroid) + Obx(() => ss.settings.developerEnabled.value + ? SettingsSection( + backgroundColor: tileColor, + children: [ + SettingsSwitch( + initialVal: ss.settings.faceTimeDiagnosticsEnabled.value, + onChanged: (bool val) async { + ss.settings.faceTimeDiagnosticsEnabled.value = val; + await ss.settings.saveOne('faceTimeDiagnosticsEnabled'); + }, + title: "Enable FaceTime diagnostics", + subtitle: "Logs FaceTime WebView, join, permission, and media state for debugging. Join recovery and End Call remain enabled when this is off.", + isThreeLine: true, + backgroundColor: tileColor, + ), + ], + ) + : const SizedBox.shrink()), if(!kIsDesktop) SettingsHeader( iosSubtitle: iosSubtitle, @@ -567,7 +594,7 @@ class _TroubleshootPanelState extends OptimizedState { backgroundColor: tileColor, children: [ Obx(() => SettingsSwitch( - onChanged: (bool val) { + onChanged: (bool val) async { if (val) { showDialog( context: context, @@ -604,7 +631,15 @@ class _TroubleshootPanelState extends OptimizedState { return; } ss.settings.developerEnabled.value = val; - ss.settings.save(); + if (!val) { + ss.settings.faceTimeDiagnosticsEnabled.value = false; + await ss.settings.saveMany([ + 'developerEnabled', + 'faceTimeDiagnosticsEnabled', + ]); + } else { + await ss.settings.saveOne('developerEnabled'); + } showSnackbar("Success", "Restart device or force quit OpenBubbles to unload extensions"); }, initialVal: ss.settings.developerEnabled.value, diff --git a/lib/database/global/settings.dart b/lib/database/global/settings.dart index f9d30fd536..09ba1812b4 100644 --- a/lib/database/global/settings.dart +++ b/lib/database/global/settings.dart @@ -211,6 +211,7 @@ class Settings { final RxBool developerEnabled = false.obs; final RxList developerMode = [].obs; + final RxBool faceTimeDiagnosticsEnabled = false.obs; final RxBool cloudSyncingEnabled = false.obs; final RxBool attachmentSyncEnabled = false.obs; @@ -432,6 +433,7 @@ class Settings { 'keychainDefaultPassword': keychainDefaultPassword.value, 'isSmsRouter': isSmsRouter.value, 'developerEnabled': developerEnabled.value, + 'faceTimeDiagnosticsEnabled': faceTimeDiagnosticsEnabled.value, 'vpnWarned': vpnWarned.value, 'smsForwardingTargets': smsRoutingTargets, 'developerMode': developerMode, @@ -612,6 +614,7 @@ class Settings { ss.settings.keychainDefaultPassword.value = map['keychainDefaultPassword']; ss.settings.isSmsRouter.value = map['isSmsRouter'] ?? false; ss.settings.developerEnabled.value = map['developerEnabled'] ?? false; + ss.settings.faceTimeDiagnosticsEnabled.value = map['faceTimeDiagnosticsEnabled'] ?? false; ss.settings.vpnWarned.value = map['vpnWarned'] ?? false; ss.settings.cachedCodes.value = map['cachedCodes'] ?? {}; ss.settings.smsForwardingTargets.value = map['smsIncomingTargets'] ?? {}; @@ -789,6 +792,7 @@ class Settings { s.keychainDefaultPassword.value = map['keychainDefaultPassword']; s.isSmsRouter.value = map['isSmsRouter'] ?? false; s.developerEnabled.value = map['developerEnabled'] ?? false; + s.faceTimeDiagnosticsEnabled.value = map['faceTimeDiagnosticsEnabled'] ?? false; s.vpnWarned.value = map['vpnWarned'] ?? false; s.cachedCodes.value = map['cachedCodes'] is String ? jsonDecode(map['cachedCodes']).cast() : {}; s.smsForwardingTargets.value = map['smsIncomingTargets'] is String ? jsonDecode(map['smsIncomingTargets']).cast() : {}; diff --git a/test/services/facetime/face_time_diagnostics_contract_test.dart b/test/services/facetime/face_time_diagnostics_contract_test.dart new file mode 100644 index 0000000000..1fb34723b9 --- /dev/null +++ b/test/services/facetime/face_time_diagnostics_contract_test.dart @@ -0,0 +1,81 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('FaceTime diagnostics default off and persist through settings maps', () { + final source = File('lib/database/global/settings.dart').readAsStringSync(); + + expect( + source, + contains('final RxBool faceTimeDiagnosticsEnabled = false.obs;'), + ); + expect( + RegExp( + "'faceTimeDiagnosticsEnabled': faceTimeDiagnosticsEnabled\\.value", + ).allMatches(source), + hasLength(1), + ); + expect( + RegExp( + r"faceTimeDiagnosticsEnabled\.value = map\['faceTimeDiagnosticsEnabled'\] \?\? false;", + ).allMatches(source), + hasLength(2), + ); + }); + + test('native preference gate requires developer mode and opt in', () { + final source = File( + 'android/app/src/main/kotlin/com/bluebubbles/messaging/services/facetime/FaceTimeDiagnostics.kt', + ).readAsStringSync(); + + expect(source, contains('"flutter.developerEnabled"')); + expect(source, contains('"flutter.faceTimeDiagnosticsEnabled"')); + expect(source, contains('developerModeEnabled && diagnosticsEnabled')); + }); + + test('disabling developer mode also clears FaceTime diagnostics', () { + final source = File( + 'lib/app/layouts/settings/pages/misc/troubleshoot_panel.dart', + ).readAsStringSync(); + final disableStart = source.indexOf('if (!val) {'); + final disableEnd = source.indexOf('} else {', disableStart); + + expect(disableStart, greaterThanOrEqualTo(0)); + expect(disableEnd, greaterThan(disableStart)); + final disablePath = source.substring(disableStart, disableEnd); + expect(disablePath, contains('faceTimeDiagnosticsEnabled.value = false')); + expect(disablePath, contains("'faceTimeDiagnosticsEnabled'")); + }); + + test('diagnostic gates do not wrap functional join or end-call paths', () { + final activity = File( + 'android/app/src/main/kotlin/com/bluebubbles/messaging/services/facetime/FaceTimeActivity.kt', + ).readAsStringSync(); + final cachedWebview = File( + 'android/app/src/main/kotlin/com/bluebubbles/messaging/services/facetime/CachedWebview.kt', + ).readAsStringSync(); + + final joinStart = activity.indexOf('private fun answerCall()'); + final joinEnd = activity.indexOf('override fun onNewIntent(', joinStart); + final endStart = activity.indexOf('fun endCall()'); + final endEnd = activity.indexOf( + 'private fun hideControlsForPIP()', + endStart, + ); + + expect(joinStart, greaterThanOrEqualTo(0)); + expect(joinEnd, greaterThan(joinStart)); + expect(endEnd, greaterThan(endStart)); + expect( + activity.substring(joinStart, joinEnd), + contains('callcontrols-join-button-session-banner'), + ); + expect( + activity.substring(endStart, endEnd), + contains('callcontrols-leave-button-session-banner'), + ); + expect(cachedWebview, contains('message=')); + expect(cachedWebview, isNot(contains('consoleMessage.message()'))); + }); +}