@_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.
- package/README.md +112 -18
- package/bin/flutter-ota-linux-x64 +0 -0
- package/bin/flutter-ota.js +12 -11
- package/dart-src/packages/cli-tools/README.md +146 -2
- package/dart-src/packages/cli-tools/lib/flutter_ota_kit_cli.dart +46 -13
- package/dart-src/packages/cli-tools/lib/src/backend.dart +6 -6
- package/dart-src/packages/cli-tools/lib/src/commands/build.dart +2 -0
- package/dart-src/packages/cli-tools/lib/src/commands/bundle.dart +7 -7
- package/dart-src/packages/cli-tools/lib/src/commands/deploy.dart +4 -1
- package/dart-src/packages/cli-tools/lib/src/commands/init.dart +36 -8
- package/dart-src/packages/cli-tools/lib/src/commands/pocketbase.dart +1 -1
- package/dart-src/packages/cli-tools/lib/src/commands/rollback.dart +1 -1
- package/dart-src/packages/cli-tools/lib/src/config.dart +5 -5
- package/dart-src/packages/cli-tools/lib/src/operations.dart +2 -2
- package/dart-src/packages/cli-tools/pubspec.yaml +8 -21
- package/dart-src/packages/cli-tools/test/backends_test.dart +3 -3
- package/dart-src/packages/cli-tools/test/cli_test.dart +1 -1
- package/dart-src/packages/cli-tools/test/mocks/mock_aws_s3_client.dart +2 -2
- package/dart-src/packages/cli-tools/test/mocks/mock_d1_client.dart +2 -2
- package/dart-src/packages/cli-tools/test/mocks/mock_pocketbase_client.dart +4 -1
- package/dart-src/packages/cli-tools/test/mocks/mock_postgres_client.dart +2 -2
- package/dart-src/packages/cli-tools/test/pocketbase_smoke_test.dart +1 -1
- package/dart-src/root/README.md +334 -0
- package/dart-src/root/analysis_options.yaml +21 -0
- package/dart-src/root/android/build.gradle +116 -0
- package/dart-src/root/android/consumer-rules.pro +23 -0
- package/dart-src/root/android/settings.gradle +1 -0
- package/dart-src/root/android/src/main/AndroidManifest.xml +21 -0
- package/dart-src/root/android/src/main/kotlin/com/flutter_patcher/flutter_patcher/ApplyResult.kt +52 -0
- package/dart-src/root/android/src/main/kotlin/com/flutter_patcher/flutter_patcher/BlacklistStore.kt +154 -0
- package/dart-src/root/android/src/main/kotlin/com/flutter_patcher/flutter_patcher/BootDiagnosticStore.kt +150 -0
- package/dart-src/root/android/src/main/kotlin/com/flutter_patcher/flutter_patcher/CrashGuard.kt +271 -0
- package/dart-src/root/android/src/main/kotlin/com/flutter_patcher/flutter_patcher/FlutterPatcherApplication.kt +201 -0
- package/dart-src/root/android/src/main/kotlin/com/flutter_patcher/flutter_patcher/FlutterPatcherAutoInitProvider.kt +93 -0
- package/dart-src/root/android/src/main/kotlin/com/flutter_patcher/flutter_patcher/FlutterPatcherPlugin.kt +245 -0
- package/dart-src/root/android/src/main/kotlin/com/flutter_patcher/flutter_patcher/LoaderHook.kt +298 -0
- package/dart-src/root/android/src/main/kotlin/com/flutter_patcher/flutter_patcher/PatchManager.kt +1579 -0
- package/dart-src/root/android/src/main/kotlin/com/flutter_patcher/flutter_patcher/PatcherConfig.kt +173 -0
- package/dart-src/root/android/src/main/kotlin/com/flutter_patcher/flutter_patcher/SignatureVerifier.kt +177 -0
- package/dart-src/root/android/src/test/kotlin/com/flutter_patcher/flutter_patcher/CrashGuardTest.kt +61 -0
- package/dart-src/root/android/src/test/kotlin/com/flutter_patcher/flutter_patcher/LoaderHookTest.kt +25 -0
- package/dart-src/root/android/src/test/kotlin/com/flutter_patcher/flutter_patcher/PatchManagerTest.kt +401 -0
- package/dart-src/root/android/src/test/kotlin/com/flutter_patcher/flutter_patcher/PatcherConfigTest.kt +30 -0
- package/dart-src/root/bin/pack.dart +182 -0
- package/dart-src/root/lib/flutter_ota_kit.dart +1170 -0
- package/dart-src/root/lib/src/app_version_resolver.dart +36 -0
- package/dart-src/root/lib/src/aws_update_source.dart +97 -0
- package/dart-src/root/lib/src/blacklist.dart +54 -0
- package/dart-src/root/lib/src/boot_diagnostic.dart +138 -0
- package/dart-src/root/lib/src/cloudflare_update_source.dart +97 -0
- package/dart-src/root/lib/src/flutter_ota_app.dart +69 -0
- package/dart-src/root/lib/src/io.dart +48 -0
- package/dart-src/root/lib/src/io_stub.dart +19 -0
- package/dart-src/root/lib/src/ota_progress_overlay.dart +1186 -0
- package/dart-src/root/lib/src/pack/pack.dart +434 -0
- package/dart-src/root/lib/src/patch_info.dart +12 -0
- package/dart-src/root/lib/src/patcher_channel.dart +160 -0
- package/dart-src/{plugins/aws/lib → root/lib/src/pkg/aws}/src/aws_database.dart +1 -1
- package/dart-src/{plugins/aws/lib → root/lib/src/pkg/aws}/src/aws_s3_client.dart +1 -1
- package/dart-src/{plugins/aws/lib → root/lib/src/pkg/aws}/src/aws_storage.dart +1 -1
- package/dart-src/{plugins/aws/lib → root/lib/src/pkg/aws}/src/aws_storage_profile.dart +1 -1
- package/dart-src/{plugins/aws/lib → root/lib/src/pkg/aws}/src/with_cloudfront_signed_url.dart +1 -1
- package/dart-src/root/lib/src/pkg/client/flutter_ota_kit_client.dart +1 -0
- package/dart-src/root/lib/src/pkg/client/src/server_update_source.dart +46 -0
- package/dart-src/{plugins/cloudflare/lib → root/lib/src/pkg/cloudflare}/src/cloudflare_worker_database.dart +1 -1
- package/dart-src/{plugins/cloudflare/lib → root/lib/src/pkg/cloudflare}/src/d1_build_where.dart +1 -1
- package/dart-src/{plugins/cloudflare/lib → root/lib/src/pkg/cloudflare}/src/d1_bundle_mapper.dart +3 -3
- package/dart-src/{plugins/cloudflare/lib → root/lib/src/pkg/cloudflare}/src/d1_database.dart +1 -1
- package/dart-src/{plugins/cloudflare/lib → root/lib/src/pkg/cloudflare}/src/d1_database_plugin.dart +2 -2
- package/dart-src/{plugins/cloudflare/lib → root/lib/src/pkg/cloudflare}/src/r2_s3_client.dart +1 -1
- package/dart-src/{plugins/cloudflare/lib → root/lib/src/pkg/cloudflare}/src/r2_storage.dart +1 -1
- package/dart-src/{plugins/cloudflare/lib → root/lib/src/pkg/cloudflare}/src/r2_storage_profile.dart +1 -1
- package/dart-src/{packages/core/lib → root/lib/src/pkg/core}/src/status.dart +1 -1
- package/dart-src/{plugins/plugin-core/lib → root/lib/src/pkg/plugin_core}/flutter_ota_kit_plugin_core.dart +1 -1
- package/dart-src/{plugins/plugin-core/lib → root/lib/src/pkg/plugin_core}/src/create_blob_database_plugin.dart +1 -1
- package/dart-src/{plugins/plugin-core/lib → root/lib/src/pkg/plugin_core}/src/filter_compatible_app_versions.dart +1 -1
- package/dart-src/{plugins/plugin-core/lib → root/lib/src/pkg/plugin_core}/src/get_update_info.dart +1 -1
- package/dart-src/{plugins/plugin-core/lib → root/lib/src/pkg/plugin_core}/src/request_update_bundle_state.dart +1 -1
- package/dart-src/{plugins/plugin-core/lib → root/lib/src/pkg/plugin_core}/src/resolve_update_info_from_bundles.dart +1 -1
- package/dart-src/{plugins/plugin-core/lib → root/lib/src/pkg/plugin_core}/src/types.dart +2 -2
- package/dart-src/{plugins/pocketbase/lib → root/lib/src/pkg/pocketbase}/src/pocketbase_bundle_mapper.dart +2 -2
- package/dart-src/{plugins/pocketbase/lib → root/lib/src/pkg/pocketbase}/src/pocketbase_database.dart +26 -15
- package/dart-src/{plugins/pocketbase/lib → root/lib/src/pkg/pocketbase}/src/pocketbase_storage.dart +1 -1
- package/dart-src/{plugins/postgres/lib → root/lib/src/pkg/postgres}/src/postgres_bundle_mapper.dart +2 -2
- package/dart-src/{plugins/postgres/lib → root/lib/src/pkg/postgres}/src/postgres_database.dart +2 -2
- package/dart-src/{plugins/postgres/lib → root/lib/src/pkg/postgres}/src/postgres_get_update_info.dart +2 -2
- package/dart-src/{plugins/postgres/lib → root/lib/src/pkg/postgres}/src/postgres_storage.dart +1 -1
- package/dart-src/{plugins/postgres/lib → root/lib/src/pkg/postgres}/src/postgres_types.dart +1 -1
- package/dart-src/{plugins/supabase/lib → root/lib/src/pkg/supabase}/src/supabase_bundle_mapper.dart +2 -2
- package/dart-src/{plugins/supabase/lib → root/lib/src/pkg/supabase}/src/supabase_database.dart +2 -2
- package/dart-src/{plugins/supabase/lib → root/lib/src/pkg/supabase}/src/supabase_edge_function_database.dart +1 -1
- package/dart-src/{plugins/supabase/lib → root/lib/src/pkg/supabase}/src/supabase_edge_function_storage.dart +1 -1
- package/dart-src/{plugins/supabase/lib → root/lib/src/pkg/supabase}/src/supabase_storage.dart +1 -1
- package/dart-src/root/lib/src/pocketbase_update_source.dart +107 -0
- package/dart-src/root/lib/src/postgres_update_source.dart +95 -0
- package/dart-src/root/lib/src/shared_update_check.dart +131 -0
- package/dart-src/root/lib/src/supabase_update_source.dart +93 -0
- package/dart-src/root/pubspec.yaml +54 -0
- package/package.json +1 -1
- package/scripts/postinstall.js +17 -13
- package/scripts/vendor.sh +22 -7
- package/dart-src/packages/core/CHANGELOG.md +0 -21
- package/dart-src/packages/core/README.md +0 -23
- package/dart-src/packages/core/example/lib/main.dart +0 -69
- package/dart-src/packages/core/pubspec.yaml +0 -13
- package/dart-src/packages/core/test/rollout_test.dart +0 -135
- package/dart-src/packages/core/test/semver_test.dart +0 -55
- package/dart-src/packages/core/test/semver_vectors.json +0 -1
- package/dart-src/packages/core/test/types_test.dart +0 -131
- package/dart-src/packages/core/test/uuid_test.dart +0 -40
- package/dart-src/packages/core/tool/dbg.dart +0 -7
- package/dart-src/plugins/aws/.gitkeep +0 -0
- package/dart-src/plugins/aws/CHANGELOG.md +0 -23
- package/dart-src/plugins/aws/LICENSE +0 -21
- package/dart-src/plugins/aws/README.md +0 -28
- package/dart-src/plugins/aws/example/lib/main.dart +0 -60
- package/dart-src/plugins/aws/pubspec.yaml +0 -30
- package/dart-src/plugins/aws/test/aws_database_test.dart +0 -161
- package/dart-src/plugins/aws/test/aws_storage_test.dart +0 -73
- package/dart-src/plugins/aws/test/mock_aws_s3_client.dart +0 -78
- package/dart-src/plugins/cloudflare/.gitkeep +0 -0
- package/dart-src/plugins/cloudflare/CHANGELOG.md +0 -23
- package/dart-src/plugins/cloudflare/LICENSE +0 -21
- package/dart-src/plugins/cloudflare/README.md +0 -25
- package/dart-src/plugins/cloudflare/example/lib/main.dart +0 -62
- package/dart-src/plugins/cloudflare/migrations/0001_hot-updater_init.sql +0 -16
- package/dart-src/plugins/cloudflare/migrations/0002_hot-updater_0.13.0.sql +0 -8
- package/dart-src/plugins/cloudflare/migrations/0003_hot-updater_0.18.0.sql +0 -38
- package/dart-src/plugins/cloudflare/migrations/0004_hot-updater_0.29.0.sql +0 -11
- package/dart-src/plugins/cloudflare/migrations/0005_hot-updater_0.31.0.sql +0 -24
- package/dart-src/plugins/cloudflare/pubspec.yaml +0 -28
- package/dart-src/plugins/cloudflare/sql/bundles.sql +0 -42
- package/dart-src/plugins/cloudflare/test/cloudflare_worker_database_test.dart +0 -126
- package/dart-src/plugins/cloudflare/test/d1_bundle_mapper_test.dart +0 -106
- package/dart-src/plugins/cloudflare/test/d1_database_test.dart +0 -200
- package/dart-src/plugins/cloudflare/test/mock_d1_client.dart +0 -261
- package/dart-src/plugins/cloudflare/test/mock_r2_client.dart +0 -78
- package/dart-src/plugins/cloudflare/test/r2_storage_test.dart +0 -74
- package/dart-src/plugins/plugin-core/CHANGELOG.md +0 -20
- package/dart-src/plugins/plugin-core/LICENSE +0 -21
- package/dart-src/plugins/plugin-core/README.md +0 -21
- package/dart-src/plugins/plugin-core/example/lib/main.dart +0 -61
- package/dart-src/plugins/plugin-core/pubspec.yaml +0 -23
- package/dart-src/plugins/plugin-core/test/asset_storage_layout_test.dart +0 -170
- package/dart-src/plugins/plugin-core/test/bundle_unit_of_work_store_test.dart +0 -74
- package/dart-src/plugins/plugin-core/test/bundle_unit_of_work_test.dart +0 -345
- package/dart-src/plugins/plugin-core/test/create_blob_database_plugin_test.dart +0 -843
- package/dart-src/plugins/plugin-core/test/create_database_plugin_test.dart +0 -298
- package/dart-src/plugins/plugin-core/test/create_storage_plugin_test.dart +0 -195
- package/dart-src/plugins/plugin-core/test/generate_min_bundle_id_test.dart +0 -56
- package/dart-src/plugins/plugin-core/test/get_update_info_test.dart +0 -1589
- package/dart-src/plugins/plugin-core/test/helpers_test.dart +0 -283
- package/dart-src/plugins/plugin-core/test/query_bundles_test.dart +0 -202
- package/dart-src/plugins/plugin-core/test/request_update_bundle_state_test.dart +0 -86
- package/dart-src/plugins/plugin-core/test/uuidv7_test.dart +0 -88
- package/dart-src/plugins/pocketbase/CHANGELOG.md +0 -23
- package/dart-src/plugins/pocketbase/LICENSE +0 -21
- package/dart-src/plugins/pocketbase/README.md +0 -54
- package/dart-src/plugins/pocketbase/example/lib/main.dart +0 -62
- package/dart-src/plugins/pocketbase/pubspec.yaml +0 -26
- package/dart-src/plugins/pocketbase/test/pb_uri_test.dart +0 -12
- package/dart-src/plugins/postgres/.gitkeep +0 -0
- package/dart-src/plugins/postgres/CHANGELOG.md +0 -23
- package/dart-src/plugins/postgres/LICENSE +0 -21
- package/dart-src/plugins/postgres/README.md +0 -29
- package/dart-src/plugins/postgres/example/lib/main.dart +0 -67
- package/dart-src/plugins/postgres/pubspec.yaml +0 -26
- package/dart-src/plugins/postgres/sql/bundles.sql +0 -45
- package/dart-src/plugins/postgres/sql/get_target_app_version_list.sql +0 -21
- package/dart-src/plugins/postgres/sql/get_update_info.spec.ts +0 -171
- package/dart-src/plugins/postgres/sql/get_update_info_by_app_version.sql +0 -126
- package/dart-src/plugins/postgres/sql/get_update_info_by_fingerprint_hash.sql +0 -125
- package/dart-src/plugins/postgres/sql/hash_user_id.sql +0 -30
- package/dart-src/plugins/postgres/sql/is_device_eligible.sql +0 -237
- package/dart-src/plugins/postgres/sql/prepareSql.ts +0 -11
- package/dart-src/plugins/postgres/test/mock_postgres_client.dart +0 -320
- package/dart-src/plugins/postgres/test/postgres_bundle_mapper_test.dart +0 -139
- package/dart-src/plugins/postgres/test/postgres_database_test.dart +0 -210
- package/dart-src/plugins/postgres/test/postgres_storage_runtime_test.dart +0 -60
- package/dart-src/plugins/supabase/CHANGELOG.md +0 -24
- package/dart-src/plugins/supabase/LICENSE +0 -21
- package/dart-src/plugins/supabase/README.md +0 -53
- package/dart-src/plugins/supabase/example/lib/main.dart +0 -63
- package/dart-src/plugins/supabase/pubspec.yaml +0 -27
- package/dart-src/plugins/supabase/supabase/edge-functions/index.ts +0 -44
- package/dart-src/plugins/supabase/supabase/edge-functions/runtime.docker.integration.spec.ts +0 -968
- package/dart-src/plugins/supabase/supabase/migrations/20250103114225_init.sql +0 -232
- package/dart-src/plugins/supabase/supabase/migrations/20250314000000_hot-updater_0.13.0.sql +0 -137
- package/dart-src/plugins/supabase/supabase/migrations/20250516000000_hot-updater_0.18.0.sql +0 -206
- package/dart-src/plugins/supabase/supabase/migrations/20251014000000_hot-updater_0.21.0.sql +0 -186
- package/dart-src/plugins/supabase/supabase/migrations/20260401000000_hot-updater_0.29.0.sql +0 -505
- package/dart-src/plugins/supabase/supabase/migrations/20260414000000_hot-updater_0.30.0.sql +0 -50
- package/dart-src/plugins/supabase/supabase/migrations/20260422000000_hot-updater_0.31.0.sql +0 -20
- package/dart-src/plugins/supabase/supabase/migrations/20260520014100_hot-updater_rls.sql +0 -75
- package/dart-src/plugins/supabase/test/mock_supabase_client.dart +0 -654
- package/dart-src/plugins/supabase/test/supabase_bundle_mapper_test.dart +0 -115
- package/dart-src/plugins/supabase/test/supabase_database_test.dart +0 -198
- package/dart-src/plugins/supabase/test/supabase_storage_test.dart +0 -168
- /package/dart-src/{packages/core → root}/LICENSE +0 -0
- /package/dart-src/{plugins/aws/lib → root/lib/src/pkg/aws}/flutter_ota_kit_aws.dart +0 -0
- /package/dart-src/{plugins/aws/lib → root/lib/src/pkg/aws}/src/aws_cloudfront_client.dart +0 -0
- /package/dart-src/{plugins/aws/lib → root/lib/src/pkg/aws}/src/aws_cloudfront_signer.dart +0 -0
- /package/dart-src/{plugins/aws/lib → root/lib/src/pkg/aws}/src/aws_config.dart +0 -0
- /package/dart-src/{plugins/aws/lib → root/lib/src/pkg/aws}/src/aws_ssm_client.dart +0 -0
- /package/dart-src/{plugins/cloudflare/lib → root/lib/src/pkg/cloudflare}/flutter_ota_kit_cloudflare.dart +0 -0
- /package/dart-src/{plugins/cloudflare/lib → root/lib/src/pkg/cloudflare}/src/d1_client.dart +0 -0
- /package/dart-src/{plugins/cloudflare/lib → root/lib/src/pkg/cloudflare}/src/d1_config.dart +0 -0
- /package/dart-src/{plugins/cloudflare/lib → root/lib/src/pkg/cloudflare}/src/r2_config.dart +0 -0
- /package/dart-src/{packages/core/lib → root/lib/src/pkg/core}/flutter_ota_kit_core.dart +0 -0
- /package/dart-src/{packages/core/lib → root/lib/src/pkg/core}/src/app_update_info.dart +0 -0
- /package/dart-src/{packages/core/lib → root/lib/src/pkg/core}/src/bundle.dart +0 -0
- /package/dart-src/{packages/core/lib → root/lib/src/pkg/core}/src/bundle_artifacts.dart +0 -0
- /package/dart-src/{packages/core/lib → root/lib/src/pkg/core}/src/bundle_patch_artifact.dart +0 -0
- /package/dart-src/{packages/core/lib → root/lib/src/pkg/core}/src/changed_asset.dart +0 -0
- /package/dart-src/{packages/core/lib → root/lib/src/pkg/core}/src/get_bundles_args.dart +0 -0
- /package/dart-src/{packages/core/lib → root/lib/src/pkg/core}/src/metadata.dart +0 -0
- /package/dart-src/{packages/core/lib → root/lib/src/pkg/core}/src/patch_info.dart +0 -0
- /package/dart-src/{packages/core/lib → root/lib/src/pkg/core}/src/platform.dart +0 -0
- /package/dart-src/{packages/core/lib → root/lib/src/pkg/core}/src/rollout.dart +0 -0
- /package/dart-src/{packages/core/lib → root/lib/src/pkg/core}/src/semver.dart +0 -0
- /package/dart-src/{packages/core/lib → root/lib/src/pkg/core}/src/semver_range.dart +0 -0
- /package/dart-src/{packages/core/lib → root/lib/src/pkg/core}/src/semver_version.dart +0 -0
- /package/dart-src/{packages/core/lib → root/lib/src/pkg/core}/src/strategy.dart +0 -0
- /package/dart-src/{packages/core/lib → root/lib/src/pkg/core}/src/update_bundle_params.dart +0 -0
- /package/dart-src/{packages/core/lib → root/lib/src/pkg/core}/src/uuid.dart +0 -0
- /package/dart-src/{plugins/plugin-core/lib → root/lib/src/pkg/plugin_core}/src/asset_storage_layout.dart +0 -0
- /package/dart-src/{plugins/plugin-core/lib → root/lib/src/pkg/plugin_core}/src/bundle_helpers.dart +0 -0
- /package/dart-src/{plugins/plugin-core/lib → root/lib/src/pkg/plugin_core}/src/bundle_storage_layout.dart +0 -0
- /package/dart-src/{plugins/plugin-core/lib → root/lib/src/pkg/plugin_core}/src/bundle_unit_of_work.dart +0 -0
- /package/dart-src/{plugins/plugin-core/lib → root/lib/src/pkg/plugin_core}/src/bundle_unit_of_work_store.dart +0 -0
- /package/dart-src/{plugins/plugin-core/lib → root/lib/src/pkg/plugin_core}/src/calculate_pagination.dart +0 -0
- /package/dart-src/{plugins/plugin-core/lib → root/lib/src/pkg/plugin_core}/src/compression_format.dart +0 -0
- /package/dart-src/{plugins/plugin-core/lib → root/lib/src/pkg/plugin_core}/src/content_addressed_assets.dart +0 -0
- /package/dart-src/{plugins/plugin-core/lib → root/lib/src/pkg/plugin_core}/src/create_database_plugin.dart +0 -0
- /package/dart-src/{plugins/plugin-core/lib → root/lib/src/pkg/plugin_core}/src/create_storage_key_builder.dart +0 -0
- /package/dart-src/{plugins/plugin-core/lib → root/lib/src/pkg/plugin_core}/src/create_storage_plugin.dart +0 -0
- /package/dart-src/{plugins/plugin-core/lib → root/lib/src/pkg/plugin_core}/src/generate_min_bundle_id.dart +0 -0
- /package/dart-src/{plugins/plugin-core/lib → root/lib/src/pkg/plugin_core}/src/is_object.dart +0 -0
- /package/dart-src/{plugins/plugin-core/lib → root/lib/src/pkg/plugin_core}/src/legacy_asset_storage_layout.dart +0 -0
- /package/dart-src/{plugins/plugin-core/lib → root/lib/src/pkg/plugin_core}/src/paginate_bundles.dart +0 -0
- /package/dart-src/{plugins/plugin-core/lib → root/lib/src/pkg/plugin_core}/src/parse_storage_uri.dart +0 -0
- /package/dart-src/{plugins/plugin-core/lib → root/lib/src/pkg/plugin_core}/src/query_bundles.dart +0 -0
- /package/dart-src/{plugins/plugin-core/lib → root/lib/src/pkg/plugin_core}/src/storage_helpers.dart +0 -0
- /package/dart-src/{plugins/plugin-core/lib → root/lib/src/pkg/plugin_core}/src/storage_profile.dart +0 -0
- /package/dart-src/{plugins/plugin-core/lib → root/lib/src/pkg/plugin_core}/src/uuidv7.dart +0 -0
- /package/dart-src/{plugins/pocketbase/lib → root/lib/src/pkg/pocketbase}/flutter_ota_kit_pocketbase.dart +0 -0
- /package/dart-src/{plugins/pocketbase/lib → root/lib/src/pkg/pocketbase}/src/pocketbase_client.dart +0 -0
- /package/dart-src/{plugins/pocketbase/lib → root/lib/src/pkg/pocketbase}/src/pocketbase_config.dart +0 -0
- /package/dart-src/{plugins/postgres/lib → root/lib/src/pkg/postgres}/flutter_ota_kit_postgres.dart +0 -0
- /package/dart-src/{plugins/postgres/lib → root/lib/src/pkg/postgres}/src/postgres_client.dart +0 -0
- /package/dart-src/{plugins/postgres/lib → root/lib/src/pkg/postgres}/src/postgres_config.dart +0 -0
- /package/dart-src/{plugins/supabase/lib → root/lib/src/pkg/supabase}/edge.dart +0 -0
- /package/dart-src/{plugins/supabase/lib → root/lib/src/pkg/supabase}/flutter_ota_kit_supabase.dart +0 -0
- /package/dart-src/{plugins/supabase/lib → root/lib/src/pkg/supabase}/src/error_message.dart +0 -0
- /package/dart-src/{plugins/supabase/lib → root/lib/src/pkg/supabase}/src/supabase_client_adapter.dart +0 -0
- /package/dart-src/{plugins/supabase/lib → root/lib/src/pkg/supabase}/src/supabase_client_http.dart +0 -0
- /package/dart-src/{plugins/supabase/lib → root/lib/src/pkg/supabase}/src/supabase_config.dart +0 -0
- /package/dart-src/{plugins/supabase/lib → root/lib/src/pkg/supabase}/src/supabase_signed_url_batcher.dart +0 -0
- /package/dart-src/{plugins/supabase/lib → root/lib/src/pkg/supabase}/src/types.dart +0 -0
package/dart-src/root/android/src/main/kotlin/com/flutter_patcher/flutter_patcher/PatchManager.kt
ADDED
|
@@ -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
|
+
}
|