diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index cdc23b80bfb..eec975299a9 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -691,7 +691,7 @@ }, { "kind": "ui-call", - "line": 174, + "line": 172, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Resolution outcome unknown. Actions stay disabled until the Gateway record is verified.", "surface": "android", @@ -699,7 +699,7 @@ }, { "kind": "ui-call", - "line": 176, + "line": 174, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "The Gateway still shows this approval as pending. Review it before trying again.", "surface": "android", @@ -707,7 +707,7 @@ }, { "kind": "ui-call", - "line": 178, + "line": 176, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load approval details. Refresh and try again.", "surface": "android", @@ -715,7 +715,7 @@ }, { "kind": "ui-call", - "line": 180, + "line": 178, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load approvals.", "surface": "android", @@ -723,7 +723,7 @@ }, { "kind": "ui-call", - "line": 182, + "line": 180, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not resolve approval. Refresh and try again.", "surface": "android", @@ -731,7 +731,7 @@ }, { "kind": "ui-call", - "line": 279, + "line": 277, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Device approved.", "surface": "android", @@ -739,7 +739,7 @@ }, { "kind": "ui-call", - "line": 280, + "line": 278, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Pairing request rejected.", "surface": "android", @@ -747,7 +747,7 @@ }, { "kind": "ui-call", - "line": 281, + "line": 279, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Paired device removed.", "surface": "android", @@ -755,7 +755,7 @@ }, { "kind": "ui-call", - "line": 342, + "line": 340, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Proposal applied.", "surface": "android", @@ -763,7 +763,7 @@ }, { "kind": "ui-call", - "line": 342, + "line": 340, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "apply", "surface": "android", @@ -771,7 +771,7 @@ }, { "kind": "ui-call", - "line": 343, + "line": 341, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Proposal rejected.", "surface": "android", @@ -779,7 +779,7 @@ }, { "kind": "ui-call", - "line": 343, + "line": 341, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "reject", "surface": "android", @@ -787,7 +787,7 @@ }, { "kind": "ui-call", - "line": 344, + "line": 342, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Proposal quarantined.", "surface": "android", @@ -795,7 +795,7 @@ }, { "kind": "ui-call", - "line": 344, + "line": 342, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "quarantine", "surface": "android", @@ -803,7 +803,7 @@ }, { "kind": "ui-call", - "line": 351, + "line": 349, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "unknown", "surface": "android", @@ -811,7 +811,7 @@ }, { "kind": "ui-call", - "line": 352, + "line": 350, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Gateway returned status '$statusLabel' after ${action.verb}.", "surface": "android", @@ -819,7 +819,7 @@ }, { "kind": "ui-call", - "line": 360, + "line": 358, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not ${action.verb} Skill Workshop proposal.", "surface": "android", @@ -827,7 +827,7 @@ }, { "kind": "ui-call", - "line": 542, + "line": 540, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connected (node offline)", "surface": "android", @@ -835,7 +835,7 @@ }, { "kind": "ui-call", - "line": 543, + "line": 541, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connected (operator offline)", "surface": "android", @@ -843,7 +843,7 @@ }, { "kind": "ui-call", - "line": 545, + "line": 543, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Reconnecting…", "surface": "android", @@ -851,7 +851,7 @@ }, { "kind": "ui-call", - "line": 553, + "line": 551, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connected (operator: $operator)", "surface": "android", @@ -859,7 +859,7 @@ }, { "kind": "ui-call", - "line": 2147, + "line": 2198, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "This automation already has a queued run.", "surface": "android", @@ -867,7 +867,7 @@ }, { "kind": "ui-call", - "line": 2177, + "line": 2228, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Automation run queued.", "surface": "android", @@ -875,7 +875,7 @@ }, { "kind": "ui-call", - "line": 2177, + "line": 2228, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Automation started.", "surface": "android", @@ -883,7 +883,7 @@ }, { "kind": "ui-call", - "line": 2190, + "line": 2241, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Gateway rejected the automation run.", "surface": "android", @@ -891,7 +891,7 @@ }, { "kind": "ui-call", - "line": 2221, + "line": 2272, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Automation enabled.", "surface": "android", @@ -899,7 +899,7 @@ }, { "kind": "ui-call", - "line": 2221, + "line": 2272, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Automation paused.", "surface": "android", @@ -907,7 +907,7 @@ }, { "kind": "ui-call", - "line": 2243, + "line": 2294, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "This automation changed on the gateway. Review the latest version before saving again.", "surface": "android", @@ -915,7 +915,7 @@ }, { "kind": "ui-call", - "line": 2249, + "line": 2300, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Automation updated.", "surface": "android", @@ -923,7 +923,7 @@ }, { "kind": "ui-call", - "line": 2264, + "line": 2315, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Automation deleted.", "surface": "android", @@ -931,7 +931,7 @@ }, { "kind": "ui-call", - "line": 2514, + "line": 2565, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Node offline. Reconnect and retry.", "surface": "android", @@ -939,7 +939,7 @@ }, { "kind": "ui-named-argument-concatenated", - "line": 2525, + "line": 2576, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Restore canvas now for session=$sessionKey source=$source. If existing A2UI state exists, replay it immediately. If not, create and render a compact mobile-friendly dashboard in Canvas.", "surface": "android", @@ -947,7 +947,7 @@ }, { "kind": "ui-call", - "line": 2546, + "line": 2597, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Failed to request restore. Tap to retry.", "surface": "android", @@ -955,7 +955,7 @@ }, { "kind": "ui-call", - "line": 2557, + "line": 2608, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "No canvas update yet. Tap to retry.", "surface": "android", @@ -963,7 +963,7 @@ }, { "kind": "ui-call", - "line": 3515, + "line": 3570, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect to a Gateway to save wake words", "surface": "android", @@ -971,7 +971,7 @@ }, { "kind": "ui-call", - "line": 3543, + "line": 3598, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Wake words saved", "surface": "android", @@ -979,7 +979,7 @@ }, { "kind": "ui-call", - "line": 3552, + "line": 3607, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not save wake words", "surface": "android", @@ -987,7 +987,7 @@ }, { "kind": "ui-call", - "line": 4266, + "line": 4321, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Failed: no secure gateway endpoint was detected. Enable gateway TLS or Tailscale Serve, or use a trusted private LAN address with Unencrypted selected.", "surface": "android", @@ -995,7 +995,7 @@ }, { "kind": "ui-call", - "line": 4270, + "line": 4325, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Failed: secure endpoint reached, but TLS fingerprint verification timed out. Check Tailscale Serve or gateway TLS and retry.", "surface": "android", @@ -1003,7 +1003,7 @@ }, { "kind": "ui-call", - "line": 4274, + "line": 4329, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Failed: couldn't reach the secure gateway endpoint for this host.", "surface": "android", @@ -1011,7 +1011,7 @@ }, { "kind": "ui-named-argument", - "line": 4318, + "line": 4373, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connecting…", "surface": "android", @@ -1019,7 +1019,7 @@ }, { "kind": "ui-named-argument", - "line": 4501, + "line": 4589, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Offline", "surface": "android", @@ -1027,7 +1027,7 @@ }, { "kind": "ui-call", - "line": 5235, + "line": 5323, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load provider catalog.", "surface": "android", @@ -1035,7 +1035,7 @@ }, { "kind": "ui-call", - "line": 5266, + "line": 5354, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Update your Gateway to view provider model config.", "surface": "android", @@ -1043,7 +1043,7 @@ }, { "kind": "ui-call", - "line": 5268, + "line": 5356, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load provider model config.", "surface": "android", @@ -1051,7 +1051,7 @@ }, { "kind": "ui-call", - "line": 5284, + "line": 5372, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Provider models loaded, but readiness is unavailable.", "surface": "android", @@ -1059,7 +1059,7 @@ }, { "kind": "ui-call", - "line": 5371, + "line": 5459, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load automations.", "surface": "android", @@ -1067,7 +1067,7 @@ }, { "kind": "ui-call", - "line": 5457, + "line": 5545, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to inspect automations.", "surface": "android", @@ -1075,7 +1075,7 @@ }, { "kind": "ui-call", - "line": 5467, + "line": 5555, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Gateway returned an invalid automation.", "surface": "android", @@ -1083,7 +1083,7 @@ }, { "kind": "ui-call", - "line": 5471, + "line": 5559, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load automation.", "surface": "android", @@ -1091,7 +1091,7 @@ }, { "kind": "ui-call", - "line": 5483, + "line": 5571, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to inspect automation run history.", "surface": "android", @@ -1099,7 +1099,7 @@ }, { "kind": "ui-call", - "line": 5514, + "line": 5602, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load automation run history.", "surface": "android", @@ -1107,7 +1107,7 @@ }, { "kind": "ui-call", - "line": 5531, + "line": 5619, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Cron changes require operator.admin access.", "surface": "android", @@ -1115,7 +1115,7 @@ }, { "kind": "ui-call", - "line": 5540, + "line": 5628, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to manage automations.", "surface": "android", @@ -1123,7 +1123,7 @@ }, { "kind": "ui-call", - "line": 5552, + "line": 5640, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Another cron action is still finishing.", "surface": "android", @@ -1131,7 +1131,7 @@ }, { "kind": "ui-call", - "line": 5599, + "line": 5687, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Cron action failed.", "surface": "android", @@ -1139,7 +1139,7 @@ }, { "kind": "ui-call", - "line": 5726, + "line": 5814, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load usage.", "surface": "android", @@ -1147,7 +1147,7 @@ }, { "kind": "ui-call", - "line": 5761, + "line": 5849, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load skills.", "surface": "android", @@ -1155,7 +1155,7 @@ }, { "kind": "ui-call", - "line": 5774, + "line": 5862, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to update skills.", "surface": "android", @@ -1163,7 +1163,7 @@ }, { "kind": "ui-call", - "line": 5778, + "line": 5866, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "This gateway connection needs operator.admin to update skills.", "surface": "android", @@ -1171,7 +1171,7 @@ }, { "kind": "conditional-branch", - "line": 5793, + "line": 5881, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not disable skill.", "surface": "android", @@ -1179,7 +1179,7 @@ }, { "kind": "conditional-branch", - "line": 5793, + "line": 5881, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not enable skill.", "surface": "android", @@ -1187,7 +1187,7 @@ }, { "kind": "ui-call", - "line": 5811, + "line": 5899, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to search ClawHub skills.", "surface": "android", @@ -1195,7 +1195,7 @@ }, { "kind": "ui-call", - "line": 5857, + "line": 5945, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not search ClawHub skills.", "surface": "android", @@ -1203,7 +1203,7 @@ }, { "kind": "ui-call", - "line": 5870, + "line": 5958, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to inspect ClawHub skills.", "surface": "android", @@ -1211,7 +1211,7 @@ }, { "kind": "conditional-branch", - "line": 5903, + "line": 5991, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "ClawHub did not return an installable version for ${skill.slug}.", "surface": "android", @@ -1219,7 +1219,7 @@ }, { "kind": "ui-call", - "line": 5919, + "line": 6007, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load ClawHub details for ${skill.slug}.", "surface": "android", @@ -1227,7 +1227,7 @@ }, { "kind": "ui-call", - "line": 5935, + "line": 6023, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to install ClawHub skills.", "surface": "android", @@ -1235,7 +1235,7 @@ }, { "kind": "ui-call", - "line": 5951, + "line": 6039, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "This gateway connection needs operator.admin to install ClawHub skills.", "surface": "android", @@ -1243,7 +1243,7 @@ }, { "kind": "conditional-branch", - "line": 6041, + "line": 6129, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Installed $slug.", "surface": "android", @@ -1251,7 +1251,7 @@ }, { "kind": "ui-call", - "line": 6048, + "line": 6136, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not install ${slug} from ClawHub.", "surface": "android", @@ -1259,7 +1259,7 @@ }, { "kind": "ui-call", - "line": 6088, + "line": 6176, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to load Skill Workshop proposals.", "surface": "android", @@ -1267,7 +1267,7 @@ }, { "kind": "ui-call", - "line": 6128, + "line": 6216, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load Skill Workshop proposals.", "surface": "android", @@ -1275,7 +1275,7 @@ }, { "kind": "ui-call", - "line": 6148, + "line": 6236, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to inspect Skill Workshop proposals.", "surface": "android", @@ -1283,7 +1283,7 @@ }, { "kind": "ui-call", - "line": 6200, + "line": 6288, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not inspect Skill Workshop proposal.", "surface": "android", @@ -1291,7 +1291,7 @@ }, { "kind": "ui-call", - "line": 6220, + "line": 6308, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Skill Workshop proposal actions require operator.admin scope.", "surface": "android", @@ -1299,7 +1299,7 @@ }, { "kind": "ui-call", - "line": 6225, + "line": 6313, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to update Skill Workshop proposals.", "surface": "android", @@ -1307,7 +1307,7 @@ }, { "kind": "ui-call", - "line": 6415, + "line": 6503, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not verify the device pairing change. Refresh and try again.", "surface": "android", @@ -1315,7 +1315,7 @@ }, { "kind": "ui-named-argument", - "line": 6497, + "line": 6585, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connected", "surface": "android", @@ -1323,7 +1323,7 @@ }, { "kind": "ui-call", - "line": 6527, + "line": 6615, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load nodes and devices.", "surface": "android", @@ -1331,7 +1331,7 @@ }, { "kind": "ui-call", - "line": 7185, + "line": 7273, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load channels.", "surface": "android", @@ -1339,7 +1339,7 @@ }, { "kind": "ui-call", - "line": 7215, + "line": 7303, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load dreaming.", "surface": "android", @@ -1347,7 +1347,7 @@ }, { "kind": "ui-call", - "line": 7252, + "line": 7340, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load gateway logs.", "surface": "android", @@ -1355,7 +1355,7 @@ }, { "kind": "ui-call", - "line": 7721, + "line": 7809, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "One time", "surface": "android", @@ -1363,7 +1363,7 @@ }, { "kind": "ui-call", - "line": 7730, + "line": 7818, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Cron", "surface": "android", @@ -1371,7 +1371,7 @@ }, { "kind": "ui-call", - "line": 7731, + "line": 7819, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Scheduled", "surface": "android", @@ -1379,7 +1379,7 @@ }, { "kind": "ui-call", - "line": 7739, + "line": 7827, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Every ${days}d", "surface": "android", @@ -1387,7 +1387,7 @@ }, { "kind": "ui-call", - "line": 7740, + "line": 7828, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Every ${hours}h", "surface": "android", @@ -1395,7 +1395,7 @@ }, { "kind": "ui-call", - "line": 7741, + "line": 7829, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Every ${minutes}m", "surface": "android", @@ -1403,7 +1403,7 @@ }, { "kind": "ui-call", - "line": 7742, + "line": 7830, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Repeating", "surface": "android", @@ -1411,7 +1411,7 @@ }, { "kind": "ui-call", - "line": 7758, + "line": 7846, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "No prompt", "surface": "android", @@ -1419,7 +1419,7 @@ }, { "kind": "ui-call", - "line": 7775, + "line": 7863, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Gateway", "surface": "android", @@ -1427,7 +1427,7 @@ }, { "kind": "ui-call", - "line": 7783, + "line": 7871, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connected to $gatewayLabel", "surface": "android", @@ -1435,7 +1435,7 @@ }, { "kind": "ui-call", - "line": 7784, + "line": 7872, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Your agents are ready", "surface": "android", @@ -1443,7 +1443,7 @@ }, { "kind": "ui-call", - "line": 7786, + "line": 7874, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "This phone stays dormant until the gateway needs it, then wakes, syncs, and goes back to sleep.", "surface": "android", @@ -1451,7 +1451,7 @@ }, { "kind": "ui-call", - "line": 7790, + "line": 7878, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Selected on this phone", "surface": "android", @@ -1459,7 +1459,7 @@ }, { "kind": "ui-call", - "line": 7793, + "line": 7881, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "The overview refreshes on reconnect and when this screen opens.", "surface": "android", @@ -1467,7 +1467,7 @@ }, { "kind": "ui-call", - "line": 7798, + "line": 7886, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Reconnecting", "surface": "android", @@ -1475,7 +1475,7 @@ }, { "kind": "ui-call", - "line": 7799, + "line": 7887, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "OpenClaw is syncing back up", "surface": "android", @@ -1483,7 +1483,7 @@ }, { "kind": "ui-call", - "line": 7801, + "line": 7889, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "The gateway session is coming back online. Agent shortcuts should settle automatically in a moment.", "surface": "android", @@ -1491,7 +1491,7 @@ }, { "kind": "ui-call", - "line": 7805, + "line": 7893, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Gateway session in progress", "surface": "android", @@ -1499,7 +1499,7 @@ }, { "kind": "ui-call", - "line": 7808, + "line": 7896, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "If the gateway is reachable, reconnect should complete without intervention.", "surface": "android", @@ -1507,7 +1507,7 @@ }, { "kind": "ui-call", - "line": 7813, + "line": 7901, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Welcome to OpenClaw", "surface": "android", @@ -1515,7 +1515,7 @@ }, { "kind": "ui-call", - "line": 7814, + "line": 7902, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Your phone stays quiet until it is needed", "surface": "android", @@ -1523,7 +1523,7 @@ }, { "kind": "ui-call", - "line": 7816, + "line": 7904, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Pair this device to your gateway to wake it only for real work, keep a live agent overview handy, and avoid battery-draining background loops.", "surface": "android", @@ -1531,7 +1531,7 @@ }, { "kind": "ui-call", - "line": 7820, + "line": 7908, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect to load your agents", "surface": "android", @@ -1539,7 +1539,7 @@ }, { "kind": "ui-call", - "line": 7823, + "line": 7911, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "When connected, the gateway can wake the phone with a silent push instead of holding an always-on session.", "surface": "android", @@ -1547,7 +1547,7 @@ }, { "kind": "ui-call", - "line": 7855, + "line": 7943, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Main", "surface": "android", @@ -1555,7 +1555,7 @@ }, { "kind": "ui-call", - "line": 7870, + "line": 7958, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Active on this phone", "surface": "android", @@ -1563,7 +1563,7 @@ }, { "kind": "ui-call", - "line": 7871, + "line": 7959, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Default agent", "surface": "android", @@ -1571,7 +1571,7 @@ }, { "kind": "ui-call", - "line": 7872, + "line": 7960, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Ready", "surface": "android", @@ -1579,7 +1579,7 @@ }, { "kind": "ui-call", - "line": 8437, + "line": 8525, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Dream", "surface": "android", @@ -1755,7 +1755,7 @@ }, { "kind": "ui-call", - "line": 1040, + "line": 1050, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Wait for the current response to finish before starting a new chat.", "surface": "android", @@ -1763,7 +1763,7 @@ }, { "kind": "ui-call", - "line": 1173, + "line": 1183, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Could not update model.", "surface": "android", @@ -1771,7 +1771,7 @@ }, { "kind": "ui-call", - "line": 1238, + "line": 1248, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Could not update thinking level.", "surface": "android", @@ -1779,7 +1779,7 @@ }, { "kind": "ui-call", - "line": 1731, + "line": 1741, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Chat failed before the run started; try again.", "surface": "android", @@ -1787,7 +1787,7 @@ }, { "kind": "ui-call", - "line": 3169, + "line": 3179, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Could not stage an attachment for sending.", "surface": "android", @@ -1795,7 +1795,7 @@ }, { "kind": "ui-call", - "line": 3202, + "line": 3212, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Offline queue is full ($OUTBOX_MAX_QUEUED messages); delete queued items first.", "surface": "android", @@ -1803,7 +1803,7 @@ }, { "kind": "ui-call", - "line": 3208, + "line": 3218, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Attachments are too large to queue for one message; remove some and try again.", "surface": "android", @@ -1811,7 +1811,7 @@ }, { "kind": "ui-call", - "line": 3214, + "line": 3224, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Offline attachment storage is full; delete queued items first.", "surface": "android", @@ -1819,7 +1819,7 @@ }, { "kind": "ui-call", - "line": 3219, + "line": 3229, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Gateway health not OK; cannot send", "surface": "android", @@ -1827,7 +1827,7 @@ }, { "kind": "ui-call", - "line": 3226, + "line": 3236, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Could not queue message for later delivery.", "surface": "android", @@ -1835,7 +1835,7 @@ }, { "kind": "ui-call", - "line": 3943, + "line": 3953, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Chat failed", "surface": "android", @@ -1843,7 +1843,7 @@ }, { "kind": "ui-call", - "line": 4081, + "line": 4091, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Event stream interrupted; try refreshing.", "surface": "android", @@ -1851,7 +1851,7 @@ }, { "kind": "ui-call", - "line": 4218, + "line": 4228, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Timed out waiting for a reply; try again or refresh.", "surface": "android", @@ -1859,7 +1859,7 @@ }, { "kind": "ui-call", - "line": 4428, + "line": 4438, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Timed out confirming the sent message; refresh to check delivery.", "surface": "android", diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index b9c8d4ff2a0..68298c347fb 100644 --- a/apps/android/app/build.gradle.kts +++ b/apps/android/app/build.gradle.kts @@ -119,6 +119,10 @@ plugins { alias(libs.plugins.ksp) } +ksp { + arg("room.schemaLocation", "$projectDir/schemas") +} + android { namespace = "ai.openclaw.app" // AndroidX Core 1.19 and Lifecycle 2.11 require API 37 compilation. @@ -327,7 +331,7 @@ dependencies { implementation(libs.kotlinx.serialization.json) implementation(libs.androidx.security.crypto) - // Room owns the disposable transcript cache and durable chat outbox; migrations preserve outbox rows. + // Room owns separate disposable gateway cache and durable client-state databases. implementation(libs.androidx.room.runtime) ksp(libs.androidx.room.compiler) implementation(libs.androidx.exifinterface) diff --git a/apps/android/app/schemas/ai.openclaw.app.chat.ClientStateDatabase/1.json b/apps/android/app/schemas/ai.openclaw.app.chat.ClientStateDatabase/1.json new file mode 100644 index 00000000000..69d18b1ef94 --- /dev/null +++ b/apps/android/app/schemas/ai.openclaw.app.chat.ClientStateDatabase/1.json @@ -0,0 +1,273 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "924cec9afdb455dced2592399a08f5da", + "entities": [ + { + "tableName": "outbox_commands", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `gatewayId` TEXT NOT NULL, `sessionKey` TEXT NOT NULL, `text` TEXT NOT NULL, `thinkingLevel` TEXT NOT NULL, `createdAtMs` INTEGER NOT NULL, `status` TEXT NOT NULL, `retryCount` INTEGER NOT NULL, `lastError` TEXT, `gatedEpoch` INTEGER, `ownerAgentId` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "gatewayId", + "columnName": "gatewayId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sessionKey", + "columnName": "sessionKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thinkingLevel", + "columnName": "thinkingLevel", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAtMs", + "columnName": "createdAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "retryCount", + "columnName": "retryCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastError", + "columnName": "lastError", + "affinity": "TEXT" + }, + { + "fieldPath": "gatedEpoch", + "columnName": "gatedEpoch", + "affinity": "INTEGER" + }, + { + "fieldPath": "ownerAgentId", + "columnName": "ownerAgentId", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "outbox_attachments", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `commandId` TEXT NOT NULL, `position` INTEGER NOT NULL, `type` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `fileName` TEXT NOT NULL, `durationMs` INTEGER, `byteLength` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "commandId", + "columnName": "commandId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fileName", + "columnName": "fileName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "durationMs", + "columnName": "durationMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "byteLength", + "columnName": "byteLength", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_outbox_attachments_commandId", + "unique": false, + "columnNames": [ + "commandId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_outbox_attachments_commandId` ON `${TABLE_NAME}` (`commandId`)" + } + ] + }, + { + "tableName": "outbox_attachment_chunks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`attachmentId` TEXT NOT NULL, `chunkIndex` INTEGER NOT NULL, `bytes` BLOB NOT NULL, PRIMARY KEY(`attachmentId`, `chunkIndex`))", + "fields": [ + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chunkIndex", + "columnName": "chunkIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bytes", + "columnName": "bytes", + "affinity": "BLOB", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "attachmentId", + "chunkIndex" + ] + } + }, + { + "tableName": "composer_send_admissions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `gatewayId` TEXT NOT NULL, `ownerAgentId` TEXT NOT NULL, `sessionKey` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "gatewayId", + "columnName": "gatewayId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ownerAgentId", + "columnName": "ownerAgentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sessionKey", + "columnName": "sessionKey", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "client_state_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + } + }, + { + "tableName": "gateway_removals", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`gatewayId` TEXT NOT NULL, `phase` TEXT NOT NULL, PRIMARY KEY(`gatewayId`))", + "fields": [ + { + "fieldPath": "gatewayId", + "columnName": "gatewayId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "phase", + "columnName": "phase", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "gatewayId" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '924cec9afdb455dced2592399a08f5da')" + ] + } +} \ No newline at end of file diff --git a/apps/android/app/schemas/ai.openclaw.app.chat.GatewayCacheDatabase/1.json b/apps/android/app/schemas/ai.openclaw.app.chat.GatewayCacheDatabase/1.json new file mode 100644 index 00000000000..68bad9dea18 --- /dev/null +++ b/apps/android/app/schemas/ai.openclaw.app.chat.GatewayCacheDatabase/1.json @@ -0,0 +1,146 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "b40806fd03e11cc1094f31253e9432fb", + "entities": [ + { + "tableName": "cached_sessions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`gatewayId` TEXT NOT NULL, `agentId` TEXT NOT NULL, `sessionKey` TEXT NOT NULL, `displayName` TEXT, `updatedAtMs` INTEGER, `rowOrder` INTEGER NOT NULL, PRIMARY KEY(`gatewayId`, `agentId`, `sessionKey`))", + "fields": [ + { + "fieldPath": "gatewayId", + "columnName": "gatewayId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "agentId", + "columnName": "agentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sessionKey", + "columnName": "sessionKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + }, + { + "fieldPath": "updatedAtMs", + "columnName": "updatedAtMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "rowOrder", + "columnName": "rowOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "gatewayId", + "agentId", + "sessionKey" + ] + } + }, + { + "tableName": "cached_messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`gatewayId` TEXT NOT NULL, `agentId` TEXT NOT NULL, `sessionKey` TEXT NOT NULL, `rowOrder` INTEGER NOT NULL, `role` TEXT NOT NULL, `textPartsJson` TEXT NOT NULL, `timestampMs` INTEGER, `idempotencyKey` TEXT, PRIMARY KEY(`gatewayId`, `agentId`, `sessionKey`, `rowOrder`))", + "fields": [ + { + "fieldPath": "gatewayId", + "columnName": "gatewayId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "agentId", + "columnName": "agentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sessionKey", + "columnName": "sessionKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rowOrder", + "columnName": "rowOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "textPartsJson", + "columnName": "textPartsJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestampMs", + "columnName": "timestampMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "idempotencyKey", + "columnName": "idempotencyKey", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "gatewayId", + "agentId", + "sessionKey", + "rowOrder" + ] + } + }, + { + "tableName": "cached_gateway_owners", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`gatewayId` TEXT NOT NULL, `agentId` TEXT NOT NULL, PRIMARY KEY(`gatewayId`))", + "fields": [ + { + "fieldPath": "gatewayId", + "columnName": "gatewayId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "agentId", + "columnName": "agentId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "gatewayId" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b40806fd03e11cc1094f31253e9432fb')" + ] + } +} \ No newline at end of file diff --git a/apps/android/app/src/main/java/ai/openclaw/app/NodeApp.kt b/apps/android/app/src/main/java/ai/openclaw/app/NodeApp.kt index 5d9197e4022..bb644120230 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/NodeApp.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/NodeApp.kt @@ -1,9 +1,5 @@ package ai.openclaw.app -import ai.openclaw.app.chat.ChatCacheDatabase -import ai.openclaw.app.chat.RoomChatCommandOutbox -import ai.openclaw.app.gateway.DeviceAuthStore -import ai.openclaw.app.gateway.DeviceIdentityStore import ai.openclaw.app.i18n.NativeStringResources import ai.openclaw.app.i18n.notifyNativeLocaleChanged import ai.openclaw.app.wear.GoogleWearMessageSender @@ -13,12 +9,10 @@ import ai.openclaw.app.wear.WearRealtimeChannelRegistry import android.app.Application import android.content.res.Configuration import android.os.StrictMode -import androidx.room.withTransaction import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import java.util.concurrent.atomic.AtomicLong /** @@ -91,38 +85,12 @@ class NodeApp : Application() { suspend fun resetGatewaySetupAuth(stableId: String): Boolean { val runtime = synchronized(runtimeLock) { - runtimeInstance?.let { return@synchronized it } - // Keep runtime construction blocked through the direct purge: a runtime built from the old - // credentials could otherwise reconnect and rewrite device auth after this reset returns. - return runCatching { resetGatewaySetupAuthBeforeRuntime(stableId) }.getOrDefault(false) + runtimeInstance + ?: NodeRuntime.forGatewayAuthReset(this, prefs).also { runtimeInstance = it } } return runtime.resetGatewaySetupAuth(stableId) } - private fun resetGatewaySetupAuthBeforeRuntime(stableId: String): Boolean { - val gatewayId = stableId.trim().takeIf { it.isNotEmpty() } ?: return false - val database = ChatCacheDatabase.open(this) - try { - runBlocking { - database.withTransaction { - database.dao().deleteMessages(gatewayId) - database.dao().deleteSessionsForGateway(gatewayId) - database.dao().deleteGatewayOwner(gatewayId) - // The outbox owns command/attachment cascade deletes; nested transactions join this one. - RoomChatCommandOutbox(database).clearGateway(gatewayId) - } - } - } finally { - database.close() - } - prefs.clearGatewayCredentials(gatewayId) - val deviceId = DeviceIdentityStore.withPrefs(this, prefs).loadOrCreate().deviceId - val deviceAuthStore = DeviceAuthStore(prefs) - deviceAuthStore.clearToken(gatewayId, deviceId, "node") - deviceAuthStore.clearToken(gatewayId, deviceId, "operator") - return true - } - override fun onCreate() { super.onCreate() NativeStringResources.install(this) diff --git a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt index 18eb9341a0c..b18e63b5b34 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt @@ -1,7 +1,7 @@ package ai.openclaw.app +import ai.openclaw.app.chat.AndroidClientDatabases import ai.openclaw.app.chat.BackgroundTask -import ai.openclaw.app.chat.ChatCacheDatabase import ai.openclaw.app.chat.ChatCacheScope import ai.openclaw.app.chat.ChatCommandEntry import ai.openclaw.app.chat.ChatCommandOutbox @@ -26,8 +26,6 @@ import ai.openclaw.app.chat.MessageSpeechClient import ai.openclaw.app.chat.MessageSpeechController import ai.openclaw.app.chat.MessageSpeechState import ai.openclaw.app.chat.OutgoingAttachment -import ai.openclaw.app.chat.RoomChatCommandOutbox -import ai.openclaw.app.chat.RoomChatTranscriptCache import ai.openclaw.app.chat.SystemSpeechSpeaker import ai.openclaw.app.gateway.DeviceAuthEntry import ai.openclaw.app.gateway.DeviceAuthStore @@ -597,6 +595,8 @@ internal fun gatewayConnectionDisplay( private data class AndroidChatStores( val transcriptCache: ChatTranscriptCache, val commandOutbox: ChatCommandOutbox, + val clientDatabases: AndroidClientDatabases, + val externalTranscriptCache: ChatTranscriptCache? = null, ) internal enum class NodeRuntimeMode { @@ -604,11 +604,43 @@ internal enum class NodeRuntimeMode { ScreenshotFixture, } -private fun openAndroidChatStores(context: Context): AndroidChatStores { - val database = ChatCacheDatabase.open(context.applicationContext) +private fun openAndroidChatStores( + context: Context, + prefs: SecurePrefs, +): AndroidChatStores { + val databases = + AndroidClientDatabases.start( + context.applicationContext, + registeredGatewayIds = + prefs.gatewayRegistry.entries.value + .map { it.stableId } + .toSet(), + ) return AndroidChatStores( - transcriptCache = RoomChatTranscriptCache(database), - commandOutbox = RoomChatCommandOutbox(database), + transcriptCache = databases.transcriptCache(), + commandOutbox = databases.commandOutbox(), + clientDatabases = databases, + ) +} + +private fun openAndroidChatStores( + context: Context, + prefs: SecurePrefs, + transcriptCache: ChatTranscriptCache, +): AndroidChatStores { + val databases = + AndroidClientDatabases.start( + context.applicationContext, + registeredGatewayIds = + prefs.gatewayRegistry.entries.value + .map { it.stableId } + .toSet(), + ) + return AndroidChatStores( + transcriptCache = transcriptCache, + commandOutbox = databases.commandOutbox(), + clientDatabases = databases, + externalTranscriptCache = transcriptCache, ) } @@ -619,9 +651,12 @@ class NodeRuntime private constructor( chatStores: AndroidChatStores, internal val mode: NodeRuntimeMode, initialForeground: Boolean, + initialReconnectSuppressed: Boolean, ) { private val chatTranscriptCache = chatStores.transcriptCache private val chatCommandOutbox = chatStores.commandOutbox + private val clientDatabases = chatStores.clientDatabases + private val externalTranscriptCache = chatStores.externalTranscriptCache private val gatewayAuthLifecycleLock = Any() private var gatewayAuthResetInProgress = false private var gatewayConnectOperationsInFlight = 0 @@ -671,9 +706,10 @@ class NodeRuntime private constructor( context = context, prefs = prefs, tlsFingerprintProbe = tlsFingerprintProbe, - chatStores = openAndroidChatStores(context), + chatStores = openAndroidChatStores(context, prefs), mode = NodeRuntimeMode.Live, initialForeground = true, + initialReconnectSuppressed = false, ) internal constructor( @@ -684,9 +720,10 @@ class NodeRuntime private constructor( context = context, prefs = prefs, tlsFingerprintProbe = ::probeGatewayTlsFingerprint, - chatStores = openAndroidChatStores(context), + chatStores = openAndroidChatStores(context, prefs), mode = NodeRuntimeMode.Live, initialForeground = initialForeground, + initialReconnectSuppressed = false, ) internal constructor( @@ -697,9 +734,10 @@ class NodeRuntime private constructor( context = context, prefs = prefs, tlsFingerprintProbe = ::probeGatewayTlsFingerprint, - chatStores = openAndroidChatStores(context), + chatStores = openAndroidChatStores(context, prefs), mode = mode, initialForeground = true, + initialReconnectSuppressed = false, ) internal constructor( @@ -710,15 +748,28 @@ class NodeRuntime private constructor( context = context, prefs = prefs, tlsFingerprintProbe = ::probeGatewayTlsFingerprint, - chatStores = - AndroidChatStores( - transcriptCache = chatTranscriptCache, - commandOutbox = RoomChatCommandOutbox(ChatCacheDatabase.open(context.applicationContext)), - ), + chatStores = openAndroidChatStores(context, prefs, chatTranscriptCache), mode = NodeRuntimeMode.Live, initialForeground = true, + initialReconnectSuppressed = false, ) + companion object { + internal fun forGatewayAuthReset( + context: Context, + prefs: SecurePrefs, + ): NodeRuntime = + NodeRuntime( + context = context, + prefs = prefs, + tlsFingerprintProbe = ::probeGatewayTlsFingerprint, + chatStores = openAndroidChatStores(context, prefs), + mode = NodeRuntimeMode.Live, + initialForeground = true, + initialReconnectSuppressed = true, + ) + } + /** * Authentication material supplied by setup/manual connect flows before gateway session routing. */ @@ -2607,11 +2658,15 @@ class NodeRuntime private constructor( // Replacing authentication retires the old identity even when the endpoint is unchanged. // Purge only that gateway; ordinary switches retain every gateway's offline state. val cacheCleared = - runCatching { chat.clearGatewayCache(stableId) } - .onFailure { err -> - Log.e("OpenClawRuntime", "Failed to purge gateway chat data before auth reset", err) - setStandaloneGatewayStatus("Failed: couldn't clear offline chat data. Retry sign out.") - }.isSuccess + runCatching { + chat.clearGatewayCache(stableId) { + clientDatabases.commitGatewayRemoval(stableId, requireCacheRemoval = true) + externalTranscriptCache?.clearGateway(stableId) + } + }.onFailure { err -> + Log.e("OpenClawRuntime", "Failed to purge gateway chat data before auth reset", err) + setStandaloneGatewayStatus("Failed: couldn't clear offline chat data. Retry sign out.") + }.isSuccess if (!cacheCleared) return false prefs.clearGatewayCredentials(stableId) val deviceId = identityStore.loadOrCreate().deviceId @@ -2645,7 +2700,7 @@ class NodeRuntime private constructor( private var didAutoConnect = false - @Volatile private var preferredGatewayReconnectSuppressed = false + @Volatile private var preferredGatewayReconnectSuppressed = initialReconnectSuppressed val chatSessionKey: StateFlow = chat.sessionKey val chatSessionOwnerAgentId: StateFlow = chat.sessionOwnerAgentId @@ -4392,21 +4447,54 @@ class NodeRuntime private constructor( prepareDisconnect(retireRunState = true) } drainIdleGatewaySessionTails() - val cacheCleared = - runCatching { chat.clearGatewayCache(normalized) } + val removalStaged = + runCatching { clientDatabases.stageGatewayRemoval(normalized) } .onFailure { err -> - Log.e("OpenClawRuntime", "Failed to purge forgotten gateway chat data", err) - setStandaloneGatewayStatus("Failed: couldn't clear offline gateway data. Retry forget.") + Log.e("OpenClawRuntime", "Failed to stage forgotten gateway data removal", err) + setStandaloneGatewayStatus("Failed: couldn't prepare offline gateway cleanup. Retry forget.") }.isSuccess + if (!removalStaged) return false + val authRetired = + runCatching { + val deviceId = identityStore.loadOrCreate().deviceId + deviceAuthStore.clearToken(normalized, deviceId, "node") + deviceAuthStore.clearToken(normalized, deviceId, "operator") + prefs.clearGatewayCredentials(normalized) + prefs.clearGatewayCustomHeaders(normalized) + prefs.clearGatewayTlsFingerprint(normalized) + prefs.clearNotificationForwardingSessionKey(normalized) + }.onFailure { err -> + runCatching { clientDatabases.cancelGatewayRemoval(normalized) } + Log.e("OpenClawRuntime", "Failed to retire forgotten gateway authentication", err) + setStandaloneGatewayStatus("Failed: couldn't clear saved gateway authentication. Retry forget.") + }.isSuccess + if (!authRetired) return false + val cacheCleared = + runCatching { + chat.clearGatewayCache(normalized) { + clientDatabases.commitGatewayRemoval(normalized, requireCacheRemoval = true) + externalTranscriptCache?.clearGateway(normalized) + } + }.onFailure { err -> + Log.e("OpenClawRuntime", "Failed to purge forgotten gateway chat data", err) + setStandaloneGatewayStatus("Failed: couldn't clear offline gateway data. Retry forget.") + }.isSuccess if (!cacheCleared) return false - val deviceId = identityStore.loadOrCreate().deviceId - deviceAuthStore.clearToken(normalized, deviceId, "node") - deviceAuthStore.clearToken(normalized, deviceId, "operator") - prefs.clearGatewayCredentials(normalized) - prefs.clearGatewayCustomHeaders(normalized) - prefs.clearGatewayTlsFingerprint(normalized) - prefs.clearNotificationForwardingSessionKey(normalized) - prefs.gatewayRegistry.remove(normalized) + // Publish registry removal only after auth, durable state, and transcripts are gone. Any + // earlier failure leaves this signed-out registration available for an idempotent retry. + val registryRemoved = + runCatching { + check(prefs.gatewayRegistry.remove(normalized)) { "Failed to persist gateway registry removal" } + }.fold( + onSuccess = { true }, + onFailure = { err -> + runCatching { clientDatabases.cancelGatewayRemoval(normalized) } + Log.e("OpenClawRuntime", "Failed to commit forgotten gateway registry removal", err) + setStandaloneGatewayStatus("Failed: couldn't remove the saved gateway. Retry forget.") + false + }, + ) + if (!registryRemoved) return false true } finally { synchronized(gatewayAuthLifecycleLock) { gatewayAuthResetInProgress = false } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatCommandOutbox.kt b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatCommandOutbox.kt index 49d464fcd4f..63b38e95dfa 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatCommandOutbox.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatCommandOutbox.kt @@ -5,6 +5,7 @@ import androidx.room.Dao import androidx.room.Entity import androidx.room.Index import androidx.room.Insert +import androidx.room.OnConflictStrategy import androidx.room.PrimaryKey import androidx.room.Query import androidx.room.withTransaction @@ -290,6 +291,41 @@ internal class OutboxAttachmentChunkEntity( @Dao internal interface ChatOutboxDao { + // One-time legacy import reads small metadata sets plus bounded BLOB pages before copying them + // into client-state. Runtime callers remain gateway-scoped below. + @Query("SELECT * FROM outbox_commands ORDER BY gatewayId ASC, createdAtMs ASC, id ASC") + suspend fun allCommands(): List + + @Query("SELECT * FROM composer_send_admissions ORDER BY gatewayId ASC, ownerAgentId ASC, id ASC") + suspend fun allAdmissionReceipts(): List + + @Query("SELECT * FROM outbox_attachments ORDER BY commandId ASC, position ASC") + suspend fun allAttachments(): List + + @Query( + "SELECT * FROM outbox_attachment_chunks " + + "WHERE :afterAttachmentId IS NULL OR attachmentId > :afterAttachmentId " + + "OR (attachmentId = :afterAttachmentId AND chunkIndex > :afterChunkIndex) " + + "ORDER BY attachmentId ASC, chunkIndex ASC LIMIT :limit", + ) + suspend fun attachmentChunkPage( + afterAttachmentId: String?, + afterChunkIndex: Int, + limit: Int, + ): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertImportedCommands(rows: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertImportedAdmissionReceipts(rows: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertImportedAttachments(rows: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertImportedAttachmentChunks(rows: List) + // id tiebreak keeps flush order deterministic when two rows share a createdAt millisecond. @Query("SELECT * FROM outbox_commands WHERE gatewayId = :gatewayId ORDER BY createdAtMs ASC, id ASC") suspend fun commands(gatewayId: String): List @@ -458,13 +494,13 @@ internal interface ChatOutboxDao { } /** - * Room-backed [ChatCommandOutbox] sharing the chat cache database. Callers pass the gateway id - * captured before their suspend point; a blank identity disables both reads and writes. + * Room-backed [ChatCommandOutbox] in the durable client-state database. Callers pass the gateway + * id captured before their suspend point; a blank identity disables both reads and writes. * Command rows and their attachment bytes are admitted and retired in single transactions, so * a crash can never orphan bytes or strand a row without its attachments. */ class RoomChatCommandOutbox internal constructor( - private val database: ChatCacheDatabase, + private val database: ClientStateDatabase, ) : ChatCommandOutbox { override suspend fun load(gatewayId: String): List { val gateway = scopedGatewayId(gatewayId) ?: return emptyList() diff --git a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt index 1313fe451ca..3756249e07e 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt @@ -602,6 +602,17 @@ class ChatController internal constructor( /** Purges cached transcripts and queued sends for one retired authentication scope. */ internal suspend fun clearGatewayCache(gatewayId: String) { + clearGatewayCache(gatewayId) { gateway -> + transcriptCache?.clearGateway(gateway) + commandOutbox?.clearGateway(gateway) + } + } + + /** Serializes an owner-provided cross-store purge with every chat cache/outbox mutation. */ + internal suspend fun clearGatewayCache( + gatewayId: String, + clearStores: suspend (String) -> Unit, + ) { val gateway = gatewayId.trim().takeIf { it.isNotEmpty() } ?: return synchronized(defaultAgentPersistenceRevisions) { defaultAgentPersistenceRevisions[gateway] = (defaultAgentPersistenceRevisions[gateway] ?: 0L) + 1L @@ -615,8 +626,7 @@ class ChatController internal constructor( // deleted here; queued old-owner saves then fail their revision check after this unlocks. defaultAgentPersistenceMutex.withLock { cacheMutationMutex.withLock { - transcriptCache?.clearGateway(gateway) - commandOutbox?.clearGateway(gateway) + clearStores(gateway) } } } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatTranscriptCache.kt b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatTranscriptCache.kt index af34ff68f27..bf095bf3708 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatTranscriptCache.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatTranscriptCache.kt @@ -1,17 +1,11 @@ package ai.openclaw.app.chat -import android.content.Context import androidx.room.Dao -import androidx.room.Database import androidx.room.Entity import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query -import androidx.room.Room -import androidx.room.RoomDatabase -import androidx.room.migration.Migration import androidx.room.withTransaction -import androidx.sqlite.db.SupportSQLiteDatabase import kotlinx.serialization.builtins.ListSerializer import kotlinx.serialization.builtins.serializer import kotlinx.serialization.json.Json @@ -20,8 +14,6 @@ import java.util.UUID /** Upper bound of cached session rows per gateway across every agent owner. */ internal const val MAX_CACHED_SESSIONS = 50 -internal const val CHAT_TRANSCRIPT_CACHE_DB_NAME = "chat-transcript-cache.db" - /** Upper bound of cached transcript rows per session; only the newest messages are kept. */ internal const val MAX_CACHED_MESSAGES_PER_SESSION = 200 @@ -226,165 +218,12 @@ internal interface ChatCacheDao { suspend fun evictGatewayOrphanedTranscripts(gatewayId: String) } -@Database( - entities = [ - CachedSessionEntity::class, - CachedMessageEntity::class, - OutboxCommandEntity::class, - OutboxAttachmentEntity::class, - OutboxAttachmentChunkEntity::class, - ComposerSendAdmissionEntity::class, - CachedGatewayOwnerEntity::class, - ], - version = 8, - exportSchema = false, -) -internal abstract class ChatCacheDatabase : RoomDatabase() { - abstract fun dao(): ChatCacheDao - - abstract fun outboxDao(): ChatOutboxDao - - companion object { - internal val MIGRATION_2_3 = - object : Migration(2, 3) { - override fun migrate(db: SupportSQLiteDatabase) { - // v2 persisted every post-dispatch exception as queued+lastError. Those rows may - // already have run, so upgrading must park them alongside crash-interrupted sends. - db.execSQL( - "UPDATE outbox_commands SET status = ?, lastError = ? " + - "WHERE status = ? OR (status = ? AND lastError IS NOT NULL)", - arrayOf( - ChatOutboxStatus.Failed.dbValue, - OUTBOX_DELIVERY_UNCONFIRMED_ERROR, - ChatOutboxStatus.Sending.dbValue, - ChatOutboxStatus.Queued.dbValue, - ), - ) - } - } - - internal val MIGRATION_3_4 = - object : Migration(3, 4) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("ALTER TABLE `outbox_commands` ADD COLUMN `gatedEpoch` INTEGER") - // Legacy queued command-shaped rows predate connection epochs; the sentinel makes - // them park for explicit retry instead of silently replaying on the next reconnect. - db.execSQL( - "UPDATE outbox_commands SET gatedEpoch = ? WHERE status = ? AND text LIKE '/%'", - arrayOf(OUTBOX_GATED_EPOCH_NEVER, ChatOutboxStatus.Queued.dbValue), - ) - db.execSQL( - "CREATE TABLE IF NOT EXISTS `outbox_attachments` (`id` TEXT NOT NULL, `commandId` TEXT NOT NULL, " + - "`position` INTEGER NOT NULL, `type` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `fileName` TEXT NOT NULL, " + - "`durationMs` INTEGER, `byteLength` INTEGER NOT NULL, PRIMARY KEY(`id`))", - ) - db.execSQL("CREATE INDEX IF NOT EXISTS `index_outbox_attachments_commandId` ON `outbox_attachments` (`commandId`)") - db.execSQL( - "CREATE TABLE IF NOT EXISTS `outbox_attachment_chunks` (`attachmentId` TEXT NOT NULL, " + - "`chunkIndex` INTEGER NOT NULL, `bytes` BLOB NOT NULL, PRIMARY KEY(`attachmentId`, `chunkIndex`))", - ) - } - } - - internal val MIGRATION_4_5 = - object : Migration(4, 5) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("ALTER TABLE `outbox_commands` ADD COLUMN `ownerAgentId` TEXT") - // Agent-qualified keys carry a durable owner in the key itself. Backfill it so session - // deletion and replay keep working after upgrade without consulting mutable defaults. - db.execSQL( - "UPDATE outbox_commands SET ownerAgentId = " + - "substr(sessionKey, 7, instr(substr(sessionKey, 7), ':') - 1) " + - "WHERE sessionKey LIKE 'agent:%:%' AND instr(substr(sessionKey, 7), ':') > 1", - ) - // Earlier rows did not persist the default agent that owned an unscoped key. Never - // guess after upgrade: queued input stays visible for manual resend, while accepted - // input remains delivery-ambiguous and must not be replayed under a different owner. - db.execSQL( - "UPDATE outbox_commands SET status = ?, lastError = ? " + - "WHERE status = ? AND sessionKey NOT LIKE 'agent:%'", - arrayOf( - ChatOutboxStatus.Failed.dbValue, - OUTBOX_OWNER_CHANGED_ERROR, - ChatOutboxStatus.Queued.dbValue, - ), - ) - db.execSQL( - "UPDATE outbox_commands SET status = ?, lastError = ? " + - "WHERE status = ? AND sessionKey NOT LIKE 'agent:%'", - arrayOf( - ChatOutboxStatus.Failed.dbValue, - OUTBOX_DELIVERY_UNCONFIRMED_ERROR, - ChatOutboxStatus.Accepted.dbValue, - ), - ) - } - } - - internal val MIGRATION_5_6 = - object : Migration(5, 6) { - override fun migrate(db: SupportSQLiteDatabase) { - // Session and transcript caches are disposable, and legacy unscoped rows have no - // provable owner. Rebuild both; the durable outbox remains intact across the upgrade. - db.execSQL("DROP TABLE IF EXISTS `cached_sessions`") - db.execSQL("DROP TABLE IF EXISTS `cached_messages`") - db.execSQL( - "CREATE TABLE IF NOT EXISTS `cached_sessions` " + - "(`gatewayId` TEXT NOT NULL, `agentId` TEXT NOT NULL, `sessionKey` TEXT NOT NULL, " + - "`displayName` TEXT, `updatedAtMs` INTEGER, `rowOrder` INTEGER NOT NULL, " + - "PRIMARY KEY(`gatewayId`, `agentId`, `sessionKey`))", - ) - db.execSQL( - "CREATE TABLE IF NOT EXISTS `cached_messages` " + - "(`gatewayId` TEXT NOT NULL, `agentId` TEXT NOT NULL, `sessionKey` TEXT NOT NULL, " + - "`rowOrder` INTEGER NOT NULL, `role` TEXT NOT NULL, `textPartsJson` TEXT NOT NULL, " + - "`timestampMs` INTEGER, `idempotencyKey` TEXT, " + - "PRIMARY KEY(`gatewayId`, `agentId`, `sessionKey`, `rowOrder`))", - ) - } - } - - internal val MIGRATION_6_7 = - object : Migration(6, 7) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL( - "CREATE TABLE IF NOT EXISTS `cached_gateway_owners` " + - "(`gatewayId` TEXT NOT NULL, `agentId` TEXT NOT NULL, PRIMARY KEY(`gatewayId`))", - ) - } - } - - internal val MIGRATION_7_8 = - object : Migration(7, 8) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL( - "CREATE TABLE IF NOT EXISTS `composer_send_admissions` " + - "(`id` TEXT NOT NULL, `gatewayId` TEXT NOT NULL, `ownerAgentId` TEXT NOT NULL, " + - "`sessionKey` TEXT NOT NULL, PRIMARY KEY(`id`))", - ) - } - } - - fun open( - context: Context, - name: String = CHAT_TRANSCRIPT_CACHE_DB_NAME, - ): ChatCacheDatabase = - Room - .databaseBuilder(context, ChatCacheDatabase::class.java, name) - .addMigrations(MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8) - // v1 has only disposable transcripts. Starting with v2, the outbox is user data, so every - // supported bump needs an explicit migration; destructive fallback remains for v1 only. - .fallbackToDestructiveMigrationFrom(true, 1) - .build() - } -} - /** * Room-backed [ChatTranscriptCache]. Callers bind every operation to the gateway scope captured * before their suspend point, so a connection switch cannot re-scope an old response. */ class RoomChatTranscriptCache internal constructor( - private val database: ChatCacheDatabase, + private val database: GatewayCacheDatabase, ) : ChatTranscriptCache { private val json = Json private val textPartsSerializer = ListSerializer(String.serializer()) diff --git a/apps/android/app/src/main/java/ai/openclaw/app/chat/ClientDatabases.kt b/apps/android/app/src/main/java/ai/openclaw/app/chat/ClientDatabases.kt new file mode 100644 index 00000000000..775f86528da --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/app/chat/ClientDatabases.kt @@ -0,0 +1,653 @@ +package ai.openclaw.app.chat + +import android.content.Context +import android.util.Log +import androidx.room.Dao +import androidx.room.Database +import androidx.room.Entity +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.PrimaryKey +import androidx.room.Query +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.room.migration.Migration +import androidx.room.withTransaction +import androidx.sqlite.db.SupportSQLiteDatabase +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.cancel +import kotlinx.coroutines.withContext +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference + +internal const val GATEWAY_CACHE_DB_NAME = "gateway-cache.db" +internal const val CLIENT_STATE_DB_NAME = "client-state.db" +internal const val LEGACY_CHAT_DATABASE_NAME = "chat-transcript-cache.db" + +private const val LEGACY_IMPORT_KEY = "legacy-chat-transcript-cache-v8" +private const val LEGACY_IMPORT_COMPLETE = "complete" +private const val LEGACY_IMPORT_CHUNK_PAGE_ROWS = 8 +private const val GATEWAY_REMOVAL_STAGED = "staged" +private const val GATEWAY_REMOVAL_COMMITTING = "committing" +private const val GATEWAY_REMOVAL_CACHE_PENDING = "cache-pending" + +@Entity(tableName = "client_state_metadata") +internal data class ClientStateMetadataEntity( + @PrimaryKey val key: String, + val value: String, +) + +@Entity(tableName = "gateway_removals") +internal data class GatewayRemovalEntity( + @PrimaryKey val gatewayId: String, + val phase: String, +) + +@Dao +internal interface ClientStateControlDao { + @Query("SELECT value FROM client_state_metadata WHERE `key` = :key") + suspend fun metadataValue(key: String): String? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertMetadata(row: ClientStateMetadataEntity) + + @Query("SELECT * FROM gateway_removals ORDER BY gatewayId ASC") + suspend fun gatewayRemovals(): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertGatewayRemoval(row: GatewayRemovalEntity) + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertGatewayRemovalIfAbsent(row: GatewayRemovalEntity) + + @Query("DELETE FROM gateway_removals WHERE gatewayId = :gatewayId AND phase = :phase") + suspend fun deleteGatewayRemovalInPhase( + gatewayId: String, + phase: String, + ) +} + +/** Disposable gateway-derived projections. Schema mismatches and corruption rebuild this file. */ +@Database( + entities = [CachedSessionEntity::class, CachedMessageEntity::class, CachedGatewayOwnerEntity::class], + version = 1, + exportSchema = true, +) +internal abstract class GatewayCacheDatabase : RoomDatabase() { + abstract fun dao(): ChatCacheDao + + companion object { + fun open( + context: Context, + name: String = GATEWAY_CACHE_DB_NAME, + ): GatewayCacheDatabase { + val appContext = context.applicationContext + + fun build(): GatewayCacheDatabase = + Room + .databaseBuilder(appContext, GatewayCacheDatabase::class.java, name) + // Cache rows are gateway-owned projections. A missing migration means rebuild, and + // dropAllTables also removes obsolete cache tables left by older formats. + .fallbackToDestructiveMigration(true) + .build() + + var database: GatewayCacheDatabase? = null + return try { + build().also { + database = it + // Room opens lazily; force validation so corruption is repaired before publication. + it.openHelper.writableDatabase + } + } catch (_: Throwable) { + database?.close() + appContext.deleteDatabase(name) + build().also { it.openHelper.writableDatabase } + } + } + } +} + +/** Durable client-owned state. Every future schema change requires an explicit Room migration. */ +@Database( + entities = [ + OutboxCommandEntity::class, + OutboxAttachmentEntity::class, + OutboxAttachmentChunkEntity::class, + ComposerSendAdmissionEntity::class, + ClientStateMetadataEntity::class, + GatewayRemovalEntity::class, + ], + version = 1, + exportSchema = true, +) +internal abstract class ClientStateDatabase : RoomDatabase() { + abstract fun outboxDao(): ChatOutboxDao + + abstract fun controlDao(): ClientStateControlDao + + companion object { + fun open( + context: Context, + name: String = CLIENT_STATE_DB_NAME, + ): ClientStateDatabase = + Room + .databaseBuilder(context.applicationContext, ClientStateDatabase::class.java, name) + .build() + .also { + // Fail closed and preserve the file if durable state cannot be opened or validated. + it.openHelper.writableDatabase + } + } +} + +/** + * Shipped combined database, retained only as the one-time import owner. + * + * Runtime reads and writes never use this type after [AndroidClientDatabases.start] completes. + */ +@Database( + entities = [ + CachedSessionEntity::class, + CachedMessageEntity::class, + OutboxCommandEntity::class, + OutboxAttachmentEntity::class, + OutboxAttachmentChunkEntity::class, + ComposerSendAdmissionEntity::class, + CachedGatewayOwnerEntity::class, + ], + version = 8, + exportSchema = false, +) +internal abstract class LegacyChatDatabase : RoomDatabase() { + abstract fun dao(): ChatCacheDao + + abstract fun outboxDao(): ChatOutboxDao + + companion object { + internal val MIGRATION_2_3 = + object : Migration(2, 3) { + override fun migrate(db: SupportSQLiteDatabase) { + // v2 persisted every post-dispatch exception as queued+lastError. Those rows may + // already have run, so upgrading must park them alongside crash-interrupted sends. + db.execSQL( + "UPDATE outbox_commands SET status = ?, lastError = ? " + + "WHERE status = ? OR (status = ? AND lastError IS NOT NULL)", + arrayOf( + ChatOutboxStatus.Failed.dbValue, + OUTBOX_DELIVERY_UNCONFIRMED_ERROR, + ChatOutboxStatus.Sending.dbValue, + ChatOutboxStatus.Queued.dbValue, + ), + ) + } + } + + internal val MIGRATION_3_4 = + object : Migration(3, 4) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `outbox_commands` ADD COLUMN `gatedEpoch` INTEGER") + // Legacy queued command-shaped rows predate connection epochs; the sentinel makes + // them park for explicit retry instead of silently replaying on the next reconnect. + db.execSQL( + "UPDATE outbox_commands SET gatedEpoch = ? WHERE status = ? AND text LIKE '/%'", + arrayOf(OUTBOX_GATED_EPOCH_NEVER, ChatOutboxStatus.Queued.dbValue), + ) + db.execSQL( + "CREATE TABLE IF NOT EXISTS `outbox_attachments` (`id` TEXT NOT NULL, `commandId` TEXT NOT NULL, " + + "`position` INTEGER NOT NULL, `type` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `fileName` TEXT NOT NULL, " + + "`durationMs` INTEGER, `byteLength` INTEGER NOT NULL, PRIMARY KEY(`id`))", + ) + db.execSQL("CREATE INDEX IF NOT EXISTS `index_outbox_attachments_commandId` ON `outbox_attachments` (`commandId`)") + db.execSQL( + "CREATE TABLE IF NOT EXISTS `outbox_attachment_chunks` (`attachmentId` TEXT NOT NULL, " + + "`chunkIndex` INTEGER NOT NULL, `bytes` BLOB NOT NULL, PRIMARY KEY(`attachmentId`, `chunkIndex`))", + ) + } + } + + internal val MIGRATION_4_5 = + object : Migration(4, 5) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `outbox_commands` ADD COLUMN `ownerAgentId` TEXT") + // Agent-qualified keys carry a durable owner in the key itself. Backfill it so session + // deletion and replay keep working after upgrade without consulting mutable defaults. + db.execSQL( + "UPDATE outbox_commands SET ownerAgentId = " + + "substr(sessionKey, 7, instr(substr(sessionKey, 7), ':') - 1) " + + "WHERE sessionKey LIKE 'agent:%:%' AND instr(substr(sessionKey, 7), ':') > 1", + ) + // Earlier rows did not persist the default agent that owned an unscoped key. Never + // guess after upgrade: queued input stays visible for manual resend, while accepted + // input remains delivery-ambiguous and must not be replayed under a different owner. + db.execSQL( + "UPDATE outbox_commands SET status = ?, lastError = ? " + + "WHERE status = ? AND sessionKey NOT LIKE 'agent:%'", + arrayOf( + ChatOutboxStatus.Failed.dbValue, + OUTBOX_OWNER_CHANGED_ERROR, + ChatOutboxStatus.Queued.dbValue, + ), + ) + db.execSQL( + "UPDATE outbox_commands SET status = ?, lastError = ? " + + "WHERE status = ? AND sessionKey NOT LIKE 'agent:%'", + arrayOf( + ChatOutboxStatus.Failed.dbValue, + OUTBOX_DELIVERY_UNCONFIRMED_ERROR, + ChatOutboxStatus.Accepted.dbValue, + ), + ) + } + } + + internal val MIGRATION_5_6 = + object : Migration(5, 6) { + override fun migrate(db: SupportSQLiteDatabase) { + // Session and transcript caches are disposable, and legacy unscoped rows have no + // provable owner. Rebuild both; the durable outbox remains intact across the upgrade. + db.execSQL("DROP TABLE IF EXISTS `cached_sessions`") + db.execSQL("DROP TABLE IF EXISTS `cached_messages`") + db.execSQL( + "CREATE TABLE IF NOT EXISTS `cached_sessions` " + + "(`gatewayId` TEXT NOT NULL, `agentId` TEXT NOT NULL, `sessionKey` TEXT NOT NULL, " + + "`displayName` TEXT, `updatedAtMs` INTEGER, `rowOrder` INTEGER NOT NULL, " + + "PRIMARY KEY(`gatewayId`, `agentId`, `sessionKey`))", + ) + db.execSQL( + "CREATE TABLE IF NOT EXISTS `cached_messages` " + + "(`gatewayId` TEXT NOT NULL, `agentId` TEXT NOT NULL, `sessionKey` TEXT NOT NULL, " + + "`rowOrder` INTEGER NOT NULL, `role` TEXT NOT NULL, `textPartsJson` TEXT NOT NULL, " + + "`timestampMs` INTEGER, `idempotencyKey` TEXT, " + + "PRIMARY KEY(`gatewayId`, `agentId`, `sessionKey`, `rowOrder`))", + ) + } + } + + internal val MIGRATION_6_7 = + object : Migration(6, 7) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "CREATE TABLE IF NOT EXISTS `cached_gateway_owners` " + + "(`gatewayId` TEXT NOT NULL, `agentId` TEXT NOT NULL, PRIMARY KEY(`gatewayId`))", + ) + } + } + + internal val MIGRATION_7_8 = + object : Migration(7, 8) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "CREATE TABLE IF NOT EXISTS `composer_send_admissions` " + + "(`id` TEXT NOT NULL, `gatewayId` TEXT NOT NULL, `ownerAgentId` TEXT NOT NULL, " + + "`sessionKey` TEXT NOT NULL, PRIMARY KEY(`id`))", + ) + } + } + + fun open( + context: Context, + name: String, + ): LegacyChatDatabase = + Room + .databaseBuilder(context.applicationContext, LegacyChatDatabase::class.java, name) + .addMigrations(MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8) + // v1 contains only disposable transcripts. Durable state starts in v2. + .fallbackToDestructiveMigrationFrom(true, 1) + .build() + .also { it.openHelper.writableDatabase } + } +} + +private class OpenedAndroidClientDatabases private constructor( + private val context: Context, + val gatewayCache: GatewayCacheDatabase, + val clientState: ClientStateDatabase, +) : AutoCloseable { + companion object { + suspend fun open( + context: Context, + gatewayCacheName: String = GATEWAY_CACHE_DB_NAME, + clientStateName: String = CLIENT_STATE_DB_NAME, + legacyName: String = LEGACY_CHAT_DATABASE_NAME, + registeredGatewayIds: Set? = null, + ): OpenedAndroidClientDatabases { + val appContext = context.applicationContext + val state = ClientStateDatabase.open(appContext, clientStateName) + var cache: GatewayCacheDatabase? = null + return try { + cache = GatewayCacheDatabase.open(appContext, gatewayCacheName) + OpenedAndroidClientDatabases(appContext, cache, state).also { databases -> + databases.importLegacyStateIfNeeded(legacyName) + databases.resolvePendingGatewayRemovals(registeredGatewayIds) + } + } catch (error: Throwable) { + cache?.close() + state.close() + throw error + } + } + } + + val transcriptCache = RoomChatTranscriptCache(gatewayCache) + + val commandOutbox = RoomChatCommandOutbox(clientState) + + suspend fun stageGatewayRemoval(gatewayId: String) { + val gateway = scopedGatewayId(gatewayId) ?: return + // A retry may race an interrupted committed purge. Never downgrade that irreversible marker. + clientState.controlDao().insertGatewayRemovalIfAbsent(GatewayRemovalEntity(gateway, GATEWAY_REMOVAL_STAGED)) + } + + suspend fun cancelGatewayRemoval(gatewayId: String) { + val gateway = scopedGatewayId(gatewayId) ?: return + clientState.controlDao().deleteGatewayRemovalInPhase(gateway, GATEWAY_REMOVAL_STAGED) + } + + /** + * Marks cleanup irreversible before touching either file. A crash can leave one database ahead + * of the other, so startup resumes this idempotent operation before publishing the stores. + */ + suspend fun commitGatewayRemoval( + gatewayId: String, + requireCacheRemoval: Boolean = false, + ) { + val gateway = scopedGatewayId(gatewayId) ?: return + withContext(NonCancellable) { + // State deletion and its phase advance are atomic. A rollback leaves no irreversible marker; + // after commit, startup may clear only disposable cache and must preserve any newer outbox rows. + clientState.withTransaction { + clientState.controlDao().upsertGatewayRemoval(GatewayRemovalEntity(gateway, GATEWAY_REMOVAL_COMMITTING)) + commandOutbox.clearGateway(gateway) + clientState.controlDao().upsertGatewayRemoval(GatewayRemovalEntity(gateway, GATEWAY_REMOVAL_CACHE_PENDING)) + } + completeCacheRemoval(gateway, propagateFailure = requireCacheRemoval) + } + } + + override fun close() { + gatewayCache.close() + clientState.close() + } + + private suspend fun importLegacyStateIfNeeded(legacyName: String) { + val control = clientState.controlDao() + if (control.metadataValue(LEGACY_IMPORT_KEY) == LEGACY_IMPORT_COMPLETE) { + context.deleteDatabase(legacyName) + return + } + val legacyFile = context.getDatabasePath(legacyName) + if (!legacyFile.exists()) { + control.upsertMetadata(ClientStateMetadataEntity(LEGACY_IMPORT_KEY, LEGACY_IMPORT_COMPLETE)) + return + } + + val legacy = LegacyChatDatabase.open(context, legacyName) + try { + val source = legacy.outboxDao() + val commands = source.allCommands() + val admissions = source.allAdmissionReceipts() + val attachments = source.allAttachments() + clientState.withTransaction { + val destination = clientState.outboxDao() + if (commands.isNotEmpty()) destination.upsertImportedCommands(commands) + if (admissions.isNotEmpty()) destination.upsertImportedAdmissionReceipts(admissions) + if (attachments.isNotEmpty()) destination.upsertImportedAttachments(attachments) + } + + var afterAttachmentId: String? = null + var afterChunkIndex = -1 + while (true) { + val chunks = source.attachmentChunkPage(afterAttachmentId, afterChunkIndex, LEGACY_IMPORT_CHUNK_PAGE_ROWS) + if (chunks.isEmpty()) break + clientState.withTransaction { + clientState.outboxDao().upsertImportedAttachmentChunks(chunks) + } + chunks.last().let { cursor -> + afterAttachmentId = cursor.attachmentId + afterChunkIndex = cursor.chunkIndex + } + } + clientState.withTransaction { + // Earlier page commits are idempotent. This marker publishes them only after the source + // cursor is exhausted, so a crash simply replays REPLACE inserts on the next start. + control.upsertMetadata(ClientStateMetadataEntity(LEGACY_IMPORT_KEY, LEGACY_IMPORT_COMPLETE)) + } + } finally { + legacy.close() + } + // If deletion fails, the next open sees the completion marker and retries only deletion. + context.deleteDatabase(legacyName) + } + + private suspend fun resolvePendingGatewayRemovals(registeredGatewayIds: Set?) { + for (removal in clientState.controlDao().gatewayRemovals()) { + when { + removal.phase == GATEWAY_REMOVAL_CACHE_PENDING -> completeCacheRemoval(removal.gatewayId, propagateFailure = false) + removal.phase == GATEWAY_REMOVAL_COMMITTING -> commitGatewayRemoval(removal.gatewayId) + registeredGatewayIds != null && removal.gatewayId !in registeredGatewayIds -> + commitGatewayRemoval(removal.gatewayId) + registeredGatewayIds != null -> cancelGatewayRemoval(removal.gatewayId) + } + } + } + + private suspend fun completeCacheRemoval( + gatewayId: String, + propagateFailure: Boolean, + ) { + try { + transcriptCache.clearGateway(gatewayId) + } catch (error: Exception) { + if (propagateFailure) throw error + // Cache is disposable. Keep cache-pending for the next open, but the durable purge has + // committed and callers may safely retire auth without risking later outbox deletion. + Log.w("ClientDatabases", "Deferring gateway cache cleanup", error) + return + } + clientState.controlDao().deleteGatewayRemovalInPhase(gatewayId, GATEWAY_REMOVAL_CACHE_PENDING) + } +} + +/** + * One installation-wide pair of multi-gateway databases initialized on a private IO scope. + * Every facade operation awaits the one-time legacy import before reaching either Room store. + */ +internal class AndroidClientDatabases private constructor( + private val scope: CoroutineScope, + private val initialization: Deferred, + private val openedReference: AtomicReference, + private val closed: AtomicBoolean, +) : AutoCloseable { + companion object { + fun start( + context: Context, + gatewayCacheName: String = GATEWAY_CACHE_DB_NAME, + clientStateName: String = CLIENT_STATE_DB_NAME, + legacyName: String = LEGACY_CHAT_DATABASE_NAME, + registeredGatewayIds: Set? = null, + ): AndroidClientDatabases { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + val openedReference = AtomicReference() + val closed = AtomicBoolean(false) + val initialization = + scope.async { + val opened = + OpenedAndroidClientDatabases.open( + context = context.applicationContext, + gatewayCacheName = gatewayCacheName, + clientStateName = clientStateName, + legacyName = legacyName, + registeredGatewayIds = registeredGatewayIds, + ) + if (closed.get()) { + opened.close() + throw CancellationException("Android client databases closed during initialization") + } + openedReference.set(opened) + if (closed.get() && openedReference.compareAndSet(opened, null)) { + opened.close() + throw CancellationException("Android client databases closed during initialization") + } + opened + } + return AndroidClientDatabases(scope, initialization, openedReference, closed) + } + } + + private val transcriptCache = DeferredChatTranscriptCache(::ready) + private val commandOutbox = DeferredChatCommandOutbox(::ready) + + fun transcriptCache(): ChatTranscriptCache = transcriptCache + + fun commandOutbox(): ChatCommandOutbox = commandOutbox + + suspend fun stageGatewayRemoval(gatewayId: String) = ready().stageGatewayRemoval(gatewayId) + + suspend fun cancelGatewayRemoval(gatewayId: String) = ready().cancelGatewayRemoval(gatewayId) + + suspend fun commitGatewayRemoval( + gatewayId: String, + requireCacheRemoval: Boolean = false, + ) = ready().commitGatewayRemoval(gatewayId, requireCacheRemoval) + + internal suspend fun gatewayCacheDatabase(): GatewayCacheDatabase = ready().gatewayCache + + internal suspend fun clientStateDatabase(): ClientStateDatabase = ready().clientState + + private suspend fun ready(): OpenedAndroidClientDatabases { + check(!closed.get()) { "Android client databases are closed" } + val opened = initialization.await() + check(!closed.get()) { "Android client databases are closed" } + return opened + } + + override fun close() { + if (!closed.compareAndSet(false, true)) return + scope.cancel() + openedReference.getAndSet(null)?.close() + } +} + +private class DeferredChatTranscriptCache( + private val ready: suspend () -> OpenedAndroidClientDatabases, +) : ChatTranscriptCache { + override suspend fun loadLastDefaultAgentId(gatewayId: String): String? = ready().transcriptCache.loadLastDefaultAgentId(gatewayId) + + override suspend fun saveLastDefaultAgentId( + gatewayId: String, + agentId: String, + ) = ready().transcriptCache.saveLastDefaultAgentId(gatewayId, agentId) + + override suspend fun loadSessions( + gatewayId: String, + agentId: String, + ): List = ready().transcriptCache.loadSessions(gatewayId, agentId) + + override suspend fun loadTranscript( + gatewayId: String, + agentId: String, + sessionKey: String, + ): List = ready().transcriptCache.loadTranscript(gatewayId, agentId, sessionKey) + + override suspend fun saveSessions( + gatewayId: String, + agentId: String, + sessions: List, + retainedSessionKey: String?, + ) = ready().transcriptCache.saveSessions(gatewayId, agentId, sessions, retainedSessionKey) + + override suspend fun saveTranscript( + gatewayId: String, + agentId: String, + sessionKey: String, + messages: List, + ) = ready().transcriptCache.saveTranscript(gatewayId, agentId, sessionKey, messages) + + override suspend fun deleteSession( + gatewayId: String, + agentId: String, + sessionKey: String, + ) = ready().transcriptCache.deleteSession(gatewayId, agentId, sessionKey) + + override suspend fun clearGateway(gatewayId: String) = ready().transcriptCache.clearGateway(gatewayId) +} + +private class DeferredChatCommandOutbox( + private val ready: suspend () -> OpenedAndroidClientDatabases, +) : ChatCommandOutbox { + override suspend fun load(gatewayId: String): List = ready().commandOutbox.load(gatewayId) + + override suspend fun wasAdmitted(id: String): Boolean = ready().commandOutbox.wasAdmitted(id) + + override suspend fun enqueue( + gatewayId: String, + sessionKey: String, + text: String, + thinkingLevel: String, + nowMs: Long, + attachments: List, + gatedEpoch: Long?, + ownerAgentId: String, + idempotencyKey: String?, + ): ChatOutboxEnqueueResult = + ready() + .commandOutbox + .enqueue(gatewayId, sessionKey, text, thinkingLevel, nowMs, attachments, gatedEpoch, ownerAgentId, idempotencyKey) + + override suspend fun loadAttachments(id: String): List = ready().commandOutbox.loadAttachments(id) + + override suspend fun updateStatus( + id: String, + status: ChatOutboxStatus, + retryCount: Int, + lastError: String?, + ): Int = ready().commandOutbox.updateStatus(id, status, retryCount, lastError) + + override suspend fun claimForSending( + id: String, + retryCount: Int, + lastError: String?, + ): Int = ready().commandOutbox.claimForSending(id, retryCount, lastError) + + override suspend fun pinSessionKey( + id: String, + sessionKey: String, + ) = ready().commandOutbox.pinSessionKey(id, sessionKey) + + override suspend fun requeueForRetry( + gatewayId: String, + id: String, + nowMs: Long, + gatedEpoch: Long?, + ownerAgentId: String?, + ): Int = ready().commandOutbox.requeueForRetry(gatewayId, id, nowMs, gatedEpoch, ownerAgentId) + + override suspend fun delete(id: String) = ready().commandOutbox.delete(id) + + override suspend fun deleteIfQueued(id: String): Boolean = ready().commandOutbox.deleteIfQueued(id) + + override suspend fun confirmDelivered(ids: Set): Int = ready().commandOutbox.confirmDelivered(ids) + + override suspend fun deleteForSession( + gatewayId: String, + sessionKey: String, + ownerAgentId: String, + ) = ready().commandOutbox.deleteForSession(gatewayId, sessionKey, ownerAgentId) + + override suspend fun clearGateway(gatewayId: String) = ready().commandOutbox.clearGateway(gatewayId) + + override suspend fun failSendingAfterRestart() = ready().commandOutbox.failSendingAfterRestart() + + override suspend fun expireStale( + gatewayId: String, + nowMs: Long, + ) = ready().commandOutbox.expireStale(gatewayId, nowMs) +} + +private fun scopedGatewayId(gatewayId: String): String? = gatewayId.trim().takeIf { it.isNotEmpty() } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayRegistry.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayRegistry.kt index 2d24faccd48..e726750ac3f 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayRegistry.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayRegistry.kt @@ -1,6 +1,7 @@ package ai.openclaw.app.gateway import ai.openclaw.app.SecurePrefs +import android.util.Log import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -97,15 +98,23 @@ class GatewayRegistryStore( upsert(existing.copy(lastConnectedAtMs = atMs)) } - fun remove(stableId: String): Unit = + fun remove(stableId: String): Boolean = synchronized(mutationLock) { val normalized = stableId.trim() - _entries.value = _entries.value.filterNot { it.stableId == normalized } - if (_activeStableId.value == normalized) { - _activeStableId.value = null - onActiveChanged?.invoke(null) + val nextEntries = _entries.value.filterNot { it.stableId == normalized } + val previousActiveStableId = _activeStableId.value + val nextActiveStableId = previousActiveStableId?.takeUnless { it == normalized } + if (!persistSynchronously(nextEntries, nextActiveStableId)) return@synchronized false + + // Publish only after the durable commit. Notification is post-commit and cannot turn a + // successful removal into a failure that would cancel the database recovery marker. + _entries.value = nextEntries + _activeStableId.value = nextActiveStableId + if (previousActiveStableId != nextActiveStableId) { + runCatching { onActiveChanged?.invoke(nextActiveStableId) } + .onFailure { Log.e("GatewayRegistry", "Active-gateway observer failed after durable removal", it) } } - persist() + true } fun activeEntry(): GatewayRegistryEntry? = @@ -117,14 +126,25 @@ class GatewayRegistryStore( internal fun storedActiveStableId(): String? = decode(prefs.getString(STORAGE_KEY)).activeStableId private fun persist() { - val registry = - PersistedGatewayRegistry( - activeStableId = _activeStableId.value, - entries = _entries.value.sortedForStorage(), - ) - prefs.putString(STORAGE_KEY, json.encodeToString(registry)) + prefs.putString(STORAGE_KEY, encodedRegistry()) } + private fun persistSynchronously( + entries: List, + activeStableId: String?, + ): Boolean = prefs.putStringSynchronously(STORAGE_KEY, encodedRegistry(entries, activeStableId)) + + private fun encodedRegistry( + entries: List = _entries.value, + activeStableId: String? = _activeStableId.value, + ): String = + json.encodeToString( + PersistedGatewayRegistry( + activeStableId = activeStableId, + entries = entries.sortedForStorage(), + ), + ) + private fun decode(raw: String?): PersistedGatewayRegistry = raw ?.let { runCatching { json.decodeFromString(it) }.getOrNull() } diff --git a/apps/android/app/src/test/java/ai/openclaw/app/GatewayBootstrapAuthTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/GatewayBootstrapAuthTest.kt index 4c3fd61e2de..fbfd9876999 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/GatewayBootstrapAuthTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/GatewayBootstrapAuthTest.kt @@ -1,5 +1,8 @@ package ai.openclaw.app +import ai.openclaw.app.chat.ChatMessage +import ai.openclaw.app.chat.ChatSessionEntry +import ai.openclaw.app.chat.ChatTranscriptCache import ai.openclaw.app.gateway.DeviceAuthStore import ai.openclaw.app.gateway.DeviceIdentityStore import ai.openclaw.app.gateway.GatewayConnectOptions @@ -849,6 +852,25 @@ class GatewayBootstrapAuthTest { assertEquals("other-node-token", authStore.loadToken(other, deviceId, "node")) } + @Test + fun resetGatewaySetupAuthClearsInjectedTranscriptStore() = + runBlocking { + val app = RuntimeEnvironment.getApplication() + val securePrefs = + app.getSharedPreferences( + "openclaw.node.secure.test.${UUID.randomUUID()}", + android.content.Context.MODE_PRIVATE, + ) + val prefs = SecurePrefs(app, securePrefsOverride = securePrefs) + val transcriptCache = RecordingTranscriptCache() + val runtime = NodeRuntime(app, prefs, transcriptCache) + val target = GatewayEndpoint.manual("target.example", 18789).stableId + + assertTrue(runtime.resetGatewaySetupAuth(target)) + + assertEquals(listOf(target), transcriptCache.clearedGatewayIds) + } + @Test fun switchToUndiscoveredGatewayKeepsCurrentConnectionAndActiveGateway() { val app = RuntimeEnvironment.getApplication() @@ -1507,4 +1529,50 @@ class GatewayBootstrapAuthTest { } error("Field $name not found on ${target.javaClass.name}") } + + private class RecordingTranscriptCache : ChatTranscriptCache { + val clearedGatewayIds = mutableListOf() + + override suspend fun loadLastDefaultAgentId(gatewayId: String): String? = null + + override suspend fun saveLastDefaultAgentId( + gatewayId: String, + agentId: String, + ) = Unit + + override suspend fun loadSessions( + gatewayId: String, + agentId: String, + ): List = emptyList() + + override suspend fun loadTranscript( + gatewayId: String, + agentId: String, + sessionKey: String, + ): List = emptyList() + + override suspend fun saveSessions( + gatewayId: String, + agentId: String, + sessions: List, + retainedSessionKey: String?, + ) = Unit + + override suspend fun saveTranscript( + gatewayId: String, + agentId: String, + sessionKey: String, + messages: List, + ) = Unit + + override suspend fun deleteSession( + gatewayId: String, + agentId: String, + sessionKey: String, + ) = Unit + + override suspend fun clearGateway(gatewayId: String) { + clearedGatewayIds += gatewayId + } + } } diff --git a/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatCacheDatabaseMigrationTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatCacheDatabaseMigrationTest.kt deleted file mode 100644 index fd9c294d4da..00000000000 --- a/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatCacheDatabaseMigrationTest.kt +++ /dev/null @@ -1,219 +0,0 @@ -package ai.openclaw.app.chat - -import android.database.sqlite.SQLiteDatabase -import kotlinx.coroutines.test.runTest -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.RuntimeEnvironment -import java.util.UUID - -@RunWith(RobolectricTestRunner::class) -class ChatCacheDatabaseMigrationTest { - @Test - fun v2AmbiguousRowsMigrateToManualOnlyAndPreservePristineQueue() = - runTest { - val context = RuntimeEnvironment.getApplication() - val databaseName = "chat-cache-migration-${UUID.randomUUID()}.db" - val databaseFile = context.getDatabasePath(databaseName) - databaseFile.parentFile?.mkdirs() - createV2Fixture(databaseFile.path) - - val database = ChatCacheDatabase.open(context, databaseName) - try { - // Opening through Room executes the production migration chain and validates the - // complete v8 schema, including columns, nullability, primary keys, and indices. - assertEquals(8, database.openHelper.writableDatabase.version) - - val outbox = RoomChatCommandOutbox(database) - val rows = outbox.load("gateway-test").associateBy { it.id } - val pristine = rows.getValue("pristine") - assertEquals(ChatOutboxStatus.Failed, pristine.status) - assertEquals(OUTBOX_OWNER_CHANGED_ERROR, pristine.lastError) - assertNull(pristine.ownerAgentId) - assertNull(pristine.gatedEpoch) - assertTrue(pristine.attachments.isEmpty()) - - for (id in listOf("legacy-queued-error", "interrupted-send")) { - val migrated = rows.getValue(id) - assertEquals(ChatOutboxStatus.Failed, migrated.status) - assertEquals(OUTBOX_DELIVERY_UNCONFIRMED_ERROR, migrated.lastError) - } - val alreadyFailed = rows.getValue("already-failed") - assertEquals(ChatOutboxStatus.Failed, alreadyFailed.status) - assertEquals("original failure", alreadyFailed.lastError) - val accepted = rows.getValue("accepted") - assertEquals(ChatOutboxStatus.Failed, accepted.status) - assertEquals(OUTBOX_DELIVERY_UNCONFIRMED_ERROR, accepted.lastError) - val explicitOwner = rows.getValue("explicit-owner") - assertEquals(ChatOutboxStatus.Queued, explicitOwner.status) - assertEquals("ops", explicitOwner.ownerAgentId) - outbox.deleteForSession("gateway-test", "agent:ops:side", "ops") - assertTrue(outbox.load("gateway-test").none { it.id == explicitOwner.id }) - // Legacy queued command-shaped rows predate connection epochs; the sentinel keeps - // them from silently replaying on the next reconnect (they park for explicit retry). - val legacyCommand = rows.getValue("legacy-command") - assertEquals(ChatOutboxStatus.Failed, legacyCommand.status) - assertEquals(OUTBOX_GATED_EPOCH_NEVER, legacyCommand.gatedEpoch) - assertEquals(OUTBOX_OWNER_CHANGED_ERROR, legacyCommand.lastError) - // Legacy session and transcript rows have no trustworthy agent owner. Both disposable - // caches are rebuilt instead of exposing another agent's metadata or history. - assertTrue(database.dao().sessions("gateway-test", "main").isEmpty()) - assertTrue(database.dao().messages("gateway-test", "main", "main").isEmpty()) - } finally { - database.close() - context.deleteDatabase(databaseName) - } - } - - @Test - fun upgradedStoreSupportsAttachmentsAndSurvivesReopen() = - runTest { - val context = RuntimeEnvironment.getApplication() - val databaseName = "chat-cache-migration-${UUID.randomUUID()}.db" - val databaseFile = context.getDatabasePath(databaseName) - databaseFile.parentFile?.mkdirs() - createV2Fixture(databaseFile.path) - - // Spans multiple chunks to prove chunked reassembly is byte-exact after a real upgrade. - val bytes = ByteArray(OUTBOX_ATTACHMENT_CHUNK_BYTES + 77) { (it % 127).toByte() } - val queuedId: String - val first = ChatCacheDatabase.open(context, databaseName) - try { - val queued = - RoomChatCommandOutbox(first).enqueue( - gatewayId = "gateway-test", - sessionKey = "main", - text = "post-upgrade media", - thinkingLevel = "off", - nowMs = System.currentTimeMillis(), - ownerAgentId = "main", - attachments = - listOf( - OutboxAttachmentPayload(type = "image", mimeType = "image/jpeg", fileName = "a.jpg", durationMs = null, bytes = bytes), - ), - ) as ChatOutboxEnqueueResult.Queued - queuedId = queued.item.id - } finally { - first.close() - } - - // Process-restart analog: a fresh open must recover the exact bytes. - val reopened = ChatCacheDatabase.open(context, databaseName) - try { - val loaded = RoomChatCommandOutbox(reopened).loadAttachments(queuedId) - assertEquals(1, loaded.size) - assertTrue(bytes.contentEquals(loaded.single().bytes)) - } finally { - reopened.close() - context.deleteDatabase(databaseName) - } - } - - private fun createV2Fixture(path: String) { - SQLiteDatabase.openOrCreateDatabase(path, null).use { database -> - val now = System.currentTimeMillis() - database.execSQL( - "CREATE TABLE IF NOT EXISTS `cached_sessions` " + - "(`gatewayId` TEXT NOT NULL, `sessionKey` TEXT NOT NULL, `displayName` TEXT, " + - "`updatedAtMs` INTEGER, `rowOrder` INTEGER NOT NULL, PRIMARY KEY(`gatewayId`, `sessionKey`))", - ) - database.execSQL( - "CREATE TABLE IF NOT EXISTS `cached_messages` " + - "(`gatewayId` TEXT NOT NULL, `sessionKey` TEXT NOT NULL, `rowOrder` INTEGER NOT NULL, " + - "`role` TEXT NOT NULL, `textPartsJson` TEXT NOT NULL, `timestampMs` INTEGER, " + - "`idempotencyKey` TEXT, PRIMARY KEY(`gatewayId`, `sessionKey`, `rowOrder`))", - ) - database.execSQL( - "CREATE TABLE IF NOT EXISTS `outbox_commands` " + - "(`id` TEXT NOT NULL, `gatewayId` TEXT NOT NULL, `sessionKey` TEXT NOT NULL, " + - "`text` TEXT NOT NULL, `thinkingLevel` TEXT NOT NULL, `createdAtMs` INTEGER NOT NULL, " + - "`status` TEXT NOT NULL, `retryCount` INTEGER NOT NULL, `lastError` TEXT, PRIMARY KEY(`id`))", - ) - database.execSQL( - "INSERT INTO cached_sessions " + - "(gatewayId, sessionKey, displayName, updatedAtMs, rowOrder) VALUES (?, ?, ?, ?, ?)", - arrayOf("gateway-test", "main", "Cached session", 10L, 0), - ) - database.execSQL( - "INSERT INTO cached_messages " + - "(gatewayId, sessionKey, rowOrder, role, textPartsJson, timestampMs, idempotencyKey) " + - "VALUES (?, ?, ?, ?, ?, ?, ?)", - arrayOf("gateway-test", "main", 0, "assistant", "[\"legacy transcript\"]", 10L, null), - ) - insertOutbox(database, id = "pristine", status = "queued", retryCount = 0, lastError = null, createdAtMs = now) - insertOutbox( - database, - id = "legacy-queued-error", - status = "queued", - retryCount = 0, - lastError = "socket closed after send", - createdAtMs = now + 1, - ) - insertOutbox( - database, - id = "interrupted-send", - status = "sending", - retryCount = 1, - lastError = null, - createdAtMs = now + 2, - ) - insertOutbox( - database, - id = "already-failed", - status = "failed", - retryCount = 3, - lastError = "original failure", - createdAtMs = now + 3, - ) - insertOutbox( - database, - id = "legacy-command", - status = "queued", - retryCount = 0, - lastError = null, - createdAtMs = now + 4, - text = "/clear", - ) - insertOutbox( - database, - id = "accepted", - status = "accepted", - retryCount = 0, - lastError = null, - createdAtMs = now + 5, - ) - insertOutbox( - database, - id = "explicit-owner", - status = "queued", - retryCount = 0, - lastError = null, - createdAtMs = now + 6, - sessionKey = "agent:ops:side", - ) - database.version = 2 - } - } - - private fun insertOutbox( - database: SQLiteDatabase, - id: String, - status: String, - retryCount: Int, - lastError: String?, - createdAtMs: Long, - text: String = id, - sessionKey: String = "main", - ) { - database.execSQL( - "INSERT INTO outbox_commands " + - "(id, gatewayId, sessionKey, text, thinkingLevel, createdAtMs, status, retryCount, lastError) " + - "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", - arrayOf(id, "gateway-test", sessionKey, text, "off", createdAtMs, status, retryCount, lastError), - ) - } -} diff --git a/apps/android/app/src/test/java/ai/openclaw/app/chat/ClientDatabasesTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/chat/ClientDatabasesTest.kt new file mode 100644 index 00000000000..f45e3977d73 --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/app/chat/ClientDatabasesTest.kt @@ -0,0 +1,485 @@ +package ai.openclaw.app.chat + +import android.database.sqlite.SQLiteDatabase +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import java.util.UUID + +@RunWith(RobolectricTestRunner::class) +class ClientDatabasesTest { + @Test + fun v2DurableRowsImportIntoClientStateWhileLegacyCacheIsDiscarded() = + runTest { + val names = databaseNames() + val context = RuntimeEnvironment.getApplication() + createV2Fixture(context.getDatabasePath(names.legacy).path) + + val databases = open(names, registeredGatewayIds = setOf("gateway-test")) + try { + assertEquals( + 1, + databases + .gatewayCacheDatabase() + .openHelper.writableDatabase.version, + ) + assertEquals( + 1, + databases + .clientStateDatabase() + .openHelper.writableDatabase.version, + ) + + val rows = databases.commandOutbox().load("gateway-test").associateBy { it.id } + val pristine = rows.getValue("pristine") + assertEquals(ChatOutboxStatus.Failed, pristine.status) + assertEquals(OUTBOX_OWNER_CHANGED_ERROR, pristine.lastError) + assertNull(pristine.ownerAgentId) + assertNull(pristine.gatedEpoch) + assertTrue(pristine.attachments.isEmpty()) + + for (id in listOf("legacy-queued-error", "interrupted-send")) { + val migrated = rows.getValue(id) + assertEquals(ChatOutboxStatus.Failed, migrated.status) + assertEquals(OUTBOX_DELIVERY_UNCONFIRMED_ERROR, migrated.lastError) + } + val alreadyFailed = rows.getValue("already-failed") + assertEquals(ChatOutboxStatus.Failed, alreadyFailed.status) + assertEquals("original failure", alreadyFailed.lastError) + val accepted = rows.getValue("accepted") + assertEquals(ChatOutboxStatus.Failed, accepted.status) + assertEquals(OUTBOX_DELIVERY_UNCONFIRMED_ERROR, accepted.lastError) + val explicitOwner = rows.getValue("explicit-owner") + assertEquals(ChatOutboxStatus.Queued, explicitOwner.status) + assertEquals("ops", explicitOwner.ownerAgentId) + databases.commandOutbox().deleteForSession("gateway-test", "agent:ops:side", "ops") + assertTrue(databases.commandOutbox().load("gateway-test").none { it.id == explicitOwner.id }) + + val legacyCommand = rows.getValue("legacy-command") + assertEquals(ChatOutboxStatus.Failed, legacyCommand.status) + assertEquals(OUTBOX_GATED_EPOCH_NEVER, legacyCommand.gatedEpoch) + assertEquals(OUTBOX_OWNER_CHANGED_ERROR, legacyCommand.lastError) + + // Legacy gateway snapshots are disposable and never cross into the new cache file. + assertTrue(databases.transcriptCache().loadSessions("gateway-test", "main").isEmpty()) + assertTrue(databases.transcriptCache().loadTranscript("gateway-test", "main", "main").isEmpty()) + assertFalse(context.getDatabasePath(names.legacy).exists()) + assertTrue(context.getDatabasePath(names.cache).exists()) + assertTrue(context.getDatabasePath(names.state).exists()) + } finally { + databases.close() + delete(names) + } + } + + @Test + fun v8AttachmentBytesAndAdmissionReceiptsImportOnceAndSurviveReopen() = + runTest { + val names = databaseNames() + val context = RuntimeEnvironment.getApplication() + createV2Fixture(context.getDatabasePath(names.legacy).path) + val bytes = ByteArray((OUTBOX_ATTACHMENT_CHUNK_BYTES * 9) + 77) { (it % 127).toByte() } + addV8AttachmentFixture(names.legacy, bytes) + + val first = open(names, registeredGatewayIds = setOf("gateway-test")) + try { + val loaded = first.commandOutbox().loadAttachments("media-command") + assertEquals(1, loaded.size) + assertTrue(bytes.contentEquals(loaded.single().bytes)) + assertTrue(first.commandOutbox().wasAdmitted("media-command")) + } finally { + first.close() + } + + // A fresh open reads only client-state.db. The completion marker prevents a stale legacy + // file from being imported twice if deletion was interrupted. + val reopened = open(names, registeredGatewayIds = setOf("gateway-test")) + try { + val loaded = reopened.commandOutbox().loadAttachments("media-command") + assertEquals(1, loaded.size) + assertTrue(bytes.contentEquals(loaded.single().bytes)) + assertTrue(reopened.commandOutbox().wasAdmitted("media-command")) + } finally { + reopened.close() + delete(names) + } + } + + @Test + fun cacheFormatMismatchRebuildsWithoutTouchingClientState() = + runTest { + val names = databaseNames() + val context = RuntimeEnvironment.getApplication() + val first = open(names, registeredGatewayIds = setOf("gateway-a")) + try { + first.transcriptCache().saveTranscript( + gatewayId = "gateway-a", + agentId = "main", + sessionKey = "main", + messages = listOf(cachedMessage("cache me")), + ) + assertTrue( + first.commandOutbox().enqueue( + gatewayId = "gateway-a", + sessionKey = "main", + text = "preserve me", + thinkingLevel = "off", + nowMs = 1, + ownerAgentId = "main", + ) is ChatOutboxEnqueueResult.Queued, + ) + } finally { + first.close() + } + + SQLiteDatabase.openDatabase(context.getDatabasePath(names.cache).path, null, SQLiteDatabase.OPEN_READWRITE).use { + it.version = 99 + } + + val reopened = open(names, registeredGatewayIds = setOf("gateway-a")) + try { + assertTrue(reopened.transcriptCache().loadTranscript("gateway-a", "main", "main").isEmpty()) + assertEquals(listOf("preserve me"), reopened.commandOutbox().load("gateway-a").map { it.text }) + } finally { + reopened.close() + delete(names) + } + } + + @Test + fun clientStateFormatMismatchFailsClosedWithoutDeletingDurableFile() = + runTest { + val names = databaseNames() + val context = RuntimeEnvironment.getApplication() + val first = open(names, registeredGatewayIds = setOf("gateway-a")) + try { + seedGateway(first, "gateway-a", "preserve") + } finally { + first.close() + } + + val statePath = context.getDatabasePath(names.state).path + SQLiteDatabase.openDatabase(statePath, null, SQLiteDatabase.OPEN_READWRITE).use { + it.version = 99 + } + + val failedOpen = open(names, registeredGatewayIds = setOf("gateway-a")) + val failure = runCatching { failedOpen.clientStateDatabase() } + failedOpen.close() + assertTrue(failure.isFailure) + assertTrue(context.getDatabasePath(names.state).exists()) + SQLiteDatabase.openDatabase(statePath, null, SQLiteDatabase.OPEN_READONLY).use { + assertEquals(99, it.version) + } + delete(names) + } + + @Test + fun absentGatewayCommitsStagedRemovalAcrossBothDatabasesAndKeepsOtherGateway() = + runTest { + val names = databaseNames() + val first = open(names, registeredGatewayIds = setOf("gateway-a", "gateway-b")) + try { + seedGateway(first, "gateway-a", "remove") + seedGateway(first, "gateway-b", "keep") + first.stageGatewayRemoval("gateway-a") + } finally { + first.close() + } + + val reopened = open(names, registeredGatewayIds = setOf("gateway-b")) + try { + assertTrue(reopened.transcriptCache().loadTranscript("gateway-a", "main", "main").isEmpty()) + assertTrue(reopened.commandOutbox().load("gateway-a").isEmpty()) + assertEquals(listOf("keep"), reopened.transcriptCache().loadTranscript("gateway-b", "main", "main").map { it.content.single().text }) + assertEquals(listOf("keep"), reopened.commandOutbox().load("gateway-b").map { it.text }) + } finally { + reopened.close() + delete(names) + } + } + + @Test + fun cachePendingRemovalNeverDeletesNewDurableRowsOnResume() = + runTest { + val names = databaseNames() + val first = open(names, registeredGatewayIds = setOf("gateway-a", "gateway-b")) + try { + seedGateway(first, "gateway-a", "remove") + seedGateway(first, "gateway-b", "keep") + // Force only the disposable half to fail after the durable state transaction commits. + first.gatewayCacheDatabase().close() + first.commitGatewayRemoval("gateway-a") + assertTrue(first.commandOutbox().load("gateway-a").isEmpty()) + assertTrue( + first.commandOutbox().enqueue( + gatewayId = "gateway-a", + sessionKey = "main", + text = "new after purge", + thinkingLevel = "off", + nowMs = 2, + ownerAgentId = "main", + ) is ChatOutboxEnqueueResult.Queued, + ) + // A retry may stage again before restart; it must not downgrade cache-pending into a + // cancelable marker that could strand the old derived rows. + first.stageGatewayRemoval("gateway-a") + } finally { + first.close() + } + + val reopened = open(names, registeredGatewayIds = setOf("gateway-a", "gateway-b")) + try { + assertTrue(reopened.transcriptCache().loadTranscript("gateway-a", "main", "main").isEmpty()) + assertEquals(listOf("new after purge"), reopened.commandOutbox().load("gateway-a").map { it.text }) + assertEquals(listOf("keep"), reopened.transcriptCache().loadTranscript("gateway-b", "main", "main").map { it.content.single().text }) + assertEquals(listOf("keep"), reopened.commandOutbox().load("gateway-b").map { it.text }) + assertTrue( + reopened + .clientStateDatabase() + .controlDao() + .gatewayRemovals() + .isEmpty(), + ) + } finally { + reopened.close() + delete(names) + } + } + + @Test + fun stillRegisteredGatewayCancelsCancelableStagedRemoval() = + runTest { + val names = databaseNames() + val first = open(names, registeredGatewayIds = setOf("gateway-a")) + try { + seedGateway(first, "gateway-a", "keep") + first.stageGatewayRemoval("gateway-a") + } finally { + first.close() + } + + val reopened = open(names, registeredGatewayIds = setOf("gateway-a")) + try { + assertEquals(listOf("keep"), reopened.transcriptCache().loadTranscript("gateway-a", "main", "main").map { it.content.single().text }) + assertEquals(listOf("keep"), reopened.commandOutbox().load("gateway-a").map { it.text }) + } finally { + reopened.close() + delete(names) + } + } + + private suspend fun seedGateway( + databases: AndroidClientDatabases, + gatewayId: String, + text: String, + ) { + databases.transcriptCache().saveTranscript( + gatewayId = gatewayId, + agentId = "main", + sessionKey = "main", + messages = listOf(cachedMessage(text)), + ) + assertTrue( + databases.commandOutbox().enqueue( + gatewayId = gatewayId, + sessionKey = "main", + text = text, + thinkingLevel = "off", + nowMs = 1, + ownerAgentId = "main", + ) is ChatOutboxEnqueueResult.Queued, + ) + } + + private fun cachedMessage(text: String): ChatMessage = + ChatMessage( + id = "id-$text", + role = "user", + content = listOf(ChatMessageContent(type = "text", text = text)), + timestampMs = 1, + ) + + private fun addV8AttachmentFixture( + legacyName: String, + bytes: ByteArray, + ) { + val context = RuntimeEnvironment.getApplication() + val legacy = LegacyChatDatabase.open(context, legacyName) + try { + val database = legacy.openHelper.writableDatabase + database.execSQL( + "INSERT INTO outbox_commands " + + "(id, gatewayId, sessionKey, text, thinkingLevel, createdAtMs, status, retryCount, lastError, gatedEpoch, ownerAgentId) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + arrayOf("media-command", "gateway-test", "main", "media", "off", 100L, "queued", 0, null, null, "main"), + ) + database.execSQL( + "INSERT INTO composer_send_admissions (id, gatewayId, ownerAgentId, sessionKey) VALUES (?, ?, ?, ?)", + arrayOf("media-command", "gateway-test", "main", "main"), + ) + database.execSQL( + "INSERT INTO outbox_attachments (id, commandId, position, type, mimeType, fileName, durationMs, byteLength) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + arrayOf("media-attachment", "media-command", 0, "image", "image/jpeg", "a.jpg", null, bytes.size.toLong()), + ) + var offset = 0 + var index = 0 + while (offset < bytes.size) { + val end = minOf(offset + OUTBOX_ATTACHMENT_CHUNK_BYTES, bytes.size) + database.execSQL( + "INSERT INTO outbox_attachment_chunks (attachmentId, chunkIndex, bytes) VALUES (?, ?, ?)", + arrayOf("media-attachment", index, bytes.copyOfRange(offset, end)), + ) + offset = end + index += 1 + } + } finally { + legacy.close() + } + } + + private fun open( + names: DatabaseNames, + registeredGatewayIds: Set, + ): AndroidClientDatabases = + AndroidClientDatabases.start( + RuntimeEnvironment.getApplication(), + gatewayCacheName = names.cache, + clientStateName = names.state, + legacyName = names.legacy, + registeredGatewayIds = registeredGatewayIds, + ) + + private fun databaseNames(): DatabaseNames { + val id = UUID.randomUUID().toString() + return DatabaseNames( + cache = "gateway-cache-$id.db", + state = "client-state-$id.db", + legacy = "chat-transcript-cache-$id.db", + ) + } + + private fun delete(names: DatabaseNames) { + val context = RuntimeEnvironment.getApplication() + context.deleteDatabase(names.cache) + context.deleteDatabase(names.state) + context.deleteDatabase(names.legacy) + } + + private data class DatabaseNames( + val cache: String, + val state: String, + val legacy: String, + ) + + private fun createV2Fixture(path: String) { + SQLiteDatabase.openOrCreateDatabase(path, null).use { database -> + val now = System.currentTimeMillis() + database.execSQL( + "CREATE TABLE IF NOT EXISTS `cached_sessions` " + + "(`gatewayId` TEXT NOT NULL, `sessionKey` TEXT NOT NULL, `displayName` TEXT, " + + "`updatedAtMs` INTEGER, `rowOrder` INTEGER NOT NULL, PRIMARY KEY(`gatewayId`, `sessionKey`))", + ) + database.execSQL( + "CREATE TABLE IF NOT EXISTS `cached_messages` " + + "(`gatewayId` TEXT NOT NULL, `sessionKey` TEXT NOT NULL, `rowOrder` INTEGER NOT NULL, " + + "`role` TEXT NOT NULL, `textPartsJson` TEXT NOT NULL, `timestampMs` INTEGER, " + + "`idempotencyKey` TEXT, PRIMARY KEY(`gatewayId`, `sessionKey`, `rowOrder`))", + ) + database.execSQL( + "CREATE TABLE IF NOT EXISTS `outbox_commands` " + + "(`id` TEXT NOT NULL, `gatewayId` TEXT NOT NULL, `sessionKey` TEXT NOT NULL, " + + "`text` TEXT NOT NULL, `thinkingLevel` TEXT NOT NULL, `createdAtMs` INTEGER NOT NULL, " + + "`status` TEXT NOT NULL, `retryCount` INTEGER NOT NULL, `lastError` TEXT, PRIMARY KEY(`id`))", + ) + database.execSQL( + "INSERT INTO cached_sessions " + + "(gatewayId, sessionKey, displayName, updatedAtMs, rowOrder) VALUES (?, ?, ?, ?, ?)", + arrayOf("gateway-test", "main", "Cached session", 10L, 0), + ) + database.execSQL( + "INSERT INTO cached_messages " + + "(gatewayId, sessionKey, rowOrder, role, textPartsJson, timestampMs, idempotencyKey) " + + "VALUES (?, ?, ?, ?, ?, ?, ?)", + arrayOf("gateway-test", "main", 0, "assistant", "[\"legacy transcript\"]", 10L, null), + ) + insertOutbox(database, id = "pristine", status = "queued", retryCount = 0, lastError = null, createdAtMs = now) + insertOutbox( + database, + id = "legacy-queued-error", + status = "queued", + retryCount = 0, + lastError = "socket closed after send", + createdAtMs = now + 1, + ) + insertOutbox( + database, + id = "interrupted-send", + status = "sending", + retryCount = 1, + lastError = null, + createdAtMs = now + 2, + ) + insertOutbox( + database, + id = "already-failed", + status = "failed", + retryCount = 3, + lastError = "original failure", + createdAtMs = now + 3, + ) + insertOutbox( + database, + id = "legacy-command", + status = "queued", + retryCount = 0, + lastError = null, + createdAtMs = now + 4, + text = "/clear", + ) + insertOutbox( + database, + id = "accepted", + status = "accepted", + retryCount = 0, + lastError = null, + createdAtMs = now + 5, + ) + insertOutbox( + database, + id = "explicit-owner", + status = "queued", + retryCount = 0, + lastError = null, + createdAtMs = now + 6, + sessionKey = "agent:ops:side", + ) + database.version = 2 + } + } + + private fun insertOutbox( + database: SQLiteDatabase, + id: String, + status: String, + retryCount: Int, + lastError: String?, + createdAtMs: Long, + text: String = id, + sessionKey: String = "main", + ) { + database.execSQL( + "INSERT INTO outbox_commands " + + "(id, gatewayId, sessionKey, text, thinkingLevel, createdAtMs, status, retryCount, lastError) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + arrayOf(id, "gateway-test", sessionKey, text, "off", createdAtMs, status, retryCount, lastError), + ) + } +} diff --git a/apps/android/app/src/test/java/ai/openclaw/app/chat/RoomChatCommandOutboxTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/chat/RoomChatCommandOutboxTest.kt index fa56aa2c5d5..393034a33a8 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/chat/RoomChatCommandOutboxTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/chat/RoomChatCommandOutboxTest.kt @@ -14,9 +14,9 @@ import org.robolectric.RuntimeEnvironment @RunWith(RobolectricTestRunner::class) class RoomChatCommandOutboxTest { - private val database: ChatCacheDatabase = + private val database: ClientStateDatabase = Room - .inMemoryDatabaseBuilder(RuntimeEnvironment.getApplication(), ChatCacheDatabase::class.java) + .inMemoryDatabaseBuilder(RuntimeEnvironment.getApplication(), ClientStateDatabase::class.java) .build() private val store = RoomChatCommandOutbox(database = database) diff --git a/apps/android/app/src/test/java/ai/openclaw/app/chat/RoomChatTranscriptCacheTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/chat/RoomChatTranscriptCacheTest.kt index 98622a8a76c..4b8788f8d40 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/chat/RoomChatTranscriptCacheTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/chat/RoomChatTranscriptCacheTest.kt @@ -12,9 +12,9 @@ import org.robolectric.RuntimeEnvironment @RunWith(RobolectricTestRunner::class) class RoomChatTranscriptCacheTest { - private val database: ChatCacheDatabase = + private val database: GatewayCacheDatabase = Room - .inMemoryDatabaseBuilder(RuntimeEnvironment.getApplication(), ChatCacheDatabase::class.java) + .inMemoryDatabaseBuilder(RuntimeEnvironment.getApplication(), GatewayCacheDatabase::class.java) .build() @After diff --git a/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewayRegistryStoreTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewayRegistryStoreTest.kt index 135848929d9..b4394234f45 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewayRegistryStoreTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewayRegistryStoreTest.kt @@ -2,8 +2,11 @@ package ai.openclaw.app.gateway import ai.openclaw.app.SecurePrefs import android.content.Context +import android.content.SharedPreferences import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @@ -29,9 +32,13 @@ class GatewayRegistryStoreTest { assertEquals(alpha.stableId, restored.activeStableId.value) assertEquals(42L, restored.activeEntry()?.lastConnectedAtMs) - restored.remove(alpha.stableId) + assertTrue(restored.remove(alpha.stableId)) assertNull(restored.activeStableId.value) assertEquals(listOf(beta.stableId), restored.entries.value.map { it.stableId }) + + val afterRemoval = GatewayRegistryStore(SecurePrefs(RuntimeEnvironment.getApplication(), securePrefs)) + assertNull(afterRemoval.activeStableId.value) + assertEquals(listOf(beta.stableId), afterRemoval.entries.value.map { it.stableId }) } @Test @@ -57,6 +64,57 @@ class GatewayRegistryStoreTest { assertEquals(first, second) } + @Test + fun failedRemovalCommitDoesNotPublishCandidateState() { + val (_, securePrefs) = freshPrefs() + val failingCommitPrefs = + object : SharedPreferences by securePrefs { + override fun edit(): SharedPreferences.Editor { + val editor = securePrefs.edit() + return object : SharedPreferences.Editor by editor { + override fun putString( + key: String?, + value: String?, + ): SharedPreferences.Editor { + editor.putString(key, value) + return this + } + + override fun commit(): Boolean = false + } + } + } + val store = GatewayRegistryStore(SecurePrefs(RuntimeEnvironment.getApplication(), failingCommitPrefs)) + val alpha = manualEntry("alpha", "alpha.example") + store.upsert(alpha) + store.setActive(alpha.stableId) + + assertFalse(store.remove(alpha.stableId)) + assertEquals(listOf(alpha.stableId), store.entries.value.map { it.stableId }) + assertEquals(alpha.stableId, store.activeStableId.value) + } + + @Test + fun postCommitObserverFailureDoesNotUndoDurableRemoval() { + val (prefs, securePrefs) = freshPrefs() + var failObserver = false + val store = + GatewayRegistryStore(prefs) { + if (failObserver) error("simulated observer failure") + } + val alpha = manualEntry("alpha", "alpha.example") + store.upsert(alpha) + store.setActive(alpha.stableId) + failObserver = true + + assertTrue(store.remove(alpha.stableId)) + assertTrue(store.entries.value.isEmpty()) + assertNull(store.activeStableId.value) + val restored = GatewayRegistryStore(SecurePrefs(RuntimeEnvironment.getApplication(), securePrefs)) + assertTrue(restored.entries.value.isEmpty()) + assertNull(restored.activeStableId.value) + } + private fun freshPrefs(): Pair { val context = RuntimeEnvironment.getApplication() context diff --git a/src/gateway/session-transcript-readers.test.ts b/src/gateway/session-transcript-readers.test.ts index bd32ec73658..2e45ff41d9c 100644 --- a/src/gateway/session-transcript-readers.test.ts +++ b/src/gateway/session-transcript-readers.test.ts @@ -8,6 +8,8 @@ import { } from "../config/sessions/session-accessor.js"; import { waitForSessionTranscriptIndexReconcile } from "../config/sessions/session-transcript-reconcile.js"; import { formatSqliteSessionFileMarker } from "../config/sessions/sqlite-marker.js"; +import { closeOpenClawAgentDatabasesForTest } from "../state/openclaw-agent-db.js"; +import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; import { captureEnv, setTestEnvValue } from "../test-utils/env.js"; import { readSessionMessagesAroundIdWithStatsAsync } from "./session-transcript-anchor-reader.js"; import { @@ -31,6 +33,8 @@ describe("session transcript reader facade", () => { }); afterEach(() => { + closeOpenClawAgentDatabasesForTest(); + closeOpenClawStateDatabaseForTest(); envSnapshot.restore(); fs.rmSync(tempDir, { recursive: true, force: true }); });