@craft-native/android 0.0.92 → 0.0.94
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 +13 -1
- package/dist/index.d.ts +111 -0
- package/dist/index.js +702 -0
- package/dist/index.js.map +11 -0
- package/dist/promise-runtime.d.ts +3 -0
- package/package.json +1 -1
- package/templates/CraftBridge.kt.template +271 -72
- package/templates/CraftNative.kt.template +12 -6
- package/templates/MainActivity.kt.template +86 -9
- package/LICENSE.md +0 -21
package/dist/index.js
ADDED
|
@@ -0,0 +1,702 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// src/index.ts
|
|
3
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "fs";
|
|
4
|
+
import { dirname, extname, isAbsolute, join, relative, resolve, sep } from "path";
|
|
5
|
+
var {$ } = globalThis.Bun;
|
|
6
|
+
|
|
7
|
+
// src/promise-runtime.ts
|
|
8
|
+
var ANDROID_PROMISE_RUNTIME = `
|
|
9
|
+
if (window.__craftRejectPendingPromises) {
|
|
10
|
+
window.__craftRejectPendingPromises('Android bridge reinitialized');
|
|
11
|
+
}
|
|
12
|
+
if (window.__craftRejectPermissionRequests) {
|
|
13
|
+
window.__craftRejectPermissionRequests('Android bridge reinitialized');
|
|
14
|
+
}
|
|
15
|
+
window.__craftPromiseRuntimeClosed = false;
|
|
16
|
+
window.__craftPendingPromises = Object.create(null);
|
|
17
|
+
window.__craftPromise = function(channel, resolveName, rejectName, invoke, timeoutMs, timeoutError) {
|
|
18
|
+
if (window.__craftPromiseRuntimeClosed) {
|
|
19
|
+
return Promise.reject(new Error('Android bridge is closed'));
|
|
20
|
+
}
|
|
21
|
+
if (window.__craftPendingPromises[channel]) {
|
|
22
|
+
return Promise.reject(new Error('A '.concat(channel, ' request is already in progress')));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return new Promise(function(resolve, reject) {
|
|
26
|
+
var entry = {settled: false, timer: null, settle: null};
|
|
27
|
+
var resolveCallback;
|
|
28
|
+
var rejectCallback;
|
|
29
|
+
|
|
30
|
+
entry.settle = function(succeeded, value) {
|
|
31
|
+
if (entry.settled || window.__craftPendingPromises[channel] !== entry) return;
|
|
32
|
+
entry.settled = true;
|
|
33
|
+
if (entry.timer !== null) clearTimeout(entry.timer);
|
|
34
|
+
if (window[resolveName] === resolveCallback) window[resolveName] = null;
|
|
35
|
+
if (window[rejectName] === rejectCallback) window[rejectName] = null;
|
|
36
|
+
delete window.__craftPendingPromises[channel];
|
|
37
|
+
if (succeeded) resolve(value);
|
|
38
|
+
else reject(value);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
resolveCallback = function(value) {
|
|
42
|
+
entry.settle(true, value);
|
|
43
|
+
};
|
|
44
|
+
rejectCallback = function(error) {
|
|
45
|
+
entry.settle(false, error);
|
|
46
|
+
};
|
|
47
|
+
window[resolveName] = resolveCallback;
|
|
48
|
+
window[rejectName] = rejectCallback;
|
|
49
|
+
window.__craftPendingPromises[channel] = entry;
|
|
50
|
+
|
|
51
|
+
if (timeoutMs > 0) {
|
|
52
|
+
entry.timer = setTimeout(function() {
|
|
53
|
+
entry.settle(false, timeoutError || new Error(channel.concat(' request timed out')));
|
|
54
|
+
}, timeoutMs);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
invoke();
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
entry.settle(false, error);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
window.__craftRejectPendingPromises = function(message) {
|
|
67
|
+
window.__craftPromiseRuntimeClosed = true;
|
|
68
|
+
Object.keys(window.__craftPendingPromises).forEach(function(channel) {
|
|
69
|
+
var entry = window.__craftPendingPromises[channel];
|
|
70
|
+
if (entry) entry.settle(false, new Error(message || 'Android bridge closed'));
|
|
71
|
+
});
|
|
72
|
+
};
|
|
73
|
+
`;
|
|
74
|
+
function renderAndroidPromiseRuntime(indent = "") {
|
|
75
|
+
return ANDROID_PROMISE_RUNTIME.trim().split(`
|
|
76
|
+
`).map((line) => `${indent}${line}`).join(`
|
|
77
|
+
`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// src/index.ts
|
|
81
|
+
var TEMPLATES_DIR = join(dirname(import.meta.dir), "templates");
|
|
82
|
+
var LOCAL_DEVELOPMENT_HOSTS = new Set(["localhost", "127.0.0.1", "10.0.2.2"]);
|
|
83
|
+
var KOTLIN_KEYWORDS = new Set([
|
|
84
|
+
"as",
|
|
85
|
+
"break",
|
|
86
|
+
"class",
|
|
87
|
+
"continue",
|
|
88
|
+
"do",
|
|
89
|
+
"else",
|
|
90
|
+
"false",
|
|
91
|
+
"for",
|
|
92
|
+
"fun",
|
|
93
|
+
"if",
|
|
94
|
+
"in",
|
|
95
|
+
"interface",
|
|
96
|
+
"is",
|
|
97
|
+
"null",
|
|
98
|
+
"object",
|
|
99
|
+
"package",
|
|
100
|
+
"return",
|
|
101
|
+
"super",
|
|
102
|
+
"this",
|
|
103
|
+
"throw",
|
|
104
|
+
"true",
|
|
105
|
+
"try",
|
|
106
|
+
"typealias",
|
|
107
|
+
"typeof",
|
|
108
|
+
"val",
|
|
109
|
+
"var",
|
|
110
|
+
"when",
|
|
111
|
+
"while"
|
|
112
|
+
]);
|
|
113
|
+
var ANDROID_MIN_SDK = 26;
|
|
114
|
+
var DEFAULT_CONFIG = {
|
|
115
|
+
version: "1.0.0",
|
|
116
|
+
versionCode: 1,
|
|
117
|
+
darkMode: true,
|
|
118
|
+
backgroundColor: "#0b1712",
|
|
119
|
+
enableSpeechRecognition: false,
|
|
120
|
+
enableHaptics: false,
|
|
121
|
+
enableShare: false,
|
|
122
|
+
enableCamera: false,
|
|
123
|
+
enableBiometric: false,
|
|
124
|
+
enablePushNotifications: false,
|
|
125
|
+
enableSecureStorage: false,
|
|
126
|
+
enableGeolocation: false,
|
|
127
|
+
enableBackgroundLocation: false,
|
|
128
|
+
enableHealthConnect: false,
|
|
129
|
+
enableKeepAwake: false,
|
|
130
|
+
enableDeepLinks: false,
|
|
131
|
+
urlSchemes: [],
|
|
132
|
+
trustedOrigins: [],
|
|
133
|
+
minSdk: ANDROID_MIN_SDK,
|
|
134
|
+
compileSdk: 36,
|
|
135
|
+
targetSdk: 35
|
|
136
|
+
};
|
|
137
|
+
function escapeXml(value) {
|
|
138
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
139
|
+
}
|
|
140
|
+
function escapeKotlinString(value) {
|
|
141
|
+
return value.replaceAll("\\", "\\\\").replaceAll('"', "\\\"").replaceAll("$", "\\$").replaceAll("\r", "\\r").replaceAll(`
|
|
142
|
+
`, "\\n");
|
|
143
|
+
}
|
|
144
|
+
function generatedPackageSegment(name) {
|
|
145
|
+
const normalized = name.toLowerCase().replace(/[^a-z0-9_]/g, "");
|
|
146
|
+
if (!normalized)
|
|
147
|
+
return "app";
|
|
148
|
+
return /^[a-z_]/.test(normalized) ? normalized : `app${normalized}`;
|
|
149
|
+
}
|
|
150
|
+
function generatedGradleProjectName(name) {
|
|
151
|
+
const invalidCharacters = ["/", "\\", ":", "<", ">", '"', "?", "*", "|"];
|
|
152
|
+
const normalized = invalidCharacters.reduce((value, character) => value.replaceAll(character, "-"), name).trim();
|
|
153
|
+
return normalized || "craft-app";
|
|
154
|
+
}
|
|
155
|
+
function requireRegularFile(path, label) {
|
|
156
|
+
if (!existsSync(path))
|
|
157
|
+
throw new Error(`${label} not found: ${path}`);
|
|
158
|
+
if (!statSync(path).isFile())
|
|
159
|
+
throw new Error(`${label} must be a file: ${path}`);
|
|
160
|
+
}
|
|
161
|
+
function containsPath(parent, candidate) {
|
|
162
|
+
const relativePath = relative(parent, candidate);
|
|
163
|
+
return relativePath === "" || relativePath !== ".." && !relativePath.startsWith(`..${sep}`) && !isAbsolute(relativePath);
|
|
164
|
+
}
|
|
165
|
+
function validateGoogleServicesFile(path, packageName) {
|
|
166
|
+
requireRegularFile(path, "Google services file");
|
|
167
|
+
let document;
|
|
168
|
+
try {
|
|
169
|
+
document = JSON.parse(readFileSync(path, "utf8"));
|
|
170
|
+
} catch (error) {
|
|
171
|
+
throw new Error(`Google services file must contain valid JSON: ${path}`, { cause: error });
|
|
172
|
+
}
|
|
173
|
+
const matchingClient = document.client?.some((client) => {
|
|
174
|
+
return client.client_info?.android_client_info?.package_name === packageName;
|
|
175
|
+
});
|
|
176
|
+
if (!matchingClient) {
|
|
177
|
+
throw new Error(`Google services file has no client for Android package ${packageName}: ${path}`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
function androidWebUrl(value, field) {
|
|
181
|
+
let url;
|
|
182
|
+
try {
|
|
183
|
+
url = new URL(value);
|
|
184
|
+
} catch {
|
|
185
|
+
throw new Error(`Invalid ${field}: ${value}`);
|
|
186
|
+
}
|
|
187
|
+
const localDevelopment = url.protocol === "http:" && LOCAL_DEVELOPMENT_HOSTS.has(url.hostname);
|
|
188
|
+
if (url.protocol !== "https:" && !localDevelopment || url.username || url.password) {
|
|
189
|
+
throw new Error(`${field} must use HTTPS or local HTTP without credentials: ${value}`);
|
|
190
|
+
}
|
|
191
|
+
return url;
|
|
192
|
+
}
|
|
193
|
+
function normalizeAndroidNetworkConfig(config) {
|
|
194
|
+
const schemes = config.urlSchemes?.map((value) => value.trim().toLowerCase()) ?? [];
|
|
195
|
+
if (schemes.some((value) => !/^[a-z][a-z0-9+.-]*$/.test(value))) {
|
|
196
|
+
throw new Error("Android deep-link schemes must be valid URI schemes");
|
|
197
|
+
}
|
|
198
|
+
config.urlSchemes = [...new Set(schemes)];
|
|
199
|
+
if (config.enableDeepLinks && config.urlSchemes.length === 0) {
|
|
200
|
+
throw new Error("Android deep links require at least one URL scheme");
|
|
201
|
+
}
|
|
202
|
+
const trustedOrigins = (config.trustedOrigins ?? []).map((value) => {
|
|
203
|
+
return androidWebUrl(value, "Android trusted origin").origin;
|
|
204
|
+
});
|
|
205
|
+
if (config.devServerURL) {
|
|
206
|
+
const devServer = androidWebUrl(config.devServerURL, "Android dev server URL");
|
|
207
|
+
config.devServerURL = devServer.toString();
|
|
208
|
+
trustedOrigins.push(devServer.origin);
|
|
209
|
+
}
|
|
210
|
+
config.trustedOrigins = [...new Set(trustedOrigins)];
|
|
211
|
+
}
|
|
212
|
+
function validateAndroidConfig(config) {
|
|
213
|
+
if (!config.appName.trim())
|
|
214
|
+
throw new Error("Android app name must not be empty");
|
|
215
|
+
const packageSegments = config.packageName.split(".");
|
|
216
|
+
if (packageSegments.length < 2 || packageSegments.some((segment) => !/^[A-Za-z_][A-Za-z0-9_]*$/.test(segment) || KOTLIN_KEYWORDS.has(segment))) {
|
|
217
|
+
throw new Error(`Invalid Android package name: ${config.packageName}`);
|
|
218
|
+
}
|
|
219
|
+
if (!/^#(?:[\dA-F]{3,4}|[\dA-F]{6}|[\dA-F]{8})$/i.test(config.backgroundColor ?? "")) {
|
|
220
|
+
throw new Error(`Invalid Android background color: ${config.backgroundColor}`);
|
|
221
|
+
}
|
|
222
|
+
for (const [name, value] of [
|
|
223
|
+
["versionCode", config.versionCode],
|
|
224
|
+
["minSdk", config.minSdk],
|
|
225
|
+
["compileSdk", config.compileSdk],
|
|
226
|
+
["targetSdk", config.targetSdk]
|
|
227
|
+
]) {
|
|
228
|
+
if (!Number.isInteger(value) || Number(value) < 1) {
|
|
229
|
+
throw new Error(`Android ${name} must be a positive integer`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (Number(config.minSdk) < ANDROID_MIN_SDK) {
|
|
233
|
+
throw new Error(`Android minSdk must be at least ${ANDROID_MIN_SDK} (Android 8.0) for the Craft runtime`);
|
|
234
|
+
}
|
|
235
|
+
if (Number(config.minSdk) > Number(config.targetSdk)) {
|
|
236
|
+
throw new Error("Android minSdk must not exceed targetSdk");
|
|
237
|
+
}
|
|
238
|
+
if (Number(config.targetSdk) > Number(config.compileSdk)) {
|
|
239
|
+
throw new Error("Android targetSdk must not exceed compileSdk");
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
function writeAndroidConfig(output, config) {
|
|
243
|
+
writeFileSync(join(output, "craft.config.json"), JSON.stringify(config, null, 2));
|
|
244
|
+
const runtimeConfig = { ...config };
|
|
245
|
+
delete runtimeConfig.appIconPath;
|
|
246
|
+
delete runtimeConfig.googleServicesFile;
|
|
247
|
+
writeFileSync(join(output, "app/src/main/assets/craft.config.json"), JSON.stringify(runtimeConfig, null, 2));
|
|
248
|
+
}
|
|
249
|
+
function syncAndroidWebAssets(source, output) {
|
|
250
|
+
const sourcePath = resolve(source);
|
|
251
|
+
if (!existsSync(sourcePath))
|
|
252
|
+
throw new Error(`Web asset path not found: ${source}`);
|
|
253
|
+
const sourceStat = statSync(sourcePath);
|
|
254
|
+
if (!sourceStat.isDirectory() && !sourceStat.isFile()) {
|
|
255
|
+
throw new Error(`Web asset path must be a file or directory: ${source}`);
|
|
256
|
+
}
|
|
257
|
+
const assetsDir = resolve(output, "app/src/main/assets");
|
|
258
|
+
if (containsPath(sourcePath, assetsDir) || containsPath(assetsDir, sourcePath)) {
|
|
259
|
+
throw new Error(`Web asset source must not overlap generated asset directory: ${source}`);
|
|
260
|
+
}
|
|
261
|
+
if (sourceStat.isDirectory()) {
|
|
262
|
+
requireRegularFile(join(sourcePath, "index.html"), "Web asset directory entry point");
|
|
263
|
+
}
|
|
264
|
+
const configPath = join(assetsDir, "craft.config.json");
|
|
265
|
+
const config = existsSync(configPath) ? readFileSync(configPath) : undefined;
|
|
266
|
+
rmSync(assetsDir, { recursive: true, force: true });
|
|
267
|
+
mkdirSync(assetsDir, { recursive: true });
|
|
268
|
+
if (sourceStat.isDirectory())
|
|
269
|
+
cpSync(sourcePath, assetsDir, { recursive: true });
|
|
270
|
+
else
|
|
271
|
+
cpSync(sourcePath, join(assetsDir, "index.html"));
|
|
272
|
+
if (!existsSync(join(assetsDir, "index.html")))
|
|
273
|
+
throw new Error(`Web asset directory must contain index.html: ${source}`);
|
|
274
|
+
if (config)
|
|
275
|
+
writeFileSync(configPath, config);
|
|
276
|
+
}
|
|
277
|
+
function renderAndroidPermissions(config) {
|
|
278
|
+
const permissions = new Set(["android.permission.INTERNET", "android.permission.ACCESS_NETWORK_STATE"]);
|
|
279
|
+
if (config.enableSpeechRecognition)
|
|
280
|
+
permissions.add("android.permission.RECORD_AUDIO");
|
|
281
|
+
if (config.enableCamera)
|
|
282
|
+
permissions.add("android.permission.CAMERA");
|
|
283
|
+
if (config.enableHaptics)
|
|
284
|
+
permissions.add("android.permission.VIBRATE");
|
|
285
|
+
if (config.enableBiometric)
|
|
286
|
+
permissions.add("android.permission.USE_BIOMETRIC");
|
|
287
|
+
if (config.enableGeolocation || config.enableBackgroundLocation) {
|
|
288
|
+
permissions.add("android.permission.ACCESS_FINE_LOCATION");
|
|
289
|
+
permissions.add("android.permission.ACCESS_COARSE_LOCATION");
|
|
290
|
+
}
|
|
291
|
+
if (config.enableBackgroundLocation) {
|
|
292
|
+
permissions.add("android.permission.ACCESS_BACKGROUND_LOCATION");
|
|
293
|
+
permissions.add("android.permission.FOREGROUND_SERVICE");
|
|
294
|
+
permissions.add("android.permission.FOREGROUND_SERVICE_LOCATION");
|
|
295
|
+
}
|
|
296
|
+
if (config.enablePushNotifications)
|
|
297
|
+
permissions.add("android.permission.POST_NOTIFICATIONS");
|
|
298
|
+
if (config.enableKeepAwake)
|
|
299
|
+
permissions.add("android.permission.WAKE_LOCK");
|
|
300
|
+
if (config.enableHealthConnect) {
|
|
301
|
+
permissions.add("android.permission.health.READ_STEPS");
|
|
302
|
+
permissions.add("android.permission.health.READ_HEART_RATE");
|
|
303
|
+
permissions.add("android.permission.health.READ_ACTIVE_CALORIES_BURNED");
|
|
304
|
+
permissions.add("android.permission.health.READ_DISTANCE");
|
|
305
|
+
permissions.add("android.permission.health.READ_EXERCISE");
|
|
306
|
+
permissions.add("android.permission.health.WRITE_EXERCISE");
|
|
307
|
+
permissions.add("android.permission.health.WRITE_ACTIVE_CALORIES_BURNED");
|
|
308
|
+
permissions.add("android.permission.health.WRITE_DISTANCE");
|
|
309
|
+
}
|
|
310
|
+
return [...permissions].map((permission) => ` <uses-permission android:name="${permission}" />`).join(`
|
|
311
|
+
`);
|
|
312
|
+
}
|
|
313
|
+
function renderAndroidDeepLinks(config) {
|
|
314
|
+
if (!config.enableDeepLinks)
|
|
315
|
+
return "";
|
|
316
|
+
const schemes = [...new Set(config.urlSchemes?.map((value) => value.trim()).filter((value) => /^[a-z][a-z0-9+.-]*$/i.test(value)) ?? [])];
|
|
317
|
+
return schemes.map((scheme) => ` <intent-filter>
|
|
318
|
+
<action android:name="android.intent.action.VIEW" />
|
|
319
|
+
<category android:name="android.intent.category.DEFAULT" />
|
|
320
|
+
<category android:name="android.intent.category.BROWSABLE" />
|
|
321
|
+
<data android:scheme="${scheme}" />
|
|
322
|
+
</intent-filter>`).join(`
|
|
323
|
+
`);
|
|
324
|
+
}
|
|
325
|
+
var RUNTIME_ABIS = ["arm64-v8a", "x86_64"];
|
|
326
|
+
function resolveRuntimeDir(override) {
|
|
327
|
+
if (override === null)
|
|
328
|
+
return null;
|
|
329
|
+
const dir = override ?? process.env.CRAFT_ANDROID_RUNTIME;
|
|
330
|
+
if (!dir)
|
|
331
|
+
return null;
|
|
332
|
+
if (!existsSync(dir)) {
|
|
333
|
+
const source = override === undefined ? "CRAFT_ANDROID_RUNTIME points at" : "runtimeDir is";
|
|
334
|
+
throw new Error(`${source} ${dir}, which does not exist.`);
|
|
335
|
+
}
|
|
336
|
+
return dir;
|
|
337
|
+
}
|
|
338
|
+
function installRuntime(output, runtimeDir) {
|
|
339
|
+
const found = RUNTIME_ABIS.map((abi) => ({ abi, source: join(runtimeDir, abi, "libcraft.so") })).filter((entry) => existsSync(entry.source));
|
|
340
|
+
if (found.length === 0) {
|
|
341
|
+
throw new Error(`${runtimeDir} has no <abi>/libcraft.so for any of ${RUNTIME_ABIS.join(", ")}. ` + "Run `zig build build-android-all -Doptimize=ReleaseSafe` in packages/zig and point at its zig-out/android.");
|
|
342
|
+
}
|
|
343
|
+
if (found.length < RUNTIME_ABIS.length) {
|
|
344
|
+
const missing = RUNTIME_ABIS.filter((abi) => !found.some((entry) => entry.abi === abi));
|
|
345
|
+
console.warn(` \u26A0 only ${found.map((entry) => entry.abi).join(", ")} was found; ${missing.join(", ")} is missing. ` + "The app will fall back to the Kotlin shim on those devices.");
|
|
346
|
+
}
|
|
347
|
+
const dest = join(output, "app/src/main/jniLibs");
|
|
348
|
+
rmSync(dest, { force: true, recursive: true });
|
|
349
|
+
for (const { abi, source } of found) {
|
|
350
|
+
const abiDir = join(dest, abi);
|
|
351
|
+
mkdirSync(abiDir, { recursive: true });
|
|
352
|
+
cpSync(source, join(abiDir, "libcraft.so"));
|
|
353
|
+
}
|
|
354
|
+
return true;
|
|
355
|
+
}
|
|
356
|
+
async function init(options) {
|
|
357
|
+
const { name, packageName, output } = options;
|
|
358
|
+
console.log(`
|
|
359
|
+
\u26A1 Initializing Craft Android project: ${name}`);
|
|
360
|
+
console.log(` Output: ${output}
|
|
361
|
+
`);
|
|
362
|
+
const finalPackageName = packageName || `com.craft.${generatedPackageSegment(name)}`;
|
|
363
|
+
const packagePath = finalPackageName.replace(/\./g, "/");
|
|
364
|
+
const config = {
|
|
365
|
+
...DEFAULT_CONFIG,
|
|
366
|
+
...options.config,
|
|
367
|
+
appName: name,
|
|
368
|
+
packageName: finalPackageName
|
|
369
|
+
};
|
|
370
|
+
if (config.enableBackgroundLocation)
|
|
371
|
+
config.enableGeolocation = true;
|
|
372
|
+
if (config.enableHealthConnect) {
|
|
373
|
+
config.compileSdk = Math.max(config.compileSdk ?? 36, 36);
|
|
374
|
+
}
|
|
375
|
+
normalizeAndroidNetworkConfig(config);
|
|
376
|
+
validateAndroidConfig(config);
|
|
377
|
+
if (config.enablePushNotifications && !config.googleServicesFile) {
|
|
378
|
+
throw new Error("Android push notifications require a googleServicesFile");
|
|
379
|
+
}
|
|
380
|
+
if (config.googleServicesFile)
|
|
381
|
+
validateGoogleServicesFile(config.googleServicesFile, finalPackageName);
|
|
382
|
+
if (config.appIconPath)
|
|
383
|
+
requireRegularFile(config.appIconPath, "App icon");
|
|
384
|
+
const appIconExtension = config.appIconPath ? extname(config.appIconPath).toLowerCase() : undefined;
|
|
385
|
+
if (config.appIconPath && ![".gif", ".jpg", ".png", ".webp"].includes(appIconExtension ?? "")) {
|
|
386
|
+
throw new Error(`Unsupported Android app icon format: ${appIconExtension || "(none)"}`);
|
|
387
|
+
}
|
|
388
|
+
const dirs = [
|
|
389
|
+
output,
|
|
390
|
+
join(output, "app/src/main/java", packagePath),
|
|
391
|
+
join(output, "app/src/main/res/layout"),
|
|
392
|
+
join(output, "app/src/main/res/values"),
|
|
393
|
+
join(output, "app/src/main/res/drawable"),
|
|
394
|
+
join(output, "app/src/main/assets"),
|
|
395
|
+
join(output, "gradle/wrapper")
|
|
396
|
+
];
|
|
397
|
+
if (existsSync(output) && !statSync(output).isDirectory()) {
|
|
398
|
+
throw new Error(`Android project output must be a directory: ${output}`);
|
|
399
|
+
}
|
|
400
|
+
for (const dir of dirs) {
|
|
401
|
+
if (!existsSync(dir)) {
|
|
402
|
+
mkdirSync(dir, { recursive: true });
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
writeAndroidConfig(output, config);
|
|
406
|
+
const hasGoogleServices = Boolean(config.googleServicesFile);
|
|
407
|
+
if (config.googleServicesFile) {
|
|
408
|
+
cpSync(config.googleServicesFile, join(output, "app/google-services.json"));
|
|
409
|
+
}
|
|
410
|
+
const mainActivityTemplate = readFileSync(join(TEMPLATES_DIR, "MainActivity.kt.template"), "utf-8");
|
|
411
|
+
const mainActivity = mainActivityTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName).replace(/\{\{APP_NAME\}\}/g, name);
|
|
412
|
+
writeFileSync(join(output, "app/src/main/java", packagePath, "MainActivity.kt"), mainActivity);
|
|
413
|
+
const craftBridgeTemplate = readFileSync(join(TEMPLATES_DIR, "CraftBridge.kt.template"), "utf-8");
|
|
414
|
+
const craftBridge = craftBridgeTemplate.replace(/\{\{PROMISE_RUNTIME\}\}/g, () => renderAndroidPromiseRuntime(" ")).replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName).replace(/\{\{ENABLE_SPEECH\}\}/g, String(Boolean(config.enableSpeechRecognition))).replace(/\{\{ENABLE_HAPTICS\}\}/g, String(Boolean(config.enableHaptics))).replace(/\{\{ENABLE_SHARE\}\}/g, String(Boolean(config.enableShare))).replace(/\{\{ENABLE_CAMERA\}\}/g, String(Boolean(config.enableCamera))).replace(/\{\{ENABLE_BIOMETRIC\}\}/g, String(Boolean(config.enableBiometric))).replace(/\{\{ENABLE_PUSH\}\}/g, String(Boolean(config.enablePushNotifications))).replace(/\{\{ENABLE_SECURE_STORAGE\}\}/g, String(Boolean(config.enableSecureStorage))).replace(/\{\{ENABLE_GEOLOCATION\}\}/g, String(Boolean(config.enableGeolocation))).replace(/\{\{ENABLE_BACKGROUND_LOCATION\}\}/g, String(Boolean(config.enableBackgroundLocation))).replace(/\{\{ENABLE_KEEP_AWAKE\}\}/g, String(Boolean(config.enableKeepAwake))).replace(/\{\{ENABLE_DEEP_LINKS\}\}/g, String(Boolean(config.enableDeepLinks))).replace(/\{\{ENABLE_HEALTH_CONNECT\}\}/g, String(Boolean(config.enableHealthConnect))).replace(/\{\{FIREBASE_IMPORT\}\}/g, config.enablePushNotifications ? "import com.google.firebase.messaging.FirebaseMessaging" : "").replace(/\{\{REGISTER_PUSH_IMPLEMENTATION\}\}/g, config.enablePushNotifications ? `activity.runOnUiThread {
|
|
415
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
|
|
416
|
+
&& ContextCompat.checkSelfPermission(activity, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
|
417
|
+
ActivityCompat.requestPermissions(activity, arrayOf(Manifest.permission.POST_NOTIFICATIONS), 4204)
|
|
418
|
+
}
|
|
419
|
+
try {
|
|
420
|
+
FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
|
|
421
|
+
val token = if (task.isSuccessful) task.result else null
|
|
422
|
+
val callback = if (token.isNullOrBlank()) "window._craftPushReject" else "window._craftPushResolve"
|
|
423
|
+
val payload = JSONObject.quote(token ?: task.exception?.message ?: "Firebase Cloud Messaging is not configured")
|
|
424
|
+
evaluatePromiseJavascript("$callback && $callback($payload)")
|
|
425
|
+
}
|
|
426
|
+
} catch (error: Exception) {
|
|
427
|
+
val payload = JSONObject.quote(error.message ?: "Firebase Cloud Messaging is not configured")
|
|
428
|
+
evaluatePromiseJavascript("window._craftPushReject && window._craftPushReject($payload)")
|
|
429
|
+
}
|
|
430
|
+
}` : `evaluatePromiseJavascript(
|
|
431
|
+
"window._craftPushReject && window._craftPushReject('Push notifications are disabled')"
|
|
432
|
+
)`);
|
|
433
|
+
writeFileSync(join(output, "app/src/main/java", packagePath, "CraftBridge.kt"), craftBridge);
|
|
434
|
+
const craftNative = readFileSync(join(TEMPLATES_DIR, "CraftNative.kt.template"), "utf-8");
|
|
435
|
+
const nativeDir = join(output, "app/src/main/java/com/craft/runtime");
|
|
436
|
+
mkdirSync(nativeDir, { recursive: true });
|
|
437
|
+
writeFileSync(join(nativeDir, "CraftNative.kt"), craftNative);
|
|
438
|
+
const serviceTemplate = readFileSync(join(TEMPLATES_DIR, "LocationRecordingService.kt.template"), "utf-8");
|
|
439
|
+
writeFileSync(join(nativeDir, "LocationRecordingService.kt"), serviceTemplate);
|
|
440
|
+
const healthTemplate = readFileSync(join(TEMPLATES_DIR, config.enableHealthConnect ? "CraftHealthConnect.kt.template" : "CraftHealthConnectStub.kt.template"), "utf-8");
|
|
441
|
+
writeFileSync(join(output, "app/src/main/java", packagePath, "CraftHealthConnect.kt"), healthTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
|
|
442
|
+
const manifestTemplate = readFileSync(join(TEMPLATES_DIR, "AndroidManifest.xml.template"), "utf-8");
|
|
443
|
+
const manifest = manifestTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName).replace(/\{\{APP_NAME\}\}/g, name).replace(/\{\{PERMISSIONS\}\}/g, renderAndroidPermissions(config)).replace(/\{\{USES_CLEARTEXT\}\}/g, config.devServerURL?.startsWith("http://") ? "true" : "false").replace(/\{\{DEEP_LINK_INTENT_FILTERS\}\}/g, renderAndroidDeepLinks(config)).replace(/\{\{BACKGROUND_SERVICE\}\}/g, config.enableBackgroundLocation ? ' <service android:name="com.craft.runtime.LocationRecordingService" android:exported="false" android:foregroundServiceType="location" android:stopWithTask="false" />' : "").replace(/\{\{HEALTH_CONNECT_QUERIES\}\}/g, config.enableHealthConnect ? ` <queries>
|
|
444
|
+
<package android:name="com.google.android.apps.healthdata" />
|
|
445
|
+
</queries>` : "").replace(/\{\{HEALTH_CONNECT_RATIONALE\}\}/g, config.enableHealthConnect ? ` <intent-filter>
|
|
446
|
+
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
|
|
447
|
+
</intent-filter>
|
|
448
|
+
<intent-filter>
|
|
449
|
+
<action android:name="android.intent.action.VIEW_PERMISSION_USAGE" />
|
|
450
|
+
<category android:name="android.intent.category.HEALTH_PERMISSIONS" />
|
|
451
|
+
</intent-filter>` : "");
|
|
452
|
+
writeFileSync(join(output, "app/src/main/AndroidManifest.xml"), manifest);
|
|
453
|
+
const projectGradleTemplate = readFileSync(join(TEMPLATES_DIR, "build.gradle.kts.project.template"), "utf-8");
|
|
454
|
+
writeFileSync(join(output, "build.gradle.kts"), projectGradleTemplate.replace(/\{\{GOOGLE_SERVICES_PLUGIN\}\}/g, hasGoogleServices ? ' id("com.google.gms.google-services") version "4.4.2" apply false' : ""));
|
|
455
|
+
const appGradleTemplate = readFileSync(join(TEMPLATES_DIR, "build.gradle.kts.app.template"), "utf-8");
|
|
456
|
+
const appGradle = appGradleTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName).replace(/\{\{VERSION_NAME\}\}/g, escapeKotlinString(config.version || "1.0.0")).replace(/\{\{VERSION_CODE\}\}/g, String(config.versionCode || 1)).replace(/\{\{MIN_SDK\}\}/g, String(config.minSdk ?? ANDROID_MIN_SDK)).replace(/\{\{COMPILE_SDK\}\}/g, String(config.compileSdk || 36)).replace(/\{\{TARGET_SDK\}\}/g, String(config.targetSdk || 35)).replace(/\{\{GOOGLE_SERVICES_PLUGIN\}\}/g, hasGoogleServices ? ' id("com.google.gms.google-services")' : "").replace(/\{\{FIREBASE_MESSAGING_DEPENDENCY\}\}/g, config.enablePushNotifications ? ' implementation("com.google.firebase:firebase-messaging:24.1.0")' : "").replace(/\{\{HEALTH_CONNECT_DEPENDENCIES\}\}/g, config.enableHealthConnect ? ` implementation("androidx.health.connect:connect-client:1.1.0")
|
|
457
|
+
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")` : "");
|
|
458
|
+
writeFileSync(join(output, "app/build.gradle.kts"), appGradle);
|
|
459
|
+
const proguardTemplate = readFileSync(join(TEMPLATES_DIR, "proguard-rules.pro.template"), "utf-8");
|
|
460
|
+
writeFileSync(join(output, "app/proguard-rules.pro"), proguardTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
|
|
461
|
+
const settingsTemplate = readFileSync(join(TEMPLATES_DIR, "settings.gradle.kts.template"), "utf-8");
|
|
462
|
+
const settings = settingsTemplate.replace(/\{\{APP_NAME\}\}/g, escapeKotlinString(generatedGradleProjectName(name)));
|
|
463
|
+
writeFileSync(join(output, "settings.gradle.kts"), settings);
|
|
464
|
+
const gradleProperties = `org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
|
465
|
+
android.useAndroidX=true
|
|
466
|
+
kotlin.code.style=official
|
|
467
|
+
android.nonTransitiveRClass=true
|
|
468
|
+
`;
|
|
469
|
+
writeFileSync(join(output, "gradle.properties"), gradleProperties);
|
|
470
|
+
writeFileSync(join(output, "local.properties"), `# SDK location will be set by Android Studio
|
|
471
|
+
`);
|
|
472
|
+
const gradleWrapperProps = `distributionBase=GRADLE_USER_HOME
|
|
473
|
+
distributionPath=wrapper/dists
|
|
474
|
+
distributionUrl=https\\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
|
|
475
|
+
networkTimeout=10000
|
|
476
|
+
validateDistributionUrl=true
|
|
477
|
+
zipStoreBase=GRADLE_USER_HOME
|
|
478
|
+
zipStorePath=wrapper/dists
|
|
479
|
+
`;
|
|
480
|
+
writeFileSync(join(output, "gradle/wrapper/gradle-wrapper.properties"), gradleWrapperProps);
|
|
481
|
+
const stringsXml = `<?xml version="1.0" encoding="utf-8"?>
|
|
482
|
+
<resources>
|
|
483
|
+
<string name="app_name">${escapeXml(name)}</string>
|
|
484
|
+
</resources>
|
|
485
|
+
`;
|
|
486
|
+
writeFileSync(join(output, "app/src/main/res/values/strings.xml"), stringsXml);
|
|
487
|
+
const colorsXml = `<?xml version="1.0" encoding="utf-8"?>
|
|
488
|
+
<resources>
|
|
489
|
+
<color name="primary">#1a1a2e</color>
|
|
490
|
+
<color name="primary_dark">#16162b</color>
|
|
491
|
+
<color name="accent">#4ade80</color>
|
|
492
|
+
<color name="background">${config.backgroundColor}</color>
|
|
493
|
+
</resources>
|
|
494
|
+
`;
|
|
495
|
+
writeFileSync(join(output, "app/src/main/res/values/colors.xml"), colorsXml);
|
|
496
|
+
const appIconXml = `<?xml version="1.0" encoding="utf-8"?>
|
|
497
|
+
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
498
|
+
android:width="108dp"
|
|
499
|
+
android:height="108dp"
|
|
500
|
+
android:viewportWidth="108"
|
|
501
|
+
android:viewportHeight="108">
|
|
502
|
+
<path android:fillColor="${config.backgroundColor}" android:pathData="M0,0h108v108h-108z" />
|
|
503
|
+
<path android:fillColor="#10B981" android:pathData="M18,78L42,42l12,18l12,-12l24,30z" />
|
|
504
|
+
</vector>
|
|
505
|
+
`;
|
|
506
|
+
if (config.appIconPath) {
|
|
507
|
+
cpSync(config.appIconPath, join(output, `app/src/main/res/drawable/craft_app_icon${appIconExtension}`));
|
|
508
|
+
} else {
|
|
509
|
+
writeFileSync(join(output, "app/src/main/res/drawable/craft_app_icon.xml"), appIconXml);
|
|
510
|
+
}
|
|
511
|
+
const themesXml = `<?xml version="1.0" encoding="utf-8"?>
|
|
512
|
+
<resources>
|
|
513
|
+
<style name="Theme.CraftApp" parent="Theme.Material3.DayNight.NoActionBar">
|
|
514
|
+
<item name="android:statusBarColor">@color/primary_dark</item>
|
|
515
|
+
<item name="android:navigationBarColor">@color/primary</item>
|
|
516
|
+
<item name="android:windowBackground">@color/background</item>
|
|
517
|
+
</style>
|
|
518
|
+
</resources>
|
|
519
|
+
`;
|
|
520
|
+
writeFileSync(join(output, "app/src/main/res/values/themes.xml"), themesXml);
|
|
521
|
+
const activityMainXml = `<?xml version="1.0" encoding="utf-8"?>
|
|
522
|
+
<androidx.coordinatorlayout.widget.CoordinatorLayout
|
|
523
|
+
xmlns:android="http://schemas.android.com/apk/res/android"
|
|
524
|
+
android:layout_width="match_parent"
|
|
525
|
+
android:layout_height="match_parent"
|
|
526
|
+
android:fitsSystemWindows="true">
|
|
527
|
+
|
|
528
|
+
<WebView
|
|
529
|
+
android:id="@+id/webview"
|
|
530
|
+
android:layout_width="match_parent"
|
|
531
|
+
android:layout_height="match_parent" />
|
|
532
|
+
|
|
533
|
+
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
|
534
|
+
`;
|
|
535
|
+
writeFileSync(join(output, "app/src/main/res/layout/activity_main.xml"), activityMainXml);
|
|
536
|
+
const placeholderHtml = `<!DOCTYPE html>
|
|
537
|
+
<html>
|
|
538
|
+
<head>
|
|
539
|
+
<meta charset="UTF-8">
|
|
540
|
+
<meta name="viewport" content="viewport-fit=cover, width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
|
541
|
+
<title>${escapeXml(name)}</title>
|
|
542
|
+
<style>
|
|
543
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
544
|
+
body {
|
|
545
|
+
font-family: 'Roboto', sans-serif;
|
|
546
|
+
background: ${config.backgroundColor};
|
|
547
|
+
color: white;
|
|
548
|
+
min-height: 100vh;
|
|
549
|
+
display: flex;
|
|
550
|
+
justify-content: center;
|
|
551
|
+
align-items: center;
|
|
552
|
+
}
|
|
553
|
+
.container { text-align: center; padding: 2rem; }
|
|
554
|
+
h1 { font-size: 2.5rem; margin-bottom: 1rem; }
|
|
555
|
+
p { opacity: 0.7; }
|
|
556
|
+
.ready { color: #4ade80; font-size: 0.9rem; margin-top: 2rem; }
|
|
557
|
+
</style>
|
|
558
|
+
</head>
|
|
559
|
+
<body>
|
|
560
|
+
<div class="container">
|
|
561
|
+
<h1>\u26A1 ${escapeXml(name)}</h1>
|
|
562
|
+
<p>Built with Craft Android</p>
|
|
563
|
+
<p class="ready" id="status">Waiting for Craft bridge...</p>
|
|
564
|
+
</div>
|
|
565
|
+
<script>
|
|
566
|
+
window.addEventListener('craftReady', (e) => {
|
|
567
|
+
document.getElementById('status').textContent = \`\u2713 Craft bridge ready (platform: \${e.detail.platform})\`;
|
|
568
|
+
console.log('Craft capabilities:', e.detail.capabilities);
|
|
569
|
+
});
|
|
570
|
+
</script>
|
|
571
|
+
</body>
|
|
572
|
+
</html>`;
|
|
573
|
+
writeFileSync(join(output, "app/src/main/assets/index.html"), placeholderHtml);
|
|
574
|
+
const runtimeDir = resolveRuntimeDir(options.runtimeDir);
|
|
575
|
+
if (runtimeDir) {
|
|
576
|
+
installRuntime(output, runtimeDir);
|
|
577
|
+
console.log(" Installed the Zig runtime from", runtimeDir);
|
|
578
|
+
}
|
|
579
|
+
console.log("\u2705 Project initialized");
|
|
580
|
+
console.log("");
|
|
581
|
+
console.log("Next steps:");
|
|
582
|
+
console.log(` 1. cd ${output}`);
|
|
583
|
+
console.log(" 2. Add your web content to app/src/main/assets/index.html");
|
|
584
|
+
console.log(" 3. Run: craft android build");
|
|
585
|
+
console.log(" 4. Run: craft android open");
|
|
586
|
+
console.log("");
|
|
587
|
+
}
|
|
588
|
+
async function build(options) {
|
|
589
|
+
const { htmlPath, devServer, output, release, compile = true } = options;
|
|
590
|
+
console.log(`
|
|
591
|
+
\uD83D\uDCE6 Building Craft Android project...`);
|
|
592
|
+
const configPath = join(output, "craft.config.json");
|
|
593
|
+
if (!existsSync(configPath)) {
|
|
594
|
+
throw new Error(`No craft.config.json found in ${output}. Run 'craft android init' first.`);
|
|
595
|
+
}
|
|
596
|
+
const config = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
597
|
+
if (existsSync(join(output, "app/src/main/jniLibs"))) {
|
|
598
|
+
const runtimeDir = resolveRuntimeDir(options.runtimeDir);
|
|
599
|
+
if (runtimeDir) {
|
|
600
|
+
installRuntime(output, runtimeDir);
|
|
601
|
+
console.log(" Refreshed the Zig runtime from", runtimeDir);
|
|
602
|
+
} else {
|
|
603
|
+
console.log(" Keeping the Zig runtime installed at init (no runtime directory configured)");
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
if (devServer) {
|
|
607
|
+
const url = androidWebUrl(devServer, "Android dev server URL");
|
|
608
|
+
config.devServerURL = url.toString();
|
|
609
|
+
config.trustedOrigins = [...new Set([
|
|
610
|
+
...(config.trustedOrigins ?? []).map((value) => androidWebUrl(value, "Android trusted origin").origin),
|
|
611
|
+
url.origin
|
|
612
|
+
])];
|
|
613
|
+
writeAndroidConfig(output, config);
|
|
614
|
+
console.log(` Dev server: ${devServer}`);
|
|
615
|
+
}
|
|
616
|
+
if (htmlPath) {
|
|
617
|
+
syncAndroidWebAssets(htmlPath, output);
|
|
618
|
+
config.hasBundledFallback = Boolean(devServer);
|
|
619
|
+
writeAndroidConfig(output, config);
|
|
620
|
+
console.log(` Synced: ${htmlPath} \u2192 assets/`);
|
|
621
|
+
}
|
|
622
|
+
if (!compile)
|
|
623
|
+
return;
|
|
624
|
+
const gradlewPath = join(output, "gradlew");
|
|
625
|
+
if (!existsSync(gradlewPath)) {
|
|
626
|
+
console.log(" Generating Gradle wrapper...");
|
|
627
|
+
try {
|
|
628
|
+
await $`cd ${output} && gradle wrapper`.quiet();
|
|
629
|
+
} catch (error) {
|
|
630
|
+
throw new Error("Gradle is unavailable. Install Android Studio or Gradle before building Android.", { cause: error });
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
const buildType = release ? "assembleRelease" : "assembleDebug";
|
|
634
|
+
console.log(` Building ${release ? "release" : "debug"} APK...`);
|
|
635
|
+
try {
|
|
636
|
+
await $`cd ${output} && ./gradlew ${buildType}`;
|
|
637
|
+
const apkPath = release ? "app/build/outputs/apk/release/app-release.apk" : "app/build/outputs/apk/debug/app-debug.apk";
|
|
638
|
+
console.log(`\u2705 APK built: ${apkPath}`);
|
|
639
|
+
} catch (error) {
|
|
640
|
+
console.error("Build failed. Open in Android Studio for details.");
|
|
641
|
+
throw error;
|
|
642
|
+
}
|
|
643
|
+
console.log("");
|
|
644
|
+
}
|
|
645
|
+
async function open(options) {
|
|
646
|
+
const { output } = options;
|
|
647
|
+
if (!existsSync(output)) {
|
|
648
|
+
throw new Error(`No Android project found in ${output}. Run 'craft android init' first.`);
|
|
649
|
+
}
|
|
650
|
+
console.log(`\uD83D\uDE80 Opening Android project in Android Studio...`);
|
|
651
|
+
try {
|
|
652
|
+
await $`open -a "Android Studio" ${output}`.quiet();
|
|
653
|
+
} catch {
|
|
654
|
+
try {
|
|
655
|
+
await $`studio ${output}`.quiet();
|
|
656
|
+
} catch {
|
|
657
|
+
console.log("\u26A0\uFE0F Android Studio not found.");
|
|
658
|
+
console.log(" Please open the project manually in Android Studio:");
|
|
659
|
+
console.log(` ${output}`);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
async function run(options) {
|
|
664
|
+
const { device, output } = options;
|
|
665
|
+
await build({ output });
|
|
666
|
+
console.log("\uD83D\uDCF1 Installing and running on device...");
|
|
667
|
+
try {
|
|
668
|
+
const apkPath = join(output, "app/build/outputs/apk/debug/app-debug.apk");
|
|
669
|
+
if (device) {
|
|
670
|
+
await $`adb -s ${device} install -r ${apkPath}`;
|
|
671
|
+
} else {
|
|
672
|
+
await $`adb install -r ${apkPath}`;
|
|
673
|
+
}
|
|
674
|
+
const config = JSON.parse(readFileSync(join(output, "craft.config.json"), "utf-8"));
|
|
675
|
+
const launchCmd = `${config.packageName}/.MainActivity`;
|
|
676
|
+
if (device) {
|
|
677
|
+
await $`adb -s ${device} shell am start -n ${launchCmd}`;
|
|
678
|
+
} else {
|
|
679
|
+
await $`adb shell am start -n ${launchCmd}`;
|
|
680
|
+
}
|
|
681
|
+
console.log("\u2705 App launched");
|
|
682
|
+
} catch (error) {
|
|
683
|
+
console.error("Failed to install/run. Make sure:");
|
|
684
|
+
console.error(" 1. ADB is installed and in PATH");
|
|
685
|
+
console.error(" 2. A device/emulator is connected (run: adb devices)");
|
|
686
|
+
throw error;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
export {
|
|
690
|
+
syncAndroidWebAssets,
|
|
691
|
+
run,
|
|
692
|
+
resolveRuntimeDir,
|
|
693
|
+
renderAndroidPermissions,
|
|
694
|
+
renderAndroidDeepLinks,
|
|
695
|
+
open,
|
|
696
|
+
installRuntime,
|
|
697
|
+
init,
|
|
698
|
+
build,
|
|
699
|
+
ANDROID_MIN_SDK
|
|
700
|
+
};
|
|
701
|
+
|
|
702
|
+
//# debugId=BDD4D60CC245FA0664756E2164756E21
|