fix(android): abandon rejected installer sessions

Expose owner-keyed cleanup for rejected or unknown PackageInstaller outcomes, so a failed confirmation cannot leave a sealed session behind. Preserve the terminal receipt and notification state without retrying the APK.
This commit is contained in:
Ouroboros 2026-09-14 13:09:11 +03:00
parent 627ac43541
commit cd1b50d2ef
5 changed files with 36 additions and 4 deletions

View file

@ -13,6 +13,9 @@ To pass a Linux download, use content.write to transfer bytes to a writable
provider URI (an app-owned MediaStore entry or a granted document), then install
that URI. mode='wt' replaces contents; mode='wa' appends a subsequent chunk.
Root android-exec remains a separate native installation path.
After a rejected or unknown PackageInstaller result, use packages.install.abandon
with the same idempotency_key to close that installer-owned session; it is a
cleanup operation and never starts a replacement installation.
location.state reports grants/providers. location.get accepts provider and
timeout_ms (1000..30000); its result includes age_ms and mock when a fix exists.

View file

@ -51,7 +51,7 @@ final class AndroidBridge implements Closeable {
static final String SOCKET = "ai.ouroboros.android.rpc";
private static final int MAX_REQUEST_BYTES = 4 * 1024 * 1024;
private static final String[] METHODS = {"capabilities", "packages.list", "packages.inspect", "packages.sessions",
"packages.install", "packages.install.status",
"packages.install", "packages.install.status", "packages.install.abandon",
"providers.list", "intent.resolve", "intent.start", "content.query", "content.call",
"content.insert", "content.update", "content.delete", "content.read", "content.write",
"location.state", "location.get", "accessibility.state", "accessibility.windows",
@ -159,6 +159,7 @@ final class AndroidBridge implements Closeable {
case "packages.sessions": return packageSessions(pm, p);
case "packages.install": return installPackage(pm, p);
case "packages.install.status": return installStatus(p);
case "packages.install.abandon": return abandonInstall(pm, p);
case "providers.list": {
JSONArray rows = new JSONArray();
List<ProviderInfo> providers = pm.queryContentProviders(null, 0, PackageManager.MATCH_DISABLED_COMPONENTS);
@ -286,6 +287,26 @@ final class AndroidBridge implements Closeable {
return new JSONObject(value).put("known", true);
}
/** Abandon one installer-owned session after an unknown or rejected outcome. */
private synchronized JSONObject abandonInstall(PackageManager pm, JSONObject p) throws Exception {
String key = p.getString("idempotency_key");
String value = receipts().getString(key, null);
if (value == null) return new JSONObject().put("known", false).put("idempotency_key", key);
JSONObject receipt = new JSONObject(value);
int sessionId = receipt.optInt("session_id", -1);
if (sessionId < 0) return receipt.put("known", true).put("outcome", "unknown")
.put("retry_automatically", false);
if (pm.getPackageInstaller().getSessionInfo(sessionId) == null)
return receipt.put("known", true).put("outcome", "unknown")
.put("retry_automatically", false);
pm.getPackageInstaller().abandonSession(sessionId);
receipt.put("status", "abandoned").put("outcome", "abandoned")
.put("completion_observed", true).put("status_message", "Session abandoned by owner");
receipts().edit().putString(key, receipt.toString()).commit();
PackageInstallReceiver.cancelNotification(context, sessionId);
return receipt.put("known", true);
}
private android.content.SharedPreferences receipts() {
return context.getSharedPreferences("package_install_receipts", Context.MODE_PRIVATE);
}

View file

@ -17,6 +17,11 @@ public final class PackageInstallReceiver extends BroadcastReceiver {
static final String SESSION = "session_id";
private static final String CHANNEL = "ouroboros_package_installs";
static void cancelNotification(Context context, int sessionId) {
NotificationManager manager = context.getSystemService(NotificationManager.class);
if (manager != null) manager.cancel(CHANNEL, sessionId);
}
@Override public void onReceive(Context context, Intent intent) {
if (!ACTION.equals(intent.getAction())) return;
String key = intent.getStringExtra(KEY);
@ -67,7 +72,7 @@ public final class PackageInstallReceiver extends BroadcastReceiver {
receipt.put("confirmation_delivery", "notification_failed");
}
} else receipt.put("confirmation_delivery", "unavailable");
} else if (manager != null) manager.cancel(CHANNEL, sessionId);
} else cancelNotification(context, sessionId);
prefs.edit().putString(key, receipt.toString()).apply();
} catch (Exception error) {
android.util.Log.e("OuroborosHost", "Package install result could not be recorded", error);

View file

@ -83,7 +83,7 @@ class AndroidHostTest(unittest.TestCase):
receiver = manifest.find("application/receiver[@android:name='.PackageInstallReceiver']", {"android": "http://schemas.android.com/apk/res/android"})
self.assertIsNotNone(receiver)
source = (HOST / "src/ai/ouroboros/android/AndroidBridge.java").read_text()
for marker in ("\"packages.sessions\"", "\"packages.install\"",
for marker in ("\"packages.sessions\"", "\"packages.install\"", "\"packages.install.abandon\"",
"idempotency_key", "source_sha256", "completion_observed",
"retry_automatically", "rollback"):
self.assertIn(marker, source)

View file

@ -244,7 +244,10 @@ package before deciding whether to recover. A pending install posts an Ouroboros
notification that opens Android's original confirmation screen when tapped;
it remains incomplete until the system reports success or failure. Keep
Ouroboros notifications enabled for this handoff. A disabled or failed notification
is reported in `confirmation_delivery`; it is not successful consent.
is reported in `confirmation_delivery`; it is not successful consent. Use
`packages.install.abandon` with the same idempotency key to close a rejected or
unknown installer session after inspecting it; it records an abandoned terminal
outcome and never starts a replacement installation.
For this path, enable **Install unknown apps** for Ouroboros in Android settings;
`capabilities.can_request_package_installs` reads that special access separately
from ordinary runtime permissions. The host can read only source URIs available