- The Big Picture
- Kotlin — The Language
- Project Structure — Where Everything Lives
- Gradle — The Build System
- AndroidManifest.xml — Your App's ID Card
- Activities — Screens in Your App
- Layouts (XML) — Building the UI
- ViewBinding — Connecting XML to Code
- Resources — Colors, Themes, Drawables, Strings
- Material Design & Themes
- Intents — Talking to Other Apps
- Services — Work in the Background
- Coroutines — Doing Things Without Freezing
- Wearable Data Layer — Watch Talks to Phone
- WearOS — Building for the Watch
- AccessibilityService — Controlling Other Apps
- Building, Installing, Debugging
- Putting It All Together — How CameraRemote Works
An Android app is basically:
- Kotlin/Java code that runs the logic
- XML files that describe what the screen looks like
- A manifest that tells Android what your app is and what it can do
- A build file that pulls everything together into an installable
.apkfile
When a user taps your app icon, Android reads the manifest, finds the "main" activity (screen), inflates (loads) the XML layout, and runs your Kotlin code.
CameraRemote has two apps in one project:
mobile/— the phone app (receives commands, controls the camera)wear/— the watch app (sends commands from your wrist)
They live in the same project but build into two separate APKs.
Android apps are written in Kotlin (or Java, but Kotlin is the modern standard). Here's a crash course on everything you'll see in this app.
// "val" = can't change it later (like a constant)
val name = "Camera Remote" // Kotlin figures out it's a String
val count: Int = 42 // You can also say the type yourself
// "var" = can change it later
var status = "Ready"
status = "Photo taken!" // this is fine
// "lateinit" = "I'll set this later, I promise"
// Used when you can't set it at creation time
private lateinit var binding: ActivityMainBinding// Basic function
fun sayHello() {
println("Hello!")
}
// Function with parameters and a return value
fun add(a: Int, b: Int): Int {
return a + b
}
// Short function (one-liner)
fun multiply(a: Int, b: Int) = a * b
// Function that doesn't return anything useful
fun logMessage(msg: String) {
Log.d("MyApp", msg)
}A class is a blueprint. You create "instances" (objects) from it.
// Simple class
class Dog(val name: String, var age: Int) {
fun bark() {
println("$name says: Woof!")
}
}
// Using it:
val myDog = Dog("Rex", 3)
myDog.bark() // prints "Rex says: Woof!"
myDog.age = 4 // you can change "var" properties// AppCompatActivity is a class Android provides
// Your activity EXTENDS it (inherits all its abilities)
class MainActivity : AppCompatActivity() {
// Now you have all the powers of AppCompatActivity
// plus whatever you add here
}
// You can also implement interfaces (contracts)
// This means "I'm an Activity AND I can receive messages"
class RemoteActivity : AppCompatActivity(), MessageClient.OnMessageReceivedListener {
// Must implement onMessageReceived() because the interface requires it
override fun onMessageReceived(messageEvent: MessageEvent) {
// handle the message
}
}This is a big deal in Kotlin. A variable can either hold a value or be null (empty/nothing). Kotlin makes you handle both cases so your app doesn't crash.
// Regular variable — CANNOT be null
var name: String = "Camera"
// name = null // ERROR! Won't compile
// Nullable variable — CAN be null (notice the ?)
var vibrator: Vibrator? = null // this is fine
// Safe call (?.) — only runs if not null
vibrator?.vibrate(50)
// If vibrator is null, this line does nothing (no crash)
// If vibrator has a value, it calls vibrate(50)
// Elvis operator (?:) — provide a fallback
val displayName = node.displayName ?: "Unknown"
// If displayName is null, use "Unknown" instead
// Let — run code only if not null
vibrator?.let {
it.vibrate(50) // "it" is the non-null vibrator
}
// !! — "I'm SURE this isn't null" (dangerous, can crash)
val v = vibrator!! // crashes if vibrator is null
// Avoid this when possible!In CameraRemote, you'll see this pattern a lot:
private var vibrator: Vibrator? = null // might not be available
// Later, safe usage:
vibrator?.vibrate(VibrationEffect.createOneShot(50, VibrationEffect.DEFAULT_AMPLITUDE))fun formatCommand(command: String): String {
return when (command) {
"open_camera" -> "Opening camera..."
"take_photo" -> "Taking photo..."
"toggle_flash" -> "Toggling flash..."
else -> command // default case
}
}val watchName = "Galaxy Watch"
val text = "Connected to $watchName" // simple
val text2 = "Version ${BuildConfig.VERSION_NAME}" // expression (use braces)// "try" the risky code, "catch" any errors
try {
startActivity(intent)
} catch (e: Exception) {
Log.e(TAG, "Failed to start activity", e)
Toast.makeText(this, "Something went wrong", Toast.LENGTH_SHORT).show()
}CameraRemote wraps almost everything in try-catch because hardware features (vibrator, wearable APIs) might not be available on every device.
class MainActivity : AppCompatActivity() {
companion object {
private const val TAG = "MainActivity" // like a static constant
private const val GITHUB_URL = "https://github.com/..."
}
// TAG and GITHUB_URL belong to the CLASS, not to any instance
// You can use them anywhere in this class
}// Instead of writing a whole function, you can write it inline:
binding.btnCapture.setOnClickListener {
sendCommand("take_photo")
}
// The { } block IS the function that runs when the button is clicked
// Longer form:
binding.btnCapture.setOnClickListener { view ->
// "view" is the button that was clicked
sendCommand("take_photo")
}Here's what the CameraRemote project looks like on disk:
CameraRemote/
├── build.gradle <-- Root build file (shared settings)
├── settings.gradle <-- Lists all modules
├── gradle.properties <-- Build settings
│
├── mobile/ <-- PHONE APP MODULE
│ ├── build.gradle <-- Phone app dependencies & config
│ └── src/main/
│ ├── AndroidManifest.xml <-- Phone app's ID card
│ ├── java/com/cameraremote/mobile/
│ │ ├── MainActivity.kt <-- Main screen
│ │ ├── CameraControlService.kt <-- Controls camera app
│ │ └── WearMessageListenerService.kt <-- Receives watch messages
│ └── res/
│ ├── layout/activity_main.xml <-- UI layout
│ ├── drawable/ <-- Icons, shapes, backgrounds
│ ├── values/colors.xml <-- Color definitions
│ ├── values/themes.xml <-- App theme
│ └── values/strings.xml <-- Text strings
│
└── wear/ <-- WATCH APP MODULE
├── build.gradle <-- Watch app dependencies & config
└── src/main/
├── AndroidManifest.xml <-- Watch app's ID card
├── java/com/cameraremote/wear/
│ ├── RemoteActivity.kt <-- Main watch screen
│ ├── CameraRemoteTileService.kt <-- Watch face tile
│ └── TileActionActivity.kt <-- Handles tile taps
└── res/
├── layout/activity_remote.xml <-- Watch UI layout
├── drawable/ <-- Watch icons, button shapes
└── values/ <-- Watch colors, themes
Key insight: The java/ folder contains your Kotlin code (confusing name, historical reason). The res/ folder contains all non-code stuff (layouts, images, colors, strings).
Gradle takes all your code, resources, and dependencies and smashes them into an APK. You configure it with .gradle files written in Groovy (a scripting language).
// This sets versions used by ALL modules
buildscript {
ext {
kotlin_version = '1.9.22'
}
}
plugins {
// These are the tools (plugins) available but not applied to root
id 'com.android.application' version '8.2.2' apply false
id 'org.jetbrains.kotlin.android' version '1.9.22' apply false
}apply false means "make this plugin available, but don't use it here — let individual modules decide."
This is where the real action is. Here's the phone app's:
plugins {
id 'com.android.application' // "this module is an Android app"
id 'org.jetbrains.kotlin.android' // "we're writing Kotlin"
}
android {
namespace 'com.cameraremote.mobile' // unique package name
compileSdk 34 // which Android version to compile against
defaultConfig {
applicationId "com.cameraremote.mobile" // unique ID on the Play Store
minSdk 26 // oldest Android version supported (Android 8.0)
targetSdk 34 // newest Android version you've tested against
versionCode 1 // integer, goes up with each release
versionName "1.0.0" // human-readable version
// Custom APK filename
setProperty("archivesBaseName", "CameraRemote-Phone-v${versionName}")
// Custom build constant accessible from Kotlin code
buildConfigField "String", "BUILD_TIMESTAMP",
"\"${new Date().format('yyyy-MM-dd HH:mm z', TimeZone.getTimeZone('UTC'))}\""
}
buildFeatures {
viewBinding true // auto-generates binding classes for layouts
buildConfig true // generates BuildConfig class
}
}
dependencies {
// These are libraries your app uses
implementation 'androidx.core:core-ktx:1.12.0'
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'com.google.android.material:material:1.11.0'
implementation 'com.google.android.gms:play-services-wearable:18.1.0'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.7.3'
}What are dependencies? Libraries other people wrote that you can use. Instead of writing your own Material Design buttons from scratch, you add com.google.android.material:material:1.11.0 and get all of Google's UI components for free.
rootProject.name = "CameraRemote"
include ':mobile' // the phone app
include ':wear' // the watch appThis tells Gradle "this project has two apps inside it."
Every Android app has a manifest. It tells the system:
- What screens (activities) exist
- What services run in the background
- What permissions the app needs
- What hardware features it requires
Here's the phone app's manifest (simplified):
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- This app needs to use the vibrator -->
<uses-permission android:name="android.permission.VIBRATE" />
<application
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:theme="@style/Theme.CameraRemote">
<!-- Main screen — the one that opens when you tap the app icon -->
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<!-- MAIN + LAUNCHER = "show this in the app drawer" -->
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- Background service that controls the camera -->
<service
android:name=".CameraControlService"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE"
android:exported="false">
<intent-filter>
<action android:name="android.accessibilityservice.AccessibilityService" />
</intent-filter>
</service>
<!-- Background listener for watch messages -->
<service
android:name=".WearMessageListenerService"
android:exported="true">
<intent-filter>
<action
android:name="com.google.android.gms.wearable.MESSAGE_RECEIVED" />
<data
android:scheme="wear"
android:host="*"
android:pathPrefix="/camera_remote" />
</intent-filter>
</service>
</application>
</manifest>Key things to notice:
android:exported="true"means other apps can start this componentintent-filterdescribes what triggers this component- The wearable listener has a data filter for the
/camera_remotepath — it only wakes up for messages on that path
An Activity is one screen. When you open CameraRemote on your phone, that's MainActivity. When you open it on your watch, that's RemoteActivity.
An activity goes through stages. Android calls specific functions at each stage:
User opens app → onCreate() → onStart() → onResume()
↓
[App is visible and interactive]
↓
User switches away → onPause() → onStop()
↓
User comes back → onRestart() → onStart() → onResume()
User closes app → onDestroy()
The ones you'll use most:
onCreate()— Set up the screen. Runs once when the activity is created.onResume()— Activity is now visible. Good place to refresh data.onPause()— Activity is going away. Good place to stop listeners.
Here's a real example from CameraRemote's watch app:
class RemoteActivity : AppCompatActivity(), MessageClient.OnMessageReceivedListener {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) // ALWAYS call super first
// Set up the UI, buttons, etc.
// This only runs ONCE when the screen is created
}
override fun onResume() {
super.onResume()
// Screen is now visible — start listening for messages
messageClient?.addListener(this)
checkConnection()
}
override fun onPause() {
super.onPause()
// Screen is going away — stop listening
messageClient?.removeListener(this)
}
}Why add/remove the listener in onResume/onPause? Because you only want to listen for messages while the screen is actually visible. If you forget to remove the listener, you waste battery and might crash.
AppCompatActivity is a fancier version of Activity from the AndroidX library. It gives you:
- Material Design support
- Dark mode support
- Better backwards compatibility
Always use AppCompatActivity for modern apps.
Your screen's appearance is defined in XML files in res/layout/. Think of it like HTML for Android.
<!-- Text label -->
<TextView
android:id="@+id/tvStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Ready"
android:textSize="14sp"
android:textColor="#FFFFFF" />
<!-- Clickable button -->
<Button
android:id="@+id/btnCapture"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Take Photo" />
<!-- Image -->
<ImageView
android:layout_width="48dp"
android:layout_height="48dp"
android:src="@drawable/ic_camera" />
<!-- Clickable image (button with icon) -->
<ImageButton
android:id="@+id/btnFlash"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="@drawable/bg_btn_flash"
android:src="@drawable/ic_flash"
android:tint="@android:color/white" />LinearLayout — stacks children in a line (vertical or horizontal):
<!-- Vertical stack -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView android:text="First" ... />
<TextView android:text="Second" ... />
<TextView android:text="Third" ... />
</LinearLayout>
<!-- Horizontal row -->
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center">
<ImageButton ... /> <!-- Left -->
<ImageButton ... /> <!-- Middle -->
<ImageButton ... /> <!-- Right -->
</LinearLayout>ScrollView — lets content scroll when it's too tall:
<ScrollView
android:id="@+id/scrollView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true">
<LinearLayout
android:orientation="vertical" ...>
<!-- All your content here -->
<!-- If it's taller than the screen, user can scroll -->
</LinearLayout>
</ScrollView>dp— density-independent pixels. Use for sizes and margins.48dplooks the same physical size on all screens.sp— scalable pixels. Use for text size. Respects the user's font size preference.match_parent— fill the parent containerwrap_content— be just big enough to fit the content
<TextView android:id="@+id/tvStatus" ... />@+id/tvStatus creates a unique ID. You use this ID to find the view from Kotlin code. The @+ means "create this ID if it doesn't exist."
Here's how the circle buttons are arranged on the watch (simplified):
<!-- BoxInsetLayout handles round watch displays -->
<androidx.wear.widget.BoxInsetLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/surface">
<ScrollView
android:id="@+id/scrollView"
app:boxedEdges="all"> <!-- Keep content inside round edges -->
<LinearLayout
android:orientation="vertical"
android:gravity="center_horizontal">
<!-- Status text at top -->
<TextView android:id="@+id/tvConnection" ... />
<TextView android:id="@+id/tvStatus" ... />
<!-- Row 1: three buttons side by side -->
<LinearLayout android:orientation="horizontal" android:gravity="center">
<!-- Each button is a vertical stack: circle icon + label -->
<LinearLayout android:orientation="vertical" android:gravity="center_horizontal">
<ImageButton
android:id="@+id/btnOpenCamera"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="@drawable/bg_btn_camera"
android:src="@drawable/ic_photo_camera" />
<TextView android:text="Open" />
</LinearLayout>
<LinearLayout android:orientation="vertical" android:gravity="center_horizontal">
<ImageButton
android:id="@+id/btnCapture"
android:layout_width="56dp"
android:layout_height="56dp"
android:background="@drawable/bg_btn_capture" ... />
<TextView android:text="Capture" />
</LinearLayout>
<!-- ...timer button... -->
</LinearLayout>
<!-- Row 2: flash, switch, video -->
<!-- Same pattern -->
</LinearLayout>
</ScrollView>
</androidx.wear.widget.BoxInsetLayout>You've built a layout in XML. Now how do you control it from Kotlin? That's what ViewBinding does.
In build.gradle:
buildFeatures {
viewBinding true
}This tells Gradle: "For every XML layout file, generate a Kotlin class I can use."
For activity_main.xml, Gradle generates ActivityMainBinding.
For activity_remote.xml, Gradle generates ActivityRemoteBinding.
class MainActivity : AppCompatActivity() {
// Step 1: Declare the binding
private lateinit var binding: ActivityMainBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Step 2: Inflate (load) the layout
binding = ActivityMainBinding.inflate(layoutInflater)
// Step 3: Set it as the screen's content
setContentView(binding.root)
// Step 4: Now you can access ANY view by its ID!
binding.tvStatus.text = "Ready" // @+id/tvStatus
binding.btnCapture.setOnClickListener { ... } // @+id/btnCapture
binding.statusIcon.setImageResource(R.drawable.ic_check_circle)
}
}How does the naming work?
- XML id:
android:id="@+id/btnOpenCamera" - Kotlin:
binding.btnOpenCamera - Rule: remove underscores and use camelCase
Why ViewBinding instead of findViewById?
// OLD way (error-prone, can crash at runtime)
val button = findViewById<Button>(R.id.btnCapture)
// NEW way (ViewBinding — type-safe, can't crash, autocomplete works)
binding.btnCaptureEverything that isn't code lives in res/. Android separates content from code so you can easily change colors, add translations, or support different screen sizes.
<resources>
<color name="surface">#1B1B1B</color> <!-- dark background -->
<color name="on_surface">#E6E1E5</color> <!-- text on dark bg -->
<color name="primary">#D0BCFF</color> <!-- accent color -->
<color name="btn_capture">#FF5252</color> <!-- red capture button -->
<color name="status_green">#4CAF50</color> <!-- green for "connected" -->
</resources>Use them in XML: android:textColor="@color/on_surface"
Use them in Kotlin: getColor(R.color.primary)
<resources>
<string name="app_name">CameraRemote</string>
</resources>Use in XML: android:text="@string/app_name"
Use in Kotlin: getString(R.string.app_name)
Why not just write text directly? Strings in a resource file can be translated. If you add a values-es/strings.xml with Spanish translations, Android automatically uses it for Spanish users.
Drawables are graphics. They can be:
Vector icons (scalable, crisp at any size):
<!-- ic_flash.xml — a flash/lightning bolt icon -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="@android:color/white">
<path
android:fillColor="@android:color/white"
android:pathData="M7,2v11h3v9l7,-12h-4l4,-8z" />
</vector>Shapes (circles, rectangles, etc.):
<!-- bg_status_active.xml — green-tinted rounded rectangle -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#1A4CAF50" /> <!-- green with low opacity -->
<corners android:radius="12dp" />
</shape>Ripple (animated touch feedback):
<!-- bg_btn_capture.xml — red circle with white ripple when pressed -->
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="@color/ripple_white"> <!-- color of the ripple wave -->
<item>
<shape android:shape="oval"> <!-- circle shape -->
<solid android:color="@color/btn_capture" /> <!-- red fill -->
</shape>
</item>
</ripple>When you set this as a button's background, pressing the button shows a white wave animation over the red circle. It's how Material Design gives touch feedback.
When you create a resource, Android generates a reference to it in a class called R:
R.drawable.ic_flash— points tores/drawable/ic_flash.xmlR.color.primary— points to theprimarycolor incolors.xmlR.layout.activity_main— points tores/layout/activity_main.xmlR.id.btnCapture— points to a view withandroid:id="@+id/btnCapture"R.string.app_name— points to theapp_namestring
Material Design is Google's design system. Material You (Material 3) is the latest version with dynamic colors that adapt to your wallpaper.
A theme is a collection of default colors and styles applied to your whole app:
<resources>
<style name="Theme.CameraRemote" parent="Theme.Material3.Dark.NoActionBar">
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="colorPrimary">@color/primary</item>
<item name="colorSurface">@color/surface</item>
<item name="colorOnSurface">@color/on_surface</item>
</style>
</resources>parent="Theme.Material3.Dark.NoActionBar"— inherit all of Material 3's dark theme defaults- You override specific colors to customize
Then in the manifest: android:theme="@style/Theme.CameraRemote"
This one line makes your app adapt to the user's wallpaper colors (Android 12+):
override fun onCreate(savedInstanceState: Bundle?) {
DynamicColors.applyToActivityIfAvailable(this) // <-- magic line
super.onCreate(savedInstanceState)
// ...
}If the user has a blue wallpaper, your app's accent colors become blue. If they have a green wallpaper, it goes green. On older phones where this isn't supported, it just uses your theme colors as fallback.
In XML, you can reference theme colors with ?attr/ or just ?:
android:textColor="?colorOnSurface" <!-- whatever the theme says "on surface" is -->
android:background="?colorSurface" <!-- the theme's background color -->This means your layout automatically adapts to different themes without hardcoding colors.
An Intent is a message that asks Android to do something. Think of it as a letter: "Dear Android, please open the camera app."
// Open the camera app
val intent = Intent(MediaStore.INTENT_ACTION_STILL_IMAGE_CAMERA)
startActivity(intent)
// Open a website
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com"))
startActivity(intent)
// Open accessibility settings
val intent = Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)
startActivity(intent)// Open a specific activity in YOUR app
val intent = Intent(this, DetailActivity::class.java)
intent.putExtra("item_id", 42) // pass data along
startActivity(intent)
// In the receiving activity:
val itemId = intent.getIntExtra("item_id", 0)The phone app uses intents to open the default camera:
private fun openCamera() {
try {
// Ask Android: "open whatever camera app the user prefers"
val intent = Intent(MediaStore.INTENT_ACTION_STILL_IMAGE_CAMERA)
startActivity(intent)
} catch (e: Exception) {
// If that fails, try a different intent
try {
val fallback = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
startActivity(fallback)
} catch (e2: Exception) {
Toast.makeText(this, "No camera app found", Toast.LENGTH_SHORT).show()
}
}
}Why the try-catch? Not every phone has a camera app that responds to these intents. The try-catch prevents a crash.
Intents can also be "broadcasts" — messages sent to anyone listening:
// Send a broadcast
val intent = Intent("com.cameraremote.ACTION_CAMERA_COMMAND")
intent.putExtra("command", "take_photo")
sendBroadcast(intent)
// Receive it (in a BroadcastReceiver)
private val commandReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val command = intent.getStringExtra("command")
// handle the command
}
}CameraRemote uses this: the WearMessageListenerService receives a message from the watch and broadcasts it to the CameraControlService.
A Service runs in the background without a visible screen. CameraRemote has two:
Wakes up when a message arrives from the watch:
class WearMessageListenerService : WearableListenerService() {
override fun onMessageReceived(messageEvent: MessageEvent) {
// This runs even if the app isn't open!
if (messageEvent.path == "/camera_remote") {
val command = String(messageEvent.data)
// Forward the command to the accessibility service
}
}
}Key: You don't start this service manually. Android starts it automatically when a wearable message arrives (because of the intent-filter in the manifest).
A special service that can see and interact with other apps' screens. Covered in detail in Section 16.
Android has a "main thread" (also called UI thread). This is where your UI runs. If you do something slow on the main thread (like a network call), the UI freezes:
// BAD — this freezes the screen for 2+ seconds while waiting
fun checkConnection() {
val nodes = Wearable.getNodeClient(this).connectedNodes // network call!
// UI is frozen until this returns
}Coroutines let you do slow work on a background thread and come back to the main thread when done:
// Create a scope for launching coroutines
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
// Launch a coroutine (runs in background)
private fun checkConnection() {
scope.launch { // <-- starts on IO thread (background)
try {
val nodes = Wearable.getNodeClient(this@RemoteActivity)
.connectedNodes
.await() // <-- waits without freezing UI
runOnUiThread { // <-- switch back to main thread for UI
if (nodes.isNotEmpty()) {
binding.tvConnection.text = "Connected to ${nodes.first().displayName}"
} else {
binding.tvConnection.text = "No phone connected"
}
}
} catch (e: Exception) {
Log.e(TAG, "Failed to check connection", e)
}
}
}CoroutineScope(Dispatchers.IO + SupervisorJob())
Dispatchers.IO— "run coroutines on a background thread meant for I/O (network, disk)"SupervisorJob()— "if one coroutine fails, don't cancel the others"
scope.launch { }
- Starts a new coroutine. Code inside runs on the background thread.
.await()
- Pauses the coroutine (not the thread!) until the result is ready.
- This is how you wait for Google's APIs without freezing.
runOnUiThread { }
- Switches back to the main thread to update UI. You MUST do this because Android only allows UI changes on the main thread.
User taps "Take Photo" button
→ [Main Thread] onClick listener runs
→ [Main Thread] vibrate() — instant, no delay
→ [Main Thread] binding.tvStatus.text = "Taking photo..."
→ scope.launch {
→ [IO Thread] getNodeClient().connectedNodes.await() (waits for network)
→ [IO Thread] sendMessage().await() (sends message to phone)
→ runOnUiThread {
→ [Main Thread] binding.tvStatus.text = "Sent!"
}
}
The key is that the user sees "Taking photo..." immediately, then the actual network call happens in the background.
The Wearable Data Layer (from play-services-wearable) is how the watch and phone communicate. CameraRemote uses MessageClient — it sends short one-off messages.
// Step 1: Find connected phones
val nodes = Wearable.getNodeClient(this).connectedNodes.await()
// Step 2: Send a message to each phone
val messageClient = Wearable.getMessageClient(this)
for (node in nodes) {
messageClient.sendMessage(
node.id, // which device to send to
"/camera_remote", // path (like a URL)
"take_photo".toByteArray() // data (as bytes)
).await()
}On the phone, WearMessageListenerService is declared in the manifest with an intent-filter for path /camera_remote. When a message arrives:
class WearMessageListenerService : WearableListenerService() {
override fun onMessageReceived(messageEvent: MessageEvent) {
if (messageEvent.path == "/camera_remote") {
val command = String(messageEvent.data) // "take_photo"
// Do something with the command
}
}
}The phone sends results back on a different path:
// Phone sends:
messageClient.sendMessage(node.id, "/camera_remote/status", "photo_taken".toByteArray())
// Watch listens:
override fun onMessageReceived(messageEvent: MessageEvent) {
if (messageEvent.path == "/camera_remote/status") {
val status = String(messageEvent.data) // "photo_taken"
runOnUiThread {
binding.tvStatus.text = "Photo taken!"
}
}
}[Watch] User taps Capture
→ sendMessage("/camera_remote", "take_photo") → [Phone]
[Phone] WearMessageListenerService receives "take_photo"
→ broadcasts to CameraControlService
[Phone] CameraControlService finds shutter button in camera app
→ clicks it
→ sendMessage("/camera_remote/status", "photo_taken") → [Watch]
[Watch] RemoteActivity receives "photo_taken"
→ shows "Photo taken!" on screen
→ vibrates
WearOS apps are regular Android apps with some special considerations.
Round watches cut off content at the edges. BoxInsetLayout pushes content inward so nothing gets hidden:
<androidx.wear.widget.BoxInsetLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<ScrollView app:boxedEdges="all">
<!-- Your content is now safe from round edges -->
</ScrollView>
</androidx.wear.widget.BoxInsetLayout>app:boxedEdges="all" means "keep all edges (top, bottom, left, right) inside the round screen."
Many watches have a rotating crown or bezel for scrolling. You handle it like this:
// In onCreate:
binding.scrollView.requestFocus() // the ScrollView needs focus to receive events
binding.scrollView.setOnGenericMotionListener { _, event ->
if (event.action == MotionEvent.ACTION_SCROLL) {
// AXIS_SCROLL gives you how much the crown rotated
val delta = -event.getAxisValue(MotionEvent.AXIS_SCROLL) * 200f
binding.scrollView.smoothScrollBy(0, delta.toInt())
true // "I handled this event"
} else {
false // "I didn't handle this, let someone else try"
}
}Tiles are widgets on the watch face you can swipe to. They're built entirely in code (no XML layouts):
class CameraRemoteTileService : TileService() {
override fun onTileRequest(requestParams: TileRequest): ListenableFuture<Tile> {
// Build the tile layout using builders
val tile = TileBuilders.Tile.Builder()
.setTimeline(...)
.build()
return Futures.immediateFuture(tile)
}
}Tile buttons launch TileActionActivity, which sends a command and immediately closes:
class TileActionActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val command = intent.getStringExtra("command") ?: run {
finish()
return
}
vibrate()
sendCommand(command) // sends to phone, then calls finish()
}
}The watch manifest has <uses-feature android:name="android.hardware.type.watch" /> to tell Android this app is specifically for watches.
This is the cleverest part of CameraRemote. Instead of building its own camera, it controls whatever camera app you already have.
An AccessibilityService was designed for users with disabilities — it can read what's on screen and interact with it. CameraRemote uses it to find the "shutter" button in your camera app and tap it.
class CameraControlService : AccessibilityService() {
// List of words that might be on a shutter button
private val shutterDescriptions = listOf(
"shutter", "take photo", "capture", "camera button", "take picture"
)
private fun takePhoto() {
// Strategy 1: Search the screen for a button matching our descriptions
if (findAndClickButton(shutterDescriptions)) {
sendStatusToWatch("photo_taken")
return
}
// Strategy 2: If we can't find a button, just tap where the shutter usually is
tapShutterFallback()
}
}The service gets access to the screen's "accessibility tree" — a tree of all UI elements:
private fun findAndClickButton(descriptions: List<String>): Boolean {
// Get the root of the screen's UI tree
val rootNode = rootInActiveWindow ?: return false
// Search for buttons with matching text
for (desc in descriptions) {
val nodes = rootNode.findAccessibilityNodeInfosByText(desc)
for (node in nodes) {
if (node.isClickable && node.isVisibleToUser) {
// Found it! Click it.
node.performAction(AccessibilityNodeInfo.ACTION_CLICK)
return true
}
}
}
return false
}If it can't find a labeled button, it taps the screen at coordinates where the shutter typically is (center, 85% down):
private fun tapShutterFallback() {
// Get screen dimensions
val metrics = DisplayMetrics()
wm.defaultDisplay.getMetrics(metrics)
val x = metrics.widthPixels / 2f // center horizontally
val y = metrics.heightPixels * 0.85f // 85% down (typical shutter position)
// Create a tap gesture
val path = Path().apply { moveTo(x, y) }
val gesture = GestureDescription.Builder()
.addStroke(GestureDescription.StrokeDescription(path, 0, 50))
.build()
// Dispatch it (Android simulates a finger tap)
dispatchGesture(gesture, ...)
}The service receives commands through broadcasts:
// WearMessageListenerService receives watch message
// → creates broadcast intent
// → CameraControlService's BroadcastReceiver catches it
private val commandReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val command = intent.getStringExtra("command") ?: return
handleCommand(command)
}
}The user must manually enable the service in Settings > Accessibility. This is an Android security measure — apps can't enable accessibility services on their own.
From the project root:
# Set up environment
export ANDROID_HOME=/path/to/android-sdk
export JAVA_HOME=/path/to/java-17
# Build everything
./gradlew assembleDebug
# Build just the phone app
./gradlew :mobile:assembleDebug
# Build just the watch app
./gradlew :wear:assembleDebug
# Clean and rebuild from scratch
./gradlew clean assembleDebugAPKs end up in:
mobile/build/outputs/apk/debug/CameraRemote-Phone-v1.0.0-debug.apkwear/build/outputs/apk/debug/CameraRemote-Watch-v1.0.0-debug.apk
# Install on phone
adb install mobile/build/outputs/apk/debug/CameraRemote-Phone-v1.0.0-debug.apk
# Install on watch (if connected via ADB)
adb -s <watch-device-id> install wear/build/outputs/apk/debug/CameraRemote-Watch-v1.0.0-debug.apkEvery Log.d(TAG, "message") in the code shows up in logcat:
# See all logs from the app
adb logcat -s "RemoteActivity:*" "MainActivity:*" "CameraControlService:*"
# See only errors
adb logcat *:E
# Filter for camera-related messages
adb logcat | grep -i "camera"The log levels:
Log.d(TAG, "...")— Debug (general info)Log.w(TAG, "...")— Warning (something fishy)Log.e(TAG, "...", exception)— Error (something broke)
For the best development experience, open the project in Android Studio. It gives you:
- Code completion and error highlighting
- Visual layout editor
- One-click build and install
- Built-in logcat viewer
- Debugger with breakpoints
Now that you know all the pieces, here's how they connect:
RemoteActivity (the screen)
│
├── Layout: activity_remote.xml
│ └── BoxInsetLayout > ScrollView > 6 circle ImageButtons
│
├── onCreate()
│ ├── Apply DynamicColors (Material You)
│ ├── Inflate layout with ViewBinding
│ ├── Initialize Vibrator (with null safety)
│ ├── Initialize MessageClient
│ ├── Set up rotary scrolling
│ └── Set click listeners on all 6 buttons
│
├── Button clicked → sendCommand("take_photo")
│ ├── vibrate() — 50ms buzz
│ ├── Update status text — "Taking photo..."
│ └── scope.launch { (background)
│ ├── Find connected phones
│ └── Send message via MessageClient
│
└── onMessageReceived() ← phone sends status back
├── Update status text — "Photo taken!"
└── vibrate()
MainActivity (setup screen)
│
├── Shows service status (enabled/disabled)
├── Shows watch connection status
├── "Enable Service" button → opens accessibility settings
└── "Open Camera" button → opens default camera app
WearMessageListenerService (runs in background)
│
└── onMessageReceived("/camera_remote", "take_photo")
├── Is CameraControlService running?
│ ├── YES → broadcast command to it
│ └── NO → send "service_not_enabled" status to watch
└── Special case: "open_camera" works even without the service
CameraControlService (AccessibilityService)
│
├── Receives broadcast with command
├── handleCommand("take_photo")
│ ├── Search screen for shutter button by text/description
│ ├── Found? → Click it → send "photo_taken" to watch
│ └── Not found? → Tap screen at (50%, 85%) → send result to watch
│
├── handleCommand("switch_camera")
│ └── Search for "switch camera", "flip camera", etc.
│
└── handleCommand("toggle_flash")
└── Search for "flash", "flash mode", etc.
1. You tap "Capture" on your watch
2. Watch vibrates (50ms)
3. Watch shows "Taking photo..."
4. Watch sends message: path="/camera_remote", data="take_photo"
↓ (via Bluetooth through Wear OS)
5. Phone's WearMessageListenerService wakes up
6. It broadcasts the command to CameraControlService
7. CameraControlService searches the camera app's UI for a shutter button
8. It finds one labeled "Take photo" and clicks it
9. Your camera takes a photo!
10. CameraControlService sends: path="/camera_remote/status", data="photo_taken"
↓ (via Bluetooth back to watch)
11. Watch receives "photo_taken"
12. Watch shows "Photo taken!"
13. Watch vibrates again
| Concept | What It Is | CameraRemote Example |
|---|---|---|
| Activity | A screen | RemoteActivity, MainActivity |
| Service | Background worker | CameraControlService, WearMessageListenerService |
| Layout XML | UI definition | activity_remote.xml |
| ViewBinding | XML → Kotlin bridge | binding.btnCapture.setOnClickListener { } |
| Intent | "Please do this" message | Intent(MediaStore.INTENT_ACTION_STILL_IMAGE_CAMERA) |
| Broadcast | Public announcement | sendBroadcast(intent) |
| Coroutine | Background task | scope.launch { ... .await() } |
| MessageClient | Watch ↔ Phone messaging | sendMessage(node.id, "/camera_remote", data) |
| Manifest | App ID card | Lists activities, services, permissions |
| Gradle | Build system | Compiles everything into an APK |
| Theme | App-wide colors/styles | Theme.Material3.Dark.NoActionBar |
| Drawable | Graphics (icons, shapes) | bg_btn_capture.xml (red circle with ripple) |
- Modify something small — Change a button color in
colors.xmland rebuild. See the result. - Add a new button — Add a "zoom in" circle button to the watch. Follow the pattern of existing buttons.
- Read the official docs — developer.android.com has guides for everything.
- Try Android Studio — Open this project in Android Studio and explore. Use the layout preview, the code suggestions, and the debugger.
- Break things on purpose — Remove a try-catch and see what happens. Change a message path and see what breaks. This is the fastest way to learn.
You already have a working app. The best way to learn is to change it and see what happens. Good luck!