mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
Implemented widget configuration for multiple profiles
This commit is contained in:
@@ -97,6 +97,16 @@
|
||||
android:resource="@xml/file_provider_paths" />
|
||||
</provider>
|
||||
|
||||
<!-- Widget configuration -->
|
||||
<activity
|
||||
android:name=".main.ui.widget.WidgetConfigurationActivity"
|
||||
android:theme="@style/AppTheme"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Debug stuff-->
|
||||
<activity
|
||||
android:name=".common.debug.recording.ui.RecorderActivity"
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package eu.darken.capod.main.ui.widget
|
||||
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.viewModels
|
||||
import androidx.core.view.isVisible
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.EdgeToEdgeHelper
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.uix.Activity2
|
||||
import eu.darken.capod.common.upgrade.UpgradeRepo
|
||||
import eu.darken.capod.databinding.WidgetConfigurationActivityBinding
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class WidgetConfigurationActivity : Activity2() {
|
||||
|
||||
private val vm: WidgetConfigurationViewModel by viewModels()
|
||||
private lateinit var ui: WidgetConfigurationActivityBinding
|
||||
|
||||
@Inject lateinit var profileAdapter: WidgetProfileSelectionAdapter
|
||||
@Inject lateinit var upgradeRepo: UpgradeRepo
|
||||
|
||||
private var widgetId: Int = AppWidgetManager.INVALID_APPWIDGET_ID
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
|
||||
// Set result to CANCELED in case user backs out
|
||||
setResult(RESULT_CANCELED)
|
||||
|
||||
// Get widget ID from intent
|
||||
widgetId = intent.getIntExtra(
|
||||
AppWidgetManager.EXTRA_APPWIDGET_ID,
|
||||
AppWidgetManager.INVALID_APPWIDGET_ID
|
||||
)
|
||||
|
||||
log(TAG) { "onCreate(widgetId=$widgetId)" }
|
||||
|
||||
// If widget ID is invalid, finish
|
||||
if (widgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
|
||||
log(TAG) { "Invalid widget ID, finishing" }
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
ui = WidgetConfigurationActivityBinding.inflate(layoutInflater)
|
||||
setContentView(ui.root)
|
||||
|
||||
EdgeToEdgeHelper(this).apply {
|
||||
insetsPadding(ui.root, top = true, bottom = true, left = true, right = true)
|
||||
}
|
||||
|
||||
ui.profilesRecycler.adapter = profileAdapter
|
||||
|
||||
ui.cancelButton.setOnClickListener {
|
||||
log(TAG) { "Cancel clicked" }
|
||||
finish()
|
||||
}
|
||||
|
||||
ui.confirmButton.setOnClickListener {
|
||||
if (vm.state.value?.isPro == true) {
|
||||
log(TAG) { "Confirm clicked" }
|
||||
confirmSelection()
|
||||
} else {
|
||||
log(TAG) { "Upgrade clicked" }
|
||||
upgradeRepo.launchBillingFlow(this@WidgetConfigurationActivity)
|
||||
}
|
||||
}
|
||||
|
||||
vm.state.observe2 { state ->
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -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<Int>(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<DeviceProfile> = 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")
|
||||
}
|
||||
}
|
||||
@@ -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<WidgetProfileSelectionAdapter.BaseVH<WidgetProfileSelectionAdapter.Item, ViewBinding>>(),
|
||||
HasAsyncDiffer<WidgetProfileSelectionAdapter.Item> {
|
||||
|
||||
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<D : Item, B : ViewBinding>(
|
||||
@LayoutRes layoutId: Int,
|
||||
parent: ViewGroup
|
||||
) : VH(layoutId, parent), BindableVH<D, B>
|
||||
|
||||
interface Item : DifferItem
|
||||
}
|
||||
@@ -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<WidgetProfileSelectionVH.Item, WidgetConfigurationProfileItemBinding>(
|
||||
R.layout.widget_configuration_profile_item,
|
||||
parent
|
||||
) {
|
||||
|
||||
override val viewBinding = lazy {
|
||||
WidgetConfigurationProfileItemBinding.bind(itemView)
|
||||
}
|
||||
|
||||
override val onBindData: WidgetConfigurationProfileItemBinding.(
|
||||
item: Item,
|
||||
payloads: List<Any>
|
||||
) -> 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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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<BleScanResult>()
|
||||
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<ProfileId, BleScanResult>) {
|
||||
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 {
|
||||
|
||||
@@ -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<PodDevice>): List<PodDevice> {
|
||||
val now = Instant.now()
|
||||
val profiles = profilesRepo.currentProfiles() ?: emptyList()
|
||||
|
||||
|
||||
return devices.sortedWith(
|
||||
compareBy<PodDevice> { 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 {
|
||||
|
||||
@@ -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<DeviceProfile>) = mutex.withLock {
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/content_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_marginHorizontal="32dp"
|
||||
android:layout_marginTop="32dp"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<com.google.android.material.textview.MaterialTextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/widget_configuration_title"
|
||||
android:textAppearance="@style/TextAppearance.Material3.TitleLarge" />
|
||||
|
||||
<com.google.android.material.textview.MaterialTextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:text="@string/widget_configuration_description"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodyMedium" />
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/profiles_recycler"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
|
||||
tools:listitem="@layout/widget_configuration_profile_item" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<com.google.android.material.textview.MaterialTextView
|
||||
android:id="@+id/pro_required_caption"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="32dp"
|
||||
android:layout_marginTop="32dp"
|
||||
android:text="@string/common_feature_requires_pro_msg"
|
||||
android:gravity="center"
|
||||
android:textAppearance="@style/TextAppearance.Material3.TitleSmall"
|
||||
android:textColor="?colorOnSurfaceVariant"
|
||||
android:visibility="gone"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/buttons_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="32dp"
|
||||
android:layout_marginTop="16dp"
|
||||
android:layout_marginBottom="32dp"
|
||||
android:gravity="end"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/cancel_button"
|
||||
style="@style/Widget.Material3.Button.TextButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:text="@android:string/cancel" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/confirm_button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:enabled="false"
|
||||
android:text="@android:string/ok" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,60 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginVertical="4dp"
|
||||
app:cardCornerRadius="12dp"
|
||||
app:cardElevation="2dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:padding="16dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/profile_icon"
|
||||
android:layout_width="28dp"
|
||||
android:layout_height="28dp"
|
||||
android:layout_gravity="center_vertical"
|
||||
android:layout_marginEnd="16dp"
|
||||
tools:src="@drawable/devic_airpods_gen1_both" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<com.google.android.material.textview.MaterialTextView
|
||||
android:id="@+id/profile_name"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodyLarge"
|
||||
android:textStyle="bold"
|
||||
tools:text="AirPods Pro" />
|
||||
|
||||
<com.google.android.material.textview.MaterialTextView
|
||||
android:id="@+id/profile_model"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
|
||||
android:textColor="?attr/colorOnSurfaceVariant"
|
||||
tools:text="AirPods Pro (2nd generation)" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<RadioButton
|
||||
android:id="@+id/profile_radio"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center_vertical"
|
||||
android:clickable="false"
|
||||
android:focusable="false" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
@@ -114,6 +114,11 @@
|
||||
<string name="translators_thanks_description">darken</string>
|
||||
|
||||
<string name="widget_description">A widget showing the last known device status.</string>
|
||||
<string name="widget_configuration_title">Select Device</string>
|
||||
<string name="widget_configuration_description">Choose which device profile this widget should display.</string>
|
||||
<string name="common_feature_requires_pro_msg">This feature requires CAPod Pro.</string>
|
||||
|
||||
<string name="widget_no_data_label">No data</string>
|
||||
<string name="settings_compat_indirectcallback_title">Indirect data delivery</string>
|
||||
<string name="settings_compat_indirectcallback_summary">Use an alternative method to receive BLE data from the system (broadcast instead of callback).</string>
|
||||
|
||||
|
||||
@@ -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" />
|
||||
Reference in New Issue
Block a user