Skip to content

Commit 88d1bd6

Browse files
Add snippets for Android Wear's Always On doc (#549)
1 parent a37e651 commit 88d1bd6

File tree

7 files changed

+1067
-3
lines changed

7 files changed

+1067
-3
lines changed

gradle/libs.versions.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ kotlinCoroutinesOkhttp = "1.0"
5151
kotlinxCoroutinesGuava = "1.10.2"
5252
kotlinxSerializationJson = "1.8.1"
5353
ksp = "2.1.20-2.0.1"
54+
lifecycleService = "2.9.1"
5455
maps-compose = "6.6.0"
5556
material = "1.13.0-alpha13"
5657
material3-adaptive = "1.1.0"
@@ -73,6 +74,7 @@ wear = "1.3.0"
7374
wearComposeFoundation = "1.5.0-beta01"
7475
wearComposeMaterial = "1.5.0-beta01"
7576
wearComposeMaterial3 = "1.5.0-beta01"
77+
wearOngoing = "1.0.0"
7678
wearToolingPreview = "1.0.0"
7779
webkit = "1.13.0"
7880

@@ -126,6 +128,7 @@ androidx-graphics-shapes = "androidx.graphics:graphics-shapes:1.0.1"
126128
androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "androidxHiltNavigationCompose" }
127129
androidx-lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "androidx-lifecycle-runtime-compose" }
128130
androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle-runtime-compose" }
131+
androidx-lifecycle-service = { module = "androidx.lifecycle:lifecycle-service", version.ref = "lifecycleService" }
129132
androidx-lifecycle-viewModelCompose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle-compose" }
130133
androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidx-lifecycle-compose" }
131134
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
149152
androidx-tiles-tooling = { module = "androidx.wear.tiles:tiles-tooling", version.ref = "tiles" }
150153
androidx-tiles-tooling-preview = { module = "androidx.wear.tiles:tiles-tooling-preview", version.ref = "tiles" }
151154
androidx-wear = { module = "androidx.wear:wear", version.ref = "wear" }
155+
androidx-wear-ongoing = { module = "androidx.wear:wear-ongoing", version.ref = "wearOngoing" }
152156
androidx-wear-tooling-preview = { module = "androidx.wear:wear-tooling-preview", version.ref = "wearToolingPreview" }
153157
androidx-webkit = { module = "androidx.webkit:webkit", version.ref = "webkit" }
154158
androidx-window = { module = "androidx.window:window", version.ref = "androidx-window" }
@@ -189,7 +193,7 @@ android-library = { id = "com.android.library", version.ref = "androidGradlePlug
189193
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
190194
gradle-versions = { id = "com.github.ben-manes.versions", version.ref = "gradle-versions" }
191195
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
192-
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
196+
kotlin-android = { id = "org.jetbrains.kotlin.android", version = "2.1.21" }
193197
kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" }
194198
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
195199
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }

wear/build.gradle.kts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,12 @@ plugins {
66

77
android {
88
namespace = "com.example.wear"
9-
compileSdk = 35
9+
compileSdk = 36
1010

1111
defaultConfig {
1212
applicationId = "com.example.wear"
1313
minSdk = 26
14-
targetSdk = 33
14+
targetSdk = 36
1515
versionCode = 1
1616
versionName = "1.0"
1717
vectorDrawables {
@@ -46,9 +46,13 @@ android {
4646
excludes += "/META-INF/{AL2.0,LGPL2.1}"
4747
}
4848
}
49+
kotlinOptions {
50+
jvmTarget = "17"
51+
}
4952
}
5053

5154
dependencies {
55+
implementation(libs.androidx.core.ktx)
5256
val composeBom = platform(libs.androidx.compose.bom)
5357
implementation(composeBom)
5458
androidTestImplementation(composeBom)
@@ -57,6 +61,8 @@ dependencies {
5761
implementation(libs.play.services.wearable)
5862
implementation(libs.androidx.tiles)
5963
implementation(libs.androidx.wear)
64+
implementation(libs.androidx.wear.ongoing)
65+
implementation(libs.androidx.lifecycle.service)
6066
implementation(libs.androidx.protolayout)
6167
implementation(libs.androidx.protolayout.material)
6268
implementation(libs.androidx.protolayout.material3)
@@ -70,6 +76,7 @@ dependencies {
7076
implementation(platform(libs.androidx.compose.bom))
7177
implementation(libs.androidx.compose.ui)
7278
implementation(libs.androidx.compose.ui.tooling.preview)
79+
implementation(libs.androidx.fragment.ktx)
7380
implementation(libs.wear.compose.material)
7481
implementation(libs.wear.compose.material3)
7582
implementation(libs.compose.foundation)
@@ -80,6 +87,7 @@ dependencies {
8087
implementation(libs.androidx.material.icons.core)
8188
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
8289
debugImplementation(libs.androidx.compose.ui.tooling)
90+
debugImplementation(libs.androidx.compose.ui.tooling.preview)
8391
debugImplementation(libs.androidx.compose.ui.test.manifest)
8492
testImplementation(libs.junit)
8593

wear/src/main/AndroidManifest.xml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
33

44
<uses-permission android:name="android.permission.WAKE_LOCK" />
5+
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
6+
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE"/>
7+
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
58

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

@@ -163,6 +166,26 @@
163166
android:resource="@drawable/tile_preview" />
164167
</service>
165168

169+
<activity
170+
android:name=".snippets.alwayson.AlwaysOnActivity"
171+
android:label="0 Stopwatch"
172+
android:exported="true"
173+
android:taskAffinity=""
174+
android:theme="@android:style/Theme.DeviceDefault">
175+
<intent-filter>
176+
<action android:name="android.intent.action.MAIN" />
177+
<category android:name="android.intent.category.LAUNCHER" />
178+
</intent-filter>
179+
</activity>
180+
181+
<service
182+
android:name=".snippets.alwayson.AlwaysOnService"
183+
android:foregroundServiceType="specialUse"
184+
android:exported="false">
185+
<property android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
186+
android:value="For Ongoing Activity"/>
187+
</service>
188+
166189
</application>
167190

168191
</manifest>
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
/*
2+
* Copyright 2025 The Android Open Source Project
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.example.wear.snippets.alwayson
18+
19+
import android.Manifest
20+
import android.content.pm.PackageManager
21+
import android.os.Build
22+
import android.os.Bundle
23+
import android.os.SystemClock
24+
import android.util.Log
25+
import androidx.activity.ComponentActivity
26+
import androidx.activity.compose.setContent
27+
import androidx.activity.result.contract.ActivityResultContracts
28+
import androidx.compose.foundation.layout.Box
29+
import androidx.compose.foundation.layout.Column
30+
import androidx.compose.foundation.layout.PaddingValues
31+
import androidx.compose.foundation.layout.Spacer
32+
import androidx.compose.foundation.layout.fillMaxSize
33+
import androidx.compose.foundation.layout.height
34+
import androidx.compose.runtime.Composable
35+
import androidx.compose.runtime.getValue
36+
import androidx.compose.runtime.mutableStateOf
37+
import androidx.compose.runtime.produceState
38+
import androidx.compose.runtime.saveable.rememberSaveable
39+
import androidx.compose.runtime.setValue
40+
import androidx.compose.ui.Alignment
41+
import androidx.compose.ui.Modifier
42+
import androidx.compose.ui.platform.LocalContext
43+
import androidx.compose.ui.tooling.preview.Preview
44+
import androidx.compose.ui.unit.dp
45+
import androidx.core.content.ContextCompat
46+
import androidx.wear.compose.material3.MaterialTheme
47+
import androidx.wear.compose.material3.SwitchButton
48+
import androidx.wear.compose.material3.Text
49+
import androidx.wear.compose.material3.dynamicColorScheme
50+
import androidx.wear.tooling.preview.devices.WearDevices
51+
import com.google.android.horologist.compose.ambient.AmbientAware
52+
import com.google.android.horologist.compose.ambient.AmbientState
53+
import kotlinx.coroutines.delay
54+
55+
private const val TAG = "AlwaysOnActivity"
56+
57+
class AlwaysOnActivity : ComponentActivity() {
58+
private val requestPermissionLauncher =
59+
registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
60+
if (isGranted) {
61+
Log.d(TAG, "POST_NOTIFICATIONS permission granted")
62+
} else {
63+
Log.w(TAG, "POST_NOTIFICATIONS permission denied")
64+
}
65+
}
66+
67+
override fun onCreate(savedInstanceState: Bundle?) {
68+
super.onCreate(savedInstanceState)
69+
Log.d(TAG, "onCreate: Activity created")
70+
71+
setTheme(android.R.style.Theme_DeviceDefault)
72+
73+
// Check and request notification permission
74+
checkAndRequestNotificationPermission()
75+
76+
setContent { WearApp() }
77+
}
78+
79+
private fun checkAndRequestNotificationPermission() {
80+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
81+
when {
82+
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) ==
83+
PackageManager.PERMISSION_GRANTED -> {
84+
Log.d(TAG, "POST_NOTIFICATIONS permission already granted")
85+
}
86+
shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS) -> {
87+
Log.d(TAG, "Should show permission rationale")
88+
// You could show a dialog here explaining why the permission is needed
89+
requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
90+
}
91+
else -> {
92+
Log.d(TAG, "Requesting POST_NOTIFICATIONS permission")
93+
requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
94+
}
95+
}
96+
}
97+
}
98+
}
99+
100+
@Composable
101+
// [START android_wear_ongoing_activity_elapsedtime]
102+
fun ElapsedTime(ambientState: AmbientState) {
103+
// [START_EXCLUDE]
104+
val startTimeMs = rememberSaveable { SystemClock.elapsedRealtime() }
105+
106+
val elapsedMs by
107+
produceState(initialValue = 0L, key1 = startTimeMs) {
108+
while (true) { // time doesn't stop!
109+
value = SystemClock.elapsedRealtime() - startTimeMs
110+
// In ambient mode, update every minute instead of every second
111+
val updateInterval = if (ambientState.isAmbient) 60_000L else 1_000L
112+
delay(updateInterval - (value % updateInterval))
113+
}
114+
}
115+
116+
val totalSeconds = elapsedMs / 1_000L
117+
val minutes = totalSeconds / 60
118+
val seconds = totalSeconds % 60
119+
120+
// [END_EXCLUDE]
121+
val timeText =
122+
if (ambientState.isAmbient) {
123+
// Show "mm:--" format in ambient mode
124+
"%02d:--".format(minutes)
125+
} else {
126+
// Show full "mm:ss" format in interactive mode
127+
"%02d:%02d".format(minutes, seconds)
128+
}
129+
130+
Text(text = timeText, style = MaterialTheme.typography.numeralMedium)
131+
}
132+
// [END android_wear_ongoing_activity_elapsedtime]
133+
134+
@Preview(
135+
device = WearDevices.LARGE_ROUND,
136+
backgroundColor = 0xff000000,
137+
showBackground = true,
138+
group = "Devices - Large Round",
139+
showSystemUi = true,
140+
)
141+
@Composable
142+
fun WearApp() {
143+
val context = LocalContext.current
144+
var isOngoingActivity by rememberSaveable { mutableStateOf(AlwaysOnService.isRunning) }
145+
MaterialTheme(
146+
colorScheme = dynamicColorScheme(LocalContext.current) ?: MaterialTheme.colorScheme
147+
) {
148+
// [START android_wear_ongoing_activity_ambientaware]
149+
AmbientAware { ambientState ->
150+
// [START_EXCLUDE]
151+
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
152+
Column(horizontalAlignment = Alignment.CenterHorizontally) {
153+
Text(text = "Elapsed Time", style = MaterialTheme.typography.titleLarge)
154+
Spacer(modifier = Modifier.height(8.dp))
155+
// [END_EXCLUDE]
156+
ElapsedTime(ambientState = ambientState)
157+
// [START_EXCLUDE]
158+
Spacer(modifier = Modifier.height(8.dp))
159+
SwitchButton(
160+
checked = isOngoingActivity,
161+
onCheckedChange = { newState ->
162+
Log.d(TAG, "Switch button changed: $newState")
163+
isOngoingActivity = newState
164+
165+
if (newState) {
166+
Log.d(TAG, "Starting AlwaysOnService")
167+
AlwaysOnService.startService(context)
168+
} else {
169+
Log.d(TAG, "Stopping AlwaysOnService")
170+
AlwaysOnService.stopService(context)
171+
}
172+
},
173+
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
174+
) {
175+
Text(
176+
text = "Ongoing Activity",
177+
style = MaterialTheme.typography.bodyExtraSmall,
178+
)
179+
}
180+
}
181+
}
182+
// [END_EXCLUDE]
183+
}
184+
// [END android_wear_ongoing_activity_ambientaware]
185+
}
186+
}

0 commit comments

Comments
 (0)