diff --git a/.gitignore b/.gitignore index e1e65aa3..55b25c74 100644 --- a/.gitignore +++ b/.gitignore @@ -21,5 +21,4 @@ wear/google-services.json wearsettings/debug wearsettings/release releases -allowed_wearsettings_callers.xml -PackageValidator.kt \ No newline at end of file +allowed_wearsettings_callers.xml \ No newline at end of file diff --git a/build.gradle b/build.gradle index 7c4e78a6..143cfc42 100644 --- a/build.gradle +++ b/build.gradle @@ -2,28 +2,29 @@ buildscript { ext { - compileSdkVersion = 34 + compileSdkVersion = 35 minSdkVersion = 24 targetSdkVersion = 34 - kotlin_version = '1.9.25' - kotlinx_version = '1.8.1' + kotlin_version = '2.1.10' + kotlinx_version = '1.10.2' - desugar_version = '2.1.1' + desugar_version = '2.1.5' - firebase_version = '33.2.0' + firebase_version = '33.12.0' - activity_version = '1.9.1' + activity_version = '1.10.1' appcompat_version = '1.7.0' - constraintlayout_version = '2.1.4' - core_version = '1.13.1' - fragment_version = '1.8.2' - lifecycle_version = '2.8.4' + constraintlayout_version = '2.2.1' + core_version = '1.16.0' + fragment_version = '1.8.6' + lifecycle_version = '2.8.7' preference_version = '1.2.1' - recyclerview_version = '1.3.2' + recyclerview_version = '1.4.0' coresplash_version = '1.0.1' - work_version = '2.9.1' - navigation_version = '2.7.7' + work_version = '2.10.0' + navigation_version = '2.8.9' + datastore_version = '1.1.4' test_core_version = '1.6.1' test_runner_version = '1.6.2' @@ -34,15 +35,15 @@ buildscript { material_version = '1.12.0' - compose_bom_version = '2024.08.00' + compose_bom_version = '2025.04.00' compose_compiler_version = '1.5.15' - wear_compose_version = '1.3.1' - wear_tiles_version = '1.4.0' + wear_compose_version = '1.4.1' + wear_tiles_version = '1.4.1' wear_watchface_version = '1.2.1' - horologist_version = '0.5.28' - accompanist_version = '0.34.0' + horologist_version = '0.6.23' + accompanist_version = '0.37.2' - gson_version = '2.11.0' + gson_version = '2.13.0' timber_version = '5.0.1' // Shizuku @@ -56,10 +57,11 @@ buildscript { } dependencies { - classpath 'com.android.tools.build:gradle:8.5.2' + classpath 'com.android.tools.build:gradle:8.9.1' classpath 'com.google.gms:google-services:4.4.2' - classpath 'com.google.firebase:firebase-crashlytics-gradle:3.0.2' + classpath 'com.google.firebase:firebase-crashlytics-gradle:3.0.3' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + classpath "org.jetbrains.kotlin:compose-compiler-gradle-plugin:$kotlin_version" // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files } diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 00000000..4794b483 --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1,7 @@ +_site +.sass-cache +.jekyll-cache +.jekyll-metadata +vendor + +Gemfile.lock \ No newline at end of file diff --git a/docs/Gemfile b/docs/Gemfile new file mode 100644 index 00000000..467697ad --- /dev/null +++ b/docs/Gemfile @@ -0,0 +1,33 @@ +source "https://rubygems.org" +# Hello! This is where you manage which Jekyll version is used to run. +# When you want to use a different version, change it below, save the +# file and run `bundle install`. Run Jekyll with `bundle exec`, like so: +# +# bundle exec jekyll serve +# +# This will help ensure the proper Jekyll version is running. +# Happy Jekylling! +#gem "jekyll", "~> 4.4.1" +# This is the default theme for new Jekyll sites. You may change this to anything you like. +#gem "minima", "~> 2.5" +# If you want to use GitHub Pages, remove the "gem "jekyll"" above and +# uncomment the line below. To upgrade, run `bundle update github-pages`. +gem "github-pages", group: :jekyll_plugins +# If you have any plugins, put them here! +group :jekyll_plugins do + gem "jekyll-feed", "~> 0.12" +end + +# Windows and JRuby does not include zoneinfo files, so bundle the tzinfo-data gem +# and associated library. +platforms :mingw, :x64_mingw, :mswin, :jruby do + gem "tzinfo", ">= 1", "< 3" + gem "tzinfo-data" +end + +# Performance-booster for watching directories on Windows +gem "wdm", "~> 0.1", :platforms => [:mingw, :x64_mingw, :mswin] + +# Lock `http_parser.rb` gem to `v0.6.x` on JRuby builds since newer versions of the gem +# do not have a Java counterpart. +gem "http_parser.rb", "~> 0.6.0", :platforms => [:jruby] diff --git a/docs/_config.yml b/docs/_config.yml new file mode 100644 index 00000000..1d74fe15 --- /dev/null +++ b/docs/_config.yml @@ -0,0 +1,53 @@ +# Welcome to Jekyll! +# +# This config file is meant for settings that affect your whole blog, values +# which you are expected to set up once and rarely edit after that. If you find +# yourself editing this file very often, consider using Jekyll's data files +# feature for the data you need to update frequently. +# +# For technical reasons, this file is *NOT* reloaded automatically when you use +# 'bundle exec jekyll serve'. If you change this file, please restart the server process. +# +# If you need help with YAML syntax, here are some quick references for you: +# https://learn-the-web.algonquindesign.ca/topics/markdown-yaml-cheat-sheet/#yaml +# https://learnxinyminutes.com/docs/yaml/ +# +# Site settings +# These are used to personalize your new site. If you look in the HTML files, +# you will see them accessed via {{ site.title }}, {{ site.email }}, and so on. +# You can create any custom variable you would like, and they will be accessible +# in the templates via {{ site.myvariable }}. + +title: SimpleWear +description: Control your Android phone from your watch +logo: /assets/img/logo.png +google_analytics: +show_downloads: true + +google: + playstore_url: "https://play.google.com/store/apps/details?id=com.thewizrd.simplewear" + +# Build settings +remote_theme: "godalming123/minimal" +plugins: + - jekyll-remote-theme # add this line to the plugins list if you already have one + +# Exclude from processing. +# The following items will not be processed, by default. +# Any item listed under the `exclude:` key here will be automatically added to +# the internal "default list". +# +# Excluded items can be processed by explicitly listing the directories or +# their entries' file path in the `include:` list. +# +# exclude: +# - .sass-cache/ +# - .jekyll-cache/ +# - gemfiles/ +# - Gemfile +# - Gemfile.lock +# - node_modules/ +# - vendor/bundle/ +# - vendor/cache/ +# - vendor/gems/ +# - vendor/ruby/ diff --git a/docs/_includes/head-custom.html b/docs/_includes/head-custom.html new file mode 100644 index 00000000..5c79acac --- /dev/null +++ b/docs/_includes/head-custom.html @@ -0,0 +1,9 @@ + + + +{% include head-custom-google-analytics.html %} + + + + + \ No newline at end of file diff --git a/docs/_layouts/default.html b/docs/_layouts/default.html new file mode 100644 index 00000000..2bc94a06 --- /dev/null +++ b/docs/_layouts/default.html @@ -0,0 +1,63 @@ + + + + + + + + {% seo %} + + {%if site.color-scheme %} + + {% else %} + + {% endif %} + + + + {% include head-custom.html %} + + +
+
+

{{ site.title | default: site.github.repository_name }}

+ + {% if site.logo %} + Logo + {% endif %} + +

{{ site.description | default: site.github.project_tagline }}

+ + {% if site.github.is_project_page %} +

View the Project on GitHub {{ site.github.repository_nwo }}

+ {% endif %} + + {% if site.github.is_user_page %} +

View My GitHub Profile

+ {% endif %} + + {% if site.show_downloads %} + + {% endif %} +
+
+ + {{ content }} + +
+ +
+ + + \ No newline at end of file diff --git a/docs/assets/img/favicon.ico b/docs/assets/img/favicon.ico new file mode 100644 index 00000000..6818b176 Binary files /dev/null and b/docs/assets/img/favicon.ico differ diff --git a/docs/assets/img/logo.png b/docs/assets/img/logo.png new file mode 100644 index 00000000..d0c585e7 Binary files /dev/null and b/docs/assets/img/logo.png differ diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 00000000..84565ed5 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,17 @@ +--- +layout: default +--- + +SimpleWear allows you to control your Android phone from your watch. + +# Store + +Download from the Google Play Store + +# Settings Helper + +[Companion app for SimpleWear](./settings-helper) + +# About + +[Privacy Policy](./privacy-policy) \ No newline at end of file diff --git a/docs/privacy-policy.md b/docs/privacy-policy.md new file mode 100644 index 00000000..51a1c72e --- /dev/null +++ b/docs/privacy-policy.md @@ -0,0 +1,51 @@ +--- +layout: default +title: Privacy Policy +permalink: /privacy-policy +--- + +# Privacy Policy + +This privacy policy describes the permissions used for this application. + +This application only asks for permission to perform certain functions on your phone. + +SimpleWear does not store, collect or share any other personal identifiable information with anyone. + +# App Permissions + +### Camera + +Allows the application to turn the flashlight on or off. + +### Device admin access + +Allows the application to lock your phone from your WearOS device. + +### Do not Disturb access + +Allows the application to change the do not disturb state on your phone. + +### Notification access + +Allows the application to access and control active media sessions / music on your phone. + +### System Settings access + +Allows the application to change system settings like brightness on your phone. + +### Bluetooth, Nearby Devices + +Allows the application to pair with wearable device and to toggle Bluetooth state on your phone. + +### Phone Access + +Allows the application to know if an active call is present on your phone (for Call Controller feature) + +### Contacts + +Allows the application to display caller name, if available (for Call Controller feature) + +### Alarms and reminders + +Allows the application to schedule actions (for Timed Actions feature) \ No newline at end of file diff --git a/docs/root-access.md b/docs/root-access.md new file mode 100644 index 00000000..d5400c16 --- /dev/null +++ b/docs/root-access.md @@ -0,0 +1,13 @@ +--- +layout: default +title: Root Access +permalink: /root-access +--- + +# Root Access + +If you are reading this message you likely do not have **root** permissions on your device. + +If you do not know what **root** is, [this article](https://www.androidauthority.com/root-android-277350/) explains what it is. _**I am not responsible for anything that may happen if you decide to root your device.**_ + +If your device is actually rooted, please check if this app was denied root access in your Superuser/Magisk/etc manager app. \ No newline at end of file diff --git a/docs/secure-settings-access.md b/docs/secure-settings-access.md new file mode 100644 index 00000000..53607e37 --- /dev/null +++ b/docs/secure-settings-access.md @@ -0,0 +1,79 @@ +--- +layout: default +title: Enable WRITE_SECURE_SETTINGS permission +permalink: /secure-settings-access +--- + +# Enable WRITE_SECURE_SETTINGS permission + +In order for SimpleWear to change **location** settings without root access, it needs the WRITE_SECURE_SETTINGS permission. As this is a system permission, it cannot be requested in app and has to be enabled manually. Please use the following tools to enable the permission. + +### **Windows** +1. **Download the ZIP file** + + Download the ZIP file [here]({{ site.github.repository_url}}/releases/download/v1.9.0_r0/SettingsEnabler.zip), and unzip it to a folder on your computer. + +2. **Enable USB Debugging** + + Enable **Developer Options**. Go to the system **Settings** app. In the **About Phone** section, find the **Build Number** option. Tap **7 times** on the **Build Number** option to enable Developer Options. + + Next, find the new **Developer Options** section. This should be on the previous page or in the **System** section (this may be different on your device). + + In **Developer Options**, enable **USB Debugging**. Once it is enabled, connect your device to your computer via USB. If a popup appears asking to enable **USB Debugging**, click **Allow**. + +3. **Run the tool** + + Once your device is connected, find the folder where you extracted the .zip file. + + Double click the _**run.bat**_ file to run the tool. + +4. **All done!** + + You should now have the required permissions. Restart the SimpleWear app on your phone to verify if the permission was enabled. + + You may disable **Developer Options** as it is no longer needed. + +### **Linux** +1. **Download ADB (SDK Platform Tools)** + + Download [SDK Platform-Tools for Linux](https://developer.android.com/studio/releases/platform-tools.html), and extract the .zip file to a folder on your computer. + +2. **Enable USB Debugging** + + Follow the same instructions to enable **USB Debugging** on Windows. + +3. **Run the following commands** + + Once your device is connected, find the folder where you extracted the .zip file. + + Open the Terminal in the unzipped folder and run the following commands: + + `./adb shell pm grant com.thewizrd.simplewear android.permission.WRITE_SECURE_SETTINGS` + + `./adb shell pm grant com.thewizrd.wearsettings android.permission.WRITE_SECURE_SETTINGS` + +4. **All done!** + + You should now have the required permissions. Restart the SimpleWear app on your phone to verify if the permission was enabled. + + You may disable **Developer Options** as it is no longer needed. + +### ADB Commands +Already have or know how to use ADB? Just run the following commands: + +`./adb shell pm grant com.thewizrd.simplewear android.permission.WRITE_SECURE_SETTINGS` + +`./adb shell pm grant com.thewizrd.wearsettings android.permission.WRITE_SECURE_SETTINGS` + +### Errors +If you see a similar error when running the script: + +`Exception occurred while executing 'grant': +java.lang.SecurityException: grantRuntimePermission: Neither user xxxx nor current process has android.permission.GRANT_RUNTIME_PERMISSIONS.` + +Please check if you have one of the following settings in Developer options and enable it: + +- USB debugging (Security settings) +- Disable Permission Monitoring + +Lastly, reboot and try again \ No newline at end of file diff --git a/docs/settings-helper.md b/docs/settings-helper.md new file mode 100644 index 00000000..a1ccdc72 --- /dev/null +++ b/docs/settings-helper.md @@ -0,0 +1,30 @@ +--- +layout: default +title: SimpleWear Settings Helper +permalink: /settings-helper +--- + +# SimpleWear Settings Helper + +Companion app for SimpleWear + +Latest version: [SimpleWear Settings v1.3.0]({{ site.github.repository_url}}/releases/download/v1.16.0_beta/wearsettings-release-1.3.0.apk) + +Previous version: [SimpleWear Settings v1.2.0]({{ site.github.repository_url}}/releases/download/v1.15.2-release/wearsettings-release-1.2.0.apk) + +## WiFi and Location Toggle + +NOTE: As of Android 10 (or Q), non-system apps are no longer allowed to toggle Wi-Fi on or off. This helper app is needed in order to allow SimpleWear to toggle Wi-Fi. The helper app is built for an older version of Android which allows it to be able to toggle Wi-Fi. + +**SimpleWear**, on its own, is unable to change system settings like mobile data and location. +In order to change these settings, the app requires the **WRITE_SECURE_SETTINGS** permission. This has to be enabled manually by the user and cannot be requested/enabled within the app. + +If you are using Android 10 and above, and want to toggle **Wi-Fi** settings or are on any Android version and want to toggle **location and mobile data**, the **SimpleWear Settings** helper app is needed. + +## Mobile Data + +SimpleWear is unable to toggle mobile data without system permissions. [Root access](./root-access) or for unrooted devices, [Shizuku](https://github.com/RikkaApps/Shizuku) can be used. Please follow the instructions in app to start. + +## Bluetooth + +As of Android 13 (or T), non-system apps are no longer allowed to toggle Bluetooth on or off. This helper app is needed in order to allow SimpleWear to toggle Bluetooth . The helper app is built for an older version of Android which allows it to be able to toggle Bluetooth. \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index fe1b06c4..a9a76950 100644 --- a/gradle.properties +++ b/gradle.properties @@ -15,6 +15,5 @@ org.gradle.jvmargs=-Xmx1536m # Android operating system, and which are packaged with your app's APK # https://developer.android.com/topic/libraries/support-library/androidx-rn android.useAndroidX=true -android.defaults.buildfeatures.buildconfig=true android.nonTransitiveRClass=false android.nonFinalResIds=false \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 84708681..cfd35bc0 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip diff --git a/hidden-api/build.gradle b/hidden-api/build.gradle index dfaecb8a..8cec97d3 100644 --- a/hidden-api/build.gradle +++ b/hidden-api/build.gradle @@ -40,5 +40,5 @@ android { dependencies { annotationProcessor 'dev.rikka.tools.refine:annotation-processor:4.4.0' compileOnly 'dev.rikka.tools.refine:annotation:4.4.0' - implementation 'androidx.annotation:annotation:1.8.2' + implementation 'androidx.annotation:annotation:1.9.1' } \ No newline at end of file diff --git a/hidden-api/src/main/java/android/net/IIntResultListener.java b/hidden-api/src/main/java/android/net/IIntResultListener.java new file mode 100644 index 00000000..f53a4ee0 --- /dev/null +++ b/hidden-api/src/main/java/android/net/IIntResultListener.java @@ -0,0 +1,24 @@ +package android.net; + +import android.os.Binder; +import android.os.IBinder; +import android.os.IInterface; + +public interface IIntResultListener extends IInterface { + void onResult(int resultCode); + + abstract class Stub extends Binder implements IIntResultListener { + public Stub() { + throw new UnsupportedOperationException(); + } + + @Override + public android.os.IBinder asBinder() { + throw new UnsupportedOperationException(); + } + + public static IIntResultListener asInterface(IBinder obj) { + throw new RuntimeException("Stub!"); + } + } +} diff --git a/hidden-api/src/main/java/android/net/ITetheringConnector.java b/hidden-api/src/main/java/android/net/ITetheringConnector.java new file mode 100644 index 00000000..39ea41dd --- /dev/null +++ b/hidden-api/src/main/java/android/net/ITetheringConnector.java @@ -0,0 +1,31 @@ +package android.net; + +import android.os.Binder; +import android.os.Build; +import android.os.IBinder; +import android.os.IInterface; + +import androidx.annotation.DeprecatedSinceApi; +import androidx.annotation.RequiresApi; + +public interface ITetheringConnector extends IInterface { + @RequiresApi(Build.VERSION_CODES.R) + @DeprecatedSinceApi(api = Build.VERSION_CODES.S) + void startTethering(TetheringRequestParcel request, String callerPkg, IIntResultListener receiver); + + @RequiresApi(Build.VERSION_CODES.R) + @DeprecatedSinceApi(api = Build.VERSION_CODES.S) + void stopTethering(int type, String callerPkg, IIntResultListener receiver); + + @RequiresApi(Build.VERSION_CODES.S) + void startTethering(TetheringRequestParcel request, String callerPkg, String callingAttributionTag, IIntResultListener receiver); + + @RequiresApi(Build.VERSION_CODES.S) + void stopTethering(int type, String callerPkg, String callingAttributionTag, IIntResultListener receiver); + + abstract class Stub extends Binder implements ITetheringConnector { + public static ITetheringConnector asInterface(IBinder obj) { + throw new RuntimeException("Stub!"); + } + } +} \ No newline at end of file diff --git a/hidden-api/src/main/java/android/net/TetheringRequestParcel.java b/hidden-api/src/main/java/android/net/TetheringRequestParcel.java new file mode 100644 index 00000000..879cff06 --- /dev/null +++ b/hidden-api/src/main/java/android/net/TetheringRequestParcel.java @@ -0,0 +1,4 @@ +package android.net; + +public class TetheringRequestParcel { +} diff --git a/mobile/build.gradle b/mobile/build.gradle index 125d8135..9c029078 100644 --- a/mobile/build.gradle +++ b/mobile/build.gradle @@ -20,9 +20,10 @@ android { minSdkVersion rootProject.minSdkVersion targetSdkVersion rootProject.targetSdkVersion - // NOTE: Version Code Format [TargetSDK, Version Name, Build Number, Variant Code (Android: 00, WearOS: 01)] - versionCode 341915050 - versionName "1.15.2" + // NOTE: Version Code Format [TargetSDK, Version Name, Build Number, Variant Code (Android: 0, WearOS: 1)] + // NOTE: update SUPPORTED_VERSION_CODE if needed + versionCode 341916040 + versionName "1.16.0" vectorDrawables.useSupportLibrary = true } @@ -45,6 +46,7 @@ android { buildFeatures { dataBinding true viewBinding true + buildConfig true } compileOptions { @@ -89,7 +91,7 @@ dependencies { implementation "androidx.work:work-runtime-ktx:$work_version" implementation "androidx.core:core-splashscreen:$coresplash_version" - implementation 'com.google.android.gms:play-services-wearable:18.2.0' + implementation 'com.google.android.gms:play-services-wearable:19.0.0' implementation 'com.google.android.play:app-update-ktx:2.1.0' implementation platform("com.google.firebase:firebase-bom:$firebase_version") diff --git a/mobile/src/main/java/com/thewizrd/simplewear/App.kt b/mobile/src/main/java/com/thewizrd/simplewear/App.kt index 763af6f3..dbec23da 100644 --- a/mobile/src/main/java/com/thewizrd/simplewear/App.kt +++ b/mobile/src/main/java/com/thewizrd/simplewear/App.kt @@ -9,6 +9,7 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter +import android.content.SharedPreferences import android.content.pm.PackageManager import android.database.ContentObserver import android.location.LocationManager @@ -24,15 +25,16 @@ import android.provider.Settings import android.telephony.TelephonyManager import android.util.Log import androidx.core.content.ContextCompat +import androidx.preference.PreferenceManager import androidx.work.Configuration import com.google.android.material.color.DynamicColors -import com.google.firebase.crashlytics.FirebaseCrashlytics import com.thewizrd.shared_resources.ApplicationLib -import com.thewizrd.shared_resources.SimpleLibrary +import com.thewizrd.shared_resources.SharedModule import com.thewizrd.shared_resources.actions.Actions import com.thewizrd.shared_resources.actions.BatteryStatus +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.JSONParser import com.thewizrd.shared_resources.utils.Logger import com.thewizrd.simplewear.camera.TorchListener @@ -41,21 +43,11 @@ import com.thewizrd.simplewear.media.MediaControllerService import com.thewizrd.simplewear.services.CallControllerService import com.thewizrd.simplewear.telephony.SubscriptionListener import com.thewizrd.simplewear.wearable.WearableWorker +import kotlinx.coroutines.cancel import kotlin.system.exitProcess -class App : Application(), ApplicationLib, ActivityLifecycleCallbacks, Configuration.Provider { - 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(), ActivityLifecycleCallbacks, Configuration.Provider { + private lateinit var applicationState: AppState private var mActivitiesStarted = 0 private lateinit var mActionsReceiver: BroadcastReceiver @@ -63,29 +55,38 @@ class App : Application(), ApplicationLib, ActivityLifecycleCallbacks, Configura 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 + } - // 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) + // Init common action broadcast receiver mActionsReceiver = object : BroadcastReceiver() { private var mBatteryPct: Int? = null private var mIsBatteryCharging: Boolean? = null + private var mStreamVolumeMap = mutableMapOf() + override fun onReceive(context: Context, intent: Intent) { + Logger.debug("ActionsReceiver", "received action - ${intent.action}") + when (intent.action) { Intent.ACTION_BATTERY_CHANGED -> { val level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) @@ -141,6 +142,45 @@ class App : Application(), ApplicationLib, ActivityLifecycleCallbacks, Configura ) ) } + "android.media.VOLUME_CHANGED_ACTION" -> { + if (intent.hasExtra("android.media.EXTRA_VOLUME_STREAM_TYPE") && intent.hasExtra( + "android.media.EXTRA_VOLUME_STREAM_VALUE" + ) + ) { + val streamType = intent.getIntExtra( + "android.media.EXTRA_VOLUME_STREAM_TYPE", + AudioManager.USE_DEFAULT_STREAM_TYPE + ) + val streamVolume = intent.getIntExtra( + "android.media.EXTRA_VOLUME_STREAM_VALUE", + Int.MIN_VALUE + ) + + // Filter for supported streams + when (streamType) { + AudioManager.STREAM_MUSIC, + AudioManager.STREAM_RING, + AudioManager.STREAM_VOICE_CALL, + AudioManager.STREAM_ALARM -> { + Logger.debug( + "ActionsReceiver", + "volume changed - streamType(${streamType}), volume(${streamVolume})" + ) + + if (mStreamVolumeMap.getOrDefault( + streamType, + Int.MIN_VALUE + ) != streamVolume + ) { + WearableWorker.sendStatusUpdate( + context, WearableWorker.ACTION_SENDAUDIOSTREAMUPDATE, + streamType + ) + } + } + } + } + } } } } @@ -153,31 +193,51 @@ class App : Application(), ApplicationLib, ActivityLifecycleCallbacks, Configura addAction(NotificationManager.ACTION_INTERRUPTION_FILTER_CHANGED) addAction(WifiManager.WIFI_STATE_CHANGED_ACTION) addAction(BluetoothAdapter.ACTION_STATE_CHANGED) + addAction("android.media.VOLUME_CHANGED_ACTION") } // Receiver exported for system broadcasts ContextCompat.registerReceiver( - appContext, + applicationContext, mActionsReceiver, actionsFilter, ContextCompat.RECEIVER_EXPORTED ) + mContentObserver = object : ContentObserver(Handler(Looper.getMainLooper())) { + override fun onChange(selfChange: Boolean, uri: Uri?) { + val uriPath = uri.toString() + + if (uriPath.contains("mobile_data")) { + WearableWorker.sendActionUpdate(applicationContext, Actions.MOBILEDATA) + } else if (uriPath.contains(Settings.System.SCREEN_BRIGHTNESS)) { + WearableWorker.sendValueStatusUpdate(applicationContext, Actions.BRIGHTNESS) + } + } + } + + runCatching { + // Register listener for brightness settings + contentResolver.registerContentObserver( + Settings.System.getUriFor(Settings.System.SCREEN_BRIGHTNESS), + false, + mContentObserver + ) + contentResolver.registerContentObserver( + Settings.System.getUriFor(Settings.System.SCREEN_BRIGHTNESS_MODE), + false, + mContentObserver + ) + } + runCatching { - if (appContext.packageManager.hasSystemFeature(PackageManager.FEATURE_TELEPHONY)) { + if (applicationContext.packageManager.hasSystemFeature(PackageManager.FEATURE_TELEPHONY)) { // Register listener for mobile data setting (default sim) val setting = Settings.Global.getUriFor("mobile_data") - mContentObserver = object : ContentObserver(Handler(Looper.getMainLooper())) { - override fun onChange(selfChange: Boolean, uri: Uri?) { - super.onChange(selfChange, uri) - if (uri.toString().contains("mobile_data")) { - WearableWorker.sendActionUpdate(appContext, Actions.MOBILEDATA) - } - } - } contentResolver.registerContentObserver(setting, false, mContentObserver) - if (appContext.packageManager.hasSystemFeature(PackageManager.FEATURE_TELEPHONY_SUBSCRIPTION)) { - val telephonyManager = appContext.getSystemService(TelephonyManager::class.java) + if (applicationContext.packageManager.hasSystemFeature(PackageManager.FEATURE_TELEPHONY_SUBSCRIPTION)) { + val telephonyManager = + applicationContext.getSystemService(TelephonyManager::class.java) val modemCount = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { telephonyManager.supportedModemCount @@ -186,16 +246,16 @@ class App : Application(), ApplicationLib, ActivityLifecycleCallbacks, Configura } if (modemCount > 1) { - SubscriptionListener.registerListener(appContext) + SubscriptionListener.registerListener(applicationContext) } } } } runCatching { - if (appContext.packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_FLASH)) { + if (applicationContext.packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_FLASH)) { // Register listener for camera flash - TorchListener.registerListener(appContext) + TorchListener.registerListener(applicationContext) } } @@ -209,7 +269,7 @@ class App : Application(), ApplicationLib, ActivityLifecycleCallbacks, Configura } } - WearableWorker.sendStatusUpdate(appContext) + WearableWorker.sendStatusUpdate(applicationContext) if (com.thewizrd.simplewear.preferences.Settings.isBridgeMediaEnabled()) { MediaControllerService.enqueueWork( this, @@ -232,13 +292,13 @@ class App : Application(), ApplicationLib, ActivityLifecycleCallbacks, Configura } override fun onTerminate() { - SubscriptionListener.unregisterListener(appContext) - TorchListener.unregisterListener(appContext) + SubscriptionListener.unregisterListener(applicationContext) + TorchListener.unregisterListener(applicationContext) contentResolver.unregisterContentObserver(mContentObserver) - appContext.unregisterReceiver(mActionsReceiver) + applicationContext.unregisterReceiver(mActionsReceiver) // Shutdown logger Logger.shutdown() - SimpleLibrary.unregister() + appLib.appScope.cancel() super.onTerminate() } @@ -255,7 +315,9 @@ class App : Application(), ApplicationLib, ActivityLifecycleCallbacks, Configura applicationContext ) ) { - PhoneStatusHelper.deActivateDeviceAdmin(applicationContext) + runCatching { + PhoneStatusHelper.deActivateDeviceAdmin(applicationContext) + } } } } @@ -283,7 +345,7 @@ class App : Application(), ApplicationLib, ActivityLifecycleCallbacks, Configura 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/mobile/src/main/java/com/thewizrd/simplewear/FirebaseConfigurator.kt b/mobile/src/main/java/com/thewizrd/simplewear/FirebaseConfigurator.kt new file mode 100644 index 00000000..1de9676d --- /dev/null +++ b/mobile/src/main/java/com/thewizrd/simplewear/FirebaseConfigurator.kt @@ -0,0 +1,36 @@ +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.ContextUtils.isLargeTablet +import com.thewizrd.shared_resources.utils.ContextUtils.isSmallestWidth +import com.thewizrd.shared_resources.utils.ContextUtils.isTv +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, if (context.isTv()) { + "tv" + } else if (context.isLargeTablet() || context.isSmallestWidth(600)) { + "tablet" + } else { + "mobile" + } + ) + + FirebaseCrashlytics.getInstance().apply { + isCrashlyticsCollectionEnabled = true + sendUnsentReports() + } + + if (!BuildConfig.DEBUG) { + Logger.registerLogger(CrashlyticsLoggingTree()) + } + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/thewizrd/simplewear/camera/TorchListener.kt b/mobile/src/main/java/com/thewizrd/simplewear/camera/TorchListener.kt index ce3393a6..7c6326b8 100644 --- a/mobile/src/main/java/com/thewizrd/simplewear/camera/TorchListener.kt +++ b/mobile/src/main/java/com/thewizrd/simplewear/camera/TorchListener.kt @@ -7,8 +7,8 @@ import android.os.Handler import android.os.Looper import android.util.Log 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.helpers.PhoneStatusHelper import com.thewizrd.simplewear.wearable.WearableWorker import java.util.concurrent.Executors @@ -24,7 +24,7 @@ object TorchListener { object : CameraManager.TorchCallback() { override fun onTorchModeChanged(cameraId: String, enabled: Boolean) { if (cameraId == primaryCameraId.value) { - val context = App.instance.appContext + val context = appLib.context isTorchEnabled = enabled @@ -39,8 +39,7 @@ object TorchListener { } private val primaryCameraId = lazy { - val cameraMgr = - App.instance.appContext.getSystemService(Context.CAMERA_SERVICE) as CameraManager + val cameraMgr = appLib.context.getSystemService(Context.CAMERA_SERVICE) as CameraManager cameraMgr.cameraIdList[0] } diff --git a/mobile/src/main/java/com/thewizrd/simplewear/helpers/PhoneStatusHelper.kt b/mobile/src/main/java/com/thewizrd/simplewear/helpers/PhoneStatusHelper.kt index bc805fca..a82571d9 100644 --- a/mobile/src/main/java/com/thewizrd/simplewear/helpers/PhoneStatusHelper.kt +++ b/mobile/src/main/java/com/thewizrd/simplewear/helpers/PhoneStatusHelper.kt @@ -18,8 +18,6 @@ import android.content.pm.PackageManager import android.hardware.camera2.CameraManager import android.location.LocationManager import android.media.AudioManager -import android.net.ConnectivityManager -import android.net.wifi.WifiConfiguration import android.net.wifi.WifiManager import android.os.BatteryManager import android.os.Build @@ -36,7 +34,6 @@ import androidx.annotation.RequiresApi import androidx.core.app.PendingIntentCompat import androidx.core.content.ContextCompat import androidx.core.location.LocationManagerCompat -import com.android.dx.stock.ProxyBuilder import com.thewizrd.shared_resources.actions.Action import com.thewizrd.shared_resources.actions.ActionStatus import com.thewizrd.shared_resources.actions.Actions @@ -58,6 +55,7 @@ import com.thewizrd.simplewear.receivers.PhoneBroadcastReceiver import com.thewizrd.simplewear.services.TorchService import com.thewizrd.simplewear.services.TorchService.Companion.enqueueWork import com.thewizrd.simplewear.services.WearAccessibilityService +import com.thewizrd.simplewear.utils.BrightnessUtils import com.thewizrd.simplewear.utils.hasAssociations import kotlinx.coroutines.delay import kotlinx.coroutines.suspendCancellableCoroutine @@ -702,22 +700,87 @@ object PhoneStatusHelper { return Settings.System.canWrite(context) } - fun getBrightnessLevel(context: Context): ValueActionState { + @SuppressLint("DiscouragedPrivateApi") + private fun getSystemBrightnessLevel(context: Context): ValueActionState { + val powerMan = context.getSystemService(Context.POWER_SERVICE) as PowerManager + val contentResolver = context.applicationContext.contentResolver - return ValueActionState( - Settings.System.getInt(contentResolver, Settings.System.SCREEN_BRIGHTNESS, 0), - 0, 255, Actions.BRIGHTNESS - ) + val brightnessLevel = + Settings.System.getInt(contentResolver, Settings.System.SCREEN_BRIGHTNESS, 0) + val brightnessState = ValueActionState(brightnessLevel, 0, 255, Actions.BRIGHTNESS) + + runCatching { + val getMaxMethod = + powerMan.javaClass.getDeclaredMethod("getMaximumScreenBrightnessSetting") + //val getMinMethod = powerMan.javaClass.getDeclaredMethod("getMinimumScreenBrightnessSetting") + + val max = getMaxMethod.invoke(powerMan) as Int + //val min = getMinMethod.invoke(powerMan) as Int + + brightnessState.maxValue = max + //brightnessState.minValue = min + } + + return brightnessState + } + + fun getBrightnessLevel(context: Context): ValueActionState { + val systemBrightness = getSystemBrightnessLevel(context) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + val gammaValue = BrightnessUtils.convertLinearToGamma( + systemBrightness.currentValue, + systemBrightness.minValue, + systemBrightness.maxValue + ) + return ValueActionState( + gammaValue, + BrightnessUtils.GAMMA_SPACE_MIN, + BrightnessUtils.GAMMA_SPACE_MAX, + Actions.BRIGHTNESS + ) + } else { + return systemBrightness + } } fun setBrightnessLevel(context: Context, value: Int): ActionStatus { if (isWriteSystemSettingsPermissionEnabled(context)) { return try { + val brightnessLevel = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + val systemBrightness = getSystemBrightnessLevel(context) + BrightnessUtils.convertGammaToLinear( + value, + systemBrightness.minValue, + systemBrightness.maxValue + ) + } else { + value + } + val contentResolver = context.applicationContext.contentResolver val retVal = Settings.System.putInt( contentResolver, Settings.System.SCREEN_BRIGHTNESS, - value + brightnessLevel + ) + if (retVal) ActionStatus.SUCCESS else ActionStatus.FAILURE + } catch (e: Exception) { + Logger.writeLine(Log.ERROR, e) + ActionStatus.FAILURE + } + } + return ActionStatus.PERMISSION_DENIED + } + + private fun setBrightnessLevel(context: Context, value: Float): ActionStatus { + if (isWriteSystemSettingsPermissionEnabled(context)) { + return try { + val contentResolver = context.applicationContext.contentResolver + val retVal = Settings.System.putInt( + contentResolver, + Settings.System.SCREEN_BRIGHTNESS, + value.roundToInt() ) if (retVal) ActionStatus.SUCCESS else ActionStatus.FAILURE } catch (e: Exception) { @@ -738,12 +801,12 @@ object PhoneStatusHelper { // Increase/decrease by 5% val value = when (direction) { ValueDirection.UP -> min( - 255, - max(0, (currentBrightness + (255 * 0.05f).roundToInt())) + 255f, + max(0f, (currentBrightness + (255 * 0.05f))) ) ValueDirection.DOWN -> min( - 255, - max(0, (currentBrightness - (255 * 0.05f).roundToInt())) + 255f, + max(0f, (currentBrightness - (255 * 0.05f))) ) } @@ -928,231 +991,8 @@ object PhoneStatusHelper { } } - /* - * Wifi Tethering Methods - * - * Credit to the following: - * https://github.com/aegis1980/WifiHotSpot - * https://stackoverflow.com/a/52219887 - * https://github.com/C-D-Lewis/dashboard - */ - private const val WIFI_AP_STATE_DISABLING = 10 - private const val WIFI_AP_STATE_DISABLED = 11 - private const val WIFI_AP_STATE_ENABLING = 12 - private const val WIFI_AP_STATE_ENABLED = 13 - private const val WIFI_AP_STATE_FAILED = 14 - - fun getWifiApState(context: Context): Int { - return runCatching { - if (ContextCompat.checkSelfPermission( - context, - Manifest.permission.ACCESS_WIFI_STATE - ) == PackageManager.PERMISSION_GRANTED - ) { - val wifiMan = - context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager - val getWifiApStateMethod = wifiMan.javaClass.getMethod("getWifiApState") - - val state = getWifiApStateMethod.invoke(wifiMan) as Int - - return state - } - - return WIFI_AP_STATE_FAILED - }.onFailure { - Logger.writeLine(Log.ERROR, it, "Error getting wifi AP state") - }.getOrDefault(WIFI_AP_STATE_ENABLED) - } - - fun isWifiApEnabled(context: Context): Boolean { - val state = getWifiApState(context) - - return when (state) { - WIFI_AP_STATE_ENABLED, WIFI_AP_STATE_ENABLING -> true - WIFI_AP_STATE_DISABLED, WIFI_AP_STATE_DISABLING -> false - else -> { - Logger.writeLine(Log.ERROR, "Invalid Wifi AP state: $state") - return false - } - } - } - - private fun getWifiApConfiguration(context: Context): WifiConfiguration? { - return runCatching { - val wifiMan = - context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager - val getWifiApConfigurationMethod = wifiMan.javaClass.getMethod("getWifiApConfiguration") - - val config = getWifiApConfigurationMethod.invoke(wifiMan) as? WifiConfiguration? + fun isWifiApEnabled(context: Context): Boolean = TetherHelper.isWifiApEnabled(context) - return config - }.onFailure { - Logger.writeLine(Log.ERROR, it, "Error getting wifi AP config") - }.getOrNull() - } - - fun setWifiApEnabled(context: Context, enable: Boolean): ActionStatus { - return runCatching { - if (ContextCompat.checkSelfPermission(context, Manifest.permission.CHANGE_WIFI_STATE) == - PackageManager.PERMISSION_GRANTED - ) { - val wifiMan = - context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - return if (isWriteSystemSettingsPermissionEnabled(context)) { - val retVal = if (enable) { - startTethering(context) - } else { - stopTethering(context) - } - - if (retVal) ActionStatus.SUCCESS else ActionStatus.FAILURE - } else { - ActionStatus.PERMISSION_DENIED - } - } else { - if (enable) { - // WiFi tethering requires WiFi to be off - wifiMan.isWifiEnabled = false - } - - val setWifiApEnabledMethod = wifiMan.javaClass.getMethod( - "setWifiApEnabled", - WifiConfiguration::class.java, - Boolean::class.java - ) - val retVal = setWifiApEnabledMethod.invoke( - wifiMan, - getWifiApConfiguration(context), - enable - ) as Boolean - return if (retVal) ActionStatus.SUCCESS else ActionStatus.FAILURE - } - } - return ActionStatus.PERMISSION_DENIED - }.onFailure { - Logger.writeLine(Log.ERROR, it, "Error setting wifi AP state") - }.getOrElse { - if (it is SecurityException || it.cause is SecurityException) { - ActionStatus.PERMISSION_DENIED - } else { - ActionStatus.FAILURE - } - } - } - - @RequiresApi(Build.VERSION_CODES.O) - private fun isTetheringActive(context: Context): Boolean { - return runCatching { - val cm = - context.applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - - val getTetheredIfacesMethod = cm.javaClass.getMethod("getTetheredIfaces") - val resArr = getTetheredIfacesMethod.invoke(cm) as? Array<*> - return !resArr.isNullOrEmpty() - }.onFailure { - Logger.writeLine(Log.ERROR, it, "Error getting tethering state") - }.getOrDefault(false) - } - - /* - * android.net - * ConnectivityManager / TetheringManager constants - */ - private const val TETHERING_INVALID = -1 - private const val TETHERING_WIFI = 0 - private const val TETHERING_USB = 1 - private const val TETHERING_BLUETOOTH = 2 - - @RequiresApi(Build.VERSION_CODES.O) - private fun startTethering(context: Context): Boolean { - return runCatching { - if (isTetheringActive(context)) { - return false - } - - val codeCacheDir = context.applicationContext.codeCacheDir - val proxy = try { - ProxyBuilder.forClass(getOnStartTetheringCallbackClass()) - .dexCache(codeCacheDir).handler { proxy, method, args -> - when (method?.name) { - "onTetheringStarted" -> { - Logger.writeLine(Log.INFO, "Proxy: onTetheringStarted") - } - "onTetheringFailed" -> { - Logger.writeLine( - Log.INFO, - "Proxy: onTetheringFailed: args = " + args.contentToString() - ) - } - else -> { - ProxyBuilder.callSuper(proxy, method, args) - } - } - - null - }.build() - } catch (e: Exception) { - Logger.writeLine(Log.ERROR, e, "startTethering: Error ProxyBuilder") - return@runCatching false - } - - val cm = - context.applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - - val method = cm.javaClass.getMethod( - "startTethering", - Int::class.java, - Boolean::class.java, - getOnStartTetheringCallbackClass(), - Handler::class.java - ) - method.invoke(cm, TETHERING_WIFI, false, proxy, null) - true - }.getOrElse { - if (it is SecurityException || it.cause is SecurityException) { - Logger.writeLine(Log.ERROR, it, "Permission denied starting tethering") - throw it - } else if (it is NoSuchMethodException) { - Logger.writeLine(Log.ERROR, "startTethering method is unavailable") - } else { - Logger.writeLine(Log.ERROR, it, "Error starting tethering") - } - - false - } - } - - @RequiresApi(Build.VERSION_CODES.O) - private fun stopTethering(context: Context): Boolean { - return runCatching { - val cm = - context.applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - val method = cm.javaClass.getMethod("stopTethering", Int::class.java) - method.invoke(cm, TETHERING_WIFI) - true - }.getOrElse { - if (it is SecurityException || it.cause is SecurityException) { - Logger.writeLine(Log.ERROR, it, "Permission denied stopping tethering") - throw it - } else if (it is NoSuchMethodException) { - Logger.writeLine(Log.ERROR, "stopTethering method is unavailable") - } else { - Logger.writeLine(Log.ERROR, it, "Error stopping tethering") - } - - false - } - } - - @RequiresApi(Build.VERSION_CODES.O) - private fun getOnStartTetheringCallbackClass(): Class<*>? { - return runCatching { - Class.forName("android.net.ConnectivityManager\$OnStartTetheringCallback") - }.onFailure { - Logger.writeLine(Log.ERROR, it, "Error getting OnStartTetheringCallback class") - }.getOrNull() - } - /* End of Wifi Tethering methods */ + fun setWifiApEnabled(context: Context, enable: Boolean): ActionStatus = + TetherHelper.setWifiApEnabled(context, enable) } \ No newline at end of file diff --git a/mobile/src/main/java/com/thewizrd/simplewear/helpers/TetherHelper.kt b/mobile/src/main/java/com/thewizrd/simplewear/helpers/TetherHelper.kt new file mode 100644 index 00000000..6db29340 --- /dev/null +++ b/mobile/src/main/java/com/thewizrd/simplewear/helpers/TetherHelper.kt @@ -0,0 +1,420 @@ +package com.thewizrd.simplewear.helpers + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.net.ConnectivityManager +import android.net.wifi.WifiConfiguration +import android.net.wifi.WifiManager +import android.os.Build +import android.os.Handler +import androidx.annotation.DeprecatedSinceApi +import androidx.annotation.RequiresApi +import androidx.core.content.ContextCompat +import com.android.dx.stock.ProxyBuilder +import com.thewizrd.shared_resources.actions.ActionStatus +import com.thewizrd.shared_resources.utils.Logger +import com.thewizrd.simplewear.helpers.PhoneStatusHelper.isWriteSystemSettingsPermissionEnabled +import java.lang.reflect.Proxy +import java.util.concurrent.Executor + +object TetherHelper { + private const val TAG = "TetherHelper" + + /* + * Wifi Tethering Methods + * + * Credit to the following: + * https://github.com/aegis1980/WifiHotSpot + * https://stackoverflow.com/a/52219887 + * https://github.com/C-D-Lewis/dashboard + */ + private const val WIFI_AP_STATE_DISABLING = 10 + private const val WIFI_AP_STATE_DISABLED = 11 + private const val WIFI_AP_STATE_ENABLING = 12 + private const val WIFI_AP_STATE_ENABLED = 13 + private const val WIFI_AP_STATE_FAILED = 14 + + fun getWifiApState(context: Context): Int { + return runCatching { + if (ContextCompat.checkSelfPermission( + context, + Manifest.permission.ACCESS_WIFI_STATE + ) == PackageManager.PERMISSION_GRANTED + ) { + val wifiMan = + context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager + val getWifiApStateMethod = wifiMan.javaClass.getMethod("getWifiApState") + + val state = getWifiApStateMethod.invoke(wifiMan) as Int + + return state + } + + return WIFI_AP_STATE_FAILED + }.onFailure { + Logger.error(TAG, it, "Error getting wifi AP state") + }.getOrDefault(WIFI_AP_STATE_ENABLED) + } + + fun isWifiApEnabled(context: Context): Boolean { + val state = getWifiApState(context) + + return when (state) { + WIFI_AP_STATE_ENABLED, WIFI_AP_STATE_ENABLING -> true + WIFI_AP_STATE_DISABLED, WIFI_AP_STATE_DISABLING -> false + else -> { + Logger.error(TAG, "Invalid Wifi AP state: $state") + return false + } + } + } + + private fun getWifiApConfiguration(context: Context): WifiConfiguration? { + return runCatching { + val wifiMan = + context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager + val getWifiApConfigurationMethod = wifiMan.javaClass.getMethod("getWifiApConfiguration") + + val config = getWifiApConfigurationMethod.invoke(wifiMan) as? WifiConfiguration? + + return config + }.onFailure { + Logger.error(TAG, it, "Error getting wifi AP config") + }.getOrNull() + } + + fun setWifiApEnabled(context: Context, enable: Boolean): ActionStatus { + return runCatching { + if (ContextCompat.checkSelfPermission(context, Manifest.permission.CHANGE_WIFI_STATE) == + PackageManager.PERMISSION_GRANTED + ) { + val wifiMan = + context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + return if (isWriteSystemSettingsPermissionEnabled(context)) { + val retVal = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + if (enable) startTethering(context) else stopTethering(context) + } else { + if (enable) startTetheringPreR(context) else stopTetheringPreR(context) + } + + if (retVal) ActionStatus.SUCCESS else ActionStatus.FAILURE + } else { + ActionStatus.PERMISSION_DENIED + } + } else { + if (enable) { + // WiFi tethering requires WiFi to be off + wifiMan.isWifiEnabled = false + } + + val setWifiApEnabledMethod = wifiMan.javaClass.getMethod( + "setWifiApEnabled", + WifiConfiguration::class.java, + Boolean::class.java + ) + val retVal = setWifiApEnabledMethod.invoke( + wifiMan, + getWifiApConfiguration(context), + enable + ) as Boolean + return if (retVal) ActionStatus.SUCCESS else ActionStatus.FAILURE + } + } + return ActionStatus.PERMISSION_DENIED + }.onFailure { + Logger.error(TAG, it, "Error setting wifi AP state") + }.getOrElse { + if (it is SecurityException || it.cause is SecurityException) { + ActionStatus.PERMISSION_DENIED + } else { + ActionStatus.FAILURE + } + } + } + + @RequiresApi(Build.VERSION_CODES.O) + @DeprecatedSinceApi(api = Build.VERSION_CODES.R) + private fun isTetheringActivePreR(context: Context): Boolean { + return runCatching { + val cm = + context.applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + + val getTetheredIfacesMethod = cm.javaClass.getMethod("getTetheredIfaces") + val resArr = getTetheredIfacesMethod.invoke(cm) as? Array<*> + return !resArr.isNullOrEmpty() && resArr.any { + it is String && (it.contains("wlan") || it.contains( + "softap" + )) + } + }.onFailure { + Logger.error(TAG, it, "Error getting tethering state") + }.getOrDefault(false) + } + + /* + * android.net + * ConnectivityManager / TetheringManager constants + */ + /* TetheringType */ + private const val TETHERING_INVALID = -1 + private const val TETHERING_WIFI = 0 + private const val TETHERING_USB = 1 + private const val TETHERING_BLUETOOTH = 2 + + /* 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 + + @RequiresApi(Build.VERSION_CODES.O) + @DeprecatedSinceApi(api = Build.VERSION_CODES.R) + private fun startTetheringPreR(context: Context): Boolean { + Logger.info(TAG, "entering startTetheringPreR...") + + return runCatching { + if (isTetheringActivePreR(context)) { + Logger.info(TAG, "tethering already enabled") + return false + } + + val codeCacheDir = context.applicationContext.codeCacheDir + val proxy = try { + ProxyBuilder.forClass(getConnMgrOnStartTetheringCallbackClass()) + .dexCache(codeCacheDir) + .handler { proxy, method, args -> + when (method?.name) { + "onTetheringStarted" -> { + Logger.info("Proxy", "onTetheringStarted") + } + + "onTetheringFailed" -> { + Logger.error( + "Proxy", + "onTetheringFailed: args = ${args.contentToString()}" + ) + } + + else -> { + ProxyBuilder.callSuper(proxy, method, args) + } + } + + null + }.build() + } catch (e: Exception) { + Logger.error(TAG, e, "startTethering: Error ProxyBuilder") + return@runCatching false + } + + val cm = + context.applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + + val method = cm.javaClass.getMethod( + "startTethering", + Int::class.java, /* type */ + Boolean::class.java, /* showProvisioningUi */ + getConnMgrOnStartTetheringCallbackClass(), + Handler::class.java + ) + method.invoke(cm, TETHERING_WIFI, false, proxy, null) + true + }.getOrElse { + if (it is SecurityException || it.cause is SecurityException) { + Logger.error(TAG, it, "Permission denied starting tethering") + throw it + } else if (it is NoSuchMethodException) { + Logger.error(TAG, "startTethering method is unavailable") + } else { + Logger.error(TAG, it, "Error starting tethering") + } + + false + } + } + + @RequiresApi(Build.VERSION_CODES.O) + @DeprecatedSinceApi(api = Build.VERSION_CODES.R) + private fun stopTetheringPreR(context: Context): Boolean { + Logger.info(TAG, "entering stopTetheringPreR...") + + return runCatching { + val cm = + context.applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val method = cm.javaClass.getMethod("stopTethering", Int::class.java) + method.invoke(cm, TETHERING_WIFI) + true + }.getOrElse { + if (it is SecurityException || it.cause is SecurityException) { + Logger.error(TAG, it, "Permission denied stopping tethering") + throw it + } else if (it is NoSuchMethodException) { + Logger.error(TAG, "stopTethering method is unavailable") + } else { + Logger.error(TAG, it, "Error stopping tethering") + } + + false + } + } + + @RequiresApi(Build.VERSION_CODES.O) + @DeprecatedSinceApi(api = Build.VERSION_CODES.R) + private fun getConnMgrOnStartTetheringCallbackClass(): Class<*>? { + return runCatching { + Class.forName("android.net.ConnectivityManager\$OnStartTetheringCallback") + }.onFailure { + Logger.error(TAG, it, "Error getting OnStartTetheringCallback class") + }.getOrNull() + } + + @RequiresApi(Build.VERSION_CODES.R) + private fun isTetheringActive(context: Context): Boolean { + return runCatching { + val getTetheredIfacesMethod = Class.forName("android.net.TetheringManager") + .getMethod("getTetheredIfaces") + val tetheringMgr = context.applicationContext.getSystemService(TETHERING_SERVICE) + + val resArr = getTetheredIfacesMethod.invoke(tetheringMgr) as? Array<*> + return !resArr.isNullOrEmpty() && resArr.any { + it is String && (it.contains("wlan") || it.contains("softap")) + } + }.getOrElse { + Logger.error(TAG, it, "Error getting tethering state") + // Fallback to ConnectivityManager + isTetheringActivePreR(context) + } + } + + @RequiresApi(Build.VERSION_CODES.R) + private fun startTethering( + context: Context, + exemptFromEntitlementCheck: Boolean = true, + shouldShowEntitlementUi: Boolean = false + ): Boolean { + Logger.info(TAG, "entering startTethering...") + + return runCatching { + if (isTetheringActive(context)) { + Logger.info(TAG, "tethering already enabled") + return false + } + + val tetherCallbackIface = getTetherMgrStartTetheringCallbackInterface() + val proxy = try { + Proxy.newProxyInstance( + tetherCallbackIface.classLoader, + arrayOf(tetherCallbackIface) + ) { _, method, args -> + when (method?.name) { + "onTetheringStarted" -> { + Logger.info("Proxy", "onTetheringStarted") + } + + "onTetheringFailed" -> { + val resultCode = args[0] as Int + + if (resultCode == TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION) { + // retry + startTethering(context, false, shouldShowEntitlementUi) + } else { + Logger.error("Proxy", "onTetheringFailed: code = $resultCode") + } + } + } + + null + } + } catch (e: Exception) { + Logger.error(TAG, e, "startTethering: Error Proxy") + return@runCatching false + } + + val tetheringMgr = context.applicationContext.getSystemService(TETHERING_SERVICE) + val tetheringMgrClass = Class.forName("android.net.TetheringManager") + + val method = tetheringMgrClass.getMethod( + "startTethering", + Class.forName("android.net.TetheringManager\$TetheringRequest"), /* request */ + Executor::class.java, + getTetherMgrStartTetheringCallbackInterface() + ) + method.invoke( + tetheringMgr, + createTetheringRequest(exemptFromEntitlementCheck, shouldShowEntitlementUi), + Executor { it.run() }, + proxy + ) + true + }.getOrElse { + if (it is SecurityException || it.cause is SecurityException) { + Logger.error(TAG, it, "Permission denied starting tethering") + throw it + } else if (it is NoSuchMethodException) { + Logger.error(TAG, "startTethering method is unavailable") + } else { + Logger.error(TAG, it, "Error starting tethering") + } + + false + } + } + + @RequiresApi(Build.VERSION_CODES.R) + private fun stopTethering(context: Context): Boolean { + Logger.info(TAG, "entering stopTethering...") + + return runCatching { + val tetheringMgr = context.applicationContext.getSystemService(TETHERING_SERVICE) + val tetheringMgrClass = Class.forName("android.net.TetheringManager") + val method = tetheringMgrClass.getMethod("stopTethering", Int::class.java) + method.invoke(tetheringMgr, TETHERING_WIFI) + true + }.getOrElse { + if (it is SecurityException || it.cause is SecurityException) { + Logger.error(TAG, it, "Permission denied stopping tethering") + throw it + } else if (it is NoSuchMethodException) { + Logger.error(TAG, "stopTethering method is unavailable") + } else { + Logger.error(TAG, it, "Error stopping tethering") + } + + false + } + } + + @RequiresApi(Build.VERSION_CODES.R) + private fun getTetherMgrStartTetheringCallbackInterface(): Class<*> { + return runCatching { + Class.forName("android.net.TetheringManager\$StartTetheringCallback") + }.onFailure { + Logger.error(TAG, it, "Error getting StartTetheringCallback class") + }.getOrThrow() + } + + 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) + } + } + } + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/thewizrd/simplewear/media/MediaControllerService.kt b/mobile/src/main/java/com/thewizrd/simplewear/media/MediaControllerService.kt index 57a6024b..080918ee 100644 --- a/mobile/src/main/java/com/thewizrd/simplewear/media/MediaControllerService.kt +++ b/mobile/src/main/java/com/thewizrd/simplewear/media/MediaControllerService.kt @@ -1,5 +1,6 @@ package com.thewizrd.simplewear.media +import android.annotation.SuppressLint import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager @@ -12,6 +13,7 @@ import android.content.Intent import android.content.pm.PackageManager import android.content.pm.ServiceInfo import android.content.res.Resources +import android.graphics.Bitmap import android.media.AudioManager import android.media.session.MediaController import android.media.session.MediaSessionManager @@ -21,7 +23,7 @@ import android.os.CountDownTimer import android.os.Handler import android.os.IBinder import android.os.Looper -import android.os.SystemClock +import android.os.PowerManager import android.provider.MediaStore import android.support.v4.media.MediaBrowserCompat import android.support.v4.media.MediaMetadataCompat @@ -34,27 +36,37 @@ import androidx.annotation.RequiresApi import androidx.core.app.NotificationCompat import androidx.core.content.ContextCompat import androidx.core.content.res.ResourcesCompat +import androidx.core.graphics.scale import androidx.media.MediaBrowserServiceCompat -import com.google.android.gms.wearable.DataClient -import com.google.android.gms.wearable.DataMap +import androidx.media.VolumeProviderCompat import com.google.android.gms.wearable.MessageClient import com.google.android.gms.wearable.MessageEvent -import com.google.android.gms.wearable.PutDataMapRequest import com.google.android.gms.wearable.Wearable import com.thewizrd.shared_resources.actions.ActionStatus import com.thewizrd.shared_resources.actions.AudioStreamState import com.thewizrd.shared_resources.actions.AudioStreamType import com.thewizrd.shared_resources.actions.ValueDirection +import com.thewizrd.shared_resources.data.AppItemData import com.thewizrd.shared_resources.helpers.MediaHelper -import com.thewizrd.shared_resources.helpers.WearableHelper import com.thewizrd.shared_resources.helpers.toImmutableCompatFlag +import com.thewizrd.shared_resources.media.ActionItem +import com.thewizrd.shared_resources.media.BrowseMediaItems +import com.thewizrd.shared_resources.media.CustomControls +import com.thewizrd.shared_resources.media.MediaItem +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.media.PositionState +import com.thewizrd.shared_resources.media.QueueItem +import com.thewizrd.shared_resources.media.QueueItems +import com.thewizrd.shared_resources.utils.ContextUtils.dpToPx import com.thewizrd.shared_resources.utils.ImageUtils +import com.thewizrd.shared_resources.utils.ImageUtils.toByteArray import com.thewizrd.shared_resources.utils.JSONParser import com.thewizrd.shared_resources.utils.Logger import com.thewizrd.shared_resources.utils.bytesToInt import com.thewizrd.shared_resources.utils.bytesToString +import com.thewizrd.shared_resources.utils.sequenceEqual import com.thewizrd.shared_resources.utils.stringToBytes import com.thewizrd.simplewear.R import com.thewizrd.simplewear.helpers.PhoneStatusHelper @@ -62,15 +74,19 @@ import com.thewizrd.simplewear.preferences.Settings import com.thewizrd.simplewear.services.NotificationListener import com.thewizrd.simplewear.wearable.WearableManager import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.tasks.await import timber.log.Timber +import java.lang.reflect.Type import java.util.Stack import java.util.concurrent.Executors @@ -79,6 +95,7 @@ class MediaControllerService : Service(), MessageClient.OnMessageReceivedListene private lateinit var mAudioManager: AudioManager private lateinit var mNotificationManager: NotificationManager private lateinit var mMediaSessionManager: MediaSessionManager + private lateinit var mPowerManager: PowerManager private var mSelectedMediaApp: MediaAppDetails? = null private var mSelectedPackageName: String? = null @@ -93,9 +110,10 @@ class MediaControllerService : Service(), MessageClient.OnMessageReceivedListene private lateinit var mAvailableMediaApps: MutableSet private lateinit var mWearableManager: WearableManager - private lateinit var mDataClient: DataClient private lateinit var mMessageClient: MessageClient + private lateinit var connectedNodes: MutableSet + private var mController: MediaControllerCompat? = null private var mBrowser: MediaBrowserCompat? = null //private var mBrowserExtraSuggested: MediaBrowserCompat? = null @@ -122,6 +140,8 @@ class MediaControllerService : Service(), MessageClient.OnMessageReceivedListene const val EXTRA_FORCEDISCONNECT = "SimpleWear.Droid.extra.FORCE_DISCONNECT" const val EXTRA_SOFTLAUNCH = "SimpleWear.Droid.extra.SOFT_LAUNCH" + private const val UPDATE_DELAY_MS = 500L + fun enqueueWork(context: Context, work: Intent) { if (NotificationListener.isEnabled(context)) { ContextCompat.startForegroundService(context, work) @@ -194,6 +214,7 @@ class MediaControllerService : Service(), MessageClient.OnMessageReceivedListene mAudioManager = getSystemService(AudioManager::class.java) mNotificationManager = getSystemService(NotificationManager::class.java) mMediaSessionManager = getSystemService(MediaSessionManager::class.java) + mPowerManager = getSystemService(PowerManager::class.java) mMediaSessionManager.addOnActiveSessionsChangedListener( this, @@ -201,10 +222,11 @@ class MediaControllerService : Service(), MessageClient.OnMessageReceivedListene ) mWearableManager = WearableManager(this) - mDataClient = Wearable.getDataClient(this) mMessageClient = Wearable.getMessageClient(this) mMessageClient.addListener(this) + connectedNodes = mutableSetOf() + mCustomControlsAdapter = CustomControlsAdapter() //mBrowseMediaItemsAdapter = BrowseMediaItemsAdapter(MediaHelper.MediaBrowserItemsPath) //mBrowseMediaItemsExtraSuggestedAdapter = BrowseMediaItemsAdapter(MediaHelper.MediaBrowserItemsExtraSuggestedPath) @@ -244,19 +266,21 @@ class MediaControllerService : Service(), MessageClient.OnMessageReceivedListene when (intent?.action) { ACTION_CONNECTCONTROLLER -> { - mSelectedPackageName = intent.getStringExtra(EXTRA_PACKAGENAME) + val selectedPackageName = intent.getStringExtra(EXTRA_PACKAGENAME) val isAutoLaunch = intent.getBooleanExtra(EXTRA_AUTOLAUNCH, false) val isSoftLaunch = intent.getBooleanExtra(EXTRA_SOFTLAUNCH, false) scope.launch { - if ((isAutoLaunch || mSelectedPackageName == mSelectedMediaApp?.packageName) && mController != null) return@launch + if ((isAutoLaunch || selectedPackageName == mSelectedMediaApp?.packageName) && mController != null) return@launch - if (!mSelectedPackageName.isNullOrBlank()) { + if (!selectedPackageName.isNullOrBlank()) { mSelectedMediaApp = mAvailableMediaApps.find { - it.packageName == mSelectedPackageName + it.packageName == selectedPackageName } + mSelectedPackageName = mSelectedMediaApp?.packageName connectMediaSession(isSoftLaunch) } else { + mSelectedPackageName = null findActiveMediaSession() } } @@ -279,12 +303,13 @@ class MediaControllerService : Service(), MessageClient.OnMessageReceivedListene override fun onDestroy() { removeMediaState() + connectedNodes.clear() mMessageClient.removeListener(this) mWearableManager.unregister() mMediaSessionManager.removeOnActiveSessionsChangedListener(this) - disconnectMedia() + disconnectMedia(invalidateData = true) stopForeground(true) scope.cancel() @@ -293,49 +318,11 @@ class MediaControllerService : Service(), MessageClient.OnMessageReceivedListene private fun removeMediaState() { scope.launch { - Timber.tag(TAG).d("removeMediaState") + Logger.debug(TAG, "removeMediaState") runCatching { - mDataClient.deleteDataItems( - WearableHelper.getWearDataUri(MediaHelper.MediaPlayerStateBridgePath) - ).await() - mDataClient.deleteDataItems( - WearableHelper.getWearDataUri(MediaHelper.MediaPlayerStatePath) - ).await() - mDataClient.deleteDataItems( - WearableHelper.getWearDataUri(MediaHelper.MediaBrowserItemsPath) - ).await() - mDataClient.deleteDataItems( - WearableHelper.getWearDataUri(MediaHelper.MediaActionsPath) - ).await() - mDataClient.deleteDataItems( - WearableHelper.getWearDataUri(MediaHelper.MediaQueueItemsPath) - ).await() - }.onFailure { - Logger.writeLine(Log.ERROR, it) - } - } - } - - private fun removeBrowserItems() { - scope.launch { - Timber.tag(TAG).d("removeBrowserItems") - runCatching { - mDataClient.deleteDataItems( - WearableHelper.getWearDataUri(MediaHelper.MediaBrowserItemsPath) - ).await() - }.onFailure { - Logger.writeLine(Log.ERROR, it) - } - } - } - - private fun removeBrowserExtraItems() { - scope.launch { - Timber.tag(TAG).d("removeBrowserExtraItems") - runCatching { - mDataClient.deleteDataItems( - WearableHelper.getWearDataUri(MediaHelper.MediaBrowserItemsExtraSuggestedPath) - ).await() + sendMediaPlayerState() + sendMediaArtwork(bitmap = null) + sendAppInfo(mediaAppDetails = null) }.onFailure { Logger.writeLine(Log.ERROR, it) } @@ -386,10 +373,9 @@ class MediaControllerService : Service(), MessageClient.OnMessageReceivedListene val firstActiveCtrlr = activeSessions.firstOrNull() if (firstActiveCtrlr != null) { // Check if active session has changed - val isPlaybackActive = isPlaybackStateActive(firstActiveCtrlr.playbackState?.state) - if (firstActiveCtrlr.packageName != mSelectedPackageName || !isPlaybackActive) { + if (mSelectedPackageName == null) { // If so reset - disconnectMedia(invalidateData = !isPlaybackActive) + disconnectMedia(invalidateData = true) mSelectedPackageName = firstActiveCtrlr.packageName mSelectedMediaApp = null } @@ -417,22 +403,6 @@ class MediaControllerService : Service(), MessageClient.OnMessageReceivedListene } } - private fun isPlaybackStateActive(state: Int?): 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 - } - } - private fun getMediaAppControllers() { scope.launch { val actionSessionDetails = MediaAppControllerUtils.getMediaAppsFromControllers( @@ -556,7 +526,7 @@ class MediaControllerService : Service(), MessageClient.OnMessageReceivedListene setupMediaController() } else { // failed - Timber.tag(TAG).d("MediaBrowser connection failed") + Logger.debug(TAG, "MediaBrowser connection failed") } } @@ -576,11 +546,13 @@ class MediaControllerService : Service(), MessageClient.OnMessageReceivedListene mCallback.onMetadataChanged(mController!!.metadata) mCallback.onAudioInfoChanged(mController!!.playbackInfo) - Timber.tag(TAG).d("MediaControllerCompat created") + sendAppInfo() + + Logger.debug(TAG, "MediaControllerCompat created") return true } catch (e: Exception) { // Failed to create MediaController from session token - Timber.tag(TAG).d("MediaBrowser connection failed") + Logger.debug(TAG, "MediaBrowser connection failed") return false } } @@ -588,35 +560,43 @@ class MediaControllerService : Service(), MessageClient.OnMessageReceivedListene private val mCallback = object : MediaControllerCompat.Callback() { private var updateJob: Job? = null + override fun onSessionReady() { + sendMediaInfo() + sendAppInfo() + } + + override fun onSessionDestroyed() { + disconnectMedia() + } + override fun onPlaybackStateChanged(state: PlaybackStateCompat?) { - Timber.tag(TAG).d("Callback: onPlaybackStateChanged") + Logger.debug(TAG, "Callback: onPlaybackStateChanged") playFromSearchTimer.cancel() updateJob?.cancel() updateJob = scope.launch { - delay(250) + delay(UPDATE_DELAY_MS) if (!isActive) return@launch onUpdate() onUpdateQueue() } - scope.launch { - if (state != null) { - mController?.let { - mCustomControlsAdapter.setActions(it, state.actions, state.customActions) - } - } else { - mCustomControlsAdapter.clearActions() + + if (state != null) { + mController?.let { + mCustomControlsAdapter.setActions(it, state.actions, state.customActions) } + } else { + mCustomControlsAdapter.clearActions() } } override fun onMetadataChanged(metadata: MediaMetadataCompat?) { - Timber.tag(TAG).d("Callback: onMetadataChanged") + Logger.debug(TAG, "Callback: onMetadataChanged") playFromSearchTimer.cancel() updateJob?.cancel() updateJob = scope.launch { - delay(250) + delay(UPDATE_DELAY_MS) if (!isActive) return@launch @@ -625,12 +605,14 @@ class MediaControllerService : Service(), MessageClient.OnMessageReceivedListene } } - override fun onAudioInfoChanged(info: MediaControllerCompat.PlaybackInfo?) { - sendVolumeStatus() + override fun onQueueChanged(queue: MutableList?) { + mController?.let { + mQueueItemsAdapter.setQueueItems(it, queue) + } } - override fun onSessionDestroyed() { - disconnectMedia() + override fun onAudioInfoChanged(info: MediaControllerCompat.PlaybackInfo?) { + scope.launch { sendVolumeStatus() } } private fun onUpdate() { @@ -641,189 +623,92 @@ class MediaControllerService : Service(), MessageClient.OnMessageReceivedListene mController?.let { mQueueItemsAdapter.setQueueItems(it, it.queue) } ?: run { - Timber.tag(TAG).e("Failed to update queue info, null MediaController.") - scope.launch { - runCatching { - mDataClient.deleteDataItems( - WearableHelper.getWearDataUri(MediaHelper.MediaQueueItemsPath) - ).await() - }.onFailure { - Logger.writeLine(Log.ERROR, it) - } - } + Logger.error(TAG, "Failed to update queue info, null MediaController.") + mQueueItemsAdapter.clear() } } } private fun sendControllerUnavailable() { - val mapRequest = PutDataMapRequest.create(MediaHelper.MediaPlayerStatePath) - - mapRequest.dataMap.putString( - MediaHelper.KEY_MEDIA_PLAYBACKSTATE, - PlaybackState.NONE.name - ) - - // Check if supports play from search - mapRequest.dataMap.putBoolean( - MediaHelper.KEY_MEDIA_SUPPORTS_PLAYFROMSEARCH, - supportsPlayFromSearch() - ) - - val request = mapRequest.asPutDataRequest() - request.setUrgent() - scope.launch { - runCatching { - Timber.tag(TAG).d("Making request: %s", mapRequest.uri) - mDataClient.deleteDataItems(mapRequest.uri).await() - mDataClient.putDataItem(request).await() - Timber.tag(TAG).d("Removing media bridge") - mDataClient.deleteDataItems(WearableHelper.getWearDataUri(MediaHelper.MediaPlayerStateBridgePath)) - .await() - mDataClient.deleteDataItems( - WearableHelper.getWearDataUri(MediaHelper.MediaBrowserItemsPath) - ).await() - mDataClient.deleteDataItems( - WearableHelper.getWearDataUri(MediaHelper.MediaActionsPath) - ).await() - mDataClient.deleteDataItems( - WearableHelper.getWearDataUri(MediaHelper.MediaQueueItemsPath) - ).await() - }.onFailure { - Logger.writeLine(Log.ERROR, it) - } + sendMediaPlayerState() } } + @SuppressLint("RestrictedApi") private fun sendMediaInfo() { - val mapRequest = PutDataMapRequest.create(MediaHelper.MediaPlayerStatePath) - if (mController == null) { - Timber.tag(TAG).e("Failed to update media info, null MediaController.") + Logger.error(TAG, "Failed to update media info, null MediaController.") scope.launch { - runCatching { - mDataClient.deleteDataItems(mapRequest.uri).await() - mDataClient.deleteDataItems(WearableHelper.getWearDataUri(MediaHelper.MediaPlayerStateBridgePath)) - .await() - }.onFailure { - Logger.writeLine(Log.ERROR, it) - } + sendMediaPlayerState() } return } - val playbackState = runCatching { - mController?.playbackState?.state - }.onFailure { - Logger.writeLine(Log.ERROR, it) - }.getOrDefault(PlaybackStateCompat.STATE_NONE) - - val stateName = when (playbackState) { - 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 - } - } - - mapRequest.dataMap.putString(MediaHelper.KEY_MEDIA_PLAYBACKSTATE, stateName.name) + val playbackState = mController?.playbackState + val stateName = playbackState?.toPlaybackState() ?: PlaybackState.NONE val mediaMetadata = mController?.metadata - var songTitle: String? = null - if (mediaMetadata != null && !mediaMetadata.isNullOrEmpty()) { - mapRequest.dataMap.putString( - MediaHelper.KEY_MEDIA_METADATA_TITLE, - (mediaMetadata.getString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE) - ?: mediaMetadata.getString(MediaMetadataCompat.METADATA_KEY_TITLE)).also { - songTitle = it - } - ) - mapRequest.dataMap.putString( - MediaHelper.KEY_MEDIA_METADATA_ARTIST, - mediaMetadata.getString(MediaMetadataCompat.METADATA_KEY_ARTIST) - ?: mediaMetadata.getString(MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST) - ) + val playerState: MediaPlayerState + var mediaMetaData: MediaMetaData? = null + var artBitmap: Bitmap? = null - val art = mediaMetadata.getBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART) + if (mediaMetadata != null && !mediaMetadata.isNullOrEmpty()) { + artBitmap = mediaMetadata.getBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART) ?: mediaMetadata.getBitmap(MediaMetadataCompat.METADATA_KEY_ART) - if (art != null) { - mapRequest.dataMap.putAsset( - MediaHelper.KEY_MEDIA_METADATA_ART, - ImageUtils.createAssetFromBitmap(art) - ) - } - mController?.let { + val positionState = mController?.let { val durationMs = mediaMetadata.getLong(MediaMetadataCompat.METADATA_KEY_DURATION) if (durationMs > 0) { - mapRequest.dataMap.putString( - MediaHelper.KEY_MEDIA_POSITIONSTATE, - JSONParser.serializer( - PositionState( - durationMs, - it.playbackState?.position ?: 0, - it.playbackState?.playbackSpeed ?: 1f - ), - PositionState::class.java - ) + PositionState( + durationMs = durationMs, + currentPositionMs = it.playbackState?.getCurrentPosition(null) ?: 0, + playbackSpeed = it.playbackState?.playbackSpeed ?: 1f ) + } else { + null } } - } else { - mapRequest.dataMap.putString( - MediaHelper.KEY_MEDIA_PLAYBACKSTATE, - PlaybackState.NONE.name + + mediaMetaData = MediaMetaData( + title = mediaMetadata.getString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE) + ?: mediaMetadata.getString(MediaMetadataCompat.METADATA_KEY_TITLE), + artist = mediaMetadata.getString(MediaMetadataCompat.METADATA_KEY_ARTIST) + ?: mediaMetadata.getString(MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST), + positionState = positionState ?: PositionState() ) - } - val request = mapRequest.asPutDataRequest() - request.setUrgent() + playerState = MediaPlayerState( + playbackState = stateName, + mediaMetaData = mediaMetaData + ) + } else { + playerState = MediaPlayerState() + } scope.launch { runCatching { - Timber.tag(TAG).d("Making request: %s", mapRequest.uri) + Logger.debug(TAG, "sending media info") - //mDataClient.deleteDataItems(mapRequest.uri).await() - mDataClient.putDataItem(request).await() + sendMediaPlayerState(playerState = playerState) + sendMediaArtwork(bitmap = artBitmap) if (Settings.isBridgeMediaEnabled()) { - if (isPlaybackStateActive(playbackState)) { - Timber.tag(TAG).d("Create media bridge request") - - mDataClient.putDataItem( - PutDataMapRequest.create(MediaHelper.MediaPlayerStateBridgePath).apply { - dataMap.putString( - MediaHelper.KEY_MEDIA_METADATA_TITLE, - songTitle ?: "" - ) - dataMap.putLong("time", SystemClock.uptimeMillis()) - } - .setUrgent() - .asPutDataRequest() - ).await() + if (playbackState?.isPlaybackStateActive() == true) { + Logger.debug(TAG, "Create media bridge request") + mWearableManager.sendMessage( + null, + MediaHelper.MediaPlayerStateBridgePath, + JSONParser.serializer(mediaMetaData, MediaMetaData::class.java) + ?.stringToBytes() + ) } else { - Timber.tag(TAG).d("Removing media bridge; playbackstate inactive") - - mDataClient.deleteDataItems( - WearableHelper.getWearDataUri(MediaHelper.MediaPlayerStateBridgePath) - ).await() + Logger.debug(TAG, "Removing media bridge; playbackstate inactive") + mWearableManager.sendMessage( + null, + MediaHelper.MediaPlayerStateBridgePath, + null + ) } } }.onFailure { @@ -832,6 +717,28 @@ class MediaControllerService : Service(), MessageClient.OnMessageReceivedListene } } + private fun sendAppInfo(mediaAppDetails: MediaAppDetails? = mSelectedMediaApp) { + scope.launch { + val appInfo = mediaAppDetails?.let { + val size = dpToPx(48f).toInt() + + AppItemData( + label = it.appName, + packageName = it.packageName, + activityName = runCatching { + packageManager.getLaunchIntentForPackage(it.packageName)?.component?.className + }.getOrNull(), + iconBitmap = it.icon.scale(size, size).toByteArray() + ) + } + + val jsonData = JSONParser.serializer(appInfo, AppItemData::class.java)?.stringToBytes() + + Logger.debug(TAG, "sendAppInfo - bytes (${jsonData?.size ?: 0})") + mWearableManager.sendMessage(null, MediaHelper.MediaPlayerAppInfoPath, jsonData) + } + } + override fun onMessageReceived(messageEvent: MessageEvent) { when (messageEvent.path) { MediaHelper.MediaActionsClickPath -> { @@ -896,25 +803,35 @@ class MediaControllerService : Service(), MessageClient.OnMessageReceivedListene } MediaHelper.MediaVolumeUpPath -> { if (!isNotificationListenerEnabled(messageEvent)) return - if (mController != null) { - mController!!.adjustVolume(AudioManager.ADJUST_RAISE, 0) - } else { + + var flags = AudioManager.FLAG_PLAY_SOUND + if (mPowerManager.isInteractive) flags = flags or AudioManager.FLAG_SHOW_UI + + mController?.takeIf { + it.playbackInfo?.volumeControl == VolumeProviderCompat.VOLUME_CONTROL_RELATIVE || it.playbackInfo?.volumeControl == VolumeProviderCompat.VOLUME_CONTROL_ABSOLUTE + }?.adjustVolume(AudioManager.ADJUST_RAISE, flags) ?: run { PhoneStatusHelper.setVolume(this, ValueDirection.UP, AudioStreamType.MUSIC) } - sendVolumeStatus(messageEvent.sourceNodeId) + + scope.launch { sendVolumeStatus(messageEvent.sourceNodeId) } } MediaHelper.MediaVolumeDownPath -> { if (!isNotificationListenerEnabled(messageEvent)) return - if (mController != null) { - mController!!.adjustVolume(AudioManager.ADJUST_LOWER, 0) - } else { + + var flags = AudioManager.FLAG_PLAY_SOUND + if (mPowerManager.isInteractive) flags = flags or AudioManager.FLAG_SHOW_UI + + mController?.takeIf { + it.playbackInfo?.volumeControl == VolumeProviderCompat.VOLUME_CONTROL_RELATIVE || it.playbackInfo?.volumeControl == VolumeProviderCompat.VOLUME_CONTROL_ABSOLUTE + }?.adjustVolume(AudioManager.ADJUST_LOWER, flags) ?: run { PhoneStatusHelper.setVolume(this, ValueDirection.DOWN, AudioStreamType.MUSIC) } - sendVolumeStatus(messageEvent.sourceNodeId) + + scope.launch { sendVolumeStatus(messageEvent.sourceNodeId) } } MediaHelper.MediaVolumeStatusPath -> { if (!isNotificationListenerEnabled(messageEvent)) return - sendVolumeStatus(messageEvent.sourceNodeId) + scope.launch { sendVolumeStatus(messageEvent.sourceNodeId) } } MediaHelper.MediaSetVolumePath -> { if (!isNotificationListenerEnabled(messageEvent)) return @@ -924,14 +841,57 @@ class MediaControllerService : Service(), MessageClient.OnMessageReceivedListene if (!isActive) return@launch - if (mController != null) { - mController!!.setVolumeTo(value, 0) - } else { - mAudioManager.setStreamVolume(AudioManager.STREAM_MUSIC, value, 0) + var flags = AudioManager.FLAG_PLAY_SOUND + if (mPowerManager.isInteractive) flags = flags or AudioManager.FLAG_SHOW_UI + + mController?.takeIf { + it.playbackInfo?.volumeControl == VolumeProviderCompat.VOLUME_CONTROL_ABSOLUTE + }?.setVolumeTo(value, flags) ?: run { + mAudioManager.setStreamVolume(AudioManager.STREAM_MUSIC, value, flags) } + + if (!isActive) return@launch + sendVolumeStatus(messageEvent.sourceNodeId) } } + MediaHelper.MediaActionsPath -> { + if (!isNotificationListenerEnabled(messageEvent)) return + mCustomControlsAdapter.onDatasetChanged() + } + + MediaHelper.MediaBrowserItemsPath -> { + if (!isNotificationListenerEnabled(messageEvent)) return + //mBrowseMediaItemsAdapter.onDatasetChanged() + } + + MediaHelper.MediaBrowserItemsExtraSuggestedPath -> { + if (!isNotificationListenerEnabled(messageEvent)) return + //mBrowseMediaItemsExtraSuggestedAdapter.onDatasetChanged() + } + + MediaHelper.MediaQueueItemsPath -> { + if (!isNotificationListenerEnabled(messageEvent)) return + mQueueItemsAdapter.onDatasetChanged() + } + + MediaHelper.MediaPlayerStatePath -> { + if (!isNotificationListenerEnabled(messageEvent)) return + sendMediaInfo() + } + + MediaHelper.MediaPlayerConnectPath -> { + connectedNodes.add(messageEvent.sourceNodeId) + } + + MediaHelper.MediaPlayerDisconnectPath -> { + connectedNodes.remove(messageEvent.sourceNodeId) + } + + MediaHelper.MediaPlayerAppInfoPath -> { + if (!isNotificationListenerEnabled(messageEvent)) return + sendAppInfo() + } } } @@ -1008,28 +968,67 @@ class MediaControllerService : Service(), MessageClient.OnMessageReceivedListene } } - private fun sendVolumeStatus(nodeID: String? = null) { - scope.launch { - val volStatus = mController?.playbackInfo?.let { - AudioStreamState( - it.currentVolume, - 0, - it.maxVolume, - AudioStreamType.MUSIC - ) - } ?: PhoneStatusHelper.getStreamVolume( - this@MediaControllerService, + private suspend fun sendDataByChannel(path: String, data: Any?, type: Type) { + val jobs = connectedNodes.toList().map { node -> + scope.async(Dispatchers.IO) { + runCatching { + Wearable.getChannelClient(this@MediaControllerService).run { + val channel = openChannel(node, path).await() + val outputStream = getOutputStream(channel).await() + + outputStream.bufferedWriter().use { writer -> + writer.write("data: ${JSONParser.serializer(data, type)}") + writer.newLine() + writer.flush() + } + close(channel) + } + }.onFailure { + Logger.error(TAG, it, "error sending data to channel; path = $path") + } + } + }.toTypedArray() + + awaitAll(*jobs) + } + + private suspend fun sendMediaPlayerState( + nodeID: String? = null, + playerState: MediaPlayerState = MediaPlayerState() + ) { + mWearableManager.sendMessage( + nodeID, + MediaHelper.MediaPlayerStatePath, + JSONParser.serializer(playerState, MediaPlayerState::class.java).stringToBytes() + ) + } + + private suspend fun sendMediaArtwork(nodeID: String? = null, bitmap: Bitmap? = null) { + val artworkBytes = bitmap?.toByteArray(format = Bitmap.CompressFormat.JPEG, quality = 50) + Logger.debug(TAG, "sendArtwork - bytes (${artworkBytes?.size ?: 0})") + mWearableManager.sendMessage(nodeID, MediaHelper.MediaPlayerArtPath, artworkBytes) + } + + private suspend fun sendVolumeStatus(nodeID: String? = null) { + val volStatus = mController?.playbackInfo?.let { + AudioStreamState( + it.currentVolume, + 0, + it.maxVolume, AudioStreamType.MUSIC ) + } ?: PhoneStatusHelper.getStreamVolume( + this@MediaControllerService, + AudioStreamType.MUSIC + ) - mWearableManager.sendMessage( - nodeID, MediaHelper.MediaVolumeStatusPath, - JSONParser.serializer( - volStatus, - AudioStreamState::class.java - )?.stringToBytes() - ) - } + mWearableManager.sendMessage( + nodeID, MediaHelper.MediaVolumeStatusPath, + JSONParser.serializer( + volStatus, + AudioStreamState::class.java + )?.stringToBytes() + ) } private inner class CustomControlsAdapter { @@ -1042,107 +1041,83 @@ class MediaControllerService : Service(), MessageClient.OnMessageReceivedListene fun onDatasetChanged() { updateJob?.cancel() - updateJob = scope.launch { - delay(250) + updateJob = scope.launch(Dispatchers.Default) { + delay(UPDATE_DELAY_MS) if (!isActive) return@launch - val mapRequest = PutDataMapRequest.create(MediaHelper.MediaActionsPath) - if (mActions.isEmpty() && !supportsPlayFromSearch) { // Remove all items (datamap) - scope.launch { - runCatching { - mDataClient.deleteDataItems(mapRequest.uri).await() - }.onFailure { - Logger.writeLine(Log.ERROR, it) - } - } + sendDataByChannel( + MediaHelper.MediaActionsPath, + null, + CustomControls::class.java + ) return@launch } // Send action items to datamap - val dataMapList = - ArrayList(mActions.size + if (supportsPlayFromSearch) 1 else 0) + val actions = + ArrayList(mActions.size + if (supportsPlayFromSearch) 1 else 0) - if (supportsPlayFromSearch) { - val d = DataMap().apply { - putString( - MediaHelper.KEY_MEDIA_ACTIONITEM_ACTION, - MediaHelper.ACTIONITEM_PLAY - ) - putString( - MediaHelper.KEY_MEDIA_ACTIONITEM_TITLE, - getString(R.string.action_musicplayback) - ) + val iconSize = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + 24f, + resources.displayMetrics + ).toInt() + if (supportsPlayFromSearch) { + runCatching { val iconDrawable = ContextCompat.getDrawable( applicationContext, R.drawable.ic_baseline_play_circle_filled_24 ) - val size = TypedValue.applyDimension( - TypedValue.COMPLEX_UNIT_DIP, - 24f, - resources.displayMetrics - ).toInt() - - putAsset( - MediaHelper.KEY_MEDIA_ACTIONITEM_ICON, - ImageUtils.createAssetFromBitmap( - ImageUtils.bitmapFromDrawable(iconDrawable!!, size, size) + actions.add( + ActionItem( + action = MediaHelper.ACTIONITEM_PLAY, + title = getString(R.string.action_musicplayback), + icon = ImageUtils.bitmapFromDrawable( + iconDrawable!!, + iconSize, + iconSize + ).toByteArray() ) ) } - - dataMapList.add(d) } - mActions.forEach { - val d = DataMap().apply { - putString(MediaHelper.KEY_MEDIA_ACTIONITEM_ACTION, it.action) - putString(MediaHelper.KEY_MEDIA_ACTIONITEM_TITLE, it.name.toString()) - mMediaAppResources?.let { mediaResources -> - val iconDrawable = try { - ResourcesCompat.getDrawable( - mediaResources, it.icon, /* theme = */null - ) - } catch (e: Exception) { - Logger.writeLine(Log.ERROR, e) - null - } - - if (iconDrawable != null) { - val size = TypedValue.applyDimension( - TypedValue.COMPLEX_UNIT_DIP, - 24f, - resources.displayMetrics - ).toInt() - - putAsset( - MediaHelper.KEY_MEDIA_ACTIONITEM_ICON, - ImageUtils.createAssetFromBitmap( - ImageUtils.bitmapFromDrawable(iconDrawable, size, size) + actions.addAll( + mActions.map { + ActionItem( + action = it.action, + title = it.name.toString(), + icon = mMediaAppResources?.let { mediaResources -> + val iconDrawable = try { + ResourcesCompat.getDrawable( + mediaResources, it.icon, /* theme = */null ) - ) + } catch (e: Exception) { + Logger.writeLine(Log.ERROR, e) + null + } + + iconDrawable?.let { drw -> + ImageUtils.bitmapFromDrawable(drw, iconSize, iconSize) + .toByteArray() + } } - } + ) } + ) - dataMapList.add(d) - } - - mapRequest.dataMap.putDataMapArrayList(MediaHelper.KEY_MEDIAITEMS, dataMapList) - - val request = mapRequest.asPutDataRequest() - request.setUrgent() + Logger.debug(TAG, "Sending media custom actions") - Timber.tag(TAG).d("Making request: %s", mapRequest.uri) - runCatching { - mDataClient.deleteDataItems(mapRequest.uri).await() - mDataClient.putDataItem(request).await() - }.onFailure { - Logger.writeLine(Log.ERROR, it) - } + val customControls = CustomControls(actions) + sendDataByChannel( + MediaHelper.MediaActionsPath, + customControls, + CustomControls::class.java + ) } } @@ -1218,58 +1193,33 @@ class MediaControllerService : Service(), MessageClient.OnMessageReceivedListene private fun onDatasetChanged() { updateJob?.cancel() - updateJob = scope.launch { - delay(250) + updateJob = scope.launch(Dispatchers.Default) { + delay(UPDATE_DELAY_MS) if (!isActive) return@launch - val mapRequest = PutDataMapRequest.create(itemNodePath) - if (mNodes.size == 0 || mItems.isNullOrEmpty()) { // Remove all items (datamap) - scope.launch { - runCatching { - mDataClient.deleteDataItems(mapRequest.uri).await() - }.onFailure { - Logger.writeLine(Log.ERROR, it) - } - } + sendDataByChannel(itemNodePath, null, BrowseMediaItems::class.java) return@launch } // Send media items to datamap - val dataMapList = ArrayList(mItems!!.size) - mItems!!.forEach { - val d = DataMap().apply { - putString(MediaHelper.KEY_MEDIAITEM_ID, it.mediaId ?: "") - putString( - MediaHelper.KEY_MEDIAITEM_TITLE, - it.description.title.toString() - ) - if (it.description.iconBitmap != null) { - putAsset( - MediaHelper.KEY_MEDIAITEM_ICON, - ImageUtils.createAssetFromBitmap(it.description.iconBitmap!!) - ) - } - } - - dataMapList.add(d) + val mediaItems = mItems?.map { + MediaItem( + mediaId = it.mediaId ?: "", + title = it.description.title.toString(), + icon = it.description.iconBitmap?.toByteArray() + ) } - mapRequest.dataMap.putDataMapArrayList(MediaHelper.KEY_MEDIAITEMS, dataMapList) - mapRequest.dataMap.putBoolean(MediaHelper.KEY_MEDIAITEM_ISROOT, treeDepth() <= 1) + Logger.debug(TAG, "Sending media browser items for path = $itemNodePath") - val request = mapRequest.asPutDataRequest() - request.setUrgent() - - Timber.tag(TAG).d("Making request: %s", mapRequest.uri) - runCatching { - mDataClient.deleteDataItems(mapRequest.uri).await() - mDataClient.putDataItem(request).await() - }.onFailure { - Logger.writeLine(Log.ERROR, it) - } + val browserItems = BrowseMediaItems( + isRoot = treeDepth() <= 1, + mediaItems = mediaItems ?: emptyList() + ) + sendDataByChannel(itemNodePath, browserItems, BrowseMediaItems::class.java) } } @@ -1355,62 +1305,34 @@ class MediaControllerService : Service(), MessageClient.OnMessageReceivedListene fun onDatasetChanged() { updateJob?.cancel() - updateJob = scope.launch { - delay(250) + updateJob = scope.launch(Dispatchers.Default) { + delay(UPDATE_DELAY_MS) if (!isActive) return@launch - val mapRequest = PutDataMapRequest.create(MediaHelper.MediaQueueItemsPath) - if (mQueueItems.isEmpty()) { // Remove all items (datamap) - scope.launch { - runCatching { - mDataClient.deleteDataItems(mapRequest.uri).await() - }.onFailure { - Logger.writeLine(Log.ERROR, it) - } - } + sendDataByChannel(MediaHelper.MediaQueueItemsPath, null, QueueItems::class.java) return@launch } // Send action items to datamap - val dataMapList = ArrayList(mQueueItems.size) - - mQueueItems.forEach { - val d = DataMap().apply { - putLong(MediaHelper.KEY_MEDIAITEM_ID, it.queueId) - putString( - MediaHelper.KEY_MEDIAITEM_TITLE, - it.description.title.toString() - ) - if (it.description.iconBitmap != null) { - putAsset( - MediaHelper.KEY_MEDIAITEM_ICON, - ImageUtils.createAssetFromBitmap(it.description.iconBitmap!!) - ) - } - } - - dataMapList.add(d) + val queueItems = mQueueItems.map { + QueueItem( + queueId = it.queueId, + title = it.description.title.toString(), + icon = it.description.iconBitmap?.toByteArray() + ) } - mapRequest.dataMap.putDataMapArrayList(MediaHelper.KEY_MEDIAITEMS, dataMapList) - mapRequest.dataMap.putLong( - MediaHelper.KEY_MEDIA_ACTIVEQUEUEITEM_ID, - mActiveQueueItemId - ) - - val request = mapRequest.asPutDataRequest() - request.setUrgent() + Logger.debug(TAG, "Sending media queue items") - Timber.tag(TAG).d("Making request: %s", mapRequest.uri) - runCatching { - mDataClient.deleteDataItems(mapRequest.uri).await() - mDataClient.putDataItem(request).await() - }.onFailure { - Logger.writeLine(Log.ERROR, it) - } + sendDataByChannel( + MediaHelper.MediaQueueItemsPath, QueueItems( + activeQueueItemId = mActiveQueueItemId, + queueItems = queueItems + ), QueueItems::class.java + ) } } @@ -1422,13 +1344,22 @@ class MediaControllerService : Service(), MessageClient.OnMessageReceivedListene controller: MediaControllerCompat, queueItems: List? ) { - 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