?
) {
- mControls = controller.transportControls
- mQueueItems = queueItems ?: emptyList()
- mActiveQueueItemId = runCatching {
- controller.playbackState?.activeQueueItemId
- }.onFailure {
- Logger.writeLine(Log.ERROR, it)
- }.getOrNull() ?: MediaSessionCompat.QueueItem.UNKNOWN_ID.toLong()
+ if (queueItems == null || !sequenceEqual(mQueueItems, queueItems)) {
+ mControls = controller.transportControls
+ mQueueItems = queueItems ?: emptyList()
+ mActiveQueueItemId = runCatching {
+ controller.playbackState?.activeQueueItemId
+ }.onFailure {
+ Logger.writeLine(Log.ERROR, it)
+ }.getOrNull() ?: MediaSessionCompat.QueueItem.UNKNOWN_ID.toLong()
+ onDatasetChanged()
+ }
+ }
+
+ fun clear() {
+ mControls = null
+ mQueueItems = emptyList()
+ mActiveQueueItemId = MediaSessionCompat.QueueItem.UNKNOWN_ID.toLong()
onDatasetChanged()
}
}
diff --git a/mobile/src/main/java/com/thewizrd/simplewear/media/MediaUtils.kt b/mobile/src/main/java/com/thewizrd/simplewear/media/MediaUtils.kt
new file mode 100644
index 00000000..493b6535
--- /dev/null
+++ b/mobile/src/main/java/com/thewizrd/simplewear/media/MediaUtils.kt
@@ -0,0 +1,72 @@
+package com.thewizrd.simplewear.media
+
+import android.os.Build
+import android.support.v4.media.session.PlaybackStateCompat
+import com.thewizrd.shared_resources.media.PlaybackState
+
+fun PlaybackStateCompat.isPlaybackStateActive(): Boolean {
+ return when (state) {
+ PlaybackStateCompat.STATE_BUFFERING,
+ PlaybackStateCompat.STATE_CONNECTING,
+ PlaybackStateCompat.STATE_FAST_FORWARDING,
+ PlaybackStateCompat.STATE_PLAYING,
+ PlaybackStateCompat.STATE_REWINDING,
+ PlaybackStateCompat.STATE_SKIPPING_TO_NEXT,
+ PlaybackStateCompat.STATE_SKIPPING_TO_PREVIOUS,
+ PlaybackStateCompat.STATE_SKIPPING_TO_QUEUE_ITEM -> {
+ true
+ }
+
+ else -> false
+ }
+}
+
+fun android.media.session.PlaybackState.isPlaybackStateActive(): Boolean {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ this.isActive
+ } else {
+ when (state) {
+ android.media.session.PlaybackState.STATE_FAST_FORWARDING,
+ android.media.session.PlaybackState.STATE_REWINDING,
+ android.media.session.PlaybackState.STATE_SKIPPING_TO_PREVIOUS,
+ android.media.session.PlaybackState.STATE_SKIPPING_TO_NEXT,
+ android.media.session.PlaybackState.STATE_SKIPPING_TO_QUEUE_ITEM,
+ android.media.session.PlaybackState.STATE_BUFFERING,
+ android.media.session.PlaybackState.STATE_CONNECTING,
+ android.media.session.PlaybackState.STATE_PLAYING -> true
+
+ else -> false
+ }
+ }
+}
+
+fun PlaybackStateCompat.toPlaybackState(): PlaybackState {
+ return when (state) {
+ PlaybackStateCompat.STATE_NONE -> {
+ PlaybackState.NONE
+ }
+
+ PlaybackStateCompat.STATE_BUFFERING,
+ PlaybackStateCompat.STATE_CONNECTING -> {
+ PlaybackState.LOADING
+ }
+
+ PlaybackStateCompat.STATE_PLAYING,
+ PlaybackStateCompat.STATE_FAST_FORWARDING,
+ PlaybackStateCompat.STATE_REWINDING,
+ PlaybackStateCompat.STATE_SKIPPING_TO_NEXT,
+ PlaybackStateCompat.STATE_SKIPPING_TO_PREVIOUS,
+ PlaybackStateCompat.STATE_SKIPPING_TO_QUEUE_ITEM -> {
+ PlaybackState.PLAYING
+ }
+
+ PlaybackStateCompat.STATE_PAUSED,
+ PlaybackStateCompat.STATE_STOPPED -> {
+ PlaybackState.PAUSED
+ }
+
+ else -> {
+ PlaybackState.NONE
+ }
+ }
+}
\ No newline at end of file
diff --git a/mobile/src/main/java/com/thewizrd/simplewear/preferences/Settings.kt b/mobile/src/main/java/com/thewizrd/simplewear/preferences/Settings.kt
index 4d570a96..2ed97941 100644
--- a/mobile/src/main/java/com/thewizrd/simplewear/preferences/Settings.kt
+++ b/mobile/src/main/java/com/thewizrd/simplewear/preferences/Settings.kt
@@ -1,8 +1,7 @@
package com.thewizrd.simplewear.preferences
import androidx.core.content.edit
-import androidx.preference.PreferenceManager
-import com.thewizrd.simplewear.App
+import com.thewizrd.shared_resources.appLib
object Settings {
private const val KEY_LOADAPPICONS = "key_loadappicons"
@@ -11,49 +10,41 @@ object Settings {
private const val KEY_VERSIONCODE = "key_versioncode"
fun isLoadAppIcons(): Boolean {
- val preferences = PreferenceManager.getDefaultSharedPreferences(App.instance.appContext)
- return preferences.getBoolean(KEY_LOADAPPICONS, false)
+ return appLib.preferences.getBoolean(KEY_LOADAPPICONS, false)
}
fun setLoadAppIcons(value: Boolean) {
- val preferences = PreferenceManager.getDefaultSharedPreferences(App.instance.appContext)
- preferences.edit {
+ appLib.preferences.edit {
putBoolean(KEY_LOADAPPICONS, value)
}
}
fun isBridgeMediaEnabled(): Boolean {
- val preferences = PreferenceManager.getDefaultSharedPreferences(App.instance.appContext)
- return preferences.getBoolean(KEY_BRIDGEMEDIA, false)
+ return appLib.preferences.getBoolean(KEY_BRIDGEMEDIA, false)
}
fun setBridgeMediaEnabled(value: Boolean) {
- val preferences = PreferenceManager.getDefaultSharedPreferences(App.instance.appContext)
- preferences.edit {
+ appLib.preferences.edit {
putBoolean(KEY_BRIDGEMEDIA, value)
}
}
fun isBridgeCallsEnabled(): Boolean {
- val preferences = PreferenceManager.getDefaultSharedPreferences(App.instance.appContext)
- return preferences.getBoolean(KEY_BRIDGECALLS, false)
+ return appLib.preferences.getBoolean(KEY_BRIDGECALLS, false)
}
fun setBridgeCallsEnabled(value: Boolean) {
- val preferences = PreferenceManager.getDefaultSharedPreferences(App.instance.appContext)
- preferences.edit {
+ appLib.preferences.edit {
putBoolean(KEY_BRIDGECALLS, value)
}
}
fun getVersionCode(): Long {
- val preferences = PreferenceManager.getDefaultSharedPreferences(App.instance.appContext)
- return preferences.getLong(KEY_VERSIONCODE, 0)
+ return appLib.preferences.getLong(KEY_VERSIONCODE, 0)
}
fun setVersionCode(value: Long) {
- val preferences = PreferenceManager.getDefaultSharedPreferences(App.instance.appContext)
- preferences.edit {
+ appLib.preferences.edit {
putLong(KEY_VERSIONCODE, value)
}
}
diff --git a/mobile/src/main/java/com/thewizrd/simplewear/receivers/PhoneBroadcastReceiver.kt b/mobile/src/main/java/com/thewizrd/simplewear/receivers/PhoneBroadcastReceiver.kt
index d24423a3..08593a3c 100644
--- a/mobile/src/main/java/com/thewizrd/simplewear/receivers/PhoneBroadcastReceiver.kt
+++ b/mobile/src/main/java/com/thewizrd/simplewear/receivers/PhoneBroadcastReceiver.kt
@@ -7,6 +7,7 @@ import android.os.Bundle
import android.util.Log
import com.thewizrd.shared_resources.actions.Action
import com.thewizrd.shared_resources.actions.EXTRA_ACTION_DATA
+import com.thewizrd.shared_resources.appLib
import com.thewizrd.shared_resources.helpers.WearableHelper
import com.thewizrd.shared_resources.utils.AnalyticsLogger
import com.thewizrd.shared_resources.utils.JSONParser
@@ -19,7 +20,6 @@ import com.thewizrd.simplewear.services.TorchService
import com.thewizrd.simplewear.wearable.WearableManager
import com.thewizrd.simplewear.wearable.WearableWorker
import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
class PhoneBroadcastReceiver : BroadcastReceiver() {
@@ -83,7 +83,7 @@ class PhoneBroadcastReceiver : BroadcastReceiver() {
action.actionType.name
)
- GlobalScope.launch(Dispatchers.Default) {
+ appLib.appScope.launch(Dispatchers.Default) {
WearableManager(context.applicationContext).run {
performAction(null, action)
}
diff --git a/mobile/src/main/java/com/thewizrd/simplewear/services/CallControllerService.kt b/mobile/src/main/java/com/thewizrd/simplewear/services/CallControllerService.kt
index f2044a49..b865c321 100644
--- a/mobile/src/main/java/com/thewizrd/simplewear/services/CallControllerService.kt
+++ b/mobile/src/main/java/com/thewizrd/simplewear/services/CallControllerService.kt
@@ -25,19 +25,18 @@ import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import androidx.lifecycle.LifecycleService
-import com.google.android.gms.wearable.DataClient
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.PutDataRequest
import com.google.android.gms.wearable.Wearable
+import com.thewizrd.shared_resources.data.CallState
import com.thewizrd.shared_resources.helpers.InCallUIHelper
-import com.thewizrd.shared_resources.helpers.WearableHelper
import com.thewizrd.shared_resources.helpers.toImmutableCompatFlag
+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.bytesToBool
import com.thewizrd.shared_resources.utils.bytesToChar
+import com.thewizrd.shared_resources.utils.stringToBytes
import com.thewizrd.simplewear.R
import com.thewizrd.simplewear.helpers.PhoneStatusHelper
import com.thewizrd.simplewear.preferences.Settings
@@ -48,9 +47,8 @@ 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.concurrent.Executors
class CallControllerService : LifecycleService(), MessageClient.OnMessageReceivedListener,
@@ -66,11 +64,11 @@ class CallControllerService : LifecycleService(), MessageClient.OnMessageReceive
private val scope = CoroutineScope(
SupervisorJob() + Executors.newSingleThreadExecutor().asCoroutineDispatcher()
)
+ private var isConnected: Boolean = false
private var disconnectJob: Job? = null
private lateinit var mMainHandler: Handler
private lateinit var mWearableManager: WearableManager
- private lateinit var mDataClient: DataClient
private lateinit var mMessageClient: MessageClient
private var mPhoneStateListener: PhoneStateListener? = null
@@ -242,7 +240,6 @@ class CallControllerService : LifecycleService(), MessageClient.OnMessageReceive
mTelecomManager = getSystemService(TelecomManager::class.java)
mWearableManager = WearableManager(this)
- mDataClient = Wearable.getDataClient(this)
mMessageClient = Wearable.getMessageClient(this)
mMessageClient.addListener(this)
@@ -284,29 +281,36 @@ class CallControllerService : LifecycleService(), MessageClient.OnMessageReceive
disconnectJob?.cancel()
startForeground(getForegroundNotification())
- Logger.writeLine(Log.INFO, "${TAG}: Intent action = ${intent?.action}")
+ Logger.info(TAG, "Intent action = ${intent?.action}")
when (intent?.action) {
ACTION_CONNECTCONTROLLER -> {
scope.launch {
- mTelecomMediaCtrlr = mMediaSessionManager.getActiveSessions(
- NotificationListener.getComponentName(this@CallControllerService)
- ).firstOrNull {
- it.packageName == "com.android.server.telecom"
- }
- // Send call state
- sendCallState(mTelephonyManager.callState, "")
- mWearableManager.sendMessage(
- null,
- InCallUIHelper.MuteMicStatusPath,
- isMicrophoneMute().booleanToBytes()
- )
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ if (!isConnected) {
+ isConnected = true
+
+ if (mTelecomMediaCtrlr == null) {
+ mTelecomMediaCtrlr = mMediaSessionManager.getActiveSessions(
+ NotificationListener.getComponentName(this@CallControllerService)
+ ).firstOrNull {
+ it.packageName == "com.android.server.telecom"
+ }
+ }
+
+ // Send call state
+ sendCallState(mTelephonyManager.callState, "")
mWearableManager.sendMessage(
null,
- InCallUIHelper.SpeakerphoneStatusPath,
- isSpeakerPhoneEnabled().booleanToBytes()
+ InCallUIHelper.MuteMicStatusPath,
+ isMicrophoneMute().booleanToBytes()
)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ mWearableManager.sendMessage(
+ null,
+ InCallUIHelper.SpeakerphoneStatusPath,
+ isSpeakerPhoneEnabled().booleanToBytes()
+ )
+ }
}
}
}
@@ -316,7 +320,11 @@ class CallControllerService : LifecycleService(), MessageClient.OnMessageReceive
disconnectJob = scope.launch {
// Delay in case service was just started as foreground
delay(1500)
- stopSelf()
+
+ if (isActive) {
+ stopSelf()
+ isConnected = false
+ }
}
}
}
@@ -357,11 +365,9 @@ class CallControllerService : LifecycleService(), MessageClient.OnMessageReceive
private fun removeCallState() {
scope.launch {
- Timber.tag(TAG).d("removeCallState")
+ Logger.debug(TAG, "removeCallState")
runCatching {
- mDataClient.deleteDataItems(
- WearableHelper.getWearDataUri(InCallUIHelper.CallStatePath)
- ).await()
+ sendCallState(nodeID = null)
}.onFailure {
Logger.writeLine(Log.ERROR, it)
}
@@ -385,12 +391,12 @@ class CallControllerService : LifecycleService(), MessageClient.OnMessageReceive
)
}.onFailure {
if (it is SecurityException) {
- Logger.writeLine(
- Log.WARN,
- "${TAG}: registerPhoneStateListener - missing read_call_state permission"
+ Logger.warn(
+ TAG,
+ "registerPhoneStateListener - missing read_call_state permission"
)
} else {
- Logger.writeLine(Log.ERROR, it)
+ Logger.error(TAG, it)
}
}
} else {
@@ -414,12 +420,12 @@ class CallControllerService : LifecycleService(), MessageClient.OnMessageReceive
tm.listen(mPhoneStateListener!!, PhoneStateListener.LISTEN_CALL_STATE)
}.onFailure {
if (it is SecurityException) {
- Logger.writeLine(
- Log.WARN,
- "${TAG}: registerPhoneStateListener - missing read_call_state permission"
+ Logger.warn(
+ TAG,
+ "registerPhoneStateListener - missing read_call_state permission"
)
} else {
- Logger.writeLine(Log.ERROR, it)
+ Logger.error(TAG, it)
}
}
}
@@ -448,12 +454,12 @@ class CallControllerService : LifecycleService(), MessageClient.OnMessageReceive
)
}.onFailure {
if (it is SecurityException) {
- Logger.writeLine(
- Log.WARN,
- "${TAG}: registerMediaControllerListener - missing notification permission"
+ Logger.warn(
+ TAG,
+ "registerMediaControllerListener - missing notification permission"
)
} else {
- Logger.writeLine(Log.ERROR, it)
+ Logger.error(TAG, it)
}
}
}
@@ -481,16 +487,11 @@ class CallControllerService : LifecycleService(), MessageClient.OnMessageReceive
}
private suspend fun sendCallState(state: Int? = null, phoneNo: String? = null) {
- val mapRequest = PutDataMapRequest.create(InCallUIHelper.CallStatePath)
-
- mapRequest.dataMap.putString(
- InCallUIHelper.KEY_CALLERNAME,
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
- OngoingCall.call?.details?.contactDisplayName
- } else {
- null
- } ?: OngoingCall.call?.details?.callerDisplayName ?: phoneNo ?: ""
- )
+ val callerName = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ OngoingCall.call?.details?.contactDisplayName
+ } else {
+ null
+ } ?: OngoingCall.call?.details?.callerDisplayName ?: phoneNo ?: ""
val callState = state ?: OngoingCall.call?.let {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
@@ -501,7 +502,6 @@ class CallControllerService : LifecycleService(), MessageClient.OnMessageReceive
} ?: TelephonyManager.CALL_STATE_IDLE
val callActive = callState != TelephonyManager.CALL_STATE_IDLE
- mapRequest.dataMap.putBoolean(InCallUIHelper.KEY_CALLACTIVE, callActive)
var supportedFeatures = 0
if (supportsSpeakerToggle()) {
@@ -511,30 +511,37 @@ class CallControllerService : LifecycleService(), MessageClient.OnMessageReceive
supportedFeatures += InCallUIHelper.INCALL_FEATURE_DTMF
}
- mapRequest.dataMap.putInt(InCallUIHelper.KEY_SUPPORTEDFEATURES, supportedFeatures)
+ val callStateData = CallState(
+ callerName = callerName,
+ callActive = callActive,
+ supportedFeatures = supportedFeatures
+ )
- mapRequest.setUrgent()
- try {
- mDataClient.deleteDataItems(mapRequest.uri).await()
- mDataClient.putDataItem(mapRequest.asPutDataRequest())
- .await()
- if (callActive) {
- if (Settings.isBridgeCallsEnabled()) {
- mDataClient.putDataItem(
- PutDataRequest.create(InCallUIHelper.CallStateBridgePath).setUrgent()
- ).await()
- }
- } else {
- mDataClient.deleteDataItems(WearableHelper.getWearDataUri(InCallUIHelper.CallStateBridgePath))
- .await()
- }
- } catch (e: Exception) {
- Logger.writeLine(Log.ERROR, e)
+ sendCallState(nodeID = null, callStateData)
+ if (Settings.isBridgeCallsEnabled()) {
+ mWearableManager.sendMessage(
+ null,
+ InCallUIHelper.CallStateBridgePath,
+ callActive.booleanToBytes()
+ )
}
}
+ private suspend fun sendCallState(nodeID: String? = null, callState: CallState = CallState()) {
+ mWearableManager.sendMessage(
+ nodeID,
+ InCallUIHelper.CallStatePath,
+ JSONParser.serializer(callState, CallState::class.java).stringToBytes()
+ )
+ }
+
override fun onMessageReceived(messageEvent: MessageEvent) {
when (messageEvent.path) {
+ InCallUIHelper.CallStatePath -> {
+ scope.launch {
+ sendCallState(mTelephonyManager.callState, "")
+ }
+ }
InCallUIHelper.EndCallPath -> {
sendHangupEvent()
}
diff --git a/mobile/src/main/java/com/thewizrd/simplewear/services/InCallManagerService.kt b/mobile/src/main/java/com/thewizrd/simplewear/services/InCallManagerService.kt
index a90500c4..b1d5d43d 100644
--- a/mobile/src/main/java/com/thewizrd/simplewear/services/InCallManagerService.kt
+++ b/mobile/src/main/java/com/thewizrd/simplewear/services/InCallManagerService.kt
@@ -14,7 +14,7 @@ import android.telephony.TelephonyManager
import androidx.annotation.MainThread
import androidx.annotation.RequiresApi
import androidx.lifecycle.MutableLiveData
-import timber.log.Timber
+import com.thewizrd.shared_resources.utils.Logger
@RequiresApi(Build.VERSION_CODES.S)
class InCallManagerService : InCallService() {
@@ -105,7 +105,7 @@ class InCallManagerAdapter private constructor() {
mInCallService?.setMuted(shouldMute)
true
} else {
- Timber.tag(TAG).e("mute: mInCallService is null")
+ Logger.error(TAG, "mute: mInCallService is null")
false
}
}
@@ -115,7 +115,7 @@ class InCallManagerAdapter private constructor() {
mInCallService?.setSpeakerPhoneEnabled(enableSpeaker)
true
} else {
- Timber.tag(TAG).e("setSpeakerPhoneEnabled: mInCallService is null")
+ Logger.error(TAG, "setSpeakerPhoneEnabled: mInCallService is null")
false
}
}
diff --git a/mobile/src/main/java/com/thewizrd/simplewear/services/NotificationListener.kt b/mobile/src/main/java/com/thewizrd/simplewear/services/NotificationListener.kt
index 2e850f0f..3e06fa6a 100644
--- a/mobile/src/main/java/com/thewizrd/simplewear/services/NotificationListener.kt
+++ b/mobile/src/main/java/com/thewizrd/simplewear/services/NotificationListener.kt
@@ -1,7 +1,9 @@
package com.thewizrd.simplewear.services
+import android.app.NotificationManager
import android.content.ComponentName
import android.content.Context
+import android.os.Build
import android.service.notification.NotificationListenerService
import androidx.core.app.NotificationManagerCompat
@@ -16,9 +18,14 @@ class NotificationListener : NotificationListenerService() {
// sessions, we need an enabled notification listener component.
companion object {
fun isEnabled(context: Context): Boolean {
- return NotificationManagerCompat
- .getEnabledListenerPackages(context)
- .contains(context.packageName)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
+ val notMgr = context.getSystemService(NotificationManager::class.java)
+ return notMgr.isNotificationListenerAccessGranted(getComponentName(context))
+ } else {
+ return NotificationManagerCompat
+ .getEnabledListenerPackages(context)
+ .contains(context.packageName)
+ }
}
fun getComponentName(context: Context): ComponentName {
diff --git a/mobile/src/main/java/com/thewizrd/simplewear/telephony/SubscriptionListener.kt b/mobile/src/main/java/com/thewizrd/simplewear/telephony/SubscriptionListener.kt
index 33649b0e..f9d08262 100644
--- a/mobile/src/main/java/com/thewizrd/simplewear/telephony/SubscriptionListener.kt
+++ b/mobile/src/main/java/com/thewizrd/simplewear/telephony/SubscriptionListener.kt
@@ -12,11 +12,10 @@ import android.telephony.SubscriptionManager
import android.util.Log
import androidx.core.content.PermissionChecker
import com.thewizrd.shared_resources.actions.Actions
+import com.thewizrd.shared_resources.appLib
import com.thewizrd.shared_resources.utils.Logger
-import com.thewizrd.simplewear.App
import com.thewizrd.simplewear.wearable.WearableWorker
import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import java.util.concurrent.Executors
@@ -50,7 +49,7 @@ object SubscriptionListener {
subMgr.addOnSubscriptionsChangedListener(listener.value)
}
- GlobalScope.launch(Dispatchers.Default) {
+ appLib.appScope.launch(Dispatchers.Default) {
listener.value.onSubscriptionsChanged()
}
@@ -65,7 +64,7 @@ object SubscriptionListener {
private fun updateActiveSubscriptions() {
runCatching {
- val appContext = App.instance.appContext
+ val appContext = appLib.context
val subMgr = appContext.getSystemService(SubscriptionManager::class.java)
if (PermissionChecker.checkSelfPermission(
@@ -78,7 +77,7 @@ object SubscriptionListener {
}
// Get active SIMs (subscriptions)
- val subList = subMgr.activeSubscriptionInfoList
+ val subList = subMgr.activeSubscriptionInfoList ?: emptyList()
val activeSubIds = subList.map { it.subscriptionId }
// Remove any subs which are no longer active
@@ -92,7 +91,7 @@ object SubscriptionListener {
}
// Register any new subs
- subMgr.activeSubscriptionInfoList.forEach {
+ subMgr.activeSubscriptionInfoList?.forEach {
if (it.subscriptionId != SubscriptionManager.INVALID_SUBSCRIPTION_ID) {
if (!subMap.containsKey(it.subscriptionId)) {
// Register listener for mobile data setting
@@ -114,7 +113,7 @@ object SubscriptionListener {
}
private fun unregisterLister() {
- unregisterListener(App.instance.appContext)
+ unregisterListener(appLib.context)
}
fun unregisterListener(context: Context) {
diff --git a/mobile/src/main/java/com/thewizrd/simplewear/utils/BrightnessUtils.java b/mobile/src/main/java/com/thewizrd/simplewear/utils/BrightnessUtils.java
new file mode 100644
index 00000000..265d1142
--- /dev/null
+++ b/mobile/src/main/java/com/thewizrd/simplewear/utils/BrightnessUtils.java
@@ -0,0 +1,144 @@
+/*
+ * Copyright (C) 2018 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.utils;
+
+/**
+ * Utility methods for calculating the display brightness.
+ */
+// Source: https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/packages/SettingsLib/DisplayUtils/src/com/android/settingslib/display/BrightnessUtils.java
+public class BrightnessUtils {
+
+ public static final int GAMMA_SPACE_MIN = 0;
+ public static final int GAMMA_SPACE_MAX = 65535;
+
+ // Hybrid Log Gamma constant values
+ private static final float R = 0.5f;
+ private static final float A = 0.17883277f;
+ private static final float B = 0.28466892f;
+ private static final float C = 0.55991073f;
+
+ /**
+ * A function for converting from the gamma space that the slider works in to the
+ * linear space that the setting works in.
+ *
+ * The gamma space effectively provides us a way to make linear changes to the slider that
+ * result in linear changes in perception. If we made changes to the slider in the linear space
+ * then we'd see an approximately logarithmic change in perception (c.f. Fechner's Law).
+ *
+ * Internally, this implements the Hybrid Log Gamma electro-optical transfer function, which is
+ * a slight improvement to the typical gamma transfer function for displays whose max
+ * brightness exceeds the 120 nit reference point, but doesn't set a specific reference
+ * brightness like the PQ function does.
+ *
+ * Note that this transfer function is only valid if the display's backlight value is a linear
+ * control. If it's calibrated to be something non-linear, then a different transfer function
+ * should be used.
+ *
+ * @param val The slider value.
+ * @param min The minimum acceptable value for the setting.
+ * @param max The maximum acceptable value for the setting.
+ * @return The corresponding setting value.
+ */
+ public static final int convertGammaToLinear(int val, int min, int max) {
+ final float normalizedVal = MathUtils.norm(GAMMA_SPACE_MIN, GAMMA_SPACE_MAX, val);
+ final float ret;
+ if (normalizedVal <= R) {
+ ret = MathUtils.sq(normalizedVal / R);
+ } else {
+ ret = MathUtils.exp((normalizedVal - C) / A) + B;
+ }
+
+ // HLG is normalized to the range [0, 12], so we need to re-normalize to the range [0, 1]
+ // in order to derive the correct setting value.
+ return Math.round(MathUtils.lerp(min, max, ret / 12));
+ }
+
+ /**
+ * Version of {@link #convertGammaToLinear} that takes and returns float values.
+ * TODO(flc): refactor Android Auto to use float version
+ *
+ * @param val The slider value.
+ * @param min The minimum acceptable value for the setting.
+ * @param max The maximum acceptable value for the setting.
+ * @return The corresponding setting value.
+ */
+ public static final float convertGammaToLinearFloat(int val, float min, float max) {
+ final float normalizedVal = MathUtils.norm(GAMMA_SPACE_MIN, GAMMA_SPACE_MAX, val);
+ final float ret;
+ if (normalizedVal <= R) {
+ ret = MathUtils.sq(normalizedVal / R);
+ } else {
+ ret = MathUtils.exp((normalizedVal - C) / A) + B;
+ }
+
+ // HLG is normalized to the range [0, 12], ensure that value is within that range,
+ // it shouldn't be out of bounds.
+ final float normalizedRet = MathUtils.constrain(ret, 0, 12);
+
+ // Re-normalize to the range [0, 1]
+ // in order to derive the correct setting value.
+ return MathUtils.lerp(min, max, normalizedRet / 12);
+ }
+
+ /**
+ * A function for converting from the linear space that the setting works in to the
+ * gamma space that the slider works in.
+ *
+ * The gamma space effectively provides us a way to make linear changes to the slider that
+ * result in linear changes in perception. If we made changes to the slider in the linear space
+ * then we'd see an approximately logarithmic change in perception (c.f. Fechner's Law).
+ *
+ * Internally, this implements the Hybrid Log Gamma opto-electronic transfer function, which is
+ * a slight improvement to the typical gamma transfer function for displays whose max
+ * brightness exceeds the 120 nit reference point, but doesn't set a specific reference
+ * brightness like the PQ function does.
+ *
+ * Note that this transfer function is only valid if the display's backlight value is a linear
+ * control. If it's calibrated to be something non-linear, then a different transfer function
+ * should be used.
+ *
+ * @param val The brightness setting value.
+ * @param min The minimum acceptable value for the setting.
+ * @param max The maximum acceptable value for the setting.
+ * @return The corresponding slider value
+ */
+ public static final int convertLinearToGamma(int val, int min, int max) {
+ return convertLinearToGammaFloat((float) val, (float) min, (float) max);
+ }
+
+ /**
+ * Version of {@link #convertLinearToGamma} that takes float values.
+ * TODO: brightnessfloat merge with above method(?)
+ *
+ * @param val The brightness setting value.
+ * @param min The minimum acceptable value for the setting.
+ * @param max The maximum acceptable value for the setting.
+ * @return The corresponding slider value
+ */
+ public static final int convertLinearToGammaFloat(float val, float min, float max) {
+ // For some reason, HLG normalizes to the range [0, 12] rather than [0, 1]
+ final float normalizedVal = MathUtils.norm(min, max, val) * 12;
+ final float ret;
+ if (normalizedVal <= 1f) {
+ ret = MathUtils.sqrt(normalizedVal) * R;
+ } else {
+ ret = A * MathUtils.log(normalizedVal - B) + C;
+ }
+
+ return Math.round(MathUtils.lerp(GAMMA_SPACE_MIN, GAMMA_SPACE_MAX, ret));
+ }
+}
\ No newline at end of file
diff --git a/mobile/src/main/java/com/thewizrd/simplewear/utils/MathUtils.java b/mobile/src/main/java/com/thewizrd/simplewear/utils/MathUtils.java
new file mode 100644
index 00000000..7394fcb7
--- /dev/null
+++ b/mobile/src/main/java/com/thewizrd/simplewear/utils/MathUtils.java
@@ -0,0 +1,279 @@
+/*
+ * Copyright (C) 2009 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.utils;
+
+/**
+ * A class that contains utility methods related to numbers.
+ *
+ * @hide Pending API council approval
+ */
+// Source: https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/core/java/android/util/MathUtils.java
+final class MathUtils {
+ private static final float DEG_TO_RAD = 3.1415926f / 180.0f;
+ private static final float RAD_TO_DEG = 180.0f / 3.1415926f;
+
+ private MathUtils() {
+ }
+
+ public static float abs(float v) {
+ return v > 0 ? v : -v;
+ }
+
+ public static int constrain(int amount, int low, int high) {
+ return amount < low ? low : (amount > high ? high : amount);
+ }
+
+ public static long constrain(long amount, long low, long high) {
+ return amount < low ? low : (amount > high ? high : amount);
+ }
+
+ public static float constrain(float amount, float low, float high) {
+ return amount < low ? low : (amount > high ? high : amount);
+ }
+
+ public static float log(float a) {
+ return (float) Math.log(a);
+ }
+
+ public static float exp(float a) {
+ return (float) Math.exp(a);
+ }
+
+ public static float pow(float a, float b) {
+ return (float) Math.pow(a, b);
+ }
+
+ public static float sqrt(float a) {
+ return (float) Math.sqrt(a);
+ }
+
+ public static float max(float a, float b) {
+ return a > b ? a : b;
+ }
+
+ public static float max(int a, int b) {
+ return a > b ? a : b;
+ }
+
+ public static float max(float a, float b, float c) {
+ return a > b ? (a > c ? a : c) : (b > c ? b : c);
+ }
+
+ public static float max(int a, int b, int c) {
+ return a > b ? (a > c ? a : c) : (b > c ? b : c);
+ }
+
+ public static float min(float a, float b) {
+ return a < b ? a : b;
+ }
+
+ public static float min(int a, int b) {
+ return a < b ? a : b;
+ }
+
+ public static float min(float a, float b, float c) {
+ return a < b ? (a < c ? a : c) : (b < c ? b : c);
+ }
+
+ public static float min(int a, int b, int c) {
+ return a < b ? (a < c ? a : c) : (b < c ? b : c);
+ }
+
+ public static float dist(float x1, float y1, float x2, float y2) {
+ final float x = (x2 - x1);
+ final float y = (y2 - y1);
+ return (float) Math.hypot(x, y);
+ }
+
+ public static float dist(float x1, float y1, float z1, float x2, float y2, float z2) {
+ final float x = (x2 - x1);
+ final float y = (y2 - y1);
+ final float z = (z2 - z1);
+ return (float) Math.sqrt(x * x + y * y + z * z);
+ }
+
+ public static float mag(float a, float b) {
+ return (float) Math.hypot(a, b);
+ }
+
+ public static float mag(float a, float b, float c) {
+ return (float) Math.sqrt(a * a + b * b + c * c);
+ }
+
+ public static float sq(float v) {
+ return v * v;
+ }
+
+ public static float dot(float v1x, float v1y, float v2x, float v2y) {
+ return v1x * v2x + v1y * v2y;
+ }
+
+ public static float cross(float v1x, float v1y, float v2x, float v2y) {
+ return v1x * v2y - v1y * v2x;
+ }
+
+ public static float radians(float degrees) {
+ return degrees * DEG_TO_RAD;
+ }
+
+ public static float degrees(float radians) {
+ return radians * RAD_TO_DEG;
+ }
+
+ public static float acos(float value) {
+ return (float) Math.acos(value);
+ }
+
+ public static float asin(float value) {
+ return (float) Math.asin(value);
+ }
+
+ public static float atan(float value) {
+ return (float) Math.atan(value);
+ }
+
+ public static float atan2(float a, float b) {
+ return (float) Math.atan2(a, b);
+ }
+
+ public static float tan(float angle) {
+ return (float) Math.tan(angle);
+ }
+
+ public static float lerp(float start, float stop, float amount) {
+ return start + (stop - start) * amount;
+ }
+
+ public static float lerp(int start, int stop, float amount) {
+ return lerp((float) start, (float) stop, amount);
+ }
+
+ /**
+ * Returns the interpolation scalar (s) that satisfies the equation: {@code value = }{@link
+ * #lerp}{@code (a, b, s)}
+ *
+ *
If {@code a == b}, then this function will return 0.
+ */
+ public static float lerpInv(float a, float b, float value) {
+ return a != b ? ((value - a) / (b - a)) : 0.0f;
+ }
+
+ /**
+ * Returns the single argument constrained between [0.0, 1.0].
+ */
+ public static float saturate(float value) {
+ return constrain(value, 0.0f, 1.0f);
+ }
+
+ /**
+ * Returns the saturated (constrained between [0, 1]) result of {@link #lerpInv}.
+ */
+ public static float lerpInvSat(float a, float b, float value) {
+ return saturate(lerpInv(a, b, value));
+ }
+
+ /**
+ * Returns an interpolated angle in degrees between a set of start and end
+ * angles.
+ *
+ * Unlike {@link #lerp(float, float, float)}, the direction and distance of
+ * travel is determined by the shortest angle between the start and end
+ * angles. For example, if the starting angle is 0 and the ending angle is
+ * 350, then the interpolated angle will be in the range [0,-10] rather
+ * than [0,350].
+ *
+ * @param start the starting angle in degrees
+ * @param end the ending angle in degrees
+ * @param amount the position between start and end in the range [0,1]
+ * where 0 is the starting angle and 1 is the ending angle
+ * @return the interpolated angle in degrees
+ */
+ public static float lerpDeg(float start, float end, float amount) {
+ final float minAngle = (((end - start) + 180) % 360) - 180;
+ return minAngle * amount + start;
+ }
+
+ public static float norm(float start, float stop, float value) {
+ return (value - start) / (stop - start);
+ }
+
+ public static float map(float minStart, float minStop, float maxStart, float maxStop, float value) {
+ return maxStart + (maxStop - maxStart) * ((value - minStart) / (minStop - minStart));
+ }
+
+ /**
+ * Calculates a value in [rangeMin, rangeMax] that maps value in [valueMin, valueMax] to
+ * returnVal in [rangeMin, rangeMax].
+ *
+ * Always returns a constrained value in the range [rangeMin, rangeMax], even if value is
+ * outside [valueMin, valueMax].
+ *
+ * Eg:
+ * constrainedMap(0f, 100f, 0f, 1f, 0.5f) = 50f
+ * constrainedMap(20f, 200f, 10f, 20f, 20f) = 200f
+ * constrainedMap(20f, 200f, 10f, 20f, 50f) = 200f
+ * constrainedMap(10f, 50f, 10f, 20f, 5f) = 10f
+ *
+ * @param rangeMin minimum of the range that should be returned.
+ * @param rangeMax maximum of the range that should be returned.
+ * @param valueMin minimum of range to map {@code value} to.
+ * @param valueMax maximum of range to map {@code value} to.
+ * @param value to map to the range [{@code valueMin}, {@code valueMax}]. Note, can be outside
+ * this range, resulting in a clamped value.
+ * @return the mapped value, constrained to [{@code rangeMin}, {@code rangeMax}.
+ */
+ public static float constrainedMap(
+ float rangeMin, float rangeMax, float valueMin, float valueMax, float value) {
+ return lerp(rangeMin, rangeMax, lerpInvSat(valueMin, valueMax, value));
+ }
+
+ /**
+ * Perform Hermite interpolation between two values.
+ * Eg:
+ * smoothStep(0, 0.5f, 0.5f) = 1f
+ * smoothStep(0, 0.5f, 0.25f) = 0.5f
+ *
+ * @param start Left edge.
+ * @param end Right edge.
+ * @param x A value between {@code start} and {@code end}.
+ * @return A number between 0 and 1 representing where {@code x} is in the interpolation.
+ */
+ public static float smoothStep(float start, float end, float x) {
+ return constrain((x - start) / (end - start), 0f, 1f);
+ }
+
+ /**
+ * Returns the sum of the two parameters, or throws an exception if the resulting sum would
+ * cause an overflow or underflow.
+ *
+ * @throws IllegalArgumentException when overflow or underflow would occur.
+ */
+ public static int addOrThrow(int a, int b) throws IllegalArgumentException {
+ if (b == 0) {
+ return a;
+ }
+
+ if (b > 0 && a <= (Integer.MAX_VALUE - b)) {
+ return a + b;
+ }
+
+ if (b < 0 && a >= (Integer.MIN_VALUE - b)) {
+ return a + b;
+ }
+ throw new IllegalArgumentException("Addition overflow: " + a + " + " + b);
+ }
+}
\ No newline at end of file
diff --git a/mobile/src/main/java/com/thewizrd/simplewear/wearable/WearableDataListenerService.kt b/mobile/src/main/java/com/thewizrd/simplewear/wearable/WearableDataListenerService.kt
index 8aa69744..39f64933 100644
--- a/mobile/src/main/java/com/thewizrd/simplewear/wearable/WearableDataListenerService.kt
+++ b/mobile/src/main/java/com/thewizrd/simplewear/wearable/WearableDataListenerService.kt
@@ -80,7 +80,7 @@ class WearableDataListenerService : WearableListenerService() {
}
} else if (messageEvent.path.startsWith(MediaHelper.MusicPlayersPath)) {
if (NotificationListener.isEnabled(ctx)) {
- mWearMgr.sendSupportedMusicPlayers()
+ mWearMgr.sendSupportedMusicPlayers(messageEvent.sourceNodeId)
mWearMgr.sendMessage(
messageEvent.sourceNodeId, messageEvent.path,
ActionStatus.SUCCESS.name.stringToBytes()
@@ -212,7 +212,7 @@ class WearableDataListenerService : WearableListenerService() {
}
}
/* InCall Actions */
- else if (messageEvent.path == InCallUIHelper.CallStatePath) {
+ else if (messageEvent.path == InCallUIHelper.ConnectPath) {
if (PhoneStatusHelper.callStatePermissionEnabled(ctx) &&
(Build.VERSION.SDK_INT < Build.VERSION_CODES.O ||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
@@ -256,6 +256,8 @@ class WearableDataListenerService : WearableListenerService() {
mWearMgr.performDPadAction(messageEvent.sourceNodeId, idx)
} else if (messageEvent.path == GestureUIHelper.DPadClickPath && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
mWearMgr.performDPadClick(messageEvent.sourceNodeId)
+ } else if (messageEvent.path == GestureUIHelper.KeyEventPath) {
+ mWearMgr.performKeyEvent(messageEvent.sourceNodeId, messageEvent.data.bytesToInt())
} else if (messageEvent.path == WearableHelper.TimedActionDeletePath) {
val action = Actions.valueOf(messageEvent.data.bytesToString())
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 6e0b8c2a..fb6b6bff 100644
--- a/mobile/src/main/java/com/thewizrd/simplewear/wearable/WearableManager.kt
+++ b/mobile/src/main/java/com/thewizrd/simplewear/wearable/WearableManager.kt
@@ -1,6 +1,7 @@
package com.thewizrd.simplewear.wearable
import android.accessibilityservice.AccessibilityService
+import android.annotation.SuppressLint
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.ComponentName
@@ -13,7 +14,6 @@ import android.graphics.Bitmap
import android.os.Build
import android.os.Bundle
import android.util.Log
-import android.util.TypedValue
import android.view.KeyEvent
import androidx.annotation.RequiresApi
import androidx.annotation.RestrictTo
@@ -21,9 +21,7 @@ import androidx.media.MediaBrowserServiceCompat
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.reflect.TypeToken
import com.google.gson.stream.JsonWriter
@@ -49,14 +47,15 @@ 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.data.AppItemData
+import com.thewizrd.shared_resources.data.AppItemSerializer.serialize
import com.thewizrd.shared_resources.helpers.GestureUIHelper
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.media.MusicPlayersData
+import com.thewizrd.shared_resources.utils.ContextUtils.dpToPx
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
@@ -71,6 +70,7 @@ import com.thewizrd.simplewear.helpers.dispatchScrollLeft
import com.thewizrd.simplewear.helpers.dispatchScrollRight
import com.thewizrd.simplewear.helpers.dispatchScrollUp
import com.thewizrd.simplewear.media.MediaAppControllerUtils
+import com.thewizrd.simplewear.media.isPlaybackStateActive
import com.thewizrd.simplewear.preferences.Settings
import com.thewizrd.simplewear.services.NotificationListener
import com.thewizrd.simplewear.services.WearAccessibilityService
@@ -79,7 +79,6 @@ 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
@@ -271,23 +270,22 @@ class WearableManager(private val mContext: Context) : OnCapabilityChangedListen
}
}
- suspend fun sendSupportedMusicPlayers() {
- val dataClient = Wearable.getDataClient(mContext)
-
+ suspend fun sendSupportedMusicPlayers(nodeID: String) {
val mediaBrowserInfos = mContext.packageManager.queryIntentServices(
Intent(MediaBrowserServiceCompat.SERVICE_INTERFACE),
PackageManager.GET_RESOLVED_FILTER
)
+ val activeSessions = MediaAppControllerUtils.getActiveMediaSessions(
+ mContext,
+ NotificationListener.getComponentName(mContext)
+ )
val activeMediaInfos = MediaAppControllerUtils.getMediaAppsFromControllers(
mContext,
- MediaAppControllerUtils.getActiveMediaSessions(
- mContext,
- NotificationListener.getComponentName(mContext)
- )
+ activeSessions
)
-
- val mapRequest = PutDataMapRequest.create(MediaHelper.MusicPlayersPath)
+ val activeController =
+ activeSessions.firstOrNull { it.playbackState?.isPlaybackStateActive() == true }
// Sort result
Collections.sort(
@@ -296,6 +294,8 @@ class WearableManager(private val mContext: Context) : OnCapabilityChangedListen
)
val supportedPlayers = ArrayList(mediaBrowserInfos.size)
+ val musicPlayers = mutableSetOf()
+ var activePlayerKey: String? = null
suspend fun addPlayerInfo(appInfo: ApplicationInfo) {
val launchIntent =
@@ -315,23 +315,24 @@ class WearableManager(private val mContext: Context) : OnCapabilityChangedListen
var iconBmp: Bitmap? = null
try {
val iconDrwble = mContext.packageManager.getActivityIcon(activityCmpName)
- val size = TypedValue.applyDimension(
- TypedValue.COMPLEX_UNIT_DIP,
- 24f,
- mContext.resources.displayMetrics
- ).toInt()
+ val size = mContext.dpToPx(24f).toInt()
iconBmp = ImageUtils.bitmapFromDrawable(iconDrwble, size, size)
} catch (ignored: PackageManager.NameNotFoundException) {
}
- val map = DataMap()
- map.putString(WearableHelper.KEY_LABEL, label)
- map.putString(WearableHelper.KEY_PKGNAME, appInfo.packageName)
- map.putString(WearableHelper.KEY_ACTIVITYNAME, activityInfo.activityInfo.name)
- iconBmp?.let {
- map.putAsset(WearableHelper.KEY_ICON, it.toAsset())
- }
- mapRequest.dataMap.putDataMap(key, map)
+
+ musicPlayers.add(
+ AppItemData(
+ label = label,
+ packageName = appInfo.packageName,
+ activityName = activityInfo.activityInfo.name,
+ iconBitmap = iconBmp?.toByteArray()
+ )
+ )
supportedPlayers.add(key)
+
+ if (activePlayerKey == null && activeController != null && appInfo.packageName == activeController.packageName) {
+ activePlayerKey = key
+ }
}
}
}
@@ -345,113 +346,28 @@ class WearableManager(private val mContext: Context) : OnCapabilityChangedListen
addPlayerInfo(info)
}
- mapRequest.dataMap.putStringArrayList(MediaHelper.KEY_SUPPORTEDPLAYERS, supportedPlayers)
- mapRequest.setUrgent()
- try {
- dataClient.deleteDataItems(mapRequest.uri).await()
- dataClient
- .putDataItem(mapRequest.asPutDataRequest())
- .await()
- } catch (e: Exception) {
- Logger.writeLine(Log.ERROR, e)
- }
- }
-
- suspend fun sendApps(nodeID: String) {
- if (Settings.isLoadAppIcons()) {
- sendAppsViaChannelWithData(nodeID)
- } else {
- sendAppsViaData()
- }
- }
-
- private suspend fun sendAppsViaData() {
- val dataClient = Wearable.getDataClient(mContext)
- val mainIntent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER)
-
- val infos = mContext.packageManager.queryIntentActivities(mainIntent, 0)
- val mapRequest = PutDataMapRequest.create(WearableHelper.AppsPath)
-
- // Sort result
- infos.sortWith(ResolveInfoActivityInfoComparator(mContext.packageManager))
-
- val availableApps = ArrayList(infos.size)
+ val playersData = MusicPlayersData(
+ musicPlayers = musicPlayers,
+ activePlayerKey = activePlayerKey
+ )
- for (info in infos) {
- val key = String.format("%s/%s", info.activityInfo.packageName, info.activityInfo.name)
- if (!availableApps.contains(key)) {
- val label =
- mContext.packageManager.getApplicationLabel(info.activityInfo.applicationInfo)
- .toString()
- val map = DataMap()
- map.putString(WearableHelper.KEY_LABEL, label)
- map.putString(WearableHelper.KEY_PKGNAME, info.activityInfo.packageName)
- map.putString(WearableHelper.KEY_ACTIVITYNAME, info.activityInfo.name)
- mapRequest.dataMap.putDataMap(key, map)
- availableApps.add(key)
- }
- }
- mapRequest.dataMap.putStringArrayList(WearableHelper.KEY_APPS, availableApps)
- mapRequest.setUrgent()
try {
- dataClient.deleteDataItems(mapRequest.uri).await()
- dataClient
- .putDataItem(mapRequest.asPutDataRequest())
- .await()
- } catch (e: Exception) {
- Logger.writeLine(Log.ERROR, e)
- }
- }
-
- private suspend fun sendAppsViaChannel(nodeID: String) {
- val channelClient = Wearable.getChannelClient(mContext)
- val mainIntent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER)
-
- val infos = mContext.packageManager.queryIntentActivities(mainIntent, 0)
- val appItems = mutableListOf()
-
- // Sort result
- infos.sortWith(ResolveInfoActivityInfoComparator(mContext.packageManager))
-
- val availableApps = ArrayList(infos.size)
-
- for (info in infos) {
- val key = String.format("%s/%s", info.activityInfo.packageName, info.activityInfo.name)
- if (!availableApps.contains(key)) {
- val label =
- mContext.packageManager.getApplicationLabel(info.activityInfo.applicationInfo)
- .toString()
- var iconBmp: Bitmap? = null
- try {
- val iconDrwble =
- info.activityInfo.applicationInfo.loadIcon(mContext.packageManager)
- val size = TypedValue.applyDimension(
- TypedValue.COMPLEX_UNIT_DIP,
- 24f,
- mContext.resources.displayMetrics
- ).toInt()
- iconBmp = ImageUtils.bitmapFromDrawable(iconDrwble, size, size)
- } catch (ignored: PackageManager.NameNotFoundException) {
- }
+ val channelClient = Wearable.getChannelClient(mContext)
- appItems.add(
- AppItemData(
- label,
- info.activityInfo.packageName,
- info.activityInfo.name,
- iconBmp?.toByteArray()
- )
- )
- }
- }
-
- try {
withContext(Dispatchers.IO) {
- val channel = channelClient.openChannel(nodeID, WearableHelper.AppsPath).await()
+ val channel =
+ channelClient.openChannel(nodeID, MediaHelper.MusicPlayersPath).await()
val outputStream = channelClient.getOutputStream(channel).await()
- outputStream.use {
- val writer = JsonWriter(BufferedWriter(OutputStreamWriter(it)))
- appItems.serialize(writer)
+ outputStream.bufferedWriter().use { writer ->
+ writer.write(
+ "data: ${
+ JSONParser.serializer(
+ playersData,
+ MusicPlayersData::class.java
+ )
+ }"
+ )
+ writer.newLine()
writer.flush()
}
channelClient.close(channel)
@@ -461,35 +377,30 @@ class WearableManager(private val mContext: Context) : OnCapabilityChangedListen
}
}
- private suspend fun sendAppsViaChannelWithData(nodeID: String) {
- val dataClient = Wearable.getDataClient(mContext)
+ suspend fun sendApps(nodeID: String) {
val channelClient = Wearable.getChannelClient(mContext)
val mainIntent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER)
val infos = mContext.packageManager.queryIntentActivities(mainIntent, 0)
- val mapRequest = PutDataMapRequest.create(WearableHelper.AppsPath)
+ val appItems = ArrayList(infos.size)
// Sort result
infos.sortWith(ResolveInfoActivityInfoComparator(mContext.packageManager))
val availableApps = ArrayList(infos.size)
- val appItems = ArrayList(infos.size)
for (info in infos) {
val key = String.format("%s/%s", info.activityInfo.packageName, info.activityInfo.name)
if (!availableApps.contains(key)) {
val label = info.activityInfo.loadLabel(mContext.packageManager)
var iconBmp: Bitmap? = null
- try {
- val iconDrwble =
- info.activityInfo.loadIcon(mContext.packageManager)
- val size = TypedValue.applyDimension(
- TypedValue.COMPLEX_UNIT_DIP,
- 24f,
- mContext.resources.displayMetrics
- ).toInt()
- iconBmp = ImageUtils.bitmapFromDrawable(iconDrwble, size, size)
- } catch (ignored: PackageManager.NameNotFoundException) {
+
+ if (Settings.isLoadAppIcons()) {
+ runCatching {
+ val iconDrwble = info.activityInfo.loadIcon(mContext.packageManager)
+ val size = mContext.dpToPx(24f).toInt()
+ iconBmp = ImageUtils.bitmapFromDrawable(iconDrwble, size, size)
+ }
}
appItems.add(
@@ -500,31 +411,11 @@ class WearableManager(private val mContext: Context) : OnCapabilityChangedListen
iconBmp?.toByteArray()
)
)
-
- val map = DataMap()
- map.putString(WearableHelper.KEY_LABEL, label.toString())
- map.putString(WearableHelper.KEY_PKGNAME, info.activityInfo.packageName)
- map.putString(WearableHelper.KEY_ACTIVITYNAME, info.activityInfo.name)
- mapRequest.dataMap.putDataMap(key, map)
- availableApps.add(key)
}
}
- val job1 = scope.async(Dispatchers.IO) {
- mapRequest.dataMap.putStringArrayList(WearableHelper.KEY_APPS, availableApps)
- mapRequest.setUrgent()
- try {
- dataClient.deleteDataItems(mapRequest.uri).await()
- dataClient
- .putDataItem(mapRequest.asPutDataRequest())
- .await()
- } catch (e: Exception) {
- Logger.writeLine(Log.ERROR, e)
- }
- }
-
- val job2 = scope.async(Dispatchers.IO) {
- try {
+ try {
+ withContext(Dispatchers.IO) {
val channel = channelClient.openChannel(nodeID, WearableHelper.AppsPath).await()
val outputStream = channelClient.getOutputStream(channel).await()
outputStream.use {
@@ -533,12 +424,10 @@ class WearableManager(private val mContext: Context) : OnCapabilityChangedListen
writer.flush()
}
channelClient.close(channel)
- } catch (e: Exception) {
- Logger.writeLine(Log.ERROR, e)
}
+ } catch (e: Exception) {
+ Logger.writeLine(Log.ERROR, e)
}
-
- awaitAll(job1, job2)
}
suspend fun launchApp(nodeID: String?, pkgName: String, activityName: String?) {
@@ -808,7 +697,8 @@ class WearableManager(private val mContext: Context) : OnCapabilityChangedListen
suspend fun sendGestureActionStatus(nodeID: String?) {
val state = GestureActionState(
accessibilityEnabled = WearAccessibilityService.isServiceBound(),
- dpadSupported = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
+ dpadSupported = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU,
+ keyEventSupported = true
)
val data = JSONParser.serializer(state, GestureActionState::class.java)
sendMessage(nodeID, GestureUIHelper.GestureStatusPath, data.stringToBytes())
@@ -1023,7 +913,7 @@ class WearableManager(private val mContext: Context) : OnCapabilityChangedListen
Actions.HOTSPOT -> {
tA = action as ToggleAction
- if (WearSettingsHelper.isWearSettingsInstalled() && Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
+ if (WearSettingsHelper.isWearSettingsInstalled()) {
val status = performRemoteAction(action)
if (status == ActionStatus.REMOTE_FAILURE ||
status == ActionStatus.REMOTE_PERMISSION_DENIED
@@ -1108,7 +998,8 @@ class WearableManager(private val mContext: Context) : OnCapabilityChangedListen
} ?: run {
val state = GestureActionState(
accessibilityEnabled = false,
- dpadSupported = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
+ dpadSupported = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU,
+ keyEventSupported = true
)
val data = JSONParser.serializer(state, GestureActionState::class.java)
sendMessage(nodeID, GestureUIHelper.GestureStatusPath, data.stringToBytes())
@@ -1128,7 +1019,8 @@ class WearableManager(private val mContext: Context) : OnCapabilityChangedListen
} ?: run {
val state = GestureActionState(
accessibilityEnabled = false,
- dpadSupported = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
+ dpadSupported = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU,
+ keyEventSupported = true
)
val data = JSONParser.serializer(state, GestureActionState::class.java)
sendMessage(nodeID, GestureUIHelper.GestureStatusPath, data.stringToBytes())
@@ -1142,7 +1034,39 @@ class WearableManager(private val mContext: Context) : OnCapabilityChangedListen
} ?: run {
val state = GestureActionState(
accessibilityEnabled = false,
- dpadSupported = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
+ dpadSupported = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU,
+ keyEventSupported = true
+ )
+ val data = JSONParser.serializer(state, GestureActionState::class.java)
+ sendMessage(nodeID, GestureUIHelper.GestureStatusPath, data.stringToBytes())
+ }
+ }
+
+ @SuppressLint("GestureBackNavigation")
+ suspend fun performKeyEvent(nodeID: String?, key: Int) {
+ WearAccessibilityService.getInstance()?.let { svc ->
+ when (key) {
+ KeyEvent.KEYCODE_BACK -> {
+ svc.performGlobalAction(AccessibilityService.GLOBAL_ACTION_BACK)
+ }
+
+ KeyEvent.KEYCODE_HOME -> {
+ svc.performGlobalAction(AccessibilityService.GLOBAL_ACTION_HOME)
+ }
+
+ KeyEvent.KEYCODE_RECENT_APPS, KeyEvent.KEYCODE_APP_SWITCH -> {
+ svc.performGlobalAction(AccessibilityService.GLOBAL_ACTION_RECENTS)
+ }
+
+ else -> {
+ // TODO: support more events with root?
+ }
+ }
+ } ?: run {
+ val state = GestureActionState(
+ accessibilityEnabled = false,
+ dpadSupported = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU,
+ keyEventSupported = true
)
val data = JSONParser.serializer(state, GestureActionState::class.java)
sendMessage(nodeID, GestureUIHelper.GestureStatusPath, data.stringToBytes())
diff --git a/mobile/src/main/java/com/thewizrd/simplewear/wearable/WearableWorker.kt b/mobile/src/main/java/com/thewizrd/simplewear/wearable/WearableWorker.kt
index 564d5662..1ce8dd45 100644
--- a/mobile/src/main/java/com/thewizrd/simplewear/wearable/WearableWorker.kt
+++ b/mobile/src/main/java/com/thewizrd/simplewear/wearable/WearableWorker.kt
@@ -1,6 +1,7 @@
package com.thewizrd.simplewear.wearable
import android.content.Context
+import android.media.AudioManager
import android.util.Log
import androidx.annotation.StringDef
import androidx.work.CoroutineWorker
@@ -9,6 +10,7 @@ import androidx.work.OneTimeWorkRequest
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import com.thewizrd.shared_resources.actions.Actions
+import com.thewizrd.shared_resources.actions.AudioStreamType
import com.thewizrd.shared_resources.helpers.WearableHelper
import com.thewizrd.shared_resources.utils.Logger
import com.thewizrd.shared_resources.utils.stringToBytes
@@ -26,6 +28,8 @@ class WearableWorker(context: Context, workerParams: WorkerParameters) : Corouti
const val ACTION_SENDACTIONUPDATE = "SimpleWear.Droid.action.SEND_ACTION_UPDATE"
const val ACTION_SENDTIMEDACTIONSUPDATE = "SimpleWear.Droid.action.SEND_TIMEDACTIONS_UPDATE"
const val ACTION_REQUESTBTDISCOVERABLE = "SimpleWear.Droid.action.REQUEST_BT_DISCOVERABLE"
+ const val ACTION_SENDAUDIOSTREAMUPDATE = "SimpleWear.Droid.action.SEND_AUDIOSTREAM_UPDATE"
+ const val ACTION_SENDVALUESTATUSUPDATE = "SimpleWear.Droid.action.SEND_VALUESTATUS_UPDATE"
// Extras
const val EXTRA_STATUS = "SimpleWear.Droid.extra.STATUS"
@@ -72,6 +76,15 @@ class WearableWorker(context: Context, workerParams: WorkerParameters) : Corouti
)
}
+ fun sendValueStatusUpdate(context: Context, action: Actions) {
+ startWork(
+ context.applicationContext, Data.Builder()
+ .putString(KEY_ACTION, ACTION_SENDVALUESTATUSUPDATE)
+ .putInt(EXTRA_ACTION_CHANGED, action.value)
+ .build()
+ )
+ }
+
private fun startWork(context: Context, intentAction: String) {
startWork(context, Data.Builder().putString(KEY_ACTION, intentAction).build())
}
@@ -87,7 +100,12 @@ class WearableWorker(context: Context, workerParams: WorkerParameters) : Corouti
}
}
- @StringDef(ACTION_SENDBATTERYUPDATE, ACTION_SENDWIFIUPDATE, ACTION_SENDBTUPDATE)
+ @StringDef(
+ ACTION_SENDBATTERYUPDATE,
+ ACTION_SENDWIFIUPDATE,
+ ACTION_SENDBTUPDATE,
+ ACTION_SENDAUDIOSTREAMUPDATE
+ )
@Retention(AnnotationRetention.SOURCE)
annotation class StatusAction
@@ -138,6 +156,27 @@ class WearableWorker(context: Context, workerParams: WorkerParameters) : Corouti
ACTION_SENDTIMEDACTIONSUPDATE -> {
mWearMgr.sendTimedActionsStatus(null)
}
+ ACTION_SENDAUDIOSTREAMUPDATE -> {
+ val streamType = when (inputData.getInt(
+ EXTRA_STATUS,
+ AudioManager.USE_DEFAULT_STREAM_TYPE
+ )) {
+ AudioManager.STREAM_MUSIC -> AudioStreamType.MUSIC
+ AudioManager.STREAM_RING -> AudioStreamType.RINGTONE
+ AudioManager.STREAM_VOICE_CALL -> AudioStreamType.VOICE_CALL
+ AudioManager.STREAM_ALARM -> AudioStreamType.ALARM
+ else -> null
+ }
+
+ if (streamType != null) {
+ mWearMgr.sendAudioModeStatus(null, streamType)
+ }
+ }
+
+ ACTION_SENDVALUESTATUSUPDATE -> {
+ val action = Actions.valueOf(inputData.getInt(EXTRA_ACTION_CHANGED, 0))
+ mWearMgr.sendValueStatus(null, action)
+ }
}
}
diff --git a/mobile/src/main/res/values/strings.xml b/mobile/src/main/res/values/strings.xml
index bfc54176..ca12cfcf 100644
--- a/mobile/src/main/res/values/strings.xml
+++ b/mobile/src/main/res/values/strings.xml
@@ -55,7 +55,7 @@
SimpleWear helper app installed
SimpleWear helper app not up-to-date
- https://github.com/SimpleAppProjects/SimpleWear/wiki/SimpleWear-Settings-helper
+ https://simpleappprojects.github.io/SimpleWear/settings-helper
System Settings
Modify System Settings permission granted.
diff --git a/shared_resources/build.gradle b/shared_resources/build.gradle
index 2ba6f524..30966e8a 100644
--- a/shared_resources/build.gradle
+++ b/shared_resources/build.gradle
@@ -26,6 +26,7 @@ android {
buildFeatures {
dataBinding true
viewBinding true
+ buildConfig true
}
compileOptions {
@@ -67,7 +68,7 @@ dependencies {
implementation "androidx.appcompat:appcompat:$appcompat_version"
- implementation 'com.google.android.gms:play-services-wearable:18.2.0'
+ implementation 'com.google.android.gms:play-services-wearable:19.0.0'
implementation platform("com.google.firebase:firebase-bom:$firebase_version")
implementation 'com.google.firebase:firebase-analytics'
diff --git a/shared_resources/consumer-rules.pro b/shared_resources/consumer-rules.pro
index 9d37afba..b75ded66 100644
--- a/shared_resources/consumer-rules.pro
+++ b/shared_resources/consumer-rules.pro
@@ -23,11 +23,13 @@
# Application classes that will be serialized/deserialized over Gson
-keep class com.thewizrd.shared_resources.actions.* { *; }
-keep class * extends com.thewizrd.shared_resources.actions.Action { *; }
+-keep class com.thewizrd.shared_resources.data.* { *; }
-keep class com.thewizrd.shared_resources.media.* { *; }
-keep public enum com.thewizrd.shared_resources.actions.* { ; }
-keep public enum com.thewizrd.shared_resources.helpers.* { ; }
-keep public enum com.thewizrd.shared_resources.media.* { ; }
-keep class androidx.core.util.Pair { *; }
+-keep class com.thewizrd.shared_resources.data.AppItemData { *; }
-keep class com.thewizrd.shared_resources.updates.UpdateInfo { *; }
##---------------Begin: proguard configuration for Gson ----------
diff --git a/shared_resources/src/main/java/com/thewizrd/shared_resources/ApplicationLib.kt b/shared_resources/src/main/java/com/thewizrd/shared_resources/ApplicationLib.kt
index e307b464..379c0d0b 100644
--- a/shared_resources/src/main/java/com/thewizrd/shared_resources/ApplicationLib.kt
+++ b/shared_resources/src/main/java/com/thewizrd/shared_resources/ApplicationLib.kt
@@ -1,10 +1,17 @@
package com.thewizrd.shared_resources
import android.content.Context
+import android.content.SharedPreferences
import com.thewizrd.shared_resources.helpers.AppState
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.MainScope
-interface ApplicationLib {
- val appContext: Context
- val applicationState: AppState
- val isPhone: Boolean
+public lateinit var appLib: ApplicationLib
+
+abstract class ApplicationLib {
+ abstract val context: Context
+ abstract val preferences: SharedPreferences
+ abstract val appState: AppState
+ abstract val isPhone: Boolean
+ open val appScope: CoroutineScope = MainScope()
}
\ No newline at end of file
diff --git a/shared_resources/src/main/java/com/thewizrd/shared_resources/SharedModule.kt b/shared_resources/src/main/java/com/thewizrd/shared_resources/SharedModule.kt
new file mode 100644
index 00000000..e1dcbfce
--- /dev/null
+++ b/shared_resources/src/main/java/com/thewizrd/shared_resources/SharedModule.kt
@@ -0,0 +1,22 @@
+package com.thewizrd.shared_resources
+
+import android.content.Context
+import com.thewizrd.shared_resources.utils.Logger
+
+private lateinit var _sharedDeps: SharedModule
+
+var sharedDeps: SharedModule
+ get() = _sharedDeps
+ set(value) {
+ _sharedDeps = value
+ value.init()
+ }
+
+abstract class SharedModule {
+ abstract val context: Context
+
+ internal fun init() {
+ // Initialize logger
+ Logger.init(context)
+ }
+}
\ No newline at end of file
diff --git a/shared_resources/src/main/java/com/thewizrd/shared_resources/SimpleLibrary.kt b/shared_resources/src/main/java/com/thewizrd/shared_resources/SimpleLibrary.kt
deleted file mode 100644
index cb93389a..00000000
--- a/shared_resources/src/main/java/com/thewizrd/shared_resources/SimpleLibrary.kt
+++ /dev/null
@@ -1,54 +0,0 @@
-package com.thewizrd.shared_resources
-
-import android.annotation.SuppressLint
-import android.content.Context
-
-class SimpleLibrary private constructor() {
- private var mApp: ApplicationLib? = null
- private var mAppContext: Context? = null
-
- private constructor(app: ApplicationLib) : this() {
- this.mApp = app
- this.mAppContext = app.appContext
- }
-
- companion object {
- @SuppressLint("StaticFieldLeak")
- @JvmStatic
- private var sSimpleLib: SimpleLibrary? = null
-
- @JvmStatic
- val instance: SimpleLibrary
- get() {
- if (sSimpleLib == null) {
- sSimpleLib = SimpleLibrary()
- }
-
- return sSimpleLib!!
- }
-
- @JvmStatic
- fun initialize(app: ApplicationLib) {
- if (sSimpleLib == null) {
- sSimpleLib = SimpleLibrary(app)
- } else {
- sSimpleLib!!.mApp = app
- sSimpleLib!!.mAppContext = app.appContext
- }
- }
-
- fun unregister() {
- sSimpleLib = null
- }
- }
-
- val app: ApplicationLib
- get() {
- return mApp!!
- }
-
- val appContext: Context
- get() {
- return mAppContext!!
- }
-}
\ No newline at end of file
diff --git a/shared_resources/src/main/java/com/thewizrd/shared_resources/actions/Action.kt b/shared_resources/src/main/java/com/thewizrd/shared_resources/actions/Action.kt
index 6715c4b5..237b1653 100644
--- a/shared_resources/src/main/java/com/thewizrd/shared_resources/actions/Action.kt
+++ b/shared_resources/src/main/java/com/thewizrd/shared_resources/actions/Action.kt
@@ -60,4 +60,22 @@ abstract class Action(_action: Actions) {
}
}
}
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is Action) return false
+
+ if (isActionSuccessful != other.isActionSuccessful) return false
+ if (actionStatus != other.actionStatus) return false
+ if (actionType != other.actionType) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = isActionSuccessful.hashCode()
+ result = 31 * result + actionStatus.hashCode()
+ result = 31 * result + actionType.hashCode()
+ return result
+ }
}
\ No newline at end of file
diff --git a/shared_resources/src/main/java/com/thewizrd/shared_resources/actions/BatteryStatus.kt b/shared_resources/src/main/java/com/thewizrd/shared_resources/actions/BatteryStatus.kt
index 2fc175d9..3d4977af 100644
--- a/shared_resources/src/main/java/com/thewizrd/shared_resources/actions/BatteryStatus.kt
+++ b/shared_resources/src/main/java/com/thewizrd/shared_resources/actions/BatteryStatus.kt
@@ -1,3 +1,19 @@
package com.thewizrd.shared_resources.actions
-class BatteryStatus(var batteryLevel: Int, var isCharging: Boolean)
\ No newline at end of file
+class BatteryStatus(var batteryLevel: Int, var isCharging: Boolean) {
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is BatteryStatus) return false
+
+ if (batteryLevel != other.batteryLevel) return false
+ if (isCharging != other.isCharging) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = batteryLevel
+ result = 31 * result + isCharging.hashCode()
+ return result
+ }
+}
\ No newline at end of file
diff --git a/shared_resources/src/main/java/com/thewizrd/shared_resources/actions/GestureActionState.kt b/shared_resources/src/main/java/com/thewizrd/shared_resources/actions/GestureActionState.kt
index d6f6406c..36b72e22 100644
--- a/shared_resources/src/main/java/com/thewizrd/shared_resources/actions/GestureActionState.kt
+++ b/shared_resources/src/main/java/com/thewizrd/shared_resources/actions/GestureActionState.kt
@@ -2,5 +2,6 @@ package com.thewizrd.shared_resources.actions
data class GestureActionState(
val accessibilityEnabled: Boolean = false,
- val dpadSupported: Boolean = false
+ val dpadSupported: Boolean = false,
+ val keyEventSupported: Boolean = false
)
\ No newline at end of file
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 8df67b25..4c87d98d 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
@@ -36,4 +36,17 @@ class MultiChoiceAction : Action {
else -> 1
}
}
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is MultiChoiceAction) return false
+
+ if (value != other.value) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ return value
+ }
}
\ No newline at end of file
diff --git a/shared_resources/src/main/java/com/thewizrd/shared_resources/actions/NormalAction.kt b/shared_resources/src/main/java/com/thewizrd/shared_resources/actions/NormalAction.kt
index 214a4d89..aa8dc320 100644
--- a/shared_resources/src/main/java/com/thewizrd/shared_resources/actions/NormalAction.kt
+++ b/shared_resources/src/main/java/com/thewizrd/shared_resources/actions/NormalAction.kt
@@ -1,3 +1,14 @@
package com.thewizrd.shared_resources.actions
-class NormalAction(action: Actions) : Action(action)
\ No newline at end of file
+class NormalAction(action: Actions) : Action(action) {
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is NormalAction) return false
+ if (!super.equals(other)) return false
+ return true
+ }
+
+ override fun hashCode(): Int {
+ return super.hashCode()
+ }
+}
\ No newline at end of file
diff --git a/shared_resources/src/main/java/com/thewizrd/shared_resources/actions/TimedAction.kt b/shared_resources/src/main/java/com/thewizrd/shared_resources/actions/TimedAction.kt
index 8c43228d..a5cde949 100644
--- a/shared_resources/src/main/java/com/thewizrd/shared_resources/actions/TimedAction.kt
+++ b/shared_resources/src/main/java/com/thewizrd/shared_resources/actions/TimedAction.kt
@@ -19,4 +19,22 @@ class TimedAction(var timeInMillis: Long, val action: Action) : Action(Actions.T
}
}
}
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is TimedAction) return false
+ if (!super.equals(other)) return false
+
+ if (timeInMillis != other.timeInMillis) return false
+ if (action != other.action) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = super.hashCode()
+ result = 31 * result + timeInMillis.hashCode()
+ result = 31 * result + action.hashCode()
+ return result
+ }
}
\ No newline at end of file
diff --git a/shared_resources/src/main/java/com/thewizrd/shared_resources/actions/ToggleAction.kt b/shared_resources/src/main/java/com/thewizrd/shared_resources/actions/ToggleAction.kt
index 5e309270..d4ee0bd6 100644
--- a/shared_resources/src/main/java/com/thewizrd/shared_resources/actions/ToggleAction.kt
+++ b/shared_resources/src/main/java/com/thewizrd/shared_resources/actions/ToggleAction.kt
@@ -4,4 +4,17 @@ class ToggleAction(action: Actions, var isEnabled: Boolean) : Action(action) {
init {
isActionSuccessful = true
}
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is ToggleAction) return false
+
+ if (isEnabled != other.isEnabled) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ return isEnabled.hashCode()
+ }
}
\ No newline at end of file
diff --git a/shared_resources/src/main/java/com/thewizrd/shared_resources/actions/ValueAction.kt b/shared_resources/src/main/java/com/thewizrd/shared_resources/actions/ValueAction.kt
index adac3ba7..5c9d80e9 100644
--- a/shared_resources/src/main/java/com/thewizrd/shared_resources/actions/ValueAction.kt
+++ b/shared_resources/src/main/java/com/thewizrd/shared_resources/actions/ValueAction.kt
@@ -1,3 +1,16 @@
package com.thewizrd.shared_resources.actions
-open class ValueAction(action: Actions, var direction: ValueDirection) : Action(action)
\ No newline at end of file
+open class ValueAction(action: Actions, var direction: ValueDirection) : Action(action) {
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is ValueAction) return false
+
+ if (direction != other.direction) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ return direction.hashCode()
+ }
+}
\ No newline at end of file
diff --git a/shared_resources/src/main/java/com/thewizrd/shared_resources/actions/VolumeAction.kt b/shared_resources/src/main/java/com/thewizrd/shared_resources/actions/VolumeAction.kt
index 99a2a868..c7f8edca 100644
--- a/shared_resources/src/main/java/com/thewizrd/shared_resources/actions/VolumeAction.kt
+++ b/shared_resources/src/main/java/com/thewizrd/shared_resources/actions/VolumeAction.kt
@@ -1,3 +1,22 @@
package com.thewizrd.shared_resources.actions
-class VolumeAction(var valueDirection: ValueDirection, var streamType: AudioStreamType?) : ValueAction(Actions.VOLUME, valueDirection)
\ No newline at end of file
+class VolumeAction(var valueDirection: ValueDirection, var streamType: AudioStreamType?) :
+ ValueAction(Actions.VOLUME, valueDirection) {
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is VolumeAction) return false
+ if (!super.equals(other)) return false
+
+ if (valueDirection != other.valueDirection) return false
+ if (streamType != other.streamType) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = super.hashCode()
+ result = 31 * result + valueDirection.hashCode()
+ result = 31 * result + (streamType?.hashCode() ?: 0)
+ return result
+ }
+}
\ No newline at end of file
diff --git a/shared_resources/src/main/java/com/thewizrd/shared_resources/controls/ActionButtonViewModel.kt b/shared_resources/src/main/java/com/thewizrd/shared_resources/controls/ActionButtonViewModel.kt
index a603984a..01215f33 100644
--- a/shared_resources/src/main/java/com/thewizrd/shared_resources/controls/ActionButtonViewModel.kt
+++ b/shared_resources/src/main/java/com/thewizrd/shared_resources/controls/ActionButtonViewModel.kt
@@ -1,5 +1,6 @@
package com.thewizrd.shared_resources.controls
+import android.content.Context
import android.os.Build
import androidx.annotation.DrawableRes
import androidx.annotation.RestrictTo
@@ -297,6 +298,22 @@ class ActionButtonViewModel(val action: Action) {
}
}
+ fun getDescription(context: Context): String {
+ var text = this.actionLabelResId
+ .takeIf { it != 0 }
+ ?.let {
+ context.getString(it)
+ } ?: ""
+
+ this.stateLabelResId
+ .takeIf { it != 0 }
+ ?.let {
+ text = String.format("%s: %s", text, context.getString(it))
+ }
+
+ return text
+ }
+
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
diff --git a/shared_resources/src/main/java/com/thewizrd/shared_resources/data/AppItemData.kt b/shared_resources/src/main/java/com/thewizrd/shared_resources/data/AppItemData.kt
new file mode 100644
index 00000000..4363ba79
--- /dev/null
+++ b/shared_resources/src/main/java/com/thewizrd/shared_resources/data/AppItemData.kt
@@ -0,0 +1,38 @@
+package com.thewizrd.shared_resources.data
+
+import com.google.gson.annotations.SerializedName
+import com.thewizrd.shared_resources.helpers.WearableHelper
+
+data class AppItemData(
+ @SerializedName(WearableHelper.KEY_LABEL) val label: String?,
+ @SerializedName(WearableHelper.KEY_PKGNAME) val packageName: String?,
+ @SerializedName(WearableHelper.KEY_ACTIVITYNAME) val activityName: String?,
+ @SerializedName(WearableHelper.KEY_ICON) val iconBitmap: ByteArray?
+) {
+ val key = "${packageName}|${activityName}"
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (javaClass != other?.javaClass) return false
+
+ other as AppItemData
+
+ if (label != other.label) return false
+ if (packageName != other.packageName) return false
+ if (activityName != other.activityName) return false
+ if (iconBitmap != null) {
+ if (other.iconBitmap == null) return false
+ if (!iconBitmap.contentEquals(other.iconBitmap)) return false
+ } else if (other.iconBitmap != null) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = label?.hashCode() ?: 0
+ result = 31 * result + (packageName?.hashCode() ?: 0)
+ result = 31 * result + (activityName?.hashCode() ?: 0)
+ result = 31 * result + (iconBitmap?.contentHashCode() ?: 0)
+ return result
+ }
+}
\ No newline at end of file
diff --git a/shared_resources/src/main/java/com/thewizrd/shared_resources/helpers/AppItemData.kt b/shared_resources/src/main/java/com/thewizrd/shared_resources/data/AppItemSerializer.kt
similarity index 65%
rename from shared_resources/src/main/java/com/thewizrd/shared_resources/helpers/AppItemData.kt
rename to shared_resources/src/main/java/com/thewizrd/shared_resources/data/AppItemSerializer.kt
index e4d970fc..0a8380fd 100644
--- a/shared_resources/src/main/java/com/thewizrd/shared_resources/helpers/AppItemData.kt
+++ b/shared_resources/src/main/java/com/thewizrd/shared_resources/data/AppItemSerializer.kt
@@ -1,43 +1,11 @@
-package com.thewizrd.shared_resources.helpers
+package com.thewizrd.shared_resources.data
+// TODO: move to data
import android.util.Base64
-import com.google.gson.annotations.SerializedName
import com.google.gson.stream.JsonReader
import com.google.gson.stream.JsonToken
import com.google.gson.stream.JsonWriter
-
-data class AppItemData(
- @SerializedName(WearableHelper.KEY_LABEL) val label: String?,
- @SerializedName(WearableHelper.KEY_PKGNAME) val packageName: String?,
- @SerializedName(WearableHelper.KEY_ACTIVITYNAME) val activityName: String?,
- @SerializedName(WearableHelper.KEY_ICON) val iconBitmap: ByteArray?
-) {
-
- override fun equals(other: Any?): Boolean {
- if (this === other) return true
- if (javaClass != other?.javaClass) return false
-
- other as AppItemData
-
- if (label != other.label) return false
- if (packageName != other.packageName) return false
- if (activityName != other.activityName) return false
- if (iconBitmap != null) {
- if (other.iconBitmap == null) return false
- if (!iconBitmap.contentEquals(other.iconBitmap)) return false
- } else if (other.iconBitmap != null) return false
-
- return true
- }
-
- override fun hashCode(): Int {
- var result = label?.hashCode() ?: 0
- result = 31 * result + (packageName?.hashCode() ?: 0)
- result = 31 * result + (activityName?.hashCode() ?: 0)
- result = 31 * result + (iconBitmap?.contentHashCode() ?: 0)
- return result
- }
-}
+import com.thewizrd.shared_resources.helpers.WearableHelper
object AppItemSerializer {
fun AppItemData.serialize(writer: JsonWriter) {
diff --git a/shared_resources/src/main/java/com/thewizrd/shared_resources/data/CallState.kt b/shared_resources/src/main/java/com/thewizrd/shared_resources/data/CallState.kt
new file mode 100644
index 00000000..01e7eca4
--- /dev/null
+++ b/shared_resources/src/main/java/com/thewizrd/shared_resources/data/CallState.kt
@@ -0,0 +1,31 @@
+package com.thewizrd.shared_resources.data
+
+data class CallState(
+ val callerName: String? = null,
+ val callerBitmap: ByteArray? = null,
+ val callActive: Boolean = false,
+ val supportedFeatures: Int = 0,
+) {
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is CallState) return false
+
+ if (callerName != other.callerName) return false
+ if (callerBitmap != null) {
+ if (other.callerBitmap == null) return false
+ if (!callerBitmap.contentEquals(other.callerBitmap)) return false
+ } else if (other.callerBitmap != null) return false
+ if (callActive != other.callActive) return false
+ if (supportedFeatures != other.supportedFeatures) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = callerName?.hashCode() ?: 0
+ result = 31 * result + (callerBitmap?.contentHashCode() ?: 0)
+ result = 31 * result + callActive.hashCode()
+ result = 31 * result + supportedFeatures
+ return result
+ }
+}
diff --git a/shared_resources/src/main/java/com/thewizrd/shared_resources/helpers/GestureUIHelper.kt b/shared_resources/src/main/java/com/thewizrd/shared_resources/helpers/GestureUIHelper.kt
index 11536b20..954074aa 100644
--- a/shared_resources/src/main/java/com/thewizrd/shared_resources/helpers/GestureUIHelper.kt
+++ b/shared_resources/src/main/java/com/thewizrd/shared_resources/helpers/GestureUIHelper.kt
@@ -5,4 +5,5 @@ object GestureUIHelper {
const val ScrollPath = "/gesture/scroll"
const val DPadPath = "/gesture/dpad"
const val DPadClickPath = "/gesture/dpad/click"
+ const val KeyEventPath = "/gesture/keyEvent"
}
\ No newline at end of file
diff --git a/shared_resources/src/main/java/com/thewizrd/shared_resources/helpers/InCallUIHelper.kt b/shared_resources/src/main/java/com/thewizrd/shared_resources/helpers/InCallUIHelper.kt
index cec3cc86..17a91472 100644
--- a/shared_resources/src/main/java/com/thewizrd/shared_resources/helpers/InCallUIHelper.kt
+++ b/shared_resources/src/main/java/com/thewizrd/shared_resources/helpers/InCallUIHelper.kt
@@ -6,6 +6,7 @@ object InCallUIHelper {
const val CallStatePath = "/incallui"
const val CallStateBridgePath = "/incallui/bridge"
+ const val ConnectPath = "/incallui/connect"
const val DisconnectPath = "/incallui/disconnect"
const val EndCallPath = "/incallui/hangup"
const val MuteMicPath = "/incallui/mute"
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 1a6bb75a..0a50af8c 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
@@ -10,7 +10,9 @@ object MediaHelper {
// For MediaController
const val MediaPlayerAutoLaunchPath = "/media/autolaunch"
const val MediaPlayerConnectPath = "/media/connect"
+ const val MediaPlayerAppInfoPath = "/media/app/info"
const val MediaPlayerStatePath = "/media/playback_state"
+ const val MediaPlayerArtPath = "/media/playback_state/art"
const val MediaPlayerStateBridgePath = "/media/playback_state/bridge"
const val MediaPlayerStateStoppedPath = "/media/playback_state/stopped"
const val MediaPlayPath = "/media/action/play"
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 4cb95454..268c6da8 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
@@ -5,13 +5,13 @@ 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
+import com.thewizrd.shared_resources.sharedDeps
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: Long = 1020000
+ private const val SUPPORTED_VERSION_CODE: Long = 1030000
fun getPackageName(): String {
var packageName = PACKAGE_NAME
@@ -20,7 +20,7 @@ object WearSettingsHelper {
}
fun isWearSettingsInstalled(): Boolean = try {
- val context = SimpleLibrary.instance.app.appContext
+ val context = sharedDeps.context
context.packageManager.getApplicationInfo(getPackageName(), 0).enabled
} catch (e: PackageManager.NameNotFoundException) {
false
@@ -28,7 +28,7 @@ object WearSettingsHelper {
fun launchWearSettings() {
runCatching {
- val context = SimpleLibrary.instance.app.appContext
+ val context = sharedDeps.context
val i = context.packageManager.getLaunchIntentForPackage(getPackageName())
if (i != null) {
@@ -42,7 +42,7 @@ object WearSettingsHelper {
}
private fun getWearSettingsVersionCode(): Long = try {
- val context = SimpleLibrary.instance.app.appContext
+ val context = sharedDeps.context
val packageInfo = context.packageManager.getPackageInfo(getPackageName(), 0)
val versionCode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
diff --git a/shared_resources/src/main/java/com/thewizrd/shared_resources/helpers/WearableHelper.kt b/shared_resources/src/main/java/com/thewizrd/shared_resources/helpers/WearableHelper.kt
index 892eeef4..ee299e28 100644
--- a/shared_resources/src/main/java/com/thewizrd/shared_resources/helpers/WearableHelper.kt
+++ b/shared_resources/src/main/java/com/thewizrd/shared_resources/helpers/WearableHelper.kt
@@ -11,7 +11,7 @@ import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailability
import com.google.android.gms.wearable.Node
import com.google.android.gms.wearable.PutDataRequest
-import com.thewizrd.shared_resources.SimpleLibrary
+import com.thewizrd.shared_resources.sharedDeps
import com.thewizrd.shared_resources.utils.Logger
object WearableHelper {
@@ -24,7 +24,7 @@ object WearableHelper {
// Link to Play Store listing
private const val PLAY_STORE_APP_URI = "market://details?id=com.thewizrd.simplewear"
- private const val VERSION_CODE: Long = 341915030
+ private const val SUPPORTED_VERSION_CODE: Long = 341916000
fun getPlayStoreURI(): Uri = Uri.parse(PLAY_STORE_APP_URI)
@@ -68,7 +68,7 @@ object WearableHelper {
fun isGooglePlayServicesInstalled(): Boolean {
val queryResult = GoogleApiAvailability.getInstance()
- .isGooglePlayServicesAvailable(SimpleLibrary.instance.app.appContext)
+ .isGooglePlayServicesAvailable(sharedDeps.context)
if (queryResult == ConnectionResult.SUCCESS) {
Logger.writeLine(Log.INFO, "App: Google Play Services is installed on this device.")
return true
@@ -160,11 +160,11 @@ object WearableHelper {
ParcelUuid.fromString("0000DA28-0000-1000-8000-00805F9B34FB")
fun isAppUpToDate(versionCode: Long): Boolean {
- return versionCode >= VERSION_CODE
+ return versionCode >= SUPPORTED_VERSION_CODE
}
fun getAppVersionCode(): Long = try {
- val context = SimpleLibrary.instance.app.appContext
+ val context = sharedDeps.context
val packageInfo = context.run {
packageManager.getPackageInfo(packageName, 0)
}
diff --git a/shared_resources/src/main/java/com/thewizrd/shared_resources/media/BrowseMediaItems.kt b/shared_resources/src/main/java/com/thewizrd/shared_resources/media/BrowseMediaItems.kt
new file mode 100644
index 00000000..29565d60
--- /dev/null
+++ b/shared_resources/src/main/java/com/thewizrd/shared_resources/media/BrowseMediaItems.kt
@@ -0,0 +1,6 @@
+package com.thewizrd.shared_resources.media
+
+data class BrowseMediaItems(
+ val isRoot: Boolean = true,
+ val mediaItems: List = emptyList()
+)
\ No newline at end of file
diff --git a/shared_resources/src/main/java/com/thewizrd/shared_resources/media/CustomControls.kt b/shared_resources/src/main/java/com/thewizrd/shared_resources/media/CustomControls.kt
new file mode 100644
index 00000000..66c72d0a
--- /dev/null
+++ b/shared_resources/src/main/java/com/thewizrd/shared_resources/media/CustomControls.kt
@@ -0,0 +1,32 @@
+package com.thewizrd.shared_resources.media
+
+data class CustomControls(
+ val actions: List = emptyList()
+)
+
+data class ActionItem(
+ val action: String,
+ val title: String,
+ val icon: ByteArray? = null
+) {
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is ActionItem) return false
+
+ if (action != other.action) return false
+ if (title != other.title) return false
+ if (icon != null) {
+ if (other.icon == null) return false
+ if (!icon.contentEquals(other.icon)) return false
+ } else if (other.icon != null) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = action.hashCode()
+ result = 31 * result + title.hashCode()
+ result = 31 * result + (icon?.contentHashCode() ?: 0)
+ return result
+ }
+}
diff --git a/shared_resources/src/main/java/com/thewizrd/shared_resources/media/MediaItem.kt b/shared_resources/src/main/java/com/thewizrd/shared_resources/media/MediaItem.kt
new file mode 100644
index 00000000..18e577d6
--- /dev/null
+++ b/shared_resources/src/main/java/com/thewizrd/shared_resources/media/MediaItem.kt
@@ -0,0 +1,28 @@
+package com.thewizrd.shared_resources.media
+
+data class MediaItem(
+ val mediaId: String,
+ val title: String,
+ val icon: ByteArray? = null
+) {
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is MediaItem) return false
+
+ if (mediaId != other.mediaId) return false
+ if (title != other.title) return false
+ if (icon != null) {
+ if (other.icon == null) return false
+ if (!icon.contentEquals(other.icon)) return false
+ } else if (other.icon != null) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = mediaId.hashCode()
+ result = 31 * result + title.hashCode()
+ result = 31 * result + (icon?.contentHashCode() ?: 0)
+ return result
+ }
+}
\ No newline at end of file
diff --git a/shared_resources/src/main/java/com/thewizrd/shared_resources/media/MediaMetaData.kt b/shared_resources/src/main/java/com/thewizrd/shared_resources/media/MediaMetaData.kt
new file mode 100644
index 00000000..6afa9270
--- /dev/null
+++ b/shared_resources/src/main/java/com/thewizrd/shared_resources/media/MediaMetaData.kt
@@ -0,0 +1,25 @@
+package com.thewizrd.shared_resources.media
+
+data class MediaMetaData(
+ val title: String? = null,
+ val artist: String? = null,
+ val positionState: PositionState = PositionState(),
+) {
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is MediaMetaData) return false
+
+ if (title != other.title) return false
+ if (artist != other.artist) return false
+ if (positionState != other.positionState) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = title?.hashCode() ?: 0
+ result = 31 * result + (artist?.hashCode() ?: 0)
+ result = 31 * result + positionState.hashCode()
+ return result
+ }
+}
\ No newline at end of file
diff --git a/shared_resources/src/main/java/com/thewizrd/shared_resources/media/MediaPlayerState.kt b/shared_resources/src/main/java/com/thewizrd/shared_resources/media/MediaPlayerState.kt
new file mode 100644
index 00000000..378f0566
--- /dev/null
+++ b/shared_resources/src/main/java/com/thewizrd/shared_resources/media/MediaPlayerState.kt
@@ -0,0 +1,24 @@
+package com.thewizrd.shared_resources.media
+
+data class MediaPlayerState(
+ val playbackState: PlaybackState = PlaybackState.NONE,
+ val mediaMetaData: MediaMetaData? = null
+) {
+ val key = "${playbackState}|${mediaMetaData?.title}|${mediaMetaData?.artist}"
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is MediaPlayerState) return false
+
+ if (playbackState != other.playbackState) return false
+ if (mediaMetaData != other.mediaMetaData) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = playbackState.hashCode()
+ result = 31 * result + (mediaMetaData?.hashCode() ?: 0)
+ return result
+ }
+}
\ No newline at end of file
diff --git a/shared_resources/src/main/java/com/thewizrd/shared_resources/media/MusicPlayersData.kt b/shared_resources/src/main/java/com/thewizrd/shared_resources/media/MusicPlayersData.kt
new file mode 100644
index 00000000..9feb7cc2
--- /dev/null
+++ b/shared_resources/src/main/java/com/thewizrd/shared_resources/media/MusicPlayersData.kt
@@ -0,0 +1,8 @@
+package com.thewizrd.shared_resources.media
+
+import com.thewizrd.shared_resources.data.AppItemData
+
+data class MusicPlayersData(
+ val musicPlayers: Set = emptySet(),
+ val activePlayerKey: String? = null
+)
\ No newline at end of file
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
index 0bbb84af..5e089916 100644
--- 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
@@ -1,8 +1,30 @@
package com.thewizrd.shared_resources.media
+import java.time.Instant
+
data class PositionState(
val durationMs: Long = 0L,
val currentPositionMs: Long = 0L,
val playbackSpeed: Float = 1f,
- val currentTimeMs: Long = System.currentTimeMillis()
-)
+ val currentTimeMs: Long = Instant.now().toEpochMilli()
+) {
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is PositionState) return false
+
+ if (durationMs != other.durationMs) return false
+ if (currentPositionMs != other.currentPositionMs) return false
+ if (playbackSpeed != other.playbackSpeed) return false
+ if (currentTimeMs != other.currentTimeMs) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = durationMs.hashCode()
+ result = 31 * result + currentPositionMs.hashCode()
+ result = 31 * result + playbackSpeed.hashCode()
+ result = 31 * result + currentTimeMs.hashCode()
+ return result
+ }
+}
diff --git a/shared_resources/src/main/java/com/thewizrd/shared_resources/media/QueueItems.kt b/shared_resources/src/main/java/com/thewizrd/shared_resources/media/QueueItems.kt
new file mode 100644
index 00000000..d9f1fc8c
--- /dev/null
+++ b/shared_resources/src/main/java/com/thewizrd/shared_resources/media/QueueItems.kt
@@ -0,0 +1,33 @@
+package com.thewizrd.shared_resources.media
+
+data class QueueItems(
+ val activeQueueItemId: Long,
+ val queueItems: List = emptyList()
+)
+
+data class QueueItem(
+ val queueId: Long,
+ val title: String,
+ val icon: ByteArray? = null
+) {
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is QueueItem) return false
+
+ if (queueId != other.queueId) return false
+ if (title != other.title) return false
+ if (icon != null) {
+ if (other.icon == null) return false
+ if (!icon.contentEquals(other.icon)) return false
+ } else if (other.icon != null) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = queueId.hashCode()
+ result = 31 * result + title.hashCode()
+ result = 31 * result + (icon?.contentHashCode() ?: 0)
+ return result
+ }
+}
\ No newline at end of file
diff --git a/shared_resources/src/main/java/com/thewizrd/shared_resources/sleeptimer/SleepTimerHelper.kt b/shared_resources/src/main/java/com/thewizrd/shared_resources/sleeptimer/SleepTimerHelper.kt
index 100b16e5..749f389f 100644
--- a/shared_resources/src/main/java/com/thewizrd/shared_resources/sleeptimer/SleepTimerHelper.kt
+++ b/shared_resources/src/main/java/com/thewizrd/shared_resources/sleeptimer/SleepTimerHelper.kt
@@ -5,7 +5,7 @@ import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import com.thewizrd.shared_resources.BuildConfig
-import com.thewizrd.shared_resources.SimpleLibrary
+import com.thewizrd.shared_resources.sharedDeps
object SleepTimerHelper {
// Link to Play Store listing
@@ -21,7 +21,7 @@ object SleepTimerHelper {
}
fun isSleepTimerInstalled(): Boolean = try {
- val context = SimpleLibrary.instance.app.appContext
+ val context = sharedDeps.context
context.packageManager.getApplicationInfo(getPackageName(), 0).enabled
} catch (e: PackageManager.NameNotFoundException) {
false
@@ -29,7 +29,7 @@ object SleepTimerHelper {
fun launchSleepTimer() {
try {
- val context = SimpleLibrary.instance.app.appContext
+ val context = sharedDeps.context
val directIntent = Intent(Intent.ACTION_VIEW)
.addCategory(Intent.CATEGORY_LAUNCHER)
@@ -44,7 +44,7 @@ object SleepTimerHelper {
context.startActivity(i)
}
}
- } catch (e: PackageManager.NameNotFoundException) {
+ } catch (_: PackageManager.NameNotFoundException) {
}
}
}
\ No newline at end of file
diff --git a/shared_resources/src/main/java/com/thewizrd/shared_resources/utils/AnalyticsLogger.kt b/shared_resources/src/main/java/com/thewizrd/shared_resources/utils/AnalyticsLogger.kt
index 47aa3029..4f8c48f0 100644
--- a/shared_resources/src/main/java/com/thewizrd/shared_resources/utils/AnalyticsLogger.kt
+++ b/shared_resources/src/main/java/com/thewizrd/shared_resources/utils/AnalyticsLogger.kt
@@ -6,12 +6,12 @@ import android.util.Log
import androidx.annotation.Size
import com.google.firebase.analytics.FirebaseAnalytics
import com.thewizrd.shared_resources.BuildConfig
-import com.thewizrd.shared_resources.SimpleLibrary
+import com.thewizrd.shared_resources.sharedDeps
import java.lang.System.lineSeparator
@SuppressLint("MissingPermission")
object AnalyticsLogger {
- private val analytics by lazy { FirebaseAnalytics.getInstance(SimpleLibrary.instance.appContext) }
+ private val analytics by lazy { FirebaseAnalytics.getInstance(sharedDeps.context) }
@JvmOverloads
@JvmStatic
@@ -24,4 +24,28 @@ object AnalyticsLogger {
analytics.logEvent(eventName.replace("[^a-zA-Z0-9]".toRegex(), "_"), properties)
}
}
+
+ @JvmStatic
+ fun setUserProperty(
+ @Size(min = 1L, max = 24L) property: String,
+ @Size(max = 36L) value: String?
+ ) {
+ analytics.setUserProperty(property, value)
+ }
+
+ @JvmStatic
+ fun setUserProperty(
+ @Size(min = 1L, max = 24L) property: String,
+ @Size(max = 36L) value: Boolean
+ ) {
+ analytics.setUserProperty(property, value.toString())
+ }
+
+ @JvmStatic
+ fun setUserProperty(
+ @Size(min = 1L, max = 24L) property: String,
+ @Size(max = 36L) value: Number
+ ) {
+ analytics.setUserProperty(property, value.toString())
+ }
}
\ No newline at end of file
diff --git a/shared_resources/src/main/java/com/thewizrd/shared_resources/utils/AnalyticsProps.kt b/shared_resources/src/main/java/com/thewizrd/shared_resources/utils/AnalyticsProps.kt
new file mode 100644
index 00000000..19c56c86
--- /dev/null
+++ b/shared_resources/src/main/java/com/thewizrd/shared_resources/utils/AnalyticsProps.kt
@@ -0,0 +1,5 @@
+package com.thewizrd.shared_resources.utils
+
+object AnalyticsProps {
+ const val DEVICE_TYPE = "device_type"
+}
\ No newline at end of file
diff --git a/shared_resources/src/main/java/com/thewizrd/shared_resources/utils/CollectionUtils.kt b/shared_resources/src/main/java/com/thewizrd/shared_resources/utils/CollectionUtils.kt
new file mode 100644
index 00000000..ca35f1f0
--- /dev/null
+++ b/shared_resources/src/main/java/com/thewizrd/shared_resources/utils/CollectionUtils.kt
@@ -0,0 +1,40 @@
+@file:JvmName("CollectionUtils")
+
+package com.thewizrd.shared_resources.utils
+
+import java.util.Objects
+
+fun sequenceEqual(iterable1: Iterable<*>?, iterable2: Iterable<*>?): Boolean {
+ if (iterable1 is Collection && iterable2 is Collection) {
+ if (iterable1.size != iterable2.size) {
+ return false
+ }
+
+ if (iterable1 is List && iterable2 is List) {
+ val count = iterable1.size
+ for (i in 0 until count) {
+ if (!Objects.equals(iterable1[i], iterable2[i])) {
+ return false
+ }
+ }
+
+ return true
+ }
+ }
+
+ return sequenceEqual(iterable1?.iterator(), iterable2?.iterator())
+}
+
+fun sequenceEqual(iterator1: Iterator<*>?, iterator2: Iterator<*>?): Boolean {
+ while (iterator1?.hasNext() == true) {
+ if (iterator2?.hasNext() != true) {
+ return false
+ }
+ val o1 = iterator1.next()
+ val o2 = iterator2.next()
+ if (!Objects.equals(o1, o2)) {
+ return false
+ }
+ }
+ return iterator2?.hasNext() != true
+}
\ No newline at end of file
diff --git a/shared_resources/src/main/java/com/thewizrd/shared_resources/utils/ContextUtils.kt b/shared_resources/src/main/java/com/thewizrd/shared_resources/utils/ContextUtils.kt
index ffe35fd9..86abfd5a 100644
--- a/shared_resources/src/main/java/com/thewizrd/shared_resources/utils/ContextUtils.kt
+++ b/shared_resources/src/main/java/com/thewizrd/shared_resources/utils/ContextUtils.kt
@@ -1,6 +1,9 @@
package com.thewizrd.shared_resources.utils
+import android.app.UiModeManager
+import android.content.ComponentName
import android.content.Context
+import android.content.pm.PackageManager
import android.content.res.ColorStateList
import android.content.res.Configuration
import android.graphics.drawable.Drawable
@@ -8,31 +11,71 @@ import android.util.TypedValue
import androidx.annotation.AnyRes
import androidx.annotation.AttrRes
import androidx.annotation.ColorInt
+import androidx.annotation.IntDef
object ContextUtils {
- fun Context.dpToPx(valueInDp: Float): Float {
+ @IntDef(
+ value = [
+ TypedValue.COMPLEX_UNIT_PX,
+ TypedValue.COMPLEX_UNIT_DIP,
+ TypedValue.COMPLEX_UNIT_SP,
+ TypedValue.COMPLEX_UNIT_PT,
+ TypedValue.COMPLEX_UNIT_IN,
+ TypedValue.COMPLEX_UNIT_MM
+ ]
+ )
+ @Retention(AnnotationRetention.SOURCE)
+ private annotation class ComplexDimensionUnit
+
+ @JvmStatic
+ fun Context.complexUnitToPx(@ComplexDimensionUnit unit: Int, valueInDp: Float): Float {
val metrics = this.resources.displayMetrics
- return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, valueInDp, metrics)
+ return TypedValue.applyDimension(unit, valueInDp, metrics)
}
+ @JvmStatic
+ fun Context.dpToPx(valueInDp: Float): Float {
+ return complexUnitToPx(TypedValue.COMPLEX_UNIT_DIP, valueInDp)
+ }
+
+ @JvmStatic
fun Context.isLargeTablet(): Boolean {
return (this.resources.configuration.screenLayout
and Configuration.SCREENLAYOUT_SIZE_MASK) >= Configuration.SCREENLAYOUT_SIZE_LARGE
}
+ @JvmStatic
fun Context.isXLargeTablet(): Boolean {
return (this.resources.configuration.screenLayout
and Configuration.SCREENLAYOUT_SIZE_MASK) >= Configuration.SCREENLAYOUT_SIZE_XLARGE
}
+ @JvmStatic
fun Context.isSmallestWidth(swdp: Int): Boolean {
return this.resources.configuration.smallestScreenWidthDp >= swdp
}
+ @JvmStatic
+ fun Context.isWidth(dp: Int): Boolean {
+ return this.resources.configuration.screenWidthDp >= dp
+ }
+
+ @JvmStatic
+ fun Context.isScreenRound(): Boolean {
+ return this.resources.configuration.isScreenRound
+ }
+
+ @JvmStatic
fun Context.getOrientation(): Int {
return this.resources.configuration.orientation
}
+ @JvmStatic
+ fun Context.isLandscape(): Boolean {
+ return this.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
+ }
+
+ @JvmStatic
fun Context.getAttrDimension(@AttrRes resId: Int): Int {
val value = TypedValue()
this.theme.resolveAttribute(resId, value, true)
@@ -42,12 +85,14 @@ object ContextUtils {
)
}
+ @JvmStatic
fun Context.getAttrValue(@AttrRes resId: Int): Int {
val value = TypedValue()
this.theme.resolveAttribute(resId, value, true)
return value.data
}
+ @JvmStatic
@ColorInt
fun Context.getAttrColor(@AttrRes resId: Int): Int {
val array = this.theme.obtainStyledAttributes(intArrayOf(resId))
@@ -56,6 +101,7 @@ object ContextUtils {
return color
}
+ @JvmStatic
fun Context.getAttrColorStateList(@AttrRes resId: Int): ColorStateList? {
val array = this.theme.obtainStyledAttributes(intArrayOf(resId))
var color: ColorStateList? = null
@@ -67,6 +113,7 @@ object ContextUtils {
return color
}
+ @JvmStatic
fun Context.getAttrDrawable(@AttrRes resId: Int): Drawable? {
val array = this.theme.obtainStyledAttributes(intArrayOf(resId))
val drawable = array.getDrawable(0)
@@ -74,11 +121,54 @@ object ContextUtils {
return drawable
}
+ @JvmStatic
@AnyRes
- fun Context.getResourceId(@AttrRes resId: Int): Int {
+ fun Context.getAttrResourceId(@AttrRes resId: Int): Int {
val array = this.theme.obtainStyledAttributes(intArrayOf(resId))
val resourceId = array.getResourceId(0, 0)
array.recycle()
return resourceId
}
+
+ @JvmStatic
+ fun Context.verifyActivityInfo(componentName: ComponentName): Boolean {
+ try {
+ packageManager.getActivityInfo(componentName, PackageManager.MATCH_DEFAULT_ONLY)
+ return true
+ } catch (e: PackageManager.NameNotFoundException) {
+ }
+
+ return false
+ }
+
+ @JvmStatic
+ fun Context.getThemeContextOverride(isLight: Boolean): Context {
+ val oldConfig = resources.configuration
+ val newConfig = Configuration(oldConfig)
+
+ newConfig.uiMode = (
+ (if (isLight) Configuration.UI_MODE_NIGHT_NO else Configuration.UI_MODE_NIGHT_YES)
+ or (newConfig.uiMode and Configuration.UI_MODE_NIGHT_MASK.inv())
+ )
+
+ return createConfigurationContext(newConfig)
+ }
+
+ @JvmStatic
+ fun Context.isNightMode(): Boolean {
+ val currentNightMode: Int =
+ this.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
+ return currentNightMode == Configuration.UI_MODE_NIGHT_YES
+ }
+
+ @JvmStatic
+ fun Context.isTv(): Boolean {
+ val uiModeMgr = this.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager
+ return uiModeMgr.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION
+ }
+
+ @JvmStatic
+ fun Context.isLargeWatch(): Boolean {
+ return (isScreenRound() && isSmallestWidth(210)) || (!isScreenRound() && isSmallestWidth(180))
+ }
}
\ No newline at end of file
diff --git a/shared_resources/src/main/java/com/thewizrd/shared_resources/utils/CrashlyticsLoggingTree.kt b/shared_resources/src/main/java/com/thewizrd/shared_resources/utils/CrashlyticsLoggingTree.kt
index b93907cc..a58b3afb 100644
--- a/shared_resources/src/main/java/com/thewizrd/shared_resources/utils/CrashlyticsLoggingTree.kt
+++ b/shared_resources/src/main/java/com/thewizrd/shared_resources/utils/CrashlyticsLoggingTree.kt
@@ -4,27 +4,34 @@ import android.annotation.SuppressLint
import android.util.Log
import com.google.firebase.crashlytics.FirebaseCrashlytics
import com.thewizrd.shared_resources.utils.CrashlyticsLoggingTree
+import com.thewizrd.shared_resources.utils.Logger.DEBUG_MODE_ENABLED
import timber.log.Timber
+@SuppressLint("LogNotTimber")
class CrashlyticsLoggingTree : Timber.Tree() {
companion object {
private const val KEY_PRIORITY = "priority"
private const val KEY_TAG = "tag"
private const val KEY_MESSAGE = "message"
+
private val TAG = CrashlyticsLoggingTree::class.java.simpleName
}
private val crashlytics = FirebaseCrashlytics.getInstance()
- @SuppressLint("LogNotTimber")
+ override fun isLoggable(tag: String?, priority: Int): Boolean {
+ return priority > Log.DEBUG || DEBUG_MODE_ENABLED
+ }
+
override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
try {
- val priorityTAG: String = when (priority) {
+ val priorityTAG = when (priority) {
+ Log.VERBOSE -> "VERBOSE"
Log.DEBUG -> "DEBUG"
Log.INFO -> "INFO"
- Log.VERBOSE -> "VERBOSE"
Log.WARN -> "WARN"
Log.ERROR -> "ERROR"
+ Log.ASSERT -> "ASSERT"
else -> "DEBUG"
}
@@ -33,10 +40,9 @@ class CrashlyticsLoggingTree : Timber.Tree() {
crashlytics.setCustomKey(KEY_MESSAGE, message)
if (tag != null) {
- crashlytics.log(String.format("%s/%s: %s", priorityTAG, tag, message))
+ crashlytics.log("$priorityTAG | $tag: $message")
} else {
- crashlytics.log(String.format("%s/%s", priorityTAG, message))
-
+ crashlytics.log("$priorityTAG | $message")
}
if (t != null) {
diff --git a/shared_resources/src/main/java/com/thewizrd/shared_resources/utils/FileLoggingTree.kt b/shared_resources/src/main/java/com/thewizrd/shared_resources/utils/FileLoggingTree.kt
index 8bfcfbc6..8a17b205 100644
--- a/shared_resources/src/main/java/com/thewizrd/shared_resources/utils/FileLoggingTree.kt
+++ b/shared_resources/src/main/java/com/thewizrd/shared_resources/utils/FileLoggingTree.kt
@@ -3,8 +3,10 @@ package com.thewizrd.shared_resources.utils
import android.annotation.SuppressLint
import android.content.Context
import android.util.Log
+import com.thewizrd.shared_resources.BuildConfig
+import com.thewizrd.shared_resources.appLib
+import com.thewizrd.shared_resources.utils.Logger.DEBUG_MODE_ENABLED
import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import timber.log.Timber
import java.io.File
@@ -12,7 +14,7 @@ import java.io.FileOutputStream
import java.time.LocalDateTime
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
-import java.util.*
+import java.util.Locale
@SuppressLint("LogNotTimber")
class FileLoggingTree(private val context: Context) : Timber.Tree() {
@@ -21,6 +23,14 @@ class FileLoggingTree(private val context: Context) : Timber.Tree() {
private var ranCleanup = false
}
+ override fun isLoggable(tag: String?, priority: Int): Boolean {
+ return if (BuildConfig.DEBUG || DEBUG_MODE_ENABLED) {
+ true
+ } else {
+ priority > Log.DEBUG
+ }
+ }
+
override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
try {
val directory = File(context.getExternalFilesDir(null).toString() + "/logs")
@@ -47,11 +57,12 @@ class FileLoggingTree(private val context: Context) : Timber.Tree() {
val fileOutputStream = FileOutputStream(file, true)
val priorityTAG = when (priority) {
+ Log.VERBOSE -> "VERBOSE"
Log.DEBUG -> "DEBUG"
Log.INFO -> "INFO"
- Log.VERBOSE -> "VERBOSE"
Log.WARN -> "WARN"
Log.ERROR -> "ERROR"
+ Log.ASSERT -> "ASSERT"
else -> "DEBUG"
}
@@ -66,7 +77,7 @@ class FileLoggingTree(private val context: Context) : Timber.Tree() {
// Cleanup old logs if they exist
if (!ranCleanup) {
- GlobalScope.launch(Dispatchers.IO) {
+ appLib.appScope.launch(Dispatchers.IO) {
try {
// Only keep a weeks worth of logs
val daysToKeep = 7
diff --git a/shared_resources/src/main/java/com/thewizrd/shared_resources/utils/FileUtils.kt b/shared_resources/src/main/java/com/thewizrd/shared_resources/utils/FileUtils.kt
index eebf1fc4..8600c7e6 100644
--- a/shared_resources/src/main/java/com/thewizrd/shared_resources/utils/FileUtils.kt
+++ b/shared_resources/src/main/java/com/thewizrd/shared_resources/utils/FileUtils.kt
@@ -1,13 +1,25 @@
package com.thewizrd.shared_resources.utils
+import android.content.Context
+import android.net.Uri
import android.util.Log
import androidx.annotation.WorkerThread
import androidx.core.util.AtomicFile
+import androidx.core.util.ObjectsCompat
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.withContext
-import java.io.*
+import java.io.BufferedReader
+import java.io.Closeable
+import java.io.File
+import java.io.FileInputStream
+import java.io.FileNotFoundException
+import java.io.FileOutputStream
+import java.io.IOException
+import java.io.InputStream
+import java.io.InputStreamReader
+import java.io.OutputStreamWriter
object FileUtils {
@JvmStatic
@@ -16,6 +28,30 @@ object FileUtils {
return file.exists() && file.length() > 0
}
+ @JvmStatic
+ fun isValid(context: Context, assetUri: Uri?): Boolean {
+ if (assetUri != null && ObjectsCompat.equals(assetUri.scheme, "file")) {
+ var path = assetUri.path
+ if (path?.startsWith("/android_asset") == true) {
+ val startAsset = path.indexOf("/android_asset/")
+ path = path.substring(startAsset + 15)
+
+ var stream: InputStream? = null
+ try {
+ stream = context.resources.assets.open(path)
+ return true
+ } catch (ignored: IOException) {
+ } finally {
+ stream?.closeQuietly()
+ }
+ } else if (path != null) {
+ return File(path).exists()
+ }
+ }
+
+ return false
+ }
+
suspend fun readFile(file: File): String? = withContext(Dispatchers.IO) {
val mFile = AtomicFile(file)
@@ -44,9 +80,7 @@ object FileUtils {
Logger.writeLine(Log.ERROR, ex)
} finally {
// Close stream
- runCatching {
- reader?.close()
- }
+ reader?.closeQuietly()
}
data
@@ -74,10 +108,8 @@ object FileUtils {
} catch (ex: IOException) {
Logger.writeLine(Log.ERROR, ex)
} finally {
- runCatching {
- writer?.close()
- outputStream?.close()
- }
+ writer?.closeQuietly()
+ outputStream?.closeQuietly()
}
}
@@ -105,6 +137,7 @@ object FileUtils {
success
}
+ @JvmStatic
@WorkerThread
fun isFileLocked(file: File): Boolean {
if (!file.exists())
@@ -114,7 +147,7 @@ object FileUtils {
try {
stream = FileInputStream(file)
- } catch (e: FileNotFoundException) {
+ } catch (fex: FileNotFoundException) {
return false
} catch (e: IOException) {
//the file is unavailable because it is:
@@ -123,12 +156,19 @@ object FileUtils {
//or does not exist (has already been processed)
return true
} finally {
- runCatching {
- stream?.close()
- }
+ stream?.closeQuietly()
}
//file is not locked
return false
}
+}
+
+fun Closeable.closeQuietly() {
+ try {
+ close()
+ } catch (e: RuntimeException) {
+ throw e
+ } catch (_: Exception) {
+ }
}
\ No newline at end of file
diff --git a/shared_resources/src/main/java/com/thewizrd/shared_resources/utils/ImageUtils.kt b/shared_resources/src/main/java/com/thewizrd/shared_resources/utils/ImageUtils.kt
index 1d9fbaff..a2391388 100644
--- a/shared_resources/src/main/java/com/thewizrd/shared_resources/utils/ImageUtils.kt
+++ b/shared_resources/src/main/java/com/thewizrd/shared_resources/utils/ImageUtils.kt
@@ -73,16 +73,19 @@ object ImageUtils {
return@withContext createAssetFromBitmap(bmp)
}
- suspend fun Bitmap.toByteArray() = withContext(Dispatchers.IO) {
+ suspend fun Bitmap.toByteArray() = toByteArray(
+ format = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ Bitmap.CompressFormat.WEBP_LOSSLESS
+ } else {
+ Bitmap.CompressFormat.WEBP
+ }
+ )
+
+ suspend fun Bitmap.toByteArray(format: Bitmap.CompressFormat, quality: Int = 100) =
+ withContext(Dispatchers.IO) {
val byteStream = ByteArrayOutputStream()
return@withContext byteStream.use { stream ->
- compress(
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
- Bitmap.CompressFormat.WEBP_LOSSLESS
- } else {
- Bitmap.CompressFormat.WEBP
- }, 100, stream
- )
+ compress(format, quality, stream)
stream.toByteArray()
}
}
diff --git a/shared_resources/src/main/java/com/thewizrd/shared_resources/utils/Logger.kt b/shared_resources/src/main/java/com/thewizrd/shared_resources/utils/Logger.kt
index 46a27a47..bf89fd5c 100644
--- a/shared_resources/src/main/java/com/thewizrd/shared_resources/utils/Logger.kt
+++ b/shared_resources/src/main/java/com/thewizrd/shared_resources/utils/Logger.kt
@@ -1,14 +1,18 @@
package com.thewizrd.shared_resources.utils
import android.content.Context
+import android.util.Log
import com.thewizrd.shared_resources.BuildConfig
+import com.thewizrd.shared_resources.appLib
import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import timber.log.Timber
import timber.log.Timber.DebugTree
object Logger {
+ @JvmStatic
+ internal var DEBUG_MODE_ENABLED = false
+
@JvmStatic
fun init(context: Context) {
if (BuildConfig.DEBUG) {
@@ -19,6 +23,26 @@ object Logger {
}
}
+ fun isDebugLoggerEnabled(): Boolean {
+ return Timber.forest().any { it is FileLoggingTree }
+ }
+
+ fun enableDebugLogger(context: Context, enable: Boolean) {
+ DEBUG_MODE_ENABLED = enable
+
+ if (enable) {
+ if (!Timber.forest().any { it is FileLoggingTree }) {
+ Timber.plant(FileLoggingTree(context.applicationContext))
+ }
+ } else {
+ Timber.forest().forEach {
+ if (it is FileLoggingTree) {
+ Timber.uproot(it)
+ }
+ }
+ }
+ }
+
@JvmStatic
fun registerLogger(tree: Timber.Tree) {
Timber.plant(tree)
@@ -44,8 +68,78 @@ object Logger {
Timber.log(priority, t)
}
+ @JvmStatic
+ fun verbose(tag: String, message: String, vararg args: Any?) {
+ log(Log.VERBOSE, tag, message = message, args = args)
+ }
+
+ @JvmStatic
+ fun verbose(tag: String, t: Throwable? = null, message: String? = null, vararg args: Any?) {
+ log(Log.VERBOSE, tag, t, message, args)
+ }
+
+ @JvmStatic
+ fun debug(tag: String, message: String, vararg args: Any?) {
+ log(Log.DEBUG, tag, message = message, args = args)
+ }
+
+ @JvmStatic
+ fun debug(tag: String, t: Throwable? = null, message: String? = null, vararg args: Any?) {
+ log(Log.DEBUG, tag, t, message, args)
+ }
+
+ @JvmStatic
+ fun info(tag: String, message: String, vararg args: Any?) {
+ log(Log.INFO, tag, message = message, args = args)
+ }
+
+ @JvmStatic
+ fun info(tag: String, t: Throwable? = null, message: String? = null, vararg args: Any?) {
+ log(Log.INFO, tag, t, message, args)
+ }
+
+ @JvmStatic
+ fun warn(tag: String, message: String, vararg args: Any?) {
+ log(Log.WARN, tag, message = message, args = args)
+ }
+
+ @JvmStatic
+ fun warn(tag: String, t: Throwable? = null, message: String? = null, vararg args: Any?) {
+ log(Log.WARN, tag, t, message, args)
+ }
+
+ @JvmStatic
+ fun error(tag: String, message: String, vararg args: Any?) {
+ log(Log.ERROR, tag, message = message, args = args)
+ }
+
+ @JvmStatic
+ fun error(tag: String, t: Throwable? = null, message: String? = null, vararg args: Any?) {
+ log(Log.ERROR, tag, t, message, args)
+ }
+
+ @JvmStatic
+ fun assert(tag: String, message: String, vararg args: Any?) {
+ log(Log.ASSERT, tag, message = message, args = args)
+ }
+
+ @JvmStatic
+ fun assert(tag: String, t: Throwable? = null, message: String? = null, vararg args: Any?) {
+ log(Log.ASSERT, tag, t, message, args)
+ }
+
+ private fun log(
+ priority: Int,
+ tag: String,
+ t: Throwable? = null,
+ message: String? = null,
+ vararg args: Any?
+ ) {
+ Timber.tag(tag).log(priority, t, message, *args)
+ }
+
private fun cleanupLogs(context: Context) {
- GlobalScope.launch(Dispatchers.IO) {
+ appLib.appScope.launch(Dispatchers.IO) {
FileUtils.deleteDirectory(context.getExternalFilesDir(null).toString() + "/logs")
}
}
diff --git a/shared_resources/src/main/java/com/thewizrd/shared_resources/wearsettings/PackageValidator.kt b/shared_resources/src/main/java/com/thewizrd/shared_resources/wearsettings/PackageValidator.kt
new file mode 100644
index 00000000..2c414646
--- /dev/null
+++ b/shared_resources/src/main/java/com/thewizrd/shared_resources/wearsettings/PackageValidator.kt
@@ -0,0 +1,307 @@
+/*
+ * Copyright 2018 Google Inc. All rights reserved.
+ *
+ * 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.shared_resources.wearsettings
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.content.pm.PackageInfo
+import android.content.pm.PackageManager
+import android.content.res.XmlResourceParser
+import android.os.Build
+import android.util.Base64
+import com.thewizrd.shared_resources.R
+import org.xmlpull.v1.XmlPullParserException
+import timber.log.Timber
+import java.io.IOException
+import java.security.MessageDigest
+import java.security.NoSuchAlgorithmException
+
+/**
+ * Validates that the calling package is authorized to access a service
+ *
+ * The list of allowed signing certificates and their corresponding package names is defined in
+ * res/xml/allowed_wearsettings_callers.xml.
+ *
+ * Based on PackageValidator used for MediaBrowserServices
+ *
+ * Reference:
+ * https://github.com/android/uamp/blob/main/common/src/main/java/com/example/android/uamp/media/PackageValidator.kt
+ * https://github.com/android/uamp/blob/main/common/src/main/res/xml/allowed_media_browser_callers.xml
+ */
+class PackageValidator(context: Context) {
+ private val context: Context
+ private val packageManager: PackageManager
+
+ private val certificateAllowList: Map
+ private val platformSignature: String
+
+ private val callerChecked = mutableMapOf()
+
+ init {
+ val parser = context.resources.getXml(R.xml.allowed_wearsettings_callers)
+ this.context = context.applicationContext
+ this.packageManager = this.context.packageManager
+
+ certificateAllowList = buildCertificateAllowList(parser)
+ platformSignature = getSystemSignature()
+ }
+
+ /**
+ * Checks whether the caller attempting to connect to a service is known.
+ *
+ * @param callingPackage The package name of the caller.
+ * @return `true` if the caller is known, `false` otherwise.
+ */
+ fun isKnownCaller(callingPackage: String): Boolean {
+ // If the caller has already been checked, return the previous result here.
+ if (callerChecked[callingPackage] == true) {
+ return true
+ }
+
+ /**
+ * Because some of these checks can be slow, we save the results in [callerChecked] after
+ * this code is run.
+ *
+ * In particular, there's little reason to recompute the calling package's certificate
+ * signature (SHA-256) each call.
+ *
+ * This is safe to do as we know the UID matches the package's UID (from the check above),
+ * and app UIDs are set at install time. Additionally, a package name + UID is guaranteed to
+ * be constant until a reboot. (After a reboot then a previously assigned UID could be
+ * reassigned.)
+ */
+
+ // Build the caller info for the rest of the checks here.
+ val callerPackageInfo = buildCallerInfo(callingPackage)
+ ?: throw IllegalStateException("Caller wasn't found in the system?")
+
+ val callerSignature = callerPackageInfo.signature
+ val isPackageInAllowList = certificateAllowList[callingPackage]?.signatures?.first {
+ it.signature == callerSignature
+ } != null
+
+ val isCallerKnown = when {
+ // If it's one of the apps on the allow list, allow it.
+ isPackageInAllowList -> true
+ // If none of the previous checks succeeded, then the caller is unrecognized.
+ else -> false
+ }
+
+ // Save our work for next time.
+ callerChecked[callingPackage] = isCallerKnown
+ return isCallerKnown
+ }
+
+ /**
+ * Builds a [CallerPackageInfo] for a given package that can be used for all the
+ * various checks that are performed before allowing an app to connect to a
+ * service
+ */
+ private fun buildCallerInfo(callingPackage: String): CallerPackageInfo? {
+ val packageInfo = getPackageInfo(callingPackage) ?: return null
+ val appInfo = packageInfo.applicationInfo ?: return null
+
+ val appName = appInfo.loadLabel(packageManager).toString()
+ val uid = appInfo.uid
+ val signature = getSignature(packageInfo)
+
+ return CallerPackageInfo(appName, callingPackage, uid, signature)
+ }
+
+ /**
+ * Looks up the [PackageInfo] for a package name.
+ * This requests both the signatures (for checking if an app is on the allow list) and
+ * the app's permissions, which allow for more flexibility in the allow list.
+ *
+ * @return [PackageInfo] for the package name or null if it's not found.
+ */
+ @SuppressLint("PackageManagerGetSignatures")
+ private fun getPackageInfo(callingPackage: String): PackageInfo? {
+ val signatureFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ PackageManager.GET_SIGNING_CERTIFICATES
+ } else {
+ PackageManager.GET_SIGNATURES
+ }
+
+ return packageManager.getPackageInfo(
+ callingPackage,
+ signatureFlag or PackageManager.GET_PERMISSIONS
+ )
+ }
+
+ /**
+ * Gets the signature of a given package's [PackageInfo].
+ *
+ * The "signature" is a SHA-256 hash of the public key of the signing certificate used by
+ * the app.
+ *
+ * If the app is not found, or if the app does not have exactly one signature, this method
+ * returns `null` as the signature.
+ */
+ private fun getSignature(packageInfo: PackageInfo): String? {
+ val signature = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ packageInfo.signingInfo?.apkContentsSigners
+ } else {
+ packageInfo.signatures
+ }?.let { signatures ->
+ if (signatures.size != 1) {
+ // Security best practices dictate that an app should be signed with exactly one (1)
+ // signature. Because of this, if there are multiple signatures, reject it.
+ null
+ } else {
+ signatures[0]
+ }
+ }
+
+ if (signature != null) {
+ val certificate = signature.toByteArray()
+ return getSignatureSha256(certificate)
+ } else {
+ return null
+ }
+ }
+
+ private fun buildCertificateAllowList(parser: XmlResourceParser): Map {
+
+ val certificateAllowList = LinkedHashMap()
+ try {
+ var eventType = parser.next()
+ while (eventType != XmlResourceParser.END_DOCUMENT) {
+ if (eventType == XmlResourceParser.START_TAG) {
+ val callerInfo = when (parser.name) {
+ "signing_certificate" -> parseV1Tag(parser)
+ "signature" -> parseV2Tag(parser)
+ else -> null
+ }
+
+ callerInfo?.let { info ->
+ val packageName = info.packageName
+ val existingCallerInfo = certificateAllowList[packageName]
+ if (existingCallerInfo != null) {
+ existingCallerInfo.signatures += callerInfo.signatures
+ } else {
+ certificateAllowList[packageName] = callerInfo
+ }
+ }
+ }
+
+ eventType = parser.next()
+ }
+ } catch (xmlException: XmlPullParserException) {
+ Timber.e(xmlException, "Could not read allowed callers from XML.")
+ } catch (ioException: IOException) {
+ Timber.e(ioException, "Could not read allowed callers from XML.")
+ }
+
+ return certificateAllowList
+ }
+
+ /**
+ * Parses a v1 format tag. See allowed_media_browser_callers.xml for more details.
+ */
+ private fun parseV1Tag(parser: XmlResourceParser): KnownCallerInfo {
+ val name = parser.getAttributeValue(null, "name")
+ val packageName = parser.getAttributeValue(null, "package")
+ val isRelease = parser.getAttributeBooleanValue(null, "release", false)
+ val certificate = parser.nextText().replace(WHITESPACE_REGEX, "")
+ val signature = getSignatureSha256(certificate)
+
+ val callerSignature = KnownSignature(signature, isRelease)
+ return KnownCallerInfo(name, packageName, mutableSetOf(callerSignature))
+ }
+
+ /**
+ * Parses a v2 format tag. See allowed_media_browser_callers.xml for more details.
+ */
+ private fun parseV2Tag(parser: XmlResourceParser): KnownCallerInfo {
+ val name = parser.getAttributeValue(null, "name")
+ val packageName = parser.getAttributeValue(null, "package")
+
+ val callerSignatures = mutableSetOf()
+ var eventType = parser.next()
+ while (eventType != XmlResourceParser.END_TAG) {
+ val isRelease = parser.getAttributeBooleanValue(null, "release", false)
+ val signature = parser.nextText().replace(WHITESPACE_REGEX, "").lowercase()
+ callerSignatures += KnownSignature(signature, isRelease)
+
+ eventType = parser.next()
+ }
+
+ return KnownCallerInfo(name, packageName, callerSignatures)
+ }
+
+ /**
+ * Finds the Android platform signing key signature. This key is never null.
+ */
+ private fun getSystemSignature(): String =
+ getPackageInfo(ANDROID_PLATFORM)?.let { platformInfo ->
+ getSignature(platformInfo)
+ } ?: throw IllegalStateException("Platform signature not found")
+
+ /**
+ * Creates a SHA-256 signature given a Base64 encoded certificate.
+ */
+ private fun getSignatureSha256(certificate: String): String {
+ return getSignatureSha256(Base64.decode(certificate, Base64.DEFAULT))
+ }
+
+ /**
+ * Creates a SHA-256 signature given a certificate byte array.
+ */
+ private fun getSignatureSha256(certificate: ByteArray): String {
+ val md: MessageDigest
+ try {
+ md = MessageDigest.getInstance("SHA256")
+ } catch (noSuchAlgorithmException: NoSuchAlgorithmException) {
+ Timber.tag(TAG).e("No such algorithm: $noSuchAlgorithmException")
+ throw RuntimeException("Could not find SHA256 hash algorithm", noSuchAlgorithmException)
+ }
+ md.update(certificate)
+
+ // This code takes the byte array generated by `md.digest()` and joins each of the bytes
+ // to a string, applying the string format `%02x` on each digit before it's appended, with
+ // a colon (':') between each of the items.
+ // For example: input=[0,2,4,6,8,10,12], output="00:02:04:06:08:0a:0c"
+ return md.digest().joinToString(":") { String.format("%02x", it) }
+ }
+
+ private data class KnownCallerInfo(
+ internal val name: String,
+ internal val packageName: String,
+ internal val signatures: MutableSet
+ )
+
+ private data class KnownSignature(
+ internal val signature: String,
+ internal val release: Boolean
+ )
+
+ /**
+ * Convenience class to hold all of the information about an app that's being checked
+ * to see if it's a known caller.
+ */
+ private data class CallerPackageInfo(
+ internal val name: String,
+ internal val packageName: String,
+ internal val uid: Int,
+ internal val signature: String?
+ )
+}
+
+private const val TAG = "PackageValidator"
+private const val ANDROID_PLATFORM = "android"
+private val WHITESPACE_REGEX = "\\s|\\n".toRegex()
\ No newline at end of file
diff --git a/shared_resources/src/main/res/drawable/ic_charging_station_24dp.xml b/shared_resources/src/main/res/drawable/ic_charging_station_24dp.xml
new file mode 100644
index 00000000..f9c5dc9d
--- /dev/null
+++ b/shared_resources/src/main/res/drawable/ic_charging_station_24dp.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
diff --git a/shared_resources/src/main/res/drawable/ic_outline_view_apps.xml b/shared_resources/src/main/res/drawable/ic_outline_view_apps.xml
new file mode 100644
index 00000000..d44bc66e
--- /dev/null
+++ b/shared_resources/src/main/res/drawable/ic_outline_view_apps.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
diff --git a/shared_resources/src/main/res/values/strings.xml b/shared_resources/src/main/res/values/strings.xml
index cce5818f..410a8cff 100644
--- a/shared_resources/src/main/res/values/strings.xml
+++ b/shared_resources/src/main/res/values/strings.xml
@@ -64,4 +64,7 @@
Time
Action not supported
+ Home
+ Recents
+
diff --git a/wear/build.gradle b/wear/build.gradle
index efbf8711..8ddcf9ba 100644
--- a/wear/build.gradle
+++ b/wear/build.gradle
@@ -3,6 +3,7 @@ apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'
apply plugin: 'com.google.gms.google-services'
apply plugin: 'com.google.firebase.crashlytics'
+apply plugin: 'org.jetbrains.kotlin.plugin.compose'
android {
compileSdk rootProject.compileSdkVersion
@@ -11,9 +12,9 @@ android {
applicationId "com.thewizrd.simplewear"
minSdkVersion 26
targetSdkVersion rootProject.targetSdkVersion
- // NOTE: Version Code Format (TargetSDK, Version Name, Build Number, Variant Code (Android: 00, WearOS: 01)
- versionCode 341915051
- versionName "1.15.2"
+ // NOTE: Version Code Format (TargetSDK, Version Name, Build Number, Variant Code (Android: 0, WearOS: 1)
+ versionCode 341916041
+ versionName "1.16.0"
vectorDrawables.useSupportLibrary = true
}
@@ -37,6 +38,7 @@ android {
compose true
dataBinding true
viewBinding true
+ buildConfig true
}
compileOptions {
@@ -77,6 +79,7 @@ dependencies {
implementation "androidx.preference:preference-ktx:$preference_version"
implementation "androidx.core:core-splashscreen:$coresplash_version"
implementation "androidx.navigation:navigation-runtime-ktx:$navigation_version"
+ implementation "androidx.datastore:datastore:$datastore_version"
implementation platform("com.google.firebase:firebase-bom:$firebase_version")
implementation 'com.google.firebase:firebase-analytics'
@@ -86,21 +89,23 @@ dependencies {
implementation "com.google.android.material:material:$material_version"
// WearOS
- implementation 'com.google.android.gms:play-services-wearable:18.2.0'
+ implementation 'com.google.android.gms:play-services-wearable:19.0.0'
compileOnly 'com.google.android.wearable:wearable:2.9.0' // Needed for Ambient Mode
implementation 'androidx.wear:wear:1.3.0'
implementation 'androidx.wear:wear-ongoing:1.0.0'
- implementation 'androidx.wear:wear-phone-interactions:1.0.1'
- implementation 'androidx.wear:wear-remote-interactions:1.0.0'
+ implementation 'androidx.wear:wear-phone-interactions:1.1.0'
+ implementation 'androidx.wear:wear-remote-interactions:1.1.0'
implementation "androidx.wear.watchface:watchface-complications-data:$wear_watchface_version"
implementation "androidx.wear.watchface:watchface-complications-data-source-ktx:$wear_watchface_version"
// WearOS Tiles
- implementation("androidx.wear.tiles:tiles:$wear_tiles_version")
- debugImplementation("androidx.wear.tiles:tiles-renderer:$wear_tiles_version")
- testImplementation("androidx.wear.tiles:tiles-testing:$wear_tiles_version")
- implementation 'androidx.wear.protolayout:protolayout-material:1.2.0'
+ implementation "androidx.wear.tiles:tiles:$wear_tiles_version"
+ debugImplementation "androidx.wear.tiles:tiles-renderer:$wear_tiles_version"
+ testImplementation "androidx.wear.tiles:tiles-testing:$wear_tiles_version"
+ debugImplementation "androidx.wear.tiles:tiles-tooling:$wear_tiles_version"
+ implementation "androidx.wear.tiles:tiles-tooling-preview:$wear_tiles_version"
+ implementation 'androidx.wear.protolayout:protolayout-material:1.2.1'
implementation "com.google.android.horologist:horologist-tiles:$horologist_version"
// WearOS Compose
@@ -112,6 +117,7 @@ dependencies {
implementation "androidx.compose.animation:animation-graphics"
implementation "androidx.compose.runtime:runtime-livedata"
implementation "androidx.compose.material:material"
+ implementation "androidx.compose.material:material-icons-core"
implementation "androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycle_version"
implementation "androidx.wear.compose:compose-foundation:$wear_compose_version"
implementation "androidx.wear.compose:compose-material:$wear_compose_version"
@@ -119,8 +125,6 @@ dependencies {
implementation "androidx.wear:wear-tooling-preview:1.0.0"
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-audio-ui:$horologist_version"
implementation "com.google.android.horologist:horologist-compose-layout:$horologist_version"
implementation "com.google.android.horologist:horologist-compose-material:$horologist_version"
diff --git a/wear/proguard-rules.pro b/wear/proguard-rules.pro
index 526cec8d..fc4af2e9 100644
--- a/wear/proguard-rules.pro
+++ b/wear/proguard-rules.pro
@@ -33,7 +33,9 @@
#-keep class com.google.gson.stream.** { *; }
# Application classes that will be serialized/deserialized over Gson
-#-keep class com.google.gson.examples.android.model.** { ; }
+-keep class com.thewizrd.simplewear.datastore.media.MediaDataCache { *; }
+-keep class com.thewizrd.simplewear.datastore.dashboard.DashboardDataCache { *; }
+-keep class com.thewizrd.simplewear.viewmodels.ConfirmationData { *; }
# Prevent proguard from stripping interface information from TypeAdapter, TypeAdapterFactory,
# JsonSerializer, JsonDeserializer instances (so they can be used in @JsonAdapter)
diff --git a/wear/src/main/AndroidManifest.xml b/wear/src/main/AndroidManifest.xml
index 6b3fd77e..9db4ae1c 100644
--- a/wear/src/main/AndroidManifest.xml
+++ b/wear/src/main/AndroidManifest.xml
@@ -139,6 +139,42 @@
android:host="*"
android:scheme="wear"
android:path="/media/playback_state/bridge" />
+
+
+
+
+
+
+
+
+
+
+
+
@@ -216,7 +252,7 @@
(
+ /**
+ * The context to avoid passing in through each render method.
+ */
+ public val context: Context,
+ public val debugResourceMode: Boolean = false,
+) : TileLayoutRenderer {
+ public val theme: Colors by lazy { createTheme() }
+
+ public open fun getFreshnessIntervalMillis(state: T): Long = 0L
+
+ final override fun renderTimeline(
+ state: T,
+ requestParams: RequestBuilders.TileRequest,
+ ): Tile {
+ val rootLayout = renderTile(state, requestParams.deviceConfiguration)
+
+ val singleTileTimeline = TimelineBuilders.Timeline.Builder()
+ .addTimelineEntry(
+ TimelineBuilders.TimelineEntry.Builder()
+ .setLayout(
+ Layout.Builder()
+ .setRoot(rootLayout)
+ .build(),
+ )
+ .build(),
+ )
+ .build()
+
+ return Tile.Builder()
+ .setResourcesVersion(
+ if (debugResourceMode) {
+ UUID.randomUUID().toString()
+ } else {
+ getResourcesVersionForTileState(state)
+ },
+ )
+ .setState(createState(state))
+ .setTileTimeline(singleTileTimeline)
+ .setFreshnessIntervalMillis(getFreshnessIntervalMillis(state))
+ .build()
+ }
+
+ public open fun getResourcesVersionForTileState(state: T): String = PERMANENT_RESOURCES_VERSION
+
+ /**
+ * Create a material theme that should be applied to all components.
+ */
+ public open fun createTheme(): Colors = Colors.DEFAULT
+
+ /**
+ * Render a single tile as a LayoutElement, that will be the only item in the timeline.
+ */
+ public abstract fun renderTile(
+ state: T,
+ deviceParameters: DeviceParametersBuilders.DeviceParameters,
+ ): LayoutElement
+
+ final override fun produceRequestedResources(
+ resourceState: R,
+ requestParams: RequestBuilders.ResourcesRequest,
+ ): Resources {
+ return Resources.Builder()
+ .setVersion(requestParams.version)
+ .apply {
+ produceRequestedResources(
+ resourceState,
+ requestParams.deviceConfiguration,
+ requestParams.resourceIds,
+ )
+ }
+ .build()
+ }
+
+ /**
+ * Add resources directly to the builder.
+ */
+ public open fun Resources.Builder.produceRequestedResources(
+ resourceState: R,
+ deviceParameters: DeviceParametersBuilders.DeviceParameters,
+ resourceIds: List,
+ ) {
+ }
+
+ public open fun createState(state: T): State = State.Builder().build()
+}
diff --git a/wear/src/main/java/com/thewizrd/simplewear/App.kt b/wear/src/main/java/com/thewizrd/simplewear/App.kt
index 5949e06c..9ae2eec6 100644
--- a/wear/src/main/java/com/thewizrd/simplewear/App.kt
+++ b/wear/src/main/java/com/thewizrd/simplewear/App.kt
@@ -3,52 +3,48 @@ package com.thewizrd.simplewear
import android.app.Activity
import android.app.Application
import android.app.Application.ActivityLifecycleCallbacks
-import android.content.Context
+import android.content.SharedPreferences
import android.os.Bundle
import android.util.Log
-import com.google.firebase.crashlytics.FirebaseCrashlytics
+import androidx.preference.PreferenceManager
import com.thewizrd.shared_resources.ApplicationLib
-import com.thewizrd.shared_resources.SimpleLibrary
+import com.thewizrd.shared_resources.SharedModule
+import com.thewizrd.shared_resources.appLib
import com.thewizrd.shared_resources.helpers.AppState
-import com.thewizrd.shared_resources.utils.CrashlyticsLoggingTree
+import com.thewizrd.shared_resources.sharedDeps
import com.thewizrd.shared_resources.utils.Logger
import com.thewizrd.simplewear.media.MediaPlayerActivity
+import kotlinx.coroutines.cancel
import kotlin.system.exitProcess
-class App : Application(), ApplicationLib, ActivityLifecycleCallbacks {
- companion object {
- @JvmStatic
- lateinit var instance: ApplicationLib
- private set
- }
-
- override lateinit var appContext: Context
- private set
- override lateinit var applicationState: AppState
- private set
+class App : Application(), ActivityLifecycleCallbacks {
+ private lateinit var applicationState: AppState
private var mActivitiesStarted = 0
- override val isPhone: Boolean = false
override fun onCreate() {
super.onCreate()
- appContext = applicationContext
- instance = this
registerActivityLifecycleCallbacks(this)
applicationState = AppState.CLOSED
mActivitiesStarted = 0
- // Init shared library
- SimpleLibrary.initialize(this)
+ // Initialize app dependencies (library module chain)
+ // 1. ApplicationLib + SharedModule, 2. Firebase
+ appLib = object : ApplicationLib() {
+ override val context = applicationContext
+ override val preferences: SharedPreferences
+ get() = PreferenceManager.getDefaultSharedPreferences(context)
+ override val appState: AppState
+ get() = applicationState
+ override val isPhone = false
+ }
- // Start logger
- Logger.init(appContext)
- Logger.registerLogger(CrashlyticsLoggingTree())
- FirebaseCrashlytics.getInstance().apply {
- setCrashlyticsCollectionEnabled(true)
- sendUnsentReports()
+ sharedDeps = object : SharedModule() {
+ override val context = appLib.context // keep same context as applib
}
+ FirebaseConfigurator.initialize(applicationContext)
+
val oldHandler = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { t, e ->
@@ -65,7 +61,7 @@ class App : Application(), ApplicationLib, ActivityLifecycleCallbacks {
super.onTerminate()
// Shutdown logger
Logger.shutdown()
- SimpleLibrary.unregister()
+ appLib.appScope.cancel()
}
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {}
@@ -87,7 +83,7 @@ class App : Application(), ApplicationLib, ActivityLifecycleCallbacks {
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
override fun onActivityDestroyed(activity: Activity) {
- if (activity.localClassName.contains("DashboardActivity")) {
+ if (activity.localClassName.contains(DashboardActivity::class.java.simpleName)) {
applicationState = AppState.CLOSED
}
}
diff --git a/wear/src/main/java/com/thewizrd/simplewear/FirebaseConfigurator.kt b/wear/src/main/java/com/thewizrd/simplewear/FirebaseConfigurator.kt
new file mode 100644
index 00000000..647439fd
--- /dev/null
+++ b/wear/src/main/java/com/thewizrd/simplewear/FirebaseConfigurator.kt
@@ -0,0 +1,25 @@
+package com.thewizrd.simplewear
+
+import android.annotation.SuppressLint
+import android.content.Context
+import com.google.firebase.analytics.FirebaseAnalytics
+import com.google.firebase.crashlytics.FirebaseCrashlytics
+import com.thewizrd.shared_resources.utils.AnalyticsProps
+import com.thewizrd.shared_resources.utils.CrashlyticsLoggingTree
+import com.thewizrd.shared_resources.utils.Logger
+
+object FirebaseConfigurator {
+ @SuppressLint("MissingPermission")
+ fun initialize(context: Context) {
+ FirebaseAnalytics.getInstance(context).setUserProperty(AnalyticsProps.DEVICE_TYPE, "watch")
+
+ FirebaseCrashlytics.getInstance().apply {
+ isCrashlyticsCollectionEnabled = true
+ sendUnsentReports()
+ }
+
+ if (!BuildConfig.DEBUG) {
+ Logger.registerLogger(CrashlyticsLoggingTree())
+ }
+ }
+}
\ No newline at end of file
diff --git a/wear/src/main/java/com/thewizrd/simplewear/controls/CustomConfirmationOverlay.kt b/wear/src/main/java/com/thewizrd/simplewear/controls/CustomConfirmationOverlay.kt
deleted file mode 100644
index 02a1dab5..00000000
--- a/wear/src/main/java/com/thewizrd/simplewear/controls/CustomConfirmationOverlay.kt
+++ /dev/null
@@ -1,428 +0,0 @@
-/*
- * Copyright 2018 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.
- */
-/*
- * ConfirmationOverlay.java
- * platform/frameworks/support
- * branch: pie-release
- */
-package com.thewizrd.simplewear.controls
-
-import android.annotation.SuppressLint
-import android.app.Activity
-import android.content.Context
-import android.graphics.drawable.Animatable
-import android.graphics.drawable.Drawable
-import android.os.Handler
-import android.os.Looper
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import android.view.ViewGroup.MarginLayoutParams
-import android.view.accessibility.AccessibilityEvent
-import android.view.accessibility.AccessibilityManager
-import android.view.animation.Animation
-import android.view.animation.AnimationUtils
-import android.widget.ImageView
-import android.widget.TextView
-import androidx.annotation.*
-import androidx.constraintlayout.widget.ConstraintLayout
-import androidx.core.content.ContextCompat
-import androidx.wear.R
-import com.thewizrd.simplewear.utils.ResourcesUtils
-import java.util.*
-import kotlin.math.max
-
-/**
- * Displays a full-screen confirmation animation with optional text and then hides it.
- *
- *
- * This is a lighter-weight version of [androidx.wear.activity.ConfirmationActivity]
- * and should be preferred when constructed from an [Activity].
- *
- *
- * Sample usage:
- *
- *
- * // Defaults to SUCCESS_ANIMATION
- * new CustomConfirmationOverlay().showOn(myActivity);
- *
- * new CustomConfirmationOverlay()
- * .setType(CustomConfirmationOverlay.OPEN_ON_PHONE_ANIMATION)
- * .setDuration(3000)
- * .setMessage("Opening...")
- * .setFinishedAnimationListener(new CustomConfirmationOverlay.OnAnimationFinishedListener() {
- * @Override
- * public void onAnimationFinished() {
- * // Finished animating and the content view has been removed from myActivity.
- * }
- * }).showOn(myActivity);
- *
- * // Default duration is [.DEFAULT_ANIMATION_DURATION_MS]
- * new CustomConfirmationOverlay()
- * .setType(CustomConfirmationOverlay.FAILURE_ANIMATION)
- * .setMessage("Failed")
- * .setFinishedAnimationListener(new CustomConfirmationOverlay.OnAnimationFinishedListener() {
- * @Override
- * public void onAnimationFinished() {
- * // Finished animating and the view has been removed from myView.getRootView().
- * }
- * }).showAbove(myView);
- *
- */
-class CustomConfirmationOverlay {
- companion object {
- /**
- * Default animation duration in ms.
- */
- const val DEFAULT_ANIMATION_DURATION_MS = 1000
-
- /** Default animation duration in ms. */
- private const val A11Y_ANIMATION_DURATION_MS = 5000
-
- /**
- * [OverlayType] indicating the success animation overlay should be displayed.
- */
- const val SUCCESS_ANIMATION = 0
-
- /**
- * [OverlayType] indicating the failure overlay should be shown. The icon associated with
- * this type, unlike the others, does not animate.
- */
- const val FAILURE_ANIMATION = 1
-
- /**
- * [OverlayType] indicating the "Open on Phone" animation overlay should be displayed.
- */
- const val OPEN_ON_PHONE_ANIMATION = 2
-
- /**
- * [OverlayType] indicating a custom animation overlay should be displayed.
- */
- const val CUSTOM_ANIMATION = 3
- }
-
- /**
- * Interface for listeners to be notified when the [CustomConfirmationOverlay] animation has
- * finished and its [View] has been removed.
- */
- interface OnAnimationFinishedListener {
- /**
- * Called when the confirmation animation is finished.
- */
- fun onAnimationFinished()
- }
-
- /**
- * Types of animations to display in the overlay.
- */
- @Retention(AnnotationRetention.SOURCE)
- @IntDef(SUCCESS_ANIMATION, FAILURE_ANIMATION, OPEN_ON_PHONE_ANIMATION, CUSTOM_ANIMATION)
- annotation class OverlayType
-
- @OverlayType
- private var mType = SUCCESS_ANIMATION
- private var mDurationMillis = DEFAULT_ANIMATION_DURATION_MS
- private var mListener: OnAnimationFinishedListener? = null
- private var mMessage: CharSequence? = null
-
- @StringRes
- private var mMessageStringResId: Int? = null
- private var mOverlayView: View? = null
- private var mOverlayDrawable: Drawable? = null
- private var mIsShowing = false
- private var mCustomDrawable: Drawable? = null
-
- @DrawableRes
- private var mCustomDrawableResId: Int? = null
- private val mMainThreadHandler = Handler(Looper.getMainLooper())
- private val mHideRunnable = Runnable { hide() }
-
- /**
- * Sets a message which will be displayed at the same time as the animation.
- *
- * @return `this` object for method chaining.
- */
- fun setMessage(message: CharSequence?): CustomConfirmationOverlay {
- mMessage = message
- return this
- }
-
- /**
- * Sets the [OverlayType] which controls which animation is displayed.
- *
- * @return `this` object for method chaining.
- */
- fun setType(@OverlayType type: Int): CustomConfirmationOverlay {
- mType = type
- return this
- }
-
- /**
- * Sets the duration in milliseconds which controls how long the animation will be displayed.
- * Default duration is [.DEFAULT_ANIMATION_DURATION_MS].
- *
- * @return `this` object for method chaining.
- */
- fun setDuration(millis: Int): CustomConfirmationOverlay {
- mDurationMillis = millis
- return this
- }
-
- /**
- * Sets the [OnAnimationFinishedListener] which will be invoked once the overlay is no
- * longer visible.
- *
- * @return `this` object for method chaining.
- */
- fun setFinishedAnimationListener(
- listener: OnAnimationFinishedListener?
- ): CustomConfirmationOverlay {
- mListener = listener
- return this
- }
-
- /**
- * Adds the overlay as a child of `view.getRootView()`, removing it when complete. While
- * it is shown, all touches will be intercepted to prevent accidental taps on obscured views.
- */
- @MainThread
- fun showAbove(view: View) {
- if (mIsShowing) {
- return
- }
- mIsShowing = true
-
- updateOverlayView(view.context)
- (view.rootView as ViewGroup).addView(mOverlayView)
- setUpForAccessibility()
- animateAndHideAfterDelay()
- }
-
- /**
- * Adds the overlay as a content view to the `activity`, removing it when complete. While
- * it is shown, all touches will be intercepted to prevent accidental taps on obscured views.
- */
- @MainThread
- fun showOn(activity: Activity) {
- if (mIsShowing) {
- return
- }
- mIsShowing = true
-
- updateOverlayView(activity)
- activity.window.addContentView(mOverlayView, mOverlayView!!.layoutParams)
- setUpForAccessibility()
- animateAndHideAfterDelay()
- }
-
- private fun setUpForAccessibility() {
- mOverlayView!!.contentDescription = getAccessibilityText()
- mOverlayView!!.requestFocus()
- mOverlayView!!.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_FOCUSED)
- }
-
- /**
- * Returns [.A11Y_ANIMATION_DURATION_MS] or [.mDurationMillis], which ever is higher
- * if accessibility is turned on or [.mDurationMillis] otherwise.
- */
- private fun getDurationMillis(): Int {
- if (mOverlayView!!.context.getSystemService(AccessibilityManager::class.java).isEnabled) {
- return max(A11Y_ANIMATION_DURATION_MS, mDurationMillis)
- } else {
- return mDurationMillis
- }
- }
-
- @MainThread
- private fun animateAndHideAfterDelay() {
- if (mOverlayDrawable is Animatable) {
- val animatable = mOverlayDrawable as Animatable
- animatable.start()
- }
- mMainThreadHandler.postDelayed(mHideRunnable, getDurationMillis().toLong())
- }
-
- /**
- * Starts a fadeout animation and removes the view once finished. This is invoked by [ ][.mHideRunnable] after [.mDurationMillis] milliseconds.
- *
- * @hide
- */
- @MainThread
- @VisibleForTesting
- @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
- fun hide() {
- val fadeOut = AnimationUtils.loadAnimation(mOverlayView!!.context, android.R.anim.fade_out)
- fadeOut.setAnimationListener(
- object : Animation.AnimationListener {
- override fun onAnimationStart(animation: Animation) {
- mOverlayView!!.clearAnimation()
- }
-
- override fun onAnimationEnd(animation: Animation) {
- (mOverlayView!!.parent as ViewGroup).removeView(mOverlayView)
- mIsShowing = false
- if (mListener != null) {
- mListener!!.onAnimationFinished()
- }
- }
-
- override fun onAnimationRepeat(animation: Animation) {}
- })
- mOverlayView!!.startAnimation(fadeOut)
- }
-
- @MainThread
- @SuppressLint("ClickableViewAccessibility")
- private fun updateOverlayView(context: Context) {
- if (mOverlayView == null) {
- mOverlayView = LayoutInflater.from(context).inflate(
- if (mType == CUSTOM_ANIMATION) {
- com.thewizrd.simplewear.R.layout.ws_customoverlay_confirmation
- } else {
- R.layout.ws_overlay_confirmation
- },
- null
- )
- }
- mOverlayView!!.setOnTouchListener { _, _ -> true }
- mOverlayView!!.layoutParams = ViewGroup.LayoutParams(
- ViewGroup.LayoutParams.MATCH_PARENT,
- ViewGroup.LayoutParams.MATCH_PARENT
- )
- updateImageView(context, mOverlayView)
- updateMessageView(context, mOverlayView)
- }
-
- @MainThread
- private fun updateMessageView(context: Context, overlayView: View?) {
- val messageView =
- overlayView!!.findViewById(R.id.wearable_support_confirmation_overlay_message)
-
- val screenWidthPx = ResourcesUtils.getScreenWidthPx(context)
- val insetMarginPx = ResourcesUtils.getFractionOfScreenPx(
- context, screenWidthPx, R.fraction.confirmation_overlay_text_inset_margin
- )
-
- val layoutParams = messageView.layoutParams as MarginLayoutParams
- layoutParams.leftMargin = insetMarginPx
- layoutParams.rightMargin = insetMarginPx
-
- messageView.layoutParams = layoutParams
- if (mMessageStringResId != null) {
- messageView.setText(mMessageStringResId!!)
- } else {
- messageView.text = mMessage
- }
- messageView.visibility = View.VISIBLE
- }
-
- @MainThread
- private fun updateImageView(context: Context, overlayView: View?) {
- mOverlayDrawable = when (mType) {
- SUCCESS_ANIMATION -> ContextCompat.getDrawable(
- context,
- R.drawable.confirmation_animation
- )
-
- FAILURE_ANIMATION -> ContextCompat.getDrawable(context, R.drawable.failure_animation)
- OPEN_ON_PHONE_ANIMATION -> ContextCompat.getDrawable(
- context,
- R.drawable.open_on_phone_animation
- )
-
- CUSTOM_ANIMATION -> {
- if (mCustomDrawableResId != null) {
- ContextCompat.getDrawable(context, mCustomDrawableResId!!)
- } else {
- checkNotNull(mCustomDrawable) { "Custom drawable is invalid" }
- mCustomDrawable
- }
- }
-
- else -> {
- val errorMessage =
- String.format(Locale.US, "Invalid ConfirmationOverlay type [%d]", mType)
- throw IllegalStateException(errorMessage)
- }
- }
-
- val imageView =
- overlayView!!.findViewById(R.id.wearable_support_confirmation_overlay_image)
- imageView.setImageDrawable(mOverlayDrawable)
- if (imageView.layoutParams is ConstraintLayout.LayoutParams) {
- val lp = imageView.layoutParams as ConstraintLayout.LayoutParams
- if (mMessage.isNullOrBlank()) lp.verticalBias = 0.5f
- imageView.layoutParams = lp
- }
- }
-
- /**
- * Returns text to be read out if accessibility is turned on.
- * @return Text from the [.mMessage] if not empty or predefined string for given
- * animation type.
- */
- private fun getAccessibilityText(): CharSequence? {
- if (mMessage.toString().isNotEmpty()) {
- return mMessage
- }
- val context = mOverlayView!!.context
- var imageDescription: CharSequence = ""
- imageDescription =
- when (mType) {
- SUCCESS_ANIMATION -> context.getString(R.string.confirmation_overlay_a11y_description_success)
- FAILURE_ANIMATION -> context.getString(R.string.confirmation_overlay_a11y_description_fail)
- OPEN_ON_PHONE_ANIMATION -> context.getString(R.string.confirmation_overlay_a11y_description_phone)
- else -> {
- val errorMessage =
- String.format(Locale.US, "Invalid ConfirmationOverlay type [%d]", mType)
- throw java.lang.IllegalStateException(errorMessage)
- }
- }
- return imageDescription
- }
-
- /**
- * Sets a message which will be displayed at the same time as the animation.
- *
- * @return `this` object for method chaining.
- */
- fun setMessage(@StringRes resId: Int?): CustomConfirmationOverlay {
- mMessageStringResId = resId
- return this
- }
-
- /**
- * Sets the custom image drawable which will be displayed.
- * This will be used if type is set to CUSTOM_ANIMATION
- *
- * @return `this` object for method chaining.
- */
- fun setCustomDrawable(@DrawableRes resId: Int?): CustomConfirmationOverlay {
- mCustomDrawableResId = resId
- return this
- }
-
- /**
- * Sets the custom image drawable which will be displayed.
- * This will be used if type is set to CUSTOM_ANIMATION
- *
- * @return `this` object for method chaining.
- */
- fun setCustomDrawable(customDrawable: Drawable?): CustomConfirmationOverlay {
- mCustomDrawable = customDrawable
- return this
- }
-}
\ No newline at end of file
diff --git a/wear/src/main/java/com/thewizrd/simplewear/datastore/dashboard/DashboardDataCache.kt b/wear/src/main/java/com/thewizrd/simplewear/datastore/dashboard/DashboardDataCache.kt
new file mode 100644
index 00000000..378372bf
--- /dev/null
+++ b/wear/src/main/java/com/thewizrd/simplewear/datastore/dashboard/DashboardDataCache.kt
@@ -0,0 +1,26 @@
+package com.thewizrd.simplewear.datastore.dashboard
+
+import com.thewizrd.shared_resources.actions.Action
+import com.thewizrd.shared_resources.actions.Actions
+import com.thewizrd.shared_resources.actions.BatteryStatus
+
+data class DashboardDataCache(
+ val batteryStatus: BatteryStatus? = null,
+ val actions: Map = emptyMap(),
+) {
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is DashboardDataCache) return false
+
+ if (batteryStatus != other.batteryStatus) return false
+ if (actions != other.actions) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = batteryStatus?.hashCode() ?: 0
+ result = 31 * result + actions.hashCode()
+ return result
+ }
+}
diff --git a/wear/src/main/java/com/thewizrd/simplewear/datastore/dashboard/DashboardDataStore.kt b/wear/src/main/java/com/thewizrd/simplewear/datastore/dashboard/DashboardDataStore.kt
new file mode 100644
index 00000000..d75923e3
--- /dev/null
+++ b/wear/src/main/java/com/thewizrd/simplewear/datastore/dashboard/DashboardDataStore.kt
@@ -0,0 +1,32 @@
+package com.thewizrd.simplewear.datastore.dashboard
+
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.core.Serializer
+import androidx.datastore.dataStore
+import com.thewizrd.shared_resources.utils.JSONParser
+import com.thewizrd.shared_resources.utils.stringToBytes
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import java.io.InputStream
+import java.io.OutputStream
+
+private object DashboardDataCacheSerializer : Serializer {
+ override val defaultValue: DashboardDataCache
+ get() = DashboardDataCache()
+
+ override suspend fun readFrom(input: InputStream): DashboardDataCache {
+ return JSONParser.deserializer(input, DashboardDataCache::class.java) ?: defaultValue
+ }
+
+ override suspend fun writeTo(t: DashboardDataCache, output: OutputStream) {
+ withContext(Dispatchers.IO) {
+ output.write(JSONParser.serializer(t, DashboardDataCache::class.java).stringToBytes())
+ }
+ }
+}
+
+val Context.dashboardDataStore: DataStore by dataStore(
+ fileName = "dashboard_cache.json",
+ serializer = DashboardDataCacheSerializer
+)
\ No newline at end of file
diff --git a/wear/src/main/java/com/thewizrd/simplewear/datastore/media/MediaDataCache.kt b/wear/src/main/java/com/thewizrd/simplewear/datastore/media/MediaDataCache.kt
new file mode 100644
index 00000000..810c3b74
--- /dev/null
+++ b/wear/src/main/java/com/thewizrd/simplewear/datastore/media/MediaDataCache.kt
@@ -0,0 +1,25 @@
+package com.thewizrd.simplewear.datastore.media
+
+import com.thewizrd.shared_resources.actions.AudioStreamState
+import com.thewizrd.shared_resources.media.MediaPlayerState
+
+data class MediaDataCache(
+ val mediaPlayerState: MediaPlayerState? = null,
+ val audioStreamState: AudioStreamState? = null
+) {
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is MediaDataCache) return false
+
+ if (mediaPlayerState != other.mediaPlayerState) return false
+ if (audioStreamState != other.audioStreamState) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = mediaPlayerState?.hashCode() ?: 0
+ result = 31 * result + (audioStreamState?.hashCode() ?: 0)
+ return result
+ }
+}
\ No newline at end of file
diff --git a/wear/src/main/java/com/thewizrd/simplewear/datastore/media/MediaPlayerDataStore.kt b/wear/src/main/java/com/thewizrd/simplewear/datastore/media/MediaPlayerDataStore.kt
new file mode 100644
index 00000000..2d913b5b
--- /dev/null
+++ b/wear/src/main/java/com/thewizrd/simplewear/datastore/media/MediaPlayerDataStore.kt
@@ -0,0 +1,73 @@
+package com.thewizrd.simplewear.datastore.media
+
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.core.Serializer
+import androidx.datastore.dataStore
+import com.thewizrd.shared_resources.data.AppItemData
+import com.thewizrd.shared_resources.utils.JSONParser
+import com.thewizrd.shared_resources.utils.stringToBytes
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import java.io.InputStream
+import java.io.OutputStream
+
+private object MediaDataCacheStateSerializer : Serializer {
+ override val defaultValue: MediaDataCache
+ get() = MediaDataCache()
+
+ override suspend fun readFrom(input: InputStream): MediaDataCache {
+ return JSONParser.deserializer(input, MediaDataCache::class.java) ?: defaultValue
+ }
+
+ override suspend fun writeTo(t: MediaDataCache, output: OutputStream) {
+ withContext(Dispatchers.IO) {
+ output.write(JSONParser.serializer(t, MediaDataCache::class.java).stringToBytes())
+ }
+ }
+}
+
+private object ArtworkCacheSerializer : Serializer {
+ override val defaultValue: ByteArray
+ get() = byteArrayOf()
+
+ override suspend fun readFrom(input: InputStream): ByteArray {
+ return input.readBytes()
+ }
+
+ override suspend fun writeTo(t: ByteArray, output: OutputStream) {
+ withContext(Dispatchers.IO) {
+ output.write(t)
+ }
+ }
+}
+
+private object AppItemCacheSerializer : Serializer {
+ override val defaultValue: AppItemData
+ get() = AppItemData(null, null, null, null)
+
+ override suspend fun readFrom(input: InputStream): AppItemData {
+ return JSONParser.deserializer(input, AppItemData::class.java) ?: defaultValue
+ }
+
+ override suspend fun writeTo(t: AppItemData, output: OutputStream) {
+ withContext(Dispatchers.IO) {
+ output.write(JSONParser.serializer(t, AppItemData::class.java).stringToBytes())
+ }
+ }
+}
+
+val Context.mediaDataStore: DataStore by dataStore(
+ fileName = "media_cache.json",
+ serializer = MediaDataCacheStateSerializer
+)
+
+val Context.artworkDataStore: DataStore by dataStore(
+ fileName = "artwork_cache.bin",
+ serializer = ArtworkCacheSerializer
+)
+
+val Context.appInfoDataStore: DataStore by dataStore(
+ fileName = "app_info_cache.json",
+ serializer = AppItemCacheSerializer
+)
\ No newline at end of file
diff --git a/wear/src/main/java/com/thewizrd/simplewear/media/AudioOutputRepository.kt b/wear/src/main/java/com/thewizrd/simplewear/media/AudioOutputRepository.kt
new file mode 100644
index 00000000..3d00d40d
--- /dev/null
+++ b/wear/src/main/java/com/thewizrd/simplewear/media/AudioOutputRepository.kt
@@ -0,0 +1,18 @@
+package com.thewizrd.simplewear.media
+
+import com.google.android.horologist.audio.AudioOutput
+import com.google.android.horologist.audio.AudioOutputRepository
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+class NoopAudioOutputRepository : AudioOutputRepository {
+ override val audioOutput: StateFlow
+ get() = MutableStateFlow(AudioOutput.None).asStateFlow()
+ override val available: StateFlow>
+ get() = MutableStateFlow(emptyList())
+
+ override fun close() {}
+
+ override fun launchOutputSelection(closeOnConnect: Boolean) {}
+}
\ 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 134c81f6..e4f44591 100644
--- a/wear/src/main/java/com/thewizrd/simplewear/media/MediaPlayerActivity.kt
+++ b/wear/src/main/java/com/thewizrd/simplewear/media/MediaPlayerActivity.kt
@@ -36,11 +36,7 @@ class MediaPlayerActivity : ComponentActivity() {
installSplashScreen()
super.onCreate(savedInstanceState)
- var startDestination = Screen.MediaPlayerList.route
-
- if (intent?.extras?.getBoolean(KEY_AUTOLAUNCH) == true) {
- startDestination = Screen.MediaPlayer.autoLaunch()
- }
+ var startDestination = Screen.MediaPlayer.autoLaunch()
intent?.extras?.getString(KEY_APPDETAILS)?.let {
val model = JSONParser.deserializer(it, AppItemViewModel::class.java)
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 02eb3cf7..b2f39118 100644
--- a/wear/src/main/java/com/thewizrd/simplewear/media/MediaPlayerViewModel.kt
+++ b/wear/src/main/java/com/thewizrd/simplewear/media/MediaPlayerViewModel.kt
@@ -1,28 +1,30 @@
+@file:OptIn(ExperimentalHorologistApi::class, ExperimentalHorologistApi::class)
+
package com.thewizrd.simplewear.media
import android.app.Application
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.ChannelClient.Channel
+import com.google.android.gms.wearable.ChannelClient.ChannelCallback
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.AudioStreamState
+import com.thewizrd.shared_resources.data.AppItemData
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.BrowseMediaItems
+import com.thewizrd.shared_resources.media.CustomControls
+import com.thewizrd.shared_resources.media.MediaPlayerState
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.media.QueueItems
+import com.thewizrd.shared_resources.utils.ImageUtils.toBitmap
import com.thewizrd.shared_resources.utils.JSONParser
import com.thewizrd.shared_resources.utils.Logger
import com.thewizrd.shared_resources.utils.booleanToBytes
@@ -34,14 +36,13 @@ 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.supervisorScope
import kotlinx.coroutines.tasks.await
data class MediaPlayerUiState(
@@ -90,7 +91,8 @@ data class PlayerState(
data class MediaPagerState(
val supportsBrowser: Boolean = false,
val supportsCustomActions: Boolean = false,
- val supportsQueue: Boolean = false
+ val supportsQueue: Boolean = false,
+ val currentPageKey: MediaPageType = MediaPageType.Player
) {
val pageCount: Int
get() {
@@ -104,8 +106,7 @@ data class MediaPagerState(
}
}
-class MediaPlayerViewModel(app: Application) : WearableListenerViewModel(app),
- DataClient.OnDataChangedListener {
+class MediaPlayerViewModel(app: Application) : WearableListenerViewModel(app) {
private val viewModelState = MutableStateFlow(MediaPlayerUiState(isLoading = true))
val uiState = viewModelState.stateIn(
@@ -127,13 +128,27 @@ class MediaPlayerViewModel(app: Application) : WearableListenerViewModel(app),
PlaybackStateEvent.INITIAL
)
- private var deleteJob: Job? = null
+ private val channelCallback = object : ChannelCallback() {
+ override fun onChannelOpened(channel: Channel) {
+ startChannelListener(channel)
+ }
- private var mediaPagerState = MediaPagerState()
- private var updatePagerJob: Job? = null
+ override fun onChannelClosed(
+ channel: Channel,
+ closeReason: Int,
+ appSpecificErrorCode: Int
+ ) {
+ Logger.debug(
+ "ChannelCallback",
+ "channel closed - reason = $closeReason | path = ${channel.path}"
+ )
+ }
+ }
init {
- Wearable.getDataClient(appContext).addListener(this)
+ Wearable.getChannelClient(appContext).run {
+ registerChannelCallback(channelCallback)
+ }
viewModelScope.launch {
eventFlow.collect { event ->
@@ -202,6 +217,58 @@ class MediaPlayerViewModel(app: Application) : WearableListenerViewModel(app),
}
}
}
+
+ viewModelScope.launch {
+ channelEventsFlow.collect { event ->
+ when (event.eventType) {
+ MediaHelper.MediaActionsPath -> {
+ val jsonData = event.data.getString(EXTRA_ACTIONDATA)
+
+ viewModelScope.launch {
+ val customControls = jsonData?.let {
+ JSONParser.deserializer(it, CustomControls::class.java)
+ }
+
+ updateCustomControls(customControls)
+ }
+ }
+
+ MediaHelper.MediaBrowserItemsPath -> {
+ val jsonData = event.data.getString(EXTRA_ACTIONDATA)
+
+ viewModelScope.launch {
+ val browseMediaItems = jsonData?.let {
+ JSONParser.deserializer(it, BrowseMediaItems::class.java)
+ }
+
+ updateBrowserItems(browseMediaItems)
+ }
+ }
+// MediaHelper.MediaBrowserItemsExtraSuggestedPath -> {
+// val jsonData = event.data.getString(EXTRA_ACTIONDATA)
+//
+// viewModelScope.launch {
+// val browseMediaItems = jsonData?.let {
+// JSONParser.deserializer(it, BrowseMediaItems::class.java)
+// }
+//
+// updateBrowserItems(browseMediaItems)
+// }
+// }
+ MediaHelper.MediaQueueItemsPath -> {
+ val jsonData = event.data.getString(EXTRA_ACTIONDATA)
+
+ viewModelScope.launch {
+ val queueItems = jsonData?.let {
+ JSONParser.deserializer(it, QueueItems::class.java)
+ }
+
+ updateQueueItems(queueItems)
+ }
+ }
+ }
+ }
+ }
}
override fun onMessageReceived(messageEvent: MessageEvent) {
@@ -254,115 +321,94 @@ class MediaPlayerViewModel(app: Application) : WearableListenerViewModel(app),
}))
}
- else -> super.onMessageReceived(messageEvent)
- }
- }
+ MediaHelper.MediaPlayerStatePath -> {
+ val playerState = messageEvent.data?.let {
+ JSONParser.deserializer(it.bytesToString(), MediaPlayerState::class.java)
+ }
- 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)
- }
- }
+ viewModelScope.launch {
+ updatePlayerState(playerState)
+ }
+ }
- MediaHelper.MediaBrowserItemsPath -> {
- try {
- updatePager(item)
- val dataMap = DataMapItem.fromDataItem(item).dataMap
- updateBrowserItems(dataMap)
- isPagerUpdated = true
- } catch (e: Exception) {
- Logger.writeLine(Log.ERROR, e)
- }
- }
+ MediaHelper.MediaPlayerArtPath -> {
+ val artworkBytes = messageEvent.data
- MediaHelper.MediaQueueItemsPath -> {
- try {
- updatePager(item)
- val dataMap = DataMapItem.fromDataItem(item).dataMap
- updateQueueItems(dataMap)
- isPagerUpdated = true
- } catch (e: Exception) {
- Logger.writeLine(Log.ERROR, e)
- }
- }
+ viewModelScope.launch {
+ updatePlayerArtwork(artworkBytes)
+ }
+ }
- MediaHelper.MediaPlayerStatePath -> {
- deleteJob?.cancel()
- val dataMap = DataMapItem.fromDataItem(item).dataMap
- updatePlayerState(dataMap)
- }
+ MediaHelper.MediaPlayerAppInfoPath -> {
+ val appInfo = messageEvent.data?.let {
+ JSONParser.deserializer(it.bytesToString(), AppItemData::class.java)
+ }
+
+ viewModelScope.launch {
+ viewModelState.update {
+ it.copy(
+ mediaPlayerDetails = AppItemViewModel().apply {
+ appLabel = appInfo?.label
+ packageName = appInfo?.packageName
+ activityName = appInfo?.activityName
+ bitmapIcon = appInfo?.iconBitmap?.toBitmap()
+ }
+ )
}
- } 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
- }
+ else -> super.onMessageReceived(messageEvent)
+ }
+ }
- MediaHelper.MediaQueueItemsPath -> {
- mediaPagerState = mediaPagerState.copy(
- supportsQueue = false,
- )
+ private fun startChannelListener(channel: Channel) {
+ when (channel.path) {
+ MediaHelper.MediaActionsPath,
+ MediaHelper.MediaBrowserItemsPath,
+ MediaHelper.MediaBrowserItemsExtraSuggestedPath,
+ MediaHelper.MediaQueueItemsPath -> {
+ createChannelListener(channel)
+ }
+ }
+ }
- viewModelState.update {
- it.copy(
- activeQueueItemId = -1
+ private fun createChannelListener(channel: Channel): Job =
+ viewModelScope.launch(Dispatchers.Default) {
+ supervisorScope {
+ runCatching {
+ val stream = Wearable.getChannelClient(appContext)
+ .getInputStream(channel).await()
+ stream.bufferedReader().use { reader ->
+ val line = reader.readLine()
+
+ when {
+ line.startsWith("data: ") -> {
+ runCatching {
+ val json = line.substringAfter("data: ")
+ _channelEventsFlow.tryEmit(
+ WearableEvent(channel.path, Bundle().apply {
+ putString(EXTRA_ACTIONDATA, json)
+ })
)
+ }.onFailure {
+ Logger.error(
+ "MediaPlayerChannelListener",
+ it,
+ "error reading data for channel = ${channel.path}"
+ )
}
-
- isPagerUpdated = true
}
- MediaHelper.MediaPlayerStatePath -> {
- deleteJob?.cancel()
- deleteJob = viewModelScope.launch delete@{
- delay(1000)
-
- if (!isActive) return@delete
-
- updatePlayerState(DataMap())
+ line.isEmpty() -> {
+ // empty line; data terminator
}
- }
- }
- }
- }
- if (isPagerUpdated) {
- updatePagerJob = viewModelScope.launch updatePagerJob@{
- delay(1000)
-
- if (!isActive) return@updatePagerJob
-
- viewModelState.update {
- it.copy(
- pagerState = mediaPagerState
- )
+ else -> {}
}
}
+ }.onFailure {
+ Logger.error("MediaPlayerChannelListener", it)
}
}
}
@@ -371,7 +417,7 @@ class MediaPlayerViewModel(app: Application) : WearableListenerViewModel(app),
viewModelState.update {
it.copy(
mediaPlayerDetails = AppItemViewModel(),
- isAutoLaunch = false
+ isAutoLaunch = true
)
}
requestPlayerConnect()
@@ -387,78 +433,6 @@ class MediaPlayerViewModel(app: Application) : WearableListenerViewModel(app),
requestPlayerConnect()
}
- 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()) {
@@ -481,11 +455,18 @@ class MediaPlayerViewModel(app: Application) : WearableListenerViewModel(app),
}
}
+ private fun requestPlayerAppInfo() {
+ requestMediaAction(MediaHelper.MediaPlayerAppInfoPath)
+ }
+
fun refreshStatus() {
viewModelScope.launch {
updateConnectionStatus()
requestPlayerConnect()
- updatePager()
+ requestPlayerAppInfo()
+ requestUpdateCustomControls()
+ //requestUpdateBrowserItems()
+ requestUpdateQueueItems()
}
}
@@ -493,80 +474,53 @@ class MediaPlayerViewModel(app: Application) : WearableListenerViewModel(app),
viewModelScope.launch {
// Request connect to media player
requestVolumeStatus()
- updatePlayerState()
+ requestUpdatePlayerState()
}
}
- 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
- }
- }
- val positionState = dataMap.getString(MediaHelper.KEY_MEDIA_POSITIONSTATE)?.let {
- JSONParser.deserializer(it, PositionState::class.java)
- }
+ fun requestUpdatePlayerState() {
+ requestMediaAction(MediaHelper.MediaPlayerStatePath)
+ }
- if (playbackState != PlaybackState.NONE) {
- viewModelState.update {
- it.copy(
- playerState = PlayerState(
- playbackState = playbackState,
- title = title,
- artist = artist,
- artworkBitmap = artBitmap,
- positionState = positionState
- ),
- isLoading = false,
- isPlaybackLoading = playbackState == PlaybackState.LOADING
- )
- }
- } else {
- viewModelState.update {
- it.copy(
- playerState = PlayerState(),
- isLoading = false,
- isPlaybackLoading = false
- )
- }
+ private suspend fun updatePlayerState(playerState: MediaPlayerState?) {
+ val playbackState = playerState?.playbackState ?: PlaybackState.NONE
+ val title = playerState?.mediaMetaData?.title
+ val artist = playerState?.mediaMetaData?.artist
+ val positionState = playerState?.mediaMetaData?.positionState
+
+ if (playbackState != PlaybackState.NONE) {
+ viewModelState.update {
+ it.copy(
+ playerState = it.playerState.copy(
+ playbackState = playbackState,
+ title = title,
+ artist = artist,
+ positionState = positionState
+ ),
+ 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)
- }
- }
+ private suspend fun updatePlayerArtwork(artworkBytes: ByteArray?) {
+ val artworkBitmap = artworkBytes?.toBitmap()
- buff.release()
- } catch (e: Exception) {
- Logger.writeLine(Log.ERROR, e)
- }
+ viewModelState.update {
+ it.copy(
+ playerState = it.playerState.copy(
+ artworkBitmap = artworkBitmap
+ )
+ )
}
}
@@ -620,125 +574,56 @@ class MediaPlayerViewModel(app: Application) : WearableListenerViewModel(app),
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)
- }
- }
+ fun requestUpdateCustomControls() {
+ requestMediaAction(MediaHelper.MediaActionsPath)
}
- 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
- }
+ private suspend fun updateCustomControls(customControls: CustomControls?) {
+ val mediaItems = customControls?.actions?.map { action ->
+ MediaItemModel(action.action).apply {
+ title = action.title
+ icon = action.icon?.toBitmap()
}
- 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
+ mediaCustomItems = mediaItems ?: emptyList(),
+ pagerState = it.pagerState.copy(
+ supportsCustomActions = !mediaItems.isNullOrEmpty()
+ )
)
}
}
// 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)
- }
- }
+ fun requestUpdateBrowserItems() {
+ requestMediaAction(MediaHelper.MediaBrowserItemsPath)
}
- private suspend fun updateBrowserItems(dataMap: DataMap) {
- val isRoot = dataMap.getBoolean(MediaHelper.KEY_MEDIAITEM_ISROOT)
- val items = dataMap.getDataMapArrayList(MediaHelper.KEY_MEDIAITEMS) ?: emptyList()
+ private suspend fun updateBrowserItems(browseMediaItems: BrowseMediaItems?) {
+ val isRoot = browseMediaItems?.isRoot ?: true
+ val items = browseMediaItems?.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
+ mediaItems.add(MediaItemModel(item.mediaId).apply {
+ this.icon = item.icon?.toBitmap()
+ this.title = item.title
})
}
viewModelState.update {
it.copy(
isLoading = false,
- mediaBrowserItems = mediaItems
+ mediaBrowserItems = mediaItems,
+ pagerState = it.pagerState.copy(
+ supportsBrowser = items.isNotEmpty()
+ )
)
}
}
@@ -752,64 +637,26 @@ class MediaPlayerViewModel(app: Application) : WearableListenerViewModel(app),
}
// 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)
- }
- }
+ fun requestUpdateQueueItems() {
+ requestMediaAction(MediaHelper.MediaQueueItemsPath)
}
- 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
- }
+ private suspend fun updateQueueItems(queueItems: QueueItems?) {
+ val mediaQueueItems = queueItems?.queueItems?.map { item ->
+ MediaItemModel(item.queueId.toString()).apply {
+ this.icon = item.icon?.toBitmap()
+ this.title = item.title
}
- 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
+ mediaQueueItems = mediaQueueItems ?: emptyList(),
+ activeQueueItemId = queueItems?.activeQueueItemId ?: -1,
+ pagerState = it.pagerState.copy(
+ supportsQueue = !mediaQueueItems.isNullOrEmpty()
+ )
)
}
}
@@ -817,4 +664,21 @@ class MediaPlayerViewModel(app: Application) : WearableListenerViewModel(app),
fun requestQueueActionItem(itemId: String) {
requestMediaAction(MediaHelper.MediaQueueItemsClickPath, itemId.stringToBytes())
}
+
+ fun updateCurrentPage(pageType: MediaPageType) {
+ viewModelState.update {
+ it.copy(
+ pagerState = it.pagerState.copy(
+ currentPageKey = pageType
+ )
+ )
+ }
+ }
+
+ override fun onCleared() {
+ Wearable.getChannelClient(appContext).run {
+ unregisterChannelCallback(channelCallback)
+ }
+ super.onCleared()
+ }
}
\ No newline at end of file
diff --git a/wear/src/main/java/com/thewizrd/simplewear/media/MediaVolumeRepository.kt b/wear/src/main/java/com/thewizrd/simplewear/media/MediaVolumeRepository.kt
new file mode 100644
index 00000000..0ead3746
--- /dev/null
+++ b/wear/src/main/java/com/thewizrd/simplewear/media/MediaVolumeRepository.kt
@@ -0,0 +1,65 @@
+package com.thewizrd.simplewear.media
+
+import androidx.lifecycle.viewModelScope
+import com.google.android.horologist.audio.VolumeRepository
+import com.google.android.horologist.audio.VolumeState
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+
+class MediaVolumeRepository(private val mediaPlayerViewModel: MediaPlayerViewModel) :
+ VolumeRepository {
+ override val volumeState: StateFlow
+ get() = localVolumeState
+
+ private val localVolumeState = MutableStateFlow(VolumeState(current = 0, max = 1))
+
+ private val remoteVolumeState = mediaPlayerViewModel.uiState.map {
+ VolumeState(
+ current = it.audioStreamState?.currentVolume ?: 0,
+ min = it.audioStreamState?.minVolume ?: 0,
+ max = it.audioStreamState?.maxVolume ?: 1
+ )
+ }
+
+ init {
+ mediaPlayerViewModel.viewModelScope.launch(Dispatchers.Default) {
+ remoteVolumeState.collectLatest { state ->
+ delay(1000)
+
+ if (!isActive) return@collectLatest
+
+ localVolumeState.emit(state)
+ }
+ }
+ }
+
+ override fun close() {}
+
+ override fun decreaseVolume() {
+ localVolumeState.update {
+ it.copy(current = it.current - 1)
+ }
+ mediaPlayerViewModel.requestVolumeDown()
+ }
+
+ override fun increaseVolume() {
+ localVolumeState.update {
+ it.copy(current = it.current + 1)
+ }
+ mediaPlayerViewModel.requestVolumeUp()
+ }
+
+ override fun setVolume(volume: Int) {
+ localVolumeState.update {
+ it.copy(current = volume)
+ }
+ mediaPlayerViewModel.requestSetVolume(volume)
+ }
+}
\ No newline at end of file
diff --git a/wear/src/main/java/com/thewizrd/simplewear/media/MediaVolumeViewModel.kt b/wear/src/main/java/com/thewizrd/simplewear/media/MediaVolumeViewModel.kt
new file mode 100644
index 00000000..c62c4f86
--- /dev/null
+++ b/wear/src/main/java/com/thewizrd/simplewear/media/MediaVolumeViewModel.kt
@@ -0,0 +1,18 @@
+@file:OptIn(ExperimentalHorologistApi::class)
+
+package com.thewizrd.simplewear.media
+
+import android.content.Context
+import android.os.Vibrator
+import com.google.android.horologist.annotations.ExperimentalHorologistApi
+import com.google.android.horologist.audio.ui.VolumeViewModel
+
+class MediaVolumeViewModel(context: Context, mediaPlayerViewModel: MediaPlayerViewModel) :
+ VolumeViewModel(
+ volumeRepository = MediaVolumeRepository(mediaPlayerViewModel),
+ audioOutputRepository = NoopAudioOutputRepository(),
+ onCleared = {
+
+ },
+ vibrator = context.getSystemService(Vibrator::class.java)
+ )
\ No newline at end of file
diff --git a/wear/src/main/java/com/thewizrd/simplewear/media/PlayerUiController.kt b/wear/src/main/java/com/thewizrd/simplewear/media/PlayerUiController.kt
new file mode 100644
index 00000000..80d6b131
--- /dev/null
+++ b/wear/src/main/java/com/thewizrd/simplewear/media/PlayerUiController.kt
@@ -0,0 +1,29 @@
+package com.thewizrd.simplewear.media
+
+interface PlayerUiController {
+ fun play()
+ fun pause()
+ fun skipToPreviousMedia()
+ fun skipToNextMedia()
+}
+
+class NoopPlayerUiController : PlayerUiController {
+ override fun play() {}
+
+ override fun pause() {}
+
+ override fun skipToPreviousMedia() {}
+
+ override fun skipToNextMedia() {}
+}
+
+class MediaPlayerUiController(private val mediaPlayerViewModel: MediaPlayerViewModel) :
+ PlayerUiController {
+ override fun play() = mediaPlayerViewModel.requestPlayPauseAction(play = true)
+
+ override fun pause() = mediaPlayerViewModel.requestPlayPauseAction(play = false)
+
+ override fun skipToPreviousMedia() = mediaPlayerViewModel.requestSkipToPreviousAction()
+
+ override fun skipToNextMedia() = mediaPlayerViewModel.requestSkipToNextAction()
+}
\ 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 e4cc0080..f0e1d156 100644
--- a/wear/src/main/java/com/thewizrd/simplewear/preferences/Settings.kt
+++ b/wear/src/main/java/com/thewizrd/simplewear/preferences/Settings.kt
@@ -1,11 +1,10 @@
package com.thewizrd.simplewear.preferences
import androidx.core.content.edit
-import androidx.preference.PreferenceManager
import com.google.gson.reflect.TypeToken
import com.thewizrd.shared_resources.actions.Actions
+import com.thewizrd.shared_resources.appLib
import com.thewizrd.shared_resources.utils.JSONParser
-import com.thewizrd.simplewear.App
import java.time.Instant
object Settings {
@@ -21,57 +20,48 @@ object Settings {
private const val KEY_LASTUPDATECHECK = "key_lastupdatecheck"
fun useGridLayout(): Boolean {
- val preferences = PreferenceManager.getDefaultSharedPreferences(App.instance.appContext)
- return preferences.getBoolean(KEY_LAYOUTMODE, true)
+ return appLib.preferences.getBoolean(KEY_LAYOUTMODE, true)
}
fun setGridLayout(value: Boolean) {
- val preferences = PreferenceManager.getDefaultSharedPreferences(App.instance.appContext)
- preferences.edit {
+ appLib.preferences.edit {
putBoolean(KEY_LAYOUTMODE, value)
}
}
val isAutoLaunchMediaCtrlsEnabled: Boolean
get() {
- val preferences = PreferenceManager.getDefaultSharedPreferences(App.instance.appContext)
- return preferences.getBoolean(KEY_AUTOLAUNCH, true)
+ return appLib.preferences.getBoolean(KEY_AUTOLAUNCH, true)
}
fun setAutoLaunchMediaCtrls(enabled: Boolean) {
- val preferences = PreferenceManager.getDefaultSharedPreferences(App.instance.appContext)
- preferences.edit {
+ appLib.preferences.edit {
putBoolean(KEY_AUTOLAUNCH, enabled)
}
}
fun getMusicPlayersFilter(): Set {
- val preferences = PreferenceManager.getDefaultSharedPreferences(App.instance.appContext)
- return preferences.getStringSet(KEY_MUSICFILTER, emptySet()) ?: emptySet()
+ return appLib.preferences.getStringSet(KEY_MUSICFILTER, emptySet()) ?: emptySet()
}
fun setMusicPlayersFilter(c: Set) {
- val preferences = PreferenceManager.getDefaultSharedPreferences(App.instance.appContext)
- preferences.edit {
+ appLib.preferences.edit {
putStringSet(KEY_MUSICFILTER, c)
}
}
fun isLoadAppIcons(): Boolean {
- val preferences = PreferenceManager.getDefaultSharedPreferences(App.instance.appContext)
- return preferences.getBoolean(KEY_LOADAPPICONS, false)
+ return appLib.preferences.getBoolean(KEY_LOADAPPICONS, false)
}
fun setLoadAppIcons(value: Boolean) {
- val preferences = PreferenceManager.getDefaultSharedPreferences(App.instance.appContext)
- preferences.edit {
+ appLib.preferences.edit {
putBoolean(KEY_LOADAPPICONS, value)
}
}
fun getDashboardTileConfig(): List? {
- val preferences = PreferenceManager.getDefaultSharedPreferences(App.instance.appContext)
- val configJSON = preferences.getString(KEY_DASHTILECONFIG, null)
+ val configJSON = appLib.preferences.getString(KEY_DASHTILECONFIG, null)
return configJSON?.let {
val arrListType = object : TypeToken>() {}.type
JSONParser.deserializer>(it, arrListType)
@@ -79,8 +69,7 @@ object Settings {
}
fun setDashboardTileConfig(actions: List?) {
- val preferences = PreferenceManager.getDefaultSharedPreferences(App.instance.appContext)
- preferences.edit {
+ appLib.preferences.edit {
putString(KEY_DASHTILECONFIG, actions?.let {
val arrListType = object : TypeToken>() {}.type
JSONParser.serializer(it, arrListType)
@@ -89,8 +78,7 @@ object Settings {
}
fun getDashboardConfig(): List? {
- val preferences = PreferenceManager.getDefaultSharedPreferences(App.instance.appContext)
- val configJSON = preferences.getString(KEY_DASHCONFIG, null)
+ val configJSON = appLib.preferences.getString(KEY_DASHCONFIG, null)
return configJSON?.let {
val arrListType = object : TypeToken>() {}.type
JSONParser.deserializer>(it, arrListType)
@@ -98,8 +86,7 @@ object Settings {
}
fun setDashboardConfig(actions: List?) {
- val preferences = PreferenceManager.getDefaultSharedPreferences(App.instance.appContext)
- preferences.edit {
+ appLib.preferences.edit {
putString(KEY_DASHCONFIG, actions?.let {
val arrListType = object : TypeToken>() {}.type
JSONParser.serializer(it, arrListType)
@@ -108,38 +95,33 @@ object Settings {
}
fun isShowBatStatus(): Boolean {
- val preferences = PreferenceManager.getDefaultSharedPreferences(App.instance.appContext)
- return preferences.getBoolean(KEY_SHOWBATSTATUS, true)
+ return appLib.preferences.getBoolean(KEY_SHOWBATSTATUS, true)
}
fun setShowBatStatus(value: Boolean) {
- val preferences = PreferenceManager.getDefaultSharedPreferences(App.instance.appContext)
- preferences.edit {
+ appLib.preferences.edit {
putBoolean(KEY_SHOWBATSTATUS, value)
}
}
fun isShowTileBatStatus(): Boolean {
- val preferences = PreferenceManager.getDefaultSharedPreferences(App.instance.appContext)
- return preferences.getBoolean(KEY_SHOWTILEBATSTATUS, true)
+ return appLib.preferences.getBoolean(KEY_SHOWTILEBATSTATUS, true)
}
fun setShowTileBatStatus(value: Boolean) {
- val preferences = PreferenceManager.getDefaultSharedPreferences(App.instance.appContext)
- preferences.edit {
+ appLib.preferences.edit {
putBoolean(KEY_SHOWTILEBATSTATUS, value)
}
}
fun getLastUpdateCheckTime(): Instant {
- val preferences = PreferenceManager.getDefaultSharedPreferences(App.instance.appContext)
- val epochSeconds = preferences.getLong(KEY_LASTUPDATECHECK, Instant.EPOCH.epochSecond)
+ val epochSeconds =
+ appLib.preferences.getLong(KEY_LASTUPDATECHECK, Instant.EPOCH.epochSecond)
return Instant.ofEpochSecond(epochSeconds)
}
fun setLastUpdateCheckTime(value: Instant) {
- val preferences = PreferenceManager.getDefaultSharedPreferences(App.instance.appContext)
- preferences.edit {
+ appLib.preferences.edit {
putLong(KEY_LASTUPDATECHECK, value.epochSecond)
}
}
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
index 7e4dfbe8..a54975fe 100644
--- a/wear/src/main/java/com/thewizrd/simplewear/ui/ambient/AmbientMode.kt
+++ b/wear/src/main/java/com/thewizrd/simplewear/ui/ambient/AmbientMode.kt
@@ -50,11 +50,13 @@ private fun rememberBurnInTranslation(
remember(ambientState) {
when (ambientState) {
AmbientState.Interactive -> 0f
- is AmbientState.Ambient -> if (ambientState.ambientDetails?.burnInProtectionRequired == true) {
+ is AmbientState.Ambient -> if (ambientState.burnInProtectionRequired) {
Random.nextInt(-BURN_IN_OFFSET_PX, BURN_IN_OFFSET_PX + 1).toFloat()
} else {
0f
}
+
+ AmbientState.Inactive -> 0f
}
}
diff --git a/wear/src/main/java/com/thewizrd/simplewear/ui/components/ConfirmationOverlay.kt b/wear/src/main/java/com/thewizrd/simplewear/ui/components/ConfirmationOverlay.kt
new file mode 100644
index 00000000..b4a4d007
--- /dev/null
+++ b/wear/src/main/java/com/thewizrd/simplewear/ui/components/ConfirmationOverlay.kt
@@ -0,0 +1,102 @@
+package com.thewizrd.simplewear.ui.components
+
+import androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi
+import androidx.compose.animation.graphics.res.animatedVectorResource
+import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter
+import androidx.compose.animation.graphics.vector.AnimatedImageVector
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.size
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalAccessibilityManager
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.unit.dp
+import androidx.wear.compose.material.Icon
+import androidx.wear.compose.material.dialog.Dialog
+import androidx.wear.compose.material.dialog.DialogDefaults
+import com.google.android.horologist.annotations.ExperimentalHorologistApi
+import com.google.android.horologist.compose.layout.ScalingLazyColumnDefaults
+import com.google.android.horologist.compose.layout.rememberColumnState
+import com.google.android.horologist.compose.material.ConfirmationContent
+import com.thewizrd.simplewear.viewmodels.ConfirmationData
+import kotlinx.coroutines.delay
+
+@OptIn(ExperimentalHorologistApi::class, ExperimentalAnimationGraphicsApi::class)
+@Composable
+fun ConfirmationOverlay(
+ confirmationData: ConfirmationData?,
+ onTimeout: () -> Unit,
+ showDialog: Boolean = confirmationData != null
+) {
+ val currentOnDismissed by rememberUpdatedState(onTimeout)
+ val durationMillis = remember(confirmationData) {
+ confirmationData?.durationMs ?: DialogDefaults.ShortDurationMillis
+ }
+
+ val a11yDurationMillis = LocalAccessibilityManager.current?.calculateRecommendedTimeoutMillis(
+ originalTimeoutMillis = durationMillis,
+ containsIcons = confirmationData?.iconResId != null,
+ containsText = confirmationData?.title != null,
+ containsControls = false,
+ ) ?: durationMillis
+
+ val columnState = rememberColumnState(
+ ScalingLazyColumnDefaults.responsive(
+ verticalArrangement = Arrangement.spacedBy(
+ space = 4.dp,
+ alignment = Alignment.CenterVertically
+ ),
+ additionalPaddingAtBottom = 0.dp,
+ ),
+ )
+
+ LaunchedEffect(a11yDurationMillis, confirmationData) {
+ if (showDialog) {
+ delay(a11yDurationMillis)
+ currentOnDismissed()
+ }
+ }
+
+ Dialog(
+ showDialog = showDialog,
+ onDismissRequest = currentOnDismissed,
+ scrollState = columnState.state,
+ ) {
+ ConfirmationContent(
+ icon = confirmationData?.animatedVectorResId?.let { iconResId ->
+ {
+ val image = AnimatedImageVector.animatedVectorResource(iconResId)
+ var atEnd by remember { mutableStateOf(false) }
+
+ Icon(
+ modifier = Modifier.size(48.dp),
+ painter = rememberAnimatedVectorPainter(image, atEnd),
+ contentDescription = null
+ )
+
+ LaunchedEffect(iconResId) {
+ atEnd = !atEnd
+ }
+ }
+ } ?: confirmationData?.iconResId?.let { iconResId ->
+ {
+ Icon(
+ modifier = Modifier.size(48.dp),
+ painter = painterResource(iconResId),
+ contentDescription = null
+ )
+ }
+ },
+ title = confirmationData?.title,
+ columnState = columnState,
+ showPositionIndicator = false,
+ )
+ }
+}
\ No newline at end of file
diff --git a/wear/src/main/java/com/thewizrd/simplewear/ui/components/ScalingLazyColumn.kt b/wear/src/main/java/com/thewizrd/simplewear/ui/components/ScalingLazyColumn.kt
index 6df2e102..5981462f 100644
--- a/wear/src/main/java/com/thewizrd/simplewear/ui/components/ScalingLazyColumn.kt
+++ b/wear/src/main/java/com/thewizrd/simplewear/ui/components/ScalingLazyColumn.kt
@@ -8,11 +8,11 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.wear.compose.foundation.ExperimentalWearFoundationApi
import androidx.wear.compose.foundation.lazy.ScalingLazyListScope
import androidx.wear.compose.foundation.rememberActiveFocusRequester
+import androidx.wear.compose.foundation.rotary.RotaryScrollableDefaults
+import androidx.wear.compose.foundation.rotary.rotaryScrollable
import com.google.android.horologist.annotations.ExperimentalHorologistApi
import com.google.android.horologist.compose.layout.ScalingLazyColumnState
import com.google.android.horologist.compose.layout.rememberResponsiveColumnState
-import com.google.android.horologist.compose.rotaryinput.rememberRotaryHapticHandler
-import com.google.android.horologist.compose.rotaryinput.rotaryWithScroll
@ExperimentalWearFoundationApi
@ExperimentalHorologistApi
@@ -26,19 +26,18 @@ fun ScalingLazyColumn(
androidx.wear.compose.foundation.lazy.ScalingLazyColumn(
modifier = modifier
.fillMaxSize()
- .rotaryWithScroll(
+ .rotaryScrollable(
+ behavior = RotaryScrollableDefaults.behavior(scrollState),
focusRequester = focusRequester,
- scrollableState = scrollState.state,
- reverseDirection = scrollState.reverseLayout,
- rotaryHaptics = rememberRotaryHapticHandler(scrollState)
+ reverseDirection = scrollState.reverseLayout
),
state = scrollState.state,
contentPadding = scrollState.contentPadding,
reverseLayout = scrollState.reverseLayout,
verticalArrangement = scrollState.verticalArrangement,
horizontalAlignment = scrollState.horizontalAlignment,
- flingBehavior = scrollState.flingBehavior
- ?: ScrollableDefaults.flingBehavior(),
+ flingBehavior = ScrollableDefaults.flingBehavior(),
+ rotaryScrollableBehavior = null,
userScrollEnabled = scrollState.userScrollEnabled,
scalingParams = scrollState.scalingParams,
anchorType = scrollState.anchorType,
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 06d3fba6..5a0ca150 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,6 @@
package com.thewizrd.simplewear.ui.simplewear
import android.content.Intent
-import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
@@ -20,13 +19,13 @@ 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.compose.LocalLifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
@@ -57,17 +56,18 @@ 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.ConfirmationOverlay
import com.thewizrd.simplewear.ui.components.LoadingContent
import com.thewizrd.simplewear.ui.components.SwipeToDismissPagerScreen
import com.thewizrd.simplewear.ui.theme.findActivity
import com.thewizrd.simplewear.viewmodels.AppLauncherUiState
import com.thewizrd.simplewear.viewmodels.AppLauncherViewModel
+import com.thewizrd.simplewear.viewmodels.ConfirmationData
+import com.thewizrd.simplewear.viewmodels.ConfirmationViewModel
import com.thewizrd.simplewear.viewmodels.WearableListenerViewModel
import kotlinx.coroutines.launch
@OptIn(
- ExperimentalFoundationApi::class,
ExperimentalHorologistApi::class
)
@Composable
@@ -83,6 +83,9 @@ fun AppLauncherScreen(
val appLauncherViewModel = viewModel()
val uiState by appLauncherViewModel.uiState.collectAsState()
+ val confirmationViewModel = viewModel()
+ val confirmationData by confirmationViewModel.confirmationEventsFlow.collectAsState()
+
val scrollState = rememberResponsiveColumnState(
contentPadding = ScalingLazyColumnDefaults.padding(
first = ScalingLazyColumnDefaults.ItemType.Unspecified,
@@ -121,6 +124,11 @@ fun AppLauncherScreen(
}
}
+ ConfirmationOverlay(
+ confirmationData = confirmationData,
+ onTimeout = { confirmationViewModel.clearFlow() },
+ )
+
LaunchedEffect(context) {
appLauncherViewModel.initActivityContext(activity)
}
@@ -174,37 +182,23 @@ fun AppLauncherScreen(
when (status) {
ActionStatus.SUCCESS -> {
- CustomConfirmationOverlay()
- .setType(CustomConfirmationOverlay.SUCCESS_ANIMATION)
- .showOn(activity)
+ confirmationViewModel.showSuccess()
}
ActionStatus.PERMISSION_DENIED -> {
- CustomConfirmationOverlay()
- .setType(CustomConfirmationOverlay.CUSTOM_ANIMATION)
- .setCustomDrawable(
- ContextCompat.getDrawable(
- activity,
- R.drawable.ws_full_sad
- )
+ confirmationViewModel.showConfirmation(
+ ConfirmationData(
+ title = context.getString(R.string.error_permissiondenied)
)
- .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)
+ confirmationViewModel.showFailure(
+ message = context.getString(R.string.error_actionfailed)
+ )
}
else -> {}
@@ -217,7 +211,7 @@ fun AppLauncherScreen(
LaunchedEffect(Unit) {
// Update statuses
- appLauncherViewModel.refreshApps(true)
+ appLauncherViewModel.refreshApps()
}
}
@@ -280,7 +274,7 @@ private fun AppLauncherScreen(
icon = {
Icon(
painter = painterResource(id = R.drawable.ic_baseline_refresh_24),
- contentDescription = null
+ contentDescription = stringResource(id = R.string.action_refresh)
)
},
onClick = onRefresh
@@ -303,19 +297,19 @@ private fun AppLauncherScreen(
items(
items = uiState.appsList,
key = { Pair(it.activityName, it.packageName) }
- ) {
+ ) { appItem ->
Chip(
modifier = Modifier.fillMaxWidth(),
label = {
- Text(text = it.appLabel ?: "")
+ Text(text = appItem.appLabel ?: "")
},
icon = if (uiState.loadAppIcons) {
- it.bitmapIcon?.let {
+ appItem.bitmapIcon?.let {
{
Icon(
modifier = Modifier.requiredSize(ChipDefaults.IconSize),
bitmap = it.asImageBitmap(),
- contentDescription = null,
+ contentDescription = appItem.appLabel,
tint = Color.Unspecified
)
}
@@ -325,7 +319,7 @@ private fun AppLauncherScreen(
},
colors = ChipDefaults.secondaryChipColors(),
onClick = {
- onItemClicked(it)
+ onItemClicked(appItem)
}
)
}
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 94d56a50..f615a52b 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
@@ -12,9 +12,11 @@ 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.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.FlowRowOverflow
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
@@ -25,7 +27,6 @@ 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.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -43,7 +44,6 @@ 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
@@ -53,8 +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.compose.LocalLifecycleOwner
import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import androidx.wear.compose.material.Button
import androidx.wear.compose.material.ButtonDefaults
@@ -66,6 +67,7 @@ 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.material.ripple
import androidx.wear.compose.ui.tooling.preview.WearPreviewDevices
import androidx.wear.compose.ui.tooling.preview.WearPreviewFontScales
import com.thewizrd.shared_resources.actions.ActionStatus
@@ -75,13 +77,15 @@ 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.ConfirmationOverlay
import com.thewizrd.simplewear.ui.components.LoadingContent
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.ConfirmationData
+import com.thewizrd.simplewear.viewmodels.ConfirmationViewModel
import com.thewizrd.simplewear.viewmodels.WearableListenerViewModel
import kotlinx.coroutines.launch
@@ -97,6 +101,9 @@ fun CallManagerUi(
val callManagerViewModel = activityViewModel()
val uiState by callManagerViewModel.uiState.collectAsState()
+ val confirmationViewModel = viewModel()
+ val confirmationData by confirmationViewModel.confirmationEventsFlow.collectAsState()
+
Scaffold(
modifier = modifier.background(MaterialTheme.colors.background),
vignette = { Vignette(vignettePosition = VignettePosition.TopAndBottom) },
@@ -118,6 +125,11 @@ fun CallManagerUi(
}
}
+ ConfirmationOverlay(
+ confirmationData = confirmationData,
+ onTimeout = { confirmationViewModel.clearFlow() },
+ )
+
LaunchedEffect(lifecycleOwner) {
lifecycleOwner.lifecycleScope.launch {
callManagerViewModel.eventFlow.collect { event ->
@@ -160,21 +172,16 @@ fun CallManagerUi(
}
}
- InCallUIHelper.CallStatePath -> {
+ InCallUIHelper.ConnectPath -> {
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
- )
+ confirmationViewModel.showConfirmation(
+ ConfirmationData(
+ title = context.getString(R.string.error_permissiondenied)
)
- .setMessage(activity.getString(R.string.error_permissiondenied))
- .showOn(activity)
+ )
callManagerViewModel.openAppOnPhone(activity, false)
}
@@ -260,7 +267,7 @@ private fun CallManagerUi(
Image(
modifier = Modifier.fillMaxSize(),
bitmap = uiState.callerBitmap.asImageBitmap(),
- contentDescription = null
+ contentDescription = stringResource(R.string.desc_contact_photo)
)
}
@@ -299,24 +306,36 @@ private fun CallManagerUi(
CallUiButton(
iconResourceId = R.drawable.ic_mic_off_24dp,
isChecked = uiState.isMuted,
- onClick = onMute
+ onClick = onMute,
+ contentDescription = if (uiState.isMuted) {
+ stringResource(R.string.volstate_muted)
+ } else {
+ stringResource(R.string.label_mute)
+ }
)
if (uiState.canSendDTMFKeys) {
CallUiButton(
iconResourceId = R.drawable.ic_dialpad_24dp,
- onClick = onShowKeypadUi
+ onClick = onShowKeypadUi,
+ contentDescription = stringResource(R.string.label_keypad)
)
}
if (uiState.supportsSpeaker) {
CallUiButton(
iconResourceId = R.drawable.ic_baseline_speaker_phone_24,
isChecked = uiState.isSpeakerPhoneOn,
- onClick = onSpeakerPhone
+ onClick = onSpeakerPhone,
+ contentDescription = if (uiState.isSpeakerPhoneOn) {
+ stringResource(R.string.desc_speakerphone_on)
+ } else {
+ stringResource(R.string.desc_speakerphone_off)
+ }
)
}
CallUiButton(
iconResourceId = R.drawable.ic_volume_up_white_24dp,
- onClick = onVolume
+ onClick = onVolume,
+ contentDescription = stringResource(R.string.action_volume)
)
}
@@ -346,7 +365,7 @@ private fun CallUiButton(
modifier: Modifier = Modifier,
isChecked: Boolean = false,
@DrawableRes iconResourceId: Int,
- contentDescription: String? = null,
+ contentDescription: String?,
onClick: () -> Unit = {}
) {
Box(
@@ -356,7 +375,7 @@ private fun CallUiButton(
onClick = onClick,
role = Role.Button,
interactionSource = remember { MutableInteractionSource() },
- indication = rememberRipple(
+ indication = ripple(
color = MaterialTheme.colors.onSurface,
radius = 20.dp
)
@@ -399,6 +418,7 @@ private fun NoCallActiveScreen() {
@OptIn(ExperimentalLayoutApi::class)
@WearPreviewDevices
+@WearPreviewFontScales
@Composable
private fun KeypadScreen(
onKeyPressed: (Char) -> Unit = {}
@@ -442,36 +462,38 @@ private fun KeypadScreen(
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
- )
+ BoxWithConstraints {
+ FlowRow(
+ modifier = Modifier
+ .fillMaxSize()
+ .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,
+ overflow = FlowRowOverflow.Visible
+ ) {
+ digits.forEach {
+ Box(
+ modifier = Modifier
+ .weight(1f, fill = true)
+ .height((this@BoxWithConstraints.maxHeight - if (isRound) 32.dp else 8.dp) / 4)
+ .clickable {
+ keypadText += it
+ onKeyPressed.invoke(it)
+ },
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = it + "",
+ maxLines = 1,
+ textAlign = TextAlign.Center,
+ fontSize = 16.sp
+ )
+ }
}
}
}
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 c77d5557..3d6fdc17 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
@@ -23,6 +23,7 @@ import androidx.compose.material.icons.filled.Info
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
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
@@ -30,12 +31,11 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
-import androidx.core.content.ContextCompat
import androidx.core.content.PermissionChecker
import androidx.lifecycle.compose.LifecycleResumeEffect
+import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.withStarted
@@ -68,11 +68,13 @@ import com.thewizrd.shared_resources.utils.JSONParser
import com.thewizrd.shared_resources.utils.Logger
import com.thewizrd.simplewear.PhoneSyncActivity
import com.thewizrd.simplewear.R
-import com.thewizrd.simplewear.controls.CustomConfirmationOverlay
import com.thewizrd.simplewear.preferences.Settings
+import com.thewizrd.simplewear.ui.components.ConfirmationOverlay
import com.thewizrd.simplewear.ui.theme.findActivity
import com.thewizrd.simplewear.updates.InAppUpdateManager
import com.thewizrd.simplewear.utils.ErrorMessage
+import com.thewizrd.simplewear.viewmodels.ConfirmationData
+import com.thewizrd.simplewear.viewmodels.ConfirmationViewModel
import com.thewizrd.simplewear.viewmodels.DashboardViewModel
import com.thewizrd.simplewear.viewmodels.WearableListenerViewModel
import kotlinx.coroutines.delay
@@ -93,6 +95,9 @@ fun Dashboard(
val lifecycleOwner = LocalLifecycleOwner.current
val dashboardViewModel = viewModel()
+ val confirmationViewModel = viewModel()
+ val confirmationData by confirmationViewModel.confirmationEventsFlow.collectAsState()
+
val scrollState = rememberScrollState()
var stateRefreshed by remember { mutableStateOf(false) }
@@ -203,6 +208,11 @@ fun Dashboard(
}
}
+ ConfirmationOverlay(
+ confirmationData = confirmationData,
+ onTimeout = { confirmationViewModel.clearFlow() },
+ )
+
val permissionLauncher =
rememberLauncherForActivityResult(contract = ActivityResultContracts.RequestMultiplePermissions()) {}
@@ -340,30 +350,18 @@ fun Dashboard(
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)
+ confirmationViewModel.showFailure(
+ message = context.getString(R.string.error_actionfailed)
+ )
}
ActionStatus.PERMISSION_DENIED -> {
if (action.actionType == Actions.TORCH) {
- CustomConfirmationOverlay()
- .setType(CustomConfirmationOverlay.CUSTOM_ANIMATION)
- .setCustomDrawable(
- ContextCompat.getDrawable(
- activity,
- R.drawable.ws_full_sad
- )
+ confirmationViewModel.showConfirmation(
+ ConfirmationData(
+ title = context.getString(R.string.error_torch_action)
)
- .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)
@@ -378,74 +376,45 @@ fun Dashboard(
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
- )
+ confirmationViewModel.showConfirmation(
+ ConfirmationData(
+ title = context.getString(R.string.error_sleeptimer_notinstalled)
)
- .showOn(activity)
+ )
}
} else {
- CustomConfirmationOverlay()
- .setType(CustomConfirmationOverlay.CUSTOM_ANIMATION)
- .setCustomDrawable(
- ContextCompat.getDrawable(
- activity,
- R.drawable.ws_full_sad
- )
+ confirmationViewModel.showConfirmation(
+ ConfirmationData(
+ title = context.getString(R.string.error_permissiondenied)
)
- .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
- )
+ confirmationViewModel.showConfirmation(
+ ConfirmationData(
+ title = context.getString(R.string.error_sendmessage)
)
- .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
- )
+ confirmationViewModel.showConfirmation(
+ ConfirmationData(
+ title = context.getString(R.string.error_remoteactionfailed)
)
- .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
- )
+ confirmationViewModel.showConfirmation(
+ ConfirmationData(
+ title = context.getString(R.string.error_permissiondenied)
)
- .setMessage(activity.getString(R.string.error_permissiondenied))
- .showOn(activity)
+ )
}
ActionStatus.SUCCESS -> {
@@ -456,6 +425,15 @@ fun Dashboard(
// Re-enable click action
dashboardViewModel.setActionsClickable(true)
}
+
+ WearableListenerViewModel.ACTION_SHOWCONFIRMATION -> {
+ val jsonData =
+ event.data.getString(WearableListenerViewModel.EXTRA_ACTIONDATA)
+
+ JSONParser.deserializer(jsonData, ConfirmationData::class.java)?.let {
+ confirmationViewModel.showConfirmation(it)
+ }
+ }
}
}
}
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 ea8036ab..c58e2131 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
@@ -1,3 +1,5 @@
+@file:OptIn(ExperimentalWearFoundationApi::class, ExperimentalMaterialApi::class)
+
package com.thewizrd.simplewear.ui.simplewear
import android.content.ComponentName
@@ -47,7 +49,6 @@ import androidx.compose.ui.layout.onGloballyPositioned
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.platform.LocalViewConfiguration
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
@@ -57,9 +58,14 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.toSize
+import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavController
import androidx.navigation.NavOptions
+import androidx.wear.compose.foundation.ExperimentalWearFoundationApi
+import androidx.wear.compose.foundation.rememberActiveFocusRequester
+import androidx.wear.compose.foundation.rotary.RotaryScrollableDefaults
+import androidx.wear.compose.foundation.rotary.rotaryScrollable
import androidx.wear.compose.material.Button
import androidx.wear.compose.material.ButtonDefaults
import androidx.wear.compose.material.Chip
@@ -74,8 +80,6 @@ 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.rotaryinput.rotaryWithScroll
import com.thewizrd.shared_resources.actions.Actions
import com.thewizrd.shared_resources.actions.BatteryStatus
import com.thewizrd.shared_resources.controls.ActionButtonViewModel
@@ -155,7 +159,6 @@ fun DashboardScreen(
)
}
-@OptIn(ExperimentalHorologistApi::class, ExperimentalMaterialApi::class)
@Composable
fun DashboardScreen(
modifier: Modifier = Modifier,
@@ -199,7 +202,10 @@ fun DashboardScreen(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scrollState)
- .rotaryWithScroll(scrollState)
+ .rotaryScrollable(
+ focusRequester = rememberActiveFocusRequester(),
+ behavior = RotaryScrollableDefaults.behavior(scrollState)
+ ),
) {
if (isPreview) {
TimeText()
@@ -250,7 +256,7 @@ private fun DeviceStateChip(
icon = {
Icon(
painter = painterResource(id = R.drawable.ic_smartphone_white_24dp),
- contentDescription = null
+ contentDescription = stringResource(R.string.desc_phone_state)
)
},
label = {
@@ -308,7 +314,7 @@ private fun BatteryStatusChip(
icon = {
Icon(
painter = painterResource(id = R.drawable.ic_battery_std_white_24dp),
- contentDescription = null
+ contentDescription = stringResource(R.string.title_batt_state)
)
},
label = {
@@ -492,7 +498,9 @@ private fun ActionGridButton(
Icon(
modifier = Modifier.requiredSize(iconSize),
painter = painterResource(id = model.drawableResId),
- contentDescription = null
+ contentDescription = remember(context, model.actionLabelResId, model.stateLabelResId) {
+ model.getDescription(context)
+ }
)
}
@@ -505,20 +513,8 @@ private fun ActionGridButton(
delay(viewConfig.longPressTimeoutMillis)
if (isActive) {
- var text = model.actionLabelResId
- .takeIf { it != 0 }
- ?.let {
- context.getString(it)
- } ?: ""
-
- model.stateLabelResId
- .takeIf { it != 0 }
- ?.let {
- text = String.format("%s: %s", text, context.getString(it))
- }
-
Toast
- .makeText(context, text, Toast.LENGTH_SHORT)
+ .makeText(context, model.getDescription(context), Toast.LENGTH_SHORT)
.show()
}
}
@@ -533,6 +529,8 @@ private fun ActionListButton(
isClickable: Boolean = true,
onClick: (ActionButtonViewModel) -> Unit
) {
+ val context = LocalContext.current
+
Chip(
modifier = Modifier.fillMaxWidth(),
enabled = model.buttonState != null,
@@ -567,7 +565,13 @@ private fun ActionListButton(
Icon(
modifier = Modifier.requiredSize(24.dp),
painter = painterResource(id = model.drawableResId),
- contentDescription = null
+ contentDescription = remember(
+ context,
+ model.actionLabelResId,
+ model.stateLabelResId
+ ) {
+ model.getDescription(context)
+ }
)
},
onClick = {
@@ -623,7 +627,11 @@ private fun LayoutPreferenceButton(
} else {
painterResource(id = R.drawable.ic_view_list_white_24dp)
},
- contentDescription = null
+ contentDescription = if (isGridLayout) {
+ stringResource(id = R.string.option_grid)
+ } else {
+ stringResource(id = R.string.option_list)
+ }
)
},
colors = ChipDefaults.secondaryChipColors(
@@ -655,7 +663,7 @@ private fun DashboardConfigButton(
icon = {
Icon(
painter = painterResource(id = R.drawable.ic_baseline_edit_24),
- contentDescription = null
+ contentDescription = stringResource(id = R.string.pref_title_dasheditor)
)
},
colors = ChipDefaults.secondaryChipColors(),
@@ -675,7 +683,7 @@ private fun TileDashboardConfigButton(
icon = {
Icon(
painter = painterResource(id = R.drawable.ic_baseline_edit_24),
- contentDescription = null
+ contentDescription = stringResource(id = R.string.pref_title_tiledasheditor)
)
},
colors = ChipDefaults.secondaryChipColors(),
diff --git a/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/GesturesUi.kt b/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/GesturesUi.kt
index 4f9e1e5c..474a7092 100644
--- a/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/GesturesUi.kt
+++ b/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/GesturesUi.kt
@@ -1,26 +1,36 @@
+@file:OptIn(ExperimentalLayoutApi::class, ExperimentalHorologistApi::class)
+
package com.thewizrd.simplewear.ui.simplewear
import android.content.Intent
+import android.view.KeyEvent
import android.view.ViewConfiguration
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.gestures.detectVerticalDragGestures
+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.FlowRowOverflow
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentHeight
+import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
+import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
+import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material.icons.filled.KeyboardArrowDown
-import androidx.compose.material.icons.filled.KeyboardArrowLeft
-import androidx.compose.material.icons.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.KeyboardArrowUp
+import androidx.compose.material.icons.outlined.Home
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -37,32 +47,40 @@ import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.rotary.onRotaryScrollEvent
import androidx.compose.ui.platform.LocalConfiguration
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.compose.ui.unit.dp
-import androidx.core.content.ContextCompat
+import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
+import androidx.wear.compose.foundation.SwipeToDismissBoxState
+import androidx.wear.compose.foundation.rememberSwipeToDismissBoxState
import androidx.wear.compose.material.CompactChip
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.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.material.Button
import com.thewizrd.shared_resources.actions.ActionStatus
+import com.thewizrd.shared_resources.actions.GestureActionState
import com.thewizrd.shared_resources.helpers.GestureUIHelper
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.ConfirmationOverlay
import com.thewizrd.simplewear.ui.components.LoadingContent
+import com.thewizrd.simplewear.ui.components.SwipeToDismissPagerScreen
import com.thewizrd.simplewear.ui.theme.activityViewModel
import com.thewizrd.simplewear.ui.theme.findActivity
+import com.thewizrd.simplewear.viewmodels.ConfirmationData
+import com.thewizrd.simplewear.viewmodels.ConfirmationViewModel
+import com.thewizrd.simplewear.viewmodels.GestureUiState
import com.thewizrd.simplewear.viewmodels.GestureUiViewModel
import com.thewizrd.simplewear.viewmodels.WearableListenerViewModel
import kotlinx.coroutines.Job
@@ -77,49 +95,35 @@ import kotlin.math.sqrt
@Composable
fun GesturesUi(
modifier: Modifier = Modifier,
- navController: NavController
+ navController: NavController,
+ swipeToDismissBoxState: SwipeToDismissBoxState = rememberSwipeToDismissBoxState()
) {
val context = LocalContext.current
val activity = context.findActivity()
- val viewConfig = remember(context) {
- ViewConfiguration.get(context)
- }
- val screenHeightPx = remember(context) {
- context.resources.displayMetrics.heightPixels
- }
- val screenWidthPx = remember(context) {
- context.resources.displayMetrics.widthPixels
- }
-
- val focusRequester = remember { FocusRequester() }
-
- val config = LocalConfiguration.current
- val inset = remember(config) {
- if (config.isScreenRound) {
- val screenHeightDp = config.screenHeightDp
- val screenWidthDp = config.smallestScreenWidthDp
- val maxSquareEdge = (sqrt(((screenHeightDp * screenWidthDp) / 2).toDouble()))
- Dp(((screenHeightDp - maxSquareEdge) / 2).toFloat())
- } else {
- 12.dp
- }
- }
-
val lifecycleOwner = LocalLifecycleOwner.current
val gestureUiViewModel = activityViewModel()
val uiState by gestureUiViewModel.uiState.collectAsState()
- var scrollOffset by remember { mutableFloatStateOf(0f) }
- var dispatchJob: Job? = null
+ val confirmationViewModel = viewModel()
+ val confirmationData by confirmationViewModel.confirmationEventsFlow.collectAsState()
- Scaffold(
+ val pagerState = rememberPagerState {
+ if (uiState.actionState.accessibilityEnabled && uiState.actionState.keyEventSupported) 2 else 1
+ }
+
+ val isRoot = navController.previousBackStackEntry == null
+
+ SwipeToDismissPagerScreen(
modifier = modifier.background(MaterialTheme.colors.background),
- vignette = { Vignette(vignettePosition = VignettePosition.TopAndBottom) },
+ isRoot = isRoot,
+ swipeToDismissBoxState = swipeToDismissBoxState,
+ state = pagerState,
timeText = {
if (!uiState.isLoading) TimeText()
},
- ) {
+ hidePagerIndicator = uiState.isLoading
+ ) { pageIdx ->
LoadingContent(
empty = !uiState.actionState.accessibilityEnabled,
emptyContent = {
@@ -131,153 +135,46 @@ fun GesturesUi(
},
loading = uiState.isLoading
) {
- Box(
- modifier = modifier
- .fillMaxSize()
- .padding(horizontal = 8.dp)
- .pointerInput("horizontalScroll") {
- detectHorizontalDragGestures(
- onDragEnd = {
- if (scrollOffset != 0f) {
- gestureUiViewModel.requestScroll(
- scrollOffset,
- 0f,
- screenWidthPx.toFloat(),
- screenHeightPx.toFloat()
- )
- }
- }
- ) { change, dragAmount ->
- change.consume()
-
- scrollOffset = if (dragAmount > 0) {
- max(scrollOffset, dragAmount + viewConfig.scaledTouchSlop)
- } else {
- min(scrollOffset, dragAmount + -viewConfig.scaledTouchSlop)
- }
-
- dispatchJob?.cancel()
- }
- }
- .pointerInput("verticalScroll") {
- detectVerticalDragGestures(
- onDragEnd = {
- if (scrollOffset != 0f) {
- gestureUiViewModel.requestScroll(
- 0f,
- scrollOffset,
- screenWidthPx.toFloat(),
- screenHeightPx.toFloat()
- )
- }
- }
- ) { change, dragAmount ->
- change.consume()
-
- scrollOffset = if (dragAmount > 0) {
- max(scrollOffset, dragAmount + viewConfig.scaledTouchSlop)
- } else {
- min(scrollOffset, dragAmount + -viewConfig.scaledTouchSlop)
+ when (pageIdx) {
+ // Gestures
+ 0 -> {
+ GestureScreen(
+ modifier = modifier,
+ uiState = uiState,
+ onDPadDirection = { direction ->
+ when (direction) {
+ KeyEvent.KEYCODE_DPAD_UP -> gestureUiViewModel.requestDPad(top = 1)
+ KeyEvent.KEYCODE_DPAD_DOWN -> gestureUiViewModel.requestDPad(bottom = 1)
+ KeyEvent.KEYCODE_DPAD_LEFT -> gestureUiViewModel.requestDPad(left = 1)
+ KeyEvent.KEYCODE_DPAD_RIGHT -> gestureUiViewModel.requestDPad(right = 1)
}
-
- dispatchJob?.cancel()
- }
- }
- .onRotaryScrollEvent { event ->
- val scrollPx = event.verticalScrollPixels
-
- scrollOffset = if (scrollPx > 0) {
- max(scrollOffset, scrollPx)
- } else {
- min(scrollOffset, scrollPx)
- }
-
- dispatchJob?.cancel()
-
- dispatchJob = lifecycleOwner.lifecycleScope.launch {
- delay((scrollPx.absoluteValue / viewConfig.scaledMaximumFlingVelocity).toLong())
-
- if (isActive) {
- gestureUiViewModel.requestScroll(
- 0f,
- scrollOffset,
- screenWidthPx.toFloat(),
- screenHeightPx.toFloat()
- )
- }
- }
- true
- }
- .focusRequester(focusRequester)
- .focusable()
- ) {
- Icon(
- modifier = Modifier
- .size(24.dp)
- .offset(y = inset)
- .align(Alignment.TopCenter)
- .clickable(uiState.actionState.dpadSupported) {
- gestureUiViewModel.requestDPad(top = 1)
- },
- imageVector = Icons.Filled.KeyboardArrowUp,
- tint = Color.White,
- contentDescription = null
- )
- Icon(
- modifier = Modifier
- .size(24.dp)
- .offset(y = -inset)
- .align(Alignment.BottomCenter)
- .clickable(uiState.actionState.dpadSupported) {
- gestureUiViewModel.requestDPad(bottom = 1)
- },
- imageVector = Icons.Filled.KeyboardArrowDown,
- tint = Color.White,
- contentDescription = null
- )
- Icon(
- modifier = Modifier
- .size(24.dp)
- .offset(x = inset)
- .align(Alignment.CenterStart)
- .clickable(uiState.actionState.dpadSupported) {
- gestureUiViewModel.requestDPad(left = 1)
},
- imageVector = Icons.Filled.KeyboardArrowLeft,
- tint = Color.White,
- contentDescription = null
- )
- Icon(
- modifier = Modifier
- .size(24.dp)
- .offset(x = -inset)
- .align(Alignment.CenterEnd)
- .clickable(uiState.actionState.dpadSupported) {
- gestureUiViewModel.requestDPad(right = 1)
+ onDPadClicked = {
+ gestureUiViewModel.requestDPadClick()
},
- imageVector = Icons.Filled.KeyboardArrowRight,
- tint = Color.White,
- contentDescription = null
- )
- if (uiState.actionState.dpadSupported) {
- Box(
- modifier = Modifier
- .size(48.dp)
- .align(Alignment.Center)
- .clickable {
- gestureUiViewModel.requestDPadClick()
- }
- .background(Color.White, shape = RoundedCornerShape(50))
+ onScroll = { dX, dY, screenWidth, screenHeight ->
+ gestureUiViewModel.requestScroll(dX, dY, screenWidth, screenHeight)
+ }
)
}
-
- LaunchedEffect(Unit) {
- focusRequester.requestFocus()
+ // Buttons
+ 1 -> {
+ ButtonScreen(
+ modifier = modifier,
+ onKeyPressed = { keyEvent ->
+ gestureUiViewModel.requestKeyEvent(keyEvent)
+ }
+ )
}
}
}
}
+ ConfirmationOverlay(
+ confirmationData = confirmationData,
+ onTimeout = { confirmationViewModel.clearFlow() },
+ )
+
LaunchedEffect(lifecycleOwner) {
lifecycleOwner.lifecycleScope.launch {
gestureUiViewModel.eventFlow.collect { event ->
@@ -325,16 +222,11 @@ fun GesturesUi(
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
- )
+ confirmationViewModel.showConfirmation(
+ ConfirmationData(
+ title = context.getString(R.string.error_permissiondenied)
)
- .setMessage(activity.getString(R.string.error_permissiondenied))
- .showOn(activity)
+ )
gestureUiViewModel.openAppOnPhone(activity, false)
}
@@ -350,6 +242,240 @@ fun GesturesUi(
}
}
+@Composable
+private fun GestureScreen(
+ modifier: Modifier = Modifier,
+ uiState: GestureUiState,
+ onDPadDirection: ((Int) -> Unit) = {},
+ onDPadClicked: () -> Unit = {},
+ onScroll: (dX: Float, dY: Float, screenWidth: Float, screenHeight: Float) -> Unit = { _, _, _, _ ->
+ }
+) {
+ val context = LocalContext.current
+
+ val config = LocalConfiguration.current
+ val inset = remember(config) {
+ if (config.isScreenRound) {
+ val screenHeightDp = config.screenHeightDp
+ val screenWidthDp = config.smallestScreenWidthDp
+ val maxSquareEdge = (sqrt(((screenHeightDp * screenWidthDp) / 2).toDouble()))
+ Dp(((screenHeightDp - maxSquareEdge) / 2).toFloat())
+ } else {
+ 12.dp
+ }
+ }
+
+ val lifecycleOwner = LocalLifecycleOwner.current
+
+ var scrollOffset by remember { mutableFloatStateOf(0f) }
+ var dispatchJob: Job? = null
+
+ val viewConfig = remember(context) {
+ ViewConfiguration.get(context)
+ }
+ val screenHeightPx = remember(context) {
+ context.resources.displayMetrics.heightPixels
+ }
+ val screenWidthPx = remember(context) {
+ context.resources.displayMetrics.widthPixels
+ }
+
+ val focusRequester = remember { FocusRequester() }
+
+ Box(
+ modifier = modifier
+ .fillMaxSize()
+ .padding(horizontal = 8.dp)
+ .pointerInput("horizontalScroll") {
+ detectHorizontalDragGestures(
+ onDragEnd = {
+ if (scrollOffset != 0f) {
+ onScroll(
+ scrollOffset,
+ 0f,
+ screenWidthPx.toFloat(),
+ screenHeightPx.toFloat()
+ )
+ }
+ }
+ ) { change, dragAmount ->
+ change.consume()
+
+ scrollOffset = if (dragAmount > 0) {
+ max(scrollOffset, dragAmount + viewConfig.scaledTouchSlop)
+ } else {
+ min(scrollOffset, dragAmount + -viewConfig.scaledTouchSlop)
+ }
+
+ dispatchJob?.cancel()
+ }
+ }
+ .pointerInput("verticalScroll") {
+ detectVerticalDragGestures(
+ onDragEnd = {
+ if (scrollOffset != 0f) {
+ onScroll(
+ 0f,
+ scrollOffset,
+ screenWidthPx.toFloat(),
+ screenHeightPx.toFloat()
+ )
+ }
+ }
+ ) { change, dragAmount ->
+ change.consume()
+
+ scrollOffset = if (dragAmount > 0) {
+ max(scrollOffset, dragAmount + viewConfig.scaledTouchSlop)
+ } else {
+ min(scrollOffset, dragAmount + -viewConfig.scaledTouchSlop)
+ }
+
+ dispatchJob?.cancel()
+ }
+ }
+ .onRotaryScrollEvent { event ->
+ val scrollPx = event.verticalScrollPixels
+
+ scrollOffset = if (scrollPx > 0) {
+ max(scrollOffset, scrollPx)
+ } else {
+ min(scrollOffset, scrollPx)
+ }
+
+ dispatchJob?.cancel()
+
+ dispatchJob = lifecycleOwner.lifecycleScope.launch {
+ delay((scrollPx.absoluteValue / viewConfig.scaledMaximumFlingVelocity).toLong())
+
+ if (isActive) {
+ onScroll(
+ 0f,
+ scrollOffset,
+ screenWidthPx.toFloat(),
+ screenHeightPx.toFloat()
+ )
+ }
+ }
+ true
+ }
+ .focusRequester(focusRequester)
+ .focusable()
+ ) {
+ Icon(
+ modifier = Modifier
+ .size(24.dp)
+ .offset(y = inset)
+ .align(Alignment.TopCenter)
+ .clickable(uiState.actionState.dpadSupported) {
+ onDPadDirection(KeyEvent.KEYCODE_DPAD_UP)
+ },
+ imageVector = Icons.Filled.KeyboardArrowUp,
+ tint = Color.White,
+ contentDescription = stringResource(R.string.label_arrow_up)
+ )
+ Icon(
+ modifier = Modifier
+ .size(24.dp)
+ .offset(y = -inset)
+ .align(Alignment.BottomCenter)
+ .clickable(uiState.actionState.dpadSupported) {
+ onDPadDirection(KeyEvent.KEYCODE_DPAD_DOWN)
+ },
+ imageVector = Icons.Filled.KeyboardArrowDown,
+ tint = Color.White,
+ contentDescription = stringResource(R.string.label_arrow_down)
+ )
+ Icon(
+ modifier = Modifier
+ .size(24.dp)
+ .offset(x = inset)
+ .align(Alignment.CenterStart)
+ .clickable(uiState.actionState.dpadSupported) {
+ onDPadDirection(KeyEvent.KEYCODE_DPAD_LEFT)
+ },
+ imageVector = Icons.AutoMirrored.Filled.KeyboardArrowLeft,
+ tint = Color.White,
+ contentDescription = stringResource(R.string.label_arrow_left)
+ )
+ Icon(
+ modifier = Modifier
+ .size(24.dp)
+ .offset(x = -inset)
+ .align(Alignment.CenterEnd)
+ .clickable(uiState.actionState.dpadSupported) {
+ onDPadDirection(KeyEvent.KEYCODE_DPAD_RIGHT)
+ },
+ imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
+ tint = Color.White,
+ contentDescription = stringResource(R.string.label_arrow_right)
+ )
+ if (uiState.actionState.dpadSupported) {
+ Box(
+ modifier = Modifier
+ .size(48.dp)
+ .align(Alignment.Center)
+ .clickable(
+ onClickLabel = stringResource(R.string.label_dpad_center)
+ ) {
+ onDPadClicked()
+ }
+ .background(Color.White, shape = RoundedCornerShape(50))
+ )
+ }
+
+ LaunchedEffect(Unit) {
+ focusRequester.requestFocus()
+ }
+ }
+}
+
+@WearPreviewDevices
+@Composable
+private fun ButtonScreen(
+ modifier: Modifier = Modifier,
+ onKeyPressed: (Int) -> Unit = {},
+) {
+ Box(
+ modifier = modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ FlowRow(
+ modifier = Modifier
+ .fillMaxWidth()
+ .wrapContentHeight(),
+ maxItemsInEachRow = 3,
+ horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally),
+ verticalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterVertically),
+ overflow = FlowRowOverflow.Visible,
+ ) {
+ Button(
+ imageVector = Icons.AutoMirrored.Outlined.ArrowBack,
+ contentDescription = stringResource(id = R.string.label_back),
+ onClick = {
+ onKeyPressed(KeyEvent.KEYCODE_BACK)
+ }
+ )
+ Button(
+ imageVector = Icons.Outlined.Home,
+ contentDescription = stringResource(id = R.string.label_home),
+ onClick = {
+ onKeyPressed(KeyEvent.KEYCODE_HOME)
+ }
+ )
+ Button(
+ id = R.drawable.ic_outline_view_apps,
+ contentDescription = stringResource(id = R.string.label_recents),
+ onClick = {
+ onKeyPressed(KeyEvent.KEYCODE_APP_SWITCH)
+ }
+ )
+ }
+ }
+}
+
+@WearPreviewDevices
+@WearPreviewFontScales
@Composable
private fun NoAccessibilityScreen(
onRefresh: () -> Unit = {}
@@ -376,11 +502,25 @@ private fun NoAccessibilityScreen(
icon = {
Icon(
painter = painterResource(id = R.drawable.ic_baseline_refresh_24),
- contentDescription = null
+ contentDescription = stringResource(id = R.string.action_refresh)
)
},
onClick = onRefresh
)
}
}
+}
+
+@WearPreviewDevices
+@Composable
+private fun PreviewGestureScreen() {
+ val uiState = remember {
+ GestureUiState(
+ connectionStatus = WearConnectionStatus.CONNECTED,
+ isLoading = false,
+ actionState = GestureActionState(dpadSupported = true)
+ )
+ }
+
+ GestureScreen(uiState = uiState)
}
\ No newline at end of file
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
index 51063db4..13a11dd9 100644
--- a/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/MediaPlayer.kt
+++ b/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/MediaPlayer.kt
@@ -24,7 +24,7 @@ import com.thewizrd.simplewear.ui.theme.findActivity
@Composable
fun MediaPlayer(
- startDestination: String = Screen.MediaPlayerList.route
+ startDestination: String = Screen.MediaPlayer.autoLaunch()
) {
WearAppTheme {
val context = LocalContext.current
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 fe71b9c7..667d5660 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,7 +1,8 @@
+@file:OptIn(ExperimentalWearFoundationApi::class, ExperimentalHorologistApi::class)
+
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
import androidx.compose.foundation.layout.Box
@@ -20,24 +21,28 @@ 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.compose.LocalLifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
+import androidx.navigation.NavOptions
+import androidx.wear.compose.foundation.ExperimentalWearFoundationApi
import androidx.wear.compose.foundation.lazy.items
+import androidx.wear.compose.foundation.rememberActiveFocusRequester
+import androidx.wear.compose.foundation.rotary.RotaryScrollableDefaults
+import androidx.wear.compose.foundation.rotary.rotaryScrollable
import androidx.wear.compose.material.Checkbox
import androidx.wear.compose.material.Chip
import androidx.wear.compose.material.ChipDefaults
@@ -45,7 +50,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.Switch
import androidx.wear.compose.material.Text
import androidx.wear.compose.material.TimeText
import androidx.wear.compose.material.ToggleChip
@@ -60,26 +64,26 @@ import com.google.android.horologist.compose.layout.rememberResponsiveColumnStat
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.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.ConfirmationOverlay
import com.thewizrd.simplewear.ui.components.LoadingContent
import com.thewizrd.simplewear.ui.components.SwipeToDismissPagerScreen
import com.thewizrd.simplewear.ui.navigation.Screen
import com.thewizrd.simplewear.ui.theme.findActivity
+import com.thewizrd.simplewear.viewmodels.ConfirmationData
+import com.thewizrd.simplewear.viewmodels.ConfirmationViewModel
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)
@Composable
fun MediaPlayerListUi(
modifier: Modifier = Modifier,
@@ -92,6 +96,9 @@ fun MediaPlayerListUi(
val mediaPlayerListViewModel = viewModel()
val uiState by mediaPlayerListViewModel.uiState.collectAsState()
+ val confirmationViewModel = viewModel()
+ val confirmationData by confirmationViewModel.confirmationEventsFlow.collectAsState()
+
val scrollState = rememberResponsiveColumnState(
contentPadding = ScalingLazyColumnDefaults.padding(
first = ScalingLazyColumnDefaults.ItemType.Unspecified,
@@ -104,8 +111,6 @@ fun MediaPlayerListUi(
pageCount = { 2 }
)
- var autoLaunched by rememberSaveable(navController) { mutableStateOf(false) }
-
SwipeToDismissPagerScreen(
state = pagerState,
hidePagerIndicator = uiState.isLoading,
@@ -128,18 +133,16 @@ fun MediaPlayerListUi(
}
}
+ ConfirmationOverlay(
+ confirmationData = confirmationData,
+ onTimeout = { confirmationViewModel.clearFlow() },
+ )
+
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) {
@@ -186,16 +189,11 @@ fun MediaPlayerListUi(
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
- )
+ confirmationViewModel.showConfirmation(
+ ConfirmationData(
+ title = context.getString(R.string.error_permissiondenied)
)
- .setMessage(activity.getString(R.string.error_permissiondenied))
- .showOn(activity)
+ )
mediaPlayerListViewModel.openAppOnPhone(
activity,
@@ -209,7 +207,13 @@ fun MediaPlayerListUi(
event.data.getSerializable(WearableListenerViewModel.EXTRA_STATUS) as ActionStatus
if (status == ActionStatus.SUCCESS) {
- navController.navigate(Screen.MediaPlayer.autoLaunch())
+ navController.navigate(
+ Screen.MediaPlayer.autoLaunch(),
+ NavOptions.Builder()
+ .setLaunchSingleTop(true)
+ .setPopUpTo(Screen.MediaPlayer.route, true)
+ .build()
+ )
}
}
}
@@ -219,7 +223,7 @@ fun MediaPlayerListUi(
LaunchedEffect(Unit) {
// Update statuses
- mediaPlayerListViewModel.refreshState(true)
+ mediaPlayerListViewModel.refreshState()
}
}
@@ -244,7 +248,13 @@ private fun MediaPlayerListScreen(
val success = mediaPlayerListViewModel.startMediaApp(it)
if (success) {
- navController.navigate(Screen.MediaPlayer.getRoute(it))
+ navController.navigate(
+ Screen.MediaPlayer.getRoute(it),
+ NavOptions.Builder()
+ .setLaunchSingleTop(true)
+ .setPopUpTo(Screen.MediaPlayer.route, true)
+ .build()
+ )
} else {
activity.showConfirmationOverlay(false)
}
@@ -293,7 +303,7 @@ private fun MediaPlayerListScreen(
icon = {
Icon(
painter = painterResource(id = R.drawable.ic_baseline_refresh_24),
- contentDescription = null
+ contentDescription = stringResource(id = R.string.action_refresh)
)
},
onClick = onRefresh
@@ -316,25 +326,29 @@ private fun MediaPlayerListScreen(
items(
items = uiState.mediaAppsSet.toList(),
key = { Pair(it.activityName, it.packageName) }
- ) {
+ ) { mediaItem ->
Chip(
modifier = Modifier.fillMaxWidth(),
label = {
- Text(text = it.appLabel ?: "")
+ Text(text = mediaItem.appLabel ?: "")
},
- icon = it.bitmapIcon?.let {
+ icon = mediaItem.bitmapIcon?.let {
{
Icon(
modifier = Modifier.requiredSize(ChipDefaults.IconSize),
bitmap = it.asImageBitmap(),
- contentDescription = null,
+ contentDescription = mediaItem.appLabel,
tint = Color.Unspecified
)
}
},
- colors = ChipDefaults.secondaryChipColors(),
+ colors = if (mediaItem.key == uiState.activePlayerKey) {
+ ChipDefaults.gradientBackgroundChipColors()
+ } else {
+ ChipDefaults.secondaryChipColors()
+ },
onClick = {
- onItemClicked(it)
+ onItemClicked(mediaItem)
}
)
}
@@ -362,7 +376,6 @@ private fun MediaPlayerListSettings(
)
}
-@OptIn(ExperimentalHorologistApi::class)
@Composable
private fun MediaPlayerListSettings(
uiState: MediaPlayerListUiState,
@@ -402,24 +415,11 @@ private fun MediaPlayerListSettings(
icon = {
Icon(
painter = painterResource(id = R.drawable.ic_baseline_filter_list_24),
- contentDescription = null
+ contentDescription = stringResource(id = R.string.title_filter_apps)
)
}
)
}
- 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(
@@ -463,7 +463,6 @@ private fun MediaPlayerListSettings(
}
}
-@OptIn(ExperimentalHorologistApi::class)
@Composable
private fun MediaPlayerFilterScreen(
uiState: MediaPlayerListUiState,
@@ -475,7 +474,10 @@ private fun MediaPlayerFilterScreen(
ScalingLazyColumn(
modifier = Modifier
.fillMaxSize()
- .rotaryWithScroll(dialogScrollState),
+ .rotaryScrollable(
+ focusRequester = rememberActiveFocusRequester(),
+ behavior = RotaryScrollableDefaults.behavior(dialogScrollState)
+ ),
columnState = dialogScrollState,
) {
item {
@@ -574,7 +576,6 @@ private fun PreviewNoContentMediaPlayerListScreen() {
mediaAppsSet = emptySet(),
filteredAppsList = emptySet(),
isLoading = false,
- isAutoLaunchEnabled = false
)
}
@@ -605,7 +606,6 @@ private fun PreviewMediaPlayerListScreen() {
mediaAppsSet = allApps,
filteredAppsList = emptySet(),
isLoading = false,
- isAutoLaunchEnabled = false
)
}
@@ -621,7 +621,6 @@ private fun PreviewMediaPlayerSettings() {
connectionStatus = WearConnectionStatus.CONNECTED,
filteredAppsList = emptySet(),
isLoading = false,
- isAutoLaunchEnabled = false
)
}
@@ -652,7 +651,6 @@ private fun PreviewMediaPlayerFilterScreen() {
mediaAppsSet = emptySet(),
filteredAppsList = setOf("com.package.0"),
isLoading = false,
- isAutoLaunchEnabled = false
)
}
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 1b5d58fd..78b353c7 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
@@ -1,4 +1,7 @@
-@file:OptIn(ExperimentalHorologistApi::class, ExperimentalFoundationApi::class)
+@file:OptIn(
+ ExperimentalHorologistApi::class, ExperimentalFoundationApi::class,
+ ExperimentalWearFoundationApi::class, ExperimentalWearMaterialApi::class
+)
package com.thewizrd.simplewear.ui.simplewear
@@ -6,17 +9,19 @@ import android.content.Intent
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
+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.Spacer
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.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
-import androidx.compose.material.LinearProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
@@ -27,35 +32,35 @@ 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.focus.FocusRequester
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
-import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.vector.ImageVector
-import androidx.compose.ui.platform.LocalConfiguration
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.res.vectorResource
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.dp
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.toBitmap
+import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
-import androidx.wear.ambient.AmbientLifecycleObserver
+import androidx.navigation.NavOptions
+import androidx.wear.compose.foundation.ExperimentalWearFoundationApi
import androidx.wear.compose.foundation.SwipeToDismissBoxState
import androidx.wear.compose.foundation.lazy.items
import androidx.wear.compose.foundation.rememberSwipeToDismissBoxState
+import androidx.wear.compose.foundation.rotary.rotaryScrollable
import androidx.wear.compose.material.ButtonDefaults
import androidx.wear.compose.material.ChipDefaults
import androidx.wear.compose.material.CompactChip
+import androidx.wear.compose.material.ExperimentalWearMaterialApi
import androidx.wear.compose.material.Icon
import androidx.wear.compose.material.MaterialTheme
import androidx.wear.compose.material.Text
@@ -63,18 +68,17 @@ 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.VolumePositionIndicator
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.VolumeViewModel
import com.google.android.horologist.audio.ui.components.actions.SettingsButton
-import com.google.android.horologist.audio.ui.rotaryVolumeControlsWithFocus
+import com.google.android.horologist.audio.ui.volumeRotaryBehavior
import com.google.android.horologist.compose.ambient.AmbientAware
import com.google.android.horologist.compose.ambient.AmbientState
-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.rotaryinput.RotaryDefaults
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
@@ -87,7 +91,6 @@ 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.AudioStreamState
import com.thewizrd.shared_resources.actions.AudioStreamType
import com.thewizrd.shared_resources.helpers.MediaHelper
@@ -96,22 +99,31 @@ 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.MediaPlayerUiController
import com.thewizrd.simplewear.media.MediaPlayerUiState
import com.thewizrd.simplewear.media.MediaPlayerViewModel
+import com.thewizrd.simplewear.media.MediaVolumeViewModel
+import com.thewizrd.simplewear.media.NoopPlayerUiController
import com.thewizrd.simplewear.media.PlayerState
+import com.thewizrd.simplewear.media.PlayerUiController
import com.thewizrd.simplewear.media.toPlaybackStateEvent
import com.thewizrd.simplewear.ui.ambient.ambientMode
+import com.thewizrd.simplewear.ui.components.ConfirmationOverlay
import com.thewizrd.simplewear.ui.components.LoadingContent
+import com.thewizrd.simplewear.ui.components.ScalingLazyColumn
import com.thewizrd.simplewear.ui.components.SwipeToDismissPagerScreen
import com.thewizrd.simplewear.ui.navigation.Screen
import com.thewizrd.simplewear.ui.theme.findActivity
+import com.thewizrd.simplewear.ui.utils.rememberFocusRequester
+import com.thewizrd.simplewear.viewmodels.ConfirmationData
+import com.thewizrd.simplewear.viewmodels.ConfirmationViewModel
import com.thewizrd.simplewear.viewmodels.WearableListenerViewModel
import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.launch
-import kotlin.math.sqrt
@Composable
fun MediaPlayerUi(
@@ -126,9 +138,18 @@ fun MediaPlayerUi(
val lifecycleOwner = LocalLifecycleOwner.current
val mediaPlayerViewModel = viewModel()
+ val volumeViewModel = remember(context, mediaPlayerViewModel) {
+ MediaVolumeViewModel(
+ context,
+ mediaPlayerViewModel
+ )
+ }
val uiState by mediaPlayerViewModel.uiState.collectAsState()
val mediaPagerState = remember(uiState) { uiState.pagerState }
+ val confirmationViewModel = viewModel()
+ val confirmationData by confirmationViewModel.confirmationEventsFlow.collectAsState()
+
val isRoot = navController.previousBackStackEntry == null
val pagerState = rememberPagerState(
@@ -137,11 +158,11 @@ fun MediaPlayerUi(
)
AmbientAware { ambientStateUpdate ->
- val ambientState = remember(ambientStateUpdate) { ambientStateUpdate.ambientState }
+ val ambientState = remember(ambientStateUpdate) { ambientStateUpdate }
val keyFunc: (Int) -> MediaPageType = remember(mediaPagerState, ambientState) {
pagerKey@{ pageIdx ->
- if (ambientState != AmbientState.Interactive)
+ if (ambientState.isAmbient)
return@pagerKey MediaPageType.Player
if (pageIdx == 1) {
@@ -174,7 +195,7 @@ fun MediaPlayerUi(
isRoot = isRoot,
swipeToDismissBoxState = swipeToDismissBoxState,
state = pagerState,
- hidePagerIndicator = ambientState != AmbientState.Interactive || uiState.isLoading || !uiState.isPlayerAvailable,
+ hidePagerIndicator = ambientState.isAmbient || uiState.isLoading || !uiState.isPlayerAvailable,
timeText = {
if (pagerState.currentPage == 0) {
TimeText()
@@ -188,6 +209,7 @@ fun MediaPlayerUi(
MediaPageType.Player -> {
MediaPlayerControlsPage(
mediaPlayerViewModel = mediaPlayerViewModel,
+ volumeViewModel = volumeViewModel,
navController = navController,
ambientState = ambientState
)
@@ -211,7 +233,20 @@ fun MediaPlayerUi(
)
}
}
+
+ LaunchedEffect(pagerState, pagerState.targetPage, pagerState.currentPage) {
+ val targetPageKey = keyFunc(pagerState.targetPage)
+ if (mediaPagerState.currentPageKey != targetPageKey) {
+ mediaPlayerViewModel.updateCurrentPage(targetPageKey)
+ }
+ }
}
+
+ ConfirmationOverlay(
+ confirmationData = confirmationData,
+ onTimeout = { confirmationViewModel.clearFlow() },
+ showDialog = ambientState.isInteractive && confirmationData != null
+ )
}
LaunchedEffect(context) {
@@ -277,16 +312,11 @@ fun MediaPlayerUi(
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
- )
+ confirmationViewModel.showConfirmation(
+ ConfirmationData(
+ title = context.getString(R.string.error_permissiondenied)
)
- .setMessage(activity.getString(R.string.error_permissiondenied))
- .showOn(activity)
+ )
mediaPlayerViewModel.openAppOnPhone(activity, false)
}
@@ -297,11 +327,11 @@ fun MediaPlayerUi(
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)
+ confirmationViewModel.showConfirmation(
+ ConfirmationData(
+ title = context.getString(R.string.error_playback_failed)
+ )
+ )
}
}
}
@@ -324,50 +354,57 @@ fun MediaPlayerUi(
@Composable
private fun MediaPlayerControlsPage(
mediaPlayerViewModel: MediaPlayerViewModel,
+ volumeViewModel: VolumeViewModel,
navController: NavController,
- ambientState: AmbientState
+ ambientState: AmbientState,
+ focusRequester: FocusRequester = rememberFocusRequester()
) {
val context = LocalContext.current
- val activity = context.findActivity()
val uiState by mediaPlayerViewModel.uiState.collectAsState()
val playerState by mediaPlayerViewModel.playerState.collectAsState()
val playbackStateEvent by mediaPlayerViewModel.playbackStateEvent.collectAsState()
+ val playerUiController =
+ remember(mediaPlayerViewModel) { MediaPlayerUiController(mediaPlayerViewModel) }
+ val volumeUiState by volumeViewModel.volumeUiState.collectAsState()
+
MediaPlayerControlsPage(
+ modifier = Modifier
+ .ambientMode(ambientState)
+ .rotaryScrollable(
+ focusRequester = focusRequester,
+ behavior = volumeRotaryBehavior(
+ volumeUiStateProvider = { volumeUiState },
+ onRotaryVolumeInput = { newVolume -> volumeViewModel.setVolume(newVolume) }
+ )
+ ),
uiState = uiState,
playerState = playerState,
playbackStateEvent = playbackStateEvent,
+ volumeUiState = volumeUiState,
ambientState = ambientState,
onRefresh = {
mediaPlayerViewModel.refreshStatus()
},
- onPlay = {
- mediaPlayerViewModel.requestPlayPauseAction(true)
- },
- onPause = {
- mediaPlayerViewModel.requestPlayPauseAction(false)
- },
- onSkipBack = {
- mediaPlayerViewModel.requestSkipToPreviousAction()
- },
- onSkipForward = {
- mediaPlayerViewModel.requestSkipToNextAction()
- },
- onVolume = {
+ onOpenPlayerList = {
navController.navigate(
- Screen.ValueAction.getRoute(Actions.VOLUME, AudioStreamType.MUSIC)
+ Screen.MediaPlayerList.route,
+ NavOptions.Builder()
+ .setLaunchSingleTop(true)
+ .setPopUpTo(Screen.MediaPlayerList.route, true)
+ .build()
)
},
onVolumeUp = {
- mediaPlayerViewModel.requestVolumeUp()
+ volumeViewModel.increaseVolume()
},
onVolumeDown = {
- mediaPlayerViewModel.requestVolumeDown()
+ volumeViewModel.decreaseVolume()
},
- onVolumeChange = {
- mediaPlayerViewModel.requestSetVolume(it)
- }
+ playerUiController = playerUiController,
+ displayVolumeIndicatorEvents = volumeViewModel.displayIndicatorEvents,
+ focusRequester = focusRequester
)
LaunchedEffect(context) {
@@ -377,26 +414,21 @@ private fun MediaPlayerControlsPage(
@Composable
private fun MediaPlayerControlsPage(
+ modifier: Modifier = Modifier,
uiState: MediaPlayerUiState,
playerState: PlayerState = uiState.playerState,
playbackStateEvent: PlaybackStateEvent = uiState.playerState.toPlaybackStateEvent(),
+ volumeUiState: VolumeUiState = VolumeUiState(),
ambientState: AmbientState = AmbientState.Interactive,
onRefresh: () -> Unit = {},
- onPlay: () -> Unit = {},
- onPause: () -> Unit = {},
- onSkipBack: () -> Unit = {},
- onSkipForward: () -> Unit = {},
- onVolume: () -> Unit = {},
+ onOpenPlayerList: () -> Unit = {},
onVolumeUp: () -> Unit = {},
onVolumeDown: () -> Unit = {},
- onVolumeChange: (Int) -> Unit = {},
+ playerUiController: PlayerUiController = NoopPlayerUiController(),
+ displayVolumeIndicatorEvents: Flow = emptyFlow(),
+ focusRequester: FocusRequester = rememberFocusRequester()
) {
- val volumeUiState = remember(uiState) {
- uiState.audioStreamState?.let {
- VolumeUiState(it.currentVolume, it.maxVolume, it.minVolume)
- }
- }
- val isAmbient = ambientState != AmbientState.Interactive
+ val isAmbient = remember(ambientState) { ambientState.isAmbient }
// Progress
val timestampProvider = remember { TimestampProvider { System.currentTimeMillis() } }
@@ -436,170 +468,179 @@ private fun MediaPlayerControlsPage(
},
loading = uiState.isLoading && !isAmbient
) {
- PlayerScreen(
- modifier = Modifier
- .ambientMode(ambientState)
- .run {
- if (!isAmbient && volumeUiState != null) {
- this.rotaryVolumeControlsWithFocus(
- volumeUiStateProvider = { volumeUiState },
- onRotaryVolumeInput = onVolumeChange,
- localView = LocalView.current,
- isLowRes = RotaryDefaults.isLowResInput()
- )
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center,
+ ) {
+ PlayerScreen(
+ modifier = modifier,
+ mediaDisplay = {
+ if (uiState.isPlaybackLoading && !isAmbient) {
+ LoadingMediaDisplay()
+ } else if (!playerState.isEmpty()) {
+ if (!isAmbient) {
+ MarqueeTextMediaDisplay(
+ title = playerState.title,
+ artist = playerState.artist
+ )
+ } else {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(
+ text = playerState.title.orEmpty(),
+ modifier = Modifier
+ .fillMaxWidth(0.7f)
+ .padding(top = 2.dp, bottom = .8.dp),
+ color = MaterialTheme.colors.onBackground,
+ textAlign = TextAlign.Center,
+ overflow = TextOverflow.Ellipsis,
+ maxLines = 1,
+ style = MaterialTheme.typography.button,
+ )
+ Text(
+ text = playerState.artist.orEmpty(),
+ modifier = Modifier
+ .fillMaxWidth(0.8f)
+ .padding(top = 2.dp, bottom = .6.dp),
+ color = MaterialTheme.colors.onBackground,
+ textAlign = TextAlign.Center,
+ overflow = TextOverflow.Ellipsis,
+ maxLines = 1,
+ style = MaterialTheme.typography.body2,
+ )
+ }
+ }
} else {
- this
+ NothingPlayingDisplay()
}
},
- mediaDisplay = {
- if (uiState.isPlaybackLoading && !isAmbient) {
- LoadingMediaDisplay()
- } else if (!playerState.isEmpty()) {
+ controlButtons = {
if (!isAmbient) {
- MarqueeTextMediaDisplay(
- title = playerState.title,
- artist = playerState.artist
- )
- } else {
- Column(
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
- Text(
- text = playerState.title.orEmpty(),
- modifier = Modifier
- .fillMaxWidth(0.7f)
- .padding(top = 2.dp, bottom = .8.dp),
- color = MaterialTheme.colors.onBackground,
- textAlign = TextAlign.Center,
- overflow = TextOverflow.Ellipsis,
- maxLines = 1,
- style = MaterialTheme.typography.button,
- )
- Text(
- text = playerState.artist.orEmpty(),
- modifier = Modifier
- .fillMaxWidth(0.8f)
- .padding(top = 2.dp, bottom = .6.dp),
- color = MaterialTheme.colors.onBackground,
- textAlign = TextAlign.Center,
- overflow = TextOverflow.Ellipsis,
- maxLines = 1,
- style = MaterialTheme.typography.body2,
+ CompositionLocalProvider(LocalTimestampProvider provides timestampProvider) {
+ AnimatedMediaControlButtons(
+ onPlayButtonClick = {
+ playerUiController.play()
+ },
+ onPauseButtonClick = {
+ playerUiController.pause()
+ },
+ playPauseButtonEnabled = !uiState.isPlaybackLoading || playerState.playbackState > PlaybackState.LOADING,
+ playing = playerState.playbackState == PlaybackState.PLAYING,
+ onSeekToPreviousButtonClick = {
+ playerUiController.skipToPreviousMedia()
+ },
+ seekToPreviousButtonEnabled = !uiState.isPlaybackLoading || playerState.playbackState > PlaybackState.LOADING,
+ onSeekToNextButtonClick = {
+ playerUiController.skipToNextMedia()
+ },
+ seekToNextButtonEnabled = !uiState.isPlaybackLoading || playerState.playbackState > PlaybackState.LOADING,
+ trackPositionUiModel = TrackPositionUiModelMapper.map(
+ playbackStateEvent
+ )
)
}
- }
- } else {
- NothingPlayingDisplay()
- }
- },
- controlButtons = {
- if (!isAmbient) {
- 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 = {},
+ 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 = {}
)
}
- } else {
- ControlButtonLayout(
- leftButton = {},
- middleButton = {
- Box(
- modifier = Modifier
- .fillMaxSize()
- .clip(CircleShape),
- contentAlignment = Alignment.Center,
+ },
+ buttons = {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ if (!isAmbient) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.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)
+ SettingsButton(
+ onClick = onVolumeDown,
+ imageVector = ImageVector.vectorResource(id = R.drawable.ic_baseline_volume_down_24),
+ contentDescription = stringResource(R.string.horologist_volume_screen_volume_down_content_description),
+ tapTargetSize = ButtonDefaults.ExtraSmallButtonSize
+ )
+ Spacer(modifier = Modifier.width(10.dp))
+ uiState.mediaPlayerDetails.bitmapIcon?.let {
+ Image(
+ modifier = Modifier
+ .size(32.dp)
+ .clickable(onClick = onOpenPlayerList),
+ bitmap = it.asImageBitmap(),
+ contentDescription = stringResource(R.string.desc_open_player_list)
)
- } else {
- MediaButton(
- onClick = {},
- icon = ImageVector.vectorResource(id = R.drawable.ic_outline_play_arrow_24),
- contentDescription = stringResource(id = R.string.horologist_play_button_content_description)
+ } ?: run {
+ Image(
+ modifier = Modifier
+ .size(32.dp)
+ .clickable(onClick = onOpenPlayerList),
+ painter = painterResource(R.drawable.ic_play_circle_filled_white_24dp),
+ contentDescription = stringResource(R.string.desc_open_player_list)
)
}
+ Spacer(modifier = Modifier.width(10.dp))
+ SettingsButton(
+ onClick = onVolumeUp,
+ imageVector = ImageVector.vectorResource(id = R.drawable.ic_volume_up_white_24dp),
+ contentDescription = stringResource(R.string.horologist_volume_screen_volume_up_content_description),
+ tapTargetSize = ButtonDefaults.ExtraSmallButtonSize
+ )
}
- },
- rightButton = {}
- )
- }
- },
- buttons = {
- if (!isAmbient) {
- if (volumeUiState != null) {
- val config = LocalConfiguration.current
- val inset = remember(config) {
- val isRound = config.isScreenRound
- val screenHeightDp = config.screenHeightDp
- var bottomInset = Dp(screenHeightDp - (screenHeightDp * 0.8733032f))
-
- if (isRound) {
- val screenWidthDp = config.smallestScreenWidthDp
- val maxSquareEdge =
- (sqrt(((screenHeightDp * screenWidthDp) / 2).toFloat()))
- bottomInset =
- Dp((screenHeightDp - (maxSquareEdge * 0.8733032f)) / 2)
- }
-
- bottomInset
- }
-
- Row(
- modifier = Modifier.padding(horizontal = inset)
- ) {
- SettingsButton(
- onClick = onVolumeDown,
- imageVector = ImageVector.vectorResource(id = R.drawable.ic_baseline_volume_down_24),
- contentDescription = stringResource(R.string.horologist_volume_screen_volume_down_content_description),
- tapTargetSize = ButtonDefaults.ExtraSmallButtonSize
- )
- LinearProgressIndicator(
- modifier = Modifier
- .weight(1f)
- .align(Alignment.CenterVertically)
- .clickable(onClick = onVolume),
- progress = volumeUiState.current.toFloat() / volumeUiState.max,
- color = MaterialTheme.colors.primary,
- strokeCap = StrokeCap.Round
- )
- SettingsButton(
- onClick = onVolumeUp,
- imageVector = ImageVector.vectorResource(id = R.drawable.ic_volume_up_white_24dp),
- contentDescription = stringResource(R.string.horologist_volume_screen_volume_up_content_description),
- tapTargetSize = ButtonDefaults.ExtraSmallButtonSize
- )
}
- } else {
- SetVolumeButton(onVolumeClick = onVolume)
+ }
+ },
+ background = {
+ playerState.artworkBitmap?.takeUnless { isAmbient }?.let {
+ Image(
+ modifier = Modifier.fillMaxSize(),
+ bitmap = it.asImageBitmap(),
+ colorFilter = ColorFilter.tint(
+ Color.Black.copy(alpha = 0.66f),
+ BlendMode.SrcAtop
+ ),
+ contentDescription = stringResource(R.string.desc_artwork)
+ )
}
}
- },
- background = {
- playerState.artworkBitmap?.takeUnless { isAmbient }?.let {
- Image(
- modifier = Modifier.fillMaxSize(),
- bitmap = it.asImageBitmap(),
- colorFilter = ColorFilter.tint(
- Color.Black.copy(alpha = 0.66f),
- BlendMode.SrcAtop
- ),
- contentDescription = null
- )
+ )
+
+ VolumePositionIndicator(
+ volumeUiState = { volumeUiState },
+ displayIndicatorEvents = displayVolumeIndicatorEvents
+ )
+
+ LaunchedEffect(uiState, uiState.pagerState) {
+ if (uiState.pagerState.currentPageKey == MediaPageType.Player) {
+ delay(500)
+ focusRequester.requestFocus()
}
}
- )
+ }
}
}
@@ -619,13 +660,14 @@ private fun MediaCustomControlsPage(
)
LaunchedEffect(context) {
- mediaPlayerViewModel.updateCustomControls()
+ mediaPlayerViewModel.requestUpdateCustomControls()
}
}
@Composable
private fun MediaCustomControlsPage(
uiState: MediaPlayerUiState,
+ focusRequester: FocusRequester = rememberFocusRequester(),
onItemClick: (MediaItemModel) -> Unit = {}
) {
LoadingContent(
@@ -646,23 +688,24 @@ private fun MediaCustomControlsPage(
TimeText(Modifier.scrollAway { scrollState })
ScalingLazyColumn(
- columnState = scrollState
+ scrollState = scrollState,
+ focusRequester = focusRequester
) {
- items(uiState.mediaCustomItems) {
+ items(uiState.mediaCustomItems) { item ->
Chip(
- label = it.title ?: "",
+ label = item.title ?: "",
icon = {
- it.icon?.let { bmp ->
+ item.icon?.let { bmp ->
Icon(
modifier = Modifier.size(ChipDefaults.IconSize),
bitmap = bmp.asImageBitmap(),
tint = Color.White,
- contentDescription = null
+ contentDescription = item.title
)
}
},
onClick = {
- onItemClick(it)
+ onItemClick(item)
},
colors = ChipDefaults.secondaryChipColors()
)
@@ -672,6 +715,13 @@ private fun MediaCustomControlsPage(
LaunchedEffect(Unit) {
scrollState.state.scrollToItem(0)
}
+
+ LaunchedEffect(uiState, uiState.pagerState) {
+ if (uiState.pagerState.currentPageKey == MediaPageType.CustomControls) {
+ delay(500)
+ focusRequester.requestFocus()
+ }
+ }
}
}
}
@@ -681,7 +731,6 @@ private fun MediaBrowserPage(
mediaPlayerViewModel: MediaPlayerViewModel
) {
val context = LocalContext.current
-
val uiState by mediaPlayerViewModel.uiState.collectAsState()
MediaBrowserPage(
@@ -692,13 +741,14 @@ private fun MediaBrowserPage(
)
LaunchedEffect(context) {
- mediaPlayerViewModel.updateBrowserItems()
+ mediaPlayerViewModel.requestUpdateBrowserItems()
}
}
@Composable
private fun MediaBrowserPage(
uiState: MediaPlayerUiState,
+ focusRequester: FocusRequester = rememberFocusRequester(),
onItemClick: (MediaItemModel) -> Unit = {}
) {
LoadingContent(
@@ -719,36 +769,37 @@ private fun MediaBrowserPage(
TimeText(Modifier.scrollAway { scrollState })
ScalingLazyColumn(
- columnState = scrollState
+ scrollState = scrollState,
+ focusRequester = focusRequester
) {
- items(uiState.mediaBrowserItems) {
+ items(uiState.mediaBrowserItems) { item ->
Chip(
- label = if (it.id == MediaHelper.ACTIONITEM_BACK) {
+ label = if (item.id == MediaHelper.ACTIONITEM_BACK) {
stringResource(id = R.string.label_back)
} else {
- it.title ?: ""
+ item.title ?: ""
},
icon = {
- if (it.id == MediaHelper.ACTIONITEM_BACK) {
+ if (item.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
+ contentDescription = stringResource(id = R.string.label_back)
)
} else {
- it.icon?.let { bmp ->
+ item.icon?.let { bmp ->
Icon(
modifier = Modifier.size(ChipDefaults.IconSize),
bitmap = bmp.asImageBitmap(),
tint = Color.White,
- contentDescription = null
+ contentDescription = item.title
)
}
}
},
onClick = {
- onItemClick(it)
+ onItemClick(item)
},
colors = ChipDefaults.secondaryChipColors()
)
@@ -758,6 +809,13 @@ private fun MediaBrowserPage(
LaunchedEffect(Unit) {
scrollState.state.scrollToItem(0)
}
+
+ LaunchedEffect(uiState, uiState.pagerState) {
+ if (uiState.pagerState.currentPageKey == MediaPageType.Browser) {
+ delay(500)
+ focusRequester.requestFocus()
+ }
+ }
}
}
}
@@ -767,7 +825,6 @@ private fun MediaQueuePage(
mediaPlayerViewModel: MediaPlayerViewModel
) {
val context = LocalContext.current
-
val uiState by mediaPlayerViewModel.uiState.collectAsState()
MediaQueuePage(
@@ -778,13 +835,14 @@ private fun MediaQueuePage(
)
LaunchedEffect(context) {
- mediaPlayerViewModel.updateQueueItems()
+ mediaPlayerViewModel.requestUpdateQueueItems()
}
}
@Composable
private fun MediaQueuePage(
uiState: MediaPlayerUiState,
+ focusRequester: FocusRequester = rememberFocusRequester(),
onItemClick: (MediaItemModel) -> Unit = {}
) {
val lifecycleOwner = LocalLifecycleOwner.current
@@ -807,29 +865,30 @@ private fun MediaQueuePage(
TimeText(Modifier.scrollAway { scrollState })
ScalingLazyColumn(
- columnState = scrollState
+ scrollState = scrollState,
+ focusRequester = focusRequester
) {
- items(uiState.mediaQueueItems) {
+ items(uiState.mediaQueueItems) { item ->
Chip(
- label = it.title ?: "",
+ label = item.title ?: "",
icon = {
- it.icon?.let { bmp ->
+ item.icon?.let { bmp ->
Icon(
modifier = Modifier.size(ChipDefaults.IconSize),
bitmap = bmp.asImageBitmap(),
- contentDescription = null,
+ contentDescription = item.title,
tint = Color.Unspecified
)
}
},
onClick = {
- onItemClick(it)
+ onItemClick(item)
lifecycleOwner.lifecycleScope.launch {
delay(1000)
scrollState.state.scrollToItem(0)
}
},
- colors = if (it.id.toLong() == uiState.activeQueueItemId) {
+ colors = if (item.id.toLong() == uiState.activeQueueItemId) {
ChipDefaults.gradientBackgroundChipColors()
} else {
ChipDefaults.secondaryChipColors()
@@ -837,14 +896,21 @@ private fun MediaQueuePage(
)
}
}
- }
- LaunchedEffect(Unit) {
- if (uiState.activeQueueItemId != -1L) {
- uiState.mediaQueueItems.indexOfFirst {
- it.id.toLong() == uiState.activeQueueItemId
- }.takeIf { it > 0 }?.run {
- scrollState.state.scrollToItem(this)
+ LaunchedEffect(Unit) {
+ if (uiState.activeQueueItemId != -1L) {
+ uiState.mediaQueueItems.indexOfFirst {
+ it.id.toLong() == uiState.activeQueueItemId
+ }.takeIf { it > 0 }?.run {
+ scrollState.state.scrollToItem(this)
+ }
+ }
+ }
+
+ LaunchedEffect(uiState, uiState.pagerState) {
+ if (uiState.pagerState.currentPageKey == MediaPageType.Queue) {
+ delay(500)
+ focusRequester.requestFocus()
}
}
}
@@ -917,10 +983,8 @@ private fun PreviewMediaControlsInAmbientMode() {
MediaPlayerControlsPage(
uiState = uiState,
ambientState = AmbientState.Ambient(
- ambientDetails = AmbientLifecycleObserver.AmbientDetails(
- burnInProtectionRequired = true,
- deviceHasLowBitAmbient = true
- )
+ burnInProtectionRequired = true,
+ deviceHasLowBitAmbient = true
)
)
}
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 8f94ad3f..84da6104 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
@@ -26,7 +26,6 @@ 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
@@ -35,6 +34,7 @@ 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.compose.LocalLifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.wear.compose.material.Button
import androidx.wear.compose.material.ButtonDefaults
@@ -272,7 +272,13 @@ private fun PhoneSyncUi(
null -> painterResource(id = R.drawable.ic_sync_24dp)
},
- contentDescription = null
+ contentDescription = when (uiState.connectionStatus) {
+ WearConnectionStatus.DISCONNECTED -> stringResource(R.string.status_disconnected)
+ WearConnectionStatus.CONNECTING -> stringResource(R.string.status_connecting)
+ WearConnectionStatus.APPNOTINSTALLED -> stringResource(R.string.error_notinstalled)
+ WearConnectionStatus.CONNECTED -> stringResource(R.string.status_connected)
+ null -> null
+ }
)
}
}
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
index b46484ae..75622b28 100644
--- a/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/SimpleWearApp.kt
+++ b/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/SimpleWearApp.kt
@@ -121,7 +121,10 @@ fun SimpleWearApp(
}
composable(Screen.GesturesAction.route) {
- GesturesUi(navController = navController)
+ GesturesUi(
+ navController = navController,
+ swipeToDismissBoxState = swipeToDismissBoxState
+ )
LaunchedEffect(navController) {
AnalyticsLogger.logEvent("nav_route", Bundle().apply {
diff --git a/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/TimedActionSetupUi.kt b/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/TimedActionSetupUi.kt
index 32839344..778bb9d3 100644
--- a/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/TimedActionSetupUi.kt
+++ b/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/TimedActionSetupUi.kt
@@ -18,6 +18,7 @@ import androidx.compose.foundation.pager.PagerState
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.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
@@ -28,12 +29,12 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
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.unit.dp
-import androidx.core.content.ContextCompat
+import androidx.lifecycle.compose.LocalLifecycleOwner
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.lazy.items
@@ -65,12 +66,14 @@ import com.thewizrd.shared_resources.actions.ToggleAction
import com.thewizrd.shared_resources.controls.ActionButtonViewModel
import com.thewizrd.shared_resources.helpers.WearableHelper
import com.thewizrd.simplewear.R
-import com.thewizrd.simplewear.controls.CustomConfirmationOverlay
import com.thewizrd.simplewear.helpers.showConfirmationOverlay
+import com.thewizrd.simplewear.ui.components.ConfirmationOverlay
import com.thewizrd.simplewear.ui.components.ScalingLazyColumn
import com.thewizrd.simplewear.ui.theme.activityViewModel
import com.thewizrd.simplewear.ui.theme.findActivity
import com.thewizrd.simplewear.ui.tools.WearPreviewDevices
+import com.thewizrd.simplewear.viewmodels.ConfirmationData
+import com.thewizrd.simplewear.viewmodels.ConfirmationViewModel
import com.thewizrd.simplewear.viewmodels.TimedActionUiViewModel
import com.thewizrd.simplewear.viewmodels.WearableListenerViewModel.Companion.EXTRA_STATUS
import kotlinx.coroutines.delay
@@ -95,6 +98,9 @@ fun TimedActionSetupUi(
val compositionScope = rememberCoroutineScope()
val timedActionUiViewModel = activityViewModel()
+ val confirmationViewModel = viewModel()
+ val confirmationData by confirmationViewModel.confirmationEventsFlow.collectAsState()
+
TimedActionSetupUi(
modifier = modifier,
onAddAction = { initialAction, timedAction ->
@@ -118,6 +124,11 @@ fun TimedActionSetupUi(
}
)
+ ConfirmationOverlay(
+ confirmationData = confirmationData,
+ onTimeout = { confirmationViewModel.clearFlow() },
+ )
+
LaunchedEffect(lifecycleOwner) {
lifecycleOwner.lifecycleScope.launch {
timedActionUiViewModel.eventFlow.collect { event ->
@@ -127,24 +138,17 @@ fun TimedActionSetupUi(
when (status) {
ActionStatus.SUCCESS -> {
- CustomConfirmationOverlay()
- .setType(CustomConfirmationOverlay.SUCCESS_ANIMATION)
- .showOn(activity)
+ confirmationViewModel.showSuccess()
navController.popBackStack()
}
ActionStatus.PERMISSION_DENIED -> {
- CustomConfirmationOverlay()
- .setType(CustomConfirmationOverlay.CUSTOM_ANIMATION)
- .setCustomDrawable(
- ContextCompat.getDrawable(
- activity,
- R.drawable.ws_full_sad
- )
+ confirmationViewModel.showConfirmation(
+ ConfirmationData(
+ title = context.getString(R.string.error_permissiondenied)
)
- .setMessage(activity.getString(R.string.error_permissiondenied))
- .showOn(activity)
+ )
timedActionUiViewModel.openAppOnPhone(
activity,
@@ -153,10 +157,9 @@ fun TimedActionSetupUi(
}
else -> {
- CustomConfirmationOverlay()
- .setType(CustomConfirmationOverlay.FAILURE_ANIMATION)
- .setMessage(activity.getString(R.string.error_actionfailed))
- .showOn(activity)
+ confirmationViewModel.showFailure(
+ message = context.getString(R.string.error_actionfailed)
+ )
}
}
}
@@ -241,7 +244,7 @@ private fun TimedActionSetupUi(
icon = {
Icon(
painter = painterResource(id = model.drawableResId),
- contentDescription = null
+ contentDescription = stringResource(id = model.actionLabelResId)
)
},
colors = ChipDefaults.secondaryChipColors(),
@@ -308,7 +311,7 @@ private fun TimedActionSetupUi(
Icon(
modifier = Modifier.align(Alignment.Center),
painter = painterResource(id = model.drawableResId),
- contentDescription = null,
+ contentDescription = stringResource(id = model.actionLabelResId),
tint = MaterialTheme.colors.onSurface
)
}
@@ -363,7 +366,7 @@ private fun TimedActionSetupUi(
Icon(
modifier = Modifier.align(Alignment.Center),
painter = painterResource(id = R.drawable.ic_alarm_white_24dp),
- contentDescription = null,
+ contentDescription = stringResource(id = R.string.label_time),
tint = MaterialTheme.colors.onSurface
)
}
diff --git a/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/TimedActionUi.kt b/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/TimedActionUi.kt
index 707e3734..9fa90441 100644
--- a/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/TimedActionUi.kt
+++ b/wear/src/main/java/com/thewizrd/simplewear/ui/simplewear/TimedActionUi.kt
@@ -33,7 +33,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
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.semantics.heading
@@ -41,12 +40,16 @@ import androidx.compose.ui.semantics.semantics
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.lifecycle.compose.LocalLifecycleOwner
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.lazy.items
+import androidx.wear.compose.foundation.rememberActiveFocusRequester
import androidx.wear.compose.foundation.rememberRevealState
+import androidx.wear.compose.foundation.rotary.RotaryScrollableDefaults
+import androidx.wear.compose.foundation.rotary.rotaryScrollable
import androidx.wear.compose.material.Button
import androidx.wear.compose.material.ButtonDefaults
import androidx.wear.compose.material.Chip
@@ -78,7 +81,6 @@ import com.google.android.horologist.compose.layout.scrollAway
import com.google.android.horologist.compose.material.ResponsiveListHeader
import com.google.android.horologist.compose.material.ToggleChip
import com.google.android.horologist.compose.material.ToggleChipToggleControl
-import com.google.android.horologist.compose.rotaryinput.rotaryWithScroll
import com.thewizrd.shared_resources.actions.ActionStatus
import com.thewizrd.shared_resources.actions.Actions
import com.thewizrd.shared_resources.actions.DNDChoice
@@ -89,12 +91,14 @@ import com.thewizrd.shared_resources.actions.ToggleAction
import com.thewizrd.shared_resources.controls.ActionButtonViewModel
import com.thewizrd.shared_resources.helpers.WearableHelper
import com.thewizrd.simplewear.R
-import com.thewizrd.simplewear.controls.CustomConfirmationOverlay
+import com.thewizrd.simplewear.ui.components.ConfirmationOverlay
import com.thewizrd.simplewear.ui.components.LoadingContent
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.ui.tools.WearPreviewDevices
+import com.thewizrd.simplewear.viewmodels.ConfirmationData
+import com.thewizrd.simplewear.viewmodels.ConfirmationViewModel
import com.thewizrd.simplewear.viewmodels.TimedActionUiViewModel
import com.thewizrd.simplewear.viewmodels.WearableListenerViewModel.Companion.EXTRA_STATUS
import kotlinx.coroutines.launch
@@ -118,6 +122,9 @@ fun TimedActionUi(
val uiState by timedActionUiViewModel.uiState.collectAsState()
val actions by timedActionUiViewModel.actions.collectAsState(initial = emptyList())
+ val confirmationViewModel = viewModel()
+ val confirmationData by confirmationViewModel.confirmationEventsFlow.collectAsState()
+
LoadingContent(
empty = actions.isEmpty(),
emptyContent = {
@@ -147,6 +154,11 @@ fun TimedActionUi(
)
}
+ ConfirmationOverlay(
+ confirmationData = confirmationData,
+ onTimeout = { confirmationViewModel.clearFlow() },
+ )
+
LaunchedEffect(Unit) {
timedActionUiViewModel.refreshState()
}
@@ -161,22 +173,15 @@ fun TimedActionUi(
when (status) {
ActionStatus.SUCCESS -> {
- CustomConfirmationOverlay()
- .setType(CustomConfirmationOverlay.SUCCESS_ANIMATION)
- .showOn(activity)
+ confirmationViewModel.showSuccess()
}
ActionStatus.PERMISSION_DENIED -> {
- CustomConfirmationOverlay()
- .setType(CustomConfirmationOverlay.CUSTOM_ANIMATION)
- .setCustomDrawable(
- ContextCompat.getDrawable(
- activity,
- R.drawable.ws_full_sad
- )
+ confirmationViewModel.showConfirmation(
+ ConfirmationData(
+ title = context.getString(R.string.error_permissiondenied)
)
- .setMessage(activity.getString(R.string.error_permissiondenied))
- .showOn(activity)
+ )
timedActionUiViewModel.openAppOnPhone(
activity,
@@ -185,10 +190,9 @@ fun TimedActionUi(
}
else -> {
- CustomConfirmationOverlay()
- .setType(CustomConfirmationOverlay.FAILURE_ANIMATION)
- .setMessage(activity.getString(R.string.error_actionfailed))
- .showOn(activity)
+ confirmationViewModel.showFailure(
+ message = context.getString(R.string.error_actionfailed)
+ )
}
}
}
@@ -222,7 +226,10 @@ private fun TimedActionUi(
ScalingLazyColumn(
modifier = modifier
.fillMaxSize()
- .rotaryWithScroll(scrollableState = scrollState),
+ .rotaryScrollable(
+ focusRequester = rememberActiveFocusRequester(),
+ behavior = RotaryScrollableDefaults.behavior(scrollState)
+ ),
columnState = scrollState
) {
item {
@@ -253,7 +260,7 @@ private fun TimedActionUi(
Button(onClick = onAddAction) {
Icon(
painter = painterResource(id = R.drawable.ic_add_white_24dp),
- contentDescription = null
+ contentDescription = stringResource(id = R.string.label_add_action)
)
}
}
@@ -299,7 +306,7 @@ private fun EmptyTimedActionUi(
) {
Icon(
painter = painterResource(id = R.drawable.ic_add_white_24dp),
- contentDescription = null
+ contentDescription = stringResource(R.string.label_add_action)
)
}
}
@@ -312,6 +319,8 @@ private fun TimedActionChip(
onActionClicked: (TimedAction) -> Unit = {},
onActionDelete: (TimedAction) -> Unit,
) {
+ val context = LocalContext.current
+
val model = remember(timedAction) {
ActionButtonViewModel(timedAction.action)
}
@@ -337,7 +346,7 @@ private fun TimedActionChip(
icon = {
Icon(
imageVector = SwipeToRevealDefaults.Delete,
- contentDescription = null
+ contentDescription = stringResource(id = R.string.action_delete)
)
},
label = {
@@ -368,7 +377,13 @@ private fun TimedActionChip(
Icon(
modifier = Modifier.size(24.dp),
painter = painterResource(id = model.drawableResId),
- contentDescription = null,
+ contentDescription = remember(
+ context,
+ model.actionLabelResId,
+ model.stateLabelResId
+ ) {
+ model.getDescription(context)
+ },
tint = chipColors.iconColor(enabled = true).value
)
}
@@ -422,6 +437,9 @@ fun TimedActionDetailUi(
val lifecycleOwner = LocalLifecycleOwner.current
val timedActionUiViewModel = activityViewModel()
+ val confirmationViewModel = viewModel()
+ val confirmationData by confirmationViewModel.confirmationEventsFlow.collectAsState()
+
TimedActionDetailUi(
modifier = modifier,
action = action,
@@ -433,6 +451,11 @@ fun TimedActionDetailUi(
}
)
+ ConfirmationOverlay(
+ confirmationData = confirmationData,
+ onTimeout = { confirmationViewModel.clearFlow() },
+ )
+
LaunchedEffect(lifecycleOwner) {
lifecycleOwner.lifecycleScope.launch {
timedActionUiViewModel.eventFlow.collect { event ->
@@ -443,24 +466,16 @@ fun TimedActionDetailUi(
when (status) {
ActionStatus.SUCCESS -> {
- CustomConfirmationOverlay()
- .setType(CustomConfirmationOverlay.SUCCESS_ANIMATION)
- .showOn(activity)
-
+ confirmationViewModel.showSuccess()
navController.popBackStack()
}
ActionStatus.PERMISSION_DENIED -> {
- CustomConfirmationOverlay()
- .setType(CustomConfirmationOverlay.CUSTOM_ANIMATION)
- .setCustomDrawable(
- ContextCompat.getDrawable(
- activity,
- R.drawable.ws_full_sad
- )
+ confirmationViewModel.showConfirmation(
+ ConfirmationData(
+ title = context.getString(R.string.error_permissiondenied)
)
- .setMessage(activity.getString(R.string.error_permissiondenied))
- .showOn(activity)
+ )
timedActionUiViewModel.openAppOnPhone(
activity,
@@ -469,10 +484,9 @@ fun TimedActionDetailUi(
}
else -> {
- CustomConfirmationOverlay()
- .setType(CustomConfirmationOverlay.FAILURE_ANIMATION)
- .setMessage(activity.getString(R.string.error_actionfailed))
- .showOn(activity)
+ confirmationViewModel.showFailure(
+ message = context.getString(R.string.error_actionfailed)
+ )
}
}
}
@@ -549,7 +563,13 @@ private fun TimedActionDetailUi(
Icon(
modifier = Modifier.align(Alignment.Center),
painter = painterResource(id = model.drawableResId),
- contentDescription = null,
+ contentDescription = remember(
+ context,
+ model.actionLabelResId,
+ model.stateLabelResId
+ ) {
+ model.getDescription(context)
+ },
tint = MaterialTheme.colors.onSurface
)
}
@@ -594,7 +614,7 @@ private fun TimedActionDetailUi(
Icon(
modifier = Modifier.align(Alignment.Center),
painter = painterResource(id = R.drawable.ic_alarm_white_24dp),
- contentDescription = null,
+ contentDescription = stringResource(id = R.string.label_time),
tint = MaterialTheme.colors.onSurface
)
}
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 81f3c597..071a66aa 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,3 +1,5 @@
+@file:OptIn(ExperimentalWearFoundationApi::class, ExperimentalHorologistApi::class)
+
package com.thewizrd.simplewear.ui.simplewear
import android.content.Intent
@@ -11,19 +13,19 @@ 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.core.content.ContextCompat
+import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.viewmodel.compose.viewModel
+import androidx.wear.compose.foundation.ExperimentalWearFoundationApi
+import androidx.wear.compose.foundation.rememberActiveFocusRequester
+import androidx.wear.compose.foundation.rotary.rotaryScrollable
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
@@ -32,26 +34,30 @@ 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.VolumePositionIndicator
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.google.android.horologist.audio.ui.volumeRotaryBehavior
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.controls.ActionButtonViewModel
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.controls.CustomConfirmationOverlay
+import com.thewizrd.simplewear.ui.components.ConfirmationOverlay
import com.thewizrd.simplewear.ui.theme.findActivity
+import com.thewizrd.simplewear.viewmodels.ConfirmationData
+import com.thewizrd.simplewear.viewmodels.ConfirmationViewModel
import com.thewizrd.simplewear.viewmodels.ValueActionUiState
import com.thewizrd.simplewear.viewmodels.ValueActionViewModel
+import com.thewizrd.simplewear.viewmodels.ValueActionVolumeViewModel
import com.thewizrd.simplewear.viewmodels.WearableListenerViewModel
-import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
+import kotlin.math.max
@Composable
fun ValueActionScreen(
@@ -64,6 +70,12 @@ fun ValueActionScreen(
val lifecycleOwner = LocalLifecycleOwner.current
val valueActionViewModel = viewModel()
+ val volumeViewModel = remember(context, valueActionViewModel) {
+ ValueActionVolumeViewModel(context, valueActionViewModel)
+ }
+
+ val confirmationViewModel = viewModel()
+ val confirmationData by confirmationViewModel.confirmationEventsFlow.collectAsState()
Scaffold(
modifier = modifier.background(MaterialTheme.colors.background),
@@ -72,9 +84,14 @@ fun ValueActionScreen(
TimeText()
},
) {
- ValueActionScreen(valueActionViewModel)
+ ValueActionScreen(valueActionViewModel, volumeViewModel)
}
+ ConfirmationOverlay(
+ confirmationData = confirmationData,
+ onTimeout = { confirmationViewModel.clearFlow() },
+ )
+
LaunchedEffect(actionType, audioStreamType) {
valueActionViewModel.onActionUpdated(actionType, audioStreamType)
}
@@ -137,29 +154,21 @@ fun ValueActionScreen(
lifecycleOwner.lifecycleScope.launch {
when (actionStatus) {
ActionStatus.UNKNOWN, ActionStatus.FAILURE -> {
- CustomConfirmationOverlay()
- .setType(CustomConfirmationOverlay.CUSTOM_ANIMATION)
- .setCustomDrawable(
- ContextCompat.getDrawable(
- activity,
- R.drawable.ws_full_sad
- )
+ confirmationViewModel.showConfirmation(
+ ConfirmationData(
+ iconResId = R.drawable.ws_full_sad,
+ title = context.getString(R.string.error_actionfailed),
)
- .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
- )
+ confirmationViewModel.showConfirmation(
+ ConfirmationData(
+ iconResId = R.drawable.ws_full_sad,
+ title = context.getString(R.string.error_permissiondenied),
)
- .setMessage(activity.getString(R.string.error_permissiondenied))
- .showOn(activity)
+ )
valueActionViewModel.openAppOnPhone(
activity,
@@ -168,16 +177,12 @@ fun ValueActionScreen(
}
ActionStatus.TIMEOUT -> {
- CustomConfirmationOverlay()
- .setType(CustomConfirmationOverlay.CUSTOM_ANIMATION)
- .setCustomDrawable(
- ContextCompat.getDrawable(
- activity,
- R.drawable.ws_full_sad
- )
+ confirmationViewModel.showConfirmation(
+ ConfirmationData(
+ iconResId = R.drawable.ws_full_sad,
+ title = context.getString(R.string.error_sendmessage)
)
- .setMessage(activity.getString(R.string.error_sendmessage))
- .showOn(activity)
+ )
}
ActionStatus.SUCCESS -> {}
@@ -193,29 +198,21 @@ fun ValueActionScreen(
when (status) {
ActionStatus.UNKNOWN, ActionStatus.FAILURE -> {
- CustomConfirmationOverlay()
- .setType(CustomConfirmationOverlay.CUSTOM_ANIMATION)
- .setCustomDrawable(
- ContextCompat.getDrawable(
- activity,
- R.drawable.ws_full_sad
- )
+ confirmationViewModel.showConfirmation(
+ ConfirmationData(
+ iconResId = R.drawable.ws_full_sad,
+ title = context.getString(R.string.error_actionfailed)
)
- .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
- )
+ confirmationViewModel.showConfirmation(
+ ConfirmationData(
+ iconResId = R.drawable.ws_full_sad,
+ title = context.getString(R.string.error_permissiondenied)
)
- .setMessage(activity.getString(R.string.error_permissiondenied))
- .showOn(activity)
+ )
valueActionViewModel.openAppOnPhone(activity, false)
}
@@ -234,46 +231,28 @@ fun ValueActionScreen(
}
}
-@OptIn(ExperimentalHorologistApi::class)
@Composable
fun ValueActionScreen(
- valueActionViewModel: ValueActionViewModel
+ valueActionViewModel: ValueActionViewModel,
+ volumeViewModel: ValueActionVolumeViewModel
) {
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())
+ val progressUiState by volumeViewModel.volumeUiState.collectAsState()
ValueActionScreen(
- modifier = Modifier.rotaryVolumeControlsWithFocus(
- volumeUiStateProvider = {
- progressUiState
- },
- onRotaryVolumeInput = {
- if (it > (uiState.valueActionState?.currentValue ?: 0)) {
- valueActionViewModel.increaseValue()
- } else {
- valueActionViewModel.decreaseValue()
- }
- },
- localView = LocalView.current,
- isLowRes = RotaryDefaults.isLowResInput()
+ modifier = Modifier.rotaryScrollable(
+ focusRequester = rememberActiveFocusRequester(),
+ behavior = volumeRotaryBehavior(
+ volumeUiStateProvider = { progressUiState },
+ onRotaryVolumeInput = { newValue -> volumeViewModel.setVolume(newValue) }
+ )
),
uiState = uiState,
- onValueChanged = {
- if (it > (uiState.valueActionState?.currentValue ?: 0)) {
- valueActionViewModel.increaseValue()
- } else {
- valueActionViewModel.decreaseValue()
- }
- },
+ volumeUiState = progressUiState,
+ onValueChanged = { newValue -> volumeViewModel.setVolume(newValue) },
onActionChange = {
valueActionViewModel.requestActionChange()
}
@@ -284,17 +263,24 @@ fun ValueActionScreen(
fun ValueActionScreen(
modifier: Modifier = Modifier,
uiState: ValueActionUiState,
+ volumeUiState: VolumeUiState = VolumeUiState(),
onValueChanged: (Int) -> Unit = {},
onActionChange: () -> Unit = {}
) {
+ val context = LocalContext.current
+
Box(modifier = modifier.fillMaxSize())
Stepper(
- value = uiState.valueActionState?.currentValue ?: 0,
+ value = volumeUiState.current,
onValueChange = onValueChanged,
valueProgression = IntProgression.fromClosedRange(
- rangeStart = uiState.valueActionState?.minValue ?: 0,
- rangeEnd = uiState.valueActionState?.maxValue ?: 100,
- step = 1
+ rangeStart = volumeUiState.min,
+ rangeEnd = volumeUiState.max,
+ step = if (uiState.action == Actions.VOLUME) {
+ 1
+ } else {
+ max(1f, (volumeUiState.max - volumeUiState.min) * 0.05f).toInt()
+ }
),
increaseIcon = {
if (uiState.action == Actions.VOLUME) {
@@ -384,7 +370,13 @@ fun ValueActionScreen(
else -> {
Icon(
painter = painterResource(id = R.drawable.ic_icon),
- contentDescription = null
+ contentDescription = remember(uiState.action) {
+ uiState.action?.let {
+ context.getString(
+ ActionButtonViewModel.getViewModelFromAction(it).actionLabelResId
+ )
+ }
+ }
)
}
}
@@ -393,13 +385,8 @@ fun ValueActionScreen(
onClick = onActionChange
)
}
- PositionIndicator(
- value = {
- uiState.valueActionState?.currentValue?.toFloat() ?: 0f
- },
- range = (uiState.valueActionState?.minValue?.toFloat()
- ?: 0f)..(uiState.valueActionState?.maxValue?.toFloat() ?: 1f),
- color = MaterialTheme.colors.primary
+ VolumePositionIndicator(
+ volumeUiState = { volumeUiState }
)
}
diff --git a/wear/src/main/java/com/thewizrd/simplewear/ui/tools/WearTilePreviewDevices.kt b/wear/src/main/java/com/thewizrd/simplewear/ui/tools/WearTilePreviewDevices.kt
new file mode 100644
index 00000000..b6fb3a84
--- /dev/null
+++ b/wear/src/main/java/com/thewizrd/simplewear/ui/tools/WearTilePreviewDevices.kt
@@ -0,0 +1,41 @@
+package com.thewizrd.simplewear.ui.tools
+
+import androidx.wear.tiles.tooling.preview.Preview
+import androidx.wear.tooling.preview.devices.WearDevices
+
+@Preview(
+ device = WearDevices.LARGE_ROUND,
+ group = "Devices - Large Round"
+)
+@Preview(
+ device = WearDevices.SMALL_ROUND,
+ group = "Devices - Small Round"
+)
+@Preview(
+ device = WearDevices.SQUARE,
+ group = "Devices - Square"
+)
+@Preview(
+ device = WearDevices.SMALL_ROUND,
+ group = "Devices - Small Round",
+ fontScale = 1.5f
+)
+public annotation class WearTilePreviewDevices
+
+@Preview(
+ device = WearDevices.SMALL_ROUND,
+ group = "Devices - Small Round"
+)
+public annotation class WearSmallRoundDeviceTilePreview
+
+@Preview(
+ device = WearDevices.LARGE_ROUND,
+ group = "Devices - Large Round"
+)
+public annotation class WearLargeRoundDeviceTilePreview
+
+@Preview(
+ device = WearDevices.SQUARE,
+ group = "Devices - Square"
+)
+public annotation class WearSquareDeviceTilePreview
\ No newline at end of file
diff --git a/wear/src/main/java/com/thewizrd/simplewear/ui/utils/Utils.kt b/wear/src/main/java/com/thewizrd/simplewear/ui/utils/Utils.kt
new file mode 100644
index 00000000..1eb4bae4
--- /dev/null
+++ b/wear/src/main/java/com/thewizrd/simplewear/ui/utils/Utils.kt
@@ -0,0 +1,10 @@
+package com.thewizrd.simplewear.ui.utils
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.focus.FocusRequester
+
+@Composable
+fun rememberFocusRequester(): FocusRequester {
+ return remember { FocusRequester() }
+}
\ 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 cfc54269..86d2d8e0 100644
--- a/wear/src/main/java/com/thewizrd/simplewear/viewmodels/AppLauncherViewModel.kt
+++ b/wear/src/main/java/com/thewizrd/simplewear/viewmodels/AppLauncherViewModel.kt
@@ -3,22 +3,16 @@ package com.thewizrd.simplewear.viewmodels
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.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.data.AppItemData
+import com.thewizrd.shared_resources.data.AppItemSerializer
import com.thewizrd.shared_resources.helpers.WearConnectionStatus
import com.thewizrd.shared_resources.helpers.WearableHelper
import com.thewizrd.shared_resources.utils.ImageUtils.toBitmap
@@ -43,12 +37,9 @@ data class AppLauncherUiState(
val loadAppIcons: Boolean = Settings.isLoadAppIcons()
)
-class AppLauncherViewModel(app: Application) : WearableListenerViewModel(app),
- OnDataChangedListener {
+class AppLauncherViewModel(app: Application) : WearableListenerViewModel(app) {
private val viewModelState = MutableStateFlow(AppLauncherUiState(isLoading = true))
- private val timer: CountDownTimer
-
val uiState = viewModelState.stateIn(
viewModelScope,
SharingStarted.Eagerly,
@@ -70,10 +61,13 @@ class AppLauncherViewModel(app: Application) : WearableListenerViewModel(app),
viewModelState.update { state ->
state.copy(
- appsList = createAppsList(items ?: emptyList())
+ appsList = createAppsList(items ?: emptyList()),
+ isLoading = false
)
}
}
+ }.onFailure {
+ Logger.writeLine(Log.ERROR, it)
}
}
}
@@ -81,50 +75,8 @@ class AppLauncherViewModel(app: Application) : WearableListenerViewModel(app),
}
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)
- }
- }
- }
+ Wearable.getChannelClient(appContext).run {
+ registerChannelCallback(channelCallback)
}
viewModelScope.launch {
@@ -163,54 +115,6 @@ class AppLauncherViewModel(app: Application) : WearableListenerViewModel(app),
}
}
- 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)
@@ -252,15 +156,11 @@ class AppLauncherViewModel(app: Application) : WearableListenerViewModel(app),
}
}
- fun refreshApps(startTimer: Boolean = false) {
+ fun refreshApps() {
// Update statuses
viewModelScope.launch {
updateConnectionStatus()
requestAppsUpdate()
- if (startTimer) {
- // Wait for apps update
- timer.start()
- }
}
}
@@ -287,8 +187,9 @@ class AppLauncherViewModel(app: Application) : WearableListenerViewModel(app),
}
override fun onCleared() {
- Wearable.getChannelClient(appContext).unregisterChannelCallback(channelCallback)
- Wearable.getDataClient(appContext).removeListener(this)
+ Wearable.getChannelClient(appContext).run {
+ unregisterChannelCallback(channelCallback)
+ }
super.onCleared()
}
}
\ No newline at end of file
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 222f502e..e37b631d 100644
--- a/wear/src/main/java/com/thewizrd/simplewear/viewmodels/CallManagerViewModel.kt
+++ b/wear/src/main/java/com/thewizrd/simplewear/viewmodels/CallManagerViewModel.kt
@@ -3,34 +3,25 @@ package com.thewizrd.simplewear.viewmodels
import android.app.Application
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.data.CallState
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.ImageUtils.toBitmap
+import com.thewizrd.shared_resources.utils.JSONParser
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 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,
@@ -46,12 +37,9 @@ data class CallManagerUiState(
val isCallActive: Boolean = false,
)
-class CallManagerViewModel(app: Application) : WearableListenerViewModel(app),
- OnDataChangedListener {
+class CallManagerViewModel(app: Application) : WearableListenerViewModel(app) {
private val viewModelState = MutableStateFlow(CallManagerUiState(isLoading = true))
- private val timer: CountDownTimer
-
val uiState = viewModelState.stateIn(
viewModelScope,
SharingStarted.Eagerly,
@@ -59,16 +47,6 @@ class CallManagerViewModel(app: Application) : WearableListenerViewModel(app),
)
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) {
@@ -100,8 +78,8 @@ class CallManagerViewModel(app: Application) : WearableListenerViewModel(app),
viewModelScope.launch {
updateConnectionStatus()
+ requestServiceConnect()
requestCallState()
- timer.start()
}
}
@@ -133,14 +111,12 @@ class CallManagerViewModel(app: Application) : WearableListenerViewModel(app),
}
}
- InCallUIHelper.CallStatePath -> {
+ InCallUIHelper.ConnectPath -> {
viewModelScope.launch {
val status = ActionStatus.valueOf(messageEvent.data.bytesToString())
when (status) {
ActionStatus.PERMISSION_DENIED -> {
- timer.cancel()
-
viewModelState.update {
it.copy(
isLoading = false,
@@ -149,7 +125,6 @@ class CallManagerViewModel(app: Application) : WearableListenerViewModel(app),
}
}
- ActionStatus.SUCCESS -> refreshCallUI()
else -> {}
}
@@ -159,57 +134,33 @@ class CallManagerViewModel(app: Application) : WearableListenerViewModel(app),
}
}
- else -> {
- super.onMessageReceived(messageEvent)
- }
- }
- }
-
- override fun onDataChanged(dataEventBuffer: DataEventBuffer) {
- viewModelScope.launch {
- viewModelState.update {
- it.copy(
- isLoading = false
- )
- }
+ InCallUIHelper.CallStatePath -> {
+ val callState = messageEvent.data?.let {
+ JSONParser.deserializer(it.bytesToString(), CallState::class.java)
+ }
- 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)
- }
- }
+ viewModelScope.launch {
+ updateCallUI(callState)
}
}
+
+ else -> super.onMessageReceived(messageEvent)
}
}
- 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)
+ private suspend fun updateCallUI(callState: CallState?) {
+ val callActive = callState?.callActive ?: false
+ val callerName = callState?.callerName
+ val callerBmp = callState?.callerBitmap?.toBitmap()
+ val inCallFeatures = callState?.supportedFeatures ?: 0
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() }
+ isLoading = false,
+ callerName = callerName?.takeIf { name -> name.isNotBlank() }
?: appContext.getString(R.string.message_callactive),
callerBitmap = if (callActive) callerBmp else null,
supportsSpeaker = callActive && supportsSpeakerToggle,
@@ -219,38 +170,10 @@ class CallManagerViewModel(app: Application) : WearableListenerViewModel(app),
}
}
- 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 requestServiceConnect() {
+ viewModelScope.launch {
+ if (connect()) {
+ sendMessage(mPhoneNodeWithApp!!.id, InCallUIHelper.ConnectPath, null)
}
}
}
@@ -313,7 +236,6 @@ class CallManagerViewModel(app: Application) : WearableListenerViewModel(app),
override fun onCleared() {
requestServiceDisconnect()
- Wearable.getDataClient(appContext).removeListener(this)
super.onCleared()
}
}
\ No newline at end of file
diff --git a/wear/src/main/java/com/thewizrd/simplewear/viewmodels/ConfirmationViewModel.kt b/wear/src/main/java/com/thewizrd/simplewear/viewmodels/ConfirmationViewModel.kt
new file mode 100644
index 00000000..42c6d0e1
--- /dev/null
+++ b/wear/src/main/java/com/thewizrd/simplewear/viewmodels/ConfirmationViewModel.kt
@@ -0,0 +1,66 @@
+package com.thewizrd.simplewear.viewmodels
+
+import androidx.annotation.DrawableRes
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import androidx.wear.compose.material.dialog.DialogDefaults
+import com.thewizrd.simplewear.R
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.update
+
+class ConfirmationViewModel : ViewModel() {
+ private val _confirmationEventsFlow = MutableStateFlow(null)
+
+ val confirmationEventsFlow = _confirmationEventsFlow
+ .distinctUntilChanged(areEquivalent = { old, new -> old == new })
+ .stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.Lazily,
+ initialValue = null
+ )
+
+ fun showConfirmation(data: ConfirmationData) {
+ _confirmationEventsFlow.update { data }
+ }
+
+ fun showSuccess(message: String? = null) {
+ _confirmationEventsFlow.update {
+ ConfirmationData(
+ animatedVectorResId = R.drawable.confirmation_animation,
+ title = message
+ )
+ }
+ }
+
+ fun showFailure(message: String? = null) {
+ _confirmationEventsFlow.update {
+ ConfirmationData(
+ animatedVectorResId = R.drawable.failure_animation,
+ title = message
+ )
+ }
+ }
+
+ fun showOpenOnPhone(message: String? = null) {
+ _confirmationEventsFlow.update {
+ ConfirmationData(
+ animatedVectorResId = R.drawable.open_on_phone_animation,
+ title = message
+ )
+ }
+ }
+
+ fun clearFlow() {
+ _confirmationEventsFlow.update { null }
+ }
+}
+
+data class ConfirmationData(
+ val title: String? = null,
+ @DrawableRes val iconResId: Int? = R.drawable.ws_full_sad,
+ @DrawableRes val animatedVectorResId: Int? = null,
+ val durationMs: Long = DialogDefaults.ShortDurationMillis
+)
\ No newline at end of file
diff --git a/wear/src/main/java/com/thewizrd/simplewear/viewmodels/DashboardViewModel.kt b/wear/src/main/java/com/thewizrd/simplewear/viewmodels/DashboardViewModel.kt
index 5de7196e..61b8712e 100644
--- a/wear/src/main/java/com/thewizrd/simplewear/viewmodels/DashboardViewModel.kt
+++ b/wear/src/main/java/com/thewizrd/simplewear/viewmodels/DashboardViewModel.kt
@@ -4,7 +4,6 @@ import android.app.Application
import android.os.Bundle
import android.os.CountDownTimer
import android.util.ArrayMap
-import androidx.core.content.ContextCompat
import androidx.lifecycle.viewModelScope
import com.google.android.gms.wearable.MessageClient
import com.google.android.gms.wearable.Wearable
@@ -18,7 +17,6 @@ import com.thewizrd.shared_resources.helpers.WearableHelper
import com.thewizrd.shared_resources.utils.JSONParser
import com.thewizrd.shared_resources.utils.bytesToLong
import com.thewizrd.simplewear.R
-import com.thewizrd.simplewear.controls.CustomConfirmationOverlay
import com.thewizrd.simplewear.preferences.Settings
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
@@ -71,16 +69,22 @@ class DashboardViewModel(app: Application) : WearableListenerViewModel(app) {
override fun onTick(millisUntilFinished: Long) {}
override fun onFinish() {
- viewModelScope.launch {
- activityContext?.let {
- CustomConfirmationOverlay()
- .setType(CustomConfirmationOverlay.CUSTOM_ANIMATION)
- .setCustomDrawable(
- ContextCompat.getDrawable(it, R.drawable.ws_full_sad)
- )
- .setMessage(it.getString(R.string.error_sendmessage))
- .showOn(it)
- }
+ activityContext?.let {
+ _eventsFlow.tryEmit(
+ WearableEvent(
+ ACTION_SHOWCONFIRMATION,
+ Bundle().apply {
+ putString(
+ EXTRA_ACTIONDATA,
+ JSONParser.serializer(
+ ConfirmationData(
+ title = it.getString(R.string.error_sendmessage)
+ ), ConfirmationData::class.java
+ )
+ )
+ }
+ )
+ )
}
}
}
diff --git a/wear/src/main/java/com/thewizrd/simplewear/viewmodels/GestureUiViewModel.kt b/wear/src/main/java/com/thewizrd/simplewear/viewmodels/GestureUiViewModel.kt
index d5c5033a..f517fbaf 100644
--- a/wear/src/main/java/com/thewizrd/simplewear/viewmodels/GestureUiViewModel.kt
+++ b/wear/src/main/java/com/thewizrd/simplewear/viewmodels/GestureUiViewModel.kt
@@ -10,6 +10,7 @@ import com.thewizrd.shared_resources.helpers.GestureUIHelper
import com.thewizrd.shared_resources.helpers.WearConnectionStatus
import com.thewizrd.shared_resources.utils.JSONParser
import com.thewizrd.shared_resources.utils.bytesToString
+import com.thewizrd.shared_resources.utils.intToBytes
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn
@@ -150,6 +151,14 @@ class GestureUiViewModel(app: Application) : WearableListenerViewModel(app) {
}
}
+ fun requestKeyEvent(key: Int) {
+ viewModelScope.launch {
+ if (connect()) {
+ sendMessage(mPhoneNodeWithApp!!.id, GestureUIHelper.KeyEventPath, key.intToBytes())
+ }
+ }
+ }
+
override fun onCleared() {
super.onCleared()
}
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 b0eb25d8..959aa6e2 100644
--- a/wear/src/main/java/com/thewizrd/simplewear/viewmodels/MediaPlayerListViewModel.kt
+++ b/wear/src/main/java/com/thewizrd/simplewear/viewmodels/MediaPlayerListViewModel.kt
@@ -2,35 +2,31 @@ 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.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.ChannelClient.Channel
+import com.google.android.gms.wearable.ChannelClient.ChannelCallback
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.media.MusicPlayersData
+import com.thewizrd.shared_resources.utils.ImageUtils.toBitmap
+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.controls.AppItemViewModel
import com.thewizrd.simplewear.helpers.AppItemComparator
import com.thewizrd.simplewear.preferences.Settings
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
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.supervisorScope
import kotlinx.coroutines.tasks.await
data class MediaPlayerListUiState(
@@ -38,17 +34,13 @@ data class MediaPlayerListUiState(
internal val allMediaAppsSet: Set = emptySet(),
val mediaAppsSet: Set = emptySet(),
val filteredAppsList: Set = Settings.getMusicPlayersFilter(),
- val isLoading: Boolean = false,
- val isAutoLaunchEnabled: Boolean = Settings.isAutoLaunchMediaCtrlsEnabled
+ val activePlayerKey: String? = null,
+ val isLoading: Boolean = false
)
-class MediaPlayerListViewModel(app: Application) : WearableListenerViewModel(app),
- OnDataChangedListener {
+class MediaPlayerListViewModel(app: Application) : WearableListenerViewModel(app) {
private val viewModelState = MutableStateFlow(MediaPlayerListUiState(isLoading = true))
- private val timer: CountDownTimer
- private val mutex = Mutex()
-
val uiState = viewModelState.stateIn(
viewModelScope,
SharingStarted.Eagerly,
@@ -57,15 +49,26 @@ class MediaPlayerListViewModel(app: Application) : WearableListenerViewModel(app
private val filteredAppsList = uiState.map { it.filteredAppsList }
- init {
- Wearable.getDataClient(appContext).addListener(this)
+ private val channelCallback = object : ChannelCallback() {
+ override fun onChannelOpened(channel: Channel) {
+ startChannelListener(channel)
+ }
- // Set timer for retrieving music player data
- timer = object : CountDownTimer(3000, 1000) {
- override fun onTick(millisUntilFinished: Long) {}
- override fun onFinish() {
- refreshMusicPlayers()
- }
+ override fun onChannelClosed(
+ channel: Channel,
+ closeReason: Int,
+ appSpecificErrorCode: Int
+ ) {
+ Logger.debug(
+ "ChannelCallback",
+ "channel closed - reason = $closeReason | path = ${channel.path}"
+ )
+ }
+ }
+
+ init {
+ Wearable.getChannelClient(appContext).run {
+ registerChannelCallback(channelCallback)
}
viewModelScope.launch {
@@ -89,6 +92,24 @@ class MediaPlayerListViewModel(app: Application) : WearableListenerViewModel(app
}
}
+ viewModelScope.launch {
+ channelEventsFlow.collect { event ->
+ when (event.eventType) {
+ MediaHelper.MusicPlayersPath -> {
+ val jsonData = event.data.getString(EXTRA_ACTIONDATA)
+
+ viewModelScope.launch {
+ val playersData = jsonData?.let {
+ JSONParser.deserializer(it, MusicPlayersData::class.java)
+ }
+
+ updateMusicPlayers(playersData)
+ }
+ }
+ }
+ }
+ }
+
viewModelScope.launch {
filteredAppsList.collect {
if (uiState.value.allMediaAppsSet.isNotEmpty()) {
@@ -104,15 +125,14 @@ class MediaPlayerListViewModel(app: Application) : WearableListenerViewModel(app
val status = ActionStatus.valueOf(messageEvent.data.bytesToString())
if (status == ActionStatus.PERMISSION_DENIED) {
- timer.cancel()
-
viewModelState.update {
- it.copy(allMediaAppsSet = emptySet())
+ it.copy(
+ allMediaAppsSet = emptySet(),
+ activePlayerKey = null
+ )
}
updateAppsList()
- } else if (status == ActionStatus.SUCCESS) {
- refreshMusicPlayers()
}
_eventsFlow.tryEmit(WearableEvent(messageEvent.path, Bundle().apply {
@@ -132,45 +152,66 @@ class MediaPlayerListViewModel(app: Application) : WearableListenerViewModel(app
}
}
- 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 (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)
+ private fun startChannelListener(channel: Channel) {
+ when (channel.path) {
+ MediaHelper.MusicPlayersPath -> {
+ createChannelListener(channel)
+ }
+ }
+ }
+
+ private fun createChannelListener(channel: Channel): Job =
+ viewModelScope.launch(Dispatchers.Default) {
+ supervisorScope {
+ runCatching {
+ val stream = Wearable.getChannelClient(appContext)
+ .getInputStream(channel).await()
+ stream.bufferedReader().use { reader ->
+ val line = reader.readLine()
+
+ when {
+ line.startsWith("data: ") -> {
+ runCatching {
+ val json = line.substringAfter("data: ")
+ _channelEventsFlow.tryEmit(
+ WearableEvent(channel.path, Bundle().apply {
+ putString(EXTRA_ACTIONDATA, json)
+ })
+ )
+ }.onFailure {
+ Logger.error(
+ "MediaPlayerListChannelListener",
+ it,
+ "error reading data for channel = ${channel.path}"
+ )
+ }
+ }
+
+ line.isEmpty() -> {
+ // empty line; data terminator
}
+
+ else -> {}
}
}
+ }.onFailure {
+ Logger.error("MediaPlayerListChannelListener", it)
}
}
}
- }
override fun onCleared() {
+ Wearable.getChannelClient(appContext).run {
+ unregisterChannelCallback(channelCallback)
+ }
requestPlayerDisconnect()
- Wearable.getDataClient(appContext).removeListener(this)
super.onCleared()
}
- fun refreshState(startTimer: Boolean = false) {
+ fun refreshState() {
viewModelScope.launch {
updateConnectionStatus()
requestPlayersUpdate()
- if (startTimer) {
- // Wait for music player update
- timer.start()
- }
}
}
@@ -200,69 +241,21 @@ class MediaPlayerListViewModel(app: Application) : WearableListenerViewModel(app
}
}
- 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(playersData: MusicPlayersData?) {
+ val mediaAppsList = playersData?.musicPlayers?.mapTo(mutableSetOf()) { player ->
+ AppItemViewModel().apply {
+ appLabel = player.label
+ packageName = player.packageName
+ activityName = player.activityName
+ bitmapIcon = player.iconBitmap?.toBitmap()
}
}
- }
-
- 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)
+ it.copy(
+ allMediaAppsSet = mediaAppsList ?: emptySet(),
+ activePlayerKey = playersData?.activePlayerKey
+ )
}
updateAppsList()
}
@@ -289,18 +282,6 @@ class MediaPlayerListViewModel(app: Application) : WearableListenerViewModel(app
}
}
- suspend fun autoLaunchMediaControls() {
- if (Settings.isAutoLaunchMediaCtrlsEnabled) {
- if (connect()) {
- sendMessage(
- mPhoneNodeWithApp!!.id,
- MediaHelper.MediaPlayerAutoLaunchPath,
- null
- )
- }
- }
- }
-
fun updateFilteredApps(items: Set) {
Settings.setMusicPlayersFilter(items)
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 cefe8726..364a4d3d 100644
--- a/wear/src/main/java/com/thewizrd/simplewear/viewmodels/ValueActionViewModel.kt
+++ b/wear/src/main/java/com/thewizrd/simplewear/viewmodels/ValueActionViewModel.kt
@@ -133,29 +133,47 @@ class ValueActionViewModel(app: Application) : WearableListenerViewModel(app) {
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)
- }
+ if (state.action != null) {
+ 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))
- }))
+ _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)
+ if (state.action != null) {
+ 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 setValue(value: Int) {
+ val state = uiState.value
- _eventsFlow.tryEmit(WearableEvent(ACTION_CHANGED, Bundle().apply {
- putString(EXTRA_ACTIONDATA, JSONParser.serializer(actionData, Action::class.java))
- }))
+ if (state.action != null) {
+ viewModelScope.launch {
+ if (state.action == Actions.VOLUME) {
+ requestSetVolume(value)
+ } else {
+ requestSetValue(value)
+ }
+ }
+ }
}
fun requestActionChange() {
diff --git a/wear/src/main/java/com/thewizrd/simplewear/viewmodels/ValueActionVolumeViewModel.kt b/wear/src/main/java/com/thewizrd/simplewear/viewmodels/ValueActionVolumeViewModel.kt
new file mode 100644
index 00000000..aa9be832
--- /dev/null
+++ b/wear/src/main/java/com/thewizrd/simplewear/viewmodels/ValueActionVolumeViewModel.kt
@@ -0,0 +1,81 @@
+package com.thewizrd.simplewear.viewmodels
+
+import android.content.Context
+import android.os.Vibrator
+import androidx.lifecycle.viewModelScope
+import com.google.android.horologist.annotations.ExperimentalHorologistApi
+import com.google.android.horologist.audio.VolumeRepository
+import com.google.android.horologist.audio.VolumeState
+import com.google.android.horologist.audio.ui.VolumeViewModel
+import com.thewizrd.simplewear.media.NoopAudioOutputRepository
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+
+@OptIn(ExperimentalHorologistApi::class)
+class ValueActionVolumeViewModel(context: Context, valueActionViewModel: ValueActionViewModel) :
+ VolumeViewModel(
+ volumeRepository = ValueActionRepository(valueActionViewModel),
+ audioOutputRepository = NoopAudioOutputRepository(),
+ onCleared = {
+
+ },
+ vibrator = context.getSystemService(Vibrator::class.java)
+ )
+
+private class ValueActionRepository(private val valueActionViewModel: ValueActionViewModel) :
+ VolumeRepository {
+ override val volumeState: StateFlow
+ get() = localValueState
+
+ private val localValueState = MutableStateFlow(VolumeState(current = 0, max = 1))
+
+ private val remoteValueState = valueActionViewModel.uiState.map {
+ VolumeState(
+ current = it.valueActionState?.currentValue ?: 0,
+ min = it.valueActionState?.minValue ?: 0,
+ max = it.valueActionState?.maxValue ?: 1
+ )
+ }
+
+ init {
+ valueActionViewModel.viewModelScope.launch(Dispatchers.Default) {
+ remoteValueState.collectLatest { state ->
+ delay(1000)
+
+ if (!isActive) return@collectLatest
+
+ localValueState.emit(state)
+ }
+ }
+ }
+
+ override fun close() {}
+
+ override fun decreaseVolume() {
+ localValueState.update {
+ it.copy(current = it.current - 1)
+ }
+ valueActionViewModel.decreaseValue()
+ }
+
+ override fun increaseVolume() {
+ localValueState.update {
+ it.copy(current = it.current + 1)
+ }
+ valueActionViewModel.increaseValue()
+ }
+
+ override fun setVolume(volume: Int) {
+ localValueState.update {
+ it.copy(current = volume)
+ }
+ valueActionViewModel.setValue(volume)
+ }
+}
\ 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 f0933cf8..01ef6036 100644
--- a/wear/src/main/java/com/thewizrd/simplewear/viewmodels/WearableListenerViewModel.kt
+++ b/wear/src/main/java/com/thewizrd/simplewear/viewmodels/WearableListenerViewModel.kt
@@ -28,14 +28,13 @@ 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.appLib
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.App
import com.thewizrd.simplewear.helpers.showConfirmationOverlay
import com.thewizrd.simplewear.utils.ErrorMessage
import kotlinx.coroutines.channels.BufferOverflow
@@ -67,6 +66,13 @@ abstract class WearableListenerViewModel(private val app: Application) : Android
)
val eventFlow: SharedFlow = _eventsFlow
+ protected val _channelEventsFlow = MutableSharedFlow(
+ replay = 0,
+ extraBufferCapacity = 64,
+ onBufferOverflow = BufferOverflow.DROP_OLDEST
+ )
+ val channelEventsFlow: SharedFlow = _channelEventsFlow
+
protected val _errorMessagesFlow = MutableSharedFlow(replay = 0)
val errorMessagesFlow: SharedFlow = _errorMessagesFlow
@@ -226,7 +232,7 @@ abstract class WearableListenerViewModel(private val app: Application) : Android
}
messageEvent.path == WearableHelper.AppStatePath -> {
- val appState: AppState = App.instance.applicationState
+ val appState = appLib.appState
sendMessage(
messageEvent.sourceNodeId,
messageEvent.path,
@@ -472,6 +478,7 @@ abstract class WearableListenerViewModel(private val app: Application) : Android
const val ACTION_UPDATECONNECTIONSTATUS =
"SimpleWear.Droid.Wear.action.UPDATE_CONNECTION_STATUS"
const val ACTION_CHANGED = "SimpleWear.Droid.Wear.action.ACTION_CHANGED"
+ const val ACTION_SHOWCONFIRMATION = "SimpleWear.Droid.Wear.action.SHOW_CONFIRMATION"
// Extras
/**
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 c875e2a6..6ae2c467 100644
--- a/wear/src/main/java/com/thewizrd/simplewear/wearable/WearableDataListenerService.kt
+++ b/wear/src/main/java/com/thewizrd/simplewear/wearable/WearableDataListenerService.kt
@@ -10,6 +10,7 @@ import android.bluetooth.le.AdvertiseData
import android.bluetooth.le.AdvertisingSetCallback
import android.bluetooth.le.AdvertisingSetParameters
import android.content.Intent
+import android.net.wifi.WifiManager
import android.os.Build
import android.util.Log
import androidx.annotation.RequiresApi
@@ -24,27 +25,45 @@ import androidx.wear.ongoing.Status
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.Action
import com.thewizrd.shared_resources.actions.Actions
+import com.thewizrd.shared_resources.actions.AudioStreamState
+import com.thewizrd.shared_resources.actions.BatteryStatus
+import com.thewizrd.shared_resources.actions.ToggleAction
+import com.thewizrd.shared_resources.appLib
+import com.thewizrd.shared_resources.data.AppItemData
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.media.MediaMetaData
+import com.thewizrd.shared_resources.media.MediaPlayerState
+import com.thewizrd.shared_resources.media.PlaybackState
+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.stringToBytes
import com.thewizrd.simplewear.DashboardActivity
import com.thewizrd.simplewear.PhoneSyncActivity
import com.thewizrd.simplewear.R
+import com.thewizrd.simplewear.datastore.dashboard.dashboardDataStore
+import com.thewizrd.simplewear.datastore.media.appInfoDataStore
+import com.thewizrd.simplewear.datastore.media.artworkDataStore
+import com.thewizrd.simplewear.datastore.media.mediaDataStore
import com.thewizrd.simplewear.media.MediaPlayerActivity
import com.thewizrd.simplewear.preferences.Settings
import com.thewizrd.simplewear.viewmodels.WearableListenerViewModel
+import com.thewizrd.simplewear.wearable.complications.BatteryStatusComplicationService
+import com.thewizrd.simplewear.wearable.tiles.DashboardTileProviderService
+import com.thewizrd.simplewear.wearable.tiles.MediaPlayerTileProviderService
import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.tasks.await
@@ -64,26 +83,301 @@ class WearableDataListenerService : WearableListenerService() {
private var mPhoneNodeWithApp: Node? = null
private lateinit var mNotificationManager: NotificationManager
+ private var mLegacyTilesEnabled: Boolean = false
override fun onCreate() {
super.onCreate()
mNotificationManager = getSystemService(NotificationManager::class.java)
+ mLegacyTilesEnabled = resources.getBoolean(R.bool.enable_unofficial_tiles)
}
override fun onMessageReceived(messageEvent: MessageEvent) {
- if (messageEvent.path == WearableHelper.StartActivityPath) {
- val startIntent = Intent(this, PhoneSyncActivity::class.java)
- this.startActivity(startIntent)
- } else if (messageEvent.path == WearableHelper.BtDiscoverPath) {
- startBTDiscovery()
-
- GlobalScope.launch(Dispatchers.Default) {
- sendMessage(
- messageEvent.sourceNodeId,
- messageEvent.path,
- Build.MODEL.stringToBytes()
- )
+ when {
+ messageEvent.path == WearableHelper.StartActivityPath -> {
+ val startIntent = Intent(this, PhoneSyncActivity::class.java)
+ this.startActivity(startIntent)
+ }
+
+ messageEvent.path == WearableHelper.BtDiscoverPath -> {
+ startBTDiscovery()
+
+ appLib.appScope.launch(Dispatchers.Default) {
+ sendMessage(
+ messageEvent.sourceNodeId,
+ messageEvent.path,
+ Build.MODEL.stringToBytes()
+ )
+ }
+ }
+
+ messageEvent.path == MediaHelper.MediaPlayerStateBridgePath -> {
+ val jsonData = messageEvent.data?.bytesToString()
+ val metadata = jsonData?.let {
+ JSONParser.deserializer(it, MediaMetaData::class.java)
+ }
+
+ if (metadata != null) {
+ createMediaOngoingActivity(metadata)
+ } else {
+ dismissMediaOngoingActivity()
+ }
+ }
+
+ messageEvent.path == InCallUIHelper.CallStateBridgePath -> {
+ val enable = messageEvent.data.bytesToBool()
+
+ if (enable) {
+ createCallOngoingActivity()
+ } else {
+ dismissCallOngoingActivity()
+ }
+ }
+
+ messageEvent.path == WearableHelper.AudioStatusPath || messageEvent.path == MediaHelper.MediaVolumeStatusPath -> {
+ val status = messageEvent.data?.let {
+ JSONParser.deserializer(
+ it.bytesToString(),
+ AudioStreamState::class.java
+ )
+ }
+
+ appLib.appScope.launch {
+ runCatching {
+ Logger.debug(TAG, "saving audio state...")
+ applicationContext.mediaDataStore.updateData { cache ->
+ cache.copy(audioStreamState = status)
+ }
+ }.onFailure {
+ Logger.error(TAG, it)
+ }
+ }
+ }
+
+ messageEvent.path == MediaHelper.MediaPlayerStatePath -> {
+ val playerState = messageEvent.data?.let {
+ JSONParser.deserializer(it.bytesToString(), MediaPlayerState::class.java)
+ }
+
+ appLib.appScope.launch {
+ runCatching {
+ val mediaDataStore = appLib.context.mediaDataStore
+ val currentState = mediaDataStore.data.firstOrNull()
+
+ Logger.debug(TAG, "saving media state - ${playerState?.key}...")
+ mediaDataStore.updateData { cache ->
+ cache.copy(mediaPlayerState = playerState)
+ }
+
+ if (!mLegacyTilesEnabled && (playerState?.key != currentState?.mediaPlayerState?.key || (playerState?.playbackState == PlaybackState.PLAYING && playerState.mediaMetaData?.positionState != currentState?.mediaPlayerState?.mediaMetaData?.positionState))) {
+ MediaPlayerTileProviderService.requestTileUpdate(appLib.context)
+ }
+ }.onFailure {
+ Logger.error(TAG, it)
+ }
+ }
+ }
+
+ messageEvent.path == MediaHelper.MediaPlayerArtPath -> {
+ val artworkBytes = messageEvent.data
+
+ appLib.appScope.launch {
+ runCatching {
+ val artworkCache = appLib.context.artworkDataStore
+ val currentState = artworkCache.data.firstOrNull()
+
+ Logger.debug(TAG, "saving art - ${artworkBytes.size}bytes...")
+ artworkCache.updateData { artworkBytes }
+
+ if (!mLegacyTilesEnabled && !artworkBytes.contentEquals(currentState)) {
+ MediaPlayerTileProviderService.requestTileUpdate(appLib.context)
+ }
+ }.onFailure {
+ Logger.error(TAG, it)
+ }
+ }
+ }
+
+ messageEvent.path == MediaHelper.MediaPlayerAppInfoPath -> {
+ val appInfo = messageEvent.data?.let {
+ JSONParser.deserializer(it.bytesToString(), AppItemData::class.java)
+ }
+
+ appLib.appScope.launch {
+ runCatching {
+ val appInfoDataStore = appLib.context.appInfoDataStore
+ val currentState = appInfoDataStore.data.firstOrNull()
+
+ Logger.debug(TAG, "saving app info - ${appInfo?.label}...")
+ appInfoDataStore.updateData { cache ->
+ cache.copy(
+ label = appInfo?.label,
+ packageName = appInfo?.packageName,
+ activityName = appInfo?.activityName,
+ iconBitmap = appInfo?.iconBitmap
+ )
+ }
+
+ if (!mLegacyTilesEnabled && appInfo?.key != currentState?.key) {
+ MediaPlayerTileProviderService.requestTileUpdate(appLib.context)
+ }
+ }.onFailure {
+ Logger.error(TAG, it)
+ }
+ }
+ }
+
+ messageEvent.path.contains(WearableHelper.WifiPath) -> {
+ messageEvent.data?.let { 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
+ }
+
+ appLib.appScope.launch {
+ runCatching {
+ val dashboardDataStore = appLib.context.dashboardDataStore
+ val currentState = dashboardDataStore.data.firstOrNull()
+ val currentAction =
+ currentState?.actions?.get(Actions.WIFI) as? ToggleAction
+
+ Logger.debug(TAG, "wifi state changed - ${enabled}...")
+
+ dashboardDataStore.updateData { cache ->
+ cache.copy(
+ actions = cache.actions.toMutableMap().apply {
+ this[Actions.WIFI] = ToggleAction(Actions.WIFI, enabled)
+ }
+ )
+ }
+
+ if (!mLegacyTilesEnabled && currentAction?.isEnabled != enabled) {
+ DashboardTileProviderService.requestTileUpdate(appLib.context)
+ }
+ }
+ }
+ }
+ }
+
+ messageEvent.path.contains(WearableHelper.BluetoothPath) -> {
+ messageEvent.data?.let { data ->
+ val btStatus = data[0].toInt()
+ var enabled = false
+
+ when (btStatus) {
+ BluetoothAdapter.STATE_OFF,
+ BluetoothAdapter.STATE_TURNING_OFF -> enabled = false
+
+ BluetoothAdapter.STATE_ON,
+ BluetoothAdapter.STATE_TURNING_ON -> enabled = true
+ }
+
+ appLib.appScope.launch {
+ runCatching {
+ val dashboardDataStore = appLib.context.dashboardDataStore
+ val currentState = dashboardDataStore.data.firstOrNull()
+ val currentAction =
+ currentState?.actions?.get(Actions.BLUETOOTH) as? ToggleAction
+
+ Logger.debug(TAG, "bluetooth state changed - ${enabled}...")
+
+ dashboardDataStore.updateData { cache ->
+ cache.copy(
+ actions = cache.actions.toMutableMap().apply {
+ this[Actions.BLUETOOTH] =
+ ToggleAction(Actions.BLUETOOTH, enabled)
+ }
+ )
+ }
+
+ if (!mLegacyTilesEnabled && currentAction?.isEnabled != enabled) {
+ DashboardTileProviderService.requestTileUpdate(appLib.context)
+ }
+ }
+ }
+ }
+ }
+
+ messageEvent.path == WearableHelper.BatteryPath -> {
+ val status = messageEvent.data?.let {
+ JSONParser.deserializer(it.bytesToString(), BatteryStatus::class.java)
+ }
+
+ appLib.appScope.launch {
+ runCatching {
+ val dashboardDataStore = appLib.context.dashboardDataStore
+ val currentState = dashboardDataStore.data.firstOrNull()
+
+ Logger.debug(
+ TAG,
+ "battery state updated - ${status?.batteryLevel}|${status?.isCharging}..."
+ )
+
+ dashboardDataStore.updateData { cache ->
+ cache.copy(batteryStatus = status)
+ }
+
+ if (currentState?.batteryStatus != status) {
+ BatteryStatusComplicationService.requestComplicationUpdate(
+ applicationContext
+ )
+ if (!mLegacyTilesEnabled) {
+ DashboardTileProviderService.requestTileUpdate(appLib.context)
+ }
+ }
+ }
+ }
+ }
+
+ messageEvent.path == WearableHelper.ActionsPath -> {
+ val jsonData = messageEvent.data?.bytesToString()
+ val action = JSONParser.deserializer(jsonData, Action::class.java)
+
+ when (action?.actionType) {
+ Actions.WIFI,
+ Actions.BLUETOOTH,
+ Actions.TORCH,
+ Actions.DONOTDISTURB,
+ Actions.RINGER,
+ Actions.MOBILEDATA,
+ Actions.LOCATION,
+ Actions.LOCKSCREEN,
+ Actions.PHONE,
+ Actions.HOTSPOT -> {
+ appLib.appScope.launch {
+ runCatching {
+ val dashboardDataStore = appLib.context.dashboardDataStore
+ val currentState = dashboardDataStore.data.firstOrNull()
+ val currentAction = currentState?.actions?.get(action.actionType)
+
+ Logger.debug(TAG, "action changed - ${action.actionType}...")
+
+ dashboardDataStore.updateData { cache ->
+ cache.copy(
+ actions = cache.actions.toMutableMap().apply {
+ this[action.actionType] = action
+ }
+ )
+ }
+
+ if (!mLegacyTilesEnabled && currentAction != action) {
+ DashboardTileProviderService.requestTileUpdate(appLib.context)
+ }
+ }
+ }
+ }
+
+ else -> {
+ // ignore unsupported action
+ }
+ }
}
}
}
@@ -96,7 +390,7 @@ class WearableDataListenerService : WearableListenerService() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && adapter.isMultipleAdvertisementSupported) {
val advertiser = adapter.bluetoothLeAdvertiser
- GlobalScope.launch(Dispatchers.Default) {
+ appLib.appScope.launch(Dispatchers.Default) {
val params = AdvertisingSetParameters.Builder()
.setLegacyMode(true)
.setConnectable(false)
@@ -167,24 +461,12 @@ class WearableDataListenerService : WearableListenerService() {
Settings.setLoadAppIcons(loadIcons)
}
}
- } else if (item.uri.path == MediaHelper.MediaPlayerStateBridgePath) {
- createMediaOngoingActivity(item)
- } else if (item.uri.path == InCallUIHelper.CallStateBridgePath) {
- createCallOngoingActivity(item)
- }
- }
- if (event.type == DataEvent.TYPE_DELETED) {
- val item = event.dataItem
- if (item.uri.path == MediaHelper.MediaPlayerStateBridgePath) {
- dismissMediaOngoingActivity()
- } else if (item.uri.path == InCallUIHelper.CallStateBridgePath) {
- dismissCallOngoingActivity()
}
}
}
}
- private fun createCallOngoingActivity(item: DataItem) {
+ private fun createCallOngoingActivity() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
initCallControllerNotifChannel()
}
@@ -229,13 +511,12 @@ class WearableDataListenerService : WearableListenerService() {
mNotificationManager.notify(1000, notifBuilder.build())
}
- private fun createMediaOngoingActivity(item: DataItem) {
+ private fun createMediaOngoingActivity(mediaMetaData: MediaMetaData) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
initMediaControllerNotifChannel()
}
- val dataMap = DataMapItem.fromDataItem(item).dataMap
- val songTitle = dataMap.getString(MediaHelper.KEY_MEDIA_METADATA_TITLE)
+ val songTitle = mediaMetaData.title
val notifTitle = getString(R.string.title_nowplaying)
val notifBuilder = NotificationCompat.Builder(this, MEDIA_NOT_CHANNEL_ID)
diff --git a/wear/src/main/java/com/thewizrd/simplewear/wearable/complications/BatteryStatusComplicationService.kt b/wear/src/main/java/com/thewizrd/simplewear/wearable/complications/BatteryStatusComplicationService.kt
index 7d05ffd8..03e11c2c 100644
--- a/wear/src/main/java/com/thewizrd/simplewear/wearable/complications/BatteryStatusComplicationService.kt
+++ b/wear/src/main/java/com/thewizrd/simplewear/wearable/complications/BatteryStatusComplicationService.kt
@@ -2,6 +2,7 @@ package com.thewizrd.simplewear.wearable.complications
import android.app.PendingIntent
import android.content.ComponentName
+import android.content.Context
import android.content.Intent
import android.graphics.drawable.Icon
import androidx.wear.watchface.complications.data.ComplicationData
@@ -15,19 +16,53 @@ import androidx.wear.watchface.complications.data.ShortTextComplicationData
import androidx.wear.watchface.complications.datasource.ComplicationDataSourceUpdateRequester
import androidx.wear.watchface.complications.datasource.ComplicationRequest
import androidx.wear.watchface.complications.datasource.SuspendingComplicationDataSourceService
+import com.thewizrd.shared_resources.actions.BatteryStatus
+import com.thewizrd.shared_resources.appLib
+import com.thewizrd.shared_resources.utils.Logger
import com.thewizrd.simplewear.DashboardActivity
import com.thewizrd.simplewear.R
+import com.thewizrd.simplewear.datastore.dashboard.dashboardDataStore
import com.thewizrd.simplewear.utils.asLauncherIntent
import com.thewizrd.simplewear.wearable.tiles.DashboardTileMessenger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.firstOrNull
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
class BatteryStatusComplicationService : SuspendingComplicationDataSourceService() {
companion object {
private const val TAG = "BatteryStatusComplicationService"
+
+ fun requestComplicationUpdate(context: Context, complicationInstanceId: Int? = null) {
+ updateJob?.cancel()
+
+ updateJob = appLib.appScope.launch {
+ delay(1000)
+ if (isActive) {
+ Logger.debug(TAG, "requesting complication update")
+
+ ComplicationDataSourceUpdateRequester.create(
+ context,
+ ComponentName(context, this::class.java)
+ ).run {
+ if (complicationInstanceId != null) {
+ requestUpdate(complicationInstanceId)
+ } else {
+ requestUpdateAll()
+ }
+ }
+ }
+ }
+ }
+
+ private var updateJob: Job? = null
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@@ -39,7 +74,6 @@ class BatteryStatusComplicationService : SuspendingComplicationDataSourceService
ComplicationType.SHORT_TEXT,
ComplicationType.LONG_TEXT
)
- private val complicationIconResId = R.drawable.ic_smartphone_white_24dp
override fun onDestroy() {
super.onDestroy()
@@ -48,13 +82,7 @@ class BatteryStatusComplicationService : SuspendingComplicationDataSourceService
override fun onComplicationActivated(complicationInstanceId: Int, type: ComplicationType) {
super.onComplicationActivated(complicationInstanceId, type)
-
- ComplicationDataSourceUpdateRequester.create(
- applicationContext,
- ComponentName(applicationContext, this::class.java)
- ).run {
- requestUpdate(complicationInstanceId)
- }
+ requestComplicationUpdate(applicationContext, complicationInstanceId)
}
override suspend fun onComplicationRequest(request: ComplicationRequest): ComplicationData {
@@ -63,8 +91,7 @@ class BatteryStatusComplicationService : SuspendingComplicationDataSourceService
}
return scope.async {
- val batteryStatus = tileMessenger.requestBatteryStatusAsync()
- ?: return@async NoDataComplicationData()
+ val batteryStatus = latestStatus() ?: return@async NoDataComplicationData()
val batteryLvl = batteryStatus.batteryLevel
val statusText = if (batteryStatus.isCharging) {
@@ -72,6 +99,11 @@ class BatteryStatusComplicationService : SuspendingComplicationDataSourceService
} else {
getString(R.string.batt_state_discharging)
}
+ val complicationIconResId = if (batteryStatus.isCharging) {
+ R.drawable.ic_charging_station_24dp
+ } else {
+ R.drawable.ic_smartphone_white_24dp
+ }
when (request.complicationType) {
ComplicationType.RANGED_VALUE -> {
@@ -92,7 +124,7 @@ class BatteryStatusComplicationService : SuspendingComplicationDataSourceService
ComplicationType.SHORT_TEXT -> {
ShortTextComplicationData.Builder(
- PlainComplicationText.Builder("70%").build(),
+ PlainComplicationText.Builder("${batteryLvl}%").build(),
PlainComplicationText.Builder("${getString(R.string.pref_title_phone_batt_state)}: ${batteryLvl}%, $statusText")
.build()
).setMonochromaticImage(
@@ -106,7 +138,7 @@ class BatteryStatusComplicationService : SuspendingComplicationDataSourceService
ComplicationType.LONG_TEXT -> {
LongTextComplicationData.Builder(
- PlainComplicationText.Builder("70%, $statusText").build(),
+ PlainComplicationText.Builder("${batteryLvl}%, $statusText").build(),
PlainComplicationText.Builder("${getString(R.string.pref_title_phone_batt_state)}: ${batteryLvl}%, $statusText")
.build()
).setTitle(
@@ -131,6 +163,8 @@ class BatteryStatusComplicationService : SuspendingComplicationDataSourceService
return NoDataComplicationData()
}
+ val complicationIconResId = R.drawable.ic_charging_station_24dp
+
return when (type) {
ComplicationType.RANGED_VALUE -> {
RangedValueComplicationData.Builder(
@@ -191,4 +225,15 @@ class BatteryStatusComplicationService : SuspendingComplicationDataSourceService
)
}
}
+
+ private suspend fun latestStatus(): BatteryStatus? {
+ var status = this.dashboardDataStore.data.map { it.batteryStatus }.firstOrNull()
+
+ if (status == null) {
+ Logger.debug(TAG, "No battery status available. loading from remote...")
+ status = tileMessenger.requestBatteryStatusAsync()
+ }
+
+ return status
+ }
}
\ No newline at end of file
diff --git a/wear/src/main/java/com/thewizrd/simplewear/wearable/tiles/DashboardTileMessenger.kt b/wear/src/main/java/com/thewizrd/simplewear/wearable/tiles/DashboardTileMessenger.kt
index f01a1cd8..ce687cb3 100644
--- a/wear/src/main/java/com/thewizrd/simplewear/wearable/tiles/DashboardTileMessenger.kt
+++ b/wear/src/main/java/com/thewizrd/simplewear/wearable/tiles/DashboardTileMessenger.kt
@@ -1,14 +1,11 @@
package com.thewizrd.simplewear.wearable.tiles
-import android.bluetooth.BluetoothAdapter
import android.content.Context
-import android.net.wifi.WifiManager
import android.util.Log
import com.google.android.gms.common.api.ApiException
import com.google.android.gms.wearable.CapabilityClient
import com.google.android.gms.wearable.CapabilityInfo
import com.google.android.gms.wearable.MessageClient
-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.WearableStatusCodes
@@ -29,232 +26,47 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
+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.suspendCancellableCoroutine
import kotlinx.coroutines.tasks.await
-import timber.log.Timber
import kotlin.coroutines.resume
-class DashboardTileMessenger(private val context: Context) :
- CapabilityClient.OnCapabilityChangedListener, MessageClient.OnMessageReceivedListener {
+class DashboardTileMessenger(
+ private val context: Context,
+ private val isLegacyTile: Boolean = false
+) : CapabilityClient.OnCapabilityChangedListener {
companion object {
private const val TAG = "DashboardTileMessenger"
- internal val tileModel by lazy { DashboardTileModel() }
}
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
+
@Volatile
private var mPhoneNodeWithApp: Node? = null
- private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
+ private val _connectionState = MutableStateFlow(WearConnectionStatus.DISCONNECTED)
+ val connectionState = _connectionState.stateIn(
+ scope,
+ SharingStarted.Eagerly,
+ _connectionState.value
+ )
fun register() {
Wearable.getCapabilityClient(context)
.addListener(this, WearableHelper.CAPABILITY_PHONE_APP)
-
- Wearable.getMessageClient(context)
- .addListener(this)
}
fun unregister() {
Wearable.getCapabilityClient(context)
.removeListener(this, WearableHelper.CAPABILITY_PHONE_APP)
- Wearable.getMessageClient(context)
- .removeListener(this)
-
scope.cancel()
}
- override fun onMessageReceived(messageEvent: MessageEvent) {
- val data = messageEvent.data ?: return
-
- Timber.tag(TAG).d("message received - path: ${messageEvent.path}")
-
- when {
- messageEvent.path.contains(WearableHelper.WifiPath) -> {
- 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
- }
-
- tileModel.setAction(Actions.WIFI, ToggleAction(Actions.WIFI, enabled))
- requestTileUpdate(context)
- }
-
- messageEvent.path.contains(WearableHelper.BluetoothPath) -> {
- val btStatus = data[0].toInt()
- var enabled = false
-
- when (btStatus) {
- BluetoothAdapter.STATE_OFF,
- BluetoothAdapter.STATE_TURNING_OFF -> enabled = false
-
- BluetoothAdapter.STATE_ON,
- BluetoothAdapter.STATE_TURNING_ON -> enabled = true
- }
-
- tileModel.setAction(Actions.BLUETOOTH, ToggleAction(Actions.BLUETOOTH, enabled))
- requestTileUpdate(context)
- }
-
- messageEvent.path == WearableHelper.BatteryPath -> {
- val jsonData: String = data.bytesToString()
- tileModel.updateBatteryStatus(
- JSONParser.deserializer(
- jsonData,
- BatteryStatus::class.java
- )
- )
- requestTileUpdate(context)
- }
-
- messageEvent.path == WearableHelper.ActionsPath -> {
- val jsonData: String = data.bytesToString()
- val action = JSONParser.deserializer(jsonData, Action::class.java)
-
- when (action?.actionType) {
- Actions.WIFI,
- Actions.BLUETOOTH,
- Actions.TORCH,
- Actions.DONOTDISTURB,
- Actions.RINGER,
- Actions.MOBILEDATA,
- Actions.LOCATION,
- Actions.LOCKSCREEN,
- Actions.PHONE,
- Actions.HOTSPOT -> {
- tileModel.setAction(action.actionType, action)
- requestTileUpdate(context)
- }
-
- else -> {
- // ignore unsupported action
- }
- }
- }
- }
- }
-
- override fun onCapabilityChanged(capabilityInfo: CapabilityInfo) {
- scope.launch {
- val connectedNodes = getConnectedNodes()
- mPhoneNodeWithApp = WearableHelper.pickBestNodeId(capabilityInfo.nodes)
-
- mPhoneNodeWithApp?.let { node ->
- if (node.isNearby && connectedNodes.any { it.id == node.id }) {
- tileModel.setConnectionStatus(WearConnectionStatus.CONNECTED)
- } else {
- try {
- sendPing(node.id)
- tileModel.setConnectionStatus(WearConnectionStatus.CONNECTED)
- } catch (e: ApiException) {
- if (e.statusCode == WearableStatusCodes.TARGET_NODE_NOT_CONNECTED) {
- tileModel.setConnectionStatus(WearConnectionStatus.DISCONNECTED)
- } else {
- Logger.writeLine(Log.ERROR, e)
- }
- }
- }
- } ?: run {
- /*
- * 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
- */
- tileModel.setConnectionStatus(
- if (connectedNodes.isEmpty()) {
- WearConnectionStatus.DISCONNECTED
- } else {
- WearConnectionStatus.APPNOTINSTALLED
- }
- )
- }
-
- requestTileUpdate(context)
- }
- }
-
- suspend fun checkConnectionStatus(refreshTile: Boolean = false) {
- val connectedNodes = getConnectedNodes()
- mPhoneNodeWithApp = checkIfPhoneHasApp()
-
- mPhoneNodeWithApp?.let { node ->
- if (node.isNearby && connectedNodes.any { it.id == node.id }) {
- tileModel.setConnectionStatus(WearConnectionStatus.CONNECTED)
- } else {
- try {
- sendPing(node.id)
- tileModel.setConnectionStatus(
- WearConnectionStatus.CONNECTED
- )
- } catch (e: ApiException) {
- if (e.statusCode == WearableStatusCodes.TARGET_NODE_NOT_CONNECTED) {
- tileModel.setConnectionStatus(
- WearConnectionStatus.DISCONNECTED
- )
- } else {
- Logger.writeLine(Log.ERROR, e)
- }
- }
- }
- } ?: run {
- /*
- * 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
- */
- tileModel.setConnectionStatus(
- if (connectedNodes.isEmpty()) {
- WearConnectionStatus.DISCONNECTED
- } else {
- WearConnectionStatus.APPNOTINSTALLED
- }
- )
- }
-
- if (refreshTile) {
- requestTileUpdate(context)
- }
- }
-
- private suspend fun checkIfPhoneHasApp(): Node? {
- var node: Node? = null
-
- try {
- val capabilityInfo = Wearable.getCapabilityClient(context)
- .getCapability(
- WearableHelper.CAPABILITY_PHONE_APP,
- CapabilityClient.FILTER_ALL
- )
- .await()
- node = pickBestNodeId(capabilityInfo.nodes)
- } catch (e: Exception) {
- Logger.writeLine(Log.ERROR, e)
- }
-
- return node
- }
-
- suspend fun connect(): Boolean {
- if (mPhoneNodeWithApp == null)
- mPhoneNodeWithApp = checkIfPhoneHasApp()
-
- return mPhoneNodeWithApp != null
- }
-
suspend fun requestUpdate() {
if (connect()) {
sendMessage(mPhoneNodeWithApp!!.id, WearableHelper.UpdatePath, null)
@@ -275,10 +87,10 @@ class DashboardTileMessenger(private val context: Context) :
}
}
- suspend fun processAction(action: Actions) {
+ private suspend fun processAction(state: DashboardTileState, action: Actions) {
when (action) {
Actions.WIFI -> run {
- val wifiAction = tileModel.getAction(Actions.WIFI) as? ToggleAction
+ val wifiAction = state.getAction(Actions.WIFI) as? ToggleAction
if (wifiAction == null) {
requestUpdate()
@@ -289,7 +101,7 @@ class DashboardTileMessenger(private val context: Context) :
}
Actions.BLUETOOTH -> run {
- val btAction = tileModel.getAction(Actions.BLUETOOTH) as? ToggleAction
+ val btAction = state.getAction(Actions.BLUETOOTH) as? ToggleAction
if (btAction == null) {
requestUpdate()
@@ -300,13 +112,11 @@ class DashboardTileMessenger(private val context: Context) :
}
Actions.LOCKSCREEN -> requestAction(
- tileModel.getAction(Actions.LOCKSCREEN) ?: NormalAction(
- Actions.LOCKSCREEN
- )
+ state.getAction(Actions.LOCKSCREEN) ?: NormalAction(Actions.LOCKSCREEN)
)
Actions.DONOTDISTURB -> run {
- val dndAction = tileModel.getAction(Actions.DONOTDISTURB)
+ val dndAction = state.getAction(Actions.DONOTDISTURB)
if (dndAction == null) {
requestUpdate()
@@ -326,7 +136,7 @@ class DashboardTileMessenger(private val context: Context) :
}
Actions.RINGER -> run {
- val ringerAction = tileModel.getAction(Actions.RINGER) as? MultiChoiceAction
+ val ringerAction = state.getAction(Actions.RINGER) as? MultiChoiceAction
if (ringerAction == null) {
requestUpdate()
@@ -337,7 +147,7 @@ class DashboardTileMessenger(private val context: Context) :
}
Actions.TORCH -> run {
- val torchAction = tileModel.getAction(Actions.TORCH) as? ToggleAction
+ val torchAction = state.getAction(Actions.TORCH) as? ToggleAction
if (torchAction == null) {
requestUpdate()
@@ -348,7 +158,7 @@ class DashboardTileMessenger(private val context: Context) :
}
Actions.MOBILEDATA -> run {
- val mobileDataAction = tileModel.getAction(Actions.MOBILEDATA) as? ToggleAction
+ val mobileDataAction = state.getAction(Actions.MOBILEDATA) as? ToggleAction
if (mobileDataAction == null) {
requestUpdate()
@@ -359,7 +169,7 @@ class DashboardTileMessenger(private val context: Context) :
}
Actions.LOCATION -> run {
- val locationAction = tileModel.getAction(Actions.LOCATION)
+ val locationAction = state.getAction(Actions.LOCATION)
if (locationAction == null) {
requestUpdate()
@@ -379,7 +189,7 @@ class DashboardTileMessenger(private val context: Context) :
}
Actions.HOTSPOT -> run {
- val hotspotAction = tileModel.getAction(Actions.HOTSPOT) as? ToggleAction
+ val hotspotAction = state.getAction(Actions.HOTSPOT) as? ToggleAction
if (hotspotAction == null) {
requestUpdate()
@@ -395,13 +205,12 @@ class DashboardTileMessenger(private val context: Context) :
}
}
- suspend fun processActionAsync(actionType: Actions) {
+ suspend fun processActionAsync(state: DashboardTileState, actionType: Actions): Boolean =
suspendCancellableCoroutine { continuation ->
val listener = MessageClient.OnMessageReceivedListener { event ->
when (actionType) {
Actions.WIFI -> {
if (event.path == WearableHelper.WifiPath) {
- onMessageReceived(event)
if (continuation.isActive) {
continuation.resume(true)
return@OnMessageReceivedListener
@@ -411,7 +220,6 @@ class DashboardTileMessenger(private val context: Context) :
Actions.BLUETOOTH -> {
if (event.path == WearableHelper.BluetoothPath) {
- onMessageReceived(event)
if (continuation.isActive) {
continuation.resume(true)
return@OnMessageReceivedListener
@@ -425,7 +233,6 @@ class DashboardTileMessenger(private val context: Context) :
val action = JSONParser.deserializer(jsonData, Action::class.java)
if (action?.actionType == actionType) {
- onMessageReceived(event)
if (continuation.isActive) {
continuation.resume(true)
return@OnMessageReceivedListener
@@ -446,10 +253,9 @@ class DashboardTileMessenger(private val context: Context) :
.addListener(listener)
.await()
- processAction(actionType)
+ processAction(state, actionType)
}
}
- }
suspend fun requestBatteryStatusAsync(): BatteryStatus? {
return suspendCancellableCoroutine { continuation ->
@@ -491,6 +297,116 @@ class DashboardTileMessenger(private val context: Context) :
}
}
+ override fun onCapabilityChanged(capabilityInfo: CapabilityInfo) {
+ scope.launch {
+ val connectedNodes = getConnectedNodes()
+ mPhoneNodeWithApp = WearableHelper.pickBestNodeId(capabilityInfo.nodes)
+ mPhoneNodeWithApp?.let { node ->
+ if (node.isNearby && connectedNodes.any { it.id == node.id }) {
+ _connectionState.update { WearConnectionStatus.CONNECTED }
+ } else {
+ try {
+ sendPing(node.id)
+ _connectionState.update { WearConnectionStatus.CONNECTED }
+ } catch (e: ApiException) {
+ if (e.statusCode == WearableStatusCodes.TARGET_NODE_NOT_CONNECTED) {
+ _connectionState.update { WearConnectionStatus.DISCONNECTED }
+ } else {
+ Logger.writeLine(Log.ERROR, e)
+ }
+ }
+ }
+ } ?: run {
+ /*
+ * 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
+ */
+ _connectionState.update {
+ if (connectedNodes.isEmpty()) {
+ WearConnectionStatus.DISCONNECTED
+ } else {
+ WearConnectionStatus.APPNOTINSTALLED
+ }
+ }
+ }
+
+ if (!isLegacyTile) {
+ requestTileUpdate(context)
+ }
+ }
+ }
+
+ suspend fun checkConnectionStatus(refreshTile: Boolean = false) {
+ val connectedNodes = getConnectedNodes()
+ mPhoneNodeWithApp = checkIfPhoneHasApp()
+
+ mPhoneNodeWithApp?.let { node ->
+ if (node.isNearby && connectedNodes.any { it.id == node.id }) {
+ _connectionState.update { WearConnectionStatus.CONNECTED }
+ } else {
+ try {
+ sendPing(node.id)
+ _connectionState.update { WearConnectionStatus.CONNECTED }
+ } catch (e: ApiException) {
+ if (e.statusCode == WearableStatusCodes.TARGET_NODE_NOT_CONNECTED) {
+ _connectionState.update { WearConnectionStatus.DISCONNECTED }
+ } else {
+ Logger.error(TAG, e)
+ }
+ }
+ }
+ } ?: run {
+ /*
+ * 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
+ */
+ _connectionState.update {
+ if (connectedNodes.isEmpty()) {
+ WearConnectionStatus.DISCONNECTED
+ } else {
+ WearConnectionStatus.APPNOTINSTALLED
+ }
+ }
+ }
+
+ if (!isLegacyTile && refreshTile) {
+ requestTileUpdate(context)
+ }
+ }
+
+ private suspend fun checkIfPhoneHasApp(): Node? {
+ var node: Node? = null
+
+ try {
+ val capabilityInfo = Wearable.getCapabilityClient(context)
+ .getCapability(
+ WearableHelper.CAPABILITY_PHONE_APP,
+ CapabilityClient.FILTER_ALL
+ )
+ .await()
+ node = pickBestNodeId(capabilityInfo.nodes)
+ } catch (e: Exception) {
+ Logger.writeLine(Log.ERROR, e)
+ }
+
+ return node
+ }
+
+ suspend fun connect(): Boolean {
+ if (mPhoneNodeWithApp == null)
+ mPhoneNodeWithApp = checkIfPhoneHasApp()
+
+ return mPhoneNodeWithApp != null
+ }
+
/*
* 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).
@@ -508,7 +424,7 @@ class DashboardTileMessenger(private val context: Context) :
return bestNode
}
- suspend fun getConnectedNodes(): List {
+ private suspend fun getConnectedNodes(): List {
try {
return Wearable.getNodeClient(context)
.connectedNodes
@@ -529,11 +445,11 @@ class DashboardTileMessenger(private val context: Context) :
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) {
- tileModel.setConnectionStatus(
- WearConnectionStatus.DISCONNECTED
- )
+ _connectionState.update { WearConnectionStatus.DISCONNECTED }
- requestTileUpdate(context)
+ if (!isLegacyTile) {
+ requestTileUpdate(context)
+ }
return
}
}
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
deleted file mode 100644
index 55390b41..00000000
--- a/wear/src/main/java/com/thewizrd/simplewear/wearable/tiles/DashboardTileModel.kt
+++ /dev/null
@@ -1,89 +0,0 @@
-package com.thewizrd.simplewear.wearable.tiles
-
-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.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
-import kotlinx.coroutines.flow.update
-
-class DashboardTileModel {
- private var mConnectionStatus = WearConnectionStatus.DISCONNECTED
-
- private var battStatus: BatteryStatus? = null
- private val tileActions = mutableListOf()
- private val actionMap = mutableMapOf().apply {
- // Add NormalActions
- putIfAbsent(Actions.LOCKSCREEN, NormalAction(Actions.LOCKSCREEN))
- }
-
- private val _tileState =
- MutableStateFlow(
- DashboardTileState(
- mConnectionStatus,
- battStatus,
- getActionMapping(),
- Settings.isShowTileBatStatus()
- )
- )
-
- val tileState: StateFlow
- get() = _tileState.asStateFlow()
-
- fun setConnectionStatus(status: WearConnectionStatus) {
- mConnectionStatus = status
- _tileState.update {
- it.copy(connectionStatus = status)
- }
- }
-
- fun updateBatteryStatus(status: BatteryStatus?) {
- battStatus = status
- _tileState.update {
- it.copy(batteryStatus = status)
- }
- }
-
- 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
- _tileState.update {
- it.copy(actions = getActionMapping())
- }
- }
-
- fun updateTileActions(actions: Collection) {
- tileActions.clear()
- tileActions.addAll(actions)
-
- _tileState.update {
- it.copy(actions = getActionMapping())
- }
- }
-
- val actionCount: Int
- get() = tileActions.size
-
- private fun getActionMapping() = tileActions.associateWith { actionMap[it] }
-}
-
-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]
-
- val isEmpty = batteryStatus == null || actions.isEmpty()
-}
\ No newline at end of file
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 19fbf5f4..07fa0eb4 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
@@ -1,8 +1,11 @@
+@file:OptIn(ExperimentalHorologistApi::class)
+
package com.thewizrd.simplewear.wearable.tiles
import android.content.Context
import android.content.Intent
import android.os.Bundle
+import android.os.SystemClock
import androidx.lifecycle.lifecycleScope
import androidx.wear.protolayout.ResourceBuilders
import androidx.wear.tiles.EventBuilders
@@ -10,60 +13,103 @@ import androidx.wear.tiles.RequestBuilders
import androidx.wear.tiles.TileBuilders
import com.google.android.horologist.annotations.ExperimentalHorologistApi
import com.google.android.horologist.tiles.SuspendingTileService
+import com.thewizrd.shared_resources.actions.Action
import com.thewizrd.shared_resources.actions.Actions
+import com.thewizrd.shared_resources.actions.NormalAction
+import com.thewizrd.shared_resources.appLib
import com.thewizrd.shared_resources.utils.AnalyticsLogger
+import com.thewizrd.shared_resources.utils.Logger
import com.thewizrd.simplewear.PhoneSyncActivity
+import com.thewizrd.simplewear.datastore.dashboard.dashboardDataStore
import com.thewizrd.simplewear.preferences.DashboardTileUtils.DEFAULT_TILES
import com.thewizrd.simplewear.preferences.Settings
-import com.thewizrd.simplewear.wearable.tiles.DashboardTileMessenger.Companion.tileModel
import com.thewizrd.simplewear.wearable.tiles.DashboardTileRenderer.Companion.ID_OPENONPHONE
import com.thewizrd.simplewear.wearable.tiles.DashboardTileRenderer.Companion.ID_PHONEDISCONNECTED
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
+import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.withTimeoutOrNull
-import timber.log.Timber
+import java.time.Duration
import kotlin.coroutines.cancellation.CancellationException
import kotlin.coroutines.coroutineContext
-@OptIn(ExperimentalHorologistApi::class)
class DashboardTileProviderService : SuspendingTileService() {
companion object {
private const val TAG = "DashTileProviderService"
fun requestTileUpdate(context: Context) {
- Timber.tag(TAG).d("$TAG: requesting tile update")
- getUpdater(context).requestUpdate(DashboardTileProviderService::class.java)
+ updateJob?.cancel()
+
+ updateJob = appLib.appScope.launch {
+ delay(1000)
+ if (isActive) {
+ Logger.debug(TAG, "requesting tile update")
+ getUpdater(context).requestUpdate(DashboardTileProviderService::class.java)
+ }
+ }
}
+ @JvmStatic
+ @Volatile
var isInFocus: Boolean = false
private set
+
+ @JvmStatic
+ @Volatile
+ var isUpdating: Boolean = false
+ private set
+
+ private var updateJob: Job? = null
}
- private val tileMessenger = DashboardTileMessenger(this)
+ private lateinit var tileMessenger: DashboardTileMessenger
private lateinit var tileStateFlow: StateFlow
-
- private var isUpdating: Boolean = false
+ private lateinit var tileRenderer: DashboardTileRenderer
override fun onCreate() {
super.onCreate()
- Timber.tag(TAG).d("creating service...")
+ Logger.debug(TAG, "creating service...")
+
+ tileMessenger = DashboardTileMessenger(this)
+ tileRenderer = DashboardTileRenderer(this)
tileMessenger.register()
- tileStateFlow = tileModel.tileState
+ tileStateFlow = this.dashboardDataStore.data
+ .combine(tileMessenger.connectionState) { cache, connectionStatus ->
+ val userActions = Settings.getDashboardTileConfig() ?: DEFAULT_TILES
+
+ DashboardTileState(
+ connectionStatus = connectionStatus,
+ batteryStatus = cache.batteryStatus,
+ actions = userActions.associateWith {
+ cache.actions.run {
+ // Add NormalActions
+ this.plus(Actions.LOCKSCREEN to NormalAction(Actions.LOCKSCREEN))
+ }[it]
+ },
+ showBatteryStatus = Settings.isShowTileBatStatus()
+ )
+ }
.stateIn(
lifecycleScope,
started = SharingStarted.WhileSubscribed(2000),
- null
+ initialValue = null
)
}
override fun onDestroy() {
- Timber.tag(TAG).d("destroying service...")
+ isUpdating = false
+ Logger.debug(TAG, "destroying service...")
tileMessenger.unregister()
super.onDestroy()
}
@@ -71,15 +117,12 @@ class DashboardTileProviderService : SuspendingTileService() {
override fun onTileEnterEvent(requestParams: EventBuilders.TileEnterEvent) {
super.onTileEnterEvent(requestParams)
- Timber.tag(TAG).d("$TAG: onTileEnterEvent called with: tileId = ${requestParams.tileId}")
+ Logger.debug(TAG, "onTileEnterEvent called with: tileId = ${requestParams.tileId}")
AnalyticsLogger.logEvent("on_tile_enter", Bundle().apply {
putString("tile", TAG)
})
isInFocus = true
- // Update tile actions
- tileModel.updateTileActions(Settings.getDashboardTileConfig() ?: DEFAULT_TILES)
-
lifecycleScope.launch {
tileMessenger.checkConnectionStatus()
tileMessenger.requestUpdate()
@@ -93,14 +136,13 @@ class DashboardTileProviderService : SuspendingTileService() {
override fun onTileLeaveEvent(requestParams: EventBuilders.TileLeaveEvent) {
super.onTileLeaveEvent(requestParams)
- Timber.tag(TAG).d("$TAG: onTileLeaveEvent called with: tileId = ${requestParams.tileId}")
+ Logger.debug(TAG, "$TAG: onTileLeaveEvent called with: tileId = ${requestParams.tileId}")
isInFocus = false
}
- private val tileRenderer = DashboardTileRenderer(this)
-
override suspend fun tileRequest(requestParams: RequestBuilders.TileRequest): TileBuilders.Tile {
- Timber.tag(TAG).d("tileRequest: ${requestParams.currentState}")
+ Logger.debug(TAG, "tileRequest: ${requestParams.currentState}")
+ val startTime = SystemClock.elapsedRealtimeNanos()
isUpdating = true
tileMessenger.checkConnectionStatus()
@@ -113,38 +155,78 @@ class DashboardTileProviderService : SuspendingTileService() {
} else {
// Process action
runCatching {
- Timber.tag(TAG)
- .d("lastClickableId = ${requestParams.currentState.lastClickableId}")
+ Logger.debug(
+ TAG,
+ "lastClickableId = ${requestParams.currentState.lastClickableId}"
+ )
val action = Actions.valueOf(requestParams.currentState.lastClickableId)
+
+ val state = latestTileState()
+ val actionState = state.getAction(action)
+
withTimeoutOrNull(5000) {
AnalyticsLogger.logEvent("dashtile_action_clicked", Bundle().apply {
putString("action", action.name)
})
- tileMessenger.processActionAsync(action)
+ val ret = tileMessenger.processActionAsync(state, action)
+ Logger.debug(TAG, "requestPlayerActionAsync = $ret")
+ }
+
+ if (Action.getDefaultAction(action) !is NormalAction) {
+ // Try to await for action change
+ withTimeoutOrNull(5000) {
+ supervisorScope {
+ tileStateFlow.collectLatest { newState ->
+ if (newState?.getAction(action) != actionState) {
+ coroutineContext.cancel()
+ }
+ }
+ }
+ }
}
}
}
}
- if (tileModel.actionCount == 0) {
- tileModel.updateTileActions(Settings.getDashboardTileConfig() ?: DEFAULT_TILES)
- }
-
- tileModel.setShowBatteryStatus(Settings.isShowTileBatStatus())
isUpdating = false
+ val tileState = latestTileState()
- if (tileModel.tileState.value.isEmpty) {
+ if (tileState.isEmpty) {
AnalyticsLogger.logEvent("dashtile_state_empty", Bundle().apply {
putBoolean("isCoroutineActive", coroutineContext.isActive)
})
}
- return tileRenderer.renderTimeline(tileModel.tileState.value, requestParams)
+ val endTime = SystemClock.elapsedRealtimeNanos()
+ Logger.debug(TAG, "Duration - ${Duration.ofNanos(endTime - startTime)}")
+ Logger.debug(TAG, "Rendering timeline...")
+ return tileRenderer.renderTimeline(tileState, requestParams)
}
private suspend fun latestTileState(): DashboardTileState {
- return tileStateFlow.filterNotNull().first()
+ var tileState = tileStateFlow.filterNotNull().first()
+
+ if (tileState.isEmpty) {
+ Logger.debug(TAG, "No tile state available. loading from remote...")
+ tileMessenger.requestUpdate()
+
+ // Try to await for full metadata change
+ runCatching {
+ withTimeoutOrNull(5000) {
+ supervisorScope {
+ tileStateFlow.filterNotNull().collectLatest { newState ->
+ if (newState.actions.isNotEmpty() && newState.batteryStatus != null) {
+ tileState = newState
+ coroutineContext.cancel()
+ }
+ }
+ }
+ }
+ }
+ }
+
+ return tileState
}
override suspend fun resourcesRequest(requestParams: RequestBuilders.ResourcesRequest): ResourceBuilders.Resources {
diff --git a/wear/src/main/java/com/thewizrd/simplewear/wearable/tiles/DashboardTileRenderer.kt b/wear/src/main/java/com/thewizrd/simplewear/wearable/tiles/DashboardTileRenderer.kt
index 24397f6a..267474b0 100644
--- a/wear/src/main/java/com/thewizrd/simplewear/wearable/tiles/DashboardTileRenderer.kt
+++ b/wear/src/main/java/com/thewizrd/simplewear/wearable/tiles/DashboardTileRenderer.kt
@@ -21,18 +21,15 @@ import androidx.wear.protolayout.material.Typography
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.shared_resources.actions.Actions
+import com.google.android.horologist.tiles.render.SingleTileLayoutRendererWithState
+import com.thewizrd.shared_resources.utils.Logger
import com.thewizrd.simplewear.PhoneSyncActivity
import com.thewizrd.simplewear.R
import com.thewizrd.simplewear.wearable.tiles.layouts.DashboardTileLayout
-import com.thewizrd.simplewear.wearable.tiles.layouts.isActionEnabled
-import timber.log.Timber
-import kotlin.time.Duration.Companion.minutes
@OptIn(ExperimentalHorologistApi::class)
class DashboardTileRenderer(context: Context, debugResourceMode: Boolean = false) :
- SingleTileLayoutRenderer(context, debugResourceMode) {
+ SingleTileLayoutRendererWithState(context, debugResourceMode) {
companion object {
// Resource identifiers for images
internal const val ID_OPENONPHONE = "open_on_phone"
@@ -67,36 +64,16 @@ class DashboardTileRenderer(context: Context, debugResourceMode: Boolean = false
internal const val ID_BUTTON_DISABLED = "round_button_disabled"
}
- private var state: DashboardTileState? = null
-
- override val freshnessIntervalMillis: Long
- get() = if (DashboardTileProviderService.isInFocus) {
- 1.minutes.inWholeMilliseconds
- } else {
- 5.minutes.inWholeMilliseconds
- }
-
- override fun createState(): StateBuilders.State {
+ override fun createState(state: DashboardTileState): StateBuilders.State {
return StateBuilders.State.Builder()
.apply {
- state?.let {
- it.actions.forEach { (actionType, _) ->
- addKeyToValueMapping(
- AppDataKey(actionType.name),
- DynamicDataBuilders.DynamicDataValue.fromBool(
- it.isActionEnabled(
- actionType
- )
- )
+ state.actions.forEach { (actionType, _) ->
+ addKeyToValueMapping(
+ AppDataKey(actionType.name),
+ DynamicDataBuilders.DynamicDataValue.fromBool(
+ state.isActionEnabled(actionType)
)
- }
- } ?: run {
- Actions.entries.forEach {
- addKeyToValueMapping(
- AppDataKey(it.name),
- DynamicDataBuilders.DynamicDataValue.fromBool(true)
- )
- }
+ )
}
}
.build()
@@ -106,8 +83,6 @@ class DashboardTileRenderer(context: Context, debugResourceMode: Boolean = false
state: DashboardTileState,
deviceParameters: DeviceParametersBuilders.DeviceParameters
): LayoutElementBuilders.LayoutElement {
- this.state = state
-
return Box.Builder()
.setWidth(expand())
.setHeight(expand())
@@ -161,9 +136,9 @@ class DashboardTileRenderer(context: Context, debugResourceMode: Boolean = false
override fun ResourceBuilders.Resources.Builder.produceRequestedResources(
resourceState: Unit,
deviceParameters: DeviceParametersBuilders.DeviceParameters,
- resourceIds: MutableList
+ resourceIds: List
) {
- Timber.tag(this::class.java.name).d("produceRequestedResources")
+ Logger.debug(this::class.java.name, "produceRequestedResources: resIds = $resourceIds")
val resources = mapOf(
ID_OPENONPHONE to R.drawable.common_full_open_on_phone,
@@ -203,8 +178,6 @@ class DashboardTileRenderer(context: Context, debugResourceMode: Boolean = false
ID_BUTTON_DISABLED to R.drawable.round_button_disabled
)
- Timber.tag(this::class.java.name).e("res - resIds = $resourceIds")
-
(resourceIds.takeIf { it.isNotEmpty() } ?: resources.keys).forEach { key ->
resources[key]?.let { resId ->
addIdToImageMapping(key, drawableResToImageResource(resId))
diff --git a/wear/src/main/java/com/thewizrd/simplewear/wearable/tiles/DashboardTileState.kt b/wear/src/main/java/com/thewizrd/simplewear/wearable/tiles/DashboardTileState.kt
new file mode 100644
index 00000000..871d785c
--- /dev/null
+++ b/wear/src/main/java/com/thewizrd/simplewear/wearable/tiles/DashboardTileState.kt
@@ -0,0 +1,104 @@
+package com.thewizrd.simplewear.wearable.tiles
+
+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.DNDChoice
+import com.thewizrd.shared_resources.actions.LocationState
+import com.thewizrd.shared_resources.actions.MultiChoiceAction
+import com.thewizrd.shared_resources.actions.NormalAction
+import com.thewizrd.shared_resources.actions.RingerChoice
+import com.thewizrd.shared_resources.actions.ToggleAction
+import com.thewizrd.shared_resources.helpers.WearConnectionStatus
+
+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]
+
+ val isEmpty = batteryStatus == null || actions.isEmpty()
+
+ fun isActionEnabled(action: Actions): Boolean {
+ return when (action) {
+ Actions.WIFI, Actions.BLUETOOTH, Actions.MOBILEDATA, Actions.TORCH, Actions.HOTSPOT -> {
+ (getAction(action) as? ToggleAction)?.isEnabled == true
+ }
+
+ Actions.LOCATION -> {
+ val locationAction = getAction(action)
+
+ val locChoice = if (locationAction is ToggleAction) {
+ if (locationAction.isEnabled) LocationState.HIGH_ACCURACY else LocationState.OFF
+ } else if (locationAction is MultiChoiceAction) {
+ LocationState.valueOf(locationAction.choice)
+ } else {
+ LocationState.OFF
+ }
+
+ locChoice != LocationState.OFF
+ }
+
+ Actions.LOCKSCREEN -> true
+ Actions.DONOTDISTURB -> {
+ val dndAction = getAction(action)
+
+ val dndChoice = if (dndAction is ToggleAction) {
+ if (dndAction.isEnabled) DNDChoice.PRIORITY else DNDChoice.OFF
+ } else if (dndAction is MultiChoiceAction) {
+ DNDChoice.valueOf(dndAction.choice)
+ } else {
+ DNDChoice.OFF
+ }
+
+ dndChoice != DNDChoice.OFF
+ }
+
+ Actions.RINGER -> {
+ val ringerAction = getAction(action) as? MultiChoiceAction
+ val ringerChoice = ringerAction?.choice?.let {
+ RingerChoice.valueOf(it)
+ } ?: RingerChoice.VIBRATION
+
+ ringerChoice != RingerChoice.SILENT
+ }
+
+ else -> false
+ }
+ }
+
+ fun isNextActionEnabled(action: Actions): Boolean {
+ val actionState = getAction(action)
+
+ if (actionState == null) {
+ return when (action) {
+ // Normal actions
+ Actions.LOCKSCREEN -> true
+ // others
+ else -> false
+ }
+ } else {
+ return when (actionState) {
+ is ToggleAction -> {
+ !actionState.isEnabled
+ }
+
+ is MultiChoiceAction -> {
+ val newChoice = actionState.choice + 1
+ val ma = MultiChoiceAction(action, newChoice)
+ ma.choice > 0
+ }
+
+ is NormalAction -> {
+ true
+ }
+
+ else -> {
+ false
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/wear/src/main/java/com/thewizrd/simplewear/wearable/tiles/MediaPlayerTileMessenger.kt b/wear/src/main/java/com/thewizrd/simplewear/wearable/tiles/MediaPlayerTileMessenger.kt
index 8b439588..9166b445 100644
--- a/wear/src/main/java/com/thewizrd/simplewear/wearable/tiles/MediaPlayerTileMessenger.kt
+++ b/wear/src/main/java/com/thewizrd/simplewear/wearable/tiles/MediaPlayerTileMessenger.kt
@@ -6,10 +6,6 @@ import com.google.android.gms.common.api.ApiException
import com.google.android.gms.wearable.CapabilityClient
import com.google.android.gms.wearable.CapabilityInfo
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.Node
@@ -20,33 +16,36 @@ 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.helpers.WearableHelper.pickBestNodeId
-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.simplewear.datastore.media.mediaDataStore
import com.thewizrd.simplewear.wearable.tiles.MediaPlayerTileProviderService.Companion.requestTileUpdate
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.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.tasks.await
-import kotlinx.coroutines.withTimeoutOrNull
-import timber.log.Timber
import kotlin.coroutines.resume
-class MediaPlayerTileMessenger(private val context: Context) :
- MessageClient.OnMessageReceivedListener, DataClient.OnDataChangedListener,
- CapabilityClient.OnCapabilityChangedListener {
+class MediaPlayerTileMessenger(
+ private val context: Context,
+ private val isLegacyTile: Boolean = false
+) :
+ MessageClient.OnMessageReceivedListener, CapabilityClient.OnCapabilityChangedListener {
companion object {
private const val TAG = "MediaPlayerTileMessenger"
- internal val tileModel by lazy { MediaPlayerTileModel() }
}
enum class PlayerAction {
@@ -63,8 +62,12 @@ class MediaPlayerTileMessenger(private val context: Context) :
@Volatile
private var mPhoneNodeWithApp: Node? = null
- private var deleteJob: Job? = null
- private var updateJob: Job? = null
+ private val _connectionState = MutableStateFlow(WearConnectionStatus.DISCONNECTED)
+ val connectionState = _connectionState.stateIn(
+ scope,
+ SharingStarted.Eagerly,
+ _connectionState.value
+ )
fun register() {
Wearable.getCapabilityClient(context)
@@ -72,9 +75,6 @@ class MediaPlayerTileMessenger(private val context: Context) :
Wearable.getMessageClient(context)
.addListener(this)
-
- Wearable.getDataClient(context)
- .addListener(this)
}
fun unregister() {
@@ -84,66 +84,29 @@ class MediaPlayerTileMessenger(private val context: Context) :
Wearable.getMessageClient(context)
.removeListener(this)
- Wearable.getDataClient(context)
- .removeListener(this)
-
scope.cancel()
}
override fun onMessageReceived(messageEvent: MessageEvent) {
val data = messageEvent.data ?: return
- Timber.tag(TAG).d("message received - path: ${messageEvent.path}")
-
- scope.launch {
- when (messageEvent.path) {
- WearableHelper.AudioStatusPath,
- MediaHelper.MediaVolumeStatusPath -> {
- val status = data.let {
- JSONParser.deserializer(
- it.bytesToString(),
- AudioStreamState::class.java
- )
- }
- tileModel.setAudioStreamState(status)
+ when (messageEvent.path) {
+ WearableHelper.AudioStatusPath,
+ MediaHelper.MediaVolumeStatusPath -> {
+ Logger.debug(TAG, "message received - path: ${messageEvent.path}")
- requestTileUpdate(context)
+ val status = data.let {
+ JSONParser.deserializer(
+ it.bytesToString(),
+ AudioStreamState::class.java
+ )
}
- }
- }
- }
-
- override fun onDataChanged(dataEventBuffer: DataEventBuffer) {
- val event =
- dataEventBuffer.findLast { it.dataItem.uri.path == MediaHelper.MediaPlayerStatePath }
-
- if (event != null) {
- processDataEvent(event)
- }
- }
-
- private fun processDataEvent(event: DataEvent) {
- val item = event.dataItem
-
- if (event.type == DataEvent.TYPE_CHANGED) {
- Timber.tag(TAG).d("processDataEvent: data changed")
-
- deleteJob?.cancel()
- val dataMap = DataMapItem.fromDataItem(item).dataMap
- updateJob?.cancel()
- updateJob = scope.launch {
- updatePlayerState(dataMap)
- }
- } else if (event.type == DataEvent.TYPE_DELETED) {
- Timber.tag(TAG).d("processDataEvent: data deleted")
-
- deleteJob?.cancel()
- deleteJob = scope.launch delete@{
- delay(1000)
- if (!isActive) return@delete
-
- updatePlayerState(DataMap())
+ scope.launch {
+ context.mediaDataStore.updateData {
+ it.copy(audioStreamState = status)
+ }
+ }
}
}
}
@@ -155,14 +118,14 @@ class MediaPlayerTileMessenger(private val context: Context) :
mPhoneNodeWithApp?.let { node ->
if (node.isNearby && connectedNodes.any { it.id == node.id }) {
- tileModel.setConnectionStatus(WearConnectionStatus.CONNECTED)
+ _connectionState.update { WearConnectionStatus.CONNECTED }
} else {
try {
sendPing(node.id)
- tileModel.setConnectionStatus(WearConnectionStatus.CONNECTED)
+ _connectionState.update { WearConnectionStatus.CONNECTED }
} catch (e: ApiException) {
if (e.statusCode == WearableStatusCodes.TARGET_NODE_NOT_CONNECTED) {
- tileModel.setConnectionStatus(WearConnectionStatus.DISCONNECTED)
+ _connectionState.update { WearConnectionStatus.DISCONNECTED }
} else {
Logger.writeLine(Log.ERROR, e)
}
@@ -177,16 +140,18 @@ class MediaPlayerTileMessenger(private val context: Context) :
*
* Verify if we're connected to any nodes; if not, we're truly disconnected
*/
- tileModel.setConnectionStatus(
+ _connectionState.update {
if (connectedNodes.isEmpty()) {
WearConnectionStatus.DISCONNECTED
} else {
WearConnectionStatus.APPNOTINSTALLED
}
- )
+ }
}
- requestTileUpdate(context)
+ if (!isLegacyTile) {
+ requestTileUpdate(context)
+ }
}
}
@@ -214,6 +179,12 @@ class MediaPlayerTileMessenger(private val context: Context) :
suspend fun requestPlayerAction(action: PlayerAction) {
if (connect()) {
+ sendMessage(
+ mPhoneNodeWithApp!!.id,
+ MediaHelper.MediaPlayerConnectPath,
+ true.booleanToBytes()
+ )
+
when (action) {
PlayerAction.PLAY -> {
sendMessage(mPhoneNodeWithApp!!.id, MediaHelper.MediaPlayPath, null)
@@ -242,55 +213,78 @@ class MediaPlayerTileMessenger(private val context: Context) :
}
}
- private suspend fun updatePlayerState(dataMap: DataMap) {
- 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 {
- withTimeoutOrNull(5000) {
- ImageUtils.bitmapFromAssetStream(Wearable.getDataClient(context), it)
- }
- } catch (e: Exception) {
- null
- }
+ suspend fun requestUpdatePlayerState() {
+ if (connect()) {
+ sendMessage(mPhoneNodeWithApp!!.id, MediaHelper.MediaPlayerStatePath, null)
}
-
- tileModel.setPlayerState(title, artist, artBitmap, playbackState)
}
- fun updatePlayerState() {
- scope.launch(Dispatchers.IO) {
- updatePlayerStateAsync()
+ suspend fun requestPlayerAppInfo() {
+ if (connect()) {
+ sendMessage(mPhoneNodeWithApp!!.id, MediaHelper.MediaPlayerAppInfoPath, null)
}
}
- suspend fun updatePlayerStateAsync() {
- try {
- val buff = Wearable.getDataClient(context)
- .getDataItems(
- WearableHelper.getWearDataUri(
- "*",
- MediaHelper.MediaPlayerStatePath
- )
+ suspend fun updatePlayerStateFromRemote() {
+ val stateListenerJob = scope.async {
+ var complete = false
+
+ val listener = MessageClient.OnMessageReceivedListener { event ->
+ if (event.path == MediaHelper.MediaPlayerStatePath) {
+ this@MediaPlayerTileMessenger.onMessageReceived(event)
+ complete = true
+ }
+ }
+
+ Wearable.getMessageClient(context)
+ .addListener(
+ listener,
+ WearableHelper.getWearDataUri("*", MediaHelper.MediaPlayerStatePath),
+ DataClient.FILTER_LITERAL
)
.await()
- val item = buff.findLast { it.uri.path == MediaHelper.MediaPlayerStatePath }
+ while (isActive && !complete) {
+ delay(250)
+ }
+
+ Wearable.getMessageClient(context).removeListener(listener)
+ }
- if (item != null) {
- val dataMap = DataMapItem.fromDataItem(item).dataMap
- updatePlayerState(dataMap)
+ val artListenerJob = scope.async {
+ var complete = false
+
+ val listener = MessageClient.OnMessageReceivedListener { event ->
+ if (event.path == MediaHelper.MediaPlayerArtPath) {
+ this@MediaPlayerTileMessenger.onMessageReceived(event)
+ complete = true
+ }
}
- buff.release()
- } catch (e: Exception) {
- Logger.writeLine(Log.ERROR, e)
+ Wearable.getMessageClient(context)
+ .addListener(
+ listener,
+ WearableHelper.getWearDataUri("*", MediaHelper.MediaPlayerArtPath),
+ DataClient.FILTER_LITERAL
+ )
+ .await()
+
+ while (isActive && !complete) {
+ delay(250)
+ }
+
+ Wearable.getMessageClient(context).removeListener(listener)
+ }
+
+ val updateRequestJob by lazy {
+ scope.async {
+ requestUpdatePlayerState()
+ }
}
+
+ awaitAll(stateListenerJob, artListenerJob, updateRequestJob)
}
- @Suppress("IMPLICIT_CAST_TO_ANY")
suspend fun requestPlayerActionAsync(action: PlayerAction): Boolean =
suspendCancellableCoroutine { continuation ->
val listener = when (action) {
@@ -301,8 +295,7 @@ class MediaPlayerTileMessenger(private val context: Context) :
this@MediaPlayerTileMessenger.onMessageReceived(event)
if (continuation.isActive) {
continuation.resume(true)
- Wearable.getMessageClient(context)
- .removeListener(this)
+ Wearable.getMessageClient(context).removeListener(this)
}
}
}
@@ -310,17 +303,13 @@ class MediaPlayerTileMessenger(private val context: Context) :
}
else -> {
- object : DataClient.OnDataChangedListener {
- override fun onDataChanged(buffer: DataEventBuffer) {
- val event =
- buffer.findLast { it.dataItem.uri.path == MediaHelper.MediaPlayerStatePath }
-
- if (event != null) {
- processDataEvent(event)
+ object : MessageClient.OnMessageReceivedListener {
+ override fun onMessageReceived(event: MessageEvent) {
+ if (event.path == MediaHelper.MediaPlayerStatePath) {
+ this@MediaPlayerTileMessenger.onMessageReceived(event)
if (continuation.isActive) {
continuation.resume(true)
- Wearable.getDataClient(context)
- .removeListener(this)
+ Wearable.getMessageClient(context).removeListener(this)
}
}
}
@@ -329,33 +318,37 @@ class MediaPlayerTileMessenger(private val context: Context) :
}
continuation.invokeOnCancellation {
- if (listener is MessageClient.OnMessageReceivedListener) {
- Wearable.getMessageClient(context)
- .removeListener(listener)
- } else if (listener is DataClient.OnDataChangedListener) {
- Wearable.getDataClient(context)
- .removeListener(listener)
- }
+ Wearable.getMessageClient(context).removeListener(listener)
}
scope.launch {
- if (listener is MessageClient.OnMessageReceivedListener) {
- Wearable.getMessageClient(context)
- .addListener(
- listener,
- WearableHelper.getWearDataUri("*", MediaHelper.MediaVolumeStatusPath),
- DataClient.FILTER_LITERAL
- )
- .await()
- } else if (listener is DataClient.OnDataChangedListener) {
- Wearable.getDataClient(context)
- .addListener(
- listener,
- WearableHelper.getWearDataUri("*", MediaHelper.MediaPlayerStatePath),
- DataClient.FILTER_LITERAL
- )
- .await()
- }
+ Wearable.getMessageClient(context)
+ .run {
+ when (action) {
+ PlayerAction.VOL_UP, PlayerAction.VOL_DOWN -> {
+ addListener(
+ listener,
+ WearableHelper.getWearDataUri(
+ "*",
+ MediaHelper.MediaVolumeStatusPath
+ ),
+ DataClient.FILTER_LITERAL
+ )
+ }
+
+ else -> {
+ addListener(
+ listener,
+ WearableHelper.getWearDataUri(
+ "*",
+ MediaHelper.MediaPlayerStatePath
+ ),
+ DataClient.FILTER_LITERAL
+ )
+ }
+ }
+ }
+ .await()
requestPlayerAction(action)
}
@@ -367,20 +360,16 @@ class MediaPlayerTileMessenger(private val context: Context) :
mPhoneNodeWithApp?.let { node ->
if (node.isNearby && connectedNodes.any { it.id == node.id }) {
- tileModel.setConnectionStatus(WearConnectionStatus.CONNECTED)
+ _connectionState.update { WearConnectionStatus.CONNECTED }
} else {
try {
sendPing(node.id)
- tileModel.setConnectionStatus(
- WearConnectionStatus.CONNECTED
- )
+ _connectionState.update { WearConnectionStatus.CONNECTED }
} catch (e: ApiException) {
if (e.statusCode == WearableStatusCodes.TARGET_NODE_NOT_CONNECTED) {
- tileModel.setConnectionStatus(
- WearConnectionStatus.DISCONNECTED
- )
+ _connectionState.update { WearConnectionStatus.DISCONNECTED }
} else {
- Logger.writeLine(Log.ERROR, e)
+ Logger.error(TAG, e)
}
}
}
@@ -393,16 +382,16 @@ class MediaPlayerTileMessenger(private val context: Context) :
*
* Verify if we're connected to any nodes; if not, we're truly disconnected
*/
- tileModel.setConnectionStatus(
+ _connectionState.update {
if (connectedNodes.isEmpty()) {
WearConnectionStatus.DISCONNECTED
} else {
WearConnectionStatus.APPNOTINSTALLED
}
- )
+ }
}
- if (refreshTile) {
+ if (!isLegacyTile && refreshTile) {
requestTileUpdate(context)
}
}
@@ -419,7 +408,7 @@ class MediaPlayerTileMessenger(private val context: Context) :
.await()
node = pickBestNodeId(capabilityInfo.nodes)
} catch (e: Exception) {
- Logger.writeLine(Log.ERROR, e)
+ Logger.error(TAG, e)
}
return node
@@ -438,7 +427,7 @@ class MediaPlayerTileMessenger(private val context: Context) :
.connectedNodes
.await()
} catch (e: Exception) {
- Logger.writeLine(Log.ERROR, e)
+ Logger.error(TAG, e)
}
return emptyList()
@@ -453,16 +442,16 @@ class MediaPlayerTileMessenger(private val context: Context) :
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) {
- tileModel.setConnectionStatus(
- WearConnectionStatus.DISCONNECTED
- )
+ _connectionState.update { WearConnectionStatus.DISCONNECTED }
- requestTileUpdate(context)
+ if (!isLegacyTile) {
+ requestTileUpdate(context)
+ }
return
}
}
- Logger.writeLine(Log.ERROR, e)
+ Logger.error(TAG, e)
}
}
@@ -476,7 +465,7 @@ class MediaPlayerTileMessenger(private val context: Context) :
val apiException = e.cause as? ApiException ?: e as ApiException
throw apiException
}
- Logger.writeLine(Log.ERROR, e)
+ Logger.error(TAG, e)
}
}
}
\ No newline at end of file
diff --git a/wear/src/main/java/com/thewizrd/simplewear/wearable/tiles/MediaPlayerTileModel.kt b/wear/src/main/java/com/thewizrd/simplewear/wearable/tiles/MediaPlayerTileModel.kt
deleted file mode 100644
index 6f1c960e..00000000
--- a/wear/src/main/java/com/thewizrd/simplewear/wearable/tiles/MediaPlayerTileModel.kt
+++ /dev/null
@@ -1,70 +0,0 @@
-package com.thewizrd.simplewear.wearable.tiles
-
-import android.graphics.Bitmap
-import com.thewizrd.shared_resources.actions.AudioStreamState
-import com.thewizrd.shared_resources.helpers.WearConnectionStatus
-import com.thewizrd.shared_resources.media.PlaybackState
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.flow.update
-
-class MediaPlayerTileModel {
- private var mConnectionStatus = WearConnectionStatus.DISCONNECTED
-
- private val _tileState =
- MutableStateFlow(
- MediaPlayerTileState(mConnectionStatus, null, null, null, null, null)
- )
-
- val tileState: StateFlow
- get() = _tileState.asStateFlow()
-
- fun setConnectionStatus(status: WearConnectionStatus) {
- mConnectionStatus = status
- _tileState.update {
- it.copy(connectionStatus = status)
- }
- }
-
- fun setPlayerState(
- title: String? = null,
- artist: String? = null,
- artwork: Bitmap? = null,
- playbackState: PlaybackState = PlaybackState.NONE
- ) {
- _tileState.update {
- it.copy(
- title = title,
- artist = artist,
- artwork = artwork,
- playbackState = playbackState
- )
- }
- }
-
- fun updateArtwork(artwork: Bitmap? = null) {
- _tileState.update {
- it.copy(artwork = artwork)
- }
- }
-
- fun setAudioStreamState(audioStreamState: AudioStreamState? = null) {
- _tileState.update {
- it.copy(audioStreamState = audioStreamState)
- }
- }
-}
-
-data class MediaPlayerTileState(
- val connectionStatus: WearConnectionStatus,
-
- val title: String?,
- val artist: String?,
- val artwork: Bitmap?,
- val playbackState: PlaybackState? = null,
-
- val audioStreamState: AudioStreamState?
-) {
- val isEmpty = audioStreamState == null || playbackState == null
-}
\ No newline at end of file
diff --git a/wear/src/main/java/com/thewizrd/simplewear/wearable/tiles/MediaPlayerTileProviderService.kt b/wear/src/main/java/com/thewizrd/simplewear/wearable/tiles/MediaPlayerTileProviderService.kt
index 459ba6d9..e8fd85ac 100644
--- a/wear/src/main/java/com/thewizrd/simplewear/wearable/tiles/MediaPlayerTileProviderService.kt
+++ b/wear/src/main/java/com/thewizrd/simplewear/wearable/tiles/MediaPlayerTileProviderService.kt
@@ -3,6 +3,7 @@ package com.thewizrd.simplewear.wearable.tiles
import android.content.Context
import android.content.Intent
import android.os.Bundle
+import android.os.SystemClock
import androidx.lifecycle.lifecycleScope
import androidx.wear.protolayout.ResourceBuilders
import androidx.wear.tiles.EventBuilders
@@ -10,22 +11,32 @@ import androidx.wear.tiles.RequestBuilders
import androidx.wear.tiles.TileBuilders
import com.google.android.horologist.annotations.ExperimentalHorologistApi
import com.google.android.horologist.tiles.SuspendingTileService
+import com.thewizrd.shared_resources.appLib
import com.thewizrd.shared_resources.utils.AnalyticsLogger
+import com.thewizrd.shared_resources.utils.Logger
import com.thewizrd.simplewear.PhoneSyncActivity
-import com.thewizrd.simplewear.wearable.tiles.MediaPlayerTileMessenger.Companion.tileModel
+import com.thewizrd.simplewear.datastore.media.appInfoDataStore
+import com.thewizrd.simplewear.datastore.media.artworkDataStore
+import com.thewizrd.simplewear.datastore.media.mediaDataStore
import com.thewizrd.simplewear.wearable.tiles.MediaPlayerTileMessenger.PlayerAction
import com.thewizrd.simplewear.wearable.tiles.MediaPlayerTileRenderer.Companion.ID_OPENONPHONE
import com.thewizrd.simplewear.wearable.tiles.MediaPlayerTileRenderer.Companion.ID_PHONEDISCONNECTED
-import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
+import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.withTimeoutOrNull
-import timber.log.Timber
+import java.time.Duration
+import kotlin.coroutines.cancellation.CancellationException
import kotlin.coroutines.coroutineContext
@OptIn(ExperimentalHorologistApi::class)
@@ -34,34 +45,70 @@ class MediaPlayerTileProviderService : SuspendingTileService() {
private const val TAG = "MediaPlayerTileProviderService"
fun requestTileUpdate(context: Context) {
- Timber.tag(TAG).d("$TAG: requesting tile update")
- getUpdater(context).requestUpdate(MediaPlayerTileProviderService::class.java)
+ updateJob?.cancel()
+
+ // Defer update to prevent spam
+ updateJob = appLib.appScope.launch {
+ delay(1000)
+ if (isActive) {
+ Logger.debug(TAG, "requesting tile update")
+ getUpdater(context).requestUpdate(MediaPlayerTileProviderService::class.java)
+ }
+ }
}
+ @JvmStatic
+ @Volatile
var isInFocus: Boolean = false
private set
+
+ @JvmStatic
+ @Volatile
+ var isUpdating: Boolean = false
+ private set
+
+ private var updateJob: Job? = null
}
- private val tileMessenger = MediaPlayerTileMessenger(this)
+ private lateinit var tileMessenger: MediaPlayerTileMessenger
private lateinit var tileStateFlow: StateFlow
-
- private var isUpdating = false
+ private lateinit var tileRenderer: MediaPlayerTileRenderer
override fun onCreate() {
super.onCreate()
- Timber.tag(TAG).d("creating service...")
+ Logger.debug(TAG, "creating service...")
+
+ tileMessenger = MediaPlayerTileMessenger(this)
+ tileRenderer = MediaPlayerTileRenderer(this)
tileMessenger.register()
- tileStateFlow = tileModel.tileState
+ tileStateFlow = combine(
+ this.mediaDataStore.data,
+ this.artworkDataStore.data,
+ this.appInfoDataStore.data,
+ tileMessenger.connectionState
+ ) { mediaCache, artwork, appInfo, connectionStatus ->
+ MediaPlayerTileState(
+ connectionStatus = connectionStatus,
+ title = mediaCache.mediaPlayerState?.mediaMetaData?.title,
+ artist = mediaCache.mediaPlayerState?.mediaMetaData?.artist,
+ artwork = artwork,
+ playbackState = mediaCache.mediaPlayerState?.playbackState,
+ positionState = mediaCache.mediaPlayerState?.mediaMetaData?.positionState,
+ audioStreamState = mediaCache.audioStreamState,
+ appIcon = appInfo.iconBitmap
+ )
+ }
.stateIn(
lifecycleScope,
started = SharingStarted.WhileSubscribed(2000),
- null
+ initialValue = null
)
}
override fun onDestroy() {
- Timber.tag(TAG).d("destroying service...")
+ isUpdating = false
+ Logger.debug(TAG, "destroying service...")
tileMessenger.unregister()
super.onDestroy()
}
@@ -69,17 +116,18 @@ class MediaPlayerTileProviderService : SuspendingTileService() {
override fun onTileEnterEvent(requestParams: EventBuilders.TileEnterEvent) {
super.onTileEnterEvent(requestParams)
- Timber.tag(TAG).d("$TAG: onTileEnterEvent called with: tileId = ${requestParams.tileId}")
+ Logger.debug(TAG, "onTileEnterEvent called with: tileId = ${requestParams.tileId}")
AnalyticsLogger.logEvent("on_tile_enter", Bundle().apply {
putString("tile", TAG)
})
isInFocus = true
- lifecycleScope.launch {
+ appLib.appScope.launch {
tileMessenger.checkConnectionStatus()
tileMessenger.requestPlayerConnect()
tileMessenger.requestVolumeStatus()
- tileMessenger.updatePlayerStateAsync()
+ tileMessenger.requestUpdatePlayerState()
+ tileMessenger.requestPlayerAppInfo()
}.invokeOnCompletion {
if (it is CancellationException || !isUpdating) {
// If update timed out
@@ -90,18 +138,17 @@ class MediaPlayerTileProviderService : SuspendingTileService() {
override fun onTileLeaveEvent(requestParams: EventBuilders.TileLeaveEvent) {
super.onTileLeaveEvent(requestParams)
- Timber.tag(TAG).d("$TAG: onTileLeaveEvent called with: tileId = ${requestParams.tileId}")
+ Logger.debug(TAG, "onTileLeaveEvent called with: tileId = ${requestParams.tileId}")
isInFocus = false
- lifecycleScope.launch {
+ appLib.appScope.launch {
tileMessenger.requestPlayerDisconnect()
}
}
- private val tileRenderer = MediaPlayerTileRenderer(this)
-
override suspend fun tileRequest(requestParams: RequestBuilders.TileRequest): TileBuilders.Tile {
- Timber.tag(TAG).d("tileRequest: ${requestParams.currentState}")
+ Logger.debug(TAG, "tileRequest: ${requestParams.currentState}")
+ val startTime = SystemClock.elapsedRealtimeNanos()
isUpdating = true
tileMessenger.checkConnectionStatus()
@@ -114,25 +161,42 @@ class MediaPlayerTileProviderService : SuspendingTileService() {
} else {
// Process action
runCatching {
- Timber.tag(TAG)
- .d("lastClickableId = ${requestParams.currentState.lastClickableId}")
+ Logger.debug(
+ TAG,
+ "lastClickableId = ${requestParams.currentState.lastClickableId}"
+ )
val action = PlayerAction.valueOf(requestParams.currentState.lastClickableId)
+
+ val state = latestTileState()
+
withTimeoutOrNull(5000) {
val ret = tileMessenger.requestPlayerActionAsync(action)
- Timber.tag(TAG).d("requestPlayerActionAsync = $ret")
- tileMessenger.updatePlayerStateAsync()
+ Logger.debug(TAG, "requestPlayerActionAsync = $ret")
+ }
+
+ // Try to await for full metadata change
+ withTimeoutOrNull(5000) {
+ supervisorScope {
+ var songChanged = false
+ tileStateFlow.collectLatest { newState ->
+ if (!songChanged && newState?.title != state.title && newState?.artist != state.artist) {
+ // new song; wait for artwork
+ songChanged = true
+ } else if (songChanged && !newState?.artwork.contentEquals(state.artwork)) {
+ coroutineContext.cancel()
+ } else if (newState?.playbackState != state.playbackState) {
+ // only playstate change
+ coroutineContext.cancel()
+ }
+ }
+ }
}
}
}
- } else {
- withTimeoutOrNull(5000) {
- tileMessenger.updatePlayerStateAsync()
- }
}
- val tileState = tileModel.tileState.value
- Timber.tag(TAG).d("State: ${tileState.title} - ${tileState.artist}")
isUpdating = false
+ val tileState = latestTileState()
if (tileState.isEmpty) {
AnalyticsLogger.logEvent("mediatile_state_empty", Bundle().apply {
@@ -140,11 +204,42 @@ class MediaPlayerTileProviderService : SuspendingTileService() {
})
}
+ val endTime = SystemClock.elapsedRealtimeNanos()
+ Logger.debug(TAG, "Current State - ${tileState.title}:${tileState.artist}")
+ Logger.debug(TAG, "Duration - ${Duration.ofNanos(endTime - startTime)}")
+ Logger.debug(TAG, "Rendering timeline...")
return tileRenderer.renderTimeline(tileState, requestParams)
}
private suspend fun latestTileState(): MediaPlayerTileState {
- return tileStateFlow.filterNotNull().first()
+ var tileState = tileStateFlow.filterNotNull().first()
+
+ if (tileState.isEmpty) {
+ Logger.debug(TAG, "No tile state available. loading from remote...")
+ tileMessenger.updatePlayerStateFromRemote()
+
+ // Try to await for full metadata change
+ runCatching {
+ withTimeoutOrNull(5000) {
+ supervisorScope {
+ var songChanged = false
+
+ tileStateFlow.filterNotNull().collectLatest { newState ->
+ if (!songChanged && newState.title != tileState.title && newState.artist != tileState.artist) {
+ // new song; wait for artwork
+ tileState = newState
+ songChanged = true
+ } else if (songChanged && !newState.artwork.contentEquals(tileState.artwork)) {
+ tileState = newState
+ coroutineContext.cancel()
+ }
+ }
+ }
+ }
+ }
+ }
+
+ return tileState
}
override suspend fun resourcesRequest(requestParams: RequestBuilders.ResourcesRequest): ResourceBuilders.Resources {
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 6c7619a4..90bb8558 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
@@ -2,11 +2,7 @@ package com.thewizrd.simplewear.wearable.tiles
import android.content.ComponentName
import android.content.Context
-import android.graphics.Bitmap
-import android.os.Build
-import androidx.core.content.ContextCompat
import androidx.wear.protolayout.ActionBuilders
-import androidx.wear.protolayout.ColorBuilders
import androidx.wear.protolayout.DeviceParametersBuilders
import androidx.wear.protolayout.DimensionBuilders.expand
import androidx.wear.protolayout.LayoutElementBuilders
@@ -17,22 +13,20 @@ import androidx.wear.protolayout.ResourceBuilders
import androidx.wear.protolayout.ResourceBuilders.IMAGE_FORMAT_UNDEFINED
import androidx.wear.protolayout.ResourceBuilders.ImageResource
import androidx.wear.protolayout.ResourceBuilders.InlineImageResource
-import androidx.wear.protolayout.material.CompactChip
-import androidx.wear.protolayout.material.Text
-import androidx.wear.protolayout.material.Typography
-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.google.android.horologist.tiles.render.SingleTileLayoutRendererWithState
+import com.thewizrd.shared_resources.media.PlaybackState
+import com.thewizrd.shared_resources.utils.ContextUtils.dpToPx
+import com.thewizrd.shared_resources.utils.Logger
import com.thewizrd.simplewear.BuildConfig
import com.thewizrd.simplewear.R
import com.thewizrd.simplewear.wearable.tiles.layouts.MediaPlayerTileLayout
-import timber.log.Timber
-import java.io.ByteArrayOutputStream
+import kotlin.math.min
@OptIn(ExperimentalHorologistApi::class)
class MediaPlayerTileRenderer(context: Context, debugResourceMode: Boolean = false) :
- SingleTileLayoutRenderer(
+ SingleTileLayoutRendererWithState(
context,
debugResourceMode
) {
@@ -48,16 +42,13 @@ class MediaPlayerTileRenderer(context: Context, debugResourceMode: Boolean = fal
internal const val ID_SKIP = "skip"
internal const val ID_VOL_UP = "vol_up"
internal const val ID_VOL_DOWN = "vol_down"
+ internal const val ID_APPICON = "app_icon"
}
- private var state: MediaPlayerTileState? = null
-
override fun renderTile(
state: MediaPlayerTileState,
deviceParameters: DeviceParametersBuilders.DeviceParameters
): LayoutElementBuilders.LayoutElement {
- this.state = state
-
return Box.Builder()
.setWidth(expand())
.setHeight(expand())
@@ -73,37 +64,7 @@ class MediaPlayerTileRenderer(context: Context, debugResourceMode: Boolean = fal
.build()
)
.addContent(
- if (state.isEmpty) {
- PrimaryLayout.Builder(deviceParameters)
- .setContent(
- Text.Builder(context, context.getString(R.string.state_loading))
- .setTypography(Typography.TYPOGRAPHY_CAPTION1)
- .setColor(
- ColorBuilders.argb(
- ContextCompat.getColor(context, R.color.colorSecondary)
- )
- )
- .setMultilineAlignment(LayoutElementBuilders.TEXT_ALIGN_CENTER)
- .setMaxLines(1)
- .build()
- )
- .setPrimaryChipContent(
- CompactChip.Builder(
- context,
- context.getString(R.string.action_refresh),
- Clickable.Builder()
- .setOnClick(
- ActionBuilders.LoadAction.Builder().build()
- )
- .build(),
- deviceParameters
- )
- .build()
- )
- .build()
- } else {
- MediaPlayerTileLayout(context, deviceParameters, state)
- }
+ MediaPlayerTileLayout(context, deviceParameters, state)
)
.build()
}
@@ -111,9 +72,9 @@ class MediaPlayerTileRenderer(context: Context, debugResourceMode: Boolean = fal
override fun ResourceBuilders.Resources.Builder.produceRequestedResources(
resourceState: MediaPlayerTileState,
deviceParameters: DeviceParametersBuilders.DeviceParameters,
- resourceIds: MutableList
+ resourceIds: List
) {
- Timber.tag(this::class.java.name).d("produceRequestedResources")
+ Logger.debug(this::class.java.name, "produceRequestedResources: resIds = $resourceIds")
val resources = mapOf(
ID_OPENONPHONE to R.drawable.common_full_open_on_phone,
@@ -128,45 +89,64 @@ class MediaPlayerTileRenderer(context: Context, debugResourceMode: Boolean = fal
ID_VOL_DOWN to R.drawable.ic_baseline_volume_down_24
)
- Timber.tag(this::class.java.name).e("res - resIds = $resourceIds")
-
(resourceIds.takeIf { it.isNotEmpty() } ?: resources.keys).forEach { key ->
resources[key]?.let { resId ->
addIdToImageMapping(key, drawableResToImageResource(resId))
}
}
- state?.artwork?.let { bitmap ->
+ resourceState.artwork?.let { bitmap ->
if (resourceIds.isEmpty() || resourceIds.contains(ID_ARTWORK)) {
addIdToImageMapping(
ID_ARTWORK,
- bitmap.run {
- val buffer = ByteArrayOutputStream().apply {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
- compress(Bitmap.CompressFormat.WEBP_LOSSY, 0, this)
- } else {
- compress(Bitmap.CompressFormat.JPEG, 0, this)
- }
- }.toByteArray()
+ ImageResource.Builder()
+ .setInlineResource(
+ InlineImageResource.Builder()
+ .setData(bitmap)
+ .setWidthPx(300)
+ .setHeightPx(300)
+ .setFormat(IMAGE_FORMAT_UNDEFINED)
+ .build()
+ )
+ .build()
+ )
+ }
+ }
- ImageResource.Builder()
- .setInlineResource(
- InlineImageResource.Builder()
- .setData(buffer)
- .setWidthPx(width)
- .setHeightPx(height)
- .setFormat(IMAGE_FORMAT_UNDEFINED)
- .build()
- )
- .build()
- }
+ resourceState.appIcon?.let { bitmap ->
+ if (resourceIds.isEmpty() || resourceIds.contains(ID_APPICON)) {
+ val size = context.dpToPx(24f).toInt()
+
+ addIdToImageMapping(
+ ID_APPICON,
+ ImageResource.Builder()
+ .setInlineResource(
+ InlineImageResource.Builder()
+ .setData(bitmap)
+ .setWidthPx(size)
+ .setHeightPx(size)
+ .setFormat(IMAGE_FORMAT_UNDEFINED)
+ .build()
+ )
+ .build()
)
}
}
}
override fun getResourcesVersionForTileState(state: MediaPlayerTileState): String {
- return "${state.title}:${state.artist}"
+ return "${state.title}:${state.artist}:${state.artwork?.size}"
+ }
+
+ override fun getFreshnessIntervalMillis(state: MediaPlayerTileState): Long {
+ return if (state.playbackState == PlaybackState.PLAYING && state.positionState != null) {
+ val elapsedTime = System.currentTimeMillis() - state.positionState.currentTimeMs
+ val estimatedPosition =
+ (state.positionState.currentPositionMs + (elapsedTime * state.positionState.playbackSpeed)).toLong()
+ state.positionState.durationMs - min(estimatedPosition, state.positionState.durationMs)
+ } else {
+ super.getFreshnessIntervalMillis(state)
+ }
}
private fun getTapAction(context: Context): ActionBuilders.Action {
diff --git a/wear/src/main/java/com/thewizrd/simplewear/wearable/tiles/MediaPlayerTileState.kt b/wear/src/main/java/com/thewizrd/simplewear/wearable/tiles/MediaPlayerTileState.kt
new file mode 100644
index 00000000..25dadb78
--- /dev/null
+++ b/wear/src/main/java/com/thewizrd/simplewear/wearable/tiles/MediaPlayerTileState.kt
@@ -0,0 +1,57 @@
+package com.thewizrd.simplewear.wearable.tiles
+
+import com.thewizrd.shared_resources.actions.AudioStreamState
+import com.thewizrd.shared_resources.helpers.WearConnectionStatus
+import com.thewizrd.shared_resources.media.PlaybackState
+import com.thewizrd.shared_resources.media.PositionState
+
+data class MediaPlayerTileState(
+ val connectionStatus: WearConnectionStatus,
+
+ val title: String?,
+ val artist: String?,
+ val artwork: ByteArray?,
+ val playbackState: PlaybackState? = null,
+ val positionState: PositionState? = null,
+
+ val audioStreamState: AudioStreamState?,
+
+ val appIcon: ByteArray? = null
+) {
+ val isEmpty = playbackState == null
+ val key = "$playbackState|$title|$artist"
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is MediaPlayerTileState) return false
+
+ if (connectionStatus != other.connectionStatus) return false
+ if (title != other.title) return false
+ if (artist != other.artist) return false
+ if (artwork != null) {
+ if (other.artwork == null) return false
+ if (!artwork.contentEquals(other.artwork)) return false
+ } else if (other.artwork != null) return false
+ if (playbackState != other.playbackState) return false
+ if (positionState != other.positionState) return false
+ if (audioStreamState != other.audioStreamState) return false
+ if (appIcon != null) {
+ if (other.appIcon == null) return false
+ if (!appIcon.contentEquals(other.appIcon)) return false
+ } else if (other.appIcon != null) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = connectionStatus.hashCode()
+ result = 31 * result + (title?.hashCode() ?: 0)
+ result = 31 * result + (artist?.hashCode() ?: 0)
+ result = 31 * result + (artwork?.contentHashCode() ?: 0)
+ result = 31 * result + (playbackState?.hashCode() ?: 0)
+ result = 31 * result + (positionState?.hashCode() ?: 0)
+ result = 31 * result + (audioStreamState?.hashCode() ?: 0)
+ result = 31 * result + (appIcon?.contentHashCode() ?: 0)
+ return result
+ }
+}
\ No newline at end of file
diff --git a/wear/src/main/java/com/thewizrd/simplewear/wearable/tiles/TilePreviews.kt b/wear/src/main/java/com/thewizrd/simplewear/wearable/tiles/TilePreviews.kt
index 75ea06ca..9dd1fbf3 100644
--- a/wear/src/main/java/com/thewizrd/simplewear/wearable/tiles/TilePreviews.kt
+++ b/wear/src/main/java/com/thewizrd/simplewear/wearable/tiles/TilePreviews.kt
@@ -20,7 +20,10 @@ import com.thewizrd.shared_resources.actions.RingerChoice
import com.thewizrd.shared_resources.actions.ToggleAction
import com.thewizrd.shared_resources.helpers.WearConnectionStatus
import com.thewizrd.shared_resources.media.PlaybackState
+import com.thewizrd.shared_resources.media.PositionState
+import com.thewizrd.shared_resources.utils.ImageUtils.toByteArray
import com.thewizrd.simplewear.R
+import kotlinx.coroutines.runBlocking
@OptIn(ExperimentalHorologistApi::class)
@WearPreviewDevices
@@ -159,7 +162,16 @@ fun MediaPlayerTilePreview() {
artist = "Artist",
playbackState = PlaybackState.PAUSED,
audioStreamState = AudioStreamState(3, 0, 5, AudioStreamType.MUSIC),
- artwork = ContextCompat.getDrawable(context, R.drawable.ws_full_sad)?.toBitmapOrNull()
+ positionState = PositionState(100, 50),
+ artwork = runBlocking {
+ ContextCompat.getDrawable(context, R.drawable.ws_full_sad)?.toBitmapOrNull()
+ ?.toByteArray()
+ },
+ appIcon = runBlocking {
+ ContextCompat.getDrawable(context, R.drawable.ic_play_circle_simpleblue)
+ ?.toBitmapOrNull()
+ ?.toByteArray()
+ }
)
}
val renderer = remember {
@@ -211,7 +223,12 @@ fun MediaPlayerNotPlayingTilePreview() {
artist = null,
playbackState = PlaybackState.NONE,
audioStreamState = AudioStreamState(3, 0, 5, AudioStreamType.MUSIC),
- artwork = null
+ artwork = null,
+ appIcon = runBlocking {
+ ContextCompat.getDrawable(context, R.drawable.ic_play_circle_simpleblue)
+ ?.toBitmapOrNull()
+ ?.toByteArray()
+ }
)
}
val renderer = remember {
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 8d0f2355..74210b39 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
@@ -41,7 +41,6 @@ import com.thewizrd.shared_resources.actions.Actions
import com.thewizrd.shared_resources.actions.DNDChoice
import com.thewizrd.shared_resources.actions.LocationState
import com.thewizrd.shared_resources.actions.MultiChoiceAction
-import com.thewizrd.shared_resources.actions.NormalAction
import com.thewizrd.shared_resources.actions.RingerChoice
import com.thewizrd.shared_resources.actions.ToggleAction
import com.thewizrd.shared_resources.helpers.WearConnectionStatus
@@ -247,9 +246,7 @@ private fun ActionButton(
.addKeyToValueMapping(
AppDataKey(action.name),
DynamicDataBuilders.DynamicDataValue.fromBool(
- state.isNextActionEnabled(
- action
- )
+ state.isNextActionEnabled(action)
)
)
.build()
@@ -373,85 +370,4 @@ private fun getResourceIdForAction(state: DashboardTileState, action: Actions):
Actions.HOTSPOT -> ID_HOTSPOT
else -> ""
}
-}
-
-fun DashboardTileState.isActionEnabled(action: Actions): Boolean {
- return when (action) {
- Actions.WIFI, Actions.BLUETOOTH, Actions.MOBILEDATA, Actions.TORCH, Actions.HOTSPOT -> {
- (getAction(action) as? ToggleAction)?.isEnabled == true
- }
-
- Actions.LOCATION -> {
- val locationAction = getAction(action)
-
- val locChoice = if (locationAction is ToggleAction) {
- if (locationAction.isEnabled) LocationState.HIGH_ACCURACY else LocationState.OFF
- } else if (locationAction is MultiChoiceAction) {
- LocationState.valueOf(locationAction.choice)
- } else {
- LocationState.OFF
- }
-
- locChoice != LocationState.OFF
- }
-
- Actions.LOCKSCREEN -> true
- Actions.DONOTDISTURB -> {
- val dndAction = getAction(action)
-
- val dndChoice = if (dndAction is ToggleAction) {
- if (dndAction.isEnabled) DNDChoice.PRIORITY else DNDChoice.OFF
- } else if (dndAction is MultiChoiceAction) {
- DNDChoice.valueOf(dndAction.choice)
- } else {
- DNDChoice.OFF
- }
-
- dndChoice != DNDChoice.OFF
- }
-
- Actions.RINGER -> {
- val ringerAction = getAction(action) as? MultiChoiceAction
- val ringerChoice = ringerAction?.choice?.let {
- RingerChoice.valueOf(it)
- } ?: RingerChoice.VIBRATION
-
- ringerChoice != RingerChoice.SILENT
- }
-
- else -> false
- }
-}
-
-fun DashboardTileState.isNextActionEnabled(action: Actions): Boolean {
- val actionState = getAction(action)
-
- if (actionState == null) {
- return when (action) {
- // Normal actions
- Actions.LOCKSCREEN -> true
- // others
- else -> false
- }
- } else {
- return when (actionState) {
- is ToggleAction -> {
- !actionState.isEnabled
- }
-
- is MultiChoiceAction -> {
- val newChoice = actionState.choice + 1
- val ma = MultiChoiceAction(action, newChoice)
- ma.choice > 0
- }
-
- is NormalAction -> {
- true
- }
-
- else -> {
- false
- }
- }
- }
}
\ No newline at end of file
diff --git a/wear/src/main/java/com/thewizrd/simplewear/wearable/tiles/layouts/MediaPlayerTileLayout.kt b/wear/src/main/java/com/thewizrd/simplewear/wearable/tiles/layouts/MediaPlayerTileLayout.kt
index ffee67dc..d47d14d8 100644
--- a/wear/src/main/java/com/thewizrd/simplewear/wearable/tiles/layouts/MediaPlayerTileLayout.kt
+++ b/wear/src/main/java/com/thewizrd/simplewear/wearable/tiles/layouts/MediaPlayerTileLayout.kt
@@ -4,6 +4,7 @@ import android.content.Context
import android.graphics.Color
import androidx.annotation.OptIn
import androidx.core.content.ContextCompat
+import androidx.core.graphics.drawable.toBitmapOrNull
import androidx.wear.protolayout.ActionBuilders
import androidx.wear.protolayout.ColorBuilders
import androidx.wear.protolayout.ColorBuilders.ColorProp
@@ -29,19 +30,31 @@ import androidx.wear.protolayout.ModifiersBuilders.Clickable
import androidx.wear.protolayout.ModifiersBuilders.Corner
import androidx.wear.protolayout.ModifiersBuilders.Modifiers
import androidx.wear.protolayout.ModifiersBuilders.Padding
+import androidx.wear.protolayout.TypeBuilders.FloatProp
+import androidx.wear.protolayout.expression.DynamicBuilders.DynamicFloat
+import androidx.wear.protolayout.expression.DynamicBuilders.DynamicInstant
import androidx.wear.protolayout.expression.ProtoLayoutExperimental
import androidx.wear.protolayout.material.Button
import androidx.wear.protolayout.material.ButtonColors
+import androidx.wear.protolayout.material.CircularProgressIndicator
import androidx.wear.protolayout.material.Colors
import androidx.wear.protolayout.material.CompactChip
+import androidx.wear.protolayout.material.ProgressIndicatorColors
import androidx.wear.protolayout.material.Text
import androidx.wear.protolayout.material.Typography
import androidx.wear.protolayout.material.layouts.MultiSlotLayout
import androidx.wear.protolayout.material.layouts.PrimaryLayout
+import androidx.wear.tiles.tooling.preview.TilePreviewData
+import com.thewizrd.shared_resources.actions.AudioStreamState
+import com.thewizrd.shared_resources.actions.AudioStreamType
import com.thewizrd.shared_resources.helpers.WearConnectionStatus
import com.thewizrd.shared_resources.media.PlaybackState
+import com.thewizrd.shared_resources.utils.ImageUtils.toByteArray
import com.thewizrd.simplewear.R
+import com.thewizrd.simplewear.ui.tools.WearTilePreviewDevices
import com.thewizrd.simplewear.wearable.tiles.MediaPlayerTileMessenger.PlayerAction
+import com.thewizrd.simplewear.wearable.tiles.MediaPlayerTileRenderer
+import com.thewizrd.simplewear.wearable.tiles.MediaPlayerTileRenderer.Companion.ID_APPICON
import com.thewizrd.simplewear.wearable.tiles.MediaPlayerTileRenderer.Companion.ID_ARTWORK
import com.thewizrd.simplewear.wearable.tiles.MediaPlayerTileRenderer.Companion.ID_OPENONPHONE
import com.thewizrd.simplewear.wearable.tiles.MediaPlayerTileRenderer.Companion.ID_PAUSE
@@ -52,6 +65,8 @@ import com.thewizrd.simplewear.wearable.tiles.MediaPlayerTileRenderer.Companion.
import com.thewizrd.simplewear.wearable.tiles.MediaPlayerTileRenderer.Companion.ID_VOL_DOWN
import com.thewizrd.simplewear.wearable.tiles.MediaPlayerTileRenderer.Companion.ID_VOL_UP
import com.thewizrd.simplewear.wearable.tiles.MediaPlayerTileState
+import kotlinx.coroutines.runBlocking
+import java.time.Instant
private val CIRCLE_SIZE = dp(48f)
private val SMALL_CIRCLE_SIZE = dp(40f)
@@ -153,7 +168,7 @@ internal fun MediaPlayerTileLayout(
context,
context.getString(R.string.action_play),
Clickable.Builder()
- .setId(ID_PLAY)
+ .setId(PlayerAction.PLAY.name)
.setOnClick(
ActionBuilders.LoadAction.Builder()
.build()
@@ -168,18 +183,14 @@ internal fun MediaPlayerTileLayout(
return Box.Builder()
.setWidth(expand())
.setHeight(expand())
- .apply {
- if (state.artwork != null) {
- addContent(
- Image.Builder()
- .setResourceId(ID_ARTWORK)
- .setWidth(expand())
- .setHeight(expand())
- .setContentScaleMode(CONTENT_SCALE_MODE_FIT)
- .build()
- )
- }
- }
+ .addContent(
+ Image.Builder()
+ .setResourceId(ID_ARTWORK)
+ .setWidth(expand())
+ .setHeight(expand())
+ .setContentScaleMode(CONTENT_SCALE_MODE_FIT)
+ .build()
+ )
.addContent(
Box.Builder()
.setWidth(expand())
@@ -282,13 +293,97 @@ internal fun MediaPlayerTileLayout(
.addSlotContent(
PlayerButton(deviceParameters, PlayerAction.PREVIOUS)
)
- .addSlotContent(
- if (state.playbackState != PlaybackState.PLAYING) {
+ .apply {
+ val playerButtonContent =
+ if (state.playbackState != PlaybackState.PLAYING) {
PlayerButton(deviceParameters, PlayerAction.PLAY)
} else {
PlayerButton(deviceParameters, PlayerAction.PAUSE)
}
- )
+
+ addSlotContent(
+ if (deviceParameters.supportsDynamicValue() && state.positionState != null) {
+ val actualPercent =
+ state.positionState.currentPositionMs.toFloat() / state.positionState.durationMs.toFloat()
+
+ Box.Builder()
+ .setWidth(dp(56f))
+ .setHeight(dp(56f))
+ .setHorizontalAlignment(HORIZONTAL_ALIGN_CENTER)
+ .setVerticalAlignment(VERTICAL_ALIGN_CENTER)
+ .addContent(playerButtonContent)
+ .addContent(
+ CircularProgressIndicator.Builder()
+ .setStartAngle(0f)
+ .setEndAngle(360f)
+ .setCircularProgressIndicatorColors(
+ ProgressIndicatorColors(
+ Colors.DEFAULT.primary,
+ 0x25FFFFFF.toInt()
+ )
+ )
+ .setStrokeWidth(dp(3f))
+ .setOuterMarginApplied(false)
+ .setProgress(
+ FloatProp.Builder(actualPercent)
+ .apply {
+ if (state.playbackState == PlaybackState.PLAYING) {
+ val durationFloat =
+ state.positionState.durationMs.toFloat() / 1000f
+
+ val positionFractional =
+ DynamicInstant.withSecondsPrecision(
+ Instant.ofEpochMilli(
+ state.positionState.currentTimeMs
+ )
+ ).durationUntil(
+ DynamicInstant.platformTimeWithSecondsPrecision()
+ )
+ .toIntSeconds()
+ .asFloat()
+ .times(state.positionState.playbackSpeed)
+ .plus(state.positionState.currentPositionMs.toFloat() / 1000f)
+
+ val predictedPercent =
+ DynamicFloat.onCondition(
+ positionFractional.gt(
+ durationFloat
+ )
+ )
+ .use(
+ durationFloat
+ )
+ .elseUse(
+ positionFractional
+ )
+ .div(
+ durationFloat
+ )
+
+ setDynamicValue(
+ DynamicFloat.onCondition(
+ predictedPercent.gt(
+ 0f
+ )
+ )
+ .use(
+ predictedPercent
+ )
+ .elseUse(0f)
+ .animate()
+ )
+ }
+ }
+ .build()
+ )
+ .build()
+ )
+ .build()
+ } else {
+ playerButtonContent
+ }
+ )
+ }
.addSlotContent(
PlayerButton(deviceParameters, PlayerAction.NEXT)
)
@@ -302,11 +397,34 @@ internal fun MediaPlayerTileLayout(
.addContent(
VolumeButton(PlayerAction.VOL_DOWN)
)
- .addContent(
- Spacer.Builder()
- .setWidth(dp(24f))
- .build()
- )
+ .apply {
+ if (state.appIcon != null) {
+ addContent(
+ Spacer.Builder()
+ .setWidth(dp(12f))
+ .build()
+ )
+ addContent(
+ Image.Builder()
+ .setResourceId(ID_APPICON)
+ .setWidth(dp(24f))
+ .setHeight(dp(24f))
+ .setContentScaleMode(CONTENT_SCALE_MODE_FIT)
+ .build()
+ )
+ addContent(
+ Spacer.Builder()
+ .setWidth(dp(12f))
+ .build()
+ )
+ } else {
+ addContent(
+ Spacer.Builder()
+ .setWidth(dp(24f))
+ .build()
+ )
+ }
+ }
.addContent(
VolumeButton(PlayerAction.VOL_UP)
)
@@ -325,9 +443,10 @@ private fun PlayerButton(
action: PlayerAction
): LayoutElement {
val isPlayPause = action == PlayerAction.PAUSE || action == PlayerAction.PLAY
+ val size = dp(50f)
return Box.Builder()
- .setHeight(dp(52f))
- .setWidth(dp(52f))
+ .setHeight(size)
+ .setWidth(size)
.setModifiers(
Modifiers.Builder()
.setBackground(
@@ -343,7 +462,7 @@ private fun PlayerButton(
)
.setCorner(
Corner.Builder()
- .setRadius(dp(52f))
+ .setRadius(size)
.build()
)
.build()
@@ -441,4 +560,30 @@ private fun getResourceIdForPlayerAction(action: PlayerAction): String {
PlayerAction.VOL_UP -> ID_VOL_UP
PlayerAction.VOL_DOWN -> ID_VOL_DOWN
}
+}
+
+@WearTilePreviewDevices
+private fun MediaPlayerTilePreview(context: Context): TilePreviewData {
+ val state = MediaPlayerTileState(
+ connectionStatus = WearConnectionStatus.CONNECTED,
+ title = "Title",
+ artist = "Artist",
+ playbackState = PlaybackState.PAUSED,
+ audioStreamState = AudioStreamState(3, 0, 5, AudioStreamType.MUSIC),
+ artwork = runBlocking {
+ ContextCompat.getDrawable(context, R.drawable.ws_full_sad)?.toBitmapOrNull()
+ ?.toByteArray()
+ },
+ appIcon = runBlocking {
+ ContextCompat.getDrawable(context, R.drawable.ic_play_circle_simpleblue)
+ ?.toBitmapOrNull()
+ ?.toByteArray()
+ }
+ )
+ val renderer = MediaPlayerTileRenderer(context, debugResourceMode = true)
+
+ return TilePreviewData(
+ onTileRequest = { renderer.renderTimeline(state, it) },
+ onTileResourceRequest = { renderer.produceRequestedResources(state, it) }
+ )
}
\ No newline at end of file
diff --git a/wear/src/main/java/com/thewizrd/simplewear/wearable/tiles/layouts/ProtoLayoutVersionUtils.kt b/wear/src/main/java/com/thewizrd/simplewear/wearable/tiles/layouts/ProtoLayoutVersionUtils.kt
new file mode 100644
index 00000000..bbd16fc3
--- /dev/null
+++ b/wear/src/main/java/com/thewizrd/simplewear/wearable/tiles/layouts/ProtoLayoutVersionUtils.kt
@@ -0,0 +1,22 @@
+package com.thewizrd.simplewear.wearable.tiles.layouts
+
+import androidx.wear.protolayout.DeviceParametersBuilders.DeviceParameters
+import androidx.wear.protolayout.expression.VersionBuilders.VersionInfo
+
+fun DeviceParameters.supportsTransformation(): Boolean {
+ // @RequiresSchemaVersion(major = 1, minor = 400)
+ val supportedVersion = VersionInfo.Builder()
+ .setMajor(1).setMinor(400)
+ .build()
+
+ return this.rendererSchemaVersion >= supportedVersion
+}
+
+fun DeviceParameters.supportsDynamicValue(): Boolean {
+ // @RequiresSchemaVersion(major = 1, minor = 200)
+ val supportedVersion = VersionInfo.Builder()
+ .setMajor(1).setMinor(200)
+ .build()
+
+ return this.rendererSchemaVersion >= supportedVersion
+}
\ No newline at end of file
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 2c248207..f3092e43 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
@@ -1,55 +1,46 @@
package com.thewizrd.simplewear.wearable.tiles.unofficial
import android.app.PendingIntent
-import android.bluetooth.BluetoothAdapter
import android.content.Context
import android.content.Intent
-import android.net.wifi.WifiManager
import android.os.Bundle
-import android.util.Log
import android.view.View
import android.widget.RemoteViews
import com.google.android.clockwork.tiles.TileData
import com.google.android.clockwork.tiles.TileProviderService
-import com.google.android.gms.common.api.ApiException
-import com.google.android.gms.wearable.CapabilityClient
-import com.google.android.gms.wearable.CapabilityInfo
-import com.google.android.gms.wearable.MessageClient
-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.WearableStatusCodes
-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.MultiChoiceAction
import com.thewizrd.shared_resources.actions.NormalAction
-import com.thewizrd.shared_resources.actions.ToggleAction
import com.thewizrd.shared_resources.controls.ActionButtonViewModel
import com.thewizrd.shared_resources.helpers.WearConnectionStatus
-import com.thewizrd.shared_resources.helpers.WearableHelper
import com.thewizrd.shared_resources.helpers.toImmutableCompatFlag
import com.thewizrd.shared_resources.utils.AnalyticsLogger
-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.PhoneSyncActivity
import com.thewizrd.simplewear.R
+import com.thewizrd.simplewear.datastore.dashboard.dashboardDataStore
import com.thewizrd.simplewear.preferences.DashboardTileUtils.DEFAULT_TILES
import com.thewizrd.simplewear.preferences.DashboardTileUtils.MAX_BUTTONS
import com.thewizrd.simplewear.preferences.Settings
+import com.thewizrd.simplewear.wearable.tiles.DashboardTileMessenger
+import com.thewizrd.simplewear.wearable.tiles.DashboardTileState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
-import kotlinx.coroutines.tasks.await
-import timber.log.Timber
+import kotlinx.coroutines.supervisorScope
+import kotlinx.coroutines.withTimeoutOrNull
import java.util.Locale
-class DashboardTileProviderService : TileProviderService(), MessageClient.OnMessageReceivedListener,
- CapabilityClient.OnCapabilityChangedListener {
+class DashboardTileProviderService : TileProviderService() {
companion object {
private const val TAG = "DashTileProviderService"
}
@@ -59,25 +50,70 @@ class DashboardTileProviderService : TileProviderService(), MessageClient.OnMess
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
+ private lateinit var tileMessenger: DashboardTileMessenger
+ private lateinit var tileStateFlow: StateFlow
+
+ override fun onCreate() {
+ super.onCreate()
+ Logger.debug(TAG, "creating service...")
+
+ tileMessenger = DashboardTileMessenger(this, isLegacyTile = true)
+ tileMessenger.register()
+
+ tileStateFlow = this.dashboardDataStore.data
+ .combine(tileMessenger.connectionState) { cache, connectionStatus ->
+ val userActions = Settings.getDashboardTileConfig() ?: DEFAULT_TILES
+
+ DashboardTileState(
+ connectionStatus = connectionStatus,
+ batteryStatus = cache.batteryStatus,
+ actions = userActions.associateWith {
+ cache.actions.run {
+ // Add NormalActions
+ this.plus(Actions.LOCKSCREEN to NormalAction(Actions.LOCKSCREEN))
+ }[it]
+ },
+ showBatteryStatus = Settings.isShowTileBatStatus()
+ )
+ }
+ .stateIn(
+ scope,
+ started = SharingStarted.WhileSubscribed(2000),
+ initialValue = null
+ )
+
+ scope.launch {
+ tileStateFlow.collectLatest {
+ if (mInFocus && isActive && !isIdForDummyData(id)) {
+ sendRemoteViews()
+ }
+ }
+ }
+ }
+
override fun onDestroy() {
- Timber.tag(TAG).d("destroying service...")
+ Logger.debug(TAG, "destroying service...")
+ tileMessenger.unregister()
super.onDestroy()
scope.cancel()
}
override fun onTileUpdate(tileId: Int) {
- Timber.tag(TAG).d("onTileUpdate called with: tileId = $tileId")
+ Logger.debug(TAG, "onTileUpdate called with: tileId = $tileId")
if (!isIdForDummyData(tileId)) {
id = tileId
- sendRemoteViews()
+
+ scope.launch {
+ sendRemoteViews()
+ }
}
}
override fun onTileFocus(tileId: Int) {
super.onTileFocus(tileId)
+ Logger.debug(TAG, "onTileFocus called with: tileId = $tileId")
- Timber.tag(TAG).d("$TAG: onTileFocus called with: tileId = $tileId")
if (!isIdForDummyData(tileId)) {
id = tileId
mInFocus = true
@@ -86,19 +122,11 @@ class DashboardTileProviderService : TileProviderService(), MessageClient.OnMess
putBoolean("isUnofficial", true)
})
- // Update tile actions
- tileActions.clear()
- tileActions.addAll(Settings.getDashboardTileConfig() ?: DEFAULT_TILES)
-
- sendRemoteViews()
-
- Wearable.getCapabilityClient(applicationContext)
- .addListener(this, WearableHelper.CAPABILITY_PHONE_APP)
- Wearable.getMessageClient(applicationContext).addListener(this)
-
scope.launch {
- checkConnectionStatus()
- requestUpdate()
+ tileMessenger.checkConnectionStatus()
+ tileMessenger.requestUpdate()
+
+ sendRemoteViews()
}
}
}
@@ -106,46 +134,31 @@ class DashboardTileProviderService : TileProviderService(), MessageClient.OnMess
override fun onTileBlur(tileId: Int) {
super.onTileBlur(tileId)
- Timber.tag(TAG).d("$TAG: onTileBlur called with: tileId = $tileId")
+ Logger.debug(TAG, "$TAG: onTileBlur called with: tileId = $tileId")
if (!isIdForDummyData(tileId)) {
mInFocus = false
-
- Wearable.getCapabilityClient(applicationContext)
- .removeListener(this, WearableHelper.CAPABILITY_PHONE_APP)
- Wearable.getMessageClient(applicationContext).removeListener(this)
}
}
- private fun sendRemoteViews() {
- Timber.tag(TAG).d("$TAG: sendRemoteViews")
- scope.launch {
- val updateViews = buildUpdate()
+ private suspend fun sendRemoteViews() {
+ Logger.debug(TAG, "$TAG: sendRemoteViews")
- val tileData = TileData.Builder()
- .setRemoteViews(updateViews)
- .build()
+ val tileState = latestTileState()
+ val updateViews = buildUpdate(tileState)
- sendUpdate(id, tileData)
- }
- }
+ val tileData = TileData.Builder()
+ .setRemoteViews(updateViews)
+ .build()
- @Volatile
- private var mPhoneNodeWithApp: Node? = null
- private var mConnectionStatus = WearConnectionStatus.DISCONNECTED
-
- private var battStatus: BatteryStatus? = null
- private val tileActions = mutableListOf()
- private val actionMap = mutableMapOf().apply {
- // Add NormalActions
- putIfAbsent(Actions.LOCKSCREEN, NormalAction(Actions.LOCKSCREEN))
+ sendUpdate(id, tileData)
}
- private fun buildUpdate(): RemoteViews {
+ private fun buildUpdate(tileState: DashboardTileState): RemoteViews {
val views: RemoteViews
- if (mConnectionStatus != WearConnectionStatus.CONNECTED) {
+ if (tileState.connectionStatus != WearConnectionStatus.CONNECTED) {
views = RemoteViews(applicationContext.packageName, R.layout.tile_disconnected)
- when (mConnectionStatus) {
+ when (tileState.connectionStatus) {
WearConnectionStatus.APPNOTINSTALLED -> {
views.setTextViewText(R.id.message, getString(R.string.error_notinstalled))
views.setImageViewResource(
@@ -169,10 +182,10 @@ class DashboardTileProviderService : TileProviderService(), MessageClient.OnMess
views = RemoteViews(applicationContext!!.packageName, R.layout.tile_layout_dashboard)
views.setOnClickPendingIntent(R.id.tile, getTapIntent(applicationContext))
- if (battStatus != null) {
+ if (tileState.batteryStatus != null) {
val battValue = String.format(
- Locale.ROOT, "%d%%, %s", battStatus!!.batteryLevel,
- if (battStatus!!.isCharging) applicationContext.getString(R.string.batt_state_charging) else applicationContext.getString(
+ Locale.ROOT, "%d%%, %s", tileState.batteryStatus.batteryLevel,
+ if (tileState.batteryStatus.isCharging) applicationContext.getString(R.string.batt_state_charging) else applicationContext.getString(
R.string.batt_state_discharging
)
)
@@ -187,15 +200,22 @@ class DashboardTileProviderService : TileProviderService(), MessageClient.OnMess
views.setViewVisibility(R.id.spacer, View.VISIBLE)
}
+ val actions = tileState.actions.keys.toList()
+
for (i in 0 until MAX_BUTTONS) {
- val action = tileActions.getOrNull(i)
- updateButton(views, i + 1, action)
+ val action = actions.getOrNull(i)
+ updateButton(views, i + 1, tileState, action)
}
return views
}
- private fun updateButton(views: RemoteViews, buttonIndex: Int, action: Actions?) {
+ private fun updateButton(
+ views: RemoteViews,
+ buttonIndex: Int,
+ tileState: DashboardTileState,
+ action: Actions?
+ ) {
val layoutId = when (buttonIndex) {
1 -> R.id.button_1_layout
2 -> R.id.button_2_layout
@@ -217,7 +237,7 @@ class DashboardTileProviderService : TileProviderService(), MessageClient.OnMess
}
if (action != null) {
- actionMap[action]?.let {
+ tileState.actions[action]?.let {
val model = ActionButtonViewModel(it)
views.setImageViewResource(buttonId, model.drawableResId)
views.setInt(
@@ -260,396 +280,40 @@ class DashboardTileProviderService : TileProviderService(), MessageClient.OnMess
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
- if (intent?.action != null) {
- when (Actions.valueOf(intent.action!!)) {
- Actions.WIFI -> run {
- val wifiAction = actionMap[Actions.WIFI] as? ToggleAction
-
- if (wifiAction == null) {
- requestUpdate()
- return@run
- }
-
- requestAction(ToggleAction(Actions.WIFI, !wifiAction.isEnabled))
- }
-
- Actions.BLUETOOTH -> run {
- val btAction = actionMap[Actions.BLUETOOTH] as? ToggleAction
-
- if (btAction == null) {
- requestUpdate()
- return@run
- }
-
- requestAction(ToggleAction(Actions.BLUETOOTH, !btAction.isEnabled))
- }
-
- Actions.LOCKSCREEN -> requestAction(
- actionMap[Actions.LOCKSCREEN] ?: NormalAction(Actions.LOCKSCREEN)
- )
-
- Actions.DONOTDISTURB -> run {
- val dndAction = actionMap[Actions.DONOTDISTURB]
-
- if (dndAction == null) {
- requestUpdate()
- return@run
- }
-
- requestAction(
- if (dndAction is ToggleAction) {
- ToggleAction(Actions.DONOTDISTURB, !dndAction.isEnabled)
- } else {
- MultiChoiceAction(
- Actions.DONOTDISTURB,
- (dndAction as MultiChoiceAction).choice + 1
- )
- }
- )
- }
-
- Actions.RINGER -> run {
- val ringerAction = actionMap[Actions.RINGER] as? MultiChoiceAction
-
- if (ringerAction == null) {
- requestUpdate()
- return@run
- }
-
- requestAction(MultiChoiceAction(Actions.RINGER, ringerAction.choice + 1))
- }
-
- Actions.TORCH -> run {
- val torchAction = actionMap[Actions.TORCH] as? ToggleAction
-
- if (torchAction == null) {
- requestUpdate()
- return@run
- }
-
- requestAction(ToggleAction(Actions.TORCH, !torchAction.isEnabled))
- }
-
- Actions.MOBILEDATA -> run {
- val mobileDataAction = actionMap[Actions.MOBILEDATA] as? ToggleAction
-
- if (mobileDataAction == null) {
- requestUpdate()
- return@run
- }
-
- requestAction(ToggleAction(Actions.MOBILEDATA, !mobileDataAction.isEnabled))
- }
+ intent?.action?.let {
+ val action = Actions.valueOf(it)
- Actions.LOCATION -> run {
- val locationAction = actionMap[Actions.LOCATION]
-
- if (locationAction == null) {
- requestUpdate()
- return@run
- }
-
- requestAction(
- if (locationAction is ToggleAction) {
- ToggleAction(Actions.LOCATION, !locationAction.isEnabled)
- } else {
- MultiChoiceAction(
- Actions.LOCATION,
- (locationAction as MultiChoiceAction).choice + 1
- )
- }
- )
- }
-
- Actions.HOTSPOT -> run {
- val hotspotAction = actionMap[Actions.HOTSPOT] as? ToggleAction
-
- if (hotspotAction == null) {
- requestUpdate()
- return@run
- }
-
- requestAction(ToggleAction(Actions.HOTSPOT, !hotspotAction.isEnabled))
- }
-
- else -> {
- // ignore unsupported actions
- }
+ scope.launch {
+ val state = latestTileState()
+ tileMessenger.processActionAsync(state, action)
}
}
- return super.onStartCommand(intent, flags, startId)
- }
-
- override fun onMessageReceived(messageEvent: MessageEvent) {
- val data = messageEvent.data ?: return
-
- scope.launch {
- when {
- messageEvent.path.contains(WearableHelper.WifiPath) -> {
- 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
- }
- actionMap[Actions.WIFI] = ToggleAction(Actions.WIFI, enabled)
- }
-
- messageEvent.path.contains(WearableHelper.BluetoothPath) -> {
- val btStatus = data[0].toInt()
- var enabled = false
-
- when (btStatus) {
- BluetoothAdapter.STATE_OFF,
- BluetoothAdapter.STATE_TURNING_OFF -> enabled = false
-
- BluetoothAdapter.STATE_ON,
- BluetoothAdapter.STATE_TURNING_ON -> enabled = true
- }
-
- actionMap[Actions.BLUETOOTH] = ToggleAction(Actions.BLUETOOTH, enabled)
- }
-
- messageEvent.path == WearableHelper.BatteryPath -> {
- val jsonData: String = data.bytesToString()
- battStatus = JSONParser.deserializer(jsonData, BatteryStatus::class.java)
- }
-
- messageEvent.path == WearableHelper.ActionsPath -> {
- val jsonData: String = data.bytesToString()
- val action = JSONParser.deserializer(jsonData, Action::class.java)
-
- when (action?.actionType) {
- Actions.WIFI,
- Actions.BLUETOOTH,
- Actions.TORCH,
- Actions.DONOTDISTURB,
- Actions.RINGER,
- Actions.MOBILEDATA,
- Actions.LOCATION,
- Actions.LOCKSCREEN,
- Actions.PHONE,
- Actions.HOTSPOT -> {
- actionMap[action.actionType] = action
- }
-
- else -> {
- // ignore unsupported action
- }
- }
- }
- }
-
- // Send update if tile is in focus
- if (mInFocus && !isIdForDummyData(id)) {
- sendRemoteViews()
- }
- }
+ return super.onStartCommand(intent, flags, startId)
}
- override fun onCapabilityChanged(capabilityInfo: CapabilityInfo) {
- scope.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)
+ private suspend fun latestTileState(): DashboardTileState {
+ var tileState = tileStateFlow.filterNotNull().first()
+
+ if (tileState.isEmpty) {
+ Logger.debug(TAG, "No tile state available. loading from remote...")
+ tileMessenger.requestUpdate()
+
+ // Try to await for full metadata change
+ runCatching {
+ withTimeoutOrNull(5000) {
+ supervisorScope {
+ tileStateFlow.filterNotNull().collectLatest { newState ->
+ if (newState.actions.isNotEmpty() && newState.batteryStatus != null) {
+ tileState = newState
+ coroutineContext.cancel()
+ }
}
}
}
}
-
- if (mInFocus && !isIdForDummyData(id)) {
- sendRemoteViews()
- }
}
- }
- private 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)
- }
- }
- }
- }
-
- if (mInFocus && !isIdForDummyData(id)) {
- sendRemoteViews()
- }
- }
-
- private suspend fun checkIfPhoneHasApp(): Node? {
- var node: Node? = null
-
- try {
- val capabilityInfo = Wearable.getCapabilityClient(this@DashboardTileProviderService)
- .getCapability(
- WearableHelper.CAPABILITY_PHONE_APP,
- CapabilityClient.FILTER_ALL
- )
- .await()
- node = pickBestNodeId(capabilityInfo.nodes)
- } catch (e: Exception) {
- Logger.writeLine(Log.ERROR, e)
- }
-
- return node
- }
-
- private suspend fun connect(): Boolean {
- if (mPhoneNodeWithApp == null)
- mPhoneNodeWithApp = checkIfPhoneHasApp()
-
- return mPhoneNodeWithApp != null
- }
-
- private fun requestUpdate() {
- scope.launch {
- if (connect()) {
- sendMessage(mPhoneNodeWithApp!!.id, WearableHelper.UpdatePath, null)
- }
- }
- }
-
- private fun requestAction(action: Action) {
- AnalyticsLogger.logEvent("dashtile_action_clicked", Bundle().apply {
- putString("action", action.actionType.name)
- })
-
- requestAction(JSONParser.serializer(action, Action::class.java))
- }
-
- private fun requestAction(actionJSONString: String) {
- scope.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).
- */
- private 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()
- }
-
- private suspend fun sendMessage(nodeID: String, path: String, data: ByteArray?) {
- try {
- Wearable.getMessageClient(this@DashboardTileProviderService)
- .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
-
- if (mInFocus && !isIdForDummyData(id)) {
- sendRemoteViews()
- }
- return
- }
- }
-
- Logger.writeLine(Log.ERROR, e)
- }
- }
-
- @Throws(ApiException::class)
- private suspend fun sendPing(nodeID: String) {
- try {
- Wearable.getMessageClient(this@DashboardTileProviderService)
- .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)
- }
+ return tileState
}
}
\ No newline at end of file
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 9c4b7719..dd417571 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
@@ -3,31 +3,43 @@ package com.thewizrd.simplewear.wearable.tiles.unofficial
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
-import android.graphics.Bitmap
import android.os.Bundle
-import android.util.Log
import android.view.View
import android.widget.RemoteViews
import com.google.android.clockwork.tiles.TileData
import com.google.android.clockwork.tiles.TileProviderService
-import com.google.android.gms.common.api.ApiException
-import com.google.android.gms.wearable.*
-import com.thewizrd.shared_resources.actions.AudioStreamState
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.helpers.toImmutableCompatFlag
import com.thewizrd.shared_resources.media.PlaybackState
-import com.thewizrd.shared_resources.utils.*
+import com.thewizrd.shared_resources.utils.AnalyticsLogger
+import com.thewizrd.shared_resources.utils.ImageUtils.toBitmap
+import com.thewizrd.shared_resources.utils.Logger
import com.thewizrd.simplewear.R
+import com.thewizrd.simplewear.datastore.media.appInfoDataStore
+import com.thewizrd.simplewear.datastore.media.artworkDataStore
+import com.thewizrd.simplewear.datastore.media.mediaDataStore
import com.thewizrd.simplewear.media.MediaPlayerActivity
-import kotlinx.coroutines.*
-import kotlinx.coroutines.tasks.await
-import timber.log.Timber
-
-class MediaPlayerTileProviderService : TileProviderService(),
- MessageClient.OnMessageReceivedListener, DataClient.OnDataChangedListener,
- CapabilityClient.OnCapabilityChangedListener {
+import com.thewizrd.simplewear.wearable.tiles.MediaPlayerTileMessenger
+import com.thewizrd.simplewear.wearable.tiles.MediaPlayerTileMessenger.PlayerAction
+import com.thewizrd.simplewear.wearable.tiles.MediaPlayerTileState
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.supervisorScope
+import kotlinx.coroutines.withTimeoutOrNull
+
+class MediaPlayerTileProviderService : TileProviderService() {
companion object {
private const val TAG = "MediaPlayerTileProviderService"
}
@@ -37,41 +49,71 @@ class MediaPlayerTileProviderService : TileProviderService(),
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
- @Volatile
- private var mPhoneNodeWithApp: Node? = null
- private var mConnectionStatus = WearConnectionStatus.DISCONNECTED
-
- private var mAudioStreamState: AudioStreamState? = null
- private var mPlayerStateData: PlayerStateData? = null
-
- private data class PlayerStateData(
- val title: String?,
- val artist: String?,
- val artwork: Bitmap?,
- val playbackState: PlaybackState
- )
+ private lateinit var tileMessenger: MediaPlayerTileMessenger
+ private lateinit var tileStateFlow: StateFlow
+
+ override fun onCreate() {
+ super.onCreate()
+ Logger.debug(TAG, "creating service...")
+
+ tileMessenger = MediaPlayerTileMessenger(this, isLegacyTile = true)
+ tileMessenger.register()
+
+ tileStateFlow = combine(
+ this.mediaDataStore.data,
+ this.artworkDataStore.data,
+ this.appInfoDataStore.data,
+ tileMessenger.connectionState
+ ) { mediaCache, artwork, appInfo, connectionStatus ->
+ MediaPlayerTileState(
+ connectionStatus = connectionStatus,
+ title = mediaCache.mediaPlayerState?.mediaMetaData?.title,
+ artist = mediaCache.mediaPlayerState?.mediaMetaData?.artist,
+ artwork = artwork,
+ playbackState = mediaCache.mediaPlayerState?.playbackState,
+ positionState = mediaCache.mediaPlayerState?.mediaMetaData?.positionState,
+ audioStreamState = mediaCache.audioStreamState,
+ appIcon = appInfo.iconBitmap
+ )
+ }
+ .stateIn(
+ scope,
+ started = SharingStarted.WhileSubscribed(2000),
+ initialValue = null
+ )
- private var deleteJob: Job? = null
+ scope.launch {
+ tileStateFlow.collectLatest {
+ if (mInFocus && isActive && !isIdForDummyData(id)) {
+ sendRemoteViews()
+ }
+ }
+ }
+ }
override fun onDestroy() {
- Timber.tag(TAG).d("destroying service...")
+ Logger.debug(TAG, "destroying service...")
+ tileMessenger.unregister()
super.onDestroy()
scope.cancel()
}
override fun onTileUpdate(tileId: Int) {
- Timber.tag(TAG).d("onTileUpdate called with: tileId = $tileId")
+ Logger.debug(TAG, "onTileUpdate called with: tileId = $tileId")
if (!isIdForDummyData(tileId)) {
id = tileId
- sendRemoteViews()
+
+ scope.launch {
+ sendRemoteViews()
+ }
}
}
override fun onTileFocus(tileId: Int) {
super.onTileFocus(tileId)
+ Logger.debug(TAG, "onTileFocus called with: tileId = $tileId")
- Timber.tag(TAG).d("$TAG: onTileFocus called with: tileId = $tileId")
if (!isIdForDummyData(tileId)) {
id = tileId
mInFocus = true
@@ -80,18 +122,14 @@ class MediaPlayerTileProviderService : TileProviderService(),
putBoolean("isUnofficial", true)
})
- sendRemoteViews()
-
- Wearable.getCapabilityClient(this)
- .addListener(this, WearableHelper.CAPABILITY_PHONE_APP)
- Wearable.getMessageClient(this).addListener(this)
- Wearable.getDataClient(this).addListener(this)
-
scope.launch {
- checkConnectionStatus()
- requestPlayerConnect()
- requestVolumeStatus()
- updatePlayerState()
+ tileMessenger.checkConnectionStatus()
+ tileMessenger.requestPlayerConnect()
+ tileMessenger.requestVolumeStatus()
+ tileMessenger.requestUpdatePlayerState()
+ tileMessenger.requestPlayerAppInfo()
+
+ sendRemoteViews()
}
}
}
@@ -99,38 +137,35 @@ class MediaPlayerTileProviderService : TileProviderService(),
override fun onTileBlur(tileId: Int) {
super.onTileBlur(tileId)
- Timber.tag(TAG).d("$TAG: onTileBlur called with: tileId = $tileId")
+ Logger.debug(TAG, "onTileBlur called with: tileId = $tileId")
if (!isIdForDummyData(tileId)) {
mInFocus = false
- Wearable.getCapabilityClient(this)
- .removeListener(this, WearableHelper.CAPABILITY_PHONE_APP)
- Wearable.getMessageClient(this).removeListener(this)
- Wearable.getDataClient(this).removeListener(this)
-
- requestPlayerDisconnect()
+ scope.launch {
+ tileMessenger.requestPlayerDisconnect()
+ }
}
}
- private fun sendRemoteViews() {
- Timber.tag(TAG).d("$TAG: sendRemoteViews")
- scope.launch {
- val updateViews = buildUpdate()
+ private suspend fun sendRemoteViews() {
+ Logger.debug(TAG, "sendRemoteViews")
- val tileData = TileData.Builder()
- .setRemoteViews(updateViews)
- .build()
+ val tileState = latestTileState()
+ val updateViews = buildUpdate(tileState)
- sendUpdate(id, tileData)
- }
+ val tileData = TileData.Builder()
+ .setRemoteViews(updateViews)
+ .build()
+
+ sendUpdate(id, tileData)
}
- private fun buildUpdate(): RemoteViews {
+ private suspend fun buildUpdate(tileState: MediaPlayerTileState): RemoteViews {
val views: RemoteViews
- if (mConnectionStatus != WearConnectionStatus.CONNECTED) {
+ if (tileState.connectionStatus != WearConnectionStatus.CONNECTED) {
views = RemoteViews(packageName, R.layout.tile_disconnected)
- when (mConnectionStatus) {
+ when (tileState.connectionStatus) {
WearConnectionStatus.APPNOTINSTALLED -> {
views.setTextViewText(R.id.message, getString(R.string.error_notinstalled))
views.setImageViewResource(
@@ -153,12 +188,11 @@ class MediaPlayerTileProviderService : TileProviderService(),
views = RemoteViews(packageName, R.layout.tile_mediaplayer)
views.setOnClickPendingIntent(R.id.tile, getTapIntent(this))
- val playerState = mPlayerStateData
-
- if (playerState == null || playerState.playbackState == PlaybackState.NONE) {
+ if (tileState.playbackState == null || tileState.playbackState == PlaybackState.NONE) {
views.setViewVisibility(R.id.player_controls, View.GONE)
views.setViewVisibility(R.id.nomedia_view, View.VISIBLE)
views.setViewVisibility(R.id.album_art_imageview, View.GONE)
+ views.setViewVisibility(R.id.app_icon, View.VISIBLE)
views.setOnClickPendingIntent(
R.id.playrandom_button,
getActionClickIntent(this, MediaHelper.MediaPlayPath)
@@ -167,27 +201,34 @@ class MediaPlayerTileProviderService : TileProviderService(),
views.setViewVisibility(R.id.player_controls, View.VISIBLE)
views.setViewVisibility(R.id.nomedia_view, View.GONE)
views.setViewVisibility(R.id.album_art_imageview, View.VISIBLE)
+ views.setViewVisibility(R.id.app_icon, View.VISIBLE)
- views.setTextViewText(R.id.title_view, playerState.title)
- views.setTextViewText(R.id.subtitle_view, playerState.artist)
+ views.setTextViewText(R.id.title_view, tileState.title)
+ views.setTextViewText(R.id.subtitle_view, tileState.artist)
views.setViewVisibility(
R.id.subtitle_view,
- if (playerState.artist.isNullOrBlank()) View.GONE else View.VISIBLE
+ if (tileState.artist.isNullOrBlank()) View.GONE else View.VISIBLE
)
views.setViewVisibility(
R.id.play_button,
- if (playerState.playbackState != PlaybackState.PLAYING) View.VISIBLE else View.GONE
+ if (tileState.playbackState != PlaybackState.PLAYING) View.VISIBLE else View.GONE
)
views.setViewVisibility(
R.id.pause_button,
- if (playerState.playbackState != PlaybackState.PLAYING) View.GONE else View.VISIBLE
+ if (tileState.playbackState != PlaybackState.PLAYING) View.GONE else View.VISIBLE
)
- views.setImageViewBitmap(R.id.album_art_imageview, playerState.artwork)
+ views.setImageViewBitmap(R.id.album_art_imageview, tileState.artwork?.toBitmap())
+
+ if (tileState.appIcon != null) {
+ views.setImageViewBitmap(R.id.app_icon, tileState.appIcon.toBitmap())
+ } else {
+ views.setImageViewResource(R.id.app_icon, R.drawable.ic_play_circle_simpleblue)
+ }
views.setProgressBar(
R.id.volume_progressBar,
- mAudioStreamState?.maxVolume ?: 100,
- mAudioStreamState?.currentVolume ?: 0,
+ tileState.audioStreamState?.maxVolume ?: 100,
+ tileState.audioStreamState?.currentVolume ?: 0,
false
)
@@ -240,358 +281,51 @@ class MediaPlayerTileProviderService : TileProviderService(),
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
- MediaHelper.MediaPlayPath -> requestPlayAction()
- MediaHelper.MediaPausePath -> requestPauseAction()
- MediaHelper.MediaPreviousPath -> requestSkipToPreviousAction()
- MediaHelper.MediaNextPath -> requestSkipToNextAction()
- MediaHelper.MediaVolumeUpPath -> requestVolumeUp()
- MediaHelper.MediaVolumeDownPath -> requestVolumeDown()
+ MediaHelper.MediaPlayPath -> requestPlayerAction(PlayerAction.PLAY)
+ MediaHelper.MediaPausePath -> requestPlayerAction(PlayerAction.PAUSE)
+ MediaHelper.MediaPreviousPath -> requestPlayerAction(PlayerAction.PREVIOUS)
+ MediaHelper.MediaNextPath -> requestPlayerAction(PlayerAction.NEXT)
+ MediaHelper.MediaVolumeUpPath -> requestPlayerAction(PlayerAction.VOL_UP)
+ MediaHelper.MediaVolumeDownPath -> requestPlayerAction(PlayerAction.VOL_DOWN)
}
return super.onStartCommand(intent, flags, startId)
}
- private fun requestPlayerConnect() {
+ private fun requestPlayerAction(action: PlayerAction) {
scope.launch {
- if (connect()) {
- sendMessage(
- mPhoneNodeWithApp!!.id,
- MediaHelper.MediaPlayerConnectPath,
- true.booleanToBytes() // isAutoLaunch
- )
- }
+ tileMessenger.requestPlayerAction(action)
}
}
- private fun requestPlayerDisconnect() {
- scope.launch {
- if (connect()) {
- sendMessage(mPhoneNodeWithApp!!.id, MediaHelper.MediaPlayerDisconnectPath, null)
- }
- }
- }
-
- private fun requestVolumeStatus() {
- scope.launch {
- if (connect()) {
- sendMessage(mPhoneNodeWithApp!!.id, MediaHelper.MediaVolumeStatusPath, null)
- }
- }
- }
-
- private fun requestPlayAction() {
- scope.launch {
- if (connect()) {
- sendMessage(mPhoneNodeWithApp!!.id, MediaHelper.MediaPlayPath, null)
- }
- }
- }
-
- private fun requestPauseAction() {
- scope.launch {
- if (connect()) {
- sendMessage(mPhoneNodeWithApp!!.id, MediaHelper.MediaPausePath, null)
- }
- }
- }
-
- private fun requestSkipToPreviousAction() {
- scope.launch {
- if (connect()) {
- sendMessage(mPhoneNodeWithApp!!.id, MediaHelper.MediaPreviousPath, null)
- }
- }
- }
-
- private fun requestSkipToNextAction() {
- scope.launch {
- if (connect()) {
- sendMessage(mPhoneNodeWithApp!!.id, MediaHelper.MediaNextPath, null)
- }
- }
- }
-
- private fun requestVolumeUp() {
- scope.launch {
- if (connect()) {
- sendMessage(mPhoneNodeWithApp!!.id, MediaHelper.MediaVolumeUpPath, null)
- }
- }
- }
-
- private fun requestVolumeDown() {
- scope.launch {
- if (connect()) {
- sendMessage(mPhoneNodeWithApp!!.id, MediaHelper.MediaVolumeDownPath, null)
- }
- }
- }
-
- private suspend fun updatePlayerState(dataMap: DataMap) {
- 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(this),
- it
- )
- } catch (e: Exception) {
- null
- }
- }
-
- mPlayerStateData = PlayerStateData(title, artist, artBitmap, playbackState)
-
- sendRemoteViews()
- }
-
- private fun updatePlayerState() {
- scope.launch(Dispatchers.IO) {
- try {
- val buff = Wearable.getDataClient(this@MediaPlayerTileProviderService)
- .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)
- }
- }
- }
-
- override fun onMessageReceived(messageEvent: MessageEvent) {
- val data = messageEvent.data ?: return
-
- scope.launch {
- when (messageEvent.path) {
- WearableHelper.AudioStatusPath,
- MediaHelper.MediaVolumeStatusPath -> {
- val status = data.let {
- JSONParser.deserializer(
- it.bytesToString(),
- AudioStreamState::class.java
- )
- }
- mAudioStreamState = status
-
- sendRemoteViews()
- }
- }
- }
- }
-
- override fun onDataChanged(dataEventBuffer: DataEventBuffer) {
- 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
- scope.launch {
- updatePlayerState(dataMap)
- }
- }
- } else if (event.type == DataEvent.TYPE_DELETED) {
- val item = event.dataItem
- if (MediaHelper.MediaPlayerStatePath == item.uri.path) {
- deleteJob?.cancel()
- deleteJob = scope.launch delete@{
- delay(1000)
-
- if (!isActive) return@delete
-
- updatePlayerState(DataMap())
- }
- }
- }
- }
- }
-
- override fun onCapabilityChanged(capabilityInfo: CapabilityInfo) {
- scope.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)
+ private suspend fun latestTileState(): MediaPlayerTileState {
+ var tileState = tileStateFlow.filterNotNull().first()
+
+ if (tileState.isEmpty) {
+ Logger.debug(TAG, "No tile state available. loading from remote...")
+ tileMessenger.updatePlayerStateFromRemote()
+
+ // Try to await for full metadata change
+ runCatching {
+ withTimeoutOrNull(5000) {
+ supervisorScope {
+ var songChanged = false
+
+ tileStateFlow.filterNotNull().collectLatest { newState ->
+ if (!songChanged && newState.title != tileState.title && newState.artist != tileState.artist) {
+ // new song; wait for artwork
+ tileState = newState
+ songChanged = true
+ } else if (songChanged && !newState.artwork.contentEquals(tileState.artwork)) {
+ tileState = newState
+ coroutineContext.cancel()
+ }
}
}
}
}
-
- if (mInFocus && !isIdForDummyData(id)) {
- sendRemoteViews()
- }
- }
- }
-
- private 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)
- }
- }
- }
}
- if (mInFocus && !isIdForDummyData(id)) {
- sendRemoteViews()
- }
- }
-
- private suspend fun checkIfPhoneHasApp(): Node? {
- var node: Node? = null
-
- try {
- val capabilityInfo = Wearable.getCapabilityClient(this)
- .getCapability(
- WearableHelper.CAPABILITY_PHONE_APP,
- CapabilityClient.FILTER_ALL
- )
- .await()
- node = pickBestNodeId(capabilityInfo.nodes)
- } catch (e: Exception) {
- Logger.writeLine(Log.ERROR, e)
- }
-
- return node
- }
-
- private suspend fun connect(): Boolean {
- if (mPhoneNodeWithApp == null)
- mPhoneNodeWithApp = checkIfPhoneHasApp()
-
- return mPhoneNodeWithApp != null
- }
-
- /*
- * 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).
- */
- private 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()
- }
-
- private suspend fun sendMessage(nodeID: String, path: String, data: ByteArray?) {
- try {
- Wearable.getMessageClient(this)
- .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
-
- if (mInFocus && !isIdForDummyData(id)) {
- sendRemoteViews()
- }
- return
- }
- }
-
- Logger.writeLine(Log.ERROR, e)
- }
- }
-
- @Throws(ApiException::class)
- private suspend fun sendPing(nodeID: String) {
- try {
- Wearable.getMessageClient(this)
- .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)
- }
+ return tileState
}
}
\ No newline at end of file
diff --git a/wear/src/main/res/drawable/ring_progress.xml b/wear/src/main/res/drawable/ring_progress.xml
new file mode 100644
index 00000000..6167e6f3
--- /dev/null
+++ b/wear/src/main/res/drawable/ring_progress.xml
@@ -0,0 +1,27 @@
+
+
+ -
+
+
+
+
+ -
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/wear/src/main/res/layout/tile_mediaplayer.xml b/wear/src/main/res/layout/tile_mediaplayer.xml
index 0867a20c..1833ea95 100644
--- a/wear/src/main/res/layout/tile_mediaplayer.xml
+++ b/wear/src/main/res/layout/tile_mediaplayer.xml
@@ -40,7 +40,7 @@
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginHorizontal="8dp"
- android:layout_marginTop="4dp"
+ android:layout_marginTop="0dp"
android:layout_weight="1"
android:autoSizeMaxTextSize="14sp"
android:autoSizeMinTextSize="12sp"
@@ -54,9 +54,8 @@
+ tools:visibility="gone" />
+
+
-
+ android:gravity="center"
+ android:orientation="vertical"
+ tools:ignore="PrivateResource">
@@ -25,15 +23,17 @@
android:id="@+id/wearable_support_confirmation_overlay_message"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
+ android:layout_marginBottom="@dimen/confirmation_overlay_text_bottom_margin"
android:background="@android:color/transparent"
- android:fontFamily="sans-serif-condensed-light"
+ android:fontFamily="sans-serif-medium"
+ android:ellipsize="end"
android:gravity="center_horizontal"
+ android:importantForAccessibility="no"
+ android:letterSpacing="@dimen/confirmation_overlay_text_letter_spacing"
+ android:paddingStart="@dimen/inner_layout_padding"
+ android:paddingEnd="@dimen/inner_layout_padding"
android:textColor="@android:color/white"
android:textSize="@dimen/confirmation_overlay_text_size"
- app:layout_constraintBottom_toBottomOf="parent"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toTopOf="parent"
- app:layout_constraintVertical_bias="0.6"
tools:text="Hello" />
-
+
+
diff --git a/wear/src/main/res/values/strings.xml b/wear/src/main/res/values/strings.xml
index 85aebd8c..714b3f7c 100644
--- a/wear/src/main/res/values/strings.xml
+++ b/wear/src/main/res/values/strings.xml
@@ -79,4 +79,20 @@
Initial State
Scheduled State
+
+ Mute
+ Keypad
+ Speakerphone On
+ Speakerphone Off
+ Contact Photo
+ Device State
+ Arrow Up
+ Arrow Down
+ Arrow Left
+ Arrow Right
+ DPad Center
+ Open Player List
+ Artwork
+ Add Action
+
diff --git a/wearsettings/build.gradle b/wearsettings/build.gradle
index 931a91f1..09431f99 100644
--- a/wearsettings/build.gradle
+++ b/wearsettings/build.gradle
@@ -12,9 +12,9 @@ android {
minSdk rootProject.minSdkVersion
//noinspection ExpiredTargetSdkVersion
targetSdk 28
- // NOTE: update SUPPORTED_VERSION_CODE
- versionCode 1020000
- versionName "1.2.0"
+ // NOTE: update SUPPORTED_VERSION_CODE if needed
+ versionCode 1030001
+ versionName "1.3.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
@@ -36,6 +36,7 @@ android {
buildFeatures {
viewBinding true
+ buildConfig true
}
compileOptions {
@@ -85,5 +86,5 @@ dependencies {
implementation "dev.rikka.shizuku:api:$shizuku_version"
implementation "dev.rikka.shizuku:provider:$shizuku_version"
implementation "dev.rikka.tools.refine:runtime:$refine_version"
- implementation 'org.lsposed.hiddenapibypass:hiddenapibypass:4.3'
+ implementation 'org.lsposed.hiddenapibypass:hiddenapibypass:6.1'
}
\ No newline at end of file
diff --git a/wearsettings/src/main/java/com/thewizrd/wearsettings/App.kt b/wearsettings/src/main/java/com/thewizrd/wearsettings/App.kt
index 16a1cfff..154a18ce 100644
--- a/wearsettings/src/main/java/com/thewizrd/wearsettings/App.kt
+++ b/wearsettings/src/main/java/com/thewizrd/wearsettings/App.kt
@@ -3,46 +3,49 @@ package com.thewizrd.wearsettings
import android.app.Activity
import android.app.Application
import android.content.Context
+import android.content.SharedPreferences
import android.os.Build
import android.os.Bundle
+import android.preference.PreferenceManager
import com.google.android.material.color.DynamicColors
import com.thewizrd.shared_resources.ApplicationLib
-import com.thewizrd.shared_resources.SimpleLibrary
+import com.thewizrd.shared_resources.SharedModule
+import com.thewizrd.shared_resources.appLib
import com.thewizrd.shared_resources.helpers.AppState
+import com.thewizrd.shared_resources.sharedDeps
import com.thewizrd.shared_resources.utils.FileLoggingTree
import com.thewizrd.shared_resources.utils.Logger
+import kotlinx.coroutines.cancel
import org.lsposed.hiddenapibypass.HiddenApiBypass
-class App : Application(), ApplicationLib, Application.ActivityLifecycleCallbacks {
- companion object {
- @JvmStatic
- lateinit var instance: ApplicationLib
- private set
- }
-
- override lateinit var appContext: Context
- private set
- override lateinit var applicationState: AppState
- private set
- override val isPhone: Boolean = true
-
+class App : Application(), Application.ActivityLifecycleCallbacks {
+ private lateinit var applicationState: AppState
private var mActivitiesStarted = 0
override fun onCreate() {
super.onCreate()
- appContext = applicationContext
- instance = this
+
registerActivityLifecycleCallbacks(this)
applicationState = AppState.CLOSED
mActivitiesStarted = 0
- // Init shared library
- SimpleLibrary.initialize(this)
+ // Initialize app dependencies (library module chain)
+ // 1. ApplicationLib + SharedModule, 2. Firebase
+ appLib = object : ApplicationLib() {
+ override val context = applicationContext
+ override val preferences: SharedPreferences
+ get() = PreferenceManager.getDefaultSharedPreferences(context)
+ override val appState: AppState
+ get() = applicationState
+ override val isPhone = true
+ }
+
+ sharedDeps = object : SharedModule() {
+ override val context = appLib.context // keep same context as applib
+ }
- // Start logger
- Logger.init(appContext)
if (!BuildConfig.DEBUG) {
- Logger.registerLogger(FileLoggingTree(appContext))
+ Logger.registerLogger(FileLoggingTree(applicationContext))
}
DynamicColors.applyToActivitiesIfAvailable(this)
@@ -58,7 +61,7 @@ class App : Application(), ApplicationLib, Application.ActivityLifecycleCallback
override fun onTerminate() {
// Shutdown logger
Logger.shutdown()
- SimpleLibrary.unregister()
+ appLib.appScope.cancel()
super.onTerminate()
}
@@ -80,7 +83,7 @@ class App : Application(), ApplicationLib, Application.ActivityLifecycleCallback
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
override fun onActivityDestroyed(activity: Activity) {
- if (activity.localClassName.contains("MainActivity")) {
+ if (activity.localClassName.contains(MainActivity::class.java.simpleName)) {
applicationState = AppState.CLOSED
}
}
diff --git a/wearsettings/src/main/java/com/thewizrd/wearsettings/Settings.kt b/wearsettings/src/main/java/com/thewizrd/wearsettings/Settings.kt
index ea4a2785..b5730844 100644
--- a/wearsettings/src/main/java/com/thewizrd/wearsettings/Settings.kt
+++ b/wearsettings/src/main/java/com/thewizrd/wearsettings/Settings.kt
@@ -1,19 +1,17 @@
package com.thewizrd.wearsettings
-import android.preference.PreferenceManager
import androidx.core.content.edit
+import com.thewizrd.shared_resources.appLib
object Settings {
private const val KEY_ROOTACCESS = "key_rootaccess"
fun isRootAccessEnabled(): Boolean {
- val preferences = PreferenceManager.getDefaultSharedPreferences(App.instance.appContext)
- return preferences.getBoolean(KEY_ROOTACCESS, false)
+ return appLib.preferences.getBoolean(KEY_ROOTACCESS, false)
}
fun setRootAccessEnabled(value: Boolean) {
- val preferences = PreferenceManager.getDefaultSharedPreferences(App.instance.appContext)
- preferences.edit {
+ appLib.preferences.edit {
putBoolean(KEY_ROOTACCESS, value)
}
}
diff --git a/wearsettings/src/main/java/com/thewizrd/wearsettings/actions/WifiHotspotAction.kt b/wearsettings/src/main/java/com/thewizrd/wearsettings/actions/WifiHotspotAction.kt
index b46d1a73..0cd83b0e 100644
--- a/wearsettings/src/main/java/com/thewizrd/wearsettings/actions/WifiHotspotAction.kt
+++ b/wearsettings/src/main/java/com/thewizrd/wearsettings/actions/WifiHotspotAction.kt
@@ -1,12 +1,17 @@
package com.thewizrd.wearsettings.actions
+import android.annotation.SuppressLint
import android.content.Context
import android.net.IConnectivityManager
+import android.net.IIntResultListener
+import android.net.ITetheringConnector
+import android.net.TetheringRequestParcel
import android.os.Build
import android.os.Bundle
import android.os.ResultReceiver
import android.util.Log
import androidx.annotation.DeprecatedSinceApi
+import androidx.annotation.RequiresApi
import com.thewizrd.shared_resources.actions.Action
import com.thewizrd.shared_resources.actions.ActionStatus
import com.thewizrd.shared_resources.actions.ToggleAction
@@ -15,11 +20,18 @@ import rikka.shizuku.Shizuku
import rikka.shizuku.ShizukuBinderWrapper
import rikka.shizuku.SystemServiceHelper
+@SuppressLint("PrivateApi")
object WifiHotspotAction {
+ private const val TAG = "WifiHotspotAction"
+
fun executeAction(context: Context, action: Action): ActionStatus {
if (action is ToggleAction) {
- return if (Shizuku.pingBinder() && Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
- setHotspotEnabledShizuku(action.isEnabled)
+ return if (Shizuku.pingBinder()) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ setHotspotEnabledShizuku(action.isEnabled)
+ } else {
+ setHotspotEnabledShizukuPreR(action.isEnabled)
+ }
} else {
ActionStatus.REMOTE_FAILURE
}
@@ -28,11 +40,24 @@ object WifiHotspotAction {
return ActionStatus.UNKNOWN
}
+ /*
+ * android.net
+ * ConnectivityManager / TetheringManager constants
+ */
+ /* TetheringType */
private const val TETHERING_WIFI = 0
+
+ /* TetheringManager service */
+ private const val TETHERING_SERVICE = "tethering"
+
+ /* Tether error codes */
private const val TETHER_ERROR_NO_ERROR = 0
+ private const val TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION = 14
@DeprecatedSinceApi(api = Build.VERSION_CODES.R)
- private fun setHotspotEnabledShizuku(enabled: Boolean): ActionStatus {
+ private fun setHotspotEnabledShizukuPreR(enabled: Boolean): ActionStatus {
+ Logger.info(TAG, "entering setHotspotEnabledShizukuPreR(enabled = ${enabled})...")
+
return runCatching {
val connMgr = SystemServiceHelper.getSystemService(Context.CONNECTIVITY_SERVICE)
.let(::ShizukuBinderWrapper)
@@ -41,16 +66,17 @@ object WifiHotspotAction {
if (enabled) {
val resultReceiver = object : ResultReceiver(null) {
override fun onReceiveResult(resultCode: Int, resultData: Bundle?) {
- if (resultCode == TETHER_ERROR_NO_ERROR) {
- Logger.writeLine(
- Log.INFO,
- "WifiHotspotAction: setHotspotEnabledShizuku(true) - success"
- )
- } else {
- Logger.writeLine(
- Log.ERROR,
- "WifiHotspotAction: setHotspotEnabledShizuku(true) - failed"
- )
+ when (resultCode) {
+ TETHER_ERROR_NO_ERROR -> {
+ Logger.info(TAG, "setHotspotEnabledShizukuPreR(true) - success")
+ }
+
+ else -> {
+ Logger.error(
+ TAG,
+ "setHotspotEnabledShizukuPreR(true) - failed. code = $resultCode"
+ )
+ }
}
}
}
@@ -66,4 +92,137 @@ object WifiHotspotAction {
ActionStatus.REMOTE_FAILURE
}
}
+
+ @RequiresApi(api = Build.VERSION_CODES.R)
+ private fun setHotspotEnabledShizuku(
+ enabled: Boolean,
+ exemptFromEntitlementCheck: Boolean = true,
+ shouldShowEntitlementUi: Boolean = false
+ ): ActionStatus {
+ Logger.info(TAG, "entering setHotspotEnabledShizuku(enabled = ${enabled})...")
+
+ return runCatching {
+ val tetheringMgr = SystemServiceHelper.getSystemService(TETHERING_SERVICE)
+ .let(::ShizukuBinderWrapper)
+ .let(ITetheringConnector.Stub::asInterface)
+
+ if (enabled) {
+ val resultListener = object : IIntResultListener.Stub() {
+ override fun onResult(resultCode: Int) {
+ when (resultCode) {
+ TETHER_ERROR_NO_ERROR -> {
+ Logger.info(TAG, "setHotspotEnabledShizuku(true) - success")
+ }
+
+ TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION -> {
+ // retry
+ setHotspotEnabledShizuku(enabled, false, shouldShowEntitlementUi)
+ }
+
+ else -> {
+ Logger.error(
+ TAG,
+ "setHotspotEnabledShizuku(true) - failed. code = $resultCode"
+ )
+ }
+ }
+ }
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ tetheringMgr.startTethering(
+ createTetheringRequestParcel(
+ exemptFromEntitlementCheck,
+ shouldShowEntitlementUi
+ ) as TetheringRequestParcel,
+ "com.android.shell",
+ "",
+ resultListener
+ )
+ } else {
+ tetheringMgr.startTethering(
+ createTetheringRequestParcel(
+ exemptFromEntitlementCheck,
+ shouldShowEntitlementUi
+ ) as TetheringRequestParcel,
+ "com.android.shell",
+ resultListener
+ )
+ }
+ } else {
+ val resultListener = object : IIntResultListener.Stub() {
+ override fun onResult(resultCode: Int) {
+ when (resultCode) {
+ TETHER_ERROR_NO_ERROR -> {
+ Logger.info(TAG, "setHotspotEnabledShizuku(false) - success")
+ }
+
+ else -> {
+ Logger.error(
+ TAG,
+ "setHotspotEnabledShizuku(false) - failed. code = $resultCode"
+ )
+ }
+ }
+ }
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ tetheringMgr.stopTethering(
+ TETHERING_WIFI,
+ "com.android.shell",
+ "",
+ resultListener
+ )
+ } else {
+ tetheringMgr.stopTethering(TETHERING_WIFI, "com.android.shell", resultListener)
+ }
+ }
+
+ ActionStatus.SUCCESS
+ }.getOrElse {
+ Logger.writeLine(Log.ERROR, it)
+ ActionStatus.REMOTE_FAILURE
+ }
+ }
+
+ private fun createTetheringRequest(
+ exemptFromEntitlementCheck: Boolean = true,
+ shouldShowEntitlementUi: Boolean = false
+ ): Any {
+ return Class.forName("android.net.TetheringManager\$TetheringRequest\$Builder").run {
+ val setExemptFromEntitlementCheck =
+ getDeclaredMethod("setExemptFromEntitlementCheck", Boolean::class.java)
+ val setShouldShowEntitlementUi =
+ getDeclaredMethod("setShouldShowEntitlementUi", Boolean::class.java)
+ val build = getDeclaredMethod("build")
+
+ getConstructor(Int::class.java).run {
+ this.newInstance(TETHERING_WIFI).let {
+ setExemptFromEntitlementCheck.invoke(it, exemptFromEntitlementCheck)
+ setShouldShowEntitlementUi.invoke(it, shouldShowEntitlementUi)
+ build.invoke(it)
+ }
+ }
+ }
+ }
+
+ private fun createTetheringRequestParcel(
+ exemptFromEntitlementCheck: Boolean = true,
+ shouldShowEntitlementUi: Boolean = false
+ ): Any {
+ return getRequestParcel(
+ createTetheringRequest(
+ exemptFromEntitlementCheck,
+ shouldShowEntitlementUi
+ )
+ )
+ }
+
+ private fun getRequestParcel(request: Any): Any {
+ return Class.forName("android.net.TetheringManager\$TetheringRequest").run {
+ val getParcel = getDeclaredMethod("getParcel")
+ getParcel.invoke(request)
+ }
+ }
}
\ No newline at end of file
diff --git a/wearsettings/src/main/res/values/strings.xml b/wearsettings/src/main/res/values/strings.xml
index f2346a2b..597bf44c 100644
--- a/wearsettings/src/main/res/values/strings.xml
+++ b/wearsettings/src/main/res/values/strings.xml
@@ -16,8 +16,8 @@
Show app icon
Show app icon in the launcher
- https://github.com/SimpleAppProjects/SimpleWear/wiki/Root-Access
- https://github.com/SimpleAppProjects/SimpleWear/wiki/Enable-WRITE_SECURE_SETTINGS-permission
+ https://simpleappprojects.github.io/SimpleWear/root-access
+ https://simpleappprojects.github.io/SimpleWear/secure-settings-access
Bluetooth
Bluetooth permission enabled