@_nazmiforreal/flutter-ota 0.1.26 → 0.1.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (259) hide show
  1. package/README.md +112 -18
  2. package/bin/flutter-ota-linux-x64 +0 -0
  3. package/bin/flutter-ota.js +12 -11
  4. package/dart-src/packages/cli-tools/README.md +146 -2
  5. package/dart-src/packages/cli-tools/lib/flutter_ota_kit_cli.dart +46 -13
  6. package/dart-src/packages/cli-tools/lib/src/backend.dart +6 -6
  7. package/dart-src/packages/cli-tools/lib/src/commands/build.dart +2 -0
  8. package/dart-src/packages/cli-tools/lib/src/commands/bundle.dart +7 -7
  9. package/dart-src/packages/cli-tools/lib/src/commands/deploy.dart +4 -1
  10. package/dart-src/packages/cli-tools/lib/src/commands/init.dart +36 -8
  11. package/dart-src/packages/cli-tools/lib/src/commands/pocketbase.dart +1 -1
  12. package/dart-src/packages/cli-tools/lib/src/commands/rollback.dart +1 -1
  13. package/dart-src/packages/cli-tools/lib/src/config.dart +5 -5
  14. package/dart-src/packages/cli-tools/lib/src/operations.dart +2 -2
  15. package/dart-src/packages/cli-tools/pubspec.yaml +8 -21
  16. package/dart-src/packages/cli-tools/test/backends_test.dart +3 -3
  17. package/dart-src/packages/cli-tools/test/cli_test.dart +1 -1
  18. package/dart-src/packages/cli-tools/test/mocks/mock_aws_s3_client.dart +2 -2
  19. package/dart-src/packages/cli-tools/test/mocks/mock_d1_client.dart +2 -2
  20. package/dart-src/packages/cli-tools/test/mocks/mock_pocketbase_client.dart +4 -1
  21. package/dart-src/packages/cli-tools/test/mocks/mock_postgres_client.dart +2 -2
  22. package/dart-src/packages/cli-tools/test/pocketbase_smoke_test.dart +1 -1
  23. package/dart-src/root/README.md +334 -0
  24. package/dart-src/root/analysis_options.yaml +21 -0
  25. package/dart-src/root/android/build.gradle +116 -0
  26. package/dart-src/root/android/consumer-rules.pro +23 -0
  27. package/dart-src/root/android/settings.gradle +1 -0
  28. package/dart-src/root/android/src/main/AndroidManifest.xml +21 -0
  29. package/dart-src/root/android/src/main/kotlin/com/flutter_patcher/flutter_patcher/ApplyResult.kt +52 -0
  30. package/dart-src/root/android/src/main/kotlin/com/flutter_patcher/flutter_patcher/BlacklistStore.kt +154 -0
  31. package/dart-src/root/android/src/main/kotlin/com/flutter_patcher/flutter_patcher/BootDiagnosticStore.kt +150 -0
  32. package/dart-src/root/android/src/main/kotlin/com/flutter_patcher/flutter_patcher/CrashGuard.kt +271 -0
  33. package/dart-src/root/android/src/main/kotlin/com/flutter_patcher/flutter_patcher/FlutterPatcherApplication.kt +201 -0
  34. package/dart-src/root/android/src/main/kotlin/com/flutter_patcher/flutter_patcher/FlutterPatcherAutoInitProvider.kt +93 -0
  35. package/dart-src/root/android/src/main/kotlin/com/flutter_patcher/flutter_patcher/FlutterPatcherPlugin.kt +245 -0
  36. package/dart-src/root/android/src/main/kotlin/com/flutter_patcher/flutter_patcher/LoaderHook.kt +298 -0
  37. package/dart-src/root/android/src/main/kotlin/com/flutter_patcher/flutter_patcher/PatchManager.kt +1579 -0
  38. package/dart-src/root/android/src/main/kotlin/com/flutter_patcher/flutter_patcher/PatcherConfig.kt +173 -0
  39. package/dart-src/root/android/src/main/kotlin/com/flutter_patcher/flutter_patcher/SignatureVerifier.kt +177 -0
  40. package/dart-src/root/android/src/test/kotlin/com/flutter_patcher/flutter_patcher/CrashGuardTest.kt +61 -0
  41. package/dart-src/root/android/src/test/kotlin/com/flutter_patcher/flutter_patcher/LoaderHookTest.kt +25 -0
  42. package/dart-src/root/android/src/test/kotlin/com/flutter_patcher/flutter_patcher/PatchManagerTest.kt +401 -0
  43. package/dart-src/root/android/src/test/kotlin/com/flutter_patcher/flutter_patcher/PatcherConfigTest.kt +30 -0
  44. package/dart-src/root/bin/pack.dart +182 -0
  45. package/dart-src/root/lib/flutter_ota_kit.dart +1170 -0
  46. package/dart-src/root/lib/src/app_version_resolver.dart +36 -0
  47. package/dart-src/root/lib/src/aws_update_source.dart +97 -0
  48. package/dart-src/root/lib/src/blacklist.dart +54 -0
  49. package/dart-src/root/lib/src/boot_diagnostic.dart +138 -0
  50. package/dart-src/root/lib/src/cloudflare_update_source.dart +97 -0
  51. package/dart-src/root/lib/src/flutter_ota_app.dart +69 -0
  52. package/dart-src/root/lib/src/io.dart +48 -0
  53. package/dart-src/root/lib/src/io_stub.dart +19 -0
  54. package/dart-src/root/lib/src/ota_progress_overlay.dart +1186 -0
  55. package/dart-src/root/lib/src/pack/pack.dart +434 -0
  56. package/dart-src/root/lib/src/patch_info.dart +12 -0
  57. package/dart-src/root/lib/src/patcher_channel.dart +160 -0
  58. package/dart-src/{plugins/aws/lib → root/lib/src/pkg/aws}/src/aws_database.dart +1 -1
  59. package/dart-src/{plugins/aws/lib → root/lib/src/pkg/aws}/src/aws_s3_client.dart +1 -1
  60. package/dart-src/{plugins/aws/lib → root/lib/src/pkg/aws}/src/aws_storage.dart +1 -1
  61. package/dart-src/{plugins/aws/lib → root/lib/src/pkg/aws}/src/aws_storage_profile.dart +1 -1
  62. package/dart-src/{plugins/aws/lib → root/lib/src/pkg/aws}/src/with_cloudfront_signed_url.dart +1 -1
  63. package/dart-src/root/lib/src/pkg/client/flutter_ota_kit_client.dart +1 -0
  64. package/dart-src/root/lib/src/pkg/client/src/server_update_source.dart +46 -0
  65. package/dart-src/{plugins/cloudflare/lib → root/lib/src/pkg/cloudflare}/src/cloudflare_worker_database.dart +1 -1
  66. package/dart-src/{plugins/cloudflare/lib → root/lib/src/pkg/cloudflare}/src/d1_build_where.dart +1 -1
  67. package/dart-src/{plugins/cloudflare/lib → root/lib/src/pkg/cloudflare}/src/d1_bundle_mapper.dart +3 -3
  68. package/dart-src/{plugins/cloudflare/lib → root/lib/src/pkg/cloudflare}/src/d1_database.dart +1 -1
  69. package/dart-src/{plugins/cloudflare/lib → root/lib/src/pkg/cloudflare}/src/d1_database_plugin.dart +2 -2
  70. package/dart-src/{plugins/cloudflare/lib → root/lib/src/pkg/cloudflare}/src/r2_s3_client.dart +1 -1
  71. package/dart-src/{plugins/cloudflare/lib → root/lib/src/pkg/cloudflare}/src/r2_storage.dart +1 -1
  72. package/dart-src/{plugins/cloudflare/lib → root/lib/src/pkg/cloudflare}/src/r2_storage_profile.dart +1 -1
  73. package/dart-src/{packages/core/lib → root/lib/src/pkg/core}/src/status.dart +1 -1
  74. package/dart-src/{plugins/plugin-core/lib → root/lib/src/pkg/plugin_core}/flutter_ota_kit_plugin_core.dart +1 -1
  75. package/dart-src/{plugins/plugin-core/lib → root/lib/src/pkg/plugin_core}/src/create_blob_database_plugin.dart +1 -1
  76. package/dart-src/{plugins/plugin-core/lib → root/lib/src/pkg/plugin_core}/src/filter_compatible_app_versions.dart +1 -1
  77. package/dart-src/{plugins/plugin-core/lib → root/lib/src/pkg/plugin_core}/src/get_update_info.dart +1 -1
  78. package/dart-src/{plugins/plugin-core/lib → root/lib/src/pkg/plugin_core}/src/request_update_bundle_state.dart +1 -1
  79. package/dart-src/{plugins/plugin-core/lib → root/lib/src/pkg/plugin_core}/src/resolve_update_info_from_bundles.dart +1 -1
  80. package/dart-src/{plugins/plugin-core/lib → root/lib/src/pkg/plugin_core}/src/types.dart +2 -2
  81. package/dart-src/{plugins/pocketbase/lib → root/lib/src/pkg/pocketbase}/src/pocketbase_bundle_mapper.dart +2 -2
  82. package/dart-src/{plugins/pocketbase/lib → root/lib/src/pkg/pocketbase}/src/pocketbase_database.dart +26 -15
  83. package/dart-src/{plugins/pocketbase/lib → root/lib/src/pkg/pocketbase}/src/pocketbase_storage.dart +1 -1
  84. package/dart-src/{plugins/postgres/lib → root/lib/src/pkg/postgres}/src/postgres_bundle_mapper.dart +2 -2
  85. package/dart-src/{plugins/postgres/lib → root/lib/src/pkg/postgres}/src/postgres_database.dart +2 -2
  86. package/dart-src/{plugins/postgres/lib → root/lib/src/pkg/postgres}/src/postgres_get_update_info.dart +2 -2
  87. package/dart-src/{plugins/postgres/lib → root/lib/src/pkg/postgres}/src/postgres_storage.dart +1 -1
  88. package/dart-src/{plugins/postgres/lib → root/lib/src/pkg/postgres}/src/postgres_types.dart +1 -1
  89. package/dart-src/{plugins/supabase/lib → root/lib/src/pkg/supabase}/src/supabase_bundle_mapper.dart +2 -2
  90. package/dart-src/{plugins/supabase/lib → root/lib/src/pkg/supabase}/src/supabase_database.dart +2 -2
  91. package/dart-src/{plugins/supabase/lib → root/lib/src/pkg/supabase}/src/supabase_edge_function_database.dart +1 -1
  92. package/dart-src/{plugins/supabase/lib → root/lib/src/pkg/supabase}/src/supabase_edge_function_storage.dart +1 -1
  93. package/dart-src/{plugins/supabase/lib → root/lib/src/pkg/supabase}/src/supabase_storage.dart +1 -1
  94. package/dart-src/root/lib/src/pocketbase_update_source.dart +107 -0
  95. package/dart-src/root/lib/src/postgres_update_source.dart +95 -0
  96. package/dart-src/root/lib/src/shared_update_check.dart +131 -0
  97. package/dart-src/root/lib/src/supabase_update_source.dart +93 -0
  98. package/dart-src/root/pubspec.yaml +54 -0
  99. package/package.json +1 -1
  100. package/scripts/postinstall.js +17 -13
  101. package/scripts/vendor.sh +22 -7
  102. package/dart-src/packages/core/CHANGELOG.md +0 -21
  103. package/dart-src/packages/core/README.md +0 -23
  104. package/dart-src/packages/core/example/lib/main.dart +0 -69
  105. package/dart-src/packages/core/pubspec.yaml +0 -13
  106. package/dart-src/packages/core/test/rollout_test.dart +0 -135
  107. package/dart-src/packages/core/test/semver_test.dart +0 -55
  108. package/dart-src/packages/core/test/semver_vectors.json +0 -1
  109. package/dart-src/packages/core/test/types_test.dart +0 -131
  110. package/dart-src/packages/core/test/uuid_test.dart +0 -40
  111. package/dart-src/packages/core/tool/dbg.dart +0 -7
  112. package/dart-src/plugins/aws/.gitkeep +0 -0
  113. package/dart-src/plugins/aws/CHANGELOG.md +0 -23
  114. package/dart-src/plugins/aws/LICENSE +0 -21
  115. package/dart-src/plugins/aws/README.md +0 -28
  116. package/dart-src/plugins/aws/example/lib/main.dart +0 -60
  117. package/dart-src/plugins/aws/pubspec.yaml +0 -30
  118. package/dart-src/plugins/aws/test/aws_database_test.dart +0 -161
  119. package/dart-src/plugins/aws/test/aws_storage_test.dart +0 -73
  120. package/dart-src/plugins/aws/test/mock_aws_s3_client.dart +0 -78
  121. package/dart-src/plugins/cloudflare/.gitkeep +0 -0
  122. package/dart-src/plugins/cloudflare/CHANGELOG.md +0 -23
  123. package/dart-src/plugins/cloudflare/LICENSE +0 -21
  124. package/dart-src/plugins/cloudflare/README.md +0 -25
  125. package/dart-src/plugins/cloudflare/example/lib/main.dart +0 -62
  126. package/dart-src/plugins/cloudflare/migrations/0001_hot-updater_init.sql +0 -16
  127. package/dart-src/plugins/cloudflare/migrations/0002_hot-updater_0.13.0.sql +0 -8
  128. package/dart-src/plugins/cloudflare/migrations/0003_hot-updater_0.18.0.sql +0 -38
  129. package/dart-src/plugins/cloudflare/migrations/0004_hot-updater_0.29.0.sql +0 -11
  130. package/dart-src/plugins/cloudflare/migrations/0005_hot-updater_0.31.0.sql +0 -24
  131. package/dart-src/plugins/cloudflare/pubspec.yaml +0 -28
  132. package/dart-src/plugins/cloudflare/sql/bundles.sql +0 -42
  133. package/dart-src/plugins/cloudflare/test/cloudflare_worker_database_test.dart +0 -126
  134. package/dart-src/plugins/cloudflare/test/d1_bundle_mapper_test.dart +0 -106
  135. package/dart-src/plugins/cloudflare/test/d1_database_test.dart +0 -200
  136. package/dart-src/plugins/cloudflare/test/mock_d1_client.dart +0 -261
  137. package/dart-src/plugins/cloudflare/test/mock_r2_client.dart +0 -78
  138. package/dart-src/plugins/cloudflare/test/r2_storage_test.dart +0 -74
  139. package/dart-src/plugins/plugin-core/CHANGELOG.md +0 -20
  140. package/dart-src/plugins/plugin-core/LICENSE +0 -21
  141. package/dart-src/plugins/plugin-core/README.md +0 -21
  142. package/dart-src/plugins/plugin-core/example/lib/main.dart +0 -61
  143. package/dart-src/plugins/plugin-core/pubspec.yaml +0 -23
  144. package/dart-src/plugins/plugin-core/test/asset_storage_layout_test.dart +0 -170
  145. package/dart-src/plugins/plugin-core/test/bundle_unit_of_work_store_test.dart +0 -74
  146. package/dart-src/plugins/plugin-core/test/bundle_unit_of_work_test.dart +0 -345
  147. package/dart-src/plugins/plugin-core/test/create_blob_database_plugin_test.dart +0 -843
  148. package/dart-src/plugins/plugin-core/test/create_database_plugin_test.dart +0 -298
  149. package/dart-src/plugins/plugin-core/test/create_storage_plugin_test.dart +0 -195
  150. package/dart-src/plugins/plugin-core/test/generate_min_bundle_id_test.dart +0 -56
  151. package/dart-src/plugins/plugin-core/test/get_update_info_test.dart +0 -1589
  152. package/dart-src/plugins/plugin-core/test/helpers_test.dart +0 -283
  153. package/dart-src/plugins/plugin-core/test/query_bundles_test.dart +0 -202
  154. package/dart-src/plugins/plugin-core/test/request_update_bundle_state_test.dart +0 -86
  155. package/dart-src/plugins/plugin-core/test/uuidv7_test.dart +0 -88
  156. package/dart-src/plugins/pocketbase/CHANGELOG.md +0 -23
  157. package/dart-src/plugins/pocketbase/LICENSE +0 -21
  158. package/dart-src/plugins/pocketbase/README.md +0 -54
  159. package/dart-src/plugins/pocketbase/example/lib/main.dart +0 -62
  160. package/dart-src/plugins/pocketbase/pubspec.yaml +0 -26
  161. package/dart-src/plugins/pocketbase/test/pb_uri_test.dart +0 -12
  162. package/dart-src/plugins/postgres/.gitkeep +0 -0
  163. package/dart-src/plugins/postgres/CHANGELOG.md +0 -23
  164. package/dart-src/plugins/postgres/LICENSE +0 -21
  165. package/dart-src/plugins/postgres/README.md +0 -29
  166. package/dart-src/plugins/postgres/example/lib/main.dart +0 -67
  167. package/dart-src/plugins/postgres/pubspec.yaml +0 -26
  168. package/dart-src/plugins/postgres/sql/bundles.sql +0 -45
  169. package/dart-src/plugins/postgres/sql/get_target_app_version_list.sql +0 -21
  170. package/dart-src/plugins/postgres/sql/get_update_info.spec.ts +0 -171
  171. package/dart-src/plugins/postgres/sql/get_update_info_by_app_version.sql +0 -126
  172. package/dart-src/plugins/postgres/sql/get_update_info_by_fingerprint_hash.sql +0 -125
  173. package/dart-src/plugins/postgres/sql/hash_user_id.sql +0 -30
  174. package/dart-src/plugins/postgres/sql/is_device_eligible.sql +0 -237
  175. package/dart-src/plugins/postgres/sql/prepareSql.ts +0 -11
  176. package/dart-src/plugins/postgres/test/mock_postgres_client.dart +0 -320
  177. package/dart-src/plugins/postgres/test/postgres_bundle_mapper_test.dart +0 -139
  178. package/dart-src/plugins/postgres/test/postgres_database_test.dart +0 -210
  179. package/dart-src/plugins/postgres/test/postgres_storage_runtime_test.dart +0 -60
  180. package/dart-src/plugins/supabase/CHANGELOG.md +0 -24
  181. package/dart-src/plugins/supabase/LICENSE +0 -21
  182. package/dart-src/plugins/supabase/README.md +0 -53
  183. package/dart-src/plugins/supabase/example/lib/main.dart +0 -63
  184. package/dart-src/plugins/supabase/pubspec.yaml +0 -27
  185. package/dart-src/plugins/supabase/supabase/edge-functions/index.ts +0 -44
  186. package/dart-src/plugins/supabase/supabase/edge-functions/runtime.docker.integration.spec.ts +0 -968
  187. package/dart-src/plugins/supabase/supabase/migrations/20250103114225_init.sql +0 -232
  188. package/dart-src/plugins/supabase/supabase/migrations/20250314000000_hot-updater_0.13.0.sql +0 -137
  189. package/dart-src/plugins/supabase/supabase/migrations/20250516000000_hot-updater_0.18.0.sql +0 -206
  190. package/dart-src/plugins/supabase/supabase/migrations/20251014000000_hot-updater_0.21.0.sql +0 -186
  191. package/dart-src/plugins/supabase/supabase/migrations/20260401000000_hot-updater_0.29.0.sql +0 -505
  192. package/dart-src/plugins/supabase/supabase/migrations/20260414000000_hot-updater_0.30.0.sql +0 -50
  193. package/dart-src/plugins/supabase/supabase/migrations/20260422000000_hot-updater_0.31.0.sql +0 -20
  194. package/dart-src/plugins/supabase/supabase/migrations/20260520014100_hot-updater_rls.sql +0 -75
  195. package/dart-src/plugins/supabase/test/mock_supabase_client.dart +0 -654
  196. package/dart-src/plugins/supabase/test/supabase_bundle_mapper_test.dart +0 -115
  197. package/dart-src/plugins/supabase/test/supabase_database_test.dart +0 -198
  198. package/dart-src/plugins/supabase/test/supabase_storage_test.dart +0 -168
  199. /package/dart-src/{packages/core → root}/LICENSE +0 -0
  200. /package/dart-src/{plugins/aws/lib → root/lib/src/pkg/aws}/flutter_ota_kit_aws.dart +0 -0
  201. /package/dart-src/{plugins/aws/lib → root/lib/src/pkg/aws}/src/aws_cloudfront_client.dart +0 -0
  202. /package/dart-src/{plugins/aws/lib → root/lib/src/pkg/aws}/src/aws_cloudfront_signer.dart +0 -0
  203. /package/dart-src/{plugins/aws/lib → root/lib/src/pkg/aws}/src/aws_config.dart +0 -0
  204. /package/dart-src/{plugins/aws/lib → root/lib/src/pkg/aws}/src/aws_ssm_client.dart +0 -0
  205. /package/dart-src/{plugins/cloudflare/lib → root/lib/src/pkg/cloudflare}/flutter_ota_kit_cloudflare.dart +0 -0
  206. /package/dart-src/{plugins/cloudflare/lib → root/lib/src/pkg/cloudflare}/src/d1_client.dart +0 -0
  207. /package/dart-src/{plugins/cloudflare/lib → root/lib/src/pkg/cloudflare}/src/d1_config.dart +0 -0
  208. /package/dart-src/{plugins/cloudflare/lib → root/lib/src/pkg/cloudflare}/src/r2_config.dart +0 -0
  209. /package/dart-src/{packages/core/lib → root/lib/src/pkg/core}/flutter_ota_kit_core.dart +0 -0
  210. /package/dart-src/{packages/core/lib → root/lib/src/pkg/core}/src/app_update_info.dart +0 -0
  211. /package/dart-src/{packages/core/lib → root/lib/src/pkg/core}/src/bundle.dart +0 -0
  212. /package/dart-src/{packages/core/lib → root/lib/src/pkg/core}/src/bundle_artifacts.dart +0 -0
  213. /package/dart-src/{packages/core/lib → root/lib/src/pkg/core}/src/bundle_patch_artifact.dart +0 -0
  214. /package/dart-src/{packages/core/lib → root/lib/src/pkg/core}/src/changed_asset.dart +0 -0
  215. /package/dart-src/{packages/core/lib → root/lib/src/pkg/core}/src/get_bundles_args.dart +0 -0
  216. /package/dart-src/{packages/core/lib → root/lib/src/pkg/core}/src/metadata.dart +0 -0
  217. /package/dart-src/{packages/core/lib → root/lib/src/pkg/core}/src/patch_info.dart +0 -0
  218. /package/dart-src/{packages/core/lib → root/lib/src/pkg/core}/src/platform.dart +0 -0
  219. /package/dart-src/{packages/core/lib → root/lib/src/pkg/core}/src/rollout.dart +0 -0
  220. /package/dart-src/{packages/core/lib → root/lib/src/pkg/core}/src/semver.dart +0 -0
  221. /package/dart-src/{packages/core/lib → root/lib/src/pkg/core}/src/semver_range.dart +0 -0
  222. /package/dart-src/{packages/core/lib → root/lib/src/pkg/core}/src/semver_version.dart +0 -0
  223. /package/dart-src/{packages/core/lib → root/lib/src/pkg/core}/src/strategy.dart +0 -0
  224. /package/dart-src/{packages/core/lib → root/lib/src/pkg/core}/src/update_bundle_params.dart +0 -0
  225. /package/dart-src/{packages/core/lib → root/lib/src/pkg/core}/src/uuid.dart +0 -0
  226. /package/dart-src/{plugins/plugin-core/lib → root/lib/src/pkg/plugin_core}/src/asset_storage_layout.dart +0 -0
  227. /package/dart-src/{plugins/plugin-core/lib → root/lib/src/pkg/plugin_core}/src/bundle_helpers.dart +0 -0
  228. /package/dart-src/{plugins/plugin-core/lib → root/lib/src/pkg/plugin_core}/src/bundle_storage_layout.dart +0 -0
  229. /package/dart-src/{plugins/plugin-core/lib → root/lib/src/pkg/plugin_core}/src/bundle_unit_of_work.dart +0 -0
  230. /package/dart-src/{plugins/plugin-core/lib → root/lib/src/pkg/plugin_core}/src/bundle_unit_of_work_store.dart +0 -0
  231. /package/dart-src/{plugins/plugin-core/lib → root/lib/src/pkg/plugin_core}/src/calculate_pagination.dart +0 -0
  232. /package/dart-src/{plugins/plugin-core/lib → root/lib/src/pkg/plugin_core}/src/compression_format.dart +0 -0
  233. /package/dart-src/{plugins/plugin-core/lib → root/lib/src/pkg/plugin_core}/src/content_addressed_assets.dart +0 -0
  234. /package/dart-src/{plugins/plugin-core/lib → root/lib/src/pkg/plugin_core}/src/create_database_plugin.dart +0 -0
  235. /package/dart-src/{plugins/plugin-core/lib → root/lib/src/pkg/plugin_core}/src/create_storage_key_builder.dart +0 -0
  236. /package/dart-src/{plugins/plugin-core/lib → root/lib/src/pkg/plugin_core}/src/create_storage_plugin.dart +0 -0
  237. /package/dart-src/{plugins/plugin-core/lib → root/lib/src/pkg/plugin_core}/src/generate_min_bundle_id.dart +0 -0
  238. /package/dart-src/{plugins/plugin-core/lib → root/lib/src/pkg/plugin_core}/src/is_object.dart +0 -0
  239. /package/dart-src/{plugins/plugin-core/lib → root/lib/src/pkg/plugin_core}/src/legacy_asset_storage_layout.dart +0 -0
  240. /package/dart-src/{plugins/plugin-core/lib → root/lib/src/pkg/plugin_core}/src/paginate_bundles.dart +0 -0
  241. /package/dart-src/{plugins/plugin-core/lib → root/lib/src/pkg/plugin_core}/src/parse_storage_uri.dart +0 -0
  242. /package/dart-src/{plugins/plugin-core/lib → root/lib/src/pkg/plugin_core}/src/query_bundles.dart +0 -0
  243. /package/dart-src/{plugins/plugin-core/lib → root/lib/src/pkg/plugin_core}/src/storage_helpers.dart +0 -0
  244. /package/dart-src/{plugins/plugin-core/lib → root/lib/src/pkg/plugin_core}/src/storage_profile.dart +0 -0
  245. /package/dart-src/{plugins/plugin-core/lib → root/lib/src/pkg/plugin_core}/src/uuidv7.dart +0 -0
  246. /package/dart-src/{plugins/pocketbase/lib → root/lib/src/pkg/pocketbase}/flutter_ota_kit_pocketbase.dart +0 -0
  247. /package/dart-src/{plugins/pocketbase/lib → root/lib/src/pkg/pocketbase}/src/pocketbase_client.dart +0 -0
  248. /package/dart-src/{plugins/pocketbase/lib → root/lib/src/pkg/pocketbase}/src/pocketbase_config.dart +0 -0
  249. /package/dart-src/{plugins/postgres/lib → root/lib/src/pkg/postgres}/flutter_ota_kit_postgres.dart +0 -0
  250. /package/dart-src/{plugins/postgres/lib → root/lib/src/pkg/postgres}/src/postgres_client.dart +0 -0
  251. /package/dart-src/{plugins/postgres/lib → root/lib/src/pkg/postgres}/src/postgres_config.dart +0 -0
  252. /package/dart-src/{plugins/supabase/lib → root/lib/src/pkg/supabase}/edge.dart +0 -0
  253. /package/dart-src/{plugins/supabase/lib → root/lib/src/pkg/supabase}/flutter_ota_kit_supabase.dart +0 -0
  254. /package/dart-src/{plugins/supabase/lib → root/lib/src/pkg/supabase}/src/error_message.dart +0 -0
  255. /package/dart-src/{plugins/supabase/lib → root/lib/src/pkg/supabase}/src/supabase_client_adapter.dart +0 -0
  256. /package/dart-src/{plugins/supabase/lib → root/lib/src/pkg/supabase}/src/supabase_client_http.dart +0 -0
  257. /package/dart-src/{plugins/supabase/lib → root/lib/src/pkg/supabase}/src/supabase_config.dart +0 -0
  258. /package/dart-src/{plugins/supabase/lib → root/lib/src/pkg/supabase}/src/supabase_signed_url_batcher.dart +0 -0
  259. /package/dart-src/{plugins/supabase/lib → root/lib/src/pkg/supabase}/src/types.dart +0 -0
@@ -0,0 +1,1579 @@
1
+ package com.flutter_patcher.flutter_patcher
2
+
3
+ import android.content.Context
4
+ import android.os.Build
5
+ import android.os.SystemClock
6
+ import android.util.Log
7
+ import io.flutter.plugin.common.StandardMessageCodec
8
+ import org.json.JSONArray
9
+ import org.json.JSONException
10
+ import org.json.JSONObject
11
+ import java.io.File
12
+ import java.io.FileOutputStream
13
+ import java.io.IOException
14
+ import java.net.HttpURLConnection
15
+ import java.net.URL
16
+ import java.nio.ByteBuffer
17
+ import java.util.Collections
18
+ import java.util.zip.ZipException
19
+ import java.util.zip.ZipEntry
20
+ import java.util.zip.ZipFile
21
+ import java.util.zip.ZipOutputStream
22
+
23
+ internal typealias ProgressCallback = (phase: String, received: Long, total: Long) -> Unit
24
+
25
+ internal object Phase {
26
+ const val DOWNLOADING = "downloading"
27
+ const val VERIFYING = "verifying"
28
+ const val FINALIZING = "finalizing"
29
+ }
30
+
31
+ internal data class ValidPatch(
32
+ val soPath: String,
33
+ val assetsPath: String?,
34
+ val assetsArchivePath: String?,
35
+ val assetsBundlePath: String?
36
+ )
37
+
38
+ internal data class HistoryEntry(
39
+ val version: String,
40
+ val md5: String,
41
+ val path: String,
42
+ val assetsRef: Int,
43
+ val installedAt: Long
44
+ )
45
+
46
+ internal enum class RollbackResult {
47
+ SUCCESS,
48
+ FALLBACK_TO_BASE,
49
+ SKIPPED_BLACKLISTED
50
+ }
51
+
52
+ private class PatchInstallException(
53
+ val code: String,
54
+ message: String,
55
+ cause: Throwable? = null
56
+ ) : RuntimeException(message, cause)
57
+
58
+ internal fun codecBufferToByteArray(buffer: ByteBuffer): ByteArray {
59
+ buffer.rewind()
60
+ val bytes = ByteArray(buffer.remaining())
61
+ buffer.get(bytes)
62
+ return bytes
63
+ }
64
+
65
+ internal class PatchManager(
66
+ private val context: Context,
67
+ private val progress: ProgressCallback? = null
68
+ ) {
69
+ companion object {
70
+ private const val TAG = "FlutterPatcher/Mgr"
71
+
72
+ private const val CONNECT_TIMEOUT_MS = 10_000
73
+ private const val READ_TIMEOUT_MS = 30_000
74
+ private const val MAX_RETRIES = 3
75
+ private const val MAX_REDIRECTS = 5
76
+ private const val PROGRESS_EMIT_INTERVAL_MS = 200L
77
+
78
+ private const val MODE_FULL = "full"
79
+ private const val FLUTTER_ASSETS_PREFIX = "assets/flutter_assets/"
80
+ private const val ASSET_MANIFEST = "AssetManifest.bin"
81
+ private const val STAGING_DIR = "staging"
82
+ private const val ASSET_DIR = "flutter_assets"
83
+ private const val ASSET_ARCHIVE = "flutter_assets.apk"
84
+ private const val PATCH_ASSET_BUNDLE = "flutter_patcher_assets"
85
+ private const val PATCH_ASSETS_PREFIX = "assets/$PATCH_ASSET_BUNDLE/"
86
+ private const val MIN_FREE_SPACE_BUFFER = 10L * 1024L * 1024L
87
+ private val MD5_HEX = Regex("^[0-9a-fA-F]{32}$")
88
+
89
+ private val APPLY_LOCK = Any()
90
+
91
+ internal fun validatePatchArgs(
92
+ version: String,
93
+ url: String,
94
+ md5: String,
95
+ mode: String,
96
+ targetVersionCode: Long?,
97
+ currentVersionCode: Long
98
+ ): ApplyResult? {
99
+ if (version.isBlank() || url.isBlank()) {
100
+ return ApplyResult.failure(ApplyErrorCode.INVALID_ARGS, "missing version/url")
101
+ }
102
+ if (md5.isNotBlank() && !MD5_HEX.matches(md5)) {
103
+ return ApplyResult.failure(
104
+ ApplyErrorCode.INVALID_ARGS,
105
+ "md5 must be 32 hex chars or empty"
106
+ )
107
+ }
108
+ if (mode != MODE_FULL) {
109
+ return ApplyResult.failure(
110
+ ApplyErrorCode.INVALID_ARGS,
111
+ "unsupported mode: $mode; only full patches are supported"
112
+ )
113
+ }
114
+ if (targetVersionCode != null) {
115
+ if (currentVersionCode == PatcherConfig.INVALID_VERSION_CODE) {
116
+ return ApplyResult.failure(
117
+ ApplyErrorCode.IO_ERROR,
118
+ "cannot resolve current app versionCode"
119
+ )
120
+ }
121
+ if (targetVersionCode != currentVersionCode) {
122
+ return ApplyResult.failure(
123
+ ApplyErrorCode.INVALID_ARGS,
124
+ "targetVersionCode=$targetVersionCode does not match current=$currentVersionCode"
125
+ )
126
+ }
127
+ }
128
+ return null
129
+ }
130
+
131
+ internal fun isSameInstalledPatch(
132
+ currentMeta: Pair<String, String>?,
133
+ version: String,
134
+ md5: String
135
+ ): Boolean {
136
+ if (currentMeta == null) return false
137
+ if (md5.isBlank()) return currentMeta.first == version
138
+ return currentMeta.first == version &&
139
+ currentMeta.second.equals(md5, ignoreCase = true)
140
+ }
141
+
142
+ internal fun isZipPayload(file: File): Boolean {
143
+ if (!file.exists() || file.length() < 4) return false
144
+ val header = ByteArray(4)
145
+ file.inputStream().use { input ->
146
+ if (input.read(header) != 4) return false
147
+ }
148
+ return header[0] == 0x50.toByte() &&
149
+ header[1] == 0x4b.toByte() &&
150
+ header[2] == 0x03.toByte() &&
151
+ header[3] == 0x04.toByte()
152
+ }
153
+
154
+ internal fun isSafeZipPath(path: String): Boolean {
155
+ if (path.isEmpty()) return false
156
+ if (path.startsWith("/") || path.startsWith("\\")) return false
157
+ if (path.contains('\u0000')) return false
158
+ return path.split('/').none { it == ".." }
159
+ }
160
+
161
+ internal fun selectPackageAbi(lib: JSONObject, supportedAbis: Array<String>): String? {
162
+ for (abi in supportedAbis) {
163
+ if (lib.has(abi)) return abi
164
+ }
165
+ return null
166
+ }
167
+
168
+ // A patch.zip is "Dart-only" when the inner manifest carries no overlay
169
+ // asset list. Both the missing `assets` block and the explicit empty
170
+ // `files: []` form are treated as Dart-only so the runtime can skip the
171
+ // asset overlay copy/repack and behave like a code-only patch.
172
+ internal fun isDartOnlyAssets(assets: JSONObject?): Boolean {
173
+ if (assets == null) return true
174
+ val files = assets.optJSONArray("files") ?: return true
175
+ return files.length() == 0
176
+ }
177
+ }
178
+
179
+ private val patchDir = File(context.filesDir, PatcherConfig.PATCH_DIR)
180
+ private val patchFile = File(patchDir, PatcherConfig.PATCH_FILENAME)
181
+ private val assetsDir = File(patchDir, ASSET_DIR)
182
+ private val assetsArchive = File(patchDir, ASSET_ARCHIVE)
183
+ private val metaFile = File(patchDir, PatcherConfig.META_FILENAME)
184
+ private val installMarkerFile = File(patchDir, "installing")
185
+ private val stagingDir = File(patchDir, STAGING_DIR)
186
+ private val pendingSo = File(patchDir, "${PatcherConfig.PATCH_FILENAME}.pending")
187
+ private val pendingMeta = File(patchDir, "${PatcherConfig.META_FILENAME}.pending")
188
+ private val pendingAssets = File(patchDir, "$ASSET_DIR.pending")
189
+ private val pendingAssetsArchive = File(patchDir, "$ASSET_ARCHIVE.pending")
190
+ private val previousSo = File(patchDir, "${PatcherConfig.PATCH_FILENAME}.previous")
191
+ private val previousMeta = File(patchDir, "${PatcherConfig.META_FILENAME}.previous")
192
+ private val previousAssets = File(patchDir, "$ASSET_DIR.previous")
193
+ private val previousAssetsArchive = File(patchDir, "$ASSET_ARCHIVE.previous")
194
+
195
+ // Patch history directories
196
+ private val historyDir = File(patchDir, "history")
197
+ private val historyIndexFile = File(historyDir, "index.json")
198
+ private val assetsHistoryDir = File(patchDir, "assets")
199
+ private val baseAssetsArchive = File(assetsHistoryDir, "0/flutter_assets.apk")
200
+
201
+ fun getValidPatchPath(
202
+ onDrop: ((status: String, version: String?, extras: Map<String, Any?>) -> Unit)? = null
203
+ ): String? = getValidPatch(onDrop)?.soPath
204
+
205
+ fun getValidPatch(
206
+ onDrop: ((status: String, version: String?, extras: Map<String, Any?>) -> Unit)? = null
207
+ ): ValidPatch? {
208
+ if (installMarkerFile.exists()) {
209
+ Log.w(TAG, "previous patch install was interrupted, recover prepared artifacts")
210
+ recoverInterruptedInstall()
211
+ }
212
+ if (!patchFile.exists() || !metaFile.exists()) return null
213
+
214
+ val meta = readMeta()
215
+ if (meta == null) {
216
+ onDrop?.invoke(
217
+ BootDiagnosticStore.DROPPED_META_CORRUPTED,
218
+ null,
219
+ mapOf("message" to "meta.json missing or unparseable")
220
+ )
221
+ deletePatch()
222
+ return null
223
+ }
224
+
225
+ val version = meta.optString("version", "").ifEmpty { null }
226
+ val patchVc = meta.optLong(
227
+ PatcherConfig.META_KEY_TARGET_VERSION_CODE,
228
+ PatcherConfig.INVALID_VERSION_CODE
229
+ )
230
+ val currentVc = PatcherConfig.currentVersionCode(context)
231
+ if (patchVc == PatcherConfig.INVALID_VERSION_CODE ||
232
+ currentVc == PatcherConfig.INVALID_VERSION_CODE ||
233
+ patchVc != currentVc
234
+ ) {
235
+ onDrop?.invoke(
236
+ BootDiagnosticStore.DROPPED_VERSION_CODE_MISMATCH,
237
+ version,
238
+ mapOf(
239
+ "patchTargetVersionCode" to patchVc,
240
+ "appVersionCode" to currentVc,
241
+ "message" to "patch built for vc=$patchVc, app is vc=$currentVc"
242
+ )
243
+ )
244
+ deletePatch()
245
+ return null
246
+ }
247
+
248
+ val expectedMd5 = meta.optString("effectiveMd5", "")
249
+ val signature = meta.optString("signature", "")
250
+ val publicKey = PatcherConfig.publicKey(context)
251
+ val strictSignature = PatcherConfig.strictSignature(context)
252
+
253
+ if (expectedMd5.isEmpty()) {
254
+ onDrop?.invoke(
255
+ BootDiagnosticStore.DROPPED_META_CORRUPTED,
256
+ version,
257
+ mapOf("message" to "meta.effectiveMd5 missing")
258
+ )
259
+ deletePatch()
260
+ return null
261
+ }
262
+
263
+ val verifyResult = SignatureVerifier.verifyDetailed(
264
+ patchFile, expectedMd5, signature, publicKey, strictSignature
265
+ )
266
+ if (verifyResult != SignatureVerifier.VerifyResult.OK) {
267
+ val status = when (verifyResult) {
268
+ SignatureVerifier.VerifyResult.MD5_MISMATCH ->
269
+ BootDiagnosticStore.DROPPED_MD5_MISMATCH
270
+ SignatureVerifier.VerifyResult.SIGNATURE_INVALID ->
271
+ BootDiagnosticStore.DROPPED_SIGNATURE_INVALID
272
+ SignatureVerifier.VerifyResult.OK -> error("unreachable")
273
+ }
274
+ onDrop?.invoke(
275
+ status,
276
+ version,
277
+ mapOf(
278
+ "blacklistMd5" to meta.optString("downloadMd5", expectedMd5),
279
+ "message" to "SignatureVerifier returned $verifyResult",
280
+ )
281
+ )
282
+ deletePatch()
283
+ return null
284
+ }
285
+
286
+ val hasAssets = meta.optBoolean("hasAssets", false)
287
+ if (hasAssets && (!assetsDir.exists() || !assetsDir.isDirectory)) {
288
+ onDrop?.invoke(
289
+ BootDiagnosticStore.DROPPED_META_CORRUPTED,
290
+ version,
291
+ mapOf("message" to "patch meta requires assets but flutter_assets is missing")
292
+ )
293
+ deletePatch()
294
+ return null
295
+ }
296
+ if (hasAssets && !File(assetsDir, ASSET_MANIFEST).exists()) {
297
+ onDrop?.invoke(
298
+ BootDiagnosticStore.DROPPED_META_CORRUPTED,
299
+ version,
300
+ mapOf("message" to "patch flutter_assets missing AssetManifest.bin")
301
+ )
302
+ deletePatch()
303
+ return null
304
+ }
305
+ if (hasAssets && (!assetsArchive.exists() || !assetsArchive.isFile)) {
306
+ onDrop?.invoke(
307
+ BootDiagnosticStore.DROPPED_META_CORRUPTED,
308
+ version,
309
+ mapOf("message" to "patch asset archive missing")
310
+ )
311
+ deletePatch()
312
+ return null
313
+ }
314
+
315
+ if (!patchFile.canRead()) patchFile.setReadable(true, false)
316
+ if (hasAssets && !assetsArchive.canRead()) assetsArchive.setReadable(true, false)
317
+ return ValidPatch(
318
+ patchFile.absolutePath,
319
+ if (hasAssets) assetsDir.absolutePath else null,
320
+ if (hasAssets) assetsArchive.absolutePath else null,
321
+ if (hasAssets) PATCH_ASSET_BUNDLE else null
322
+ )
323
+ }
324
+
325
+ fun currentVersion(): String = readMeta()?.optString("version", "") ?: ""
326
+
327
+ fun currentMeta(): Pair<String, String>? {
328
+ val meta = readMeta() ?: return null
329
+ val version = meta.optString("version", "")
330
+ val md5 = meta.optString("downloadMd5", meta.optString("effectiveMd5", ""))
331
+ if (version.isEmpty() || md5.isEmpty()) return null
332
+ return version to md5
333
+ }
334
+
335
+ fun applyPatch(info: Map<String, Any?>): ApplyResult = synchronized(APPLY_LOCK) {
336
+ val version = (info["version"] as? String).orEmpty()
337
+ val url = (info["patchUrl"] as? String).orEmpty()
338
+ val md5 = (info["md5"] as? String).orEmpty()
339
+ val signature = (info["signature"] as? String).orEmpty()
340
+ val mode = ((info["mode"] as? String) ?: MODE_FULL).lowercase()
341
+ val hasTargetVersionCode = info.containsKey("targetVersionCode")
342
+ val serverTargetVc = (info["targetVersionCode"] as? Number)?.toLong()
343
+ if (hasTargetVersionCode && serverTargetVc == null) {
344
+ return ApplyResult.failure(
345
+ ApplyErrorCode.INVALID_ARGS,
346
+ "targetVersionCode must be a number"
347
+ )
348
+ }
349
+
350
+ val currentVc = PatcherConfig.currentVersionCode(context)
351
+ validatePatchArgs(
352
+ version = version,
353
+ url = url,
354
+ md5 = md5,
355
+ mode = mode,
356
+ targetVersionCode = serverTargetVc,
357
+ currentVersionCode = currentVc
358
+ )?.let { return it }
359
+ if (serverTargetVc == null && currentVc == PatcherConfig.INVALID_VERSION_CODE) {
360
+ return ApplyResult.failure(
361
+ ApplyErrorCode.IO_ERROR,
362
+ "cannot resolve current app versionCode"
363
+ )
364
+ }
365
+
366
+ val blacklistHit = if (md5.isBlank()) {
367
+ BlacklistStore.containsByVersion(context, version)
368
+ } else {
369
+ BlacklistStore.contains(context, version, md5)
370
+ }
371
+ if (blacklistHit) {
372
+ return ApplyResult.failure(
373
+ ApplyErrorCode.BLACKLISTED,
374
+ "patch (version=$version, md5=$md5) was previously blacklisted"
375
+ )
376
+ }
377
+ if (isSameInstalledPatch(currentMeta(), version, md5)) {
378
+ Log.d(TAG, "patch $version with md5=$md5 already installed")
379
+ return ApplyResult.SUCCESS
380
+ }
381
+
382
+ patchDir.mkdirs()
383
+ val downloaded = File(patchDir, "temp_download.bin")
384
+ var lastNetworkError: String? = null
385
+ var actualMd5: String? = null
386
+ // Set when the last failure was a content/MD5 mismatch rather than a
387
+ // transport error, so we can surface the more precise MD5_MISMATCH after
388
+ // exhausting retries instead of a generic NETWORK error.
389
+ var lastFailureWasMd5 = false
390
+
391
+ for (attempt in 1..MAX_RETRIES) {
392
+ try {
393
+ progress?.invoke(Phase.DOWNLOADING, 0L, -1L)
394
+ downloadTo(url, downloaded) { received, total ->
395
+ progress?.invoke(Phase.DOWNLOADING, received, total)
396
+ }
397
+ progress?.invoke(Phase.VERIFYING, 0L, 0L)
398
+ val verifiedMd5 = SignatureVerifier.md5(downloaded)
399
+ if (md5.isNotBlank()) {
400
+ if (!verifiedMd5.equals(md5, ignoreCase = true)) {
401
+ // A mismatch is almost always a corrupted/partial download
402
+ // (CDN blip, truncated body). Treat it as retryable: a fresh
403
+ // download usually yields the correct bytes. Only fail hard
404
+ // once retries are exhausted (handled after the loop).
405
+ Log.w(
406
+ TAG,
407
+ "attempt=$attempt md5 mismatch: expected=$md5 actual=$verifiedMd5"
408
+ )
409
+ lastNetworkError = "md5 mismatch: expected=$md5 actual=$verifiedMd5"
410
+ lastFailureWasMd5 = true
411
+ downloaded.delete()
412
+ stagingDir.deleteRecursively()
413
+ if (attempt < MAX_RETRIES) {
414
+ val backoff = 2000L * (1L shl (attempt - 1))
415
+ try {
416
+ Thread.sleep(backoff)
417
+ } catch (_: InterruptedException) {
418
+ return ApplyResult.failure(
419
+ ApplyErrorCode.NETWORK,
420
+ "interrupted during backoff"
421
+ )
422
+ }
423
+ }
424
+ continue
425
+ }
426
+ val publicKey = PatcherConfig.publicKey(context)
427
+ val strictSignature = PatcherConfig.strictSignature(context)
428
+ if (!SignatureVerifier.verifySignatureOnly(
429
+ verifiedMd5.lowercase(), signature, publicKey, strictSignature
430
+ )
431
+ ) {
432
+ // MD5 already matched, so the bytes are intact — a signature
433
+ // failure is deterministic and re-downloading won't help.
434
+ // Fail fast.
435
+ downloaded.delete()
436
+ return ApplyResult.failure(
437
+ ApplyErrorCode.SIGNATURE_INVALID,
438
+ "ed25519 signature verify failed"
439
+ )
440
+ }
441
+ } else {
442
+ Log.w(TAG, "expected md5 empty, skip md5 & signature verify")
443
+ }
444
+ actualMd5 = verifiedMd5.lowercase()
445
+ lastFailureWasMd5 = false
446
+ break
447
+ } catch (e: Exception) {
448
+ Log.w(TAG, "attempt=$attempt failed: ${e.message}", e)
449
+ lastNetworkError = e.message
450
+ lastFailureWasMd5 = false
451
+ downloaded.delete()
452
+ stagingDir.deleteRecursively()
453
+ if (attempt < MAX_RETRIES) {
454
+ val backoff = 2000L * (1L shl (attempt - 1))
455
+ try {
456
+ Thread.sleep(backoff)
457
+ } catch (_: InterruptedException) {
458
+ return ApplyResult.failure(
459
+ ApplyErrorCode.NETWORK,
460
+ "interrupted during backoff"
461
+ )
462
+ }
463
+ }
464
+ }
465
+ }
466
+
467
+ val verifiedMd5 = actualMd5 ?: return if (lastFailureWasMd5) {
468
+ ApplyResult.failure(
469
+ ApplyErrorCode.MD5_MISMATCH,
470
+ "download corrupted after $MAX_RETRIES attempts: $lastNetworkError"
471
+ )
472
+ } else {
473
+ ApplyResult.failure(
474
+ ApplyErrorCode.NETWORK,
475
+ "download failed after $MAX_RETRIES attempts: $lastNetworkError"
476
+ )
477
+ }
478
+
479
+ progress?.invoke(Phase.FINALIZING, 0L, 0L)
480
+ val targetVersionCode = serverTargetVc ?: currentVc
481
+ val result = try {
482
+ if (isZipPayload(downloaded)) {
483
+ installPackagePatch(
484
+ payload = downloaded,
485
+ version = version,
486
+ downloadMd5 = md5,
487
+ effectiveMd5 = verifiedMd5,
488
+ signature = signature,
489
+ targetVersionCode = targetVersionCode,
490
+ )
491
+ } else {
492
+ installLegacyPatch(
493
+ downloaded = downloaded,
494
+ version = version,
495
+ downloadMd5 = md5,
496
+ effectiveMd5 = verifiedMd5,
497
+ signature = signature,
498
+ targetVersionCode = targetVersionCode,
499
+ )
500
+ }
501
+ } catch (e: IOException) {
502
+ ApplyResult.failure(ApplyErrorCode.IO_ERROR, e.message ?: e.javaClass.simpleName)
503
+ } catch (e: Exception) {
504
+ ApplyResult.failure(ApplyErrorCode.UNKNOWN, e.message ?: e.javaClass.simpleName)
505
+ }
506
+ downloaded.delete()
507
+ stagingDir.deleteRecursively()
508
+ if (result != null) return result
509
+
510
+ CrashGuard(context).reset()
511
+ Log.d(TAG, "patch $version ready, takes effect on next cold start")
512
+ return ApplyResult.SUCCESS
513
+ }
514
+
515
+ // ========== Patch History Management ==========
516
+
517
+ private fun ensureHistoryDirs() {
518
+ historyDir.mkdirs()
519
+ assetsHistoryDir.mkdirs()
520
+ }
521
+
522
+ private fun readHistoryIndex(): List<HistoryEntry> {
523
+ if (!historyIndexFile.exists()) return emptyList()
524
+ return try {
525
+ val json = JSONObject(historyIndexFile.readText())
526
+ val arr = json.optJSONArray("history") ?: return emptyList()
527
+ (0 until arr.length()).mapNotNull { i ->
528
+ val obj = arr.optJSONObject(i) ?: return@mapNotNull null
529
+ HistoryEntry(
530
+ version = obj.optString("version", ""),
531
+ md5 = obj.optString("md5", ""),
532
+ path = obj.optString("path", ""),
533
+ assetsRef = obj.optInt("assetsRef", 0),
534
+ installedAt = obj.optLong("installedAt", 0)
535
+ ).takeIf { it.version.isNotEmpty() && it.md5.isNotEmpty() && it.path.isNotEmpty() }
536
+ }
537
+ } catch (e: Exception) {
538
+ Log.w(TAG, "failed to read history index", e)
539
+ emptyList()
540
+ }
541
+ }
542
+
543
+ private fun writeHistoryIndex(entries: List<HistoryEntry>) {
544
+ ensureHistoryDirs()
545
+ val json = JSONObject().put("history", JSONArray().apply {
546
+ entries.forEach { entry ->
547
+ put(JSONObject().apply {
548
+ put("version", entry.version)
549
+ put("md5", entry.md5)
550
+ put("path", entry.path)
551
+ put("assetsRef", entry.assetsRef)
552
+ put("installedAt", entry.installedAt)
553
+ })
554
+ }
555
+ })
556
+ writeTextSync(historyIndexFile, json.toString())
557
+ }
558
+
559
+ private fun archiveCurrentPatchToHistory(meta: JSONObject): Int {
560
+ ensureHistoryDirs()
561
+
562
+ val history = readHistoryIndex()
563
+ val maxHistory = PatcherConfig.maxPatchHistory(context)
564
+
565
+ // Determine next history slot (1-based, newest at front)
566
+ val nextSlot = if (history.isEmpty()) 1 else history.first().path.split("/").last().toIntOrNull()?.plus(1) ?: history.size + 1
567
+ val historyPath = "history/$nextSlot"
568
+ val historyEntryDir = File(patchDir, historyPath)
569
+ historyEntryDir.mkdirs()
570
+
571
+ // Move current patch to history
572
+ val currentVersion = meta.optString("version", "")
573
+ val currentMd5 = meta.optString("effectiveMd5", meta.optString("downloadMd5", ""))
574
+ val currentAssetsRef = meta.optInt("assetsRef", 0)
575
+
576
+ if (patchFile.exists()) {
577
+ patchFile.renameTo(File(historyEntryDir, "libapp_patch.so"))
578
+ }
579
+ if (metaFile.exists()) {
580
+ metaFile.renameTo(File(historyEntryDir, "patch_meta.json"))
581
+ }
582
+ if (assetsDir.exists()) {
583
+ assetsDir.renameTo(File(historyEntryDir, ASSET_DIR))
584
+ }
585
+ if (assetsArchive.exists()) {
586
+ assetsArchive.renameTo(File(historyEntryDir, ASSET_ARCHIVE))
587
+ }
588
+
589
+ // Add to history index (newest first)
590
+ val newEntry = HistoryEntry(
591
+ version = currentVersion,
592
+ md5 = currentMd5,
593
+ path = historyPath,
594
+ assetsRef = currentAssetsRef,
595
+ installedAt = meta.optLong("installed_at", System.currentTimeMillis())
596
+ )
597
+ val updatedHistory = (listOf(newEntry) + history).take(maxHistory)
598
+ writeHistoryIndex(updatedHistory)
599
+
600
+ // Prune old history directories
601
+ pruneOldHistoryDirectories(updatedHistory)
602
+
603
+ return newEntry.assetsRef
604
+ }
605
+
606
+ private fun pruneOldHistoryDirectories(validEntries: List<HistoryEntry>) {
607
+ val validPaths = validEntries.map { it.path }.toSet()
608
+ historyDir.listFiles()?.forEach { dir ->
609
+ if (dir.isDirectory && !validPaths.contains(dir.name)) {
610
+ dir.deleteRecursively()
611
+ }
612
+ }
613
+ }
614
+
615
+ private fun extractBaseAssetsIfNeeded(): Int {
616
+ // Extract base APK assets to assets/0/ if not already done
617
+ if (!baseAssetsArchive.exists()) {
618
+ baseAssetsArchive.parentFile?.mkdirs()
619
+ try {
620
+ copyInstalledFlutterAssets(File(assetsHistoryDir, "0"))
621
+ writeFlutterAssetsArchive(File(assetsHistoryDir, "0"), baseAssetsArchive)
622
+ } catch (e: Exception) {
623
+ Log.w(TAG, "failed to extract base assets", e)
624
+ }
625
+ }
626
+ return 0
627
+ }
628
+
629
+ private fun assetsChanged(newAssetsDir: File): Boolean {
630
+ if (!assetsDir.exists()) return true
631
+ if (!newAssetsDir.exists()) return false
632
+ try {
633
+ // Compare AssetManifest.bin to detect asset changes
634
+ val oldManifest = File(assetsDir, ASSET_MANIFEST)
635
+ val newManifest = File(newAssetsDir, ASSET_MANIFEST)
636
+ if (!oldManifest.exists() || !newManifest.exists()) return true
637
+ return !filesContentEqual(oldManifest, newManifest)
638
+ } catch (e: Exception) {
639
+ return true
640
+ }
641
+ }
642
+
643
+ private fun filesContentEqual(f1: File, f2: File): Boolean {
644
+ if (f1.length() != f2.length()) return false
645
+ f1.inputStream().use { in1 ->
646
+ f2.inputStream().use { in2 ->
647
+ val buf1 = ByteArray(8192)
648
+ val buf2 = ByteArray(8192)
649
+ while (true) {
650
+ val n1 = in1.read(buf1)
651
+ val n2 = in2.read(buf2)
652
+ if (n1 != n2) return false
653
+ if (n1 == -1) return true
654
+ // Manual comparison since ByteArray.contentEquals doesn't support offset/length
655
+ var i = 0
656
+ while (i < n1) {
657
+ if (buf1[i] != buf2[i]) return false
658
+ i++
659
+ }
660
+ }
661
+ }
662
+ }
663
+ }
664
+
665
+ private fun saveNewAssetsArchive(newAssetsDir: File, nextAssetsRef: Int): File {
666
+ val assetsArchiveDir = File(assetsHistoryDir, nextAssetsRef.toString())
667
+ assetsArchiveDir.mkdirs()
668
+ val newArchive = File(assetsArchiveDir, ASSET_ARCHIVE)
669
+ writeFlutterAssetsArchive(newAssetsDir, newArchive)
670
+ return newArchive
671
+ }
672
+
673
+ fun rollbackToPrevious(): RollbackResult {
674
+ val history = readHistoryIndex()
675
+ if (history.isEmpty()) {
676
+ Log.i(TAG, "no history available, falling back to base APK")
677
+ deletePatch()
678
+ CrashGuard(context).reset()
679
+ return RollbackResult.FALLBACK_TO_BASE
680
+ }
681
+
682
+ for (entry in history) {
683
+ // Skip blacklisted entries
684
+ if (BlacklistStore.contains(context, entry.version, entry.md5)) {
685
+ Log.w(TAG, "skipping blacklisted history entry: ${entry.version}")
686
+ continue
687
+ }
688
+
689
+ val entryDir = File(patchDir, entry.path)
690
+ val entrySo = File(entryDir, "libapp_patch.so")
691
+ val entryMeta = File(entryDir, "patch_meta.json")
692
+ val entryAssets = File(entryDir, ASSET_DIR)
693
+ val entryAssetsArchive = File(entryDir, ASSET_ARCHIVE)
694
+
695
+ if (!entrySo.exists() || !entryMeta.exists()) {
696
+ Log.w(TAG, "history entry incomplete, skipping: ${entry.version}")
697
+ continue
698
+ }
699
+
700
+ // Restore this entry as active patch
701
+ if (patchFile.exists()) patchFile.delete()
702
+ if (metaFile.exists()) metaFile.delete()
703
+ if (assetsDir.exists()) assetsDir.deleteRecursively()
704
+ if (assetsArchive.exists()) assetsArchive.delete()
705
+
706
+ entrySo.renameTo(patchFile)
707
+ entryMeta.renameTo(metaFile)
708
+
709
+ val assetsArchiveRef = File(assetsHistoryDir, "${entry.assetsRef}/$ASSET_ARCHIVE")
710
+ if (assetsArchiveRef.exists()) {
711
+ assetsArchiveRef.copyTo(assetsArchive, overwrite = true)
712
+ }
713
+ if (entryAssets.exists()) {
714
+ entryAssets.renameTo(assetsDir)
715
+ } else if (assetsArchive.exists()) {
716
+ // Extract assets from archive if directory missing
717
+ try {
718
+ ZipFile(assetsArchive).use { zip ->
719
+ assetsDir.mkdirs()
720
+ val entries = zip.entries()
721
+ while (entries.hasMoreElements()) {
722
+ val ze = entries.nextElement()
723
+ if (!ze.isDirectory && ze.name.startsWith(PATCH_ASSETS_PREFIX)) {
724
+ val relative = ze.name.removePrefix(PATCH_ASSETS_PREFIX)
725
+ if (isSafeZipPath(relative)) {
726
+ extractZipEntry(zip, ze, File(assetsDir, relative))
727
+ }
728
+ }
729
+ }
730
+ }
731
+ } catch (e: Exception) {
732
+ Log.w(TAG, "failed to extract assets from archive", e)
733
+ }
734
+ }
735
+
736
+ // Remove from history
737
+ val updatedHistory = history.filterNot { it.path == entry.path }
738
+ writeHistoryIndex(updatedHistory)
739
+ entryDir.deleteRecursively()
740
+
741
+ // Prune unused asset archives
742
+ pruneUnusedAssetArchives(updatedHistory)
743
+
744
+ CrashGuard(context).reset()
745
+ Log.i(TAG, "rolled back to previous patch: ${entry.version}")
746
+ return RollbackResult.SUCCESS
747
+ }
748
+
749
+ // All history entries blacklisted
750
+ Log.i(TAG, "all history entries blacklisted, falling back to base APK")
751
+ deletePatch()
752
+ CrashGuard(context).reset()
753
+ return RollbackResult.FALLBACK_TO_BASE
754
+ }
755
+
756
+ private fun pruneUnusedAssetArchives(validEntries: List<HistoryEntry>) {
757
+ val usedRefs = validEntries.map { it.assetsRef }.toSet()
758
+ assetsHistoryDir.listFiles()?.forEach { dir ->
759
+ if (dir.isDirectory && dir.name != "0") {
760
+ val ref = dir.name.toIntOrNull()
761
+ if (ref != null && ref !in usedRefs) {
762
+ dir.deleteRecursively()
763
+ }
764
+ }
765
+ }
766
+ }
767
+
768
+ fun rollback() {
769
+ deletePatch()
770
+ CrashGuard(context).reset()
771
+ Log.d(TAG, "rolled back to built-in version")
772
+ }
773
+
774
+ private fun installLegacyPatch(
775
+ downloaded: File,
776
+ version: String,
777
+ downloadMd5: String,
778
+ effectiveMd5: String,
779
+ signature: String,
780
+ targetVersionCode: Long,
781
+ ): ApplyResult? {
782
+ val meta = JSONObject().apply {
783
+ put("version", version)
784
+ put("downloadMd5", downloadMd5)
785
+ put("effectiveMd5", effectiveMd5)
786
+ put("signature", signature)
787
+ put(PatcherConfig.META_KEY_TARGET_VERSION_CODE, targetVersionCode)
788
+ put("hasAssets", false)
789
+ put("installed_at", System.currentTimeMillis())
790
+ }
791
+ return finalizePatch(downloaded, null, null, meta)
792
+ }
793
+
794
+ private fun installPackagePatch(
795
+ payload: File,
796
+ version: String,
797
+ downloadMd5: String,
798
+ effectiveMd5: String,
799
+ signature: String,
800
+ targetVersionCode: Long,
801
+ ): ApplyResult? {
802
+ return try {
803
+ ZipFile(payload).use { zip ->
804
+ val packageManifest = readZipJson(zip, "manifest.json")
805
+ ?: return ApplyResult.failure(
806
+ ApplyErrorCode.ASSET_PACKAGE_INVALID,
807
+ "patch.zip missing manifest.json"
808
+ )
809
+ if (packageManifest.optInt("schemaVersion", -1) != 2) {
810
+ return ApplyResult.failure(
811
+ ApplyErrorCode.ASSET_PACKAGE_INVALID,
812
+ "unsupported package schemaVersion=${packageManifest.opt("schemaVersion")}"
813
+ )
814
+ }
815
+ val packageTargetVc = packageManifest.optLong(
816
+ "targetVersionCode",
817
+ PatcherConfig.INVALID_VERSION_CODE
818
+ )
819
+ if (packageTargetVc != targetVersionCode) {
820
+ return ApplyResult.failure(
821
+ ApplyErrorCode.INVALID_ARGS,
822
+ "package targetVersionCode=$packageTargetVc does not match current=$targetVersionCode"
823
+ )
824
+ }
825
+
826
+ val lib = packageManifest.optJSONObject("lib")
827
+ ?: return ApplyResult.failure(
828
+ ApplyErrorCode.ASSET_PACKAGE_INVALID,
829
+ "patch.zip manifest missing lib map"
830
+ )
831
+ val abi = selectPackageAbi(lib, Build.SUPPORTED_ABIS)
832
+ ?: return ApplyResult.failure(
833
+ ApplyErrorCode.UNSUPPORTED_ABI,
834
+ "no libapp.so for device ABI ${Build.SUPPORTED_ABIS.joinToString(",")}"
835
+ )
836
+ val libInfo = lib.optJSONObject(abi)
837
+ ?: return ApplyResult.failure(
838
+ ApplyErrorCode.UNSUPPORTED_ABI,
839
+ "lib info missing for $abi"
840
+ )
841
+ val libPath = libInfo.optString("path")
842
+ if (!isSafeZipPath(libPath)) {
843
+ return ApplyResult.failure(ApplyErrorCode.ASSET_PACKAGE_INVALID, "bad lib path")
844
+ }
845
+ val libEntry = zip.getEntry(libPath)
846
+ ?: return ApplyResult.failure(
847
+ ApplyErrorCode.ASSET_PACKAGE_INVALID,
848
+ "missing $libPath"
849
+ )
850
+ val stagedSo = File(stagingDir, "libapp_patch.so")
851
+ resetStaging()
852
+ extractZipEntry(zip, libEntry, stagedSo)
853
+ val installedLibMd5 = SignatureVerifier.md5(stagedSo)
854
+ val expectedLibMd5 = libInfo.optString("md5")
855
+ if (expectedLibMd5.isNotEmpty() &&
856
+ !installedLibMd5.equals(expectedLibMd5, ignoreCase = true)
857
+ ) {
858
+ return ApplyResult.failure(
859
+ ApplyErrorCode.MD5_MISMATCH,
860
+ "lib md5 mismatch for $libPath"
861
+ )
862
+ }
863
+
864
+ val assets = packageManifest.optJSONObject("assets")
865
+ if (isDartOnlyAssets(assets)) {
866
+ val meta = JSONObject().apply {
867
+ put("version", version)
868
+ put("downloadMd5", downloadMd5)
869
+ put("effectiveMd5", installedLibMd5)
870
+ put("signature", "")
871
+ put("payloadMd5", effectiveMd5)
872
+ put("payloadSignature", signature)
873
+ put(PatcherConfig.META_KEY_TARGET_VERSION_CODE, targetVersionCode)
874
+ put("hasAssets", false)
875
+ put("installed_at", System.currentTimeMillis())
876
+ }
877
+ return finalizePatch(stagedSo, null, null, meta)
878
+ }
879
+ if (assets!!.optString("mode") != "overlay") {
880
+ return ApplyResult.failure(
881
+ ApplyErrorCode.ASSET_PACKAGE_INVALID,
882
+ "unsupported assets.mode=${assets.optString("mode")}"
883
+ )
884
+ }
885
+
886
+ val stagingAssets = File(stagingDir, ASSET_DIR)
887
+ val installedAssetBytes = installedFlutterAssetsSize()
888
+ val requiredBytes = installedAssetBytes + payload.length() + MIN_FREE_SPACE_BUFFER
889
+ if (patchDir.usableSpace < requiredBytes) {
890
+ return ApplyResult.failure(
891
+ ApplyErrorCode.IO_ERROR,
892
+ "not enough free space for asset patch"
893
+ )
894
+ }
895
+
896
+ copyInstalledFlutterAssets(stagingAssets)
897
+ overlayPatchAssets(zip, assets, stagingAssets)
898
+ applyManifestPatch(zip, assets, stagingAssets)
899
+ verifyOverlayFiles(assets, stagingAssets)?.let { return it }
900
+ val stagedAssetsArchive = File(stagingDir, ASSET_ARCHIVE)
901
+ writeFlutterAssetsArchive(stagingAssets, stagedAssetsArchive)
902
+
903
+ val meta = JSONObject().apply {
904
+ put("version", version)
905
+ put("downloadMd5", downloadMd5)
906
+ put("effectiveMd5", installedLibMd5)
907
+ put("signature", "")
908
+ put("payloadMd5", effectiveMd5)
909
+ put("payloadSignature", signature)
910
+ put(PatcherConfig.META_KEY_TARGET_VERSION_CODE, targetVersionCode)
911
+ put("hasAssets", true)
912
+ put("assetMode", "overlay")
913
+ put("installed_at", System.currentTimeMillis())
914
+ }
915
+ return finalizePatch(stagedSo, stagingAssets, stagedAssetsArchive, meta)
916
+ }
917
+ } catch (e: PatchInstallException) {
918
+ ApplyResult.failure(e.code, e.message)
919
+ } catch (e: ZipException) {
920
+ ApplyResult.failure(
921
+ ApplyErrorCode.ASSET_PACKAGE_INVALID,
922
+ e.message ?: "invalid patch.zip"
923
+ )
924
+ } catch (e: JSONException) {
925
+ ApplyResult.failure(
926
+ ApplyErrorCode.ASSET_PACKAGE_INVALID,
927
+ e.message ?: "invalid patch package json"
928
+ )
929
+ } catch (e: IOException) {
930
+ ApplyResult.failure(
931
+ ApplyErrorCode.IO_ERROR,
932
+ e.message ?: e.javaClass.simpleName
933
+ )
934
+ } catch (e: ClassCastException) {
935
+ ApplyResult.failure(
936
+ ApplyErrorCode.ASSET_PACKAGE_INVALID,
937
+ e.message ?: "invalid manifest patch type"
938
+ )
939
+ } catch (e: IllegalArgumentException) {
940
+ ApplyResult.failure(
941
+ ApplyErrorCode.ASSET_PACKAGE_INVALID,
942
+ e.message ?: "invalid patch package"
943
+ )
944
+ }
945
+ }
946
+
947
+ private fun resetStaging() {
948
+ stagingDir.deleteRecursively()
949
+ stagingDir.mkdirs()
950
+ }
951
+
952
+ private fun finalizePatch(
953
+ finalSo: File,
954
+ finalAssets: File?,
955
+ finalAssetsArchive: File?,
956
+ meta: JSONObject
957
+ ): ApplyResult? {
958
+ var committed = false
959
+ var backedSo = false
960
+ var backedMeta = false
961
+ var backedAssets = false
962
+ var backedAssetsArchive = false
963
+ var promotedSo = false
964
+ var promotedMeta = false
965
+ var promotedAssets = false
966
+ var promotedAssetsArchive = false
967
+ return try {
968
+ cleanupPreparedArtifacts(includePrevious = true)
969
+ installMarkerFile.delete()
970
+
971
+ // Archive current patch to history BEFORE committing new one
972
+ var assetsRef = 0
973
+ if (patchFile.exists() && metaFile.exists()) {
974
+ // Read current meta to get its assetsRef
975
+ val currentMeta = readMeta()
976
+ val currentAssetsRef = currentMeta?.optInt("assetsRef", 0) ?: 0
977
+
978
+ // Check if new assets are different from current
979
+ val newAssetsChanged = finalAssets != null && assetsChanged(finalAssets)
980
+
981
+ if (newAssetsChanged) {
982
+ // Save new assets to history and get new ref
983
+ val history = readHistoryIndex()
984
+ val nextAssetsRef = if (history.isEmpty()) 1 else (history.map { it.assetsRef }.maxOrNull() ?: 0) + 1
985
+ val maxAssetHistory = PatcherConfig.maxAssetHistory(context)
986
+ if (nextAssetsRef <= maxAssetHistory) {
987
+ saveNewAssetsArchive(finalAssets, nextAssetsRef)
988
+ assetsRef = nextAssetsRef
989
+ } else {
990
+ assetsRef = currentAssetsRef // reuse if at limit
991
+ }
992
+ } else {
993
+ // Reuse current assets reference
994
+ assetsRef = currentAssetsRef
995
+ }
996
+
997
+ // Archive current patch to history
998
+ meta.put("assetsRef", assetsRef)
999
+ archiveCurrentPatchToHistory(meta)
1000
+ } else {
1001
+ // First patch ever - extract base assets
1002
+ assetsRef = extractBaseAssetsIfNeeded()
1003
+ meta.put("assetsRef", assetsRef)
1004
+ }
1005
+
1006
+ if (!finalSo.renameTo(pendingSo)) {
1007
+ copyFile(finalSo, pendingSo)
1008
+ finalSo.delete()
1009
+ }
1010
+ if (finalAssets != null) {
1011
+ if (!finalAssets.renameTo(pendingAssets)) {
1012
+ copyDirectory(finalAssets, pendingAssets)
1013
+ finalAssets.deleteRecursively()
1014
+ }
1015
+ }
1016
+ if (finalAssetsArchive != null) {
1017
+ if (!finalAssetsArchive.renameTo(pendingAssetsArchive)) {
1018
+ copyFile(finalAssetsArchive, pendingAssetsArchive)
1019
+ finalAssetsArchive.delete()
1020
+ }
1021
+ }
1022
+
1023
+ writeTextSync(pendingMeta, meta.toString())
1024
+ writeTextSync(installMarkerFile, "installing")
1025
+
1026
+ // No need to backup to .previous anymore since we archived to history
1027
+ // Just clean up any existing .previous files
1028
+ previousSo.delete()
1029
+ previousMeta.delete()
1030
+ previousAssets.deleteRecursively()
1031
+ previousAssetsArchive.delete()
1032
+
1033
+ if (!pendingSo.renameTo(patchFile)) {
1034
+ throw PatchInstallException(
1035
+ ApplyErrorCode.IO_ERROR,
1036
+ "rename to ${patchFile.absolutePath} failed"
1037
+ )
1038
+ }
1039
+ promotedSo = true
1040
+
1041
+ if (finalAssets != null) {
1042
+ if (!pendingAssets.renameTo(assetsDir)) {
1043
+ throw PatchInstallException(
1044
+ ApplyErrorCode.IO_ERROR,
1045
+ "rename to ${assetsDir.absolutePath} failed"
1046
+ )
1047
+ }
1048
+ promotedAssets = true
1049
+ }
1050
+ if (finalAssetsArchive != null) {
1051
+ if (!pendingAssetsArchive.renameTo(assetsArchive)) {
1052
+ throw PatchInstallException(
1053
+ ApplyErrorCode.IO_ERROR,
1054
+ "rename to ${assetsArchive.absolutePath} failed"
1055
+ )
1056
+ }
1057
+ promotedAssetsArchive = true
1058
+ }
1059
+
1060
+ if (!pendingMeta.renameTo(metaFile)) {
1061
+ throw PatchInstallException(
1062
+ ApplyErrorCode.IO_ERROR,
1063
+ "rename to ${metaFile.absolutePath} failed"
1064
+ )
1065
+ }
1066
+ promotedMeta = true
1067
+
1068
+ committed = true
1069
+ cleanupPreparedArtifacts(includePrevious = true)
1070
+ null
1071
+ } catch (e: PatchInstallException) {
1072
+ Log.e(TAG, "finalize patch failed", e)
1073
+ rollbackPreparedCommit(
1074
+ promotedSo = promotedSo,
1075
+ promotedMeta = promotedMeta,
1076
+ promotedAssets = promotedAssets,
1077
+ promotedAssetsArchive = promotedAssetsArchive,
1078
+ backedSo = backedSo,
1079
+ backedMeta = backedMeta,
1080
+ backedAssets = backedAssets,
1081
+ backedAssetsArchive = backedAssetsArchive,
1082
+ )
1083
+ ApplyResult.failure(e.code, e.message)
1084
+ } catch (e: Exception) {
1085
+ Log.e(TAG, "finalize patch failed", e)
1086
+ rollbackPreparedCommit(
1087
+ promotedSo = promotedSo,
1088
+ promotedMeta = promotedMeta,
1089
+ promotedAssets = promotedAssets,
1090
+ promotedAssetsArchive = promotedAssetsArchive,
1091
+ backedSo = backedSo,
1092
+ backedMeta = backedMeta,
1093
+ backedAssets = backedAssets,
1094
+ backedAssetsArchive = backedAssetsArchive,
1095
+ )
1096
+ ApplyResult.failure(ApplyErrorCode.IO_ERROR, e.message ?: e.javaClass.simpleName)
1097
+ } finally {
1098
+ cleanupPreparedArtifacts(includePrevious = committed)
1099
+ stagingDir.deleteRecursively()
1100
+ installMarkerFile.delete()
1101
+ }
1102
+ }
1103
+
1104
+ private fun readZipJson(zip: ZipFile, path: String): JSONObject? {
1105
+ val entry = zip.getEntry(path) ?: return null
1106
+ return JSONObject(zip.getInputStream(entry).bufferedReader(Charsets.UTF_8).readText())
1107
+ }
1108
+
1109
+ private fun extractZipEntry(zip: ZipFile, entry: java.util.zip.ZipEntry, dest: File) {
1110
+ if (!isSafeZipPath(entry.name)) {
1111
+ throw PatchInstallException(
1112
+ ApplyErrorCode.ASSET_PACKAGE_INVALID,
1113
+ "unsafe zip path: ${entry.name}"
1114
+ )
1115
+ }
1116
+ dest.parentFile?.mkdirs()
1117
+ zip.getInputStream(entry).use { input ->
1118
+ FileOutputStream(dest).use { output ->
1119
+ input.copyTo(output)
1120
+ output.fd.sync()
1121
+ }
1122
+ }
1123
+ }
1124
+
1125
+ private fun copyInstalledFlutterAssets(dest: File) {
1126
+ try {
1127
+ dest.deleteRecursively()
1128
+ dest.mkdirs()
1129
+ for (apkPath in installedApkPaths()) {
1130
+ ZipFile(apkPath).use { zip ->
1131
+ val entries = zip.entries()
1132
+ while (entries.hasMoreElements()) {
1133
+ val entry = entries.nextElement()
1134
+ if (entry.isDirectory) continue
1135
+ if (!entry.name.startsWith(FLUTTER_ASSETS_PREFIX)) continue
1136
+ val relative = entry.name.removePrefix(FLUTTER_ASSETS_PREFIX)
1137
+ if (!isSafeZipPath(relative)) continue
1138
+ extractZipEntry(zip, entry, File(dest, relative))
1139
+ }
1140
+ }
1141
+ }
1142
+ } catch (e: IOException) {
1143
+ throw PatchInstallException(
1144
+ ApplyErrorCode.IO_ERROR,
1145
+ e.message ?: "copy installed flutter_assets failed",
1146
+ e
1147
+ )
1148
+ }
1149
+ }
1150
+
1151
+ private fun installedFlutterAssetsSize(): Long {
1152
+ try {
1153
+ var total = 0L
1154
+ for (apkPath in installedApkPaths()) {
1155
+ ZipFile(apkPath).use { zip ->
1156
+ val entries = zip.entries()
1157
+ while (entries.hasMoreElements()) {
1158
+ val entry = entries.nextElement()
1159
+ if (!entry.isDirectory && entry.name.startsWith(FLUTTER_ASSETS_PREFIX)) {
1160
+ total += entry.size.coerceAtLeast(0L)
1161
+ }
1162
+ }
1163
+ }
1164
+ }
1165
+ return total
1166
+ } catch (e: IOException) {
1167
+ throw PatchInstallException(
1168
+ ApplyErrorCode.IO_ERROR,
1169
+ e.message ?: "scan installed flutter_assets failed",
1170
+ e
1171
+ )
1172
+ }
1173
+ }
1174
+
1175
+ private fun installedApkPaths(): List<String> {
1176
+ val info = context.applicationInfo
1177
+ val paths = mutableListOf<String>()
1178
+ paths.add(info.sourceDir)
1179
+ info.splitSourceDirs?.forEach { paths.add(it) }
1180
+ return paths.filter { it.isNotBlank() }
1181
+ }
1182
+
1183
+ private fun overlayPatchAssets(zip: ZipFile, assets: JSONObject, stagingAssets: File) {
1184
+ val prefix = assets.optString("prefix", "assets/")
1185
+ if (!isSafeZipPath(prefix.trimEnd('/'))) {
1186
+ throw PatchInstallException(
1187
+ ApplyErrorCode.ASSET_PACKAGE_INVALID,
1188
+ "unsafe assets prefix: $prefix"
1189
+ )
1190
+ }
1191
+ val files = assets.optJSONArray("files") ?: JSONArray()
1192
+ for (i in 0 until files.length()) {
1193
+ val file = files.getJSONObject(i)
1194
+ val path = file.optString("path")
1195
+ if (!isSafeZipPath(path)) {
1196
+ throw PatchInstallException(
1197
+ ApplyErrorCode.ASSET_PACKAGE_INVALID,
1198
+ "unsafe asset path: $path"
1199
+ )
1200
+ }
1201
+ val entryName = "$prefix$path"
1202
+ val entry = zip.getEntry(entryName)
1203
+ ?: throw PatchInstallException(
1204
+ ApplyErrorCode.ASSET_PACKAGE_INVALID,
1205
+ "missing asset entry: $entryName"
1206
+ )
1207
+ extractZipEntry(zip, entry, File(stagingAssets, path))
1208
+ }
1209
+ }
1210
+
1211
+ private fun verifyOverlayFiles(assets: JSONObject, stagingAssets: File): ApplyResult? {
1212
+ val files = assets.optJSONArray("files") ?: JSONArray()
1213
+ for (i in 0 until files.length()) {
1214
+ val file = files.getJSONObject(i)
1215
+ val path = file.optString("path")
1216
+ val expectedMd5 = file.optString("md5")
1217
+ if (expectedMd5.isEmpty()) continue
1218
+ val actualFile = File(stagingAssets, path)
1219
+ if (!actualFile.exists()) {
1220
+ return ApplyResult.failure(
1221
+ ApplyErrorCode.ASSET_PACKAGE_INVALID,
1222
+ "asset missing after overlay: $path"
1223
+ )
1224
+ }
1225
+ val actualMd5 = SignatureVerifier.md5(actualFile)
1226
+ if (!actualMd5.equals(expectedMd5, ignoreCase = true)) {
1227
+ return ApplyResult.failure(
1228
+ ApplyErrorCode.MD5_MISMATCH,
1229
+ "asset md5 mismatch for $path"
1230
+ )
1231
+ }
1232
+ }
1233
+ return null
1234
+ }
1235
+
1236
+ private fun applyManifestPatch(zip: ZipFile, assets: JSONObject, stagingAssets: File) {
1237
+ val patchPath = assets.optString("manifestPatch", "manifest_patch.json")
1238
+ if (!isSafeZipPath(patchPath)) {
1239
+ throw PatchInstallException(
1240
+ ApplyErrorCode.ASSET_PACKAGE_INVALID,
1241
+ "unsafe manifest patch path"
1242
+ )
1243
+ }
1244
+ val patch = readZipJson(zip, patchPath)
1245
+ ?: throw PatchInstallException(
1246
+ ApplyErrorCode.ASSET_PACKAGE_INVALID,
1247
+ "missing manifest patch: $patchPath"
1248
+ )
1249
+ if (patch.optInt("schemaVersion", -1) != 1) {
1250
+ throw PatchInstallException(
1251
+ ApplyErrorCode.ASSET_PACKAGE_INVALID,
1252
+ "unsupported manifest_patch schemaVersion"
1253
+ )
1254
+ }
1255
+ if (patch.optString("manifestFormat") != "bin") {
1256
+ throw PatchInstallException(
1257
+ ApplyErrorCode.ASSET_PACKAGE_INVALID,
1258
+ "unsupported manifest format"
1259
+ )
1260
+ }
1261
+
1262
+ val manifestFile = File(stagingAssets, ASSET_MANIFEST)
1263
+ if (!manifestFile.exists()) {
1264
+ throw PatchInstallException(
1265
+ ApplyErrorCode.ASSET_PACKAGE_INVALID,
1266
+ "AssetManifest.bin missing"
1267
+ )
1268
+ }
1269
+ val expectedSize = patch.optLong("baseManifestSize", -1)
1270
+ if (expectedSize >= 0 && expectedSize != manifestFile.length()) {
1271
+ Log.w(TAG, "baseManifestSize mismatch: patch=$expectedSize actual=${manifestFile.length()}")
1272
+ }
1273
+
1274
+ val decoded = StandardMessageCodec.INSTANCE.decodeMessage(
1275
+ ByteBuffer.wrap(manifestFile.readBytes())
1276
+ )
1277
+ @Suppress("UNCHECKED_CAST")
1278
+ val manifest = LinkedHashMap<String, Any?>(
1279
+ decoded as? Map<String, Any?> ?: throw PatchInstallException(
1280
+ ApplyErrorCode.ASSET_PACKAGE_INVALID,
1281
+ "AssetManifest.bin is not a map"
1282
+ )
1283
+ )
1284
+ val operations = patch.optJSONArray("operations")
1285
+ ?: throw PatchInstallException(
1286
+ ApplyErrorCode.ASSET_PACKAGE_INVALID,
1287
+ "manifest patch operations missing"
1288
+ )
1289
+ for (i in 0 until operations.length()) {
1290
+ val op = operations.getJSONObject(i)
1291
+ when (op.optString("op")) {
1292
+ "upsert" -> {
1293
+ val key = op.optString("key")
1294
+ if (!isSafeZipPath(key)) {
1295
+ throw PatchInstallException(
1296
+ ApplyErrorCode.ASSET_PACKAGE_INVALID,
1297
+ "unsafe manifest key: $key"
1298
+ )
1299
+ }
1300
+ manifest[key] = jsonArrayToList(op.getJSONArray("variants"))
1301
+ }
1302
+ else -> throw PatchInstallException(
1303
+ ApplyErrorCode.ASSET_PACKAGE_INVALID,
1304
+ "unsupported manifest patch op: ${op.optString("op")}"
1305
+ )
1306
+ }
1307
+ }
1308
+ val encoded = StandardMessageCodec.INSTANCE.encodeMessage(manifest)
1309
+ ?: throw PatchInstallException(
1310
+ ApplyErrorCode.ASSET_PACKAGE_INVALID,
1311
+ "failed to encode AssetManifest.bin"
1312
+ )
1313
+ val bytes = codecBufferToByteArray(encoded)
1314
+ FileOutputStream(manifestFile).use { output ->
1315
+ output.write(bytes)
1316
+ output.fd.sync()
1317
+ }
1318
+ }
1319
+
1320
+ private fun jsonArrayToList(array: JSONArray): List<Any?> {
1321
+ val list = ArrayList<Any?>(array.length())
1322
+ for (i in 0 until array.length()) {
1323
+ list.add(jsonToPlain(array.get(i)))
1324
+ }
1325
+ return list
1326
+ }
1327
+
1328
+ private fun jsonObjectToMap(json: JSONObject): Map<String, Any?> {
1329
+ val map = LinkedHashMap<String, Any?>()
1330
+ val keys = json.keys()
1331
+ while (keys.hasNext()) {
1332
+ val key = keys.next()
1333
+ map[key] = jsonToPlain(json.get(key))
1334
+ }
1335
+ return map
1336
+ }
1337
+
1338
+ private fun jsonToPlain(value: Any?): Any? {
1339
+ return when (value) {
1340
+ null, JSONObject.NULL -> null
1341
+ is JSONObject -> jsonObjectToMap(value)
1342
+ is JSONArray -> jsonArrayToList(value)
1343
+ else -> value
1344
+ }
1345
+ }
1346
+
1347
+ private fun writeTextSync(file: File, text: String) {
1348
+ FileOutputStream(file).use { output ->
1349
+ output.write(text.toByteArray(Charsets.UTF_8))
1350
+ output.fd.sync()
1351
+ }
1352
+ }
1353
+
1354
+ private fun downloadTo(
1355
+ url: String,
1356
+ dest: File,
1357
+ onBytes: ((received: Long, total: Long) -> Unit)? = null
1358
+ ) {
1359
+ val parsed = URL(url)
1360
+ when (parsed.protocol?.lowercase()) {
1361
+ "http", "https" -> downloadHttp(parsed, dest, onBytes)
1362
+ "file" -> copyFromFile(parsed, dest, onBytes)
1363
+ else -> throw RuntimeException("unsupported URL scheme: ${parsed.protocol}")
1364
+ }
1365
+ }
1366
+
1367
+ private fun downloadHttp(
1368
+ url: URL,
1369
+ dest: File,
1370
+ onBytes: ((received: Long, total: Long) -> Unit)?
1371
+ ) {
1372
+ var current = url
1373
+ var redirects = 0
1374
+ while (true) {
1375
+ val conn = current.openConnection() as HttpURLConnection
1376
+ try {
1377
+ conn.connectTimeout = CONNECT_TIMEOUT_MS
1378
+ conn.readTimeout = READ_TIMEOUT_MS
1379
+ conn.requestMethod = "GET"
1380
+ // Handle redirects manually so we can follow an https→http→https
1381
+ // chain and, crucially, cross-protocol redirects that
1382
+ // HttpURLConnection refuses to auto-follow (e.g. S3/R2/CDN 301/302
1383
+ // to a different host or scheme). Without this a presigned URL that
1384
+ // 30x-redirects silently yields the redirect HTML as the "patch",
1385
+ // which then fails MD5 and looks like a corrupt bundle.
1386
+ conn.instanceFollowRedirects = false
1387
+ val code = conn.responseCode
1388
+ if (code in 300..399) {
1389
+ val location = conn.getHeaderField("Location")
1390
+ ?: throw RuntimeException("HTTP $code with no Location header")
1391
+ if (++redirects > MAX_REDIRECTS) {
1392
+ throw RuntimeException("too many redirects (> $MAX_REDIRECTS)")
1393
+ }
1394
+ // Resolve relative Location against the current URL.
1395
+ current = URL(current, location)
1396
+ conn.disconnect()
1397
+ continue
1398
+ }
1399
+ if (code !in 200..299) throw RuntimeException("HTTP $code")
1400
+ streamToFile(conn.inputStream, dest, conn.contentLengthLong, onBytes)
1401
+ return
1402
+ } finally {
1403
+ conn.disconnect()
1404
+ }
1405
+ }
1406
+ }
1407
+
1408
+ private fun copyFromFile(
1409
+ url: URL,
1410
+ dest: File,
1411
+ onBytes: ((received: Long, total: Long) -> Unit)?
1412
+ ) {
1413
+ val src = File(url.path)
1414
+ if (!src.exists()) throw RuntimeException("file not found: ${src.absolutePath}")
1415
+ if (!src.canRead()) throw RuntimeException("file not readable: ${src.absolutePath}")
1416
+ streamToFile(src.inputStream(), dest, src.length(), onBytes)
1417
+ }
1418
+
1419
+ private fun streamToFile(
1420
+ input: java.io.InputStream,
1421
+ dest: File,
1422
+ total: Long,
1423
+ onBytes: ((received: Long, total: Long) -> Unit)?
1424
+ ) {
1425
+ var received = 0L
1426
+ var lastEmit = 0L
1427
+ input.use { ins ->
1428
+ FileOutputStream(dest).use { output ->
1429
+ val buf = ByteArray(8192)
1430
+ while (true) {
1431
+ val n = ins.read(buf)
1432
+ if (n <= 0) break
1433
+ output.write(buf, 0, n)
1434
+ received += n
1435
+ if (onBytes != null) {
1436
+ val now = SystemClock.uptimeMillis()
1437
+ if (now - lastEmit >= PROGRESS_EMIT_INTERVAL_MS) {
1438
+ onBytes(received, total)
1439
+ lastEmit = now
1440
+ }
1441
+ }
1442
+ }
1443
+ output.fd.sync()
1444
+ }
1445
+ }
1446
+ // Guard against a silently truncated body (connection dropped mid-stream,
1447
+ // proxy cut it off). When the server advertised a Content-Length, the
1448
+ // written file must match it exactly. Throwing here routes into the retry
1449
+ // loop; without md5 this is the only line of defense against installing a
1450
+ // half-downloaded patch.
1451
+ if (total > 0 && received != total) {
1452
+ throw IOException("incomplete download: got $received of $total bytes")
1453
+ }
1454
+ onBytes?.invoke(received, total)
1455
+ }
1456
+
1457
+ private fun copyFile(src: File, dest: File) {
1458
+ dest.parentFile?.mkdirs()
1459
+ src.inputStream().use { input ->
1460
+ FileOutputStream(dest).use { output ->
1461
+ input.copyTo(output)
1462
+ output.fd.sync()
1463
+ }
1464
+ }
1465
+ }
1466
+
1467
+ private fun copyDirectory(src: File, dest: File) {
1468
+ src.walkTopDown().forEach { file ->
1469
+ val relative = file.relativeTo(src).path
1470
+ val target = File(dest, relative)
1471
+ if (file.isDirectory) {
1472
+ target.mkdirs()
1473
+ } else {
1474
+ copyFile(file, target)
1475
+ }
1476
+ }
1477
+ }
1478
+
1479
+ private fun writeFlutterAssetsArchive(src: File, dest: File) {
1480
+ dest.parentFile?.mkdirs()
1481
+ ZipOutputStream(FileOutputStream(dest)).use { zip ->
1482
+ src.walkTopDown().forEach { file ->
1483
+ if (!file.isFile) return@forEach
1484
+ val relative = file.relativeTo(src).invariantSeparatorsPath
1485
+ if (!isSafeZipPath(relative)) {
1486
+ throw PatchInstallException(
1487
+ ApplyErrorCode.ASSET_PACKAGE_INVALID,
1488
+ "unsafe staged asset path: $relative"
1489
+ )
1490
+ }
1491
+ val entry = ZipEntry("$PATCH_ASSETS_PREFIX$relative")
1492
+ zip.putNextEntry(entry)
1493
+ file.inputStream().use { input -> input.copyTo(zip) }
1494
+ zip.closeEntry()
1495
+ }
1496
+ }
1497
+ }
1498
+
1499
+ private fun cleanupPreparedArtifacts(includePrevious: Boolean) {
1500
+ pendingSo.delete()
1501
+ pendingMeta.delete()
1502
+ pendingAssets.deleteRecursively()
1503
+ pendingAssetsArchive.delete()
1504
+ if (includePrevious) {
1505
+ previousSo.delete()
1506
+ previousMeta.delete()
1507
+ previousAssets.deleteRecursively()
1508
+ previousAssetsArchive.delete()
1509
+ }
1510
+ }
1511
+
1512
+ private fun recoverInterruptedInstall() {
1513
+ val backedSo = previousSo.exists()
1514
+ val backedMeta = previousMeta.exists()
1515
+ val backedAssets = previousAssets.exists()
1516
+ val backedAssetsArchive = previousAssetsArchive.exists()
1517
+ if (backedSo || backedMeta || backedAssets || backedAssetsArchive) {
1518
+ rollbackPreparedCommit(
1519
+ promotedSo = backedSo,
1520
+ promotedMeta = backedMeta,
1521
+ promotedAssets = backedAssets,
1522
+ promotedAssetsArchive = backedAssetsArchive,
1523
+ backedSo = backedSo,
1524
+ backedMeta = backedMeta,
1525
+ backedAssets = backedAssets,
1526
+ backedAssetsArchive = backedAssetsArchive,
1527
+ )
1528
+ } else {
1529
+ cleanupPreparedArtifacts(includePrevious = false)
1530
+ if (!patchFile.exists() || !metaFile.exists()) {
1531
+ patchFile.delete()
1532
+ metaFile.delete()
1533
+ assetsDir.deleteRecursively()
1534
+ assetsArchive.delete()
1535
+ }
1536
+ }
1537
+ installMarkerFile.delete()
1538
+ }
1539
+
1540
+ private fun rollbackPreparedCommit(
1541
+ promotedSo: Boolean,
1542
+ promotedMeta: Boolean,
1543
+ promotedAssets: Boolean,
1544
+ promotedAssetsArchive: Boolean,
1545
+ backedSo: Boolean,
1546
+ backedMeta: Boolean,
1547
+ backedAssets: Boolean,
1548
+ backedAssetsArchive: Boolean,
1549
+ ) {
1550
+ if (promotedSo) patchFile.delete()
1551
+ if (backedSo && previousSo.exists()) previousSo.renameTo(patchFile)
1552
+
1553
+ if (promotedAssets) assetsDir.deleteRecursively()
1554
+ if (backedAssets && previousAssets.exists()) previousAssets.renameTo(assetsDir)
1555
+
1556
+ if (promotedAssetsArchive) assetsArchive.delete()
1557
+ if (backedAssetsArchive && previousAssetsArchive.exists()) {
1558
+ previousAssetsArchive.renameTo(assetsArchive)
1559
+ }
1560
+
1561
+ if (promotedMeta) metaFile.delete()
1562
+ if (backedMeta && previousMeta.exists()) previousMeta.renameTo(metaFile)
1563
+
1564
+ cleanupPreparedArtifacts(includePrevious = true)
1565
+ }
1566
+
1567
+ private fun deletePatch() {
1568
+ if (patchDir.exists()) patchDir.deleteRecursively()
1569
+ }
1570
+
1571
+ private fun readMeta(): JSONObject? {
1572
+ if (!metaFile.exists()) return null
1573
+ return try {
1574
+ JSONObject(metaFile.readText())
1575
+ } catch (_: Exception) {
1576
+ null
1577
+ }
1578
+ }
1579
+ }