@_nazmiforreal/flutter-ota 0.1.0 → 0.1.2

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 (167) hide show
  1. package/bin/flutter-patcher.js +48 -39
  2. package/dart-src/packages/cli-tools/.gitkeep +0 -0
  3. package/dart-src/packages/cli-tools/bin/flutter_patcher.dart +9 -0
  4. package/dart-src/packages/cli-tools/lib/flutter_patcher_cli.dart +73 -0
  5. package/dart-src/packages/cli-tools/lib/src/backend.dart +162 -0
  6. package/dart-src/packages/cli-tools/lib/src/cli_base.dart +56 -0
  7. package/dart-src/packages/cli-tools/lib/src/commands/build.dart +99 -0
  8. package/dart-src/packages/cli-tools/lib/src/commands/bundle.dart +151 -0
  9. package/dart-src/packages/cli-tools/lib/src/commands/channel.dart +140 -0
  10. package/dart-src/packages/cli-tools/lib/src/commands/config_command.dart +113 -0
  11. package/dart-src/packages/cli-tools/lib/src/commands/console.dart +55 -0
  12. package/dart-src/packages/cli-tools/lib/src/commands/deploy.dart +95 -0
  13. package/dart-src/packages/cli-tools/lib/src/commands/doctor.dart +48 -0
  14. package/dart-src/packages/cli-tools/lib/src/commands/fingerprint.dart +29 -0
  15. package/dart-src/packages/cli-tools/lib/src/commands/init.dart +82 -0
  16. package/dart-src/packages/cli-tools/lib/src/commands/keys.dart +55 -0
  17. package/dart-src/packages/cli-tools/lib/src/commands/migrate.dart +132 -0
  18. package/dart-src/packages/cli-tools/lib/src/commands/rollback.dart +41 -0
  19. package/dart-src/packages/cli-tools/lib/src/config.dart +715 -0
  20. package/dart-src/packages/cli-tools/lib/src/operations.dart +206 -0
  21. package/dart-src/packages/cli-tools/lib/src/pack.dart +417 -0
  22. package/dart-src/packages/cli-tools/lib/src/sign.dart +60 -0
  23. package/dart-src/packages/cli-tools/lib/src/util.dart +86 -0
  24. package/dart-src/packages/cli-tools/migrations/cloudflare/0001_hot-updater_init.sql +16 -0
  25. package/dart-src/packages/cli-tools/migrations/cloudflare/0002_hot-updater_0.13.0.sql +8 -0
  26. package/dart-src/packages/cli-tools/migrations/cloudflare/0003_hot-updater_0.18.0.sql +38 -0
  27. package/dart-src/packages/cli-tools/migrations/cloudflare/0004_hot-updater_0.29.0.sql +11 -0
  28. package/dart-src/packages/cli-tools/migrations/cloudflare/0005_hot-updater_0.31.0.sql +24 -0
  29. package/dart-src/packages/cli-tools/migrations/cloudflare/bundles.sql +42 -0
  30. package/dart-src/packages/cli-tools/migrations/supabase/20250103114225_init.sql +228 -0
  31. package/dart-src/packages/cli-tools/migrations/supabase/20250314000000_hot-updater_0.13.0.sql +134 -0
  32. package/dart-src/packages/cli-tools/migrations/supabase/20250516000000_hot-updater_0.18.0.sql +194 -0
  33. package/dart-src/packages/cli-tools/migrations/supabase/20251014000000_hot-updater_0.21.0.sql +184 -0
  34. package/dart-src/packages/cli-tools/migrations/supabase/20260401000000_hot-updater_0.29.0.sql +503 -0
  35. package/dart-src/packages/cli-tools/migrations/supabase/20260414000000_hot-updater_0.30.0.sql +50 -0
  36. package/dart-src/packages/cli-tools/migrations/supabase/20260422000000_hot-updater_0.31.0.sql +20 -0
  37. package/dart-src/packages/cli-tools/migrations/supabase/20260520014100_hot-updater_rls.sql +48 -0
  38. package/dart-src/packages/cli-tools/pubspec.yaml +37 -0
  39. package/dart-src/packages/core/lib/flutter_patcher_core.dart +29 -0
  40. package/dart-src/packages/core/lib/src/app_update_info.dart +111 -0
  41. package/dart-src/packages/core/lib/src/bundle.dart +191 -0
  42. package/dart-src/packages/core/lib/src/bundle_artifacts.dart +39 -0
  43. package/dart-src/packages/core/lib/src/bundle_patch_artifact.dart +47 -0
  44. package/dart-src/packages/core/lib/src/changed_asset.dart +77 -0
  45. package/dart-src/packages/core/lib/src/get_bundles_args.dart +78 -0
  46. package/dart-src/packages/core/lib/src/metadata.dart +31 -0
  47. package/dart-src/packages/core/lib/src/patch_info.dart +284 -0
  48. package/dart-src/packages/core/lib/src/platform.dart +15 -0
  49. package/dart-src/packages/core/lib/src/rollout.dart +209 -0
  50. package/dart-src/packages/core/lib/src/semver.dart +70 -0
  51. package/dart-src/packages/core/lib/src/semver_range.dart +358 -0
  52. package/dart-src/packages/core/lib/src/semver_version.dart +94 -0
  53. package/dart-src/packages/core/lib/src/status.dart +24 -0
  54. package/dart-src/packages/core/lib/src/strategy.dart +10 -0
  55. package/dart-src/packages/core/lib/src/update_bundle_params.dart +30 -0
  56. package/dart-src/packages/core/lib/src/uuid.dart +45 -0
  57. package/dart-src/packages/core/pubspec.yaml +9 -0
  58. package/dart-src/packages/core/tool/dbg.dart +6 -0
  59. package/dart-src/plugins/aws/.gitkeep +0 -0
  60. package/dart-src/plugins/aws/lib/flutter_patcher_aws.dart +12 -0
  61. package/dart-src/plugins/aws/lib/src/aws_cloudfront_client.dart +207 -0
  62. package/dart-src/plugins/aws/lib/src/aws_cloudfront_signer.dart +130 -0
  63. package/dart-src/plugins/aws/lib/src/aws_config.dart +59 -0
  64. package/dart-src/plugins/aws/lib/src/aws_database.dart +198 -0
  65. package/dart-src/plugins/aws/lib/src/aws_lambda_edge_storage.dart +72 -0
  66. package/dart-src/plugins/aws/lib/src/aws_s3_client.dart +312 -0
  67. package/dart-src/plugins/aws/lib/src/aws_ssm_client.dart +125 -0
  68. package/dart-src/plugins/aws/lib/src/aws_storage.dart +20 -0
  69. package/dart-src/plugins/aws/lib/src/aws_storage_profile.dart +123 -0
  70. package/dart-src/plugins/aws/lib/src/with_cloudfront_signed_url.dart +136 -0
  71. package/dart-src/plugins/aws/pubspec.yaml +22 -0
  72. package/dart-src/plugins/cloudflare/.gitkeep +0 -0
  73. package/dart-src/plugins/cloudflare/lib/flutter_patcher_cloudflare.dart +18 -0
  74. package/dart-src/plugins/cloudflare/lib/src/cloudflare_worker_database.dart +29 -0
  75. package/dart-src/plugins/cloudflare/lib/src/d1_build_where.dart +93 -0
  76. package/dart-src/plugins/cloudflare/lib/src/d1_bundle_mapper.dart +131 -0
  77. package/dart-src/plugins/cloudflare/lib/src/d1_client.dart +66 -0
  78. package/dart-src/plugins/cloudflare/lib/src/d1_config.dart +30 -0
  79. package/dart-src/plugins/cloudflare/lib/src/d1_database.dart +16 -0
  80. package/dart-src/plugins/cloudflare/lib/src/d1_database_plugin.dart +314 -0
  81. package/dart-src/plugins/cloudflare/lib/src/r2_config.dart +50 -0
  82. package/dart-src/plugins/cloudflare/lib/src/r2_s3_client.dart +287 -0
  83. package/dart-src/plugins/cloudflare/lib/src/r2_storage.dart +23 -0
  84. package/dart-src/plugins/cloudflare/lib/src/r2_storage_profile.dart +110 -0
  85. package/dart-src/plugins/cloudflare/migrations/0001_hot-updater_init.sql +16 -0
  86. package/dart-src/plugins/cloudflare/migrations/0002_hot-updater_0.13.0.sql +8 -0
  87. package/dart-src/plugins/cloudflare/migrations/0003_hot-updater_0.18.0.sql +38 -0
  88. package/dart-src/plugins/cloudflare/migrations/0004_hot-updater_0.29.0.sql +11 -0
  89. package/dart-src/plugins/cloudflare/migrations/0005_hot-updater_0.31.0.sql +24 -0
  90. package/dart-src/plugins/cloudflare/pubspec.yaml +20 -0
  91. package/dart-src/plugins/cloudflare/sql/bundles.sql +42 -0
  92. package/dart-src/plugins/plugin-core/lib/flutter_patcher_plugin_core.dart +42 -0
  93. package/dart-src/plugins/plugin-core/lib/src/asset_storage_layout.dart +94 -0
  94. package/dart-src/plugins/plugin-core/lib/src/bundle_storage_layout.dart +44 -0
  95. package/dart-src/plugins/plugin-core/lib/src/bundle_unit_of_work.dart +259 -0
  96. package/dart-src/plugins/plugin-core/lib/src/bundle_unit_of_work_store.dart +20 -0
  97. package/dart-src/plugins/plugin-core/lib/src/calculate_pagination.dart +29 -0
  98. package/dart-src/plugins/plugin-core/lib/src/compression_format.dart +63 -0
  99. package/dart-src/plugins/plugin-core/lib/src/content_addressed_assets.dart +22 -0
  100. package/dart-src/plugins/plugin-core/lib/src/create_blob_database_plugin.dart +853 -0
  101. package/dart-src/plugins/plugin-core/lib/src/create_database_plugin.dart +525 -0
  102. package/dart-src/plugins/plugin-core/lib/src/create_storage_key_builder.dart +15 -0
  103. package/dart-src/plugins/plugin-core/lib/src/create_storage_plugin.dart +130 -0
  104. package/dart-src/plugins/plugin-core/lib/src/filter_compatible_app_versions.dart +18 -0
  105. package/dart-src/plugins/plugin-core/lib/src/generate_min_bundle_id.dart +19 -0
  106. package/dart-src/plugins/plugin-core/lib/src/get_update_info.dart +241 -0
  107. package/dart-src/plugins/plugin-core/lib/src/is_object.dart +5 -0
  108. package/dart-src/plugins/plugin-core/lib/src/legacy_asset_storage_layout.dart +9 -0
  109. package/dart-src/plugins/plugin-core/lib/src/paginate_bundles.dart +112 -0
  110. package/dart-src/plugins/plugin-core/lib/src/parse_storage_uri.dart +44 -0
  111. package/dart-src/plugins/plugin-core/lib/src/query_bundles.dart +70 -0
  112. package/dart-src/plugins/plugin-core/lib/src/request_update_bundle_state.dart +62 -0
  113. package/dart-src/plugins/plugin-core/lib/src/resolve_update_info_from_bundles.dart +43 -0
  114. package/dart-src/plugins/plugin-core/lib/src/storage_profile.dart +27 -0
  115. package/dart-src/plugins/plugin-core/lib/src/types.dart +454 -0
  116. package/dart-src/plugins/plugin-core/lib/src/uuidv7.dart +41 -0
  117. package/dart-src/plugins/plugin-core/pubspec.yaml +15 -0
  118. package/dart-src/plugins/postgres/.gitkeep +0 -0
  119. package/dart-src/plugins/postgres/lib/flutter_patcher_postgres.dart +17 -0
  120. package/dart-src/plugins/postgres/lib/src/postgres_bundle_mapper.dart +130 -0
  121. package/dart-src/plugins/postgres/lib/src/postgres_client.dart +90 -0
  122. package/dart-src/plugins/postgres/lib/src/postgres_config.dart +35 -0
  123. package/dart-src/plugins/postgres/lib/src/postgres_database.dart +302 -0
  124. package/dart-src/plugins/postgres/lib/src/postgres_get_update_info.dart +120 -0
  125. package/dart-src/plugins/postgres/lib/src/postgres_storage.dart +205 -0
  126. package/dart-src/plugins/postgres/lib/src/postgres_types.dart +132 -0
  127. package/dart-src/plugins/postgres/pubspec.yaml +18 -0
  128. package/dart-src/plugins/postgres/sql/bundles.sql +45 -0
  129. package/dart-src/plugins/postgres/sql/get_target_app_version_list.sql +21 -0
  130. package/dart-src/plugins/postgres/sql/get_update_info.spec.ts +171 -0
  131. package/dart-src/plugins/postgres/sql/get_update_info_by_app_version.sql +126 -0
  132. package/dart-src/plugins/postgres/sql/get_update_info_by_fingerprint_hash.sql +125 -0
  133. package/dart-src/plugins/postgres/sql/hash_user_id.sql +30 -0
  134. package/dart-src/plugins/postgres/sql/is_device_eligible.sql +237 -0
  135. package/dart-src/plugins/postgres/sql/prepareSql.ts +11 -0
  136. package/dart-src/plugins/standalone/.gitkeep +0 -0
  137. package/dart-src/plugins/standalone/lib/flutter_patcher_standalone.dart +12 -0
  138. package/dart-src/plugins/standalone/lib/src/standalone_config.dart +107 -0
  139. package/dart-src/plugins/standalone/lib/src/standalone_database.dart +233 -0
  140. package/dart-src/plugins/standalone/lib/src/standalone_storage.dart +175 -0
  141. package/dart-src/plugins/standalone/pubspec.yaml +18 -0
  142. package/dart-src/plugins/supabase/lib/edge.dart +10 -0
  143. package/dart-src/plugins/supabase/lib/flutter_patcher_supabase.dart +21 -0
  144. package/dart-src/plugins/supabase/lib/src/supabase_bundle_mapper.dart +129 -0
  145. package/dart-src/plugins/supabase/lib/src/supabase_client_adapter.dart +154 -0
  146. package/dart-src/plugins/supabase/lib/src/supabase_client_http.dart +516 -0
  147. package/dart-src/plugins/supabase/lib/src/supabase_config.dart +34 -0
  148. package/dart-src/plugins/supabase/lib/src/supabase_database.dart +422 -0
  149. package/dart-src/plugins/supabase/lib/src/supabase_edge_function_database.dart +32 -0
  150. package/dart-src/plugins/supabase/lib/src/supabase_edge_function_storage.dart +99 -0
  151. package/dart-src/plugins/supabase/lib/src/supabase_signed_url_batcher.dart +145 -0
  152. package/dart-src/plugins/supabase/lib/src/supabase_storage.dart +298 -0
  153. package/dart-src/plugins/supabase/lib/src/types.dart +130 -0
  154. package/dart-src/plugins/supabase/pubspec.yaml +19 -0
  155. package/dart-src/plugins/supabase/supabase/edge-functions/index.ts +44 -0
  156. package/dart-src/plugins/supabase/supabase/edge-functions/runtime.docker.integration.spec.ts +968 -0
  157. package/dart-src/plugins/supabase/supabase/migrations/20250103114225_init.sql +228 -0
  158. package/dart-src/plugins/supabase/supabase/migrations/20250314000000_hot-updater_0.13.0.sql +134 -0
  159. package/dart-src/plugins/supabase/supabase/migrations/20250516000000_hot-updater_0.18.0.sql +194 -0
  160. package/dart-src/plugins/supabase/supabase/migrations/20251014000000_hot-updater_0.21.0.sql +184 -0
  161. package/dart-src/plugins/supabase/supabase/migrations/20260401000000_hot-updater_0.29.0.sql +503 -0
  162. package/dart-src/plugins/supabase/supabase/migrations/20260414000000_hot-updater_0.30.0.sql +50 -0
  163. package/dart-src/plugins/supabase/supabase/migrations/20260422000000_hot-updater_0.31.0.sql +20 -0
  164. package/dart-src/plugins/supabase/supabase/migrations/20260520014100_hot-updater_rls.sql +48 -0
  165. package/package.json +2 -1
  166. package/scripts/postinstall.js +53 -25
  167. /package/bin/{flutter-patcher-linux → flutter-patcher-linux-x64} +0 -0
@@ -0,0 +1,206 @@
1
+ import 'dart:convert';
2
+ import 'dart:io';
3
+
4
+ import 'package:crypto/crypto.dart';
5
+ import 'package:flutter_patcher_core/flutter_patcher_core.dart';
6
+ import 'package:flutter_patcher_plugin_core/flutter_patcher_plugin_core.dart';
7
+
8
+ import 'backend.dart';
9
+ import 'sign.dart';
10
+ import 'util.dart';
11
+
12
+ /// Options for [deployBundle].
13
+ class DeployOptions {
14
+ const DeployOptions({
15
+ required this.source,
16
+ required this.channel,
17
+ required this.platform,
18
+ this.message,
19
+ this.force = false,
20
+ this.targetAppVersion,
21
+ this.fingerprintHash,
22
+ this.signingKeyBase64,
23
+ this.gitCommitHash,
24
+ this.bundleId,
25
+ this.metadata,
26
+ });
27
+
28
+ final String source;
29
+ final String channel;
30
+ final String platform;
31
+ final String? message;
32
+ final bool force;
33
+ final String? targetAppVersion;
34
+ final String? fingerprintHash;
35
+ final String? signingKeyBase64;
36
+ final String? gitCommitHash;
37
+ final String? bundleId;
38
+ final BundleMetadata? metadata;
39
+ }
40
+
41
+ /// Options for [listBundles].
42
+ class ListOptions {
43
+ const ListOptions({
44
+ this.channel,
45
+ this.platform,
46
+ this.enabled,
47
+ this.limit = 20,
48
+ });
49
+
50
+ final String? channel;
51
+ final String? platform;
52
+ final bool? enabled;
53
+ final int limit;
54
+ }
55
+
56
+ /// Build the zip, upload it, and register a [Bundle] via the backend.
57
+ Future<Bundle> deployBundle(Backend backend, DeployOptions opts) async {
58
+ if (opts.targetAppVersion != null && opts.fingerprintHash != null) {
59
+ throw StateError(
60
+ 'Use only one of target-app-version / fingerprint-hash (DB CHECK).',
61
+ );
62
+ }
63
+ // Resolve the artifact to upload. If the source is already a zip (e.g. the
64
+ // `patch.zip` produced by `flutter_patcher build`/`pack`), upload it directly.
65
+ // Otherwise, if the source directory contains a `patch.zip`, use that. As a
66
+ // fallback, zip the source directory (for pre-built bundle layouts).
67
+ final sourceFile = File(opts.source);
68
+ final bool sourceIsFile = sourceFile.existsSync() && sourceFile.statSync().type == FileSystemEntityType.file;
69
+ final String zipPath;
70
+ final bool shouldDelete;
71
+ if (sourceIsFile && opts.source.endsWith('.zip')) {
72
+ zipPath = opts.source;
73
+ shouldDelete = false;
74
+ } else {
75
+ final dir = Directory(opts.source);
76
+ final packaged = File('${dir.path}/patch.zip');
77
+ if (await packaged.exists()) {
78
+ zipPath = packaged.path;
79
+ shouldDelete = false;
80
+ } else {
81
+ zipPath = await zipDirectory(opts.source);
82
+ shouldDelete = true;
83
+ }
84
+ }
85
+
86
+ try {
87
+ final zipBytes = await File(zipPath).readAsBytes();
88
+ // Device SDK verifies the artifact against an MD5 hex of the whole file,
89
+ // so `fileHash` is always the MD5 hex (never a `sig:`-prefixed value).
90
+ final md5Hex = md5.convert(zipBytes).toString();
91
+ final signature = opts.signingKeyBase64 != null
92
+ ? await ed25519Sign(utf8.encode(md5Hex), opts.signingKeyBase64!)
93
+ : null;
94
+
95
+ final bundleId = opts.bundleId ?? uuidV7();
96
+ final key = backend.storageKeyFor(bundleId, 'patch.zip');
97
+ final uploaded = await backend.storage.upload(key, zipPath);
98
+ final storageUri = uploaded['storageUri'];
99
+ if (storageUri == null || storageUri.isEmpty) {
100
+ throw StateError('Storage upload returned no storageUri.');
101
+ }
102
+
103
+ final bundle = Bundle(
104
+ id: bundleId,
105
+ platform: Platform.fromValue(opts.platform),
106
+ shouldForceUpdate: opts.force,
107
+ enabled: true,
108
+ fileHash: md5Hex,
109
+ storageUri: storageUri,
110
+ channel: opts.channel,
111
+ message: opts.message,
112
+ gitCommitHash: opts.gitCommitHash,
113
+ targetAppVersion: opts.targetAppVersion,
114
+ fingerprintHash: opts.fingerprintHash,
115
+ metadata:
116
+ opts.metadata ?? (signature != null ? BundleMetadata(signature: signature) : null),
117
+ rolloutCohortCount: defaultRolloutCohortCount,
118
+ );
119
+
120
+ await backend.db.appendBundle(bundle);
121
+ await backend.db.commitBundle();
122
+ return bundle;
123
+ } finally {
124
+ if (shouldDelete) {
125
+ try {
126
+ await File(zipPath).delete();
127
+ } catch (_) {
128
+ // best effort cleanup.
129
+ }
130
+ }
131
+ }
132
+ }
133
+
134
+ /// List bundles, filtered by the supplied options.
135
+ Future<Paginated<List<Bundle>>> listBundles(
136
+ Backend backend,
137
+ ListOptions opts,
138
+ ) async {
139
+ return backend.db.getBundles(
140
+ DatabaseBundleQueryOptions(
141
+ where: DatabaseBundleQueryWhere(
142
+ channel: opts.channel,
143
+ platform: opts.platform != null ? Platform.fromValue(opts.platform!) : null,
144
+ enabled: opts.enabled,
145
+ ),
146
+ limit: opts.limit,
147
+ orderBy: const DatabaseBundleQueryOrder(direction: 'desc'),
148
+ ),
149
+ );
150
+ }
151
+
152
+ /// Delete a bundle by id.
153
+ Future<void> deleteBundle(Backend backend, String id) async {
154
+ final existing = await backend.db.getBundleById(id);
155
+ if (existing == null) {
156
+ throw StateError('Bundle "$id" not found.');
157
+ }
158
+ await backend.db.deleteBundle(existing);
159
+ await backend.db.commitBundle();
160
+ }
161
+
162
+ /// Promote a bundle to a channel (enable + assign channel).
163
+ Future<void> promoteBundle(
164
+ Backend backend,
165
+ String id,
166
+ String channel,
167
+ ) async {
168
+ final existing = await backend.db.getBundleById(id);
169
+ if (existing == null) {
170
+ throw StateError('Bundle "$id" not found.');
171
+ }
172
+ await backend.db.updateBundle(id, {'enabled': true, 'channel': channel});
173
+ await backend.db.commitBundle();
174
+ }
175
+
176
+ /// Disable the latest enabled bundle on a channel (rollback).
177
+ ///
178
+ /// Returns the id of the bundle that was disabled.
179
+ Future<String> rollbackChannel(Backend backend, String channel) async {
180
+ final res = await backend.db.getBundles(
181
+ DatabaseBundleQueryOptions(
182
+ where: DatabaseBundleQueryWhere(channel: channel, enabled: true),
183
+ limit: 1,
184
+ orderBy: const DatabaseBundleQueryOrder(direction: 'desc'),
185
+ ),
186
+ );
187
+ if (res.data.isEmpty) {
188
+ throw StateError('No enabled bundle on channel "$channel" to roll back.');
189
+ }
190
+ final target = res.data.first;
191
+ await backend.db.updateBundle(target.id, {'enabled': false});
192
+ await backend.db.commitBundle();
193
+ return target.id;
194
+ }
195
+
196
+ /// List channels configured on the backend.
197
+ Future<List<String>> listChannels(Backend backend) => backend.db.getChannels();
198
+
199
+ /// Return the currently enabled (live) bundle for a channel, if any.
200
+ Future<Bundle?> getChannel(Backend backend, String channel) async {
201
+ final res = await listBundles(
202
+ backend,
203
+ ListOptions(channel: channel, enabled: true, limit: 1),
204
+ );
205
+ return res.data.isEmpty ? null : res.data.first;
206
+ }
@@ -0,0 +1,417 @@
1
+ import 'dart:convert';
2
+ import 'dart:io';
3
+ import 'dart:typed_data';
4
+
5
+ import 'package:archive/archive.dart';
6
+ import 'package:crypto/crypto.dart';
7
+
8
+ const List<String> abiPriority = <String>['arm64-v8a', 'armeabi-v7a', 'x86_64'];
9
+ const String flutterAssetsPrefix = 'assets/flutter_assets/';
10
+ const String assetManifestPath = '${flutterAssetsPrefix}AssetManifest.bin';
11
+
12
+ /// Result of a successful [packPatch] run.
13
+ class PackResult {
14
+ const PackResult({
15
+ required this.payloadPath,
16
+ required this.manifestPath,
17
+ required this.abis,
18
+ required this.md5,
19
+ required this.version,
20
+ required this.targetVersionCode,
21
+ required this.assetCount,
22
+ });
23
+
24
+ final String payloadPath;
25
+ final String manifestPath;
26
+ final List<String> abis;
27
+ final String md5;
28
+ final String version;
29
+ final int targetVersionCode;
30
+ final int assetCount;
31
+ }
32
+
33
+ /// Pack a release APK into a device-compatible OTA patch (`patch.zip`).
34
+ ///
35
+ /// Mirrors the standalone `bin/pack.dart` CLI, but as a library usable by the
36
+ /// `flutter_patcher build` command. Includes **every** ABI found in the APK so
37
+ /// a single bundle serves all device ABIs (the device SDK picks its ABI).
38
+ Future<PackResult> packPatch({
39
+ required String apkPath,
40
+ required String version,
41
+ required int targetVersionCode,
42
+ String? abi,
43
+ List<String> requestedAssets = const [],
44
+ required String out,
45
+ }) async {
46
+ final apkFile = File(apkPath);
47
+ if (!apkFile.existsSync()) {
48
+ throw PackException('APK not found: $apkPath');
49
+ }
50
+
51
+ final apkBytes = apkFile.readAsBytesSync();
52
+ final archive = ZipDecoder().decodeBytes(apkBytes);
53
+
54
+ final libs = _collectLibapps(archive);
55
+ if (libs.isEmpty) {
56
+ throw PackException(
57
+ 'libapp.so not found in APK for any of $abiPriority '
58
+ '(or requested --abi $abi).',
59
+ );
60
+ }
61
+
62
+ if (abi != null && !libs.any((e) => e.$1 == abi)) {
63
+ throw PackException('requested --abi $abi not present in APK.');
64
+ }
65
+
66
+ final outDir = Directory(out);
67
+ outDir.createSync(recursive: true);
68
+ final result = _writePatchPackage(
69
+ outDir: outDir,
70
+ archive: archive,
71
+ libs: libs,
72
+ version: version,
73
+ targetVersionCode: targetVersionCode,
74
+ requestedAssets: requestedAssets,
75
+ );
76
+ return result;
77
+ }
78
+
79
+ PackResult _writePatchPackage({
80
+ required Directory outDir,
81
+ required Archive archive,
82
+ required List<(String, List<int>)> libs,
83
+ required String version,
84
+ required int targetVersionCode,
85
+ required List<String> requestedAssets,
86
+ }) {
87
+ final patchFiles = <String, _PatchAssetFile>{};
88
+ final operations = <Map<String, dynamic>>[];
89
+ var baseManifestSize = 0;
90
+
91
+ if (requestedAssets.isNotEmpty) {
92
+ final manifestEntry = _findFile(archive, assetManifestPath);
93
+ if (manifestEntry == null) {
94
+ throw PackException(
95
+ 'AssetManifest.bin not found in APK: $assetManifestPath',
96
+ );
97
+ }
98
+ final manifestBytes = _archiveFileBytes(manifestEntry);
99
+ baseManifestSize = manifestBytes.length;
100
+ final decoded = _FlutterStandardMessageCodec().decode(manifestBytes);
101
+ if (decoded is! Map) {
102
+ throw PackException('AssetManifest.bin must decode to a map.');
103
+ }
104
+ final assetManifest = Map<String, dynamic>.from(decoded);
105
+
106
+ for (final key in requestedAssets) {
107
+ final variantsRaw = assetManifest[key];
108
+ if (variantsRaw is! List) {
109
+ throw PackException(
110
+ 'asset key not found in AssetManifest.bin: $key');
111
+ }
112
+ final variants = variantsRaw
113
+ .map((variant) => Map<String, dynamic>.from(variant as Map))
114
+ .toList(growable: false);
115
+ operations.add({
116
+ 'op': 'upsert',
117
+ 'key': key,
118
+ 'variants': variants,
119
+ });
120
+ for (final variant in variants) {
121
+ final assetPath = variant['asset'];
122
+ if (assetPath is! String || assetPath.isEmpty) {
123
+ throw PackException('invalid variant asset for key $key');
124
+ }
125
+ _validateArchiveRelativePath(assetPath, 'asset path');
126
+ final apkPath = '$flutterAssetsPrefix$assetPath';
127
+ final entry = _findFile(archive, apkPath);
128
+ if (entry == null) {
129
+ throw PackException(
130
+ 'variant file for $key not found in APK: $apkPath',
131
+ );
132
+ }
133
+ final bytes = _archiveFileBytes(entry);
134
+ patchFiles[assetPath] = _PatchAssetFile(
135
+ path: assetPath,
136
+ bytes: bytes,
137
+ md5Hex: md5.convert(bytes).toString(),
138
+ );
139
+ }
140
+ }
141
+ }
142
+
143
+ final libManifest = <String, dynamic>{
144
+ for (final (currentAbi, soBytes) in libs) currentAbi: {
145
+ 'path': 'lib/$currentAbi/libapp.so',
146
+ 'md5': md5.convert(soBytes).toString(),
147
+ },
148
+ };
149
+
150
+ final zipManifest = <String, dynamic>{
151
+ 'schemaVersion': 2,
152
+ 'version': version,
153
+ 'targetVersionCode': targetVersionCode,
154
+ 'lib': libManifest,
155
+ if (requestedAssets.isNotEmpty)
156
+ 'assets': {
157
+ 'mode': 'overlay',
158
+ 'manifestPatch': 'manifest_patch.json',
159
+ 'prefix': 'assets/',
160
+ 'files': patchFiles.values
161
+ .map((file) => {
162
+ 'path': file.path,
163
+ 'md5': file.md5Hex,
164
+ 'size': file.bytes.length,
165
+ })
166
+ .toList(),
167
+ },
168
+ };
169
+
170
+ final package = Archive()
171
+ ..addFile(_jsonArchiveFile('manifest.json', zipManifest));
172
+ for (final (currentAbi, soBytes) in libs) {
173
+ package.addFile(
174
+ ArchiveFile('lib/$currentAbi/libapp.so', soBytes.length, soBytes));
175
+ }
176
+
177
+ if (requestedAssets.isNotEmpty) {
178
+ final manifestPatch = <String, dynamic>{
179
+ 'schemaVersion': 1,
180
+ 'manifestFormat': 'bin',
181
+ 'baseManifestSize': baseManifestSize,
182
+ 'operations': operations,
183
+ };
184
+ package.addFile(_jsonArchiveFile('manifest_patch.json', manifestPatch));
185
+ for (final file in patchFiles.values) {
186
+ package.addFile(
187
+ ArchiveFile('assets/${file.path}', file.bytes.length, file.bytes),
188
+ );
189
+ }
190
+ }
191
+
192
+ // archive 3.x returns List<int>?, 4.x returns List<int>; coerce to bytes.
193
+ // ignore: unnecessary_nullable_for_final_variable_declarations, dead_null_aware_expression
194
+ final List<int> packageBytes = ZipEncoder().encode(package) ?? const <int>[];
195
+ final outZip = File('${outDir.path}/patch.zip');
196
+ outZip.writeAsBytesSync(packageBytes);
197
+
198
+ final payloadMd5 = md5.convert(packageBytes).toString();
199
+ final outerManifest = <String, dynamic>{
200
+ 'schemaVersion': 2,
201
+ 'version': version,
202
+ 'md5': payloadMd5,
203
+ 'targetVersionCode': targetVersionCode,
204
+ 'abis': libs.map((e) => e.$1).toList(),
205
+ 'payload': 'patch.zip',
206
+ };
207
+ _writeJson(File('${outDir.path}/manifest.json'), outerManifest);
208
+
209
+ return PackResult(
210
+ payloadPath: outZip.path,
211
+ manifestPath: '${outDir.path}/manifest.json',
212
+ abis: libs.map((e) => e.$1).toList(),
213
+ md5: payloadMd5,
214
+ version: version,
215
+ targetVersionCode: targetVersionCode,
216
+ assetCount: patchFiles.length,
217
+ );
218
+ }
219
+
220
+ ArchiveFile _jsonArchiveFile(String name, Map<String, dynamic> json) {
221
+ final bytes =
222
+ utf8.encode('${const JsonEncoder.withIndent(' ').convert(json)}\n');
223
+ return ArchiveFile(name, bytes.length, bytes);
224
+ }
225
+
226
+ /// Collect every `lib/<abi>/libapp.so` present in the APK.
227
+ List<(String, List<int>)> _collectLibapps(Archive archive) {
228
+ final found = <(String, List<int>)>[];
229
+ final seen = <String>{};
230
+ final regex = RegExp(r'^lib/([^/]+)/libapp\.so$');
231
+ for (final file in archive.files) {
232
+ final m = regex.firstMatch(file.name);
233
+ if (m != null && seen.add(m.group(1)!)) {
234
+ found.add((m.group(1)!, _archiveFileBytes(file)));
235
+ }
236
+ }
237
+ found.sort((a, b) {
238
+ final ia = abiPriority.indexOf(a.$1);
239
+ final ib = abiPriority.indexOf(b.$1);
240
+ final ra = ia < 0 ? _nonPriorityRank(a.$1) : ia;
241
+ final rb = ib < 0 ? _nonPriorityRank(b.$1) : ib;
242
+ return ra.compareTo(rb);
243
+ });
244
+ return found;
245
+ }
246
+
247
+ int _nonPriorityRank(String abi) => abiPriority.length;
248
+
249
+ ArchiveFile? _findFile(Archive archive, String name) {
250
+ for (final file in archive.files) {
251
+ if (!file.isFile) continue;
252
+ if (file.name == name) return file;
253
+ }
254
+ return null;
255
+ }
256
+
257
+ void _writeJson(File file, Map<String, dynamic> json) {
258
+ file.writeAsStringSync(
259
+ '${const JsonEncoder.withIndent(' ').convert(json)}\n');
260
+ }
261
+
262
+ void _validateArchiveRelativePath(String path, String label) {
263
+ if (path.isEmpty ||
264
+ path.startsWith('/') ||
265
+ path.startsWith('\\') ||
266
+ path.contains('\u0000') ||
267
+ path.split('/').contains('..')) {
268
+ throw PackException('invalid $label: $path');
269
+ }
270
+ }
271
+
272
+ List<int> _archiveFileBytes(ArchiveFile file) {
273
+ final dynamic dynamicFile = file;
274
+ try {
275
+ return List<int>.from(dynamicFile.readBytes() as List<int>);
276
+ } on NoSuchMethodError {
277
+ return List<int>.from(dynamicFile.content as List<int>);
278
+ }
279
+ }
280
+
281
+ class _PatchAssetFile {
282
+ const _PatchAssetFile({
283
+ required this.path,
284
+ required this.bytes,
285
+ required this.md5Hex,
286
+ });
287
+
288
+ final String path;
289
+ final List<int> bytes;
290
+ final String md5Hex;
291
+ }
292
+
293
+ /// Error carrying a stable CLI exit code.
294
+ class PackException implements Exception {
295
+ const PackException(this.message, [this.exitCode = 1]);
296
+
297
+ final String message;
298
+ final int exitCode;
299
+ }
300
+
301
+ class _FlutterStandardMessageCodec {
302
+ late ByteData _data;
303
+ late int _offset;
304
+
305
+ Object? decode(List<int> bytes) {
306
+ final list = Uint8List.fromList(bytes);
307
+ _data = ByteData.sublistView(list);
308
+ _offset = 0;
309
+ final value = _readValue();
310
+ if (_offset != _data.lengthInBytes) {
311
+ throw PackException('AssetManifest.bin has trailing bytes.');
312
+ }
313
+ return value;
314
+ }
315
+
316
+ Object? _readValue() {
317
+ final type = _readUint8();
318
+ switch (type) {
319
+ case 0:
320
+ return null;
321
+ case 1:
322
+ return true;
323
+ case 2:
324
+ return false;
325
+ case 3:
326
+ return _readInt32();
327
+ case 4:
328
+ return _readInt64();
329
+ case 6:
330
+ _alignTo(8);
331
+ return _readFloat64();
332
+ case 7:
333
+ final len = _readSize();
334
+ final bytes = _readBytes(len);
335
+ return utf8.decode(bytes);
336
+ case 12:
337
+ final len = _readSize();
338
+ return List<Object?>.generate(len, (_) => _readValue());
339
+ case 13:
340
+ final len = _readSize();
341
+ final map = <Object?, Object?>{};
342
+ for (var i = 0; i < len; i++) {
343
+ map[_readValue()] = _readValue();
344
+ }
345
+ return map;
346
+ default:
347
+ throw PackException(
348
+ 'unsupported StandardMessageCodec type in AssetManifest.bin: $type',
349
+ );
350
+ }
351
+ }
352
+
353
+ int _readSize() {
354
+ final first = _readUint8();
355
+ if (first < 254) return first;
356
+ if (first == 254) return _readUint16();
357
+ return _readUint32();
358
+ }
359
+
360
+ int _readUint8() {
361
+ _ensure(1);
362
+ return _data.getUint8(_offset++);
363
+ }
364
+
365
+ int _readUint16() {
366
+ _ensure(2);
367
+ final value = _data.getUint16(_offset, Endian.little);
368
+ _offset += 2;
369
+ return value;
370
+ }
371
+
372
+ int _readUint32() {
373
+ _ensure(4);
374
+ final value = _data.getUint32(_offset, Endian.little);
375
+ _offset += 4;
376
+ return value;
377
+ }
378
+
379
+ int _readInt32() {
380
+ _ensure(4);
381
+ final value = _data.getInt32(_offset, Endian.little);
382
+ _offset += 4;
383
+ return value;
384
+ }
385
+
386
+ int _readInt64() {
387
+ _ensure(8);
388
+ final value = _data.getInt64(_offset, Endian.little);
389
+ _offset += 8;
390
+ return value;
391
+ }
392
+
393
+ double _readFloat64() {
394
+ _ensure(8);
395
+ final value = _data.getFloat64(_offset, Endian.little);
396
+ _offset += 8;
397
+ return value;
398
+ }
399
+
400
+ Uint8List _readBytes(int length) {
401
+ _ensure(length);
402
+ final start = _offset;
403
+ _offset += length;
404
+ return _data.buffer.asUint8List(start, length);
405
+ }
406
+
407
+ void _alignTo(int alignment) {
408
+ final mod = _offset % alignment;
409
+ if (mod != 0) _offset += alignment - mod;
410
+ }
411
+
412
+ void _ensure(int length) {
413
+ if (_offset + length > _data.lengthInBytes) {
414
+ throw PackException('truncated AssetManifest.bin');
415
+ }
416
+ }
417
+ }
@@ -0,0 +1,60 @@
1
+ import 'dart:convert';
2
+
3
+ import 'package:cryptography/cryptography.dart';
4
+
5
+ /// Verify an Ed25519 signature over [message].
6
+ ///
7
+ /// [publicKeyBase64] may be either a raw 32-byte Ed25519 public key or the
8
+ /// X.509 SubjectPublicKeyInfo (SPKI) form expected by the Android device SDK.
9
+ Future<bool> ed25519Verify(
10
+ List<int> message,
11
+ String signatureBase64,
12
+ String publicKeyBase64,
13
+ ) async {
14
+ var pkBytes = base64Decode(publicKeyBase64);
15
+ // Strip the X.509 SPKI prefix (12 bytes) if present so [SimplePublicKey]
16
+ // receives the raw 32-byte key.
17
+ if (pkBytes.length == 44 && _startsWith(pkBytes, _ed25519SpkiPrefix)) {
18
+ pkBytes = pkBytes.sublist(_ed25519SpkiPrefix.length);
19
+ }
20
+ final public = SimplePublicKey(pkBytes, type: KeyPairType.ed25519);
21
+ final sig = Signature(base64Decode(signatureBase64), publicKey: public);
22
+ return Ed25519().verify(message, signature: sig);
23
+ }
24
+
25
+ /// The 12-byte DER prefix of an Ed25519 X.509 SubjectPublicKeyInfo.
26
+ const List<int> _ed25519SpkiPrefix = <int>[
27
+ 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
28
+ ];
29
+
30
+ bool _startsWith(List<int> bytes, List<int> prefix) {
31
+ if (bytes.length < prefix.length) return false;
32
+ for (var i = 0; i < prefix.length; i++) {
33
+ if (bytes[i] != prefix[i]) return false;
34
+ }
35
+ return true;
36
+ }
37
+
38
+ /// Generate a fresh Ed25519 keypair, returning (privateSeedB64, publicB64).
39
+ ///
40
+ /// The returned public key is the X.509 SubjectPublicKeyInfo (SPKI) base64
41
+ /// form, which is exactly what the Android device SDK expects
42
+ /// (`SignatureVerifier.verifyEd25519` decodes X.509).
43
+ Future<(String, String)> generateEd25519KeyPair() async {
44
+ final keyPair = await Ed25519().newKeyPair();
45
+ final private = await keyPair.extractPrivateKeyBytes();
46
+ final rawPublic = (await keyPair.extractPublicKey()).bytes;
47
+ final spki = <int>[
48
+ ..._ed25519SpkiPrefix,
49
+ ...rawPublic,
50
+ ];
51
+ return (base64Encode(private), base64Encode(spki));
52
+ }
53
+
54
+ /// Sign [message] with an Ed25519 private seed (base64, 32 bytes).
55
+ Future<String> ed25519Sign(List<int> message, String privateKeyBase64) async {
56
+ final seed = base64Decode(privateKeyBase64);
57
+ final keyPair = await Ed25519().newKeyPairFromSeed(seed);
58
+ final signature = await Ed25519().sign(message, keyPair: keyPair);
59
+ return base64Encode(signature.bytes);
60
+ }