Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -36,8 +43,12 @@ class CachedWebview(context: Context, name: String?, desc: String, url: String)
val deferredRequests = arrayListOf<PermissionRequest>()
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)
Expand Down Expand Up @@ -85,14 +96,36 @@ 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",
"utf-8",
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)

Expand All @@ -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=<omitted>")
}
return false
}

override fun getDefaultVideoPoster(): Bitmap {
return Bitmap.createBitmap(1, 1, Bitmap.Config.RGB_565)
}
Expand All @@ -129,4 +171,4 @@ class CachedWebview(context: Context, name: String?, desc: String, url: String)
webView.loadUrl(url)
}

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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 {
Expand 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({
Expand Down Expand Up @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -419,4 +430,4 @@ class FaceTimeActivity : Activity() {
handlePermissionRequests()
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
Original file line number Diff line number Diff line change
@@ -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"))
}
}
39 changes: 37 additions & 2 deletions lib/app/layouts/settings/pages/misc/troubleshoot_panel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -556,6 +556,33 @@ class _TroubleshootPanelState extends OptimizedState<TroubleshootPanel> {
)) : 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,
Expand All @@ -567,7 +594,7 @@ class _TroubleshootPanelState extends OptimizedState<TroubleshootPanel> {
backgroundColor: tileColor,
children: [
Obx(() => SettingsSwitch(
onChanged: (bool val) {
onChanged: (bool val) async {
if (val) {
showDialog(
context: context,
Expand Down Expand Up @@ -604,7 +631,15 @@ class _TroubleshootPanelState extends OptimizedState<TroubleshootPanel> {
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,
Expand Down
4 changes: 4 additions & 0 deletions lib/database/global/settings.dart
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@ class Settings {

final RxBool developerEnabled = false.obs;
final RxList<String> developerMode = <String>[].obs;
final RxBool faceTimeDiagnosticsEnabled = false.obs;

final RxBool cloudSyncingEnabled = false.obs;
final RxBool attachmentSyncEnabled = false.obs;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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'] ?? {};
Expand Down Expand Up @@ -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<String, String>() : <String, String>{};
s.smsForwardingTargets.value = map['smsIncomingTargets'] is String ? jsonDecode(map['smsIncomingTargets']).cast<String, String>() : <String, String>{};
Expand Down
Loading