Skip to content

Commit

Permalink
Add screen to manage devices per connector
Browse files Browse the repository at this point in the history
View what devices are synced through a connector.
Closes #3

Allow deleting individual devices from a connector.
Closes #9
  • Loading branch information
d4rken committed Sep 4, 2024
1 parent 3c30464 commit 98314f2
Show file tree
Hide file tree
Showing 21 changed files with 663 additions and 26 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package eu.darken.octi.sync.ui.devices

import android.text.format.DateUtils
import android.view.ViewGroup
import androidx.core.view.isGone
import eu.darken.octi.R
import eu.darken.octi.common.debug.logging.asLog
import eu.darken.octi.databinding.SyncDevicesItemDefaultBinding
import eu.darken.octi.modules.meta.core.MetaInfo
import eu.darken.octi.sync.core.DeviceId
import java.time.Instant


class DefaultSyncDeviceVH(parent: ViewGroup) :
SyncDevicesAdapter.BaseVH<DefaultSyncDeviceVH.Item, SyncDevicesItemDefaultBinding>(
R.layout.sync_devices_item_default,
parent
) {

override val viewBinding = lazy { SyncDevicesItemDefaultBinding.bind(itemView) }

override val onBindData: SyncDevicesItemDefaultBinding.(
item: Item,
payloads: List<Any>
) -> Unit = { item, _ ->
icon.setImageResource(
when (item.metaInfo?.deviceType) {
MetaInfo.DeviceType.PHONE -> R.drawable.ic_baseline_phone_android_24
MetaInfo.DeviceType.TABLET -> R.drawable.ic_baseline_tablet_android_24
else -> R.drawable.ic_baseline_question_mark_24
}
)
title.text = item.metaInfo?.labelOrFallback
subtitle.text = item.deviceId.id
octiVersion.text = item.metaInfo?.octiVersionName
lastSeen.text = item.lastSeen?.let { DateUtils.getRelativeTimeSpanString(it.toEpochMilli()) }
errorDesc.apply {
text = item.error?.asLog()
isGone = text.isEmpty()
}
itemView.setOnClickListener { item.onClick() }
}

data class Item(
val deviceId: DeviceId,
val metaInfo: MetaInfo?,
val lastSeen: Instant?,
val error: Exception?,
val onClick: () -> Unit,
) : SyncDevicesAdapter.Item {
override val stableId: Long
get() = deviceId.hashCode().toLong()
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package eu.darken.octi.sync.ui.devices

import android.view.ViewGroup
import androidx.annotation.LayoutRes
import androidx.viewbinding.ViewBinding
import eu.darken.octi.common.lists.BindableVH
import eu.darken.octi.common.lists.differ.AsyncDiffer
import eu.darken.octi.common.lists.differ.DifferItem
import eu.darken.octi.common.lists.differ.HasAsyncDiffer
import eu.darken.octi.common.lists.differ.setupDiffer
import eu.darken.octi.common.lists.modular.ModularAdapter
import eu.darken.octi.common.lists.modular.mods.DataBinderMod
import eu.darken.octi.common.lists.modular.mods.TypedVHCreatorMod
import javax.inject.Inject


class SyncDevicesAdapter @Inject constructor() :
ModularAdapter<SyncDevicesAdapter.BaseVH<SyncDevicesAdapter.Item, ViewBinding>>(),
HasAsyncDiffer<SyncDevicesAdapter.Item> {

override val asyncDiffer: AsyncDiffer<*, Item> = setupDiffer()

override fun getItemCount(): Int = data.size

init {
modules.add(DataBinderMod(data))
modules.add(TypedVHCreatorMod({ data[it] is DefaultSyncDeviceVH.Item }) { DefaultSyncDeviceVH(it) })
}

abstract class BaseVH<D : Item, B : ViewBinding>(
@LayoutRes layoutId: Int,
parent: ViewGroup
) : VH(layoutId, parent), BindableVH<D, B>

interface Item : DifferItem {
override val payloadProvider: ((DifferItem, DifferItem) -> DifferItem?)
get() = { old, new -> if (new::class.isInstance(old)) new else null }
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package eu.darken.octi.sync.ui.devices

import android.os.Bundle
import android.view.View
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import androidx.navigation.ui.setupWithNavController
import dagger.hilt.android.AndroidEntryPoint
import eu.darken.octi.R
import eu.darken.octi.common.lists.differ.update
import eu.darken.octi.common.lists.setupDefaults
import eu.darken.octi.common.uix.Fragment3
import eu.darken.octi.common.viewbinding.viewBinding
import eu.darken.octi.databinding.SyncDevicesFragmentBinding
import javax.inject.Inject


@AndroidEntryPoint
class SyncDevicesFragment : Fragment3(R.layout.sync_devices_fragment) {

override val vm: SyncDevicesVM by viewModels()
override val ui: SyncDevicesFragmentBinding by viewBinding()

@Inject lateinit var adapter: SyncDevicesAdapter

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
ui.toolbar.setupWithNavController(findNavController())


ui.list.setupDefaults(adapter)
vm.state.observe2(ui) { state ->
adapter.update(state.items)
}
super.onViewCreated(view, savedInstanceState)
}

}
94 changes: 94 additions & 0 deletions app/src/main/java/eu/darken/octi/sync/ui/devices/SyncDevicesVM.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package eu.darken.octi.sync.ui.devices

import androidx.lifecycle.SavedStateHandle
import dagger.hilt.android.lifecycle.HiltViewModel
import eu.darken.octi.common.coroutine.DispatcherProvider
import eu.darken.octi.common.debug.logging.Logging.Priority.ERROR
import eu.darken.octi.common.debug.logging.asLog
import eu.darken.octi.common.debug.logging.log
import eu.darken.octi.common.debug.logging.logTag
import eu.darken.octi.common.navigation.navArgs
import eu.darken.octi.common.uix.ViewModel3
import eu.darken.octi.modules.meta.MetaModule
import eu.darken.octi.modules.meta.core.MetaSerializer
import eu.darken.octi.sync.core.ConnectorId
import eu.darken.octi.sync.core.SyncConnector
import eu.darken.octi.sync.core.SyncManager
import eu.darken.octi.sync.core.SyncSettings
import eu.darken.octi.sync.core.getConnectorById
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import java.time.Instant
import javax.inject.Inject

@HiltViewModel
class SyncDevicesVM @Inject constructor(
handle: SavedStateHandle,
dispatcherProvider: DispatcherProvider,
syncManager: SyncManager,
private val syncSettings: SyncSettings,
private val manager: SyncManager,
private val metaSerializer: MetaSerializer,
) : ViewModel3(dispatcherProvider = dispatcherProvider) {

private val navArgs by handle.navArgs<SyncDevicesFragmentArgs>()

private val connectorId: ConnectorId
get() = navArgs.connectorId

data class State(
val items: List<SyncDevicesAdapter.Item> = emptyList()
)

val state = manager.getConnectorById<SyncConnector>(connectorId)
.catch { if (it is NoSuchElementException) popNavStack() else throw it }
.flatMapLatest { connector ->
connector.state
.distinctUntilChangedBy { it.devices }
.flatMapLatest { state ->
connector.data
.map { data ->
data?.devices
?.flatMap { it.modules }
?.filter { it.moduleId == MetaModule.MODULE_ID }
}
.map { metaDatas ->
val items = mutableListOf<SyncDevicesAdapter.Item>()
log(TAG) { "Loading devices for $state" }

var error: Exception? = null
state.devices?.map { deviceId ->
var lastSeen: Instant? = null
val metaInfo = metaDatas?.find { it.deviceId == deviceId }?.let {
lastSeen = it.modifiedAt
try {
metaSerializer.deserialize(it.payload)
} catch (e: Exception) {
log(TAG, ERROR) { "Failed to deserialize MetaInfo:\n${e.asLog()}" }
error = e
null
}
}
DefaultSyncDeviceVH.Item(
deviceId = deviceId,
metaInfo = metaInfo,
lastSeen = lastSeen,
error = error,
onClick = {
SyncDevicesFragmentDirections.actionSyncDevicesFragmentToDeviceActionsFragment(
connectorId, deviceId
).navigate()
},
)
}?.run { items.addAll(this) }
State(items)
}
}
}.asLiveData2()

companion object {
private val TAG = logTag("Sync", "Devices", "Fragment", "VM")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package eu.darken.octi.sync.ui.devices.actions

import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isGone
import androidx.fragment.app.viewModels
import dagger.hilt.android.AndroidEntryPoint
import eu.darken.octi.R
import eu.darken.octi.common.uix.BottomSheetDialogFragment2
import eu.darken.octi.databinding.SyncDevicesDeviceActionsBinding

@AndroidEntryPoint
class DeviceActionsFragment : BottomSheetDialogFragment2() {

override val vm: DeviceActionsVM by viewModels()
override lateinit var ui: SyncDevicesDeviceActionsBinding

override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
ui = SyncDevicesDeviceActionsBinding.inflate(inflater, container, false)
return ui.root
}

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
ui.deleteAction.setOnClickListener { vm.deleteDevice() }

vm.state.observe2(ui) { state ->
title.text = state.metaInfo?.labelOrFallback ?: "?"
subtitle.text = state.deviceId.id
deleteHint.apply {
text = when (state.removeIsRevoke) {
true -> getString(R.string.sync_delete_device_revokeaccess_caveat)
false -> getString(R.string.sync_delete_device_keepaccess_caveat)
null -> ""
}
isGone = state.removeIsRevoke == null
}
}
super.onViewCreated(view, savedInstanceState)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package eu.darken.octi.sync.ui.devices.actions

import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import eu.darken.octi.common.coroutine.AppScope
import eu.darken.octi.common.coroutine.DispatcherProvider
import eu.darken.octi.common.debug.logging.Logging.Priority.ERROR
import eu.darken.octi.common.debug.logging.Logging.Priority.INFO
import eu.darken.octi.common.debug.logging.Logging.Priority.WARN
import eu.darken.octi.common.debug.logging.log
import eu.darken.octi.common.debug.logging.logTag
import eu.darken.octi.common.flow.replayingShare
import eu.darken.octi.common.navigation.navArgs
import eu.darken.octi.common.uix.ViewModel3
import eu.darken.octi.modules.meta.MetaModule
import eu.darken.octi.modules.meta.core.MetaInfo
import eu.darken.octi.modules.meta.core.MetaSerializer
import eu.darken.octi.sync.core.DeviceId
import eu.darken.octi.sync.core.SyncConnector
import eu.darken.octi.sync.core.SyncManager
import eu.darken.octi.sync.core.SyncOptions
import eu.darken.octi.sync.core.SyncSettings
import eu.darken.octi.sync.core.getConnectorById
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import javax.inject.Inject

@HiltViewModel
class DeviceActionsVM @Inject constructor(
handle: SavedStateHandle,
@AppScope private val appScope: CoroutineScope,
private val dispatcherProvider: DispatcherProvider,
private val syncManager: SyncManager,
private val syncSettings: SyncSettings,
private val metaSerializer: MetaSerializer,
) : ViewModel3(dispatcherProvider = dispatcherProvider) {

private val navArgs: DeviceActionsFragmentArgs by handle.navArgs()

data class State(
val deviceId: DeviceId,
val metaInfo: MetaInfo?,
val removeIsRevoke: Boolean?,
)

init {
log(TAG) { "Loading for ${navArgs.deviceId} on ${navArgs.connectorId}" }
}

private val connectorFlow: Flow<SyncConnector> = syncManager
.getConnectorById<SyncConnector>(navArgs.connectorId)
.catch {
if (it is NoSuchElementException) popNavStack() else errorEvents.postValue(it)
}
.replayingShare(viewModelScope)

val state = connectorFlow.map { connector ->
val metaInfo = connector.data.firstOrNull()
?.devices
?.firstOrNull { it.deviceId == navArgs.deviceId }
?.modules
?.firstOrNull { it.moduleId == MetaModule.MODULE_ID }
?.let {
try {
metaSerializer.deserialize(it.payload)
} catch (e: Exception) {
log(TAG, ERROR) { "Failed to deserialize MetaInfo for ${navArgs.deviceId}" }
null
}
}
State(
deviceId = navArgs.deviceId,
metaInfo = metaInfo,
removeIsRevoke = when (connector.identifier.type) {
"gdrive" -> false
"kserver" -> true
else -> null
}
)
}.asLiveData2()

fun deleteDevice() = launch {
log(TAG, INFO) { "Deleting device ${navArgs.deviceId}" }
appScope.launch {
if (syncSettings.deviceId == navArgs.deviceId) {
log(TAG, WARN) { "We are deleting US, doing disconnect instead of delete" }
syncManager.disconnect(navArgs.connectorId)
} else {
connectorFlow.first().apply {
deleteDevice(navArgs.deviceId)
sync(SyncOptions(writeData = false))
}
}
}
popNavStack()
}

companion object {
private val TAG = logTag("Sync", "Devices", "Device", "Actions", "Fragment", "VM")
}
}
Loading

0 comments on commit 98314f2

Please sign in to comment.