diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index babb910f0..f705a6920 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -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"
@@ -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"
@@ -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" }
@@ -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" }
@@ -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" }
diff --git a/wear/build.gradle.kts b/wear/build.gradle.kts
index 888f3d434..eb7c66445 100644
--- a/wear/build.gradle.kts
+++ b/wear/build.gradle.kts
@@ -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 {
@@ -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)
@@ -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)
@@ -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)
@@ -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)
diff --git a/wear/src/main/AndroidManifest.xml b/wear/src/main/AndroidManifest.xml
index c7209c3a7..770b6d956 100644
--- a/wear/src/main/AndroidManifest.xml
+++ b/wear/src/main/AndroidManifest.xml
@@ -2,6 +2,9 @@
+
+
+
@@ -163,6 +166,26 @@
android:resource="@drawable/tile_preview" />
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/wear/src/main/java/com/example/wear/snippets/alwayson/AlwaysOnActivity.kt b/wear/src/main/java/com/example/wear/snippets/alwayson/AlwaysOnActivity.kt
new file mode 100644
index 000000000..17b18d8af
--- /dev/null
+++ b/wear/src/main/java/com/example/wear/snippets/alwayson/AlwaysOnActivity.kt
@@ -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]
+ }
+}
diff --git a/wear/src/main/java/com/example/wear/snippets/alwayson/AlwaysOnService.kt b/wear/src/main/java/com/example/wear/snippets/alwayson/AlwaysOnService.kt
new file mode 100644
index 000000000..59ed0f8af
--- /dev/null
+++ b/wear/src/main/java/com/example/wear/snippets/alwayson/AlwaysOnService.kt
@@ -0,0 +1,145 @@
+/*
+ * 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.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.util.Log
+import androidx.core.app.NotificationCompat
+import androidx.core.content.getSystemService
+import androidx.lifecycle.LifecycleService
+import androidx.wear.ongoing.OngoingActivity
+import androidx.wear.ongoing.Status
+import com.example.wear.R
+
+class AlwaysOnService : LifecycleService() {
+
+ private val notificationManager by lazy { getSystemService() }
+
+ companion object {
+ private const val TAG = "AlwaysOnService"
+ private const val NOTIFICATION_ID = 1001
+ private const val CHANNEL_ID = "always_on_service_channel"
+ private const val CHANNEL_NAME = "Always On Service"
+ @Volatile
+ var isRunning = false
+ private set
+
+ fun startService(context: Context) {
+ Log.d(TAG, "Starting AlwaysOnService")
+ val intent = Intent(context, AlwaysOnService::class.java)
+ context.startForegroundService(intent)
+ }
+
+ fun stopService(context: Context) {
+ Log.d(TAG, "Stopping AlwaysOnService")
+ context.stopService(Intent(context, AlwaysOnService::class.java))
+ }
+ }
+
+ override fun onCreate() {
+ super.onCreate()
+ Log.d(TAG, "onCreate: Service created")
+ isRunning = true
+ createNotificationChannel()
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ super.onStartCommand(intent, flags, startId)
+ Log.d(TAG, "onStartCommand: Service started with startId: $startId")
+
+ // Create and start foreground notification
+ val notification = createNotification()
+ startForeground(NOTIFICATION_ID, notification)
+
+ Log.d(TAG, "onStartCommand: Service is now running as foreground service")
+
+ return START_STICKY
+ }
+
+ override fun onDestroy() {
+ Log.d(TAG, "onDestroy: Service destroyed")
+ isRunning = false
+ super.onDestroy()
+ }
+
+ private fun createNotificationChannel() {
+ val channel =
+ NotificationChannel(CHANNEL_ID, CHANNEL_NAME, NotificationManager.IMPORTANCE_DEFAULT)
+ .apply {
+ description = "Always On Service notification channel"
+ setShowBadge(false)
+ }
+
+ notificationManager?.createNotificationChannel(channel)
+ Log.d(TAG, "createNotificationChannel: Notification channel created")
+ }
+
+ // [START android_wear_ongoing_activity_create_notification]
+ private fun createNotification(): Notification {
+ val activityIntent =
+ Intent(this, AlwaysOnActivity::class.java).apply {
+ flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
+ }
+
+ val pendingIntent =
+ PendingIntent.getActivity(
+ this,
+ 0,
+ activityIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+
+ val notificationBuilder =
+ NotificationCompat.Builder(this, CHANNEL_ID)
+ // ...
+ // [START_EXCLUDE]
+ .setContentTitle("Always On Service")
+ .setContentText("Service is running in background")
+ .setSmallIcon(R.drawable.animated_walk)
+ .setContentIntent(pendingIntent)
+ .setCategory(NotificationCompat.CATEGORY_STOPWATCH)
+ .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
+ // [END_EXCLUDE]
+ .setOngoing(true)
+
+ // [START_EXCLUDE]
+ // Create an Ongoing Activity
+ val ongoingActivityStatus = Status.Builder().addTemplate("Stopwatch running").build()
+ // [END_EXCLUDE]
+
+ val ongoingActivity =
+ OngoingActivity.Builder(applicationContext, NOTIFICATION_ID, notificationBuilder)
+ // ...
+ // [START_EXCLUDE]
+ .setStaticIcon(R.drawable.ic_walk)
+ .setAnimatedIcon(R.drawable.animated_walk)
+ .setStatus(ongoingActivityStatus)
+ // [END_EXCLUDE]
+ .setTouchIntent(pendingIntent)
+ .build()
+
+ ongoingActivity.apply(applicationContext)
+
+ return notificationBuilder.build()
+ }
+ // [END android_wear_ongoing_activity_create_notification]
+}
diff --git a/wear/src/main/res/drawable/animated_walk.xml b/wear/src/main/res/drawable/animated_walk.xml
new file mode 100644
index 000000000..e94991e07
--- /dev/null
+++ b/wear/src/main/res/drawable/animated_walk.xml
@@ -0,0 +1,682 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/wear/src/main/res/drawable/ic_walk.xml b/wear/src/main/res/drawable/ic_walk.xml
new file mode 100644
index 000000000..6c226e943
--- /dev/null
+++ b/wear/src/main/res/drawable/ic_walk.xml
@@ -0,0 +1,16 @@
+
+
+
+
+