From 8d28598367c86c0cc72995d3e43deaeb6c56fb2a Mon Sep 17 00:00:00 2001 From: Andrey Gusev Date: Sat, 27 Jan 2024 19:16:53 +0300 Subject: [PATCH 01/58] Increase resolution for ic_play_circle_simpleblue.xml The player controller icon looks blurry in the app menu. Increasing the icon resolution makes it sharper --- wear/src/main/res/drawable/ic_play_circle_simpleblue.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/wear/src/main/res/drawable/ic_play_circle_simpleblue.xml b/wear/src/main/res/drawable/ic_play_circle_simpleblue.xml index d9b3210..1fa6098 100644 --- a/wear/src/main/res/drawable/ic_play_circle_simpleblue.xml +++ b/wear/src/main/res/drawable/ic_play_circle_simpleblue.xml @@ -1,7 +1,7 @@ - Date: Sun, 24 Sep 2023 00:45:03 -0400 Subject: [PATCH 02/58] TileProviderUpdateRequester: PendingIntent FLAG_IMMUTABLE --- .../android/clockwork/tiles/TileProviderUpdateRequester.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/unofficialtileapi/src/main/java/com/google/android/clockwork/tiles/TileProviderUpdateRequester.java b/unofficialtileapi/src/main/java/com/google/android/clockwork/tiles/TileProviderUpdateRequester.java index caff379..a0a65c4 100644 --- a/unofficialtileapi/src/main/java/com/google/android/clockwork/tiles/TileProviderUpdateRequester.java +++ b/unofficialtileapi/src/main/java/com/google/android/clockwork/tiles/TileProviderUpdateRequester.java @@ -26,7 +26,7 @@ public final Intent buildIntentToRequestUpdateAll() { Intent intent = new Intent("android.support.wearable.tiles.ACTION_REQUEST_UPDATE_ALL"); intent.setPackage("com.google.android.wearable.app"); intent.putExtra("android.support.wearable.tiles.EXTRA_PROVIDER_COMPONENT", this.mProviderComponent); - intent.putExtra("android.support.wearable.tiles.EXTRA_PENDING_INTENT", PendingIntent.getActivity(this.mContext, 0, new Intent(""), 0)); + intent.putExtra("android.support.wearable.tiles.EXTRA_PENDING_INTENT", PendingIntent.getActivity(this.mContext, 0, new Intent(""), PendingIntent.FLAG_IMMUTABLE)); return intent; } From d71421f5ce35ad7d210b7bdbf69f86fa756e4b39 Mon Sep 17 00:00:00 2001 From: Dave Antoine Date: Sun, 24 Sep 2023 00:45:24 -0400 Subject: [PATCH 03/58] mobile: update WorkManagerInitializer --- mobile/src/main/AndroidManifest.xml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/mobile/src/main/AndroidManifest.xml b/mobile/src/main/AndroidManifest.xml index f5285cd..a4adcc1 100644 --- a/mobile/src/main/AndroidManifest.xml +++ b/mobile/src/main/AndroidManifest.xml @@ -174,6 +174,18 @@ + + + + + \ No newline at end of file From aa4442dd18b18c1ca2cc5e7a1ffb398191f8a52a Mon Sep 17 00:00:00 2001 From: Dave Antoine Date: Sun, 24 Sep 2023 00:45:56 -0400 Subject: [PATCH 04/58] MediaPlayerTileRenderer: fix tap action * Use exported activity name --- .../wearable/tiles/MediaPlayerTileRenderer.kt | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/wear/src/main/java/com/thewizrd/simplewear/wearable/tiles/MediaPlayerTileRenderer.kt b/wear/src/main/java/com/thewizrd/simplewear/wearable/tiles/MediaPlayerTileRenderer.kt index 937e079..6c7619a 100644 --- a/wear/src/main/java/com/thewizrd/simplewear/wearable/tiles/MediaPlayerTileRenderer.kt +++ b/wear/src/main/java/com/thewizrd/simplewear/wearable/tiles/MediaPlayerTileRenderer.kt @@ -1,5 +1,6 @@ package com.thewizrd.simplewear.wearable.tiles +import android.content.ComponentName import android.content.Context import android.graphics.Bitmap import android.os.Build @@ -23,7 +24,7 @@ import androidx.wear.protolayout.material.layouts.PrimaryLayout import com.google.android.horologist.annotations.ExperimentalHorologistApi import com.google.android.horologist.tiles.images.drawableResToImageResource import com.google.android.horologist.tiles.render.SingleTileLayoutRenderer -import com.thewizrd.simplewear.MediaPlayerListActivity +import com.thewizrd.simplewear.BuildConfig import com.thewizrd.simplewear.R import com.thewizrd.simplewear.wearable.tiles.layouts.MediaPlayerTileLayout import timber.log.Timber @@ -169,13 +170,10 @@ class MediaPlayerTileRenderer(context: Context, debugResourceMode: Boolean = fal } private fun getTapAction(context: Context): ActionBuilders.Action { - return ActionBuilders.LaunchAction.Builder() - .setAndroidActivity( - ActionBuilders.AndroidActivity.Builder() - .setPackageName(context.packageName) - .setClassName(MediaPlayerListActivity::class.java.name) - .build() - ) - .build() + return ActionBuilders.launchAction( + ComponentName(context.packageName, context.packageName.run { + if (BuildConfig.DEBUG) removeSuffix(".debug") else this + } + ".MediaControllerActivity") + ) } } \ No newline at end of file From 7c95c123b1037d0fb736c89c8adc5599e365533e Mon Sep 17 00:00:00 2001 From: Dave Antoine Date: Tue, 23 Jan 2024 22:16:33 -0500 Subject: [PATCH 05/58] gradle: update dependencies --- build.gradle | 33 ++++++++++--------- gradle/wrapper/gradle-wrapper.properties | 2 +- hidden-api/build.gradle | 6 ++-- mobile/build.gradle | 2 +- .../main/java/com/thewizrd/simplewear/App.kt | 5 ++- wear/build.gradle | 10 +++--- 6 files changed, 29 insertions(+), 29 deletions(-) diff --git a/build.gradle b/build.gradle index f2f0d52..ac8cb3b 100644 --- a/build.gradle +++ b/build.gradle @@ -6,36 +6,37 @@ buildscript { minSdkVersion = 24 targetSdkVersion = 33 - kotlin_version = '1.9.0' + kotlin_version = '1.9.20' kotlinx_version = '1.7.3' - desugar_version = '2.0.3' + desugar_version = '2.0.4' - firebase_version = '32.3.1' + firebase_version = '32.7.0' appcompat_version = '1.6.1' constraintlayout_version = '2.1.4' core_version = '1.12.0' - fragment_version = '1.6.1' - lifecycle_version = '2.6.2' + fragment_version = '1.6.2' + lifecycle_version = '2.7.0' preference_version = '1.2.1' - recyclerview_version = '1.3.1' - coresplash_version = '1.0.0' - work_version = '2.8.1' + recyclerview_version = '1.3.2' + coresplash_version = '1.0.1' + work_version = '2.9.0' test_core_version = '1.5.0' test_runner_version = '1.5.2' test_rules_version = '1.5.0' junit_version = '1.1.5' androidx_truth_version = '1.5.0' - google_truth_version = '1.1.5' + google_truth_version = '1.3.0' - material_version = '1.9.0' + material_version = '1.11.0' - compose_compiler_version = '1.5.2' - compose_version = '1.5.1' + compose_bom_version = '2023.10.01' + compose_compiler_version = '1.5.4' + compose_version = '1.5.4' wear_compose_version = '1.2.0' - horologist_version = '0.4.12' + horologist_version = '0.5.19' accompanist_version = '0.30.1' gson_version = '2.10.1' @@ -43,7 +44,7 @@ buildscript { // Shizuku shizuku_version = '13.1.5' - refine_version = '4.3.0' + refine_version = '4.4.0' } repositories { @@ -52,8 +53,8 @@ buildscript { } dependencies { - classpath 'com.android.tools.build:gradle:8.1.1' - classpath 'com.google.gms:google-services:4.3.15' + classpath 'com.android.tools.build:gradle:8.2.1' + classpath 'com.google.gms:google-services:4.4.0' classpath 'com.google.firebase:firebase-crashlytics-gradle:2.9.9' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" // NOTE: Do not place your application dependencies here; they belong diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 2eb27a8..2faf7cf 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip diff --git a/hidden-api/build.gradle b/hidden-api/build.gradle index 8b841a2..907769f 100644 --- a/hidden-api/build.gradle +++ b/hidden-api/build.gradle @@ -37,7 +37,7 @@ android { } dependencies { - annotationProcessor 'dev.rikka.tools.refine:annotation-processor:4.3.0' - compileOnly 'dev.rikka.tools.refine:annotation:4.3.0' - implementation 'androidx.annotation:annotation:1.7.0' + annotationProcessor 'dev.rikka.tools.refine:annotation-processor:4.4.0' + compileOnly 'dev.rikka.tools.refine:annotation:4.4.0' + implementation 'androidx.annotation:annotation:1.7.1' } \ No newline at end of file diff --git a/mobile/build.gradle b/mobile/build.gradle index 64173f4..eccb9f3 100644 --- a/mobile/build.gradle +++ b/mobile/build.gradle @@ -97,7 +97,7 @@ dependencies { implementation "androidx.appcompat:appcompat:$appcompat_version" implementation "androidx.constraintlayout:constraintlayout:$constraintlayout_version" - implementation 'androidx.media:media:1.6.0' + implementation 'androidx.media:media:1.7.0' implementation "com.google.android.material:material:$material_version" diff --git a/mobile/src/main/java/com/thewizrd/simplewear/App.kt b/mobile/src/main/java/com/thewizrd/simplewear/App.kt index daafd28..bd30c0a 100644 --- a/mobile/src/main/java/com/thewizrd/simplewear/App.kt +++ b/mobile/src/main/java/com/thewizrd/simplewear/App.kt @@ -245,9 +245,8 @@ class App : Application(), ApplicationLib, ActivityLifecycleCallbacks, Configura } } - override fun getWorkManagerConfiguration(): Configuration { - return Configuration.Builder() + override val workManagerConfiguration: Configuration + get() = Configuration.Builder() .setMinimumLoggingLevel(if (BuildConfig.DEBUG) Log.DEBUG else Log.INFO) .build() - } } \ No newline at end of file diff --git a/wear/build.gradle b/wear/build.gradle index 5cacd7f..3e5d368 100644 --- a/wear/build.gradle +++ b/wear/build.gradle @@ -101,12 +101,12 @@ dependencies { implementation "com.google.android.horologist:horologist-compose-tools:$horologist_version" // WearOS Compose - implementation "androidx.compose.compiler:compiler:$compose_compiler_version" - implementation "androidx.compose.ui:ui:$compose_version" - implementation "androidx.compose.ui:ui-tooling-preview:$compose_version" + implementation platform("androidx.compose:compose-bom:$compose_bom_version") + implementation "androidx.compose.ui:ui" + implementation "androidx.compose.ui:ui-tooling-preview" - androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version" - debugImplementation "androidx.compose.ui:ui-tooling:$compose_version" + androidTestImplementation "androidx.compose.ui:ui-test-junit4" + debugImplementation "androidx.compose.ui:ui-tooling" implementation "com.jakewharton.timber:timber:$timber_version" implementation "com.google.code.gson:gson:$gson_version" From ac02608c78a5f51bafb12282146cd2e8784702d8 Mon Sep 17 00:00:00 2001 From: Dave Antoine Date: Tue, 23 Jan 2024 22:57:49 -0500 Subject: [PATCH 06/58] enum.values() -> .entries --- .../simplewear/wearable/WearableManager.kt | 50 ++++++++++++++++--- .../actions/MultiChoiceAction.kt | 7 +-- .../simplewear/ValueActionActivity.kt | 2 +- .../simplewear/adapters/ActionItemAdapter.kt | 4 +- .../simplewear/media/MediaPlayerActivity.kt | 2 +- .../preferences/DashboardConfigActivity.kt | 6 +-- .../DashboardTileConfigActivity.kt | 2 +- 7 files changed, 56 insertions(+), 17 deletions(-) diff --git a/mobile/src/main/java/com/thewizrd/simplewear/wearable/WearableManager.kt b/mobile/src/main/java/com/thewizrd/simplewear/wearable/WearableManager.kt index 25e4bd7..d1ceffc 100644 --- a/mobile/src/main/java/com/thewizrd/simplewear/wearable/WearableManager.kt +++ b/mobile/src/main/java/com/thewizrd/simplewear/wearable/WearableManager.kt @@ -16,29 +16,67 @@ import android.util.TypedValue import android.view.KeyEvent import androidx.annotation.RestrictTo import androidx.media.MediaBrowserServiceCompat -import com.google.android.gms.wearable.* +import com.google.android.gms.wearable.CapabilityClient import com.google.android.gms.wearable.CapabilityClient.OnCapabilityChangedListener +import com.google.android.gms.wearable.CapabilityInfo +import com.google.android.gms.wearable.DataMap +import com.google.android.gms.wearable.Node +import com.google.android.gms.wearable.PutDataMapRequest +import com.google.android.gms.wearable.Wearable import com.google.gson.stream.JsonWriter -import com.thewizrd.shared_resources.actions.* +import com.thewizrd.shared_resources.actions.ACTION_PERFORMACTION +import com.thewizrd.shared_resources.actions.Action +import com.thewizrd.shared_resources.actions.ActionStatus +import com.thewizrd.shared_resources.actions.Actions +import com.thewizrd.shared_resources.actions.AudioStreamState +import com.thewizrd.shared_resources.actions.AudioStreamType +import com.thewizrd.shared_resources.actions.BatteryStatus +import com.thewizrd.shared_resources.actions.DNDChoice +import com.thewizrd.shared_resources.actions.EXTRA_ACTION_CALLINGPKG +import com.thewizrd.shared_resources.actions.EXTRA_ACTION_DATA +import com.thewizrd.shared_resources.actions.EXTRA_ACTION_ERROR +import com.thewizrd.shared_resources.actions.MultiChoiceAction +import com.thewizrd.shared_resources.actions.NormalAction +import com.thewizrd.shared_resources.actions.RemoteActionReceiver +import com.thewizrd.shared_resources.actions.RingerChoice +import com.thewizrd.shared_resources.actions.ToggleAction +import com.thewizrd.shared_resources.actions.ValueAction +import com.thewizrd.shared_resources.actions.ValueActionState +import com.thewizrd.shared_resources.actions.VolumeAction +import com.thewizrd.shared_resources.actions.toRemoteAction import com.thewizrd.shared_resources.helpers.AppItemData import com.thewizrd.shared_resources.helpers.AppItemSerializer.serialize import com.thewizrd.shared_resources.helpers.MediaHelper import com.thewizrd.shared_resources.helpers.WearSettingsHelper import com.thewizrd.shared_resources.helpers.WearableHelper -import com.thewizrd.shared_resources.utils.* +import com.thewizrd.shared_resources.utils.ImageUtils import com.thewizrd.shared_resources.utils.ImageUtils.toAsset import com.thewizrd.shared_resources.utils.ImageUtils.toByteArray +import com.thewizrd.shared_resources.utils.JSONParser +import com.thewizrd.shared_resources.utils.Logger +import com.thewizrd.shared_resources.utils.booleanToBytes +import com.thewizrd.shared_resources.utils.stringToBytes import com.thewizrd.shared_resources.wearsettings.PackageValidator import com.thewizrd.simplewear.helpers.PhoneStatusHelper import com.thewizrd.simplewear.helpers.ResolveInfoActivityInfoComparator import com.thewizrd.simplewear.media.MediaAppControllerUtils import com.thewizrd.simplewear.preferences.Settings import com.thewizrd.simplewear.services.NotificationListener -import kotlinx.coroutines.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch import kotlinx.coroutines.tasks.await +import kotlinx.coroutines.withContext import java.io.BufferedWriter import java.io.OutputStreamWriter -import java.util.* +import java.util.Collections @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) class WearableManager(private val mContext: Context) : OnCapabilityChangedListener, @@ -681,7 +719,7 @@ class WearableManager(private val mContext: Context) : OnCapabilityChangedListen fun sendActionsUpdate(nodeID: String?) { scope.launch { - for (act in Actions.values()) { + for (act in Actions.entries) { async { sendActionsUpdate(nodeID, act) } } } diff --git a/shared_resources/src/main/java/com/thewizrd/shared_resources/actions/MultiChoiceAction.kt b/shared_resources/src/main/java/com/thewizrd/shared_resources/actions/MultiChoiceAction.kt index edbdb01..8df67b2 100644 --- a/shared_resources/src/main/java/com/thewizrd/shared_resources/actions/MultiChoiceAction.kt +++ b/shared_resources/src/main/java/com/thewizrd/shared_resources/actions/MultiChoiceAction.kt @@ -29,9 +29,10 @@ class MultiChoiceAction : Action { Actions.TORCH, Actions.LOCKSCREEN, Actions.VOLUME -> 1 - Actions.LOCATION -> if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) LocationState.values().size else 1 - Actions.DONOTDISTURB -> if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) DNDChoice.values().size else 1 - Actions.RINGER -> RingerChoice.values().size + + Actions.LOCATION -> if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) LocationState.entries.size else 1 + Actions.DONOTDISTURB -> if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) DNDChoice.entries.size else 1 + Actions.RINGER -> RingerChoice.entries.size else -> 1 } } diff --git a/wear/src/main/java/com/thewizrd/simplewear/ValueActionActivity.kt b/wear/src/main/java/com/thewizrd/simplewear/ValueActionActivity.kt index f8f373e..34f898d 100644 --- a/wear/src/main/java/com/thewizrd/simplewear/ValueActionActivity.kt +++ b/wear/src/main/java/com/thewizrd/simplewear/ValueActionActivity.kt @@ -262,7 +262,7 @@ class ValueActionActivity : WearableListenerActivity() { } binding.actionIcon.setOnClickListener { if (mStreamType != null && mAction == Actions.VOLUME) { - val maxStates = AudioStreamType.values().size + val maxStates = AudioStreamType.entries.size var newValue = (mStreamType!!.value + 1) % maxStates if (newValue < 0) newValue += maxStates mStreamType = AudioStreamType.valueOf(newValue) diff --git a/wear/src/main/java/com/thewizrd/simplewear/adapters/ActionItemAdapter.kt b/wear/src/main/java/com/thewizrd/simplewear/adapters/ActionItemAdapter.kt index 9b06c24..65f7a58 100644 --- a/wear/src/main/java/com/thewizrd/simplewear/adapters/ActionItemAdapter.kt +++ b/wear/src/main/java/com/thewizrd/simplewear/adapters/ActionItemAdapter.kt @@ -34,7 +34,7 @@ class ActionItemAdapter(activity: Activity) : RecyclerView.Adapter?) { - resetActions(actions ?: Actions.values().toList()) + resetActions(actions ?: Actions.entries) notifyDataSetChanged() } } \ No newline at end of file diff --git a/wear/src/main/java/com/thewizrd/simplewear/media/MediaPlayerActivity.kt b/wear/src/main/java/com/thewizrd/simplewear/media/MediaPlayerActivity.kt index 2246e1d..4c308ee 100644 --- a/wear/src/main/java/com/thewizrd/simplewear/media/MediaPlayerActivity.kt +++ b/wear/src/main/java/com/thewizrd/simplewear/media/MediaPlayerActivity.kt @@ -295,7 +295,7 @@ class MediaPlayerActivity : WearableListenerActivity(), AmbientModeSupport.Ambie Queue(4); companion object { - fun valueOf(value: Int) = MediaPageType.values().firstOrNull() { it.value == value } + fun valueOf(value: Int) = entries.firstOrNull { it.value == value } } } diff --git a/wear/src/main/java/com/thewizrd/simplewear/preferences/DashboardConfigActivity.kt b/wear/src/main/java/com/thewizrd/simplewear/preferences/DashboardConfigActivity.kt index 1e32d3d..0884d33 100644 --- a/wear/src/main/java/com/thewizrd/simplewear/preferences/DashboardConfigActivity.kt +++ b/wear/src/main/java/com/thewizrd/simplewear/preferences/DashboardConfigActivity.kt @@ -23,8 +23,8 @@ import kotlin.math.roundToInt class DashboardConfigActivity : AppCompatLiteActivity() { companion object { - private val MAX_BUTTONS = Actions.values().size - private val DEFAULT_TILES = Actions.values().toList() + private val MAX_BUTTONS = Actions.entries.size + private val DEFAULT_TILES = Actions.entries } private lateinit var binding: LayoutDashboardConfigBinding @@ -77,7 +77,7 @@ class DashboardConfigActivity : AppCompatLiteActivity() { } addButtonAdapter.setOnClickListener { - val allowedActions = Actions.values().toMutableList() + val allowedActions = Actions.entries.toMutableList() // Remove current actions allowedActions.removeAll(actionAdapter.getActions()) diff --git a/wear/src/main/java/com/thewizrd/simplewear/preferences/DashboardTileConfigActivity.kt b/wear/src/main/java/com/thewizrd/simplewear/preferences/DashboardTileConfigActivity.kt index 33c3cfe..e3b17cf 100644 --- a/wear/src/main/java/com/thewizrd/simplewear/preferences/DashboardTileConfigActivity.kt +++ b/wear/src/main/java/com/thewizrd/simplewear/preferences/DashboardTileConfigActivity.kt @@ -75,7 +75,7 @@ class DashboardTileConfigActivity : AppCompatLiteActivity() { } addButtonAdapter.setOnClickListener { - val allowedActions = Actions.values().toMutableList() + val allowedActions = Actions.entries.toMutableList() // Remove current actions allowedActions.removeAll(actionAdapter.getActions()) // Remove other actions which need an activity From 320d66aa90c3f25e80ed0dff2a32a0c56426a0e1 Mon Sep 17 00:00:00 2001 From: Dave Antoine Date: Tue, 23 Jan 2024 22:59:34 -0500 Subject: [PATCH 07/58] Settings: add option to remove battery status from dashboard --- .../thewizrd/simplewear/DashboardActivity.kt | 22 +++++++++++++++++++ .../simplewear/preferences/Settings.kt | 13 +++++++++++ .../res/layout/dashboard_drawer_layout.xml | 14 ++++++++++++ wear/src/main/res/values/strings.xml | 1 + 4 files changed, 50 insertions(+) diff --git a/wear/src/main/java/com/thewizrd/simplewear/DashboardActivity.kt b/wear/src/main/java/com/thewizrd/simplewear/DashboardActivity.kt index 2710b83..2a7b73c 100644 --- a/wear/src/main/java/com/thewizrd/simplewear/DashboardActivity.kt +++ b/wear/src/main/java/com/thewizrd/simplewear/DashboardActivity.kt @@ -26,6 +26,7 @@ import androidx.core.content.PermissionChecker import androidx.core.view.InputDeviceCompat import androidx.core.view.MotionEventCompat import androidx.core.view.ViewConfigurationCompat +import androidx.core.view.isVisible import androidx.lifecycle.lifecycleScope import androidx.lifecycle.withStarted import androidx.localbroadcastmanager.content.LocalBroadcastManager @@ -448,6 +449,16 @@ class DashboardActivity : WearableListenerActivity(), OnSharedPreferenceChangeLi packageManager.getComponentEnabledSetting(mediaCtrlrComponent) <= PackageManager.COMPONENT_ENABLED_STATE_ENABLED } + findViewById(R.id.batt_stat_pref).also { battStatPref -> + battStatPref.setOnClickListener { + battStatPref.toggle() + + Settings.setShowBatStatus(battStatPref.isChecked) + } + + battStatPref.isChecked = Settings.isShowBatStatus() + } + intentFilter = IntentFilter().apply { addAction(ACTION_UPDATECONNECTIONSTATUS) addAction(ACTION_OPENONPHONE) @@ -616,6 +627,7 @@ class DashboardActivity : WearableListenerActivity(), OnSharedPreferenceChangeLi } } } + Settings.KEY_DASHCONFIG -> { lifecycleScope.launch { runCatching { @@ -625,6 +637,16 @@ class DashboardActivity : WearableListenerActivity(), OnSharedPreferenceChangeLi } } } + + Settings.KEY_SHOWBATSTATUS -> { + lifecycleScope.launch { + runCatching { + withStarted { + binding.battStat.isVisible = Settings.isShowBatStatus() + } + } + } + } } } } \ No newline at end of file diff --git a/wear/src/main/java/com/thewizrd/simplewear/preferences/Settings.kt b/wear/src/main/java/com/thewizrd/simplewear/preferences/Settings.kt index 7c77d95..303a401 100644 --- a/wear/src/main/java/com/thewizrd/simplewear/preferences/Settings.kt +++ b/wear/src/main/java/com/thewizrd/simplewear/preferences/Settings.kt @@ -15,6 +15,7 @@ object Settings { private const val KEY_LOADAPPICONS = "key_loadappicons" private const val KEY_DASHTILECONFIG = "key_dashtileconfig" const val KEY_DASHCONFIG = "key_dashconfig" + const val KEY_SHOWBATSTATUS = "key_showbatstatus" fun useGridLayout(): Boolean { val preferences = PreferenceManager.getDefaultSharedPreferences(App.instance.appContext) @@ -102,4 +103,16 @@ object Settings { }) } } + + fun isShowBatStatus(): Boolean { + val preferences = PreferenceManager.getDefaultSharedPreferences(App.instance.appContext) + return preferences.getBoolean(KEY_SHOWBATSTATUS, true) + } + + fun setShowBatStatus(value: Boolean) { + val preferences = PreferenceManager.getDefaultSharedPreferences(App.instance.appContext) + preferences.edit { + putBoolean(KEY_SHOWBATSTATUS, value) + } + } } \ No newline at end of file diff --git a/wear/src/main/res/layout/dashboard_drawer_layout.xml b/wear/src/main/res/layout/dashboard_drawer_layout.xml index ef128b5..84dba43 100644 --- a/wear/src/main/res/layout/dashboard_drawer_layout.xml +++ b/wear/src/main/res/layout/dashboard_drawer_layout.xml @@ -50,6 +50,20 @@ android:layout_width="wrap_content" android:layout_height="4dp" /> + + + + Dashboard Editor Add Media Controller to launcher + Show Battery Status Loading… Refresh From 41a5f21835c53700627fc4858cd30cb085fd4a5f Mon Sep 17 00:00:00 2001 From: Dave Antoine Date: Wed, 24 Jan 2024 20:11:14 -0500 Subject: [PATCH 08/58] Dashboard: integrate battery status visibility setting into editor --- .../thewizrd/simplewear/DashboardActivity.kt | 22 ++- .../adapters/DashBattStatusItemAdapter.kt | 131 ++++++++++++++++++ .../simplewear/controls/WearChipButton.kt | 6 + .../preferences/DashboardConfigActivity.kt | 33 ++++- .../DashboardTileConfigActivity.kt | 32 ++++- .../simplewear/preferences/Settings.kt | 13 ++ .../wearable/tiles/DashboardTileModel.kt | 17 ++- .../tiles/DashboardTileProviderService.kt | 2 + .../tiles/layouts/DashboardTileLayout.kt | 87 ++++++------ .../DashboardTileProviderService.kt | 8 ++ .../res/layout/dashboard_drawer_layout.xml | 14 -- .../main/res/layout/tile_layout_dashboard.xml | 12 +- wear/src/main/res/values/strings.xml | 4 + 13 files changed, 309 insertions(+), 72 deletions(-) create mode 100644 wear/src/main/java/com/thewizrd/simplewear/adapters/DashBattStatusItemAdapter.kt diff --git a/wear/src/main/java/com/thewizrd/simplewear/DashboardActivity.kt b/wear/src/main/java/com/thewizrd/simplewear/DashboardActivity.kt index 2a7b73c..e5cd539 100644 --- a/wear/src/main/java/com/thewizrd/simplewear/DashboardActivity.kt +++ b/wear/src/main/java/com/thewizrd/simplewear/DashboardActivity.kt @@ -410,6 +410,7 @@ class DashboardActivity : WearableListenerActivity(), OnSharedPreferenceChangeLi isIndeterminate = true indicatorSize = height }) + binding.battStat.isVisible = Settings.isShowBatStatus() binding.actionsList.isFocusable = false binding.actionsList.clearFocus() @@ -449,16 +450,6 @@ class DashboardActivity : WearableListenerActivity(), OnSharedPreferenceChangeLi packageManager.getComponentEnabledSetting(mediaCtrlrComponent) <= PackageManager.COMPONENT_ENABLED_STATE_ENABLED } - findViewById(R.id.batt_stat_pref).also { battStatPref -> - battStatPref.setOnClickListener { - battStatPref.toggle() - - Settings.setShowBatStatus(battStatPref.isChecked) - } - - battStatPref.isChecked = Settings.isShowBatStatus() - } - intentFilter = IntentFilter().apply { addAction(ACTION_UPDATECONNECTIONSTATUS) addAction(ACTION_OPENONPHONE) @@ -498,6 +489,9 @@ class DashboardActivity : WearableListenerActivity(), OnSharedPreferenceChangeLi } } } + + PreferenceManager.getDefaultSharedPreferences(this@DashboardActivity) + .registerOnSharedPreferenceChangeListener(this@DashboardActivity) } private fun showProgressBar(show: Boolean) { @@ -566,8 +560,6 @@ class DashboardActivity : WearableListenerActivity(), OnSharedPreferenceChangeLi override fun onStart() { super.onStart() - PreferenceManager.getDefaultSharedPreferences(this) - .registerOnSharedPreferenceChangeListener(this) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (PermissionChecker.checkSelfPermission( @@ -610,9 +602,13 @@ class DashboardActivity : WearableListenerActivity(), OnSharedPreferenceChangeLi } override fun onStop() { + super.onStop() + } + + override fun onDestroy() { PreferenceManager.getDefaultSharedPreferences(this) .unregisterOnSharedPreferenceChangeListener(this) - super.onStop() + super.onDestroy() } override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { diff --git a/wear/src/main/java/com/thewizrd/simplewear/adapters/DashBattStatusItemAdapter.kt b/wear/src/main/java/com/thewizrd/simplewear/adapters/DashBattStatusItemAdapter.kt new file mode 100644 index 0000000..7ade463 --- /dev/null +++ b/wear/src/main/java/com/thewizrd/simplewear/adapters/DashBattStatusItemAdapter.kt @@ -0,0 +1,131 @@ +package com.thewizrd.simplewear.adapters + +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.RecyclerView +import com.thewizrd.shared_resources.utils.ContextUtils.getAttrColorStateList +import com.thewizrd.simplewear.R +import com.thewizrd.simplewear.controls.WearChipButton + +class DashBattStatusItemAdapter : RecyclerView.Adapter() { + companion object { + const val ITEM_TYPE = R.drawable.ic_battery_std_white_24dp + } + + private var itemPosition = RecyclerView.NO_POSITION + private var recyclerView: RecyclerView? = null + + var isVisible: Boolean = true + set(value) { + val oldValue = field + field = value + + if (oldValue != value) { + notifyItemChanged(0) + } + } + + var isChecked: Boolean = false + set(value) { + val oldValue = field + field = value + + if (oldValue != value) { + notifyItemChanged(0) + } + } + + inner class ViewHolder(private val button: WearChipButton) : + RecyclerView.ViewHolder(button) { + fun bind(isChecked: Boolean, isVisible: Boolean) { + if (!isVisible) { + button.setIconResource(R.drawable.ic_add_white_24dp) + button.setIconTint(button.context.getAttrColorStateList(R.attr.colorSurface)) + button.setBackgroundColor(button.context.getAttrColorStateList(R.attr.colorOnSurface)) + button.findViewById(R.id.wear_chip_primary_text)?.let { + it.setTextColor(button.context.getAttrColorStateList(R.attr.colorSurface)) + it.setText(R.string.action_add_batt_state) + } + } else if (isChecked) { + button.setIconResource(R.drawable.ic_close_white_24dp) + button.setIconTint(button.context.getAttrColorStateList(R.attr.colorSurface)) + button.setBackgroundColor(button.context.getAttrColorStateList(R.attr.colorOnSurface)) + button.findViewById(R.id.wear_chip_primary_text)?.let { + it.setTextColor(button.context.getAttrColorStateList(R.attr.colorSurface)) + it.setText(R.string.action_remove_batt_state) + } + } else { + button.setIconTint(button.context.getAttrColorStateList(R.attr.colorOnSurface)) + button.setIconResource(R.drawable.ic_battery_std_white_24dp) + button.setBackgroundColor( + ContextCompat.getColor( + button.context, + R.color.buttonDisabled + ) + ) + button.findViewById(R.id.wear_chip_primary_text)?.let { + it.setTextColor( + ContextCompat.getColor( + it.context, + R.color.wear_chip_primary_text_color + ) + ) + it.setText(R.string.title_batt_state) + } + } + + button.requestFocus() + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + return ViewHolder(WearChipButton(parent.context).apply { + setIconResource(R.drawable.ic_battery_std_white_24dp) + setText(R.string.title_batt_state) + isCheckable = false + }) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + itemPosition = holder.bindingAdapterPosition + holder.bind(isChecked, isVisible) + + holder.itemView.setOnClickListener { + if (!isVisible) { + isVisible = true + isChecked = false + } else if (isChecked) { + isVisible = false + isChecked = false + } else { + isChecked = true + } + } + } + + override fun getItemCount(): Int { + return 1 + } + + override fun getItemViewType(position: Int): Int { + return ITEM_TYPE + } + + override fun onAttachedToRecyclerView(recyclerView: RecyclerView) { + this.recyclerView = recyclerView + } + + override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) { + this.recyclerView = null + } + + fun getSelection(): View? { + if (itemPosition >= 0) { + return recyclerView?.findViewHolderForLayoutPosition(itemPosition)?.itemView + } + + return null + } +} \ No newline at end of file diff --git a/wear/src/main/java/com/thewizrd/simplewear/controls/WearChipButton.kt b/wear/src/main/java/com/thewizrd/simplewear/controls/WearChipButton.kt index 771a453..f032f9e 100644 --- a/wear/src/main/java/com/thewizrd/simplewear/controls/WearChipButton.kt +++ b/wear/src/main/java/com/thewizrd/simplewear/controls/WearChipButton.kt @@ -11,6 +11,7 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.* +import androidx.annotation.ColorInt import androidx.annotation.ColorRes import androidx.annotation.DrawableRes import androidx.annotation.IntDef @@ -283,6 +284,11 @@ class WearChipButton @JvmOverloads constructor( updateBackgroundTint() } + override fun setBackgroundColor(@ColorInt color: Int) { + buttonBackgroundTint = ColorStateList.valueOf(color) + updateBackgroundTint() + } + fun getControlButtonColor(): ColorStateList? { return buttonControlTint } diff --git a/wear/src/main/java/com/thewizrd/simplewear/preferences/DashboardConfigActivity.kt b/wear/src/main/java/com/thewizrd/simplewear/preferences/DashboardConfigActivity.kt index 0884d33..ceb5db7 100644 --- a/wear/src/main/java/com/thewizrd/simplewear/preferences/DashboardConfigActivity.kt +++ b/wear/src/main/java/com/thewizrd/simplewear/preferences/DashboardConfigActivity.kt @@ -10,11 +10,13 @@ import androidx.core.view.MotionEventCompat import androidx.core.view.ViewConfigurationCompat import androidx.recyclerview.widget.ConcatAdapter import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.GridLayoutManager.SpanSizeLookup import androidx.recyclerview.widget.ItemTouchHelper import com.thewizrd.shared_resources.actions.Actions import com.thewizrd.simplewear.R import com.thewizrd.simplewear.activities.AppCompatLiteActivity import com.thewizrd.simplewear.adapters.AddButtonAdapter +import com.thewizrd.simplewear.adapters.DashBattStatusItemAdapter import com.thewizrd.simplewear.adapters.TileActionAdapter import com.thewizrd.simplewear.databinding.LayoutDashboardConfigBinding import com.thewizrd.simplewear.helpers.AcceptDenyDialog @@ -29,6 +31,7 @@ class DashboardConfigActivity : AppCompatLiteActivity() { private lateinit var binding: LayoutDashboardConfigBinding private lateinit var concatAdapter: ConcatAdapter + private lateinit var dashBattStatAdapter: DashBattStatusItemAdapter private lateinit var actionAdapter: TileActionAdapter private lateinit var addButtonAdapter: AddButtonAdapter private lateinit var itemTouchHelper: ItemTouchHelper @@ -39,8 +42,21 @@ class DashboardConfigActivity : AppCompatLiteActivity() { binding = LayoutDashboardConfigBinding.inflate(layoutInflater) setContentView(binding.root) - binding.tileGridLayout.layoutManager = GridLayoutManager(this, 3) + binding.tileGridLayout.layoutManager = GridLayoutManager(this, 3).also { layoutMgr -> + layoutMgr.spanSizeLookup = object : SpanSizeLookup() { + override fun getSpanSize(position: Int): Int { + return if (concatAdapter.getItemViewType(position) == DashBattStatusItemAdapter.ITEM_TYPE) { + 3 + } else { + 1 + } + } + } + } + dashBattStatAdapter = DashBattStatusItemAdapter().apply { + isVisible = Settings.isShowBatStatus() + } actionAdapter = TileActionAdapter() addButtonAdapter = AddButtonAdapter() @@ -48,6 +64,7 @@ class DashboardConfigActivity : AppCompatLiteActivity() { ConcatAdapter.Config.Builder() .setIsolateViewTypes(false) .build(), + dashBattStatAdapter, actionAdapter ).also { concatAdapter = it @@ -97,6 +114,9 @@ class DashboardConfigActivity : AppCompatLiteActivity() { DialogInterface.BUTTON_POSITIVE -> { actionAdapter.submitActions(DEFAULT_TILES) Settings.setDashboardConfig(null) + + dashBattStatAdapter.isVisible = true + Settings.setShowBatStatus(true) } } } @@ -108,6 +128,8 @@ class DashboardConfigActivity : AppCompatLiteActivity() { val currentList = actionAdapter.getActions() Settings.setDashboardConfig(currentList) + Settings.setShowBatStatus(dashBattStatAdapter.isVisible) + // Close activity finish() } @@ -123,6 +145,15 @@ class DashboardConfigActivity : AppCompatLiteActivity() { if (!r.contains(ev.rawX.toInt(), ev.rawY.toInt())) { actionAdapter.clearSelection() } + } else { + dashBattStatAdapter.getSelection()?.let { battView -> + val r = Rect().also { + battView.getGlobalVisibleRect(it) + } + if (!r.contains(ev.rawX.toInt(), ev.rawY.toInt())) { + dashBattStatAdapter.isChecked = false + } + } } } return super.dispatchTouchEvent(ev) diff --git a/wear/src/main/java/com/thewizrd/simplewear/preferences/DashboardTileConfigActivity.kt b/wear/src/main/java/com/thewizrd/simplewear/preferences/DashboardTileConfigActivity.kt index e3b17cf..8bab55c 100644 --- a/wear/src/main/java/com/thewizrd/simplewear/preferences/DashboardTileConfigActivity.kt +++ b/wear/src/main/java/com/thewizrd/simplewear/preferences/DashboardTileConfigActivity.kt @@ -15,6 +15,7 @@ import com.thewizrd.shared_resources.actions.Actions import com.thewizrd.simplewear.R import com.thewizrd.simplewear.activities.AppCompatLiteActivity import com.thewizrd.simplewear.adapters.AddButtonAdapter +import com.thewizrd.simplewear.adapters.DashBattStatusItemAdapter import com.thewizrd.simplewear.adapters.TileActionAdapter import com.thewizrd.simplewear.databinding.LayoutTileDashboardConfigBinding import com.thewizrd.simplewear.helpers.AcceptDenyDialog @@ -27,6 +28,7 @@ import kotlin.math.roundToInt class DashboardTileConfigActivity : AppCompatLiteActivity() { private lateinit var binding: LayoutTileDashboardConfigBinding private lateinit var concatAdapter: ConcatAdapter + private lateinit var dashBattStatAdapter: DashBattStatusItemAdapter private lateinit var actionAdapter: TileActionAdapter private lateinit var addButtonAdapter: AddButtonAdapter private lateinit var itemTouchHelper: ItemTouchHelper @@ -37,8 +39,21 @@ class DashboardTileConfigActivity : AppCompatLiteActivity() { binding = LayoutTileDashboardConfigBinding.inflate(layoutInflater) setContentView(binding.root) - binding.tileGridLayout.layoutManager = GridLayoutManager(this, 3) + binding.tileGridLayout.layoutManager = GridLayoutManager(this, 3).also { layoutMgr -> + layoutMgr.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() { + override fun getSpanSize(position: Int): Int { + return if (concatAdapter.getItemViewType(position) == DashBattStatusItemAdapter.ITEM_TYPE) { + 3 + } else { + 1 + } + } + } + } + dashBattStatAdapter = DashBattStatusItemAdapter().apply { + isVisible = Settings.isShowTileBatStatus() + } actionAdapter = TileActionAdapter() addButtonAdapter = AddButtonAdapter() @@ -46,6 +61,7 @@ class DashboardTileConfigActivity : AppCompatLiteActivity() { ConcatAdapter.Config.Builder() .setIsolateViewTypes(false) .build(), + dashBattStatAdapter, actionAdapter ).also { concatAdapter = it @@ -97,6 +113,9 @@ class DashboardTileConfigActivity : AppCompatLiteActivity() { DialogInterface.BUTTON_POSITIVE -> { actionAdapter.submitActions(DEFAULT_TILES) Settings.setDashboardTileConfig(null) + + dashBattStatAdapter.isVisible = true + Settings.setShowTileBatStatus(true) } } } @@ -108,6 +127,8 @@ class DashboardTileConfigActivity : AppCompatLiteActivity() { val currentList = actionAdapter.getActions() Settings.setDashboardTileConfig(currentList) + Settings.setShowTileBatStatus(dashBattStatAdapter.isVisible) + // Close activity finish() } @@ -123,6 +144,15 @@ class DashboardTileConfigActivity : AppCompatLiteActivity() { if (!r.contains(ev.rawX.toInt(), ev.rawY.toInt())) { actionAdapter.clearSelection() } + } else { + dashBattStatAdapter.getSelection()?.let { battView -> + val r = Rect().also { + battView.getGlobalVisibleRect(it) + } + if (!r.contains(ev.rawX.toInt(), ev.rawY.toInt())) { + dashBattStatAdapter.isChecked = false + } + } } } return super.dispatchTouchEvent(ev) diff --git a/wear/src/main/java/com/thewizrd/simplewear/preferences/Settings.kt b/wear/src/main/java/com/thewizrd/simplewear/preferences/Settings.kt index 303a401..a2e64ee 100644 --- a/wear/src/main/java/com/thewizrd/simplewear/preferences/Settings.kt +++ b/wear/src/main/java/com/thewizrd/simplewear/preferences/Settings.kt @@ -16,6 +16,7 @@ object Settings { private const val KEY_DASHTILECONFIG = "key_dashtileconfig" const val KEY_DASHCONFIG = "key_dashconfig" const val KEY_SHOWBATSTATUS = "key_showbatstatus" + const val KEY_SHOWTILEBATSTATUS = "key_showtilebatstatus" fun useGridLayout(): Boolean { val preferences = PreferenceManager.getDefaultSharedPreferences(App.instance.appContext) @@ -115,4 +116,16 @@ object Settings { putBoolean(KEY_SHOWBATSTATUS, value) } } + + fun isShowTileBatStatus(): Boolean { + val preferences = PreferenceManager.getDefaultSharedPreferences(App.instance.appContext) + return preferences.getBoolean(KEY_SHOWTILEBATSTATUS, true) + } + + fun setShowTileBatStatus(value: Boolean) { + val preferences = PreferenceManager.getDefaultSharedPreferences(App.instance.appContext) + preferences.edit { + putBoolean(KEY_SHOWTILEBATSTATUS, value) + } + } } \ No newline at end of file diff --git a/wear/src/main/java/com/thewizrd/simplewear/wearable/tiles/DashboardTileModel.kt b/wear/src/main/java/com/thewizrd/simplewear/wearable/tiles/DashboardTileModel.kt index b3db2b9..55390b4 100644 --- a/wear/src/main/java/com/thewizrd/simplewear/wearable/tiles/DashboardTileModel.kt +++ b/wear/src/main/java/com/thewizrd/simplewear/wearable/tiles/DashboardTileModel.kt @@ -5,6 +5,7 @@ import com.thewizrd.shared_resources.actions.Actions import com.thewizrd.shared_resources.actions.BatteryStatus import com.thewizrd.shared_resources.actions.NormalAction import com.thewizrd.shared_resources.helpers.WearConnectionStatus +import com.thewizrd.simplewear.preferences.Settings import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -21,7 +22,14 @@ class DashboardTileModel { } private val _tileState = - MutableStateFlow(DashboardTileState(mConnectionStatus, battStatus, getActionMapping())) + MutableStateFlow( + DashboardTileState( + mConnectionStatus, + battStatus, + getActionMapping(), + Settings.isShowTileBatStatus() + ) + ) val tileState: StateFlow get() = _tileState.asStateFlow() @@ -40,6 +48,12 @@ class DashboardTileModel { } } + fun setShowBatteryStatus(show: Boolean) { + _tileState.update { + it.copy(showBatteryStatus = show) + } + } + fun getAction(actionType: Actions): Action? = actionMap[actionType] fun setAction(actionType: Actions, action: Action) { actionMap[actionType] = action @@ -67,6 +81,7 @@ data class DashboardTileState( val connectionStatus: WearConnectionStatus, val batteryStatus: BatteryStatus? = null, val actions: Map = emptyMap(), + val showBatteryStatus: Boolean = true ) { fun getAction(actionType: Actions): Action? = actions[actionType] diff --git a/wear/src/main/java/com/thewizrd/simplewear/wearable/tiles/DashboardTileProviderService.kt b/wear/src/main/java/com/thewizrd/simplewear/wearable/tiles/DashboardTileProviderService.kt index c67e1e5..3a085e5 100644 --- a/wear/src/main/java/com/thewizrd/simplewear/wearable/tiles/DashboardTileProviderService.kt +++ b/wear/src/main/java/com/thewizrd/simplewear/wearable/tiles/DashboardTileProviderService.kt @@ -107,6 +107,8 @@ class DashboardTileProviderService : SuspendingTileService() { tileModel.updateTileActions(Settings.getDashboardTileConfig() ?: DEFAULT_TILES) } + tileModel.setShowBatteryStatus(Settings.isShowTileBatStatus()) + return tileRenderer.renderTimeline(tileModel.tileState.value, requestParams) } diff --git a/wear/src/main/java/com/thewizrd/simplewear/wearable/tiles/layouts/DashboardTileLayout.kt b/wear/src/main/java/com/thewizrd/simplewear/wearable/tiles/layouts/DashboardTileLayout.kt index 29914a6..9930698 100644 --- a/wear/src/main/java/com/thewizrd/simplewear/wearable/tiles/layouts/DashboardTileLayout.kt +++ b/wear/src/main/java/com/thewizrd/simplewear/wearable/tiles/layouts/DashboardTileLayout.kt @@ -16,6 +16,7 @@ import androidx.wear.protolayout.LayoutElementBuilders.FONT_WEIGHT_MEDIUM import androidx.wear.protolayout.LayoutElementBuilders.FontStyle import androidx.wear.protolayout.LayoutElementBuilders.HORIZONTAL_ALIGN_CENTER import androidx.wear.protolayout.LayoutElementBuilders.LayoutElement +import androidx.wear.protolayout.LayoutElementBuilders.Spacer import androidx.wear.protolayout.LayoutElementBuilders.SpanImage import androidx.wear.protolayout.LayoutElementBuilders.SpanText import androidx.wear.protolayout.LayoutElementBuilders.Spannable @@ -152,48 +153,52 @@ internal fun DashboardTileLayout( } else { return PrimaryLayout.Builder(deviceParameters) .setPrimaryLabelTextContent( - Spannable.Builder() - .addSpan( - SpanImage.Builder() - .setResourceId(ID_BATTERY) - .setWidth(dp(16f)) - .setHeight(dp(16f)) - .build() - ) - .addSpan( - SpanText.Builder() - .setText( - state.batteryStatus?.let { status -> - String.format( - Locale.ROOT, - "%d%%, %s", - status.batteryLevel, - if (status.isCharging) { - context.getString(R.string.batt_state_charging) - } else context.getString( - R.string.batt_state_discharging + if (state.showBatteryStatus) { + Spannable.Builder() + .addSpan( + SpanImage.Builder() + .setResourceId(ID_BATTERY) + .setWidth(dp(16f)) + .setHeight(dp(16f)) + .build() + ) + .addSpan( + SpanText.Builder() + .setText( + state.batteryStatus?.let { status -> + String.format( + Locale.ROOT, + "%d%%, %s", + status.batteryLevel, + if (status.isCharging) { + context.getString(R.string.batt_state_charging) + } else context.getString( + R.string.batt_state_discharging + ) ) - ) - } ?: context.getString(R.string.state_unknown) - ) - .setFontStyle( - FontStyle.Builder() - .setSize(sp(12f)) - .setWeight(FONT_WEIGHT_MEDIUM) - .setVariant(FONT_VARIANT_BODY) - .build() - ) - .setAndroidTextStyle( - AndroidTextStyle.Builder() - .setExcludeFontPadding(false) - .build() - ) - .build() - ) - .setMaxLines(1) - .setMultilineAlignment(HORIZONTAL_ALIGN_CENTER) - .setOverflow(TEXT_OVERFLOW_MARQUEE) - .build() + } ?: context.getString(R.string.state_unknown) + ) + .setFontStyle( + FontStyle.Builder() + .setSize(sp(12f)) + .setWeight(FONT_WEIGHT_MEDIUM) + .setVariant(FONT_VARIANT_BODY) + .build() + ) + .setAndroidTextStyle( + AndroidTextStyle.Builder() + .setExcludeFontPadding(false) + .build() + ) + .build() + ) + .setMaxLines(1) + .setMultilineAlignment(HORIZONTAL_ALIGN_CENTER) + .setOverflow(TEXT_OVERFLOW_MARQUEE) + .build() + } else { + Spacer.Builder().build() + } ) .setContent( MultiButtonLayout.Builder() diff --git a/wear/src/main/java/com/thewizrd/simplewear/wearable/tiles/unofficial/DashboardTileProviderService.kt b/wear/src/main/java/com/thewizrd/simplewear/wearable/tiles/unofficial/DashboardTileProviderService.kt index 68261a0..89f02bf 100644 --- a/wear/src/main/java/com/thewizrd/simplewear/wearable/tiles/unofficial/DashboardTileProviderService.kt +++ b/wear/src/main/java/com/thewizrd/simplewear/wearable/tiles/unofficial/DashboardTileProviderService.kt @@ -173,6 +173,14 @@ class DashboardTileProviderService : TileProviderService(), MessageClient.OnMess views.setTextViewText(R.id.batt_stat_text, battValue) } + if (Settings.isShowTileBatStatus()) { + views.setViewVisibility(R.id.batt_stat_layout, View.VISIBLE) + views.setViewVisibility(R.id.spacer, View.GONE) + } else { + views.setViewVisibility(R.id.batt_stat_layout, View.GONE) + views.setViewVisibility(R.id.spacer, View.VISIBLE) + } + for (i in 0 until MAX_BUTTONS) { val action = tileActions.getOrNull(i) updateButton(views, i + 1, action) diff --git a/wear/src/main/res/layout/dashboard_drawer_layout.xml b/wear/src/main/res/layout/dashboard_drawer_layout.xml index 84dba43..ef128b5 100644 --- a/wear/src/main/res/layout/dashboard_drawer_layout.xml +++ b/wear/src/main/res/layout/dashboard_drawer_layout.xml @@ -50,20 +50,6 @@ android:layout_width="wrap_content" android:layout_height="4dp" /> - - - - + + + android:orientation="vertical" + android:visibility="visible" + tools:visibility="gone"> Add Media Controller to launcher Show Battery Status + Battery State Loading… Refresh Play + Add Battery State + Remove Battery State + From ecfd104b1620a88eabcc2bbea83cea57a732b82b Mon Sep 17 00:00:00 2001 From: Dave Antoine Date: Wed, 24 Jan 2024 20:34:50 -0500 Subject: [PATCH 09/58] res: update splash screen --- .../src/main/java/com/thewizrd/simplewear/MainActivity.kt | 3 +-- mobile/src/main/res/values/styles.xml | 6 +++--- .../java/com/thewizrd/simplewear/PhoneSyncActivity.kt | 3 +-- wear/src/main/res/values/styles.xml | 8 ++++---- 4 files changed, 9 insertions(+), 11 deletions(-) diff --git a/mobile/src/main/java/com/thewizrd/simplewear/MainActivity.kt b/mobile/src/main/java/com/thewizrd/simplewear/MainActivity.kt index ce52a07..a1270be 100644 --- a/mobile/src/main/java/com/thewizrd/simplewear/MainActivity.kt +++ b/mobile/src/main/java/com/thewizrd/simplewear/MainActivity.kt @@ -12,10 +12,9 @@ class MainActivity : AppCompatActivity() { private var isReadyToView = false override fun onCreate(savedInstanceState: Bundle?) { + installSplashScreen() super.onCreate(savedInstanceState) - val splashScreen = installSplashScreen() - // Note: needed due to splash screen theme DynamicColors.applyIfAvailable(this) diff --git a/mobile/src/main/res/values/styles.xml b/mobile/src/main/res/values/styles.xml index 642af82..dc679dd 100644 --- a/mobile/src/main/res/values/styles.xml +++ b/mobile/src/main/res/values/styles.xml @@ -34,10 +34,10 @@ true - diff --git a/wear/src/main/java/com/thewizrd/simplewear/PhoneSyncActivity.kt b/wear/src/main/java/com/thewizrd/simplewear/PhoneSyncActivity.kt index 597eeae..818ffbb 100644 --- a/wear/src/main/java/com/thewizrd/simplewear/PhoneSyncActivity.kt +++ b/wear/src/main/java/com/thewizrd/simplewear/PhoneSyncActivity.kt @@ -42,9 +42,8 @@ class PhoneSyncActivity : WearableListenerActivity() { private lateinit var permissionRequestLauncher: ActivityResultLauncher> override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - installSplashScreen() + super.onCreate(savedInstanceState) // Create your application here binding = ActivitySetupSyncBinding.inflate(layoutInflater) diff --git a/wear/src/main/res/values/styles.xml b/wear/src/main/res/values/styles.xml index 60a1bb5..742fa65 100644 --- a/wear/src/main/res/values/styles.xml +++ b/wear/src/main/res/values/styles.xml @@ -30,11 +30,11 @@ @style/Widget.Wear.ActionButton - + + \ No newline at end of file From fcd9339a95067fcfb7831e70c6ba7dd2f3e71887 Mon Sep 17 00:00:00 2001 From: Dave Antoine Date: Sun, 24 Mar 2024 13:25:21 -0400 Subject: [PATCH 14/58] mobile: check for wearsettings app version --- mobile/src/main/AndroidManifest.xml | 6 ++- .../simplewear/PermissionCheckFragment.kt | 40 +++++++++++++++---- mobile/src/main/res/values/strings.xml | 1 + .../helpers/WearSettingsHelper.kt | 16 ++++++++ wearsettings/build.gradle | 4 ++ 5 files changed, 59 insertions(+), 8 deletions(-) diff --git a/mobile/src/main/AndroidManifest.xml b/mobile/src/main/AndroidManifest.xml index a4adcc1..a05c5de 100644 --- a/mobile/src/main/AndroidManifest.xml +++ b/mobile/src/main/AndroidManifest.xml @@ -34,7 +34,9 @@ - + @@ -52,6 +54,8 @@ + + diff --git a/mobile/src/main/java/com/thewizrd/simplewear/PermissionCheckFragment.kt b/mobile/src/main/java/com/thewizrd/simplewear/PermissionCheckFragment.kt index 9da7b1d..ba8c6b9 100644 --- a/mobile/src/main/java/com/thewizrd/simplewear/PermissionCheckFragment.kt +++ b/mobile/src/main/java/com/thewizrd/simplewear/PermissionCheckFragment.kt @@ -6,8 +6,15 @@ import android.app.Activity import android.app.admin.DevicePolicyManager import android.bluetooth.BluetoothAdapter import android.bluetooth.le.ScanFilter -import android.companion.* -import android.content.* +import android.companion.AssociationRequest +import android.companion.BluetoothDeviceFilter +import android.companion.BluetoothLeDeviceFilter +import android.companion.CompanionDeviceManager +import android.companion.WifiDeviceFilter +import android.content.ComponentName +import android.content.Context +import android.content.DialogInterface +import android.content.Intent import android.content.pm.PackageManager import android.graphics.Color import android.net.Uri @@ -316,7 +323,7 @@ class PermissionCheckFragment : LifecycleAwareFragment() { } binding.wearsettingsPref.setOnClickListener { - if (!WearSettingsHelper.isWearSettingsInstalled()) { + if (!WearSettingsHelper.isWearSettingsInstalled() || !WearSettingsHelper.isWearSettingsUpToDate()) { val i = Intent(Intent.ACTION_VIEW).apply { data = Uri.parse(getString(R.string.url_wearsettings_helper)) } @@ -532,7 +539,10 @@ class PermissionCheckFragment : LifecycleAwareFragment() { com.thewizrd.simplewear.preferences.Settings.setBridgeCallsEnabled(false) } - updateWearSettingsHelperPref(WearSettingsHelper.isWearSettingsInstalled()) + updateWearSettingsHelperPref( + WearSettingsHelper.isWearSettingsInstalled(), + WearSettingsHelper.isWearSettingsUpToDate() + ) updateSystemSettingsPref( PhoneStatusHelper.isWriteSystemSettingsPermissionEnabled( requireContext() @@ -583,9 +593,25 @@ class PermissionCheckFragment : LifecycleAwareFragment() { binding.callcontrolSummary.setTextColor(if (enabled) Color.GREEN else Color.RED) } - private fun updateWearSettingsHelperPref(installed: Boolean) { - binding.wearsettingsPrefSummary.setText(if (installed) R.string.preference_summary_wearsettings_installed else R.string.preference_summary_wearsettings_notinstalled) - binding.wearsettingsPrefSummary.setTextColor(if (installed) Color.GREEN else Color.RED) + private fun updateWearSettingsHelperPref(installed: Boolean, upToDate: Boolean) { + binding.wearsettingsPrefSummary.setText( + if (installed) { + if (upToDate) { + R.string.preference_summary_wearsettings_installed + } else { + R.string.preference_summary_wearsettings_outdated + } + } else { + R.string.preference_summary_wearsettings_notinstalled + } + ) + binding.wearsettingsPrefSummary.setTextColor( + if (installed) { + if (upToDate) Color.GREEN else Color.argb(0xFF, 0xFF, 0xA5, 0) + } else { + Color.RED + } + ) } private fun updateSystemSettingsPref(enabled: Boolean) { diff --git a/mobile/src/main/res/values/strings.xml b/mobile/src/main/res/values/strings.xml index 73d642d..20d01e1 100644 --- a/mobile/src/main/res/values/strings.xml +++ b/mobile/src/main/res/values/strings.xml @@ -49,6 +49,7 @@ Location and Mobile Data SimpleWear helper app required to toggle system settings SimpleWear helper app installed + SimpleWear helper app not up-to-date https://github.com/SimpleAppProjects/SimpleWear/wiki/SimpleWear-Settings-helper diff --git a/shared_resources/src/main/java/com/thewizrd/shared_resources/helpers/WearSettingsHelper.kt b/shared_resources/src/main/java/com/thewizrd/shared_resources/helpers/WearSettingsHelper.kt index 639a4bb..61e5a85 100644 --- a/shared_resources/src/main/java/com/thewizrd/shared_resources/helpers/WearSettingsHelper.kt +++ b/shared_resources/src/main/java/com/thewizrd/shared_resources/helpers/WearSettingsHelper.kt @@ -2,6 +2,7 @@ package com.thewizrd.shared_resources.helpers import android.content.ComponentName import android.content.pm.PackageManager +import android.os.Build import android.util.Log import com.thewizrd.shared_resources.BuildConfig import com.thewizrd.shared_resources.SimpleLibrary @@ -10,6 +11,7 @@ import com.thewizrd.shared_resources.utils.Logger object WearSettingsHelper { // Link to Play Store listing const val PACKAGE_NAME = "com.thewizrd.wearsettings" + private const val SUPPORTED_VERSION_CODE = 1010000 fun getPackageName(): String { var packageName = PACKAGE_NAME @@ -39,6 +41,20 @@ object WearSettingsHelper { } } + private fun getWearSettingsVersionCode(): Int { + val context = SimpleLibrary.instance.app.appContext + val packageInfo = context.packageManager.getPackageInfo(getPackageName(), 0) + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + packageInfo.longVersionCode.toInt() + } else { + packageInfo.versionCode + } + } + + fun isWearSettingsUpToDate(): Boolean { + return getWearSettingsVersionCode() >= SUPPORTED_VERSION_CODE + } + fun getSettingsServiceComponent(): ComponentName { return ComponentName(getPackageName(), "$PACKAGE_NAME.SettingsService") } diff --git a/wearsettings/build.gradle b/wearsettings/build.gradle index 8142138..c7e0363 100644 --- a/wearsettings/build.gradle +++ b/wearsettings/build.gradle @@ -38,6 +38,8 @@ android { } compileOptions { + // Flag to enable support for the new language APIs + coreLibraryDesugaringEnabled true // Sets Java compatibility to Java 8 sourceCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17 @@ -53,6 +55,8 @@ dependencies { implementation project(":shared_resources") compileOnly project(':hidden-api') + coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:$desugar_version" + // Unit Testing androidTestImplementation "androidx.test:core:$test_core_version" From 1864151c744bd372cfe05068599d4f043a93a1a1 Mon Sep 17 00:00:00 2001 From: Dave Antoine Date: Sun, 24 Mar 2024 17:39:04 -0400 Subject: [PATCH 15/58] wearsettings: update location action for shizuku --- .../android/content/pm/IPackageManager.java | 22 ++++++ .../android/location/ILocationManager.java | 20 +++++ .../wearsettings/actions/LocationAction.kt | 76 +++++++++++++++---- .../actions/SecureSettingsAction.kt | 4 +- .../wearsettings/shizuku/ShizukuOperations.kt | 27 +++++++ .../wearsettings/shizuku/ShizukuUtils.kt | 7 ++ 6 files changed, 140 insertions(+), 16 deletions(-) create mode 100644 hidden-api/src/main/java/android/content/pm/IPackageManager.java create mode 100644 hidden-api/src/main/java/android/location/ILocationManager.java create mode 100644 wearsettings/src/main/java/com/thewizrd/wearsettings/shizuku/ShizukuOperations.kt diff --git a/hidden-api/src/main/java/android/content/pm/IPackageManager.java b/hidden-api/src/main/java/android/content/pm/IPackageManager.java new file mode 100644 index 0000000..50c6bcb --- /dev/null +++ b/hidden-api/src/main/java/android/content/pm/IPackageManager.java @@ -0,0 +1,22 @@ +package android.content.pm; + +import android.os.Binder; +import android.os.Build; +import android.os.IBinder; +import android.os.IInterface; + +import androidx.annotation.DeprecatedSinceApi; + +public interface IPackageManager extends IInterface { + + void grantRuntimePermission(String packageName, String permissionName, int userId); + + @DeprecatedSinceApi(api = Build.VERSION_CODES.R) + void revokeRuntimePermission(String packageName, String permissionName, int userId); + + abstract class Stub extends Binder implements IPackageManager { + public static IPackageManager asInterface(IBinder obj) { + throw new RuntimeException("Stub!"); + } + } +} \ No newline at end of file diff --git a/hidden-api/src/main/java/android/location/ILocationManager.java b/hidden-api/src/main/java/android/location/ILocationManager.java new file mode 100644 index 0000000..97af98a --- /dev/null +++ b/hidden-api/src/main/java/android/location/ILocationManager.java @@ -0,0 +1,20 @@ +package android.location; + +import android.os.Binder; +import android.os.Build; +import android.os.IBinder; +import android.os.IInterface; + +import androidx.annotation.RequiresApi; + +public interface ILocationManager extends IInterface { + + @RequiresApi(api = Build.VERSION_CODES.P) + void setLocationEnabledForUser(boolean enabled, int userId); + + abstract class Stub extends Binder implements ILocationManager { + public static ILocationManager asInterface(IBinder obj) { + throw new RuntimeException("Stub!"); + } + } +} \ No newline at end of file diff --git a/wearsettings/src/main/java/com/thewizrd/wearsettings/actions/LocationAction.kt b/wearsettings/src/main/java/com/thewizrd/wearsettings/actions/LocationAction.kt index 22789a5..9e2eb75 100644 --- a/wearsettings/src/main/java/com/thewizrd/wearsettings/actions/LocationAction.kt +++ b/wearsettings/src/main/java/com/thewizrd/wearsettings/actions/LocationAction.kt @@ -3,10 +3,23 @@ package com.thewizrd.wearsettings.actions import android.Manifest import android.content.Context import android.content.pm.PackageManager +import android.location.ILocationManager +import android.os.Build import android.provider.Settings +import android.util.Log import androidx.core.content.ContextCompat -import com.thewizrd.shared_resources.actions.* +import com.thewizrd.shared_resources.actions.Action +import com.thewizrd.shared_resources.actions.ActionStatus +import com.thewizrd.shared_resources.actions.LocationState +import com.thewizrd.shared_resources.actions.MultiChoiceAction +import com.thewizrd.shared_resources.actions.ToggleAction +import com.thewizrd.shared_resources.utils.Logger import com.thewizrd.wearsettings.root.RootHelper +import com.thewizrd.wearsettings.shizuku.ShizukuUtils +import com.thewizrd.wearsettings.shizuku.grantSecureSettingsPermission +import rikka.shizuku.Shizuku +import rikka.shizuku.ShizukuBinderWrapper +import rikka.shizuku.SystemServiceHelper import com.thewizrd.wearsettings.Settings as SettingsHelper object LocationAction { @@ -16,26 +29,21 @@ object LocationAction { return if (checkSecureSettingsPermission(context)) { setLocationState(context, state) + } else if (Shizuku.pingBinder()) { + context.grantSecureSettingsPermission() + setLocationState(context, state) } else if (SettingsHelper.isRootAccessEnabled() && RootHelper.isRootEnabled()) { - SecureSettingsAction.putSetting( - Settings.Secure.LOCATION_MODE, - state.toSecureSettingsInt().toString() - ) + setLocationStateRoot(context, state) } else { ActionStatus.REMOTE_PERMISSION_DENIED } } else if (action is ToggleAction) { - return if (checkSecureSettingsPermission(context)) { + return if (Shizuku.pingBinder()) { + setLocationEnabledShizuku(context, action.isEnabled) + } else if (checkSecureSettingsPermission(context)) { setLocationEnabled(context, action.isEnabled) } else if (SettingsHelper.isRootAccessEnabled() && RootHelper.isRootEnabled()) { - SecureSettingsAction.putSetting( - Settings.Secure.LOCATION_MODE, - if (action.isEnabled) { - Settings.Secure.LOCATION_MODE_HIGH_ACCURACY - } else { - Settings.Secure.LOCATION_MODE_OFF - }.toString() - ) + setLocationEnabledRoot(context, action.isEnabled) } else { ActionStatus.REMOTE_PERMISSION_DENIED } @@ -78,6 +86,13 @@ object LocationAction { } } + private fun setLocationStateRoot(context: Context, state: LocationState): ActionStatus { + return SecureSettingsAction.putSettingRoot( + Settings.Secure.LOCATION_MODE, + state.toSecureSettingsInt().toString() + ) + } + private fun setLocationEnabled(context: Context, enable: Boolean): ActionStatus { return if (ContextCompat.checkSelfPermission( context, @@ -97,4 +112,37 @@ object LocationAction { ActionStatus.REMOTE_PERMISSION_DENIED } } + + private fun setLocationEnabledRoot(context: Context, enable: Boolean): ActionStatus { + return setLocationStateRoot( + context, + if (enable) { + LocationState.HIGH_ACCURACY + } else { + LocationState.OFF + } + ) + } + + private fun setLocationEnabledShizuku(context: Context, enable: Boolean): ActionStatus { + return runCatching { + val locationMgr = SystemServiceHelper.getSystemService(Context.LOCATION_SERVICE) + .let(::ShizukuBinderWrapper) + .let(ILocationManager.Stub::asInterface) + + val userId = ShizukuUtils.getUserId() + + val ret = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + locationMgr.setLocationEnabledForUser(enable, userId) + true + } else { + false + } + + if (ret) ActionStatus.SUCCESS else ActionStatus.REMOTE_FAILURE + }.getOrElse { + Logger.writeLine(Log.ERROR, it) + ActionStatus.REMOTE_FAILURE + } + } } \ No newline at end of file diff --git a/wearsettings/src/main/java/com/thewizrd/wearsettings/actions/SecureSettingsAction.kt b/wearsettings/src/main/java/com/thewizrd/wearsettings/actions/SecureSettingsAction.kt index 59c78b5..b9c1adc 100644 --- a/wearsettings/src/main/java/com/thewizrd/wearsettings/actions/SecureSettingsAction.kt +++ b/wearsettings/src/main/java/com/thewizrd/wearsettings/actions/SecureSettingsAction.kt @@ -4,7 +4,7 @@ import com.thewizrd.shared_resources.actions.ActionStatus import com.topjohnwu.superuser.Shell object SecureSettingsAction { - fun putSetting(key: String, value: String): ActionStatus { + fun putSettingRoot(key: String, value: String): ActionStatus { if (!Shell.rootAccess()) { return ActionStatus.REMOTE_PERMISSION_DENIED } @@ -20,7 +20,7 @@ object SecureSettingsAction { } object GlobalSettingsAction { - fun putSetting(key: String, value: String): ActionStatus { + fun putSettingRoot(key: String, value: String): ActionStatus { if (!Shell.rootAccess()) { return ActionStatus.REMOTE_PERMISSION_DENIED } diff --git a/wearsettings/src/main/java/com/thewizrd/wearsettings/shizuku/ShizukuOperations.kt b/wearsettings/src/main/java/com/thewizrd/wearsettings/shizuku/ShizukuOperations.kt new file mode 100644 index 0000000..4ac6fb0 --- /dev/null +++ b/wearsettings/src/main/java/com/thewizrd/wearsettings/shizuku/ShizukuOperations.kt @@ -0,0 +1,27 @@ +package com.thewizrd.wearsettings.shizuku + +import android.Manifest +import android.content.Context +import android.content.pm.IPackageManager +import rikka.shizuku.ShizukuBinderWrapper +import rikka.shizuku.SystemServiceHelper + +fun Context.grantPermissionThroughShizuku(permission: String): Boolean { + return try { + val packageMgr = SystemServiceHelper.getSystemService("package") + .let(::ShizukuBinderWrapper) + .let(IPackageManager.Stub::asInterface) + + val userId = ShizukuUtils.getUserId() + + packageMgr.grantRuntimePermission(packageName, permission, userId) + + true + } catch (e: IllegalStateException) { + false + } +} + +fun Context.grantSecureSettingsPermission(): Boolean { + return grantPermissionThroughShizuku(Manifest.permission.WRITE_SECURE_SETTINGS) +} \ No newline at end of file diff --git a/wearsettings/src/main/java/com/thewizrd/wearsettings/shizuku/ShizukuUtils.kt b/wearsettings/src/main/java/com/thewizrd/wearsettings/shizuku/ShizukuUtils.kt index a358a3c..331fef8 100644 --- a/wearsettings/src/main/java/com/thewizrd/wearsettings/shizuku/ShizukuUtils.kt +++ b/wearsettings/src/main/java/com/thewizrd/wearsettings/shizuku/ShizukuUtils.kt @@ -7,6 +7,7 @@ import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.net.Uri +import android.os.Process import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.fragment.app.Fragment @@ -110,6 +111,12 @@ object ShizukuUtils { private fun getPlayStoreWebURI(): Uri { return Uri.parse(PLAY_STORE_APP_WEBURI) } + + fun getUserId(): Int { + val isRoot = Shizuku.getUid() == 0 + + return if (isRoot) Process.myUserHandle().hashCode() else 0 + } } enum class ShizukuState { From b22344c431af8d23a4a7905b5605432582a3586f Mon Sep 17 00:00:00 2001 From: Dave Antoine Date: Sun, 24 Mar 2024 23:17:09 -0400 Subject: [PATCH 16/58] ValueActionActivity: migrate to compose --- wear/build.gradle | 1 + .../thewizrd/simplewear/DashboardActivity.kt | 4 +- .../simplewear/ValueActionActivity.kt | 696 +++++------------- .../ui/simplewear/ValueActionScreen.kt | 243 ++++++ .../viewmodels/ValueActionViewModel.kt | 297 ++++++++ 5 files changed, 712 insertions(+), 529 deletions(-) create mode 100644 wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/ValueActionScreen.kt create mode 100644 wear/src/main/java/com/thewizrd/simplewear/viewmodels/ValueActionViewModel.kt diff --git a/wear/build.gradle b/wear/build.gradle index 4038788..e8b7eb9 100644 --- a/wear/build.gradle +++ b/wear/build.gradle @@ -121,6 +121,7 @@ dependencies { implementation "com.google.accompanist:accompanist-flowlayout:$accompanist_version" implementation "com.google.accompanist:accompanist-swiperefresh:$accompanist_version" implementation "com.google.android.horologist:horologist-compose-layout:$horologist_version" + implementation "com.google.android.horologist:horologist-audio-ui:$horologist_version" androidTestImplementation "androidx.compose.ui:ui-test-junit4" debugImplementation "androidx.compose.ui:ui-tooling" diff --git a/wear/src/main/java/com/thewizrd/simplewear/DashboardActivity.kt b/wear/src/main/java/com/thewizrd/simplewear/DashboardActivity.kt index 624e60c..a15f14d 100644 --- a/wear/src/main/java/com/thewizrd/simplewear/DashboardActivity.kt +++ b/wear/src/main/java/com/thewizrd/simplewear/DashboardActivity.kt @@ -7,11 +7,11 @@ import android.content.SharedPreferences.OnSharedPreferenceChangeListener import android.os.Build import android.os.Bundle import android.widget.Toast +import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.viewModels import androidx.core.content.ContextCompat import androidx.core.content.PermissionChecker -import androidx.fragment.app.FragmentActivity import androidx.lifecycle.lifecycleScope import androidx.lifecycle.withStarted import androidx.preference.PreferenceManager @@ -40,7 +40,7 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.guava.await import kotlinx.coroutines.launch -class DashboardActivity : FragmentActivity(), OnSharedPreferenceChangeListener { +class DashboardActivity : ComponentActivity(), OnSharedPreferenceChangeListener { private val dashboardViewModel by viewModels() private lateinit var remoteActivityHelper: RemoteActivityHelper diff --git a/wear/src/main/java/com/thewizrd/simplewear/ValueActionActivity.kt b/wear/src/main/java/com/thewizrd/simplewear/ValueActionActivity.kt index 34f898d..32e65a5 100644 --- a/wear/src/main/java/com/thewizrd/simplewear/ValueActionActivity.kt +++ b/wear/src/main/java/com/thewizrd/simplewear/ValueActionActivity.kt @@ -1,286 +1,51 @@ package com.thewizrd.simplewear -import android.content.BroadcastReceiver -import android.content.Context import android.content.Intent -import android.content.IntentFilter -import android.os.Build import android.os.Bundle -import android.os.CountDownTimer -import android.util.Log -import android.view.MotionEvent -import android.view.ViewConfiguration +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.viewModels import androidx.core.content.ContextCompat -import androidx.core.view.InputDeviceCompat -import androidx.core.view.MotionEventCompat -import androidx.core.view.ViewConfigurationCompat import androidx.lifecycle.lifecycleScope -import androidx.localbroadcastmanager.content.LocalBroadcastManager -import com.google.android.gms.wearable.MessageEvent +import androidx.wear.remote.interactions.RemoteActivityHelper import com.thewizrd.shared_resources.actions.Action import com.thewizrd.shared_resources.actions.ActionStatus import com.thewizrd.shared_resources.actions.Actions -import com.thewizrd.shared_resources.actions.AudioStreamState import com.thewizrd.shared_resources.actions.AudioStreamType -import com.thewizrd.shared_resources.actions.ValueAction -import com.thewizrd.shared_resources.actions.ValueActionState -import com.thewizrd.shared_resources.actions.ValueDirection -import com.thewizrd.shared_resources.actions.VolumeAction import com.thewizrd.shared_resources.helpers.WearConnectionStatus import com.thewizrd.shared_resources.helpers.WearableHelper import com.thewizrd.shared_resources.utils.JSONParser -import com.thewizrd.shared_resources.utils.Logger -import com.thewizrd.shared_resources.utils.bytesToBool -import com.thewizrd.shared_resources.utils.bytesToString -import com.thewizrd.shared_resources.utils.intToBytes -import com.thewizrd.shared_resources.utils.stringToBytes import com.thewizrd.simplewear.controls.CustomConfirmationOverlay -import com.thewizrd.simplewear.databinding.ActivityValueactionBinding import com.thewizrd.simplewear.helpers.showConfirmationOverlay +import com.thewizrd.simplewear.ui.simplewear.ValueActionScreen +import com.thewizrd.simplewear.viewmodels.ValueActionViewModel +import com.thewizrd.simplewear.viewmodels.WearableListenerViewModel +import com.thewizrd.simplewear.viewmodels.WearableListenerViewModel.Companion.ACTION_UPDATECONNECTIONSTATUS +import com.thewizrd.simplewear.viewmodels.WearableListenerViewModel.Companion.EXTRA_ACTION +import com.thewizrd.simplewear.viewmodels.WearableListenerViewModel.Companion.EXTRA_ACTIONDATA +import com.thewizrd.simplewear.viewmodels.WearableListenerViewModel.Companion.EXTRA_STATUS import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.asCoroutineDispatcher -import kotlinx.coroutines.async -import kotlinx.coroutines.cancel import kotlinx.coroutines.guava.await -import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import java.util.concurrent.Executors -import kotlin.math.max -import kotlin.math.min -import kotlin.math.roundToInt -// TODO: Move volume actions into separate VolumeActionActivity -class ValueActionActivity : WearableListenerActivity() { +class ValueActionActivity : ComponentActivity() { companion object { const val EXTRA_STREAMTYPE = "SimpleWear.Droid.Wear.extra.STREAM_TYPE" } - override lateinit var broadcastReceiver: BroadcastReceiver - private set - override lateinit var intentFilter: IntentFilter - private set + private val valueActionViewModel by viewModels() - private lateinit var binding: ActivityValueactionBinding - - private var mAction: Actions? = null - private var mRemoteValue: Float? = null - - private var mValueActionState: ValueActionState? = null - private var mStreamType: AudioStreamType? = AudioStreamType.MUSIC - - private var timer: CountDownTimer? = null - - private val rsbScope = CoroutineScope( - SupervisorJob() + Executors.newSingleThreadExecutor().asCoroutineDispatcher() - ) - private var progressBarJob: Job? = null + private lateinit var remoteActivityHelper: RemoteActivityHelper override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - binding = ActivityValueactionBinding.inflate(layoutInflater) - setContentView(binding.root) - + remoteActivityHelper = RemoteActivityHelper(this) handleIntent(intent) - broadcastReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - lifecycleScope.launch { - if (intent.action != null) { - if (ACTION_UPDATECONNECTIONSTATUS == intent.action) { - when (WearConnectionStatus.valueOf( - intent.getIntExtra( - EXTRA_CONNECTIONSTATUS, - 0 - ) - )) { - WearConnectionStatus.DISCONNECTED -> { - // Navigate - startActivity( - Intent( - this@ValueActionActivity, - PhoneSyncActivity::class.java - ) - ) - finishAffinity() - } - WearConnectionStatus.APPNOTINSTALLED -> { - // Open store on remote device - val intentAndroid = Intent(Intent.ACTION_VIEW) - .addCategory(Intent.CATEGORY_BROWSABLE) - .setData(WearableHelper.getPlayStoreURI()) - - runCatching { - remoteActivityHelper.startRemoteActivity(intentAndroid) - .await() - - showConfirmationOverlay(true) - }.onFailure { - if (it !is CancellationException) { - showConfirmationOverlay(false) - } - } - - // Navigate - startActivity( - Intent( - this@ValueActionActivity, - PhoneSyncActivity::class.java - ) - ) - finishAffinity() - } - - else -> {} - } - } else if (WearableHelper.ActionsPath == intent.action) { - timer?.cancel() - - val jsonData = intent.getStringExtra(EXTRA_ACTIONDATA) - val action = JSONParser.deserializer(jsonData, Action::class.java) - val actionSuccessful = action?.isActionSuccessful ?: false - val actionStatus = action?.actionStatus ?: ActionStatus.UNKNOWN - - if (!actionSuccessful) { - lifecycleScope.launch { - when (actionStatus) { - ActionStatus.UNKNOWN, ActionStatus.FAILURE -> { - CustomConfirmationOverlay() - .setType(CustomConfirmationOverlay.CUSTOM_ANIMATION) - .setCustomDrawable( - ContextCompat.getDrawable( - this@ValueActionActivity, - R.drawable.ws_full_sad - ) - ) - .setMessage(getString(R.string.error_actionfailed)) - .showOn(this@ValueActionActivity) - } - ActionStatus.PERMISSION_DENIED -> { - CustomConfirmationOverlay() - .setType(CustomConfirmationOverlay.CUSTOM_ANIMATION) - .setCustomDrawable( - ContextCompat.getDrawable( - this@ValueActionActivity, - R.drawable.ws_full_sad - ) - ) - .setMessage(getString(R.string.error_permissiondenied)) - .showOn(this@ValueActionActivity) - - openAppOnPhone(false) - } - ActionStatus.TIMEOUT -> { - CustomConfirmationOverlay() - .setType(CustomConfirmationOverlay.CUSTOM_ANIMATION) - .setCustomDrawable( - ContextCompat.getDrawable( - this@ValueActionActivity, - R.drawable.ws_full_sad - ) - ) - .setMessage(getString(R.string.error_sendmessage)) - .showOn(this@ValueActionActivity) - } - - ActionStatus.SUCCESS -> {} - else -> {} - } - } - } - } else if (ACTION_CHANGED == intent.action) { - val jsonData = intent.getStringExtra(EXTRA_ACTIONDATA) - val action = JSONParser.deserializer(jsonData, Action::class.java) - requestAction(jsonData) - - lifecycleScope.launch { - timer?.cancel() - timer = object : CountDownTimer(3000, 500) { - override fun onTick(millisUntilFinished: Long) {} - override fun onFinish() { - action!!.setActionSuccessful(ActionStatus.TIMEOUT) - LocalBroadcastManager.getInstance(this@ValueActionActivity) - .sendBroadcast( - Intent(WearableHelper.ActionsPath) - .putExtra( - EXTRA_ACTIONDATA, - JSONParser.serializer( - action, - Action::class.java - ) - ) - ) - } - } - timer!!.start() - } - } else { - Logger.writeLine( - Log.INFO, - "%s: Unhandled action: %s", - "ValueActionActivity", - intent.action - ) - } - } - } - } + setContent { + ValueActionScreen() } - - binding.increaseBtn.setOnClickListener { - val actionData = if (mAction == Actions.VOLUME && mStreamType != null) { - VolumeAction(ValueDirection.UP, mStreamType) - } else { - ValueAction(mAction!!, ValueDirection.UP) - } - LocalBroadcastManager.getInstance(this@ValueActionActivity) - .sendBroadcast( - Intent(ACTION_CHANGED) - .putExtra( - EXTRA_ACTIONDATA, - JSONParser.serializer(actionData, Action::class.java) - ) - ) - } - binding.decreaseBtn.setOnClickListener { - val actionData = if (mAction == Actions.VOLUME && mStreamType != null) { - VolumeAction(ValueDirection.DOWN, mStreamType) - } else { - ValueAction(mAction!!, ValueDirection.DOWN) - } - LocalBroadcastManager.getInstance(this@ValueActionActivity) - .sendBroadcast( - Intent(ACTION_CHANGED) - .putExtra( - EXTRA_ACTIONDATA, - JSONParser.serializer(actionData, Action::class.java) - ) - ) - } - binding.actionIcon.setOnClickListener { - if (mStreamType != null && mAction == Actions.VOLUME) { - val maxStates = AudioStreamType.entries.size - var newValue = (mStreamType!!.value + 1) % maxStates - if (newValue < 0) newValue += maxStates - mStreamType = AudioStreamType.valueOf(newValue) - - lifecycleScope.launch { - requestAudioStreamState() - } - } else if (mAction == Actions.BRIGHTNESS) { - lifecycleScope.launch { - requestToggleAutoBrightness() - } - } - } - - intentFilter = IntentFilter() - intentFilter.addAction(ACTION_UPDATECONNECTIONSTATUS) - intentFilter.addAction(ACTION_CHANGED) - intentFilter.addAction(WearableHelper.ActionsPath) } override fun onNewIntent(intent: Intent?) { @@ -293,18 +58,15 @@ class ValueActionActivity : WearableListenerActivity() { if (intent == null) return if (intent.hasExtra(EXTRA_ACTION)) { - mAction = intent.getSerializableExtra(EXTRA_ACTION) as Actions - when (mAction) { - Actions.VOLUME -> { - if (intent.hasExtra(EXTRA_STREAMTYPE)) { - mStreamType = - intent.getSerializableExtra(EXTRA_STREAMTYPE) as? AudioStreamType - ?: AudioStreamType.MUSIC - } + val action = intent.getSerializableExtra(EXTRA_ACTION) as Actions + + when (action) { + Actions.VOLUME -> { /* Valid action */ } - Actions.BRIGHTNESS -> { - // Valid action + + Actions.BRIGHTNESS -> { /* Valid action */ } + else -> { // Not a ValueAction setResult(RESULT_CANCELED) @@ -313,305 +75,185 @@ class ValueActionActivity : WearableListenerActivity() { } } - updateActionView() - } - } - - private fun updateActionView() { - when (mAction) { - Actions.VOLUME -> { - binding.actionIcon.setImageResource(R.drawable.ic_volume_up_white_24dp) - binding.actionTitle.setText(R.string.action_volume) - } + if (action == Actions.VOLUME && intent.hasExtra(EXTRA_STREAMTYPE)) { + val streamType = intent.getSerializableExtra(EXTRA_STREAMTYPE) as? AudioStreamType + ?: AudioStreamType.MUSIC - Actions.BRIGHTNESS -> { - binding.actionIcon.setImageResource(R.drawable.ic_brightness_medium) - binding.actionTitle.setText(R.string.action_brightness) + valueActionViewModel.onActionUpdated(action, streamType) + } else { + valueActionViewModel.onActionUpdated(action) } - - else -> {} } } - override fun onMessageReceived(messageEvent: MessageEvent) { - super.onMessageReceived(messageEvent) + override fun onStart() { + super.onStart() + + valueActionViewModel.initActivityContext(this) lifecycleScope.launch { - if (messageEvent.path == WearableHelper.AudioStatusPath && mAction == Actions.VOLUME) { - progressBarJob?.cancel() - progressBarJob = async { - if (!isActive) return@async + valueActionViewModel.eventFlow.collect { event -> + when (event.eventType) { + ACTION_UPDATECONNECTIONSTATUS -> { + val connectionStatus = WearConnectionStatus.valueOf( + event.data.getInt( + WearableListenerViewModel.EXTRA_CONNECTIONSTATUS, + 0 + ) + ) - val status = messageEvent.data?.let { - JSONParser.deserializer(it.bytesToString(), AudioStreamState::class.java) - } - mValueActionState = status - - if (!isActive) return@async - - if (status == null) { - mStreamType = null - binding.actionIcon.setImageResource(R.drawable.ic_volume_up_white_24dp) - } else { - mStreamType = status.streamType - when (status.streamType) { - AudioStreamType.MUSIC -> binding.actionIcon.setImageResource(R.drawable.ic_music_note_white_24dp) - AudioStreamType.RINGTONE -> binding.actionIcon.setImageResource(R.drawable.ic_baseline_ring_volume_24dp) - AudioStreamType.VOICE_CALL -> binding.actionIcon.setImageResource(R.drawable.ic_baseline_call_24dp) - AudioStreamType.ALARM -> binding.actionIcon.setImageResource(R.drawable.ic_alarm_white_24dp) - } - } + when (connectionStatus) { + WearConnectionStatus.DISCONNECTED -> { + // Navigate + startActivity( + Intent( + this@ValueActionActivity, + PhoneSyncActivity::class.java + ) + ) + finishAffinity() + } - updateProgressBar(status) - } - } else if (messageEvent.path == WearableHelper.ValueStatusPath) { - progressBarJob?.cancel() - progressBarJob = async { - if (!isActive) return@async + WearConnectionStatus.APPNOTINSTALLED -> { + // Open store on remote device + val intentAndroid = Intent(Intent.ACTION_VIEW) + .addCategory(Intent.CATEGORY_BROWSABLE) + .setData(WearableHelper.getPlayStoreURI()) - val status = messageEvent.data?.let { - JSONParser.deserializer(it.bytesToString(), ValueActionState::class.java) - } - mValueActionState = status + runCatching { + remoteActivityHelper.startRemoteActivity(intentAndroid) + .await() - if (!isActive) return@async + showConfirmationOverlay(true) + }.onFailure { + if (it !is CancellationException) { + showConfirmationOverlay(false) + } + } - updateProgressBar(status) - } - } else if (messageEvent.path == WearableHelper.BrightnessModePath && mAction == Actions.BRIGHTNESS) { - val enabled = messageEvent.data.bytesToBool() - if (enabled) { - binding.actionIcon.setImageResource(R.drawable.ic_brightness_auto) - } else { - binding.actionIcon.setImageResource(R.drawable.ic_brightness_medium) - } - } else if (messageEvent.path == WearableHelper.AudioVolumePath || messageEvent.path == WearableHelper.ValueStatusSetPath) { - val status = ActionStatus.valueOf(messageEvent.data.bytesToString()) - - when (status) { - ActionStatus.UNKNOWN, ActionStatus.FAILURE -> { - CustomConfirmationOverlay() - .setType(CustomConfirmationOverlay.CUSTOM_ANIMATION) - .setCustomDrawable( - ContextCompat.getDrawable( - this@ValueActionActivity, - R.drawable.ws_full_sad - ) - ) - .setMessage(getString(R.string.error_actionfailed)) - .showOn(this@ValueActionActivity) - } - ActionStatus.PERMISSION_DENIED -> { - CustomConfirmationOverlay() - .setType(CustomConfirmationOverlay.CUSTOM_ANIMATION) - .setCustomDrawable( - ContextCompat.getDrawable( - this@ValueActionActivity, - R.drawable.ws_full_sad + // Navigate + startActivity( + Intent( + this@ValueActionActivity, + PhoneSyncActivity::class.java + ) ) - ) - .setMessage(getString(R.string.error_permissiondenied)) - .showOn(this@ValueActionActivity) + finishAffinity() + } - openAppOnPhone(false) + else -> {} + } } - else -> {} - } - } - } - } - - override fun onResume() { - super.onResume() + WearableHelper.ActionsPath -> { + val jsonData = event.data.getString(EXTRA_ACTIONDATA) + val action = JSONParser.deserializer(jsonData, Action::class.java) - // Update statuses - lifecycleScope.launch { - updateConnectionStatus() - when (mAction) { - Actions.VOLUME -> { - requestAudioStreamState() - } - else -> { - requestValueState() - } - } - } - } + val actionSuccessful = action?.isActionSuccessful ?: false + val actionStatus = action?.actionStatus ?: ActionStatus.UNKNOWN - override fun onDestroy() { - rsbScope.cancel() - super.onDestroy() - } + if (!actionSuccessful) { + lifecycleScope.launch { + when (actionStatus) { + ActionStatus.UNKNOWN, ActionStatus.FAILURE -> { + CustomConfirmationOverlay() + .setType(CustomConfirmationOverlay.CUSTOM_ANIMATION) + .setCustomDrawable( + ContextCompat.getDrawable( + this@ValueActionActivity, + R.drawable.ws_full_sad + ) + ) + .setMessage(getString(R.string.error_actionfailed)) + .showOn(this@ValueActionActivity) + } - private suspend fun requestAudioStreamState() { - if (connect()) { - sendMessage( - mPhoneNodeWithApp!!.id, - WearableHelper.AudioStatusPath, - mStreamType?.name?.stringToBytes() - ) - } - } + ActionStatus.PERMISSION_DENIED -> { + CustomConfirmationOverlay() + .setType(CustomConfirmationOverlay.CUSTOM_ANIMATION) + .setCustomDrawable( + ContextCompat.getDrawable( + this@ValueActionActivity, + R.drawable.ws_full_sad + ) + ) + .setMessage(getString(R.string.error_permissiondenied)) + .showOn(this@ValueActionActivity) - private suspend fun requestValueState() { - if (connect()) { - sendMessage( - mPhoneNodeWithApp!!.id, - WearableHelper.ValueStatusPath, - mAction?.value?.intToBytes() - ) - } - } + valueActionViewModel.openAppOnPhone( + this@ValueActionActivity, + false + ) + } - private fun requestSetVolume(value: Int) { - rsbScope.launch { - if (connect()) { - sendMessage(mPhoneNodeWithApp!!.id, WearableHelper.AudioVolumePath, - (mValueActionState as? AudioStreamState)?.let { - JSONParser.serializer( - AudioStreamState(value, it.minVolume, it.maxVolume, it.streamType), - AudioStreamState::class.java - ).stringToBytes() - } - ) - } - } - } + ActionStatus.TIMEOUT -> { + CustomConfirmationOverlay() + .setType(CustomConfirmationOverlay.CUSTOM_ANIMATION) + .setCustomDrawable( + ContextCompat.getDrawable( + this@ValueActionActivity, + R.drawable.ws_full_sad + ) + ) + .setMessage(getString(R.string.error_sendmessage)) + .showOn(this@ValueActionActivity) + } - private fun requestSetValue(value: Int) { - rsbScope.launch { - if (connect()) { - sendMessage(mPhoneNodeWithApp!!.id, WearableHelper.ValueStatusSetPath, - mValueActionState?.let { - JSONParser.serializer( - ValueActionState(value, it.minValue, it.maxValue, it.actionType), - ValueActionState::class.java - ).stringToBytes() + ActionStatus.SUCCESS -> {} + else -> {} + } + } + } } - ) - } - } - } - private suspend fun requestToggleAutoBrightness() { - if (connect()) { - sendMessage( - mPhoneNodeWithApp!!.id, - WearableHelper.BrightnessModePath, - null - ) - } - } + WearableHelper.AudioVolumePath, WearableHelper.ValueStatusSetPath -> { + val status = event.data.getSerializable(EXTRA_STATUS) as ActionStatus - override fun onGenericMotionEvent(event: MotionEvent): Boolean { - if (event.action == MotionEvent.ACTION_SCROLL && - event.isFromSource(InputDeviceCompat.SOURCE_ROTARY_ENCODER) - ) { - val scaleFactor = ViewConfigurationCompat.getScaledVerticalScrollFactor( - ViewConfiguration.get(this), this - ) - // Don't forget the negation here - val delta = -event.getAxisValue(MotionEventCompat.AXIS_SCROLL) * scaleFactor - - // Scaling to (25 * scaleFactor) seems to be good - // On emulator = ~2400 - val scaleMax = 25 * scaleFactor - val valueState = mValueActionState - - if (valueState != null) { - val maxValue = - scaleValueFromState(valueState.maxValue.toFloat(), valueState, 0f, scaleMax) - val currValue = mRemoteValue ?: scaleValueFromState( - valueState.currentValue.toFloat(), - valueState, - 0f, - scaleMax - ) - val minValue = - scaleValueFromState(valueState.minValue.toFloat(), valueState, 0f, scaleMax) - - val scaledValue = currValue + delta - mRemoteValue = normalize(scaledValue, 0f, scaleMax) - - val scaledDownValue = scaleValueToState(scaledValue, 0f, scaleMax, valueState).run { - if (!this.isNaN()) this else 0f - } - val value = normalizeToState(scaledDownValue.roundToInt(), valueState) - - if (BuildConfig.DEBUG) { - Log.d( - "ValueActionScroller", - "currVal = ${(currValue).roundToInt()}, " + - "maxVal = ${(maxValue).roundToInt()}, " + - "minVal = ${(minValue).roundToInt()}, " + - "delta = $delta, " + - "scaleFactor = $scaleFactor, " + - "scaledValue = $scaledValue, " + - "setValue = $value" - ) - } + when (status) { + ActionStatus.UNKNOWN, ActionStatus.FAILURE -> { + CustomConfirmationOverlay() + .setType(CustomConfirmationOverlay.CUSTOM_ANIMATION) + .setCustomDrawable( + ContextCompat.getDrawable( + this@ValueActionActivity, + R.drawable.ws_full_sad + ) + ) + .setMessage(getString(R.string.error_actionfailed)) + .showOn(this@ValueActionActivity) + } - if (valueState is AudioStreamState) { - requestSetVolume(value) - } else { - requestSetValue(value) - } - } - } + ActionStatus.PERMISSION_DENIED -> { + CustomConfirmationOverlay() + .setType(CustomConfirmationOverlay.CUSTOM_ANIMATION) + .setCustomDrawable( + ContextCompat.getDrawable( + this@ValueActionActivity, + R.drawable.ws_full_sad + ) + ) + .setMessage(getString(R.string.error_permissiondenied)) + .showOn(this@ValueActionActivity) - return super.onGenericMotionEvent(event) - } + valueActionViewModel.openAppOnPhone(this@ValueActionActivity, false) + } - private fun updateProgressBar(state: ValueActionState?) { - if (state == null) { - binding.actionValueProgress.progress = 0 - } else { - binding.actionValueProgress.max = state.maxValue - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - binding.actionValueProgress.min = state.minValue + else -> {} + } + } + } } - binding.actionValueProgress.progress = state.currentValue } } - private fun scaleValue( - value: Float, - minValue: Float, - maxValue: Float, - scaleMin: Float, - scaleMax: Float - ): Float { - return ((value - minValue) / (maxValue - minValue)) * (scaleMax - scaleMin) + scaleMin - } - - private fun scaleValueFromState( - value: Float, - state: ValueActionState, - scaleMin: Float, - scaleMax: Float - ): Float { - return ((value - state.minValue) / (state.maxValue - state.minValue)) * (scaleMax - scaleMin) + scaleMin - } - - private fun scaleValueToState( - value: Float, - minValue: Float, - maxValue: Float, - state: ValueActionState - ): Float { - return ((value - minValue) / (maxValue - minValue)) * (state.maxValue - state.minValue) + state.minValue - } - - private fun normalize(value: Int, minValue: Int, maxValue: Int): Int { - return min(maxValue, max(value, minValue)) - } + override fun onResume() { + super.onResume() - private fun normalize(value: Float, minValue: Float, maxValue: Float): Float { - return min(maxValue, max(value, minValue)) + // Update statuses + valueActionViewModel.refreshState() } - private fun normalizeToState(value: Int, state: ValueActionState): Int { - return normalize(value, state.minValue, state.maxValue) + override fun onDestroy() { + super.onDestroy() } } \ No newline at end of file diff --git a/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/ValueActionScreen.kt b/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/ValueActionScreen.kt new file mode 100644 index 0000000..9d98c0b --- /dev/null +++ b/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/ValueActionScreen.kt @@ -0,0 +1,243 @@ +package com.thewizrd.simplewear.ui.simplewear + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.wear.compose.material.Chip +import androidx.wear.compose.material.ChipDefaults +import androidx.wear.compose.material.Icon +import androidx.wear.compose.material.MaterialTheme +import androidx.wear.compose.material.PositionIndicator +import androidx.wear.compose.material.Scaffold +import androidx.wear.compose.material.Stepper +import androidx.wear.compose.material.Text +import androidx.wear.compose.material.Vignette +import androidx.wear.compose.material.VignettePosition +import androidx.wear.compose.ui.tooling.preview.WearPreviewDevices +import com.google.android.horologist.annotations.ExperimentalHorologistApi +import com.google.android.horologist.audio.ui.VolumeUiState +import com.google.android.horologist.audio.ui.rotaryVolumeControlsWithFocus +import com.google.android.horologist.compose.rotaryinput.RotaryDefaults +import com.thewizrd.shared_resources.actions.Actions +import com.thewizrd.shared_resources.actions.AudioStreamType +import com.thewizrd.shared_resources.actions.ValueActionState +import com.thewizrd.shared_resources.helpers.WearConnectionStatus +import com.thewizrd.simplewear.R +import com.thewizrd.simplewear.ui.theme.WearAppTheme +import com.thewizrd.simplewear.ui.theme.activityViewModel +import com.thewizrd.simplewear.ui.theme.findActivity +import com.thewizrd.simplewear.viewmodels.ValueActionUiState +import com.thewizrd.simplewear.viewmodels.ValueActionViewModel +import kotlinx.coroutines.flow.map + +@Composable +fun ValueActionScreen( + modifier: Modifier = Modifier +) { + val valueActionViewModel = activityViewModel() + + WearAppTheme { + Scaffold( + modifier = modifier.background(MaterialTheme.colors.background), + vignette = { Vignette(vignettePosition = VignettePosition.TopAndBottom) }, + ) { + ValueActionScreen(valueActionViewModel) + } + } +} + +@OptIn(ExperimentalHorologistApi::class) +@Composable +fun ValueActionScreen( + valueActionViewModel: ValueActionViewModel +) { + val lifecycleOwner = LocalLifecycleOwner.current + val activityCtx = LocalContext.current.findActivity() + + val uiState by valueActionViewModel.uiState.collectAsState() + val progressUiState by valueActionViewModel.uiState.map { + VolumeUiState( + current = it.valueActionState?.currentValue ?: 0, + min = it.valueActionState?.minValue ?: 0, + max = it.valueActionState?.maxValue ?: 1 + ) + }.collectAsState(VolumeUiState()) + + ValueActionScreen( + modifier = Modifier.rotaryVolumeControlsWithFocus( + volumeUiStateProvider = { + progressUiState + }, + onRotaryVolumeInput = { + if (it > (uiState.valueActionState?.currentValue ?: 0)) { + valueActionViewModel.increaseValue() + } else { + valueActionViewModel.decreaseValue() + } + }, + localView = LocalView.current, + isLowRes = RotaryDefaults.isLowResInput() + ), + uiState = uiState, + onValueChanged = { + if (it > (uiState.valueActionState?.currentValue ?: 0)) { + valueActionViewModel.increaseValue() + } else { + valueActionViewModel.decreaseValue() + } + }, + onActionChange = { + valueActionViewModel.requestActionChange() + } + ) +} + +@Composable +fun ValueActionScreen( + modifier: Modifier = Modifier, + uiState: ValueActionUiState, + onValueChanged: (Int) -> Unit = {}, + onActionChange: () -> Unit = {} +) { + Box(modifier = modifier.fillMaxSize()) + Stepper( + value = uiState.valueActionState?.currentValue ?: 0, + onValueChange = onValueChanged, + valueProgression = IntProgression.fromClosedRange( + rangeStart = uiState.valueActionState?.minValue ?: 0, + rangeEnd = uiState.valueActionState?.maxValue ?: 100, + step = 1 + ), + increaseIcon = { + if (uiState.action == Actions.VOLUME) { + Icon( + painter = painterResource(id = R.drawable.ic_volume_up_white_24dp), + contentDescription = stringResource(id = R.string.horologist_stepper_increase_content_description) + ) + } else { + Icon( + painter = painterResource(id = R.drawable.ic_add_white_24dp), + contentDescription = stringResource(id = R.string.horologist_stepper_increase_content_description) + ) + } + }, + decreaseIcon = { + if (uiState.action == Actions.VOLUME) { + Icon( + painter = painterResource(id = R.drawable.ic_baseline_volume_down_24), + contentDescription = stringResource(id = R.string.horologist_stepper_decrease_content_description) + ) + } else { + Icon( + painter = painterResource(id = R.drawable.ic_remove_white_24dp), + contentDescription = stringResource(id = R.string.horologist_stepper_decrease_content_description) + ) + } + } + ) { + Chip( + label = { + when (uiState.action) { + Actions.VOLUME -> { + Text( + text = stringResource(id = R.string.action_volume), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + Actions.BRIGHTNESS -> { + Text( + text = stringResource(id = R.string.action_brightness), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + else -> { + Text( + text = stringResource(id = R.string.title_actions), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + }, + icon = { + when (uiState.action) { + Actions.VOLUME -> { + Icon( + painter = painterResource( + id = when (uiState.streamType) { + AudioStreamType.MUSIC -> R.drawable.ic_music_note_white_24dp + AudioStreamType.RINGTONE -> R.drawable.ic_baseline_ring_volume_24dp + AudioStreamType.VOICE_CALL -> R.drawable.ic_baseline_call_24dp + AudioStreamType.ALARM -> R.drawable.ic_alarm_white_24dp + null -> R.drawable.ic_volume_up_white_24dp + } + ), + contentDescription = stringResource(id = R.string.action_volume) + ) + } + + Actions.BRIGHTNESS -> { + Icon( + painter = painterResource( + id = if (uiState.isAutoBrightnessEnabled) { + R.drawable.ic_brightness_auto + } else { + R.drawable.ic_brightness_medium + } + ), + contentDescription = stringResource(id = R.string.action_brightness) + ) + } + + else -> { + Icon( + painter = painterResource(id = R.drawable.ic_icon), + contentDescription = null + ) + } + } + }, + colors = ChipDefaults.secondaryChipColors(), + onClick = onActionChange + ) + } + PositionIndicator( + value = { + uiState.valueActionState?.currentValue?.toFloat() ?: 0f + }, + range = (uiState.valueActionState?.minValue?.toFloat() + ?: 0f)..(uiState.valueActionState?.maxValue?.toFloat() ?: 1f), + color = MaterialTheme.colors.primary + ) +} + +@WearPreviewDevices +@Composable +private fun PreviewValueActionScreen() { + val state = remember { + ValueActionUiState( + connectionStatus = WearConnectionStatus.CONNECTED, + action = Actions.VOLUME, + valueActionState = ValueActionState(50, 0, 100, Actions.VOLUME) + ) + } + + ValueActionScreen( + uiState = state + ) +} \ No newline at end of file diff --git a/wear/src/main/java/com/thewizrd/simplewear/viewmodels/ValueActionViewModel.kt b/wear/src/main/java/com/thewizrd/simplewear/viewmodels/ValueActionViewModel.kt new file mode 100644 index 0000000..0b12c41 --- /dev/null +++ b/wear/src/main/java/com/thewizrd/simplewear/viewmodels/ValueActionViewModel.kt @@ -0,0 +1,297 @@ +package com.thewizrd.simplewear.viewmodels + +import android.app.Application +import android.os.Bundle +import android.os.CountDownTimer +import androidx.lifecycle.viewModelScope +import com.google.android.gms.wearable.MessageEvent +import com.thewizrd.shared_resources.actions.Action +import com.thewizrd.shared_resources.actions.ActionStatus +import com.thewizrd.shared_resources.actions.Actions +import com.thewizrd.shared_resources.actions.AudioStreamState +import com.thewizrd.shared_resources.actions.AudioStreamType +import com.thewizrd.shared_resources.actions.ValueAction +import com.thewizrd.shared_resources.actions.ValueActionState +import com.thewizrd.shared_resources.actions.ValueDirection +import com.thewizrd.shared_resources.actions.VolumeAction +import com.thewizrd.shared_resources.helpers.WearConnectionStatus +import com.thewizrd.shared_resources.helpers.WearableHelper +import com.thewizrd.shared_resources.utils.JSONParser +import com.thewizrd.shared_resources.utils.bytesToBool +import com.thewizrd.shared_resources.utils.bytesToString +import com.thewizrd.shared_resources.utils.intToBytes +import com.thewizrd.shared_resources.utils.stringToBytes +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class ValueActionUiState( + val connectionStatus: WearConnectionStatus? = null, + val action: Actions? = null, + val remoteValue: Float? = null, + val valueActionState: ValueActionState? = null, + val streamType: AudioStreamType? = AudioStreamType.MUSIC, + val isAutoBrightnessEnabled: Boolean = true +) + +class ValueActionViewModel(app: Application) : WearableListenerViewModel(app) { + private val viewModelState = MutableStateFlow(ValueActionUiState()) + + private var timer: CountDownTimer? = null + + val uiState = viewModelState.stateIn( + viewModelScope, + SharingStarted.Eagerly, + viewModelState.value + ) + + init { + viewModelScope.launch { + eventFlow.collect { event -> + when (event.eventType) { + ACTION_UPDATECONNECTIONSTATUS -> { + val connectionStatus = WearConnectionStatus.valueOf( + event.data.getInt( + EXTRA_CONNECTIONSTATUS, + 0 + ) + ) + + viewModelState.update { + it.copy( + connectionStatus = connectionStatus + ) + } + } + + WearableHelper.ActionsPath -> { + timer?.cancel() + } + + ACTION_CHANGED -> { + val jsonData = event.data.getString(EXTRA_ACTIONDATA) + val action = JSONParser.deserializer(jsonData, Action::class.java)!! + + requestAction(jsonData) + + viewModelScope.launch { + timer?.cancel() + timer = object : CountDownTimer(3000, 500) { + override fun onTick(millisUntilFinished: Long) {} + + override fun onFinish() { + action.setActionSuccessful(ActionStatus.TIMEOUT) + _eventsFlow.tryEmit( + WearableEvent( + WearableHelper.ActionsPath, + Bundle().apply { + putString( + EXTRA_ACTIONDATA, + JSONParser.serializer( + action, + Action::class.java + ) + ) + } + ) + ) + } + } + timer!!.start() + } + } + } + } + } + } + + fun refreshState() { + viewModelScope.launch { + updateConnectionStatus() + when (uiState.value.action) { + Actions.VOLUME -> { + requestAudioStreamState() + } + + else -> { + requestValueState() + } + } + } + } + + fun onActionUpdated(action: Actions, streamType: AudioStreamType? = null) { + viewModelState.update { + it.copy( + action = action, + streamType = streamType ?: it.streamType + ) + } + } + + fun increaseValue() { + val state = uiState.value + + val actionData = if (state.action == Actions.VOLUME && state.streamType != null) { + VolumeAction(ValueDirection.UP, state.streamType) + } else { + ValueAction(state.action!!, ValueDirection.UP) + } + + _eventsFlow.tryEmit(WearableEvent(ACTION_CHANGED, Bundle().apply { + putString(EXTRA_ACTIONDATA, JSONParser.serializer(actionData, Action::class.java)) + })) + } + + fun decreaseValue() { + val state = uiState.value + + val actionData = if (state.action == Actions.VOLUME && state.streamType != null) { + VolumeAction(ValueDirection.DOWN, state.streamType) + } else { + ValueAction(state.action!!, ValueDirection.DOWN) + } + + _eventsFlow.tryEmit(WearableEvent(ACTION_CHANGED, Bundle().apply { + putString(EXTRA_ACTIONDATA, JSONParser.serializer(actionData, Action::class.java)) + })) + } + + fun requestActionChange() { + val state = uiState.value + + if (state.streamType != null && state.action == Actions.VOLUME) { + viewModelState.update { + val maxStates = AudioStreamType.entries.size + var newValue = (state.streamType.value + 1) % maxStates + if (newValue < 0) newValue += maxStates + + it.copy(streamType = AudioStreamType.valueOf(newValue)) + } + + viewModelScope.launch { + requestAudioStreamState() + } + } else if (state.action == Actions.BRIGHTNESS) { + viewModelScope.launch { + requestToggleAutoBrightness() + } + } + } + + private suspend fun requestAudioStreamState() { + val state = uiState.value + + if (connect()) { + sendMessage( + mPhoneNodeWithApp!!.id, + WearableHelper.AudioStatusPath, + state.streamType?.name?.stringToBytes() + ) + } + } + + private suspend fun requestValueState() { + val state = uiState.value + + if (connect()) { + sendMessage( + mPhoneNodeWithApp!!.id, + WearableHelper.ValueStatusPath, + state.action?.value?.intToBytes() + ) + } + } + + private suspend fun requestSetVolume(value: Int) { + val state = uiState.value + + if (connect()) { + sendMessage(mPhoneNodeWithApp!!.id, WearableHelper.AudioVolumePath, + (state.valueActionState as? AudioStreamState)?.let { + JSONParser.serializer( + AudioStreamState(value, it.minVolume, it.maxVolume, it.streamType), + AudioStreamState::class.java + ).stringToBytes() + } + ) + } + } + + private suspend fun requestSetValue(value: Int) { + val state = uiState.value + + if (connect()) { + sendMessage(mPhoneNodeWithApp!!.id, WearableHelper.ValueStatusSetPath, + state.valueActionState?.let { + JSONParser.serializer( + ValueActionState(value, it.minValue, it.maxValue, it.actionType), + ValueActionState::class.java + ).stringToBytes() + } + ) + } + } + + private suspend fun requestToggleAutoBrightness() { + if (connect()) { + sendMessage( + mPhoneNodeWithApp!!.id, + WearableHelper.BrightnessModePath, + null + ) + } + } + + override fun onMessageReceived(messageEvent: MessageEvent) { + val state = uiState.value + + if (messageEvent.path == WearableHelper.AudioStatusPath && state.action == Actions.VOLUME) { + viewModelScope.launch { + val status = messageEvent.data?.let { + JSONParser.deserializer(it.bytesToString(), AudioStreamState::class.java) + } + + viewModelState.update { + it.copy( + valueActionState = status, + streamType = status?.streamType + ) + } + } + } else if (messageEvent.path == WearableHelper.ValueStatusPath) { + viewModelScope.launch { + val status = messageEvent.data?.let { + JSONParser.deserializer(it.bytesToString(), ValueActionState::class.java) + } + + viewModelState.update { + it.copy( + valueActionState = status + ) + } + } + } else if (messageEvent.path == WearableHelper.BrightnessModePath && state.action == Actions.BRIGHTNESS) { + viewModelScope.launch { + val enabled = messageEvent.data.bytesToBool() + viewModelState.update { + it.copy( + isAutoBrightnessEnabled = enabled + ) + } + } + } else if (messageEvent.path == WearableHelper.AudioVolumePath || messageEvent.path == WearableHelper.ValueStatusSetPath) { + viewModelScope.launch { + val status = ActionStatus.valueOf(messageEvent.data.bytesToString()) + + _eventsFlow.tryEmit(WearableEvent(messageEvent.path, Bundle().apply { + putSerializable(EXTRA_STATUS, status) + })) + } + } else { + super.onMessageReceived(messageEvent) + } + } +} \ No newline at end of file From 3b3de4df6d36e13062c162a950a572c60fc0fb0c Mon Sep 17 00:00:00 2001 From: Dave Antoine Date: Tue, 26 Mar 2024 12:22:42 -0400 Subject: [PATCH 17/58] AppLauncherActivity: migrate to compose --- wear/build.gradle | 5 +- .../simplewear/AppLauncherActivity.kt | 494 ++++-------------- .../simplewear/adapters/AppsListAdapter.kt | 43 -- .../ui/components/LoadingContent.kt | 32 ++ .../simplewear/ui/components/WearDivider.kt | 6 +- .../ui/simplewear/AppLauncherScreen.kt | 390 ++++++++++++++ .../ui/simplewear/DashboardScreen.kt | 2 +- .../viewmodels/AppLauncherViewModel.kt | 278 ++++++++++ .../viewmodels/WearableListenerViewModel.kt | 25 +- .../main/res/layout/activity_applauncher.xml | 86 --- .../main/res/layout/activity_dashboard.xml | 179 ------- .../main/res/layout/activity_valueaction.xml | 81 --- .../res/layout/applauncher_drawer_layout.xml | 43 -- 13 files changed, 818 insertions(+), 846 deletions(-) delete mode 100644 wear/src/main/java/com/thewizrd/simplewear/adapters/AppsListAdapter.kt create mode 100644 wear/src/main/java/com/thewizrd/simplewear/ui/components/LoadingContent.kt create mode 100644 wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/AppLauncherScreen.kt create mode 100644 wear/src/main/java/com/thewizrd/simplewear/viewmodels/AppLauncherViewModel.kt delete mode 100644 wear/src/main/res/layout/activity_applauncher.xml delete mode 100644 wear/src/main/res/layout/activity_dashboard.xml delete mode 100644 wear/src/main/res/layout/activity_valueaction.xml delete mode 100644 wear/src/main/res/layout/applauncher_drawer_layout.xml diff --git a/wear/build.gradle b/wear/build.gradle index e8b7eb9..58aecb9 100644 --- a/wear/build.gradle +++ b/wear/build.gradle @@ -100,7 +100,6 @@ dependencies { testImplementation("androidx.wear.tiles:tiles-testing:$wear_tiles_version") implementation 'androidx.wear.protolayout:protolayout-material:1.1.0' implementation "com.google.android.horologist:horologist-tiles:$horologist_version" - implementation "com.google.android.horologist:horologist-compose-tools:$horologist_version" // WearOS Compose implementation "androidx.activity:activity-compose:$activity_version" @@ -120,8 +119,10 @@ dependencies { implementation "com.google.accompanist:accompanist-drawablepainter:$accompanist_version" implementation "com.google.accompanist:accompanist-flowlayout:$accompanist_version" implementation "com.google.accompanist:accompanist-swiperefresh:$accompanist_version" - implementation "com.google.android.horologist:horologist-compose-layout:$horologist_version" implementation "com.google.android.horologist:horologist-audio-ui:$horologist_version" + implementation "com.google.android.horologist:horologist-compose-layout:$horologist_version" + implementation "com.google.android.horologist:horologist-compose-material:$horologist_version" + implementation "com.google.android.horologist:horologist-compose-tools:$horologist_version" androidTestImplementation "androidx.compose.ui:ui-test-junit4" debugImplementation "androidx.compose.ui:ui-tooling" diff --git a/wear/src/main/java/com/thewizrd/simplewear/AppLauncherActivity.kt b/wear/src/main/java/com/thewizrd/simplewear/AppLauncherActivity.kt index 9fdcce4..94e1116 100644 --- a/wear/src/main/java/com/thewizrd/simplewear/AppLauncherActivity.kt +++ b/wear/src/main/java/com/thewizrd/simplewear/AppLauncherActivity.kt @@ -1,455 +1,155 @@ package com.thewizrd.simplewear -import android.content.BroadcastReceiver -import android.content.Context import android.content.Intent -import android.content.IntentFilter import android.os.Bundle -import android.os.CountDownTimer -import android.util.Log -import android.view.View +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.viewModels import androidx.core.content.ContextCompat import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.ConcatAdapter -import androidx.wear.widget.WearableLinearLayoutManager -import androidx.wear.widget.drawer.WearableDrawerLayout -import androidx.wear.widget.drawer.WearableDrawerView -import com.google.android.gms.wearable.ChannelClient -import com.google.android.gms.wearable.DataClient.OnDataChangedListener -import com.google.android.gms.wearable.DataEvent -import com.google.android.gms.wearable.DataEventBuffer -import com.google.android.gms.wearable.DataMap -import com.google.android.gms.wearable.DataMapItem -import com.google.android.gms.wearable.MessageEvent -import com.google.android.gms.wearable.PutDataMapRequest -import com.google.android.gms.wearable.Wearable -import com.google.gson.stream.JsonReader +import androidx.wear.remote.interactions.RemoteActivityHelper import com.thewizrd.shared_resources.actions.ActionStatus -import com.thewizrd.shared_resources.helpers.AppItemData -import com.thewizrd.shared_resources.helpers.AppItemSerializer -import com.thewizrd.shared_resources.helpers.ListAdapterOnClickInterface import com.thewizrd.shared_resources.helpers.WearConnectionStatus import com.thewizrd.shared_resources.helpers.WearableHelper -import com.thewizrd.shared_resources.utils.ContextUtils.dpToPx -import com.thewizrd.shared_resources.utils.ImageUtils.toBitmap -import com.thewizrd.shared_resources.utils.Logger -import com.thewizrd.shared_resources.utils.bytesToString -import com.thewizrd.simplewear.adapters.AppsListAdapter -import com.thewizrd.simplewear.adapters.ListHeaderAdapter -import com.thewizrd.simplewear.adapters.SpacerAdapter -import com.thewizrd.simplewear.controls.AppItemViewModel import com.thewizrd.simplewear.controls.CustomConfirmationOverlay -import com.thewizrd.simplewear.controls.WearChipButton -import com.thewizrd.simplewear.databinding.ActivityApplauncherBinding -import com.thewizrd.simplewear.helpers.CustomScrollingLayoutCallback -import com.thewizrd.simplewear.helpers.SpacerItemDecoration import com.thewizrd.simplewear.helpers.showConfirmationOverlay -import com.thewizrd.simplewear.preferences.Settings +import com.thewizrd.simplewear.ui.simplewear.AppLauncherScreen +import com.thewizrd.simplewear.viewmodels.AppLauncherViewModel +import com.thewizrd.simplewear.viewmodels.WearableListenerViewModel import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.guava.await import kotlinx.coroutines.launch -import kotlinx.coroutines.tasks.await -import java.io.InputStreamReader -class AppLauncherActivity : WearableListenerActivity(), OnDataChangedListener { - override lateinit var broadcastReceiver: BroadcastReceiver - private set - override lateinit var intentFilter: IntentFilter - private set +class AppLauncherActivity : ComponentActivity() { + private val appLauncherViewModel by viewModels() - private lateinit var binding: ActivityApplauncherBinding - private lateinit var mAdapter: AppsListAdapter - private var timer: CountDownTimer? = null + private lateinit var remoteActivityHelper: RemoteActivityHelper override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - binding = ActivityApplauncherBinding.inflate(layoutInflater) - setContentView(binding.root) + remoteActivityHelper = RemoteActivityHelper(this) - broadcastReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - lifecycleScope.launch { - if (intent.action != null) { - if (ACTION_UPDATECONNECTIONSTATUS == intent.action) { - when (WearConnectionStatus.valueOf( - intent.getIntExtra( - EXTRA_CONNECTIONSTATUS, - 0 - ) - )) { - WearConnectionStatus.DISCONNECTED -> { - // Navigate - startActivity( - Intent( - this@AppLauncherActivity, - PhoneSyncActivity::class.java - ) - ) - finishAffinity() - } - WearConnectionStatus.APPNOTINSTALLED -> { - // Open store on remote device - val intentAndroid = Intent(Intent.ACTION_VIEW) - .addCategory(Intent.CATEGORY_BROWSABLE) - .setData(WearableHelper.getPlayStoreURI()) - - runCatching { - remoteActivityHelper.startRemoteActivity(intentAndroid) - .await() - - showConfirmationOverlay(true) - }.onFailure { - if (it !is CancellationException) { - showConfirmationOverlay(false) - } - } - - // Navigate - startActivity( - Intent( - this@AppLauncherActivity, - PhoneSyncActivity::class.java - ) - ) - finishAffinity() - } - else -> { - } - } - } else { - Logger.writeLine( - Log.INFO, - "%s: Unhandled action: %s", - "AppLauncherActivity", - intent.action - ) - } - } - } - } + setContent { + AppLauncherScreen() } + } - binding.drawerLayout.setDrawerStateCallback(object : - WearableDrawerLayout.DrawerStateCallback() { - override fun onDrawerOpened( - layout: WearableDrawerLayout, - drawerView: WearableDrawerView - ) { - super.onDrawerOpened(layout, drawerView) - drawerView.requestFocus() - } - - override fun onDrawerClosed( - layout: WearableDrawerLayout, - drawerView: WearableDrawerView - ) { - super.onDrawerClosed(layout, drawerView) - drawerView.clearFocus() - binding.appList.requestFocus() - } - - override fun onDrawerStateChanged(layout: WearableDrawerLayout, newState: Int) { - super.onDrawerStateChanged(layout, newState) - if (newState == WearableDrawerView.STATE_IDLE && binding.bottomActionDrawer.isPeeking) { - binding.bottomActionDrawer.clearFocus() - binding.appList.requestFocus() - } - } - }) + override fun onStart() { + super.onStart() - binding.bottomActionDrawer.visibility = View.VISIBLE - binding.bottomActionDrawer.isPeekOnScrollDownEnabled = true - binding.bottomActionDrawer.setIsAutoPeekEnabled(true) - binding.bottomActionDrawer.setIsLocked(false) + appLauncherViewModel.initActivityContext(this) - findViewById(R.id.icons_pref).also { iconsPref -> - iconsPref.setOnClickListener { - iconsPref.toggle() - Settings.setLoadAppIcons(iconsPref.isChecked) - lifecycleScope.launch(Dispatchers.IO) { - val dataRequest = PutDataMapRequest.create(WearableHelper.AppsIconSettingsPath) - dataRequest.dataMap.putBoolean(WearableHelper.KEY_ICON, iconsPref.isChecked) - dataRequest.setUrgent() - runCatching { - Wearable - .getDataClient(this@AppLauncherActivity) - .putDataItem(dataRequest.asPutDataRequest()) - .await() - }.onFailure { - Logger.writeLine(Log.ERROR, it) - } - } - } - iconsPref.isChecked = Settings.isLoadAppIcons() - } - - binding.appList.setHasFixedSize(true) - //binding.appList.isEdgeItemsCenteringEnabled = true - binding.appList.addItemDecoration( - SpacerItemDecoration( - dpToPx(16f).toInt(), - dpToPx(4f).toInt() - ) - ) - - binding.appList.layoutManager = - WearableLinearLayoutManager(this, CustomScrollingLayoutCallback()) - mAdapter = AppsListAdapter() - mAdapter.setOnClickListener(object : ListAdapterOnClickInterface { - override fun onClick(view: View, item: AppItemViewModel) { - lifecycleScope.launch { - val success = runCatching { - val intent = WearableHelper.createRemoteActivityIntent( - item.packageName!!, - item.activityName!! + lifecycleScope.launch { + appLauncherViewModel.eventFlow.collect { event -> + when (event.eventType) { + WearableListenerViewModel.ACTION_UPDATECONNECTIONSTATUS -> { + val connectionStatus = WearConnectionStatus.valueOf( + event.data.getInt( + WearableListenerViewModel.EXTRA_CONNECTIONSTATUS, + 0 + ) ) - startRemoteActivity(intent) - }.getOrDefault(false) - - showConfirmationOverlay(success) - } - } - }) - binding.appList.adapter = ConcatAdapter( - ListHeaderAdapter(getString(R.string.action_apps)), - mAdapter, - SpacerAdapter(dpToPx(48f).toInt()) - ) - binding.retryFab.setOnClickListener { - lifecycleScope.launch { - updateConnectionStatus() - requestAppsUpdate() - } - } - - intentFilter = IntentFilter() - intentFilter.addAction(ACTION_UPDATECONNECTIONSTATUS) - - // Set timer for retrieving music player data - timer = object : CountDownTimer(3000, 1000) { - override fun onTick(millisUntilFinished: Long) {} - override fun onFinish() { - lifecycleScope.launch(Dispatchers.IO) { - try { - val buff = Wearable.getDataClient(this@AppLauncherActivity) - .getDataItems( - WearableHelper.getWearDataUri( - "*", - WearableHelper.AppsPath + when (connectionStatus) { + WearConnectionStatus.DISCONNECTED -> { + // Navigate + startActivity( + Intent( + this@AppLauncherActivity, + PhoneSyncActivity::class.java + ) ) - ) - .await() - - for (i in 0 until buff.count) { - val item = buff[i] - if (WearableHelper.AppsPath == item.uri.path) { - val appsList = try { - val dataMap = DataMapItem.fromDataItem(item).dataMap - createAppsList(dataMap) - } catch (e: Exception) { - Logger.writeLine(Log.ERROR, e) - null - } - updateAppsList(appsList ?: emptyList()) - showProgressBar(false) + finishAffinity() } - } - - buff.release() - } catch (e: Exception) { - Logger.writeLine(Log.ERROR, e) - } - } - } - } - } - private fun showProgressBar(show: Boolean) { - lifecycleScope.launch { - if (show) { - binding.progressBar.show() - } else { - binding.progressBar.hide() - } - } - } + WearConnectionStatus.APPNOTINSTALLED -> { + // Open store on remote device + val intentAndroid = Intent(Intent.ACTION_VIEW) + .addCategory(Intent.CATEGORY_BROWSABLE) + .setData(WearableHelper.getPlayStoreURI()) - override fun onMessageReceived(messageEvent: MessageEvent) { - super.onMessageReceived(messageEvent) + runCatching { + remoteActivityHelper.startRemoteActivity(intentAndroid) + .await() - lifecycleScope.launch { - if (messageEvent.data != null && messageEvent.path == WearableHelper.LaunchAppPath) { - val status = ActionStatus.valueOf(messageEvent.data.bytesToString()) - - lifecycleScope.launch { - when (status) { - ActionStatus.SUCCESS -> { - CustomConfirmationOverlay() - .setType(CustomConfirmationOverlay.SUCCESS_ANIMATION) - .showOn(this@AppLauncherActivity) - } - ActionStatus.PERMISSION_DENIED -> { - CustomConfirmationOverlay() - .setType(CustomConfirmationOverlay.CUSTOM_ANIMATION) - .setCustomDrawable( - ContextCompat.getDrawable( - this@AppLauncherActivity, - R.drawable.ws_full_sad - ) - ) - .setMessage(this@AppLauncherActivity.getString(R.string.error_permissiondenied)) - .showOn(this@AppLauncherActivity) + showConfirmationOverlay(true) + }.onFailure { + if (it !is CancellationException) { + showConfirmationOverlay(false) + } + } - openAppOnPhone(false) - } - ActionStatus.FAILURE -> { - CustomConfirmationOverlay() - .setType(CustomConfirmationOverlay.CUSTOM_ANIMATION) - .setCustomDrawable( - ContextCompat.getDrawable( + // Navigate + startActivity( + Intent( this@AppLauncherActivity, - R.drawable.ws_full_sad + PhoneSyncActivity::class.java ) ) - .setMessage(this@AppLauncherActivity.getString(R.string.error_actionfailed)) - .showOn(this@AppLauncherActivity) - } - - else -> {} - } - } - } - } - } - - override fun onDataChanged(dataEventBuffer: DataEventBuffer) { - lifecycleScope.launch { - // Cancel timer - timer?.cancel() + finishAffinity() + } - for (event in dataEventBuffer) { - if (event.type == DataEvent.TYPE_CHANGED) { - val item = event.dataItem - if (WearableHelper.AppsPath == item.uri.path) { - val appsList = try { - val dataMap = DataMapItem.fromDataItem(item).dataMap - createAppsList(dataMap) - } catch (e: Exception) { - Logger.writeLine(Log.ERROR, e) - null + else -> { + } } - updateAppsList(appsList ?: emptyList()) - showProgressBar(false) } - } - } - } - } - - private fun createAppsList(dataMap: DataMap): List { - val availableApps = - dataMap.getStringArrayList(WearableHelper.KEY_APPS) ?: return emptyList() - val viewModels = ArrayList() - for (key in availableApps) { - val map = dataMap.getDataMap(key) ?: continue - val model = AppItemViewModel().apply { - appType = AppItemViewModel.AppType.APP - appLabel = map.getString(WearableHelper.KEY_LABEL) - packageName = map.getString(WearableHelper.KEY_PKGNAME) - activityName = map.getString(WearableHelper.KEY_ACTIVITYNAME) - } - viewModels.add(model) - } + WearableHelper.LaunchAppPath -> { + val status = + event.data.getSerializable(WearableListenerViewModel.EXTRA_STATUS) as ActionStatus - return viewModels - } + when (status) { + ActionStatus.SUCCESS -> { + CustomConfirmationOverlay() + .setType(CustomConfirmationOverlay.SUCCESS_ANIMATION) + .showOn(this@AppLauncherActivity) + } - private suspend fun createAppsList(items: List): List { - val viewModels = ArrayList(items.size) + ActionStatus.PERMISSION_DENIED -> { + CustomConfirmationOverlay() + .setType(CustomConfirmationOverlay.CUSTOM_ANIMATION) + .setCustomDrawable( + ContextCompat.getDrawable( + this@AppLauncherActivity, + R.drawable.ws_full_sad + ) + ) + .setMessage(this@AppLauncherActivity.getString(R.string.error_permissiondenied)) + .showOn(this@AppLauncherActivity) - items.forEach { item -> - val model = AppItemViewModel().apply { - appType = AppItemViewModel.AppType.APP - appLabel = item.label - packageName = item.packageName - activityName = item.activityName - bitmapIcon = item.iconBitmap?.toBitmap() - } - viewModels.add(model) - } + appLauncherViewModel.openAppOnPhone(this@AppLauncherActivity, false) + } - return viewModels - } + ActionStatus.FAILURE -> { + CustomConfirmationOverlay() + .setType(CustomConfirmationOverlay.CUSTOM_ANIMATION) + .setCustomDrawable( + ContextCompat.getDrawable( + this@AppLauncherActivity, + R.drawable.ws_full_sad + ) + ) + .setMessage(this@AppLauncherActivity.getString(R.string.error_actionfailed)) + .showOn(this@AppLauncherActivity) + } - private fun updateAppsList(viewModels: List) { - lifecycleScope.launch { - mAdapter.submitList(viewModels) - showProgressBar(false) - binding.noappsView.visibility = if (viewModels.isNotEmpty()) View.GONE else View.VISIBLE - binding.appList.visibility = if (viewModels.isNotEmpty()) View.VISIBLE else View.GONE - lifecycleScope.launch { - if (!binding.bottomActionDrawer.isOpened && binding.appList.visibility == View.VISIBLE && !binding.appList.hasFocus()) { - binding.appList.requestFocus() + else -> {} + } + } } } } } - private fun requestAppsUpdate() { - lifecycleScope.launch { - if (connect()) { - sendMessage(mPhoneNodeWithApp!!.id, WearableHelper.AppsPath, null) - } - } - } - override fun onResume() { super.onResume() - Wearable.getDataClient(this).addListener(this) - Wearable.getChannelClient(this).registerChannelCallback(mChannelCallback) - - if (binding.bottomActionDrawer.isOpened) { - binding.bottomActionDrawer.requestFocus() - } else { - binding.appList.requestFocus() - } // Update statuses - lifecycleScope.launch { - updateConnectionStatus() - requestAppsUpdate() - // Wait for music player update - timer!!.start() - } + appLauncherViewModel.refreshApps(true) } - override fun onPause() { - Wearable.getChannelClient(this).unregisterChannelCallback(mChannelCallback) - Wearable.getDataClient(this).removeListener(this) - super.onPause() - } - - private val mChannelCallback = object : ChannelClient.ChannelCallback() { - override fun onChannelOpened(channel: ChannelClient.Channel) { - super.onChannelOpened(channel) - // Check if we can load the data - if (channel.path == WearableHelper.AppsPath) { - lifecycleScope.launch(Dispatchers.IO) { - val channelClient = Wearable.getChannelClient(this@AppLauncherActivity) - runCatching { - val inputStream = channelClient.getInputStream(channel).await() - inputStream.use { - val reader = JsonReader(InputStreamReader(it)) - val items = AppItemSerializer.deserialize(reader) - updateAppsList(createAppsList(items ?: emptyList())) - } - } - } - } - } + override fun onDestroy() { + super.onDestroy() } } \ No newline at end of file diff --git a/wear/src/main/java/com/thewizrd/simplewear/adapters/AppsListAdapter.kt b/wear/src/main/java/com/thewizrd/simplewear/adapters/AppsListAdapter.kt deleted file mode 100644 index 45a37e6..0000000 --- a/wear/src/main/java/com/thewizrd/simplewear/adapters/AppsListAdapter.kt +++ /dev/null @@ -1,43 +0,0 @@ -package com.thewizrd.simplewear.adapters - -import android.annotation.SuppressLint -import android.view.ViewGroup -import androidx.core.graphics.drawable.toDrawable -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import com.thewizrd.shared_resources.helpers.ListAdapterOnClickInterface -import com.thewizrd.simplewear.controls.AppItemViewModel -import com.thewizrd.simplewear.controls.WearChipButton - -class AppsListAdapter : ListAdapter(AppItemDiffer()) { - private var onClickListener: ListAdapterOnClickInterface? = null - - fun setOnClickListener(onClickListener: ListAdapterOnClickInterface?) { - this.onClickListener = onClickListener - } - - inner class ViewHolder(var mItem: WearChipButton) : RecyclerView.ViewHolder(mItem) - - @SuppressLint("NewApi") // Create new views (invoked by the layout manager) - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { - // create a new view - val v = WearChipButton(parent.context).apply { - layoutParams = RecyclerView.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT - ) - } - - return ViewHolder(v) - } - - // Replace the contents of a view (invoked by the layout manager) - override fun onBindViewHolder(holder: ViewHolder, position: Int) { - val viewModel = getItem(position) - holder.mItem.setIconDrawable(viewModel.bitmapIcon?.toDrawable(holder.itemView.context.resources)) - holder.mItem.setPrimaryText(viewModel.appLabel) - holder.mItem.setOnClickListener { v -> - onClickListener?.onClick(v, viewModel) - } - } -} \ No newline at end of file diff --git a/wear/src/main/java/com/thewizrd/simplewear/ui/components/LoadingContent.kt b/wear/src/main/java/com/thewizrd/simplewear/ui/components/LoadingContent.kt new file mode 100644 index 0000000..993170d --- /dev/null +++ b/wear/src/main/java/com/thewizrd/simplewear/ui/components/LoadingContent.kt @@ -0,0 +1,32 @@ +package com.thewizrd.simplewear.ui.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.wear.compose.material.CircularProgressIndicator + +@Composable +fun LoadingContent( + empty: Boolean, + emptyContent: @Composable () -> Unit, + loading: Boolean, + loadingContent: @Composable () -> Unit = { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + }, + content: @Composable () -> Unit +) { + if (loading) { + loadingContent() + } else if (empty) { + emptyContent() + } else { + content() + } +} \ No newline at end of file diff --git a/wear/src/main/java/com/thewizrd/simplewear/ui/components/WearDivider.kt b/wear/src/main/java/com/thewizrd/simplewear/ui/components/WearDivider.kt index 2b18fd1..1889a16 100644 --- a/wear/src/main/java/com/thewizrd/simplewear/ui/components/WearDivider.kt +++ b/wear/src/main/java/com/thewizrd/simplewear/ui/components/WearDivider.kt @@ -18,11 +18,13 @@ import com.thewizrd.shared_resources.utils.ContextUtils.dpToPx @Preview @Composable -fun WearDivider() { +fun WearDivider( + modifier: Modifier = Modifier +) { val ctx = LocalContext.current Box( - modifier = Modifier + modifier = modifier .fillMaxWidth() .height(24.dp), contentAlignment = Alignment.Center diff --git a/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/AppLauncherScreen.kt b/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/AppLauncherScreen.kt new file mode 100644 index 0000000..d8933a4 --- /dev/null +++ b/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/AppLauncherScreen.kt @@ -0,0 +1,390 @@ +package com.thewizrd.simplewear.ui.simplewear + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.core.content.ContextCompat +import androidx.core.graphics.drawable.toBitmap +import androidx.lifecycle.lifecycleScope +import androidx.wear.compose.foundation.ExperimentalWearFoundationApi +import androidx.wear.compose.foundation.HierarchicalFocusCoordinator +import androidx.wear.compose.foundation.edgeSwipeToDismiss +import androidx.wear.compose.foundation.lazy.items +import androidx.wear.compose.foundation.rememberSwipeToDismissBoxState +import androidx.wear.compose.material.Chip +import androidx.wear.compose.material.ChipDefaults +import androidx.wear.compose.material.CompactChip +import androidx.wear.compose.material.Icon +import androidx.wear.compose.material.MaterialTheme +import androidx.wear.compose.material.PositionIndicator +import androidx.wear.compose.material.SwipeToDismissBox +import androidx.wear.compose.material.Switch +import androidx.wear.compose.material.Text +import androidx.wear.compose.material.TimeText +import androidx.wear.compose.material.ToggleChip +import androidx.wear.compose.ui.tooling.preview.WearPreviewDevices +import com.google.android.horologist.annotations.ExperimentalHorologistApi +import com.google.android.horologist.compose.layout.PagerScaffold +import com.google.android.horologist.compose.layout.ScalingLazyColumn +import com.google.android.horologist.compose.layout.ScalingLazyColumnDefaults +import com.google.android.horologist.compose.layout.ScalingLazyColumnState +import com.google.android.horologist.compose.layout.rememberResponsiveColumnState +import com.google.android.horologist.compose.layout.scrollAway +import com.google.android.horologist.compose.material.ListHeaderDefaults.firstItemPadding +import com.google.android.horologist.compose.material.ResponsiveListHeader +import com.google.android.horologist.compose.pager.HorizontalPagerDefaults +import com.thewizrd.shared_resources.helpers.WearConnectionStatus +import com.thewizrd.shared_resources.helpers.WearableHelper +import com.thewizrd.simplewear.R +import com.thewizrd.simplewear.controls.AppItemViewModel +import com.thewizrd.simplewear.helpers.showConfirmationOverlay +import com.thewizrd.simplewear.ui.components.LoadingContent +import com.thewizrd.simplewear.ui.theme.WearAppTheme +import com.thewizrd.simplewear.ui.theme.activityViewModel +import com.thewizrd.simplewear.ui.theme.findActivity +import com.thewizrd.simplewear.viewmodels.AppLauncherUiState +import com.thewizrd.simplewear.viewmodels.AppLauncherViewModel +import kotlinx.coroutines.launch + +@OptIn( + ExperimentalFoundationApi::class, + ExperimentalWearFoundationApi::class, + ExperimentalHorologistApi::class +) +@Composable +fun AppLauncherScreen( + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val activity = context.findActivity() + + val appLauncherViewModel = activityViewModel() + + val scrollState = rememberResponsiveColumnState( + contentPadding = ScalingLazyColumnDefaults.padding( + first = ScalingLazyColumnDefaults.ItemType.Unspecified, + last = ScalingLazyColumnDefaults.ItemType.Chip, + ) + ) + val swipeToDismissBoxState = rememberSwipeToDismissBoxState() + + val pagerState = rememberPagerState( + initialPage = 0, + pageCount = { 2 } + ) + + WearAppTheme { + PagerScaffold( + modifier = Modifier.fillMaxSize(), + timeText = { + if (pagerState.currentPage == 0) { + TimeText(modifier = Modifier.scrollAway { scrollState }) + } + }, + pagerState = pagerState + ) { + SwipeToDismissBox( + modifier = Modifier.background(MaterialTheme.colors.background), + onDismissed = { + activity.onBackPressed() + }, + state = swipeToDismissBoxState + ) { isBackground -> + if (isBackground) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colors.background) + ) + } else { + HorizontalPager( + modifier = modifier.edgeSwipeToDismiss(swipeToDismissBoxState), + state = pagerState, + flingBehavior = HorizontalPagerDefaults.flingParams(pagerState) + ) { pageIdx -> + HierarchicalFocusCoordinator(requiresFocus = { pageIdx == pagerState.currentPage }) { + if (pageIdx == 0) { + AppLauncherScreen( + appLauncherViewModel = appLauncherViewModel, + scrollState = scrollState + ) + } else { + AppLauncherSettings( + appLauncherViewModel = appLauncherViewModel + ) + } + } + } + } + } + } + } +} + +@OptIn(ExperimentalHorologistApi::class) +@Composable +private fun AppLauncherScreen( + appLauncherViewModel: AppLauncherViewModel, + scrollState: ScalingLazyColumnState +) { + val context = LocalContext.current + val activity = context.findActivity() + + val lifecycleOwner = LocalLifecycleOwner.current + + val uiState by appLauncherViewModel.uiState.collectAsState() + + AppLauncherScreen( + uiState = uiState, + scrollState = scrollState, + onItemClicked = { + lifecycleOwner.lifecycleScope.launch { + val success = runCatching { + val intent = WearableHelper.createRemoteActivityIntent( + it.packageName!!, + it.activityName!! + ) + appLauncherViewModel.startRemoteActivity(intent) + }.getOrDefault(false) + + activity.showConfirmationOverlay(success) + } + } + ) +} + +@OptIn(ExperimentalHorologistApi::class) +@Composable +private fun AppLauncherScreen( + uiState: AppLauncherUiState, + scrollState: ScalingLazyColumnState = rememberResponsiveColumnState(), + onItemClicked: (AppItemViewModel) -> Unit = {}, + onRefresh: () -> Unit = {} +) { + Box( + modifier = Modifier.fillMaxSize() + ) { + LoadingContent( + empty = uiState.appsList.isEmpty(), + emptyContent = { + Box( + modifier = Modifier + .fillMaxSize() + .wrapContentHeight(), + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(text = stringResource(id = R.string.error_noapps)) + CompactChip( + label = { + Text(text = stringResource(id = R.string.action_refresh)) + }, + icon = { + Icon( + painter = painterResource(id = R.drawable.ic_baseline_refresh_24), + contentDescription = null + ) + }, + onClick = onRefresh + ) + } + } + }, + loading = uiState.isLoading + ) { + ScalingLazyColumn( + modifier = Modifier.fillMaxSize(), + columnState = scrollState, + ) { + item { + ResponsiveListHeader(contentPadding = firstItemPadding()) { + Text(text = stringResource(id = R.string.action_apps)) + } + } + + items( + items = uiState.appsList, + key = { Pair(it.activityName, it.packageName) } + ) { + Chip( + modifier = Modifier.fillMaxWidth(), + label = { + Text(text = it.appLabel ?: "") + }, + icon = if (uiState.loadAppIcons) { + it.bitmapIcon?.let { + { + Icon( + modifier = Modifier.requiredSize(ChipDefaults.IconSize), + bitmap = it.asImageBitmap(), + contentDescription = null, + tint = Color.Unspecified + ) + } + } + } else { + null + }, + colors = ChipDefaults.secondaryChipColors(), + onClick = { + onItemClicked(it) + } + ) + } + } + + PositionIndicator(scalingLazyListState = scrollState.state) + } + } +} + +@Composable +private fun AppLauncherSettings( + appLauncherViewModel: AppLauncherViewModel +) { + val uiState by appLauncherViewModel.uiState.collectAsState() + + AppLauncherSettings( + uiState = uiState, + onCheckChanged = { + appLauncherViewModel.showAppIcons(it) + } + ) +} + +@OptIn(ExperimentalHorologistApi::class) +@Composable +private fun AppLauncherSettings( + uiState: AppLauncherUiState, + onCheckChanged: (Boolean) -> Unit = {} +) { + val scrollState = rememberResponsiveColumnState( + contentPadding = ScalingLazyColumnDefaults.padding( + first = ScalingLazyColumnDefaults.ItemType.Unspecified, + last = ScalingLazyColumnDefaults.ItemType.Chip, + ) + ) + + ScalingLazyColumn( + columnState = scrollState + ) { + item { + ResponsiveListHeader( + modifier = Modifier.fillMaxWidth(), + contentPadding = firstItemPadding() + ) { + Text(text = stringResource(id = R.string.title_settings)) + } + } + item { + ToggleChip( + modifier = Modifier.fillMaxWidth(), + label = { + Text(text = stringResource(id = R.string.pref_loadicons_title)) + }, + checked = uiState.loadAppIcons, + onCheckedChange = onCheckChanged, + toggleControl = { + Switch(checked = uiState.loadAppIcons) + } + ) + } + } +} + +@OptIn(ExperimentalHorologistApi::class) +@WearPreviewDevices +@Composable +private fun PreviewAppLauncherScreen() { + val context = LocalContext.current + + val uiState = remember(context) { + AppLauncherUiState( + connectionStatus = WearConnectionStatus.CONNECTED, + appsList = List(10) { index -> + AppItemViewModel().apply { + appLabel = "App ${index + 1}" + packageName = "com.package.${index}" + bitmapIcon = ContextCompat.getDrawable(context, R.drawable.ic_icon)!!.toBitmap() + } + }, + isLoading = false, + loadAppIcons = true + ) + } + + AppLauncherScreen(uiState = uiState) +} + +@OptIn(ExperimentalHorologistApi::class) +@WearPreviewDevices +@Composable +private fun PreviewLoadingAppLauncherScreen() { + val context = LocalContext.current + + val uiState = remember(context) { + AppLauncherUiState( + connectionStatus = WearConnectionStatus.CONNECTED, + appsList = emptyList(), + isLoading = true, + loadAppIcons = true + ) + } + + AppLauncherScreen(uiState = uiState) +} + +@OptIn(ExperimentalHorologistApi::class) +@WearPreviewDevices +@Composable +private fun PreviewNoContentAppLauncherScreen() { + val context = LocalContext.current + + val uiState = remember(context) { + AppLauncherUiState( + connectionStatus = WearConnectionStatus.CONNECTED, + appsList = emptyList(), + isLoading = false, + loadAppIcons = true + ) + } + + AppLauncherScreen(uiState = uiState) +} + +@WearPreviewDevices +@Composable +private fun PreviewAppLauncherSettings() { + val uiState = remember { + AppLauncherUiState( + connectionStatus = WearConnectionStatus.CONNECTED, + appsList = emptyList(), + isLoading = true, + loadAppIcons = true + ) + } + + AppLauncherSettings(uiState = uiState) +} \ No newline at end of file diff --git a/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/DashboardScreen.kt b/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/DashboardScreen.kt index 9e7db44..c658254 100644 --- a/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/DashboardScreen.kt +++ b/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/DashboardScreen.kt @@ -193,7 +193,7 @@ fun DashboardScreen( onActionClicked = onActionClicked ) // Settings - WearDivider() + WearDivider(modifier = Modifier.padding(vertical = 8.dp)) DashboardSettings(dashboardState, scrollState) } } diff --git a/wear/src/main/java/com/thewizrd/simplewear/viewmodels/AppLauncherViewModel.kt b/wear/src/main/java/com/thewizrd/simplewear/viewmodels/AppLauncherViewModel.kt new file mode 100644 index 0000000..9e26bd7 --- /dev/null +++ b/wear/src/main/java/com/thewizrd/simplewear/viewmodels/AppLauncherViewModel.kt @@ -0,0 +1,278 @@ +package com.thewizrd.simplewear.viewmodels + +import android.app.Application +import android.os.Bundle +import android.os.CountDownTimer +import android.util.Log +import androidx.lifecycle.viewModelScope +import com.google.android.gms.wearable.ChannelClient +import com.google.android.gms.wearable.DataClient.OnDataChangedListener +import com.google.android.gms.wearable.DataEvent +import com.google.android.gms.wearable.DataEventBuffer +import com.google.android.gms.wearable.DataMap +import com.google.android.gms.wearable.DataMapItem +import com.google.android.gms.wearable.MessageEvent +import com.google.android.gms.wearable.PutDataMapRequest +import com.google.android.gms.wearable.Wearable +import com.google.gson.stream.JsonReader +import com.thewizrd.shared_resources.actions.ActionStatus +import com.thewizrd.shared_resources.helpers.AppItemData +import com.thewizrd.shared_resources.helpers.AppItemSerializer +import com.thewizrd.shared_resources.helpers.WearConnectionStatus +import com.thewizrd.shared_resources.helpers.WearableHelper +import com.thewizrd.shared_resources.utils.ImageUtils.toBitmap +import com.thewizrd.shared_resources.utils.Logger +import com.thewizrd.shared_resources.utils.bytesToString +import com.thewizrd.simplewear.controls.AppItemViewModel +import com.thewizrd.simplewear.preferences.Settings +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.tasks.await +import java.io.InputStreamReader + +data class AppLauncherUiState( + val connectionStatus: WearConnectionStatus? = null, + val appsList: List = emptyList(), + val isLoading: Boolean = false, + val loadAppIcons: Boolean = Settings.isLoadAppIcons() +) + +class AppLauncherViewModel(app: Application) : WearableListenerViewModel(app), + OnDataChangedListener { + private val viewModelState = MutableStateFlow(AppLauncherUiState(isLoading = true)) + + private val timer: CountDownTimer + + val uiState = viewModelState.stateIn( + viewModelScope, + SharingStarted.Eagerly, + viewModelState.value + ) + + private val channelCallback = object : ChannelClient.ChannelCallback() { + override fun onChannelOpened(channel: ChannelClient.Channel) { + super.onChannelOpened(channel) + // Check if we can load the data + if (channel.path == WearableHelper.AppsPath) { + viewModelScope.launch(Dispatchers.IO) { + val channelClient = Wearable.getChannelClient(appContext) + runCatching { + val inputStream = channelClient.getInputStream(channel).await() + inputStream.use { + val reader = JsonReader(InputStreamReader(it)) + val items = AppItemSerializer.deserialize(reader) + + viewModelState.update { state -> + state.copy( + appsList = createAppsList(items ?: emptyList()) + ) + } + } + } + } + } + } + } + + init { + Wearable.getDataClient(appContext).addListener(this) + Wearable.getChannelClient(appContext).registerChannelCallback(channelCallback) + + // Set timer for retrieving music player data + timer = object : CountDownTimer(3000, 1000) { + override fun onTick(millisUntilFinished: Long) {} + override fun onFinish() { + viewModelScope.launch(Dispatchers.IO) { + try { + val buff = Wearable.getDataClient(appContext) + .getDataItems( + WearableHelper.getWearDataUri( + "*", + WearableHelper.AppsPath + ) + ) + .await() + + for (i in 0 until buff.count) { + val item = buff[i] + if (WearableHelper.AppsPath == item.uri.path) { + val appsList = try { + val dataMap = DataMapItem.fromDataItem(item).dataMap + createAppsList(dataMap) + } catch (e: Exception) { + Logger.writeLine(Log.ERROR, e) + null + } + + viewModelState.update { + it.copy( + appsList = appsList ?: emptyList(), + isLoading = false + ) + } + } + } + + buff.release() + } catch (e: Exception) { + Logger.writeLine(Log.ERROR, e) + } + } + } + } + + viewModelScope.launch { + eventFlow.collect { event -> + when (event.eventType) { + ACTION_UPDATECONNECTIONSTATUS -> { + val connectionStatus = WearConnectionStatus.valueOf( + event.data.getInt( + EXTRA_CONNECTIONSTATUS, + 0 + ) + ) + + viewModelState.update { + it.copy( + connectionStatus = connectionStatus + ) + } + } + } + } + } + } + + override fun onMessageReceived(messageEvent: MessageEvent) { + if (messageEvent.path == WearableHelper.LaunchAppPath) { + viewModelScope.launch { + val status = ActionStatus.valueOf(messageEvent.data.bytesToString()) + + _eventsFlow.tryEmit(WearableEvent(messageEvent.path, Bundle().apply { + putSerializable(EXTRA_STATUS, status) + })) + } + } else { + super.onMessageReceived(messageEvent) + } + } + + override fun onDataChanged(dataEventBuffer: DataEventBuffer) { + viewModelScope.launch { + // Cancel timer + timer.cancel() + + for (event in dataEventBuffer) { + if (event.type == DataEvent.TYPE_CHANGED) { + val item = event.dataItem + if (WearableHelper.AppsPath == item.uri.path) { + val appsList = try { + val dataMap = DataMapItem.fromDataItem(item).dataMap + createAppsList(dataMap) + } catch (e: Exception) { + Logger.writeLine(Log.ERROR, e) + null + } + + viewModelState.update { + it.copy( + appsList = appsList ?: emptyList(), + isLoading = false + ) + } + } + } + } + } + } + + private fun createAppsList(dataMap: DataMap): List { + val availableApps = + dataMap.getStringArrayList(WearableHelper.KEY_APPS) ?: return emptyList() + val viewModels = ArrayList() + for (key in availableApps) { + val map = dataMap.getDataMap(key) ?: continue + + val model = AppItemViewModel().apply { + appType = AppItemViewModel.AppType.APP + appLabel = map.getString(WearableHelper.KEY_LABEL) + packageName = map.getString(WearableHelper.KEY_PKGNAME) + activityName = map.getString(WearableHelper.KEY_ACTIVITYNAME) + } + viewModels.add(model) + } + + return viewModels + } + + private suspend fun createAppsList(items: List): List { + val viewModels = ArrayList(items.size) + + items.forEach { item -> + val model = AppItemViewModel().apply { + appType = AppItemViewModel.AppType.APP + appLabel = item.label + packageName = item.packageName + activityName = item.activityName + bitmapIcon = item.iconBitmap?.toBitmap() + } + viewModels.add(model) + } + + return viewModels + } + + fun showAppIcons(show: Boolean = true) { + Settings.setLoadAppIcons(show) + + viewModelScope.launch(Dispatchers.IO) { + val dataRequest = PutDataMapRequest.create(WearableHelper.AppsIconSettingsPath) + dataRequest.dataMap.putBoolean(WearableHelper.KEY_ICON, show) + dataRequest.setUrgent() + runCatching { + Wearable + .getDataClient(appContext) + .putDataItem(dataRequest.asPutDataRequest()) + .await() + }.onFailure { + Logger.writeLine(Log.ERROR, it) + } + } + + viewModelState.update { + it.copy( + loadAppIcons = show + ) + } + } + + fun refreshApps(startTimer: Boolean = false) { + // Update statuses + viewModelScope.launch { + updateConnectionStatus() + requestAppsUpdate() + if (startTimer) { + // Wait for apps update + timer.start() + } + } + } + + private fun requestAppsUpdate() { + viewModelScope.launch { + if (connect()) { + sendMessage(mPhoneNodeWithApp!!.id, WearableHelper.AppsPath, null) + } + } + } + + override fun onCleared() { + Wearable.getChannelClient(appContext).unregisterChannelCallback(channelCallback) + Wearable.getDataClient(appContext).removeListener(this) + super.onCleared() + } +} \ No newline at end of file diff --git a/wear/src/main/java/com/thewizrd/simplewear/viewmodels/WearableListenerViewModel.kt b/wear/src/main/java/com/thewizrd/simplewear/viewmodels/WearableListenerViewModel.kt index f72a589..b9b306c 100644 --- a/wear/src/main/java/com/thewizrd/simplewear/viewmodels/WearableListenerViewModel.kt +++ b/wear/src/main/java/com/thewizrd/simplewear/viewmodels/WearableListenerViewModel.kt @@ -49,7 +49,7 @@ import kotlin.coroutines.cancellation.CancellationException abstract class WearableListenerViewModel(private val app: Application) : AndroidViewModel(app), OnMessageReceivedListener, OnCapabilityChangedListener { - private val context: Context + protected val appContext: Context get() = app.applicationContext @SuppressLint("StaticFieldLeak") @@ -59,7 +59,7 @@ abstract class WearableListenerViewModel(private val app: Application) : Android protected var mPhoneNodeWithApp: Node? = null private var mConnectionStatus = WearConnectionStatus.CONNECTING - protected val remoteActivityHelper: RemoteActivityHelper = RemoteActivityHelper(context) + protected val remoteActivityHelper: RemoteActivityHelper = RemoteActivityHelper(appContext) protected val _eventsFlow = MutableSharedFlow( replay = 0, @@ -72,8 +72,9 @@ abstract class WearableListenerViewModel(private val app: Application) : Android val errorMessagesFlow: SharedFlow = _errorMessagesFlow init { - Wearable.getCapabilityClient(context).addListener(this, WearableHelper.CAPABILITY_PHONE_APP) - Wearable.getMessageClient(context).addListener(this) + Wearable.getCapabilityClient(appContext) + .addListener(this, WearableHelper.CAPABILITY_PHONE_APP) + Wearable.getMessageClient(appContext).addListener(this) } fun initActivityContext(activity: Activity) { @@ -82,9 +83,9 @@ abstract class WearableListenerViewModel(private val app: Application) : Android override fun onCleared() { super.onCleared() - Wearable.getCapabilityClient(context) + Wearable.getCapabilityClient(appContext) .removeListener(this, WearableHelper.CAPABILITY_PHONE_APP) - Wearable.getMessageClient(context).removeListener(this) + Wearable.getMessageClient(appContext).removeListener(this) activityContext = null } @@ -95,7 +96,7 @@ abstract class WearableListenerViewModel(private val app: Application) : Android if (mPhoneNodeWithApp == null) { _errorMessagesFlow.tryEmit("Device is not connected or app is not installed on device...") - when (PhoneTypeHelper.getPhoneDeviceType(context)) { + when (PhoneTypeHelper.getPhoneDeviceType(appContext)) { PhoneTypeHelper.DEVICE_TYPE_ANDROID -> { // Open store on remote device val intentAndroid = Intent(Intent.ACTION_VIEW) @@ -138,7 +139,7 @@ abstract class WearableListenerViewModel(private val app: Application) : Android } } - protected suspend fun startRemoteActivity(intent: Intent): Boolean { + suspend fun startRemoteActivity(intent: Intent): Boolean { return runCatching { remoteActivityHelper.startRemoteActivity(intent).await() true @@ -331,7 +332,7 @@ abstract class WearableListenerViewModel(private val app: Application) : Android var node: Node? = null try { - val capabilityInfo = Wearable.getCapabilityClient(context) + val capabilityInfo = Wearable.getCapabilityClient(appContext) .getCapability( WearableHelper.CAPABILITY_PHONE_APP, CapabilityClient.FILTER_ALL @@ -395,7 +396,7 @@ abstract class WearableListenerViewModel(private val app: Application) : Android private suspend fun getConnectedNodes(): List { try { - return Wearable.getNodeClient(context) + return Wearable.getNodeClient(appContext) .connectedNodes .await() } catch (e: Exception) { @@ -407,7 +408,7 @@ abstract class WearableListenerViewModel(private val app: Application) : Android protected suspend fun sendMessage(nodeID: String, path: String, data: ByteArray?): Int? { try { - return Wearable.getMessageClient(context) + return Wearable.getMessageClient(appContext) .sendMessage(nodeID, path, data).await() } catch (e: Exception) { if (e is ApiException || e.cause is ApiException) { @@ -434,7 +435,7 @@ abstract class WearableListenerViewModel(private val app: Application) : Android @Throws(ApiException::class) protected suspend fun sendPing(nodeID: String) { try { - Wearable.getMessageClient(context) + Wearable.getMessageClient(appContext) .sendMessage(nodeID, WearableHelper.PingPath, null).await() } catch (e: Exception) { if (e is ApiException || e.cause is ApiException) { diff --git a/wear/src/main/res/layout/activity_applauncher.xml b/wear/src/main/res/layout/activity_applauncher.xml deleted file mode 100644 index 69a7f30..0000000 --- a/wear/src/main/res/layout/activity_applauncher.xml +++ /dev/null @@ -1,86 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/wear/src/main/res/layout/activity_dashboard.xml b/wear/src/main/res/layout/activity_dashboard.xml deleted file mode 100644 index fd1ac4b..0000000 --- a/wear/src/main/res/layout/activity_dashboard.xml +++ /dev/null @@ -1,179 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/wear/src/main/res/layout/activity_valueaction.xml b/wear/src/main/res/layout/activity_valueaction.xml deleted file mode 100644 index 6747cf6..0000000 --- a/wear/src/main/res/layout/activity_valueaction.xml +++ /dev/null @@ -1,81 +0,0 @@ - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/wear/src/main/res/layout/applauncher_drawer_layout.xml b/wear/src/main/res/layout/applauncher_drawer_layout.xml deleted file mode 100644 index f53898c..0000000 --- a/wear/src/main/res/layout/applauncher_drawer_layout.xml +++ /dev/null @@ -1,43 +0,0 @@ - - - - - - - - - - From 96af0e73ac0d36570968fc38e838468844e188f8 Mon Sep 17 00:00:00 2001 From: Dave Antoine Date: Fri, 29 Mar 2024 00:11:32 -0400 Subject: [PATCH 18/58] CallManagerActivity: migrate to compose --- .../simplewear/CallManagerActivity.kt | 409 ++++------------- .../simplewear/ui/simplewear/CallManagerUi.kt | 410 ++++++++++++++++++ .../ui/simplewear/ValueActionScreen.kt | 4 + .../viewmodels/CallManagerViewModel.kt | 331 ++++++++++++++ .../main/res/layout/activity_callmanager.xml | 176 -------- wear/src/main/res/layout/layout_keypad.xml | 177 -------- 6 files changed, 823 insertions(+), 684 deletions(-) create mode 100644 wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/CallManagerUi.kt create mode 100644 wear/src/main/java/com/thewizrd/simplewear/viewmodels/CallManagerViewModel.kt delete mode 100644 wear/src/main/res/layout/activity_callmanager.xml delete mode 100644 wear/src/main/res/layout/layout_keypad.xml diff --git a/wear/src/main/java/com/thewizrd/simplewear/CallManagerActivity.kt b/wear/src/main/java/com/thewizrd/simplewear/CallManagerActivity.kt index 40840e5..0c1114a 100644 --- a/wear/src/main/java/com/thewizrd/simplewear/CallManagerActivity.kt +++ b/wear/src/main/java/com/thewizrd/simplewear/CallManagerActivity.kt @@ -1,381 +1,128 @@ package com.thewizrd.simplewear -import android.content.BroadcastReceiver -import android.content.Context import android.content.Intent -import android.content.IntentFilter import android.os.Bundle -import android.os.CountDownTimer -import android.util.Log -import android.view.View -import android.widget.TextView +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.viewModels import androidx.core.content.ContextCompat -import androidx.core.view.isVisible import androidx.lifecycle.lifecycleScope -import androidx.wear.widget.SwipeDismissFrameLayout -import com.google.android.gms.wearable.* +import androidx.wear.remote.interactions.RemoteActivityHelper import com.thewizrd.shared_resources.actions.ActionStatus -import com.thewizrd.shared_resources.actions.Actions -import com.thewizrd.shared_resources.actions.AudioStreamType import com.thewizrd.shared_resources.helpers.InCallUIHelper import com.thewizrd.shared_resources.helpers.WearConnectionStatus import com.thewizrd.shared_resources.helpers.WearableHelper -import com.thewizrd.shared_resources.utils.* import com.thewizrd.simplewear.controls.CustomConfirmationOverlay -import com.thewizrd.simplewear.databinding.ActivityCallmanagerBinding import com.thewizrd.simplewear.helpers.showConfirmationOverlay -import kotlinx.coroutines.* +import com.thewizrd.simplewear.ui.simplewear.CallManagerUi +import com.thewizrd.simplewear.viewmodels.CallManagerViewModel +import com.thewizrd.simplewear.viewmodels.WearableListenerViewModel +import com.thewizrd.simplewear.viewmodels.WearableListenerViewModel.Companion.ACTION_UPDATECONNECTIONSTATUS +import com.thewizrd.simplewear.viewmodels.WearableListenerViewModel.Companion.EXTRA_STATUS +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.guava.await -import kotlinx.coroutines.tasks.await +import kotlinx.coroutines.launch -class CallManagerActivity : WearableListenerActivity(), DataClient.OnDataChangedListener { - override lateinit var broadcastReceiver: BroadcastReceiver - private set - override lateinit var intentFilter: IntentFilter - private set +class CallManagerActivity : ComponentActivity() { + private val callManagerViewModel by viewModels() - private lateinit var binding: ActivityCallmanagerBinding - private lateinit var timer: CountDownTimer + private lateinit var remoteActivityHelper: RemoteActivityHelper override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - binding = ActivityCallmanagerBinding.inflate(layoutInflater) - setContentView(binding.root) + remoteActivityHelper = RemoteActivityHelper(this) - broadcastReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - lifecycleScope.launch { - if (intent.action != null) { - if (ACTION_UPDATECONNECTIONSTATUS == intent.action) { - when (WearConnectionStatus.valueOf( - intent.getIntExtra( - EXTRA_CONNECTIONSTATUS, - 0 - ) - )) { - WearConnectionStatus.DISCONNECTED -> { - // Navigate - startActivity( - Intent( - this@CallManagerActivity, - PhoneSyncActivity::class.java - ) - ) - finishAffinity() - } - WearConnectionStatus.APPNOTINSTALLED -> { - // Open store on remote device - val intentAndroid = Intent(Intent.ACTION_VIEW) - .addCategory(Intent.CATEGORY_BROWSABLE) - .setData(WearableHelper.getPlayStoreURI()) - - runCatching { - remoteActivityHelper.startRemoteActivity(intentAndroid) - .await() - - showConfirmationOverlay(true) - }.onFailure { - if (it !is CancellationException) { - showConfirmationOverlay(false) - } - } - - // Navigate - startActivity( - Intent( - this@CallManagerActivity, - PhoneSyncActivity::class.java - ) - ) - finishAffinity() - } - else -> { - } - } - } else { - Logger.writeLine( - Log.INFO, - "%s: Unhandled action: %s", - "CallManagerActivity", - intent.action - ) - } - } - } - } - } - - intentFilter = IntentFilter() - intentFilter.addAction(ACTION_UPDATECONNECTIONSTATUS) - - // Set timer for retrieving call status - timer = object : CountDownTimer(3000, 1000) { - override fun onTick(millisUntilFinished: Long) {} - override fun onFinish() { - refreshCallUI() - } + setContent { + CallManagerUi() } - - binding.endcallButton.setOnClickListener { - lifecycleScope.launch { - if (connect()) { - sendMessage(mPhoneNodeWithApp!!.id, InCallUIHelper.EndCallPath, null) - } - } - } - - binding.muteButton.setOnClickListener { - val isChecked = binding.muteButton.isChecked - lifecycleScope.launch { - if (connect()) { - sendMessage( - mPhoneNodeWithApp!!.id, - InCallUIHelper.MuteMicPath, - isChecked.booleanToBytes() - ) - } - } - } - - binding.volumeButton.setOnClickListener { - val intent: Intent = Intent(this, ValueActionActivity::class.java) - .putExtra(EXTRA_ACTION, Actions.VOLUME) - .putExtra(ValueActionActivity.EXTRA_STREAMTYPE, AudioStreamType.VOICE_CALL) - this.startActivityForResult(intent, -1) - } - - binding.speakerphoneButton.setOnClickListener { - val isChecked = binding.speakerphoneButton.isChecked - lifecycleScope.launch { - if (connect()) { - sendMessage( - mPhoneNodeWithApp!!.id, - InCallUIHelper.SpeakerphonePath, - isChecked.booleanToBytes() - ) - } - } - } - - binding.keypadButton.setOnClickListener { - binding.keypadLayout.root.isVisible = true - } - - binding.keypadLayout.root.addCallback(object : SwipeDismissFrameLayout.Callback() { - override fun onDismissed(layout: SwipeDismissFrameLayout?) { - super.onDismissed(layout) - layout?.isVisible = false - } - }) - - binding.keypadLayout.keypadText.setText("") - binding.keypadLayout.keypad0.setOnClickListener(keypadBtnOnClickListener) - binding.keypadLayout.keypad1.setOnClickListener(keypadBtnOnClickListener) - binding.keypadLayout.keypad2.setOnClickListener(keypadBtnOnClickListener) - binding.keypadLayout.keypad3.setOnClickListener(keypadBtnOnClickListener) - binding.keypadLayout.keypad4.setOnClickListener(keypadBtnOnClickListener) - binding.keypadLayout.keypad5.setOnClickListener(keypadBtnOnClickListener) - binding.keypadLayout.keypad6.setOnClickListener(keypadBtnOnClickListener) - binding.keypadLayout.keypad7.setOnClickListener(keypadBtnOnClickListener) - binding.keypadLayout.keypad8.setOnClickListener(keypadBtnOnClickListener) - binding.keypadLayout.keypad9.setOnClickListener(keypadBtnOnClickListener) - binding.keypadLayout.keypadPound.setOnClickListener(keypadBtnOnClickListener) - binding.keypadLayout.keypadStar.setOnClickListener(keypadBtnOnClickListener) } - private val keypadBtnOnClickListener = View.OnClickListener { - val digit = (it as TextView).text[0] - binding.keypadLayout.keypadText.text?.append(digit) - requestSendDTMFTone(digit) - } - - private fun requestSendDTMFTone(digit: Char) { - lifecycleScope.launch { - if (connect()) { - sendMessage(mPhoneNodeWithApp!!.id, InCallUIHelper.DTMFPath, digit.charToBytes()) - } - } - } + override fun onStart() { + super.onStart() - private fun showProgressBar(show: Boolean) { lifecycleScope.launch { - if (show) { - binding.progressBar.show() - } else { - binding.progressBar.hide() - } - } - } - - override fun onMessageReceived(messageEvent: MessageEvent) { - super.onMessageReceived(messageEvent) - - when (messageEvent.path) { - InCallUIHelper.CallStatePath -> { - val status = ActionStatus.valueOf(messageEvent.data.bytesToString()) + callManagerViewModel.eventFlow.collect { event -> + when (event.eventType) { + ACTION_UPDATECONNECTIONSTATUS -> { + val connectionStatus = WearConnectionStatus.valueOf( + event.data.getInt( + WearableListenerViewModel.EXTRA_CONNECTIONSTATUS, + 0 + ) + ) - if (status == ActionStatus.PERMISSION_DENIED) { - timer.cancel() + when (connectionStatus) { + WearConnectionStatus.DISCONNECTED -> { + // Navigate + startActivity( + Intent( + this@CallManagerActivity, + PhoneSyncActivity::class.java + ) + ) + finishAffinity() + } - CustomConfirmationOverlay() - .setType(CustomConfirmationOverlay.CUSTOM_ANIMATION) - .setCustomDrawable(ContextCompat.getDrawable(this, R.drawable.ws_full_sad)) - .setMessage(getString(R.string.error_permissiondenied)) - .showOn(this) + WearConnectionStatus.APPNOTINSTALLED -> { + // Open store on remote device + val intentAndroid = Intent(Intent.ACTION_VIEW) + .addCategory(Intent.CATEGORY_BROWSABLE) + .setData(WearableHelper.getPlayStoreURI()) - openAppOnPhone(false) + runCatching { + remoteActivityHelper.startRemoteActivity(intentAndroid) + .await() - showProgressBar(false) - showInCallUI(false) - } else if (status == ActionStatus.SUCCESS) { - refreshCallUI() - } - } - InCallUIHelper.MuteMicStatusPath -> { - val toggle = messageEvent.data.bytesToBool() - binding.muteButton.isChecked = toggle - } - InCallUIHelper.SpeakerphoneStatusPath -> { - val toggle = messageEvent.data.bytesToBool() - binding.speakerphoneButton.isChecked = toggle - } - } - } + showConfirmationOverlay(true) + }.onFailure { + if (it !is CancellationException) { + showConfirmationOverlay(false) + } + } - override fun onDataChanged(dataEventBuffer: DataEventBuffer) { - lifecycleScope.launch { - showProgressBar(false) + // Navigate + startActivity( + Intent( + this@CallManagerActivity, + PhoneSyncActivity::class.java + ) + ) + finishAffinity() + } - for (event in dataEventBuffer) { - if (event.type == DataEvent.TYPE_CHANGED) { - val item = event.dataItem - if (InCallUIHelper.CallStatePath == item.uri.path) { - try { - val dataMap = DataMapItem.fromDataItem(item).dataMap - updateCallUI(dataMap) - } catch (e: Exception) { - Logger.writeLine(Log.ERROR, e) + else -> {} } } - } - } - } - } - - private suspend fun updateCallUI(dataMap: DataMap) { - val callActive = dataMap.getBoolean(InCallUIHelper.KEY_CALLACTIVE, false) - val callerName = dataMap.getString(InCallUIHelper.KEY_CALLERNAME) - val callerBmp = dataMap.getAsset(InCallUIHelper.KEY_CALLERBMP)?.let { - try { - ImageUtils.bitmapFromAssetStream( - Wearable.getDataClient(this), - it - ) - } catch (e: Exception) { - null - } - } - val inCallFeatures = dataMap.getInt(InCallUIHelper.KEY_SUPPORTEDFEATURES) - val supportsSpeakerToggle = - inCallFeatures and InCallUIHelper.INCALL_FEATURE_SPEAKERPHONE != 0 - val canSendDTMFKey = inCallFeatures and InCallUIHelper.INCALL_FEATURE_DTMF != 0 - - lifecycleScope.launch { - if (callActive) { - if (!callerName.isNullOrBlank()) { - binding.incallCallerName.text = callerName - } else { - binding.incallCallerName.setText(R.string.message_callactive) - } - binding.callBackground.setImageBitmap(callerBmp) - binding.speakerphoneButton.isVisible = supportsSpeakerToggle - binding.keypadButton.isVisible = canSendDTMFKey - showInCallUI() - } else { - binding.speakerphoneButton.isVisible = false - binding.keypadButton.isVisible = false - binding.keypadLayout.root.isVisible = false - showInCallUI(false) - } - } - } - private fun showInCallUI(show: Boolean = true) { - binding.incallUi.visibility = if (show) View.VISIBLE else View.GONE - binding.nocallPrompt.visibility = if (show) View.GONE else View.VISIBLE - } + InCallUIHelper.CallStatePath -> { + val status = event.data.getSerializable(EXTRA_STATUS) as ActionStatus - private fun refreshCallUI() { - lifecycleScope.launch(Dispatchers.IO) { - try { - val buff = Wearable.getDataClient(this@CallManagerActivity) - .getDataItems( - WearableHelper.getWearDataUri( - "*", - InCallUIHelper.CallStatePath - ) - ) - .await() + if (status == ActionStatus.PERMISSION_DENIED) { + CustomConfirmationOverlay() + .setType(CustomConfirmationOverlay.CUSTOM_ANIMATION) + .setCustomDrawable( + ContextCompat.getDrawable( + this@CallManagerActivity, + R.drawable.ws_full_sad + ) + ) + .setMessage(getString(R.string.error_permissiondenied)) + .showOn(this@CallManagerActivity) - for (i in 0 until buff.count) { - val item = buff[i] - if (InCallUIHelper.CallStatePath == item.uri.path) { - try { - val dataMap = DataMapItem.fromDataItem(item).dataMap - updateCallUI(dataMap) - } catch (e: Exception) { - Logger.writeLine(Log.ERROR, e) + callManagerViewModel.openAppOnPhone(this@CallManagerActivity, false) } - showProgressBar(false) } } - - buff.release() - } catch (e: Exception) { - Logger.writeLine(Log.ERROR, e) - } - } - } - - private fun requestCallState() { - lifecycleScope.launch { - if (connect()) { - sendMessage(mPhoneNodeWithApp!!.id, InCallUIHelper.CallStatePath, null) - } - } - } - - private fun requestServiceDisconnect() { - lifecycleScope.launch { - if (connect()) { - sendMessage(mPhoneNodeWithApp!!.id, InCallUIHelper.DisconnectPath, null) } } } override fun onResume() { super.onResume() - Wearable.getDataClient(this).addListener(this) - - binding.incallCallerName.requestFocus() // Update statuses - lifecycleScope.launch { - showProgressBar(true) - binding.nocallPrompt.visibility = View.GONE - - updateConnectionStatus() - requestCallState() - // Wait for call state update - timer.start() - } - } - - override fun onPause() { - requestServiceDisconnect() - Wearable.getDataClient(this).removeListener(this) - super.onPause() + callManagerViewModel.refreshCallState() } override fun onDestroy() { diff --git a/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/CallManagerUi.kt b/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/CallManagerUi.kt new file mode 100644 index 0000000..f6c2ed6 --- /dev/null +++ b/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/CallManagerUi.kt @@ -0,0 +1,410 @@ +package com.thewizrd.simplewear.ui.simplewear + +import android.graphics.Bitmap +import androidx.annotation.DrawableRes +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.requiredSizeIn +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.wear.compose.material.Button +import androidx.wear.compose.material.ButtonDefaults +import androidx.wear.compose.material.Icon +import androidx.wear.compose.material.MaterialTheme +import androidx.wear.compose.material.Scaffold +import androidx.wear.compose.material.Text +import androidx.wear.compose.material.TimeText +import androidx.wear.compose.material.Vignette +import androidx.wear.compose.material.VignettePosition +import androidx.wear.compose.material.dialog.Dialog +import androidx.wear.compose.ui.tooling.preview.WearPreviewDevices +import androidx.wear.compose.ui.tooling.preview.WearPreviewFontScales +import com.thewizrd.shared_resources.helpers.WearConnectionStatus +import com.thewizrd.simplewear.R +import com.thewizrd.simplewear.ui.components.LoadingContent +import com.thewizrd.simplewear.ui.theme.WearAppTheme +import com.thewizrd.simplewear.ui.theme.activityViewModel +import com.thewizrd.simplewear.ui.theme.findActivity +import com.thewizrd.simplewear.viewmodels.CallManagerUiState +import com.thewizrd.simplewear.viewmodels.CallManagerViewModel + +@Composable +fun CallManagerUi( + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val activity = context.findActivity() + + val callManagerViewModel = activityViewModel() + val uiState by callManagerViewModel.uiState.collectAsState() + + WearAppTheme { + Scaffold( + modifier = modifier.background(MaterialTheme.colors.background), + vignette = { Vignette(vignettePosition = VignettePosition.TopAndBottom) }, + timeText = { + if (!uiState.isLoading) TimeText() + }, + ) { + LoadingContent( + empty = !uiState.isCallActive, + emptyContent = { + NoCallActiveScreen() + }, + loading = uiState.isLoading + ) { + CallManagerUi(callManagerViewModel = callManagerViewModel) + } + } + } +} + +@Composable +fun CallManagerUi( + callManagerViewModel: CallManagerViewModel +) { + val context = LocalContext.current + val activity = context.findActivity() + + val uiState by callManagerViewModel.uiState.collectAsState() + + var showKeyPadUi by remember { mutableStateOf(false) } + + CallManagerUi( + uiState = uiState, + onShowKeypadUi = { + showKeyPadUi = true + }, + onMute = { + callManagerViewModel.setMuteEnabled(!uiState.isMuted) + }, + onSpeakerPhone = { + callManagerViewModel.enableSpeakerphone(!uiState.isSpeakerPhoneOn) + }, + onVolume = { + callManagerViewModel.showCallVolumeActivity(activity) + }, + onEndCall = { + callManagerViewModel.endCall() + } + ) + + Dialog( + modifier = Modifier.fillMaxSize(), + showDialog = showKeyPadUi, + onDismissRequest = { showKeyPadUi = false } + ) { + KeypadScreen( + onKeyPressed = { digit -> + callManagerViewModel.requestSendDTMFTone(digit) + } + ) + } +} + +@OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class) +@Composable +private fun CallManagerUi( + uiState: CallManagerUiState, + onMute: () -> Unit = {}, + onShowKeypadUi: () -> Unit = {}, + onSpeakerPhone: () -> Unit = {}, + onVolume: () -> Unit = {}, + onEndCall: () -> Unit = {} +) { + val isPreview = LocalInspectionMode.current + val isRound = LocalConfiguration.current.isScreenRound + + Box( + modifier = Modifier.fillMaxSize() + ) { + if (isPreview) { + TimeText() + } + + if (uiState.callerBitmap != null) { + Image( + modifier = Modifier.fillMaxSize(), + bitmap = uiState.callerBitmap.asImageBitmap(), + contentDescription = null + ) + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(5.dp) + ) { + Spacer(modifier = Modifier.height(16.dp)) + + Box( + modifier = Modifier + .weight(1f, fill = true) + .fillMaxWidth() + .padding(horizontal = if (isRound) 32.dp else 8.dp), + contentAlignment = Alignment.Center + ) { + Text( + modifier = Modifier + .wrapContentHeight() + .basicMarquee(iterations = Int.MAX_VALUE), + text = uiState.callerName ?: stringResource(id = R.string.message_callactive), + maxLines = 1, + overflow = TextOverflow.Visible, + textAlign = TextAlign.Center + ) + } + + FlowRow( + modifier = Modifier + .fillMaxWidth(), + maxItemsInEachRow = 2, + horizontalArrangement = Arrangement.SpaceEvenly, + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + CallUiButton( + iconResourceId = R.drawable.ic_mic_off_24dp, + isChecked = uiState.isMuted, + onClick = onMute + ) + if (uiState.canSendDTMFKeys) { + CallUiButton( + iconResourceId = R.drawable.ic_dialpad_24dp, + onClick = onShowKeypadUi + ) + } + if (uiState.supportsSpeaker) { + CallUiButton( + iconResourceId = R.drawable.ic_baseline_speaker_phone_24, + isChecked = uiState.isSpeakerPhoneOn, + onClick = onSpeakerPhone + ) + } + CallUiButton( + iconResourceId = R.drawable.ic_volume_up_white_24dp, + onClick = onVolume + ) + } + + Button( + modifier = Modifier + .requiredSize(40.dp) + .align(Alignment.CenterHorizontally), + colors = ButtonDefaults.primaryButtonColors( + backgroundColor = colorResource(id = android.R.color.holo_red_dark), + contentColor = Color.White + ), + onClick = onEndCall + ) { + Icon( + painter = painterResource(id = R.drawable.ic_call_end_24dp), + contentDescription = stringResource(id = R.string.action_hangup) + ) + } + + Spacer(modifier = Modifier.height(4.dp)) + } + } +} + +@Composable +private fun CallUiButton( + modifier: Modifier = Modifier, + isChecked: Boolean = false, + @DrawableRes iconResourceId: Int, + contentDescription: String? = null, + onClick: () -> Unit = {} +) { + Box( + modifier = modifier + .requiredSizeIn(40.dp, 40.dp) + .clickable( + onClick = onClick, + role = Role.Button, + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple( + color = MaterialTheme.colors.onSurface, + radius = 20.dp + ) + ) + .border( + width = 1.dp, + brush = SolidColor(if (isChecked) Color.White else Color.Transparent), + shape = MaterialTheme.shapes.small + ), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier.padding( + horizontal = 12.dp + ) + ) { + Icon( + modifier = Modifier.requiredSize(24.dp), + painter = painterResource(id = iconResourceId), + contentDescription = contentDescription + ) + } + } +} + +@WearPreviewDevices +@Composable +private fun NoCallActiveScreen() { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = stringResource(id = R.string.message_nocall_active), + textAlign = TextAlign.Center + ) + } +} + +@OptIn(ExperimentalLayoutApi::class) +@WearPreviewDevices +@Composable +private fun KeypadScreen( + onKeyPressed: (Char) -> Unit = {} +) { + val isPreview = LocalInspectionMode.current + val context = LocalContext.current + val isRound = LocalConfiguration.current.isScreenRound + val screenHeightDp = LocalConfiguration.current.screenHeightDp + + var keypadText by remember { mutableStateOf("") } + val digits by remember { + derivedStateOf { listOf('1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '0', '#') } + } + + Column( + modifier = Modifier.fillMaxSize() + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(0.2f) + .background(Color(0xFF444444)) + .padding( + start = if (isRound) 48.dp else 8.dp, + end = if (isRound) 48.dp else 8.dp, + bottom = 4.dp + ) + .clipToBounds(), + contentAlignment = Alignment.BottomCenter + ) { + Text( + modifier = Modifier.wrapContentWidth( + align = Alignment.End, + unbounded = true + ), + text = if (isPreview) "01234567891110" else keypadText, + fontWeight = FontWeight.Light, + fontSize = 18.sp, + maxLines = 1, + textAlign = TextAlign.Center, + overflow = TextOverflow.Visible + ) + } + FlowRow( + modifier = Modifier + .fillMaxWidth() + .weight(1f, fill = true) + .padding( + start = if (isRound) 32.dp else 8.dp, + end = if (isRound) 32.dp else 8.dp, + bottom = if (isRound) 32.dp else 8.dp + ), + maxItemsInEachRow = 3, + horizontalArrangement = Arrangement.Center, + verticalArrangement = Arrangement.Center + ) { + digits.forEach { + Box( + modifier = Modifier + .weight(1f, fill = true) + .fillMaxHeight(1f / 4f) + .clickable { + keypadText += it + onKeyPressed.invoke(it) + }, + contentAlignment = Alignment.Center + ) { + Text( + text = it + "", + maxLines = 1, + textAlign = TextAlign.Center, + fontSize = 16.sp + ) + } + } + } + } +} + +@WearPreviewDevices +@WearPreviewFontScales +@Composable +private fun PreviewCallManagerUi() { + val bmp = remember { + Bitmap.createBitmap(intArrayOf(0x50400080), 1, 1, Bitmap.Config.ARGB_8888) + } + + val uiState = remember { + CallManagerUiState( + connectionStatus = WearConnectionStatus.CONNECTED, + callerBitmap = bmp, + isSpeakerPhoneOn = true, + isCallActive = true, + isMuted = true, + supportsSpeaker = true, + canSendDTMFKeys = true + ) + } + + CallManagerUi(uiState = uiState) +} \ No newline at end of file diff --git a/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/ValueActionScreen.kt b/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/ValueActionScreen.kt index 9d98c0b..801689a 100644 --- a/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/ValueActionScreen.kt +++ b/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/ValueActionScreen.kt @@ -22,6 +22,7 @@ import androidx.wear.compose.material.PositionIndicator import androidx.wear.compose.material.Scaffold import androidx.wear.compose.material.Stepper import androidx.wear.compose.material.Text +import androidx.wear.compose.material.TimeText import androidx.wear.compose.material.Vignette import androidx.wear.compose.material.VignettePosition import androidx.wear.compose.ui.tooling.preview.WearPreviewDevices @@ -51,6 +52,9 @@ fun ValueActionScreen( Scaffold( modifier = modifier.background(MaterialTheme.colors.background), vignette = { Vignette(vignettePosition = VignettePosition.TopAndBottom) }, + timeText = { + TimeText() + }, ) { ValueActionScreen(valueActionViewModel) } diff --git a/wear/src/main/java/com/thewizrd/simplewear/viewmodels/CallManagerViewModel.kt b/wear/src/main/java/com/thewizrd/simplewear/viewmodels/CallManagerViewModel.kt new file mode 100644 index 0000000..f707191 --- /dev/null +++ b/wear/src/main/java/com/thewizrd/simplewear/viewmodels/CallManagerViewModel.kt @@ -0,0 +1,331 @@ +package com.thewizrd.simplewear.viewmodels + +import android.app.Activity +import android.app.Application +import android.content.Intent +import android.graphics.Bitmap +import android.os.Bundle +import android.os.CountDownTimer +import android.util.Log +import androidx.lifecycle.viewModelScope +import com.google.android.gms.wearable.DataClient.OnDataChangedListener +import com.google.android.gms.wearable.DataEvent +import com.google.android.gms.wearable.DataEventBuffer +import com.google.android.gms.wearable.DataMap +import com.google.android.gms.wearable.DataMapItem +import com.google.android.gms.wearable.MessageEvent +import com.google.android.gms.wearable.Wearable +import com.thewizrd.shared_resources.actions.ActionStatus +import com.thewizrd.shared_resources.actions.Actions +import com.thewizrd.shared_resources.actions.AudioStreamType +import com.thewizrd.shared_resources.helpers.InCallUIHelper +import com.thewizrd.shared_resources.helpers.WearConnectionStatus +import com.thewizrd.shared_resources.helpers.WearableHelper +import com.thewizrd.shared_resources.utils.ImageUtils +import com.thewizrd.shared_resources.utils.Logger +import com.thewizrd.shared_resources.utils.booleanToBytes +import com.thewizrd.shared_resources.utils.bytesToBool +import com.thewizrd.shared_resources.utils.bytesToString +import com.thewizrd.shared_resources.utils.charToBytes +import com.thewizrd.simplewear.R +import com.thewizrd.simplewear.ValueActionActivity +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.tasks.await + +data class CallManagerUiState( + val connectionStatus: WearConnectionStatus? = null, + val isLoading: Boolean = false, + val isSpeakerPhoneOn: Boolean = false, + val isMuted: Boolean = false, + + // InCallUi + val callerName: String? = null, + val callerBitmap: Bitmap? = null, + val supportsSpeaker: Boolean = false, + val canSendDTMFKeys: Boolean = false, + val isCallActive: Boolean = false, +) + +class CallManagerViewModel(app: Application) : WearableListenerViewModel(app), + OnDataChangedListener { + private val viewModelState = MutableStateFlow(CallManagerUiState(isLoading = true)) + + private val timer: CountDownTimer + + val uiState = viewModelState.stateIn( + viewModelScope, + SharingStarted.Eagerly, + viewModelState.value + ) + + init { + Wearable.getDataClient(appContext).addListener(this) + + // Set timer for retrieving call status + timer = object : CountDownTimer(3000, 1000) { + override fun onTick(millisUntilFinished: Long) {} + override fun onFinish() { + refreshCallUI() + } + } + + viewModelScope.launch { + eventFlow.collect { event -> + when (event.eventType) { + ACTION_UPDATECONNECTIONSTATUS -> { + val connectionStatus = WearConnectionStatus.valueOf( + event.data.getInt( + EXTRA_CONNECTIONSTATUS, + 0 + ) + ) + + viewModelState.update { + it.copy( + connectionStatus = connectionStatus + ) + } + } + } + } + } + } + + fun refreshCallState() { + viewModelState.update { + it.copy( + isLoading = true + ) + } + + viewModelScope.launch { + updateConnectionStatus() + requestCallState() + timer.start() + } + } + + override fun onMessageReceived(messageEvent: MessageEvent) { + when (messageEvent.path) { + WearableHelper.LaunchAppPath -> { + viewModelScope.launch { + val status = ActionStatus.valueOf(messageEvent.data.bytesToString()) + + _eventsFlow.tryEmit(WearableEvent(messageEvent.path, Bundle().apply { + putSerializable(EXTRA_STATUS, status) + })) + } + } + + InCallUIHelper.MuteMicStatusPath -> { + viewModelState.update { + it.copy( + isMuted = messageEvent.data.bytesToBool() + ) + } + } + + InCallUIHelper.SpeakerphoneStatusPath -> { + viewModelState.update { + it.copy( + isSpeakerPhoneOn = messageEvent.data.bytesToBool() + ) + } + } + + InCallUIHelper.CallStatePath -> { + viewModelScope.launch { + val status = ActionStatus.valueOf(messageEvent.data.bytesToString()) + + when (status) { + ActionStatus.PERMISSION_DENIED -> { + timer.cancel() + + viewModelState.update { + it.copy( + isLoading = false, + isCallActive = false + ) + } + } + + ActionStatus.SUCCESS -> refreshCallUI() + else -> {} + } + + _eventsFlow.tryEmit(WearableEvent(messageEvent.path, Bundle().apply { + putSerializable(EXTRA_STATUS, status) + })) + } + } + + else -> { + super.onMessageReceived(messageEvent) + } + } + } + + override fun onDataChanged(dataEventBuffer: DataEventBuffer) { + viewModelScope.launch { + viewModelState.update { + it.copy( + isLoading = false + ) + } + + for (event in dataEventBuffer) { + if (event.type == DataEvent.TYPE_CHANGED) { + val item = event.dataItem + if (InCallUIHelper.CallStatePath == item.uri.path) { + try { + val dataMap = DataMapItem.fromDataItem(item).dataMap + updateCallUI(dataMap) + } catch (e: Exception) { + Logger.writeLine(Log.ERROR, e) + } + } + } + } + } + } + + private suspend fun updateCallUI(dataMap: DataMap) { + val callActive = dataMap.getBoolean(InCallUIHelper.KEY_CALLACTIVE, false) + val callerName = dataMap.getString(InCallUIHelper.KEY_CALLERNAME) + val callerBmp = dataMap.getAsset(InCallUIHelper.KEY_CALLERBMP)?.let { + try { + ImageUtils.bitmapFromAssetStream( + Wearable.getDataClient(appContext), + it + ) + } catch (e: Exception) { + null + } + } + val inCallFeatures = dataMap.getInt(InCallUIHelper.KEY_SUPPORTEDFEATURES) + val supportsSpeakerToggle = + inCallFeatures and InCallUIHelper.INCALL_FEATURE_SPEAKERPHONE != 0 + val canSendDTMFKey = inCallFeatures and InCallUIHelper.INCALL_FEATURE_DTMF != 0 + + viewModelState.update { + it.copy( + callerName = callerName?.takeIf { it.isNotBlank() } + ?: appContext.getString(R.string.message_callactive), + callerBitmap = if (callActive) callerBmp else null, + supportsSpeaker = callActive && supportsSpeakerToggle, + canSendDTMFKeys = callActive && canSendDTMFKey, + isCallActive = callActive + ) + } + } + + private fun refreshCallUI() { + viewModelScope.launch(Dispatchers.IO) { + try { + val buff = Wearable.getDataClient(appContext) + .getDataItems( + WearableHelper.getWearDataUri( + "*", + InCallUIHelper.CallStatePath + ) + ) + .await() + + for (i in 0 until buff.count) { + val item = buff[i] + if (InCallUIHelper.CallStatePath == item.uri.path) { + try { + val dataMap = DataMapItem.fromDataItem(item).dataMap + updateCallUI(dataMap) + } catch (e: Exception) { + Logger.writeLine(Log.ERROR, e) + } + viewModelState.update { + it.copy( + isLoading = false + ) + } + } + } + + buff.release() + } catch (e: Exception) { + Logger.writeLine(Log.ERROR, e) + } + } + } + + private fun requestCallState() { + viewModelScope.launch { + if (connect()) { + sendMessage(mPhoneNodeWithApp!!.id, InCallUIHelper.CallStatePath, null) + } + } + } + + private fun requestServiceDisconnect() { + viewModelScope.launch { + if (connect()) { + sendMessage(mPhoneNodeWithApp!!.id, InCallUIHelper.DisconnectPath, null) + } + } + } + + fun requestSendDTMFTone(digit: Char) { + viewModelScope.launch { + if (connect()) { + sendMessage(mPhoneNodeWithApp!!.id, InCallUIHelper.DTMFPath, digit.charToBytes()) + } + } + } + + fun enableSpeakerphone(enable: Boolean = true) { + viewModelScope.launch { + if (connect()) { + sendMessage( + mPhoneNodeWithApp!!.id, + InCallUIHelper.SpeakerphonePath, + enable.booleanToBytes() + ) + } + } + } + + fun setMuteEnabled(enable: Boolean = true) { + viewModelScope.launch { + if (connect()) { + sendMessage( + mPhoneNodeWithApp!!.id, + InCallUIHelper.MuteMicPath, + enable.booleanToBytes() + ) + } + } + } + + fun endCall() { + viewModelScope.launch { + if (connect()) { + sendMessage(mPhoneNodeWithApp!!.id, InCallUIHelper.EndCallPath, null) + } + } + } + + fun showCallVolumeActivity(activityContext: Activity) { + val intent: Intent = Intent(activityContext, ValueActionActivity::class.java) + .putExtra(EXTRA_ACTION, Actions.VOLUME) + .putExtra(ValueActionActivity.EXTRA_STREAMTYPE, AudioStreamType.VOICE_CALL) + activityContext.startActivityForResult(intent, -1) + } + + override fun onCleared() { + requestServiceDisconnect() + Wearable.getDataClient(appContext).removeListener(this) + super.onCleared() + } +} \ No newline at end of file diff --git a/wear/src/main/res/layout/activity_callmanager.xml b/wear/src/main/res/layout/activity_callmanager.xml deleted file mode 100644 index 0de2851..0000000 --- a/wear/src/main/res/layout/activity_callmanager.xml +++ /dev/null @@ -1,176 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/wear/src/main/res/layout/layout_keypad.xml b/wear/src/main/res/layout/layout_keypad.xml deleted file mode 100644 index 73765f1..0000000 --- a/wear/src/main/res/layout/layout_keypad.xml +++ /dev/null @@ -1,177 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file From c259e189891f879bec3c1d450295693f6428aa3b Mon Sep 17 00:00:00 2001 From: Dave Antoine Date: Sat, 30 Mar 2024 14:17:03 -0400 Subject: [PATCH 19/58] PhoneSyncActivity: migrate to compose --- .../simplewear/AppLauncherActivity.kt | 23 +- .../simplewear/CallManagerActivity.kt | 24 +- .../thewizrd/simplewear/DashboardActivity.kt | 49 +-- .../thewizrd/simplewear/PhoneSyncActivity.kt | 260 ++------------- .../simplewear/ValueActionActivity.kt | 22 +- .../simplewear/ui/simplewear/PhoneSyncUi.kt | 313 ++++++++++++++++++ .../thewizrd/simplewear/utils/ErrorMessage.kt | 13 + .../viewmodels/PhoneSyncViewModel.kt | 194 +++++++++++ .../viewmodels/WearableListenerViewModel.kt | 51 +-- 9 files changed, 600 insertions(+), 349 deletions(-) create mode 100644 wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/PhoneSyncUi.kt create mode 100644 wear/src/main/java/com/thewizrd/simplewear/utils/ErrorMessage.kt create mode 100644 wear/src/main/java/com/thewizrd/simplewear/viewmodels/PhoneSyncViewModel.kt diff --git a/wear/src/main/java/com/thewizrd/simplewear/AppLauncherActivity.kt b/wear/src/main/java/com/thewizrd/simplewear/AppLauncherActivity.kt index 94e1116..65d6edf 100644 --- a/wear/src/main/java/com/thewizrd/simplewear/AppLauncherActivity.kt +++ b/wear/src/main/java/com/thewizrd/simplewear/AppLauncherActivity.kt @@ -7,29 +7,21 @@ import androidx.activity.compose.setContent import androidx.activity.viewModels import androidx.core.content.ContextCompat import androidx.lifecycle.lifecycleScope -import androidx.wear.remote.interactions.RemoteActivityHelper import com.thewizrd.shared_resources.actions.ActionStatus import com.thewizrd.shared_resources.helpers.WearConnectionStatus import com.thewizrd.shared_resources.helpers.WearableHelper import com.thewizrd.simplewear.controls.CustomConfirmationOverlay -import com.thewizrd.simplewear.helpers.showConfirmationOverlay import com.thewizrd.simplewear.ui.simplewear.AppLauncherScreen import com.thewizrd.simplewear.viewmodels.AppLauncherViewModel import com.thewizrd.simplewear.viewmodels.WearableListenerViewModel -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.guava.await import kotlinx.coroutines.launch class AppLauncherActivity : ComponentActivity() { private val appLauncherViewModel by viewModels() - private lateinit var remoteActivityHelper: RemoteActivityHelper - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - remoteActivityHelper = RemoteActivityHelper(this) - setContent { AppLauncherScreen() } @@ -65,20 +57,7 @@ class AppLauncherActivity : ComponentActivity() { WearConnectionStatus.APPNOTINSTALLED -> { // Open store on remote device - val intentAndroid = Intent(Intent.ACTION_VIEW) - .addCategory(Intent.CATEGORY_BROWSABLE) - .setData(WearableHelper.getPlayStoreURI()) - - runCatching { - remoteActivityHelper.startRemoteActivity(intentAndroid) - .await() - - showConfirmationOverlay(true) - }.onFailure { - if (it !is CancellationException) { - showConfirmationOverlay(false) - } - } + appLauncherViewModel.openPlayStore(this@AppLauncherActivity) // Navigate startActivity( diff --git a/wear/src/main/java/com/thewizrd/simplewear/CallManagerActivity.kt b/wear/src/main/java/com/thewizrd/simplewear/CallManagerActivity.kt index 0c1114a..edbc46c 100644 --- a/wear/src/main/java/com/thewizrd/simplewear/CallManagerActivity.kt +++ b/wear/src/main/java/com/thewizrd/simplewear/CallManagerActivity.kt @@ -7,32 +7,23 @@ import androidx.activity.compose.setContent import androidx.activity.viewModels import androidx.core.content.ContextCompat import androidx.lifecycle.lifecycleScope -import androidx.wear.remote.interactions.RemoteActivityHelper import com.thewizrd.shared_resources.actions.ActionStatus import com.thewizrd.shared_resources.helpers.InCallUIHelper import com.thewizrd.shared_resources.helpers.WearConnectionStatus -import com.thewizrd.shared_resources.helpers.WearableHelper import com.thewizrd.simplewear.controls.CustomConfirmationOverlay -import com.thewizrd.simplewear.helpers.showConfirmationOverlay import com.thewizrd.simplewear.ui.simplewear.CallManagerUi import com.thewizrd.simplewear.viewmodels.CallManagerViewModel import com.thewizrd.simplewear.viewmodels.WearableListenerViewModel import com.thewizrd.simplewear.viewmodels.WearableListenerViewModel.Companion.ACTION_UPDATECONNECTIONSTATUS import com.thewizrd.simplewear.viewmodels.WearableListenerViewModel.Companion.EXTRA_STATUS -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.guava.await import kotlinx.coroutines.launch class CallManagerActivity : ComponentActivity() { private val callManagerViewModel by viewModels() - private lateinit var remoteActivityHelper: RemoteActivityHelper - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - remoteActivityHelper = RemoteActivityHelper(this) - setContent { CallManagerUi() } @@ -66,20 +57,7 @@ class CallManagerActivity : ComponentActivity() { WearConnectionStatus.APPNOTINSTALLED -> { // Open store on remote device - val intentAndroid = Intent(Intent.ACTION_VIEW) - .addCategory(Intent.CATEGORY_BROWSABLE) - .setData(WearableHelper.getPlayStoreURI()) - - runCatching { - remoteActivityHelper.startRemoteActivity(intentAndroid) - .await() - - showConfirmationOverlay(true) - }.onFailure { - if (it !is CancellationException) { - showConfirmationOverlay(false) - } - } + callManagerViewModel.openPlayStore(this@CallManagerActivity) // Navigate startActivity( diff --git a/wear/src/main/java/com/thewizrd/simplewear/DashboardActivity.kt b/wear/src/main/java/com/thewizrd/simplewear/DashboardActivity.kt index a15f14d..21c92f7 100644 --- a/wear/src/main/java/com/thewizrd/simplewear/DashboardActivity.kt +++ b/wear/src/main/java/com/thewizrd/simplewear/DashboardActivity.kt @@ -15,8 +15,6 @@ import androidx.core.content.PermissionChecker import androidx.lifecycle.lifecycleScope import androidx.lifecycle.withStarted import androidx.preference.PreferenceManager -import androidx.wear.remote.interactions.RemoteActivityHelper -import androidx.wear.widget.ConfirmationOverlay import com.thewizrd.shared_resources.actions.Action import com.thewizrd.shared_resources.actions.ActionStatus import com.thewizrd.shared_resources.actions.Actions @@ -26,30 +24,21 @@ import com.thewizrd.shared_resources.sleeptimer.SleepTimerHelper import com.thewizrd.shared_resources.utils.JSONParser import com.thewizrd.simplewear.controls.ActionButtonViewModel import com.thewizrd.simplewear.controls.CustomConfirmationOverlay -import com.thewizrd.simplewear.helpers.showConfirmationOverlay import com.thewizrd.simplewear.preferences.Settings import com.thewizrd.simplewear.ui.simplewear.Dashboard +import com.thewizrd.simplewear.utils.ErrorMessage import com.thewizrd.simplewear.viewmodels.DashboardViewModel -import com.thewizrd.simplewear.viewmodels.WearableListenerViewModel.Companion.ACTION_OPENONPHONE import com.thewizrd.simplewear.viewmodels.WearableListenerViewModel.Companion.ACTION_UPDATECONNECTIONSTATUS import com.thewizrd.simplewear.viewmodels.WearableListenerViewModel.Companion.EXTRA_ACTIONDATA import com.thewizrd.simplewear.viewmodels.WearableListenerViewModel.Companion.EXTRA_CONNECTIONSTATUS -import com.thewizrd.simplewear.viewmodels.WearableListenerViewModel.Companion.EXTRA_SHOWANIMATION -import com.thewizrd.simplewear.viewmodels.WearableListenerViewModel.Companion.EXTRA_SUCCESS -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.guava.await import kotlinx.coroutines.launch class DashboardActivity : ComponentActivity(), OnSharedPreferenceChangeListener { private val dashboardViewModel by viewModels() - private lateinit var remoteActivityHelper: RemoteActivityHelper - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - remoteActivityHelper = RemoteActivityHelper(this) - PreferenceManager.getDefaultSharedPreferences(this@DashboardActivity) .registerOnSharedPreferenceChangeListener(this@DashboardActivity) @@ -108,20 +97,7 @@ class DashboardActivity : ComponentActivity(), OnSharedPreferenceChangeListener WearConnectionStatus.CONNECTING -> {} WearConnectionStatus.APPNOTINSTALLED -> { // Open store on remote device - val intentAndroid = Intent(Intent.ACTION_VIEW) - .addCategory(Intent.CATEGORY_BROWSABLE) - .setData(WearableHelper.getPlayStoreURI()) - - runCatching { - remoteActivityHelper.startRemoteActivity(intentAndroid) - .await() - - showConfirmationOverlay(true) - }.onFailure { - if (it !is CancellationException) { - showConfirmationOverlay(false) - } - } + dashboardViewModel.openPlayStore(this@DashboardActivity) // Navigate startActivity( @@ -137,16 +113,6 @@ class DashboardActivity : ComponentActivity(), OnSharedPreferenceChangeListener } } - ACTION_OPENONPHONE -> { - val success = event.data.getBoolean(EXTRA_SUCCESS, false) - val showAni = event.data.getBoolean(EXTRA_SHOWANIMATION, false) - if (showAni) { - ConfirmationOverlay() - .setType(if (success) ConfirmationOverlay.OPEN_ON_PHONE_ANIMATION else ConfirmationOverlay.FAILURE_ANIMATION) - .showOn(this@DashboardActivity) - } - } - WearableHelper.ActionsPath -> { val jsonData = event.data.getString(EXTRA_ACTIONDATA) val action = JSONParser.deserializer(jsonData, Action::class.java)!! @@ -281,7 +247,16 @@ class DashboardActivity : ComponentActivity(), OnSharedPreferenceChangeListener lifecycleScope.launch { dashboardViewModel.errorMessagesFlow.collect { error -> - Toast.makeText(applicationContext, error, Toast.LENGTH_SHORT).show() + when (error) { + is ErrorMessage.String -> { + Toast.makeText(applicationContext, error.message, Toast.LENGTH_SHORT).show() + } + + is ErrorMessage.Resource -> { + Toast.makeText(applicationContext, error.stringId, Toast.LENGTH_SHORT) + .show() + } + } } } } diff --git a/wear/src/main/java/com/thewizrd/simplewear/PhoneSyncActivity.kt b/wear/src/main/java/com/thewizrd/simplewear/PhoneSyncActivity.kt index 818ffbb..b8e2bd0 100644 --- a/wear/src/main/java/com/thewizrd/simplewear/PhoneSyncActivity.kt +++ b/wear/src/main/java/com/thewizrd/simplewear/PhoneSyncActivity.kt @@ -1,261 +1,69 @@ package com.thewizrd.simplewear -import android.Manifest -import android.bluetooth.BluetoothAdapter -import android.bluetooth.BluetoothManager -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.content.pm.PackageManager -import android.net.wifi.WifiManager -import android.os.Build import android.os.Bundle -import android.provider.Settings -import android.view.View import android.widget.Toast -import androidx.activity.result.ActivityResultLauncher -import androidx.activity.result.contract.ActivityResultContracts -import androidx.core.content.ContextCompat -import androidx.core.content.PermissionChecker +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.viewModels import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.lifecycle.lifecycleScope -import androidx.wear.widget.ConfirmationOverlay -import com.thewizrd.shared_resources.helpers.WearConnectionStatus -import com.thewizrd.shared_resources.helpers.WearableHelper -import com.thewizrd.simplewear.databinding.ActivitySetupSyncBinding -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.guava.await -import kotlinx.coroutines.isActive +import com.thewizrd.simplewear.ui.simplewear.PhoneSyncUi +import com.thewizrd.simplewear.utils.ErrorMessage +import com.thewizrd.simplewear.viewmodels.PhoneSyncViewModel +import com.thewizrd.simplewear.viewmodels.WearableListenerViewModel.Companion.ACTION_OPENONPHONE import kotlinx.coroutines.launch -class PhoneSyncActivity : WearableListenerActivity() { - override lateinit var broadcastReceiver: BroadcastReceiver - private set - override lateinit var intentFilter: IntentFilter - private set - - private lateinit var binding: ActivitySetupSyncBinding - - private lateinit var bluetoothRequestLauncher: ActivityResultLauncher - private lateinit var permissionRequestLauncher: ActivityResultLauncher> +class PhoneSyncActivity : ComponentActivity() { + private val phoneSyncViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { installSplashScreen() super.onCreate(savedInstanceState) - // Create your application here - binding = ActivitySetupSyncBinding.inflate(layoutInflater) - setContentView(binding.root) - - binding.wifiButton.setOnClickListener { - runCatching { - startActivity(Intent(Settings.ACTION_WIFI_SETTINGS)) - } + setContent { + PhoneSyncUi() } + } - binding.bluetoothButton.setOnClickListener { - runCatching { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && PermissionChecker.checkSelfPermission( - this, - Manifest.permission.BLUETOOTH_CONNECT - ) != PermissionChecker.PERMISSION_GRANTED - ) { - permissionRequestLauncher.launch(arrayOf(Manifest.permission.BLUETOOTH_CONNECT)) - } else { - bluetoothRequestLauncher.launch(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)) - } - } - } - - startProgressBar() - - broadcastReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - if (ACTION_UPDATECONNECTIONSTATUS == intent.action) { - when (WearConnectionStatus.valueOf( - intent.getIntExtra( - EXTRA_CONNECTIONSTATUS, - 0 - ) - )) { - WearConnectionStatus.DISCONNECTED -> { - binding.message.setText(R.string.status_disconnected) - binding.button.setImageDrawable( - ContextCompat.getDrawable( - context, - R.drawable.ic_phonelink_erase_white_24dp - ) - ) - binding.circularProgress.setOnClickListener { - lifecycleScope.launch { - startProgressBar() - updateConnectionStatus() - } - } - checkNetworkStatus() - stopProgressBar() - } - WearConnectionStatus.CONNECTING -> { - binding.message.setText(R.string.status_connecting) - binding.button.setImageDrawable( - ContextCompat.getDrawable( - context, - android.R.drawable.ic_popup_sync - ) - ) - binding.wifiButton.visibility = View.GONE - binding.bluetoothButton.visibility = View.GONE - } - WearConnectionStatus.APPNOTINSTALLED -> { - binding.message.setText(R.string.error_notinstalled) - - binding.circularProgress.setOnClickListener { - // Open store on remote device - val intentAndroid = Intent(Intent.ACTION_VIEW) - .addCategory(Intent.CATEGORY_BROWSABLE) - .setData(WearableHelper.getPlayStoreURI()) - - lifecycleScope.launch { - runCatching { - remoteActivityHelper.startRemoteActivity(intentAndroid) - .await() - - // Show open on phone animation - ConfirmationOverlay() - .setType(ConfirmationOverlay.OPEN_ON_PHONE_ANIMATION) - .setMessage(this@PhoneSyncActivity.getString(R.string.message_openedonphone)) - .showOn(this@PhoneSyncActivity) - } - } - } - binding.button.setImageDrawable( - ContextCompat.getDrawable( - context, - R.drawable.common_full_open_on_phone - ) - ) - binding.wifiButton.visibility = View.GONE - binding.bluetoothButton.visibility = View.GONE - - stopProgressBar() - } - WearConnectionStatus.CONNECTED -> { - binding.message.setText(R.string.status_connected) - binding.button.setImageDrawable( - ContextCompat.getDrawable( - context, - android.R.drawable.ic_popup_sync - ) - ) - binding.bluetoothButton.visibility = View.GONE - - lifecycleScope.launch { - // Verify connection by sending a 'ping' - runCatching { - sendPing(mPhoneNodeWithApp!!.id) - }.onSuccess { - // Continue operation - startActivity( - Intent( - this@PhoneSyncActivity, - DashboardActivity::class.java - ).addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION) - ) - stopProgressBar() - finishAfterTransition() - }.onFailure { - setConnectionStatus(WearConnectionStatus.DISCONNECTED) - } - } - } - } - } else if (ACTION_OPENONPHONE == intent.action) { - val success = intent.getBooleanExtra(EXTRA_SUCCESS, false) + override fun onStart() { + super.onStart() - ConfirmationOverlay() - .setType(if (success) ConfirmationOverlay.OPEN_ON_PHONE_ANIMATION else ConfirmationOverlay.FAILURE_ANIMATION) - .showOn(this@PhoneSyncActivity) + phoneSyncViewModel.initActivityContext(this) - if (!success) { - binding.message.setText(R.string.error_syncing) + lifecycleScope.launch { + phoneSyncViewModel.eventFlow.collect { event -> + when (event.eventType) { + ACTION_OPENONPHONE -> { + Toast.makeText( + this@PhoneSyncActivity, + R.string.error_syncing, + Toast.LENGTH_SHORT + ).show() } } } } - binding.message.setText(R.string.message_gettingstatus) - - intentFilter = IntentFilter(ACTION_UPDATECONNECTIONSTATUS) - - bluetoothRequestLauncher = - registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - if (it.resultCode == RESULT_OK) { - lifecycleScope.launch(Dispatchers.Main) { - delay(2000) - startProgressBar() - delay(10000) - if (isActive) { - stopProgressBar() - } + lifecycleScope.launch { + phoneSyncViewModel.errorMessagesFlow.collect { error -> + when (error) { + is ErrorMessage.String -> { + Toast.makeText(applicationContext, error.message, Toast.LENGTH_SHORT).show() } - } - } - permissionRequestLauncher = - registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions -> - permissions.entries.forEach { (permission, granted) -> - when (permission) { - Manifest.permission.BLUETOOTH_CONNECT -> { - if (granted) { - bluetoothRequestLauncher.launch(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)) - } - } + is ErrorMessage.Resource -> { + Toast.makeText(applicationContext, error.stringId, Toast.LENGTH_SHORT) + .show() } } } - } - - private fun stopProgressBar() { - binding.circularProgress.isIndeterminate = false - } - - private fun startProgressBar() { - binding.circularProgress.isIndeterminate = true + } } override fun onResume() { super.onResume() // Update statuses - lifecycleScope.launch { - updateConnectionStatus() - } - } - - private fun checkNetworkStatus() { - val btAdapter = getSystemService(BluetoothManager::class.java)?.adapter - if (btAdapter != null) { - if (btAdapter.isEnabled || btAdapter.state == BluetoothAdapter.STATE_TURNING_ON) { - binding.bluetoothButton.visibility = View.GONE - } else { - Toast.makeText(this, R.string.message_enablebt, Toast.LENGTH_SHORT).show() - binding.bluetoothButton.visibility = View.VISIBLE - } - } else { - binding.bluetoothButton.visibility = View.GONE - } - - val wifiMgr = ContextCompat.getSystemService(this, WifiManager::class.java) - if (wifiMgr != null && packageManager.hasSystemFeature(PackageManager.FEATURE_WIFI)) { - if (!wifiMgr.isWifiEnabled) { - binding.wifiButton.visibility = View.VISIBLE - } else { - binding.wifiButton.visibility = View.GONE - } - } else { - binding.wifiButton.visibility = View.GONE - } + phoneSyncViewModel.refreshConnectionStatus() } } \ No newline at end of file diff --git a/wear/src/main/java/com/thewizrd/simplewear/ValueActionActivity.kt b/wear/src/main/java/com/thewizrd/simplewear/ValueActionActivity.kt index 32e65a5..6941db5 100644 --- a/wear/src/main/java/com/thewizrd/simplewear/ValueActionActivity.kt +++ b/wear/src/main/java/com/thewizrd/simplewear/ValueActionActivity.kt @@ -7,7 +7,6 @@ import androidx.activity.compose.setContent import androidx.activity.viewModels import androidx.core.content.ContextCompat import androidx.lifecycle.lifecycleScope -import androidx.wear.remote.interactions.RemoteActivityHelper import com.thewizrd.shared_resources.actions.Action import com.thewizrd.shared_resources.actions.ActionStatus import com.thewizrd.shared_resources.actions.Actions @@ -16,7 +15,6 @@ import com.thewizrd.shared_resources.helpers.WearConnectionStatus import com.thewizrd.shared_resources.helpers.WearableHelper import com.thewizrd.shared_resources.utils.JSONParser import com.thewizrd.simplewear.controls.CustomConfirmationOverlay -import com.thewizrd.simplewear.helpers.showConfirmationOverlay import com.thewizrd.simplewear.ui.simplewear.ValueActionScreen import com.thewizrd.simplewear.viewmodels.ValueActionViewModel import com.thewizrd.simplewear.viewmodels.WearableListenerViewModel @@ -24,8 +22,6 @@ import com.thewizrd.simplewear.viewmodels.WearableListenerViewModel.Companion.AC import com.thewizrd.simplewear.viewmodels.WearableListenerViewModel.Companion.EXTRA_ACTION import com.thewizrd.simplewear.viewmodels.WearableListenerViewModel.Companion.EXTRA_ACTIONDATA import com.thewizrd.simplewear.viewmodels.WearableListenerViewModel.Companion.EXTRA_STATUS -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.guava.await import kotlinx.coroutines.launch class ValueActionActivity : ComponentActivity() { @@ -35,12 +31,9 @@ class ValueActionActivity : ComponentActivity() { private val valueActionViewModel by viewModels() - private lateinit var remoteActivityHelper: RemoteActivityHelper - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - remoteActivityHelper = RemoteActivityHelper(this) handleIntent(intent) setContent { @@ -116,20 +109,7 @@ class ValueActionActivity : ComponentActivity() { WearConnectionStatus.APPNOTINSTALLED -> { // Open store on remote device - val intentAndroid = Intent(Intent.ACTION_VIEW) - .addCategory(Intent.CATEGORY_BROWSABLE) - .setData(WearableHelper.getPlayStoreURI()) - - runCatching { - remoteActivityHelper.startRemoteActivity(intentAndroid) - .await() - - showConfirmationOverlay(true) - }.onFailure { - if (it !is CancellationException) { - showConfirmationOverlay(false) - } - } + valueActionViewModel.openPlayStore(this@ValueActionActivity) // Navigate startActivity( diff --git a/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/PhoneSyncUi.kt b/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/PhoneSyncUi.kt new file mode 100644 index 0000000..ac1ab83 --- /dev/null +++ b/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/PhoneSyncUi.kt @@ -0,0 +1,313 @@ +package com.thewizrd.simplewear.ui.simplewear + +import android.Manifest +import android.bluetooth.BluetoothAdapter +import android.content.Intent +import android.os.Build +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import androidx.core.content.PermissionChecker +import androidx.lifecycle.lifecycleScope +import androidx.wear.compose.material.Button +import androidx.wear.compose.material.ButtonDefaults +import androidx.wear.compose.material.CircularProgressIndicator +import androidx.wear.compose.material.Icon +import androidx.wear.compose.material.ListHeader +import androidx.wear.compose.material.MaterialTheme +import androidx.wear.compose.material.Scaffold +import androidx.wear.compose.material.Text +import androidx.wear.compose.material.TimeText +import androidx.wear.compose.material.Vignette +import androidx.wear.compose.material.VignettePosition +import androidx.wear.compose.ui.tooling.preview.WearPreviewDevices +import androidx.wear.compose.ui.tooling.preview.WearPreviewFontScales +import com.google.accompanist.drawablepainter.rememberDrawablePainter +import com.thewizrd.shared_resources.helpers.WearConnectionStatus +import com.thewizrd.simplewear.R +import com.thewizrd.simplewear.activities.AppCompatLiteActivity +import com.thewizrd.simplewear.ui.theme.WearAppTheme +import com.thewizrd.simplewear.ui.theme.activityViewModel +import com.thewizrd.simplewear.ui.theme.findActivity +import com.thewizrd.simplewear.viewmodels.PhoneSyncUiState +import com.thewizrd.simplewear.viewmodels.PhoneSyncViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +@Composable +fun PhoneSyncUi( + modifier: Modifier = Modifier +) { + val phoneSyncViewModel = activityViewModel() + + WearAppTheme { + Scaffold( + modifier = modifier.background(MaterialTheme.colors.background), + vignette = { Vignette(vignettePosition = VignettePosition.TopAndBottom) }, + timeText = { TimeText() }, + ) { + PhoneSyncUi(phoneSyncViewModel) + } + } +} + +@Composable +private fun PhoneSyncUi( + phoneSyncViewModel: PhoneSyncViewModel +) { + val context = LocalContext.current + val activity = context.findActivity() + + val lifecycleOwner = LocalLifecycleOwner.current + + val uiState by phoneSyncViewModel.uiState.collectAsState() + + val bluetoothRequestLauncher = + rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) { + if (it.resultCode == AppCompatLiteActivity.RESULT_OK) { + lifecycleOwner.lifecycleScope.launch(Dispatchers.Main) { + delay(2000) + phoneSyncViewModel.showProgressBar() + delay(10000) + if (isActive) { + phoneSyncViewModel.showProgressBar(false) + } + } + } + } + + val permissionRequestLauncher = + rememberLauncherForActivityResult(contract = ActivityResultContracts.RequestMultiplePermissions()) { permissions -> + permissions.entries.forEach { (permission, granted) -> + when (permission) { + Manifest.permission.BLUETOOTH_CONNECT -> { + if (granted) { + bluetoothRequestLauncher.launch(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)) + } + } + } + } + } + + PhoneSyncUi( + uiState = uiState, + onBTButtonClicked = { + runCatching { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && PermissionChecker.checkSelfPermission( + context, + Manifest.permission.BLUETOOTH_CONNECT + ) != PermissionChecker.PERMISSION_GRANTED + ) { + permissionRequestLauncher.launch(arrayOf(Manifest.permission.BLUETOOTH_CONNECT)) + } else { + bluetoothRequestLauncher.launch(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)) + } + } + }, + onWifiButtonClicked = { + phoneSyncViewModel.openWifiSettings(activity) + }, + onSyncButtonClicked = { + when (uiState.connectionStatus) { + WearConnectionStatus.DISCONNECTED -> { + phoneSyncViewModel.refreshConnectionStatus() + } + + WearConnectionStatus.APPNOTINSTALLED -> { + lifecycleOwner.lifecycleScope.launch { + phoneSyncViewModel.openPlayStore(activity) + } + } + + else -> {} + } + } + ) +} + +@Composable +private fun PhoneSyncUi( + uiState: PhoneSyncUiState, + onWifiButtonClicked: () -> Unit = {}, + onBTButtonClicked: () -> Unit = {}, + onSyncButtonClicked: () -> Unit = {}, +) { + val context = LocalContext.current + val isRound = LocalConfiguration.current.isScreenRound + + Box( + modifier = Modifier.fillMaxSize() + ) { + Column( + modifier = Modifier.fillMaxSize() + ) { + ListHeader( + modifier = Modifier + .fillMaxWidth() + .padding( + top = if (isRound) 32.dp else 8.dp, + start = 14.dp, + end = 14.dp + ) + ) { + Text( + modifier = Modifier.fillMaxWidth(), + text = when (uiState.connectionStatus) { + WearConnectionStatus.DISCONNECTED -> { + stringResource(id = R.string.status_disconnected) + } + + WearConnectionStatus.CONNECTING -> { + stringResource(id = R.string.status_connecting) + } + + WearConnectionStatus.APPNOTINSTALLED -> { + stringResource(id = R.string.error_notinstalled) + } + + WearConnectionStatus.CONNECTED -> { + stringResource(id = R.string.status_connected) + } + + null -> { + stringResource(id = R.string.message_gettingstatus) + } + }, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.button, + textAlign = TextAlign.Center + ) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .weight(1f, fill = true), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally) + ) { + if (uiState.showWifiButton) { + Button( + modifier = Modifier.requiredSize(36.dp), + onClick = onWifiButtonClicked, + colors = ButtonDefaults.primaryButtonColors( + backgroundColor = colorResource(id = R.color.colorPrimary) + ) + ) { + Icon( + painter = painterResource(id = R.drawable.ic_network_wifi_white_24dp), + contentDescription = stringResource(id = R.string.action_wifi) + ) + } + } + + Box( + contentAlignment = Alignment.Center + ) { + if (uiState.isLoading) { + CircularProgressIndicator( + modifier = Modifier.requiredSize(44.dp), + trackColor = Color.Transparent, + strokeWidth = 4.dp + ) + } + + Button( + modifier = Modifier.requiredSize(36.dp), + onClick = onSyncButtonClicked, + colors = ButtonDefaults.primaryButtonColors( + backgroundColor = colorResource(id = R.color.colorPrimary) + ) + ) { + Icon( + modifier = Modifier.size(24.dp), + painter = when (uiState.connectionStatus) { + WearConnectionStatus.DISCONNECTED -> { + painterResource(id = R.drawable.ic_phonelink_erase_white_24dp) + } + + WearConnectionStatus.CONNECTING, WearConnectionStatus.CONNECTED -> { + val drawable = remember(context) { + ContextCompat.getDrawable( + context, + android.R.drawable.ic_popup_sync + ) + } + rememberDrawablePainter( + drawable = drawable + ) + } + + WearConnectionStatus.APPNOTINSTALLED -> { + painterResource(id = R.drawable.common_full_open_on_phone) + } + + null -> painterResource(id = R.drawable.ic_sync_24dp) + }, + contentDescription = null + ) + } + } + + if (uiState.showBTButton) { + Button( + modifier = Modifier.requiredSize(36.dp), + onClick = onBTButtonClicked, + colors = ButtonDefaults.primaryButtonColors( + backgroundColor = colorResource(id = R.color.colorPrimary) + ) + ) { + Icon( + painter = painterResource(id = R.drawable.ic_bluetooth_white_24dp), + contentDescription = stringResource(id = R.string.action_bt) + ) + } + } + } + } + } +} + +@WearPreviewDevices +@WearPreviewFontScales +@Composable +private fun PreviewPhoneSyncUi() { + val uiState = remember { + PhoneSyncUiState( + connectionStatus = WearConnectionStatus.CONNECTED, + isLoading = true, + showWifiButton = true, + showBTButton = true + ) + } + + PhoneSyncUi(uiState = uiState) +} \ No newline at end of file diff --git a/wear/src/main/java/com/thewizrd/simplewear/utils/ErrorMessage.kt b/wear/src/main/java/com/thewizrd/simplewear/utils/ErrorMessage.kt new file mode 100644 index 0000000..b097fe2 --- /dev/null +++ b/wear/src/main/java/com/thewizrd/simplewear/utils/ErrorMessage.kt @@ -0,0 +1,13 @@ +package com.thewizrd.simplewear.utils + +import androidx.annotation.StringRes + +interface ErrorMessage { + data class Resource( + @StringRes val stringId: Int + ) : ErrorMessage + + data class String( + val message: kotlin.String + ) : ErrorMessage +} \ No newline at end of file diff --git a/wear/src/main/java/com/thewizrd/simplewear/viewmodels/PhoneSyncViewModel.kt b/wear/src/main/java/com/thewizrd/simplewear/viewmodels/PhoneSyncViewModel.kt new file mode 100644 index 0000000..bfb75d8 --- /dev/null +++ b/wear/src/main/java/com/thewizrd/simplewear/viewmodels/PhoneSyncViewModel.kt @@ -0,0 +1,194 @@ +package com.thewizrd.simplewear.viewmodels + +import android.app.Activity +import android.app.Application +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothManager +import android.content.Intent +import android.content.pm.PackageManager +import android.net.wifi.WifiManager +import android.provider.Settings +import androidx.core.content.ContextCompat +import androidx.lifecycle.viewModelScope +import com.thewizrd.shared_resources.helpers.WearConnectionStatus +import com.thewizrd.simplewear.DashboardActivity +import com.thewizrd.simplewear.R +import com.thewizrd.simplewear.utils.ErrorMessage +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class PhoneSyncUiState( + val connectionStatus: WearConnectionStatus? = null, + val isLoading: Boolean = false, + val showWifiButton: Boolean = false, + val showBTButton: Boolean = false +) + +class PhoneSyncViewModel(app: Application) : WearableListenerViewModel(app) { + private val viewModelState = MutableStateFlow(PhoneSyncUiState(isLoading = true)) + + val uiState = viewModelState.stateIn( + viewModelScope, + SharingStarted.Eagerly, + viewModelState.value + ) + + init { + viewModelScope.launch { + eventFlow.collect { event -> + when (event.eventType) { + ACTION_UPDATECONNECTIONSTATUS -> { + val connectionStatus = WearConnectionStatus.valueOf( + event.data.getInt( + EXTRA_CONNECTIONSTATUS, + 0 + ) + ) + + when (connectionStatus) { + WearConnectionStatus.DISCONNECTED -> { + checkNetworkStatus() + viewModelState.update { + it.copy( + isLoading = false + ) + } + } + + WearConnectionStatus.CONNECTING -> { + viewModelState.update { + it.copy( + showWifiButton = false, + showBTButton = false + ) + } + } + + WearConnectionStatus.APPNOTINSTALLED -> { + viewModelState.update { + it.copy( + showWifiButton = false, + showBTButton = false, + isLoading = false + ) + } + } + + WearConnectionStatus.CONNECTED -> { + viewModelState.update { + it.copy( + showWifiButton = false, + showBTButton = false, + isLoading = false + ) + } + + viewModelScope.launch { + // Verify connection by sending a 'ping' + runCatching { + sendPing(mPhoneNodeWithApp!!.id) + }.onSuccess { + // Continue operation + activityContext?.startActivity( + Intent( + activityContext, + DashboardActivity::class.java + ).addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION) + ) + + viewModelState.update { + it.copy( + isLoading = false + ) + } + + activityContext?.finishAfterTransition() + }.onFailure { + setConnectionStatus(WearConnectionStatus.DISCONNECTED) + } + } + } + } + + viewModelState.update { + it.copy( + connectionStatus = connectionStatus + ) + } + } + } + } + } + } + + fun refreshConnectionStatus() { + viewModelState.update { + it.copy( + isLoading = true + ) + } + + viewModelScope.launch { + updateConnectionStatus() + } + } + + fun checkNetworkStatus() { + val btAdapter = appContext.getSystemService(BluetoothManager::class.java)?.adapter + if (btAdapter != null) { + if (btAdapter.isEnabled || btAdapter.state == BluetoothAdapter.STATE_TURNING_ON) { + viewModelState.update { + it.copy( + showBTButton = false + ) + } + } else { + _errorMessagesFlow.tryEmit(ErrorMessage.Resource(R.string.message_enablebt)) + + viewModelState.update { + it.copy( + showBTButton = true + ) + } + } + } else { + viewModelState.update { + it.copy( + showBTButton = false + ) + } + } + + val wifiMgr = ContextCompat.getSystemService(appContext, WifiManager::class.java) + if (wifiMgr != null && appContext.packageManager.hasSystemFeature(PackageManager.FEATURE_WIFI)) { + viewModelState.update { + it.copy( + showWifiButton = !wifiMgr.isWifiEnabled + ) + } + } else { + viewModelState.update { + it.copy( + showWifiButton = false + ) + } + } + } + + fun openWifiSettings(activity: Activity) { + runCatching { + activity.startActivity(Intent(Settings.ACTION_WIFI_SETTINGS)) + } + } + + fun showProgressBar(show: Boolean = true) { + viewModelState.update { + it.copy( + isLoading = show + ) + } + } +} \ No newline at end of file diff --git a/wear/src/main/java/com/thewizrd/simplewear/viewmodels/WearableListenerViewModel.kt b/wear/src/main/java/com/thewizrd/simplewear/viewmodels/WearableListenerViewModel.kt index b9b306c..516fadb 100644 --- a/wear/src/main/java/com/thewizrd/simplewear/viewmodels/WearableListenerViewModel.kt +++ b/wear/src/main/java/com/thewizrd/simplewear/viewmodels/WearableListenerViewModel.kt @@ -39,6 +39,7 @@ import com.thewizrd.simplewear.App import com.thewizrd.simplewear.ValueActionActivity import com.thewizrd.simplewear.WearableListenerActivity import com.thewizrd.simplewear.helpers.showConfirmationOverlay +import com.thewizrd.simplewear.utils.ErrorMessage import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow @@ -68,8 +69,8 @@ abstract class WearableListenerViewModel(private val app: Application) : Android ) val eventFlow: SharedFlow = _eventsFlow - protected val _errorMessagesFlow = MutableSharedFlow(replay = 0) - val errorMessagesFlow: SharedFlow = _errorMessagesFlow + protected val _errorMessagesFlow = MutableSharedFlow(replay = 0) + val errorMessagesFlow: SharedFlow = _errorMessagesFlow init { Wearable.getCapabilityClient(appContext) @@ -94,33 +95,19 @@ abstract class WearableListenerViewModel(private val app: Application) : Android connect() if (mPhoneNodeWithApp == null) { - _errorMessagesFlow.tryEmit("Device is not connected or app is not installed on device...") + _errorMessagesFlow.tryEmit(ErrorMessage.String("Device is not connected or app is not installed on device...")) when (PhoneTypeHelper.getPhoneDeviceType(appContext)) { PhoneTypeHelper.DEVICE_TYPE_ANDROID -> { - // Open store on remote device - val intentAndroid = Intent(Intent.ACTION_VIEW) - .addCategory(Intent.CATEGORY_BROWSABLE) - .setData(WearableHelper.getPlayStoreURI()) - - runCatching { - remoteActivityHelper.startRemoteActivity(intentAndroid) - .await() - - activity.showConfirmationOverlay(true) - }.onFailure { - if (it !is CancellationException) { - activity.showConfirmationOverlay(false) - } - } + openPlayStore(activity, showAnimation) } PhoneTypeHelper.DEVICE_TYPE_IOS -> { - _errorMessagesFlow.tryEmit("Connected device is not supported") + _errorMessagesFlow.tryEmit(ErrorMessage.String("Connected device is not supported")) } else -> { - _errorMessagesFlow.tryEmit("Connected device is not supported") + _errorMessagesFlow.tryEmit(ErrorMessage.String("Connected device is not supported")) } } } else { @@ -131,6 +118,10 @@ abstract class WearableListenerViewModel(private val app: Application) : Android ByteArray(0) ) + if (showAnimation) { + activity.showConfirmationOverlay(result != -1) + } + _eventsFlow.tryEmit(WearableEvent(ACTION_OPENONPHONE, Bundle().apply { putBoolean(EXTRA_SUCCESS, result != -1) putBoolean(EXTRA_SHOWANIMATION, showAnimation) @@ -139,6 +130,26 @@ abstract class WearableListenerViewModel(private val app: Application) : Android } } + suspend fun openPlayStore(activity: Activity, showAnimation: Boolean = true) { + // Open store on remote device + val intentAndroid = Intent(Intent.ACTION_VIEW) + .addCategory(Intent.CATEGORY_BROWSABLE) + .setData(WearableHelper.getPlayStoreURI()) + + runCatching { + remoteActivityHelper.startRemoteActivity(intentAndroid) + .await() + + if (showAnimation) { + activity.showConfirmationOverlay(true) + } + }.onFailure { + if (it !is CancellationException && showAnimation) { + activity.showConfirmationOverlay(false) + } + } + } + suspend fun startRemoteActivity(intent: Intent): Boolean { return runCatching { remoteActivityHelper.startRemoteActivity(intent).await() From 1c95993197ba93822ae8d09a7b934187e4db7638 Mon Sep 17 00:00:00 2001 From: Dave Antoine Date: Sun, 31 Mar 2024 01:37:16 -0400 Subject: [PATCH 20/58] MediaPlayerListActivity: migrate to compose --- .../simplewear/MediaPlayerListActivity.kt | 482 +++------------ .../simplewear/adapters/ListHeaderAdapter.kt | 39 -- .../adapters/MediaPlayerListAdapter.kt | 70 --- .../simplewear/controls/ActionButton.kt | 149 ----- .../simplewear/media/MediaPlayerActivity.kt | 46 +- .../media/MediaPlayerFilterFragment.kt | 103 ---- .../ui/simplewear/AppLauncherScreen.kt | 31 +- .../ui/simplewear/MediaPlayerListUi.kt | 579 ++++++++++++++++++ .../viewmodels/AppLauncherViewModel.kt | 16 + .../viewmodels/MediaPlayerListViewModel.kt | 323 +++++++++- .../res/layout/activity_musicplayerlist.xml | 87 --- .../main/res/layout/activity_setup_sync.xml | 87 --- .../res/layout/control_fabtogglebutton.xml | 103 ---- .../main/res/layout/layout_list_header.xml | 24 - .../res/layout/mediaplayerfilter_list.xml | 100 --- .../main/res/layout/mediaplayerlist_item.xml | 16 - .../layout/musicplayer_item_sleeptimer.xml | 33 - .../musicplayerlistactivity_drawer_layout.xml | 64 -- 18 files changed, 1028 insertions(+), 1324 deletions(-) delete mode 100644 wear/src/main/java/com/thewizrd/simplewear/adapters/ListHeaderAdapter.kt delete mode 100644 wear/src/main/java/com/thewizrd/simplewear/adapters/MediaPlayerListAdapter.kt delete mode 100644 wear/src/main/java/com/thewizrd/simplewear/controls/ActionButton.kt delete mode 100644 wear/src/main/java/com/thewizrd/simplewear/media/MediaPlayerFilterFragment.kt create mode 100644 wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/MediaPlayerListUi.kt delete mode 100644 wear/src/main/res/layout/activity_musicplayerlist.xml delete mode 100644 wear/src/main/res/layout/activity_setup_sync.xml delete mode 100644 wear/src/main/res/layout/control_fabtogglebutton.xml delete mode 100644 wear/src/main/res/layout/layout_list_header.xml delete mode 100644 wear/src/main/res/layout/mediaplayerfilter_list.xml delete mode 100644 wear/src/main/res/layout/mediaplayerlist_item.xml delete mode 100644 wear/src/main/res/layout/musicplayer_item_sleeptimer.xml delete mode 100644 wear/src/main/res/layout/musicplayerlistactivity_drawer_layout.xml diff --git a/wear/src/main/java/com/thewizrd/simplewear/MediaPlayerListActivity.kt b/wear/src/main/java/com/thewizrd/simplewear/MediaPlayerListActivity.kt index 9935856..7aa7c90 100644 --- a/wear/src/main/java/com/thewizrd/simplewear/MediaPlayerListActivity.kt +++ b/wear/src/main/java/com/thewizrd/simplewear/MediaPlayerListActivity.kt @@ -1,463 +1,125 @@ package com.thewizrd.simplewear import android.annotation.SuppressLint -import android.content.* +import android.content.Intent import android.os.Bundle -import android.os.CountDownTimer -import android.util.Log -import android.view.View +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent import androidx.activity.viewModels import androidx.core.content.ContextCompat import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.ConcatAdapter -import androidx.wear.widget.WearableLinearLayoutManager -import androidx.wear.widget.drawer.WearableDrawerLayout -import androidx.wear.widget.drawer.WearableDrawerView -import com.google.android.gms.wearable.* -import com.google.android.gms.wearable.DataClient.OnDataChangedListener import com.thewizrd.shared_resources.actions.ActionStatus -import com.thewizrd.shared_resources.helpers.* -import com.thewizrd.shared_resources.utils.* -import com.thewizrd.shared_resources.utils.ContextUtils.dpToPx -import com.thewizrd.simplewear.adapters.ListHeaderAdapter -import com.thewizrd.simplewear.adapters.MusicPlayerListAdapter -import com.thewizrd.simplewear.adapters.SpacerAdapter -import com.thewizrd.simplewear.controls.AppItemViewModel +import com.thewizrd.shared_resources.helpers.MediaHelper +import com.thewizrd.shared_resources.helpers.WearConnectionStatus import com.thewizrd.simplewear.controls.CustomConfirmationOverlay -import com.thewizrd.simplewear.controls.WearChipButton -import com.thewizrd.simplewear.databinding.ActivityMusicplayerlistBinding -import com.thewizrd.simplewear.helpers.AppItemComparator -import com.thewizrd.simplewear.helpers.CustomScrollingLayoutCallback -import com.thewizrd.simplewear.helpers.SpacerItemDecoration -import com.thewizrd.simplewear.helpers.showConfirmationOverlay import com.thewizrd.simplewear.media.MediaPlayerActivity -import com.thewizrd.simplewear.media.MediaPlayerFilterFragment -import com.thewizrd.simplewear.preferences.Settings +import com.thewizrd.simplewear.ui.simplewear.MediaPlayerListUi import com.thewizrd.simplewear.viewmodels.MediaPlayerListViewModel -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.guava.await +import com.thewizrd.simplewear.viewmodels.WearableListenerViewModel.Companion.ACTION_UPDATECONNECTIONSTATUS +import com.thewizrd.simplewear.viewmodels.WearableListenerViewModel.Companion.EXTRA_CONNECTIONSTATUS +import com.thewizrd.simplewear.viewmodels.WearableListenerViewModel.Companion.EXTRA_STATUS import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.tasks.await -import java.util.* -class MediaPlayerListActivity : WearableListenerActivity(), MessageClient.OnMessageReceivedListener, - OnDataChangedListener { - private val mutex = Mutex() - - override lateinit var broadcastReceiver: BroadcastReceiver - private set - override lateinit var intentFilter: IntentFilter - private set - - private lateinit var binding: ActivityMusicplayerlistBinding - private lateinit var mAdapter: MusicPlayerListAdapter - private var timer: CountDownTimer? = null - - private val mMediaAppsList: MutableSet = TreeSet(AppItemComparator()) - private val viewModel: MediaPlayerListViewModel by viewModels() +class MediaPlayerListActivity : ComponentActivity() { + private val mediaPlayerListViewModel by viewModels() @SuppressLint("UseSwitchCompatOrMaterialCode") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - binding = ActivityMusicplayerlistBinding.inflate(layoutInflater) - setContentView(binding.root) - - broadcastReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - lifecycleScope.launch { - if (intent.action != null) { - if (ACTION_UPDATECONNECTIONSTATUS == intent.action) { - when (WearConnectionStatus.valueOf( - intent.getIntExtra( - EXTRA_CONNECTIONSTATUS, - 0 - ) - )) { - WearConnectionStatus.DISCONNECTED -> { - // Navigate - startActivity( - Intent( - this@MediaPlayerListActivity, - PhoneSyncActivity::class.java - ) - ) - finishAffinity() - } - WearConnectionStatus.APPNOTINSTALLED -> { - // Open store on remote device - val intentAndroid = Intent(Intent.ACTION_VIEW) - .addCategory(Intent.CATEGORY_BROWSABLE) - .setData(WearableHelper.getPlayStoreURI()) - - runCatching { - remoteActivityHelper.startRemoteActivity(intentAndroid) - .await() - - showConfirmationOverlay(true) - }.onFailure { - if (it !is CancellationException) { - showConfirmationOverlay(false) - } - } - - // Navigate - startActivity( - Intent( - this@MediaPlayerListActivity, - PhoneSyncActivity::class.java - ) - ) - finishAffinity() - } - - else -> {} - } - } else { - Logger.writeLine( - Log.INFO, - "%s: Unhandled action: %s", - "MediaPlayerListActivity", - intent.action - ) - } - } - } - } - } - - binding.drawerLayout.setDrawerStateCallback(object : WearableDrawerLayout.DrawerStateCallback() { - override fun onDrawerOpened(layout: WearableDrawerLayout, drawerView: WearableDrawerView) { - super.onDrawerOpened(layout, drawerView) - drawerView.requestFocus() - } - - override fun onDrawerClosed(layout: WearableDrawerLayout, drawerView: WearableDrawerView) { - super.onDrawerClosed(layout, drawerView) - drawerView.clearFocus() - binding.playerList.requestFocus() - } - - override fun onDrawerStateChanged(layout: WearableDrawerLayout, newState: Int) { - super.onDrawerStateChanged(layout, newState) - if (newState == WearableDrawerView.STATE_IDLE && binding.bottomActionDrawer.isPeeking) { - binding.bottomActionDrawer.clearFocus() - binding.playerList.requestFocus() - } - } - }) - - binding.bottomActionDrawer.visibility = View.VISIBLE - binding.bottomActionDrawer.isPeekOnScrollDownEnabled = true - binding.bottomActionDrawer.setIsAutoPeekEnabled(true) - binding.bottomActionDrawer.setIsLocked(false) - - val mSwitch = findViewById(R.id.autolaunch_pref) - mSwitch.isChecked = Settings.isAutoLaunchMediaCtrlsEnabled - mSwitch.setOnClickListener { - val state = !Settings.isAutoLaunchMediaCtrlsEnabled - Settings.setAutoLaunchMediaCtrls(state) - mSwitch.isChecked = state - } - - findViewById(R.id.filter_apps_btn).setOnClickListener { v -> - val fragment = MediaPlayerFilterFragment() - - supportFragmentManager.setFragmentResultListener( - fragment.javaClass.simpleName, - this - ) { _, _ -> - if (binding.bottomActionDrawer.isOpened) { - binding.bottomActionDrawer.requestFocus() - } else { - binding.playerList.requestFocus() - } - } - - supportFragmentManager.beginTransaction() - .replace(android.R.id.content, fragment, fragment.javaClass.simpleName) - .commit() - } - - binding.playerList.setHasFixedSize(true) - //binding.playerList.isEdgeItemsCenteringEnabled = true - binding.playerList.addItemDecoration( - SpacerItemDecoration( - dpToPx(16f).toInt(), - dpToPx(4f).toInt() - ) - ) - - binding.playerList.layoutManager = - WearableLinearLayoutManager(this, CustomScrollingLayoutCallback()) - mAdapter = MusicPlayerListAdapter() - mAdapter.setOnClickListener(object : ListAdapterOnClickInterface { - override fun onClick(view: View, item: AppItemViewModel) { - lifecycleScope.launch { - val success = runCatching { - val intent = MediaHelper.createRemoteActivityIntent( - item.packageName!!, - item.activityName!! - ) - startRemoteActivity(intent) - }.getOrDefault(false) - - if (success) { - startActivity( - MediaPlayerActivity.buildIntent( - this@MediaPlayerListActivity, - item - ) - ) - } else { - showConfirmationOverlay(false) - } - } - } - }) - binding.playerList.adapter = ConcatAdapter( - ListHeaderAdapter(getString(R.string.action_apps)), - mAdapter, - SpacerAdapter(dpToPx(48f).toInt()) - ) - - binding.retryFab.setOnClickListener { - lifecycleScope.launch { - updateConnectionStatus() - requestPlayersUpdate() - } - } - - intentFilter = IntentFilter() - intentFilter.addAction(ACTION_UPDATECONNECTIONSTATUS) - - // Set timer for retrieving music player data - timer = object : CountDownTimer(3000, 1000) { - override fun onTick(millisUntilFinished: Long) {} - override fun onFinish() { - refreshMusicPlayers() - } + setContent { + MediaPlayerListUi() } lifecycleScope.launchWhenResumed { - if (Settings.isAutoLaunchMediaCtrlsEnabled) { - if (connect()) { - sendMessage( - mPhoneNodeWithApp!!.id, - MediaHelper.MediaPlayerAutoLaunchPath, - null - ) - } - } + mediaPlayerListViewModel.autoLaunchMediaControls() } } override fun onStart() { super.onStart() - viewModel.filteredAppsList.value = Settings.getMusicPlayersFilter() - viewModel.filteredAppsList.observe(this, { - if (mMediaAppsList.isNotEmpty()) { - updateAppsList() - } - }) - } + mediaPlayerListViewModel.initActivityContext(this) - private fun showProgressBar(show: Boolean) { lifecycleScope.launch { - if (show) { - binding.progressBar.show() - } else { - binding.progressBar.hide() - } - } - } - - override fun onMessageReceived(messageEvent: MessageEvent) { - super.onMessageReceived(messageEvent) - - when (messageEvent.path) { - MediaHelper.MusicPlayersPath -> { - val status = ActionStatus.valueOf(messageEvent.data.bytesToString()) - - if (status == ActionStatus.PERMISSION_DENIED) { - timer?.cancel() - - CustomConfirmationOverlay() - .setType(CustomConfirmationOverlay.CUSTOM_ANIMATION) - .setCustomDrawable(ContextCompat.getDrawable(this, R.drawable.ws_full_sad)) - .setMessage(getString(R.string.error_permissiondenied)) - .showOn(this) - - openAppOnPhone(false) - - mMediaAppsList.clear() - updateAppsList() - } else if (status == ActionStatus.SUCCESS) { - refreshMusicPlayers() - } - } - MediaHelper.MediaPlayerAutoLaunchPath -> { - val status = ActionStatus.valueOf(messageEvent.data.bytesToString()) + mediaPlayerListViewModel.eventFlow.collect { event -> + when (event.eventType) { + ACTION_UPDATECONNECTIONSTATUS -> { + val connectionStatus = WearConnectionStatus.valueOf( + event.data.getInt( + EXTRA_CONNECTIONSTATUS, + 0 + ) + ) - if (status == ActionStatus.SUCCESS) { - startActivity(MediaPlayerActivity.buildAutoLaunchIntent(this)) - } - } - } - } + when (connectionStatus) { + WearConnectionStatus.DISCONNECTED -> { + // Navigate + startActivity( + Intent( + this@MediaPlayerListActivity, + PhoneSyncActivity::class.java + ) + ) + finishAffinity() + } - override fun onDataChanged(dataEventBuffer: DataEventBuffer) { - lifecycleScope.launch { - // Cancel timer - timer?.cancel() - showProgressBar(false) + WearConnectionStatus.APPNOTINSTALLED -> { + // Open store on remote device + mediaPlayerListViewModel.openPlayStore(this@MediaPlayerListActivity) - for (event in dataEventBuffer) { - if (event.type == DataEvent.TYPE_CHANGED) { - val item = event.dataItem - if (MediaHelper.MusicPlayersPath == item.uri.path) { - try { - val dataMap = DataMapItem.fromDataItem(item).dataMap - updateMusicPlayers(dataMap) - } catch (e: Exception) { - Logger.writeLine(Log.ERROR, e) - } - } - } - } - } - } - - private fun refreshMusicPlayers() { - lifecycleScope.launch(Dispatchers.IO) { - try { - val buff = Wearable.getDataClient(this@MediaPlayerListActivity) - .getDataItems( - WearableHelper.getWearDataUri( - "*", - MediaHelper.MusicPlayersPath - ) - ) - .await() + // Navigate + startActivity( + Intent( + this@MediaPlayerListActivity, + PhoneSyncActivity::class.java + ) + ) + finishAffinity() + } - for (i in 0 until buff.count) { - val item = buff[i] - if (MediaHelper.MusicPlayersPath == item.uri.path) { - try { - val dataMap = DataMapItem.fromDataItem(item).dataMap - updateMusicPlayers(dataMap) - } catch (e: Exception) { - Logger.writeLine(Log.ERROR, e) + else -> {} } - showProgressBar(false) } - } - - buff.release() - } catch (e: Exception) { - Logger.writeLine(Log.ERROR, e) - } - } - } - - private suspend fun updateMusicPlayers(dataMap: DataMap) = mutex.withLock { - val supported_players = - dataMap.getStringArrayList(MediaHelper.KEY_SUPPORTEDPLAYERS) ?: return - mMediaAppsList.clear() + MediaHelper.MusicPlayersPath -> { + val status = event.data.getSerializable(EXTRA_STATUS) as ActionStatus - for (key in supported_players) { - val map = dataMap.getDataMap(key) ?: continue + if (status == ActionStatus.PERMISSION_DENIED) { + CustomConfirmationOverlay() + .setType(CustomConfirmationOverlay.CUSTOM_ANIMATION) + .setCustomDrawable( + ContextCompat.getDrawable( + this@MediaPlayerListActivity, + R.drawable.ws_full_sad + ) + ) + .setMessage(getString(R.string.error_permissiondenied)) + .showOn(this@MediaPlayerListActivity) - val model = AppItemViewModel().apply { - appLabel = map.getString(WearableHelper.KEY_LABEL) - packageName = map.getString(WearableHelper.KEY_PKGNAME) - activityName = map.getString(WearableHelper.KEY_ACTIVITYNAME) - bitmapIcon = map.getAsset(WearableHelper.KEY_ICON)?.let { - try { - ImageUtils.bitmapFromAssetStream( - Wearable.getDataClient(this@MediaPlayerListActivity), - it - ) - } catch (e: Exception) { - null + mediaPlayerListViewModel.openAppOnPhone( + this@MediaPlayerListActivity, + false + ) + } } - } - } - mMediaAppsList.add(model) - } - - viewModel.mediaAppsList.postValue(mMediaAppsList.toList()) - updateAppsList() - } - private fun updateAppsList() { - lifecycleScope.launch { - val filteredApps = Settings.getMusicPlayersFilter() + MediaHelper.MediaPlayerAutoLaunchPath -> { + val status = event.data.getSerializable(EXTRA_STATUS) as ActionStatus - mAdapter.submitList( - if (filteredApps.isNullOrEmpty()) { - mMediaAppsList.toList() - } else { - mMediaAppsList.toMutableList().apply { - removeIf { !filteredApps.contains(it.packageName) } + if (status == ActionStatus.SUCCESS) { + startActivity(MediaPlayerActivity.buildAutoLaunchIntent(this@MediaPlayerListActivity)) + } } } - ) - showProgressBar(false) - binding.noplayersView.visibility = - if (mMediaAppsList.size > 0) View.GONE else View.VISIBLE - binding.playerList.visibility = if (mMediaAppsList.size > 0) View.VISIBLE else View.GONE - lifecycleScope.launch { - if (!binding.bottomActionDrawer.isOpened && binding.playerList.visibility == View.VISIBLE && !binding.playerList.hasFocus()) { - binding.playerList.requestFocus() - } - } - } - } - - private fun requestPlayersUpdate() { - lifecycleScope.launch { - if (connect()) { - sendMessage(mPhoneNodeWithApp!!.id, MediaHelper.MusicPlayersPath, null) - } - } - } - - private fun requestPlayerDisconnect() { - lifecycleScope.launch { - if (connect()) { - sendMessage(mPhoneNodeWithApp!!.id, MediaHelper.MediaPlayerDisconnectPath, null) } } } override fun onResume() { super.onResume() - Wearable.getDataClient(this).addListener(this) - - if (binding.bottomActionDrawer.isOpened) { - binding.bottomActionDrawer.requestFocus() - } else { - binding.playerList.requestFocus() - } // Update statuses - lifecycleScope.launch { - updateConnectionStatus() - requestPlayersUpdate() - // Wait for music player update - timer!!.start() - } - } - - override fun onPause() { - requestPlayerDisconnect() - Wearable.getDataClient(this).removeListener(this) - super.onPause() + mediaPlayerListViewModel.refreshState(true) } } \ No newline at end of file diff --git a/wear/src/main/java/com/thewizrd/simplewear/adapters/ListHeaderAdapter.kt b/wear/src/main/java/com/thewizrd/simplewear/adapters/ListHeaderAdapter.kt deleted file mode 100644 index 54fc31a..0000000 --- a/wear/src/main/java/com/thewizrd/simplewear/adapters/ListHeaderAdapter.kt +++ /dev/null @@ -1,39 +0,0 @@ -package com.thewizrd.simplewear.adapters - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.recyclerview.widget.RecyclerView -import com.thewizrd.simplewear.databinding.LayoutListHeaderBinding - -class ListHeaderAdapter : RecyclerView.Adapter { - var headerText: CharSequence = "" - set(value) { - field = value - notifyItemChanged(0) - } - - constructor(headerText: CharSequence) : super() { - this.headerText = headerText - } - - inner class ViewHolder(val binding: LayoutListHeaderBinding) : - RecyclerView.ViewHolder(binding.root) - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { - return ViewHolder( - LayoutListHeaderBinding.inflate(LayoutInflater.from(parent.context)).apply { - root.layoutParams = RecyclerView.LayoutParams( - RecyclerView.LayoutParams.MATCH_PARENT, - RecyclerView.LayoutParams.WRAP_CONTENT - ) - }) - } - - override fun onBindViewHolder(holder: ViewHolder, position: Int) { - holder.binding.header.text = headerText - } - - override fun getItemCount(): Int { - return 1 - } -} \ No newline at end of file diff --git a/wear/src/main/java/com/thewizrd/simplewear/adapters/MediaPlayerListAdapter.kt b/wear/src/main/java/com/thewizrd/simplewear/adapters/MediaPlayerListAdapter.kt deleted file mode 100644 index 0d60a69..0000000 --- a/wear/src/main/java/com/thewizrd/simplewear/adapters/MediaPlayerListAdapter.kt +++ /dev/null @@ -1,70 +0,0 @@ -package com.thewizrd.simplewear.adapters - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import com.thewizrd.simplewear.controls.AppItemViewModel -import com.thewizrd.simplewear.databinding.MediaplayerlistItemBinding - -class MediaPlayerListAdapter : ListAdapter { - private val CHECKED_PAYLOAD = -1 - - private val selectedItems: MutableSet - - constructor() : super(AppItemDiffer()) { - this.selectedItems = HashSet() - } - - constructor(selectedItems: Collection) : super(AppItemDiffer()) { - this.selectedItems = HashSet(selectedItems) - } - - inner class ViewHolder(val binding: MediaplayerlistItemBinding) : - RecyclerView.ViewHolder(binding.root) { - fun bind(model: AppItemViewModel, onlyChecked: Boolean = false) { - if (!onlyChecked) { - binding.playerlistItem.text = model.appLabel - } - binding.playerlistItem.isChecked = selectedItems.contains(model.packageName) - } - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { - return ViewHolder(MediaplayerlistItemBinding.inflate(LayoutInflater.from(parent.context))) - } - - override fun onBindViewHolder(holder: ViewHolder, position: Int) {} - - override fun onBindViewHolder(holder: ViewHolder, position: Int, payloads: MutableList) { - val onlyChecked = payloads.firstOrNull() == CHECKED_PAYLOAD - - val viewModel = getItem(position) - holder.bind(viewModel, onlyChecked) - - if (!onlyChecked) { - holder.itemView.setOnClickListener { - holder.binding.playerlistItem.toggle() - if (holder.binding.playerlistItem.isChecked) { - selectedItems.add(viewModel.packageName!!) - } else { - selectedItems.remove(viewModel.packageName) - } - } - } - } - - fun getSelectedItems(): Set { - return selectedItems.toSet() - } - - fun setSelectedItems(items: Collection) { - selectedItems.clear() - selectedItems.addAll(items) - } - - fun clearSelections() { - selectedItems.clear() - notifyItemRangeChanged(0, itemCount, CHECKED_PAYLOAD) - } -} \ No newline at end of file diff --git a/wear/src/main/java/com/thewizrd/simplewear/controls/ActionButton.kt b/wear/src/main/java/com/thewizrd/simplewear/controls/ActionButton.kt deleted file mode 100644 index c1700ea..0000000 --- a/wear/src/main/java/com/thewizrd/simplewear/controls/ActionButton.kt +++ /dev/null @@ -1,149 +0,0 @@ -package com.thewizrd.simplewear.controls - -import android.content.Context -import android.text.Editable -import android.text.TextWatcher -import android.util.AttributeSet -import android.view.LayoutInflater -import android.view.MotionEvent -import android.widget.Checkable -import androidx.annotation.Px -import androidx.constraintlayout.widget.ConstraintLayout -import androidx.core.view.setPadding -import androidx.core.view.updateLayoutParams -import com.thewizrd.simplewear.R -import com.thewizrd.simplewear.databinding.ControlFabtogglebuttonBinding -import com.thewizrd.simplewear.ui.utils.setTextResId - -class ActionButton @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyleAttr: Int = R.attr.wearActionButtonStyle, - defStyleRes: Int = DEF_STYLE_RES -) : ConstraintLayout(context, attrs, defStyleAttr, defStyleRes), Checkable { - companion object { - private const val DEF_STYLE_RES = R.style.Widget_Wear_ActionButton - - private val ENABLED_STATE_SET = intArrayOf(android.R.attr.state_enabled) - private val CHECKED_STATE_SET = intArrayOf(android.R.attr.state_checked) - } - - private lateinit var binding: ControlFabtogglebuttonBinding - private var _isExpanded = false - private var _isChecked = false - - init { - initialize(context) - - val a = context.obtainStyledAttributes( - attrs, - R.styleable.ActionButton, - defStyleAttr, - defStyleRes - ) - try { - if (a.hasValue(R.styleable.ActionButton_minHeight)) { - minHeight = - a.getDimensionPixelSize(R.styleable.ActionButton_minHeight, 0) - } - } finally { - a.recycle() - } - } - - private fun initialize(context: Context) { - binding = ControlFabtogglebuttonBinding.inflate(LayoutInflater.from(context), this) - binding.buttonState.addTextChangedListener(object : TextWatcher { - override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {} - override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) { - if (isExpanded) { - binding.buttonState.visibility = if (s.isEmpty()) GONE else VISIBLE - } - } - - override fun afterTextChanged(s: Editable) {} - }) - - binding.actionIcon.setImageResource(R.drawable.ic_icon) - - isExpanded = _isExpanded - } - - fun updateButton(viewModel: ActionButtonViewModel) { - binding.actionIcon.setImageResource(viewModel.drawableResId) - binding.buttonLabel.setTextResId(viewModel.actionLabelResId) - binding.buttonState.setTextResId(viewModel.stateLabelResId) - - if (viewModel.buttonState == null) { - // Indeterminate state - isEnabled = false - isChecked = false - } else { - isEnabled = true - isChecked = viewModel.buttonState!! - } - refreshDrawableState() - } - - var isExpanded: Boolean - get() = _isExpanded - set(expanded) { - _isExpanded = expanded - binding.buttonLabel.visibility = if (expanded) VISIBLE else GONE - binding.buttonState.visibility = if (expanded) VISIBLE else GONE - binding.spacerGroup.visibility = if (expanded) VISIBLE else GONE - } - - internal fun setIconSize(@Px size: Int, padding: Int? = null) { - binding.actionIcon.updateLayoutParams { - height = size - width = size - } - - if (padding != null) { - binding.actionIcon.setPadding(padding) - } - } - - override fun setOnClickListener(l: OnClickListener?) { - super.setOnClickListener(l) - binding.actionIcon.setOnClickListener(l) - } - - override fun setOnLongClickListener(l: OnLongClickListener?) { - super.setOnLongClickListener(l) - binding.actionIcon.setOnLongClickListener(l) - } - - override fun setChecked(checked: Boolean) { - _isChecked = checked - refreshDrawableState() - } - - override fun isChecked(): Boolean { - return _isChecked - } - - override fun toggle() { - isChecked = !isChecked - } - - override fun onCreateDrawableState(extraSpace: Int): IntArray { - val drawableState = super.onCreateDrawableState(extraSpace + 2) - - if (isChecked) { - mergeDrawableStates(drawableState, CHECKED_STATE_SET) - } - - if (isEnabled) { - mergeDrawableStates(drawableState, ENABLED_STATE_SET) - } - - return drawableState - } - - override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean { - // Disable touch events on children - return true - } -} \ No newline at end of file diff --git a/wear/src/main/java/com/thewizrd/simplewear/media/MediaPlayerActivity.kt b/wear/src/main/java/com/thewizrd/simplewear/media/MediaPlayerActivity.kt index 4c308ee..afb81c3 100644 --- a/wear/src/main/java/com/thewizrd/simplewear/media/MediaPlayerActivity.kt +++ b/wear/src/main/java/com/thewizrd/simplewear/media/MediaPlayerActivity.kt @@ -1,7 +1,10 @@ package com.thewizrd.simplewear.media import android.annotation.SuppressLint -import android.content.* +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter import android.os.Bundle import android.util.Log import android.view.View @@ -14,12 +17,23 @@ import androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.recyclerview.widget.RecyclerView import androidx.viewpager2.adapter.FragmentStateAdapter import androidx.wear.ambient.AmbientModeSupport -import com.google.android.gms.wearable.* +import com.google.android.gms.wearable.DataClient +import com.google.android.gms.wearable.DataEvent +import com.google.android.gms.wearable.DataEventBuffer +import com.google.android.gms.wearable.DataItem +import com.google.android.gms.wearable.DataMapItem +import com.google.android.gms.wearable.MessageEvent +import com.google.android.gms.wearable.Wearable import com.thewizrd.shared_resources.actions.ActionStatus import com.thewizrd.shared_resources.helpers.MediaHelper import com.thewizrd.shared_resources.helpers.WearConnectionStatus import com.thewizrd.shared_resources.helpers.WearableHelper -import com.thewizrd.shared_resources.utils.* +import com.thewizrd.shared_resources.utils.JSONParser +import com.thewizrd.shared_resources.utils.Logger +import com.thewizrd.shared_resources.utils.booleanToBytes +import com.thewizrd.shared_resources.utils.bytesToString +import com.thewizrd.shared_resources.utils.intToBytes +import com.thewizrd.shared_resources.utils.stringToBytes import com.thewizrd.simplewear.PhoneSyncActivity import com.thewizrd.simplewear.R import com.thewizrd.simplewear.WearableListenerActivity @@ -28,10 +42,14 @@ import com.thewizrd.simplewear.controls.AppItemViewModel import com.thewizrd.simplewear.controls.CustomConfirmationOverlay import com.thewizrd.simplewear.databinding.ActivityMusicplaybackBinding import com.thewizrd.simplewear.helpers.showConfirmationOverlay -import kotlinx.coroutines.* +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.guava.await +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch import kotlinx.coroutines.tasks.await -import java.util.* class MediaPlayerActivity : WearableListenerActivity(), AmbientModeSupport.AmbientCallbackProvider, DataClient.OnDataChangedListener { @@ -44,18 +62,18 @@ class MediaPlayerActivity : WearableListenerActivity(), AmbientModeSupport.Ambie const val ACTION_UPDATEAMBIENTMODE = "SimpleWear.Droid.action.UPDATE_AMBIENT_MODE" fun buildIntent(context: Context, appDetails: AppItemViewModel): Intent { - val intent = Intent(context, MediaPlayerActivity::class.java) - intent.putExtra( - KEY_APPDETAILS, - JSONParser.serializer(appDetails, AppItemViewModel::class.java) - ) - return intent + return Intent(context, MediaPlayerActivity::class.java).apply { + putExtra( + KEY_APPDETAILS, + JSONParser.serializer(appDetails, AppItemViewModel::class.java) + ) + } } fun buildAutoLaunchIntent(context: Context): Intent { - val intent = Intent(context, MediaPlayerActivity::class.java) - intent.putExtra(KEY_AUTOLAUNCH, true) - return intent + return Intent(context, MediaPlayerActivity::class.java).apply { + putExtra(KEY_AUTOLAUNCH, true) + } } } diff --git a/wear/src/main/java/com/thewizrd/simplewear/media/MediaPlayerFilterFragment.kt b/wear/src/main/java/com/thewizrd/simplewear/media/MediaPlayerFilterFragment.kt deleted file mode 100644 index 27c2aed..0000000 --- a/wear/src/main/java/com/thewizrd/simplewear/media/MediaPlayerFilterFragment.kt +++ /dev/null @@ -1,103 +0,0 @@ -package com.thewizrd.simplewear.media - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.MotionEvent -import android.view.View -import android.view.ViewConfiguration -import android.view.ViewGroup -import androidx.core.view.InputDeviceCompat -import androidx.core.view.MotionEventCompat -import androidx.core.view.ViewConfigurationCompat -import androidx.fragment.app.DialogFragment -import androidx.fragment.app.activityViewModels -import androidx.fragment.app.setFragmentResult -import androidx.wear.widget.SwipeDismissFrameLayout -import com.thewizrd.simplewear.adapters.MediaPlayerListAdapter -import com.thewizrd.simplewear.databinding.MediaplayerfilterListBinding -import com.thewizrd.simplewear.helpers.SimpleRecyclerViewAdapterObserver -import com.thewizrd.simplewear.preferences.Settings -import com.thewizrd.simplewear.viewmodels.MediaPlayerListViewModel -import kotlin.math.roundToInt - -class MediaPlayerFilterFragment : DialogFragment() { - private lateinit var binding: MediaplayerfilterListBinding - private lateinit var mAdapter: MediaPlayerListAdapter - - private val viewModel: MediaPlayerListViewModel by activityViewModels() - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - binding = MediaplayerfilterListBinding.inflate(inflater, container, false) - - val filteredApps = Settings.getMusicPlayersFilter() - binding.playerList.adapter = MediaPlayerListAdapter(filteredApps).also { - mAdapter = it - } - - binding.playerList.setHasFixedSize(false) - - mAdapter.registerAdapterDataObserver(object : SimpleRecyclerViewAdapterObserver() { - override fun onChanged() { - super.onChanged() - mAdapter.unregisterAdapterDataObserver(this) - binding.progressBar.hide() - binding.scrollViewContent.visibility = View.VISIBLE - } - }) - - binding.root.addCallback(object : SwipeDismissFrameLayout.Callback() { - override fun onDismissed(layout: SwipeDismissFrameLayout?) { - dismissFragment() - } - }) - - binding.clearButton.setOnClickListener { - mAdapter.clearSelections() - } - - binding.confirmButton.setOnClickListener { - dismissFragment() - } - - binding.root.setOnGenericMotionListener { view, event -> - if (event.action == MotionEvent.ACTION_SCROLL && event.isFromSource(InputDeviceCompat.SOURCE_ROTARY_ENCODER)) { - // Don't forget the negation here - val delta = -event.getAxisValue(MotionEventCompat.AXIS_SCROLL) * - ViewConfigurationCompat.getScaledVerticalScrollFactor( - ViewConfiguration.get(view.context), view.context - ) - - // Swap these axes if you want to do horizontal scrolling instead - binding.scrollView.scrollBy(0, delta.roundToInt()) - - return@setOnGenericMotionListener true - } - false - } - - return binding.root - } - - fun dismissFragment() { - // Update filtered apps - Settings.setMusicPlayersFilter(mAdapter.getSelectedItems()) - viewModel.filteredAppsList.postValue(mAdapter.getSelectedItems()) - dismiss() - tag?.let { - setFragmentResult(it, Bundle.EMPTY) - } - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - binding.scrollView.requestFocus() - - viewModel.mediaAppsList.observe(viewLifecycleOwner) { - mAdapter.submitList(it.toList()) - } - } -} \ No newline at end of file diff --git a/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/AppLauncherScreen.kt b/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/AppLauncherScreen.kt index d8933a4..37fbe91 100644 --- a/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/AppLauncherScreen.kt +++ b/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/AppLauncherScreen.kt @@ -19,12 +19,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.core.content.ContextCompat import androidx.core.graphics.drawable.toBitmap -import androidx.lifecycle.lifecycleScope import androidx.wear.compose.foundation.ExperimentalWearFoundationApi import androidx.wear.compose.foundation.HierarchicalFocusCoordinator import androidx.wear.compose.foundation.edgeSwipeToDismiss @@ -53,17 +52,14 @@ import com.google.android.horologist.compose.material.ListHeaderDefaults.firstIt import com.google.android.horologist.compose.material.ResponsiveListHeader import com.google.android.horologist.compose.pager.HorizontalPagerDefaults import com.thewizrd.shared_resources.helpers.WearConnectionStatus -import com.thewizrd.shared_resources.helpers.WearableHelper import com.thewizrd.simplewear.R import com.thewizrd.simplewear.controls.AppItemViewModel -import com.thewizrd.simplewear.helpers.showConfirmationOverlay import com.thewizrd.simplewear.ui.components.LoadingContent import com.thewizrd.simplewear.ui.theme.WearAppTheme import com.thewizrd.simplewear.ui.theme.activityViewModel import com.thewizrd.simplewear.ui.theme.findActivity import com.thewizrd.simplewear.viewmodels.AppLauncherUiState import com.thewizrd.simplewear.viewmodels.AppLauncherViewModel -import kotlinx.coroutines.launch @OptIn( ExperimentalFoundationApi::class, @@ -78,6 +74,7 @@ fun AppLauncherScreen( val activity = context.findActivity() val appLauncherViewModel = activityViewModel() + val uiState by appLauncherViewModel.uiState.collectAsState() val scrollState = rememberResponsiveColumnState( contentPadding = ScalingLazyColumnDefaults.padding( @@ -100,7 +97,7 @@ fun AppLauncherScreen( TimeText(modifier = Modifier.scrollAway { scrollState }) } }, - pagerState = pagerState + pagerState = if (uiState.isLoading) null else pagerState ) { SwipeToDismissBox( modifier = Modifier.background(MaterialTheme.colors.background), @@ -149,25 +146,16 @@ private fun AppLauncherScreen( val context = LocalContext.current val activity = context.findActivity() - val lifecycleOwner = LocalLifecycleOwner.current - val uiState by appLauncherViewModel.uiState.collectAsState() AppLauncherScreen( uiState = uiState, scrollState = scrollState, onItemClicked = { - lifecycleOwner.lifecycleScope.launch { - val success = runCatching { - val intent = WearableHelper.createRemoteActivityIntent( - it.packageName!!, - it.activityName!! - ) - appLauncherViewModel.startRemoteActivity(intent) - }.getOrDefault(false) - - activity.showConfirmationOverlay(success) - } + appLauncherViewModel.openRemoteApp(activity, it) + }, + onRefresh = { + appLauncherViewModel.refreshApps() } ) } @@ -196,7 +184,10 @@ private fun AppLauncherScreen( modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally ) { - Text(text = stringResource(id = R.string.error_noapps)) + Text( + text = stringResource(id = R.string.error_noapps), + textAlign = TextAlign.Center + ) CompactChip( label = { Text(text = stringResource(id = R.string.action_refresh)) diff --git a/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/MediaPlayerListUi.kt b/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/MediaPlayerListUi.kt new file mode 100644 index 0000000..5a55cf7 --- /dev/null +++ b/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/MediaPlayerListUi.kt @@ -0,0 +1,579 @@ +package com.thewizrd.simplewear.ui.simplewear + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import androidx.core.graphics.drawable.toBitmap +import androidx.wear.compose.foundation.ExperimentalWearFoundationApi +import androidx.wear.compose.foundation.HierarchicalFocusCoordinator +import androidx.wear.compose.foundation.edgeSwipeToDismiss +import androidx.wear.compose.foundation.lazy.items +import androidx.wear.compose.foundation.rememberSwipeToDismissBoxState +import androidx.wear.compose.material.Checkbox +import androidx.wear.compose.material.Chip +import androidx.wear.compose.material.ChipDefaults +import androidx.wear.compose.material.CompactChip +import androidx.wear.compose.material.Icon +import androidx.wear.compose.material.MaterialTheme +import androidx.wear.compose.material.PositionIndicator +import androidx.wear.compose.material.SwipeToDismissBox +import androidx.wear.compose.material.Switch +import androidx.wear.compose.material.Text +import androidx.wear.compose.material.TimeText +import androidx.wear.compose.material.ToggleChip +import androidx.wear.compose.material.dialog.Dialog +import androidx.wear.compose.ui.tooling.preview.WearPreviewDevices +import androidx.wear.compose.ui.tooling.preview.WearPreviewFontScales +import com.google.android.horologist.annotations.ExperimentalHorologistApi +import com.google.android.horologist.compose.layout.PagerScaffold +import com.google.android.horologist.compose.layout.ScalingLazyColumn +import com.google.android.horologist.compose.layout.ScalingLazyColumnDefaults +import com.google.android.horologist.compose.layout.ScalingLazyColumnState +import com.google.android.horologist.compose.layout.rememberResponsiveColumnState +import com.google.android.horologist.compose.layout.scrollAway +import com.google.android.horologist.compose.material.ListHeaderDefaults +import com.google.android.horologist.compose.material.ResponsiveListHeader +import com.google.android.horologist.compose.pager.HorizontalPagerDefaults +import com.google.android.horologist.compose.rotaryinput.rotaryWithScroll +import com.thewizrd.shared_resources.helpers.WearConnectionStatus +import com.thewizrd.simplewear.R +import com.thewizrd.simplewear.controls.AppItemViewModel +import com.thewizrd.simplewear.preferences.Settings +import com.thewizrd.simplewear.ui.components.LoadingContent +import com.thewizrd.simplewear.ui.theme.WearAppTheme +import com.thewizrd.simplewear.ui.theme.activityViewModel +import com.thewizrd.simplewear.ui.theme.findActivity +import com.thewizrd.simplewear.viewmodels.MediaPlayerListUiState +import com.thewizrd.simplewear.viewmodels.MediaPlayerListViewModel + +@OptIn( + ExperimentalHorologistApi::class, ExperimentalFoundationApi::class, + ExperimentalWearFoundationApi::class +) +@Composable +fun MediaPlayerListUi( + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val activity = context.findActivity() + + val mediaPlayerListViewModel = activityViewModel() + val uiState by mediaPlayerListViewModel.uiState.collectAsState() + + val scrollState = rememberResponsiveColumnState( + contentPadding = ScalingLazyColumnDefaults.padding( + first = ScalingLazyColumnDefaults.ItemType.Unspecified, + last = ScalingLazyColumnDefaults.ItemType.Chip, + ) + ) + val swipeToDismissBoxState = rememberSwipeToDismissBoxState() + + val pagerState = rememberPagerState( + initialPage = 0, + pageCount = { 2 } + ) + + WearAppTheme { + PagerScaffold( + modifier = Modifier.fillMaxSize(), + timeText = { + if (pagerState.currentPage == 0) { + TimeText(modifier = Modifier.scrollAway { scrollState }) + } + }, + pagerState = if (uiState.isLoading) null else pagerState + ) { + SwipeToDismissBox( + modifier = Modifier.background(MaterialTheme.colors.background), + onDismissed = { + activity.onBackPressed() + }, + state = swipeToDismissBoxState + ) { isBackground -> + if (isBackground) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colors.background) + ) + } else { + HorizontalPager( + modifier = modifier.edgeSwipeToDismiss(swipeToDismissBoxState), + state = pagerState, + flingBehavior = HorizontalPagerDefaults.flingParams(pagerState) + ) { pageIdx -> + HierarchicalFocusCoordinator(requiresFocus = { pageIdx == pagerState.currentPage }) { + if (pageIdx == 0) { + MediaPlayerListScreen( + mediaPlayerListViewModel = mediaPlayerListViewModel, + scrollState = scrollState + ) + } else { + MediaPlayerListSettings( + mediaPlayerListViewModel = mediaPlayerListViewModel + ) + } + } + } + } + } + } + } +} + +@OptIn(ExperimentalHorologistApi::class) +@Composable +private fun MediaPlayerListScreen( + mediaPlayerListViewModel: MediaPlayerListViewModel, + scrollState: ScalingLazyColumnState +) { + val context = LocalContext.current + val activity = context.findActivity() + + val uiState by mediaPlayerListViewModel.uiState.collectAsState() + + MediaPlayerListScreen( + uiState = uiState, + scrollState = scrollState, + onItemClicked = { + mediaPlayerListViewModel.startMediaApp(activity, it) + }, + onRefresh = { + mediaPlayerListViewModel.refreshState() + } + ) +} + +@OptIn(ExperimentalHorologistApi::class) +@Composable +private fun MediaPlayerListScreen( + uiState: MediaPlayerListUiState, + scrollState: ScalingLazyColumnState = rememberResponsiveColumnState(), + onItemClicked: (AppItemViewModel) -> Unit = {}, + onRefresh: () -> Unit = {} +) { + Box( + modifier = Modifier.fillMaxSize() + ) { + LoadingContent( + empty = uiState.mediaAppsSet.isEmpty(), + emptyContent = { + Box( + modifier = Modifier + .fillMaxSize() + .wrapContentHeight(), + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterVertically) + ) { + Text( + modifier = Modifier.padding(horizontal = 14.dp), + text = stringResource(id = R.string.error_nomusicplayers), + textAlign = TextAlign.Center + ) + CompactChip( + label = { + Text(text = stringResource(id = R.string.action_refresh)) + }, + icon = { + Icon( + painter = painterResource(id = R.drawable.ic_baseline_refresh_24), + contentDescription = null + ) + }, + onClick = onRefresh + ) + } + } + }, + loading = uiState.isLoading + ) { + ScalingLazyColumn( + modifier = Modifier.fillMaxSize(), + columnState = scrollState, + ) { + item { + ResponsiveListHeader(contentPadding = ListHeaderDefaults.firstItemPadding()) { + Text(text = stringResource(id = R.string.action_apps)) + } + } + + items( + items = uiState.mediaAppsSet.toList(), + key = { Pair(it.activityName, it.packageName) } + ) { + Chip( + modifier = Modifier.fillMaxWidth(), + label = { + Text(text = it.appLabel ?: "") + }, + icon = it.bitmapIcon?.let { + { + Icon( + modifier = Modifier.requiredSize(ChipDefaults.IconSize), + bitmap = it.asImageBitmap(), + contentDescription = null, + tint = Color.Unspecified + ) + } + }, + colors = ChipDefaults.secondaryChipColors(), + onClick = { + onItemClicked(it) + } + ) + } + } + + PositionIndicator(scalingLazyListState = scrollState.state) + } + } +} + +@Composable +private fun MediaPlayerListSettings( + mediaPlayerListViewModel: MediaPlayerListViewModel +) { + val uiState by mediaPlayerListViewModel.uiState.collectAsState() + + MediaPlayerListSettings( + uiState = uiState, + onCheckChanged = { + Settings.setAutoLaunchMediaCtrls(it) + }, + onCommitSelectedItems = { + mediaPlayerListViewModel.updateFilteredApps(it) + } + ) +} + +@OptIn(ExperimentalHorologistApi::class) +@Composable +private fun MediaPlayerListSettings( + uiState: MediaPlayerListUiState, + onCheckChanged: (Boolean) -> Unit = {}, + onCommitSelectedItems: (Set) -> Unit = {} +) { + val scrollState = rememberResponsiveColumnState( + contentPadding = ScalingLazyColumnDefaults.padding( + first = ScalingLazyColumnDefaults.ItemType.Unspecified, + last = ScalingLazyColumnDefaults.ItemType.Chip, + ) + ) + + var showFilterDialog by remember { mutableStateOf(false) } + + ScalingLazyColumn( + columnState = scrollState + ) { + item { + ResponsiveListHeader( + modifier = Modifier.fillMaxWidth(), + contentPadding = ListHeaderDefaults.firstItemPadding() + ) { + Text(text = stringResource(id = R.string.title_settings)) + } + } + item { + Chip( + modifier = Modifier.fillMaxWidth(), + label = { + Text(text = stringResource(id = R.string.title_filter_apps)) + }, + onClick = { + showFilterDialog = true + }, + colors = ChipDefaults.secondaryChipColors(), + icon = { + Icon( + painter = painterResource(id = R.drawable.ic_baseline_filter_list_24), + contentDescription = null + ) + } + ) + } + item { + ToggleChip( + modifier = Modifier.fillMaxWidth(), + label = { + Text(text = stringResource(id = R.string.title_autolaunchmediactrls)) + }, + checked = uiState.isAutoLaunchEnabled, + onCheckedChange = onCheckChanged, + toggleControl = { + Switch(checked = uiState.isAutoLaunchEnabled) + } + ) + } + } + + val dialogScrollState = rememberResponsiveColumnState( + contentPadding = ScalingLazyColumnDefaults.padding( + first = ScalingLazyColumnDefaults.ItemType.Unspecified, + last = ScalingLazyColumnDefaults.ItemType.Chip, + ) + ) + + var selectedItems by remember(uiState.filteredAppsList) { + mutableStateOf(uiState.filteredAppsList) + } + + Dialog( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colors.background), + showDialog = showFilterDialog, + onDismissRequest = { + onCommitSelectedItems.invoke(selectedItems) + showFilterDialog = false + }, + scrollState = dialogScrollState.state + ) { + MediaPlayerFilterScreen( + uiState = uiState, + dialogScrollState = dialogScrollState, + selectedItems = selectedItems, + onSelectedItemsChanged = { + selectedItems = it + }, + onDismissRequest = { + onCommitSelectedItems.invoke(selectedItems) + showFilterDialog = false + } + ) + + LaunchedEffect(showFilterDialog) { + dialogScrollState.state.scrollToItem(0) + } + } +} + +@OptIn(ExperimentalHorologistApi::class) +@Composable +private fun MediaPlayerFilterScreen( + uiState: MediaPlayerListUiState, + dialogScrollState: ScalingLazyColumnState = rememberResponsiveColumnState(), + selectedItems: Set = emptySet(), + onSelectedItemsChanged: (Set) -> Unit = {}, + onDismissRequest: () -> Unit = {} +) { + ScalingLazyColumn( + modifier = Modifier + .fillMaxSize() + .rotaryWithScroll(dialogScrollState), + columnState = dialogScrollState, + ) { + item { + ResponsiveListHeader( + modifier = Modifier.fillMaxWidth(), + contentPadding = ListHeaderDefaults.firstItemPadding() + ) { + Text(text = stringResource(id = R.string.title_filter_apps)) + } + } + + items(uiState.allMediaAppsSet.toList()) { + val isChecked = selectedItems.contains(it.packageName!!) + + ToggleChip( + modifier = Modifier.fillMaxWidth(), + checked = isChecked, + onCheckedChange = { checked -> + onSelectedItemsChanged.invoke( + if (!checked) { + selectedItems.minusElement(it.packageName!!) + } else { + selectedItems.plusElement(it.packageName!!) + } + ) + }, + label = { + Text( + text = it.appLabel!! + ) + }, + toggleControl = { + Checkbox( + checked = isChecked + ) + } + ) + } + + item { + Spacer(modifier = Modifier.height(8.dp)) + } + + item { + Chip( + modifier = Modifier.fillMaxWidth(), + label = { + Text(text = stringResource(id = R.string.clear_all)) + }, + icon = { + Icon( + painter = painterResource(id = R.drawable.ic_clear_all_24dp), + contentDescription = stringResource(id = R.string.clear_all) + ) + }, + onClick = { + onSelectedItemsChanged.invoke(emptySet()) + }, + colors = ChipDefaults.secondaryChipColors( + backgroundColor = MaterialTheme.colors.onBackground, + contentColor = MaterialTheme.colors.background + ) + ) + } + + item { + Chip( + modifier = Modifier.fillMaxWidth(), + label = { + Text(text = stringResource(id = android.R.string.ok)) + }, + icon = { + Icon( + painter = painterResource(id = R.drawable.ic_check_white_24dp), + contentDescription = stringResource(id = android.R.string.ok) + ) + }, + onClick = { + onDismissRequest.invoke() + }, + colors = ChipDefaults.primaryChipColors() + ) + } + } +} + +@OptIn(ExperimentalHorologistApi::class) +@WearPreviewDevices +@WearPreviewFontScales +@Composable +private fun PreviewNoContentMediaPlayerListScreen() { + val uiState = remember { + MediaPlayerListUiState( + connectionStatus = WearConnectionStatus.CONNECTED, + allMediaAppsSet = emptySet(), + mediaAppsSet = emptySet(), + filteredAppsList = emptySet(), + isLoading = false, + isAutoLaunchEnabled = false + ) + } + + MediaPlayerListScreen(uiState = uiState) +} + +@OptIn(ExperimentalHorologistApi::class) +@WearPreviewDevices +@WearPreviewFontScales +@Composable +private fun PreviewMediaPlayerListScreen() { + val context = LocalContext.current + + val allApps = remember { + List(5) { + AppItemViewModel().apply { + appLabel = "App ${it + 1}" + packageName = "com.package.${it}" + bitmapIcon = ContextCompat.getDrawable(context, R.drawable.ic_icon)!!.toBitmap() + } + }.toSet() + } + + val uiState = remember { + MediaPlayerListUiState( + connectionStatus = WearConnectionStatus.CONNECTED, + allMediaAppsSet = allApps, + mediaAppsSet = allApps, + filteredAppsList = emptySet(), + isLoading = false, + isAutoLaunchEnabled = false + ) + } + + MediaPlayerListScreen(uiState = uiState) +} + +@WearPreviewDevices +@WearPreviewFontScales +@Composable +private fun PreviewMediaPlayerSettings() { + val uiState = remember { + MediaPlayerListUiState( + connectionStatus = WearConnectionStatus.CONNECTED, + filteredAppsList = emptySet(), + isLoading = false, + isAutoLaunchEnabled = false + ) + } + + MediaPlayerListSettings(uiState = uiState) +} + +@OptIn(ExperimentalHorologistApi::class) +@WearPreviewDevices +@WearPreviewFontScales +@Composable +private fun PreviewMediaPlayerFilterScreen() { + val context = LocalContext.current + + val allApps = remember { + List(2) { + AppItemViewModel().apply { + appLabel = "App ${it + 1}" + packageName = "com.package.${it}" + bitmapIcon = ContextCompat.getDrawable(context, R.drawable.ic_icon)!!.toBitmap() + } + }.toSet() + } + + val uiState = remember { + MediaPlayerListUiState( + connectionStatus = WearConnectionStatus.CONNECTED, + allMediaAppsSet = allApps, + mediaAppsSet = emptySet(), + filteredAppsList = setOf("com.package.0"), + isLoading = false, + isAutoLaunchEnabled = false + ) + } + + MediaPlayerFilterScreen( + uiState = uiState, + selectedItems = uiState.filteredAppsList + ) +} \ No newline at end of file diff --git a/wear/src/main/java/com/thewizrd/simplewear/viewmodels/AppLauncherViewModel.kt b/wear/src/main/java/com/thewizrd/simplewear/viewmodels/AppLauncherViewModel.kt index 9e26bd7..cfc5426 100644 --- a/wear/src/main/java/com/thewizrd/simplewear/viewmodels/AppLauncherViewModel.kt +++ b/wear/src/main/java/com/thewizrd/simplewear/viewmodels/AppLauncherViewModel.kt @@ -1,5 +1,6 @@ package com.thewizrd.simplewear.viewmodels +import android.app.Activity import android.app.Application import android.os.Bundle import android.os.CountDownTimer @@ -24,6 +25,7 @@ import com.thewizrd.shared_resources.utils.ImageUtils.toBitmap import com.thewizrd.shared_resources.utils.Logger import com.thewizrd.shared_resources.utils.bytesToString import com.thewizrd.simplewear.controls.AppItemViewModel +import com.thewizrd.simplewear.helpers.showConfirmationOverlay import com.thewizrd.simplewear.preferences.Settings import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -262,6 +264,20 @@ class AppLauncherViewModel(app: Application) : WearableListenerViewModel(app), } } + fun openRemoteApp(activity: Activity, item: AppItemViewModel) { + viewModelScope.launch { + val success = runCatching { + val intent = WearableHelper.createRemoteActivityIntent( + item.packageName!!, + item.activityName!! + ) + startRemoteActivity(intent) + }.getOrDefault(false) + + activity.showConfirmationOverlay(success) + } + } + private fun requestAppsUpdate() { viewModelScope.launch { if (connect()) { diff --git a/wear/src/main/java/com/thewizrd/simplewear/viewmodels/MediaPlayerListViewModel.kt b/wear/src/main/java/com/thewizrd/simplewear/viewmodels/MediaPlayerListViewModel.kt index 5b643c5..ed631fe 100644 --- a/wear/src/main/java/com/thewizrd/simplewear/viewmodels/MediaPlayerListViewModel.kt +++ b/wear/src/main/java/com/thewizrd/simplewear/viewmodels/MediaPlayerListViewModel.kt @@ -1,10 +1,323 @@ package com.thewizrd.simplewear.viewmodels -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel +import android.app.Activity +import android.app.Application +import android.os.Bundle +import android.os.CountDownTimer +import android.util.Log +import androidx.lifecycle.viewModelScope +import com.google.android.gms.wearable.DataClient.OnDataChangedListener +import com.google.android.gms.wearable.DataEvent +import com.google.android.gms.wearable.DataEventBuffer +import com.google.android.gms.wearable.DataMap +import com.google.android.gms.wearable.DataMapItem +import com.google.android.gms.wearable.MessageEvent +import com.google.android.gms.wearable.Wearable +import com.thewizrd.shared_resources.actions.ActionStatus +import com.thewizrd.shared_resources.helpers.MediaHelper +import com.thewizrd.shared_resources.helpers.WearConnectionStatus +import com.thewizrd.shared_resources.helpers.WearableHelper +import com.thewizrd.shared_resources.utils.ImageUtils +import com.thewizrd.shared_resources.utils.Logger +import com.thewizrd.shared_resources.utils.bytesToString import com.thewizrd.simplewear.controls.AppItemViewModel +import com.thewizrd.simplewear.helpers.AppItemComparator +import com.thewizrd.simplewear.helpers.showConfirmationOverlay +import com.thewizrd.simplewear.media.MediaPlayerActivity +import com.thewizrd.simplewear.preferences.Settings +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.tasks.await -class MediaPlayerListViewModel : ViewModel() { - val mediaAppsList: MutableLiveData> = MutableLiveData() - val filteredAppsList: MutableLiveData> = MutableLiveData() +data class MediaPlayerListUiState( + val connectionStatus: WearConnectionStatus? = null, + internal val allMediaAppsSet: Set = emptySet(), + val mediaAppsSet: Set = emptySet(), + val filteredAppsList: Set = Settings.getMusicPlayersFilter(), + val isLoading: Boolean = false, + val isAutoLaunchEnabled: Boolean = Settings.isAutoLaunchMediaCtrlsEnabled +) + +class MediaPlayerListViewModel(app: Application) : WearableListenerViewModel(app), + OnDataChangedListener { + private val viewModelState = MutableStateFlow(MediaPlayerListUiState(isLoading = true)) + + private val timer: CountDownTimer + private val mutex = Mutex() + + val uiState = viewModelState.stateIn( + viewModelScope, + SharingStarted.Eagerly, + viewModelState.value + ) + + private val filteredAppsList = uiState.map { it.filteredAppsList } + + init { + Wearable.getDataClient(appContext).addListener(this) + + // Set timer for retrieving music player data + timer = object : CountDownTimer(3000, 1000) { + override fun onTick(millisUntilFinished: Long) {} + override fun onFinish() { + refreshMusicPlayers() + } + } + + viewModelScope.launch { + eventFlow.collect { event -> + when (event.eventType) { + ACTION_UPDATECONNECTIONSTATUS -> { + val connectionStatus = WearConnectionStatus.valueOf( + event.data.getInt( + EXTRA_CONNECTIONSTATUS, + 0 + ) + ) + + viewModelState.update { + it.copy( + connectionStatus = connectionStatus + ) + } + } + } + } + } + + viewModelScope.launch { + filteredAppsList.collect { + if (uiState.value.allMediaAppsSet.isNotEmpty()) { + updateAppsList() + } + } + } + } + + override fun onMessageReceived(messageEvent: MessageEvent) { + when (messageEvent.path) { + MediaHelper.MusicPlayersPath -> { + val status = ActionStatus.valueOf(messageEvent.data.bytesToString()) + + if (status == ActionStatus.PERMISSION_DENIED) { + timer.cancel() + + viewModelState.update { + it.copy(allMediaAppsSet = emptySet()) + } + + updateAppsList() + } else if (status == ActionStatus.SUCCESS) { + refreshMusicPlayers() + } + + _eventsFlow.tryEmit(WearableEvent(messageEvent.path, Bundle().apply { + putSerializable(EXTRA_STATUS, status) + })) + } + + MediaHelper.MediaPlayerAutoLaunchPath -> { + val status = ActionStatus.valueOf(messageEvent.data.bytesToString()) + + _eventsFlow.tryEmit(WearableEvent(messageEvent.path, Bundle().apply { + putSerializable(EXTRA_STATUS, status) + })) + } + + else -> super.onMessageReceived(messageEvent) + } + } + + override fun onDataChanged(dataEventBuffer: DataEventBuffer) { + viewModelScope.launch { + // Cancel timer + timer.cancel() + viewModelState.update { + it.copy(isLoading = false) + } + + for (event in dataEventBuffer) { + if (event.type == DataEvent.TYPE_CHANGED) { + val item = event.dataItem + if (MediaHelper.MusicPlayersPath == item.uri.path) { + try { + val dataMap = DataMapItem.fromDataItem(item).dataMap + updateMusicPlayers(dataMap) + } catch (e: Exception) { + Logger.writeLine(Log.ERROR, e) + } + } + } + } + } + } + + override fun onCleared() { + requestPlayerDisconnect() + Wearable.getDataClient(appContext).removeListener(this) + super.onCleared() + } + + fun refreshState(startTimer: Boolean = false) { + viewModelScope.launch { + updateConnectionStatus() + requestPlayersUpdate() + if (startTimer) { + // Wait for music player update + timer.start() + } + } + } + + fun startMediaApp(activity: Activity, item: AppItemViewModel) { + viewModelScope.launch { + val success = runCatching { + val intent = MediaHelper.createRemoteActivityIntent( + item.packageName!!, + item.activityName!! + ) + startRemoteActivity(intent) + }.getOrDefault(false) + + if (success) { + activity.startActivity(MediaPlayerActivity.buildIntent(activity, item)) + } else { + activity.showConfirmationOverlay(false) + } + } + } + + private fun requestPlayersUpdate() { + viewModelScope.launch { + if (connect()) { + sendMessage(mPhoneNodeWithApp!!.id, MediaHelper.MusicPlayersPath, null) + } + } + } + + private fun requestPlayerDisconnect() { + viewModelScope.launch { + if (connect()) { + sendMessage(mPhoneNodeWithApp!!.id, MediaHelper.MediaPlayerDisconnectPath, null) + } + } + } + + private fun refreshMusicPlayers() { + viewModelScope.launch(Dispatchers.IO) { + try { + val buff = Wearable.getDataClient(appContext) + .getDataItems( + WearableHelper.getWearDataUri( + "*", + MediaHelper.MusicPlayersPath + ) + ) + .await() + + for (i in 0 until buff.count) { + val item = buff[i] + if (MediaHelper.MusicPlayersPath == item.uri.path) { + try { + val dataMap = DataMapItem.fromDataItem(item).dataMap + updateMusicPlayers(dataMap) + } catch (e: Exception) { + Logger.writeLine(Log.ERROR, e) + } + viewModelState.update { + it.copy(isLoading = false) + } + } + } + + buff.release() + } catch (e: Exception) { + Logger.writeLine(Log.ERROR, e) + } + } + } + + private suspend fun updateMusicPlayers(dataMap: DataMap) = mutex.withLock { + val supportedPlayers = + dataMap.getStringArrayList(MediaHelper.KEY_SUPPORTEDPLAYERS) ?: return + + val mediaAppsList = mutableSetOf() + + for (key in supportedPlayers) { + val map = dataMap.getDataMap(key) ?: continue + + val model = AppItemViewModel().apply { + appLabel = map.getString(WearableHelper.KEY_LABEL) + packageName = map.getString(WearableHelper.KEY_PKGNAME) + activityName = map.getString(WearableHelper.KEY_ACTIVITYNAME) + bitmapIcon = map.getAsset(WearableHelper.KEY_ICON)?.let { + try { + ImageUtils.bitmapFromAssetStream( + Wearable.getDataClient(appContext), + it + ) + } catch (e: Exception) { + null + } + } + } + mediaAppsList.add(model) + } + + viewModelState.update { + it.copy(allMediaAppsSet = mediaAppsList) + } + updateAppsList() + } + + private fun updateAppsList() { + viewModelScope.launch { + val filteredApps = Settings.getMusicPlayersFilter() + + if (filteredApps.isEmpty()) { + viewModelState.update { + it.copy( + mediaAppsSet = it.allMediaAppsSet.toSortedSet(AppItemComparator()), + isLoading = false + ) + } + } else { + viewModelState.update { state -> + state.copy( + mediaAppsSet = state.allMediaAppsSet.toMutableList().apply { + removeIf { !filteredApps.contains(it.packageName) } + }.toSortedSet(AppItemComparator()), + isLoading = false + ) + } + } + } + } + + suspend fun autoLaunchMediaControls() { + if (Settings.isAutoLaunchMediaCtrlsEnabled) { + if (connect()) { + sendMessage( + mPhoneNodeWithApp!!.id, + MediaHelper.MediaPlayerAutoLaunchPath, + null + ) + } + } + } + + fun updateFilteredApps(items: Set) { + Settings.setMusicPlayersFilter(items) + + viewModelState.update { + it.copy(filteredAppsList = items) + } + } } \ No newline at end of file diff --git a/wear/src/main/res/layout/activity_musicplayerlist.xml b/wear/src/main/res/layout/activity_musicplayerlist.xml deleted file mode 100644 index 419d2ac..0000000 --- a/wear/src/main/res/layout/activity_musicplayerlist.xml +++ /dev/null @@ -1,87 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/wear/src/main/res/layout/activity_setup_sync.xml b/wear/src/main/res/layout/activity_setup_sync.xml deleted file mode 100644 index 791dde6..0000000 --- a/wear/src/main/res/layout/activity_setup_sync.xml +++ /dev/null @@ -1,87 +0,0 @@ - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/wear/src/main/res/layout/control_fabtogglebutton.xml b/wear/src/main/res/layout/control_fabtogglebutton.xml deleted file mode 100644 index b99c557..0000000 --- a/wear/src/main/res/layout/control_fabtogglebutton.xml +++ /dev/null @@ -1,103 +0,0 @@ - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/wear/src/main/res/layout/layout_list_header.xml b/wear/src/main/res/layout/layout_list_header.xml deleted file mode 100644 index adfee09..0000000 --- a/wear/src/main/res/layout/layout_list_header.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/wear/src/main/res/layout/mediaplayerfilter_list.xml b/wear/src/main/res/layout/mediaplayerfilter_list.xml deleted file mode 100644 index dab9c13..0000000 --- a/wear/src/main/res/layout/mediaplayerfilter_list.xml +++ /dev/null @@ -1,100 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/wear/src/main/res/layout/mediaplayerlist_item.xml b/wear/src/main/res/layout/mediaplayerlist_item.xml deleted file mode 100644 index 0bd50e5..0000000 --- a/wear/src/main/res/layout/mediaplayerlist_item.xml +++ /dev/null @@ -1,16 +0,0 @@ - - \ No newline at end of file diff --git a/wear/src/main/res/layout/musicplayer_item_sleeptimer.xml b/wear/src/main/res/layout/musicplayer_item_sleeptimer.xml deleted file mode 100644 index fd0c699..0000000 --- a/wear/src/main/res/layout/musicplayer_item_sleeptimer.xml +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - - - diff --git a/wear/src/main/res/layout/musicplayerlistactivity_drawer_layout.xml b/wear/src/main/res/layout/musicplayerlistactivity_drawer_layout.xml deleted file mode 100644 index 3368bc2..0000000 --- a/wear/src/main/res/layout/musicplayerlistactivity_drawer_layout.xml +++ /dev/null @@ -1,64 +0,0 @@ - - - - - - - - - - - - - - - - - - From 348a80e626f55e1f5a7ce7d2e92e926a278a59ab Mon Sep 17 00:00:00 2001 From: Dave Antoine Date: Sun, 31 Mar 2024 11:31:46 -0400 Subject: [PATCH 21/58] res: remove unused resources --- .../thewizrd/simplewear/controls/AppItem.kt | 48 ------------------- wear/src/main/res/color/button_checkable.xml | 5 -- .../color/checkable_button_stroke_color.xml | 7 --- .../res/drawable/button_background_stroke.xml | 11 ----- .../src/main/res/drawable/button_noborder.xml | 13 ----- .../res/drawable/ic_baseline_apps_24dp.xml | 10 ---- .../ic_do_not_disturb_on_white_24dp.xml | 10 ---- wear/src/main/res/drawable/ic_mic_24dp.xml | 10 ---- .../res/drawable/ic_sleep_timer_launcher.xml | 12 ----- .../main/res/drawable/ic_sleep_timer_logo.xml | 15 ------ .../main/res/drawable/ic_stop_white_24dp.xml | 10 ---- .../drawable/ic_sync_disabled_white_24dp.xml | 10 ---- .../main/res/drawable/round_button_cancel.xml | 16 ------- .../res/drawable/round_button_clearall.xml | 16 ------- .../src/main/res/drawable/round_button_ok.xml | 16 ------- .../res/drawable/wear_checkbox_icon_anim.xml | 35 -------------- wear/src/main/res/drawable/wear_divider.xml | 14 ------ .../res/drawable/wear_radio_icon_anim.xml | 35 -------------- .../res/layout/layout_timetext_header.xml | 5 -- wear/src/main/res/values-round/dimens.xml | 7 --- .../res/values-sw180dp-notround/dimens.xml | 1 - .../main/res/values-sw210dp-round/dimens.xml | 1 - wear/src/main/res/values/dimens.xml | 23 --------- wear/src/main/res/values/styles.xml | 28 ----------- wear/src/main/res/values/wear_styles.xml | 32 ------------- 25 files changed, 390 deletions(-) delete mode 100644 wear/src/main/java/com/thewizrd/simplewear/controls/AppItem.kt delete mode 100644 wear/src/main/res/color/button_checkable.xml delete mode 100644 wear/src/main/res/color/checkable_button_stroke_color.xml delete mode 100644 wear/src/main/res/drawable/button_background_stroke.xml delete mode 100644 wear/src/main/res/drawable/button_noborder.xml delete mode 100644 wear/src/main/res/drawable/ic_baseline_apps_24dp.xml delete mode 100644 wear/src/main/res/drawable/ic_do_not_disturb_on_white_24dp.xml delete mode 100644 wear/src/main/res/drawable/ic_mic_24dp.xml delete mode 100644 wear/src/main/res/drawable/ic_sleep_timer_launcher.xml delete mode 100644 wear/src/main/res/drawable/ic_sleep_timer_logo.xml delete mode 100644 wear/src/main/res/drawable/ic_stop_white_24dp.xml delete mode 100644 wear/src/main/res/drawable/ic_sync_disabled_white_24dp.xml delete mode 100644 wear/src/main/res/drawable/round_button_cancel.xml delete mode 100644 wear/src/main/res/drawable/round_button_clearall.xml delete mode 100644 wear/src/main/res/drawable/round_button_ok.xml delete mode 100644 wear/src/main/res/drawable/wear_checkbox_icon_anim.xml delete mode 100644 wear/src/main/res/drawable/wear_divider.xml delete mode 100644 wear/src/main/res/drawable/wear_radio_icon_anim.xml delete mode 100644 wear/src/main/res/layout/layout_timetext_header.xml diff --git a/wear/src/main/java/com/thewizrd/simplewear/controls/AppItem.kt b/wear/src/main/java/com/thewizrd/simplewear/controls/AppItem.kt deleted file mode 100644 index 10d6875..0000000 --- a/wear/src/main/java/com/thewizrd/simplewear/controls/AppItem.kt +++ /dev/null @@ -1,48 +0,0 @@ -package com.thewizrd.simplewear.controls - -import android.content.Context -import android.util.AttributeSet -import android.view.LayoutInflater -import android.widget.LinearLayout -import com.thewizrd.simplewear.R -import com.thewizrd.simplewear.databinding.AppItemBinding - -class AppItem : LinearLayout { - private lateinit var binding: AppItemBinding - - constructor(context: Context) : super(context) { - initialize(context) - } - - constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) { - initialize(context) - } - - constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { - initialize(context) - } - - constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int) : super(context, attrs, defStyleAttr, defStyleRes) { - initialize(context) - } - - private fun initialize(context: Context) { - val inflater = LayoutInflater.from(context) - binding = AppItemBinding.inflate(inflater, this, true) - } - - fun updateItem(viewModel: AppItemViewModel) { - if (viewModel.bitmapIcon != null) { - binding.appIcon.setImageBitmap(viewModel.bitmapIcon) - } else { - binding.appIcon.setImageResource( - if (viewModel.appType == AppItemViewModel.AppType.MUSIC_PLAYER) { - R.drawable.ic_play_circle_filled_white_24dp - } else { - R.drawable.ic_baseline_android_24dp - } - ) - } - binding.appName.text = viewModel.appLabel - } -} \ No newline at end of file diff --git a/wear/src/main/res/color/button_checkable.xml b/wear/src/main/res/color/button_checkable.xml deleted file mode 100644 index cef216a..0000000 --- a/wear/src/main/res/color/button_checkable.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/wear/src/main/res/color/checkable_button_stroke_color.xml b/wear/src/main/res/color/checkable_button_stroke_color.xml deleted file mode 100644 index fcc1777..0000000 --- a/wear/src/main/res/color/checkable_button_stroke_color.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/wear/src/main/res/drawable/button_background_stroke.xml b/wear/src/main/res/drawable/button_background_stroke.xml deleted file mode 100644 index 5df062f..0000000 --- a/wear/src/main/res/drawable/button_background_stroke.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - diff --git a/wear/src/main/res/drawable/button_noborder.xml b/wear/src/main/res/drawable/button_noborder.xml deleted file mode 100644 index c7daee1..0000000 --- a/wear/src/main/res/drawable/button_noborder.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file diff --git a/wear/src/main/res/drawable/ic_baseline_apps_24dp.xml b/wear/src/main/res/drawable/ic_baseline_apps_24dp.xml deleted file mode 100644 index f6d8651..0000000 --- a/wear/src/main/res/drawable/ic_baseline_apps_24dp.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/wear/src/main/res/drawable/ic_do_not_disturb_on_white_24dp.xml b/wear/src/main/res/drawable/ic_do_not_disturb_on_white_24dp.xml deleted file mode 100644 index 9e86d3e..0000000 --- a/wear/src/main/res/drawable/ic_do_not_disturb_on_white_24dp.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/wear/src/main/res/drawable/ic_mic_24dp.xml b/wear/src/main/res/drawable/ic_mic_24dp.xml deleted file mode 100644 index fff02b5..0000000 --- a/wear/src/main/res/drawable/ic_mic_24dp.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/wear/src/main/res/drawable/ic_sleep_timer_launcher.xml b/wear/src/main/res/drawable/ic_sleep_timer_launcher.xml deleted file mode 100644 index 3f20dcc..0000000 --- a/wear/src/main/res/drawable/ic_sleep_timer_launcher.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/wear/src/main/res/drawable/ic_sleep_timer_logo.xml b/wear/src/main/res/drawable/ic_sleep_timer_logo.xml deleted file mode 100644 index a87da29..0000000 --- a/wear/src/main/res/drawable/ic_sleep_timer_logo.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - diff --git a/wear/src/main/res/drawable/ic_stop_white_24dp.xml b/wear/src/main/res/drawable/ic_stop_white_24dp.xml deleted file mode 100644 index e0a3185..0000000 --- a/wear/src/main/res/drawable/ic_stop_white_24dp.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/wear/src/main/res/drawable/ic_sync_disabled_white_24dp.xml b/wear/src/main/res/drawable/ic_sync_disabled_white_24dp.xml deleted file mode 100644 index 00caf22..0000000 --- a/wear/src/main/res/drawable/ic_sync_disabled_white_24dp.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/wear/src/main/res/drawable/round_button_cancel.xml b/wear/src/main/res/drawable/round_button_cancel.xml deleted file mode 100644 index 96f397e..0000000 --- a/wear/src/main/res/drawable/round_button_cancel.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/wear/src/main/res/drawable/round_button_clearall.xml b/wear/src/main/res/drawable/round_button_clearall.xml deleted file mode 100644 index 66ff2dc..0000000 --- a/wear/src/main/res/drawable/round_button_clearall.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/wear/src/main/res/drawable/round_button_ok.xml b/wear/src/main/res/drawable/round_button_ok.xml deleted file mode 100644 index c27c64d..0000000 --- a/wear/src/main/res/drawable/round_button_ok.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/wear/src/main/res/drawable/wear_checkbox_icon_anim.xml b/wear/src/main/res/drawable/wear_checkbox_icon_anim.xml deleted file mode 100644 index 9565872..0000000 --- a/wear/src/main/res/drawable/wear_checkbox_icon_anim.xml +++ /dev/null @@ -1,35 +0,0 @@ - - - - - - - - diff --git a/wear/src/main/res/drawable/wear_divider.xml b/wear/src/main/res/drawable/wear_divider.xml deleted file mode 100644 index 81ee3b9..0000000 --- a/wear/src/main/res/drawable/wear_divider.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/wear/src/main/res/drawable/wear_radio_icon_anim.xml b/wear/src/main/res/drawable/wear_radio_icon_anim.xml deleted file mode 100644 index 108adb7..0000000 --- a/wear/src/main/res/drawable/wear_radio_icon_anim.xml +++ /dev/null @@ -1,35 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/wear/src/main/res/layout/layout_timetext_header.xml b/wear/src/main/res/layout/layout_timetext_header.xml deleted file mode 100644 index d9a1aeb..0000000 --- a/wear/src/main/res/layout/layout_timetext_header.xml +++ /dev/null @@ -1,5 +0,0 @@ - - \ No newline at end of file diff --git a/wear/src/main/res/values-round/dimens.xml b/wear/src/main/res/values-round/dimens.xml index 9053af8..8ec24f7 100644 --- a/wear/src/main/res/values-round/dimens.xml +++ b/wear/src/main/res/values-round/dimens.xml @@ -1,18 +1,11 @@ 32dp - 48dp 36dp - 16dp - 8dp - 48dp - 12dp 36dp - 48dp - 14dp 24dp diff --git a/wear/src/main/res/values-sw180dp-notround/dimens.xml b/wear/src/main/res/values-sw180dp-notround/dimens.xml index 647c18f..2250edc 100644 --- a/wear/src/main/res/values-sw180dp-notround/dimens.xml +++ b/wear/src/main/res/values-sw180dp-notround/dimens.xml @@ -1,7 +1,6 @@ 48dp - 48dp 48dp 12dp \ No newline at end of file diff --git a/wear/src/main/res/values-sw210dp-round/dimens.xml b/wear/src/main/res/values-sw210dp-round/dimens.xml index b617da2..a706320 100644 --- a/wear/src/main/res/values-sw210dp-round/dimens.xml +++ b/wear/src/main/res/values-sw210dp-round/dimens.xml @@ -1,7 +1,6 @@ 54dp - 48dp 48dp 12dp \ No newline at end of file diff --git a/wear/src/main/res/values/dimens.xml b/wear/src/main/res/values/dimens.xml index 627ef58..9e63e81 100644 --- a/wear/src/main/res/values/dimens.xml +++ b/wear/src/main/res/values/dimens.xml @@ -1,25 +1,8 @@ - - 0dp - - - 5dp - 8dp - 8dp 32dp - 0dp - 0dp - 4dp 22dp 4dp @@ -37,13 +20,7 @@ 48dp 48dp - 0dp 8dp - 8dp - 8dp - - 40dp - 24dp 8dp diff --git a/wear/src/main/res/values/styles.xml b/wear/src/main/res/values/styles.xml index 742fa65..1b40df5 100644 --- a/wear/src/main/res/values/styles.xml +++ b/wear/src/main/res/values/styles.xml @@ -37,10 +37,6 @@ @color/ic_launcher_background - - - - - - - - - - - - - - - - - - - - \ No newline at end of file From 71b88f1da0f7608dbac68a0e40a7bc5d574bfca9 Mon Sep 17 00:00:00 2001 From: Dave Antoine Date: Wed, 3 Apr 2024 20:17:34 -0400 Subject: [PATCH 22/58] MediaPlayer: migrate to compose --- wear/build.gradle | 1 + .../controls/AmbientModeViewModel.kt | 14 - .../simplewear/media/MediaBrowserFragment.kt | 270 ------ .../media/MediaCustomControlsFragment.kt | 307 ------- .../simplewear/media/MediaPlayerActivity.kt | 606 ++----------- .../media/MediaPlayerControlsFragment.kt | 565 ------------ .../simplewear/media/MediaPlayerViewModel.kt | 805 ++++++++++++++++++ .../simplewear/media/MediaQueueFragment.kt | 375 -------- .../simplewear/ui/ambient/AmbientMode.kt | 84 ++ .../ui/simplewear/AppLauncherScreen.kt | 3 + .../simplewear/ui/simplewear/CallManagerUi.kt | 1 + .../simplewear/ui/simplewear/MediaPlayerUi.kt | 725 ++++++++++++++++ .../viewmodels/MediaPlayerListViewModel.kt | 41 +- .../viewmodels/ValueActionViewModel.kt | 1 - .../res/layout/activity_musicplayback.xml | 54 -- .../main/res/layout/fragment_browser_list.xml | 28 - .../main/res/layout/media_player_controls.xml | 241 ------ wear/src/main/res/values/strings.xml | 1 + 18 files changed, 1716 insertions(+), 2406 deletions(-) delete mode 100644 wear/src/main/java/com/thewizrd/simplewear/controls/AmbientModeViewModel.kt delete mode 100644 wear/src/main/java/com/thewizrd/simplewear/media/MediaBrowserFragment.kt delete mode 100644 wear/src/main/java/com/thewizrd/simplewear/media/MediaCustomControlsFragment.kt delete mode 100644 wear/src/main/java/com/thewizrd/simplewear/media/MediaPlayerControlsFragment.kt create mode 100644 wear/src/main/java/com/thewizrd/simplewear/media/MediaPlayerViewModel.kt delete mode 100644 wear/src/main/java/com/thewizrd/simplewear/media/MediaQueueFragment.kt create mode 100644 wear/src/main/java/com/thewizrd/simplewear/ui/ambient/AmbientMode.kt create mode 100644 wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/MediaPlayerUi.kt delete mode 100644 wear/src/main/res/layout/activity_musicplayback.xml delete mode 100644 wear/src/main/res/layout/fragment_browser_list.xml delete mode 100644 wear/src/main/res/layout/media_player_controls.xml diff --git a/wear/build.gradle b/wear/build.gradle index 58aecb9..23c21cb 100644 --- a/wear/build.gradle +++ b/wear/build.gradle @@ -123,6 +123,7 @@ dependencies { implementation "com.google.android.horologist:horologist-compose-layout:$horologist_version" implementation "com.google.android.horologist:horologist-compose-material:$horologist_version" implementation "com.google.android.horologist:horologist-compose-tools:$horologist_version" + implementation "com.google.android.horologist:horologist-media-ui:$horologist_version" androidTestImplementation "androidx.compose.ui:ui-test-junit4" debugImplementation "androidx.compose.ui:ui-tooling" diff --git a/wear/src/main/java/com/thewizrd/simplewear/controls/AmbientModeViewModel.kt b/wear/src/main/java/com/thewizrd/simplewear/controls/AmbientModeViewModel.kt deleted file mode 100644 index 5e81e6e..0000000 --- a/wear/src/main/java/com/thewizrd/simplewear/controls/AmbientModeViewModel.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.thewizrd.simplewear.controls - -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel - -class AmbientModeViewModel : ViewModel() { - val ambientModeEnabled = MutableLiveData(false) - val isLowBitAmbient = MutableLiveData(false) - val doBurnInProtection = MutableLiveData(false) - - override fun onCleared() { - super.onCleared() - } -} \ No newline at end of file diff --git a/wear/src/main/java/com/thewizrd/simplewear/media/MediaBrowserFragment.kt b/wear/src/main/java/com/thewizrd/simplewear/media/MediaBrowserFragment.kt deleted file mode 100644 index 70eef88..0000000 --- a/wear/src/main/java/com/thewizrd/simplewear/media/MediaBrowserFragment.kt +++ /dev/null @@ -1,270 +0,0 @@ -package com.thewizrd.simplewear.media - -import android.content.Intent -import android.os.Bundle -import android.util.Log -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.core.graphics.drawable.toDrawable -import androidx.lifecycle.lifecycleScope -import androidx.localbroadcastmanager.content.LocalBroadcastManager -import androidx.recyclerview.widget.ConcatAdapter -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import androidx.wear.widget.WearableLinearLayoutManager -import com.google.android.gms.wearable.DataClient -import com.google.android.gms.wearable.DataEvent -import com.google.android.gms.wearable.DataEventBuffer -import com.google.android.gms.wearable.DataMap -import com.google.android.gms.wearable.DataMapItem -import com.google.android.gms.wearable.Wearable -import com.thewizrd.shared_resources.helpers.MediaHelper -import com.thewizrd.shared_resources.helpers.WearableHelper -import com.thewizrd.shared_resources.lifecycle.LifecycleAwareFragment -import com.thewizrd.shared_resources.utils.ContextUtils.dpToPx -import com.thewizrd.shared_resources.utils.ImageUtils -import com.thewizrd.shared_resources.utils.Logger -import com.thewizrd.simplewear.R -import com.thewizrd.simplewear.adapters.SpacerAdapter -import com.thewizrd.simplewear.controls.WearChipButton -import com.thewizrd.simplewear.databinding.FragmentBrowserListBinding -import com.thewizrd.simplewear.helpers.CustomScrollingLayoutCallback -import com.thewizrd.simplewear.helpers.SpacerItemDecoration -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.tasks.await -import java.util.Objects - -class MediaBrowserFragment : LifecycleAwareFragment(), DataClient.OnDataChangedListener { - private lateinit var binding: FragmentBrowserListBinding - private lateinit var mBrowserAdapter: MediaBrowserItemsAdapter - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - binding = FragmentBrowserListBinding.inflate(inflater, container, false) - - binding.listView.setHasFixedSize(true) - binding.listView.isEdgeItemsCenteringEnabled = false - binding.listView.addItemDecoration( - SpacerItemDecoration( - requireContext().dpToPx(16f).toInt(), - requireContext().dpToPx(4f).toInt() - ) - ) - - binding.listView.layoutManager = - WearableLinearLayoutManager(requireContext(), CustomScrollingLayoutCallback()) - - mBrowserAdapter = MediaBrowserItemsAdapter() - binding.listView.adapter = ConcatAdapter( - SpacerAdapter(requireContext().dpToPx(48f).toInt()), - mBrowserAdapter, - SpacerAdapter(requireContext().dpToPx(48f).toInt()) - ) - - mBrowserAdapter.setOnClickListener(object : MediaBrowserItemsAdapter.OnClickListener { - override fun onClick(item: MediaItemModel) { - if (item.id == MediaHelper.ACTIONITEM_BACK) { - LocalBroadcastManager.getInstance(requireContext()) - .sendBroadcast(Intent(MediaHelper.MediaBrowserItemsBackPath)) - } else { - LocalBroadcastManager.getInstance(requireContext()) - .sendBroadcast( - Intent(MediaHelper.MediaBrowserItemsClickPath) - .putExtra(MediaHelper.KEY_MEDIAITEM_ID, item.id) - ) - } - } - }) - - binding.listView.addOnScrollListener(object : RecyclerView.OnScrollListener() { - override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { - binding.timeText.apply { - translationY = -recyclerView.computeVerticalScrollOffset().toFloat() - } - } - }) - - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - } - - override fun onResume() { - super.onResume() - Wearable.getDataClient(requireContext()).addListener(this) - - binding.listView.requestFocus() - - updateBrowserItems() - } - - override fun onPause() { - Wearable.getDataClient(requireContext()).removeListener(this) - super.onPause() - } - - override fun onDestroyView() { - super.onDestroyView() - } - - private fun showLoading(show: Boolean) { - if (show) { - binding.progressBar.show() - } else { - binding.progressBar.hide() - } - binding.listView.visibility = if (show) View.INVISIBLE else View.VISIBLE - } - - private class MediaBrowserItemsAdapter : - ListAdapter(diffCallback) { - private var onClickListener: OnClickListener? = null - - companion object { - private val diffCallback = object : DiffUtil.ItemCallback() { - override fun areItemsTheSame( - oldItem: MediaItemModel, - newItem: MediaItemModel - ): Boolean { - return Objects.equals(oldItem.id, newItem.id) - } - - override fun areContentsTheSame( - oldItem: MediaItemModel, - newItem: MediaItemModel - ): Boolean { - return Objects.equals(oldItem, newItem) - } - } - } - - fun setOnClickListener(listener: OnClickListener?) { - this.onClickListener = listener - } - - inner class ViewHolder(val button: WearChipButton) : - RecyclerView.ViewHolder(button) { - - fun bind(model: MediaItemModel) { - if (model.id == MediaHelper.ACTIONITEM_BACK) { - button.setIconResource(R.drawable.ic_baseline_arrow_back_24) - button.setPrimaryText(R.string.label_back) - } else { - button.setIconDrawable(model.icon?.toDrawable(button.context.resources)) - button.setPrimaryText(model.title) - } - button.setOnClickListener { - onClickListener?.onClick(model) - } - } - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { - return ViewHolder(WearChipButton(parent.context).apply { - layoutParams = RecyclerView.LayoutParams( - RecyclerView.LayoutParams.MATCH_PARENT, - RecyclerView.LayoutParams.WRAP_CONTENT - ) - }) - } - - override fun onBindViewHolder(holder: ViewHolder, position: Int) { - holder.bind(getItem(position)) - } - - interface OnClickListener { - fun onClick(item: MediaItemModel) - } - } - - private fun updateBrowserItems() { - lifecycleScope.launch(Dispatchers.IO) { - try { - val buff = Wearable.getDataClient(requireContext()) - .getDataItems( - WearableHelper.getWearDataUri( - "*", - MediaHelper.MediaBrowserItemsPath - ) - ) - .await() - - for (i in 0 until buff.count) { - val item = buff[i] - if (MediaHelper.MediaBrowserItemsPath == item.uri.path) { - val dataMap = DataMapItem.fromDataItem(item).dataMap - updateBrowserItems(dataMap) - } - } - - buff.release() - } catch (e: Exception) { - Logger.writeLine(Log.ERROR, e) - } - } - } - - private suspend fun updateBrowserItems(dataMap: DataMap) { - val isRoot = dataMap.getBoolean(MediaHelper.KEY_MEDIAITEM_ISROOT) - val items = dataMap.getDataMapArrayList(MediaHelper.KEY_MEDIAITEMS) ?: emptyList() - val mediaItems = ArrayList(if (isRoot) items.size else items.size + 1) - if (!isRoot) { - mediaItems.add(MediaItemModel(MediaHelper.ACTIONITEM_BACK)) - } - - for (item in items) { - val id = item.getString(MediaHelper.KEY_MEDIAITEM_ID) ?: continue - val icon = item.getAsset(MediaHelper.KEY_MEDIAITEM_ICON)?.let { - try { - ImageUtils.bitmapFromAssetStream( - Wearable.getDataClient(requireContext()), - it - ) - } catch (e: Exception) { - null - } - } - val title = item.getString(MediaHelper.KEY_MEDIAITEM_TITLE) - - mediaItems.add(MediaItemModel(id).apply { - this.icon = icon - this.title = title - }) - } - - runWithView { - showLoading(false) - mBrowserAdapter.submitList(mediaItems) - } - } - - override fun onDataChanged(dataEventBuffer: DataEventBuffer) { - lifecycleScope.launch { - for (event in dataEventBuffer) { - if (event.type == DataEvent.TYPE_CHANGED) { - val item = event.dataItem - if (MediaHelper.MediaBrowserItemsPath == item.uri.path) { - try { - val dataMap = DataMapItem.fromDataItem(item).dataMap - updateBrowserItems(dataMap) - } catch (e: Exception) { - Logger.writeLine(Log.ERROR, e) - } - } - } - } - } - } -} \ No newline at end of file diff --git a/wear/src/main/java/com/thewizrd/simplewear/media/MediaCustomControlsFragment.kt b/wear/src/main/java/com/thewizrd/simplewear/media/MediaCustomControlsFragment.kt deleted file mode 100644 index 15eb0f8..0000000 --- a/wear/src/main/java/com/thewizrd/simplewear/media/MediaCustomControlsFragment.kt +++ /dev/null @@ -1,307 +0,0 @@ -package com.thewizrd.simplewear.media - -import android.content.Intent -import android.graphics.Color -import android.os.Bundle -import android.util.Log -import android.view.LayoutInflater -import android.view.MotionEvent -import android.view.View -import android.view.ViewConfiguration -import android.view.ViewGroup -import androidx.core.graphics.drawable.toDrawable -import androidx.core.view.InputDeviceCompat -import androidx.core.view.MotionEventCompat -import androidx.core.view.ViewConfigurationCompat -import androidx.lifecycle.lifecycleScope -import androidx.localbroadcastmanager.content.LocalBroadcastManager -import androidx.recyclerview.widget.ConcatAdapter -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import androidx.wear.widget.WearableLinearLayoutManager -import com.google.android.gms.wearable.DataClient -import com.google.android.gms.wearable.DataEvent -import com.google.android.gms.wearable.DataEventBuffer -import com.google.android.gms.wearable.DataMap -import com.google.android.gms.wearable.DataMapItem -import com.google.android.gms.wearable.MessageClient -import com.google.android.gms.wearable.MessageEvent -import com.google.android.gms.wearable.Wearable -import com.thewizrd.shared_resources.actions.ActionStatus -import com.thewizrd.shared_resources.helpers.MediaHelper -import com.thewizrd.shared_resources.helpers.WearableHelper -import com.thewizrd.shared_resources.lifecycle.LifecycleAwareFragment -import com.thewizrd.shared_resources.utils.ContextUtils.dpToPx -import com.thewizrd.shared_resources.utils.ImageUtils -import com.thewizrd.shared_resources.utils.Logger -import com.thewizrd.shared_resources.utils.bytesToString -import com.thewizrd.simplewear.R -import com.thewizrd.simplewear.adapters.SpacerAdapter -import com.thewizrd.simplewear.controls.CustomConfirmationOverlay -import com.thewizrd.simplewear.controls.WearChipButton -import com.thewizrd.simplewear.databinding.FragmentBrowserListBinding -import com.thewizrd.simplewear.helpers.CustomScrollingLayoutCallback -import com.thewizrd.simplewear.helpers.SpacerItemDecoration -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.tasks.await -import java.util.Objects -import kotlin.math.roundToInt - -class MediaCustomControlsFragment : LifecycleAwareFragment(), MessageClient.OnMessageReceivedListener, - DataClient.OnDataChangedListener { - private lateinit var binding: FragmentBrowserListBinding - private lateinit var mCustomControlsAdapter: MediaCustomControlsItemsAdapter - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - binding = FragmentBrowserListBinding.inflate(inflater, container, false) - - binding.listView.setHasFixedSize(true) - binding.listView.isEdgeItemsCenteringEnabled = false - binding.listView.layoutManager = - WearableLinearLayoutManager(requireContext(), CustomScrollingLayoutCallback()) - mCustomControlsAdapter = MediaCustomControlsItemsAdapter() - binding.listView.adapter = ConcatAdapter( - SpacerAdapter(requireContext().dpToPx(48f).toInt()), - mCustomControlsAdapter, - SpacerAdapter(requireContext().dpToPx(48f).toInt()) - ) - binding.listView.addItemDecoration( - SpacerItemDecoration( - requireContext().dpToPx(16f).toInt(), - requireContext().dpToPx(4f).toInt() - ) - ) - - mCustomControlsAdapter.setOnClickListener(object : - MediaCustomControlsItemsAdapter.OnClickListener { - override fun onClick(item: MediaItemModel) { - LocalBroadcastManager.getInstance(requireContext()) - .sendBroadcast( - Intent(MediaHelper.MediaActionsClickPath) - .putExtra(MediaHelper.KEY_MEDIA_ACTIONITEM_ACTION, item.id) - ) - } - }) - - binding.listView.addOnScrollListener(object : RecyclerView.OnScrollListener() { - override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { - binding.timeText.apply { - translationY = -recyclerView.computeVerticalScrollOffset().toFloat() - } - } - }) - - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - view.setOnGenericMotionListener { v, event -> - if (event.action == MotionEvent.ACTION_SCROLL && event.isFromSource(InputDeviceCompat.SOURCE_ROTARY_ENCODER)) { - // Don't forget the negation here - val delta = -event.getAxisValue(MotionEventCompat.AXIS_SCROLL) * - ViewConfigurationCompat.getScaledVerticalScrollFactor( - ViewConfiguration.get(v.context), v.context - ) - - // Swap these axes if you want to do horizontal scrolling instead - binding.listView.scrollBy(0, delta.roundToInt()) - - return@setOnGenericMotionListener true - } - false - } - } - - override fun onResume() { - super.onResume() - Wearable.getMessageClient(requireContext()).addListener(this) - Wearable.getDataClient(requireContext()).addListener(this) - - binding.listView.requestFocus() - - updateCustomControls() - } - - override fun onPause() { - Wearable.getMessageClient(requireContext()).removeListener(this) - Wearable.getDataClient(requireContext()).removeListener(this) - super.onPause() - } - - override fun onDestroyView() { - super.onDestroyView() - } - - private fun showLoading(show: Boolean) { - if (show) { - binding.progressBar.show() - } else { - binding.progressBar.hide() - } - binding.listView.visibility = if (show) View.INVISIBLE else View.VISIBLE - } - - private class MediaCustomControlsItemsAdapter() : - ListAdapter(diffCallback) { - private var onClickListener: OnClickListener? = null - - companion object { - private val diffCallback = object : DiffUtil.ItemCallback() { - override fun areItemsTheSame( - oldItem: MediaItemModel, - newItem: MediaItemModel - ): Boolean { - return Objects.equals(oldItem.id, newItem.id) - } - - override fun areContentsTheSame( - oldItem: MediaItemModel, - newItem: MediaItemModel - ): Boolean { - return Objects.equals(oldItem, newItem) - } - } - } - - fun setOnClickListener(listener: OnClickListener?) { - this.onClickListener = listener - } - - inner class ViewHolder(val button: WearChipButton) : - RecyclerView.ViewHolder(button) { - - fun bind(model: MediaItemModel) { - button.setIconDrawable(model.icon?.toDrawable(button.context.resources)?.let { - it.mutate().apply { - setTint(Color.WHITE) - } - }) - button.setPrimaryText(model.title) - button.setOnClickListener { - onClickListener?.onClick(model) - } - } - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { - return ViewHolder(WearChipButton(parent.context).apply { - layoutParams = RecyclerView.LayoutParams( - RecyclerView.LayoutParams.MATCH_PARENT, - RecyclerView.LayoutParams.WRAP_CONTENT - ) - }) - } - - override fun onBindViewHolder(holder: ViewHolder, position: Int) { - holder.bind(getItem(position)) - } - - interface OnClickListener { - fun onClick(item: MediaItemModel) - } - } - - private fun updateCustomControls() { - lifecycleScope.launch(Dispatchers.IO) { - try { - val buff = Wearable.getDataClient(requireContext()) - .getDataItems( - WearableHelper.getWearDataUri( - "*", - MediaHelper.MediaActionsPath - ) - ) - .await() - - for (i in 0 until buff.count) { - val item = buff[i] - if (MediaHelper.MediaActionsPath == item.uri.path) { - val dataMap = DataMapItem.fromDataItem(item).dataMap - updateCustomControls(dataMap) - } - } - - buff.release() - } catch (e: Exception) { - Logger.writeLine(Log.ERROR, e) - } - } - } - - private suspend fun updateCustomControls(dataMap: DataMap) { - val items = dataMap.getDataMapArrayList(MediaHelper.KEY_MEDIAITEMS) ?: emptyList() - val mediaItems = ArrayList(items.size) - - for (item in items) { - val id = item.getString(MediaHelper.KEY_MEDIA_ACTIONITEM_ACTION) ?: continue - val icon = item.getAsset(MediaHelper.KEY_MEDIA_ACTIONITEM_ICON)?.let { - try { - ImageUtils.bitmapFromAssetStream( - Wearable.getDataClient(requireContext()), - it - ) - } catch (e: Exception) { - null - } - } - val title = item.getString(MediaHelper.KEY_MEDIA_ACTIONITEM_TITLE) - - mediaItems.add(MediaItemModel(id).apply { - this.icon = icon - this.title = title - }) - } - - runWithView { - showLoading(false) - mCustomControlsAdapter.submitList(mediaItems) - binding.listView.requestFocus() - } - } - - override fun onMessageReceived(messageEvent: MessageEvent) { - lifecycleScope.launch { - if (messageEvent.path == MediaHelper.MediaPlayPath) { - val actionStatus = ActionStatus.valueOf(messageEvent.data.bytesToString()) - - if (actionStatus == ActionStatus.TIMEOUT) { - CustomConfirmationOverlay() - .setType(CustomConfirmationOverlay.CUSTOM_ANIMATION) - .setCustomDrawable(R.drawable.ws_full_sad) - .setMessage(R.string.error_playback_failed) - .showAbove(binding.root) - } - } - } - } - - override fun onDataChanged(dataEventBuffer: DataEventBuffer) { - lifecycleScope.launch { - for (event in dataEventBuffer) { - if (event.type == DataEvent.TYPE_CHANGED) { - val item = event.dataItem - if (MediaHelper.MediaActionsPath == item.uri.path) { - try { - val dataMap = DataMapItem.fromDataItem(item).dataMap - updateCustomControls(dataMap) - } catch (e: Exception) { - Logger.writeLine(Log.ERROR, e) - } - } - } - } - } - } -} \ No newline at end of file diff --git a/wear/src/main/java/com/thewizrd/simplewear/media/MediaPlayerActivity.kt b/wear/src/main/java/com/thewizrd/simplewear/media/MediaPlayerActivity.kt index afb81c3..3f6bdb8 100644 --- a/wear/src/main/java/com/thewizrd/simplewear/media/MediaPlayerActivity.kt +++ b/wear/src/main/java/com/thewizrd/simplewear/media/MediaPlayerActivity.kt @@ -1,66 +1,32 @@ package com.thewizrd.simplewear.media -import android.annotation.SuppressLint -import android.content.BroadcastReceiver import android.content.Context import android.content.Intent -import android.content.IntentFilter import android.os.Bundle -import android.util.Log -import android.view.View +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent import androidx.activity.viewModels import androidx.core.content.ContextCompat -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentActivity import androidx.lifecycle.lifecycleScope -import androidx.localbroadcastmanager.content.LocalBroadcastManager -import androidx.recyclerview.widget.RecyclerView -import androidx.viewpager2.adapter.FragmentStateAdapter -import androidx.wear.ambient.AmbientModeSupport -import com.google.android.gms.wearable.DataClient -import com.google.android.gms.wearable.DataEvent -import com.google.android.gms.wearable.DataEventBuffer -import com.google.android.gms.wearable.DataItem -import com.google.android.gms.wearable.DataMapItem -import com.google.android.gms.wearable.MessageEvent -import com.google.android.gms.wearable.Wearable import com.thewizrd.shared_resources.actions.ActionStatus import com.thewizrd.shared_resources.helpers.MediaHelper import com.thewizrd.shared_resources.helpers.WearConnectionStatus -import com.thewizrd.shared_resources.helpers.WearableHelper import com.thewizrd.shared_resources.utils.JSONParser -import com.thewizrd.shared_resources.utils.Logger -import com.thewizrd.shared_resources.utils.booleanToBytes -import com.thewizrd.shared_resources.utils.bytesToString -import com.thewizrd.shared_resources.utils.intToBytes -import com.thewizrd.shared_resources.utils.stringToBytes import com.thewizrd.simplewear.PhoneSyncActivity import com.thewizrd.simplewear.R -import com.thewizrd.simplewear.WearableListenerActivity -import com.thewizrd.simplewear.controls.AmbientModeViewModel import com.thewizrd.simplewear.controls.AppItemViewModel import com.thewizrd.simplewear.controls.CustomConfirmationOverlay -import com.thewizrd.simplewear.databinding.ActivityMusicplaybackBinding -import com.thewizrd.simplewear.helpers.showConfirmationOverlay -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.guava.await -import kotlinx.coroutines.isActive +import com.thewizrd.simplewear.ui.simplewear.MediaPlayerUi +import com.thewizrd.simplewear.viewmodels.WearableListenerViewModel +import com.thewizrd.simplewear.viewmodels.WearableListenerViewModel.Companion.ACTION_UPDATECONNECTIONSTATUS +import com.thewizrd.simplewear.viewmodels.WearableListenerViewModel.Companion.EXTRA_STATUS import kotlinx.coroutines.launch -import kotlinx.coroutines.tasks.await -class MediaPlayerActivity : WearableListenerActivity(), AmbientModeSupport.AmbientCallbackProvider, - DataClient.OnDataChangedListener { +class MediaPlayerActivity : ComponentActivity() { companion object { private const val KEY_APPDETAILS = "SimpleWear.Droid.extra.APP_DETAILS" private const val KEY_AUTOLAUNCH = "SimpleWear.Droid.extra.AUTO_LAUNCH" - const val ACTION_ENTERAMBIENTMODE = "SimpleWear.Droid.action.ENTER_AMBIENT_MODE" - const val ACTION_EXITAMBIENTMODE = "SimpleWear.Droid.action.EXIT_AMBIENT_MODE" - const val ACTION_UPDATEAMBIENTMODE = "SimpleWear.Droid.action.UPDATE_AMBIENT_MODE" - fun buildIntent(context: Context, appDetails: AppItemViewModel): Intent { return Intent(context, MediaPlayerActivity::class.java).apply { putExtra( @@ -77,23 +43,7 @@ class MediaPlayerActivity : WearableListenerActivity(), AmbientModeSupport.Ambie } } - override lateinit var broadcastReceiver: BroadcastReceiver - private set - override lateinit var intentFilter: IntentFilter - private set - - private lateinit var binding: ActivityMusicplaybackBinding - private val mMediaPlayerDetails: AppItemViewModel by viewModels() - - private lateinit var mViewPagerAdapter: MediaFragmentPagerAdapter - private var supportsBrowser: Boolean = false - private var supportsCustomActions: Boolean = false - private var supportsQueue: Boolean = false - - private var updateJob: Job? = null - - private lateinit var mAmbientController: AmbientModeSupport.AmbientController - private val mAmbientMode: AmbientModeViewModel by viewModels() + private val mediaPlayerViewModel by viewModels() private var isAutoLaunch = false @@ -102,473 +52,102 @@ class MediaPlayerActivity : WearableListenerActivity(), AmbientModeSupport.Ambie handleIntent(intent) - binding = ActivityMusicplaybackBinding.inflate(layoutInflater) - setContentView(binding.root) - - broadcastReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - lifecycleScope.launch { - if (intent.action != null) { - when (intent.action) { - ACTION_UPDATECONNECTIONSTATUS -> { - when (WearConnectionStatus.valueOf( - intent.getIntExtra( - EXTRA_CONNECTIONSTATUS, - 0 - ) - )) { - WearConnectionStatus.DISCONNECTED -> { - // Navigate - startActivity( - Intent( - this@MediaPlayerActivity, - PhoneSyncActivity::class.java - ) - ) - finishAffinity() - } - WearConnectionStatus.APPNOTINSTALLED -> { - // Open store on remote device - val intentAndroid = Intent(Intent.ACTION_VIEW) - .addCategory(Intent.CATEGORY_BROWSABLE) - .setData(WearableHelper.getPlayStoreURI()) - - runCatching { - remoteActivityHelper.startRemoteActivity(intentAndroid) - .await() - - showConfirmationOverlay(true) - }.onFailure { - if (it !is CancellationException) { - showConfirmationOverlay(false) - } - } - - // Navigate - startActivity( - Intent( - this@MediaPlayerActivity, - PhoneSyncActivity::class.java - ) - ) - finishAffinity() - } - - else -> {} - } - } - MediaHelper.MediaPlayPath, - MediaHelper.MediaPausePath, - MediaHelper.MediaPreviousPath, - MediaHelper.MediaNextPath, - MediaHelper.MediaBrowserItemsBackPath, - MediaHelper.MediaVolumeUpPath, - MediaHelper.MediaVolumeDownPath, - MediaHelper.MediaVolumeStatusPath -> { - lifecycleScope.launch { - if (connect()) { - mPhoneNodeWithApp?.id?.let { nodeID -> - sendMessage( - nodeID, - MediaHelper.MediaPlayerConnectPath, - if (isAutoLaunch) isAutoLaunch.booleanToBytes() else mMediaPlayerDetails.packageName?.stringToBytes() - ) - sendMessage(nodeID, intent.action!!, null) - } - } - } - } - MediaHelper.MediaSetVolumePath -> { - lifecycleScope.launch { - if (connect()) { - mPhoneNodeWithApp?.id?.let { nodeID -> - sendMessage( - nodeID, - MediaHelper.MediaPlayerConnectPath, - if (isAutoLaunch) isAutoLaunch.booleanToBytes() else mMediaPlayerDetails.packageName?.stringToBytes() - ) - sendMessage( - nodeID, - intent.action!!, - intent.getIntExtra(MediaHelper.KEY_VOLUME, 0) - .intToBytes() - ) - } - } - } - } - MediaHelper.MediaBrowserItemsClickPath, - MediaHelper.MediaBrowserItemsExtraSuggestedClickPath, - MediaHelper.MediaQueueItemsClickPath -> { - val id = intent.getStringExtra(MediaHelper.KEY_MEDIAITEM_ID) - - lifecycleScope.launch { - if (connect()) { - mPhoneNodeWithApp?.id?.let { nodeID -> - sendMessage( - nodeID, - MediaHelper.MediaPlayerConnectPath, - if (isAutoLaunch) isAutoLaunch.booleanToBytes() else mMediaPlayerDetails.packageName?.stringToBytes() - ) - sendMessage( - nodeID, - intent.action!!, - id!!.stringToBytes() - ) - } - } - } - } - MediaHelper.MediaActionsClickPath -> { - val id = - intent.getStringExtra(MediaHelper.KEY_MEDIA_ACTIONITEM_ACTION) - - lifecycleScope.launch { - if (connect()) { - mPhoneNodeWithApp?.id?.let { nodeID -> - sendMessage( - nodeID, - MediaHelper.MediaPlayerConnectPath, - if (isAutoLaunch) isAutoLaunch.booleanToBytes() else mMediaPlayerDetails.packageName?.stringToBytes() - ) - sendMessage( - nodeID, - intent.action!!, - id!!.stringToBytes() - ) - } - } - } - } - ACTION_CHANGED -> { - val jsonData = intent.getStringExtra(EXTRA_ACTIONDATA) - requestAction(jsonData) - } - else -> { - Logger.writeLine( - Log.INFO, - "%s: Unhandled action: %s", - "MediaPlayerActivity", - intent.action - ) - } - } - } - } - } - } - - binding.mediaViewpager.adapter = MediaFragmentPagerAdapter(this).also { - mViewPagerAdapter = it + setContent { + MediaPlayerUi() } - - binding.mediaViewpagerIndicator.dotFadeWhenIdle = false - binding.mediaViewpagerIndicator.setPager(binding.mediaViewpager) - - binding.retryFab.setOnClickListener { - lifecycleScope.launch { - updateConnectionStatus() - requestPlayerConnect() - updatePager() - } - } - - intentFilter = IntentFilter().apply { - addAction(ACTION_UPDATECONNECTIONSTATUS) - addAction(MediaHelper.MediaPlayPath) - addAction(MediaHelper.MediaPausePath) - addAction(MediaHelper.MediaPreviousPath) - addAction(MediaHelper.MediaNextPath) - addAction(MediaHelper.MediaBrowserItemsClickPath) - addAction(MediaHelper.MediaBrowserItemsBackPath) - addAction(MediaHelper.MediaActionsClickPath) - addAction(MediaHelper.MediaQueueItemsClickPath) - addAction(MediaHelper.MediaVolumeUpPath) - addAction(MediaHelper.MediaVolumeDownPath) - addAction(MediaHelper.MediaVolumeStatusPath) - addAction(MediaHelper.MediaSetVolumePath) - } - - mAmbientController = AmbientModeSupport.attach(this) - mAmbientMode.ambientModeEnabled.value = mAmbientController.isAmbient } override fun onStart() { super.onStart() - mAmbientMode.ambientModeEnabled.observe(this) { enabled -> - if (enabled) { - binding.mediaViewpagerIndicator.visibility = View.INVISIBLE - binding.mediaViewpager.setCurrentItem(0, false) - } else { - binding.mediaViewpagerIndicator.visibility = View.VISIBLE - } - } - } - - private enum class MediaPageType(val value: Int) { - Player(1), - CustomControls(2), - Browser(3), - Queue(4); - - companion object { - fun valueOf(value: Int) = entries.firstOrNull { it.value == value } - } - } - - private inner class MediaFragmentPagerAdapter(activity: FragmentActivity) : - FragmentStateAdapter(activity) { - private val supportedPageTypes = mutableListOf(MediaPageType.Player) - - override fun getItemCount(): Int { - return supportedPageTypes.size - } - - override fun createFragment(position: Int): Fragment { - val type = supportedPageTypes[position] - - return when (type) { - MediaPageType.Player -> { - MediaPlayerControlsFragment() - } - MediaPageType.Browser -> { - MediaBrowserFragment() - } - MediaPageType.CustomControls -> { - MediaCustomControlsFragment() - } - MediaPageType.Queue -> { - MediaQueueFragment() - } - } - } - - @SuppressLint("NotifyDataSetChanged") - @Synchronized - fun updateSupportedPages( - supportsBrowser: Boolean, - supportsQueue: Boolean, - supportsCustomActions: Boolean - ) { - supportedPageTypes.clear() - supportedPageTypes.add(MediaPageType.Player) - if (supportsCustomActions) { - supportedPageTypes.add(MediaPageType.CustomControls) - } - if (supportsQueue) { - supportedPageTypes.add(MediaPageType.Queue) - } - if (supportsBrowser) { - supportedPageTypes.add(MediaPageType.Browser) - } - this.notifyDataSetChanged() - } - - override fun getItemId(position: Int): Long { - return if (position >= 0 && position < supportedPageTypes.size) { - supportedPageTypes[position].value.toLong() - } else { - RecyclerView.NO_ID - } - } - - override fun containsItem(itemId: Long): Boolean { - val pageType = MediaPageType.valueOf(itemId.toInt()) - return pageType != null && supportedPageTypes.contains(pageType) - } - } - - override fun onMessageReceived(messageEvent: MessageEvent) { - super.onMessageReceived(messageEvent) + mediaPlayerViewModel.initActivityContext(this) lifecycleScope.launch { - when (messageEvent.path) { - MediaHelper.MediaPlayerConnectPath, - MediaHelper.MediaPlayerAutoLaunchPath -> { - val actionStatus = ActionStatus.valueOf(messageEvent.data.bytesToString()) - - if (actionStatus == ActionStatus.PERMISSION_DENIED) { - CustomConfirmationOverlay() - .setType(CustomConfirmationOverlay.CUSTOM_ANIMATION) - .setCustomDrawable( - ContextCompat.getDrawable( - this@MediaPlayerActivity, - R.drawable.ws_full_sad - ) + mediaPlayerViewModel.eventFlow.collect { event -> + when (event.eventType) { + ACTION_UPDATECONNECTIONSTATUS -> { + val connectionStatus = WearConnectionStatus.valueOf( + event.data.getInt( + WearableListenerViewModel.EXTRA_CONNECTIONSTATUS, + 0 ) - .setMessage(getString(R.string.error_permissiondenied)) - .showOn(this@MediaPlayerActivity) + ) + + when (connectionStatus) { + WearConnectionStatus.DISCONNECTED -> { + // Navigate + startActivity( + Intent( + this@MediaPlayerActivity, + PhoneSyncActivity::class.java + ) + ) + finishAffinity() + } - openAppOnPhone(false) + WearConnectionStatus.APPNOTINSTALLED -> { + // Open store on remote device + mediaPlayerViewModel.openPlayStore(this@MediaPlayerActivity) - showNoPlayersView(true) - } else if (actionStatus == ActionStatus.SUCCESS) { - showNoPlayersView(false) - } - } - MediaHelper.MediaBrowserItemsClickPath, - MediaHelper.MediaBrowserItemsExtraSuggestedClickPath -> { - val actionStatus = ActionStatus.valueOf(messageEvent.data.bytesToString()) + // Navigate + startActivity( + Intent( + this@MediaPlayerActivity, + PhoneSyncActivity::class.java + ) + ) + finishAffinity() + } - if (actionStatus == ActionStatus.SUCCESS) { - binding.mediaViewpager.currentItem = 0 + else -> {} + } } - } - } - } - } - - private fun showNoPlayersView(show: Boolean) { - if (!mAmbientController.isAmbient) { - binding.noplayersView.visibility = if (show) View.VISIBLE else View.GONE - binding.mediaViewpager.visibility = if (show) View.INVISIBLE else View.VISIBLE - binding.mediaViewpagerIndicator.visibility = if (show) View.INVISIBLE else View.VISIBLE - } - } - override fun onDataChanged(dataEventBuffer: DataEventBuffer) { - lifecycleScope.launch { - updateJob?.cancel() + MediaHelper.MediaPlayerConnectPath, + MediaHelper.MediaPlayerAutoLaunchPath -> { + val actionStatus = event.data.getSerializable(EXTRA_STATUS) as ActionStatus + + if (actionStatus == ActionStatus.PERMISSION_DENIED) { + CustomConfirmationOverlay() + .setType(CustomConfirmationOverlay.CUSTOM_ANIMATION) + .setCustomDrawable( + ContextCompat.getDrawable( + this@MediaPlayerActivity, + R.drawable.ws_full_sad + ) + ) + .setMessage(getString(R.string.error_permissiondenied)) + .showOn(this@MediaPlayerActivity) - for (event in dataEventBuffer) { - if (event.type == DataEvent.TYPE_CHANGED) { - val item = event.dataItem - updatePager(item) - } else if (event.type == DataEvent.TYPE_DELETED) { - val item = event.dataItem - when (item.uri.path) { - MediaHelper.MediaBrowserItemsPath -> { - supportsBrowser = false - } - MediaHelper.MediaActionsPath -> { - supportsCustomActions = false - } - MediaHelper.MediaQueueItemsPath -> { - supportsQueue = false + mediaPlayerViewModel.openAppOnPhone(this@MediaPlayerActivity, false) } } - } - } - - updateJob = lifecycleScope.launch updateJob@{ - delay(1000) - if (!isActive) return@updateJob - - mViewPagerAdapter.updateSupportedPages( - supportsBrowser, - supportsQueue, - supportsCustomActions - ) - } - } - } - - private fun updatePager() { - lifecycleScope.launch(Dispatchers.IO) { - try { - val buff = Wearable.getDataClient(this@MediaPlayerActivity) - .getDataItems( - WearableHelper.getWearDataUri( - "*", - "/media" - ), - DataClient.FILTER_PREFIX - ) - .await() - - for (i in 0 until buff.count) { - val item = buff[i] - updatePager(item) - } - - buff.release() - - lifecycleScope.launch(Dispatchers.Main) { - mViewPagerAdapter.updateSupportedPages( - supportsBrowser, - supportsQueue, - supportsCustomActions - ) - } - } catch (e: Exception) { - Logger.writeLine(Log.ERROR, e) - } - } - } + MediaHelper.MediaPlayPath -> { + val actionStatus = event.data.getSerializable(EXTRA_STATUS) as ActionStatus - private fun updatePager(item: DataItem) { - when (item.uri.path) { - MediaHelper.MediaBrowserItemsPath -> { - supportsBrowser = try { - val dataMap = DataMapItem.fromDataItem(item).dataMap - val items = dataMap.getDataMapArrayList(MediaHelper.KEY_MEDIAITEMS) - !items.isNullOrEmpty() - } catch (e: Exception) { - Logger.writeLine(Log.ERROR, e) - false - } - } - MediaHelper.MediaActionsPath -> { - supportsCustomActions = try { - val dataMap = DataMapItem.fromDataItem(item).dataMap - val items = dataMap.getDataMapArrayList(MediaHelper.KEY_MEDIAITEMS) - !items.isNullOrEmpty() - } catch (e: Exception) { - Logger.writeLine(Log.ERROR, e) - false - } - } - MediaHelper.MediaQueueItemsPath -> { - supportsQueue = try { - val dataMap = DataMapItem.fromDataItem(item).dataMap - val items = dataMap.getDataMapArrayList(MediaHelper.KEY_MEDIAITEMS) - !items.isNullOrEmpty() - } catch (e: Exception) { - Logger.writeLine(Log.ERROR, e) - false + if (actionStatus == ActionStatus.TIMEOUT) { + CustomConfirmationOverlay() + .setType(CustomConfirmationOverlay.CUSTOM_ANIMATION) + .setCustomDrawable(R.drawable.ws_full_sad) + .setMessage(R.string.error_playback_failed) + .showOn(this@MediaPlayerActivity) + } + } } } } } - private fun requestPlayerConnect() { - lifecycleScope.launch { - if (connect()) { - sendMessage( - mPhoneNodeWithApp!!.id, - MediaHelper.MediaPlayerConnectPath, - if (isAutoLaunch) isAutoLaunch.booleanToBytes() else mMediaPlayerDetails.packageName?.stringToBytes() - ) - } - } - } - - private fun requestPlayerDisconnect() { - lifecycleScope.launch { - if (connect()) { - sendMessage(mPhoneNodeWithApp!!.id, MediaHelper.MediaPlayerDisconnectPath, null) - } - } - } - override fun onResume() { super.onResume() - Wearable.getDataClient(this).addListener(this) // Update statuses - lifecycleScope.launch { - updateConnectionStatus() - requestPlayerConnect() - updatePager() - } + mediaPlayerViewModel.refreshStatus() } override fun onPause() { - requestPlayerDisconnect() - Wearable.getMessageClient(this).removeListener(this) - Wearable.getDataClient(this).removeListener(this) + mediaPlayerViewModel.requestPlayerDisconnect() super.onPause() } @@ -592,44 +171,11 @@ class MediaPlayerActivity : WearableListenerActivity(), AmbientModeSupport.Ambie } if (model != null) { - mMediaPlayerDetails.apply { - appLabel = model.appLabel - packageName = model.packageName - activityName = model.activityName - } + mediaPlayerViewModel.updateMediaPlayerDetails(model) } } - override fun getAmbientCallback(): AmbientModeSupport.AmbientCallback { - return MediaPlayerAmbientCallback() - } - - private inner class MediaPlayerAmbientCallback : AmbientModeSupport.AmbientCallback() { - override fun onEnterAmbient(ambientDetails: Bundle) { - super.onEnterAmbient(ambientDetails) - - val isLowBitAmbient = - ambientDetails.getBoolean(AmbientModeSupport.EXTRA_LOWBIT_AMBIENT, false) - val doBurnInProtection = - ambientDetails.getBoolean(AmbientModeSupport.EXTRA_BURN_IN_PROTECTION, false) - - mAmbientMode.isLowBitAmbient.value = isLowBitAmbient - mAmbientMode.doBurnInProtection.value = doBurnInProtection - mAmbientMode.ambientModeEnabled.value = true - } - - override fun onExitAmbient() { - super.onExitAmbient() - mAmbientMode.ambientModeEnabled.value = false - } - - override fun onUpdateAmbient() { - super.onUpdateAmbient() - - mAmbientMode.ambientModeEnabled.value = true - - LocalBroadcastManager.getInstance(this@MediaPlayerActivity) - .sendBroadcast(Intent(ACTION_UPDATEAMBIENTMODE)) - } + override fun onDestroy() { + super.onDestroy() } } \ No newline at end of file diff --git a/wear/src/main/java/com/thewizrd/simplewear/media/MediaPlayerControlsFragment.kt b/wear/src/main/java/com/thewizrd/simplewear/media/MediaPlayerControlsFragment.kt deleted file mode 100644 index 6059b25..0000000 --- a/wear/src/main/java/com/thewizrd/simplewear/media/MediaPlayerControlsFragment.kt +++ /dev/null @@ -1,565 +0,0 @@ -package com.thewizrd.simplewear.media - -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.os.Build -import android.os.Bundle -import android.util.Log -import android.view.* -import androidx.core.view.InputDeviceCompat -import androidx.core.view.MotionEventCompat -import androidx.core.view.ViewConfigurationCompat -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.lifecycleScope -import androidx.localbroadcastmanager.content.LocalBroadcastManager -import com.google.android.gms.wearable.* -import com.thewizrd.shared_resources.actions.* -import com.thewizrd.shared_resources.helpers.MediaHelper -import com.thewizrd.shared_resources.helpers.WearableHelper -import com.thewizrd.shared_resources.lifecycle.LifecycleAwareFragment -import com.thewizrd.shared_resources.media.PlaybackState -import com.thewizrd.shared_resources.utils.ImageUtils -import com.thewizrd.shared_resources.utils.JSONParser -import com.thewizrd.shared_resources.utils.Logger -import com.thewizrd.shared_resources.utils.bytesToString -import com.thewizrd.simplewear.BuildConfig -import com.thewizrd.simplewear.R -import com.thewizrd.simplewear.controls.AmbientModeViewModel -import com.thewizrd.simplewear.controls.CustomConfirmationOverlay -import com.thewizrd.simplewear.databinding.MediaPlayerControlsBinding -import kotlinx.coroutines.* -import kotlinx.coroutines.tasks.await -import java.util.concurrent.Executors -import kotlin.math.max -import kotlin.math.min -import kotlin.math.roundToInt -import kotlin.random.Random - -class MediaPlayerControlsFragment : LifecycleAwareFragment(), MessageClient.OnMessageReceivedListener, - DataClient.OnDataChangedListener { - private lateinit var binding: MediaPlayerControlsBinding - - private var deleteJob: Job? = null - - private lateinit var mAmbientReceiver: BroadcastReceiver - private val mAmbientMode: AmbientModeViewModel by activityViewModels() - - private var showLoading = false - private var showPlaybackLoading = false - - private var mAudioStreamState: AudioStreamState? = null - - private val volumeScope = CoroutineScope( - SupervisorJob() + Executors.newSingleThreadExecutor().asCoroutineDispatcher() - ) - private var progressBarJob: Job? = null - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - mAmbientReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent?) { - when (intent?.action) { - MediaPlayerActivity.ACTION_UPDATEAMBIENTMODE -> { - if (mAmbientMode.ambientModeEnabled.value == true && - mAmbientMode.doBurnInProtection.value == true && - view != null - ) { - binding.root.translationX = - Random.nextInt(-10, 10 + 1).toFloat() - binding.root.translationY = - Random.nextInt(-10, 10 + 1).toFloat() - } - } - } - } - } - - val intentFilter = IntentFilter().apply { - addAction(MediaPlayerActivity.ACTION_ENTERAMBIENTMODE) - addAction(MediaPlayerActivity.ACTION_EXITAMBIENTMODE) - addAction(MediaPlayerActivity.ACTION_UPDATEAMBIENTMODE) - } - - LocalBroadcastManager.getInstance(requireContext()) - .registerReceiver(mAmbientReceiver, intentFilter) - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - binding = MediaPlayerControlsBinding.inflate(inflater, container, false) - - binding.volUpButton.setOnClickListener { - requestVolumeUp() - } - - binding.volDownButton.setOnClickListener { - requestVolumeDown() - } - - binding.playpauseButton.isCheckable = true - binding.playpauseButton.isChecked = false - binding.playpauseButton.setOnClickListener { - // not checked -> checked (paused -> playing) - // checked -> not checked (playing -> paused) - requestPlayPauseAction(!binding.playpauseButton.isChecked) - } - - binding.prevButton.setOnClickListener { - requestSkipToPreviousAction() - } - binding.nextButton.setOnClickListener { - requestSkipToNextAction() - } - - binding.volumeProgressBar.setOnGenericMotionListener(object : View.OnGenericMotionListener { - private var mRemoteVolume: Float? = null - - override fun onGenericMotion(v: View, event: MotionEvent): Boolean { - if (event.action == MotionEvent.ACTION_SCROLL && - event.isFromSource(InputDeviceCompat.SOURCE_ROTARY_ENCODER) && - mAmbientMode.ambientModeEnabled.value != true && - mAudioStreamState != null - ) { - val scaleFactor = ViewConfigurationCompat.getScaledVerticalScrollFactor( - ViewConfiguration.get(v.context), v.context - ) - // Don't forget the negation here - val delta = -event.getAxisValue(MotionEventCompat.AXIS_SCROLL) * scaleFactor - - // Scaling to (25 * scaleFactor) seems to be good - // On emulator = ~2400 - val scaleMax = 25 * scaleFactor - val audioState = mAudioStreamState!! - - val maxVolume = scaleValueFromState( - audioState.maxVolume.toFloat(), - audioState, - 0f, - scaleMax - ) - val currVolume = mRemoteVolume ?: scaleValueFromState( - audioState.currentVolume.toFloat(), - audioState, - 0f, - scaleMax - ) - val minVolume = scaleValueFromState( - audioState.minVolume.toFloat(), - audioState, - 0f, - scaleMax - ) - - val scaledVolume = currVolume + delta - mRemoteVolume = normalize(scaledVolume, 0f, scaleMax) - - val scaledDownValue = - scaleValueToState(scaledVolume, 0f, scaleMax, audioState).run { - if (!this.isNaN()) this else 0f - } - val volume = normalizeToState(scaledDownValue.roundToInt(), audioState) - - if (BuildConfig.DEBUG) { - Log.d( - "MediaVolScroller", - "currVol = ${(currVolume).roundToInt()}, " + - "maxVol = ${(maxVolume).roundToInt()}, " + - "minVol = ${(minVolume).roundToInt()}, " + - "delta = $delta, " + - "scaleFactor = $scaleFactor, " + - "scaledVol = $scaledVolume, " + - "setVol = $volume" - ) - } - - requestSetVolume(volume) - - return true - } else { - return false - } - } - }) - - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - mAmbientMode.ambientModeEnabled.observe(viewLifecycleOwner) { enabled -> - if (enabled) { - enterAmbientMode() - } else { - exitAmbientMode() - } - } - } - - override fun onDestroyView() { - super.onDestroyView() - } - - override fun onDestroy() { - super.onDestroy() - LocalBroadcastManager.getInstance(requireContext()) - .unregisterReceiver(mAmbientReceiver) - volumeScope.cancel() - } - - private fun enterAmbientMode() { - showPlaybackLoading(false) - showLoading(false) - - binding.albumArtImageview.visibility = View.GONE - binding.prevButton.visibility = View.INVISIBLE - binding.playbackLoadingbar.visibility = View.GONE - binding.playpauseButton.visibility = View.VISIBLE - binding.nextButton.visibility = View.INVISIBLE - binding.volumeControls.visibility = View.INVISIBLE - binding.progressBar.hide() - - binding.playpauseButton.setImageResource(R.drawable.playpause_button_ambient) - - binding.titleView.isSelected = false - - if (mAmbientMode.isLowBitAmbient.value == true) { - binding.timeText.enterLowBitAmbientMode() - binding.titleView.paint.isAntiAlias = false - binding.subtitleView.paint.isAntiAlias = false - } - - binding.volumeProgressBar.clearFocus() - } - - private fun exitAmbientMode() { - binding.albumArtImageview.visibility = View.VISIBLE - binding.prevButton.visibility = View.VISIBLE - binding.playbackLoadingbar.visibility = View.GONE - binding.playpauseButton.visibility = View.VISIBLE - binding.nextButton.visibility = View.VISIBLE - binding.volumeControls.visibility = View.VISIBLE - - binding.playpauseButton.setImageResource(R.drawable.playpause_button) - - showPlaybackLoading(showPlaybackLoading) - showLoading(showLoading) - - binding.titleView.isSelected = true - - if (mAmbientMode.isLowBitAmbient.value == true) { - binding.timeText.exitLowBitAmbientMode() - binding.titleView.paint.isAntiAlias = true - binding.subtitleView.paint.isAntiAlias = true - } - - if (mAmbientMode.doBurnInProtection.value == true) { - binding.root.translationX = 0f - binding.root.translationY = 0f - } - - binding.volumeProgressBar.requestFocus() - } - - override fun onResume() { - super.onResume() - Wearable.getMessageClient(requireContext()).addListener(this) - Wearable.getDataClient(requireContext()).addListener(this) - - // Request connect to media player - requestVolumeStatus() - updatePlayerState() - - if (mAmbientMode.ambientModeEnabled.value != true) { - binding.titleView.isSelected = true - } - - binding.volumeProgressBar.requestFocus() - } - - override fun onPause() { - Wearable.getDataClient(requireContext()).removeListener(this) - Wearable.getMessageClient(requireContext()).removeListener(this) - super.onPause() - } - - override fun onDataChanged(dataEventBuffer: DataEventBuffer) { - lifecycleScope.launch { - for (event in dataEventBuffer) { - if (event.type == DataEvent.TYPE_CHANGED) { - val item = event.dataItem - if (MediaHelper.MediaPlayerStatePath == item.uri.path) { - deleteJob?.cancel() - val dataMap = DataMapItem.fromDataItem(item).dataMap - updatePlayerState(dataMap) - } - } else if (event.type == DataEvent.TYPE_DELETED) { - val item = event.dataItem - if (MediaHelper.MediaPlayerStatePath == item.uri.path) { - deleteJob?.cancel() - deleteJob = lifecycleScope.launch delete@{ - delay(1000) - - if (!isActive) return@delete - - updatePlayerState(DataMap()) - } - } - } - } - } - } - - override fun onMessageReceived(messageEvent: MessageEvent) { - lifecycleScope.launch { - when (messageEvent.path) { - WearableHelper.AudioStatusPath, - MediaHelper.MediaVolumeStatusPath -> { - progressBarJob?.cancel() - progressBarJob = async { - if (!isActive) return@async - - val status = messageEvent.data?.let { - JSONParser.deserializer( - it.bytesToString(), - AudioStreamState::class.java - ) - } - mAudioStreamState = status - - if (!isActive) return@async - - updateProgressBar(status) - } - } - MediaHelper.MediaPlayPath -> { - val actionStatus = ActionStatus.valueOf(messageEvent.data.bytesToString()) - - if (actionStatus == ActionStatus.TIMEOUT) { - CustomConfirmationOverlay() - .setType(CustomConfirmationOverlay.CUSTOM_ANIMATION) - .setCustomDrawable(R.drawable.ws_full_sad) - .setMessage(R.string.error_playback_failed) - .showAbove(binding.root) - } - } - } - } - } - - private fun updateProgressBar(state: ValueActionState?) { - if (state == null) { - binding.volumeProgressBar.progress = 0 - } else { - binding.volumeProgressBar.max = state.maxValue - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - binding.volumeProgressBar.min = state.minValue - } - binding.volumeProgressBar.progress = state.currentValue - } - } - - private fun updatePlayerState(dataMap: DataMap) { - runWithView { - val stateName = dataMap.getString(MediaHelper.KEY_MEDIA_PLAYBACKSTATE) - val playbackState = stateName?.let { PlaybackState.valueOf(it) } ?: PlaybackState.NONE - val title = dataMap.getString(MediaHelper.KEY_MEDIA_METADATA_TITLE) - val artist = dataMap.getString(MediaHelper.KEY_MEDIA_METADATA_ARTIST) - val artBitmap = dataMap.getAsset(MediaHelper.KEY_MEDIA_METADATA_ART)?.let { - try { - ImageUtils.bitmapFromAssetStream( - Wearable.getDataClient(binding.albumArtImageview.context), - it - ) - } catch (e: Exception) { - null - } - } - - if (playbackState != PlaybackState.NONE) { - binding.titleView.text = title - binding.subtitleView.text = artist - binding.albumArtImageview.setImageBitmap(artBitmap) - - binding.subtitleView.visibility = - if (artist.isNullOrBlank()) View.GONE else View.VISIBLE - } else { - binding.titleView.text = - binding.titleView.context.getString(R.string.message_playback_stopped) - binding.subtitleView.text = "" - binding.albumArtImageview.setImageBitmap(null) - - binding.subtitleView.visibility = View.GONE - } - - when (playbackState) { - PlaybackState.NONE -> { - showLoading(false) - showPlaybackLoading(false) - binding.playpauseButton.setChecked(false, false) - } - PlaybackState.LOADING -> { - showLoading(false) - showPlaybackLoading(true) - } - PlaybackState.PLAYING -> { - showLoading(false) - showPlaybackLoading(false) - binding.playpauseButton.setChecked(true, false) - } - PlaybackState.PAUSED -> { - showLoading(false) - showPlaybackLoading(false) - binding.playpauseButton.setChecked(false, false) - } - } - } - } - - private fun updatePlayerState() { - lifecycleScope.launch(Dispatchers.IO) { - try { - val buff = Wearable.getDataClient(requireContext()) - .getDataItems( - WearableHelper.getWearDataUri( - "*", - MediaHelper.MediaPlayerStatePath - ) - ) - .await() - - for (i in 0 until buff.count) { - val item = buff[i] - if (MediaHelper.MediaPlayerStatePath == item.uri.path) { - val dataMap = DataMapItem.fromDataItem(item).dataMap - updatePlayerState(dataMap) - } - } - - buff.release() - } catch (e: Exception) { - Logger.writeLine(Log.ERROR, e) - } - } - } - - private fun requestPlayPauseAction(checked: Boolean) { - lifecycleScope.launch { - LocalBroadcastManager.getInstance(requireContext()) - .sendBroadcast(Intent(if (checked) MediaHelper.MediaPlayPath else MediaHelper.MediaPausePath)) - } - } - - private fun requestSkipToPreviousAction() { - lifecycleScope.launch { - LocalBroadcastManager.getInstance(requireContext()) - .sendBroadcast(Intent(MediaHelper.MediaPreviousPath)) - } - } - - private fun requestSkipToNextAction() { - lifecycleScope.launch { - LocalBroadcastManager.getInstance(requireContext()) - .sendBroadcast(Intent(MediaHelper.MediaNextPath)) - } - } - - private fun requestVolumeUp() { - lifecycleScope.launch { - LocalBroadcastManager.getInstance(requireContext()) - .sendBroadcast(Intent(MediaHelper.MediaVolumeUpPath)) - } - } - - private fun requestVolumeDown() { - lifecycleScope.launch { - LocalBroadcastManager.getInstance(requireContext()) - .sendBroadcast(Intent(MediaHelper.MediaVolumeDownPath)) - } - } - - private fun requestVolumeStatus() { - lifecycleScope.launch { - LocalBroadcastManager.getInstance(requireContext()) - .sendBroadcast(Intent(MediaHelper.MediaVolumeStatusPath)) - } - } - - private fun requestSetVolume(value: Int) { - volumeScope.launch { - LocalBroadcastManager.getInstance(requireContext()) - .sendBroadcastSync(Intent(MediaHelper.MediaSetVolumePath).apply { - putExtra(MediaHelper.KEY_VOLUME, value) - }) - } - } - - private fun showLoading(show: Boolean) { - showLoading = show - if (mAmbientMode.ambientModeEnabled.value == true) return - if (show) { - binding.progressBar.show() - } else { - binding.progressBar.hide() - } - binding.albumArtImageview.visibility = if (show) View.INVISIBLE else View.VISIBLE - binding.playerControls.visibility = if (show) View.INVISIBLE else View.VISIBLE - if (!show) { - binding.volumeProgressBar.requestFocus() - } - } - - private fun showPlaybackLoading(show: Boolean) { - showPlaybackLoading = show - if (mAmbientMode.ambientModeEnabled.value == true) return - binding.playpauseButton.visibility = if (show) View.GONE else View.VISIBLE - binding.playbackLoadingbar.visibility = if (show) View.VISIBLE else View.GONE - } - - /* Value scaling */ - private fun scaleValue( - value: Float, - minValue: Float, - maxValue: Float, - scaleMin: Float, - scaleMax: Float - ): Float { - return ((value - minValue) / (maxValue - minValue)) * (scaleMax - scaleMin) + scaleMin - } - - private fun scaleValueFromState( - value: Float, - state: ValueActionState, - scaleMin: Float, - scaleMax: Float - ): Float { - return ((value - state.minValue) / (state.maxValue - state.minValue)) * (scaleMax - scaleMin) + scaleMin - } - - private fun scaleValueToState( - value: Float, - minValue: Float, - maxValue: Float, - state: ValueActionState - ): Float { - return ((value - minValue) / (maxValue - minValue)) * (state.maxValue - state.minValue) + state.minValue - } - - private fun normalize(value: Int, minValue: Int, maxValue: Int): Int { - return min(maxValue, max(value, minValue)) - } - - private fun normalize(value: Float, minValue: Float, maxValue: Float): Float { - return min(maxValue, max(value, minValue)) - } - - private fun normalizeToState(value: Int, state: ValueActionState): Int { - return normalize(value, state.minValue, state.maxValue) - } -} \ No newline at end of file diff --git a/wear/src/main/java/com/thewizrd/simplewear/media/MediaPlayerViewModel.kt b/wear/src/main/java/com/thewizrd/simplewear/media/MediaPlayerViewModel.kt new file mode 100644 index 0000000..6b3b7c9 --- /dev/null +++ b/wear/src/main/java/com/thewizrd/simplewear/media/MediaPlayerViewModel.kt @@ -0,0 +1,805 @@ +package com.thewizrd.simplewear.media + +import android.app.Activity +import android.app.Application +import android.content.Intent +import android.graphics.Bitmap +import android.os.Bundle +import android.util.Log +import androidx.lifecycle.viewModelScope +import com.google.android.gms.wearable.DataClient +import com.google.android.gms.wearable.DataEvent +import com.google.android.gms.wearable.DataEventBuffer +import com.google.android.gms.wearable.DataItem +import com.google.android.gms.wearable.DataMap +import com.google.android.gms.wearable.DataMapItem +import com.google.android.gms.wearable.MessageEvent +import com.google.android.gms.wearable.Wearable +import com.thewizrd.shared_resources.actions.ActionStatus +import com.thewizrd.shared_resources.actions.Actions +import com.thewizrd.shared_resources.actions.AudioStreamState +import com.thewizrd.shared_resources.actions.AudioStreamType +import com.thewizrd.shared_resources.helpers.MediaHelper +import com.thewizrd.shared_resources.helpers.WearConnectionStatus +import com.thewizrd.shared_resources.helpers.WearableHelper +import com.thewizrd.shared_resources.media.PlaybackState +import com.thewizrd.shared_resources.utils.ImageUtils +import com.thewizrd.shared_resources.utils.JSONParser +import com.thewizrd.shared_resources.utils.Logger +import com.thewizrd.shared_resources.utils.booleanToBytes +import com.thewizrd.shared_resources.utils.bytesToString +import com.thewizrd.shared_resources.utils.intToBytes +import com.thewizrd.shared_resources.utils.stringToBytes +import com.thewizrd.simplewear.ValueActionActivity +import com.thewizrd.simplewear.WearableListenerActivity +import com.thewizrd.simplewear.controls.AppItemViewModel +import com.thewizrd.simplewear.viewmodels.WearableEvent +import com.thewizrd.simplewear.viewmodels.WearableListenerViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.tasks.await + +data class MediaPlayerUiState( + val connectionStatus: WearConnectionStatus? = null, + val isLoading: Boolean = false, + val isPlaybackLoading: Boolean = false, + val isPlayerAvailable: Boolean = false, + val mediaPlayerDetails: AppItemViewModel = AppItemViewModel(), + val audioStreamState: AudioStreamState? = null, + // ViewPager pages + val pagerState: MediaPagerState = MediaPagerState(), + // Auto-launch + val isAutoLaunch: Boolean = false, + // Controls + val playerState: PlayerState = PlayerState(), + // Custom Controls + val mediaCustomItems: List = emptyList(), + // Media Browser + val mediaBrowserItems: List = emptyList(), + // Media Queue + val mediaQueueItems: List = emptyList(), + val activeQueueItemId: Long = -1 +) + +enum class MediaPageType(val value: Int) { + Player(1), + CustomControls(2), + Browser(3), + Queue(4); + + companion object { + fun valueOf(value: Int) = entries.firstOrNull { it.value == value } + } +} + +data class PlayerState( + val playbackState: PlaybackState = PlaybackState.NONE, + val title: String? = null, + val artist: String? = null, + val artworkBitmap: Bitmap? = null +) { + fun isEmpty(): Boolean = title.isNullOrEmpty() && artist.isNullOrEmpty() +} + +data class MediaPagerState( + val supportsBrowser: Boolean = false, + val supportsCustomActions: Boolean = false, + val supportsQueue: Boolean = false +) { + val pageCount: Int + get() { + var pageCount = 1 + + if (supportsCustomActions) pageCount++ + if (supportsBrowser) pageCount++ + if (supportsQueue) pageCount++ + + return pageCount + } +} + +class MediaPlayerViewModel(app: Application) : WearableListenerViewModel(app), + DataClient.OnDataChangedListener { + private val viewModelState = MutableStateFlow(MediaPlayerUiState(isLoading = true)) + + val uiState = viewModelState.stateIn( + viewModelScope, + SharingStarted.Eagerly, + viewModelState.value + ) + + val playerState = viewModelState.map { it.playerState }.stateIn( + viewModelScope, + SharingStarted.Eagerly, + viewModelState.value.playerState + ) + + private var deleteJob: Job? = null + + private var mediaPagerState = MediaPagerState() + private var updatePagerJob: Job? = null + + init { + Wearable.getDataClient(appContext).addListener(this) + + viewModelScope.launch { + eventFlow.collect { event -> + when (event.eventType) { + ACTION_UPDATECONNECTIONSTATUS -> { + val connectionStatus = WearConnectionStatus.valueOf( + event.data.getInt( + EXTRA_CONNECTIONSTATUS, + 0 + ) + ) + + viewModelState.update { + it.copy( + connectionStatus = connectionStatus + ) + } + } + + MediaHelper.MediaBrowserItemsBackPath -> { + viewModelScope.launch { + if (connect()) { + val state = uiState.value + + mPhoneNodeWithApp?.id?.let { nodeID -> + sendMessage( + nodeID, + MediaHelper.MediaPlayerConnectPath, + if (state.isAutoLaunch) state.isAutoLaunch.booleanToBytes() else state.mediaPlayerDetails.packageName?.stringToBytes() + ) + sendMessage(nodeID, event.eventType, null) + } + } + } + } + + MediaHelper.MediaBrowserItemsClickPath, + MediaHelper.MediaBrowserItemsExtraSuggestedClickPath, + MediaHelper.MediaQueueItemsClickPath -> { + val id = event.data.getString(MediaHelper.KEY_MEDIAITEM_ID) + + viewModelScope.launch { + if (connect()) { + val state = uiState.value + + mPhoneNodeWithApp?.id?.let { nodeID -> + sendMessage( + nodeID, + MediaHelper.MediaPlayerConnectPath, + if (state.isAutoLaunch) state.isAutoLaunch.booleanToBytes() else state.mediaPlayerDetails.packageName?.stringToBytes() + ) + sendMessage( + nodeID, + event.eventType, + id!!.stringToBytes() + ) + } + } + } + } + + ACTION_CHANGED -> { + val jsonData = + event.data.getString(WearableListenerActivity.EXTRA_ACTIONDATA) + requestAction(jsonData) + } + } + } + } + } + + override fun onMessageReceived(messageEvent: MessageEvent) { + when (messageEvent.path) { + MediaHelper.MediaPlayerConnectPath, + MediaHelper.MediaPlayerAutoLaunchPath -> { + val actionStatus = ActionStatus.valueOf(messageEvent.data.bytesToString()) + + if (actionStatus == ActionStatus.PERMISSION_DENIED) { + viewModelState.update { + it.copy(isPlayerAvailable = false) + } + } else if (actionStatus == ActionStatus.SUCCESS) { + viewModelState.update { + it.copy(isPlayerAvailable = true) + } + } + + _eventsFlow.tryEmit(WearableEvent(messageEvent.path, Bundle().apply { + putSerializable(EXTRA_STATUS, actionStatus) + })) + } + + MediaHelper.MediaBrowserItemsClickPath, + MediaHelper.MediaBrowserItemsExtraSuggestedClickPath -> { + val actionStatus = ActionStatus.valueOf(messageEvent.data.bytesToString()) + _eventsFlow.tryEmit(WearableEvent(messageEvent.path, Bundle().apply { + putSerializable(EXTRA_STATUS, actionStatus) + })) + } + + WearableHelper.AudioStatusPath, + MediaHelper.MediaVolumeStatusPath -> { + val status = messageEvent.data?.let { + JSONParser.deserializer( + it.bytesToString(), + AudioStreamState::class.java + ) + } + + viewModelState.update { + it.copy(audioStreamState = status) + } + } + + MediaHelper.MediaPlayPath -> { + val actionStatus = ActionStatus.valueOf(messageEvent.data.bytesToString()) + _eventsFlow.tryEmit(WearableEvent(messageEvent.path, Bundle().apply { + putSerializable(EXTRA_STATUS, actionStatus) + })) + } + + else -> super.onMessageReceived(messageEvent) + } + } + + override fun onDataChanged(dataEventBuffer: DataEventBuffer) { + viewModelScope.launch { + updatePagerJob?.cancel() + var isPagerUpdated = false + + for (event in dataEventBuffer) { + if (event.type == DataEvent.TYPE_CHANGED) { + val item = event.dataItem + when (item.uri.path) { + MediaHelper.MediaActionsPath -> { + try { + updatePager(item) + val dataMap = DataMapItem.fromDataItem(item).dataMap + updateCustomControls(dataMap) + isPagerUpdated = true + } catch (e: Exception) { + Logger.writeLine(Log.ERROR, e) + } + } + + MediaHelper.MediaBrowserItemsPath -> { + try { + updatePager(item) + val dataMap = DataMapItem.fromDataItem(item).dataMap + updateBrowserItems(dataMap) + isPagerUpdated = true + } catch (e: Exception) { + Logger.writeLine(Log.ERROR, e) + } + } + + MediaHelper.MediaQueueItemsPath -> { + try { + updatePager(item) + val dataMap = DataMapItem.fromDataItem(item).dataMap + updateQueueItems(dataMap) + isPagerUpdated = true + } catch (e: Exception) { + Logger.writeLine(Log.ERROR, e) + } + } + + MediaHelper.MediaPlayerStatePath -> { + deleteJob?.cancel() + val dataMap = DataMapItem.fromDataItem(item).dataMap + updatePlayerState(dataMap) + } + } + } else if (event.type == DataEvent.TYPE_DELETED) { + val item = event.dataItem + when (item.uri.path) { + MediaHelper.MediaBrowserItemsPath -> { + mediaPagerState = mediaPagerState.copy( + supportsBrowser = false + ) + isPagerUpdated = true + } + + MediaHelper.MediaActionsPath -> { + mediaPagerState = mediaPagerState.copy( + supportsCustomActions = false + ) + isPagerUpdated = true + } + + MediaHelper.MediaQueueItemsPath -> { + mediaPagerState = mediaPagerState.copy( + supportsQueue = false, + ) + + viewModelState.update { + it.copy( + activeQueueItemId = -1 + ) + } + + isPagerUpdated = true + } + + MediaHelper.MediaPlayerStatePath -> { + deleteJob?.cancel() + deleteJob = viewModelScope.launch delete@{ + delay(1000) + + if (!isActive) return@delete + + updatePlayerState(DataMap()) + } + } + } + } + } + + if (isPagerUpdated) { + updatePagerJob = viewModelScope.launch updatePagerJob@{ + delay(1000) + + if (!isActive) return@updatePagerJob + + viewModelState.update { + it.copy( + pagerState = mediaPagerState + ) + } + } + } + } + } + + fun updateMediaPlayerDetails(player: AppItemViewModel) { + viewModelState.update { + it.copy(mediaPlayerDetails = player) + } + } + + private fun updatePager() { + viewModelScope.launch(Dispatchers.IO) { + try { + val buff = Wearable.getDataClient(appContext) + .getDataItems( + WearableHelper.getWearDataUri( + "*", + "/media" + ), + DataClient.FILTER_PREFIX + ) + .await() + + for (i in 0 until buff.count) { + val item = buff[i] + updatePager(item) + } + + buff.release() + + viewModelState.update { + it.copy(pagerState = mediaPagerState) + } + } catch (e: Exception) { + Logger.writeLine(Log.ERROR, e) + } + } + } + + private fun updatePager(item: DataItem) { + when (item.uri.path) { + MediaHelper.MediaBrowserItemsPath -> { + mediaPagerState = mediaPagerState.copy( + supportsBrowser = try { + val dataMap = DataMapItem.fromDataItem(item).dataMap + val items = dataMap.getDataMapArrayList(MediaHelper.KEY_MEDIAITEMS) + !items.isNullOrEmpty() + } catch (e: Exception) { + Logger.writeLine(Log.ERROR, e) + false + } + ) + } + + MediaHelper.MediaActionsPath -> { + mediaPagerState = mediaPagerState.copy( + supportsCustomActions = try { + val dataMap = DataMapItem.fromDataItem(item).dataMap + val items = dataMap.getDataMapArrayList(MediaHelper.KEY_MEDIAITEMS) + !items.isNullOrEmpty() + } catch (e: Exception) { + Logger.writeLine(Log.ERROR, e) + false + } + ) + } + + MediaHelper.MediaQueueItemsPath -> { + mediaPagerState = mediaPagerState.copy( + supportsQueue = try { + val dataMap = DataMapItem.fromDataItem(item).dataMap + val items = dataMap.getDataMapArrayList(MediaHelper.KEY_MEDIAITEMS) + !items.isNullOrEmpty() + } catch (e: Exception) { + Logger.writeLine(Log.ERROR, e) + false + } + ) + } + } + } + + private fun requestPlayerConnect() { + viewModelScope.launch { + if (connect()) { + val state = uiState.value + + sendMessage( + mPhoneNodeWithApp!!.id, + MediaHelper.MediaPlayerConnectPath, + if (state.isAutoLaunch) state.isAutoLaunch.booleanToBytes() else state.mediaPlayerDetails.packageName?.stringToBytes() + ) + } + } + } + + fun requestPlayerDisconnect() { + viewModelScope.launch { + if (connect()) { + sendMessage(mPhoneNodeWithApp!!.id, MediaHelper.MediaPlayerDisconnectPath, null) + } + } + } + + fun refreshStatus() { + viewModelScope.launch { + updateConnectionStatus() + requestPlayerConnect() + updatePager() + } + } + + fun refreshPlayerState() { + viewModelScope.launch { + // Request connect to media player + requestVolumeStatus() + updatePlayerState() + } + } + + private fun updatePlayerState(dataMap: DataMap) { + viewModelScope.launch { + val stateName = dataMap.getString(MediaHelper.KEY_MEDIA_PLAYBACKSTATE) + val playbackState = stateName?.let { PlaybackState.valueOf(it) } ?: PlaybackState.NONE + val title = dataMap.getString(MediaHelper.KEY_MEDIA_METADATA_TITLE) + val artist = dataMap.getString(MediaHelper.KEY_MEDIA_METADATA_ARTIST) + val artBitmap = dataMap.getAsset(MediaHelper.KEY_MEDIA_METADATA_ART)?.let { + try { + ImageUtils.bitmapFromAssetStream( + Wearable.getDataClient(appContext), + it + ) + } catch (e: Exception) { + null + } + } + + if (playbackState != PlaybackState.NONE) { + viewModelState.update { + it.copy( + playerState = PlayerState( + playbackState = playbackState, + title = title, + artist = artist, + artworkBitmap = artBitmap + ), + isLoading = false, + isPlaybackLoading = playbackState == PlaybackState.LOADING + ) + } + } else { + viewModelState.update { + it.copy( + playerState = PlayerState(), + isLoading = false, + isPlaybackLoading = false + ) + } + } + } + } + + private fun updatePlayerState() { + viewModelScope.launch(Dispatchers.IO) { + try { + val buff = Wearable.getDataClient(appContext) + .getDataItems( + WearableHelper.getWearDataUri( + "*", + MediaHelper.MediaPlayerStatePath + ) + ) + .await() + + for (i in 0 until buff.count) { + val item = buff[i] + if (MediaHelper.MediaPlayerStatePath == item.uri.path) { + val dataMap = DataMapItem.fromDataItem(item).dataMap + updatePlayerState(dataMap) + } + } + + buff.release() + } catch (e: Exception) { + Logger.writeLine(Log.ERROR, e) + } + } + } + + fun requestPlayPauseAction(play: Boolean = true) { + requestMediaAction(if (play) MediaHelper.MediaPlayPath else MediaHelper.MediaPausePath) + } + + fun requestSkipToPreviousAction() { + requestMediaAction(MediaHelper.MediaPreviousPath) + } + + fun requestSkipToNextAction() { + requestMediaAction(MediaHelper.MediaNextPath) + } + + private fun requestVolumeUp() { + requestMediaAction(MediaHelper.MediaVolumeUpPath) + } + + private fun requestVolumeDown() { + requestMediaAction(MediaHelper.MediaVolumeDownPath) + } + + private fun requestVolumeStatus() { + requestMediaAction(MediaHelper.MediaVolumeStatusPath) + } + + private fun requestSetVolume(value: Int) { + requestMediaAction(MediaHelper.MediaSetVolumePath, value.intToBytes()) + } + + private fun requestMediaAction(path: String, data: ByteArray? = null) { + viewModelScope.launch { + if (connect()) { + val state = uiState.value + + mPhoneNodeWithApp?.id?.let { nodeID -> + sendMessage( + nodeID, + MediaHelper.MediaPlayerConnectPath, + if (state.isAutoLaunch) state.isAutoLaunch.booleanToBytes() else state.mediaPlayerDetails.packageName?.stringToBytes() + ) + sendMessage(nodeID, path, data) + } + } + } + } + + fun showCallVolumeActivity(activityContext: Activity) { + val intent: Intent = Intent(activityContext, ValueActionActivity::class.java) + .putExtra(EXTRA_ACTION, Actions.VOLUME) + .putExtra(ValueActionActivity.EXTRA_STREAMTYPE, AudioStreamType.MUSIC) + activityContext.startActivityForResult(intent, -1) + } + + // Custom Controls + fun requestCustomMediaActionItem(itemId: String) { + requestMediaAction(MediaHelper.MediaActionsClickPath, itemId.stringToBytes()) + } + + fun updateCustomControls() { + viewModelScope.launch(Dispatchers.IO) { + try { + val buff = Wearable.getDataClient(appContext) + .getDataItems( + WearableHelper.getWearDataUri( + "*", + MediaHelper.MediaActionsPath + ) + ) + .await() + + for (i in 0 until buff.count) { + val item = buff[i] + if (MediaHelper.MediaActionsPath == item.uri.path) { + val dataMap = DataMapItem.fromDataItem(item).dataMap + updateCustomControls(dataMap) + } + } + + buff.release() + } catch (e: Exception) { + Logger.writeLine(Log.ERROR, e) + } + } + } + + private suspend fun updateCustomControls(dataMap: DataMap) { + val items = dataMap.getDataMapArrayList(MediaHelper.KEY_MEDIAITEMS) ?: emptyList() + val mediaItems = ArrayList(items.size) + + for (item in items) { + val id = item.getString(MediaHelper.KEY_MEDIA_ACTIONITEM_ACTION) ?: continue + val icon = item.getAsset(MediaHelper.KEY_MEDIA_ACTIONITEM_ICON)?.let { + try { + ImageUtils.bitmapFromAssetStream( + Wearable.getDataClient(appContext), + it + ) + } catch (e: Exception) { + null + } + } + val title = item.getString(MediaHelper.KEY_MEDIA_ACTIONITEM_TITLE) + + mediaItems.add(MediaItemModel(id).apply { + this.icon = icon + this.title = title + }) + } + + viewModelState.update { + it.copy( + isLoading = false, + mediaCustomItems = mediaItems + ) + } + } + + // Media Browser + fun updateBrowserItems() { + viewModelScope.launch(Dispatchers.IO) { + try { + val buff = Wearable.getDataClient(appContext) + .getDataItems( + WearableHelper.getWearDataUri( + "*", + MediaHelper.MediaBrowserItemsPath + ) + ) + .await() + + for (i in 0 until buff.count) { + val item = buff[i] + if (MediaHelper.MediaBrowserItemsPath == item.uri.path) { + val dataMap = DataMapItem.fromDataItem(item).dataMap + updateBrowserItems(dataMap) + } + } + + buff.release() + } catch (e: Exception) { + Logger.writeLine(Log.ERROR, e) + } + } + } + + private suspend fun updateBrowserItems(dataMap: DataMap) { + val isRoot = dataMap.getBoolean(MediaHelper.KEY_MEDIAITEM_ISROOT) + val items = dataMap.getDataMapArrayList(MediaHelper.KEY_MEDIAITEMS) ?: emptyList() + val mediaItems = ArrayList(if (isRoot) items.size else items.size + 1) + if (!isRoot) { + mediaItems.add(MediaItemModel(MediaHelper.ACTIONITEM_BACK)) + } + + for (item in items) { + val id = item.getString(MediaHelper.KEY_MEDIAITEM_ID) ?: continue + val icon = item.getAsset(MediaHelper.KEY_MEDIAITEM_ICON)?.let { + try { + ImageUtils.bitmapFromAssetStream( + Wearable.getDataClient(appContext), + it + ) + } catch (e: Exception) { + null + } + } + val title = item.getString(MediaHelper.KEY_MEDIAITEM_TITLE) + + mediaItems.add(MediaItemModel(id).apply { + this.icon = icon + this.title = title + }) + } + + viewModelState.update { + it.copy( + isLoading = false, + mediaBrowserItems = mediaItems + ) + } + } + + fun requestBrowserActionItem(itemId: String) { + if (itemId == MediaHelper.ACTIONITEM_BACK) { + requestMediaAction(MediaHelper.MediaBrowserItemsBackPath) + } else { + requestMediaAction(MediaHelper.MediaBrowserItemsClickPath, itemId.stringToBytes()) + } + } + + // Media Queue + fun updateQueueItems() { + viewModelScope.launch(Dispatchers.IO) { + try { + val buff = Wearable.getDataClient(appContext) + .getDataItems( + WearableHelper.getWearDataUri( + "*", + MediaHelper.MediaQueueItemsPath + ) + ) + .await() + + for (i in 0 until buff.count) { + val item = buff[i] + if (MediaHelper.MediaQueueItemsPath == item.uri.path) { + val dataMap = DataMapItem.fromDataItem(item).dataMap + updateQueueItems(dataMap) + } + } + + buff.release() + } catch (e: Exception) { + Logger.writeLine(Log.ERROR, e) + } + } + } + + private suspend fun updateQueueItems(dataMap: DataMap) { + val items = dataMap.getDataMapArrayList(MediaHelper.KEY_MEDIAITEMS) ?: emptyList() + val mediaItems = ArrayList(items.size) + + for (item in items) { + val id = item.getLong(MediaHelper.KEY_MEDIAITEM_ID) + val icon = item.getAsset(MediaHelper.KEY_MEDIAITEM_ICON)?.let { + try { + ImageUtils.bitmapFromAssetStream( + Wearable.getDataClient(appContext), + it + ) + } catch (e: Exception) { + null + } + } + val title = item.getString(MediaHelper.KEY_MEDIAITEM_TITLE) + + mediaItems.add(MediaItemModel(id.toString()).apply { + this.icon = icon + this.title = title + }) + } + + val newQueueId = dataMap.getLong(MediaHelper.KEY_MEDIA_ACTIVEQUEUEITEM_ID, -1) + + viewModelState.update { + it.copy( + isLoading = false, + mediaQueueItems = mediaItems, + activeQueueItemId = newQueueId + ) + } + } + + fun requestQueueActionItem(itemId: String) { + requestMediaAction(MediaHelper.MediaQueueItemsClickPath, itemId.stringToBytes()) + } +} \ No newline at end of file diff --git a/wear/src/main/java/com/thewizrd/simplewear/media/MediaQueueFragment.kt b/wear/src/main/java/com/thewizrd/simplewear/media/MediaQueueFragment.kt deleted file mode 100644 index 31e4b8d..0000000 --- a/wear/src/main/java/com/thewizrd/simplewear/media/MediaQueueFragment.kt +++ /dev/null @@ -1,375 +0,0 @@ -package com.thewizrd.simplewear.media - -import android.content.Intent -import android.media.session.MediaSession -import android.os.Bundle -import android.text.Spannable -import android.text.SpannableString -import android.text.style.ForegroundColorSpan -import android.util.Log -import android.view.* -import androidx.core.graphics.drawable.toDrawable -import androidx.core.view.InputDeviceCompat -import androidx.core.view.MotionEventCompat -import androidx.core.view.ViewConfigurationCompat -import androidx.lifecycle.lifecycleScope -import androidx.localbroadcastmanager.content.LocalBroadcastManager -import androidx.recyclerview.widget.ConcatAdapter -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import androidx.wear.widget.WearableLinearLayoutManager -import com.google.android.gms.wearable.* -import com.thewizrd.shared_resources.helpers.MediaHelper -import com.thewizrd.shared_resources.helpers.WearableHelper -import com.thewizrd.shared_resources.lifecycle.LifecycleAwareFragment -import com.thewizrd.shared_resources.utils.ContextUtils.dpToPx -import com.thewizrd.shared_resources.utils.ContextUtils.getAttrColor -import com.thewizrd.shared_resources.utils.ImageUtils -import com.thewizrd.shared_resources.utils.Logger -import com.thewizrd.simplewear.R -import com.thewizrd.simplewear.adapters.SpacerAdapter -import com.thewizrd.simplewear.controls.WearChipButton -import com.thewizrd.simplewear.databinding.FragmentBrowserListBinding -import com.thewizrd.simplewear.helpers.CustomScrollingLayoutCallback -import com.thewizrd.simplewear.helpers.SimpleRecyclerViewAdapterObserver -import com.thewizrd.simplewear.helpers.SpacerItemDecoration -import kotlinx.coroutines.* -import kotlinx.coroutines.tasks.await -import java.util.* -import kotlin.math.roundToInt - -class MediaQueueFragment : LifecycleAwareFragment(), DataClient.OnDataChangedListener { - private lateinit var binding: FragmentBrowserListBinding - private lateinit var mQueueItemsAdapter: MediaQueueItemsAdapter - private lateinit var mLayoutManager: WearableLinearLayoutManager - - private var deleteJob: Job? = null - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - binding = FragmentBrowserListBinding.inflate(inflater, container, false) - - binding.listView.setHasFixedSize(true) - binding.listView.isEdgeItemsCenteringEnabled = false - binding.listView.layoutManager = - WearableLinearLayoutManager(requireContext(), CustomScrollingLayoutCallback()).also { - mLayoutManager = it - } - mQueueItemsAdapter = MediaQueueItemsAdapter() - binding.listView.adapter = ConcatAdapter( - SpacerAdapter(requireContext().dpToPx(48f).toInt()), - mQueueItemsAdapter, - SpacerAdapter(requireContext().dpToPx(48f).toInt()) - ) - binding.listView.addItemDecoration( - SpacerItemDecoration( - requireContext().dpToPx(16f).toInt(), - requireContext().dpToPx(4f).toInt() - ) - ) - - mQueueItemsAdapter.setOnClickListener(object : MediaQueueItemsAdapter.OnClickListener { - override fun onClick(item: MediaItemModel) { - LocalBroadcastManager.getInstance(requireContext()) - .sendBroadcast( - Intent(MediaHelper.MediaQueueItemsClickPath) - .putExtra(MediaHelper.KEY_MEDIAITEM_ID, item.id) - ) - } - }) - - binding.listView.addOnScrollListener(object : RecyclerView.OnScrollListener() { - override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { - binding.timeText.apply { - translationY = -recyclerView.computeVerticalScrollOffset().toFloat() - } - } - }) - - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - view.setOnGenericMotionListener { v, event -> - if (event.action == MotionEvent.ACTION_SCROLL && event.isFromSource(InputDeviceCompat.SOURCE_ROTARY_ENCODER)) { - // Don't forget the negation here - val delta = -event.getAxisValue(MotionEventCompat.AXIS_SCROLL) * - ViewConfigurationCompat.getScaledVerticalScrollFactor( - ViewConfiguration.get(v.context), v.context - ) - - // Swap these axes if you want to do horizontal scrolling instead - binding.listView.scrollBy(0, delta.roundToInt()) - - return@setOnGenericMotionListener true - } - false - } - } - - override fun onResume() { - super.onResume() - Wearable.getDataClient(requireContext()).addListener(this) - - binding.listView.requestFocus() - - updateQueueItems() - } - - override fun onPause() { - Wearable.getDataClient(requireContext()).removeListener(this) - super.onPause() - } - - override fun onDestroyView() { - super.onDestroyView() - } - - private fun showLoading(show: Boolean) { - if (show) { - binding.progressBar.show() - } else { - binding.progressBar.hide() - } - binding.listView.visibility = if (show) View.INVISIBLE else View.VISIBLE - } - - private class MediaQueueItemsAdapter : - ListAdapter(diffCallback) { - private var onClickListener: OnClickListener? = null - var mActiveQueueItemId: Long = MediaSession.QueueItem.UNKNOWN_ID.toLong() - - companion object { - private val diffCallback = object : DiffUtil.ItemCallback() { - override fun areItemsTheSame( - oldItem: MediaItemModel, - newItem: MediaItemModel - ): Boolean { - return Objects.equals(oldItem.id, newItem.id) - } - - override fun areContentsTheSame( - oldItem: MediaItemModel, - newItem: MediaItemModel - ): Boolean { - return Objects.equals(oldItem, newItem) - } - } - } - - fun setOnClickListener(listener: OnClickListener?) { - this.onClickListener = listener - } - - inner class ViewHolder(val button: WearChipButton) : - RecyclerView.ViewHolder(button) { - private val inActiveSpan = - ForegroundColorSpan(button.context.getAttrColor(R.attr.colorOnSurfaceVariant)) - - fun bind(model: MediaItemModel, isActive: Boolean) { - button.setIconDrawable(model.icon?.toDrawable(button.context.resources)) - bindTitle(model, isActive) - button.setOnClickListener { - onClickListener?.onClick(model) - } - } - - fun bindTitle(model: MediaItemModel, isActive: Boolean) { - button.setPrimaryText(if (isActive) { - model.title - } else { - SpannableString(model.title).apply { - setSpan( - inActiveSpan, - 0, - this.length, - Spannable.SPAN_EXCLUSIVE_EXCLUSIVE - ) - } - }) - } - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { - return ViewHolder(WearChipButton(parent.context).apply { - layoutParams = RecyclerView.LayoutParams( - RecyclerView.LayoutParams.MATCH_PARENT, - RecyclerView.LayoutParams.WRAP_CONTENT - ) - }) - } - - override fun onBindViewHolder(holder: ViewHolder, position: Int) { - val item = getItem(position) - holder.bind(item, item.id.toLong() == mActiveQueueItemId) - } - - override fun onBindViewHolder( - holder: ViewHolder, - position: Int, - payloads: MutableList - ) { - if (payloads.isNullOrEmpty()) { - super.onBindViewHolder(holder, position, payloads) - } else { - val item = getItem(position) - holder.bindTitle(item, item.id.toLong() == mActiveQueueItemId) - } - } - - interface OnClickListener { - fun onClick(item: MediaItemModel) - } - } - - private fun updateQueueItems() { - lifecycleScope.launch(Dispatchers.IO) { - try { - val buff = Wearable.getDataClient(requireContext()) - .getDataItems( - WearableHelper.getWearDataUri( - "*", - MediaHelper.MediaQueueItemsPath - ) - ) - .await() - - for (i in 0 until buff.count) { - val item = buff[i] - if (MediaHelper.MediaQueueItemsPath == item.uri.path) { - val dataMap = DataMapItem.fromDataItem(item).dataMap - updateQueueItems(dataMap, true) - } - } - - buff.release() - } catch (e: Exception) { - Logger.writeLine(Log.ERROR, e) - } - } - } - - private suspend fun updateQueueItems(dataMap: DataMap, scrollToActive: Boolean = false) { - val items = dataMap.getDataMapArrayList(MediaHelper.KEY_MEDIAITEMS) ?: emptyList() - val mediaItems = ArrayList(items.size) - - for (item in items) { - val id = item.getLong(MediaHelper.KEY_MEDIAITEM_ID) - val icon = item.getAsset(MediaHelper.KEY_MEDIAITEM_ICON)?.let { - try { - ImageUtils.bitmapFromAssetStream( - Wearable.getDataClient(requireContext()), - it - ) - } catch (e: Exception) { - null - } - } - val title = item.getString(MediaHelper.KEY_MEDIAITEM_TITLE) - - mediaItems.add(MediaItemModel(id.toString()).apply { - this.icon = icon - this.title = title - }) - } - - val newQueueId = dataMap.getLong(MediaHelper.KEY_MEDIA_ACTIVEQUEUEITEM_ID, -1) - - runWithView { - showLoading(false) - binding.listView.requestFocus() - - if (newQueueId != mQueueItemsAdapter.mActiveQueueItemId) { - mQueueItemsAdapter.mActiveQueueItemId = newQueueId - mQueueItemsAdapter.notifyItemRangeChanged( - 0, - mQueueItemsAdapter.itemCount, - newQueueId - ) - } - - if (scrollToActive && mQueueItemsAdapter.mActiveQueueItemId >= 0) { - // Register scroller - mQueueItemsAdapter.registerAdapterDataObserver(object : - SimpleRecyclerViewAdapterObserver() { - override fun onChanged() { - val position = mQueueItemsAdapter.currentList.indexOfFirst { - it.id.toLong() == mQueueItemsAdapter.mActiveQueueItemId - } - - if (position >= 0 && mQueueItemsAdapter.itemCount > position) { - mQueueItemsAdapter.unregisterAdapterDataObserver(this) - - binding.listView.viewTreeObserver.addOnGlobalLayoutListener(object : - ViewTreeObserver.OnGlobalLayoutListener { - override fun onGlobalLayout() { - binding.listView.viewTreeObserver.removeOnGlobalLayoutListener( - this - ) - binding.listView.postOnAnimation { - mLayoutManager.findViewByPosition(0)?.let { - val containerHeight = binding.listView.measuredHeight - val totalViewsInContainer = - containerHeight / it.measuredHeight - mLayoutManager.scrollToPositionWithOffset( - position + 1, - it.measuredHeight * (totalViewsInContainer / 2) - ) - } - } - } - }) - } - } - }) - } - - mQueueItemsAdapter.submitList(mediaItems) - } - } - - override fun onDataChanged(dataEventBuffer: DataEventBuffer) { - lifecycleScope.launch { - for (event in dataEventBuffer) { - if (event.type == DataEvent.TYPE_CHANGED) { - val item = event.dataItem - if (MediaHelper.MediaQueueItemsPath == item.uri.path) { - deleteJob?.cancel() - try { - val dataMap = DataMapItem.fromDataItem(item).dataMap - updateQueueItems(dataMap) - } catch (e: Exception) { - Logger.writeLine(Log.ERROR, e) - } - } - } else if (event.type == DataEvent.TYPE_DELETED) { - val item = event.dataItem - if (MediaHelper.MediaQueueItemsPath == item.uri.path) { - deleteJob = lifecycleScope.launch { - delay(1000) - - if (!isActive) return@launch - - mQueueItemsAdapter.mActiveQueueItemId = -1 - mQueueItemsAdapter.notifyItemRangeChanged( - 0, - mQueueItemsAdapter.itemCount, - -1 - ) - } - } - } - } - } - } -} \ No newline at end of file diff --git a/wear/src/main/java/com/thewizrd/simplewear/ui/ambient/AmbientMode.kt b/wear/src/main/java/com/thewizrd/simplewear/ui/ambient/AmbientMode.kt new file mode 100644 index 0000000..7e4dfbe --- /dev/null +++ b/wear/src/main/java/com/thewizrd/simplewear/ui/ambient/AmbientMode.kt @@ -0,0 +1,84 @@ +package com.thewizrd.simplewear.ui.ambient + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.toRect +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.ColorMatrix +import androidx.compose.ui.graphics.Paint +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.withSaveLayer +import com.google.android.horologist.compose.ambient.AmbientState +import kotlin.random.Random + +/** + * Number of pixels to offset the content rendered in the display to prevent screen burn-in. + */ +private const val BURN_IN_OFFSET_PX = 10 + +/** + * If the screen requires burn-in protection, items must be shifted around periodically + * in ambient mode. To ensure that content isn't shifted off the screen, avoid placing + * content within 10 pixels of the edge of the screen. + * + * Activities should also avoid solid white areas to prevent pixel burn-in. Both of + * these requirements only apply in ambient mode, and only when + * [AmbientState.Ambient.doBurnInProtection] is set to true. + */ +fun Modifier.ambientMode( + ambientState: AmbientState +): Modifier = composed { + val translationX = rememberBurnInTranslation(ambientState) + val translationY = rememberBurnInTranslation(ambientState) + + this + .graphicsLayer { + this.translationX = translationX + this.translationY = translationY + } + .ambientGray(ambientState) +} + +@Composable +private fun rememberBurnInTranslation( + ambientState: AmbientState +): Float = + remember(ambientState) { + when (ambientState) { + AmbientState.Interactive -> 0f + is AmbientState.Ambient -> if (ambientState.ambientDetails?.burnInProtectionRequired == true) { + Random.nextInt(-BURN_IN_OFFSET_PX, BURN_IN_OFFSET_PX + 1).toFloat() + } else { + 0f + } + } + } + +private val grayscale = Paint().apply { + colorFilter = ColorFilter.colorMatrix( + ColorMatrix().apply { + setToSaturation(0f) + } + ) + isAntiAlias = false +} + +internal fun Modifier.ambientGray(ambientState: AmbientState): Modifier = + if (ambientState is AmbientState.Ambient) { + graphicsLayer { + scaleX = 0.9f + scaleY = 0.9f + }.drawWithContent { + drawIntoCanvas { + it.withSaveLayer(size.toRect(), grayscale) { + drawContent() + } + } + } + } else { + this + } \ No newline at end of file diff --git a/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/AppLauncherScreen.kt b/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/AppLauncherScreen.kt index 37fbe91..8023b6d 100644 --- a/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/AppLauncherScreen.kt +++ b/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/AppLauncherScreen.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.pager.HorizontalPager @@ -22,6 +23,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.core.graphics.drawable.toBitmap import androidx.wear.compose.foundation.ExperimentalWearFoundationApi @@ -185,6 +187,7 @@ private fun AppLauncherScreen( horizontalAlignment = Alignment.CenterHorizontally ) { Text( + modifier = Modifier.padding(horizontal = 14.dp), text = stringResource(id = R.string.error_noapps), textAlign = TextAlign.Center ) diff --git a/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/CallManagerUi.kt b/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/CallManagerUi.kt index f6c2ed6..f1912d1 100644 --- a/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/CallManagerUi.kt +++ b/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/CallManagerUi.kt @@ -299,6 +299,7 @@ private fun NoCallActiveScreen() { contentAlignment = Alignment.Center ) { Text( + modifier = Modifier.padding(horizontal = 14.dp), text = stringResource(id = R.string.message_nocall_active), textAlign = TextAlign.Center ) diff --git a/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/MediaPlayerUi.kt b/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/MediaPlayerUi.kt new file mode 100644 index 0000000..5934014 --- /dev/null +++ b/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/MediaPlayerUi.kt @@ -0,0 +1,725 @@ +@file:OptIn(ExperimentalHorologistApi::class) + +package com.thewizrd.simplewear.ui.simplewear + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import androidx.core.graphics.drawable.toBitmap +import androidx.wear.ambient.AmbientLifecycleObserver +import androidx.wear.compose.foundation.ExperimentalWearFoundationApi +import androidx.wear.compose.foundation.HierarchicalFocusCoordinator +import androidx.wear.compose.foundation.edgeSwipeToDismiss +import androidx.wear.compose.foundation.lazy.items +import androidx.wear.compose.foundation.rememberSwipeToDismissBoxState +import androidx.wear.compose.material.ChipDefaults +import androidx.wear.compose.material.CompactChip +import androidx.wear.compose.material.Icon +import androidx.wear.compose.material.MaterialTheme +import androidx.wear.compose.material.SwipeToDismissBox +import androidx.wear.compose.material.Text +import androidx.wear.compose.material.TimeText +import androidx.wear.compose.ui.tooling.preview.WearPreviewDevices +import androidx.wear.compose.ui.tooling.preview.WearPreviewFontScales +import com.google.android.horologist.annotations.ExperimentalHorologistApi +import com.google.android.horologist.audio.ui.VolumeUiState +import com.google.android.horologist.audio.ui.components.actions.SetVolumeButton +import com.google.android.horologist.audio.ui.components.animated.AnimatedSetVolumeButton +import com.google.android.horologist.compose.ambient.AmbientAware +import com.google.android.horologist.compose.ambient.AmbientState +import com.google.android.horologist.compose.layout.PagerScaffold +import com.google.android.horologist.compose.layout.ScalingLazyColumn +import com.google.android.horologist.compose.layout.ScalingLazyColumnDefaults +import com.google.android.horologist.compose.layout.rememberResponsiveColumnState +import com.google.android.horologist.compose.layout.scrollAway +import com.google.android.horologist.compose.material.Chip +import com.google.android.horologist.compose.pager.HorizontalPagerDefaults +import com.google.android.horologist.media.ui.components.ControlButtonLayout +import com.google.android.horologist.media.ui.components.animated.AnimatedMediaControlButtons +import com.google.android.horologist.media.ui.components.animated.MarqueeTextMediaDisplay +import com.google.android.horologist.media.ui.components.controls.MediaButton +import com.google.android.horologist.media.ui.components.display.LoadingMediaDisplay +import com.google.android.horologist.media.ui.components.display.NothingPlayingDisplay +import com.google.android.horologist.media.ui.screens.player.PlayerScreen +import com.google.android.horologist.media.ui.state.model.TrackPositionUiModel +import com.thewizrd.shared_resources.helpers.MediaHelper +import com.thewizrd.shared_resources.media.PlaybackState +import com.thewizrd.simplewear.R +import com.thewizrd.simplewear.media.MediaItemModel +import com.thewizrd.simplewear.media.MediaPageType +import com.thewizrd.simplewear.media.MediaPlayerUiState +import com.thewizrd.simplewear.media.MediaPlayerViewModel +import com.thewizrd.simplewear.media.PlayerState +import com.thewizrd.simplewear.ui.ambient.ambientMode +import com.thewizrd.simplewear.ui.components.LoadingContent +import com.thewizrd.simplewear.ui.theme.WearAppTheme +import com.thewizrd.simplewear.ui.theme.activityViewModel +import com.thewizrd.simplewear.ui.theme.findActivity + +@OptIn( + ExperimentalFoundationApi::class, + ExperimentalWearFoundationApi::class +) +@Composable +fun MediaPlayerUi( + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val activity = context.findActivity() + + val mediaPlayerViewModel = activityViewModel() + val uiState by mediaPlayerViewModel.uiState.collectAsState() + val mediaPagerState = remember(uiState) { uiState.pagerState } + val swipeToDismissBoxState = rememberSwipeToDismissBoxState() + + val pagerState = rememberPagerState( + initialPage = 0, + pageCount = { mediaPagerState.pageCount } + ) + + WearAppTheme { + AmbientAware { ambientStateUpdate -> + val ambientState = remember(ambientStateUpdate) { ambientStateUpdate.ambientState } + + PagerScaffold( + modifier = Modifier.fillMaxSize(), + timeText = { + if (pagerState.currentPage == 0) { + TimeText() + } + }, + pagerState = if (ambientState != AmbientState.Interactive || uiState.isLoading || !uiState.isPlayerAvailable) null else pagerState + ) { + SwipeToDismissBox( + modifier = Modifier.background(MaterialTheme.colors.background), + onDismissed = { + activity.onBackPressed() + }, + state = swipeToDismissBoxState + ) { isBackground -> + if (isBackground) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colors.background) + ) + } else { + val keyFunc: (Int) -> MediaPageType = remember(uiState) { + pagerKey@{ pageIdx -> + if (ambientState != AmbientState.Interactive) + return@pagerKey MediaPageType.Player + + if (pageIdx == 1) { + if (mediaPagerState.supportsCustomActions) { + return@pagerKey MediaPageType.CustomControls + } + if (mediaPagerState.supportsQueue) { + return@pagerKey MediaPageType.Queue + } + if (mediaPagerState.supportsBrowser) { + return@pagerKey MediaPageType.Browser + } + } else if (pageIdx == 2) { + if (mediaPagerState.supportsQueue) { + return@pagerKey MediaPageType.Queue + } + if (mediaPagerState.supportsBrowser) { + return@pagerKey MediaPageType.Browser + } + } else if (pageIdx == 3) { + return@pagerKey MediaPageType.Browser + } + + MediaPageType.Player + } + } + + HorizontalPager( + modifier = modifier.edgeSwipeToDismiss(swipeToDismissBoxState), + state = pagerState, + flingBehavior = HorizontalPagerDefaults.flingParams(pagerState), + key = keyFunc + ) { pageIdx -> + HierarchicalFocusCoordinator(requiresFocus = { pageIdx == pagerState.currentPage }) { + val key = keyFunc(pageIdx) + + when (key) { + MediaPageType.Player -> { + MediaPlayerControlsPage( + mediaPlayerViewModel = mediaPlayerViewModel, + ambientState = ambientState + ) + } + + MediaPageType.CustomControls -> { + MediaCustomControlsPage( + mediaPlayerViewModel = mediaPlayerViewModel + ) + } + + MediaPageType.Browser -> { + MediaBrowserPage( + mediaPlayerViewModel = mediaPlayerViewModel + ) + } + + MediaPageType.Queue -> { + MediaQueuePage( + mediaPlayerViewModel = mediaPlayerViewModel + ) + } + } + } + } + } + } + } + } + } +} + +@Composable +private fun MediaPlayerControlsPage( + mediaPlayerViewModel: MediaPlayerViewModel, + ambientState: AmbientState +) { + val context = LocalContext.current + val activity = context.findActivity() + + val uiState by mediaPlayerViewModel.uiState.collectAsState() + val playerState by mediaPlayerViewModel.playerState.collectAsState() + + MediaPlayerControlsPage( + uiState = uiState, + playerState = playerState, + ambientState = ambientState, + onRefresh = { + mediaPlayerViewModel.refreshStatus() + }, + onPlay = { + mediaPlayerViewModel.requestPlayPauseAction(true) + }, + onPause = { + mediaPlayerViewModel.requestPlayPauseAction(false) + }, + onSkipBack = { + mediaPlayerViewModel.requestSkipToPreviousAction() + }, + onSkipForward = { + mediaPlayerViewModel.requestSkipToNextAction() + }, + onVolume = { + mediaPlayerViewModel.showCallVolumeActivity(activity) + } + ) + + LaunchedEffect(context) { + mediaPlayerViewModel.refreshPlayerState() + } +} + +@Composable +private fun MediaPlayerControlsPage( + uiState: MediaPlayerUiState, + playerState: PlayerState = uiState.playerState, + ambientState: AmbientState = AmbientState.Interactive, + onRefresh: () -> Unit = {}, + onPlay: () -> Unit = {}, + onPause: () -> Unit = {}, + onSkipBack: () -> Unit = {}, + onSkipForward: () -> Unit = {}, + onVolume: () -> Unit = {}, +) { + val volumeUiState = remember(uiState) { + uiState.audioStreamState?.let { + VolumeUiState(it.currentVolume, it.maxVolume, it.minVolume) + } + } + val isAmbient = ambientState != AmbientState.Interactive + + LoadingContent( + empty = !uiState.isPlayerAvailable && !isAmbient, + emptyContent = { + Box( + modifier = Modifier + .fillMaxSize() + .wrapContentHeight(), + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + modifier = Modifier.padding(horizontal = 14.dp), + text = stringResource(id = R.string.error_nomusicplayers), + textAlign = TextAlign.Center + ) + CompactChip( + label = { + Text(text = stringResource(id = R.string.action_retry)) + }, + icon = { + Icon( + painter = painterResource(id = R.drawable.ic_baseline_refresh_24), + contentDescription = stringResource(id = R.string.action_retry) + ) + }, + onClick = onRefresh + ) + } + } + }, + loading = uiState.isLoading && !isAmbient + ) { + PlayerScreen( + modifier = Modifier.ambientMode(ambientState), + mediaDisplay = { + if (uiState.isPlaybackLoading) { + LoadingMediaDisplay() + } else if (!playerState.isEmpty()) { + MarqueeTextMediaDisplay( + title = playerState.title, + artist = playerState.artist + ) + } else { + NothingPlayingDisplay() + } + }, + controlButtons = { + if (!isAmbient) { + AnimatedMediaControlButtons( + onPlayButtonClick = onPlay, + onPauseButtonClick = onPause, + playPauseButtonEnabled = !uiState.isPlaybackLoading || playerState.playbackState > PlaybackState.LOADING, + playing = playerState.playbackState == PlaybackState.PLAYING, + onSeekToPreviousButtonClick = onSkipBack, + seekToPreviousButtonEnabled = !uiState.isPlaybackLoading || playerState.playbackState > PlaybackState.LOADING, + onSeekToNextButtonClick = onSkipForward, + seekToNextButtonEnabled = !uiState.isPlaybackLoading || playerState.playbackState > PlaybackState.LOADING, + trackPositionUiModel = TrackPositionUiModel.Hidden + ) + } else { + ControlButtonLayout( + leftButton = {}, + middleButton = { + Box( + modifier = Modifier + .fillMaxSize() + .clip(CircleShape), + contentAlignment = Alignment.Center, + ) { + if (playerState.playbackState == PlaybackState.PLAYING) { + MediaButton( + onClick = {}, + icon = ImageVector.vectorResource(id = R.drawable.ic_outline_pause_24), + contentDescription = stringResource(id = R.string.horologist_pause_button_content_description) + ) + } else { + MediaButton( + onClick = {}, + icon = ImageVector.vectorResource(id = R.drawable.ic_outline_play_arrow_24), + contentDescription = stringResource(id = R.string.horologist_play_button_content_description) + ) + } + } + }, + rightButton = {} + ) + } + }, + buttons = { + if (!isAmbient) { + if (volumeUiState != null) { + AnimatedSetVolumeButton( + onVolumeClick = onVolume, + volumeUiState = volumeUiState + ) + } else { + SetVolumeButton(onVolumeClick = onVolume) + } + } + }, + background = { + playerState.artworkBitmap?.takeUnless { isAmbient }?.let { + Image( + bitmap = it.asImageBitmap(), + colorFilter = ColorFilter.tint( + Color.Black.copy(alpha = 0.66f), + BlendMode.SrcAtop + ), + contentDescription = null + ) + } + } + ) + } +} + +@Composable +private fun MediaCustomControlsPage( + mediaPlayerViewModel: MediaPlayerViewModel +) { + val context = LocalContext.current + + val uiState by mediaPlayerViewModel.uiState.collectAsState() + + MediaCustomControlsPage( + uiState = uiState, + onItemClick = { item -> + mediaPlayerViewModel.requestCustomMediaActionItem(item.id) + } + ) + + LaunchedEffect(context) { + mediaPlayerViewModel.updateCustomControls() + } +} + +@Composable +private fun MediaCustomControlsPage( + uiState: MediaPlayerUiState, + onItemClick: (MediaItemModel) -> Unit = {} +) { + LoadingContent( + empty = false, + emptyContent = {}, + loading = uiState.isLoading + ) { + val scrollState = rememberResponsiveColumnState( + contentPadding = ScalingLazyColumnDefaults.padding( + first = ScalingLazyColumnDefaults.ItemType.Chip, + last = ScalingLazyColumnDefaults.ItemType.Chip + ) + ) + + Box( + modifier = Modifier.fillMaxSize() + ) { + TimeText(Modifier.scrollAway { scrollState }) + + ScalingLazyColumn( + columnState = scrollState + ) { + items(uiState.mediaCustomItems) { + Chip( + label = it.title ?: "", + icon = { + it.icon?.let { bmp -> + Icon( + modifier = Modifier.size(ChipDefaults.IconSize), + bitmap = bmp.asImageBitmap(), + tint = Color.White, + contentDescription = null + ) + } + }, + onClick = { + onItemClick(it) + }, + colors = ChipDefaults.secondaryChipColors() + ) + } + } + + LaunchedEffect(Unit) { + scrollState.state.scrollToItem(0) + } + } + } +} + +@Composable +private fun MediaBrowserPage( + mediaPlayerViewModel: MediaPlayerViewModel +) { + val context = LocalContext.current + + val uiState by mediaPlayerViewModel.uiState.collectAsState() + + MediaBrowserPage( + uiState = uiState, + onItemClick = { item -> + mediaPlayerViewModel.requestBrowserActionItem(item.id) + } + ) + + LaunchedEffect(context) { + mediaPlayerViewModel.updateBrowserItems() + } +} + +@Composable +private fun MediaBrowserPage( + uiState: MediaPlayerUiState, + onItemClick: (MediaItemModel) -> Unit = {} +) { + LoadingContent( + empty = false, + emptyContent = {}, + loading = uiState.isLoading + ) { + val scrollState = rememberResponsiveColumnState( + contentPadding = ScalingLazyColumnDefaults.padding( + first = ScalingLazyColumnDefaults.ItemType.Chip, + last = ScalingLazyColumnDefaults.ItemType.Chip + ) + ) + + Box( + modifier = Modifier.fillMaxSize() + ) { + TimeText(Modifier.scrollAway { scrollState }) + + ScalingLazyColumn( + columnState = scrollState + ) { + items(uiState.mediaBrowserItems) { + Chip( + label = if (it.id == MediaHelper.ACTIONITEM_BACK) { + stringResource(id = R.string.label_back) + } else { + it.title ?: "" + }, + icon = { + if (it.id == MediaHelper.ACTIONITEM_BACK) { + Icon( + modifier = Modifier.size(ChipDefaults.IconSize), + painter = painterResource(id = R.drawable.ic_baseline_arrow_back_24), + tint = Color.White, + contentDescription = null + ) + } else { + it.icon?.let { bmp -> + Icon( + modifier = Modifier.size(ChipDefaults.IconSize), + bitmap = bmp.asImageBitmap(), + tint = Color.White, + contentDescription = null + ) + } + } + }, + onClick = { + onItemClick(it) + }, + colors = ChipDefaults.secondaryChipColors() + ) + } + } + + LaunchedEffect(Unit) { + scrollState.state.scrollToItem(0) + } + } + } +} + +@Composable +private fun MediaQueuePage( + mediaPlayerViewModel: MediaPlayerViewModel +) { + val context = LocalContext.current + + val uiState by mediaPlayerViewModel.uiState.collectAsState() + + MediaQueuePage( + uiState = uiState, + onItemClick = { item -> + mediaPlayerViewModel.requestQueueActionItem(item.id) + } + ) + + LaunchedEffect(context) { + mediaPlayerViewModel.updateQueueItems() + } +} + +@Composable +private fun MediaQueuePage( + uiState: MediaPlayerUiState, + onItemClick: (MediaItemModel) -> Unit = {} +) { + LoadingContent( + empty = false, + emptyContent = {}, + loading = uiState.isLoading + ) { + val scrollState = rememberResponsiveColumnState( + contentPadding = ScalingLazyColumnDefaults.padding( + first = ScalingLazyColumnDefaults.ItemType.Chip, + last = ScalingLazyColumnDefaults.ItemType.Chip + ) + ) + + Box( + modifier = Modifier.fillMaxSize() + ) { + TimeText(Modifier.scrollAway { scrollState }) + + ScalingLazyColumn( + columnState = scrollState + ) { + items(uiState.mediaQueueItems) { + Chip( + label = it.title ?: "", + icon = { + it.icon?.let { bmp -> + Icon( + modifier = Modifier.size(ChipDefaults.IconSize), + bitmap = bmp.asImageBitmap(), + contentDescription = null, + tint = Color.Unspecified + ) + } + }, + onClick = { + onItemClick(it) + }, + colors = if (it.id.toLong() == uiState.activeQueueItemId) { + ChipDefaults.gradientBackgroundChipColors() + } else { + ChipDefaults.secondaryChipColors() + } + ) + } + } + } + + LaunchedEffect(Unit) { + if (uiState.activeQueueItemId != -1L) { + uiState.mediaQueueItems.indexOfFirst { + it.id.toLong() == uiState.activeQueueItemId + }.takeIf { it > 0 }?.run { + scrollState.state.scrollToItem(this) + } + } + } + } +} + +@WearPreviewDevices +@WearPreviewFontScales +@Composable +private fun PreviewNoMediaPlayer() { + val uiState = remember { + MediaPlayerUiState() + } + + MediaPlayerControlsPage( + uiState = uiState + ) +} + +@WearPreviewDevices +@WearPreviewFontScales +@Composable +private fun PreviewMediaControls() { + val context = LocalContext.current + + val background = remember(context) { + ContextCompat.getDrawable(context, R.drawable.sample_image)?.toBitmap() + } + + val uiState = remember { + MediaPlayerUiState( + isPlayerAvailable = true, + playerState = PlayerState( + playbackState = PlaybackState.PLAYING, + title = "Title", + artist = "Artist", + artworkBitmap = background + ) + ) + } + + MediaPlayerControlsPage( + uiState = uiState + ) +} + +@WearPreviewDevices +@WearPreviewFontScales +@Composable +private fun PreviewMediaControlsInAmbientMode() { + val context = LocalContext.current + + val background = remember(context) { + ContextCompat.getDrawable(context, R.drawable.sample_image)?.toBitmap() + } + + val uiState = remember { + MediaPlayerUiState( + isPlayerAvailable = true, + playerState = PlayerState( + playbackState = PlaybackState.PLAYING, + title = "Title", + artist = "Artist", + artworkBitmap = background + ) + ) + } + + MediaPlayerControlsPage( + uiState = uiState, + ambientState = AmbientState.Ambient( + ambientDetails = AmbientLifecycleObserver.AmbientDetails( + true, + true + ) + ) + ) +} + +@WearPreviewDevices +@WearPreviewFontScales +@Composable +private fun PreviewCustomControls() { + val context = LocalContext.current + + val uiState = remember { + MediaPlayerUiState( + isPlayerAvailable = true, + mediaCustomItems = List(5) { + MediaItemModel(it.toString()).apply { + title = "Item ${it + 1}" + icon = ContextCompat.getDrawable(context, R.drawable.ic_icon)!!.toBitmap() + } + } + ) + } + + MediaCustomControlsPage( + uiState = uiState + ) +} \ No newline at end of file diff --git a/wear/src/main/java/com/thewizrd/simplewear/viewmodels/MediaPlayerListViewModel.kt b/wear/src/main/java/com/thewizrd/simplewear/viewmodels/MediaPlayerListViewModel.kt index ed631fe..51b7dd5 100644 --- a/wear/src/main/java/com/thewizrd/simplewear/viewmodels/MediaPlayerListViewModel.kt +++ b/wear/src/main/java/com/thewizrd/simplewear/viewmodels/MediaPlayerListViewModel.kt @@ -139,9 +139,6 @@ class MediaPlayerListViewModel(app: Application) : WearableListenerViewModel(app viewModelScope.launch { // Cancel timer timer.cancel() - viewModelState.update { - it.copy(isLoading = false) - } for (event in dataEventBuffer) { if (event.type == DataEvent.TYPE_CHANGED) { @@ -152,6 +149,10 @@ class MediaPlayerListViewModel(app: Application) : WearableListenerViewModel(app updateMusicPlayers(dataMap) } catch (e: Exception) { Logger.writeLine(Log.ERROR, e) + + viewModelState.update { + it.copy(isLoading = false) + } } } } @@ -278,25 +279,23 @@ class MediaPlayerListViewModel(app: Application) : WearableListenerViewModel(app } private fun updateAppsList() { - viewModelScope.launch { - val filteredApps = Settings.getMusicPlayersFilter() + val filteredApps = Settings.getMusicPlayersFilter() - if (filteredApps.isEmpty()) { - viewModelState.update { - it.copy( - mediaAppsSet = it.allMediaAppsSet.toSortedSet(AppItemComparator()), - isLoading = false - ) - } - } else { - viewModelState.update { state -> - state.copy( - mediaAppsSet = state.allMediaAppsSet.toMutableList().apply { - removeIf { !filteredApps.contains(it.packageName) } - }.toSortedSet(AppItemComparator()), - isLoading = false - ) - } + if (filteredApps.isEmpty()) { + viewModelState.update { + it.copy( + mediaAppsSet = it.allMediaAppsSet.toSortedSet(AppItemComparator()), + isLoading = false + ) + } + } else { + viewModelState.update { state -> + state.copy( + mediaAppsSet = state.allMediaAppsSet.toMutableList().apply { + removeIf { !filteredApps.contains(it.packageName) } + }.toSortedSet(AppItemComparator()), + isLoading = false + ) } } } diff --git a/wear/src/main/java/com/thewizrd/simplewear/viewmodels/ValueActionViewModel.kt b/wear/src/main/java/com/thewizrd/simplewear/viewmodels/ValueActionViewModel.kt index 0b12c41..cefe872 100644 --- a/wear/src/main/java/com/thewizrd/simplewear/viewmodels/ValueActionViewModel.kt +++ b/wear/src/main/java/com/thewizrd/simplewear/viewmodels/ValueActionViewModel.kt @@ -30,7 +30,6 @@ import kotlinx.coroutines.launch data class ValueActionUiState( val connectionStatus: WearConnectionStatus? = null, val action: Actions? = null, - val remoteValue: Float? = null, val valueActionState: ValueActionState? = null, val streamType: AudioStreamType? = AudioStreamType.MUSIC, val isAutoBrightnessEnabled: Boolean = true diff --git a/wear/src/main/res/layout/activity_musicplayback.xml b/wear/src/main/res/layout/activity_musicplayback.xml deleted file mode 100644 index b889ea9..0000000 --- a/wear/src/main/res/layout/activity_musicplayback.xml +++ /dev/null @@ -1,54 +0,0 @@ - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/wear/src/main/res/layout/fragment_browser_list.xml b/wear/src/main/res/layout/fragment_browser_list.xml deleted file mode 100644 index 8a7b31a..0000000 --- a/wear/src/main/res/layout/fragment_browser_list.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/wear/src/main/res/layout/media_player_controls.xml b/wear/src/main/res/layout/media_player_controls.xml deleted file mode 100644 index 12fb718..0000000 --- a/wear/src/main/res/layout/media_player_controls.xml +++ /dev/null @@ -1,241 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/wear/src/main/res/values/strings.xml b/wear/src/main/res/values/strings.xml index 570c9a9..383f357 100644 --- a/wear/src/main/res/values/strings.xml +++ b/wear/src/main/res/values/strings.xml @@ -63,6 +63,7 @@ Loading… Refresh + Retry Play Add Battery State From 4eeffb8b2ff09934070702d1c6a36047b04425c8 Mon Sep 17 00:00:00 2001 From: Dave Antoine Date: Fri, 5 Apr 2024 22:39:57 -0400 Subject: [PATCH 23/58] MediaPlayerUi: support media timeline progress --- .../media/MediaControllerService.kt | 63 +++++++++++++++-- .../shared_resources/helpers/MediaHelper.kt | 1 + .../shared_resources/media/PositionState.kt | 8 +++ .../simplewear/media/MediaPlayerViewModel.kt | 21 +++++- .../media/PlaybackStateEventUtils.kt | 32 +++++++++ .../simplewear/ui/simplewear/MediaPlayerUi.kt | 68 ++++++++++++++----- 6 files changed, 165 insertions(+), 28 deletions(-) create mode 100644 shared_resources/src/main/java/com/thewizrd/shared_resources/media/PositionState.kt create mode 100644 wear/src/main/java/com/thewizrd/simplewear/media/PlaybackStateEventUtils.kt diff --git a/mobile/src/main/java/com/thewizrd/simplewear/media/MediaControllerService.kt b/mobile/src/main/java/com/thewizrd/simplewear/media/MediaControllerService.kt index 6b0b87e..062d717 100644 --- a/mobile/src/main/java/com/thewizrd/simplewear/media/MediaControllerService.kt +++ b/mobile/src/main/java/com/thewizrd/simplewear/media/MediaControllerService.kt @@ -1,6 +1,11 @@ package com.thewizrd.simplewear.media -import android.app.* +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.SearchManager +import android.app.Service import android.content.ContentResolver import android.content.Context import android.content.Intent @@ -9,7 +14,13 @@ import android.content.res.Resources import android.media.AudioManager import android.media.session.MediaController import android.media.session.MediaSessionManager -import android.os.* +import android.os.Build +import android.os.Bundle +import android.os.CountDownTimer +import android.os.Handler +import android.os.IBinder +import android.os.Looper +import android.os.SystemClock import android.provider.MediaStore import android.support.v4.media.MediaBrowserCompat import android.support.v4.media.MediaMetadataCompat @@ -23,22 +34,43 @@ import androidx.core.app.NotificationCompat import androidx.core.content.ContextCompat import androidx.core.content.res.ResourcesCompat import androidx.media.MediaBrowserServiceCompat -import com.google.android.gms.wearable.* -import com.thewizrd.shared_resources.actions.* +import com.google.android.gms.wearable.DataClient +import com.google.android.gms.wearable.DataMap +import com.google.android.gms.wearable.MessageClient +import com.google.android.gms.wearable.MessageEvent +import com.google.android.gms.wearable.PutDataMapRequest +import com.google.android.gms.wearable.Wearable +import com.thewizrd.shared_resources.actions.ActionStatus +import com.thewizrd.shared_resources.actions.AudioStreamState +import com.thewizrd.shared_resources.actions.AudioStreamType +import com.thewizrd.shared_resources.actions.ValueDirection import com.thewizrd.shared_resources.helpers.MediaHelper import com.thewizrd.shared_resources.helpers.WearableHelper import com.thewizrd.shared_resources.helpers.toImmutableCompatFlag import com.thewizrd.shared_resources.media.PlaybackState -import com.thewizrd.shared_resources.utils.* +import com.thewizrd.shared_resources.media.PositionState +import com.thewizrd.shared_resources.utils.ImageUtils +import com.thewizrd.shared_resources.utils.JSONParser +import com.thewizrd.shared_resources.utils.Logger +import com.thewizrd.shared_resources.utils.bytesToInt +import com.thewizrd.shared_resources.utils.bytesToString +import com.thewizrd.shared_resources.utils.stringToBytes import com.thewizrd.simplewear.R import com.thewizrd.simplewear.helpers.PhoneStatusHelper import com.thewizrd.simplewear.preferences.Settings import com.thewizrd.simplewear.services.NotificationListener import com.thewizrd.simplewear.wearable.WearableManager -import kotlinx.coroutines.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch import kotlinx.coroutines.tasks.await import timber.log.Timber -import java.util.* +import java.util.Stack import java.util.concurrent.Executors class MediaControllerService : Service(), MessageClient.OnMessageReceivedListener, @@ -700,6 +732,23 @@ class MediaControllerService : Service(), MessageClient.OnMessageReceivedListene ImageUtils.createAssetFromBitmap(art) ) } + + mController?.let { + val durationMs = mediaMetadata.getLong(MediaMetadataCompat.METADATA_KEY_DURATION) + if (durationMs > 0) { + mapRequest.dataMap.putString( + MediaHelper.KEY_MEDIA_POSITIONSTATE, + JSONParser.serializer( + PositionState( + durationMs, + it.playbackState.position, + it.playbackState.playbackSpeed + ), + PositionState::class.java + ) + ) + } + } } else { mapRequest.dataMap.putString( MediaHelper.KEY_MEDIA_PLAYBACKSTATE, diff --git a/shared_resources/src/main/java/com/thewizrd/shared_resources/helpers/MediaHelper.kt b/shared_resources/src/main/java/com/thewizrd/shared_resources/helpers/MediaHelper.kt index 1705321..1a6bb75 100644 --- a/shared_resources/src/main/java/com/thewizrd/shared_resources/helpers/MediaHelper.kt +++ b/shared_resources/src/main/java/com/thewizrd/shared_resources/helpers/MediaHelper.kt @@ -53,6 +53,7 @@ object MediaHelper { const val KEY_MEDIA_METADATA_ARTIST = "key_media_metadata_artist" const val KEY_MEDIA_METADATA_ART = "key_media_metadata_art" const val KEY_MEDIA_PLAYBACKSTATE = "key_media_playbackstate" + const val KEY_MEDIA_POSITIONSTATE = "key_media_positionstate" const val KEY_MEDIA_SUPPORTS_PLAYFROMSEARCH = "key_media_supports_playfromsearch" diff --git a/shared_resources/src/main/java/com/thewizrd/shared_resources/media/PositionState.kt b/shared_resources/src/main/java/com/thewizrd/shared_resources/media/PositionState.kt new file mode 100644 index 0000000..0bbb84a --- /dev/null +++ b/shared_resources/src/main/java/com/thewizrd/shared_resources/media/PositionState.kt @@ -0,0 +1,8 @@ +package com.thewizrd.shared_resources.media + +data class PositionState( + val durationMs: Long = 0L, + val currentPositionMs: Long = 0L, + val playbackSpeed: Float = 1f, + val currentTimeMs: Long = System.currentTimeMillis() +) diff --git a/wear/src/main/java/com/thewizrd/simplewear/media/MediaPlayerViewModel.kt b/wear/src/main/java/com/thewizrd/simplewear/media/MediaPlayerViewModel.kt index 6b3b7c9..7a31486 100644 --- a/wear/src/main/java/com/thewizrd/simplewear/media/MediaPlayerViewModel.kt +++ b/wear/src/main/java/com/thewizrd/simplewear/media/MediaPlayerViewModel.kt @@ -15,6 +15,8 @@ import com.google.android.gms.wearable.DataMap import com.google.android.gms.wearable.DataMapItem import com.google.android.gms.wearable.MessageEvent import com.google.android.gms.wearable.Wearable +import com.google.android.horologist.annotations.ExperimentalHorologistApi +import com.google.android.horologist.media.model.PlaybackStateEvent import com.thewizrd.shared_resources.actions.ActionStatus import com.thewizrd.shared_resources.actions.Actions import com.thewizrd.shared_resources.actions.AudioStreamState @@ -23,6 +25,7 @@ import com.thewizrd.shared_resources.helpers.MediaHelper import com.thewizrd.shared_resources.helpers.WearConnectionStatus import com.thewizrd.shared_resources.helpers.WearableHelper import com.thewizrd.shared_resources.media.PlaybackState +import com.thewizrd.shared_resources.media.PositionState import com.thewizrd.shared_resources.utils.ImageUtils import com.thewizrd.shared_resources.utils.JSONParser import com.thewizrd.shared_resources.utils.Logger @@ -51,7 +54,7 @@ data class MediaPlayerUiState( val connectionStatus: WearConnectionStatus? = null, val isLoading: Boolean = false, val isPlaybackLoading: Boolean = false, - val isPlayerAvailable: Boolean = false, + val isPlayerAvailable: Boolean = true, val mediaPlayerDetails: AppItemViewModel = AppItemViewModel(), val audioStreamState: AudioStreamState? = null, // ViewPager pages @@ -84,7 +87,8 @@ data class PlayerState( val playbackState: PlaybackState = PlaybackState.NONE, val title: String? = null, val artist: String? = null, - val artworkBitmap: Bitmap? = null + val artworkBitmap: Bitmap? = null, + val positionState: PositionState? = null ) { fun isEmpty(): Boolean = title.isNullOrEmpty() && artist.isNullOrEmpty() } @@ -122,6 +126,13 @@ class MediaPlayerViewModel(app: Application) : WearableListenerViewModel(app), viewModelState.value.playerState ) + @OptIn(ExperimentalHorologistApi::class) + val playbackStateEvent = viewModelState.map { it.playerState.toPlaybackStateEvent() }.stateIn( + viewModelScope, + SharingStarted.Eagerly, + PlaybackStateEvent.INITIAL + ) + private var deleteJob: Job? = null private var mediaPagerState = MediaPagerState() @@ -495,6 +506,9 @@ class MediaPlayerViewModel(app: Application) : WearableListenerViewModel(app), null } } + val positionState = dataMap.getString(MediaHelper.KEY_MEDIA_POSITIONSTATE)?.let { + JSONParser.deserializer(it, PositionState::class.java) + } if (playbackState != PlaybackState.NONE) { viewModelState.update { @@ -503,7 +517,8 @@ class MediaPlayerViewModel(app: Application) : WearableListenerViewModel(app), playbackState = playbackState, title = title, artist = artist, - artworkBitmap = artBitmap + artworkBitmap = artBitmap, + positionState = positionState ), isLoading = false, isPlaybackLoading = playbackState == PlaybackState.LOADING diff --git a/wear/src/main/java/com/thewizrd/simplewear/media/PlaybackStateEventUtils.kt b/wear/src/main/java/com/thewizrd/simplewear/media/PlaybackStateEventUtils.kt new file mode 100644 index 0000000..97dd59f --- /dev/null +++ b/wear/src/main/java/com/thewizrd/simplewear/media/PlaybackStateEventUtils.kt @@ -0,0 +1,32 @@ +@file:OptIn(ExperimentalHorologistApi::class) + +package com.thewizrd.simplewear.media + +import com.google.android.horologist.annotations.ExperimentalHorologistApi +import com.google.android.horologist.media.model.PlaybackStateEvent +import com.google.android.horologist.media.model.PlayerState +import com.thewizrd.shared_resources.media.PlaybackState +import kotlin.time.Duration.Companion.milliseconds + +fun PlaybackState.toPlayerState(): PlayerState { + return when (this) { + PlaybackState.NONE -> PlayerState.Stopped + PlaybackState.LOADING -> PlayerState.Loading + PlaybackState.PLAYING -> PlayerState.Playing + PlaybackState.PAUSED -> PlayerState.Idle + } +} + +fun com.thewizrd.simplewear.media.PlayerState.toPlaybackStateEvent(): PlaybackStateEvent { + return PlaybackStateEvent( + playbackState = com.google.android.horologist.media.model.PlaybackState( + playerState = this.playbackState.toPlayerState(), + isLive = false, + currentPosition = this.positionState?.currentPositionMs?.milliseconds, + duration = this.positionState?.durationMs?.milliseconds, + playbackSpeed = this.positionState?.playbackSpeed ?: 1f + ), + cause = PlaybackStateEvent.Cause.PlayerStateChanged, + timestamp = this.positionState?.currentTimeMs?.milliseconds + ) +} \ No newline at end of file diff --git a/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/MediaPlayerUi.kt b/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/MediaPlayerUi.kt index 5934014..0a67c20 100644 --- a/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/MediaPlayerUi.kt +++ b/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/MediaPlayerUi.kt @@ -16,6 +16,7 @@ import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -29,6 +30,7 @@ import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource @@ -36,6 +38,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.core.graphics.drawable.toBitmap +import androidx.lifecycle.lifecycleScope import androidx.wear.ambient.AmbientLifecycleObserver import androidx.wear.compose.foundation.ExperimentalWearFoundationApi import androidx.wear.compose.foundation.HierarchicalFocusCoordinator @@ -64,14 +67,18 @@ import com.google.android.horologist.compose.layout.rememberResponsiveColumnStat import com.google.android.horologist.compose.layout.scrollAway import com.google.android.horologist.compose.material.Chip import com.google.android.horologist.compose.pager.HorizontalPagerDefaults +import com.google.android.horologist.media.model.PlaybackStateEvent +import com.google.android.horologist.media.model.TimestampProvider import com.google.android.horologist.media.ui.components.ControlButtonLayout import com.google.android.horologist.media.ui.components.animated.AnimatedMediaControlButtons import com.google.android.horologist.media.ui.components.animated.MarqueeTextMediaDisplay import com.google.android.horologist.media.ui.components.controls.MediaButton import com.google.android.horologist.media.ui.components.display.LoadingMediaDisplay import com.google.android.horologist.media.ui.components.display.NothingPlayingDisplay +import com.google.android.horologist.media.ui.components.display.TextMediaDisplay import com.google.android.horologist.media.ui.screens.player.PlayerScreen -import com.google.android.horologist.media.ui.state.model.TrackPositionUiModel +import com.google.android.horologist.media.ui.state.LocalTimestampProvider +import com.google.android.horologist.media.ui.state.mapper.TrackPositionUiModelMapper import com.thewizrd.shared_resources.helpers.MediaHelper import com.thewizrd.shared_resources.media.PlaybackState import com.thewizrd.simplewear.R @@ -80,11 +87,14 @@ import com.thewizrd.simplewear.media.MediaPageType import com.thewizrd.simplewear.media.MediaPlayerUiState import com.thewizrd.simplewear.media.MediaPlayerViewModel import com.thewizrd.simplewear.media.PlayerState +import com.thewizrd.simplewear.media.toPlaybackStateEvent import com.thewizrd.simplewear.ui.ambient.ambientMode import com.thewizrd.simplewear.ui.components.LoadingContent import com.thewizrd.simplewear.ui.theme.WearAppTheme import com.thewizrd.simplewear.ui.theme.activityViewModel import com.thewizrd.simplewear.ui.theme.findActivity +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch @OptIn( ExperimentalFoundationApi::class, @@ -218,10 +228,12 @@ private fun MediaPlayerControlsPage( val uiState by mediaPlayerViewModel.uiState.collectAsState() val playerState by mediaPlayerViewModel.playerState.collectAsState() + val playbackStateEvent by mediaPlayerViewModel.playbackStateEvent.collectAsState() MediaPlayerControlsPage( uiState = uiState, playerState = playerState, + playbackStateEvent = playbackStateEvent, ambientState = ambientState, onRefresh = { mediaPlayerViewModel.refreshStatus() @@ -252,6 +264,7 @@ private fun MediaPlayerControlsPage( private fun MediaPlayerControlsPage( uiState: MediaPlayerUiState, playerState: PlayerState = uiState.playerState, + playbackStateEvent: PlaybackStateEvent = uiState.playerState.toPlaybackStateEvent(), ambientState: AmbientState = AmbientState.Interactive, onRefresh: () -> Unit = {}, onPlay: () -> Unit = {}, @@ -267,6 +280,9 @@ private fun MediaPlayerControlsPage( } val isAmbient = ambientState != AmbientState.Interactive + // Progress + val timestampProvider = remember { TimestampProvider { System.currentTimeMillis() } } + LoadingContent( empty = !uiState.isPlayerAvailable && !isAmbient, emptyContent = { @@ -308,27 +324,36 @@ private fun MediaPlayerControlsPage( if (uiState.isPlaybackLoading) { LoadingMediaDisplay() } else if (!playerState.isEmpty()) { - MarqueeTextMediaDisplay( - title = playerState.title, - artist = playerState.artist - ) + if (!isAmbient) { + MarqueeTextMediaDisplay( + title = playerState.title, + artist = playerState.artist + ) + } else { + TextMediaDisplay( + title = playerState.title.orEmpty(), + subtitle = playerState.artist.orEmpty() + ) + } } else { NothingPlayingDisplay() } }, controlButtons = { if (!isAmbient) { - AnimatedMediaControlButtons( - onPlayButtonClick = onPlay, - onPauseButtonClick = onPause, - playPauseButtonEnabled = !uiState.isPlaybackLoading || playerState.playbackState > PlaybackState.LOADING, - playing = playerState.playbackState == PlaybackState.PLAYING, - onSeekToPreviousButtonClick = onSkipBack, - seekToPreviousButtonEnabled = !uiState.isPlaybackLoading || playerState.playbackState > PlaybackState.LOADING, - onSeekToNextButtonClick = onSkipForward, - seekToNextButtonEnabled = !uiState.isPlaybackLoading || playerState.playbackState > PlaybackState.LOADING, - trackPositionUiModel = TrackPositionUiModel.Hidden - ) + CompositionLocalProvider(LocalTimestampProvider provides timestampProvider) { + AnimatedMediaControlButtons( + onPlayButtonClick = onPlay, + onPauseButtonClick = onPause, + playPauseButtonEnabled = !uiState.isPlaybackLoading || playerState.playbackState > PlaybackState.LOADING, + playing = playerState.playbackState == PlaybackState.PLAYING, + onSeekToPreviousButtonClick = onSkipBack, + seekToPreviousButtonEnabled = !uiState.isPlaybackLoading || playerState.playbackState > PlaybackState.LOADING, + onSeekToNextButtonClick = onSkipForward, + seekToNextButtonEnabled = !uiState.isPlaybackLoading || playerState.playbackState > PlaybackState.LOADING, + trackPositionUiModel = TrackPositionUiModelMapper.map(playbackStateEvent) + ) + } } else { ControlButtonLayout( leftButton = {}, @@ -373,6 +398,7 @@ private fun MediaPlayerControlsPage( background = { playerState.artworkBitmap?.takeUnless { isAmbient }?.let { Image( + modifier = Modifier.fillMaxSize(), bitmap = it.asImageBitmap(), colorFilter = ColorFilter.tint( Color.Black.copy(alpha = 0.66f), @@ -570,6 +596,8 @@ private fun MediaQueuePage( uiState: MediaPlayerUiState, onItemClick: (MediaItemModel) -> Unit = {} ) { + val lifecycleOwner = LocalLifecycleOwner.current + LoadingContent( empty = false, emptyContent = {}, @@ -605,6 +633,10 @@ private fun MediaQueuePage( }, onClick = { onItemClick(it) + lifecycleOwner.lifecycleScope.launch { + delay(1000) + scrollState.state.scrollToItem(0) + } }, colors = if (it.id.toLong() == uiState.activeQueueItemId) { ChipDefaults.gradientBackgroundChipColors() @@ -694,8 +726,8 @@ private fun PreviewMediaControlsInAmbientMode() { uiState = uiState, ambientState = AmbientState.Ambient( ambientDetails = AmbientLifecycleObserver.AmbientDetails( - true, - true + burnInProtectionRequired = true, + deviceHasLowBitAmbient = true ) ) ) From 85a55867d252e8b9ea787579b01e093d5b6b808e Mon Sep 17 00:00:00 2001 From: Dave Antoine Date: Sat, 6 Apr 2024 17:56:55 -0400 Subject: [PATCH 24/58] SimpleWear: more compose migration * Remove extra activities * Use compose navigation * Cleanup unused resources --- build.gradle | 1 + wear/build.gradle | 1 + wear/src/main/AndroidManifest.xml | 44 +- .../main/java/com/thewizrd/simplewear/App.kt | 3 +- .../simplewear/AppLauncherActivity.kt | 134 ---- .../simplewear/CallManagerActivity.kt | 109 --- .../thewizrd/simplewear/DashboardActivity.kt | 311 +------- .../simplewear/MediaPlayerListActivity.kt | 125 ---- .../simplewear/ValueActionActivity.kt | 239 ------- .../simplewear/WearableListenerActivity.kt | 510 -------------- .../simplewear/adapters/AppItemDiffer.kt | 16 - .../adapters/MusicPlayerListAdapter.kt | 48 -- .../simplewear/adapters/SpacerAdapter.kt | 26 - .../controls/ActionButtonViewModel.kt | 24 +- .../controls/CheckableImageButton.java | 238 ------- .../controls/PageIndicatorView2.java | 665 ------------------ .../controls/timetext/TextViewWrapper.kt | 78 -- .../simplewear/controls/timetext/TimeText.kt | 315 --------- .../fragments/SwipeDismissFragment.kt | 37 - .../helpers/CustomScrollingLayoutCallback.kt | 75 -- .../SimpleRecyclerViewAdapterObserver.java | 35 - .../simplewear/media/MediaPlayerActivity.kt | 152 +--- .../simplewear/media/MediaPlayerViewModel.kt | 32 +- .../ui/components/SwipeToClosePagerScreen.kt | 169 +++++ .../simplewear/ui/navigation/Screen.kt | 39 + .../ui/simplewear/AppLauncherScreen.kt | 176 +++-- .../simplewear/ui/simplewear/CallManagerUi.kt | 127 +++- .../simplewear/ui/simplewear/Dashboard.kt | 339 ++++++++- .../ui/simplewear/DashboardScreen.kt | 6 +- .../simplewear/ui/simplewear/MediaPlayer.kt | 108 +++ .../ui/simplewear/MediaPlayerListUi.kt | 188 +++-- .../simplewear/ui/simplewear/MediaPlayerUi.kt | 296 +++++--- .../simplewear/ui/simplewear/PhoneSyncUi.kt | 4 +- .../simplewear/ui/simplewear/SimpleWearApp.kt | 88 +++ .../ui/simplewear/ValueActionScreen.kt | 201 +++++- .../viewmodels/CallManagerViewModel.kt | 12 - .../viewmodels/MediaPlayerListViewModel.kt | 27 +- .../viewmodels/WearableListenerViewModel.kt | 8 - .../wearable/WearableDataListenerService.kt | 24 +- .../MediaPlayerTileProviderService.kt | 4 +- wear/src/main/res/layout/curved_time_text.xml | 48 -- .../main/res/layout/straight_time_text.xml | 62 -- .../main/res/layout/swipe_dismiss_layout.xml | 8 - wear/src/main/res/values/styles.xml | 7 + 44 files changed, 1616 insertions(+), 3543 deletions(-) delete mode 100644 wear/src/main/java/com/thewizrd/simplewear/AppLauncherActivity.kt delete mode 100644 wear/src/main/java/com/thewizrd/simplewear/CallManagerActivity.kt delete mode 100644 wear/src/main/java/com/thewizrd/simplewear/MediaPlayerListActivity.kt delete mode 100644 wear/src/main/java/com/thewizrd/simplewear/ValueActionActivity.kt delete mode 100644 wear/src/main/java/com/thewizrd/simplewear/WearableListenerActivity.kt delete mode 100644 wear/src/main/java/com/thewizrd/simplewear/adapters/AppItemDiffer.kt delete mode 100644 wear/src/main/java/com/thewizrd/simplewear/adapters/MusicPlayerListAdapter.kt delete mode 100644 wear/src/main/java/com/thewizrd/simplewear/adapters/SpacerAdapter.kt delete mode 100644 wear/src/main/java/com/thewizrd/simplewear/controls/CheckableImageButton.java delete mode 100644 wear/src/main/java/com/thewizrd/simplewear/controls/PageIndicatorView2.java delete mode 100644 wear/src/main/java/com/thewizrd/simplewear/controls/timetext/TextViewWrapper.kt delete mode 100644 wear/src/main/java/com/thewizrd/simplewear/controls/timetext/TimeText.kt delete mode 100644 wear/src/main/java/com/thewizrd/simplewear/fragments/SwipeDismissFragment.kt delete mode 100644 wear/src/main/java/com/thewizrd/simplewear/helpers/CustomScrollingLayoutCallback.kt delete mode 100644 wear/src/main/java/com/thewizrd/simplewear/helpers/SimpleRecyclerViewAdapterObserver.java create mode 100644 wear/src/main/java/com/thewizrd/simplewear/ui/components/SwipeToClosePagerScreen.kt create mode 100644 wear/src/main/java/com/thewizrd/simplewear/ui/navigation/Screen.kt create mode 100644 wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/MediaPlayer.kt create mode 100644 wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/SimpleWearApp.kt delete mode 100644 wear/src/main/res/layout/curved_time_text.xml delete mode 100644 wear/src/main/res/layout/straight_time_text.xml delete mode 100644 wear/src/main/res/layout/swipe_dismiss_layout.xml diff --git a/build.gradle b/build.gradle index b31c1fa..23a25d3 100644 --- a/build.gradle +++ b/build.gradle @@ -23,6 +23,7 @@ buildscript { recyclerview_version = '1.3.2' coresplash_version = '1.0.1' work_version = '2.9.0' + navigation_version = '2.7.7' test_core_version = '1.5.0' test_runner_version = '1.5.2' diff --git a/wear/build.gradle b/wear/build.gradle index 23c21cb..5d06838 100644 --- a/wear/build.gradle +++ b/wear/build.gradle @@ -76,6 +76,7 @@ dependencies { implementation "androidx.recyclerview:recyclerview:$recyclerview_version" implementation "androidx.preference:preference-ktx:$preference_version" implementation "androidx.core:core-splashscreen:$coresplash_version" + implementation "androidx.navigation:navigation-runtime-ktx:$navigation_version" implementation platform("com.google.firebase:firebase-bom:$firebase_version") implementation 'com.google.firebase:firebase-analytics' diff --git a/wear/src/main/AndroidManifest.xml b/wear/src/main/AndroidManifest.xml index 33905aa..710e22f 100644 --- a/wear/src/main/AndroidManifest.xml +++ b/wear/src/main/AndroidManifest.xml @@ -57,71 +57,53 @@ android:name=".PhoneSyncActivity" android:launchMode="singleInstance" android:theme="@style/WearAppTheme.Launcher" - android:exported="true"> + android:exported="true" + android:taskAffinity="com.thewizrd.simplewear.PhoneSyncActivity"> + - + android:theme="@style/WearAppTheme" + android:taskAffinity="com.thewizrd.simplewear.DashboardActivity" /> + android:theme="@style/WearAppTheme.MediaLauncher"> - - - - - + android:theme="@style/WearAppTheme.MediaLauncher" + android:taskAffinity="com.thewizrd.simplewear.media.MediaPlayerActivity" /> + android:theme="@style/WearAppTheme" + android:taskAffinity="com.thewizrd.simplewear.preferences.DashboardTileConfigActivity" /> + android:theme="@style/WearAppTheme" + android:taskAffinity="com.thewizrd.simplewear.preferences.DashboardConfigActivity" /> () - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - setContent { - AppLauncherScreen() - } - } - - override fun onStart() { - super.onStart() - - appLauncherViewModel.initActivityContext(this) - - lifecycleScope.launch { - appLauncherViewModel.eventFlow.collect { event -> - when (event.eventType) { - WearableListenerViewModel.ACTION_UPDATECONNECTIONSTATUS -> { - val connectionStatus = WearConnectionStatus.valueOf( - event.data.getInt( - WearableListenerViewModel.EXTRA_CONNECTIONSTATUS, - 0 - ) - ) - - when (connectionStatus) { - WearConnectionStatus.DISCONNECTED -> { - // Navigate - startActivity( - Intent( - this@AppLauncherActivity, - PhoneSyncActivity::class.java - ) - ) - finishAffinity() - } - - WearConnectionStatus.APPNOTINSTALLED -> { - // Open store on remote device - appLauncherViewModel.openPlayStore(this@AppLauncherActivity) - - // Navigate - startActivity( - Intent( - this@AppLauncherActivity, - PhoneSyncActivity::class.java - ) - ) - finishAffinity() - } - - else -> { - } - } - } - - WearableHelper.LaunchAppPath -> { - val status = - event.data.getSerializable(WearableListenerViewModel.EXTRA_STATUS) as ActionStatus - - when (status) { - ActionStatus.SUCCESS -> { - CustomConfirmationOverlay() - .setType(CustomConfirmationOverlay.SUCCESS_ANIMATION) - .showOn(this@AppLauncherActivity) - } - - ActionStatus.PERMISSION_DENIED -> { - CustomConfirmationOverlay() - .setType(CustomConfirmationOverlay.CUSTOM_ANIMATION) - .setCustomDrawable( - ContextCompat.getDrawable( - this@AppLauncherActivity, - R.drawable.ws_full_sad - ) - ) - .setMessage(this@AppLauncherActivity.getString(R.string.error_permissiondenied)) - .showOn(this@AppLauncherActivity) - - appLauncherViewModel.openAppOnPhone(this@AppLauncherActivity, false) - } - - ActionStatus.FAILURE -> { - CustomConfirmationOverlay() - .setType(CustomConfirmationOverlay.CUSTOM_ANIMATION) - .setCustomDrawable( - ContextCompat.getDrawable( - this@AppLauncherActivity, - R.drawable.ws_full_sad - ) - ) - .setMessage(this@AppLauncherActivity.getString(R.string.error_actionfailed)) - .showOn(this@AppLauncherActivity) - } - - else -> {} - } - } - } - } - } - } - - override fun onResume() { - super.onResume() - - // Update statuses - appLauncherViewModel.refreshApps(true) - } - - override fun onDestroy() { - super.onDestroy() - } -} \ No newline at end of file diff --git a/wear/src/main/java/com/thewizrd/simplewear/CallManagerActivity.kt b/wear/src/main/java/com/thewizrd/simplewear/CallManagerActivity.kt deleted file mode 100644 index edbc46c..0000000 --- a/wear/src/main/java/com/thewizrd/simplewear/CallManagerActivity.kt +++ /dev/null @@ -1,109 +0,0 @@ -package com.thewizrd.simplewear - -import android.content.Intent -import android.os.Bundle -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.activity.viewModels -import androidx.core.content.ContextCompat -import androidx.lifecycle.lifecycleScope -import com.thewizrd.shared_resources.actions.ActionStatus -import com.thewizrd.shared_resources.helpers.InCallUIHelper -import com.thewizrd.shared_resources.helpers.WearConnectionStatus -import com.thewizrd.simplewear.controls.CustomConfirmationOverlay -import com.thewizrd.simplewear.ui.simplewear.CallManagerUi -import com.thewizrd.simplewear.viewmodels.CallManagerViewModel -import com.thewizrd.simplewear.viewmodels.WearableListenerViewModel -import com.thewizrd.simplewear.viewmodels.WearableListenerViewModel.Companion.ACTION_UPDATECONNECTIONSTATUS -import com.thewizrd.simplewear.viewmodels.WearableListenerViewModel.Companion.EXTRA_STATUS -import kotlinx.coroutines.launch - -class CallManagerActivity : ComponentActivity() { - private val callManagerViewModel by viewModels() - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - setContent { - CallManagerUi() - } - } - - override fun onStart() { - super.onStart() - - lifecycleScope.launch { - callManagerViewModel.eventFlow.collect { event -> - when (event.eventType) { - ACTION_UPDATECONNECTIONSTATUS -> { - val connectionStatus = WearConnectionStatus.valueOf( - event.data.getInt( - WearableListenerViewModel.EXTRA_CONNECTIONSTATUS, - 0 - ) - ) - - when (connectionStatus) { - WearConnectionStatus.DISCONNECTED -> { - // Navigate - startActivity( - Intent( - this@CallManagerActivity, - PhoneSyncActivity::class.java - ) - ) - finishAffinity() - } - - WearConnectionStatus.APPNOTINSTALLED -> { - // Open store on remote device - callManagerViewModel.openPlayStore(this@CallManagerActivity) - - // Navigate - startActivity( - Intent( - this@CallManagerActivity, - PhoneSyncActivity::class.java - ) - ) - finishAffinity() - } - - else -> {} - } - } - - InCallUIHelper.CallStatePath -> { - val status = event.data.getSerializable(EXTRA_STATUS) as ActionStatus - - if (status == ActionStatus.PERMISSION_DENIED) { - CustomConfirmationOverlay() - .setType(CustomConfirmationOverlay.CUSTOM_ANIMATION) - .setCustomDrawable( - ContextCompat.getDrawable( - this@CallManagerActivity, - R.drawable.ws_full_sad - ) - ) - .setMessage(getString(R.string.error_permissiondenied)) - .showOn(this@CallManagerActivity) - - callManagerViewModel.openAppOnPhone(this@CallManagerActivity, false) - } - } - } - } - } - } - - override fun onResume() { - super.onResume() - - // Update statuses - callManagerViewModel.refreshCallState() - } - - override fun onDestroy() { - super.onDestroy() - } -} \ No newline at end of file diff --git a/wear/src/main/java/com/thewizrd/simplewear/DashboardActivity.kt b/wear/src/main/java/com/thewizrd/simplewear/DashboardActivity.kt index 21c92f7..7527052 100644 --- a/wear/src/main/java/com/thewizrd/simplewear/DashboardActivity.kt +++ b/wear/src/main/java/com/thewizrd/simplewear/DashboardActivity.kt @@ -1,314 +1,37 @@ package com.thewizrd.simplewear -import android.Manifest -import android.content.Intent -import android.content.SharedPreferences -import android.content.SharedPreferences.OnSharedPreferenceChangeListener import android.os.Build import android.os.Bundle -import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.setContent -import androidx.activity.viewModels -import androidx.core.content.ContextCompat -import androidx.core.content.PermissionChecker -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.withStarted -import androidx.preference.PreferenceManager -import com.thewizrd.shared_resources.actions.Action -import com.thewizrd.shared_resources.actions.ActionStatus import com.thewizrd.shared_resources.actions.Actions -import com.thewizrd.shared_resources.helpers.WearConnectionStatus -import com.thewizrd.shared_resources.helpers.WearableHelper -import com.thewizrd.shared_resources.sleeptimer.SleepTimerHelper -import com.thewizrd.shared_resources.utils.JSONParser -import com.thewizrd.simplewear.controls.ActionButtonViewModel -import com.thewizrd.simplewear.controls.CustomConfirmationOverlay -import com.thewizrd.simplewear.preferences.Settings -import com.thewizrd.simplewear.ui.simplewear.Dashboard -import com.thewizrd.simplewear.utils.ErrorMessage -import com.thewizrd.simplewear.viewmodels.DashboardViewModel -import com.thewizrd.simplewear.viewmodels.WearableListenerViewModel.Companion.ACTION_UPDATECONNECTIONSTATUS -import com.thewizrd.simplewear.viewmodels.WearableListenerViewModel.Companion.EXTRA_ACTIONDATA -import com.thewizrd.simplewear.viewmodels.WearableListenerViewModel.Companion.EXTRA_CONNECTIONSTATUS -import kotlinx.coroutines.launch - -class DashboardActivity : ComponentActivity(), OnSharedPreferenceChangeListener { - private val dashboardViewModel by viewModels() +import com.thewizrd.simplewear.ui.navigation.Screen +import com.thewizrd.simplewear.ui.simplewear.SimpleWearApp +import com.thewizrd.simplewear.viewmodels.WearableListenerViewModel.Companion.EXTRA_ACTION +class DashboardActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - PreferenceManager.getDefaultSharedPreferences(this@DashboardActivity) - .registerOnSharedPreferenceChangeListener(this@DashboardActivity) - - setContent { - Dashboard() - } - } - - override fun onStart() { - super.onStart() + var startDestination = Screen.Dashboard.route - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - if (PermissionChecker.checkSelfPermission( - this, - Manifest.permission.POST_NOTIFICATIONS - ) != PermissionChecker.PERMISSION_GRANTED - ) { - requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 0) + if (intent?.hasExtra(EXTRA_ACTION) == true) { + val actionType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getSerializableExtra(EXTRA_ACTION, Actions::class.java) + } else { + intent.getSerializableExtra(EXTRA_ACTION) as Actions } - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - if (PermissionChecker.checkSelfPermission( - this, - Manifest.permission.BLUETOOTH_ADVERTISE - ) != PermissionChecker.PERMISSION_GRANTED - ) { - requestPermissions(arrayOf(Manifest.permission.BLUETOOTH_ADVERTISE), 0) + when (actionType) { + Actions.PHONE -> startDestination = Screen.CallManager.route + else -> {} } } - dashboardViewModel.initActivityContext(this) - - lifecycleScope.launch { - dashboardViewModel.eventFlow.collect { event -> - when (event.eventType) { - ACTION_UPDATECONNECTIONSTATUS -> { - val connectionStatus = WearConnectionStatus.valueOf( - event.data.getInt( - EXTRA_CONNECTIONSTATUS, - 0 - ) - ) - - when (connectionStatus) { - WearConnectionStatus.DISCONNECTED -> { - startActivity( - Intent( - this@DashboardActivity, - PhoneSyncActivity::class.java - ) - ) - finishAffinity() - } - - WearConnectionStatus.CONNECTING -> {} - WearConnectionStatus.APPNOTINSTALLED -> { - // Open store on remote device - dashboardViewModel.openPlayStore(this@DashboardActivity) - - // Navigate - startActivity( - Intent( - this@DashboardActivity, - PhoneSyncActivity::class.java - ) - ) - finishAffinity() - } - - WearConnectionStatus.CONNECTED -> {} - } - } - - WearableHelper.ActionsPath -> { - val jsonData = event.data.getString(EXTRA_ACTIONDATA) - val action = JSONParser.deserializer(jsonData, Action::class.java)!! - - dashboardViewModel.cancelTimer(action.actionType) - dashboardViewModel.updateButton(ActionButtonViewModel(action)) - - val actionStatus = action.actionStatus - - if (!action.isActionSuccessful) { - when (actionStatus) { - ActionStatus.UNKNOWN, ActionStatus.FAILURE -> { - CustomConfirmationOverlay() - .setType(CustomConfirmationOverlay.CUSTOM_ANIMATION) - .setCustomDrawable( - ContextCompat.getDrawable( - this@DashboardActivity, - R.drawable.ws_full_sad - ) - ) - .setMessage(this@DashboardActivity.getString(R.string.error_actionfailed)) - .showOn(this@DashboardActivity) - } - - ActionStatus.PERMISSION_DENIED -> { - if (action.actionType == Actions.TORCH) { - CustomConfirmationOverlay() - .setType(CustomConfirmationOverlay.CUSTOM_ANIMATION) - .setCustomDrawable( - ContextCompat.getDrawable( - this@DashboardActivity, - R.drawable.ws_full_sad - ) - ) - .setMessage(this@DashboardActivity.getString(R.string.error_torch_action)) - .showOn(this@DashboardActivity) - } else if (action.actionType == Actions.SLEEPTIMER) { - // Open store on device - val intentAndroid = Intent(Intent.ACTION_VIEW) - .addCategory(Intent.CATEGORY_BROWSABLE) - .setData(SleepTimerHelper.getPlayStoreURI()) - - if (intentAndroid.resolveActivity(packageManager) != null) { - startActivity(intentAndroid) - Toast.makeText( - this@DashboardActivity, - R.string.error_sleeptimer_notinstalled, - Toast.LENGTH_LONG - ).show() - } else { - CustomConfirmationOverlay() - .setType(CustomConfirmationOverlay.CUSTOM_ANIMATION) - .setCustomDrawable( - ContextCompat.getDrawable( - this@DashboardActivity, - R.drawable.ws_full_sad - ) - ) - .setMessage( - this@DashboardActivity.getString( - R.string.error_sleeptimer_notinstalled - ) - ) - .showOn(this@DashboardActivity) - } - } else { - CustomConfirmationOverlay() - .setType(CustomConfirmationOverlay.CUSTOM_ANIMATION) - .setCustomDrawable( - ContextCompat.getDrawable( - this@DashboardActivity, - R.drawable.ws_full_sad - ) - ) - .setMessage(this@DashboardActivity.getString(R.string.error_permissiondenied)) - .showOn(this@DashboardActivity) - } - - dashboardViewModel.openAppOnPhone(this@DashboardActivity, false) - } - - ActionStatus.TIMEOUT -> { - CustomConfirmationOverlay() - .setType(CustomConfirmationOverlay.CUSTOM_ANIMATION) - .setCustomDrawable( - ContextCompat.getDrawable( - this@DashboardActivity, - R.drawable.ws_full_sad - ) - ) - .setMessage(this@DashboardActivity.getString(R.string.error_sendmessage)) - .showOn(this@DashboardActivity) - } - - ActionStatus.REMOTE_FAILURE -> { - CustomConfirmationOverlay() - .setType(CustomConfirmationOverlay.CUSTOM_ANIMATION) - .setCustomDrawable( - ContextCompat.getDrawable( - this@DashboardActivity, - R.drawable.ws_full_sad - ) - ) - .setMessage(this@DashboardActivity.getString(R.string.error_remoteactionfailed)) - .showOn(this@DashboardActivity) - } - - ActionStatus.REMOTE_PERMISSION_DENIED -> { - CustomConfirmationOverlay() - .setType(CustomConfirmationOverlay.CUSTOM_ANIMATION) - .setCustomDrawable( - ContextCompat.getDrawable( - this@DashboardActivity, - R.drawable.ws_full_sad - ) - ) - .setMessage(this@DashboardActivity.getString(R.string.error_permissiondenied)) - .showOn(this@DashboardActivity) - } - - ActionStatus.SUCCESS -> { - } - } - } - - // Re-enable click action - dashboardViewModel.setActionsClickable(true) - } - } - } - } - - lifecycleScope.launch { - dashboardViewModel.errorMessagesFlow.collect { error -> - when (error) { - is ErrorMessage.String -> { - Toast.makeText(applicationContext, error.message, Toast.LENGTH_SHORT).show() - } - - is ErrorMessage.Resource -> { - Toast.makeText(applicationContext, error.stringId, Toast.LENGTH_SHORT) - .show() - } - } - } - } - } - - override fun onResume() { - super.onResume() - - // Update statuses - dashboardViewModel.refreshStatus() - } - - override fun onStop() { - super.onStop() - } - - override fun onDestroy() { - PreferenceManager.getDefaultSharedPreferences(this) - .unregisterOnSharedPreferenceChangeListener(this) - super.onDestroy() - } - - override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { - when (key) { - Settings.KEY_LAYOUTMODE -> { - lifecycleScope.launch { - runCatching { - withStarted { - dashboardViewModel.updateLayout(Settings.useGridLayout()) - } - } - } - } - - Settings.KEY_DASHCONFIG -> { - lifecycleScope.launch { - runCatching { - withStarted { - dashboardViewModel.resetDashboard() - } - } - } - } - - Settings.KEY_SHOWBATSTATUS -> { - lifecycleScope.launch { - runCatching { - withStarted { - dashboardViewModel.showBatteryState(Settings.isShowBatStatus()) - } - } - } - } + setContent { + SimpleWearApp( + startDestination = startDestination + ) } } } \ No newline at end of file diff --git a/wear/src/main/java/com/thewizrd/simplewear/MediaPlayerListActivity.kt b/wear/src/main/java/com/thewizrd/simplewear/MediaPlayerListActivity.kt deleted file mode 100644 index 7aa7c90..0000000 --- a/wear/src/main/java/com/thewizrd/simplewear/MediaPlayerListActivity.kt +++ /dev/null @@ -1,125 +0,0 @@ -package com.thewizrd.simplewear - -import android.annotation.SuppressLint -import android.content.Intent -import android.os.Bundle -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.activity.viewModels -import androidx.core.content.ContextCompat -import androidx.lifecycle.lifecycleScope -import com.thewizrd.shared_resources.actions.ActionStatus -import com.thewizrd.shared_resources.helpers.MediaHelper -import com.thewizrd.shared_resources.helpers.WearConnectionStatus -import com.thewizrd.simplewear.controls.CustomConfirmationOverlay -import com.thewizrd.simplewear.media.MediaPlayerActivity -import com.thewizrd.simplewear.ui.simplewear.MediaPlayerListUi -import com.thewizrd.simplewear.viewmodels.MediaPlayerListViewModel -import com.thewizrd.simplewear.viewmodels.WearableListenerViewModel.Companion.ACTION_UPDATECONNECTIONSTATUS -import com.thewizrd.simplewear.viewmodels.WearableListenerViewModel.Companion.EXTRA_CONNECTIONSTATUS -import com.thewizrd.simplewear.viewmodels.WearableListenerViewModel.Companion.EXTRA_STATUS -import kotlinx.coroutines.launch - -class MediaPlayerListActivity : ComponentActivity() { - private val mediaPlayerListViewModel by viewModels() - - @SuppressLint("UseSwitchCompatOrMaterialCode") - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - setContent { - MediaPlayerListUi() - } - - lifecycleScope.launchWhenResumed { - mediaPlayerListViewModel.autoLaunchMediaControls() - } - } - - override fun onStart() { - super.onStart() - - mediaPlayerListViewModel.initActivityContext(this) - - lifecycleScope.launch { - mediaPlayerListViewModel.eventFlow.collect { event -> - when (event.eventType) { - ACTION_UPDATECONNECTIONSTATUS -> { - val connectionStatus = WearConnectionStatus.valueOf( - event.data.getInt( - EXTRA_CONNECTIONSTATUS, - 0 - ) - ) - - when (connectionStatus) { - WearConnectionStatus.DISCONNECTED -> { - // Navigate - startActivity( - Intent( - this@MediaPlayerListActivity, - PhoneSyncActivity::class.java - ) - ) - finishAffinity() - } - - WearConnectionStatus.APPNOTINSTALLED -> { - // Open store on remote device - mediaPlayerListViewModel.openPlayStore(this@MediaPlayerListActivity) - - // Navigate - startActivity( - Intent( - this@MediaPlayerListActivity, - PhoneSyncActivity::class.java - ) - ) - finishAffinity() - } - - else -> {} - } - } - - MediaHelper.MusicPlayersPath -> { - val status = event.data.getSerializable(EXTRA_STATUS) as ActionStatus - - if (status == ActionStatus.PERMISSION_DENIED) { - CustomConfirmationOverlay() - .setType(CustomConfirmationOverlay.CUSTOM_ANIMATION) - .setCustomDrawable( - ContextCompat.getDrawable( - this@MediaPlayerListActivity, - R.drawable.ws_full_sad - ) - ) - .setMessage(getString(R.string.error_permissiondenied)) - .showOn(this@MediaPlayerListActivity) - - mediaPlayerListViewModel.openAppOnPhone( - this@MediaPlayerListActivity, - false - ) - } - } - - MediaHelper.MediaPlayerAutoLaunchPath -> { - val status = event.data.getSerializable(EXTRA_STATUS) as ActionStatus - - if (status == ActionStatus.SUCCESS) { - startActivity(MediaPlayerActivity.buildAutoLaunchIntent(this@MediaPlayerListActivity)) - } - } - } - } - } - } - - override fun onResume() { - super.onResume() - - // Update statuses - mediaPlayerListViewModel.refreshState(true) - } -} \ No newline at end of file diff --git a/wear/src/main/java/com/thewizrd/simplewear/ValueActionActivity.kt b/wear/src/main/java/com/thewizrd/simplewear/ValueActionActivity.kt deleted file mode 100644 index 6941db5..0000000 --- a/wear/src/main/java/com/thewizrd/simplewear/ValueActionActivity.kt +++ /dev/null @@ -1,239 +0,0 @@ -package com.thewizrd.simplewear - -import android.content.Intent -import android.os.Bundle -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.activity.viewModels -import androidx.core.content.ContextCompat -import androidx.lifecycle.lifecycleScope -import com.thewizrd.shared_resources.actions.Action -import com.thewizrd.shared_resources.actions.ActionStatus -import com.thewizrd.shared_resources.actions.Actions -import com.thewizrd.shared_resources.actions.AudioStreamType -import com.thewizrd.shared_resources.helpers.WearConnectionStatus -import com.thewizrd.shared_resources.helpers.WearableHelper -import com.thewizrd.shared_resources.utils.JSONParser -import com.thewizrd.simplewear.controls.CustomConfirmationOverlay -import com.thewizrd.simplewear.ui.simplewear.ValueActionScreen -import com.thewizrd.simplewear.viewmodels.ValueActionViewModel -import com.thewizrd.simplewear.viewmodels.WearableListenerViewModel -import com.thewizrd.simplewear.viewmodels.WearableListenerViewModel.Companion.ACTION_UPDATECONNECTIONSTATUS -import com.thewizrd.simplewear.viewmodels.WearableListenerViewModel.Companion.EXTRA_ACTION -import com.thewizrd.simplewear.viewmodels.WearableListenerViewModel.Companion.EXTRA_ACTIONDATA -import com.thewizrd.simplewear.viewmodels.WearableListenerViewModel.Companion.EXTRA_STATUS -import kotlinx.coroutines.launch - -class ValueActionActivity : ComponentActivity() { - companion object { - const val EXTRA_STREAMTYPE = "SimpleWear.Droid.Wear.extra.STREAM_TYPE" - } - - private val valueActionViewModel by viewModels() - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - handleIntent(intent) - - setContent { - ValueActionScreen() - } - } - - override fun onNewIntent(intent: Intent?) { - super.onNewIntent(intent) - setIntent(intent) - handleIntent(intent) - } - - private fun handleIntent(intent: Intent?) { - if (intent == null) return - - if (intent.hasExtra(EXTRA_ACTION)) { - val action = intent.getSerializableExtra(EXTRA_ACTION) as Actions - - when (action) { - Actions.VOLUME -> { /* Valid action */ - } - - Actions.BRIGHTNESS -> { /* Valid action */ - } - - else -> { - // Not a ValueAction - setResult(RESULT_CANCELED) - finish() - return - } - } - - if (action == Actions.VOLUME && intent.hasExtra(EXTRA_STREAMTYPE)) { - val streamType = intent.getSerializableExtra(EXTRA_STREAMTYPE) as? AudioStreamType - ?: AudioStreamType.MUSIC - - valueActionViewModel.onActionUpdated(action, streamType) - } else { - valueActionViewModel.onActionUpdated(action) - } - } - } - - override fun onStart() { - super.onStart() - - valueActionViewModel.initActivityContext(this) - - lifecycleScope.launch { - valueActionViewModel.eventFlow.collect { event -> - when (event.eventType) { - ACTION_UPDATECONNECTIONSTATUS -> { - val connectionStatus = WearConnectionStatus.valueOf( - event.data.getInt( - WearableListenerViewModel.EXTRA_CONNECTIONSTATUS, - 0 - ) - ) - - when (connectionStatus) { - WearConnectionStatus.DISCONNECTED -> { - // Navigate - startActivity( - Intent( - this@ValueActionActivity, - PhoneSyncActivity::class.java - ) - ) - finishAffinity() - } - - WearConnectionStatus.APPNOTINSTALLED -> { - // Open store on remote device - valueActionViewModel.openPlayStore(this@ValueActionActivity) - - // Navigate - startActivity( - Intent( - this@ValueActionActivity, - PhoneSyncActivity::class.java - ) - ) - finishAffinity() - } - - else -> {} - } - } - - WearableHelper.ActionsPath -> { - val jsonData = event.data.getString(EXTRA_ACTIONDATA) - val action = JSONParser.deserializer(jsonData, Action::class.java) - - val actionSuccessful = action?.isActionSuccessful ?: false - val actionStatus = action?.actionStatus ?: ActionStatus.UNKNOWN - - if (!actionSuccessful) { - lifecycleScope.launch { - when (actionStatus) { - ActionStatus.UNKNOWN, ActionStatus.FAILURE -> { - CustomConfirmationOverlay() - .setType(CustomConfirmationOverlay.CUSTOM_ANIMATION) - .setCustomDrawable( - ContextCompat.getDrawable( - this@ValueActionActivity, - R.drawable.ws_full_sad - ) - ) - .setMessage(getString(R.string.error_actionfailed)) - .showOn(this@ValueActionActivity) - } - - ActionStatus.PERMISSION_DENIED -> { - CustomConfirmationOverlay() - .setType(CustomConfirmationOverlay.CUSTOM_ANIMATION) - .setCustomDrawable( - ContextCompat.getDrawable( - this@ValueActionActivity, - R.drawable.ws_full_sad - ) - ) - .setMessage(getString(R.string.error_permissiondenied)) - .showOn(this@ValueActionActivity) - - valueActionViewModel.openAppOnPhone( - this@ValueActionActivity, - false - ) - } - - ActionStatus.TIMEOUT -> { - CustomConfirmationOverlay() - .setType(CustomConfirmationOverlay.CUSTOM_ANIMATION) - .setCustomDrawable( - ContextCompat.getDrawable( - this@ValueActionActivity, - R.drawable.ws_full_sad - ) - ) - .setMessage(getString(R.string.error_sendmessage)) - .showOn(this@ValueActionActivity) - } - - ActionStatus.SUCCESS -> {} - else -> {} - } - } - } - } - - WearableHelper.AudioVolumePath, WearableHelper.ValueStatusSetPath -> { - val status = event.data.getSerializable(EXTRA_STATUS) as ActionStatus - - when (status) { - ActionStatus.UNKNOWN, ActionStatus.FAILURE -> { - CustomConfirmationOverlay() - .setType(CustomConfirmationOverlay.CUSTOM_ANIMATION) - .setCustomDrawable( - ContextCompat.getDrawable( - this@ValueActionActivity, - R.drawable.ws_full_sad - ) - ) - .setMessage(getString(R.string.error_actionfailed)) - .showOn(this@ValueActionActivity) - } - - ActionStatus.PERMISSION_DENIED -> { - CustomConfirmationOverlay() - .setType(CustomConfirmationOverlay.CUSTOM_ANIMATION) - .setCustomDrawable( - ContextCompat.getDrawable( - this@ValueActionActivity, - R.drawable.ws_full_sad - ) - ) - .setMessage(getString(R.string.error_permissiondenied)) - .showOn(this@ValueActionActivity) - - valueActionViewModel.openAppOnPhone(this@ValueActionActivity, false) - } - - else -> {} - } - } - } - } - } - } - - override fun onResume() { - super.onResume() - - // Update statuses - valueActionViewModel.refreshState() - } - - override fun onDestroy() { - super.onDestroy() - } -} \ No newline at end of file diff --git a/wear/src/main/java/com/thewizrd/simplewear/WearableListenerActivity.kt b/wear/src/main/java/com/thewizrd/simplewear/WearableListenerActivity.kt deleted file mode 100644 index e1f957f..0000000 --- a/wear/src/main/java/com/thewizrd/simplewear/WearableListenerActivity.kt +++ /dev/null @@ -1,510 +0,0 @@ -package com.thewizrd.simplewear - -import android.bluetooth.BluetoothAdapter -import android.content.BroadcastReceiver -import android.content.Intent -import android.content.IntentFilter -import android.net.wifi.WifiManager -import android.os.Bundle -import android.util.Log -import android.widget.Toast -import androidx.annotation.RestrictTo -import androidx.annotation.VisibleForTesting -import androidx.lifecycle.lifecycleScope -import androidx.localbroadcastmanager.content.LocalBroadcastManager -import androidx.wear.phone.interactions.PhoneTypeHelper -import androidx.wear.remote.interactions.RemoteActivityHelper -import com.google.android.gms.common.api.ApiException -import com.google.android.gms.wearable.* -import com.google.android.gms.wearable.CapabilityClient.OnCapabilityChangedListener -import com.google.android.gms.wearable.MessageClient.OnMessageReceivedListener -import com.thewizrd.shared_resources.actions.Action -import com.thewizrd.shared_resources.actions.Actions -import com.thewizrd.shared_resources.actions.BatteryStatus -import com.thewizrd.shared_resources.actions.ToggleAction -import com.thewizrd.shared_resources.helpers.AppState -import com.thewizrd.shared_resources.helpers.WearConnectionStatus -import com.thewizrd.shared_resources.helpers.WearableHelper -import com.thewizrd.shared_resources.utils.JSONParser -import com.thewizrd.shared_resources.utils.Logger -import com.thewizrd.shared_resources.utils.bytesToString -import com.thewizrd.shared_resources.utils.stringToBytes -import com.thewizrd.simplewear.activities.AppCompatLiteActivity -import com.thewizrd.simplewear.helpers.showConfirmationOverlay -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.guava.await -import kotlinx.coroutines.launch -import kotlinx.coroutines.tasks.await - -abstract class WearableListenerActivity : AppCompatLiteActivity(), OnMessageReceivedListener, - OnCapabilityChangedListener { - companion object { - // Actions - const val ACTION_OPENONPHONE = "SimpleWear.Droid.Wear.action.OPEN_APP_ON_PHONE" - const val ACTION_SHOWSTORELISTING = "SimpleWear.Droid.Wear.action.SHOW_STORE_LISTING" - const val ACTION_UPDATECONNECTIONSTATUS = - "SimpleWear.Droid.Wear.action.UPDATE_CONNECTION_STATUS" - const val ACTION_CHANGED = "SimpleWear.Droid.Wear.action.ACTION_CHANGED" - - // Extras - /** - * Extra contains success flag for open on phone action. - * - * @see ACTION_OPENONPHONE - */ - const val EXTRA_SUCCESS = "SimpleWear.Droid.Wear.extra.SUCCESS" - - /** - * Extra contains flag for whether or not to show the animation for the open on phone action. - * - * @see ACTION_OPENONPHONE - */ - const val EXTRA_SHOWANIMATION = "SimpleWear.Droid.Wear.extra.SHOW_ANIMATION" - - /** - * Extra contains Action type to be changed for ValueActionActivity - * - * @see Actions - * - * @see ValueActionActivity - */ - const val EXTRA_ACTION = "SimpleWear.Droid.Wear.extra.ACTION" - - /** - * Extra contains Action data (serialized class in JSON) to be passed to BroadcastReceiver or Activity - * - * @see Action - * - * @see WearableListenerActivity - */ - const val EXTRA_ACTIONDATA = "SimpleWear.Droid.Wear.extra.ACTION_DATA" - - /** - * Extra contains Status data (serialized class in JSON) for complex Status types - * - * @see BatteryStatus - */ - const val EXTRA_STATUS = "SimpleWear.Droid.Wear.extra.STATUS" - - /** - * Extra contains connection status for WearOS device and connected phone - * - * @see WearConnectionStatus - * - * @see WearableListenerActivity - */ - const val EXTRA_CONNECTIONSTATUS = "SimpleWear.Droid.Wear.extra.CONNECTION_STATUS" - } - - @Volatile - protected var mPhoneNodeWithApp: Node? = null - private var mConnectionStatus = WearConnectionStatus.CONNECTING - - protected abstract val broadcastReceiver: BroadcastReceiver - protected abstract val intentFilter: IntentFilter - - protected lateinit var remoteActivityHelper: RemoteActivityHelper - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - remoteActivityHelper = RemoteActivityHelper(this) - } - - override fun onResume() { - super.onResume() - - Wearable.getCapabilityClient(this).addListener(this, WearableHelper.CAPABILITY_PHONE_APP) - Wearable.getMessageClient(this).addListener(this) - - LocalBroadcastManager.getInstance(this) - .registerReceiver(broadcastReceiver, intentFilter) - } - - override fun onPause() { - LocalBroadcastManager.getInstance(this) - .unregisterReceiver(broadcastReceiver) - Wearable.getCapabilityClient(this).removeListener(this, WearableHelper.CAPABILITY_PHONE_APP) - Wearable.getMessageClient(this).removeListener(this) - super.onPause() - } - - protected fun openAppOnPhone(showAnimation: Boolean = true) { - lifecycleScope.launch { - connect() - - if (mPhoneNodeWithApp == null) { - Toast.makeText( - this@WearableListenerActivity, - "Device is not connected or app is not installed on device...", - Toast.LENGTH_SHORT - ).show() - - when (PhoneTypeHelper.getPhoneDeviceType(this@WearableListenerActivity)) { - PhoneTypeHelper.DEVICE_TYPE_ANDROID -> { - // Open store on remote device - val intentAndroid = Intent(Intent.ACTION_VIEW) - .addCategory(Intent.CATEGORY_BROWSABLE) - .setData(WearableHelper.getPlayStoreURI()) - - runCatching { - remoteActivityHelper.startRemoteActivity(intentAndroid) - .await() - - showConfirmationOverlay(true) - }.onFailure { - if (it !is CancellationException) { - showConfirmationOverlay(false) - } - } - } - PhoneTypeHelper.DEVICE_TYPE_IOS -> { - Toast.makeText( - this@WearableListenerActivity, - "Connected device is not supported", - Toast.LENGTH_SHORT - ).show() - } - else -> { - Toast.makeText( - this@WearableListenerActivity, - "Connected device is not supported", - Toast.LENGTH_SHORT - ).show() - } - } - } else { - // Send message to device to start activity - val result = sendMessage( - mPhoneNodeWithApp!!.id, - WearableHelper.StartActivityPath, - ByteArray(0) - ) - - LocalBroadcastManager.getInstance(this@WearableListenerActivity) - .sendBroadcast( - Intent(ACTION_OPENONPHONE) - .putExtra(EXTRA_SUCCESS, result != -1) - .putExtra(EXTRA_SHOWANIMATION, showAnimation) - ) - } - } - } - - protected suspend fun startRemoteActivity(intent: Intent): Boolean { - return runCatching { - remoteActivityHelper.startRemoteActivity(intent).await() - true - }.onFailure { - Logger.writeLine(Log.ERROR, it, "Error starting remote activity") - }.getOrDefault(false) - } - - override fun onMessageReceived(messageEvent: MessageEvent) { - lifecycleScope.launch { - when { - messageEvent.path.contains(WearableHelper.WifiPath) -> { - val data = messageEvent.data - val wifiStatus = data[0].toInt() - var enabled = false - when (wifiStatus) { - WifiManager.WIFI_STATE_DISABLING, - WifiManager.WIFI_STATE_DISABLED, - WifiManager.WIFI_STATE_UNKNOWN -> { - enabled = false - } - WifiManager.WIFI_STATE_ENABLING, - WifiManager.WIFI_STATE_ENABLED -> { - enabled = true - } - } - - LocalBroadcastManager.getInstance(this@WearableListenerActivity) - .sendBroadcast( - Intent(WearableHelper.ActionsPath) - .putExtra( - EXTRA_ACTIONDATA, - JSONParser.serializer( - ToggleAction(Actions.WIFI, enabled), - Action::class.java - ) - ) - ) - } - messageEvent.path.contains(WearableHelper.BluetoothPath) -> { - val data = messageEvent.data - val bt_status = data[0].toInt() - var enabled = false - - when (bt_status) { - BluetoothAdapter.STATE_OFF, - BluetoothAdapter.STATE_TURNING_OFF -> { - enabled = false - } - BluetoothAdapter.STATE_ON, - BluetoothAdapter.STATE_TURNING_ON -> { - enabled = true - } - } - - LocalBroadcastManager.getInstance(this@WearableListenerActivity) - .sendBroadcast( - Intent(WearableHelper.ActionsPath) - .putExtra( - EXTRA_ACTIONDATA, - JSONParser.serializer( - ToggleAction(Actions.BLUETOOTH, enabled), - Action::class.java - ) - ) - ) - } - messageEvent.path == WearableHelper.BatteryPath -> { - val data = messageEvent.data - val jsonData: String = data.bytesToString() - LocalBroadcastManager.getInstance(this@WearableListenerActivity) - .sendBroadcast( - Intent(WearableHelper.BatteryPath) - .putExtra(EXTRA_STATUS, jsonData) - ) - } - messageEvent.path == WearableHelper.AppStatePath -> { - val appState: AppState = App.instance.applicationState - sendMessage( - messageEvent.sourceNodeId, - messageEvent.path, - appState.name.stringToBytes() - ) - } - messageEvent.path == WearableHelper.ActionsPath -> { - val data = messageEvent.data - val jsonData: String = data.bytesToString() - LocalBroadcastManager.getInstance(this@WearableListenerActivity) - .sendBroadcast( - Intent(WearableHelper.ActionsPath) - .putExtra(EXTRA_ACTIONDATA, jsonData) - ) - } - } - } - } - - override fun onCapabilityChanged(capabilityInfo: CapabilityInfo) { - lifecycleScope.launch { - val connectedNodes = getConnectedNodes() - mPhoneNodeWithApp = pickBestNodeId(capabilityInfo.nodes) - - if (mPhoneNodeWithApp == null) { - /* - * If a device is disconnected from the wear network, capable nodes are empty - * - * No capable nodes can mean the app is not installed on the remote device or the - * device is disconnected. - * - * Verify if we're connected to any nodes; if not, we're truly disconnected - */ - mConnectionStatus = if (connectedNodes.isNullOrEmpty()) { - WearConnectionStatus.DISCONNECTED - } else { - WearConnectionStatus.APPNOTINSTALLED - } - } else { - if (mPhoneNodeWithApp!!.isNearby && connectedNodes.any { it.id == mPhoneNodeWithApp!!.id }) { - mConnectionStatus = WearConnectionStatus.CONNECTED - } else { - try { - sendPing(mPhoneNodeWithApp!!.id) - mConnectionStatus = WearConnectionStatus.CONNECTED - } catch (e: ApiException) { - if (e.statusCode == WearableStatusCodes.TARGET_NODE_NOT_CONNECTED) { - mConnectionStatus = WearConnectionStatus.DISCONNECTED - } else { - Logger.writeLine(Log.ERROR, e) - } - } - } - } - - LocalBroadcastManager.getInstance(this@WearableListenerActivity) - .sendBroadcast(Intent(ACTION_UPDATECONNECTIONSTATUS) - .putExtra(EXTRA_CONNECTIONSTATUS, mConnectionStatus.value)) - } - } - - protected suspend fun updateConnectionStatus() { - checkConnectionStatus() - - LocalBroadcastManager.getInstance(this@WearableListenerActivity) - .sendBroadcast(Intent(ACTION_UPDATECONNECTIONSTATUS) - .putExtra(EXTRA_CONNECTIONSTATUS, mConnectionStatus.value)) - } - - protected suspend fun checkConnectionStatus() { - val connectedNodes = getConnectedNodes() - mPhoneNodeWithApp = checkIfPhoneHasApp() - - if (mPhoneNodeWithApp == null) { - /* - * If a device is disconnected from the wear network, capable nodes are empty - * - * No capable nodes can mean the app is not installed on the remote device or the - * device is disconnected. - * - * Verify if we're connected to any nodes; if not, we're truly disconnected - */ - mConnectionStatus = if (connectedNodes.isNullOrEmpty()) { - WearConnectionStatus.DISCONNECTED - } else { - WearConnectionStatus.APPNOTINSTALLED - } - } else { - if (mPhoneNodeWithApp!!.isNearby && connectedNodes.any { it.id == mPhoneNodeWithApp!!.id }) { - mConnectionStatus = WearConnectionStatus.CONNECTED - } else { - try { - sendPing(mPhoneNodeWithApp!!.id) - mConnectionStatus = WearConnectionStatus.CONNECTED - } catch (e: ApiException) { - if (e.statusCode == WearableStatusCodes.TARGET_NODE_NOT_CONNECTED) { - mConnectionStatus = WearConnectionStatus.DISCONNECTED - } else { - Logger.writeLine(Log.ERROR, e) - } - } - } - } - } - - suspend fun getConnectionStatus(): WearConnectionStatus { - checkConnectionStatus() - return mConnectionStatus - } - - protected suspend fun checkIfPhoneHasApp(): Node? { - var node: Node? = null - - try { - val capabilityInfo = Wearable.getCapabilityClient(this@WearableListenerActivity) - .getCapability( - WearableHelper.CAPABILITY_PHONE_APP, - CapabilityClient.FILTER_ALL - ) - .await() - node = pickBestNodeId(capabilityInfo.nodes) - } catch (e: Exception) { - Logger.writeLine(Log.ERROR, e) - } - - return node - } - - protected suspend fun connect(): Boolean { - if (mPhoneNodeWithApp == null) - mPhoneNodeWithApp = checkIfPhoneHasApp() - - return mPhoneNodeWithApp != null - } - - protected fun requestUpdate() { - lifecycleScope.launch { - if (connect()) { - sendMessage(mPhoneNodeWithApp!!.id, WearableHelper.UpdatePath, null) - } - } - } - - protected fun requestAction(action: Action?) { - requestAction(JSONParser.serializer(action, Action::class.java)) - } - - protected fun requestAction(actionJSONString: String?) { - lifecycleScope.launch { - if (connect()) { - sendMessage( - mPhoneNodeWithApp!!.id, - WearableHelper.ActionsPath, - actionJSONString?.stringToBytes() - ) - } - } - } - - /* - * There should only ever be one phone in a node set (much less w/ the correct capability), so - * I am just grabbing the first one (which should be the only one). - */ - protected fun pickBestNodeId(nodes: Collection): Node? { - var bestNode: Node? = null - - // Find a nearby node/phone or pick one arbitrarily. Realistically, there is only one phone. - for (node in nodes) { - if (node.isNearby) { - return node - } - bestNode = node - } - return bestNode - } - - private suspend fun getConnectedNodes(): List { - try { - return Wearable.getNodeClient(this) - .connectedNodes - .await() - } catch (e: Exception) { - Logger.writeLine(Log.ERROR, e) - } - - return emptyList() - } - - protected suspend fun sendMessage(nodeID: String, path: String, data: ByteArray?): Int? { - try { - return Wearable.getMessageClient(this@WearableListenerActivity) - .sendMessage(nodeID, path, data).await() - } catch (e: Exception) { - if (e is ApiException || e.cause is ApiException) { - val apiException = e.cause as? ApiException ?: e as? ApiException - if (apiException?.statusCode == WearableStatusCodes.TARGET_NODE_NOT_CONNECTED) { - mConnectionStatus = WearConnectionStatus.DISCONNECTED - - LocalBroadcastManager.getInstance(this@WearableListenerActivity) - .sendBroadcast( - Intent(ACTION_UPDATECONNECTIONSTATUS) - .putExtra(EXTRA_CONNECTIONSTATUS, mConnectionStatus.value) - ) - } - } - - Logger.writeLine(Log.ERROR, e) - } - - return -1 - } - - @Throws(ApiException::class) - protected suspend fun sendPing(nodeID: String) { - try { - Wearable.getMessageClient(this@WearableListenerActivity) - .sendMessage(nodeID, WearableHelper.PingPath, null).await() - } catch (e: Exception) { - if (e is ApiException || e.cause is ApiException) { - val apiException = e.cause as? ApiException ?: e as ApiException - throw apiException - } - Logger.writeLine(Log.ERROR, e) - } - } - - @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE) - @RestrictTo(RestrictTo.Scope.SUBCLASSES) - protected fun setConnectionStatus(status: WearConnectionStatus) { - mConnectionStatus = status - - LocalBroadcastManager.getInstance(this@WearableListenerActivity) - .sendBroadcast( - Intent(ACTION_UPDATECONNECTIONSTATUS) - .putExtra(EXTRA_CONNECTIONSTATUS, mConnectionStatus.value) - ) - } -} \ No newline at end of file diff --git a/wear/src/main/java/com/thewizrd/simplewear/adapters/AppItemDiffer.kt b/wear/src/main/java/com/thewizrd/simplewear/adapters/AppItemDiffer.kt deleted file mode 100644 index c6bdab7..0000000 --- a/wear/src/main/java/com/thewizrd/simplewear/adapters/AppItemDiffer.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.thewizrd.simplewear.adapters - -import androidx.core.util.ObjectsCompat -import androidx.recyclerview.widget.DiffUtil -import com.thewizrd.simplewear.controls.AppItemViewModel - -class AppItemDiffer : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: AppItemViewModel, newItem: AppItemViewModel): Boolean { - return ObjectsCompat.equals(oldItem.packageName, newItem.packageName) && - ObjectsCompat.equals(oldItem.activityName, newItem.activityName) - } - - override fun areContentsTheSame(oldItem: AppItemViewModel, newItem: AppItemViewModel): Boolean { - return ObjectsCompat.equals(oldItem, newItem) - } -} \ No newline at end of file diff --git a/wear/src/main/java/com/thewizrd/simplewear/adapters/MusicPlayerListAdapter.kt b/wear/src/main/java/com/thewizrd/simplewear/adapters/MusicPlayerListAdapter.kt deleted file mode 100644 index d94d423..0000000 --- a/wear/src/main/java/com/thewizrd/simplewear/adapters/MusicPlayerListAdapter.kt +++ /dev/null @@ -1,48 +0,0 @@ -package com.thewizrd.simplewear.adapters - -import android.view.ViewGroup -import androidx.core.graphics.drawable.toDrawable -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import com.thewizrd.shared_resources.helpers.ListAdapterOnClickInterface -import com.thewizrd.simplewear.R -import com.thewizrd.simplewear.controls.AppItemViewModel -import com.thewizrd.simplewear.controls.WearChipButton - -class MusicPlayerListAdapter : - ListAdapter(AppItemDiffer()) { - - private var onClickListener: ListAdapterOnClickInterface? = null - - fun setOnClickListener(onClickListener: ListAdapterOnClickInterface?) { - this.onClickListener = onClickListener - } - - inner class ViewHolder(var mItem: WearChipButton) : RecyclerView.ViewHolder(mItem) - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { - // create a new view - val v = WearChipButton(parent.context).apply { - layoutParams = RecyclerView.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT - ) - } - - return ViewHolder(v) - } - - // Replace the contents of a view (invoked by the layout manager) - override fun onBindViewHolder(holder: ViewHolder, position: Int) { - val viewModel = getItem(position) - if (viewModel.bitmapIcon != null) { - holder.mItem.setIconDrawable(viewModel.bitmapIcon?.toDrawable(holder.itemView.context.resources)) - } else { - holder.mItem.setIconResource(R.drawable.ic_play_circle_filled_white_24dp) - } - holder.mItem.setPrimaryText(viewModel.appLabel) - holder.mItem.setOnClickListener { v -> - onClickListener?.onClick(v, viewModel) - } - } -} \ No newline at end of file diff --git a/wear/src/main/java/com/thewizrd/simplewear/adapters/SpacerAdapter.kt b/wear/src/main/java/com/thewizrd/simplewear/adapters/SpacerAdapter.kt deleted file mode 100644 index baa0931..0000000 --- a/wear/src/main/java/com/thewizrd/simplewear/adapters/SpacerAdapter.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.thewizrd.simplewear.adapters - -import android.view.ViewGroup -import android.view.ViewGroup.LayoutParams.MATCH_PARENT -import android.widget.Space -import androidx.annotation.Px -import androidx.recyclerview.widget.RecyclerView - -class SpacerAdapter(@Px private val spacerSize: Int) : - RecyclerView.Adapter() { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - return object : RecyclerView.ViewHolder(Space(parent.context).apply { - layoutParams = RecyclerView.LayoutParams( - MATCH_PARENT, spacerSize - ) - }) {} - } - - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - // no-op - } - - override fun getItemCount(): Int { - return 1 - } -} \ No newline at end of file diff --git a/wear/src/main/java/com/thewizrd/simplewear/controls/ActionButtonViewModel.kt b/wear/src/main/java/com/thewizrd/simplewear/controls/ActionButtonViewModel.kt index 6dbbd9d..d2f35cf 100644 --- a/wear/src/main/java/com/thewizrd/simplewear/controls/ActionButtonViewModel.kt +++ b/wear/src/main/java/com/thewizrd/simplewear/controls/ActionButtonViewModel.kt @@ -1,10 +1,9 @@ package com.thewizrd.simplewear.controls -import android.app.Activity -import android.content.Intent import android.os.Build import androidx.annotation.DrawableRes import androidx.annotation.StringRes +import androidx.navigation.NavController import androidx.recyclerview.widget.DiffUtil import com.thewizrd.shared_resources.actions.Action import com.thewizrd.shared_resources.actions.ActionStatus @@ -18,12 +17,8 @@ import com.thewizrd.shared_resources.actions.ToggleAction import com.thewizrd.shared_resources.actions.ValueAction import com.thewizrd.shared_resources.actions.ValueDirection import com.thewizrd.shared_resources.sleeptimer.SleepTimerHelper -import com.thewizrd.simplewear.AppLauncherActivity -import com.thewizrd.simplewear.CallManagerActivity -import com.thewizrd.simplewear.MediaPlayerListActivity import com.thewizrd.simplewear.R -import com.thewizrd.simplewear.ValueActionActivity -import com.thewizrd.simplewear.WearableListenerActivity.Companion.EXTRA_ACTION +import com.thewizrd.simplewear.ui.navigation.Screen import java.util.Objects class ActionButtonViewModel(val action: Action) { @@ -137,19 +132,16 @@ class ActionButtonViewModel(val action: Action) { } fun onClick( - activityContext: Activity, + navController: NavController, onActionChanged: (Action) -> Unit, onActionStatus: (Action) -> Unit ) { action.isActionSuccessful = true if (action is ValueAction) { - val intent: Intent = Intent(activityContext, ValueActionActivity::class.java) - .putExtra(EXTRA_ACTION, actionType) - activityContext.startActivityForResult(intent, -1) + navController.navigate("${Screen.ValueAction.route}/${actionType.value}") } else if (action is NormalAction && action.actionType == Actions.MUSICPLAYBACK) { - val intent = Intent(activityContext, MediaPlayerListActivity::class.java) - activityContext.startActivityForResult(intent, -1) + navController.navigate(Screen.MediaPlayerList.route) } else if (action is NormalAction && action.actionType == Actions.SLEEPTIMER) { if (SleepTimerHelper.isSleepTimerInstalled()) { SleepTimerHelper.launchSleepTimer() @@ -159,11 +151,9 @@ class ActionButtonViewModel(val action: Action) { onActionStatus.invoke(action) } } else if (action is NormalAction && action.actionType == Actions.APPS) { - val intent = Intent(activityContext, AppLauncherActivity::class.java) - activityContext.startActivityForResult(intent, -1) + navController.navigate(Screen.AppLauncher.route) } else if (action is NormalAction && action.actionType == Actions.PHONE) { - val intent = Intent(activityContext, CallManagerActivity::class.java) - activityContext.startActivityForResult(intent, -1) + navController.navigate(Screen.CallManager.route) } else { if (action is ToggleAction) { val tA = action diff --git a/wear/src/main/java/com/thewizrd/simplewear/controls/CheckableImageButton.java b/wear/src/main/java/com/thewizrd/simplewear/controls/CheckableImageButton.java deleted file mode 100644 index a894ba0..0000000 --- a/wear/src/main/java/com/thewizrd/simplewear/controls/CheckableImageButton.java +++ /dev/null @@ -1,238 +0,0 @@ -/* - * Copyright (C) 2016 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 - * - * http://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.thewizrd.simplewear.controls; - -import android.content.Context; -import android.os.Parcel; -import android.os.Parcelable; -import android.util.AttributeSet; -import android.view.View; -import android.view.accessibility.AccessibilityEvent; -import android.widget.Checkable; - -import androidx.annotation.NonNull; -import androidx.appcompat.widget.AppCompatImageButton; -import androidx.core.view.AccessibilityDelegateCompat; -import androidx.core.view.ViewCompat; -import androidx.core.view.accessibility.AccessibilityEventCompat; -import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; -import androidx.customview.view.AbsSavedState; - -public class CheckableImageButton extends AppCompatImageButton implements Checkable { - - private static final int[] DRAWABLE_STATE_CHECKED = new int[]{android.R.attr.state_checked}; - - private boolean checked; - private boolean checkable = true; - private boolean pressable = true; - - private OnCheckedChangeListener mOnCheckedChangeListener; - - public CheckableImageButton(Context context) { - this(context, null); - } - - public CheckableImageButton(Context context, AttributeSet attrs) { - this(context, attrs, androidx.appcompat.R.attr.imageButtonStyle); - } - - public CheckableImageButton(Context context, AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - - ViewCompat.setAccessibilityDelegate( - this, - new AccessibilityDelegateCompat() { - @Override - public void onInitializeAccessibilityEvent(View host, @NonNull AccessibilityEvent event) { - super.onInitializeAccessibilityEvent(host, event); - event.setChecked(isChecked()); - } - - @Override - public void onInitializeAccessibilityNodeInfo( - View host, @NonNull AccessibilityNodeInfoCompat info) { - super.onInitializeAccessibilityNodeInfo(host, info); - info.setCheckable(isCheckable()); - info.setChecked(isChecked()); - } - }); - } - - @Override - public void setChecked(boolean checked) { - setChecked(checked, true); - } - - public void setChecked(boolean checked, boolean raiseEvent) { - if (checkable && this.checked != checked) { - this.checked = checked; - refreshDrawableState(); - if (raiseEvent && mOnCheckedChangeListener != null) { - mOnCheckedChangeListener.onCheckedChanged(this, this.checked); - } - sendAccessibilityEvent(AccessibilityEventCompat.TYPE_WINDOW_CONTENT_CHANGED); - } - } - - @Override - public boolean isChecked() { - return checked; - } - - @Override - public void toggle() { - setChecked(!checked); - } - - @Override - public void setPressed(boolean pressed) { - if (pressable) { - super.setPressed(pressed); - } - } - - /** - * Register a callback to be invoked when the checked state of this button - * changes. - * - * @param listener the callback to call on checked state change - */ - public void setOnCheckedChangeListener(@androidx.annotation.Nullable OnCheckedChangeListener listener) { - mOnCheckedChangeListener = listener; - } - - @Override - public int[] onCreateDrawableState(int extraSpace) { - if (checked) { - return mergeDrawableStates( - super.onCreateDrawableState(extraSpace + DRAWABLE_STATE_CHECKED.length), - DRAWABLE_STATE_CHECKED); - } else { - return super.onCreateDrawableState(extraSpace); - } - } - - @NonNull - @Override - protected Parcelable onSaveInstanceState() { - Parcelable superState = super.onSaveInstanceState(); - CheckableImageButton.SavedState savedState = new CheckableImageButton.SavedState(superState); - savedState.checked = checked; - return savedState; - } - - @Override - protected void onRestoreInstanceState(Parcelable state) { - if (!(state instanceof CheckableImageButton.SavedState)) { - super.onRestoreInstanceState(state); - return; - } - CheckableImageButton.SavedState savedState = (CheckableImageButton.SavedState) state; - super.onRestoreInstanceState(savedState.getSuperState()); - setChecked(savedState.checked); - } - - /** - * Sets image button to be checkable or not. - */ - public void setCheckable(boolean checkable) { - if (this.checkable != checkable) { - this.checkable = checkable; - sendAccessibilityEvent(AccessibilityEventCompat.CONTENT_CHANGE_TYPE_UNDEFINED); - } - } - - /** - * Returns whether the image button is checkable. - */ - public boolean isCheckable() { - return checkable; - } - - /** - * Sets image button to be pressable or not. - */ - public void setPressable(boolean pressable) { - this.pressable = pressable; - } - - /** - * Returns whether the image button is pressable. - */ - public boolean isPressable() { - return pressable; - } - - static class SavedState extends AbsSavedState { - - boolean checked; - - public SavedState(Parcelable superState) { - super(superState); - } - - public SavedState(@NonNull Parcel source, ClassLoader loader) { - super(source, loader); - readFromParcel(source); - } - - @Override - public void writeToParcel(@NonNull Parcel out, int flags) { - super.writeToParcel(out, flags); - out.writeInt(checked ? 1 : 0); - } - - private void readFromParcel(@NonNull Parcel in) { - checked = in.readInt() == 1; - } - - public static final Creator CREATOR = - new ClassLoaderCreator() { - @NonNull - @Override - public CheckableImageButton.SavedState createFromParcel(@NonNull Parcel in, ClassLoader loader) { - return new CheckableImageButton.SavedState(in, loader); - } - - @NonNull - @Override - public CheckableImageButton.SavedState createFromParcel(@NonNull Parcel in) { - return new CheckableImageButton.SavedState(in, null); - } - - @NonNull - @Override - public CheckableImageButton.SavedState[] newArray(int size) { - return new CheckableImageButton.SavedState[size]; - } - }; - } - - /** - * Interface definition for a callback to be invoked when the checked state - * of a compound button changed. - */ - public static interface OnCheckedChangeListener { - /** - * Called when the checked state of a compound button has changed. - * - * @param buttonView The compound button view whose state has changed. - * @param isChecked The new checked state of buttonView. - */ - void onCheckedChanged(CheckableImageButton buttonView, boolean isChecked); - } -} diff --git a/wear/src/main/java/com/thewizrd/simplewear/controls/PageIndicatorView2.java b/wear/src/main/java/com/thewizrd/simplewear/controls/PageIndicatorView2.java deleted file mode 100644 index 6fb3f6c..0000000 --- a/wear/src/main/java/com/thewizrd/simplewear/controls/PageIndicatorView2.java +++ /dev/null @@ -1,665 +0,0 @@ -/* - * Copyright (C) 2017 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 - * - * http://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.thewizrd.simplewear.controls; - -import static androidx.viewpager2.widget.ViewPager2.SCROLL_STATE_DRAGGING; -import static androidx.viewpager2.widget.ViewPager2.SCROLL_STATE_IDLE; - -import android.animation.Animator; -import android.content.Context; -import android.content.res.TypedArray; -import android.graphics.Canvas; -import android.graphics.Color; -import android.graphics.Paint; -import android.graphics.Paint.Style; -import android.graphics.RadialGradient; -import android.graphics.Shader; -import android.graphics.Shader.TileMode; -import android.util.AttributeSet; -import android.view.View; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.recyclerview.widget.RecyclerView; -import androidx.viewpager2.widget.ViewPager2; -import androidx.wear.R; -import androidx.wear.widget.SimpleAnimatorListener; - -import java.lang.ref.WeakReference; -import java.util.concurrent.TimeUnit; - -/** - * A page indicator for {@link androidx.viewpager2.widget.ViewPager2} based on {@link - * androidx.wear.widget.drawer.PageIndicatorView} and {@link - * com.google.android.material.tabs.TabLayoutMediator} - * - *

Use {@link #setPager(androidx.viewpager2.widget.ViewPager2)} to connect this view to a pager instance. - */ -public class PageIndicatorView2 extends View { - - private static final String TAG = "Dots"; - private final Paint mDotPaint; - private final Paint mDotPaintShadow; - private final Paint mDotPaintSelected; - private final Paint mDotPaintShadowSelected; - private int mDotSpacing; - private float mDotRadius; - private float mDotRadiusSelected; - private int mDotColor; - private int mDotColorSelected; - private boolean mDotFadeWhenIdle; - int mDotFadeOutDelay; - int mDotFadeOutDuration; - private int mDotFadeInDuration; - private float mDotShadowDx; - private float mDotShadowDy; - private float mDotShadowRadius; - private int mDotShadowColor; - @Nullable - private RecyclerView.Adapter mAdapter; - private int mNumberOfPositions; - private int mSelectedPosition; - private int mCurrentViewPagerState; - boolean mVisible; - - public PageIndicatorView2(Context context) { - this(context, null); - } - - public PageIndicatorView2(Context context, AttributeSet attrs) { - this(context, attrs, 0); - } - - public PageIndicatorView2(Context context, AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - - final TypedArray a = - getContext() - .obtainStyledAttributes( - attrs, R.styleable.PageIndicatorView, defStyleAttr, - R.style.WsPageIndicatorViewStyle); - - mDotSpacing = a.getDimensionPixelOffset( - R.styleable.PageIndicatorView_wsPageIndicatorDotSpacing, 0); - mDotRadius = a.getDimension(R.styleable.PageIndicatorView_wsPageIndicatorDotRadius, 0); - mDotRadiusSelected = - a.getDimension(R.styleable.PageIndicatorView_wsPageIndicatorDotRadiusSelected, 0); - mDotColor = a.getColor(R.styleable.PageIndicatorView_wsPageIndicatorDotColor, 0); - mDotColorSelected = a - .getColor(R.styleable.PageIndicatorView_wsPageIndicatorDotColorSelected, 0); - mDotFadeOutDelay = - a.getInt(R.styleable.PageIndicatorView_wsPageIndicatorDotFadeOutDelay, 0); - mDotFadeOutDuration = - a.getInt(R.styleable.PageIndicatorView_wsPageIndicatorDotFadeOutDuration, 0); - mDotFadeInDuration = - a.getInt(R.styleable.PageIndicatorView_wsPageIndicatorDotFadeInDuration, 0); - mDotFadeWhenIdle = - a.getBoolean(R.styleable.PageIndicatorView_wsPageIndicatorDotFadeWhenIdle, false); - mDotShadowDx = a.getDimension(R.styleable.PageIndicatorView_wsPageIndicatorDotShadowDx, 0); - mDotShadowDy = a.getDimension(R.styleable.PageIndicatorView_wsPageIndicatorDotShadowDy, 0); - mDotShadowRadius = - a.getDimension(R.styleable.PageIndicatorView_wsPageIndicatorDotShadowRadius, 0); - mDotShadowColor = - a.getColor(R.styleable.PageIndicatorView_wsPageIndicatorDotShadowColor, 0); - a.recycle(); - - mDotPaint = new Paint(Paint.ANTI_ALIAS_FLAG); - mDotPaint.setColor(mDotColor); - mDotPaint.setStyle(Style.FILL); - - mDotPaintSelected = new Paint(Paint.ANTI_ALIAS_FLAG); - mDotPaintSelected.setColor(mDotColorSelected); - mDotPaintSelected.setStyle(Style.FILL); - mDotPaintShadow = new Paint(Paint.ANTI_ALIAS_FLAG); - mDotPaintShadowSelected = new Paint(Paint.ANTI_ALIAS_FLAG); - - mCurrentViewPagerState = SCROLL_STATE_IDLE; - if (isInEditMode()) { - // When displayed in layout preview: - // Simulate 5 positions, currently on the 3rd position. - mNumberOfPositions = 5; - mSelectedPosition = 2; - mDotFadeWhenIdle = false; - } - - if (mDotFadeWhenIdle) { - mVisible = false; - animate().alpha(0f).setStartDelay(2000).setDuration(mDotFadeOutDuration).start(); - } else { - animate().cancel(); - setAlpha(1.0f); - } - updateShadows(); - } - - private void updateShadows() { - updateDotPaint( - mDotPaint, mDotPaintShadow, mDotRadius, mDotShadowRadius, mDotColor, - mDotShadowColor); - updateDotPaint( - mDotPaintSelected, - mDotPaintShadowSelected, - mDotRadiusSelected, - mDotShadowRadius, - mDotColorSelected, - mDotShadowColor); - } - - private void updateDotPaint( - Paint dotPaint, - Paint shadowPaint, - float baseRadius, - float shadowRadius, - int color, - int shadowColor) { - float radius = baseRadius + shadowRadius; - float shadowStart = baseRadius / radius; - Shader gradient = - new RadialGradient( - 0, - 0, - radius, - new int[]{shadowColor, shadowColor, Color.TRANSPARENT}, - new float[]{0f, shadowStart, 1f}, - TileMode.CLAMP); - - shadowPaint.setShader(gradient); - dotPaint.setColor(color); - dotPaint.setStyle(Style.FILL); - } - - /** - * Supplies the ViewPager2 instance, and attaches this view to the pager. - * - * @param pager the pager for the page indicator - */ - public void setPager(ViewPager2 pager) { - mAdapter = pager.getAdapter(); - if (mAdapter == null) { - throw new IllegalStateException( - "PageIndicatorView attached before ViewPager2 has an " + "adapter"); - } - - // Add our custom OnPageChangeCallback to the ViewPager - pager.registerOnPageChangeCallback(new PageIndicatorOnPageChangeCallback(this)); - mAdapter.registerAdapterDataObserver(new PagerAdapterObserver()); - - updateNumberOfPositions(); - - // Now update the scroll position to match the ViewPager's current item - positionChanged(pager.getCurrentItem()); - } - - /** - * Gets the center-to-center distance between page dots. - * - * @return the distance between page dots - */ - public float getDotSpacing() { - return mDotSpacing; - } - - /** - * Sets the center-to-center distance between page dots. - * - * @param spacing the distance between page dots - */ - public void setDotSpacing(int spacing) { - if (mDotSpacing != spacing) { - mDotSpacing = spacing; - requestLayout(); - } - } - - /** - * Gets the radius of the page dots. - * - * @return the radius of the page dots - */ - public float getDotRadius() { - return mDotRadius; - } - - /** - * Sets the radius of the page dots. - * - * @param radius the radius of the page dots - */ - public void setDotRadius(int radius) { - if (mDotRadius != radius) { - mDotRadius = radius; - updateShadows(); - invalidate(); - } - } - - /** - * Gets the radius of the page dot for the selected page. - * - * @return the radius of the selected page dot - */ - public float getDotRadiusSelected() { - return mDotRadiusSelected; - } - - /** - * Sets the radius of the page dot for the selected page. - * - * @param radius the radius of the selected page dot - */ - public void setDotRadiusSelected(int radius) { - if (mDotRadiusSelected != radius) { - mDotRadiusSelected = radius; - updateShadows(); - invalidate(); - } - } - - /** - * Returns the color used for dots other than the selected page. - * - * @return color the color used for dots other than the selected page - */ - public int getDotColor() { - return mDotColor; - } - - /** - * Sets the color used for dots other than the selected page. - * - * @param color the color used for dots other than the selected page - */ - public void setDotColor(int color) { - if (mDotColor != color) { - mDotColor = color; - invalidate(); - } - } - - /** - * Returns the color of the dot for the selected page. - * - * @return the color used for the selected page dot - */ - public int getDotColorSelected() { - return mDotColorSelected; - } - - /** - * Sets the color of the dot for the selected page. - * - * @param color the color of the dot for the selected page - */ - public void setDotColorSelected(int color) { - if (mDotColorSelected != color) { - mDotColorSelected = color; - invalidate(); - } - } - - /** - * Indicates if the dots fade out when the pager is idle. - * - * @return whether the dots fade out when idle - */ - public boolean getDotFadeWhenIdle() { - return mDotFadeWhenIdle; - } - - /** - * Sets whether the dots fade out when the pager is idle. - * - * @param fade whether the dots fade out when idle - */ - public void setDotFadeWhenIdle(boolean fade) { - mDotFadeWhenIdle = fade; - if (!fade) { - fadeIn(); - } - } - - /** - * Returns the duration of fade out animation, in milliseconds. - * - * @return the duration of the fade out animation, in milliseconds - */ - public int getDotFadeOutDuration() { - return mDotFadeOutDuration; - } - - /** - * Sets the duration of the fade out animation. - * - * @param duration the duration of the fade out animation - */ - public void setDotFadeOutDuration(int duration, TimeUnit unit) { - mDotFadeOutDuration = (int) TimeUnit.MILLISECONDS.convert(duration, unit); - } - - /** - * Returns the duration of the fade in duration, in milliseconds. - * - * @return the duration of the fade in duration, in milliseconds - */ - public int getDotFadeInDuration() { - return mDotFadeInDuration; - } - - /** - * Sets the duration of the fade in animation. - * - * @param duration the duration of the fade in animation - */ - public void setDotFadeInDuration(int duration, TimeUnit unit) { - mDotFadeInDuration = (int) TimeUnit.MILLISECONDS.convert(duration, unit); - } - - /** - * Sets the delay between the pager arriving at an idle state, and the fade out animation - * beginning, in milliseconds. - * - * @return the delay before the fade out animation begins, in milliseconds - */ - public int getDotFadeOutDelay() { - return mDotFadeOutDelay; - } - - /** - * Sets the delay between the pager arriving at an idle state, and the fade out animation - * beginning, in milliseconds. - * - * @param delay the delay before the fade out animation begins, in milliseconds - */ - public void setDotFadeOutDelay(int delay) { - mDotFadeOutDelay = delay; - } - - /** - * Sets the pixel radius of shadows drawn beneath the dots. - * - * @return the pixel radius of shadows rendered beneath the dots - */ - public float getDotShadowRadius() { - return mDotShadowRadius; - } - - /** - * Sets the pixel radius of shadows drawn beneath the dots. - * - * @param radius the pixel radius of shadows rendered beneath the dots - */ - public void setDotShadowRadius(float radius) { - if (mDotShadowRadius != radius) { - mDotShadowRadius = radius; - updateShadows(); - invalidate(); - } - } - - /** - * Returns the horizontal offset of shadows drawn beneath the dots. - * - * @return the horizontal offset of shadows drawn beneath the dots - */ - public float getDotShadowDx() { - return mDotShadowDx; - } - - /** - * Sets the horizontal offset of shadows drawn beneath the dots. - * - * @param dx the horizontal offset of shadows drawn beneath the dots - */ - public void setDotShadowDx(float dx) { - mDotShadowDx = dx; - invalidate(); - } - - /** - * Returns the vertical offset of shadows drawn beneath the dots. - * - * @return the vertical offset of shadows drawn beneath the dots - */ - public float getDotShadowDy() { - return mDotShadowDy; - } - - /** - * Sets the vertical offset of shadows drawn beneath the dots. - * - * @param dy the vertical offset of shadows drawn beneath the dots - */ - public void setDotShadowDy(float dy) { - mDotShadowDy = dy; - invalidate(); - } - - /** - * Returns the color of the shadows drawn beneath the dots. - * - * @return the color of the shadows drawn beneath the dots - */ - public int getDotShadowColor() { - return mDotShadowColor; - } - - /** - * Sets the color of the shadows drawn beneath the dots. - * - * @param color the color of the shadows drawn beneath the dots - */ - public void setDotShadowColor(int color) { - mDotShadowColor = color; - updateShadows(); - invalidate(); - } - - private void positionChanged(int position) { - mSelectedPosition = position; - invalidate(); - } - - private void updateNumberOfPositions() { - if (mAdapter != null) { - int count = mAdapter.getItemCount(); - if (count != mNumberOfPositions) { - mNumberOfPositions = count; - requestLayout(); - } - } else { - mNumberOfPositions = 0; - requestLayout(); - } - } - - private void fadeIn() { - mVisible = true; - animate().cancel(); - animate().alpha(1f).setStartDelay(0).setDuration(mDotFadeInDuration).start(); - } - - private void fadeOut(long delayMillis) { - mVisible = false; - animate().cancel(); - animate().alpha(0f).setStartDelay(delayMillis).setDuration(mDotFadeOutDuration).start(); - } - - private void fadeInOut() { - mVisible = true; - animate().cancel(); - animate() - .alpha(1f) - .setStartDelay(0) - .setDuration(mDotFadeInDuration) - .setListener( - new SimpleAnimatorListener() { - @Override - public void onAnimationComplete(Animator animator) { - mVisible = false; - animate() - .alpha(0f) - .setListener(null) - .setStartDelay(mDotFadeOutDelay) - .setDuration(mDotFadeOutDuration) - .start(); - } - }) - .start(); - } - - @Override - protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { - int totalWidth; - if (MeasureSpec.getMode(widthMeasureSpec) == MeasureSpec.EXACTLY) { - totalWidth = MeasureSpec.getSize(widthMeasureSpec); - } else { - int contentWidth = mNumberOfPositions * mDotSpacing; - totalWidth = contentWidth + getPaddingLeft() + getPaddingRight(); - } - int totalHeight; - if (MeasureSpec.getMode(heightMeasureSpec) == MeasureSpec.EXACTLY) { - totalHeight = MeasureSpec.getSize(heightMeasureSpec); - } else { - float maxRadius = - Math.max(mDotRadius + mDotShadowRadius, mDotRadiusSelected + mDotShadowRadius); - int contentHeight = (int) Math.ceil(maxRadius * 2); - contentHeight = (int) (contentHeight + mDotShadowDy); - totalHeight = contentHeight + getPaddingTop() + getPaddingBottom(); - } - setMeasuredDimension( - resolveSizeAndState(totalWidth, widthMeasureSpec, 0), - resolveSizeAndState(totalHeight, heightMeasureSpec, 0)); - } - - @Override - protected void onDraw(Canvas canvas) { - super.onDraw(canvas); - - if (mNumberOfPositions > 1) { - float dotCenterLeft = getPaddingLeft() + (mDotSpacing / 2f); - float dotCenterTop = getHeight() / 2f; - canvas.save(); - canvas.translate(dotCenterLeft, dotCenterTop); - for (int i = 0; i < mNumberOfPositions; i++) { - if (i == mSelectedPosition) { - float radius = mDotRadiusSelected + mDotShadowRadius; - canvas.drawCircle(mDotShadowDx, mDotShadowDy, radius, mDotPaintShadowSelected); - canvas.drawCircle(0, 0, mDotRadiusSelected, mDotPaintSelected); - } else { - float radius = mDotRadius + mDotShadowRadius; - canvas.drawCircle(mDotShadowDx, mDotShadowDy, radius, mDotPaintShadow); - canvas.drawCircle(0, 0, mDotRadius, mDotPaint); - } - canvas.translate(mDotSpacing, 0); - } - canvas.restore(); - } - } - - private static class PageIndicatorOnPageChangeCallback extends ViewPager2.OnPageChangeCallback { - @NonNull - private final WeakReference pageIndicatorRef; - - PageIndicatorOnPageChangeCallback(PageIndicatorView2 pageIndicator) { - pageIndicatorRef = new WeakReference<>(pageIndicator); - } - - @Override - public void onPageScrollStateChanged(final int state) { - PageIndicatorView2 pageIndicator = pageIndicatorRef.get(); - if (pageIndicator != null) { - if (pageIndicator.mCurrentViewPagerState != state) { - pageIndicator.mCurrentViewPagerState = state; - if (pageIndicator.mDotFadeWhenIdle) { - if (state == SCROLL_STATE_IDLE) { - if (pageIndicator.mVisible) { - pageIndicator.fadeOut(pageIndicator.mDotFadeOutDelay); - } else { - pageIndicator.fadeInOut(); - } - } - } - } - } - } - - @Override - public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { - PageIndicatorView2 pageIndicator = pageIndicatorRef.get(); - if (pageIndicator != null) { - if (pageIndicator.mDotFadeWhenIdle) { - if (pageIndicator.mCurrentViewPagerState == SCROLL_STATE_DRAGGING) { - if (positionOffset != 0) { - if (!pageIndicator.mVisible) { - pageIndicator.fadeIn(); - } - } else { - if (pageIndicator.mVisible) { - pageIndicator.fadeOut(0); - } - } - } - } - } - } - - @Override - public void onPageSelected(final int position) { - PageIndicatorView2 pageIndicator = pageIndicatorRef.get(); - if (pageIndicator != null) { - if (position != pageIndicator.mSelectedPosition) { - pageIndicator.positionChanged(position); - } - } - } - } - - private class PagerAdapterObserver extends RecyclerView.AdapterDataObserver { - PagerAdapterObserver() { - } - - @Override - public void onChanged() { - updateNumberOfPositions(); - } - - @Override - public void onItemRangeChanged(int positionStart, int itemCount) { - updateNumberOfPositions(); - } - - @Override - public void onItemRangeChanged(int positionStart, int itemCount, @Nullable Object payload) { - updateNumberOfPositions(); - } - - @Override - public void onItemRangeInserted(int positionStart, int itemCount) { - updateNumberOfPositions(); - } - - @Override - public void onItemRangeRemoved(int positionStart, int itemCount) { - updateNumberOfPositions(); - } - - @Override - public void onItemRangeMoved(int fromPosition, int toPosition, int itemCount) { - updateNumberOfPositions(); - } - } -} diff --git a/wear/src/main/java/com/thewizrd/simplewear/controls/timetext/TextViewWrapper.kt b/wear/src/main/java/com/thewizrd/simplewear/controls/timetext/TextViewWrapper.kt deleted file mode 100644 index db6b191..0000000 --- a/wear/src/main/java/com/thewizrd/simplewear/controls/timetext/TextViewWrapper.kt +++ /dev/null @@ -1,78 +0,0 @@ -/* - * Copyright 2021 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.thewizrd.simplewear.controls.timetext - -import android.view.View -import android.widget.TextView -import androidx.wear.widget.CurvedTextView - -/** - * A wrapper around a [TextView] like object, that may not actually extend [TextView] (like a [CurvedTextView]). - */ -interface TextViewWrapper { - val view: View - var text: CharSequence? - var textColor: Int - var paintAntiAlias: Boolean -} - -/** - * A [TextViewWrapper] wrapping a [CurvedTextView]. - */ -class CurvedTextViewWrapper( - override val view: CurvedTextView -) : TextViewWrapper { - override var text: CharSequence? - get() = view.text - set(value) { - view.text = value?.toString().orEmpty() - } - - override var textColor: Int - get() = view.textColor - set(value) { - view.textColor = value - } - - override var paintAntiAlias: Boolean - get() = false - set(value) {} -} - -/** - * A [TextViewWrapper] wrapping a [TextView]. - */ -class NormalTextViewWrapper( - override val view: TextView -) : TextViewWrapper { - override var text: CharSequence? - get() = view.text - set(value) { - view.text = value - } - - override var textColor: Int - get() = view.currentTextColor - set(value) { - view.setTextColor(value) - } - - override var paintAntiAlias: Boolean - get() = view.paint.isAntiAlias - set(value) { - view.paint.isAntiAlias = value - } -} \ No newline at end of file diff --git a/wear/src/main/java/com/thewizrd/simplewear/controls/timetext/TimeText.kt b/wear/src/main/java/com/thewizrd/simplewear/controls/timetext/TimeText.kt deleted file mode 100644 index f4f0cf5..0000000 --- a/wear/src/main/java/com/thewizrd/simplewear/controls/timetext/TimeText.kt +++ /dev/null @@ -1,315 +0,0 @@ -/* - * Copyright 2021 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.thewizrd.simplewear.controls.timetext - -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.database.ContentObserver -import android.graphics.Color -import android.provider.Settings -import android.text.format.DateFormat -import android.util.AttributeSet -import android.view.LayoutInflater -import android.widget.FrameLayout -import androidx.annotation.AttrRes -import androidx.annotation.StyleRes -import androidx.annotation.VisibleForTesting -import androidx.core.content.res.use -import androidx.core.os.ConfigurationCompat -import androidx.core.view.isGone -import com.thewizrd.simplewear.R -import com.thewizrd.simplewear.controls.timetext.TimeText.Clock -import com.thewizrd.simplewear.controls.timetext.TimeTextViewBinding.TimeTextCurvedViewBinding -import com.thewizrd.simplewear.controls.timetext.TimeTextViewBinding.TimeTextStraightViewBinding -import com.thewizrd.simplewear.databinding.CurvedTimeTextBinding -import com.thewizrd.simplewear.databinding.StraightTimeTextBinding -import java.util.* - -/** - * The max sweep angle for the [TimeText] to occupy. - */ -private const val MAX_SWEEP_ANGLE = 90f - -class TimeText @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - @AttrRes defStyleAttr: Int = 0, - @StyleRes defStyleRes: Int = 0 -) : FrameLayout(context, attrs, defStyleAttr, defStyleRes) { - - /** - * The underlying [Calendar] instance for producing the time. - * - * This will be updated in [onTimeZoneChange] in response to any timezone updates. - */ - private var time = Calendar.getInstance() - - /** - * True if we should format the time in the 24 hour manner. - * - * This will be updated in [onTimeFormatChange] in response to any format updates. - */ - private var use24HourFormat = DateFormat.is24HourFormat(context) - - /** - * An [IntentFilter] for any time related broadcast. - */ - private val timeBroadcastReceiverFilter = IntentFilter().apply { - addAction(Intent.ACTION_TIME_TICK) - addAction(Intent.ACTION_TIME_CHANGED) - addAction(Intent.ACTION_TIMEZONE_CHANGED) - } - - private val timeBroadcastReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - when (intent.action) { - Intent.ACTION_TIMEZONE_CHANGED -> onTimeZoneChange() - Intent.ACTION_TIME_TICK, Intent.ACTION_TIME_CHANGED -> onTimeChange() - } - } - } - - /** - * The wrapped view binding for the inflated views. - */ - private val timeTextViewBinding: TimeTextViewBinding - - /** - * A non-clock portion of the time text to display. - */ - var title: CharSequence? = null - set(value) { - field = value - - timeTextViewBinding.timeTextTitle.text = title - - // Only show the title and divider if the title is non-empty - val hideTitle = title.isNullOrEmpty() - timeTextViewBinding.timeTextTitle.view.isGone = hideTitle - timeTextViewBinding.timeTextDivider.view.isGone = hideTitle - } - - /** - * The color of the non-clock portion of the time text. - */ - var titleTextColor: Int = Color.WHITE - set(value) { - field = value - - timeTextViewBinding.timeTextTitle.textColor = titleTextColor - } - - /** - * The backing [Clock] used to drive the time. - * - * Overridable for testing. - */ - @VisibleForTesting - var clock: Clock = Clock(System::currentTimeMillis) - set(value) { - field = value - onTimeChange() - } - - fun enterLowBitAmbientMode() { - timeTextViewBinding.timeTextClock.paintAntiAlias = false - timeTextViewBinding.timeTextDivider.paintAntiAlias = false - timeTextViewBinding.timeTextTitle.paintAntiAlias = false - } - - fun exitLowBitAmbientMode() { - timeTextViewBinding.timeTextClock.paintAntiAlias = true - timeTextViewBinding.timeTextDivider.paintAntiAlias = true - timeTextViewBinding.timeTextTitle.paintAntiAlias = true - } - - /** - * The [ContentObserver] listening for a time format change. - * - * This is constructed lazily, since [getHandler] needs the view to be attached. - */ - private val timeContentObserver by lazy(LazyThreadSafetyMode.NONE) { - object : ContentObserver(handler) { - override fun onChange(selfChange: Boolean) { - super.onChange(selfChange) - onTimeFormatChange() - } - } - } - - init { - val layoutInflater = LayoutInflater.from(context) - - // Create the view structure based on whether the screen is round. - // This will inflate one of two distinct layouts, which we abstract away in a TimeTextViewBinding - timeTextViewBinding = if (resources.configuration.isScreenRound) { - TimeTextCurvedViewBinding(CurvedTimeTextBinding.inflate(layoutInflater, this, true)) - } else { - TimeTextStraightViewBinding(StraightTimeTextBinding.inflate(layoutInflater, this, true)) - } - - // Set the divider text - timeTextViewBinding.timeTextDivider.text = "·" - - // Update based on the styled attributes. - // Note that this runs the side-effects of setting those attributes. - context.obtainStyledAttributes(attrs, R.styleable.TimeText, defStyleAttr, defStyleRes) - .use { typedArray -> - titleTextColor = - typedArray.getColor(R.styleable.TimeText_android_titleTextColor, titleTextColor) - title = typedArray.getString(R.styleable.TimeText_titleText) - } - } - - /** - * Restrict the total sweep angle on round screens to [MAX_SWEEP_ANGLE]. - * - * We accomplish this with two measure passes: - * - * After the first, we measure to get the angle that the clock and divider occupy (together, these shouldn't ever - * be more than [MAX_SWEEP_ANGLE]. - * - * Then, we update the title's max sweep angle to the remaining angle, and measure again to apply the limit. - */ - override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { - when (timeTextViewBinding) { - is TimeTextCurvedViewBinding -> { - // Reset the title sweep to ensure we get a true initial measurement - timeTextViewBinding.timeTextTitle.view.setSweepRangeDegrees(0f, MAX_SWEEP_ANGLE) - super.onMeasure(widthMeasureSpec, heightMeasureSpec) - - val clockSweepAngle = - timeTextViewBinding.timeTextClock.view.sweepAngleDegrees.coerceAtLeast(0f) - - // Avoid getting the divider sweep angle if it is gone, since it won't be accurate - val dividerSweepAngle = if (timeTextViewBinding.timeTextDivider.view.isGone) { - 0f - } else { - timeTextViewBinding.timeTextDivider.view.sweepAngleDegrees.coerceAtLeast(0f) - } - - val maxTitleSweepAngle = MAX_SWEEP_ANGLE - clockSweepAngle - dividerSweepAngle - - // Update the title max sweep angle to effectively get a total max sweep of MAX_SWEEP_ANGLE - timeTextViewBinding.timeTextTitle.view.setSweepRangeDegrees(0f, maxTitleSweepAngle) - - // Measure again, with the updated max sweep - super.onMeasure(widthMeasureSpec, heightMeasureSpec) - } - is TimeTextStraightViewBinding -> { - // Need to do nothing special the for the straight view, just call through to super - super.onMeasure(widthMeasureSpec, heightMeasureSpec) - } - } - } - - override fun onAttachedToWindow() { - super.onAttachedToWindow() - - onTimeZoneChange() - onTimeFormatChange() - onTimeChange() - - context.contentResolver.registerContentObserver( - Settings.System.getUriFor(Settings.System.TIME_12_24), - true, - timeContentObserver - ) - context.registerReceiver(timeBroadcastReceiver, timeBroadcastReceiverFilter) - } - - override fun onDetachedFromWindow() { - super.onDetachedFromWindow() - - context.contentResolver.unregisterContentObserver(timeContentObserver) - context.unregisterReceiver(timeBroadcastReceiver) - } - - private fun onTimeChange() { - val pattern = DateFormat.getBestDateTimePattern( - ConfigurationCompat.getLocales(resources.configuration)[0], - if (use24HourFormat) "Hm" else "hm" - ) - // Remove the am/pm indicator (if any). This is locale safe. - val patternWithoutAmPm = pattern.replace("a", "").trim() - - time.timeInMillis = clock.getCurrentTimeMillis() - timeTextViewBinding.timeTextClock.text = DateFormat.format(patternWithoutAmPm, time) - } - - private fun onTimeZoneChange() { - time = Calendar.getInstance() - onTimeChange() - } - - private fun onTimeFormatChange() { - use24HourFormat = DateFormat.is24HourFormat(context) - onTimeChange() - } - - /** - * A provider of the current time. - */ - fun interface Clock { - - /** - * Returns the current time in milliseconds since the epoch. - */ - fun getCurrentTimeMillis(): Long - } -} - -/** - * An abstraction around the view binding, since we inflate two different layouts depending on the shape of the screen. - */ -private sealed class TimeTextViewBinding { - - abstract val timeTextTitle: TextViewWrapper - - abstract val timeTextDivider: TextViewWrapper - - abstract val timeTextClock: TextViewWrapper - - /** - * The [TimeTextViewBinding] wrapping the [CurvedTimeTextBinding]. - */ - class TimeTextCurvedViewBinding( - timeTextBinding: CurvedTimeTextBinding - ) : TimeTextViewBinding() { - override val timeTextTitle: CurvedTextViewWrapper = - CurvedTextViewWrapper(timeTextBinding.timeTextTitle) - override val timeTextDivider: CurvedTextViewWrapper = - CurvedTextViewWrapper(timeTextBinding.timeTextDivider) - override val timeTextClock: CurvedTextViewWrapper = - CurvedTextViewWrapper(timeTextBinding.timeTextClock) - } - - /** - * The [TimeTextViewBinding] wrapping the [StraightTimeTextBinding]. - */ - class TimeTextStraightViewBinding( - timeTextBinding: StraightTimeTextBinding - ) : TimeTextViewBinding() { - override val timeTextTitle: NormalTextViewWrapper = - NormalTextViewWrapper(timeTextBinding.timeTextTitle) - override val timeTextDivider: NormalTextViewWrapper = - NormalTextViewWrapper(timeTextBinding.timeTextDivider) - override val timeTextClock: NormalTextViewWrapper = - NormalTextViewWrapper(timeTextBinding.timeTextClock) - } -} \ No newline at end of file diff --git a/wear/src/main/java/com/thewizrd/simplewear/fragments/SwipeDismissFragment.kt b/wear/src/main/java/com/thewizrd/simplewear/fragments/SwipeDismissFragment.kt deleted file mode 100644 index 84e2d06..0000000 --- a/wear/src/main/java/com/thewizrd/simplewear/fragments/SwipeDismissFragment.kt +++ /dev/null @@ -1,37 +0,0 @@ -package com.thewizrd.simplewear.fragments - -import android.annotation.SuppressLint -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.annotation.CallSuper -import androidx.fragment.app.Fragment -import androidx.wear.widget.SwipeDismissFrameLayout -import com.thewizrd.simplewear.databinding.SwipeDismissLayoutBinding - -open class SwipeDismissFragment : Fragment() { - private lateinit var binding: SwipeDismissLayoutBinding - private lateinit var swipeCallback: SwipeDismissFrameLayout.Callback - - @CallSuper - @SuppressLint("RestrictedApi") - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { - binding = SwipeDismissLayoutBinding.inflate(inflater, container, false) - - binding.swipeLayout.isSwipeable = true - swipeCallback = object : SwipeDismissFrameLayout.Callback() { - override fun onDismissed(layout: SwipeDismissFrameLayout) { - activity?.onBackPressedDispatcher?.onBackPressed() - } - } - binding.swipeLayout.addCallback(swipeCallback) - - return binding.swipeLayout - } - - override fun onDestroyView() { - binding.swipeLayout.removeCallback(swipeCallback) - super.onDestroyView() - } -} \ No newline at end of file diff --git a/wear/src/main/java/com/thewizrd/simplewear/helpers/CustomScrollingLayoutCallback.kt b/wear/src/main/java/com/thewizrd/simplewear/helpers/CustomScrollingLayoutCallback.kt deleted file mode 100644 index eacfa7b..0000000 --- a/wear/src/main/java/com/thewizrd/simplewear/helpers/CustomScrollingLayoutCallback.kt +++ /dev/null @@ -1,75 +0,0 @@ -package com.thewizrd.simplewear.helpers - -import android.view.View -import android.view.ViewGroup -import android.view.animation.PathInterpolator -import androidx.recyclerview.widget.RecyclerView -import androidx.wear.widget.WearableLinearLayoutManager -import kotlin.math.min - -// Based on ScalingLazyColumn (wear-compose) -class CustomScrollingLayoutCallback : WearableLinearLayoutManager.LayoutCallback() { - companion object { - private const val edgeScale = 0.85f /* original: 0.5f */ - private const val edgeAlpha = 0.5f /* original: 0.5f */ - private const val minElementHeight = 0.5f /* original: 0.2f */ - private const val maxElementHeight = 0.8f /* original: 0.8f */ - private const val minTransitionArea = 0.35f /* original: 0.2f */ - private const val maxTransitionArea = 0.75f /* original: 0.6f */ - } - - private val scaleInterpolator = PathInterpolator(0.25f, 0f, 0.75f, 1f) - - override fun onLayoutFinished(child: View, parent: RecyclerView) { - child.apply { - val container = parent.parent as ViewGroup - val viewPortStartPx = 0 - val viewPortEndPx = container.height - val viewportPortHeight = (viewPortEndPx - viewPortStartPx).toFloat() - val itemHeight = height.toFloat() - val itemEdgeAsFractionOfViewport = - min(bottom - viewPortStartPx, viewPortEndPx - top) / viewportPortHeight - - val heightAsFractionOfViewPort = itemHeight / viewportPortHeight - if (itemEdgeAsFractionOfViewport > 0.0f && itemEdgeAsFractionOfViewport < 1.0f) { - // Work out the scaling line based on size, this is a value between 0.0..1.0 - val sizeRatio: Float = - ( - (heightAsFractionOfViewPort - minElementHeight) / - (maxElementHeight - minElementHeight) - ).coerceIn(0f, 1f) - - val scalingLineAsFractionOfViewPort = - minTransitionArea + - (maxTransitionArea - minTransitionArea) * - sizeRatio - - if (itemEdgeAsFractionOfViewport < scalingLineAsFractionOfViewPort) { - // We are scaling - val fractionOfDiffToApplyRaw = - (scalingLineAsFractionOfViewPort - itemEdgeAsFractionOfViewport) / - scalingLineAsFractionOfViewPort - val fractionOfDiffToApplyInterpolated = - scaleInterpolator.getInterpolation(fractionOfDiffToApplyRaw) - - val scaleToApply = - edgeScale + - (1.0f - edgeScale) * - (1.0f - fractionOfDiffToApplyInterpolated) - val alphaToApply = - edgeAlpha + - (1.0f - edgeAlpha) * - (1.0f - fractionOfDiffToApplyInterpolated) - - scaleX = scaleToApply - scaleY = scaleToApply - alpha = alphaToApply - } else { - scaleX = 1f - scaleY = 1f - alpha = 1f - } - } - } - } -} \ No newline at end of file diff --git a/wear/src/main/java/com/thewizrd/simplewear/helpers/SimpleRecyclerViewAdapterObserver.java b/wear/src/main/java/com/thewizrd/simplewear/helpers/SimpleRecyclerViewAdapterObserver.java deleted file mode 100644 index a9d55dc..0000000 --- a/wear/src/main/java/com/thewizrd/simplewear/helpers/SimpleRecyclerViewAdapterObserver.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.thewizrd.simplewear.helpers; - -import androidx.annotation.Nullable; -import androidx.recyclerview.widget.RecyclerView; - -public class SimpleRecyclerViewAdapterObserver extends RecyclerView.AdapterDataObserver { - @Override - public void onChanged() { - } - - @Override - public void onItemRangeChanged(int positionStart, int itemCount) { - onChanged(); - } - - @Override - public void onItemRangeChanged(int positionStart, int itemCount, @Nullable Object payload) { - onChanged(); - } - - @Override - public void onItemRangeInserted(int positionStart, int itemCount) { - onChanged(); - } - - @Override - public void onItemRangeRemoved(int positionStart, int itemCount) { - onChanged(); - } - - @Override - public void onItemRangeMoved(int fromPosition, int toPosition, int itemCount) { - onChanged(); - } -} \ No newline at end of file diff --git a/wear/src/main/java/com/thewizrd/simplewear/media/MediaPlayerActivity.kt b/wear/src/main/java/com/thewizrd/simplewear/media/MediaPlayerActivity.kt index 3f6bdb8..134c81f 100644 --- a/wear/src/main/java/com/thewizrd/simplewear/media/MediaPlayerActivity.kt +++ b/wear/src/main/java/com/thewizrd/simplewear/media/MediaPlayerActivity.kt @@ -5,22 +5,11 @@ import android.content.Intent import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent -import androidx.activity.viewModels -import androidx.core.content.ContextCompat -import androidx.lifecycle.lifecycleScope -import com.thewizrd.shared_resources.actions.ActionStatus -import com.thewizrd.shared_resources.helpers.MediaHelper -import com.thewizrd.shared_resources.helpers.WearConnectionStatus +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import com.thewizrd.shared_resources.utils.JSONParser -import com.thewizrd.simplewear.PhoneSyncActivity -import com.thewizrd.simplewear.R import com.thewizrd.simplewear.controls.AppItemViewModel -import com.thewizrd.simplewear.controls.CustomConfirmationOverlay -import com.thewizrd.simplewear.ui.simplewear.MediaPlayerUi -import com.thewizrd.simplewear.viewmodels.WearableListenerViewModel -import com.thewizrd.simplewear.viewmodels.WearableListenerViewModel.Companion.ACTION_UPDATECONNECTIONSTATUS -import com.thewizrd.simplewear.viewmodels.WearableListenerViewModel.Companion.EXTRA_STATUS -import kotlinx.coroutines.launch +import com.thewizrd.simplewear.ui.navigation.Screen +import com.thewizrd.simplewear.ui.simplewear.MediaPlayer class MediaPlayerActivity : ComponentActivity() { companion object { @@ -43,139 +32,28 @@ class MediaPlayerActivity : ComponentActivity() { } } - private val mediaPlayerViewModel by viewModels() - - private var isAutoLaunch = false - override fun onCreate(savedInstanceState: Bundle?) { + installSplashScreen() super.onCreate(savedInstanceState) - handleIntent(intent) + var startDestination = Screen.MediaPlayerList.route - setContent { - MediaPlayerUi() + if (intent?.extras?.getBoolean(KEY_AUTOLAUNCH) == true) { + startDestination = Screen.MediaPlayer.autoLaunch() } - } - - override fun onStart() { - super.onStart() - - mediaPlayerViewModel.initActivityContext(this) - - lifecycleScope.launch { - mediaPlayerViewModel.eventFlow.collect { event -> - when (event.eventType) { - ACTION_UPDATECONNECTIONSTATUS -> { - val connectionStatus = WearConnectionStatus.valueOf( - event.data.getInt( - WearableListenerViewModel.EXTRA_CONNECTIONSTATUS, - 0 - ) - ) - - when (connectionStatus) { - WearConnectionStatus.DISCONNECTED -> { - // Navigate - startActivity( - Intent( - this@MediaPlayerActivity, - PhoneSyncActivity::class.java - ) - ) - finishAffinity() - } - WearConnectionStatus.APPNOTINSTALLED -> { - // Open store on remote device - mediaPlayerViewModel.openPlayStore(this@MediaPlayerActivity) + intent?.extras?.getString(KEY_APPDETAILS)?.let { + val model = JSONParser.deserializer(it, AppItemViewModel::class.java) - // Navigate - startActivity( - Intent( - this@MediaPlayerActivity, - PhoneSyncActivity::class.java - ) - ) - finishAffinity() - } - - else -> {} - } - } - - MediaHelper.MediaPlayerConnectPath, - MediaHelper.MediaPlayerAutoLaunchPath -> { - val actionStatus = event.data.getSerializable(EXTRA_STATUS) as ActionStatus - - if (actionStatus == ActionStatus.PERMISSION_DENIED) { - CustomConfirmationOverlay() - .setType(CustomConfirmationOverlay.CUSTOM_ANIMATION) - .setCustomDrawable( - ContextCompat.getDrawable( - this@MediaPlayerActivity, - R.drawable.ws_full_sad - ) - ) - .setMessage(getString(R.string.error_permissiondenied)) - .showOn(this@MediaPlayerActivity) - - mediaPlayerViewModel.openAppOnPhone(this@MediaPlayerActivity, false) - } - } - - MediaHelper.MediaPlayPath -> { - val actionStatus = event.data.getSerializable(EXTRA_STATUS) as ActionStatus - - if (actionStatus == ActionStatus.TIMEOUT) { - CustomConfirmationOverlay() - .setType(CustomConfirmationOverlay.CUSTOM_ANIMATION) - .setCustomDrawable(R.drawable.ws_full_sad) - .setMessage(R.string.error_playback_failed) - .showOn(this@MediaPlayerActivity) - } - } - } + if (model != null && !model.key.isNullOrBlank()) { + startDestination = Screen.MediaPlayer.getRoute(model) } } - } - - override fun onResume() { - super.onResume() - // Update statuses - mediaPlayerViewModel.refreshStatus() - } - - override fun onPause() { - mediaPlayerViewModel.requestPlayerDisconnect() - super.onPause() - } - - override fun onNewIntent(intent: Intent?) { - super.onNewIntent(intent) - setIntent(intent) - - handleIntent(intent) - } - - private fun handleIntent(intent: Intent?) { - if (intent == null) return - - if (intent.extras?.getBoolean(KEY_AUTOLAUNCH) == true) { - isAutoLaunch = true - return - } - - val model = intent.extras?.getString(KEY_APPDETAILS)?.let { - JSONParser.deserializer(it, AppItemViewModel::class.java) - } - - if (model != null) { - mediaPlayerViewModel.updateMediaPlayerDetails(model) + setContent { + MediaPlayer( + startDestination = startDestination + ) } } - - override fun onDestroy() { - super.onDestroy() - } } \ No newline at end of file diff --git a/wear/src/main/java/com/thewizrd/simplewear/media/MediaPlayerViewModel.kt b/wear/src/main/java/com/thewizrd/simplewear/media/MediaPlayerViewModel.kt index 7a31486..f198300 100644 --- a/wear/src/main/java/com/thewizrd/simplewear/media/MediaPlayerViewModel.kt +++ b/wear/src/main/java/com/thewizrd/simplewear/media/MediaPlayerViewModel.kt @@ -1,8 +1,6 @@ package com.thewizrd.simplewear.media -import android.app.Activity import android.app.Application -import android.content.Intent import android.graphics.Bitmap import android.os.Bundle import android.util.Log @@ -18,9 +16,7 @@ import com.google.android.gms.wearable.Wearable import com.google.android.horologist.annotations.ExperimentalHorologistApi import com.google.android.horologist.media.model.PlaybackStateEvent import com.thewizrd.shared_resources.actions.ActionStatus -import com.thewizrd.shared_resources.actions.Actions import com.thewizrd.shared_resources.actions.AudioStreamState -import com.thewizrd.shared_resources.actions.AudioStreamType import com.thewizrd.shared_resources.helpers.MediaHelper import com.thewizrd.shared_resources.helpers.WearConnectionStatus import com.thewizrd.shared_resources.helpers.WearableHelper @@ -33,8 +29,6 @@ import com.thewizrd.shared_resources.utils.booleanToBytes import com.thewizrd.shared_resources.utils.bytesToString import com.thewizrd.shared_resources.utils.intToBytes import com.thewizrd.shared_resources.utils.stringToBytes -import com.thewizrd.simplewear.ValueActionActivity -import com.thewizrd.simplewear.WearableListenerActivity import com.thewizrd.simplewear.controls.AppItemViewModel import com.thewizrd.simplewear.viewmodels.WearableEvent import com.thewizrd.simplewear.viewmodels.WearableListenerViewModel @@ -202,8 +196,7 @@ class MediaPlayerViewModel(app: Application) : WearableListenerViewModel(app), } ACTION_CHANGED -> { - val jsonData = - event.data.getString(WearableListenerActivity.EXTRA_ACTIONDATA) + val jsonData = event.data.getString(EXTRA_ACTIONDATA) requestAction(jsonData) } } @@ -374,10 +367,24 @@ class MediaPlayerViewModel(app: Application) : WearableListenerViewModel(app), } } + fun autoLaunch() { + viewModelState.update { + it.copy( + mediaPlayerDetails = AppItemViewModel(), + isAutoLaunch = false + ) + } + requestPlayerConnect() + } + fun updateMediaPlayerDetails(player: AppItemViewModel) { viewModelState.update { - it.copy(mediaPlayerDetails = player) + it.copy( + mediaPlayerDetails = player, + isAutoLaunch = false + ) } + requestPlayerConnect() } private fun updatePager() { @@ -608,13 +615,6 @@ class MediaPlayerViewModel(app: Application) : WearableListenerViewModel(app), } } - fun showCallVolumeActivity(activityContext: Activity) { - val intent: Intent = Intent(activityContext, ValueActionActivity::class.java) - .putExtra(EXTRA_ACTION, Actions.VOLUME) - .putExtra(ValueActionActivity.EXTRA_STREAMTYPE, AudioStreamType.MUSIC) - activityContext.startActivityForResult(intent, -1) - } - // Custom Controls fun requestCustomMediaActionItem(itemId: String) { requestMediaAction(MediaHelper.MediaActionsClickPath, itemId.stringToBytes()) diff --git a/wear/src/main/java/com/thewizrd/simplewear/ui/components/SwipeToClosePagerScreen.kt b/wear/src/main/java/com/thewizrd/simplewear/ui/components/SwipeToClosePagerScreen.kt new file mode 100644 index 0000000..0c06fa4 --- /dev/null +++ b/wear/src/main/java/com/thewizrd/simplewear/ui/components/SwipeToClosePagerScreen.kt @@ -0,0 +1,169 @@ +package com.thewizrd.simplewear.ui.components + +import androidx.compose.animation.core.tween +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerDefaults +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalViewConfiguration +import androidx.compose.ui.platform.ViewConfiguration +import androidx.compose.ui.semantics.ScrollAxisRange +import androidx.compose.ui.semantics.horizontalScrollAxisRange +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import androidx.wear.compose.foundation.ExperimentalWearFoundationApi +import androidx.wear.compose.foundation.HierarchicalFocusCoordinator +import com.google.android.horologist.compose.layout.PagerScaffold +import kotlinx.coroutines.coroutineScope + +// https://slack-chats.kotlinlang.org/t/16230979/problem-changing-basicswipetodismiss-background-color-gt +@OptIn(ExperimentalFoundationApi::class, ExperimentalWearFoundationApi::class) +@Composable +fun SwipeToClosePagerScreen( + modifier: Modifier = Modifier, + state: PagerState, + isLoading: Boolean = false, + timeText: (@Composable () -> Unit)? = null, + content: @Composable (Int) -> Unit +) { + val screenWidth = with(LocalDensity.current) { + LocalConfiguration.current.screenWidthDp.dp.toPx() + } + var allowPaging by remember { mutableStateOf(true) } + + val originalTouchSlop = LocalViewConfiguration.current.touchSlop + + CustomTouchSlopProvider(newTouchSlop = originalTouchSlop * 2) { + PagerScaffold( + modifier = Modifier + .fillMaxSize() + .pointerInput(screenWidth) { + coroutineScope { + awaitEachGesture { + allowPaging = true + val firstDown = + awaitFirstDown(false, PointerEventPass.Initial) + val xPosition = firstDown.position.x + // Define edge zone of 15% + allowPaging = xPosition > screenWidth * 0.15f + } + } + } + .semantics { + horizontalScrollAxisRange = if (allowPaging) { + ScrollAxisRange(value = { state.currentPage.toFloat() }, + maxValue = { 3f }) + } else { + // signals system swipe to dismiss that they can take over + ScrollAxisRange(value = { 0f }, + maxValue = { 0f }) + } + }, + timeText = timeText, + pagerState = if (isLoading) null else state + ) { + HorizontalPager( + modifier = modifier, + state = state, + flingBehavior = + PagerDefaults.flingBehavior( + state, + snapAnimationSpec = tween(10, 0), + ), + userScrollEnabled = allowPaging + ) { page -> + ClippedBox(state) { + HierarchicalFocusCoordinator(requiresFocus = { page == state.currentPage }) { + content(page) + } + } + } + } + } +} + +//MARK: - Horologist code + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun ClippedBox(pagerState: PagerState, content: @Composable () -> Unit) { + val shape = rememberClipWhenScrolling(pagerState) + Box( + modifier = Modifier + .fillMaxSize() + .optionalClip(shape), + ) { + content() + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun rememberClipWhenScrolling(state: PagerState): State { + val shape = if (LocalConfiguration.current.isScreenRound) CircleShape else null + return remember(state) { + derivedStateOf { + if (shape != null && state.currentPageOffsetFraction != 0f) { + shape + } else { + null + } + } + } +} + +private fun Modifier.optionalClip(shapeState: State): Modifier { + val shape = shapeState.value + + return if (shape != null) { + clip(shape) + } else { + this + } +} + + +// MARK: - Steve Bower code + +@Composable +private fun CustomTouchSlopProvider( + newTouchSlop: Float, + content: @Composable () -> Unit +) { + CompositionLocalProvider( + LocalViewConfiguration provides CustomTouchSlop( + newTouchSlop, + LocalViewConfiguration.current + ) + ) { + content() + } +} + +private class CustomTouchSlop( + private val customTouchSlop: Float, + currentConfiguration: ViewConfiguration +) : ViewConfiguration by currentConfiguration { + override val touchSlop: Float + get() = customTouchSlop +} \ No newline at end of file diff --git a/wear/src/main/java/com/thewizrd/simplewear/ui/navigation/Screen.kt b/wear/src/main/java/com/thewizrd/simplewear/ui/navigation/Screen.kt new file mode 100644 index 0000000..0a6e9aa --- /dev/null +++ b/wear/src/main/java/com/thewizrd/simplewear/ui/navigation/Screen.kt @@ -0,0 +1,39 @@ +package com.thewizrd.simplewear.ui.navigation + +import com.thewizrd.shared_resources.actions.Actions +import com.thewizrd.shared_resources.actions.AudioStreamType +import com.thewizrd.shared_resources.utils.JSONParser +import com.thewizrd.simplewear.controls.AppItemViewModel + +sealed class Screen( + val route: String +) { + data object Dashboard : Screen("dashboard") + data object AppLauncher : Screen("appLauncher") + data object CallManager : Screen("callManager") + data object ValueAction : Screen("valueAction") { + fun getRoute(actionType: Actions, streamType: AudioStreamType? = null): String { + return if (streamType != null) { + "${route}/${actionType.value}?streamType=${streamType}" + } else { + "${route}/${actionType.value}" + } + + } + } + + data object MediaPlayerList : Screen("mediaPlayerList") + data object MediaPlayer : Screen("mediaPlayer") { + fun autoLaunch(): String { + return "$route?autoLaunch=true" + } + + fun getRoute(app: String): String { + return "$route?app=$app" + } + + fun getRoute(model: AppItemViewModel): String { + return "$route?app=${JSONParser.serializer(model, AppItemViewModel::class.java)}" + } + } +} \ No newline at end of file diff --git a/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/AppLauncherScreen.kt b/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/AppLauncherScreen.kt index 8023b6d..d7b381d 100644 --- a/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/AppLauncherScreen.kt +++ b/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/AppLauncherScreen.kt @@ -1,7 +1,7 @@ package com.thewizrd.simplewear.ui.simplewear +import android.content.Intent import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize @@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -20,14 +21,18 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.core.graphics.drawable.toBitmap +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.viewmodel.compose.viewModel import androidx.wear.compose.foundation.ExperimentalWearFoundationApi import androidx.wear.compose.foundation.HierarchicalFocusCoordinator +import androidx.wear.compose.foundation.SwipeToDismissBoxState import androidx.wear.compose.foundation.edgeSwipeToDismiss import androidx.wear.compose.foundation.lazy.items import androidx.wear.compose.foundation.rememberSwipeToDismissBoxState @@ -35,9 +40,7 @@ import androidx.wear.compose.material.Chip import androidx.wear.compose.material.ChipDefaults import androidx.wear.compose.material.CompactChip import androidx.wear.compose.material.Icon -import androidx.wear.compose.material.MaterialTheme import androidx.wear.compose.material.PositionIndicator -import androidx.wear.compose.material.SwipeToDismissBox import androidx.wear.compose.material.Switch import androidx.wear.compose.material.Text import androidx.wear.compose.material.TimeText @@ -53,15 +56,19 @@ import com.google.android.horologist.compose.layout.scrollAway import com.google.android.horologist.compose.material.ListHeaderDefaults.firstItemPadding import com.google.android.horologist.compose.material.ResponsiveListHeader import com.google.android.horologist.compose.pager.HorizontalPagerDefaults +import com.thewizrd.shared_resources.actions.ActionStatus import com.thewizrd.shared_resources.helpers.WearConnectionStatus +import com.thewizrd.shared_resources.helpers.WearableHelper +import com.thewizrd.simplewear.PhoneSyncActivity import com.thewizrd.simplewear.R import com.thewizrd.simplewear.controls.AppItemViewModel +import com.thewizrd.simplewear.controls.CustomConfirmationOverlay import com.thewizrd.simplewear.ui.components.LoadingContent -import com.thewizrd.simplewear.ui.theme.WearAppTheme -import com.thewizrd.simplewear.ui.theme.activityViewModel import com.thewizrd.simplewear.ui.theme.findActivity import com.thewizrd.simplewear.viewmodels.AppLauncherUiState import com.thewizrd.simplewear.viewmodels.AppLauncherViewModel +import com.thewizrd.simplewear.viewmodels.WearableListenerViewModel +import kotlinx.coroutines.launch @OptIn( ExperimentalFoundationApi::class, @@ -70,12 +77,14 @@ import com.thewizrd.simplewear.viewmodels.AppLauncherViewModel ) @Composable fun AppLauncherScreen( - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + swipeToDismissBoxState: SwipeToDismissBoxState = rememberSwipeToDismissBoxState() ) { val context = LocalContext.current val activity = context.findActivity() + val lifecycleOwner = LocalLifecycleOwner.current - val appLauncherViewModel = activityViewModel() + val appLauncherViewModel = viewModel() val uiState by appLauncherViewModel.uiState.collectAsState() val scrollState = rememberResponsiveColumnState( @@ -84,59 +93,140 @@ fun AppLauncherScreen( last = ScalingLazyColumnDefaults.ItemType.Chip, ) ) - val swipeToDismissBoxState = rememberSwipeToDismissBoxState() val pagerState = rememberPagerState( initialPage = 0, pageCount = { 2 } ) - WearAppTheme { - PagerScaffold( - modifier = Modifier.fillMaxSize(), - timeText = { - if (pagerState.currentPage == 0) { - TimeText(modifier = Modifier.scrollAway { scrollState }) - } - }, - pagerState = if (uiState.isLoading) null else pagerState - ) { - SwipeToDismissBox( - modifier = Modifier.background(MaterialTheme.colors.background), - onDismissed = { - activity.onBackPressed() - }, - state = swipeToDismissBoxState - ) { isBackground -> - if (isBackground) { - Box( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colors.background) + PagerScaffold( + modifier = Modifier + .fillMaxSize() + .edgeSwipeToDismiss(swipeToDismissBoxState), + timeText = { + if (pagerState.currentPage == 0) { + TimeText(modifier = Modifier.scrollAway { scrollState }) + } + }, + pagerState = if (uiState.isLoading) null else pagerState + ) { + HorizontalPager( + state = pagerState, + flingBehavior = HorizontalPagerDefaults.flingParams(pagerState) + ) { pageIdx -> + HierarchicalFocusCoordinator(requiresFocus = { pageIdx == pagerState.currentPage }) { + if (pageIdx == 0) { + AppLauncherScreen( + appLauncherViewModel = appLauncherViewModel, + scrollState = scrollState ) } else { - HorizontalPager( - modifier = modifier.edgeSwipeToDismiss(swipeToDismissBoxState), - state = pagerState, - flingBehavior = HorizontalPagerDefaults.flingParams(pagerState) - ) { pageIdx -> - HierarchicalFocusCoordinator(requiresFocus = { pageIdx == pagerState.currentPage }) { - if (pageIdx == 0) { - AppLauncherScreen( - appLauncherViewModel = appLauncherViewModel, - scrollState = scrollState + AppLauncherSettings( + appLauncherViewModel = appLauncherViewModel + ) + } + } + } + } + + LaunchedEffect(context) { + appLauncherViewModel.initActivityContext(activity) + } + + LaunchedEffect(lifecycleOwner) { + lifecycleOwner.lifecycleScope.launch { + appLauncherViewModel.eventFlow.collect { event -> + when (event.eventType) { + WearableListenerViewModel.ACTION_UPDATECONNECTIONSTATUS -> { + val connectionStatus = WearConnectionStatus.valueOf( + event.data.getInt( + WearableListenerViewModel.EXTRA_CONNECTIONSTATUS, + 0 + ) + ) + + when (connectionStatus) { + WearConnectionStatus.DISCONNECTED -> { + // Navigate + activity.startActivity( + Intent( + activity, + PhoneSyncActivity::class.java + ) ) - } else { - AppLauncherSettings( - appLauncherViewModel = appLauncherViewModel + activity.finishAffinity() + } + + WearConnectionStatus.APPNOTINSTALLED -> { + // Open store on remote device + appLauncherViewModel.openPlayStore(activity) + + // Navigate + activity.startActivity( + Intent( + activity, + PhoneSyncActivity::class.java + ) ) + activity.finishAffinity() + } + + else -> { } } } + + WearableHelper.LaunchAppPath -> { + val status = + event.data.getSerializable(WearableListenerViewModel.EXTRA_STATUS) as ActionStatus + + when (status) { + ActionStatus.SUCCESS -> { + CustomConfirmationOverlay() + .setType(CustomConfirmationOverlay.SUCCESS_ANIMATION) + .showOn(activity) + } + + ActionStatus.PERMISSION_DENIED -> { + CustomConfirmationOverlay() + .setType(CustomConfirmationOverlay.CUSTOM_ANIMATION) + .setCustomDrawable( + ContextCompat.getDrawable( + activity, + R.drawable.ws_full_sad + ) + ) + .setMessage(activity.getString(R.string.error_permissiondenied)) + .showOn(activity) + + appLauncherViewModel.openAppOnPhone(activity, false) + } + + ActionStatus.FAILURE -> { + CustomConfirmationOverlay() + .setType(CustomConfirmationOverlay.CUSTOM_ANIMATION) + .setCustomDrawable( + ContextCompat.getDrawable( + activity, + R.drawable.ws_full_sad + ) + ) + .setMessage(activity.getString(R.string.error_actionfailed)) + .showOn(activity) + } + + else -> {} + } + } } } } } + + LaunchedEffect(Unit) { + // Update statuses + appLauncherViewModel.refreshApps(true) + } } @OptIn(ExperimentalHorologistApi::class) diff --git a/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/CallManagerUi.kt b/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/CallManagerUi.kt index f1912d1..94d56a5 100644 --- a/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/CallManagerUi.kt +++ b/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/CallManagerUi.kt @@ -1,5 +1,6 @@ package com.thewizrd.simplewear.ui.simplewear +import android.content.Intent import android.graphics.Bitmap import androidx.annotation.DrawableRes import androidx.compose.foundation.ExperimentalFoundationApi @@ -26,6 +27,7 @@ import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.material.ripple.rememberRipple import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -41,6 +43,7 @@ import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -50,6 +53,9 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.core.content.ContextCompat +import androidx.lifecycle.lifecycleScope +import androidx.navigation.NavController import androidx.wear.compose.material.Button import androidx.wear.compose.material.ButtonDefaults import androidx.wear.compose.material.Icon @@ -62,49 +68,132 @@ import androidx.wear.compose.material.VignettePosition import androidx.wear.compose.material.dialog.Dialog import androidx.wear.compose.ui.tooling.preview.WearPreviewDevices import androidx.wear.compose.ui.tooling.preview.WearPreviewFontScales +import com.thewizrd.shared_resources.actions.ActionStatus +import com.thewizrd.shared_resources.actions.Actions +import com.thewizrd.shared_resources.actions.AudioStreamType +import com.thewizrd.shared_resources.helpers.InCallUIHelper import com.thewizrd.shared_resources.helpers.WearConnectionStatus +import com.thewizrd.simplewear.PhoneSyncActivity import com.thewizrd.simplewear.R +import com.thewizrd.simplewear.controls.CustomConfirmationOverlay import com.thewizrd.simplewear.ui.components.LoadingContent -import com.thewizrd.simplewear.ui.theme.WearAppTheme +import com.thewizrd.simplewear.ui.navigation.Screen import com.thewizrd.simplewear.ui.theme.activityViewModel import com.thewizrd.simplewear.ui.theme.findActivity import com.thewizrd.simplewear.viewmodels.CallManagerUiState import com.thewizrd.simplewear.viewmodels.CallManagerViewModel +import com.thewizrd.simplewear.viewmodels.WearableListenerViewModel +import kotlinx.coroutines.launch @Composable fun CallManagerUi( - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + navController: NavController ) { val context = LocalContext.current val activity = context.findActivity() + val lifecycleOwner = LocalLifecycleOwner.current val callManagerViewModel = activityViewModel() val uiState by callManagerViewModel.uiState.collectAsState() - WearAppTheme { - Scaffold( - modifier = modifier.background(MaterialTheme.colors.background), - vignette = { Vignette(vignettePosition = VignettePosition.TopAndBottom) }, - timeText = { - if (!uiState.isLoading) TimeText() + Scaffold( + modifier = modifier.background(MaterialTheme.colors.background), + vignette = { Vignette(vignettePosition = VignettePosition.TopAndBottom) }, + timeText = { + if (!uiState.isLoading) TimeText() + }, + ) { + LoadingContent( + empty = !uiState.isCallActive, + emptyContent = { + NoCallActiveScreen() }, + loading = uiState.isLoading ) { - LoadingContent( - empty = !uiState.isCallActive, - emptyContent = { - NoCallActiveScreen() - }, - loading = uiState.isLoading - ) { - CallManagerUi(callManagerViewModel = callManagerViewModel) + CallManagerUi( + callManagerViewModel = callManagerViewModel, + navController = navController + ) + } + } + + LaunchedEffect(lifecycleOwner) { + lifecycleOwner.lifecycleScope.launch { + callManagerViewModel.eventFlow.collect { event -> + when (event.eventType) { + WearableListenerViewModel.ACTION_UPDATECONNECTIONSTATUS -> { + val connectionStatus = WearConnectionStatus.valueOf( + event.data.getInt( + WearableListenerViewModel.EXTRA_CONNECTIONSTATUS, + 0 + ) + ) + + when (connectionStatus) { + WearConnectionStatus.DISCONNECTED -> { + // Navigate + activity.startActivity( + Intent( + activity, + PhoneSyncActivity::class.java + ) + ) + activity.finishAffinity() + } + + WearConnectionStatus.APPNOTINSTALLED -> { + // Open store on remote device + callManagerViewModel.openPlayStore(activity) + + // Navigate + activity.startActivity( + Intent( + activity, + PhoneSyncActivity::class.java + ) + ) + activity.finishAffinity() + } + + else -> {} + } + } + + InCallUIHelper.CallStatePath -> { + val status = + event.data.getSerializable(WearableListenerViewModel.EXTRA_STATUS) as ActionStatus + + if (status == ActionStatus.PERMISSION_DENIED) { + CustomConfirmationOverlay() + .setType(CustomConfirmationOverlay.CUSTOM_ANIMATION) + .setCustomDrawable( + ContextCompat.getDrawable( + activity, + R.drawable.ws_full_sad + ) + ) + .setMessage(activity.getString(R.string.error_permissiondenied)) + .showOn(activity) + + callManagerViewModel.openAppOnPhone(activity, false) + } + } + } } } } + + LaunchedEffect(Unit) { + // Update statuses + callManagerViewModel.refreshCallState() + } } @Composable fun CallManagerUi( - callManagerViewModel: CallManagerViewModel + callManagerViewModel: CallManagerViewModel, + navController: NavController ) { val context = LocalContext.current val activity = context.findActivity() @@ -125,7 +214,9 @@ fun CallManagerUi( callManagerViewModel.enableSpeakerphone(!uiState.isSpeakerPhoneOn) }, onVolume = { - callManagerViewModel.showCallVolumeActivity(activity) + navController.navigate( + Screen.ValueAction.getRoute(Actions.VOLUME, AudioStreamType.VOICE_CALL) + ) }, onEndCall = { callManagerViewModel.endCall() diff --git a/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/Dashboard.kt b/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/Dashboard.kt index 0eb7bc4..78289d7 100644 --- a/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/Dashboard.kt +++ b/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/Dashboard.kt @@ -1,9 +1,28 @@ package com.thewizrd.simplewear.ui.simplewear +import android.Manifest +import android.content.Intent +import android.content.SharedPreferences +import android.os.Build +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background import androidx.compose.foundation.rememberScrollState import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.core.content.ContextCompat +import androidx.core.content.PermissionChecker +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.lifecycle.withStarted +import androidx.navigation.NavController +import androidx.preference.PreferenceManager import androidx.wear.compose.material.MaterialTheme import androidx.wear.compose.material.PositionIndicator import androidx.wear.compose.material.Scaffold @@ -11,31 +30,317 @@ import androidx.wear.compose.material.TimeText import androidx.wear.compose.material.Vignette import androidx.wear.compose.material.VignettePosition import com.google.android.horologist.compose.layout.scrollAway -import com.thewizrd.simplewear.ui.theme.WearAppTheme -import com.thewizrd.simplewear.ui.theme.activityViewModel +import com.thewizrd.shared_resources.actions.Action +import com.thewizrd.shared_resources.actions.ActionStatus +import com.thewizrd.shared_resources.actions.Actions +import com.thewizrd.shared_resources.helpers.WearConnectionStatus +import com.thewizrd.shared_resources.helpers.WearableHelper +import com.thewizrd.shared_resources.sleeptimer.SleepTimerHelper +import com.thewizrd.shared_resources.utils.JSONParser +import com.thewizrd.simplewear.PhoneSyncActivity +import com.thewizrd.simplewear.R +import com.thewizrd.simplewear.controls.ActionButtonViewModel +import com.thewizrd.simplewear.controls.CustomConfirmationOverlay +import com.thewizrd.simplewear.preferences.Settings +import com.thewizrd.simplewear.ui.theme.findActivity +import com.thewizrd.simplewear.utils.ErrorMessage import com.thewizrd.simplewear.viewmodels.DashboardViewModel +import com.thewizrd.simplewear.viewmodels.WearableListenerViewModel +import kotlinx.coroutines.launch @Composable fun Dashboard( - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + navController: NavController ) { - val dashboardViewModel = activityViewModel() + val context = LocalContext.current + val activity = context.findActivity() + + val lifecycleOwner = LocalLifecycleOwner.current + val dashboardViewModel = viewModel() val scrollState = rememberScrollState() - WearAppTheme { - Scaffold( - modifier = modifier.background(MaterialTheme.colors.background), - timeText = { - TimeText(modifier = Modifier.scrollAway { scrollState }) - }, - vignette = { Vignette(vignettePosition = VignettePosition.TopAndBottom) }, - positionIndicator = { PositionIndicator(scrollState = scrollState) } - ) { - DashboardScreen( - dashboardViewModel = dashboardViewModel, - scrollState = scrollState - ) + Scaffold( + modifier = modifier.background(MaterialTheme.colors.background), + timeText = { + TimeText(modifier = Modifier.scrollAway { scrollState }) + }, + vignette = { Vignette(vignettePosition = VignettePosition.TopAndBottom) }, + positionIndicator = { PositionIndicator(scrollState = scrollState) } + ) { + DashboardScreen( + dashboardViewModel = dashboardViewModel, + scrollState = scrollState, + navController = navController + ) + } + + val permissionLauncher = + rememberLauncherForActivityResult(contract = ActivityResultContracts.RequestMultiplePermissions()) {} + + val sharedPreferenceListener = remember { + SharedPreferences.OnSharedPreferenceChangeListener { _, key -> + when (key) { + Settings.KEY_LAYOUTMODE -> { + lifecycleOwner.lifecycleScope.launch { + runCatching { + lifecycleOwner.withStarted { + dashboardViewModel.updateLayout(Settings.useGridLayout()) + } + } + } + } + + Settings.KEY_DASHCONFIG -> { + lifecycleOwner.lifecycleScope.launch { + runCatching { + lifecycleOwner.withStarted { + dashboardViewModel.resetDashboard() + } + } + } + } + + Settings.KEY_SHOWBATSTATUS -> { + lifecycleOwner.lifecycleScope.launch { + runCatching { + lifecycleOwner.withStarted { + dashboardViewModel.showBatteryState(Settings.isShowBatStatus()) + } + } + } + } + } + } + } + + LaunchedEffect(context) { + PreferenceManager.getDefaultSharedPreferences(context) + .registerOnSharedPreferenceChangeListener(sharedPreferenceListener) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (PermissionChecker.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS + ) != PermissionChecker.PERMISSION_GRANTED + ) { + permissionLauncher.launch(arrayOf(Manifest.permission.POST_NOTIFICATIONS)) + } + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + if (PermissionChecker.checkSelfPermission( + context, + Manifest.permission.BLUETOOTH_ADVERTISE + ) != PermissionChecker.PERMISSION_GRANTED + ) { + permissionLauncher.launch(arrayOf(Manifest.permission.BLUETOOTH_ADVERTISE)) + } + } + + dashboardViewModel.initActivityContext(activity) + } + + DisposableEffect(context) { + onDispose { + PreferenceManager.getDefaultSharedPreferences(context) + .unregisterOnSharedPreferenceChangeListener(sharedPreferenceListener) + } + } + + LaunchedEffect(lifecycleOwner) { + lifecycleOwner.lifecycleScope.launch { + dashboardViewModel.eventFlow.collect { event -> + when (event.eventType) { + WearableListenerViewModel.ACTION_UPDATECONNECTIONSTATUS -> { + val connectionStatus = WearConnectionStatus.valueOf( + event.data.getInt( + WearableListenerViewModel.EXTRA_CONNECTIONSTATUS, + 0 + ) + ) + + when (connectionStatus) { + WearConnectionStatus.DISCONNECTED -> { + activity.startActivity( + Intent( + activity, + PhoneSyncActivity::class.java + ) + ) + activity.finishAffinity() + } + + WearConnectionStatus.CONNECTING -> {} + WearConnectionStatus.APPNOTINSTALLED -> { + // Open store on remote device + dashboardViewModel.openPlayStore(activity) + + // Navigate + activity.startActivity( + Intent( + activity, + PhoneSyncActivity::class.java + ) + ) + activity.finishAffinity() + } + + WearConnectionStatus.CONNECTED -> {} + } + } + + WearableHelper.ActionsPath -> { + val jsonData = + event.data.getString(WearableListenerViewModel.EXTRA_ACTIONDATA) + val action = JSONParser.deserializer(jsonData, Action::class.java)!! + + dashboardViewModel.cancelTimer(action.actionType) + dashboardViewModel.updateButton(ActionButtonViewModel(action)) + + val actionStatus = action.actionStatus + + if (!action.isActionSuccessful) { + when (actionStatus) { + ActionStatus.UNKNOWN, ActionStatus.FAILURE -> { + CustomConfirmationOverlay() + .setType(CustomConfirmationOverlay.CUSTOM_ANIMATION) + .setCustomDrawable( + ContextCompat.getDrawable( + activity, + R.drawable.ws_full_sad + ) + ) + .setMessage(activity.getString(R.string.error_actionfailed)) + .showOn(activity) + } + + ActionStatus.PERMISSION_DENIED -> { + if (action.actionType == Actions.TORCH) { + CustomConfirmationOverlay() + .setType(CustomConfirmationOverlay.CUSTOM_ANIMATION) + .setCustomDrawable( + ContextCompat.getDrawable( + activity, + R.drawable.ws_full_sad + ) + ) + .setMessage(activity.getString(R.string.error_torch_action)) + .showOn(activity) + } else if (action.actionType == Actions.SLEEPTIMER) { + // Open store on device + val intentAndroid = Intent(Intent.ACTION_VIEW) + .addCategory(Intent.CATEGORY_BROWSABLE) + .setData(SleepTimerHelper.getPlayStoreURI()) + + if (intentAndroid.resolveActivity(activity.packageManager) != null) { + activity.startActivity(intentAndroid) + Toast.makeText( + activity, + R.string.error_sleeptimer_notinstalled, + Toast.LENGTH_LONG + ).show() + } else { + CustomConfirmationOverlay() + .setType(CustomConfirmationOverlay.CUSTOM_ANIMATION) + .setCustomDrawable( + ContextCompat.getDrawable( + activity, + R.drawable.ws_full_sad + ) + ) + .setMessage( + activity.getString( + R.string.error_sleeptimer_notinstalled + ) + ) + .showOn(activity) + } + } else { + CustomConfirmationOverlay() + .setType(CustomConfirmationOverlay.CUSTOM_ANIMATION) + .setCustomDrawable( + ContextCompat.getDrawable( + activity, + R.drawable.ws_full_sad + ) + ) + .setMessage(activity.getString(R.string.error_permissiondenied)) + .showOn(activity) + } + + dashboardViewModel.openAppOnPhone(activity, false) + } + + ActionStatus.TIMEOUT -> { + CustomConfirmationOverlay() + .setType(CustomConfirmationOverlay.CUSTOM_ANIMATION) + .setCustomDrawable( + ContextCompat.getDrawable( + activity, + R.drawable.ws_full_sad + ) + ) + .setMessage(activity.getString(R.string.error_sendmessage)) + .showOn(activity) + } + + ActionStatus.REMOTE_FAILURE -> { + CustomConfirmationOverlay() + .setType(CustomConfirmationOverlay.CUSTOM_ANIMATION) + .setCustomDrawable( + ContextCompat.getDrawable( + activity, + R.drawable.ws_full_sad + ) + ) + .setMessage(activity.getString(R.string.error_remoteactionfailed)) + .showOn(activity) + } + + ActionStatus.REMOTE_PERMISSION_DENIED -> { + CustomConfirmationOverlay() + .setType(CustomConfirmationOverlay.CUSTOM_ANIMATION) + .setCustomDrawable( + ContextCompat.getDrawable( + activity, + R.drawable.ws_full_sad + ) + ) + .setMessage(activity.getString(R.string.error_permissiondenied)) + .showOn(activity) + } + + ActionStatus.SUCCESS -> { + } + } + } + + // Re-enable click action + dashboardViewModel.setActionsClickable(true) + } + } + } } + + lifecycleOwner.lifecycleScope.launch { + dashboardViewModel.errorMessagesFlow.collect { error -> + when (error) { + is ErrorMessage.String -> { + Toast.makeText(activity, error.message, Toast.LENGTH_SHORT).show() + } + + is ErrorMessage.Resource -> { + Toast.makeText(activity, error.stringId, Toast.LENGTH_SHORT).show() + } + } + } + } + } + + LaunchedEffect(Unit) { + // Update statuses + dashboardViewModel.refreshStatus() } } \ No newline at end of file diff --git a/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/DashboardScreen.kt b/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/DashboardScreen.kt index c658254..a8cd67a 100644 --- a/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/DashboardScreen.kt +++ b/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/DashboardScreen.kt @@ -54,6 +54,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.toSize import androidx.lifecycle.lifecycleScope +import androidx.navigation.NavController import androidx.wear.compose.material.Button import androidx.wear.compose.material.ButtonDefaults import androidx.wear.compose.material.Chip @@ -96,7 +97,8 @@ import kotlin.math.sqrt @Composable fun DashboardScreen( dashboardViewModel: DashboardViewModel, - scrollState: ScrollState = rememberScrollState() + scrollState: ScrollState = rememberScrollState(), + navController: NavController ) { val lifecycleOwner = LocalLifecycleOwner.current val activityCtx = LocalContext.current.findActivity() @@ -118,7 +120,7 @@ fun DashboardScreen( scrollState = scrollState, onActionClicked = { model -> model.onClick( - activityCtx, + navController, onActionChanged = { dashboardViewModel.requestActionChange(it) }, diff --git a/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/MediaPlayer.kt b/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/MediaPlayer.kt new file mode 100644 index 0000000..5b55f76 --- /dev/null +++ b/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/MediaPlayer.kt @@ -0,0 +1,108 @@ +package com.thewizrd.simplewear.ui.simplewear + +import android.os.Build +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.navigation.NavType +import androidx.navigation.navArgument +import androidx.wear.compose.foundation.rememberSwipeToDismissBoxState +import androidx.wear.compose.navigation.SwipeDismissableNavHost +import androidx.wear.compose.navigation.composable +import androidx.wear.compose.navigation.rememberSwipeDismissableNavController +import androidx.wear.compose.navigation.rememberSwipeDismissableNavHostState +import com.thewizrd.shared_resources.actions.Actions +import com.thewizrd.shared_resources.actions.AudioStreamType +import com.thewizrd.shared_resources.utils.JSONParser +import com.thewizrd.simplewear.controls.AppItemViewModel +import com.thewizrd.simplewear.ui.navigation.Screen +import com.thewizrd.simplewear.ui.theme.WearAppTheme +import com.thewizrd.simplewear.ui.theme.findActivity + +@Composable +fun MediaPlayer( + startDestination: String = Screen.MediaPlayerList.route +) { + WearAppTheme { + val context = LocalContext.current + val activity = context.findActivity() + + val navController = rememberSwipeDismissableNavController() + val swipeToDismissBoxState = rememberSwipeToDismissBoxState() + val swipeDismissNavState = rememberSwipeDismissableNavHostState( + swipeToDismissBoxState = swipeToDismissBoxState + ) + + SwipeDismissableNavHost( + navController = navController, + startDestination = startDestination, + state = swipeDismissNavState + ) { + composable(route = Screen.MediaPlayerList.route) { + MediaPlayerListUi( + navController = navController, + swipeToDismissBoxState = swipeToDismissBoxState + ) + } + + composable( + route = Screen.MediaPlayer.route, + arguments = listOf( + navArgument("autoLaunch") { + type = NavType.BoolType + defaultValue = false + }, + navArgument("app") { + type = NavType.StringType + nullable = true + } + ) + ) { backstackEntry -> + val autoLaunch = backstackEntry.arguments?.getBoolean("autoLaunch") + val app = remember(backstackEntry) { + JSONParser.deserializer( + backstackEntry.arguments?.getString("app"), + AppItemViewModel::class.java + ) + } + + MediaPlayerUi( + navController = navController, + swipeToDismissBoxState = swipeToDismissBoxState, + autoLaunch = autoLaunch ?: (app == null), + app = app + ) + } + + composable( + route = Screen.ValueAction.route + "/{actionId}?streamType={streamType}", + arguments = listOf( + navArgument("actionId") { + type = NavType.IntType + }, + navArgument("streamType") { + type = NavType.EnumType(AudioStreamType::class.java) + defaultValue = AudioStreamType.MUSIC + } + ) + ) { backstackEntry -> + val actionType = backstackEntry.arguments?.getInt("actionId")?.let { + Actions.valueOf(it) + } + val streamType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + backstackEntry.arguments?.getSerializable( + "streamType", + AudioStreamType::class.java + ) + } else { + backstackEntry.arguments?.getSerializable("streamType") as AudioStreamType + } + + ValueActionScreen( + actionType = actionType ?: Actions.VOLUME, + audioStreamType = streamType + ) + } + } + } +} \ No newline at end of file diff --git a/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/MediaPlayerListUi.kt b/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/MediaPlayerListUi.kt index 5a55cf7..1d90426 100644 --- a/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/MediaPlayerListUi.kt +++ b/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/MediaPlayerListUi.kt @@ -1,5 +1,6 @@ package com.thewizrd.simplewear.ui.simplewear +import android.content.Intent import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -20,20 +21,26 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +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.graphics.Color import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.core.graphics.drawable.toBitmap +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController import androidx.wear.compose.foundation.ExperimentalWearFoundationApi import androidx.wear.compose.foundation.HierarchicalFocusCoordinator +import androidx.wear.compose.foundation.SwipeToDismissBoxState import androidx.wear.compose.foundation.edgeSwipeToDismiss import androidx.wear.compose.foundation.lazy.items import androidx.wear.compose.foundation.rememberSwipeToDismissBoxState @@ -44,7 +51,6 @@ import androidx.wear.compose.material.CompactChip import androidx.wear.compose.material.Icon import androidx.wear.compose.material.MaterialTheme import androidx.wear.compose.material.PositionIndicator -import androidx.wear.compose.material.SwipeToDismissBox import androidx.wear.compose.material.Switch import androidx.wear.compose.material.Text import androidx.wear.compose.material.TimeText @@ -63,16 +69,22 @@ import com.google.android.horologist.compose.material.ListHeaderDefaults import com.google.android.horologist.compose.material.ResponsiveListHeader import com.google.android.horologist.compose.pager.HorizontalPagerDefaults import com.google.android.horologist.compose.rotaryinput.rotaryWithScroll +import com.thewizrd.shared_resources.actions.ActionStatus +import com.thewizrd.shared_resources.helpers.MediaHelper import com.thewizrd.shared_resources.helpers.WearConnectionStatus +import com.thewizrd.simplewear.PhoneSyncActivity import com.thewizrd.simplewear.R import com.thewizrd.simplewear.controls.AppItemViewModel +import com.thewizrd.simplewear.controls.CustomConfirmationOverlay +import com.thewizrd.simplewear.helpers.showConfirmationOverlay import com.thewizrd.simplewear.preferences.Settings import com.thewizrd.simplewear.ui.components.LoadingContent -import com.thewizrd.simplewear.ui.theme.WearAppTheme -import com.thewizrd.simplewear.ui.theme.activityViewModel +import com.thewizrd.simplewear.ui.navigation.Screen import com.thewizrd.simplewear.ui.theme.findActivity import com.thewizrd.simplewear.viewmodels.MediaPlayerListUiState import com.thewizrd.simplewear.viewmodels.MediaPlayerListViewModel +import com.thewizrd.simplewear.viewmodels.WearableListenerViewModel +import kotlinx.coroutines.launch @OptIn( ExperimentalHorologistApi::class, ExperimentalFoundationApi::class, @@ -80,12 +92,15 @@ import com.thewizrd.simplewear.viewmodels.MediaPlayerListViewModel ) @Composable fun MediaPlayerListUi( - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + navController: NavController, + swipeToDismissBoxState: SwipeToDismissBoxState = rememberSwipeToDismissBoxState() ) { val context = LocalContext.current val activity = context.findActivity() - val mediaPlayerListViewModel = activityViewModel() + val lifecycleOwner = LocalLifecycleOwner.current + val mediaPlayerListViewModel = viewModel() val uiState by mediaPlayerListViewModel.uiState.collectAsState() val scrollState = rememberResponsiveColumnState( @@ -94,77 +109,166 @@ fun MediaPlayerListUi( last = ScalingLazyColumnDefaults.ItemType.Chip, ) ) - val swipeToDismissBoxState = rememberSwipeToDismissBoxState() val pagerState = rememberPagerState( initialPage = 0, pageCount = { 2 } ) - WearAppTheme { - PagerScaffold( - modifier = Modifier.fillMaxSize(), - timeText = { - if (pagerState.currentPage == 0) { - TimeText(modifier = Modifier.scrollAway { scrollState }) - } - }, - pagerState = if (uiState.isLoading) null else pagerState - ) { - SwipeToDismissBox( - modifier = Modifier.background(MaterialTheme.colors.background), - onDismissed = { - activity.onBackPressed() - }, - state = swipeToDismissBoxState - ) { isBackground -> - if (isBackground) { - Box( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colors.background) + var autoLaunched by rememberSaveable(navController) { mutableStateOf(false) } + + PagerScaffold( + modifier = Modifier + .fillMaxSize() + .edgeSwipeToDismiss(swipeToDismissBoxState), + timeText = { + if (pagerState.currentPage == 0) { + TimeText(modifier = Modifier.scrollAway { scrollState }) + } + }, + pagerState = if (uiState.isLoading) null else pagerState + ) { + HorizontalPager( + state = pagerState, + flingBehavior = HorizontalPagerDefaults.flingParams(pagerState) + ) { pageIdx -> + HierarchicalFocusCoordinator(requiresFocus = { pageIdx == pagerState.currentPage }) { + if (pageIdx == 0) { + MediaPlayerListScreen( + mediaPlayerListViewModel = mediaPlayerListViewModel, + navController = navController, + scrollState = scrollState ) } else { - HorizontalPager( - modifier = modifier.edgeSwipeToDismiss(swipeToDismissBoxState), - state = pagerState, - flingBehavior = HorizontalPagerDefaults.flingParams(pagerState) - ) { pageIdx -> - HierarchicalFocusCoordinator(requiresFocus = { pageIdx == pagerState.currentPage }) { - if (pageIdx == 0) { - MediaPlayerListScreen( - mediaPlayerListViewModel = mediaPlayerListViewModel, - scrollState = scrollState + MediaPlayerListSettings( + mediaPlayerListViewModel = mediaPlayerListViewModel + ) + } + } + } + } + + LaunchedEffect(context) { + mediaPlayerListViewModel.initActivityContext(activity) + } + + LaunchedEffect(lifecycleOwner) { + lifecycleOwner.lifecycleScope.launchWhenResumed { + if (!autoLaunched) { + mediaPlayerListViewModel.autoLaunchMediaControls() + autoLaunched = true + } + } + + lifecycleOwner.lifecycleScope.launch { + mediaPlayerListViewModel.eventFlow.collect { event -> + when (event.eventType) { + WearableListenerViewModel.ACTION_UPDATECONNECTIONSTATUS -> { + val connectionStatus = WearConnectionStatus.valueOf( + event.data.getInt( + WearableListenerViewModel.EXTRA_CONNECTIONSTATUS, + 0 + ) + ) + + when (connectionStatus) { + WearConnectionStatus.DISCONNECTED -> { + // Navigate + activity.startActivity( + Intent( + activity, + PhoneSyncActivity::class.java + ) ) - } else { - MediaPlayerListSettings( - mediaPlayerListViewModel = mediaPlayerListViewModel + activity.finishAffinity() + } + + WearConnectionStatus.APPNOTINSTALLED -> { + // Open store on remote device + mediaPlayerListViewModel.openPlayStore(activity) + + // Navigate + activity.startActivity( + Intent( + activity, + PhoneSyncActivity::class.java + ) ) + activity.finishAffinity() } + + else -> {} + } + } + + MediaHelper.MusicPlayersPath -> { + val status = + event.data.getSerializable(WearableListenerViewModel.EXTRA_STATUS) as ActionStatus + + if (status == ActionStatus.PERMISSION_DENIED) { + CustomConfirmationOverlay() + .setType(CustomConfirmationOverlay.CUSTOM_ANIMATION) + .setCustomDrawable( + ContextCompat.getDrawable( + activity, + R.drawable.ws_full_sad + ) + ) + .setMessage(activity.getString(R.string.error_permissiondenied)) + .showOn(activity) + + mediaPlayerListViewModel.openAppOnPhone( + activity, + false + ) + } + } + + MediaHelper.MediaPlayerAutoLaunchPath -> { + val status = + event.data.getSerializable(WearableListenerViewModel.EXTRA_STATUS) as ActionStatus + + if (status == ActionStatus.SUCCESS) { + navController.navigate(Screen.MediaPlayer.autoLaunch()) } } } } } } + + LaunchedEffect(Unit) { + // Update statuses + mediaPlayerListViewModel.refreshState(true) + } } @OptIn(ExperimentalHorologistApi::class) @Composable private fun MediaPlayerListScreen( mediaPlayerListViewModel: MediaPlayerListViewModel, + navController: NavController, scrollState: ScalingLazyColumnState ) { val context = LocalContext.current val activity = context.findActivity() + val lifecycleOwner = LocalLifecycleOwner.current val uiState by mediaPlayerListViewModel.uiState.collectAsState() MediaPlayerListScreen( uiState = uiState, scrollState = scrollState, onItemClicked = { - mediaPlayerListViewModel.startMediaApp(activity, it) + lifecycleOwner.lifecycleScope.launch { + val success = mediaPlayerListViewModel.startMediaApp(it) + + if (success) { + navController.navigate(Screen.MediaPlayer.getRoute(it)) + } else { + activity.showConfirmationOverlay(false) + } + } }, onRefresh = { mediaPlayerListViewModel.refreshState() diff --git a/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/MediaPlayerUi.kt b/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/MediaPlayerUi.kt index 0a67c20..3f24969 100644 --- a/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/MediaPlayerUi.kt +++ b/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/MediaPlayerUi.kt @@ -2,9 +2,9 @@ package com.thewizrd.simplewear.ui.simplewear +import android.content.Intent import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize @@ -17,6 +17,7 @@ import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -39,17 +40,18 @@ import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.core.graphics.drawable.toBitmap import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController import androidx.wear.ambient.AmbientLifecycleObserver import androidx.wear.compose.foundation.ExperimentalWearFoundationApi import androidx.wear.compose.foundation.HierarchicalFocusCoordinator +import androidx.wear.compose.foundation.SwipeToDismissBoxState import androidx.wear.compose.foundation.edgeSwipeToDismiss import androidx.wear.compose.foundation.lazy.items import androidx.wear.compose.foundation.rememberSwipeToDismissBoxState import androidx.wear.compose.material.ChipDefaults import androidx.wear.compose.material.CompactChip import androidx.wear.compose.material.Icon -import androidx.wear.compose.material.MaterialTheme -import androidx.wear.compose.material.SwipeToDismissBox import androidx.wear.compose.material.Text import androidx.wear.compose.material.TimeText import androidx.wear.compose.ui.tooling.preview.WearPreviewDevices @@ -79,9 +81,16 @@ import com.google.android.horologist.media.ui.components.display.TextMediaDispla import com.google.android.horologist.media.ui.screens.player.PlayerScreen import com.google.android.horologist.media.ui.state.LocalTimestampProvider import com.google.android.horologist.media.ui.state.mapper.TrackPositionUiModelMapper +import com.thewizrd.shared_resources.actions.ActionStatus +import com.thewizrd.shared_resources.actions.Actions +import com.thewizrd.shared_resources.actions.AudioStreamType import com.thewizrd.shared_resources.helpers.MediaHelper +import com.thewizrd.shared_resources.helpers.WearConnectionStatus import com.thewizrd.shared_resources.media.PlaybackState +import com.thewizrd.simplewear.PhoneSyncActivity import com.thewizrd.simplewear.R +import com.thewizrd.simplewear.controls.AppItemViewModel +import com.thewizrd.simplewear.controls.CustomConfirmationOverlay import com.thewizrd.simplewear.media.MediaItemModel import com.thewizrd.simplewear.media.MediaPageType import com.thewizrd.simplewear.media.MediaPlayerUiState @@ -90,9 +99,9 @@ import com.thewizrd.simplewear.media.PlayerState import com.thewizrd.simplewear.media.toPlaybackStateEvent import com.thewizrd.simplewear.ui.ambient.ambientMode import com.thewizrd.simplewear.ui.components.LoadingContent -import com.thewizrd.simplewear.ui.theme.WearAppTheme -import com.thewizrd.simplewear.ui.theme.activityViewModel +import com.thewizrd.simplewear.ui.navigation.Screen import com.thewizrd.simplewear.ui.theme.findActivity +import com.thewizrd.simplewear.viewmodels.WearableListenerViewModel import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -102,125 +111,220 @@ import kotlinx.coroutines.launch ) @Composable fun MediaPlayerUi( - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + navController: NavController, + swipeToDismissBoxState: SwipeToDismissBoxState = rememberSwipeToDismissBoxState(), + app: AppItemViewModel? = null, + autoLaunch: Boolean = (app == null), ) { val context = LocalContext.current val activity = context.findActivity() - val mediaPlayerViewModel = activityViewModel() + val lifecycleOwner = LocalLifecycleOwner.current + val mediaPlayerViewModel = viewModel() val uiState by mediaPlayerViewModel.uiState.collectAsState() val mediaPagerState = remember(uiState) { uiState.pagerState } - val swipeToDismissBoxState = rememberSwipeToDismissBoxState() val pagerState = rememberPagerState( initialPage = 0, pageCount = { mediaPagerState.pageCount } ) - WearAppTheme { - AmbientAware { ambientStateUpdate -> - val ambientState = remember(ambientStateUpdate) { ambientStateUpdate.ambientState } + AmbientAware { ambientStateUpdate -> + val ambientState = remember(ambientStateUpdate) { ambientStateUpdate.ambientState } - PagerScaffold( - modifier = Modifier.fillMaxSize(), - timeText = { - if (pagerState.currentPage == 0) { - TimeText() + PagerScaffold( + modifier = Modifier + .fillMaxSize() + .edgeSwipeToDismiss(swipeToDismissBoxState), + timeText = { + if (pagerState.currentPage == 0) { + TimeText() + } + }, + pagerState = if (ambientState != AmbientState.Interactive || uiState.isLoading || !uiState.isPlayerAvailable) null else pagerState + ) { + val keyFunc: (Int) -> MediaPageType = remember(uiState) { + pagerKey@{ pageIdx -> + if (ambientState != AmbientState.Interactive) + return@pagerKey MediaPageType.Player + + if (pageIdx == 1) { + if (mediaPagerState.supportsCustomActions) { + return@pagerKey MediaPageType.CustomControls + } + if (mediaPagerState.supportsQueue) { + return@pagerKey MediaPageType.Queue + } + if (mediaPagerState.supportsBrowser) { + return@pagerKey MediaPageType.Browser + } + } else if (pageIdx == 2) { + if (mediaPagerState.supportsQueue) { + return@pagerKey MediaPageType.Queue + } + if (mediaPagerState.supportsBrowser) { + return@pagerKey MediaPageType.Browser + } + } else if (pageIdx == 3) { + return@pagerKey MediaPageType.Browser } - }, - pagerState = if (ambientState != AmbientState.Interactive || uiState.isLoading || !uiState.isPlayerAvailable) null else pagerState - ) { - SwipeToDismissBox( - modifier = Modifier.background(MaterialTheme.colors.background), - onDismissed = { - activity.onBackPressed() - }, - state = swipeToDismissBoxState - ) { isBackground -> - if (isBackground) { - Box( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colors.background) + + MediaPageType.Player + } + } + + HorizontalPager( + state = pagerState, + flingBehavior = HorizontalPagerDefaults.flingParams(pagerState), + key = keyFunc + ) { pageIdx -> + HierarchicalFocusCoordinator(requiresFocus = { pageIdx == pagerState.currentPage }) { + val key = keyFunc(pageIdx) + + when (key) { + MediaPageType.Player -> { + MediaPlayerControlsPage( + mediaPlayerViewModel = mediaPlayerViewModel, + navController = navController, + ambientState = ambientState + ) + } + + MediaPageType.CustomControls -> { + MediaCustomControlsPage( + mediaPlayerViewModel = mediaPlayerViewModel + ) + } + + MediaPageType.Browser -> { + MediaBrowserPage( + mediaPlayerViewModel = mediaPlayerViewModel + ) + } + + MediaPageType.Queue -> { + MediaQueuePage( + mediaPlayerViewModel = mediaPlayerViewModel + ) + } + } + } + } + } + } + + LaunchedEffect(context) { + mediaPlayerViewModel.initActivityContext(activity) + } + + LaunchedEffect(app, autoLaunch) { + if (autoLaunch) { + mediaPlayerViewModel.autoLaunch() + return@LaunchedEffect + } + + if (app != null) { + mediaPlayerViewModel.updateMediaPlayerDetails(app) + } + } + + LaunchedEffect(lifecycleOwner) { + lifecycleOwner.lifecycleScope.launch { + mediaPlayerViewModel.eventFlow.collect { event -> + when (event.eventType) { + WearableListenerViewModel.ACTION_UPDATECONNECTIONSTATUS -> { + val connectionStatus = WearConnectionStatus.valueOf( + event.data.getInt( + WearableListenerViewModel.EXTRA_CONNECTIONSTATUS, + 0 + ) ) - } else { - val keyFunc: (Int) -> MediaPageType = remember(uiState) { - pagerKey@{ pageIdx -> - if (ambientState != AmbientState.Interactive) - return@pagerKey MediaPageType.Player - - if (pageIdx == 1) { - if (mediaPagerState.supportsCustomActions) { - return@pagerKey MediaPageType.CustomControls - } - if (mediaPagerState.supportsQueue) { - return@pagerKey MediaPageType.Queue - } - if (mediaPagerState.supportsBrowser) { - return@pagerKey MediaPageType.Browser - } - } else if (pageIdx == 2) { - if (mediaPagerState.supportsQueue) { - return@pagerKey MediaPageType.Queue - } - if (mediaPagerState.supportsBrowser) { - return@pagerKey MediaPageType.Browser - } - } else if (pageIdx == 3) { - return@pagerKey MediaPageType.Browser - } - MediaPageType.Player + when (connectionStatus) { + WearConnectionStatus.DISCONNECTED -> { + // Navigate + activity.startActivity( + Intent( + activity, + PhoneSyncActivity::class.java + ) + ) + activity.finishAffinity() } - } - HorizontalPager( - modifier = modifier.edgeSwipeToDismiss(swipeToDismissBoxState), - state = pagerState, - flingBehavior = HorizontalPagerDefaults.flingParams(pagerState), - key = keyFunc - ) { pageIdx -> - HierarchicalFocusCoordinator(requiresFocus = { pageIdx == pagerState.currentPage }) { - val key = keyFunc(pageIdx) - - when (key) { - MediaPageType.Player -> { - MediaPlayerControlsPage( - mediaPlayerViewModel = mediaPlayerViewModel, - ambientState = ambientState - ) - } - - MediaPageType.CustomControls -> { - MediaCustomControlsPage( - mediaPlayerViewModel = mediaPlayerViewModel - ) - } - - MediaPageType.Browser -> { - MediaBrowserPage( - mediaPlayerViewModel = mediaPlayerViewModel - ) - } - - MediaPageType.Queue -> { - MediaQueuePage( - mediaPlayerViewModel = mediaPlayerViewModel - ) - } - } + WearConnectionStatus.APPNOTINSTALLED -> { + // Open store on remote device + mediaPlayerViewModel.openPlayStore(activity) + + // Navigate + activity.startActivity( + Intent( + activity, + PhoneSyncActivity::class.java + ) + ) + activity.finishAffinity() } + + else -> {} + } + } + + MediaHelper.MediaPlayerConnectPath, + MediaHelper.MediaPlayerAutoLaunchPath -> { + val actionStatus = + event.data.getSerializable(WearableListenerViewModel.EXTRA_STATUS) as ActionStatus + + if (actionStatus == ActionStatus.PERMISSION_DENIED) { + CustomConfirmationOverlay() + .setType(CustomConfirmationOverlay.CUSTOM_ANIMATION) + .setCustomDrawable( + ContextCompat.getDrawable( + activity, + R.drawable.ws_full_sad + ) + ) + .setMessage(activity.getString(R.string.error_permissiondenied)) + .showOn(activity) + + mediaPlayerViewModel.openAppOnPhone(activity, false) + } + } + + MediaHelper.MediaPlayPath -> { + val actionStatus = + event.data.getSerializable(WearableListenerViewModel.EXTRA_STATUS) as ActionStatus + + if (actionStatus == ActionStatus.TIMEOUT) { + CustomConfirmationOverlay() + .setType(CustomConfirmationOverlay.CUSTOM_ANIMATION) + .setCustomDrawable(R.drawable.ws_full_sad) + .setMessage(R.string.error_playback_failed) + .showOn(activity) } } } } } } + + LaunchedEffect(Unit) { + // Update statuses + mediaPlayerViewModel.refreshStatus() + } + + DisposableEffect(Unit) { + onDispose { + mediaPlayerViewModel.requestPlayerDisconnect() + } + } } @Composable private fun MediaPlayerControlsPage( mediaPlayerViewModel: MediaPlayerViewModel, + navController: NavController, ambientState: AmbientState ) { val context = LocalContext.current @@ -251,7 +355,9 @@ private fun MediaPlayerControlsPage( mediaPlayerViewModel.requestSkipToNextAction() }, onVolume = { - mediaPlayerViewModel.showCallVolumeActivity(activity) + navController.navigate( + Screen.ValueAction.getRoute(Actions.VOLUME, AudioStreamType.MUSIC) + ) } ) diff --git a/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/PhoneSyncUi.kt b/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/PhoneSyncUi.kt index ac1ab83..8f94ad3 100644 --- a/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/PhoneSyncUi.kt +++ b/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/PhoneSyncUi.kt @@ -1,6 +1,7 @@ package com.thewizrd.simplewear.ui.simplewear import android.Manifest +import android.app.Activity import android.bluetooth.BluetoothAdapter import android.content.Intent import android.os.Build @@ -51,7 +52,6 @@ import androidx.wear.compose.ui.tooling.preview.WearPreviewFontScales import com.google.accompanist.drawablepainter.rememberDrawablePainter import com.thewizrd.shared_resources.helpers.WearConnectionStatus import com.thewizrd.simplewear.R -import com.thewizrd.simplewear.activities.AppCompatLiteActivity import com.thewizrd.simplewear.ui.theme.WearAppTheme import com.thewizrd.simplewear.ui.theme.activityViewModel import com.thewizrd.simplewear.ui.theme.findActivity @@ -92,7 +92,7 @@ private fun PhoneSyncUi( val bluetoothRequestLauncher = rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) { - if (it.resultCode == AppCompatLiteActivity.RESULT_OK) { + if (it.resultCode == Activity.RESULT_OK) { lifecycleOwner.lifecycleScope.launch(Dispatchers.Main) { delay(2000) phoneSyncViewModel.showProgressBar() diff --git a/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/SimpleWearApp.kt b/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/SimpleWearApp.kt new file mode 100644 index 0000000..893086e --- /dev/null +++ b/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/SimpleWearApp.kt @@ -0,0 +1,88 @@ +package com.thewizrd.simplewear.ui.simplewear + +import android.os.Build +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import androidx.navigation.NavType +import androidx.navigation.activity +import androidx.navigation.navArgument +import androidx.wear.compose.foundation.rememberSwipeToDismissBoxState +import androidx.wear.compose.navigation.SwipeDismissableNavHost +import androidx.wear.compose.navigation.composable +import androidx.wear.compose.navigation.rememberSwipeDismissableNavController +import androidx.wear.compose.navigation.rememberSwipeDismissableNavHostState +import com.thewizrd.shared_resources.actions.Actions +import com.thewizrd.shared_resources.actions.AudioStreamType +import com.thewizrd.simplewear.media.MediaPlayerActivity +import com.thewizrd.simplewear.ui.navigation.Screen +import com.thewizrd.simplewear.ui.theme.WearAppTheme + +@Composable +fun SimpleWearApp( + startDestination: String = Screen.Dashboard.route +) { + WearAppTheme { + val context = LocalContext.current + + val navController = rememberSwipeDismissableNavController() + val swipeToDismissBoxState = rememberSwipeToDismissBoxState() + val swipeDismissNavState = rememberSwipeDismissableNavHostState( + swipeToDismissBoxState = swipeToDismissBoxState + ) + + SwipeDismissableNavHost( + navController = navController, + startDestination = startDestination, + state = swipeDismissNavState + ) { + composable(Screen.Dashboard.route) { + Dashboard(navController = navController) + } + + composable( + route = Screen.ValueAction.route + "/{actionId}?streamType={streamType}", + arguments = listOf( + navArgument("actionId") { + type = NavType.IntType + }, + navArgument("streamType") { + type = NavType.EnumType(AudioStreamType::class.java) + defaultValue = AudioStreamType.MUSIC + } + ) + ) { backstackEntry -> + val actionType = backstackEntry.arguments?.getInt("actionId")?.let { + Actions.valueOf(it) + } + val streamType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + backstackEntry.arguments?.getSerializable( + "streamType", + AudioStreamType::class.java + ) + } else { + backstackEntry.arguments?.getSerializable("streamType") as AudioStreamType + } + + ValueActionScreen( + actionType = actionType ?: Actions.VOLUME, + audioStreamType = streamType + ) + } + + activity(route = Screen.MediaPlayerList.route) { + targetPackage = context.packageName + activityClass = MediaPlayerActivity::class + } + + composable(Screen.AppLauncher.route) { + AppLauncherScreen( + swipeToDismissBoxState = swipeToDismissBoxState + ) + } + + composable(Screen.CallManager.route) { + CallManagerUi(navController = navController) + } + } + } +} \ No newline at end of file diff --git a/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/ValueActionScreen.kt b/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/ValueActionScreen.kt index 801689a..81f3c59 100644 --- a/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/ValueActionScreen.kt +++ b/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/ValueActionScreen.kt @@ -1,9 +1,11 @@ package com.thewizrd.simplewear.ui.simplewear +import android.content.Intent import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -14,6 +16,9 @@ import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow +import androidx.core.content.ContextCompat +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.viewmodel.compose.viewModel import androidx.wear.compose.material.Chip import androidx.wear.compose.material.ChipDefaults import androidx.wear.compose.material.Icon @@ -30,35 +35,203 @@ import com.google.android.horologist.annotations.ExperimentalHorologistApi import com.google.android.horologist.audio.ui.VolumeUiState import com.google.android.horologist.audio.ui.rotaryVolumeControlsWithFocus import com.google.android.horologist.compose.rotaryinput.RotaryDefaults +import com.thewizrd.shared_resources.actions.Action +import com.thewizrd.shared_resources.actions.ActionStatus import com.thewizrd.shared_resources.actions.Actions import com.thewizrd.shared_resources.actions.AudioStreamType import com.thewizrd.shared_resources.actions.ValueActionState import com.thewizrd.shared_resources.helpers.WearConnectionStatus +import com.thewizrd.shared_resources.helpers.WearableHelper +import com.thewizrd.shared_resources.utils.JSONParser +import com.thewizrd.simplewear.PhoneSyncActivity import com.thewizrd.simplewear.R -import com.thewizrd.simplewear.ui.theme.WearAppTheme -import com.thewizrd.simplewear.ui.theme.activityViewModel +import com.thewizrd.simplewear.controls.CustomConfirmationOverlay import com.thewizrd.simplewear.ui.theme.findActivity import com.thewizrd.simplewear.viewmodels.ValueActionUiState import com.thewizrd.simplewear.viewmodels.ValueActionViewModel +import com.thewizrd.simplewear.viewmodels.WearableListenerViewModel import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch @Composable fun ValueActionScreen( - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + actionType: Actions, + audioStreamType: AudioStreamType? = null ) { - val valueActionViewModel = activityViewModel() - - WearAppTheme { - Scaffold( - modifier = modifier.background(MaterialTheme.colors.background), - vignette = { Vignette(vignettePosition = VignettePosition.TopAndBottom) }, - timeText = { - TimeText() - }, - ) { - ValueActionScreen(valueActionViewModel) + val context = LocalContext.current + val activity = context.findActivity() + + val lifecycleOwner = LocalLifecycleOwner.current + val valueActionViewModel = viewModel() + + Scaffold( + modifier = modifier.background(MaterialTheme.colors.background), + vignette = { Vignette(vignettePosition = VignettePosition.TopAndBottom) }, + timeText = { + TimeText() + }, + ) { + ValueActionScreen(valueActionViewModel) + } + + LaunchedEffect(actionType, audioStreamType) { + valueActionViewModel.onActionUpdated(actionType, audioStreamType) + } + + LaunchedEffect(context) { + valueActionViewModel.initActivityContext(activity) + } + + LaunchedEffect(lifecycleOwner) { + lifecycleOwner.lifecycleScope.launch { + valueActionViewModel.eventFlow.collect { event -> + when (event.eventType) { + WearableListenerViewModel.ACTION_UPDATECONNECTIONSTATUS -> { + val connectionStatus = WearConnectionStatus.valueOf( + event.data.getInt( + WearableListenerViewModel.EXTRA_CONNECTIONSTATUS, + 0 + ) + ) + + when (connectionStatus) { + WearConnectionStatus.DISCONNECTED -> { + // Navigate + activity.startActivity( + Intent( + activity, + PhoneSyncActivity::class.java + ) + ) + activity.finishAffinity() + } + + WearConnectionStatus.APPNOTINSTALLED -> { + // Open store on remote device + valueActionViewModel.openPlayStore(activity) + + // Navigate + activity.startActivity( + Intent( + activity, + PhoneSyncActivity::class.java + ) + ) + activity.finishAffinity() + } + + else -> {} + } + } + + WearableHelper.ActionsPath -> { + val jsonData = + event.data.getString(WearableListenerViewModel.EXTRA_ACTIONDATA) + val action = JSONParser.deserializer(jsonData, Action::class.java) + + val actionSuccessful = action?.isActionSuccessful ?: false + val actionStatus = action?.actionStatus ?: ActionStatus.UNKNOWN + + if (!actionSuccessful) { + lifecycleOwner.lifecycleScope.launch { + when (actionStatus) { + ActionStatus.UNKNOWN, ActionStatus.FAILURE -> { + CustomConfirmationOverlay() + .setType(CustomConfirmationOverlay.CUSTOM_ANIMATION) + .setCustomDrawable( + ContextCompat.getDrawable( + activity, + R.drawable.ws_full_sad + ) + ) + .setMessage(activity.getString(R.string.error_actionfailed)) + .showOn(activity) + } + + ActionStatus.PERMISSION_DENIED -> { + CustomConfirmationOverlay() + .setType(CustomConfirmationOverlay.CUSTOM_ANIMATION) + .setCustomDrawable( + ContextCompat.getDrawable( + activity, + R.drawable.ws_full_sad + ) + ) + .setMessage(activity.getString(R.string.error_permissiondenied)) + .showOn(activity) + + valueActionViewModel.openAppOnPhone( + activity, + false + ) + } + + ActionStatus.TIMEOUT -> { + CustomConfirmationOverlay() + .setType(CustomConfirmationOverlay.CUSTOM_ANIMATION) + .setCustomDrawable( + ContextCompat.getDrawable( + activity, + R.drawable.ws_full_sad + ) + ) + .setMessage(activity.getString(R.string.error_sendmessage)) + .showOn(activity) + } + + ActionStatus.SUCCESS -> {} + else -> {} + } + } + } + } + + WearableHelper.AudioVolumePath, WearableHelper.ValueStatusSetPath -> { + val status = + event.data.getSerializable(WearableListenerViewModel.EXTRA_STATUS) as ActionStatus + + when (status) { + ActionStatus.UNKNOWN, ActionStatus.FAILURE -> { + CustomConfirmationOverlay() + .setType(CustomConfirmationOverlay.CUSTOM_ANIMATION) + .setCustomDrawable( + ContextCompat.getDrawable( + activity, + R.drawable.ws_full_sad + ) + ) + .setMessage(activity.getString(R.string.error_actionfailed)) + .showOn(activity) + } + + ActionStatus.PERMISSION_DENIED -> { + CustomConfirmationOverlay() + .setType(CustomConfirmationOverlay.CUSTOM_ANIMATION) + .setCustomDrawable( + ContextCompat.getDrawable( + activity, + R.drawable.ws_full_sad + ) + ) + .setMessage(activity.getString(R.string.error_permissiondenied)) + .showOn(activity) + + valueActionViewModel.openAppOnPhone(activity, false) + } + + else -> {} + } + } + } + } } } + + LaunchedEffect(Unit) { + // Update statuses + valueActionViewModel.refreshState() + } } @OptIn(ExperimentalHorologistApi::class) diff --git a/wear/src/main/java/com/thewizrd/simplewear/viewmodels/CallManagerViewModel.kt b/wear/src/main/java/com/thewizrd/simplewear/viewmodels/CallManagerViewModel.kt index f707191..222f502 100644 --- a/wear/src/main/java/com/thewizrd/simplewear/viewmodels/CallManagerViewModel.kt +++ b/wear/src/main/java/com/thewizrd/simplewear/viewmodels/CallManagerViewModel.kt @@ -1,8 +1,6 @@ package com.thewizrd.simplewear.viewmodels -import android.app.Activity import android.app.Application -import android.content.Intent import android.graphics.Bitmap import android.os.Bundle import android.os.CountDownTimer @@ -16,8 +14,6 @@ import com.google.android.gms.wearable.DataMapItem import com.google.android.gms.wearable.MessageEvent import com.google.android.gms.wearable.Wearable import com.thewizrd.shared_resources.actions.ActionStatus -import com.thewizrd.shared_resources.actions.Actions -import com.thewizrd.shared_resources.actions.AudioStreamType import com.thewizrd.shared_resources.helpers.InCallUIHelper import com.thewizrd.shared_resources.helpers.WearConnectionStatus import com.thewizrd.shared_resources.helpers.WearableHelper @@ -28,7 +24,6 @@ import com.thewizrd.shared_resources.utils.bytesToBool import com.thewizrd.shared_resources.utils.bytesToString import com.thewizrd.shared_resources.utils.charToBytes import com.thewizrd.simplewear.R -import com.thewizrd.simplewear.ValueActionActivity import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -316,13 +311,6 @@ class CallManagerViewModel(app: Application) : WearableListenerViewModel(app), } } - fun showCallVolumeActivity(activityContext: Activity) { - val intent: Intent = Intent(activityContext, ValueActionActivity::class.java) - .putExtra(EXTRA_ACTION, Actions.VOLUME) - .putExtra(ValueActionActivity.EXTRA_STREAMTYPE, AudioStreamType.VOICE_CALL) - activityContext.startActivityForResult(intent, -1) - } - override fun onCleared() { requestServiceDisconnect() Wearable.getDataClient(appContext).removeListener(this) diff --git a/wear/src/main/java/com/thewizrd/simplewear/viewmodels/MediaPlayerListViewModel.kt b/wear/src/main/java/com/thewizrd/simplewear/viewmodels/MediaPlayerListViewModel.kt index 51b7dd5..b0eb25d 100644 --- a/wear/src/main/java/com/thewizrd/simplewear/viewmodels/MediaPlayerListViewModel.kt +++ b/wear/src/main/java/com/thewizrd/simplewear/viewmodels/MediaPlayerListViewModel.kt @@ -1,6 +1,5 @@ package com.thewizrd.simplewear.viewmodels -import android.app.Activity import android.app.Application import android.os.Bundle import android.os.CountDownTimer @@ -22,8 +21,6 @@ import com.thewizrd.shared_resources.utils.Logger import com.thewizrd.shared_resources.utils.bytesToString import com.thewizrd.simplewear.controls.AppItemViewModel import com.thewizrd.simplewear.helpers.AppItemComparator -import com.thewizrd.simplewear.helpers.showConfirmationOverlay -import com.thewizrd.simplewear.media.MediaPlayerActivity import com.thewizrd.simplewear.preferences.Settings import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -177,22 +174,14 @@ class MediaPlayerListViewModel(app: Application) : WearableListenerViewModel(app } } - fun startMediaApp(activity: Activity, item: AppItemViewModel) { - viewModelScope.launch { - val success = runCatching { - val intent = MediaHelper.createRemoteActivityIntent( - item.packageName!!, - item.activityName!! - ) - startRemoteActivity(intent) - }.getOrDefault(false) - - if (success) { - activity.startActivity(MediaPlayerActivity.buildIntent(activity, item)) - } else { - activity.showConfirmationOverlay(false) - } - } + suspend fun startMediaApp(item: AppItemViewModel): Boolean { + return runCatching { + val intent = MediaHelper.createRemoteActivityIntent( + item.packageName!!, + item.activityName!! + ) + startRemoteActivity(intent) + }.getOrDefault(false) } private fun requestPlayersUpdate() { diff --git a/wear/src/main/java/com/thewizrd/simplewear/viewmodels/WearableListenerViewModel.kt b/wear/src/main/java/com/thewizrd/simplewear/viewmodels/WearableListenerViewModel.kt index 516fadb..f0933cf 100644 --- a/wear/src/main/java/com/thewizrd/simplewear/viewmodels/WearableListenerViewModel.kt +++ b/wear/src/main/java/com/thewizrd/simplewear/viewmodels/WearableListenerViewModel.kt @@ -36,8 +36,6 @@ import com.thewizrd.shared_resources.utils.Logger import com.thewizrd.shared_resources.utils.bytesToString import com.thewizrd.shared_resources.utils.stringToBytes import com.thewizrd.simplewear.App -import com.thewizrd.simplewear.ValueActionActivity -import com.thewizrd.simplewear.WearableListenerActivity import com.thewizrd.simplewear.helpers.showConfirmationOverlay import com.thewizrd.simplewear.utils.ErrorMessage import kotlinx.coroutines.channels.BufferOverflow @@ -494,8 +492,6 @@ abstract class WearableListenerViewModel(private val app: Application) : Android * Extra contains Action type to be changed for ValueActionActivity * * @see Actions - * - * @see ValueActionActivity */ const val EXTRA_ACTION = "SimpleWear.Droid.Wear.extra.ACTION" @@ -503,8 +499,6 @@ abstract class WearableListenerViewModel(private val app: Application) : Android * Extra contains Action data (serialized class in JSON) to be passed to BroadcastReceiver or Activity * * @see Action - * - * @see WearableListenerActivity */ const val EXTRA_ACTIONDATA = "SimpleWear.Droid.Wear.extra.ACTION_DATA" @@ -519,8 +513,6 @@ abstract class WearableListenerViewModel(private val app: Application) : Android * Extra contains connection status for WearOS device and connected phone * * @see WearConnectionStatus - * - * @see WearableListenerActivity */ const val EXTRA_CONNECTIONSTATUS = "SimpleWear.Droid.Wear.extra.CONNECTION_STATUS" } diff --git a/wear/src/main/java/com/thewizrd/simplewear/wearable/WearableDataListenerService.kt b/wear/src/main/java/com/thewizrd/simplewear/wearable/WearableDataListenerService.kt index d6ce198..1a256c6 100644 --- a/wear/src/main/java/com/thewizrd/simplewear/wearable/WearableDataListenerService.kt +++ b/wear/src/main/java/com/thewizrd/simplewear/wearable/WearableDataListenerService.kt @@ -20,17 +20,27 @@ import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.graphics.drawable.IconCompat import androidx.wear.ongoing.OngoingActivity import androidx.wear.ongoing.Status -import com.google.android.gms.wearable.* +import com.google.android.gms.wearable.CapabilityInfo +import com.google.android.gms.wearable.DataEvent +import com.google.android.gms.wearable.DataEventBuffer +import com.google.android.gms.wearable.DataItem +import com.google.android.gms.wearable.DataMapItem +import com.google.android.gms.wearable.MessageEvent +import com.google.android.gms.wearable.Node +import com.google.android.gms.wearable.Wearable +import com.google.android.gms.wearable.WearableListenerService +import com.thewizrd.shared_resources.actions.Actions import com.thewizrd.shared_resources.helpers.InCallUIHelper import com.thewizrd.shared_resources.helpers.MediaHelper import com.thewizrd.shared_resources.helpers.WearableHelper import com.thewizrd.shared_resources.utils.Logger import com.thewizrd.shared_resources.utils.stringToBytes -import com.thewizrd.simplewear.CallManagerActivity +import com.thewizrd.simplewear.DashboardActivity import com.thewizrd.simplewear.PhoneSyncActivity import com.thewizrd.simplewear.R import com.thewizrd.simplewear.media.MediaPlayerActivity import com.thewizrd.simplewear.preferences.Settings +import com.thewizrd.simplewear.viewmodels.WearableListenerViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.delay @@ -281,7 +291,9 @@ class WearableDataListenerService : WearableListenerService() { private fun getCallControllerIntent(): PendingIntent { return PendingIntent.getActivity( this, 1000, - Intent(this, CallManagerActivity::class.java), + Intent(this, DashboardActivity::class.java).apply { + putExtra(WearableListenerViewModel.EXTRA_ACTION, Actions.PHONE) + }, PendingIntent.FLAG_IMMUTABLE ) } @@ -316,8 +328,10 @@ class WearableDataListenerService : WearableListenerService() { .setShortLabel(getString(R.string.title_callcontroller)) .setIcon(IconCompat.createWithResource(this, R.drawable.ic_phone_simpleblue)) .setIntent( - Intent(this, CallManagerActivity::class.java) - .setAction(Intent.ACTION_VIEW) + Intent(this, DashboardActivity::class.java) + .setAction(Intent.ACTION_VIEW).apply { + putExtra(WearableListenerViewModel.EXTRA_ACTION, Actions.PHONE) + } ) .setLocusId(LocusIdCompat(CALLS_LOCUS_ID)) .build() diff --git a/wear/src/main/java/com/thewizrd/simplewear/wearable/tiles/unofficial/MediaPlayerTileProviderService.kt b/wear/src/main/java/com/thewizrd/simplewear/wearable/tiles/unofficial/MediaPlayerTileProviderService.kt index 5f2a36a..20902ed 100644 --- a/wear/src/main/java/com/thewizrd/simplewear/wearable/tiles/unofficial/MediaPlayerTileProviderService.kt +++ b/wear/src/main/java/com/thewizrd/simplewear/wearable/tiles/unofficial/MediaPlayerTileProviderService.kt @@ -18,8 +18,8 @@ import com.thewizrd.shared_resources.helpers.WearableHelper import com.thewizrd.shared_resources.helpers.toImmutableCompatFlag import com.thewizrd.shared_resources.media.PlaybackState import com.thewizrd.shared_resources.utils.* -import com.thewizrd.simplewear.MediaPlayerListActivity import com.thewizrd.simplewear.R +import com.thewizrd.simplewear.media.MediaPlayerActivity import kotlinx.coroutines.* import kotlinx.coroutines.tasks.await import timber.log.Timber @@ -216,7 +216,7 @@ class MediaPlayerTileProviderService : TileProviderService(), } private fun getTapIntent(context: Context): PendingIntent { - val onClickIntent = Intent(context.applicationContext, MediaPlayerListActivity::class.java) + val onClickIntent = Intent(context.applicationContext, MediaPlayerActivity::class.java) return PendingIntent.getActivity(context, 0, onClickIntent, PendingIntent.FLAG_IMMUTABLE) } diff --git a/wear/src/main/res/layout/curved_time_text.xml b/wear/src/main/res/layout/curved_time_text.xml deleted file mode 100644 index 6e660fe..0000000 --- a/wear/src/main/res/layout/curved_time_text.xml +++ /dev/null @@ -1,48 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/wear/src/main/res/layout/straight_time_text.xml b/wear/src/main/res/layout/straight_time_text.xml deleted file mode 100644 index 28d5da6..0000000 --- a/wear/src/main/res/layout/straight_time_text.xml +++ /dev/null @@ -1,62 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/wear/src/main/res/layout/swipe_dismiss_layout.xml b/wear/src/main/res/layout/swipe_dismiss_layout.xml deleted file mode 100644 index babddb3..0000000 --- a/wear/src/main/res/layout/swipe_dismiss_layout.xml +++ /dev/null @@ -1,8 +0,0 @@ - - \ No newline at end of file diff --git a/wear/src/main/res/values/styles.xml b/wear/src/main/res/values/styles.xml index 1b40df5..1af9d00 100644 --- a/wear/src/main/res/values/styles.xml +++ b/wear/src/main/res/values/styles.xml @@ -37,6 +37,13 @@ @color/ic_launcher_background + +