Skip to content

Add snippets for Android Wear's Always On doc #549

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 19 commits into from
Jun 20, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ kotlinCoroutinesOkhttp = "1.0"
kotlinxCoroutinesGuava = "1.10.2"
kotlinxSerializationJson = "1.8.1"
ksp = "2.1.20-2.0.1"
lifecycleService = "2.9.1"
maps-compose = "6.6.0"
material = "1.13.0-alpha13"
material3-adaptive = "1.1.0"
Expand All @@ -73,6 +74,7 @@ wear = "1.3.0"
wearComposeFoundation = "1.5.0-beta01"
wearComposeMaterial = "1.5.0-beta01"
wearComposeMaterial3 = "1.5.0-beta01"
wearOngoing = "1.0.0"
wearToolingPreview = "1.0.0"
webkit = "1.13.0"

Expand Down Expand Up @@ -126,6 +128,7 @@ androidx-graphics-shapes = "androidx.graphics:graphics-shapes:1.0.1"
androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "androidxHiltNavigationCompose" }
androidx-lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "androidx-lifecycle-runtime-compose" }
androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle-runtime-compose" }
androidx-lifecycle-service = { module = "androidx.lifecycle:lifecycle-service", version.ref = "lifecycleService" }
androidx-lifecycle-viewModelCompose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle-compose" }
androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidx-lifecycle-compose" }
androidx-material-icons-core = { module = "androidx.compose.material:material-icons-core" }
Expand All @@ -149,6 +152,7 @@ androidx-tiles-testing = { module = "androidx.wear.tiles:tiles-testing", version
androidx-tiles-tooling = { module = "androidx.wear.tiles:tiles-tooling", version.ref = "tiles" }
androidx-tiles-tooling-preview = { module = "androidx.wear.tiles:tiles-tooling-preview", version.ref = "tiles" }
androidx-wear = { module = "androidx.wear:wear", version.ref = "wear" }
androidx-wear-ongoing = { module = "androidx.wear:wear-ongoing", version.ref = "wearOngoing" }
androidx-wear-tooling-preview = { module = "androidx.wear:wear-tooling-preview", version.ref = "wearToolingPreview" }
androidx-webkit = { module = "androidx.webkit:webkit", version.ref = "webkit" }
androidx-window = { module = "androidx.window:window", version.ref = "androidx-window" }
Expand Down Expand Up @@ -189,7 +193,7 @@ android-library = { id = "com.android.library", version.ref = "androidGradlePlug
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
gradle-versions = { id = "com.github.ben-manes.versions", version.ref = "gradle-versions" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version = "2.1.21" }
kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
Expand Down
12 changes: 10 additions & 2 deletions wear/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@ plugins {

android {
namespace = "com.example.wear"
compileSdk = 35
compileSdk = 36

defaultConfig {
applicationId = "com.example.wear"
minSdk = 26
targetSdk = 33
targetSdk = 36
versionCode = 1
versionName = "1.0"
vectorDrawables {
Expand Down Expand Up @@ -46,9 +46,13 @@ android {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
kotlinOptions {
jvmTarget = "17"
}
}

dependencies {
implementation(libs.androidx.core.ktx)
val composeBom = platform(libs.androidx.compose.bom)
implementation(composeBom)
androidTestImplementation(composeBom)
Expand All @@ -57,6 +61,8 @@ dependencies {
implementation(libs.play.services.wearable)
implementation(libs.androidx.tiles)
implementation(libs.androidx.wear)
implementation(libs.androidx.wear.ongoing)
implementation(libs.androidx.lifecycle.service)
implementation(libs.androidx.protolayout)
implementation(libs.androidx.protolayout.material)
implementation(libs.androidx.protolayout.material3)
Expand All @@ -70,6 +76,7 @@ dependencies {
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.fragment.ktx)
implementation(libs.wear.compose.material)
implementation(libs.wear.compose.material3)
implementation(libs.compose.foundation)
Expand All @@ -80,6 +87,7 @@ dependencies {
implementation(libs.androidx.material.icons.core)
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.ui.tooling.preview)
debugImplementation(libs.androidx.compose.ui.test.manifest)
testImplementation(libs.junit)

Expand Down
23 changes: 23 additions & 0 deletions wear/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

<uses-feature android:name="android.hardware.type.watch" />

Expand Down Expand Up @@ -163,6 +166,26 @@
android:resource="@drawable/tile_preview" />
</service>

<activity
android:name=".snippets.alwayson.AlwaysOnActivity"
android:label="0 Stopwatch"
android:exported="true"
android:taskAffinity=""
android:theme="@android:style/Theme.DeviceDefault">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>

<service
android:name=".snippets.alwayson.AlwaysOnService"
android:foregroundServiceType="specialUse"
android:exported="false">
<property android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="For Ongoing Activity"/>
</service>

</application>

</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
/*
* Copyright 2025 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.example.wear.snippets.alwayson

import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.os.SystemClock
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.wear.compose.material3.MaterialTheme
import androidx.wear.compose.material3.SwitchButton
import androidx.wear.compose.material3.Text
import androidx.wear.compose.material3.dynamicColorScheme
import androidx.wear.tooling.preview.devices.WearDevices
import com.google.android.horologist.compose.ambient.AmbientAware
import com.google.android.horologist.compose.ambient.AmbientState
import kotlinx.coroutines.delay

private const val TAG = "AlwaysOnActivity"

class AlwaysOnActivity : ComponentActivity() {
private val requestPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
if (isGranted) {
Log.d(TAG, "POST_NOTIFICATIONS permission granted")
} else {
Log.w(TAG, "POST_NOTIFICATIONS permission denied")
}
}

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Log.d(TAG, "onCreate: Activity created")

setTheme(android.R.style.Theme_DeviceDefault)

// Check and request notification permission
checkAndRequestNotificationPermission()

setContent { WearApp() }
}

private fun checkAndRequestNotificationPermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
when {
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED -> {
Log.d(TAG, "POST_NOTIFICATIONS permission already granted")
}
shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS) -> {
Log.d(TAG, "Should show permission rationale")
// You could show a dialog here explaining why the permission is needed
requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
else -> {
Log.d(TAG, "Requesting POST_NOTIFICATIONS permission")
requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
}
}
}

@Composable
// [START android_wear_ongoing_activity_elapsedtime]
fun ElapsedTime(ambientState: AmbientState) {
// [START_EXCLUDE]
val startTimeMs = rememberSaveable { SystemClock.elapsedRealtime() }

val elapsedMs by
produceState(initialValue = 0L, key1 = startTimeMs) {
while (true) { // time doesn't stop!
value = SystemClock.elapsedRealtime() - startTimeMs
// In ambient mode, update every minute instead of every second
val updateInterval = if (ambientState.isAmbient) 60_000L else 1_000L
delay(updateInterval - (value % updateInterval))
}
}

val totalSeconds = elapsedMs / 1_000L
val minutes = totalSeconds / 60
val seconds = totalSeconds % 60

// [END_EXCLUDE]
val timeText =
if (ambientState.isAmbient) {
// Show "mm:--" format in ambient mode
"%02d:--".format(minutes)
} else {
// Show full "mm:ss" format in interactive mode
"%02d:%02d".format(minutes, seconds)
}

Text(text = timeText, style = MaterialTheme.typography.numeralMedium)
}
// [END android_wear_ongoing_activity_elapsedtime]

@Preview(
device = WearDevices.LARGE_ROUND,
backgroundColor = 0xff000000,
showBackground = true,
group = "Devices - Large Round",
showSystemUi = true,
)
@Composable
fun WearApp() {
val context = LocalContext.current
var isOngoingActivity by rememberSaveable { mutableStateOf(AlwaysOnService.isRunning) }
MaterialTheme(
colorScheme = dynamicColorScheme(LocalContext.current) ?: MaterialTheme.colorScheme
) {
// [START android_wear_ongoing_activity_ambientaware]
AmbientAware { ambientState ->
// [START_EXCLUDE]
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(text = "Elapsed Time", style = MaterialTheme.typography.titleLarge)
Spacer(modifier = Modifier.height(8.dp))
// [END_EXCLUDE]
ElapsedTime(ambientState = ambientState)
// [START_EXCLUDE]
Spacer(modifier = Modifier.height(8.dp))
SwitchButton(
checked = isOngoingActivity,
onCheckedChange = { newState ->
Log.d(TAG, "Switch button changed: $newState")
isOngoingActivity = newState

if (newState) {
Log.d(TAG, "Starting AlwaysOnService")
AlwaysOnService.startService(context)
} else {
Log.d(TAG, "Stopping AlwaysOnService")
AlwaysOnService.stopService(context)
}
},
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
) {
Text(
text = "Ongoing Activity",
style = MaterialTheme.typography.bodyExtraSmall,
)
}
}
}
// [END_EXCLUDE]
}
// [END android_wear_ongoing_activity_ambientaware]
}
}
Loading