diff --git a/app-common/src/main/java/eu/darken/capod/devices/core/DeviceProfile.kt b/app-common/src/main/java/eu/darken/capod/devices/core/DeviceProfile.kt new file mode 100644 index 00000000..cb33e984 --- /dev/null +++ b/app-common/src/main/java/eu/darken/capod/devices/core/DeviceProfile.kt @@ -0,0 +1,23 @@ +package eu.darken.capod.devices.core + +import android.os.Parcelable +import com.squareup.moshi.JsonClass +import eu.darken.capod.common.bluetooth.BluetoothAddress +import eu.darken.capod.pods.core.PodDevice +import eu.darken.capod.pods.core.apple.protocol.IdentityResolvingKey +import eu.darken.capod.pods.core.apple.protocol.ProximityEncryptionKey +import kotlinx.parcelize.Parcelize +import java.util.UUID + +@Parcelize +@JsonClass(generateAdapter = true) +data class DeviceProfile( + val id: String = UUID.randomUUID().toString(), + val name: String, + val address: BluetoothAddress? = null, + val model: PodDevice.Model = PodDevice.Model.UNKNOWN, + val identityKey: IdentityResolvingKey? = null, + val encryptionKey: ProximityEncryptionKey? = null, + val minimumSignalQuality: Float = 0.20f, + val isEnabled: Boolean = true +) : Parcelable \ No newline at end of file diff --git a/app-common/src/main/java/eu/darken/capod/devices/core/DeviceProfilesRepo.kt b/app-common/src/main/java/eu/darken/capod/devices/core/DeviceProfilesRepo.kt new file mode 100644 index 00000000..1f51943d --- /dev/null +++ b/app-common/src/main/java/eu/darken/capod/devices/core/DeviceProfilesRepo.kt @@ -0,0 +1,87 @@ +package eu.darken.capod.devices.core + +import android.content.Context +import android.content.SharedPreferences +import com.squareup.moshi.Moshi +import com.squareup.moshi.Types +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 kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class DeviceProfilesRepo @Inject constructor( + @ApplicationContext private val context: Context, + private val moshi: Moshi, +) { + + private val preferences: SharedPreferences = context.getSharedPreferences("device_profiles", Context.MODE_PRIVATE) + + private val _profiles = MutableStateFlow>(emptyList()) + val profiles: Flow> = _profiles.asStateFlow() + + private val listType = Types.newParameterizedType(List::class.java, DeviceProfile::class.java) + private val adapter = moshi.adapter>(listType) + + init { + loadProfiles() + } + + private fun loadProfiles() { + val json = preferences.getString(KEY_PROFILES, null) + val loadedProfiles = if (json != null) { + try { + adapter.fromJson(json) ?: emptyList() + } catch (e: Exception) { + log(VERBOSE) { "Failed to load device profiles: $e" } + emptyList() + } + } else { + emptyList() + } + _profiles.value = loadedProfiles + log(VERBOSE) { "Loaded ${loadedProfiles.size} device profiles" } + } + + private fun saveProfiles() { + val json = adapter.toJson(_profiles.value) + preferences.edit().putString(KEY_PROFILES, json).apply() + log(VERBOSE) { "Saved ${_profiles.value.size} device profiles" } + } + + fun addProfile(profile: DeviceProfile) { + val updatedProfiles = _profiles.value.toMutableList() + updatedProfiles.add(profile) + _profiles.value = updatedProfiles + saveProfiles() + } + + fun updateProfile(profile: DeviceProfile) { + val updatedProfiles = _profiles.value.toMutableList() + val index = updatedProfiles.indexOfFirst { it.id == profile.id } + if (index != -1) { + updatedProfiles[index] = profile + _profiles.value = updatedProfiles + saveProfiles() + } + } + + fun removeProfile(profileId: String) { + val updatedProfiles = _profiles.value.toMutableList() + updatedProfiles.removeAll { it.id == profileId } + _profiles.value = updatedProfiles + saveProfiles() + } + + fun getProfile(profileId: String): DeviceProfile? { + return _profiles.value.find { it.id == profileId } + } + + companion object { + private const val KEY_PROFILES = "profiles" + } +} \ No newline at end of file diff --git a/app-common/src/main/res/values/strings.xml b/app-common/src/main/res/values/strings.xml index 70b5bd51..30c851c2 100644 --- a/app-common/src/main/res/values/strings.xml +++ b/app-common/src/main/res/values/strings.xml @@ -6,9 +6,10 @@ N/A Error Grant permission + Manage devices - No primary device - All detected devices are unlikely to be yours. Power on and connect your device or adjust the settings. + No device configured + Configure your device to start monitoring battery levels and enable additional features. Bluetooth is disabled Bluetooth is disabled, enable it ;) diff --git a/app/src/main/java/eu/darken/capod/devices/ui/DeviceManagerAdapter.kt b/app/src/main/java/eu/darken/capod/devices/ui/DeviceManagerAdapter.kt new file mode 100644 index 00000000..e088a175 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/devices/ui/DeviceManagerAdapter.kt @@ -0,0 +1,35 @@ +package eu.darken.capod.devices.ui + +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 DeviceManagerAdapter @Inject constructor() : + ModularAdapter>(), + HasAsyncDiffer { + + override val asyncDiffer: AsyncDiffer<*, Item> = setupDiffer() + + init { + modules.add(DataBinderMod(data)) + modules.add(TypedVHCreatorMod({ data[it] is DeviceProfileVH.Item }) { DeviceProfileVH(it) }) + } + + override fun getItemCount(): Int = data.size + + abstract class BaseVH( + @LayoutRes layoutId: Int, + parent: ViewGroup + ) : ModularAdapter.VH(layoutId, parent), BindableVH + + interface Item : DifferItem +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/devices/ui/DeviceManagerFragment.kt b/app/src/main/java/eu/darken/capod/devices/ui/DeviceManagerFragment.kt new file mode 100644 index 00000000..10433529 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/devices/ui/DeviceManagerFragment.kt @@ -0,0 +1,67 @@ +package eu.darken.capod.devices.ui + +import android.os.Bundle +import android.util.TypedValue +import android.view.View +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updateLayoutParams +import androidx.fragment.app.viewModels +import dagger.hilt.android.AndroidEntryPoint +import eu.darken.capod.R +import eu.darken.capod.common.EdgeToEdgeHelper +import eu.darken.capod.common.lists.differ.update +import eu.darken.capod.common.lists.setupDefaults +import eu.darken.capod.common.uix.Fragment3 +import eu.darken.capod.common.viewbinding.viewBinding +import eu.darken.capod.databinding.DeviceManagerFragmentBinding +import javax.inject.Inject + +@AndroidEntryPoint +class DeviceManagerFragment : Fragment3(R.layout.device_manager_fragment) { + + override val vm: DeviceManagerFragmentVM by viewModels() + override val ui: DeviceManagerFragmentBinding by viewBinding() + + @Inject + lateinit var adapter: DeviceManagerAdapter + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + EdgeToEdgeHelper(requireActivity()).apply { + insetsPadding(ui.root, left = true, right = true) + insetsPadding(ui.toolbar, top = true) + } + + // Handle FAB margins for edge-to-edge + ViewCompat.setOnApplyWindowInsetsListener(ui.fab) { view, insets -> + val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + val baseMargin = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, 16f, + resources.displayMetrics + ).toInt() + view.updateLayoutParams { + bottomMargin = baseMargin + systemBars.bottom + marginEnd = baseMargin + systemBars.right + } + insets + } + + ui.apply { + list.setupDefaults(adapter, dividers = false) + + toolbar.setNavigationOnClickListener { + vm.onBackPressed() + } + + fab.setOnClickListener { + vm.onAddDevice() + } + } + + vm.listItems.observe2(ui) { items -> + adapter.update(items) + } + + super.onViewCreated(view, savedInstanceState) + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/devices/ui/DeviceManagerFragmentVM.kt b/app/src/main/java/eu/darken/capod/devices/ui/DeviceManagerFragmentVM.kt new file mode 100644 index 00000000..1ea1fe65 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/devices/ui/DeviceManagerFragmentVM.kt @@ -0,0 +1,60 @@ +package eu.darken.capod.devices.ui + +import androidx.lifecycle.LiveData +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.asLiveData +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.uix.ViewModel3 +import eu.darken.capod.devices.core.DeviceProfile +import eu.darken.capod.devices.core.DeviceProfilesRepo +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import javax.inject.Inject + +@HiltViewModel +class DeviceManagerFragmentVM @Inject constructor( + @Suppress("UNUSED_PARAMETER") handle: SavedStateHandle, + dispatcherProvider: DispatcherProvider, + private val deviceProfilesRepo: DeviceProfilesRepo, +) : ViewModel3(dispatcherProvider = dispatcherProvider) { + + val listItems: LiveData> = deviceProfilesRepo.profiles + .map { profiles -> + profiles.map { profile -> + DeviceProfileVH.Item( + profile = profile, + onItemClick = { onItemClick(it) }, + onMenuClick = { onMenuClick(it) } + ) + } + } + .onEach { log(TAG) { "Profiles updated: ${it.size} items" } } + .asLiveData() + + fun onAddDevice() { + log(TAG) { "onAddDevice()" } + // TODO: Navigate to device profile creation screen + } + + fun onBackPressed() { + log(TAG) { "onBackPressed()" } + navEvents.postValue(null) + } + + private fun onItemClick(profile: DeviceProfile) { + log(TAG) { "onItemClick(): $profile" } + // TODO: Navigate to device profile edit screen + } + + private fun onMenuClick(profile: DeviceProfile) { + log(TAG) { "onMenuClick(): $profile" } + // TODO: Show menu with edit/delete options + } + + companion object { + private val TAG = logTag("DeviceManager", "ViewModel") + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/devices/ui/DeviceProfileVH.kt b/app/src/main/java/eu/darken/capod/devices/ui/DeviceProfileVH.kt new file mode 100644 index 00000000..48ce1c44 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/devices/ui/DeviceProfileVH.kt @@ -0,0 +1,42 @@ +package eu.darken.capod.devices.ui + +import android.view.ViewGroup +import eu.darken.capod.R +import eu.darken.capod.databinding.DeviceManagerItemBinding +import eu.darken.capod.devices.core.DeviceProfile + +class DeviceProfileVH(parent: ViewGroup) : + DeviceManagerAdapter.BaseVH( + R.layout.device_manager_item, + parent + ) { + + override val viewBinding = lazy { DeviceManagerItemBinding.bind(itemView) } + + override val onBindData: DeviceManagerItemBinding.( + item: Item, + payloads: List + ) -> Unit = { item, _ -> + val profile = item.profile + + deviceName.text = profile.name + deviceDetails.text = buildString { + profile.address?.let { + append(it.toString()) + append(" • ") + } + append(profile.model.name) + } + + itemView.setOnClickListener { item.onItemClick(profile) } + menuButton.setOnClickListener { item.onMenuClick(profile) } + } + + data class Item( + val profile: DeviceProfile, + val onItemClick: (DeviceProfile) -> Unit, + val onMenuClick: (DeviceProfile) -> Unit, + ) : DeviceManagerAdapter.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/overview/OverviewFragment.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewFragment.kt index 657335e8..8f2791ca 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewFragment.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewFragment.kt @@ -60,6 +60,11 @@ class OverviewFragment : Fragment3(R.layout.main_fragment) { ui.toolbar.apply { setOnMenuItemClickListener { when (it.itemId) { + R.id.menu_item_devices -> { + vm.goToDeviceManager() + true + } + R.id.menu_item_settings -> { vm.goToSettings() true diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewFragmentVM.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewFragmentVM.kt index d83ab6d3..a0f0e525 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewFragmentVM.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewFragmentVM.kt @@ -141,9 +141,11 @@ class OverviewFragmentVM @Inject constructor( if (!isBluetoothEnabled) { items.add(0, BluetoothDisabledVH.Item) } else if (mainPod == null) { - items.add(0, MissingMainDeviceVH.Item { - OverviewFragmentDirections.actionOverviewFragmentToTroubleShooterFragment().navigate() - }) + items.add(0, MissingMainDeviceVH.Item( + onManageDevices = { + OverviewFragmentDirections.actionOverviewFragmentToDeviceManagerFragment().navigate() + } + )) } } @@ -189,6 +191,10 @@ class OverviewFragmentVM @Inject constructor( OverviewFragmentDirections.actionOverviewFragmentToSettingsFragment().navigate() } + fun goToDeviceManager() = launch { + OverviewFragmentDirections.actionOverviewFragmentToDeviceManagerFragment().navigate() + } + fun onUpgrade() = launch { val call: (Activity) -> Unit = { upgradeRepo.launchBillingFlow(it) diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/MissingMainDeviceVH.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/MissingMainDeviceVH.kt index 97741bfc..4a1582f7 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/MissingMainDeviceVH.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/MissingMainDeviceVH.kt @@ -21,11 +21,11 @@ class MissingMainDeviceVH(parent: ViewGroup) : item: Item, payloads: List ) -> Unit = binding(payload = true) { item -> - troubleshootAction.setOnClickListener { item.onTroubleShoot() } + manageDevicesAction.setOnClickListener { item.onManageDevices() } } data class Item( - val onTroubleShoot: () -> Unit, + val onManageDevices: () -> Unit, ) : OverviewAdapter.Item { override val stableId: Long = Item::class.hashCode().toLong() diff --git a/app/src/main/java/eu/darken/capod/main/ui/settings/SettingsIndexFragment.kt b/app/src/main/java/eu/darken/capod/main/ui/settings/SettingsIndexFragment.kt index b1b737b0..a14c6ba2 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/settings/SettingsIndexFragment.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/settings/SettingsIndexFragment.kt @@ -2,8 +2,10 @@ package eu.darken.capod.main.ui.settings import android.os.Bundle import android.view.View +import androidx.navigation.fragment.findNavController import androidx.preference.Preference import dagger.hilt.android.AndroidEntryPoint +import eu.darken.capod.MainDirections import eu.darken.capod.R import eu.darken.capod.common.BuildConfigWrap import eu.darken.capod.common.PrivacyPolicy @@ -43,6 +45,10 @@ class SettingsIndexFragment : PreferenceFragment2() { webpageTool.open(PrivacyPolicy.URL) true } + findPreference("core.profile.manager")!!.setOnPreferenceClickListener { + findNavController().navigate(MainDirections.actionGlobalDeviceManagerFragment()) + true + } super.onPreferencesCreated() } diff --git a/app/src/main/java/eu/darken/capod/main/ui/settings/support/SupportFragment.kt b/app/src/main/java/eu/darken/capod/main/ui/settings/support/SupportFragment.kt index e34feb57..e1a8d362 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/settings/support/SupportFragment.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/settings/support/SupportFragment.kt @@ -4,6 +4,7 @@ import android.os.Bundle import android.view.View import androidx.annotation.Keep import androidx.fragment.app.viewModels +import androidx.navigation.findNavController import androidx.preference.Preference import dagger.hilt.android.AndroidEntryPoint import eu.darken.capod.R @@ -12,6 +13,7 @@ import eu.darken.capod.common.debug.recording.ui.RecorderConsentDialog import eu.darken.capod.common.observe2 import eu.darken.capod.common.uix.PreferenceFragment3 import eu.darken.capod.main.core.GeneralSettings +import eu.darken.capod.main.ui.settings.SettingsFragmentDirections import javax.inject.Inject @Keep @@ -28,6 +30,7 @@ class SupportFragment : PreferenceFragment3() { @Inject lateinit var webpageTool: WebpageTool private val debugLogPref by lazy { findPreference("support.debuglog")!! } + private val troubleshooterPref by lazy { findPreference("support.troubleshooter")!! } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { vm.recorderState.observe2(this) { state -> @@ -55,6 +58,13 @@ class SupportFragment : PreferenceFragment3() { true } } + + troubleshooterPref.setOnPreferenceClickListener { + val navController = requireActivity().findNavController(R.id.nav_host) + navController.navigate(SettingsFragmentDirections.actionSettingsFragmentToTroubleShooterFragment()) + true + } + super.onViewCreated(view, savedInstanceState) } } \ No newline at end of file diff --git a/app/src/main/res/layout/device_manager_fragment.xml b/app/src/main/res/layout/device_manager_fragment.xml new file mode 100644 index 00000000..0074a489 --- /dev/null +++ b/app/src/main/res/layout/device_manager_fragment.xml @@ -0,0 +1,43 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/device_manager_item.xml b/app/src/main/res/layout/device_manager_item.xml new file mode 100644 index 00000000..d3b170f9 --- /dev/null +++ b/app/src/main/res/layout/device_manager_item.xml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/overview_nomaindevice_item.xml b/app/src/main/res/layout/overview_nomaindevice_item.xml index e5a7744b..c90e191b 100644 --- a/app/src/main/res/layout/overview_nomaindevice_item.xml +++ b/app/src/main/res/layout/overview_nomaindevice_item.xml @@ -33,18 +33,18 @@ android:layout_marginHorizontal="16dp" android:layout_marginTop="4dp" android:text="@string/overview_nomaindevice_description" - app:layout_constraintBottom_toTopOf="@id/troubleshoot_action" + app:layout_constraintBottom_toTopOf="@id/manage_devices_action" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/permission_label" /> diff --git a/app/src/main/res/menu/main.xml b/app/src/main/res/menu/main.xml index 50613f0c..c87e2e07 100644 --- a/app/src/main/res/menu/main.xml +++ b/app/src/main/res/menu/main.xml @@ -16,6 +16,11 @@ android:visible="false" tool:visible="true" app:showAsAction="always" /> + + @@ -27,7 +30,16 @@ android:id="@+id/settingsFragment" android:name="eu.darken.capod.main.ui.settings.SettingsFragment" android:label="SettingsFragment" - tools:layout="@layout/settings_fragment" /> + tools:layout="@layout/settings_fragment"> + + + + + \ 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 66c7123f..d2090969 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -40,6 +40,8 @@ If Android does not automatically connect, we can ask it too. This will set the monitor mode setting to \'Always\'. Auto connect condition When should we try to connect to your device? + Devices + Manage your devices. Reactions React to events and behaviors. Your device @@ -128,6 +130,7 @@ Use an alternative method to receive BLE data from the system (broadcast instead of callback). Troubleshooter + Diagnose and fix Bluetooth connectivity issues. Bluetooth Low Energy Broadcasts AirPods (and similar headphones) broadcast status information using a BLE technology called \"advertisements\". Some phones don\'t implement this technology correctly. CAPod can attempt to fix this by trying different compatibility options until data is received. Start music playback on your headphones and place them close to your phone, then start the process. Start troubleshooting diff --git a/app/src/main/res/xml/preferences_index.xml b/app/src/main/res/xml/preferences_index.xml index 4626026c..7d0a3646 100644 --- a/app/src/main/res/xml/preferences_index.xml +++ b/app/src/main/res/xml/preferences_index.xml @@ -7,6 +7,12 @@ app:summary="@string/settings_general_description" app:title="@string/settings_general_label" /> + + +