diff --git a/CHANGELOG.md b/CHANGELOG.md index 6284d6d..d5640f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.0.3] + +### Fixes + +- Keep the main app process active while the WebView is in the foreground, by binding it to the WebView's process. Without this, Android can freeze the main process during a browser session, so browser events stop being processed and the app never reacts to the page finishing ([RMET-5394](https://outsystemsrd.atlassian.net/browse/RMET-5394)). + ## [2.0.2] ### Fixes diff --git a/README.md b/README.md index 8ff7310..c83f1fa 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,11 @@ Each is detailed in the following sections. - [Open a URL in a System Browser](#open-a-url-in-a-system-browser) - [Open a URL in a Web View](#open-a-url-in-a-web-view) - [Close](#close) +- [Debug log capture (RMET-5394)](#debug-log-capture-rmet-5394) + - [Enabling capture](#enabling-capture) + - [Sharing log files](#sharing-log-files) + - [Removing log files](#removing-log-files) + - [What gets logged](#what-gets-logged) ## Motivation @@ -78,4 +83,73 @@ fun close(completionHandler: (Boolean) -> Unit) ``` Handles closing an opened browser. The method is composed of the following input parameters: -- **completionHandler**: The callback with the result of closing the browser. \ No newline at end of file +- **completionHandler**: The callback with the result of closing the browser. + +## Debug log capture (RMET-5394) + +> This is a **debug-only diagnostic tool**, not part of the library's public API surface, and not intended for production builds. It exists to capture repro sessions for [RMET-5394](https://outsystemsrd.atlassian.net/browse/RMET-5394) (main process getting frozen while the isolated Web View is in the foreground) on devices/scenarios where staying attached via `adb` isn't practical - USB debugging suppresses the freeze under investigation, and Wi-Fi debugging has been unstable in practice. + +`OSIABLogCaptureHelper` (in `helpers/OSIABLogCaptureHelper.kt`) shells out to the device's `logcat` binary and tails the full device log - not just this library's own log lines, but everything logged under the app's UID (other plugins, host app code, etc.) - to a rotating set of files on disk, with no live debugger connection required. + +### Enabling capture + +Because the Web View runs in its own isolated process, capture needs to start as early as possible in **both** processes. Android calls `Application.onCreate()` independently in every process the app spawns (main process at launch, and again in the isolated process when it's created for the browser), so adding a single call there covers both: + +```kotlin +class MyApplication : Application() { + override fun onCreate() { + super.onCreate() + OSIABLogCaptureHelper.start(this) + OSIABLogCaptureHelper.startShakeToShare(this) // see "Sharing log files" below + } + + // optional but recommended: see "What gets logged" below + override fun onTrimMemory(level: Int) { + super.onTrimMemory(level) + OSIABLogCaptureHelper.logTrimMemory(level) + } +} +``` + +Register the custom `Application` class in the consuming app's manifest if it doesn't already declare one: + +```xml + +``` + +Logs are written to `/logs/oslog--.txt`, rotating every 10 MB per file. Files are **not** deleted automatically - only [`deleteLogs`](#removing-log-files) removes them - so remember to clear them between test sessions. + +Requires API 28+ (`Application.getProcessName()`); below that, capture still runs but can't distinguish the isolated process from the main one. + +### Sharing log files + +Call `OSIABLogCaptureHelper.shareLogs(context)` to open a standard share chooser (email, Drive, Slack, etc.) with every captured log file attached: + +```kotlin +OSIABLogCaptureHelper.shareLogs(context) +``` + +Two built-in triggers cover most cases: +- **Long-press the Close button** - `OSIABWebViewActivity`-only, so only reachable when the browser is open and the toolbar is shown (`showToolbar: true`). +- **Shake the device** (2 shakes within ~3 seconds) - works anywhere in the app, not just inside the Web View, since `OSIABLogCaptureHelper.startShakeToShare(context)` registers a single accelerometer listener per process (see [Enabling capture](#enabling-capture)) rather than being tied to any one Activity's lifecycle. A hardware volume-key trigger was tried first, but on-device testing showed the OS intercepts volume key events before they ever reach the Activity, so it never fired reliably; a shake gesture reads the accelerometer directly and doesn't depend on Android's key/touch dispatch pipeline at all. + +You can still call `shareLogs(context)` directly from anywhere else convenient (e.g. temporarily added to app code) if neither of those fits your repro. + +### Removing log files + +Call `OSIABLogCaptureHelper.deleteLogs(context)` to delete every captured log file: + +```kotlin +OSIABLogCaptureHelper.deleteLogs(context) +``` + +### What gets logged + +Beyond the raw `logcat` tail, a few signals are deliberately emitted to make the captured logs useful for diagnosing RMET-5394 specifically: + +- **`OSIABEvents` send/receive timestamps** - `broadcastEvent()` logs right before sending (from the isolated process, which never freezes), and the registered receiver logs immediately on `onReceive()` (in whichever process registered it, typically the main process). The gap between these two log lines is the most direct evidence of the main process being frozen - e.g. "sent at T, received at T+40s" - rather than something inferred indirectly. +- **Activity lifecycle breadcrumbs** - `OSIABWebViewActivity.onCreate`/`onDestroy` log the `browserId` and (for `onDestroy`) `isFinishing`, and `OSIABEvents.registerReceiver`/`unregisterReceiver` log their ref-counted register/unregister transitions. Mainly for timeline correlation across the two processes' separate log files. +- **`onTrimMemory` levels** - `OSIABLogCaptureHelper.logTrimMemory(level)` logs Android's own `ComponentCallbacks2.onTrimMemory()` signal, an early OS-native indicator of a process trending toward the cached/frozen state, ahead of an actual freeze taking effect. `OSIABWebViewActivity` already logs this for the isolated process; call it from the consuming app's own `Application.onTrimMemory()` (see [Enabling capture](#enabling-capture)) to get the same signal for the main process. +- **WebView JS console messages** - `OSIABWebChromeClient.onConsoleMessage` bridges page `console.log`/`warn`/`error` output into the same log capture, so what the page believed happened (e.g. "payment complete, notifying app") can be correlated against when the native app actually reacted. \ No newline at end of file diff --git a/RMET-5394-file-logging-plan.md b/RMET-5394-file-logging-plan.md new file mode 100644 index 0000000..b5f430d --- /dev/null +++ b/RMET-5394-file-logging-plan.md @@ -0,0 +1,132 @@ +# RMET-5394 — On-device file logging (implementation plan) + +## Context + +We can't reliably reproduce/observe the main-process-freeze bug (RMET-5394) while +attached to `adb` — USB debugging suppresses Samsung's freezing behavior, and +wireless debugging has been unstable in practice. This plan adds a small, +debug-only logging capture to the library so a QA/dev session can be recorded +to disk with no live debugger attached, then shared afterwards (email/Drive/Slack) +via a normal share-sheet chooser. + +This is a diagnostic tool for this branch (`test/RMET-5394/file-logging`), not a +customer-facing feature. It's meant to be built into a one-off test APK, used to +capture a repro, and shared/pulled after the fact. + +## Design summary (decisions made) + +- **Capture everything, not just this library's own logs.** Achieved by shelling + out to the `logcat` binary (`ProcessBuilder`/`Runtime.exec`) rather than a custom + log wrapper — `logcat` reads the OS-level `logd` ring buffer, which contains every + `Log.*` call from any code running under this app's UID (this library, other + Cordova plugins, host app code), not just calls routed through our own code. + No special permission is needed to read our own app's UID logs. +- **One call site, both processes.** `Application.onCreate()` runs once per OS + process — Android instantiates the `Application` object and calls `onCreate()` + independently in the main process at launch **and again** in the isolated + `:OSInAppBrowser` process the moment it's created for `OSIABWebViewActivity`, + before that activity's own `onCreate()` runs. So a single unconditional call + added to the *test app's* `Application.onCreate()` starts capture as early as + possible in both processes, with no extra hook needed in the WebView activity. +- **Each process needs its own file set.** Since the same call runs in both + processes, the target file name must be derived from the process name + (e.g. suffix `main` vs `isolated`) — otherwise two independent `logcat` + subprocesses would race to write/rotate the same file. +- **The isolated process's file is the reliable one.** Android's cached-apps-freezer + freezes a process's whole cgroup, including any forked/exec'd children — so if + the main process freezes, its own `logcat` child freezes with it and that file + stops growing for the duration (expected — this is itself useful evidence). + The isolated WebView process stays visible/unfrozen for the whole session, so + its file (which also contains the main process's lines, since both share a UID + and log to the same buffer) is the one to treat as the continuous record. +- **Rotation:** `logcat -v threadtime -r -n -f `. `-r` caps each + file at ~10 MB (`-r 10240`); `-n` (default 4) must be set high (e.g. `9999`) since + `logcat` overwrites the oldest file once the count is hit — we want manual + deletion only, not auto-recycling. +- **Sharing:** reuse the existing `FileProvider` (`${applicationId}.fileprovider`). + `file_paths.xml` already maps the entire cache dir (``), so writing logs under a subdirectory of `context.cacheDir` needs + **no manifest/xml changes**. Share via `Intent.ACTION_SEND` + chooser, with + `FLAG_GRANT_READ_URI_PERMISSION` explicitly added to the intent — same pattern + already used for the camera/video capture intents fixed in #57, since chooser + targets otherwise can't read the `content://` URI. +- **Trigger:** no permanent UI. The share/delete calls will be invoked ad hoc by + temporarily editing source and rebuilding, per the current debugging workflow. + +## Proposed API + +New file: `helpers/OSIABLogCaptureHelper.kt` (mirrors `OSIABPdfHelper.kt`'s +placement/style). + +```kotlin +object OSIABLogCaptureHelper { + fun start(context: Context) // no-op if already started in this process + fun stop() // destroys the logcat subprocess, if running + fun shareLogs(context: Context) // ACTION_SEND chooser with all log files for this device/session + fun deleteLogs(context: Context) // deletes all files under the log directory +} +``` + +- Log directory: `context.cacheDir/logs/`. +- File base name: `oslog-.txt` where `processSuffix` is derived + from `Application.getProcessName()` (guarded by `SDK_INT >= P`, same pattern as + `isIsolatedProcess()` in `OSIABWebViewActivity`), e.g. `oslog-main.txt`, + `oslog-isolated.txt` (+ `.1`, `.2`, ... rotated siblings). +- `start()` builds and runs: + `logcat -v threadtime -r 10240 -n 9999 -f /logs/oslog-.txt` +- `shareLogs()` collects every file in `context.cacheDir/logs/`, builds an + `ArrayList` via `FileProvider.getUriForFile`, and sends + `Intent.ACTION_SEND_MULTIPLE` with `FLAG_GRANT_READ_URI_PERMISSION`, wrapped in + `Intent.createChooser(...)`. Needs `FLAG_ACTIVITY_NEW_TASK` if invoked from a + non-Activity `Context` (e.g. called from `Application`). + +## Implementation steps + +1. Add `helpers/OSIABLogCaptureHelper.kt` with `start`/`stop`/`shareLogs`/`deleteLogs` + as above. +2. Wire `OSIABLogCaptureHelper.start(this)` into the test app's `Application` + subclass `onCreate()` (this lives in the consuming Cordova app, not this + library — will need a custom `Application` class + `android:name` on + `` in that app's manifest if one doesn't already exist). +3. Add a temporary call to `OSIABLogCaptureHelper.shareLogs(this)` at whatever + point in the library/app code is convenient for the current repro (e.g. a + button, or directly in `onDestroy`/a specific callback) — edited in and out + manually as needed, not a permanent UI element. +4. No `AndroidManifest.xml` or `file_paths.xml` changes expected (existing + `FileProvider`/cache-path mapping already covers this). +5. Manual verification on a real Samsung device per the existing repro + difficulties (see prior discussion) — confirm both files are produced, confirm + rotation at 10 MB, confirm the isolated-process file keeps growing through a + simulated freeze while the main-process file stalls, confirm `shareLogs()` + chooser works and the recipient app can actually open the shared files. + +## Open items / risks to resolve before or during implementation + +1. **`logcat -f` restart behavior is unverified.** Unclear whether re-running the + command (e.g. next app launch, log files from a previous session still present) + appends, truncates, or errors on an existing target file. Needs an on-device + check; if it doesn't behave as desired, `start()` may need to pick a + timestamped file name per launch instead of a fixed base name. +2. **`Application.getProcessName()` requires API 28+.** minSdk for this library is + 26. On API 26/27 the process-suffix check can't run the same way + `isIsolatedProcess()` already does — needs a decision: accept API 28+ only for + this diagnostic tool (consistent with existing precedent), or add a fallback + (e.g. reading `/proc/self/cmdline`). +3. **No automatic cap on total disk usage.** Per-file size is bounded (10 MB) and + rotation count is intentionally large to avoid auto-deletion, so total usage + grows unbounded across a long session until `deleteLogs()` is called manually. + Acceptable for a manual debug workflow, but worth remembering. +4. **Orphaned `logcat` subprocess on process death.** Not yet confirmed whether the + spawned `logcat` process reliably terminates when its parent app process is + killed/removed from recents, or lingers as an orphan. Low priority (doesn't + block the plan) but worth a quick check so stale processes don't accumulate + during repeated test cycles. +5. **Consuming test app changes needed.** Since `Application.onCreate()` lives in + the host Cordova app, not this library, we need write access to (or a local + checkout of) the specific customer app being used for repro, including adding + a custom `Application` class if one doesn't exist yet. Confirm that app/checkout + is available before starting implementation. +6. **No test coverage planned.** This is a temporary diagnostic tool spawning a + native subprocess and doing file I/O — hard to meaningfully unit test, and not + intended to ship, so no automated tests are planned. Flagging explicitly so + it's a conscious choice, not an oversight. diff --git a/pom.xml b/pom.xml index 259c045..41e4cf8 100644 --- a/pom.xml +++ b/pom.xml @@ -6,5 +6,5 @@ 4.0.0 io.ionic.libs ioninappbrowser-android - 2.0.2 + 2.0.3 diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml index 2218bcf..10c7dc4 100644 --- a/src/main/AndroidManifest.xml +++ b/src/main/AndroidManifest.xml @@ -49,6 +49,9 @@ android:resource="@xml/file_paths" /> + diff --git a/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/OSIABEvents.kt b/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/OSIABEvents.kt index 263c278..3275ad3 100644 --- a/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/OSIABEvents.kt +++ b/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/OSIABEvents.kt @@ -4,6 +4,7 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter +import android.util.Log import androidx.core.content.ContextCompat import androidx.core.content.IntentCompat import kotlinx.coroutines.flow.MutableSharedFlow @@ -35,6 +36,11 @@ sealed class OSIABEvents : Serializable { ) : OSIABEvents() companion object { + // RMET-5394 debug logging tag: measures the gap between broadcastEvent() (sent + // from the isolated, never-frozen process) and onReceive() (received in the + // main process) - the most direct evidence of the main process being frozen. + private const val LOG_TAG = "OSIABEvents" + const val EXTRA_BROWSER_ID = "com.outsystems.plugins.inappbrowser.osinappbrowserlib.EXTRA_BROWSER_ID" const val ACTION_IAB_EVENT = "com.outsystems.plugins.inappbrowser.osinappbrowserlib.ACTION_IAB_EVENT" const val ACTION_CLOSE_WEBVIEW = "com.outsystems.plugins.inappbrowser.osinappbrowserlib.ACTION_CLOSE_WEBVIEW" @@ -66,8 +72,11 @@ sealed class OSIABEvents : Serializable { EXTRA_EVENT_DATA, OSIABEvents::class.java ) - event?.let { - _events.tryEmit(it) + if (event != null) { + Log.d(LOG_TAG, "received ${event::class.simpleName} browserId=${event.browserId}${urlLogSuffix(event)}") + _events.tryEmit(event) + } else { + Log.d(LOG_TAG, "received ACTION_IAB_EVENT with null/undecodable payload") } } } @@ -80,6 +89,7 @@ sealed class OSIABEvents : Serializable { filter, ContextCompat.RECEIVER_NOT_EXPORTED ) + Log.d(LOG_TAG, "registerReceiver: registered (refCount=$receiverRefCount)") } /** @@ -96,6 +106,7 @@ sealed class OSIABEvents : Serializable { receiver?.let { try { context.applicationContext.unregisterReceiver(it) + Log.d(LOG_TAG, "unregisterReceiver: unregistered") } catch (e: Exception) { // Receiver may not be registered, ignore } @@ -113,12 +124,20 @@ sealed class OSIABEvents : Serializable { * Only data-only events should be broadcast (BrowserPageLoaded, BrowserFinished, etc.). */ fun broadcastEvent(context: Context, event: OSIABEvents) { + Log.d(LOG_TAG, "broadcastEvent: sending ${event::class.simpleName} browserId=${event.browserId}${urlLogSuffix(event)}") val intent = Intent(ACTION_IAB_EVENT).apply { setPackage(context.packageName) putExtra(EXTRA_EVENT_DATA, event) } context.sendBroadcast(intent) } + + // RMET-5394 debug logging only: BrowserPageNavigationCompleted is the only + // event carrying a URL. Not logged by default (query params can carry + // session tokens), but needed here to tell whether a "successful" native + // navigation actually landed on the expected page. + private fun urlLogSuffix(event: OSIABEvents): String = + (event as? BrowserPageNavigationCompleted)?.url?.let { " url=$it" }.orEmpty() } } diff --git a/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/OSIABKeepAliveService.kt b/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/OSIABKeepAliveService.kt new file mode 100644 index 0000000..bbbcced --- /dev/null +++ b/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/OSIABKeepAliveService.kt @@ -0,0 +1,15 @@ +package com.outsystems.plugins.inappbrowser.osinappbrowserlib + +import android.app.Service +import android.content.Intent +import android.os.Binder +import android.os.IBinder + +/** + * Runs in the main app process. While the isolated WebView activity is bound to it, + * the main process is not eligible for OS app freezing, so it keeps processing + * browser events while the WebView is in the foreground. + */ +class OSIABKeepAliveService : Service() { + override fun onBind(intent: Intent?): IBinder = Binder() +} diff --git a/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/helpers/OSIABLogCaptureHelper.kt b/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/helpers/OSIABLogCaptureHelper.kt new file mode 100644 index 0000000..79ae5eb --- /dev/null +++ b/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/helpers/OSIABLogCaptureHelper.kt @@ -0,0 +1,195 @@ +package com.outsystems.plugins.inappbrowser.osinappbrowserlib.helpers + +import android.app.Application +import android.content.ComponentCallbacks2 +import android.content.Context +import android.content.Intent +import android.hardware.Sensor +import android.hardware.SensorEvent +import android.hardware.SensorEventListener +import android.hardware.SensorManager +import android.os.Build +import android.os.SystemClock +import android.util.Log +import androidx.core.content.FileProvider +import java.io.File +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import java.util.Locale + +/** + * Debug-only helper (RMET-5394) that captures the full device log - not just this + * library's own log lines - to a rotating set of files, for repro sessions where a + * live adb connection can't be used (USB suppresses the process freeze under + * investigation; Wi-Fi debugging has been unstable). Not intended for production use. + * + * Meant to be started once from the consuming app's Application.onCreate(), which + * Android calls independently in every process the app spawns (main process at + * launch, and again in the isolated WebView process when it's created), so a single + * call site covers both. + */ +object OSIABLogCaptureHelper { + + private const val LOG_TAG = "OSIABLogCaptureHelper" + private const val LOG_DIR_NAME = "logs" + private const val ROTATE_KB = "10240" // 10 MB per file + private const val MAX_ROTATED_FILES = "9999" // large on purpose: only deleteLogs() should remove files + private const val ISOLATED_PROCESS_SUFFIX = ":OSInAppBrowser" + + // RMET-5394 debug build only: shake-to-share tuning + private const val SHAKE_THRESHOLD_GRAVITY = 2.7f + private const val SHAKE_SLOP_TIME_MS = 200L + private const val SHAKE_COUNT_RESET_TIME_MS = 3000L + private const val SHAKE_COUNT_THRESHOLD = 2 + private const val SHAKE_TRIGGER_COOLDOWN_MS = 5000L + + private var logcatProcess: Process? = null + private var shakeSensorManager: SensorManager? = null + private var shakeCount = 0 + private var lastShakeTimestamp = 0L + private var lastShakeTriggerTimestamp = 0L + + /** + * Starts a `logcat` subprocess that tails the device log to a timestamped file + * under [Context.getCacheDir]/logs. No-op if already started in this process. + */ + fun start(context: Context) { + if (logcatProcess != null) return + + val logDir = File(context.cacheDir, LOG_DIR_NAME).apply { mkdirs() } + val timestamp = LocalDateTime.now().format( + DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss", Locale.getDefault()) + ) + val logFile = File(logDir, "oslog-${processSuffix()}-$timestamp.txt") + + try { + logcatProcess = ProcessBuilder( + "logcat", "-v", "threadtime", + "-r", ROTATE_KB, + "-n", MAX_ROTATED_FILES, + "-f", logFile.absolutePath + ).redirectErrorStream(true).start() + } catch (e: Exception) { + Log.d(LOG_TAG, "Failed to start log capture: ${e.message}") + } + } + + /** + * Stops the `logcat` subprocess for this process, if running. + */ + fun stop() { + logcatProcess?.destroy() + logcatProcess = null + } + + /** + * Opens a share chooser with every captured log file for this device. + * [context] must be an Activity context, or the intent will carry + * FLAG_ACTIVITY_NEW_TASK to allow launching from an Application context. + */ + fun shareLogs(context: Context) { + val files = File(context.cacheDir, LOG_DIR_NAME).listFiles()?.filter { it.isFile } + if (files.isNullOrEmpty()) return + + val authority = "${context.packageName}.fileprovider" + val uris = ArrayList(files.map { FileProvider.getUriForFile(context, authority, it) }) + + val sendIntent = Intent(Intent.ACTION_SEND_MULTIPLE).apply { + type = "text/plain" + putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + val chooserIntent = Intent.createChooser(sendIntent, "Share OSIAB logs").apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + + try { + context.startActivity(chooserIntent) + } catch (e: Exception) { + Log.d(LOG_TAG, "Failed to launch log share chooser: ${e.message}") + } + } + + /** + * Deletes every captured log file for this device. + */ + fun deleteLogs(context: Context) { + File(context.cacheDir, LOG_DIR_NAME).listFiles()?.forEach { it.delete() } + } + + /** + * Starts shake-to-share: 2 shakes within ~3 seconds calls [shareLogs]. Meant to + * be started once from the consuming app's Application.onCreate() (same call + * site, and same reasoning, as [start]) - since sensors aren't tied to any + * specific Activity's focus, one registration per process covers every screen, + * including both inside and outside the isolated Web View. No-op if already + * started in this process. + */ + fun startShakeToShare(context: Context) { + if (shakeSensorManager != null) return + val appContext = context.applicationContext + val manager = appContext.getSystemService(Context.SENSOR_SERVICE) as? SensorManager ?: return + val accelerometer = manager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) ?: return + manager.registerListener( + object : SensorEventListener { + override fun onSensorChanged(event: SensorEvent) { + val gX = event.values[0] / SensorManager.GRAVITY_EARTH + val gY = event.values[1] / SensorManager.GRAVITY_EARTH + val gZ = event.values[2] / SensorManager.GRAVITY_EARTH + val gForce = kotlin.math.sqrt(gX * gX + gY * gY + gZ * gZ) + if (gForce < SHAKE_THRESHOLD_GRAVITY) return + + val now = SystemClock.elapsedRealtime() + if (lastShakeTimestamp + SHAKE_SLOP_TIME_MS > now) return + if (lastShakeTimestamp + SHAKE_COUNT_RESET_TIME_MS < now) shakeCount = 0 + lastShakeTimestamp = now + shakeCount++ + + if (shakeCount >= SHAKE_COUNT_THRESHOLD && + now - lastShakeTriggerTimestamp > SHAKE_TRIGGER_COOLDOWN_MS + ) { + lastShakeTriggerTimestamp = now + shakeCount = 0 + shareLogs(appContext) + } + } + + override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {} + }, + accelerometer, + SensorManager.SENSOR_DELAY_UI + ) + shakeSensorManager = manager + } + + /** + * Logs a ComponentCallbacks2.onTrimMemory() level. Meant to be called from both + * the consuming app's Application.onTrimMemory() (main process) and this + * library's Activity.onTrimMemory() (isolated process) - it's an early, OS-native + * signal of a process trending toward the cached/frozen state, ahead of an actual + * freeze taking effect. + */ + fun logTrimMemory(level: Int) { + Log.d(LOG_TAG, "onTrimMemory level=$level (${trimMemoryLevelName(level)})") + } + + private fun trimMemoryLevelName(level: Int): String = when (level) { + ComponentCallbacks2.TRIM_MEMORY_COMPLETE -> "COMPLETE" + ComponentCallbacks2.TRIM_MEMORY_MODERATE -> "MODERATE" + ComponentCallbacks2.TRIM_MEMORY_BACKGROUND -> "BACKGROUND" + ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN -> "UI_HIDDEN" + ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL -> "RUNNING_CRITICAL" + ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW -> "RUNNING_LOW" + ComponentCallbacks2.TRIM_MEMORY_RUNNING_MODERATE -> "RUNNING_MODERATE" + else -> "UNKNOWN($level)" + } + + private fun processSuffix(): String = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && + Application.getProcessName().endsWith(ISOLATED_PROCESS_SUFFIX) + ) { + "isolated" + } else { + "main" + } +} diff --git a/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/models/OSIABWebViewOptions.kt b/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/models/OSIABWebViewOptions.kt index 17a1711..512c84e 100644 --- a/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/models/OSIABWebViewOptions.kt +++ b/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/models/OSIABWebViewOptions.kt @@ -17,5 +17,8 @@ data class OSIABWebViewOptions( @SerializedName("hardwareBack") val hardwareBack: Boolean = true, @SerializedName("pauseMedia") val pauseMedia: Boolean = true, @SerializedName("customUserAgent") val customUserAgent: String? = null, - @SerializedName("isIsolated") val isIsolated: Boolean = true + @SerializedName("isIsolated") val isIsolated: Boolean = true, + // RMET-5394: regex patterns checked against each finished page load; a match closes the + // browser natively (no dependency on MainActivity's WebView JS being able to react). + @SerializedName("successUrlPatterns") val successUrlPatterns: List? = null ) : OSIABOptions, Serializable diff --git a/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/views/OSIABWebViewActivity.kt b/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/views/OSIABWebViewActivity.kt index 6665832..003149a 100644 --- a/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/views/OSIABWebViewActivity.kt +++ b/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/views/OSIABWebViewActivity.kt @@ -4,18 +4,22 @@ import android.Manifest import android.app.Application import android.app.Activity import android.content.BroadcastReceiver +import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.IntentFilter +import android.content.ServiceConnection import android.content.pm.PackageManager import android.graphics.Bitmap import android.net.Uri import android.os.Build import android.os.Bundle +import android.os.IBinder import android.provider.MediaStore import android.util.Log import android.view.Gravity import android.view.View +import android.webkit.ConsoleMessage import android.webkit.CookieManager import android.webkit.GeolocationPermissions import android.webkit.PermissionRequest @@ -23,6 +27,7 @@ import android.webkit.ValueCallback import android.webkit.WebChromeClient import android.webkit.WebResourceError import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse import android.webkit.WebView import android.webkit.WebViewClient import android.widget.Button @@ -41,8 +46,11 @@ import androidx.core.content.ContextCompat import androidx.core.content.FileProvider import androidx.core.view.isVisible import androidx.lifecycle.lifecycleScope +import java.util.regex.PatternSyntaxException import com.outsystems.plugins.inappbrowser.osinappbrowserlib.OSIABEvents +import com.outsystems.plugins.inappbrowser.osinappbrowserlib.OSIABKeepAliveService import com.outsystems.plugins.inappbrowser.osinappbrowserlib.R +import com.outsystems.plugins.inappbrowser.osinappbrowserlib.helpers.OSIABLogCaptureHelper import com.outsystems.plugins.inappbrowser.osinappbrowserlib.helpers.OSIABPdfHelper import com.outsystems.plugins.inappbrowser.osinappbrowserlib.models.OSIABToolbarPosition import com.outsystems.plugins.inappbrowser.osinappbrowserlib.models.OSIABWebViewOptions @@ -73,6 +81,8 @@ open class OSIABWebViewActivity : AppCompatActivity() { private var closeReceiver: BroadcastReceiver? = null + private var keepAliveConnection: ServiceConnection? = null + // for the browserPageLoaded event, which we only want to trigger on the first URL loaded in the WebView private var isFirstLoad = true @@ -139,6 +149,11 @@ open class OSIABWebViewActivity : AppCompatActivity() { const val REQUEST_LOCATION_PERMISSION = 623 const val REQUEST_CAMERA_PERMISSION = 624 const val LOG_TAG = "OSIABWebViewActivity" + const val ISOLATED_PROCESS_SUFFIX = ":OSInAppBrowser" + + private fun isIsolatedProcess(): Boolean = + Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && + Application.getProcessName().endsWith(ISOLATED_PROCESS_SUFFIX) val errorsToHandle = listOf( WebViewClient.ERROR_HOST_LOOKUP, WebViewClient.ERROR_UNSUPPORTED_SCHEME, @@ -153,15 +168,12 @@ open class OSIABWebViewActivity : AppCompatActivity() { } init { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - try { - val processName = Application.getProcessName() - if (processName.endsWith(":OSInAppBrowser")) { - WebView.setDataDirectorySuffix("OSInAppBrowser") - } - } catch (e: Exception) { - Log.d(LOG_TAG, "Suffix already set or error: ${e.message}") + try { + if (isIsolatedProcess()) { + WebView.setDataDirectorySuffix("OSInAppBrowser") } + } catch (e: Exception) { + Log.d(LOG_TAG, "Suffix already set or error: ${e.message}") } } } @@ -179,6 +191,27 @@ open class OSIABWebViewActivity : AppCompatActivity() { onBackPressedDispatcher.addCallback(this, onBackPressedCallback) browserId = intent.getStringExtra(OSIABEvents.EXTRA_BROWSER_ID) ?: "" + Log.d(LOG_TAG, "onCreate browserId=$browserId") + + // keep the main process out of the freezable state while the browser is in front, + // otherwise events queue and the app's close flow stalls until it unfreezes. + // RMET-5394: BIND_IMPORTANT added on top of BIND_AUTO_CREATE - the plain binding was + // enough to avoid the OS freezer, but not enough to guarantee normal scheduling once + // the main process wakes up to react to an event; a completion-page network call was + // observed failing (started, never got a chance to finish before something moved on) + // even with zero freeze/unfreeze events recorded for that session. + if (isIsolatedProcess()) { + val connection = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName?, service: IBinder?) {} + override fun onServiceDisconnected(name: ComponentName?) {} + } + bindService( + Intent(this, OSIABKeepAliveService::class.java), + connection, + Context.BIND_AUTO_CREATE or Context.BIND_IMPORTANT + ) + keepAliveConnection = connection + } // Register receiver for close commands from main process closeReceiver = object : BroadcastReceiver() { @@ -231,6 +264,13 @@ open class OSIABWebViewActivity : AppCompatActivity() { closeButton.setOnClickListener { finish() } + // RMET-5394 debug build only: long-press Close to share captured logs. + // Lives here (not tied to openWebView) because this activity's process is + // the one guaranteed to still be responsive even if the main process is frozen. + closeButton.setOnLongClickListener { + OSIABLogCaptureHelper.shareLogs(this) + true + } if (options.showToolbar) updateToolbar( @@ -268,6 +308,7 @@ open class OSIABWebViewActivity : AppCompatActivity() { } override fun onDestroy() { + Log.d(LOG_TAG, "onDestroy browserId=$browserId isFinishing=$isFinishing") // sent here instead of onStop, which is skipped when finish() happens on an already stopped activity if (isFinishing) { sendWebViewEvent(OSIABEvents.BrowserFinished(browserId)) @@ -280,6 +321,14 @@ open class OSIABWebViewActivity : AppCompatActivity() { } closeReceiver = null } + keepAliveConnection?.let { + try { + unbindService(it) + } catch (e: Exception) { + // Service may not be bound, ignore + } + keepAliveConnection = null + } webView.destroy() super.onDestroy() } @@ -291,6 +340,13 @@ open class OSIABWebViewActivity : AppCompatActivity() { } } + // RMET-5394 debug build only: an early, OS-native signal of this (isolated) + // process trending toward the cached/frozen state, ahead of an actual freeze. + override fun onTrimMemory(level: Int) { + super.onTrimMemory(level) + OSIABLogCaptureHelper.logTrimMemory(level) + } + private fun handleLoadUrl(url: String, additionalHttpHeaders: Map? = null) { if (additionalHttpHeaders.isNullOrEmpty()) { webView.loadUrl(url) @@ -496,6 +552,22 @@ open class OSIABWebViewActivity : AppCompatActivity() { sendWebViewEvent(OSIABEvents.BrowserPageNavigationCompleted(browserId, resolvedUrl)) } + // RMET-5394: close natively the moment an app-configured "done" pattern matches, + // independent of whether MainActivity's WebView JS is able to react to the event + // above - sendWebViewEvent() above is synchronous (see #59), so the broadcast is + // already in flight by the time finish() runs below. + val matchesSuccessPattern = resolvedUrl != null && options.successUrlPatterns?.any { pattern -> + try { + Regex(pattern).containsMatchIn(resolvedUrl) + } catch (e: PatternSyntaxException) { + Log.d(LOG_TAG, "Invalid successUrlPatterns regex '$pattern': ${e.message}") + false + } + } == true + if (matchesSuccessPattern) { + finish() + } + if (url?.startsWith(PDF_VIEWER_URL_PREFIX) == true && options.clearCache) { webView.evaluateJavascript( "localStorage.clear(); sessionStorage.clear();", null @@ -558,6 +630,16 @@ open class OSIABWebViewActivity : AppCompatActivity() { // let all errors first be handled by the WebView default error handling mechanism super.onReceivedError(view, request, error) + // RMET-5394 debug build only: log every resource-level network error (not + // just the main-frame ones handled below), since this is the native error + // code/description behind failures JS can only see as a generic rejected + // fetch/promise (e.g. "TypeError: Failed to fetch"). + Log.d( + LOG_TAG, + "onReceivedError url=${request?.url} isForMainFrame=${request?.isForMainFrame} " + + "errorCode=${error?.errorCode} description=${error?.description}" + ) + // We only want to show the error screen for some errors (e.g. no internet) // e.g. we don't want to show it for an error where an image fails to load. // Also, we only want to show the error screen for errors in loading the main page, @@ -570,6 +652,37 @@ open class OSIABWebViewActivity : AppCompatActivity() { } } + // RMET-5394 debug build only: HTTP-level errors (4xx/5xx responses) for any + // resource, distinct from onReceivedError (which covers network/transport + // failures like DNS or connection errors, not server-returned error statuses). + override fun onReceivedHttpError( + view: WebView?, + request: WebResourceRequest?, + errorResponse: WebResourceResponse? + ) { + super.onReceivedHttpError(view, request, errorResponse) + Log.d( + LOG_TAG, + "onReceivedHttpError url=${request?.url} statusCode=${errorResponse?.statusCode} " + + "reasonPhrase=${errorResponse?.reasonPhrase}" + ) + } + + // RMET-5394 debug build only: logs every request the WebView makes - page + // navigations, scripts, images, and crucially any fetch()/XHR calls the page's + // own JS makes - since those aren't otherwise visible to native code. Called + // on a background thread; only observes, never intercepts (always returns null + // to let the WebView handle the request normally). + override fun shouldInterceptRequest( + view: WebView?, + request: WebResourceRequest? + ): WebResourceResponse? { + request?.let { + Log.d(LOG_TAG, "request: ${it.method} ${it.url}") + } + return super.shouldInterceptRequest(view, request) + } + override fun doUpdateVisitedHistory(view: WebView?, url: String?, isReload: Boolean) { // to implement predictive back navigation // we only want to have the callback enabled if the WebView can go back to previous page @@ -629,6 +742,19 @@ open class OSIABWebViewActivity : AppCompatActivity() { } } + // RMET-5394 debug build only: bridge page console.log/warn/error into the + // native log capture, to correlate what the page believed happened (e.g. + // "payment complete, notifying app") against when the app actually reacted. + override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean { + consoleMessage?.let { + Log.d( + LOG_TAG, + "console[${it.messageLevel()}] ${it.message()} (${it.sourceId()}:${it.lineNumber()})" + ) + } + return false + } + // specifically handle geolocation permission override fun onGeolocationPermissionsShowPrompt( origin: String?, diff --git a/tmp_instructions_logcat_full.txt b/tmp_instructions_logcat_full.txt new file mode 100644 index 0000000..8f160f6 --- /dev/null +++ b/tmp_instructions_logcat_full.txt @@ -0,0 +1,28 @@ +Since OSIABLogCaptureHelper runs inside the app process, it can only read logcat lines tagged with the app's own UID — Android restricts cross-UID log reads for regular apps. adb, running from your + computer as the shell user, isn't subject to that restriction and can see the whole system, including system_server's process-freezer decisions. Here's how to get that capture: + + One-time setup + 1. Make sure adb is installed (brew install android-platform-tools if you don't have it, or it's included with Android Studio's SDK platform-tools). + 2. On the device: Settings → About phone → tap "Build number" 7 times to enable Developer options → Settings → Developer options → enable "USB debugging". + 3. Connect the device via USB, then run adb devices — accept the "Allow USB debugging?" prompt on the device screen. It should list your device as device (not unauthorized). + + Capturing during a repro + 1. Clear the buffer right before you start, so the file only contains this run: + adb logcat -c + 2. Start an unfiltered capture across all buffers (this is the key difference from the in-app tool — no UID filter, and -b all includes the system/events buffers where freezer activity gets logged): + adb logcat -v threadtime -b all > ~/Downloads/full_logcat_$(date +%s).txt + 2. Leave this running in a terminal window. + 3. Do the repro (open the InAppBrowser, deliberately take your time entering the card details past the ~1 minute mark, wait for/reach the freeze). + 4. Once you've either seen the freeze or force-closed, go back to the terminal and press Ctrl+C to stop the capture. + + Optional: raise freezer log verbosity + Some of the freezer's debug-level tracing may be gated behind a per-tag log level that isn't on by default. If the first capture doesn't show anything under CachedAppOptimizer, try this before repro'ing + again: + adb shell setprop log.tag.CachedAppOptimizer VERBOSE + adb shell setprop log.tag.OomAdjuster VERBOSE + + What to grep for afterward (send me the file and I'll do this, or you can spot-check yourself): + grep -iE "CachedAppOptimizer|am_freeze|am_unfreeze|Freezing|Unfreezing|OomAdjuster|oom_adj" full_logcat_*.txt + + One caveat: this file will contain the whole system's logs, not just PingoDoceExpress — much noisier than the app-scoped captures we've been using, so it'll need tighter time-window/PID filtering once we + have it (same PID numbers as the existing OSIABLogCaptureHelper files should still line up, since it's the same run).