Build simple Android apps and sideload them to a phone. Use when the user wants to create an Android app, build an APK, or install an app on their Android device.
Build simple Android apps from the command line and install them on a connected device.
Already installed on Kim's machine:
adb via Homebrew (android-platform-tools)gradle via Homebrew~/Library/Android/sdk (platform-tools, build-tools;34.0.0, platforms;android-34)/opt/homebrew/opt/openjdk@17If SDK not installed, set it up:
# Install Android SDK command line tools
brew install --cask android-commandlinetools
brew install gradle openjdk@17
# Create SDK directory and manually accept licenses
# Note: `yes | sdkmanager --licenses` doesn't work reliably on macOS
mkdir -p ~/Library/Android/sdk/licenses
echo -e "\n24333f8a63b6825ea9c5514f83c2829b004d1fee" > ~/Library/Android/sdk/licenses/android-sdk-license
echo -e "\n84831b9409646a918e30573bab4c9c91346d8abd" >> ~/Library/Android/sdk/licenses/android-sdk-license
echo -e "\nd975f751698a77b662f1254ddbeed3901e976f5a" >> ~/Library/Android/sdk/licenses/android-sdk-license
# Install SDK components
/opt/homebrew/share/android-commandlinetools/cmdline-tools/latest/bin/sdkmanager \
--sdk_root=/Users/kim/Library/Android/sdk \
"platform-tools" "build-tools;34.0.0" "platforms;android-34"
IMPORTANT: Pairing codes expire quickly - have Claude ready before opening the pairing dialog.
adb pair <ip>:<pairing-port> <pairing-code>
adb connect <ip>:<connection-port>
Gotchas:
adb devices shows TWO entries (IP:port and mDNS name). Always use the IP:port form with -s flag - the mDNS entry sometimes fails:
adb -s 10.1.1.209:42917 install app/build/outputs/apk/debug/app-debug.apk
adb connect again (port may change)adb devices shows "unauthorized")adb devicesThis skill uses AGP 8.2.0 + Gradle 8.4 + Java 17 - a tested compatible combination.
Create all files:
mkdir -p /tmp/myapp/app/src/main/java/com/example/myapp
mkdir -p /tmp/myapp/app/src/main/res/drawable
mkdir -p /tmp/myapp/app/src/main/res/mipmap-anydpi-v26
mkdir -p /tmp/myapp/gradle/wrapper
cd /tmp/myapp
/tmp/myapp/
├── settings.gradle.kts
├── build.gradle.kts
├── gradle.properties
├── gradle/wrapper/
│ └── (generated by gradle wrapper)
└── app/
├── build.gradle.kts
└── src/main/
├── AndroidManifest.xml
├── java/com/example/myapp/
│ └── MainActivity.java
└── res/
├── drawable/
│ └── ic_launcher_foreground.xml
└── mipmap-anydpi-v26/
└── ic_launcher.xml
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "myapp"
include(":app")
plugins {
id("com.android.application") version "8.2.0" apply false
}
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
plugins {
id("com.android.application")
}
android {
namespace = "com.example.myapp"
compileSdk = 34
defaultConfig {
applicationId = "com.example.myapp"
minSdk = 24
targetSdk = 34
versionCode = 1
versionName = "1.0"
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="My App"
android:icon="@mipmap/ic_launcher"
android:theme="@android:style/Theme.Material.Light">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
package com.example.myapp;
import android.app.Activity;
import android.os.Bundle;
import android.widget.TextView;
import android.view.Gravity;
public class MainActivity extends Activity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
TextView tv = new TextView(this);
tv.setText("Hello World!");
tv.setTextSize(24);
tv.setGravity(Gravity.CENTER);
setContentView(tv);
}
}
Design a unique icon for each app. The icon should visually represent what the app does. Use vector drawables with simple shapes - they scale perfectly and keep APK size small.
Example structure (customize colors and pathData for each app):
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Background shape (circle, rounded rect, etc.) -->
<path
android:fillColor="#3DDC84"
android:pathData="M54,54m-40,0a40,40 0,1 1,80 0a40,40 0,1 1,-80 0"/>
<!-- Foreground symbol -->
<path
android:fillColor="#FFFFFF"
android:pathData="M44,44L64,54L44,64Z"/>
</vector>
Icon design tips:
M cx,cy m-r,0 a r,r 0 1,1 2r,0 a r,r 0 1,1 -2r,0), rectangles, triangles<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@android:color/white"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
cd /tmp/myapp
gradle wrapper --gradle-version 8.4
# Must use Java 17 (AGP 8.2.0 requirement)
JAVA_HOME=/opt/homebrew/opt/openjdk@17 \
ANDROID_HOME=/Users/kim/Library/Android/sdk \
./gradlew assembleDebug
# APK at: app/build/outputs/apk/debug/app-debug.apk
# Install (use -s if multiple devices; always prefer IP:port form)
adb -s <device-id> install app/build/outputs/apk/debug/app-debug.apk
# Reinstall/update existing app
adb -s <device-id> install -r app/build/outputs/apk/debug/app-debug.apk
# Launch the app
adb -s <device-id> shell am start -n com.example.myapp/.MainActivity
# Uninstall
adb uninstall com.example.myapp
For apps distributed via GitHub releases, add a self-update mechanism:
<!-- AndroidManifest.xml -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!-- Inside <application>, for sharing the downloaded APK with the installer -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<!-- res/xml/file_paths.xml -->
<paths><cache-path name="apk_updates" path="updates/" /></paths>
Use version.properties alongside build.gradle.kts:
VERSION_CODE=1
VERSION_NAME=1.0
Read in gradle:
val versionProps = java.util.Properties().apply {
load(file("version.properties").inputStream())
}
defaultConfig {
versionCode = versionProps["VERSION_CODE"].toString().toInt()
versionName = versionProps["VERSION_NAME"].toString()
}
https://api.github.com/repos/OWNER/REPO/releases/latest.apk asset URL from the assets arraygithub.com, *.github.com, or *.githubusercontent.comgetCacheDir()/updates/app-update.apkFileProvider.getUriForFile() + ACTION_VIEW intent to launch the system installerAfter downloading, verify the APK is signed with the same certificate as the installed app. This prevents installing a tampered APK if the GitHub release is compromised.
// Get SHA-256 digest of a signing certificate
private static String certSha256(android.content.pm.Signature sig) {
byte[] digest = java.security.MessageDigest.getInstance("SHA-256").digest(sig.toByteArray());
StringBuilder sb = new StringBuilder(digest.length * 2);
for (byte b : digest) sb.append(String.format("%02x", b & 0xff));
return sb.toString();
}
// Compare installed vs downloaded APK certs
// Use GET_SIGNING_CERTIFICATES on API 28+, GET_SIGNATURES on older
// Use PackageManager.getPackageArchiveInfo() to read the downloaded APK's certs
// If no cert digest matches, delete the APK and abort
Why TOFU, not hardcoded cert digests? The hardcoded digest lives in the APK itself — an attacker who can replace the APK can patch the digest too. TOFU is equally secure and doesn't break on signing key rotation. This is exactly how Android's own package update system works.
See kim-em/claude-chat for a complete working implementation.
# Check connected devices
adb devices
# View device logs (filter by package)
adb logcat --pid=$(adb shell pidof com.example.myapp)
# Push file to device
adb push local-file.txt /sdcard/Download/
# Pull file from device
adb pull /sdcard/Download/file.txt ./
# Open shell on device
adb shell
# Take screenshot
adb exec-out screencap -p > screenshot.png
# List installed packages
adb shell pm list packages | grep myapp
"more than one device/emulator":
adb -s <device-id> to specify which deviceadb devices to see device IDsBuild fails with Java version error:
JAVA_HOME=/opt/homebrew/opt/openjdk@17 before building"unauthorized" in adb devices:
"no devices/emulators found":
"INSTALL_FAILED_UPDATE_INCOMPATIBLE":
adb uninstall com.example.myapp"INSTALL_FAILED_VERSION_DOWNGRADE":
sdkmanager "platforms;android-XX"