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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
76 changes: 75 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
- **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
<application
android:name=".MyApplication"
...>
```

Logs are written to `<cacheDir>/logs/oslog-<main|isolated>-<yyyyMMdd_HHmmss>.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.
132 changes: 132 additions & 0 deletions RMET-5394-file-logging-plan.md
Original file line number Diff line number Diff line change
@@ -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 <kb> -n <count> -f <path>`. `-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 (`<cache-path name="camera"
path="." />`), 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-<processSuffix>.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 <cacheDir>/logs/oslog-<suffix>.txt`
- `shareLogs()` collects every file in `context.cacheDir/logs/`, builds an
`ArrayList<Uri>` 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
`<application>` 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.
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,5 @@
<modelVersion>4.0.0</modelVersion>
<groupId>io.ionic.libs</groupId>
<artifactId>ioninappbrowser-android</artifactId>
<version>2.0.2</version>
<version>2.0.3</version>
</project>
3 changes: 3 additions & 0 deletions src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@
android:resource="@xml/file_paths" />
</provider>

<service
android:name=".OSIABKeepAliveService"
android:exported="false" />
</application>

<queries>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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")
}
}
}
Expand All @@ -80,6 +89,7 @@ sealed class OSIABEvents : Serializable {
filter,
ContextCompat.RECEIVER_NOT_EXPORTED
)
Log.d(LOG_TAG, "registerReceiver: registered (refCount=$receiverRefCount)")
}

/**
Expand All @@ -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
}
Expand All @@ -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()
}

}
Original file line number Diff line number Diff line change
@@ -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()
}
Loading
Loading