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 @@ + + + + +