AI;DR – This blog post was mostly generated by Claude (via Claude Code) as was the app which is described here. If you don’t want to read “AI slop”, stop reading now.
First some background: I did not start with the idea that I wanted to create an Android App. The real reason I did this was because there already was an App on Google Play that claimed to do what I wanted to do and did actually work fine. But when I started it the second time it prompted me to allow cookies from tens of web urls and did not let me select “none” or “required only”. That was a big no no, so I uninstalled it.
Later that day I was doing something completely different with Claude Code and it occurred to me whether it was possible to create such a simple Android app myself, without knowing anything about Android programming, without installing Android Studio or anything else that would permanently stay on the computer. And when that turned out to be possible, I wondered whether it would be possible to automate all of it to the point that a single Bash script could do the whole thing. That too turned out to be possible.
The rest that follows is written mostly by Claude and mentions some details that I have not fact checked. I can confirm that the script works and that the generated app also works on my smartphone.
So, is it possible to create an Android app on a Linux computer without root access?
The answer, it turns out, is yes – and not just possible, but automatable all the way to a working app on a real phone, provided you are on x86_64 with glibc. The result is a tiny project called hotspot-shortcut and a single self-contained build script, build-hotspot-apk.sh, that provisions the entire toolchain and produces the APK.
The app
The app does exactly one thing: when you tap its launcher icon, it jumps straight to the system Tethering & Hotspot settings screen and then closes itself. No UI of its own. On my phone the hotspot toggle is buried several taps deep, and this turns it into a one-tap shortcut.
Technically it is about as small as an Android app gets. The single activity uses Theme.NoDisplay and calls finish() immediately, so it never draws anything. It fires an explicit intent at the internal com.android.settings.TetherSettings activity, and if that is missing (some OEM ROMs move or rename it) it falls back to the public ACTION_WIRELESS_SETTINGS intent. The whole thing is a few lines of Kotlin:
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
openTetherSettings()
finish()
}
Because it is sideloaded rather than shipped through the Play Store, a debug-signed APK is perfectly fine – no release keystore needed.
The interesting constraint: no root, nothing system-wide
The work started from a completely bare Linux machine (WSL2, x86_64): no JDK, no Android SDK, no Gradle, no ANDROID_HOME, and crucially no root and no passwordless sudo. The entire toolchain had to be provisioned user-locally, inside the working directory, touching nothing outside it.
This turns out to be entirely feasible, and the key insight is simple: every required piece ships as a download-and-extract archive that runs fine from any directory. The JDK, the Android SDK command-line tools, and Gradle are all just tarballs or zips. The one step in a normal setup that genuinely needs root – installing a JDK via the package manager – has a rootless alternative: download the Eclipse Temurin tarball and extract it. Nothing has to live in /usr or /opt.
So the script lands everything under a single base directory next to itself: JDK 17 from Adoptium, the Android SDK (platform-tools, the android-34 platform, build-tools 34.0.0), and Gradle 8.7. The Android and Kotlin libraries are pulled in by Gradle on the first build. To uninstall the lot, you delete two folders. The first from-scratch run fetches roughly 500 MB.
Fail fast, in plain language
Once the manual build worked, I captured the whole flow in one bash script. The design priority, before anything else, was hard-requirement detection up front: check everything that could possibly go wrong, and if it will, abort immediately with a clear message and a suggested fix – before downloading a single byte.
So phase zero of the script verifies the OS is Linux, the CPU is x86_64, the C library is glibc and not musl, the handful of needed CLI tools are present, there is about 3 GiB of free disk, and all four download hosts are reachable. Only then does it print a summary of exactly what it will download and where, and ask for confirmation.
Three gotchas worth the post
1. /dev/tty can exist but be unopenable. The script asks for confirmation before downloading, so it needs a terminal to read the answer from. The naive check [ -r /dev/tty ] is a trap: in a sandbox with no controlling terminal, that test passes, yet actually reading from /dev/tty fails with “No such device or address”. The robust approach is to prefer stdin when it is a terminal, and only fall back to /dev/tty after confirming it can really be opened:
if [ -t 0 ]; then
TTY_SRC="stdin"
elif ( : < /dev/tty ) 2>/dev/null; then
TTY_SRC="/dev/tty"
fi
If neither works, the run is treated as non-interactive, and there is an ASSUME_YES=1 escape hatch for CI use.
2. ARM is a genuine dead end. It is tempting to detect the architecture and pick the matching binary, but for the Android SDK there is no matching binary: Google ships the build tools (aapt2, aidl, d8 and friends) only as Linux x86_64 executables. There is no official ARM build, so this cannot run on a Raspberry Pi, an ARM cloud VM, or an Apple-Silicon Linux VM. The honest thing is to detect that early and explain it, rather than crash deep inside the build with a confusing error. The same reasoning applies to musl: the JDK and SDK tools are glibc builds and simply will not run on Alpine.
3. “Idempotent” does not mean “skip everything on a re-run.” The downloads are idempotent: each of the JDK, SDK and Gradle is skipped if its extracted result is already there, so a re-run does not re-fetch half a gigabyte. But the script deliberately has no global “already done, do nothing” exit. The SDK package check, the project-file generation, and the build itself run every single time. For a build script that is correct – you want a rebuild after you change the source, and you want a partial install to self-heal. The consequence, though, is that the generated project files are regenerated on every run, so any manual edits to them get overwritten. The confirmation prompt warns about exactly that.
A vector launcher icon, no binary assets
One detail I am quietly pleased with. The first build used the default launcher icon; adding a real one would normally mean dropping a stack of PNGs into five density buckets. Instead the icon is a vector adaptive icon, which keeps the whole project text-only – it can still be generated entirely from heredocs in the script, with no binary file to embed or download.
The reason it stays simple is a happy coincidence of version numbers: the app’s minSdk is 26, which is exactly the API level where adaptive icons were introduced. So a single mipmap-anydpi-v26 entry covers every device that can run the app, and none of the legacy PNG density buckets are needed. The icon ends up being a handful of small XML files: a background colour, an adaptive-icon wrapper, and a vector drawable that takes the 24×24 Material “wifi” glyph and scales it 2.5x onto the 108×108 canvas, kept inside the central safe zone the launcher guarantees to show.
The result, and an honest caveat
The first full build produced a roughly 2 MB debug-signed APK, verified with aapt dump badging (correct package id, minSdk 26, targetSdk 34, the launchable activity and the vector icon all present). I then installed it on a real phone, and it works: tapping the icon goes straight to the tethering and hotspot settings.
The caveat I want to be honest about: one phone working is not proof it works everywhere. com.android.settings.TetherSettings is an undocumented internal activity. It works on most stock ROMs and on my device, and the ACTION_WIRELESS_SETTINGS fallback covers ROMs that moved or renamed it – but I cannot guarantee behaviour across the full range of vendor ROMs from a single test.
Still, the headline holds up: a locked-down Linux box with no admin rights can build a real, working Android app, as long as it is x86_64 and glibc. The rootless toolchain trick, the fail-fast preflight, and those three small gotchas are the parts I would carry over to any similar provisioning script.
The whole script
For the curious, here is build-hotspot-apk.sh in full. It is self-contained: drop it into an empty directory on an x86_64 glibc Linux box and run it, and it provisions the toolchain and builds the APK from scratch.
#!/usr/bin/env bash
#
# build-hotspot-apk.sh
#
# Provisions a complete, self-contained Android build toolchain (JDK 17,
# Android SDK, Gradle) entirely inside one base directory under your home
# folder, then builds the "hotspot-shortcut" debug APK.
#
# No root required. Nothing is installed system-wide. To remove everything,
# just delete the toolchain and project directories printed at the end.
#
# Override locations if you like:
# BASE_DIR=/somewhere/toolchain PROJECT_DIR=/somewhere/app ./build-hotspot-apk.sh
#
set -euo pipefail
# --------------------------------------------------------------------------
# Configuration
# --------------------------------------------------------------------------
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BASE_DIR="${BASE_DIR:-$SCRIPT_DIR/toolchain}"
PROJECT_DIR="${PROJECT_DIR:-$SCRIPT_DIR/hotspot-shortcut}"
CMDLINE_TOOLS_VERSION="11076708" # Android command-line tools build
GRADLE_VERSION="8.7" # AGP 8.5 requires Gradle >= 8.7
BUILD_TOOLS_VERSION="34.0.0"
PLATFORM="android-34"
ADOPTIUM_URL="https://api.adoptium.net/v3/binary/latest/17/ga/linux/x64/jdk/hotspot/normal/eclipse"
CMDLINE_TOOLS_URL="https://dl.google.com/android/repository/commandlinetools-linux-${CMDLINE_TOOLS_VERSION}_latest.zip"
GRADLE_URL="https://services.gradle.org/distributions/gradle-${GRADLE_VERSION}-bin.zip"
# Hosts we must be able to reach (for the network preflight).
NET_HOSTS=(
"https://api.adoptium.net/"
"https://dl.google.com/"
"https://services.gradle.org/"
"https://repo1.maven.org/maven2/"
)
MIN_DISK_KB=3145728 # require ~3 GiB free
# --------------------------------------------------------------------------
# Output helpers
# --------------------------------------------------------------------------
if [ -t 1 ]; then BOLD=$'\033[1m'; RED=$'\033[31m'; GRN=$'\033[32m'; YEL=$'\033[33m'; RST=$'\033[0m'
else BOLD=""; RED=""; GRN=""; YEL=""; RST=""; fi
info() { printf '%s==>%s %s\n' "$GRN" "$RST" "$*"; }
warn() { printf '%sWARNING:%s %s\n' "$YEL" "$RST" "$*" >&2; }
# fail "<one-line summary>" "<what to do about it>"
fail() {
printf '\n%sERROR:%s %s\n' "$RED$BOLD" "$RST" "$1" >&2
if [ "${2:-}" != "" ]; then
printf ' %s\n' "$2" >&2
fi
printf '\nAborted. Nothing was changed on your system.\n' >&2
exit 1
}
# ==========================================================================
# PHASE 0 — HARD REQUIREMENT CHECKS (fail fast, before touching anything)
# ==========================================================================
info "Checking that this machine can run the build..."
# --- Operating system ---
if [ "$(uname -s)" != "Linux" ]; then
fail "This script only runs on Linux (detected: $(uname -s))." \
"Use a Linux machine, WSL, or a Linux container/VM."
fi
# --- CPU architecture ---
# Google ships the Android SDK build tools (aapt2, aidl, d8, ...) ONLY as
# Linux x86_64 binaries. There is no ARM build of the SDK, so this is a hard
# limit, not something the script can work around.
ARCH="$(uname -m)"
case "$ARCH" in
x86_64|amd64) ;;
*)
fail "Unsupported CPU architecture: '$ARCH'. The Android SDK build tools exist only for 64-bit Intel/AMD (x86_64)." \
"This will NOT work on ARM (e.g. Raspberry Pi, Apple Silicon Linux VMs, ARM cloud servers). Use an x86_64 Linux machine."
;;
esac
# --- C library (glibc vs musl) ---
# The Temurin JDK and the Android SDK tools are glibc builds and will not run
# on musl-based distributions such as Alpine.
if command -v ldd >/dev/null 2>&1; then
if ldd --version 2>&1 | grep -qi musl; then
fail "This distribution uses the 'musl' C library (e.g. Alpine Linux)." \
"The downloaded JDK and Android tools require glibc. Use a glibc-based distro, for example: Ubuntu, Debian, Linux Mint, Fedora, RHEL, CentOS Stream, Rocky Linux, AlmaLinux, openSUSE, or Arch Linux."
fi
elif ls /lib/ld-musl-* >/dev/null 2>&1; then
fail "This distribution uses the 'musl' C library (e.g. Alpine Linux)." \
"The downloaded JDK and Android tools require glibc. Use a glibc-based distro, for example: Ubuntu, Debian, Linux Mint, Fedora, RHEL, CentOS Stream, Rocky Linux, AlmaLinux, openSUSE, or Arch Linux."
fi
# --- Required command-line tools ---
# Pick a downloader.
DL_TOOL=""
if command -v curl >/dev/null 2>&1; then DL_TOOL="curl"
elif command -v wget >/dev/null 2>&1; then DL_TOOL="wget"; fi
MISSING=()
[ -n "$DL_TOOL" ] || MISSING+=("curl or wget")
for tool in tar unzip find awk df grep sed; do
command -v "$tool" >/dev/null 2>&1 || MISSING+=("$tool")
done
if [ "${#MISSING[@]}" -gt 0 ]; then
fail "Missing required tool(s): ${MISSING[*]}." \
"Install them with your package manager (this step needs admin rights, e.g. 'sudo apt install ${MISSING[*]}'). Everything afterwards needs no admin rights."
fi
# --- Free disk space ---
mkdir -p "$BASE_DIR"
AVAIL_KB="$(df -Pk "$BASE_DIR" | awk 'NR==2 {print $4}')"
if [ -z "$AVAIL_KB" ] || [ "$AVAIL_KB" -lt "$MIN_DISK_KB" ]; then
HAVE_GB=$(( ${AVAIL_KB:-0} / 1024 / 1024 ))
fail "Not enough free disk space at '$BASE_DIR' (have ~${HAVE_GB} GiB, need ~3 GiB)." \
"Free up space or set BASE_DIR to a location with more room."
fi
# --- Network reachability ---
check_url() {
if [ "$DL_TOOL" = "curl" ]; then
curl -sSfL -I -m 15 -o /dev/null "$1" >/dev/null 2>&1
else
wget -q --spider --timeout=15 -t 1 "$1"
fi
}
for host in "${NET_HOSTS[@]}"; do
if ! check_url "$host"; then
fail "Cannot reach $host" \
"An internet connection to this host is required to download the toolchain. Check your network, proxy, or firewall (a sandbox may be blocking it)."
fi
done
# --- Gentle, non-fatal notes ---
if [ "$(id -u)" = "0" ]; then
warn "Running as root. This is not required; the script is designed for a regular user. Continuing anyway."
fi
info "All requirements satisfied (Linux / x86_64 / glibc, tools present, disk and network OK)."
# ==========================================================================
# Confirmation — tell the user exactly what is about to happen
# ==========================================================================
# Detect which toolchain parts already exist, so the summary can show that only
# the missing parts will be downloaded (anything present is reused, not re-fetched).
JDK_FOUND="$(find "$BASE_DIR" -maxdepth 1 -type d -name 'jdk-17*' 2>/dev/null | head -1 || true)"
if [ -n "$JDK_FOUND" ] && [ -x "$JDK_FOUND/bin/java" ]; then
JDK_STAT="already installed, will skip"
else
JDK_STAT="will download"
fi
if [ -x "$BASE_DIR/android-sdk/cmdline-tools/latest/bin/sdkmanager" ] \
&& [ -d "$BASE_DIR/android-sdk/platforms/$PLATFORM" ] \
&& [ -d "$BASE_DIR/android-sdk/build-tools/$BUILD_TOOLS_VERSION" ] \
&& [ -d "$BASE_DIR/android-sdk/platform-tools" ]; then
SDK_STAT="already installed, will skip"
else
SDK_STAT="will download missing parts"
fi
if [ -x "$BASE_DIR/gradle-$GRADLE_VERSION/bin/gradle" ]; then
GRADLE_STAT="already installed, will skip"
else
GRADLE_STAT="will download"
fi
cat <<EOF
${BOLD}About to set up an Android build toolchain and build the APK.${RST}
Only the parts that do not already exist are downloaded; anything already
present under the toolchain directory is reused. Status for this run:
- JDK 17 (Eclipse Temurin) [$JDK_STAT]
- Android SDK (cmdline-tools, $PLATFORM, build-tools $BUILD_TOOLS_VERSION) [$SDK_STAT]
- Gradle $GRADLE_VERSION (build tool) [$GRADLE_STAT]
- Android/Kotlin libraries (fetched by Gradle as needed during the build)
Download sources: api.adoptium.net, dl.google.com, services.gradle.org,
repo1.maven.org. A first-time, from-scratch run fetches ~500 MB plus build deps.
Everything is placed UNDER these directories only (no system-wide changes,
no admin rights, removable by deleting them):
- Toolchain: $BASE_DIR
- Project: $PROJECT_DIR
${YEL}${BOLD}WARNING:${RST} the project files under the project directory are (re)generated
on every run. Any manual changes you made to those files WILL BE OVERWRITTEN.
EOF
# Allow non-interactive use: ASSUME_YES=1 ./build-hotspot-apk.sh
if [ "${ASSUME_YES:-}" = "1" ]; then
info "ASSUME_YES=1 set — proceeding without prompting."
else
# Find a usable terminal to read the answer from: prefer stdin if it is a
# terminal; otherwise fall back to /dev/tty, but only if it can actually be
# opened (it may exist yet be unusable when there is no controlling tty).
TTY_SRC=""
if [ -t 0 ]; then
TTY_SRC="stdin"
elif ( : < /dev/tty ) 2>/dev/null; then
TTY_SRC="/dev/tty"
fi
if [ -z "$TTY_SRC" ]; then
fail "No terminal available to confirm, and ASSUME_YES is not set." \
"Re-run interactively, or run non-interactively with: ASSUME_YES=1 $0"
fi
printf '%sProceed? [y/N] %s' "$BOLD" "$RST"
if [ "$TTY_SRC" = "stdin" ]; then
read -r reply
else
read -r reply < /dev/tty
fi
case "$reply" in
[yY]|[yY][eE][sS]) ;;
*) printf '\nCancelled. Nothing was downloaded or changed.\n'; exit 0 ;;
esac
fi
# ==========================================================================
# Download helper
# ==========================================================================
download() {
# download <url> <destination-file>
local url="$1" dest="$2"
info "Downloading $(basename "$dest") ..."
if [ "$DL_TOOL" = "curl" ]; then
curl -fSL --retry 3 --connect-timeout 30 -o "$dest" "$url"
else
wget -q --tries=3 -O "$dest" "$url"
fi
}
DL_DIR="$BASE_DIR/dl"
mkdir -p "$DL_DIR"
# ==========================================================================
# PHASE 1 — JDK 17 (idempotent)
# ==========================================================================
JDK_DIR="$(find "$BASE_DIR" -maxdepth 1 -type d -name 'jdk-17*' 2>/dev/null | head -1 || true)"
if [ -n "$JDK_DIR" ] && [ -x "$JDK_DIR/bin/java" ]; then
info "JDK 17 already present: $JDK_DIR"
else
download "$ADOPTIUM_URL" "$DL_DIR/jdk17.tar.gz"
info "Extracting JDK 17 ..."
tar -xzf "$DL_DIR/jdk17.tar.gz" -C "$BASE_DIR"
JDK_DIR="$(find "$BASE_DIR" -maxdepth 1 -type d -name 'jdk-17*' | head -1)"
if [ -z "$JDK_DIR" ] || [ ! -x "$JDK_DIR/bin/java" ]; then
fail "JDK extraction did not produce a usable java binary." "The download may be corrupt; delete '$DL_DIR' and re-run."
fi
fi
export JAVA_HOME="$JDK_DIR"
export PATH="$JAVA_HOME/bin:$PATH"
info "Using Java: $("$JAVA_HOME/bin/java" -version 2>&1 | head -1)"
# ==========================================================================
# PHASE 2 — Android SDK (idempotent)
# ==========================================================================
export ANDROID_HOME="$BASE_DIR/android-sdk"
SDKMANAGER="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager"
if [ ! -x "$SDKMANAGER" ]; then
download "$CMDLINE_TOOLS_URL" "$DL_DIR/cmdline-tools.zip"
info "Installing Android command-line tools ..."
mkdir -p "$ANDROID_HOME/cmdline-tools"
rm -rf "$ANDROID_HOME/cmdline-tools/latest" "$ANDROID_HOME/cmdline-tools/cmdline-tools"
unzip -q "$DL_DIR/cmdline-tools.zip" -d "$ANDROID_HOME/cmdline-tools"
mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest"
[ -x "$SDKMANAGER" ] || fail "sdkmanager not found after unzip." "The command-line tools download may be corrupt; delete '$DL_DIR' and re-run."
else
info "Android command-line tools already present."
fi
info "Accepting SDK licenses ..."
yes | "$SDKMANAGER" --sdk_root="$ANDROID_HOME" --licenses >/dev/null 2>&1 || true
info "Installing SDK packages (platform-tools, $PLATFORM, build-tools;$BUILD_TOOLS_VERSION) ..."
if ! "$SDKMANAGER" --sdk_root="$ANDROID_HOME" \
"platform-tools" "platforms;$PLATFORM" "build-tools;$BUILD_TOOLS_VERSION" >/dev/null 2>&1; then
fail "Failed to install Android SDK packages." \
"This usually means a download was interrupted. Re-run the script (it resumes), or delete '$ANDROID_HOME' and try again."
fi
# ==========================================================================
# PHASE 3 — Gradle (idempotent)
# ==========================================================================
GRADLE_BIN="$BASE_DIR/gradle-$GRADLE_VERSION/bin/gradle"
if [ ! -x "$GRADLE_BIN" ]; then
download "$GRADLE_URL" "$DL_DIR/gradle.zip"
info "Extracting Gradle $GRADLE_VERSION ..."
unzip -q "$DL_DIR/gradle.zip" -d "$BASE_DIR"
[ -x "$GRADLE_BIN" ] || fail "Gradle binary not found after unzip." "The Gradle download may be corrupt; delete '$DL_DIR' and re-run."
else
info "Gradle $GRADLE_VERSION already present."
fi
# ==========================================================================
# PHASE 4 — Create the project files
# ==========================================================================
info "Creating project at $PROJECT_DIR ..."
APP_PKG_DIR="$PROJECT_DIR/app/src/main/java/com/example/hotspotshortcut"
RES_DIR="$PROJECT_DIR/app/src/main/res"
mkdir -p "$APP_PKG_DIR" "$RES_DIR/values" "$RES_DIR/drawable" "$RES_DIR/mipmap-anydpi-v26"
cat > "$PROJECT_DIR/settings.gradle.kts" <<'EOF'
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
}
rootProject.name = "hotspot-shortcut"
include(":app")
EOF
cat > "$PROJECT_DIR/build.gradle.kts" <<'EOF'
plugins {
id("com.android.application") version "8.5.0" apply false
id("org.jetbrains.kotlin.android") version "1.9.24" apply false
}
EOF
cat > "$PROJECT_DIR/gradle.properties" <<'EOF'
org.gradle.jvmargs=-Xmx2048m
android.useAndroidX=true
kotlin.code.style=official
EOF
# local.properties must point at the SDK we just installed (resolved path).
printf 'sdk.dir=%s\n' "$ANDROID_HOME" > "$PROJECT_DIR/local.properties"
cat > "$PROJECT_DIR/app/build.gradle.kts" <<'EOF'
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.example.hotspotshortcut"
compileSdk = 34
defaultConfig {
applicationId = "com.example.hotspotshortcut"
minSdk = 26
targetSdk = 34
versionCode = 1
versionName = "1.0"
}
buildTypes {
release {
isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}
dependencies {
implementation("androidx.core:core-ktx:1.13.1")
}
EOF
cat > "$PROJECT_DIR/app/src/main/AndroidManifest.xml" <<'EOF'
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher"
android:allowBackup="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@android:style/Theme.NoDisplay">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
EOF
cat > "$PROJECT_DIR/app/src/main/res/values/strings.xml" <<'EOF'
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Hotspot Settings</string>
</resources>
EOF
# --- Launcher icon (vector adaptive icon; all plain XML, no binary assets) ---
# minSdk is 26, which is exactly when adaptive icons were introduced, so a single
# mipmap-anydpi-v26 entry covers every device that can run the app — no legacy
# PNG densities required.
cat > "$RES_DIR/values/ic_launcher_background.xml" <<'EOF'
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#1565C0</color>
</resources>
EOF
cat > "$RES_DIR/mipmap-anydpi-v26/ic_launcher.xml" <<'EOF'
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
EOF
# Foreground: a white Wi-Fi/hotspot glyph, drawn on the 108x108 adaptive-icon
# canvas and kept inside the ~72dp central safe zone the launcher guarantees to
# show. The 24x24 Material "wifi" path is scaled 2.5x and centred via a group.
cat > "$RES_DIR/drawable/ic_launcher_foreground.xml" <<'EOF'
<?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">
<group android:scaleX="2.5" android:scaleY="2.5"
android:translateX="24" android:translateY="24">
<path android:fillColor="#FFFFFF"
android:pathData="M1,9 L3,11 C7.97,6.03 16.03,6.03 21,11 L23,9 C16.93,2.93 7.08,2.93 1,9 Z M5,13 L7,15 C9.76,12.24 14.24,12.24 17,15 L19,13 C15.14,9.14 8.87,9.14 5,13 Z M9,17 L12,20 L15,17 C13.35,15.34 10.66,15.34 9,17 Z" />
</group>
</vector>
EOF
cat > "$APP_PKG_DIR/MainActivity.kt" <<'EOF'
package com.example.hotspotshortcut
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Intent
import android.os.Bundle
import android.provider.Settings
import android.widget.Toast
class MainActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
openTetherSettings()
finish()
}
private fun openTetherSettings() {
// Primary: the internal tethering/hotspot settings activity.
val tether = Intent().apply {
setClassName(
"com.android.settings",
"com.android.settings.TetherSettings"
)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
try {
startActivity(tether)
return
} catch (e: ActivityNotFoundException) {
// OEM ROM moved/renamed it — fall through to the public fallback.
}
// Fallback: broader wireless settings (always available).
try {
startActivity(
Intent(Settings.ACTION_WIRELESS_SETTINGS)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
} catch (e: ActivityNotFoundException) {
Toast.makeText(this, "Could not open settings", Toast.LENGTH_LONG).show()
}
}
}
EOF
# ==========================================================================
# PHASE 5 — Generate the Gradle wrapper and build
# ==========================================================================
cd "$PROJECT_DIR"
if [ ! -x "$PROJECT_DIR/gradlew" ]; then
info "Generating Gradle wrapper ..."
"$GRADLE_BIN" wrapper --gradle-version "$GRADLE_VERSION" >/dev/null
fi
info "Building the debug APK (first run downloads Android/Kotlin dependencies) ..."
if ! "$GRADLE_BIN" --no-daemon assembleDebug; then
fail "The Gradle build failed (see the output above)." \
"If it mentions a blocked host or download timeout, check your network/proxy and re-run."
fi
# ==========================================================================
# PHASE 6 — Verify and report
# ==========================================================================
APK="$PROJECT_DIR/app/build/outputs/apk/debug/app-debug.apk"
if [ ! -s "$APK" ]; then
fail "Build reported success but the APK is missing or empty." \
"Expected at: $APK"
fi
# Copy the APK to the project root, named after the project (e.g. hotspot-shortcut.apk).
PROJECT_NAME="$(basename "$PROJECT_DIR")"
FINAL_APK="$PROJECT_DIR/$PROJECT_NAME.apk"
cp -f "$APK" "$FINAL_APK"
printf '\n%s%sBUILD SUCCESSFUL%s\n' "$GRN" "$BOLD" "$RST"
printf ' APK: %s\n' "$FINAL_APK"
printf ' (also at: %s)\n' "$APK"
printf ' Size: %s\n' "$(du -h "$FINAL_APK" | cut -f1)"
printf ' Toolchain: %s\n' "$BASE_DIR"
printf '\nInstall it on a phone with:\n'
printf ' %s/android-sdk/platform-tools/adb install "%s"\n' "$BASE_DIR" "$FINAL_APK"
printf 'or copy the APK to the device and tap it (enable "Install unknown apps" first).\n'
Discussion about this in the corresponding post in the international Delphi Praxis forum.