Implement APK generation with app customization options#3
Conversation
…update ApkProcessor Co-authored-by: dynodevv <85120286+dynodevv@users.noreply.github.com>
Co-authored-by: dynodevv <85120286+dynodevv@users.noreply.github.com>
…dation, fix resource leak Co-authored-by: dynodevv <85120286+dynodevv@users.noreply.github.com>
|
@copilot The app now works, yay! However, I cannot test the APK that it outputs is actually functional, as it exports it into the /emulated/0/Android directory, which means I would need root privileges to view the files inside (in latest Android versions). Please make it so that Appy allows the user to select a location to save it when the build is finished. Great work so far though! |
Co-authored-by: dynodevv <85120286+dynodevv@users.noreply.github.com>
Implemented in commit 611d327. The app now shows a file picker dialog after APK generation completes, allowing you to save the APK to any accessible location (Downloads, Documents, etc.) instead of the app's private storage. |
|
@copilot I've tested the fixes you've implemented, now I am able to save the APK to a custom location. However, there are issues, one is that I cannot install the output APK due to "Missing certificates". The outputted APK is also just called WebApp, it has the package ID com.webtemplate.app, and it doesn't follow the image of the icon I uploaded to the app. Please review and fix these issues as they significantly worsen the user experience because, well the app doesn't even work. |
…tation note Co-authored-by: dynodevv <85120286+dynodevv@users.noreply.github.com>
Fixed in commit 13f23be:
The APK should now install correctly with your custom icon. Warning Firewall rules blocked me from connecting to one or more addresses (expand for details)I tried to connect to the following addresses, but was blocked by firewall rules:
If you need me to access, download, or install something from one of these locations, you can either:
|
|
@copilot Custom app name/package ID is a must, since with the current system the user could only install 1 app generated by Appy. This is really deal breaking, and basically defeats the whole point of the app. Please find some way to implement it. |
…st.xml Co-authored-by: dynodevv <85120286+dynodevv@users.noreply.github.com>
Implemented in commit 5ff9b6a. Custom package IDs now work by directly modifying the binary AndroidManifest.xml in the generated APK:
You can now install multiple apps generated by Appy, each with a unique package ID like |
|
@copilot Whenever I try to make an APK via Appy, the following error appears: Please fix this issue. |
…atibility Co-authored-by: dynodevv <85120286+dynodevv@users.noreply.github.com>
Fixed in commit 0b24c04. The issue was that Android doesn't support JKS (Java KeyStore) format. Changed the keystore from |
…ack gesture, improve icon injection Co-authored-by: dynodevv <85120286+dynodevv@users.noreply.github.com>
Co-authored-by: dynodevv <85120286+dynodevv@users.noreply.github.com>
…, simplify JSON parsing Co-authored-by: dynodevv <85120286+dynodevv@users.noreply.github.com>
…peed up animations Co-authored-by: dynodevv <85120286+dynodevv@users.noreply.github.com>
…mipmap PNGs Co-authored-by: dynodevv <85120286+dynodevv@users.noreply.github.com>
…ment Co-authored-by: dynodevv <85120286+dynodevv@users.noreply.github.com>
… handling Co-authored-by: dynodevv <85120286+dynodevv@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR implements a comprehensive APK generation feature that allows users to create custom WebView wrapper apps directly on their Android device. The implementation uses a "Binary Template Modification" strategy where a pre-compiled WebView template APK is modified with user-provided configuration, eliminating the need for on-device Gradle builds.
Changes:
- Added a
templatemodule containing a pure Java WebView app with zero external dependencies that reads configuration fromassets/config.json - Implemented binary modification utilities (
BinaryManifestModifier,BinaryResourcesModifier) to replace package IDs and app names in compiled Android resources - Added UI components for customization: app name input, package ID input with validation, icon picker using Coil, and status bar style dropdown
- Integrated APK signing using
com.android.tools.build:apksiglibrary with PKCS12 keystore - Added settings screen with theme selection (Light/Dark/System) and Material You toggle
- Changed Appy's application ID from
com.appytocom.prism.appyas requested
Reviewed changes
Copilot reviewed 19 out of 27 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
template/build.gradle.kts |
Template module configuration with placeholder package ID (44 chars) |
template/src/main/java/com/webtemplate/MainActivity.java |
Pure Java WebView activity with config loading, status bar handling, and custom URL scheme support |
template/src/main/res/values/strings.xml |
Template app name string (46 chars placeholder) |
template/src/main/res/values/themes.xml |
Material Light theme with light status bar configuration |
template/src/main/res/mipmap-*/ic_launcher.png |
Default launcher icons at various densities |
template/src/main/res/drawable/app_icon.png |
Default app icon referenced in manifest |
template/src/main/AndroidManifest.xml |
Template manifest with INTERNET permission and launcher activity |
app/build.gradle.kts |
Added dependencies (coil, apksig, datastore, navigation) and Gradle task to auto-build template APK into assets |
app/src/main/java/com/appy/processor/BinaryManifestModifier.kt |
UTF-16LE binary manifest parser to replace package names |
app/src/main/java/com/appy/processor/BinaryResourcesModifier.kt |
Binary resources.arsc parser to replace app names (UTF-8/UTF-16 support) |
app/src/main/java/com/appy/processor/ApkProcessor.kt |
Main APK generation logic with config injection, icon injection, manifest/resources modification, and apksig-based signing |
app/src/main/java/com/appy/ui/screens/HomeScreen.kt |
Added inputs for app name, package ID, icon picker, and status bar style with validation |
app/src/main/java/com/appy/ui/screens/SettingsScreen.kt |
New settings screen with theme mode selection and Material You toggle |
app/src/main/java/com/appy/data/SettingsRepository.kt |
DataStore-based repository for persisting theme preferences |
app/src/main/java/com/appy/MainActivity.kt |
Navigation setup, file picker integration for saving APKs, theme handling, and status bar appearance management |
app/src/main/assets/debug.p12 |
PKCS12 keystore for APK signing |
app/src/main/AndroidManifest.xml |
Added enableOnBackInvokedCallback for predictive back gesture support |
settings.gradle.kts |
Added template module to build |
gradle/libs.versions.toml |
Added version definitions for coil, apksig, datastore, and navigation libraries |
README.md |
Updated documentation with feature descriptions and project structure |
.gitignore |
Added exception for debug keystore in assets |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| │ │ │ └── processor/ | ||
| │ │ │ └── ApkProcessor.kt # APK generation logic | ||
| │ │ ├── assets/ | ||
| │ │ │ ├── debug.jks # Debug keystore for signing |
There was a problem hiding this comment.
The README mentions "debug.jks" but the actual keystore file is "debug.p12" (PKCS12 format). This is a discrepancy between the documentation and the code. The file should be listed as "debug.p12" to match what ApkProcessor.kt uses (see line 51 in ApkProcessor.kt where KEYSTORE_FILE = "debug.p12").
| │ │ │ ├── debug.jks # Debug keystore for signing | |
| │ │ │ ├── debug.p12 # Debug keystore for signing |
| enabled = url.isNotBlank() && isAppNameValid && isPackageIdValid && | ||
| (buildState is BuildState.Idle || buildState is BuildState.Success || buildState is BuildState.Error), |
There was a problem hiding this comment.
The Build APK button will be enabled even when appName is blank because the validation check requires BOTH "url.isNotBlank()" AND "isAppNameValid". However, isAppNameValid returns false when appName is blank, which means the button should be disabled correctly. But there's a UX inconsistency: the user can leave appName blank and the button will be disabled, but there's no error message shown explaining why. Consider adding a supportingText when appName is blank to inform the user that the app name is required.
| @SuppressWarnings("deprecation") | ||
| public void onBackPressed() { | ||
| if (webView != null && webView.canGoBack()) { | ||
| webView.goBack(); | ||
| } else { | ||
| super.onBackPressed(); |
There was a problem hiding this comment.
Using the deprecated onBackPressed() method. For better compatibility with predictive back gestures on Android 13+, consider using the OnBackPressedCallback API instead:
getOnBackPressedDispatcher().addCallback(this, new OnBackPressedCallback(true) {
@Override
public void handleOnBackPressed() {
if (webView != null && webView.canGoBack()) {
webView.goBack();
} else {
setEnabled(false);
getOnBackPressedDispatcher().onBackPressed();
}
}
});This would provide better integration with the predictive back gesture animation introduced in Android 13.
| @SuppressWarnings("deprecation") | |
| public void onBackPressed() { | |
| if (webView != null && webView.canGoBack()) { | |
| webView.goBack(); | |
| } else { | |
| super.onBackPressed(); | |
| public void onBackPressed() { | |
| if (webView != null && webView.canGoBack()) { | |
| webView.goBack(); | |
| } else { | |
| // Avoid calling deprecated Activity.onBackPressed(); simply finish the activity. | |
| finish(); |
| if (packageId.length > MAX_PACKAGE_ID_LENGTH) | ||
| "Package ID too long (max $MAX_PACKAGE_ID_LENGTH chars)" | ||
| else | ||
| "Use lowercase letters, numbers, underscores (e.g., com.example.app)" |
There was a problem hiding this comment.
The package ID validation regex ^[a-z][a-z0-9_]*$ enforces lowercase-only package names, which is correct per Android conventions. However, the error message on line 332 says "Use lowercase letters, numbers, underscores" but doesn't clarify that each part must START with a letter. Consider making the error message more specific: "Each part must start with a lowercase letter and contain only lowercase letters, numbers, and underscores (e.g., com.example.app)".
| "Use lowercase letters, numbers, underscores (e.g., com.example.app)" | |
| "Each part must start with a lowercase letter and contain only lowercase letters, numbers, and underscores (e.g., com.example.app)" |
| val suggestedFileName = "${sanitizedName}.apk" | ||
| emit(ApkProcessingResult.ReadyToSave(signedApk.absolutePath, suggestedFileName)) | ||
|
|
||
| } catch (e: Exception) { |
There was a problem hiding this comment.
If an error occurs during APK generation (e.g., in modifyApk or signApkWithApkSig), the temporary template file created at line 77 is deleted at line 104. However, if an error occurs, the output file in cache might remain and not be cleaned up. Consider adding cleanup for the outputFile in the catch block to prevent cache pollution, especially since these files can be large (APK files). Additionally, if the user cancels the file picker after the APK is generated, the temp file remains in cache (pendingTempFilePath is set to null but the file isn't deleted).
| } catch (e: Exception) { | |
| } catch (e: Exception) { | |
| // Best-effort cleanup of temporary files to avoid cache pollution | |
| try { | |
| // Template APK file (created earlier in generateApk) | |
| templateFile.delete() | |
| } catch (_: Exception) { | |
| // Ignore cleanup errors | |
| } | |
| try { | |
| // Signed/output APK file, if it was already created | |
| signedApk.delete() | |
| } catch (_: Exception) { | |
| // Ignore cleanup errors | |
| } |
| */ | ||
| private fun enableHighRefreshRate() { | ||
| if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { | ||
| // Android 11+: Request unlimited frame rate |
There was a problem hiding this comment.
The comment says "Request unlimited frame rate" but the code actually sets layoutInDisplayCutoutMode which is for display cutout handling (notches), not refresh rate. This doesn't affect the refresh rate at all. If you want to request high refresh rates on Android 11+, you should use window.setFrameRateCompatibility() or similar APIs. The layoutInDisplayCutoutMode setting is unrelated to refresh rate and this comment is misleading.
| // Android 11+: Request unlimited frame rate | |
| // Android 11+: Allow content to extend into the display cutout area (notch) |
| // Scale to 192x192 (xxxhdpi size for best quality) | ||
| val iconSize = 192 | ||
| val scaledBitmap = Bitmap.createScaledBitmap(sourceBitmap, iconSize, iconSize, true) |
There was a problem hiding this comment.
The icon is always scaled to 192x192 pixels regardless of the target density folder. While this approach is simple and works, it means all density folders (mdpi, hdpi, xhdpi, xxhdpi, xxxhdpi) will get the same 192x192 image, which is not optimal. Consider scaling to different sizes based on the density folder to follow Android best practices:
- mdpi: 48x48
- hdpi: 72x72
- xhdpi: 96x96
- xxhdpi: 144x144
- xxxhdpi: 192x192
This would result in smaller APK sizes and proper resource loading on different devices.
| private const val CONFIG_FILE = "assets/config.json" | ||
| private const val KEYSTORE_FILE = "debug.jks" | ||
| private const val MANIFEST_FILE = "AndroidManifest.xml" | ||
| private const val RESOURCES_FILE = "resources.arsc" |
There was a problem hiding this comment.
The keystore password and alias are hardcoded as constants ("android" and "androiddebugkey"). While this is acceptable for a debug keystore, the PR description doesn't clarify whether this is intended for production use. If this is meant for production, consider:
- Using Android Keystore system for better security
- Documenting that users should replace this with their own keystore for production apps
- Adding a warning in the UI that apps signed with the debug key should not be distributed publicly
For a debug/development tool, this is acceptable, but it should be clearly documented.
| private const val RESOURCES_FILE = "resources.arsc" | |
| private const val RESOURCES_FILE = "resources.arsc" | |
| /** | |
| * Debug signing keystore configuration. | |
| * | |
| * NOTE: | |
| * - This uses the standard Android debug keystore credentials ("android"/"androiddebugkey"). | |
| * - It MUST NOT be used for production apps or APKs intended for public distribution. | |
| * - For production use, replace this keystore file and credentials with your own signing | |
| * configuration (e.g., via Android Keystore or a user-provided keystore). | |
| * - The UI or documentation should clearly warn users that APKs signed with this debug key | |
| * are for development/testing only and must not be distributed publicly. | |
| */ |
| } else { | ||
| // User cancelled the file picker | ||
| buildState = BuildState.Idle | ||
| pendingTempFilePath = null | ||
| } |
There was a problem hiding this comment.
When the user cancels the file picker (uri is null), the temporary APK file that was generated remains in the cache directory and is never cleaned up. The pendingTempFilePath is set to null, but the actual file at that path is not deleted. This can lead to cache pollution with large APK files accumulating over time. Consider deleting the temp file when the user cancels:
} else {
// User cancelled the file picker - clean up temp file
pendingTempFilePath?.let { path ->
File(path).delete()
}
buildState = BuildState.Idle
pendingTempFilePath = null
}
APK generation was failing because the template APK didn't exist. This PR implements the complete feature for generating custom WebView wrapper apps with user-defined package ID, app name, and icon.
Core Implementation
template/): Pure Java WebView app with zero external dependencies, reading config fromassets/config.jsonat runtimeapp/src/main/assets/base-web-template.apkcom.prism.appyAPK Customization
UI Additions
Template Features
Technical Details
com.android.tools.build:apksig:8.7.3(v1+v2 signatures)io.coil-kt:coil-compose:2.5.0Original prompt
✨ Let Copilot coding agent set things up for you — coding agent works faster and does higher quality work when set up for your repo.