From d1268578a71d16f4fe96fcdf19f60de8d10b1ec9 Mon Sep 17 00:00:00 2001 From: Matthias Urhahn Date: Wed, 28 Aug 2024 16:19:12 +0200 Subject: [PATCH] Add base support for new synchronization option: `K-Server` (#48) * Bump gradle and related dependencies * Rename to avoid conflicts * wip * Add local dev server config * wip * wip * Remove label, this should not be provided extra, labeling is an extra module * Adjust server IPs * align API * align API * Remove health endpoint query, not using it --- PRIVACY_POLICY.md | 20 +- .../coroutine/CoroutinesTestExtension.kt | 24 -- app/build.gradle.kts | 8 +- .../androidTest/java/testhelper/BaseUITest.kt | 4 +- .../debug/res/xml/network_security_config.xml | 4 + app/src/main/AndroidManifest.xml | 1 + .../darken/octi/sync/ui/add/SyncAddAdapter.kt | 4 +- .../eu/darken/octi/sync/ui/add/SyncAddVM.kt | 4 + .../octi/sync/ui/list/SyncListAdapter.kt | 4 +- .../eu/darken/octi/sync/ui/list/SyncListVM.kt | 17 +- .../{LinkOption.kt => JServerLinkOption.kt} | 2 +- .../link/client/JServerLinkClientFragment.kt | 22 +- .../ui/link/client/JServerLinkClientVM.kt | 6 +- .../ui/link/host/JServerLinkHostFragment.kt | 22 +- .../jserver/ui/link/host/JServerLinkHostVM.kt | 6 +- .../octi/syncs/kserver/ui/KServerStateVH.kt | 79 ++++ .../syncs/kserver/ui/actions/ActionEvents.kt | 3 + .../ui/actions/KServerActionsFragment.kt | 61 +++ .../kserver/ui/actions/KServerActionsVM.kt | 72 ++++ .../syncs/kserver/ui/add/AddKServerDataVH.kt | 26 ++ .../kserver/ui/add/AddKServerFragment.kt | 95 +++++ .../octi/syncs/kserver/ui/add/AddKServerVM.kt | 75 ++++ .../kserver/ui/link/KServerLinkOption.kt | 7 + .../link/client/KServerLinkClientFragment.kt | 101 +++++ .../ui/link/client/KServerLinkClientVM.kt | 63 ++++ .../ui/link/host/KServerLinkHostFragment.kt | 86 +++++ .../kserver/ui/link/host/KServerLinkHostVM.kt | 103 ++++++ .../layout/sync_actions_kserver_fragment.xml | 71 ++++ .../main/res/layout/sync_add_item_kserver.xml | 39 ++ .../layout/sync_add_new_kserver_fragment.xml | 122 ++++++ .../sync_kserver_link_client_fragment.xml | 167 +++++++++ .../sync_kserver_link_host_fragment.xml | 157 ++++++++ .../res/layout/sync_list_item_kserver.xml | 148 ++++++++ app/src/main/res/menu/menu_kserver_add.xml | 10 + app/src/main/res/navigation/main.xml | 43 ++- app/src/main/res/values/strings.xml | 25 ++ .../main/res/xml/network_security_config.xml | 4 + buildSrc/build.gradle.kts | 5 +- buildSrc/src/main/java/ProjectConfig.kt | 7 +- buildSrc/src/main/java/Versions.kt | 10 +- gradle.properties | 5 +- gradle/wrapper/gradle-wrapper.properties | 6 +- settings.gradle | 7 +- syncs-kserver/.gitignore | 1 + syncs-kserver/build.gradle.kts | 50 +++ syncs-kserver/proguard-rules.pro | 21 ++ .../syncs/jserver/ExampleInstrumentedTest.kt | 22 ++ syncs-kserver/src/main/AndroidManifest.xml | 5 + .../octi/syncs/kserver/KServerModule.kt | 18 + .../kserver/core/BasicAuthInterceptor.kt | 37 ++ .../darken/octi/syncs/kserver/core/KServer.kt | 61 +++ .../syncs/kserver/core/KServerAccountRepo.kt | 105 ++++++ .../octi/syncs/kserver/core/KServerApi.kt | 81 ++++ .../syncs/kserver/core/KServerConnector.kt | 350 ++++++++++++++++++ .../octi/syncs/kserver/core/KServerData.kt | 9 + .../syncs/kserver/core/KServerDeviceData.kt | 9 + .../syncs/kserver/core/KServerEndpoint.kt | 161 ++++++++ .../octi/syncs/kserver/core/KServerHub.kt | 83 +++++ .../syncs/kserver/core/KServerModuleData.kt | 16 + .../octi/syncs/kserver/core/LinkingData.kt | 37 ++ .../syncs/kserver/KServerConnectorTest.kt | 25 ++ 61 files changed, 2759 insertions(+), 77 deletions(-) delete mode 100644 app-common-test/src/main/java/testhelpers/coroutine/CoroutinesTestExtension.kt create mode 100644 app/src/debug/res/xml/network_security_config.xml rename app/src/main/java/eu/darken/octi/syncs/jserver/ui/link/{LinkOption.kt => JServerLinkOption.kt} (71%) create mode 100644 app/src/main/java/eu/darken/octi/syncs/kserver/ui/KServerStateVH.kt create mode 100644 app/src/main/java/eu/darken/octi/syncs/kserver/ui/actions/ActionEvents.kt create mode 100644 app/src/main/java/eu/darken/octi/syncs/kserver/ui/actions/KServerActionsFragment.kt create mode 100644 app/src/main/java/eu/darken/octi/syncs/kserver/ui/actions/KServerActionsVM.kt create mode 100644 app/src/main/java/eu/darken/octi/syncs/kserver/ui/add/AddKServerDataVH.kt create mode 100644 app/src/main/java/eu/darken/octi/syncs/kserver/ui/add/AddKServerFragment.kt create mode 100644 app/src/main/java/eu/darken/octi/syncs/kserver/ui/add/AddKServerVM.kt create mode 100644 app/src/main/java/eu/darken/octi/syncs/kserver/ui/link/KServerLinkOption.kt create mode 100644 app/src/main/java/eu/darken/octi/syncs/kserver/ui/link/client/KServerLinkClientFragment.kt create mode 100644 app/src/main/java/eu/darken/octi/syncs/kserver/ui/link/client/KServerLinkClientVM.kt create mode 100644 app/src/main/java/eu/darken/octi/syncs/kserver/ui/link/host/KServerLinkHostFragment.kt create mode 100644 app/src/main/java/eu/darken/octi/syncs/kserver/ui/link/host/KServerLinkHostVM.kt create mode 100644 app/src/main/res/layout/sync_actions_kserver_fragment.xml create mode 100644 app/src/main/res/layout/sync_add_item_kserver.xml create mode 100644 app/src/main/res/layout/sync_add_new_kserver_fragment.xml create mode 100644 app/src/main/res/layout/sync_kserver_link_client_fragment.xml create mode 100644 app/src/main/res/layout/sync_kserver_link_host_fragment.xml create mode 100644 app/src/main/res/layout/sync_list_item_kserver.xml create mode 100644 app/src/main/res/menu/menu_kserver_add.xml create mode 100644 app/src/main/res/xml/network_security_config.xml create mode 100644 syncs-kserver/.gitignore create mode 100644 syncs-kserver/build.gradle.kts create mode 100644 syncs-kserver/proguard-rules.pro create mode 100644 syncs-kserver/src/androidTest/java/eu/darken/octi/syncs/jserver/ExampleInstrumentedTest.kt create mode 100644 syncs-kserver/src/main/AndroidManifest.xml create mode 100644 syncs-kserver/src/main/java/eu/darken/octi/syncs/kserver/KServerModule.kt create mode 100644 syncs-kserver/src/main/java/eu/darken/octi/syncs/kserver/core/BasicAuthInterceptor.kt create mode 100644 syncs-kserver/src/main/java/eu/darken/octi/syncs/kserver/core/KServer.kt create mode 100644 syncs-kserver/src/main/java/eu/darken/octi/syncs/kserver/core/KServerAccountRepo.kt create mode 100644 syncs-kserver/src/main/java/eu/darken/octi/syncs/kserver/core/KServerApi.kt create mode 100644 syncs-kserver/src/main/java/eu/darken/octi/syncs/kserver/core/KServerConnector.kt create mode 100644 syncs-kserver/src/main/java/eu/darken/octi/syncs/kserver/core/KServerData.kt create mode 100644 syncs-kserver/src/main/java/eu/darken/octi/syncs/kserver/core/KServerDeviceData.kt create mode 100644 syncs-kserver/src/main/java/eu/darken/octi/syncs/kserver/core/KServerEndpoint.kt create mode 100644 syncs-kserver/src/main/java/eu/darken/octi/syncs/kserver/core/KServerHub.kt create mode 100644 syncs-kserver/src/main/java/eu/darken/octi/syncs/kserver/core/KServerModuleData.kt create mode 100644 syncs-kserver/src/main/java/eu/darken/octi/syncs/kserver/core/LinkingData.kt create mode 100644 syncs-kserver/src/test/java/eu/darken/octi/syncs/kserver/KServerConnectorTest.kt diff --git a/PRIVACY_POLICY.md b/PRIVACY_POLICY.md index 6a6a3dfc..19163b40 100644 --- a/PRIVACY_POLICY.md +++ b/PRIVACY_POLICY.md @@ -26,18 +26,34 @@ Camera data is not stored and only used for processing the QR-Code. ### Query installed apps -Octi allows you to see which apps are installed on your other devices, to do this, the `QUERY_ALL_PACKAGES` is required. This information is only available to you and encrypted when exchanged between devices. Two edge cases exist: Information about installed apps may be contained in manually generated [debug logs](#debug-log) and [automatic error reports](#automatic-error-reports). +Octi allows you to see which apps are installed on your other devices, to do this, the `QUERY_ALL_PACKAGES` is required. +This information is only available to you and encrypted when exchanged between devices. Two edge cases exist: +Information about installed apps may be contained in manually generated [debug logs](#debug-log) +and [automatic error reports](#automatic-error-reports). ## Sync services Octi provides different mechanisms to syncronize data across different devices. The following explains the different mechanisms and how your data is handled. +### K-Server + +K-Server is an end to end encrypted open-source sync server hosted by me. + +Synced data can't be viewed by me. Data is encrypted client-side. The encryption key is only available on your devices +and is unknown to the server. + +Some meta data like access times and IP addresses are temporarily stored to allow for anti-abuse mechanisms. + +Any stored data can be deleted from within the app by deleting your account. If your account is not accessed at least +once within 30 days, your data is also deleted. + ### J-Server J-Server is an end to end encrypted open-source sync server hosted by me. -Synced data can't be viewed by me. Data is encrypted client-side. The encryption key is only available on your devices and is unknown to the server. +Synced data can't be viewed by me. Data is encrypted client-side. The encryption key is only available on your devices +and is unknown to the server. Some meta data like access times and IP addresses are temporarily stored to allow for anti-abuse mechanisms. diff --git a/app-common-test/src/main/java/testhelpers/coroutine/CoroutinesTestExtension.kt b/app-common-test/src/main/java/testhelpers/coroutine/CoroutinesTestExtension.kt deleted file mode 100644 index 34bf30a4..00000000 --- a/app-common-test/src/main/java/testhelpers/coroutine/CoroutinesTestExtension.kt +++ /dev/null @@ -1,24 +0,0 @@ -package testhelpers.coroutine - -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.* -import org.junit.jupiter.api.extension.AfterEachCallback -import org.junit.jupiter.api.extension.BeforeEachCallback -import org.junit.jupiter.api.extension.ExtensionContext - -@ExperimentalCoroutinesApi -class CoroutinesTestExtension( - private val dispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher() -) : BeforeEachCallback, AfterEachCallback, - TestCoroutineScope by createTestCoroutineScope(TestCoroutineDispatcher() + TestCoroutineExceptionHandler() + dispatcher) { - - override fun beforeEach(context: ExtensionContext?) { - Dispatchers.setMain(dispatcher) - } - - override fun afterEach(context: ExtensionContext?) { - cleanupTestCoroutines() - Dispatchers.resetMain() - } -} diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a8be3af2..d11f6218 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -116,6 +116,11 @@ android { useJUnitPlatform() } } + packaging { + resources { + excludes += "META-INF/INDEX.LIST" + } + } } dependencies { @@ -124,8 +129,9 @@ dependencies { implementation(project(":app-common")) testImplementation(project(":app-common-test")) implementation(project(":sync-core")) - implementation(project(":syncs-jserver")) implementation(project(":syncs-gdrive")) + implementation(project(":syncs-jserver")) + implementation(project(":syncs-kserver")) implementation(project(":module-core")) implementation(project(":modules-meta")) implementation(project(":modules-power")) diff --git a/app/src/androidTest/java/testhelper/BaseUITest.kt b/app/src/androidTest/java/testhelper/BaseUITest.kt index 3d4da5c0..0e18375f 100644 --- a/app/src/androidTest/java/testhelper/BaseUITest.kt +++ b/app/src/androidTest/java/testhelper/BaseUITest.kt @@ -1,5 +1,3 @@ package testhelper -import testhelpers.BaseTestInstrumentation - -abstract class BaseUITest : BaseTestInstrumentation() +abstract class BaseUITest diff --git a/app/src/debug/res/xml/network_security_config.xml b/app/src/debug/res/xml/network_security_config.xml new file mode 100644 index 00000000..dca93c07 --- /dev/null +++ b/app/src/debug/res/xml/network_security_config.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 736198b1..c405b75b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -12,6 +12,7 @@ android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" + android:networkSecurityConfig="@xml/network_security_config" android:theme="@style/AppTheme"> ( @LayoutRes layoutId: Int, parent: ViewGroup - ) : ModularAdapter.VH(layoutId, parent), BindableVH + ) : VH(layoutId, parent), BindableVH interface Item : DifferItem { override val payloadProvider: ((DifferItem, DifferItem) -> DifferItem?) diff --git a/app/src/main/java/eu/darken/octi/sync/ui/add/SyncAddVM.kt b/app/src/main/java/eu/darken/octi/sync/ui/add/SyncAddVM.kt index f5195161..3cb2231a 100644 --- a/app/src/main/java/eu/darken/octi/sync/ui/add/SyncAddVM.kt +++ b/app/src/main/java/eu/darken/octi/sync/ui/add/SyncAddVM.kt @@ -7,6 +7,7 @@ import eu.darken.octi.common.debug.logging.logTag import eu.darken.octi.common.uix.ViewModel3 import eu.darken.octi.syncs.gdrive.ui.add.AddGDriveVH import eu.darken.octi.syncs.jserver.ui.add.AddJServerDataVH +import eu.darken.octi.syncs.kserver.ui.add.AddKServerDataVH import kotlinx.coroutines.flow.flow import javax.inject.Inject @@ -26,6 +27,9 @@ class SyncAddVM @Inject constructor( AddJServerDataVH.Item { SyncAddFragmentDirections.actionSyncAddFragmentToAddJServerFragment().navigate() }.run { items.add(this) } + AddKServerDataVH.Item { + SyncAddFragmentDirections.actionSyncAddFragmentToAddKServerFragment().navigate() + }.run { items.add(this) } emit(items) }.asLiveData2() diff --git a/app/src/main/java/eu/darken/octi/sync/ui/list/SyncListAdapter.kt b/app/src/main/java/eu/darken/octi/sync/ui/list/SyncListAdapter.kt index 460e9969..5e9502d8 100644 --- a/app/src/main/java/eu/darken/octi/sync/ui/list/SyncListAdapter.kt +++ b/app/src/main/java/eu/darken/octi/sync/ui/list/SyncListAdapter.kt @@ -13,6 +13,7 @@ import eu.darken.octi.common.lists.modular.mods.DataBinderMod import eu.darken.octi.common.lists.modular.mods.TypedVHCreatorMod import eu.darken.octi.syncs.gdrive.ui.GDriveStateVH import eu.darken.octi.syncs.jserver.ui.JServerStateVH +import eu.darken.octi.syncs.kserver.ui.KServerStateVH import javax.inject.Inject @@ -28,12 +29,13 @@ class SyncListAdapter @Inject constructor() : modules.add(DataBinderMod(data)) modules.add(TypedVHCreatorMod({ data[it] is GDriveStateVH.Item }) { GDriveStateVH(it) }) modules.add(TypedVHCreatorMod({ data[it] is JServerStateVH.Item }) { JServerStateVH(it) }) + modules.add(TypedVHCreatorMod({ data[it] is KServerStateVH.Item }) { KServerStateVH(it) }) } abstract class BaseVH( @LayoutRes layoutId: Int, parent: ViewGroup - ) : ModularAdapter.VH(layoutId, parent), BindableVH + ) : VH(layoutId, parent), BindableVH interface Item : DifferItem { override val payloadProvider: ((DifferItem, DifferItem) -> DifferItem?) diff --git a/app/src/main/java/eu/darken/octi/sync/ui/list/SyncListVM.kt b/app/src/main/java/eu/darken/octi/sync/ui/list/SyncListVM.kt index f351ac0d..281f648f 100644 --- a/app/src/main/java/eu/darken/octi/sync/ui/list/SyncListVM.kt +++ b/app/src/main/java/eu/darken/octi/sync/ui/list/SyncListVM.kt @@ -11,6 +11,8 @@ import eu.darken.octi.syncs.gdrive.core.GDriveAppDataConnector import eu.darken.octi.syncs.gdrive.ui.GDriveStateVH import eu.darken.octi.syncs.jserver.core.JServerConnector import eu.darken.octi.syncs.jserver.ui.JServerStateVH +import eu.darken.octi.syncs.kserver.core.KServerConnector +import eu.darken.octi.syncs.kserver.ui.KServerStateVH import kotlinx.coroutines.flow.* import javax.inject.Inject @@ -49,16 +51,29 @@ class SyncListVM @Inject constructor( ).navigate() } ) + is JServerConnector -> JServerStateVH.Item( credentials = connector.credentials, ourState = state, otherStates = (connectors - connector).map { it.state.first() }, onManage = { - SyncListFragmentDirections.actionSyncListFragmentToSyrvJServerActionsFragment( + SyncListFragmentDirections.actionSyncListFragmentToJServerActionsFragment( connector.identifier ).navigate() } ) + + is KServerConnector -> KServerStateVH.Item( + credentials = connector.credentials, + ourState = state, + otherStates = (connectors - connector).map { it.state.first() }, + onManage = { + SyncListFragmentDirections.actionSyncListFragmentToKServerActionsFragment( + connector.identifier + ).navigate() + } + ) + else -> { log(TAG, WARN) { "Unknown connector type: $connector" } null diff --git a/app/src/main/java/eu/darken/octi/syncs/jserver/ui/link/LinkOption.kt b/app/src/main/java/eu/darken/octi/syncs/jserver/ui/link/JServerLinkOption.kt similarity index 71% rename from app/src/main/java/eu/darken/octi/syncs/jserver/ui/link/LinkOption.kt rename to app/src/main/java/eu/darken/octi/syncs/jserver/ui/link/JServerLinkOption.kt index 0421d1d1..22eb50af 100644 --- a/app/src/main/java/eu/darken/octi/syncs/jserver/ui/link/LinkOption.kt +++ b/app/src/main/java/eu/darken/octi/syncs/jserver/ui/link/JServerLinkOption.kt @@ -1,6 +1,6 @@ package eu.darken.octi.syncs.jserver.ui.link -enum class LinkOption { +enum class JServerLinkOption { DIRECT, QRCODE, NFC diff --git a/app/src/main/java/eu/darken/octi/syncs/jserver/ui/link/client/JServerLinkClientFragment.kt b/app/src/main/java/eu/darken/octi/syncs/jserver/ui/link/client/JServerLinkClientFragment.kt index f028aa4c..85502f6a 100644 --- a/app/src/main/java/eu/darken/octi/syncs/jserver/ui/link/client/JServerLinkClientFragment.kt +++ b/app/src/main/java/eu/darken/octi/syncs/jserver/ui/link/client/JServerLinkClientFragment.kt @@ -18,7 +18,7 @@ import eu.darken.octi.common.debug.logging.logTag import eu.darken.octi.common.uix.Fragment3 import eu.darken.octi.common.viewbinding.viewBinding import eu.darken.octi.databinding.SyncJserverLinkClientFragmentBinding -import eu.darken.octi.syncs.jserver.ui.link.LinkOption +import eu.darken.octi.syncs.jserver.ui.link.JServerLinkOption @AndroidEntryPoint @@ -43,9 +43,9 @@ class JServerLinkClientFragment : Fragment3(R.layout.sync_jserver_link_client_fr ui.linkOptions.setOnCheckedChangeListener { _, checkedId -> when (checkedId) { - R.id.link_option_direct -> vm.onLinkOptionSelected(LinkOption.DIRECT) - R.id.link_option_qrcode -> vm.onLinkOptionSelected(LinkOption.QRCODE) - R.id.link_option_nfc -> vm.onLinkOptionSelected(LinkOption.NFC) + R.id.link_option_direct -> vm.onLinkOptionSelected(JServerLinkOption.DIRECT) + R.id.link_option_qrcode -> vm.onLinkOptionSelected(JServerLinkOption.QRCODE) + R.id.link_option_nfc -> vm.onLinkOptionSelected(JServerLinkOption.NFC) } } @@ -70,19 +70,21 @@ class JServerLinkClientFragment : Fragment3(R.layout.sync_jserver_link_client_fr } vm.state.observe2(ui) { state -> - linkContainerDirect.isGone = state.linkOption != LinkOption.DIRECT - linkContainerQrcode.isGone = state.linkOption != LinkOption.QRCODE - linkContainerNfc.isGone = state.linkOption != LinkOption.NFC + linkContainerDirect.isGone = state.linkOption != JServerLinkOption.DIRECT + linkContainerQrcode.isGone = state.linkOption != JServerLinkOption.QRCODE + linkContainerNfc.isGone = state.linkOption != JServerLinkOption.NFC when (state.linkOption) { - LinkOption.DIRECT -> { + JServerLinkOption.DIRECT -> { linkOptions.check(R.id.link_option_direct) linkCodeActual.text = null } - LinkOption.QRCODE -> { + + JServerLinkOption.QRCODE -> { linkOptions.check(R.id.link_option_qrcode) } - LinkOption.NFC -> { + + JServerLinkOption.NFC -> { linkOptions.check(R.id.link_option_nfc) // TODO NOOP? } diff --git a/app/src/main/java/eu/darken/octi/syncs/jserver/ui/link/client/JServerLinkClientVM.kt b/app/src/main/java/eu/darken/octi/syncs/jserver/ui/link/client/JServerLinkClientVM.kt index 39b10e6a..6b0c4703 100644 --- a/app/src/main/java/eu/darken/octi/syncs/jserver/ui/link/client/JServerLinkClientVM.kt +++ b/app/src/main/java/eu/darken/octi/syncs/jserver/ui/link/client/JServerLinkClientVM.kt @@ -9,7 +9,7 @@ import eu.darken.octi.common.debug.logging.logTag import eu.darken.octi.common.uix.ViewModel3 import eu.darken.octi.syncs.jserver.core.JServerHub import eu.darken.octi.syncs.jserver.core.LinkingData -import eu.darken.octi.syncs.jserver.ui.link.LinkOption +import eu.darken.octi.syncs.jserver.ui.link.JServerLinkOption import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -27,14 +27,14 @@ class JServerLinkClientVM @Inject constructor( data class State( val encodedLinkCode: String? = null, - val linkOption: LinkOption = LinkOption.QRCODE, + val linkOption: JServerLinkOption = JServerLinkOption.QRCODE, val isBusy: Boolean = false, ) private val _state = MutableStateFlow(State()) val state = _state.asLiveData2() - fun onLinkOptionSelected(option: LinkOption) = launch { + fun onLinkOptionSelected(option: JServerLinkOption) = launch { log(TAG) { "onLinkOptionSelected(option=$option)" } stateLock.withLock { _state.value = _state.value.copy(linkOption = option) diff --git a/app/src/main/java/eu/darken/octi/syncs/jserver/ui/link/host/JServerLinkHostFragment.kt b/app/src/main/java/eu/darken/octi/syncs/jserver/ui/link/host/JServerLinkHostFragment.kt index 2a64dc79..52d95cdd 100644 --- a/app/src/main/java/eu/darken/octi/syncs/jserver/ui/link/host/JServerLinkHostFragment.kt +++ b/app/src/main/java/eu/darken/octi/syncs/jserver/ui/link/host/JServerLinkHostFragment.kt @@ -16,7 +16,7 @@ import eu.darken.octi.common.navigation.popBackStack import eu.darken.octi.common.uix.Fragment3 import eu.darken.octi.common.viewbinding.viewBinding import eu.darken.octi.databinding.SyncJserverLinkHostFragmentBinding -import eu.darken.octi.syncs.jserver.ui.link.LinkOption +import eu.darken.octi.syncs.jserver.ui.link.JServerLinkOption @AndroidEntryPoint @@ -32,25 +32,26 @@ class JServerLinkHostFragment : Fragment3(R.layout.sync_jserver_link_host_fragme ui.linkOptions.setOnCheckedChangeListener { _, checkedId -> when (checkedId) { - R.id.link_option_direct -> vm.onLinkOptionSelected(LinkOption.DIRECT) - R.id.link_option_qrcode -> vm.onLinkOptionSelected(LinkOption.QRCODE) - R.id.link_option_nfc -> vm.onLinkOptionSelected(LinkOption.NFC) + R.id.link_option_direct -> vm.onLinkOptionSelected(JServerLinkOption.DIRECT) + R.id.link_option_qrcode -> vm.onLinkOptionSelected(JServerLinkOption.QRCODE) + R.id.link_option_nfc -> vm.onLinkOptionSelected(JServerLinkOption.NFC) } } ui.linkCodeInputAction.setOnClickListener { vm.shareLinkCode(requireActivity()) } vm.state.observe2(ui) { state -> - linkContainerDirect.isGone = state.linkOption != LinkOption.DIRECT - linkContainerQrcode.isGone = state.linkOption != LinkOption.QRCODE - linkContainerNfc.isGone = state.linkOption != LinkOption.NFC + linkContainerDirect.isGone = state.linkOption != JServerLinkOption.DIRECT + linkContainerQrcode.isGone = state.linkOption != JServerLinkOption.QRCODE + linkContainerNfc.isGone = state.linkOption != JServerLinkOption.NFC when (state.linkOption) { - LinkOption.DIRECT -> { + JServerLinkOption.DIRECT -> { linkOptions.check(R.id.link_option_direct) linkCodeActual.text = state.encodedLinkCode } - LinkOption.QRCODE -> { + + JServerLinkOption.QRCODE -> { linkOptions.check(R.id.link_option_qrcode) try { val size = ui.root.width @@ -62,7 +63,8 @@ class JServerLinkHostFragment : Fragment3(R.layout.sync_jserver_link_host_fragme e.asErrorDialogBuilder(requireContext()).show() } } - LinkOption.NFC -> { + + JServerLinkOption.NFC -> { linkOptions.check(R.id.link_option_nfc) // TODO NOOP? } diff --git a/app/src/main/java/eu/darken/octi/syncs/jserver/ui/link/host/JServerLinkHostVM.kt b/app/src/main/java/eu/darken/octi/syncs/jserver/ui/link/host/JServerLinkHostVM.kt index a5f22944..7f488542 100644 --- a/app/src/main/java/eu/darken/octi/syncs/jserver/ui/link/host/JServerLinkHostVM.kt +++ b/app/src/main/java/eu/darken/octi/syncs/jserver/ui/link/host/JServerLinkHostVM.kt @@ -15,7 +15,7 @@ import eu.darken.octi.sync.core.SyncManager import eu.darken.octi.sync.core.SyncOptions import eu.darken.octi.sync.core.getConnectorById import eu.darken.octi.syncs.jserver.core.JServerConnector -import eu.darken.octi.syncs.jserver.ui.link.LinkOption +import eu.darken.octi.syncs.jserver.ui.link.JServerLinkOption import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* @@ -37,7 +37,7 @@ class JServerLinkHostVM @Inject constructor( data class State( val encodedLinkCode: String? = null, - val linkOption: LinkOption = LinkOption.QRCODE, + val linkOption: JServerLinkOption = JServerLinkOption.QRCODE, ) private val _state = MutableStateFlow(State()) @@ -77,7 +77,7 @@ class JServerLinkHostVM @Inject constructor( } } - fun onLinkOptionSelected(option: LinkOption) = launch { + fun onLinkOptionSelected(option: JServerLinkOption) = launch { log(TAG) { "onLinkOptionSelected(option=$option)" } stateLock.withLock { _state.value = _state.value.copy(linkOption = option) diff --git a/app/src/main/java/eu/darken/octi/syncs/kserver/ui/KServerStateVH.kt b/app/src/main/java/eu/darken/octi/syncs/kserver/ui/KServerStateVH.kt new file mode 100644 index 00000000..cac09b77 --- /dev/null +++ b/app/src/main/java/eu/darken/octi/syncs/kserver/ui/KServerStateVH.kt @@ -0,0 +1,79 @@ +package eu.darken.octi.syncs.kserver.ui + +import android.text.format.DateUtils +import android.text.format.Formatter +import android.view.ViewGroup +import androidx.core.view.isGone +import eu.darken.octi.R +import eu.darken.octi.common.getColorForAttr +import eu.darken.octi.databinding.SyncListItemKserverBinding +import eu.darken.octi.sync.core.SyncConnectorState +import eu.darken.octi.sync.ui.list.SyncListAdapter +import eu.darken.octi.syncs.kserver.core.KServer + + +class KServerStateVH(parent: ViewGroup) : + SyncListAdapter.BaseVH(R.layout.sync_list_item_kserver, parent) { + + override val viewBinding = lazy { SyncListItemKserverBinding.bind(itemView) } + + override val onBindData: SyncListItemKserverBinding.( + item: Item, + payloads: List + ) -> Unit = { item, _ -> + title.text = "${getString(R.string.sync_kserver_type_label)} (${item.credentials.serverAdress.domain})" + + accountText.text = item.credentials.accountId.id + + lastSyncText.apply { + text = item.ourState.lastSyncAt + ?.let { DateUtils.getRelativeTimeSpanString(it.toEpochMilli()) } + ?: getString(R.string.sync_last_never_label) + + if (item.ourState.lastError != null) { + setTextColor(context.getColorForAttr(R.attr.colorError)) + } else { + setTextColor(context.getColorForAttr(android.R.attr.textColorPrimary)) + } + } + syncProgressIndicator.isGone = !item.ourState.isBusy + + quotaText.text = item.ourState.quota + ?.let { stats -> + val total = Formatter.formatShortFileSize(context, stats.storageTotal) + val used = Formatter.formatShortFileSize(context, stats.storageUsed) + val free = Formatter.formatShortFileSize(context, stats.storageFree) + getString(R.string.sync_quota_storage_msg, free, used, total) + } + ?: getString(R.string.general_na_label) + + devicesText.text = item.ourState.devices?.let { ourDevices -> + var deviceString = getQuantityString(R.plurals.general_devices_count_label, ourDevices.size) + + val devicesFromConnectors = item.otherStates.mapNotNull { it.devices }.flatten().toSet() + val uniqueDevices = ourDevices - devicesFromConnectors + if (uniqueDevices.isNotEmpty()) { + val uniquesString = getQuantityString(R.plurals.general_unique_devices_count_label, uniqueDevices.size) + deviceString += " ($uniquesString)" + } + + deviceString + } ?: getString(R.string.general_na_label) + + itemView.setOnClickListener { item.onManage() } + } + + data class Item( + val credentials: KServer.Credentials, + val ourState: SyncConnectorState, + val otherStates: Collection, + val onManage: () -> Unit, + ) : SyncListAdapter.Item { + override val stableId: Long + get() { + var result = this.javaClass.hashCode().toLong() + result = 31 * result + credentials.hashCode() + return result + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/octi/syncs/kserver/ui/actions/ActionEvents.kt b/app/src/main/java/eu/darken/octi/syncs/kserver/ui/actions/ActionEvents.kt new file mode 100644 index 00000000..f02353b5 --- /dev/null +++ b/app/src/main/java/eu/darken/octi/syncs/kserver/ui/actions/ActionEvents.kt @@ -0,0 +1,3 @@ +package eu.darken.octi.syncs.kserver.ui.actions + +sealed class ActionEvents diff --git a/app/src/main/java/eu/darken/octi/syncs/kserver/ui/actions/KServerActionsFragment.kt b/app/src/main/java/eu/darken/octi/syncs/kserver/ui/actions/KServerActionsFragment.kt new file mode 100644 index 00000000..efd36d70 --- /dev/null +++ b/app/src/main/java/eu/darken/octi/syncs/kserver/ui/actions/KServerActionsFragment.kt @@ -0,0 +1,61 @@ +package eu.darken.octi.syncs.kserver.ui.actions + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.viewModels +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import dagger.hilt.android.AndroidEntryPoint +import eu.darken.octi.R +import eu.darken.octi.common.uix.BottomSheetDialogFragment2 +import eu.darken.octi.databinding.SyncActionsKserverFragmentBinding + +@AndroidEntryPoint +class KServerActionsFragment : BottomSheetDialogFragment2() { + + override val vm: KServerActionsVM by viewModels() + override lateinit var ui: SyncActionsKserverFragmentBinding + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + ui = SyncActionsKserverFragmentBinding.inflate(inflater, container, false) + return ui.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + ui.syncAction.setOnClickListener { vm.forceSync() } + ui.linkAction.setOnClickListener { vm.linkNewDevice() } + ui.disconnectAction.setOnClickListener { + MaterialAlertDialogBuilder(requireContext()).apply { + setMessage(R.string.sync_kserver_disconnect_confirmation_desc) + setPositiveButton(R.string.general_disconnect_action) { _, _ -> + vm.disconnct() + } + setNegativeButton(R.string.general_cancel_action) { _, _ -> + + } + }.show() + } + ui.wipeAction.setOnClickListener { + MaterialAlertDialogBuilder(requireContext()).apply { + setMessage(R.string.sync_kserver_wipe_confirmation_desc) + setPositiveButton(R.string.general_wipe_action) { _, _ -> + vm.wipe() + } + setNegativeButton(R.string.general_cancel_action) { _, _ -> + + } + }.show() + } + + vm.state.observe2(ui) { + title.text = "${getString(R.string.sync_kserver_type_label)} (${it.credentials.serverAdress.domain})" + subtitle.text = it.credentials.accountId.id + } + + vm.actionEvents.observe2(ui) { + + } + super.onViewCreated(view, savedInstanceState) + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/octi/syncs/kserver/ui/actions/KServerActionsVM.kt b/app/src/main/java/eu/darken/octi/syncs/kserver/ui/actions/KServerActionsVM.kt new file mode 100644 index 00000000..0028f7f9 --- /dev/null +++ b/app/src/main/java/eu/darken/octi/syncs/kserver/ui/actions/KServerActionsVM.kt @@ -0,0 +1,72 @@ +package eu.darken.octi.syncs.kserver.ui.actions + +import androidx.lifecycle.SavedStateHandle +import dagger.hilt.android.lifecycle.HiltViewModel +import eu.darken.octi.common.coroutine.DispatcherProvider +import eu.darken.octi.common.debug.logging.log +import eu.darken.octi.common.debug.logging.logTag +import eu.darken.octi.common.livedata.SingleLiveEvent +import eu.darken.octi.common.navigation.navArgs +import eu.darken.octi.common.uix.ViewModel3 +import eu.darken.octi.sync.core.SyncManager +import eu.darken.octi.sync.core.getConnectorById +import eu.darken.octi.syncs.kserver.core.KServer +import eu.darken.octi.syncs.kserver.core.KServerConnector +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +@HiltViewModel +class KServerActionsVM @Inject constructor( + @Suppress("UNUSED_PARAMETER") handle: SavedStateHandle, + private val dispatcherProvider: DispatcherProvider, + private val syncManager: SyncManager, +) : ViewModel3(dispatcherProvider = dispatcherProvider) { + + private val navArgs: KServerActionsFragmentArgs by handle.navArgs() + + val actionEvents = SingleLiveEvent() + + data class State( + val credentials: KServer.Credentials + ) + + val state = syncManager.getConnectorById(navArgs.identifier) + .map { + State(it.credentials) + } + .catch { + if (it is NoSuchElementException) navEvents.postValue(null) + else throw it + } + .asLiveData2() + + fun linkNewDevice() { + log(TAG) { "linkNewDevice()" } + KServerActionsFragmentDirections.actionKServerActionsFragmentToKServerLinkFragment( + navArgs.identifier + ).navigate() + } + + fun disconnct() = launch { + log(TAG) { "disconnct()" } + syncManager.disconnect(navArgs.identifier) + navEvents.postValue(null) + } + + fun wipe() = launch { + log(TAG) { "wipe()" } + syncManager.wipe(navArgs.identifier) + navEvents.postValue(null) + } + + fun forceSync() = launch { + log(TAG) { "forceSync()" } + syncManager.sync(navArgs.identifier) + popNavStack() + } + + companion object { + private val TAG = logTag("Sync", "KServer", "Actions", "Fragment", "VM") + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/octi/syncs/kserver/ui/add/AddKServerDataVH.kt b/app/src/main/java/eu/darken/octi/syncs/kserver/ui/add/AddKServerDataVH.kt new file mode 100644 index 00000000..f4b48d4c --- /dev/null +++ b/app/src/main/java/eu/darken/octi/syncs/kserver/ui/add/AddKServerDataVH.kt @@ -0,0 +1,26 @@ +package eu.darken.octi.syncs.kserver.ui.add + +import android.view.ViewGroup +import eu.darken.octi.R +import eu.darken.octi.databinding.SyncAddItemKserverBinding +import eu.darken.octi.sync.ui.add.SyncAddAdapter + + +class AddKServerDataVH(parent: ViewGroup) : + SyncAddAdapter.BaseVH(R.layout.sync_add_item_kserver, parent) { + + override val viewBinding = lazy { SyncAddItemKserverBinding.bind(itemView) } + + override val onBindData: SyncAddItemKserverBinding.( + item: Item, + payloads: List + ) -> Unit = { item, _ -> + itemView.setOnClickListener { item.onClick() } + } + + data class Item( + val onClick: () -> Unit, + ) : SyncAddAdapter.Item { + override val stableId: Long = Item::class.java.hashCode().toLong() + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/octi/syncs/kserver/ui/add/AddKServerFragment.kt b/app/src/main/java/eu/darken/octi/syncs/kserver/ui/add/AddKServerFragment.kt new file mode 100644 index 00000000..f08062a2 --- /dev/null +++ b/app/src/main/java/eu/darken/octi/syncs/kserver/ui/add/AddKServerFragment.kt @@ -0,0 +1,95 @@ +package eu.darken.octi.syncs.kserver.ui.add + +import android.os.Bundle +import android.view.View +import androidx.core.view.isGone +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import androidx.navigation.ui.setupWithNavController +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import dagger.hilt.android.AndroidEntryPoint +import eu.darken.octi.R +import eu.darken.octi.common.BuildConfigWrap +import eu.darken.octi.common.WebpageTool +import eu.darken.octi.common.uix.Fragment3 +import eu.darken.octi.common.viewbinding.viewBinding +import eu.darken.octi.databinding.SyncAddNewKserverFragmentBinding +import eu.darken.octi.syncs.kserver.core.KServer +import javax.inject.Inject + + +@AndroidEntryPoint +class AddKServerFragment : Fragment3(R.layout.sync_add_new_kserver_fragment) { + + override val vm: AddKServerVM by viewModels() + override val ui: SyncAddNewKserverFragmentBinding by viewBinding() + @Inject lateinit var webpageTool: WebpageTool + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + ui.toolbar.apply { + setupWithNavController(findNavController()) + setOnMenuItemClickListener { + when (it.itemId) { + R.id.action_info -> { + showAbout() + true + } + + else -> super.onOptionsItemSelected(it) + } + } + } + + ui.serverGroup.setOnCheckedChangeListener { group, checkedId -> + when (checkedId) { + R.id.server_kserver_prod_item -> vm.selectType(KServer.Official.PROD) + R.id.server_kserver_beta_item -> vm.selectType(KServer.Official.BETA) + R.id.server_kserver_dev_item -> vm.selectType(KServer.Official.DEV) + R.id.server_kserver_local_item -> vm.selectType(KServer.Official.LOCAL) + } + } + ui.serverKserverProdItem.apply { + text = "${KServer.Official.PROD.address.domain} (Production)" + } + ui.serverKserverBetaItem.apply { + text = "${KServer.Official.BETA.address.domain} (Beta)" + isGone = true + } + ui.serverKserverDevItem.apply { + text = "${KServer.Official.DEV.address.domain} (dev)" + isGone = true + } + ui.serverKserverLocalItem.apply { + text = "${KServer.Official.LOCAL.address.domain} (local)" + isGone = !BuildConfigWrap.DEBUG + } + + vm.state.observe2(ui) { state -> + when (state.serverType) { + KServer.Official.PROD -> serverGroup.check(R.id.server_kserver_prod_item) + KServer.Official.BETA -> serverGroup.check(R.id.server_kserver_beta_item) + KServer.Official.DEV -> serverGroup.check(R.id.server_kserver_dev_item) + KServer.Official.LOCAL -> serverGroup.check(R.id.server_kserver_local_item) + } + createNewAccount.isEnabled = !state.isBusy + linkExistingAccount.isEnabled = !state.isBusy + serverGroup.isEnabled = !state.isBusy + } + + ui.createNewAccount.setOnClickListener { vm.createAccount() } + ui.linkExistingAccount.setOnClickListener { vm.linkAccount() } + + super.onViewCreated(view, savedInstanceState) + } + + private fun showAbout() = MaterialAlertDialogBuilder(requireContext()).apply { + setTitle(R.string.sync_kserver_about_title) + setMessage(R.string.sync_kserver_about_desc) + setPositiveButton(R.string.general_gotit_action) { _, _ -> + + } + setNeutralButton(R.string.sync_kserver_about_source_action) { _, _ -> + webpageTool.open("https://github.com/d4rken/octi-sync-server-kotlin") + } + }.show() +} diff --git a/app/src/main/java/eu/darken/octi/syncs/kserver/ui/add/AddKServerVM.kt b/app/src/main/java/eu/darken/octi/syncs/kserver/ui/add/AddKServerVM.kt new file mode 100644 index 00000000..75c23472 --- /dev/null +++ b/app/src/main/java/eu/darken/octi/syncs/kserver/ui/add/AddKServerVM.kt @@ -0,0 +1,75 @@ +package eu.darken.octi.syncs.kserver.ui.add + +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.INFO +import eu.darken.octi.common.debug.logging.log +import eu.darken.octi.common.debug.logging.logTag +import eu.darken.octi.common.uix.ViewModel3 +import eu.darken.octi.sync.core.SyncSettings +import eu.darken.octi.syncs.kserver.core.KServer +import eu.darken.octi.syncs.kserver.core.KServerAccountRepo +import eu.darken.octi.syncs.kserver.core.KServerEndpoint +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.withContext +import javax.inject.Inject + +@HiltViewModel +class AddKServerVM @Inject constructor( + @Suppress("UNUSED_PARAMETER") handle: SavedStateHandle, + private val dispatcherProvider: DispatcherProvider, + private val kServerAccountRepo: KServerAccountRepo, + private val kServerEndpointFactory: KServerEndpoint.Factory, + private val syncSettings: SyncSettings, +) : ViewModel3(dispatcherProvider = dispatcherProvider) { + + data class State( + val serverType: KServer.Official = KServer.Official.PROD, + val isBusy: Boolean = false + ) + + private val _state = MutableStateFlow(State()) + val state = _state.asLiveData2() + + fun selectType(type: KServer.Official) { + log(TAG) { "selectType(type=$type)" } + _state.value = _state.value.copy(serverType = type) + } + + fun createAccount() = launch { + log(TAG) { "createAccount()" } + _state.value = _state.value.copy(isBusy = true) + try { + val type = _state.value.serverType.address + log(TAG) { "createAccount(): $type" } + val endpoint = kServerEndpointFactory.create(type) + + withContext(NonCancellable) { + log(TAG) { "Creating account..." } + val newCredentials = endpoint.createNewAccount() + log(TAG, INFO) { "New account created: $newCredentials" } + kServerAccountRepo.add(newCredentials) + } + navEvents.postValue(null) + + } finally { + _state.value = _state.value.copy(isBusy = false) + } + } + + fun linkAccount() = launch { + log(TAG) { "linkAccount()" } + _state.value = _state.value.copy(isBusy = true) + try { + AddKServerFragmentDirections.actionAddKServerFragmentToKServerLinkClientFragment().navigate() + } finally { + _state.value = _state.value.copy(isBusy = false) + } + } + + companion object { + private val TAG = logTag("Sync", "Add", "KServer", "Fragment", "VM") + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/octi/syncs/kserver/ui/link/KServerLinkOption.kt b/app/src/main/java/eu/darken/octi/syncs/kserver/ui/link/KServerLinkOption.kt new file mode 100644 index 00000000..7cff183c --- /dev/null +++ b/app/src/main/java/eu/darken/octi/syncs/kserver/ui/link/KServerLinkOption.kt @@ -0,0 +1,7 @@ +package eu.darken.octi.syncs.kserver.ui.link + +enum class KServerLinkOption { + DIRECT, + QRCODE, + NFC +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/octi/syncs/kserver/ui/link/client/KServerLinkClientFragment.kt b/app/src/main/java/eu/darken/octi/syncs/kserver/ui/link/client/KServerLinkClientFragment.kt new file mode 100644 index 00000000..bc2fd8c1 --- /dev/null +++ b/app/src/main/java/eu/darken/octi/syncs/kserver/ui/link/client/KServerLinkClientFragment.kt @@ -0,0 +1,101 @@ +package eu.darken.octi.syncs.kserver.ui.link.client + +import android.os.Bundle +import android.view.View +import android.view.inputmethod.EditorInfo +import androidx.core.view.isGone +import androidx.core.view.isVisible +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import androidx.navigation.ui.setupWithNavController +import com.journeyapps.barcodescanner.ScanContract +import com.journeyapps.barcodescanner.ScanIntentResult +import com.journeyapps.barcodescanner.ScanOptions +import dagger.hilt.android.AndroidEntryPoint +import eu.darken.octi.R +import eu.darken.octi.common.debug.logging.log +import eu.darken.octi.common.debug.logging.logTag +import eu.darken.octi.common.uix.Fragment3 +import eu.darken.octi.common.viewbinding.viewBinding +import eu.darken.octi.databinding.SyncKserverLinkClientFragmentBinding +import eu.darken.octi.syncs.kserver.ui.link.KServerLinkOption + + +@AndroidEntryPoint +class KServerLinkClientFragment : Fragment3(R.layout.sync_kserver_link_client_fragment) { + + override val vm: KServerLinkClientVM by viewModels() + override val ui: SyncKserverLinkClientFragmentBinding by viewBinding() + + private val barcodeLauncher = registerForActivityResult(ScanContract()) { result: ScanIntentResult -> + if (result.contents == null) { + log(TAG) { "QRCode scan was cancelled." } + } else { + log(TAG) { "QRCode scanned: $result" } + vm.onCodeEntered(result.contents) + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + ui.toolbar.apply { + setupWithNavController(findNavController()) + } + + ui.linkOptions.setOnCheckedChangeListener { _, checkedId -> + when (checkedId) { + R.id.link_option_direct -> vm.onLinkOptionSelected(KServerLinkOption.DIRECT) + R.id.link_option_qrcode -> vm.onLinkOptionSelected(KServerLinkOption.QRCODE) + R.id.link_option_nfc -> vm.onLinkOptionSelected(KServerLinkOption.NFC) + } + } + + ui.apply { + linkCodeInputAction.setOnClickListener { vm.onCodeEntered(ui.linkCodeActual.text.toString()) } + linkCodeActual.setOnEditorActionListener { _, actionId, _ -> + when (actionId) { + EditorInfo.IME_ACTION_GO -> { + vm.onCodeEntered(ui.linkCodeActual.text.toString()) + true + } + + else -> false + } + } + } + + ui.linkQrcodeCameraAction.setOnClickListener { + val options = ScanOptions().apply { + setOrientationLocked(false) + } + barcodeLauncher.launch(options) + } + + vm.state.observe2(ui) { state -> + linkContainerDirect.isGone = state.linkOption != KServerLinkOption.DIRECT + linkContainerQrcode.isGone = state.linkOption != KServerLinkOption.QRCODE + linkContainerNfc.isGone = state.linkOption != KServerLinkOption.NFC + + when (state.linkOption) { + KServerLinkOption.DIRECT -> { + linkOptions.check(R.id.link_option_direct) + linkCodeActual.text = null + } + + KServerLinkOption.QRCODE -> { + linkOptions.check(R.id.link_option_qrcode) + } + + KServerLinkOption.NFC -> { + linkOptions.check(R.id.link_option_nfc) + // TODO NOOP? + } + } + busyContainer.isVisible = state.isBusy + } + super.onViewCreated(view, savedInstanceState) + } + + companion object { + private val TAG = logTag("Sync", "KServer", "Link", "Client", "Fragment") + } +} diff --git a/app/src/main/java/eu/darken/octi/syncs/kserver/ui/link/client/KServerLinkClientVM.kt b/app/src/main/java/eu/darken/octi/syncs/kserver/ui/link/client/KServerLinkClientVM.kt new file mode 100644 index 00000000..060ccb06 --- /dev/null +++ b/app/src/main/java/eu/darken/octi/syncs/kserver/ui/link/client/KServerLinkClientVM.kt @@ -0,0 +1,63 @@ +package eu.darken.octi.syncs.kserver.ui.link.client + +import androidx.lifecycle.SavedStateHandle +import com.squareup.moshi.Moshi +import dagger.hilt.android.lifecycle.HiltViewModel +import eu.darken.octi.common.coroutine.DispatcherProvider +import eu.darken.octi.common.debug.logging.log +import eu.darken.octi.common.debug.logging.logTag +import eu.darken.octi.common.uix.ViewModel3 +import eu.darken.octi.syncs.kserver.core.KServerHub +import eu.darken.octi.syncs.kserver.core.LinkingData +import eu.darken.octi.syncs.kserver.ui.link.KServerLinkOption +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import javax.inject.Inject + +@HiltViewModel +class KServerLinkClientVM @Inject constructor( + @Suppress("UNUSED_PARAMETER") handle: SavedStateHandle, + private val dispatcherProvider: DispatcherProvider, + private val moshi: Moshi, + private val kServerHub: KServerHub, +) : ViewModel3(dispatcherProvider = dispatcherProvider) { + + private val stateLock = Mutex() + + data class State( + val encodedLinkCode: String? = null, + val linkOption: KServerLinkOption = KServerLinkOption.QRCODE, + val isBusy: Boolean = false, + ) + + private val _state = MutableStateFlow(State()) + val state = _state.asLiveData2() + + fun onLinkOptionSelected(option: KServerLinkOption) = launch { + log(TAG) { "onLinkOptionSelected(option=$option)" } + stateLock.withLock { + _state.value = _state.value.copy(linkOption = option) + } + } + + fun onCodeEntered(rawCode: String) = launch { + log(TAG) { "onCodeEntered(rawCode=$rawCode)" } + _state.value = _state.value.copy(isBusy = true) + try { + val linkContainer = LinkingData.fromEncodedString(moshi, rawCode).also { + log(TAG) { "Got container: $it" } + } + + kServerHub.linkAcount(linkContainer) + + popNavStack() + } finally { + _state.value = _state.value.copy(isBusy = false) + } + } + + companion object { + private val TAG = logTag("Sync", "KServer", "Link", "Client", "Fragment", "VM") + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/octi/syncs/kserver/ui/link/host/KServerLinkHostFragment.kt b/app/src/main/java/eu/darken/octi/syncs/kserver/ui/link/host/KServerLinkHostFragment.kt new file mode 100644 index 00000000..b9375916 --- /dev/null +++ b/app/src/main/java/eu/darken/octi/syncs/kserver/ui/link/host/KServerLinkHostFragment.kt @@ -0,0 +1,86 @@ +package eu.darken.octi.syncs.kserver.ui.link.host + +import android.os.Bundle +import android.view.View +import android.widget.Toast +import androidx.core.view.isGone +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import androidx.navigation.ui.setupWithNavController +import com.google.zxing.BarcodeFormat +import com.journeyapps.barcodescanner.BarcodeEncoder +import dagger.hilt.android.AndroidEntryPoint +import eu.darken.octi.R +import eu.darken.octi.common.error.asErrorDialogBuilder +import eu.darken.octi.common.navigation.popBackStack +import eu.darken.octi.common.uix.Fragment3 +import eu.darken.octi.common.viewbinding.viewBinding +import eu.darken.octi.databinding.SyncKserverLinkHostFragmentBinding +import eu.darken.octi.syncs.kserver.ui.link.KServerLinkOption + + +@AndroidEntryPoint +class KServerLinkHostFragment : Fragment3(R.layout.sync_kserver_link_host_fragment) { + + override val vm: KServerLinkHostVM by viewModels() + override val ui: SyncKserverLinkHostFragmentBinding by viewBinding() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + ui.toolbar.apply { + setupWithNavController(findNavController()) + } + + ui.linkOptions.setOnCheckedChangeListener { _, checkedId -> + when (checkedId) { + R.id.link_option_direct -> vm.onLinkOptionSelected(KServerLinkOption.DIRECT) + R.id.link_option_qrcode -> vm.onLinkOptionSelected(KServerLinkOption.QRCODE) + R.id.link_option_nfc -> vm.onLinkOptionSelected(KServerLinkOption.NFC) + } + } + + ui.linkCodeInputAction.setOnClickListener { vm.shareLinkCode(requireActivity()) } + + vm.state.observe2(ui) { state -> + linkContainerDirect.isGone = state.linkOption != KServerLinkOption.DIRECT + linkContainerQrcode.isGone = state.linkOption != KServerLinkOption.QRCODE + linkContainerNfc.isGone = state.linkOption != KServerLinkOption.NFC + + when (state.linkOption) { + KServerLinkOption.DIRECT -> { + linkOptions.check(R.id.link_option_direct) + linkCodeActual.text = state.encodedLinkCode + } + + KServerLinkOption.QRCODE -> { + linkOptions.check(R.id.link_option_qrcode) + try { + val size = ui.root.width + val qrcode = state.encodedLinkCode?.let { + BarcodeEncoder().encodeBitmap(it, BarcodeFormat.QR_CODE, size, size) + } + qrcodeImage.setImageBitmap(qrcode) + } catch (e: Exception) { + e.asErrorDialogBuilder(requireContext()).show() + } + } + + KServerLinkOption.NFC -> { + linkOptions.check(R.id.link_option_nfc) + // TODO NOOP? + } + } + } + + vm.autoNavOnNewDevice.observe2(ui) { + Toast.makeText( + requireActivity(), + R.string.sync_kserver_link_host_device_linked_message, + Toast.LENGTH_LONG + ).show() + popBackStack() + } + + super.onViewCreated(view, savedInstanceState) + } + +} diff --git a/app/src/main/java/eu/darken/octi/syncs/kserver/ui/link/host/KServerLinkHostVM.kt b/app/src/main/java/eu/darken/octi/syncs/kserver/ui/link/host/KServerLinkHostVM.kt new file mode 100644 index 00000000..3dadcd53 --- /dev/null +++ b/app/src/main/java/eu/darken/octi/syncs/kserver/ui/link/host/KServerLinkHostVM.kt @@ -0,0 +1,103 @@ +package eu.darken.octi.syncs.kserver.ui.link.host + +import android.app.Activity +import android.content.Intent +import androidx.lifecycle.SavedStateHandle +import com.squareup.moshi.Moshi +import dagger.hilt.android.lifecycle.HiltViewModel +import eu.darken.octi.common.coroutine.DispatcherProvider +import eu.darken.octi.common.debug.logging.log +import eu.darken.octi.common.debug.logging.logTag +import eu.darken.octi.common.flow.withPrevious +import eu.darken.octi.common.navigation.navArgs +import eu.darken.octi.common.uix.ViewModel3 +import eu.darken.octi.sync.core.SyncManager +import eu.darken.octi.sync.core.SyncOptions +import eu.darken.octi.sync.core.getConnectorById +import eu.darken.octi.syncs.kserver.core.KServerConnector +import eu.darken.octi.syncs.kserver.ui.link.KServerLinkOption +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.isActive +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import javax.inject.Inject + +@HiltViewModel +class KServerLinkHostVM @Inject constructor( + @Suppress("UNUSED_PARAMETER") handle: SavedStateHandle, + private val dispatcherProvider: DispatcherProvider, + private val syncManager: SyncManager, + private val moshi: Moshi +) : ViewModel3(dispatcherProvider = dispatcherProvider) { + + private val navArgs: KServerLinkHostFragmentArgs by handle.navArgs() + private val stateLock = Mutex() + + data class State( + val encodedLinkCode: String? = null, + val linkOption: KServerLinkOption = KServerLinkOption.QRCODE, + ) + + private val _state = MutableStateFlow(State()) + val state = _state.asLiveData2() + + val autoNavOnNewDevice = syncManager + .getConnectorById(navArgs.identifier) + .flatMapLatest { it.state } + .map { it.devices } + .withPrevious() + .map { (old, new) -> + if (old == null) return@map null + if (new == null) return@map null + if (new.size <= old.size) return@map null + Unit + } + .filterNotNull() + .asLiveData2() + + init { + launch { + val connector = syncManager.getConnectorById(navArgs.identifier).first() + val container = connector.createLinkCode() + log(TAG) { "New magic link code generated." } + handle["code"] = container + + stateLock.withLock { + _state.value = _state.value.copy(encodedLinkCode = container.toEncodedString(moshi)) + } + } + launch { + val connector = syncManager.getConnectorById(navArgs.identifier).first() + while (currentCoroutineContext().isActive) { + connector.sync(SyncOptions()) + delay(3000) + } + } + } + + fun onLinkOptionSelected(option: KServerLinkOption) = launch { + log(TAG) { "onLinkOptionSelected(option=$option)" } + stateLock.withLock { + _state.value = _state.value.copy(linkOption = option) + } + } + + fun shareLinkCode(activity: Activity) = launch { + log(TAG) { "shareLinkCode()" } + val encodedCode = _state.value.encodedLinkCode!! + val sendIntent = Intent().apply { + action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_TEXT, encodedCode) + type = "text/plain" + } + + val shareIntent = Intent.createChooser(sendIntent, "Octi - Link device") + activity.startActivity(shareIntent) + } + + companion object { + private val TAG = logTag("Sync", "KServer", "Link", "Host", "Fragment", "VM") + } +} \ No newline at end of file diff --git a/app/src/main/res/layout/sync_actions_kserver_fragment.xml b/app/src/main/res/layout/sync_actions_kserver_fragment.xml new file mode 100644 index 00000000..053167de --- /dev/null +++ b/app/src/main/res/layout/sync_actions_kserver_fragment.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/sync_add_item_kserver.xml b/app/src/main/res/layout/sync_add_item_kserver.xml new file mode 100644 index 00000000..b2feb74a --- /dev/null +++ b/app/src/main/res/layout/sync_add_item_kserver.xml @@ -0,0 +1,39 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/sync_add_new_kserver_fragment.xml b/app/src/main/res/layout/sync_add_new_kserver_fragment.xml new file mode 100644 index 00000000..1fd83fb2 --- /dev/null +++ b/app/src/main/res/layout/sync_add_new_kserver_fragment.xml @@ -0,0 +1,122 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/sync_kserver_link_client_fragment.xml b/app/src/main/res/layout/sync_kserver_link_client_fragment.xml new file mode 100644 index 00000000..604c567f --- /dev/null +++ b/app/src/main/res/layout/sync_kserver_link_client_fragment.xml @@ -0,0 +1,167 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/sync_kserver_link_host_fragment.xml b/app/src/main/res/layout/sync_kserver_link_host_fragment.xml new file mode 100644 index 00000000..e5c23073 --- /dev/null +++ b/app/src/main/res/layout/sync_kserver_link_host_fragment.xml @@ -0,0 +1,157 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/sync_list_item_kserver.xml b/app/src/main/res/layout/sync_list_item_kserver.xml new file mode 100644 index 00000000..3eac63c9 --- /dev/null +++ b/app/src/main/res/layout/sync_list_item_kserver.xml @@ -0,0 +1,148 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/menu_kserver_add.xml b/app/src/main/res/menu/menu_kserver_add.xml new file mode 100644 index 00000000..c670c619 --- /dev/null +++ b/app/src/main/res/menu/menu_kserver_add.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/main.xml b/app/src/main/res/navigation/main.xml index 52dcf15a..f3029fae 100644 --- a/app/src/main/res/navigation/main.xml +++ b/app/src/main/res/navigation/main.xml @@ -41,6 +41,10 @@ android:id="@+id/action_syncAddFragment_to_addJServerFragment" app:destination="@id/addJServerFragment" app:popUpTo="@id/syncListFragment" /> + + + + + + + + + + + + + + Link device Looking for NFC host. Hold it close to the device offering to share account access. + K-Server + A open-source sync server for Octi made by this app\'s developer. Data is encrypted client-side, only your devices can decrypt it.\n\nAs both the app and the server are still being improved, it may be less stable than other sync options. + Inactive accounts are deleted after a few weeks. + Add new Google Drive + You will need access to another device that is already linked. + About KServer + KServer is an open-source sync server (in Kotlin) for Octi by darken. + Author + Source code + Reset this account and delete all stored data. Your device stays linked to the account. + This will remove this device from your account. If this is the last linked device, your account will be deleted. + Link new device + Link code + Create a text code that you can send through other apps + Show a QRCode + Link device via NFC + A new device was linked to your account. + Looking for NFC client. Hold this device close to the device you want link to your account. + Manually copy and paste a link code + Scan a QR code from another device + Use two devices with NFC + Start camera + Link device + Looking for NFC host. Hold it close to the device offering to share account access. + Android %s User interface diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 00000000..a8686a3d --- /dev/null +++ b/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 75aac336..24a9d19b 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -8,7 +8,8 @@ repositories { mavenCentral() } dependencies { - implementation("com.android.tools.build:gradle:7.3.0") - implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.10") + implementation("com.android.tools.build:gradle:8.4.0") + implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.23") implementation("com.squareup:javapoet:1.13.0") + implementation("com.android.tools:common:31.4.0") } \ No newline at end of file diff --git a/buildSrc/src/main/java/ProjectConfig.kt b/buildSrc/src/main/java/ProjectConfig.kt index f0713e87..49ca5a04 100644 --- a/buildSrc/src/main/java/ProjectConfig.kt +++ b/buildSrc/src/main/java/ProjectConfig.kt @@ -1,10 +1,11 @@ +import com.android.build.api.dsl.Packaging import com.android.build.gradle.LibraryExtension import org.gradle.api.Action import org.gradle.api.JavaVersion import java.io.File import java.io.FileInputStream import java.time.Instant -import java.util.* +import java.util.Properties object ProjectConfig { const val minSdk = 23 @@ -79,7 +80,7 @@ fun LibraryExtension.setupLibraryDefaults() { ) } - packagingOptions { + fun Packaging.() { resources.excludes += "DebugProbesKt.bin" } } @@ -89,6 +90,8 @@ fun com.android.build.api.dsl.CommonExtension< com.android.build.api.dsl.LibraryBuildType, com.android.build.api.dsl.LibraryDefaultConfig, com.android.build.api.dsl.LibraryProductFlavor, + *, + * >.setupModuleBuildTypes() { buildTypes { debug { diff --git a/buildSrc/src/main/java/Versions.kt b/buildSrc/src/main/java/Versions.kt index 69a110aa..f6b0715a 100644 --- a/buildSrc/src/main/java/Versions.kt +++ b/buildSrc/src/main/java/Versions.kt @@ -1,19 +1,19 @@ object Versions { object Gradle { - const val buildTools = "7.3.0" + const val buildTools = "8.4.0" } object Kotlin { - const val core = "1.6.10" - const val coroutines = "1.6.0" + const val core = "1.9.23" + const val coroutines = "1.8.0" } object Dagger { - const val core = "2.40.5" + const val core = "2.51.1" } object Moshi { - const val core = "1.13.0" + const val core = "1.15.1" } object AndroidX { diff --git a/gradle.properties b/gradle.properties index 25217527..dced6000 100644 --- a/gradle.properties +++ b/gradle.properties @@ -16,4 +16,7 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 # https://developer.android.com/topic/libraries/support-library/androidx-rn android.useAndroidX=true # Kotlin code style for this project: "official" or "obsolete": -kotlin.code.style=official \ No newline at end of file +kotlin.code.style=official +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 3f94b72f..6747f111 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ -#Wed Jul 13 14:45:28 CEST 2022 +#Fri May 03 17:27:23 CEST 2024 distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.2-bin.zip distributionPath=wrapper/dists -zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/settings.gradle b/settings.gradle index 87110458..0a7db6f2 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,13 +1,14 @@ rootProject.name = "Octi" include ':app' -include ':syncs-jserver' include ':app-common' -include ':sync-core' include ':module-core' -include ':syncs-gdrive' include ':app-common-test' include ':modules-power' include ':modules-meta' include ':modules-wifi' include ':modules-apps' include ':modules-clipboard' +include ':sync-core' +include ':syncs-gdrive' +include ':syncs-jserver' +include ':syncs-kserver' diff --git a/syncs-kserver/.gitignore b/syncs-kserver/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/syncs-kserver/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/syncs-kserver/build.gradle.kts b/syncs-kserver/build.gradle.kts new file mode 100644 index 00000000..e9967669 --- /dev/null +++ b/syncs-kserver/build.gradle.kts @@ -0,0 +1,50 @@ +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") + id("kotlin-android") + id("kotlin-kapt") + id("kotlin-parcelize") +} + +apply(plugin = "dagger.hilt.android.plugin") + +android { + namespace = "eu.darken.octi.syncs.kserver" + compileSdk = ProjectConfig.compileSdk + + defaultConfig { + minSdk = ProjectConfig.minSdk + targetSdk = ProjectConfig.targetSdk + } + + setupModuleBuildTypes() + + setupCompileOptions() + + setupKotlinOptions() + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + tasks.withType { + useJUnitPlatform() + } + } +} + +dependencies { + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:${Versions.Desugar.core}") + + implementation(project(":app-common")) + implementation(project(":sync-core")) + + addAndroidCore() + addAndroidUI() + addDI() + addCoroutines() + addSerialization() + addIO() + addRetrofit() + addTesting() +} \ No newline at end of file diff --git a/syncs-kserver/proguard-rules.pro b/syncs-kserver/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/syncs-kserver/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/syncs-kserver/src/androidTest/java/eu/darken/octi/syncs/jserver/ExampleInstrumentedTest.kt b/syncs-kserver/src/androidTest/java/eu/darken/octi/syncs/jserver/ExampleInstrumentedTest.kt new file mode 100644 index 00000000..4133ed6e --- /dev/null +++ b/syncs-kserver/src/androidTest/java/eu/darken/octi/syncs/jserver/ExampleInstrumentedTest.kt @@ -0,0 +1,22 @@ +package eu.darken.octi.syncs.jserver + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("eu.darken.octi.syncs.jserver", appContext.packageName) + } +} \ No newline at end of file diff --git a/syncs-kserver/src/main/AndroidManifest.xml b/syncs-kserver/src/main/AndroidManifest.xml new file mode 100644 index 00000000..f1bb078d --- /dev/null +++ b/syncs-kserver/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/syncs-kserver/src/main/java/eu/darken/octi/syncs/kserver/KServerModule.kt b/syncs-kserver/src/main/java/eu/darken/octi/syncs/kserver/KServerModule.kt new file mode 100644 index 00000000..0e523b86 --- /dev/null +++ b/syncs-kserver/src/main/java/eu/darken/octi/syncs/kserver/KServerModule.kt @@ -0,0 +1,18 @@ +package eu.darken.octi.syncs.kserver + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.IntoSet +import eu.darken.octi.sync.core.ConnectorHub +import eu.darken.octi.syncs.kserver.core.KServerHub + +@InstallIn(SingletonComponent::class) +@Module +abstract class KServerModule { + + @Binds + @IntoSet + abstract fun hub(hub: KServerHub): ConnectorHub +} \ No newline at end of file diff --git a/syncs-kserver/src/main/java/eu/darken/octi/syncs/kserver/core/BasicAuthInterceptor.kt b/syncs-kserver/src/main/java/eu/darken/octi/syncs/kserver/core/BasicAuthInterceptor.kt new file mode 100644 index 00000000..2c18b72e --- /dev/null +++ b/syncs-kserver/src/main/java/eu/darken/octi/syncs/kserver/core/BasicAuthInterceptor.kt @@ -0,0 +1,37 @@ +package eu.darken.octi.syncs.kserver.core + +import eu.darken.octi.common.debug.logging.Logging.Priority.VERBOSE +import eu.darken.octi.common.debug.logging.log +import eu.darken.octi.common.debug.logging.logTag +import okhttp3.Credentials +import okhttp3.Interceptor +import okhttp3.Response +import java.io.IOException +import javax.inject.Inject + +class BasicAuthInterceptor @Inject constructor() : Interceptor { + + private var kServerCredentials: KServer.Credentials? = null + private val okHttpCredentials: String? + get() = kServerCredentials?.let { Credentials.basic(it.accountId.id, it.devicePassword.password) } + + @Throws(IOException::class) + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + val authenticatedRequest = request.newBuilder().apply { + okHttpCredentials?.let { + header("Authorization", it) + } + }.build() + return chain.proceed(authenticatedRequest) + } + + fun setCredentials(credentials: KServer.Credentials?) { + log(TAG, VERBOSE) { "setCredentials(credentials=$credentials)" } + kServerCredentials = credentials + } + + companion object { + private val TAG = logTag("Sync", "KServer", "Endpoint", "BasicAuth") + } +} \ No newline at end of file diff --git a/syncs-kserver/src/main/java/eu/darken/octi/syncs/kserver/core/KServer.kt b/syncs-kserver/src/main/java/eu/darken/octi/syncs/kserver/core/KServer.kt new file mode 100644 index 00000000..d09e09f4 --- /dev/null +++ b/syncs-kserver/src/main/java/eu/darken/octi/syncs/kserver/core/KServer.kt @@ -0,0 +1,61 @@ +package eu.darken.octi.syncs.kserver.core + +import android.os.Parcelable +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import eu.darken.octi.sync.core.encryption.PayloadEncryption +import kotlinx.parcelize.Parcelize +import java.time.Instant +import java.util.UUID + + +interface KServer { + + @JsonClass(generateAdapter = false) + enum class Official(val address: Address) { + @Json(name = "PROD") PROD(Address("prod.kserver.octi.darken.eu")), + @Json(name = "BETA") BETA(Address("beta.kserver.octi.darken.eu")), + @Json(name = "DEV") DEV(Address("dev.kserver.octi.darken.eu")), + @Json(name = "LOCAL") LOCAL(Address("blasphemy", protocol = "http", port = 8080)), + } + + @JsonClass(generateAdapter = true) + @Parcelize + data class Address( + @Json(name = "domain") val domain: String, + @Json(name = "protocol") val protocol: String = "https", + @Json(name = "port") val port: Int = 443, + ) : Parcelable { + val httpUrl: String + get() = "$protocol://$domain:$port/v1/" + } + + @JsonClass(generateAdapter = true) + data class Credentials( + @Json(name = "serverAdress") val serverAdress: Address, + @Json(name = "accountId") val accountId: AccountId, + @Json(name = "devicePassword") val devicePassword: DevicePassword, + @Json(name = "encryptionKeyset") val encryptionKeyset: PayloadEncryption.KeySet, + @Json(name = "createdAt") val createdAt: Instant = Instant.now(), + ) { + + override fun toString(): String = + "KServer.Credentials(server=$serverAdress, account=$accountId, password=$devicePassword)" + + @JsonClass(generateAdapter = true) + @Parcelize + data class AccountId(@Json(name = "id") val id: String = UUID.randomUUID().toString()) : Parcelable + + @JsonClass(generateAdapter = true) + @Parcelize + data class DevicePassword(@Json(name = "password") val password: String) : Parcelable { + override fun toString(): String = "DevicePassword(code=${password.take(4)}...)" + } + + @JsonClass(generateAdapter = true) + @Parcelize + data class LinkCode(@Json(name = "code") val code: String) : Parcelable { + override fun toString(): String = "ShareCode(code=${code.take(4)}...)" + } + } +} diff --git a/syncs-kserver/src/main/java/eu/darken/octi/syncs/kserver/core/KServerAccountRepo.kt b/syncs-kserver/src/main/java/eu/darken/octi/syncs/kserver/core/KServerAccountRepo.kt new file mode 100644 index 00000000..2fc43d11 --- /dev/null +++ b/syncs-kserver/src/main/java/eu/darken/octi/syncs/kserver/core/KServerAccountRepo.kt @@ -0,0 +1,105 @@ +package eu.darken.octi.syncs.kserver.core + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import com.squareup.moshi.Moshi +import com.squareup.moshi.adapter +import dagger.hilt.android.qualifiers.ApplicationContext +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.WARN +import eu.darken.octi.common.debug.logging.log +import eu.darken.octi.common.debug.logging.logTag +import eu.darken.octi.common.flow.DynamicStateFlow +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.plus +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class KServerAccountRepo @Inject constructor( + @AppScope private val scope: CoroutineScope, + dispatcherProvider: DispatcherProvider, + @ApplicationContext private val context: Context, + moshi: Moshi, +) { + private val adapterCredentials by lazy { moshi.adapter() } + + private val Context.dataStore by preferencesDataStore(name = "syncs_kserver_credentials") + + private val dataStore: DataStore + get() = context.dataStore + + private val _accounts = DynamicStateFlow(parentScope = scope + dispatcherProvider.Default) { + dataStore.data.first() + .asMap() + .filter { + if (!it.key.name.startsWith(KEY_PREFIX)) { + log(TAG, ERROR) { "Unknown entry: $it" } + return@filter false + } + if (it.value !is String) { + log(TAG, ERROR) { "Unknown data: $it" } + return@filter false + } + true + } + .map { it.value as String } + .map { adapterCredentials.fromJson(it)!! } + .toSet() + } + + val accounts: Flow> = _accounts.flow + + + suspend fun add(acc: KServer.Credentials): Boolean { + log(TAG) { "add(acc=$acc)" } + var added = false + + _accounts.updateBlocking { + if (any { it.accountId == acc.accountId }) { + log(TAG, WARN) { "Account $acc is already added" } + return@updateBlocking this + } + + added = true + + dataStore.edit { + it[stringPreferencesKey("$KEY_PREFIX.${acc.accountId.id}")] = adapterCredentials.toJson(acc) + } + + this + acc + } + return added + } + + suspend fun remove(id: KServer.Credentials.AccountId) { + log(TAG) { "remove(id=$id)" } + _accounts.updateBlocking { + val toRemove = firstOrNull { it.accountId == id } + + if (toRemove == null) { + log(TAG, WARN) { "Account $id is unknown" } + return@updateBlocking this + } + + dataStore.edit { + it.remove(stringPreferencesKey("$KEY_PREFIX.${id.id}")) + } + + this - toRemove + } + } + + companion object { + private const val KEY_PREFIX = "credentials" + private val TAG = logTag("Sync", "KServer", "AccountRepo") + } +} \ No newline at end of file diff --git a/syncs-kserver/src/main/java/eu/darken/octi/syncs/kserver/core/KServerApi.kt b/syncs-kserver/src/main/java/eu/darken/octi/syncs/kserver/core/KServerApi.kt new file mode 100644 index 00000000..2588d5bc --- /dev/null +++ b/syncs-kserver/src/main/java/eu/darken/octi/syncs/kserver/core/KServerApi.kt @@ -0,0 +1,81 @@ +package eu.darken.octi.syncs.kserver.core + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import okhttp3.RequestBody +import okhttp3.ResponseBody +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.DELETE +import retrofit2.http.GET +import retrofit2.http.Header +import retrofit2.http.POST +import retrofit2.http.Path +import retrofit2.http.Query + +interface KServerApi { + + @JsonClass(generateAdapter = true) + data class RegisterResponse( + @Json(name = "account") val accountID: String, + @Json(name = "password") val password: String + ) + + @POST("account") + suspend fun register( + @Header("X-Device-ID") deviceID: String, + @Query("share") shareCode: String? = null, + ): RegisterResponse + + @DELETE("account") + suspend fun delete( + @Header("X-Device-ID") deviceID: String, + ) + + @JsonClass(generateAdapter = true) + data class ShareCodeResponse( + @Json(name = "code") val shareCode: String, + ) + + @POST("account/share") + suspend fun createShareCode( + @Header("X-Device-ID") deviceID: String, + ): ShareCodeResponse + + @JsonClass(generateAdapter = true) + data class DevicesResponse( + @Json(name = "devices") val devices: List, + ) { + @JsonClass(generateAdapter = true) + data class Device( + @Json(name = "id") val id: String, + @Json(name = "version") val version: String?, + ) + } + + @GET("devices") + suspend fun getDeviceList( + @Header("X-Device-ID") deviceID: String, + ): DevicesResponse + + @GET("module/{moduleId}") + suspend fun readModule( + @Path("moduleId") moduleId: String, + @Header("X-Device-ID") callerDeviceId: String, + @Query("device-id") targetDeviceId: String, + ): Response + + @POST("module/{moduleId}") + suspend fun writeModule( + @Path("moduleId") moduleId: String, + @Header("X-Device-ID") deviceId: String, + @Query("device-id") targetDeviceId: String, + @Body payload: RequestBody, + ) + + @DELETE("module") + suspend fun deleteModules( + @Header("X-Device-ID") callerDeviceId: String, + @Query("device-id") targetDeviceId: String, + ) +} \ No newline at end of file diff --git a/syncs-kserver/src/main/java/eu/darken/octi/syncs/kserver/core/KServerConnector.kt b/syncs-kserver/src/main/java/eu/darken/octi/syncs/kserver/core/KServerConnector.kt new file mode 100644 index 00000000..1be43012 --- /dev/null +++ b/syncs-kserver/src/main/java/eu/darken/octi/syncs/kserver/core/KServerConnector.kt @@ -0,0 +1,350 @@ +package eu.darken.octi.syncs.kserver.core + +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import eu.darken.octi.common.collections.fromGzip +import eu.darken.octi.common.collections.toGzip +import eu.darken.octi.common.coroutine.AppScope +import eu.darken.octi.common.coroutine.DispatcherProvider +import eu.darken.octi.common.debug.logging.Logging.Priority.DEBUG +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.VERBOSE +import eu.darken.octi.common.debug.logging.Logging.Priority.WARN +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.flow.DynamicStateFlow +import eu.darken.octi.common.flow.setupCommonEventHandlers +import eu.darken.octi.common.network.NetworkStateProvider +import eu.darken.octi.module.core.ModuleId +import eu.darken.octi.sync.core.ConnectorId +import eu.darken.octi.sync.core.DeviceId +import eu.darken.octi.sync.core.SyncConnector +import eu.darken.octi.sync.core.SyncConnectorState +import eu.darken.octi.sync.core.SyncOptions +import eu.darken.octi.sync.core.SyncRead +import eu.darken.octi.sync.core.SyncSettings +import eu.darken.octi.sync.core.SyncWrite +import eu.darken.octi.sync.core.encryption.PayloadEncryption +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.retry +import kotlinx.coroutines.plus +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import okio.ByteString +import retrofit2.HttpException +import java.time.Duration +import java.time.Instant +import kotlin.math.max + + +@Suppress("BlockingMethodInNonBlockingContext") +class KServerConnector @AssistedInject constructor( + @Assisted val credentials: KServer.Credentials, + @AppScope private val scope: CoroutineScope, + private val dispatcherProvider: DispatcherProvider, + private val endpointFactory: KServerEndpoint.Factory, + private val networkStateProvider: NetworkStateProvider, + private val syncSettings: SyncSettings, + private val supportedModuleIds: Set<@JvmSuppressWildcards ModuleId>, +) : SyncConnector { + + private val endpoint by lazy { + endpointFactory.create(credentials.serverAdress).also { + it.setCredentials(credentials) + } + } + + private val crypti by lazy { PayloadEncryption(credentials.encryptionKeyset) } + + data class State( + override val readActions: Int = 0, + override val writeActions: Int = 0, + override val lastReadAt: Instant? = null, + override val lastWriteAt: Instant? = null, + override val lastError: Exception? = null, + override val quota: SyncConnectorState.Quota? = null, + override val devices: Collection? = null, + override val isAvailable: Boolean = true, + ) : SyncConnectorState + + private val _state = DynamicStateFlow( + parentScope = scope + dispatcherProvider.IO, + loggingTag = TAG, + ) { + State() + } + + override val state: Flow = _state.flow + private val _data = MutableStateFlow(null) + override val data: Flow = _data + + private val writeQueue = MutableSharedFlow() + private val writeLock = Mutex() + private val readLock = Mutex() + + override val identifier: ConnectorId = ConnectorId( + type = "kserver", + subtype = credentials.serverAdress.domain, + account = credentials.accountId.id, + ) + + init { + writeQueue + .onEach { toWrite -> + writeServerWrapper { + writeServer(toWrite) + } + } + .retry { + delay(5000) + true + } + .setupCommonEventHandlers(TAG) { "writeQueue" } + .launchIn(scope) + } + + private suspend fun isInternetAvailable() = networkStateProvider.networkState.first().isInternetAvailable + + override suspend fun write(toWrite: SyncWrite) { + log(TAG) { "write(toWrite=$toWrite)" } + writeQueue.emit(toWrite) + } + + override suspend fun deleteAll() = writeServerWrapper { + log(TAG, INFO) { "deleteAll()" } + val deviceIds = endpoint.listDevices() + log(TAG, VERBOSE) { "deleteAll(): Found devices: $deviceIds" } + + deviceIds.forEach { + log(TAG, VERBOSE) { "deleteAll(): Deleting module data for $it" } + try { + endpoint.deleteModules(it) + } catch (e: Exception) { + log(TAG, WARN) { "Failed to delete $it" } + } + } + } + + override suspend fun deleteDevice(deviceId: DeviceId) = writeServerWrapper { + log(TAG, INFO) { "deleteDevice(deviceId=$deviceId)" } + try { + endpoint.deleteModules(deviceId) + } catch (e: HttpException) { + // TODO once we have device deletion as an API, remove this edge case catch + if (e.code() == 401) { + log(TAG, WARN) { "Can't delete device because we are not authorized" } + } else { + throw e + } + } + } + + suspend fun createLinkCode(): LinkingData { + log(TAG) { "createLinkCode()" } + val linkCode = endpoint.createLinkCode() + + return LinkingData( + serverAdress = credentials.serverAdress, + linkCode = linkCode, + encryptionKeyset = credentials.encryptionKeyset, + ) + } + + override suspend fun sync(options: SyncOptions) { + log(TAG) { "sync(options=$options)" } + + if (!isInternetAvailable()) { + log(TAG, WARN) { "sync(): Skipping, we are offline." } + return + } + + if (options.writeData) { + // TODO + } + + if (options.readData) { + log(TAG) { "read()" } + try { + readServerWrapper { + _data.value = readServer() + } + } catch (e: Exception) { + log(TAG, ERROR) { "Failed to read: ${e.asLog()}" } + _state.updateBlocking { copy(lastError = e) } + } + } + + if (options.stats) { + try { + val knownDeviceIds = endpoint.listDevices() + _state.updateBlocking { copy(devices = knownDeviceIds) } + } catch (e: Exception) { + log(TAG, ERROR) { "Failed to list of known devices: ${e.asLog()}" } + } + } + } + + private suspend fun fetchModule(deviceId: DeviceId, moduleId: ModuleId): KServerModuleData? { + val readData = endpoint.readModule(deviceId = deviceId, moduleId = moduleId) ?: return null + + val payload = if (readData.payload != ByteString.EMPTY) { + crypti.decrypt(readData.payload).fromGzip() + } else { + ByteString.EMPTY + } + + return KServerModuleData( + connectorId = identifier, + deviceId = deviceId, + moduleId = moduleId, + modifiedAt = readData.modifiedAt, + payload = payload, + ).also { log(TAG, VERBOSE) { "readServer(): Module data: $it" } } + } + + private suspend fun readServer(): KServerData { + log(TAG, DEBUG) { "readServer(): Starting..." } + val deviceIds = endpoint.listDevices() + log(TAG, VERBOSE) { "readServer(): Found devices: $deviceIds" } + + val devices = deviceIds.map { deviceId -> + scope.async moduleFetch@{ + val moduleFetchJobs = supportedModuleIds.map { moduleId -> + val fetchResult = try { + fetchModule(deviceId, moduleId) + } catch (e: Exception) { + log(TAG, ERROR) { "Failed to fetch: $deviceId:$moduleId:\n${e.asLog()}" } + null + } + log(TAG, VERBOSE) { "Module fetched: $fetchResult" } + delay(1000) + fetchResult + } + + val modules = moduleFetchJobs.filterNotNull() + + KServerDeviceData( + deviceId = deviceId, + modules = modules, + ) + } + }.awaitAll() + + return KServerData( + connectorId = identifier, + devices = devices + ) + } + + private suspend fun writeServer(data: SyncWrite) { + log(TAG, DEBUG) { "writeServer(): $data)" } + + // TODO cache write data for when we are online again? + if (!isInternetAvailable()) { + log(TAG, WARN) { "writeServer(): Skipping, we are offline." } + return + } + + data.modules.forEach { module -> + endpoint.writeModule( + moduleId = module.moduleId, + payload = crypti.encrypt(module.payload.toGzip()), + ) + } + log(TAG, VERBOSE) { "writeServer(): Done" } + } + + private fun getStorageStats(): SyncConnectorState.Quota { + log(TAG, VERBOSE) { "getStorageStats()" } + + return SyncConnectorState.Quota() + } + + private suspend fun readServerWrapper(block: suspend () -> Unit) { + val start = System.currentTimeMillis() + log(TAG, VERBOSE) { "readAction(block=$block)" } + + var newStorageQuota: SyncConnectorState.Quota? = null + + if (_state.value().readActions > 0) { + log(TAG, WARN) { "Already executing read skipping." } + return + } + try { + _state.updateBlocking { + copy(readActions = readActions + 1) + } + + block() + + val lastStats = _state.value().quota?.updatedAt + if (lastStats == null || Duration.between(lastStats, Instant.now()) > Duration.ofSeconds(60)) { + log(TAG) { "readAction(block=$block): Updating storage stats" } + newStorageQuota = getStorageStats() + } + } catch (e: Exception) { + log(TAG, ERROR) { "readAction(block=$block) failed: ${e.asLog()}" } + throw e + } finally { + _state.updateBlocking { + copy( + readActions = max(readActions - 1, 0), + quota = newStorageQuota ?: quota, + lastReadAt = Instant.now(), + ) + } + } + + log(TAG, VERBOSE) { "readAction(block=$block) finished after ${System.currentTimeMillis() - start}ms" } + } + + private suspend fun writeServerWrapper(block: suspend () -> Unit) = withContext(NonCancellable) { + val start = System.currentTimeMillis() + log(TAG, VERBOSE) { "writeAction(block=$block)" } + + _state.updateBlocking { copy(writeActions = writeActions + 1) } + + try { + writeLock.withLock { + try { + block() + } catch (e: Exception) { + log(TAG, ERROR) { "writeAction(block=$block) failed: ${e.asLog()}" } + throw e + } + } + } finally { + _state.updateBlocking { + log(TAG, VERBOSE) { "writeAction(block=$block) finished" } + copy( + writeActions = writeActions - 1, + lastWriteAt = Instant.now(), + ) + } + log(TAG, VERBOSE) { "writeAction(block=$block) finished after ${System.currentTimeMillis() - start}ms" } + } + } + + @AssistedFactory + interface Factory { + fun create(account: KServer.Credentials): KServerConnector + } + + companion object { + private val TAG = logTag("Sync", "KServer", "Connector") + } +} \ No newline at end of file diff --git a/syncs-kserver/src/main/java/eu/darken/octi/syncs/kserver/core/KServerData.kt b/syncs-kserver/src/main/java/eu/darken/octi/syncs/kserver/core/KServerData.kt new file mode 100644 index 00000000..feb34812 --- /dev/null +++ b/syncs-kserver/src/main/java/eu/darken/octi/syncs/kserver/core/KServerData.kt @@ -0,0 +1,9 @@ +package eu.darken.octi.syncs.kserver.core + +import eu.darken.octi.sync.core.ConnectorId +import eu.darken.octi.sync.core.SyncRead + +data class KServerData( + override val connectorId: ConnectorId, + override val devices: Collection = emptySet(), +) : SyncRead \ No newline at end of file diff --git a/syncs-kserver/src/main/java/eu/darken/octi/syncs/kserver/core/KServerDeviceData.kt b/syncs-kserver/src/main/java/eu/darken/octi/syncs/kserver/core/KServerDeviceData.kt new file mode 100644 index 00000000..596acee6 --- /dev/null +++ b/syncs-kserver/src/main/java/eu/darken/octi/syncs/kserver/core/KServerDeviceData.kt @@ -0,0 +1,9 @@ +package eu.darken.octi.syncs.kserver.core + +import eu.darken.octi.sync.core.DeviceId +import eu.darken.octi.sync.core.SyncRead + +data class KServerDeviceData( + override val deviceId: DeviceId, + override val modules: Collection, +) : SyncRead.Device \ No newline at end of file diff --git a/syncs-kserver/src/main/java/eu/darken/octi/syncs/kserver/core/KServerEndpoint.kt b/syncs-kserver/src/main/java/eu/darken/octi/syncs/kserver/core/KServerEndpoint.kt new file mode 100644 index 00000000..c5e562de --- /dev/null +++ b/syncs-kserver/src/main/java/eu/darken/octi/syncs/kserver/core/KServerEndpoint.kt @@ -0,0 +1,161 @@ +package eu.darken.octi.syncs.kserver.core + +import com.squareup.moshi.Moshi +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import eu.darken.octi.common.collections.toByteString +import eu.darken.octi.common.coroutine.DispatcherProvider +import eu.darken.octi.common.debug.logging.log +import eu.darken.octi.common.debug.logging.logTag +import eu.darken.octi.module.core.ModuleId +import eu.darken.octi.sync.core.DeviceId +import eu.darken.octi.sync.core.SyncSettings +import eu.darken.octi.sync.core.encryption.PayloadEncryption +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.RequestBody.Companion.toRequestBody +import okio.ByteString +import retrofit2.HttpException +import retrofit2.Retrofit +import retrofit2.converter.moshi.MoshiConverterFactory +import java.time.Instant +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter + +class KServerEndpoint @AssistedInject constructor( + @Assisted private val serverAdress: KServer.Address, + private val dispatcherProvider: DispatcherProvider, + private val syncSettings: SyncSettings, + private val baseHttpClient: OkHttpClient, + private val baseMoshi: Moshi, + private val basicAuthInterceptor: BasicAuthInterceptor, +) { + + private val httpClient by lazy { + baseHttpClient.newBuilder().apply { + addInterceptor(basicAuthInterceptor) + }.build() + } + + private val api: KServerApi by lazy { + Retrofit.Builder().apply { + baseUrl(serverAdress.httpUrl) + client(httpClient) + addConverterFactory(MoshiConverterFactory.create(baseMoshi).asLenient()) + }.build().create(KServerApi::class.java) + } + + private val ourDeviceIdString: String + get() = syncSettings.deviceId.id + + private var credentials: KServer.Credentials? = null + fun setCredentials(credentials: KServer.Credentials?) { + log(TAG) { "setCredentials(credentials=$credentials)" } + basicAuthInterceptor.setCredentials(credentials) + this.credentials = credentials + } + + suspend fun createNewAccount(): KServer.Credentials = withContext(dispatcherProvider.IO) { + log(TAG) { "createNewAccount()" } + val response = api.register(deviceID = ourDeviceIdString) + + KServer.Credentials( + createdAt = Instant.now(), + serverAdress = serverAdress, + accountId = KServer.Credentials.AccountId(response.accountID), + devicePassword = KServer.Credentials.DevicePassword(response.password), + encryptionKeyset = PayloadEncryption().exportKeyset() + ) + } + + data class LinkedAccount( + val accountId: KServer.Credentials.AccountId, + val devicePassword: KServer.Credentials.DevicePassword, + ) + + suspend fun linkToExistingAccount( + linkCode: KServer.Credentials.LinkCode, + ): LinkedAccount = withContext(dispatcherProvider.IO) { + log(TAG) { "linkToExistingAccount(linkCode=$linkCode)" } + val response = api.register( + deviceID = ourDeviceIdString, + shareCode = linkCode.code, + ) + + LinkedAccount( + accountId = KServer.Credentials.AccountId(response.accountID), + devicePassword = KServer.Credentials.DevicePassword(response.password), + ) + } + + suspend fun createLinkCode(): KServer.Credentials.LinkCode = withContext(dispatcherProvider.IO) { + log(TAG) { "createLinkCode(account=$credentials)" } + val response = api.createShareCode(deviceID = ourDeviceIdString) + return@withContext KServer.Credentials.LinkCode(code = response.shareCode) + } + + suspend fun listDevices(linkCode: KServer.Credentials.LinkCode? = null): Collection = + withContext(dispatcherProvider.IO) { + log(TAG) { "listDevices(linkCode=$linkCode)" } + val response = api.getDeviceList( + deviceID = ourDeviceIdString, + ) + response.devices.map { DeviceId(it.id) } + } + + data class ReadData( + val modifiedAt: Instant, + val payload: ByteString, + ) + + suspend fun readModule(deviceId: DeviceId, moduleId: ModuleId): ReadData? = withContext(dispatcherProvider.IO) { + log(TAG) { "readModule(deviceId=$deviceId, moduleId=$moduleId)" } + val response = api.readModule( + callerDeviceId = ourDeviceIdString, + moduleId = moduleId.id, + targetDeviceId = deviceId.id, + ) + + if (!response.isSuccessful) throw HttpException(response) + + val lastModifiedAt = response.headers()["X-Modified-At"] + ?.let { ZonedDateTime.parse(it, DateTimeFormatter.RFC_1123_DATE_TIME) }?.toInstant() + ?: return@withContext null + + val body = response.body()?.byteString()?.takeIf { it != NULL_BODY } ?: ByteString.EMPTY + + ReadData( + modifiedAt = lastModifiedAt, + payload = body, + ) + } + + suspend fun writeModule(moduleId: ModuleId, payload: ByteString) = withContext(dispatcherProvider.IO) { + log(TAG) { "writeModule(moduleId=$moduleId, payload=$payload)" } + api.writeModule( + deviceId = ourDeviceIdString, + moduleId = moduleId.id, + targetDeviceId = ourDeviceIdString, + payload = payload.toRequestBody(), + ) + } + + suspend fun deleteModules(deviceId: DeviceId) = withContext(dispatcherProvider.IO) { + api.deleteModules( + callerDeviceId = ourDeviceIdString, + targetDeviceId = deviceId.id, + ) + } + + @AssistedFactory + interface Factory { + fun create(account: KServer.Address): KServerEndpoint + } + + companion object { + private val NULL_BODY = "null".toByteString() + private val TAG = logTag("Sync", "KServer", "Connector", "Endpoint") + } +} + diff --git a/syncs-kserver/src/main/java/eu/darken/octi/syncs/kserver/core/KServerHub.kt b/syncs-kserver/src/main/java/eu/darken/octi/syncs/kserver/core/KServerHub.kt new file mode 100644 index 00000000..9e1d76d8 --- /dev/null +++ b/syncs-kserver/src/main/java/eu/darken/octi/syncs/kserver/core/KServerHub.kt @@ -0,0 +1,83 @@ +package eu.darken.octi.syncs.kserver.core + +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.asLog +import eu.darken.octi.common.debug.logging.log +import eu.darken.octi.common.debug.logging.logTag +import eu.darken.octi.common.flow.setupCommonEventHandlers +import eu.darken.octi.common.flow.shareLatest +import eu.darken.octi.sync.core.ConnectorHub +import eu.darken.octi.sync.core.ConnectorId +import eu.darken.octi.sync.core.SyncConnector +import eu.darken.octi.sync.core.SyncSettings +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.plus +import kotlinx.coroutines.withContext +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class KServerHub @Inject constructor( + @AppScope private val scope: CoroutineScope, + dispatcherProvider: DispatcherProvider, + private val accountRepo: KServerAccountRepo, + private val connectorFactory: KServerConnector.Factory, + private val endpointFactory: KServerEndpoint.Factory, + private val syncSettings: SyncSettings, +) : ConnectorHub { + + private val _connectors = accountRepo.accounts + .mapLatest { acc -> + acc.map { connectorFactory.create(it) } + } + .setupCommonEventHandlers(TAG) { "connectors" } + .shareLatest(scope + dispatcherProvider.Default) + + override val connectors: Flow> = _connectors + + override suspend fun owns(connectorId: ConnectorId): Boolean { + return _connectors.first().any { it.identifier == connectorId } + } + + override suspend fun remove(connectorId: ConnectorId) = withContext(NonCancellable) { + log(TAG) { "remove(id=$connectorId)" } + val connector = _connectors.first().single { it.identifier == connectorId } + try { + connector.deleteDevice(syncSettings.deviceId) + } catch (e: Exception) { + log(TAG, ERROR) { "Failed to delete ourselves from $connectorId: ${e.asLog()}" } + } + accountRepo.remove(connector.credentials.accountId) + } + + suspend fun linkAcount(linkingData: LinkingData) = withContext(NonCancellable) { + log(TAG) { "linkAccount(link=$linkingData)" } + + val endPoint = endpointFactory.create(linkingData.serverAdress) + + val linkedAccount = endPoint.linkToExistingAccount( + linkingData.linkCode, + ) + + val newCredentials = KServer.Credentials( + serverAdress = linkingData.serverAdress, + accountId = linkedAccount.accountId, + devicePassword = linkedAccount.devicePassword, + encryptionKeyset = linkingData.encryptionKeyset, + ) + + log(TAG) { "Account successfully linked: $newCredentials" } + + accountRepo.add(newCredentials) + } + + companion object { + private val TAG = logTag("Sync", "KServer", "Hub") + } +} \ No newline at end of file diff --git a/syncs-kserver/src/main/java/eu/darken/octi/syncs/kserver/core/KServerModuleData.kt b/syncs-kserver/src/main/java/eu/darken/octi/syncs/kserver/core/KServerModuleData.kt new file mode 100644 index 00000000..6bfb928d --- /dev/null +++ b/syncs-kserver/src/main/java/eu/darken/octi/syncs/kserver/core/KServerModuleData.kt @@ -0,0 +1,16 @@ +package eu.darken.octi.syncs.kserver.core + +import eu.darken.octi.module.core.ModuleId +import eu.darken.octi.sync.core.ConnectorId +import eu.darken.octi.sync.core.DeviceId +import eu.darken.octi.sync.core.SyncRead +import okio.ByteString +import java.time.Instant + +data class KServerModuleData( + override val connectorId: ConnectorId, + override val deviceId: DeviceId, + override val moduleId: ModuleId, + override val modifiedAt: Instant, + override val payload: ByteString +) : SyncRead.Device.Module \ No newline at end of file diff --git a/syncs-kserver/src/main/java/eu/darken/octi/syncs/kserver/core/LinkingData.kt b/syncs-kserver/src/main/java/eu/darken/octi/syncs/kserver/core/LinkingData.kt new file mode 100644 index 00000000..3b635d0c --- /dev/null +++ b/syncs-kserver/src/main/java/eu/darken/octi/syncs/kserver/core/LinkingData.kt @@ -0,0 +1,37 @@ +package eu.darken.octi.syncs.kserver.core + +import android.os.Parcelable +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import com.squareup.moshi.Moshi +import com.squareup.moshi.adapter +import eu.darken.octi.common.collections.fromGzip +import eu.darken.octi.common.collections.toGzip +import eu.darken.octi.common.serialization.fromJson +import eu.darken.octi.sync.core.encryption.PayloadEncryption +import kotlinx.parcelize.Parcelize +import okio.ByteString.Companion.decodeBase64 +import okio.ByteString.Companion.toByteString + +@JsonClass(generateAdapter = true) +@Parcelize +data class LinkingData( + @Json(name = "serverAddress") val serverAdress: KServer.Address, + @Json(name = "shareCode") val linkCode: KServer.Credentials.LinkCode, + @Json(name = "encryptionKeySet") val encryptionKeyset: PayloadEncryption.KeySet, +) : Parcelable { + + fun toEncodedString(moshi: Moshi): String = moshi.adapter() + .toJson(this) + .toByteArray() + .toByteString() + .toGzip() + .base64() + + companion object { + fun fromEncodedString(moshi: Moshi, encoded: String): LinkingData = encoded + .decodeBase64()!! + .fromGzip() + .let { moshi.adapter().fromJson(it)!! } + } +} \ No newline at end of file diff --git a/syncs-kserver/src/test/java/eu/darken/octi/syncs/kserver/KServerConnectorTest.kt b/syncs-kserver/src/test/java/eu/darken/octi/syncs/kserver/KServerConnectorTest.kt new file mode 100644 index 00000000..3950eff3 --- /dev/null +++ b/syncs-kserver/src/test/java/eu/darken/octi/syncs/kserver/KServerConnectorTest.kt @@ -0,0 +1,25 @@ +package eu.darken.octi.syncs.kserver + +import eu.darken.octi.common.collections.fromGzip +import eu.darken.octi.common.collections.toByteString +import eu.darken.octi.common.collections.toGzip +import eu.darken.octi.sync.core.encryption.PayloadEncryption +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.Test + + +class KServerConnectorTest { + @Test + fun `test encryption`() { + val testData = "The cake is a lie!" + val crypti = PayloadEncryption() + + val compressed = testData.toByteString().toGzip() + val encrypted = crypti.encrypt(compressed) + val decrypted = crypti.decrypt(encrypted) + val decompressed = decrypted.fromGzip() + + decrypted shouldBe compressed + decompressed shouldBe testData.toByteString() + } +} \ No newline at end of file