From f3589071a4e1775972ffb68842458c6b7b150406 Mon Sep 17 00:00:00 2001 From: Xare123 <57245242+Xare123@users.noreply.github.com> Date: Sat, 15 Aug 2026 17:27:33 -0700 Subject: [PATCH] Guard FaceTime join and teardown races --- .../services/facetime/CachedWebview.kt | 130 ++++++++++- .../services/facetime/FaceTimeActivity.kt | 205 ++++++++++++++---- .../facetime/FaceTimeCallStateHandler.kt | 3 +- .../services/facetime/FaceTimeJoinPolicy.kt | 61 ++++++ .../intents/InternalIntentReceiver.kt | 3 +- .../main/res/layout/activity_face_time.xml | 49 ++++- .../facetime/FaceTimeJoinPolicyTest.kt | 51 +++++ 7 files changed, 449 insertions(+), 53 deletions(-) create mode 100644 android/app/src/main/kotlin/com/bluebubbles/messaging/services/facetime/FaceTimeJoinPolicy.kt create mode 100644 android/app/src/test/kotlin/com/bluebubbles/messaging/services/facetime/FaceTimeJoinPolicyTest.kt 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..44272aad84 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 @@ -8,9 +8,11 @@ import android.os.Handler import android.os.Looper import android.util.Log import android.view.View +import android.webkit.ConsoleMessage import android.webkit.JavascriptInterface import android.webkit.PermissionRequest import android.webkit.WebChromeClient +import android.webkit.WebResourceError import android.webkit.WebResourceRequest import android.webkit.WebResourceResponse import android.webkit.WebView @@ -24,7 +26,13 @@ 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 callbackHandler = Handler(Looper.getMainLooper()) + private var mirrorReadyRunnable: Runnable? = null var mirrorReady = false var mirrorReadyCall: (() -> Unit)? = null @@ -36,6 +44,37 @@ class CachedWebview(context: Context, name: String?, desc: String, url: String) val deferredRequests = arrayListOf() var deferredRequestsUpdated: () -> Unit = {} + fun cancelCallbacks() { + mirrorReadyRunnable?.let(callbackHandler::removeCallbacks) + mirrorReadyRunnable = null + mirrorReadyCall = null + deferredRequestsUpdated = {} + } + + private fun safeResourceLabel(requestUrl: String?): String { + if (requestUrl == null) return "unknown" + return try { + val uri = android.net.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" + } + } + + private fun safeConsoleText(message: String): String { + return message + .replace("https?://\\S+".toRegex(), "") + .replace("[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,}".toRegex(RegexOption.IGNORE_CASE), "") + .replace("[A-Za-z0-9_-]{32,}".toRegex(), "") + .take(240) + } + fun getScriptData(request: WebResourceRequest, client: OkHttpClient, name: String?, desc: String): String { Log.i("FT", "Getting script") // OKHTTP should handle caching for us @@ -55,14 +94,28 @@ class CachedWebview(context: Context, name: String?, desc: String, url: String) } val body = response.body() ?: throw Exception("Failed to load resource! Empty body!") var string = body.string() + val waitingPattern = """"GenericToast\.Waiting": *"Waiting to be let in…",""".toRegex() + val bannerPattern = """"SessionBanner\.FaceTime": *"FaceTime Call",""".toRegex() + val submitNamePattern = "(submitName: *([a-zA-Z]+?)[ a-zA-Z,}=:]*?;)".toRegex() + val waitingMatches = waitingPattern.findAll(string).count() + val bannerMatches = bannerPattern.findAll(string).count() + val leaveMatches = "this.onLeave.notifyListeners()".toRegex().findAll(string).count() + val submitNameMatches = if (name != null) submitNamePattern.findAll(string).count() else 0 + + string = string .replace(""""GenericToast\.Waiting": *"Waiting to be let in…",""".toRegex(), """"GenericToast.Waiting":"Connecting…",""") .replace(""""SessionBanner\.FaceTime": *"FaceTime Call",""".toRegex(), """"SessionBanner.FaceTime":"$desc",""") .replace("this.onLeave.notifyListeners()", "Native.leave(), this.onLeave.notifyListeners()") if (name != null) { - string = string.replace("(submitName: *([a-zA-Z]+?)[ a-zA-Z,}=:]*?;)".toRegex(), "$1 $2(\"$name\").then(() => Native.mirrored());") + string = string.replace(submitNamePattern, "$1 $2(\"$name\").then(() => Native.mirrored());") } + Log.i( + diagnosticTag, + "main.js bytes=${string.length} patches waiting=$waitingMatches banner=$bannerMatches leave=$leaveMatches submitName=$submitNameMatches nameProvided=${name != null}" + ) + return string } @@ -82,10 +135,21 @@ class CachedWebview(context: Context, name: String?, desc: String, url: String) request: WebResourceRequest? ): WebResourceResponse? { if (request == null) return null - if (!request.url.toString().endsWith("main.js")) return null + if (!request.url.toString().endsWith("main.js")) { + if (request.url.lastPathSegment == "main.js") { + Log.w(diagnosticTag, "main.js candidate was not intercepted because its URL has a suffix") + } + return null + } // intercept and patch request - val scriptData = getScriptData(request, client, name, desc) + Log.i(diagnosticTag, "intercepting ${safeResourceLabel(request.url.toString())}") + val scriptData = try { + getScriptData(request, client, name, desc) + } catch (error: Exception) { + Log.e(diagnosticTag, "main.js interception failed: ${error.javaClass.simpleName}") + throw error + } return WebResourceResponse( "application/javascript", @@ -93,34 +157,84 @@ class CachedWebview(context: Context, name: String?, desc: String, url: String) ByteArrayInputStream(scriptData.encodeToByteArray()) ) } + + override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { + Log.i(diagnosticTag, "page started ${safeResourceLabel(url)}") + } + + override fun onPageFinished(view: WebView?, url: String?) { + Log.i(diagnosticTag, "page finished ${safeResourceLabel(url)} mirrorReady=$mirrorReady") + } + + override fun onReceivedError( + view: WebView?, + request: WebResourceRequest?, + error: WebResourceError? + ) { + 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? + ) { + Log.w( + diagnosticTag, + "http error mainFrame=${request?.isForMainFrame} status=${errorResponse?.statusCode} resource=${safeResourceLabel(request?.url?.toString())}" + ) + } } webView.setBackgroundColor(Color.BLACK) webView.addJavascriptInterface(object { @JavascriptInterface fun leave() { - endTask() + callbackHandler.post { endTask() } } @JavascriptInterface fun mirrored() { + if (mirrorReady || mirrorReadyRunnable != null) { + Log.i(diagnosticTag, "duplicate Native.mirrored ignored") + return + } // takes a second for the mirror to be ready - Handler(Looper.getMainLooper()).postDelayed({ + val runnable = Runnable { + mirrorReadyRunnable = null mirrorReady = true mirrorReadyCall?.let { it() } - }, 250) - Log.i("Got Mirror", "") + } + mirrorReadyRunnable = runnable + callbackHandler.postDelayed(runnable, 250) + Log.i(diagnosticTag, "Native.mirrored received; mirrorReady scheduled") } }, "Native") webView.webChromeClient = object : WebChromeClient() { override fun onPermissionRequest(request: PermissionRequest?) { if (request == null) return + Log.i(diagnosticTag, "WebView permission request resources=${request.resources.sorted().joinToString()}") deferredRequests.add(request) deferredRequestsUpdated() } + override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean { + if (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=${safeConsoleText(consoleMessage.message())}" + ) + } + return false + } + override fun getDefaultVideoPoster(): Bitmap { return Bitmap.createBitmap(1, 1, Bitmap.Config.RGB_565) } @@ -129,4 +243,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..6151f15822 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 @@ -43,6 +43,12 @@ import com.google.android.material.math.MathUtils import kotlin.math.roundToInt class FaceTimeActivity : Activity() { + companion object { + private const val diagnosticTag = "FaceTimeDiag" + var activeFaceTimeActivity: FaceTimeActivity? = null + var cachedWebview: CachedWebview? = null + } + private lateinit var binding: ActivityFaceTimeBinding private var permissionRequests = ArrayList() @@ -59,14 +65,108 @@ class FaceTimeActivity : Activity() { private lateinit var webView: WebView private var initialMediaVolume: Int? = null; + private val mainHandler = Handler(Looper.getMainLooper()) + private val joinPolicy = FaceTimeJoinPolicy() + private var joinRetryRunnable: Runnable? = null + private var manualRecoveryRunnable: Runnable? = null + private var endFallbackRunnable: Runnable? = null + private var callEnding = false + + private val joinButtonScript = """ + (() => { + const visible = (element) => !!element && element.offsetParent !== null; + const label = (element) => (element?.innerText || element?.textContent || element?.getAttribute?.("aria-label") || "").trim(); + const buttons = Array.from(document.querySelectorAll("button")); + const leave = document.getElementById("callcontrols-leave-button-session-banner") || + buttons.find((button) => /^(leave|end call)$/i.test(label(button))); + if (visible(leave)) return "already-joined"; + const join = document.getElementById("callcontrols-join-button-session-banner") || + buttons.find((button) => /^(join|rejoin)$/i.test(label(button))); + if (!join) return "missing"; + if (join.disabled || join.getAttribute("aria-disabled") === "true") return "disabled"; + if (!visible(join)) return "hidden"; + join.click(); + return "clicked"; + })() + """.trimIndent() + + private fun logJoinButtonState(reason: String) { + webView.evaluateJavascript( + """(() => { const button = document.getElementById("callcontrols-join-button-session-banner"); return button ? "present:" + (!button.disabled) + ":" + (button.offsetParent !== null) : "missing"; })()""" + ) { result -> + Log.i(diagnosticTag, "join button state reason=$reason result=$result mirrorReady=$mirrorReady answered=$answered") + } + } - companion object { - var activeFaceTimeActivity: FaceTimeActivity? = null - var cachedWebview: CachedWebview? = null + private fun showCallUi(joined: Boolean) { + binding.mainFrame.visibility = View.VISIBLE + binding.splashLayout.visibility = View.GONE + binding.nativeCallControls.visibility = View.VISIBLE + binding.connectionStatus.visibility = if (joined) View.GONE else View.VISIBLE + if (!joined) { + binding.connectionStatus.text = "Finishing FaceTime connection..." + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + window.setBackgroundBlurRadius(0) + } + } + + private fun scheduleJoinAttempt(reason: String, delayMillis: Long = 0) { + if (!answered || callEnding || joinPolicy.joined || isFinishing || isDestroyed) return + joinRetryRunnable?.let(mainHandler::removeCallbacks) + val runnable = Runnable { attemptJoin(reason) } + joinRetryRunnable = runnable + mainHandler.postDelayed(runnable, delayMillis) + } + + private fun attemptJoin(reason: String) { + if (!answered || callEnding || joinPolicy.joined || isFinishing || isDestroyed) return + webView.evaluateJavascript(joinButtonScript) { result -> + if (callEnding || isFinishing || isDestroyed) return@evaluateJavascript + val decision = joinPolicy.record(result) + Log.i( + diagnosticTag, + "join attempt reason=$reason attempt=${joinPolicy.attempts} outcome=${decision.outcome} mirrorReady=$mirrorReady answered=$answered" + ) + if (decision.joined) { + showCallUi(joined = true) + return@evaluateJavascript + } + if (decision.revealManualRecovery) { + showCallUi(joined = false) + } + if (decision.retry) { + scheduleJoinAttempt("retry-${decision.outcome}", 750) + } else { + showCallUi(joined = false) + binding.connectionStatus.text = "Tap Join or Rejoin to connect" + Log.w(diagnosticTag, "automatic join attempts exhausted") + } + } } fun endCall() { - webView.loadUrl("javascript:document.getElementById(\"callcontrols-leave-button-session-banner\").click()") + if (callEnding) return + callEnding = true + joinRetryRunnable?.let(mainHandler::removeCallbacks) + binding.connectionStatus.text = "Ending FaceTime..." + binding.connectionStatus.visibility = View.VISIBLE + binding.endCall.isEnabled = false + val fallback = Runnable { + if (!isFinishing && !isDestroyed) { + Log.w(diagnosticTag, "native end call fallback finishing activity") + finishAndRemoveTask() + } + } + endFallbackRunnable = fallback + mainHandler.postDelayed(fallback, 1500) + webView.evaluateJavascript( + """(() => { const buttons = Array.from(document.querySelectorAll("button")); const label = (element) => (element?.innerText || element?.textContent || element?.getAttribute?.("aria-label") || "").trim(); const button = document.getElementById("callcontrols-leave-button-session-banner") || buttons.find((item) => /^(leave|end call)$/i.test(label(item))); if (!button) return "missing"; button.click(); return "clicked"; })()""" + ) { result -> + Log.i(diagnosticTag, "native end call result=$result") + mainHandler.removeCallbacks(fallback) + mainHandler.postDelayed(fallback, 500) + } } private fun hideControlsForPIP() { @@ -165,6 +265,8 @@ class FaceTimeActivity : Activity() { private fun answerCall() { answered = true + Log.i(diagnosticTag, "answer requested mirrorReady=$mirrorReady deferredPermissions=${cached.deferredRequests.size}") + handlePermissionRequests() if (notificationId != 0) { @@ -172,12 +274,8 @@ class FaceTimeActivity : Activity() { } if (mirrorReady) { - binding.mainFrame.visibility = View.VISIBLE - binding.splashLayout.visibility = View.GONE - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - window.setBackgroundBlurRadius(0) - } - webView.loadUrl("javascript:document.getElementById(\"callcontrols-join-button-session-banner\").click()") + logJoinButtonState("answer-ready") + scheduleJoinAttempt("answer-ready") } else { connecting() } @@ -222,6 +320,10 @@ class FaceTimeActivity : Activity() { decline() } + binding.endCall.setOnClickListener { + endCall() + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { @@ -281,6 +383,10 @@ class FaceTimeActivity : Activity() { fun handlePermissionRequest(request: PermissionRequest) { val permissions = request.resources.flatMap { i -> permissionMap[i] ?: listOf() } + Log.i( + diagnosticTag, + "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() @@ -291,27 +397,37 @@ class FaceTimeActivity : Activity() { } override fun onDestroy() { - webView.destroy() - activeFaceTimeActivity = null - - val intent = Intent(this, FaceTimeInCallService::class.java) - stopService(intent) - serviceStarted = false - - // restore default media volume - initialMediaVolume?.let { - try { - val audioManager = getSystemService(AUDIO_SERVICE) as AudioManager - audioManager.setStreamVolume( - AudioManager.STREAM_MUSIC, - it, - 0 - ) - } catch (e: SecurityException) { - Log.w("FaceTime", "Unable to set stream volume!") + joinRetryRunnable?.let(mainHandler::removeCallbacks) + manualRecoveryRunnable?.let(mainHandler::removeCallbacks) + endFallbackRunnable?.let(mainHandler::removeCallbacks) + if (::cached.isInitialized) { + cached.cancelCallbacks() + } + + val isCurrentActivity = activeFaceTimeActivity === this + if (isCurrentActivity) { + activeFaceTimeActivity = null + val intent = Intent(this, FaceTimeInCallService::class.java) + stopService(intent) + serviceStarted = false + + // An older FaceTime activity must not mute or reroute a newer call. + initialMediaVolume?.let { + try { + val audioManager = getSystemService(AUDIO_SERVICE) as AudioManager + audioManager.setStreamVolume( + AudioManager.STREAM_MUSIC, + it, + 0 + ) + } catch (e: SecurityException) { + Log.w("FaceTime", "Unable to set stream volume!") + } } } + if (::webView.isInitialized) webView.destroy() + contentObserver?.let { applicationContext.contentResolver.unregisterContentObserver(it) } @@ -325,6 +441,10 @@ class FaceTimeActivity : Activity() { grantResults: IntArray ) { if (requestCode != 1) return + Log.i( + diagnosticTag, + "Android permission result ${permissions.zip(grantResults.toTypedArray()).joinToString { (permission, result) -> "$permission=${result == PackageManager.PERMISSION_GRANTED}" }}" + ) for (request in permissionRequests) { request.grant(request.resources.filter { i -> (permissionMap[i] ?: listOf()).all { @@ -338,15 +458,18 @@ class FaceTimeActivity : Activity() { } private fun connecting() { + Log.i(diagnosticTag, "waiting for mirrorReady") binding.acceptButtons.visibility = View.GONE binding.loadingBanner.text = "Connecting..." - Handler(Looper.getMainLooper()).postDelayed({ - binding.mainFrame.visibility = View.VISIBLE - binding.splashLayout.visibility = View.GONE - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - window.setBackgroundBlurRadius(0) - } - }, 15000) + scheduleJoinAttempt("connecting") + val recoveryRunnable = Runnable { + if (callEnding || isFinishing || isDestroyed || joinPolicy.joined) return@Runnable + Log.w(diagnosticTag, "mirrorReady timeout reached mirrorReady=$mirrorReady answered=$answered") + logJoinButtonState("mirror-timeout") + showCallUi(joined = false) + } + manualRecoveryRunnable = recoveryRunnable + mainHandler.postDelayed(recoveryRunnable, 15000) } private fun handleConfig(extras: Bundle) { @@ -368,13 +491,10 @@ class FaceTimeActivity : Activity() { mirrorReady = cached.mirrorReady cached.mirrorReadyCall = { mirrorReady = true + Log.i(diagnosticTag, "mirrorReady callback answered=$answered") if (answered) { - binding.mainFrame.visibility = View.VISIBLE - binding.splashLayout.visibility = View.GONE - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - window.setBackgroundBlurRadius(0) - } - webView.loadUrl("javascript:document.getElementById(\"callcontrols-join-button-session-banner\").click()") + logJoinButtonState("mirror-ready") + scheduleJoinAttempt("mirror-ready") } } @@ -413,10 +533,11 @@ class FaceTimeActivity : Activity() { } else { binding.splashLayout.visibility = View.GONE binding.mainFrame.visibility = View.VISIBLE + binding.nativeCallControls.visibility = View.VISIBLE if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { window.setBackgroundBlurRadius(0) } handlePermissionRequests() } } -} \ No newline at end of file +} diff --git a/android/app/src/main/kotlin/com/bluebubbles/messaging/services/facetime/FaceTimeCallStateHandler.kt b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/facetime/FaceTimeCallStateHandler.kt index 61cc59f7ce..2b57a65723 100644 --- a/android/app/src/main/kotlin/com/bluebubbles/messaging/services/facetime/FaceTimeCallStateHandler.kt +++ b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/facetime/FaceTimeCallStateHandler.kt @@ -36,6 +36,7 @@ class FaceTimeCallStateHandler: MethodCallHandlerImpl() { } // cancel any unused webview FaceTimeActivity.cachedWebview?.let { + it.cancelCallbacks() it.webView.destroy() FaceTimeActivity.cachedWebview = null } @@ -44,4 +45,4 @@ class FaceTimeCallStateHandler: MethodCallHandlerImpl() { result.success(null) } -} \ No newline at end of file +} diff --git a/android/app/src/main/kotlin/com/bluebubbles/messaging/services/facetime/FaceTimeJoinPolicy.kt b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/facetime/FaceTimeJoinPolicy.kt new file mode 100644 index 0000000000..14a8a2d6d8 --- /dev/null +++ b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/facetime/FaceTimeJoinPolicy.kt @@ -0,0 +1,61 @@ +package com.bluebubbles.messaging.services.facetime + +internal enum class FaceTimeJoinOutcome { + CLICKED, + ALREADY_JOINED, + MISSING, + DISABLED, + HIDDEN, + UNKNOWN, +} + +internal data class FaceTimeJoinDecision( + val outcome: FaceTimeJoinOutcome, + val joined: Boolean, + val revealManualRecovery: Boolean, + val retry: Boolean, +) + +internal class FaceTimeJoinPolicy( + private val manualRecoveryAttempt: Int = 20, + private val maxAttempts: Int = 80, +) { + var attempts: Int = 0 + private set + + var joined: Boolean = false + private set + + fun record(rawResult: String?): FaceTimeJoinDecision { + attempts += 1 + val outcome = parseOutcome(rawResult) + if (outcome == FaceTimeJoinOutcome.CLICKED || outcome == FaceTimeJoinOutcome.ALREADY_JOINED) { + joined = true + } + + return FaceTimeJoinDecision( + outcome = outcome, + joined = joined, + revealManualRecovery = joined || attempts >= manualRecoveryAttempt, + retry = !joined && attempts < maxAttempts, + ) + } + + companion object { + fun parseOutcome(rawResult: String?): FaceTimeJoinOutcome { + val normalized = rawResult + ?.trim() + ?.removeSurrounding("\"") + ?.lowercase() + + return when (normalized) { + "clicked" -> FaceTimeJoinOutcome.CLICKED + "already-joined" -> FaceTimeJoinOutcome.ALREADY_JOINED + "missing" -> FaceTimeJoinOutcome.MISSING + "disabled" -> FaceTimeJoinOutcome.DISABLED + "hidden" -> FaceTimeJoinOutcome.HIDDEN + else -> FaceTimeJoinOutcome.UNKNOWN + } + } + } +} diff --git a/android/app/src/main/kotlin/com/bluebubbles/messaging/services/intents/InternalIntentReceiver.kt b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/intents/InternalIntentReceiver.kt index 02ab55955d..55830ee081 100644 --- a/android/app/src/main/kotlin/com/bluebubbles/messaging/services/intents/InternalIntentReceiver.kt +++ b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/intents/InternalIntentReceiver.kt @@ -49,6 +49,7 @@ class InternalIntentReceiver: BroadcastReceiver() { val notificationId: Int = intent.getIntExtra("notificationId", 0) DeleteNotificationHandler().deleteNotification(context, notificationId, null) FaceTimeActivity.cachedWebview?.let { + it.cancelCallbacks() it.webView.destroy() FaceTimeActivity.cachedWebview = null } @@ -115,4 +116,4 @@ class InternalIntentReceiver: BroadcastReceiver() { } } } -} \ No newline at end of file +} diff --git a/android/app/src/main/res/layout/activity_face_time.xml b/android/app/src/main/res/layout/activity_face_time.xml index 00447c806d..8e0c5c5545 100644 --- a/android/app/src/main/res/layout/activity_face_time.xml +++ b/android/app/src/main/res/layout/activity_face_time.xml @@ -140,4 +140,51 @@ - \ No newline at end of file + + + + + + + + + + diff --git a/android/app/src/test/kotlin/com/bluebubbles/messaging/services/facetime/FaceTimeJoinPolicyTest.kt b/android/app/src/test/kotlin/com/bluebubbles/messaging/services/facetime/FaceTimeJoinPolicyTest.kt new file mode 100644 index 0000000000..0f8a5406d2 --- /dev/null +++ b/android/app/src/test/kotlin/com/bluebubbles/messaging/services/facetime/FaceTimeJoinPolicyTest.kt @@ -0,0 +1,51 @@ +package com.bluebubbles.messaging.services.facetime + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class FaceTimeJoinPolicyTest { + @Test + fun clickedStopsRetryAndRevealsCall() { + val decision = FaceTimeJoinPolicy().record("\"clicked\"") + + assertEquals(FaceTimeJoinOutcome.CLICKED, decision.outcome) + assertTrue(decision.joined) + assertTrue(decision.revealManualRecovery) + assertFalse(decision.retry) + } + + @Test + fun alreadyJoinedIsIdempotent() { + val decision = FaceTimeJoinPolicy().record("\"already-joined\"") + + assertTrue(decision.joined) + assertFalse(decision.retry) + } + + @Test + fun manualRecoveryAppearsWhileRetriesContinue() { + val policy = FaceTimeJoinPolicy(manualRecoveryAttempt = 2, maxAttempts = 4) + + val first = policy.record("\"missing\"") + val second = policy.record("\"hidden\"") + + assertFalse(first.revealManualRecovery) + assertTrue(first.retry) + assertTrue(second.revealManualRecovery) + assertTrue(second.retry) + } + + @Test + fun retriesEventuallyStop() { + val policy = FaceTimeJoinPolicy(manualRecoveryAttempt = 1, maxAttempts = 2) + + policy.record("\"disabled\"") + val finalDecision = policy.record(null) + + assertEquals(FaceTimeJoinOutcome.UNKNOWN, finalDecision.outcome) + assertTrue(finalDecision.revealManualRecovery) + assertFalse(finalDecision.retry) + } +}