diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 958c9b09..ddaad4b2 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -97,6 +97,16 @@ android:resource="@xml/file_provider_paths" /> + + + + + + + + val adapterItems = state.profiles.map { profile -> + WidgetProfileSelectionVH.Item( + profile = profile, + isSelected = profile.id == state.selectedProfile, + onProfileClick = { vm.selectProfile(it.id) } + ) + } + profileAdapter.asyncDiffer.submitUpdate(adapterItems) + + ui.proRequiredCaption.isVisible = !state.isPro + + if (state.isPro) { + ui.confirmButton.text = getString(android.R.string.ok) + ui.confirmButton.isEnabled = state.canConfirm + } else { + ui.confirmButton.text = getString(R.string.general_upgrade_action) + ui.confirmButton.isEnabled = true + } + } + } + + private fun confirmSelection() { + vm.confirmSelection() + + val resultValue = Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, widgetId) + setResult(RESULT_OK, resultValue) + + val appWidgetManager = AppWidgetManager.getInstance(this@WidgetConfigurationActivity) + + WidgetProvider.updateWidget( + context = this@WidgetConfigurationActivity, + appWidgetManager = appWidgetManager, + widgetId = widgetId + ) + + finish() + + } + + companion object { + private val TAG = logTag("Widget", "ConfigurationActivity") + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetConfigurationViewModel.kt b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetConfigurationViewModel.kt new file mode 100644 index 00000000..e251a23f --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetConfigurationViewModel.kt @@ -0,0 +1,79 @@ +package eu.darken.capod.main.ui.widget + +import android.appwidget.AppWidgetManager +import androidx.lifecycle.SavedStateHandle +import dagger.hilt.android.lifecycle.HiltViewModel +import eu.darken.capod.common.coroutine.DispatcherProvider +import eu.darken.capod.common.debug.logging.log +import eu.darken.capod.common.debug.logging.logTag +import eu.darken.capod.common.flow.combine +import eu.darken.capod.common.uix.ViewModel3 +import eu.darken.capod.common.upgrade.UpgradeRepo +import eu.darken.capod.profiles.core.DeviceProfile +import eu.darken.capod.profiles.core.DeviceProfilesRepo +import eu.darken.capod.profiles.core.ProfileId +import kotlinx.coroutines.flow.MutableStateFlow +import javax.inject.Inject + +@HiltViewModel +class WidgetConfigurationViewModel @Inject constructor( + private val savedStateHandle: SavedStateHandle, + dispatcherProvider: DispatcherProvider, + private val deviceProfilesRepo: DeviceProfilesRepo, + private val widgetSettings: WidgetSettings, + private val upgradeRepo: UpgradeRepo, +) : ViewModel3(dispatcherProvider) { + + val widgetId: Int + get() = savedStateHandle.get(AppWidgetManager.EXTRA_APPWIDGET_ID) ?: AppWidgetManager.INVALID_APPWIDGET_ID + + init { + log(TAG) { "ViewModel init(widgetId=$widgetId)" } + + if (widgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { + log(TAG) { "Invalid widget ID" } + } + } + + private val selectedProfile = MutableStateFlow(widgetSettings.getWidgetProfile(widgetId)) + + val state = combine( + selectedProfile, + deviceProfilesRepo.profiles, + upgradeRepo.upgradeInfo, + ) { selected, profiles, upgradeInfo -> + log(TAG) { "loadProfiles()" } + + + State( + profiles = profiles, + isPro = upgradeInfo.isPro, + selectedProfile = selected, + ) + }.asLiveData2() + + data class State( + val profiles: List = emptyList(), + val selectedProfile: ProfileId? = null, + val isPro: Boolean = false, + ) { + val canConfirm: Boolean = selectedProfile != null + } + + fun selectProfile(profileId: ProfileId) { + log(TAG) { "selectProfile(profileId=$profileId)" } + selectedProfile.value = profileId + } + + fun confirmSelection() { + val selectedProfile = selectedProfile.value + if (selectedProfile != null) { + log(TAG) { "confirmSelection(widgetId=$widgetId, selectedProfile=$selectedProfile)" } + widgetSettings.saveWidgetProfile(widgetId, selectedProfile) + } + } + + companion object { + private val TAG = logTag("Widget", "ConfigurationVM") + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetProfileSelectionAdapter.kt b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetProfileSelectionAdapter.kt new file mode 100644 index 00000000..61f04056 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetProfileSelectionAdapter.kt @@ -0,0 +1,35 @@ +package eu.darken.capod.main.ui.widget + +import android.view.ViewGroup +import androidx.annotation.LayoutRes +import androidx.viewbinding.ViewBinding +import eu.darken.capod.common.lists.BindableVH +import eu.darken.capod.common.lists.differ.AsyncDiffer +import eu.darken.capod.common.lists.differ.DifferItem +import eu.darken.capod.common.lists.differ.HasAsyncDiffer +import eu.darken.capod.common.lists.differ.setupDiffer +import eu.darken.capod.common.lists.modular.ModularAdapter +import eu.darken.capod.common.lists.modular.mods.DataBinderMod +import eu.darken.capod.common.lists.modular.mods.TypedVHCreatorMod +import javax.inject.Inject + +class WidgetProfileSelectionAdapter @Inject constructor() : + ModularAdapter>(), + HasAsyncDiffer { + + override val asyncDiffer: AsyncDiffer<*, Item> = setupDiffer() + + init { + modules.add(DataBinderMod(data)) + modules.add(TypedVHCreatorMod({ data[it] is WidgetProfileSelectionVH.Item }) { WidgetProfileSelectionVH(it) }) + } + + override fun getItemCount(): Int = data.size + + abstract class BaseVH( + @LayoutRes layoutId: Int, + parent: ViewGroup + ) : VH(layoutId, parent), BindableVH + + interface Item : DifferItem +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetProfileSelectionVH.kt b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetProfileSelectionVH.kt new file mode 100644 index 00000000..07b1b1c2 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetProfileSelectionVH.kt @@ -0,0 +1,46 @@ +package eu.darken.capod.main.ui.widget + +import android.view.ViewGroup +import eu.darken.capod.R +import eu.darken.capod.common.lists.binding +import eu.darken.capod.databinding.WidgetConfigurationProfileItemBinding +import eu.darken.capod.pods.core.PodDevice +import eu.darken.capod.profiles.core.DeviceProfile + +class WidgetProfileSelectionVH(parent: ViewGroup) : + WidgetProfileSelectionAdapter.BaseVH( + R.layout.widget_configuration_profile_item, + parent + ) { + + override val viewBinding = lazy { + WidgetConfigurationProfileItemBinding.bind(itemView) + } + + override val onBindData: WidgetConfigurationProfileItemBinding.( + item: Item, + payloads: List + ) -> Unit = binding(payload = true) { item -> + val profile = item.profile + + profileName.text = profile.label + val modelText = when (profile.model) { + PodDevice.Model.UNKNOWN -> context.getString(R.string.pods_unknown_label) + else -> profile.model.label + } + profileModel.text = modelText + profileIcon.setImageResource(profile.model.iconRes) + profileRadio.isChecked = item.isSelected + root.isChecked = item.isSelected + + root.setOnClickListener { item.onProfileClick(profile) } + } + + data class Item( + val profile: DeviceProfile, + val isSelected: Boolean, + val onProfileClick: (DeviceProfile) -> Unit, + ) : WidgetProfileSelectionAdapter.Item { + override val stableId: Long = profile.id.hashCode().toLong() + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetProvider.kt b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetProvider.kt index 6a017e4b..15c9f07b 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetProvider.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetProvider.kt @@ -35,6 +35,7 @@ import eu.darken.capod.pods.core.getBatteryLevelCase import eu.darken.capod.pods.core.getBatteryLevelHeadset import eu.darken.capod.pods.core.getBatteryLevelLeftPod import eu.darken.capod.pods.core.getBatteryLevelRightPod +import eu.darken.capod.profiles.core.ProfileId import finish2 import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -50,6 +51,7 @@ class WidgetProvider : AppWidgetProvider() { @Inject lateinit var podDeviceCache: PodDeviceCache @Inject lateinit var podFactory: PodFactory @Inject lateinit var upgradeRepo: UpgradeRepo + @Inject lateinit var widgetSettings: WidgetSettings @AppScope @Inject lateinit var appScope: CoroutineScope private fun executeAsync( @@ -97,6 +99,15 @@ class WidgetProvider : AppWidgetProvider() { } } + override fun onDeleted(context: Context, appWidgetIds: IntArray) { + log(TAG) { "onDeleted(appWidgetIds=${appWidgetIds.toList()})" } + executeAsync("onDeleted") { + appWidgetIds.forEach { widgetId -> + widgetSettings.removeWidget(widgetId) + } + } + } + /** * Returns number of cells needed for given size of the widget. * @@ -124,9 +135,10 @@ class WidgetProvider : AppWidgetProvider() { widgetId: Int, options: Bundle? ) { - log(TAG) { "updateWidget(widgetId=$widgetId, options=$options)" } + val profileId: ProfileId? = widgetSettings.getWidgetProfile(widgetId) + log(TAG) { "updateWidget(widgetId=$widgetId, profileId=$profileId options=$options)" } - val device: PodDevice? = podMonitor.latestMainDevice() + val device: PodDevice? = profileId?.let { podMonitor.getDeviceForProfile(it) } val layout = when { !upgradeRepo.isPro() -> createUpgradeRequiredLayout(context) @@ -141,15 +153,16 @@ class WidgetProvider : AppWidgetProvider() { * added if these restrictions will ever be loosened in the future. */ val layout = when (columns) { - in 1 .. 4 -> R.layout.widget_pod_dual_compact_layout + in 1..4 -> R.layout.widget_pod_dual_compact_layout else -> R.layout.widget_pod_dual_wide_layout } createDualPodLayout(context, device, layout) } + device is SinglePodDevice -> createSinglePodLayout(context, device) device is PodDevice -> createUnknownPodLayout(context, device) - else -> createNoDeviceLayout(context) + else -> createNoDeviceLayout(context, profileId != null) } widgetManager.updateAppWidget(widgetId, layout) } @@ -191,8 +204,9 @@ class WidgetProvider : AppWidgetProvider() { private fun createNoDeviceLayout( context: Context, + hasConfiguredProfile: Boolean = false ): RemoteViews = RemoteViews(context.packageName, R.layout.widget_message_layout).apply { - log(TAG, VERBOSE) { "createNoDeviceLayout(context=$context)" } + log(TAG, VERBOSE) { "createNoDeviceLayout(context=$context, hasConfiguredProfile=$hasConfiguredProfile)" } val pendingIntent: PendingIntent = PendingIntent.getActivity( context, 0, @@ -202,7 +216,12 @@ class WidgetProvider : AppWidgetProvider() { setOnClickPendingIntent(R.id.widget_root, pendingIntent) - setTextViewText(R.id.primary, context.getString(R.string.overview_nomaindevice_label)) + val messageRes = if (hasConfiguredProfile) { + R.string.widget_no_data_label + } else { + R.string.overview_nomaindevice_label + } + setTextViewText(R.id.primary, context.getString(messageRes)) } private fun createDualPodLayout( @@ -286,5 +305,13 @@ class WidgetProvider : AppWidgetProvider() { companion object { val TAG = logTag("Widget", "Provider") + + fun updateWidget(context: Context, appWidgetManager: AppWidgetManager, widgetId: Int) { + val intent = Intent(context, WidgetProvider::class.java).apply { + action = AppWidgetManager.ACTION_APPWIDGET_UPDATE + putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, intArrayOf(widgetId)) + } + context.sendBroadcast(intent) + } } } \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetSettings.kt b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetSettings.kt new file mode 100644 index 00000000..e0bdae3d --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetSettings.kt @@ -0,0 +1,50 @@ +package eu.darken.capod.main.ui.widget + +import android.content.Context +import android.content.SharedPreferences +import androidx.core.content.edit +import dagger.hilt.android.qualifiers.ApplicationContext +import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE +import eu.darken.capod.common.debug.logging.log +import eu.darken.capod.common.debug.logging.logTag +import eu.darken.capod.profiles.core.ProfileId +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class WidgetSettings @Inject constructor( + @ApplicationContext private val context: Context +) { + + private val preferences: SharedPreferences = context.getSharedPreferences( + "widget_preferences", + Context.MODE_PRIVATE + ) + + fun saveWidgetProfile(widgetId: Int, profileId: ProfileId) { + log(TAG, VERBOSE) { "saveWidgetProfile(widgetId=$widgetId, profileId=$profileId)" } + preferences.edit { + putString(getWidgetProfileKey(widgetId), profileId) + } + } + + fun getWidgetProfile(widgetId: Int): ProfileId? { + val profileId = preferences.getString(getWidgetProfileKey(widgetId), null) + log(TAG, VERBOSE) { "getWidgetProfile(widgetId=$widgetId) = $profileId" } + return profileId + } + + fun removeWidget(widgetId: Int) { + log(TAG, VERBOSE) { "removeWidget(widgetId=$widgetId)" } + preferences.edit { + remove(getWidgetProfileKey(widgetId)) + } + } + + private fun getWidgetProfileKey(widgetId: Int): String = "$WIDGET_PROFILE_PREFIX$widgetId" + + companion object { + private const val WIDGET_PROFILE_PREFIX = "widget_profile_" + private val TAG = logTag("Widget", "Settings") + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/monitor/core/PodDeviceCache.kt b/app/src/main/java/eu/darken/capod/monitor/core/PodDeviceCache.kt index a0763e7b..dcea022a 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/PodDeviceCache.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/PodDeviceCache.kt @@ -28,47 +28,54 @@ class PodDeviceCache @Inject constructor( private val cacheDir by lazy { File(context.cacheDir, "device_cache").apply { mkdirs() } } - private val mainDeviceCacheFile = File(cacheDir, "main_device.raw") private val jsonAdapter = moshi.adapter() private val lock = Mutex() - suspend fun saveMainDevice(device: BleScanResult?) = withContext(dispatcherProvider.IO) { - log(TAG, VERBOSE) { "saveMainDevice(device=$device)" } - lock.withLock { - try { - if (device == null) { - mainDeviceCacheFile.delete() - } else { - val json = jsonAdapter.toJson(device) - mainDeviceCacheFile.writeText(json) - } - } catch (e: Exception) { - log(TAG, ERROR) { "Failed to save $device:${e.asLog()}" } - } - } - } + private fun ProfileId.toCacheFile(): File = File(cacheDir, "profile_${this}.json") - suspend fun loadMainDevice(): BleScanResult? = withContext(dispatcherProvider.IO) { - log(TAG, VERBOSE) { "loadMainDevice()" } + suspend fun load(id: ProfileId): BleScanResult? = withContext(dispatcherProvider.IO) { + log(TAG, VERBOSE) { "load(id=$id)" } + val cacheFile = id.toCacheFile() lock.withLock { - if (!mainDeviceCacheFile.exists()) return@withLock null + if (!cacheFile.exists()) return@withLock null try { - val raw = mainDeviceCacheFile.readText() + val raw = cacheFile.readText() jsonAdapter.fromJson(raw) } catch (e: Exception) { - log(TAG, ERROR) { "Failed to read main-device:${e.asLog()}" } + log(TAG, ERROR) { "Failed to read profile $id device: ${e.asLog()}, deleting corrupted cache file" } + cacheFile.delete() null } } } - fun load(id: ProfileId): BleScanResult? { - log(TAG, VERBOSE) { "load(): $id" } - return null + suspend fun saveAll(data: Map) { + log(TAG, VERBOSE) { "saveAll(): ${data.size} entries" } + lock.withLock { + data.forEach { (id, device) -> + log(TAG, VERBOSE) { "save(id=$id, device=$device)" } + val cacheFile = id.toCacheFile() + try { + val json = jsonAdapter.toJson(device) + cacheFile.writeText(json) + } catch (e: Exception) { + log(TAG, ERROR) { "Failed to save profile $id device $device: ${e.asLog()}" } + cacheFile.delete() + } + } + } } - fun save(id: ProfileId) { - log(TAG, VERBOSE) { "save(): $id" } + suspend fun delete(id: ProfileId) { + log(TAG, VERBOSE) { "delete(): profileId=$id" } + lock.withLock { + val cacheFile = id.toCacheFile() + try { + cacheFile.delete() + } catch (e: Exception) { + log(TAG, ERROR) { "Failed to delete profile for $id: ${e.asLog()}" } + } + } } companion object { diff --git a/app/src/main/java/eu/darken/capod/monitor/core/PodMonitor.kt b/app/src/main/java/eu/darken/capod/monitor/core/PodMonitor.kt index b661583e..92e664a5 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/PodMonitor.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/PodMonitor.kt @@ -72,7 +72,7 @@ class PodMonitor @Inject constructor( } .map { results -> results?.mapNotNull { podFactory.createPod(it) } } .map { processWithCache(it).values } - .flatMapLatest { devices -> + .flatMapLatest { devices -> flowOf(sortPodsToInterest(devices)) } .retryWhen { cause, attempt -> @@ -86,7 +86,7 @@ class PodMonitor @Inject constructor( private suspend fun sortPodsToInterest(devices: Collection): List { val now = Instant.now() val profiles = profilesRepo.currentProfiles() ?: emptyList() - + return devices.sortedWith( compareBy { device -> // Use profile position in list as priority (0 = highest priority) @@ -178,17 +178,33 @@ class PodMonitor @Inject constructor( deviceCache[it.identifier] = it pods[it.identifier] = it } + + newPods + .mapNotNull { + val profileId = it.device.meta.profile?.id ?: return@mapNotNull null + profileId to it.device.scanResult + } + .toMap() + .run { podDeviceCache.saveAll(this) } return pods } - suspend fun latestMainDevice(): PodDevice? { - val currentMain = devices.firstOrNull()?.firstOrNull() - log(TAG) { "Live mainDevice is $currentMain" } + suspend fun getDeviceForProfile(profileId: String): PodDevice? { + log(TAG) { "getDeviceForProfile(profileId=$profileId)" } - return currentMain ?: profilesRepo.currentProfiles().firstOrNull() - ?.let { podDeviceCache.load(it.id) } - ?.let { podFactory.createPod(it)?.device } - .also { log(TAG) { "Cached mainDevice is $it" } } + val liveDevice = devices.firstOrNull()?.firstOrNull { device -> + device.meta.profile?.id == profileId + } + if (liveDevice != null) { + log(TAG) { "Found live device for profile $profileId: $liveDevice" } + return liveDevice + } + + val cachedDevice = podDeviceCache.load(profileId)?.let { + podFactory.createPod(it)?.device + } + log(TAG) { "Cached device for profile $profileId: $cachedDevice" } + return cachedDevice } companion object { diff --git a/app/src/main/java/eu/darken/capod/profiles/core/DeviceProfilesRepo.kt b/app/src/main/java/eu/darken/capod/profiles/core/DeviceProfilesRepo.kt index 181c228b..66d0bb2f 100644 --- a/app/src/main/java/eu/darken/capod/profiles/core/DeviceProfilesRepo.kt +++ b/app/src/main/java/eu/darken/capod/profiles/core/DeviceProfilesRepo.kt @@ -8,6 +8,7 @@ import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.main.core.GeneralSettings +import eu.darken.capod.monitor.core.PodDeviceCache import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map @@ -23,6 +24,7 @@ class DeviceProfilesRepo @Inject constructor( @ApplicationContext private val context: Context, private val generalSettings: GeneralSettings, private val settings: DeviceProfilesSettings, + private val podDeviceCache: PodDeviceCache, ) { private val mutex = Mutex() @@ -74,11 +76,12 @@ class DeviceProfilesRepo @Inject constructor( log(VERBOSE) { "Updated device profile: ${profile.label}" } } - suspend fun removeProfile(profileId: String) = mutex.withLock { + suspend fun removeProfile(profileId: ProfileId) = mutex.withLock { val currentContainer = settings.profiles.value val updatedProfiles = currentContainer.profiles.filter { it.id != profileId } settings.profiles.value = DeviceProfilesContainer(updatedProfiles) log(VERBOSE) { "Removed device profile with ID: $profileId" } + podDeviceCache.delete(profileId) } suspend fun reorderProfiles(profiles: List) = mutex.withLock { diff --git a/app/src/main/res/layout/widget_configuration_activity.xml b/app/src/main/res/layout/widget_configuration_activity.xml new file mode 100644 index 00000000..63fd8e51 --- /dev/null +++ b/app/src/main/res/layout/widget_configuration_activity.xml @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/widget_configuration_profile_item.xml b/app/src/main/res/layout/widget_configuration_profile_item.xml new file mode 100644 index 00000000..700b4290 --- /dev/null +++ b/app/src/main/res/layout/widget_configuration_profile_item.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4a53b6f3..b5c38bba 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -114,6 +114,11 @@ darken A widget showing the last known device status. + Select Device + Choose which device profile this widget should display. + This feature requires CAPod Pro. + + No data Indirect data delivery Use an alternative method to receive BLE data from the system (broadcast instead of callback). diff --git a/app/src/main/res/xml/battery_widget_info.xml b/app/src/main/res/xml/battery_widget_info.xml index ace71875..fa101795 100644 --- a/app/src/main/res/xml/battery_widget_info.xml +++ b/app/src/main/res/xml/battery_widget_info.xml @@ -11,5 +11,6 @@ android:initialLayout="@layout/widget_loading_layout" android:resizeMode="horizontal|vertical" android:widgetCategory="home_screen" - android:widgetFeatures="reconfigurable|configuration_optional" + android:widgetFeatures="reconfigurable" + android:configure="eu.darken.capod.main.ui.widget.WidgetConfigurationActivity" tools:targetApi="s" /> \ No newline at end of file