mirror of
https://github.com/openMF/mifos-mobile.git
synced 2026-02-06 11:26:51 +00:00
chore: sync missing root files and folders from KMP project template (#2965)
This commit is contained in:
parent
a6713db018
commit
a98b87a5cc
@ -0,0 +1,179 @@
|
|||||||
|
/**
|
||||||
|
* Kotlin Multiplatform project hierarchy template configuration.
|
||||||
|
*
|
||||||
|
* This file defines a structured hierarchy for organizing source sets in Kotlin Multiplatform
|
||||||
|
* projects. It establishes a logical grouping of platform targets that enables efficient code
|
||||||
|
* sharing across platforms with similar characteristics.
|
||||||
|
*
|
||||||
|
* The hierarchy template creates the following logical groupings:
|
||||||
|
* - `common`: Base shared code for all platforms
|
||||||
|
* - `nonAndroid`: Code shared between JVM, JS, and native platforms, excluding Android
|
||||||
|
* - `jsCommon`: Code shared between JavaScript and WebAssembly JavaScript targets
|
||||||
|
* - `nonJsCommon`: Code shared between JVM and native platforms, excluding JS platforms
|
||||||
|
* - `jvmCommon`: Code shared between Android and JVM targets
|
||||||
|
* - `nonJvmCommon`: Code shared between JS and native platforms, excluding JVM platforms
|
||||||
|
* - `native`: Code shared across all native platforms
|
||||||
|
* - `apple`: Code shared across Apple platforms (iOS, macOS)
|
||||||
|
* - `ios`: iOS-specific code
|
||||||
|
* - `macos`: macOS-specific code
|
||||||
|
* - `nonNative`: Code shared between JS and JVM platforms
|
||||||
|
*
|
||||||
|
* This template applies to both main and test source sets, establishing a consistent
|
||||||
|
* structure throughout the project.
|
||||||
|
*
|
||||||
|
* Note: This implementation uses experimental Kotlin Gradle plugin APIs and may be subject
|
||||||
|
* to change in future Kotlin releases.
|
||||||
|
*/
|
||||||
|
@file:OptIn(ExperimentalKotlinGradlePluginApi::class)
|
||||||
|
|
||||||
|
package org.mifos.mobile
|
||||||
|
|
||||||
|
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
|
||||||
|
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
|
||||||
|
import org.jetbrains.kotlin.gradle.plugin.KotlinHierarchyBuilder
|
||||||
|
import org.jetbrains.kotlin.gradle.plugin.KotlinHierarchyTemplate
|
||||||
|
import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSetTree
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines the hierarchical structure for source set organization.
|
||||||
|
*
|
||||||
|
* This template establishes the relationships between different platform targets,
|
||||||
|
* creating logical groupings based on platform similarities to facilitate code sharing.
|
||||||
|
*/
|
||||||
|
private val hierarchyTemplate = KotlinHierarchyTemplate {
|
||||||
|
withSourceSetTree(
|
||||||
|
KotlinSourceSetTree.main,
|
||||||
|
KotlinSourceSetTree.test,
|
||||||
|
)
|
||||||
|
|
||||||
|
common {
|
||||||
|
withCompilations { true }
|
||||||
|
|
||||||
|
groupNonAndroid()
|
||||||
|
groupJsCommon()
|
||||||
|
groupNonJsCommon()
|
||||||
|
groupJvmCommon()
|
||||||
|
groupNonJvmCommon()
|
||||||
|
groupNative()
|
||||||
|
groupNonNative()
|
||||||
|
groupJvmJsCommon()
|
||||||
|
groupMobile()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a group of non-Android platforms (JVM, JS, and native).
|
||||||
|
*/
|
||||||
|
private fun KotlinHierarchyBuilder.groupNonAndroid() {
|
||||||
|
group("nonAndroid") {
|
||||||
|
withJvm()
|
||||||
|
groupJsCommon()
|
||||||
|
groupNative()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a group of JavaScript-related platforms (JS and WebAssembly JS).
|
||||||
|
*/
|
||||||
|
private fun KotlinHierarchyBuilder.groupJsCommon() {
|
||||||
|
group("jsCommon") {
|
||||||
|
withJs()
|
||||||
|
withWasmJs()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a group of non-JavaScript platforms (JVM-based and native).
|
||||||
|
*/
|
||||||
|
private fun KotlinHierarchyBuilder.groupNonJsCommon() {
|
||||||
|
group("nonJsCommon") {
|
||||||
|
groupJvmCommon()
|
||||||
|
groupNative()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a group of JVM-based platforms (Android and JVM).
|
||||||
|
*/
|
||||||
|
private fun KotlinHierarchyBuilder.groupJvmCommon() {
|
||||||
|
group("jvmCommon") {
|
||||||
|
withAndroidTarget()
|
||||||
|
withJvm()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a group of non-JVM platforms (JavaScript and native).
|
||||||
|
*/
|
||||||
|
private fun KotlinHierarchyBuilder.groupNonJvmCommon() {
|
||||||
|
group("nonJvmCommon") {
|
||||||
|
groupJsCommon()
|
||||||
|
groupNative()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a group of JVM, JS platforms (JavaScript and JVM).
|
||||||
|
*/
|
||||||
|
private fun KotlinHierarchyBuilder.groupJvmJsCommon() {
|
||||||
|
group("jvmJsCommon") {
|
||||||
|
groupJsCommon()
|
||||||
|
withJvm()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a hierarchical group of native platforms with subgroups for Apple platforms.
|
||||||
|
*/
|
||||||
|
private fun KotlinHierarchyBuilder.groupNative() {
|
||||||
|
group("native") {
|
||||||
|
withNative()
|
||||||
|
|
||||||
|
group("apple") {
|
||||||
|
withApple()
|
||||||
|
|
||||||
|
group("ios") {
|
||||||
|
withIos()
|
||||||
|
}
|
||||||
|
|
||||||
|
group("macos") {
|
||||||
|
withMacos()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a group of non-native platforms (JavaScript and JVM-based).
|
||||||
|
*/
|
||||||
|
private fun KotlinHierarchyBuilder.groupNonNative() {
|
||||||
|
group("nonNative") {
|
||||||
|
groupJsCommon()
|
||||||
|
groupJvmCommon()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun KotlinHierarchyBuilder.groupMobile() {
|
||||||
|
group("mobile") {
|
||||||
|
withAndroidTarget()
|
||||||
|
withApple()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies the predefined hierarchy template to a Kotlin Multiplatform project.
|
||||||
|
*
|
||||||
|
* This extension function should be called within the `kotlin` block of a Multiplatform
|
||||||
|
* project's build script to establish the source set hierarchy defined in this file.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```
|
||||||
|
* kotlin {
|
||||||
|
* applyProjectHierarchyTemplate()
|
||||||
|
* // Configure targets...
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
fun KotlinMultiplatformExtension.applyProjectHierarchyTemplate() {
|
||||||
|
applyHierarchyTemplate(hierarchyTemplate)
|
||||||
|
}
|
||||||
@ -9,7 +9,7 @@ import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
|
|||||||
@OptIn(ExperimentalWasmDsl::class, ExperimentalKotlinGradlePluginApi::class)
|
@OptIn(ExperimentalWasmDsl::class, ExperimentalKotlinGradlePluginApi::class)
|
||||||
internal fun Project.configureKotlinMultiplatform() {
|
internal fun Project.configureKotlinMultiplatform() {
|
||||||
extensions.configure<KotlinMultiplatformExtension> {
|
extensions.configure<KotlinMultiplatformExtension> {
|
||||||
applyDefaultHierarchyTemplate()
|
applyProjectHierarchyTemplate()
|
||||||
|
|
||||||
jvm("desktop")
|
jvm("desktop")
|
||||||
androidTarget()
|
androidTarget()
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
package: name='org.mifospay' versionCode='1' versionName='2024.12.4-beta.0.4' platformBuildVersionName='15' platformBuildVersionCode='35' compileSdkVersion='35' compileSdkVersionCodename='15'
|
package: name='org.mifos.mobile' versionCode='1' versionName='2024.12.4-beta.0.4' platformBuildVersionName='15' platformBuildVersionCode='35' compileSdkVersion='35' compileSdkVersionCodename='15'
|
||||||
sdkVersion:'26'
|
sdkVersion:'26'
|
||||||
targetSdkVersion:'34'
|
targetSdkVersion:'34'
|
||||||
uses-permission: name='android.permission.INTERNET'
|
uses-permission: name='android.permission.INTERNET'
|
||||||
@ -12,100 +12,100 @@ uses-permission: name='android.permission.WAKE_LOCK'
|
|||||||
uses-permission: name='com.google.android.finsky.permission.BIND_GET_INSTALL_REFERRER_SERVICE'
|
uses-permission: name='com.google.android.finsky.permission.BIND_GET_INSTALL_REFERRER_SERVICE'
|
||||||
uses-permission: name='android.permission.ACCESS_ADSERVICES_ATTRIBUTION'
|
uses-permission: name='android.permission.ACCESS_ADSERVICES_ATTRIBUTION'
|
||||||
uses-permission: name='android.permission.ACCESS_ADSERVICES_AD_ID'
|
uses-permission: name='android.permission.ACCESS_ADSERVICES_AD_ID'
|
||||||
uses-permission: name='org.mifospay.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION'
|
uses-permission: name='org.mifos.mobile.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION'
|
||||||
application-label:'Mifos Pay'
|
application-label:'Mifos Mobile'
|
||||||
application-label-af:'Mifos Pay'
|
application-label-af:'Mifos Mobile'
|
||||||
application-label-am:'Mifos Pay'
|
application-label-am:'Mifos Mobile'
|
||||||
application-label-ar:'Mifos Pay'
|
application-label-ar:'Mifos Mobile'
|
||||||
application-label-as:'Mifos Pay'
|
application-label-as:'Mifos Mobile'
|
||||||
application-label-az:'Mifos Pay'
|
application-label-az:'Mifos Mobile'
|
||||||
application-label-be:'Mifos Pay'
|
application-label-be:'Mifos Mobile'
|
||||||
application-label-bg:'Mifos Pay'
|
application-label-bg:'Mifos Mobile'
|
||||||
application-label-bn:'Mifos Pay'
|
application-label-bn:'Mifos Mobile'
|
||||||
application-label-bs:'Mifos Pay'
|
application-label-bs:'Mifos Mobile'
|
||||||
application-label-ca:'Mifos Pay'
|
application-label-ca:'Mifos Mobile'
|
||||||
application-label-cs:'Mifos Pay'
|
application-label-cs:'Mifos Mobile'
|
||||||
application-label-da:'Mifos Pay'
|
application-label-da:'Mifos Mobile'
|
||||||
application-label-de:'Mifos Pay'
|
application-label-de:'Mifos Mobile'
|
||||||
application-label-el:'Mifos Pay'
|
application-label-el:'Mifos Mobile'
|
||||||
application-label-en-AU:'Mifos Pay'
|
application-label-en-AU:'Mifos Mobile'
|
||||||
application-label-en-CA:'Mifos Pay'
|
application-label-en-CA:'Mifos Mobile'
|
||||||
application-label-en-GB:'Mifos Pay'
|
application-label-en-GB:'Mifos Mobile'
|
||||||
application-label-en-IN:'Mifos Pay'
|
application-label-en-IN:'Mifos Mobile'
|
||||||
application-label-en-XC:'Mifos Pay'
|
application-label-en-XC:'Mifos Mobile'
|
||||||
application-label-es:'Mifos Pay'
|
application-label-es:'Mifos Mobile'
|
||||||
application-label-es-US:'Mifos Pay'
|
application-label-es-US:'Mifos Mobile'
|
||||||
application-label-et:'Mifos Pay'
|
application-label-et:'Mifos Mobile'
|
||||||
application-label-eu:'Mifos Pay'
|
application-label-eu:'Mifos Mobile'
|
||||||
application-label-fa:'Mifos Pay'
|
application-label-fa:'Mifos Mobile'
|
||||||
application-label-fi:'Mifos Pay'
|
application-label-fi:'Mifos Mobile'
|
||||||
application-label-fr:'Mifos Pay'
|
application-label-fr:'Mifos Mobile'
|
||||||
application-label-fr-CA:'Mifos Pay'
|
application-label-fr-CA:'Mifos Mobile'
|
||||||
application-label-gl:'Mifos Pay'
|
application-label-gl:'Mifos Mobile'
|
||||||
application-label-gu:'Mifos Pay'
|
application-label-gu:'Mifos Mobile'
|
||||||
application-label-hi:'Mifos Pay'
|
application-label-hi:'Mifos Mobile'
|
||||||
application-label-hr:'Mifos Pay'
|
application-label-hr:'Mifos Mobile'
|
||||||
application-label-hu:'Mifos Pay'
|
application-label-hu:'Mifos Mobile'
|
||||||
application-label-hy:'Mifos Pay'
|
application-label-hy:'Mifos Mobile'
|
||||||
application-label-in:'Mifos Pay'
|
application-label-in:'Mifos Mobile'
|
||||||
application-label-is:'Mifos Pay'
|
application-label-is:'Mifos Mobile'
|
||||||
application-label-it:'Mifos Pay'
|
application-label-it:'Mifos Mobile'
|
||||||
application-label-iw:'Mifos Pay'
|
application-label-iw:'Mifos Mobile'
|
||||||
application-label-ja:'Mifos Pay'
|
application-label-ja:'Mifos Mobile'
|
||||||
application-label-ka:'Mifos Pay'
|
application-label-ka:'Mifos Mobile'
|
||||||
application-label-kk:'Mifos Pay'
|
application-label-kk:'Mifos Mobile'
|
||||||
application-label-km:'Mifos Pay'
|
application-label-km:'Mifos Mobile'
|
||||||
application-label-kn:'Mifos Pay'
|
application-label-kn:'Mifos Mobile'
|
||||||
application-label-ko:'Mifos Pay'
|
application-label-ko:'Mifos Mobile'
|
||||||
application-label-ky:'Mifos Pay'
|
application-label-ky:'Mifos Mobile'
|
||||||
application-label-lo:'Mifos Pay'
|
application-label-lo:'Mifos Mobile'
|
||||||
application-label-lt:'Mifos Pay'
|
application-label-lt:'Mifos Mobile'
|
||||||
application-label-lv:'Mifos Pay'
|
application-label-lv:'Mifos Mobile'
|
||||||
application-label-mk:'Mifos Pay'
|
application-label-mk:'Mifos Mobile'
|
||||||
application-label-ml:'Mifos Pay'
|
application-label-ml:'Mifos Mobile'
|
||||||
application-label-mn:'Mifos Pay'
|
application-label-mn:'Mifos Mobile'
|
||||||
application-label-mr:'Mifos Pay'
|
application-label-mr:'Mifos Mobile'
|
||||||
application-label-ms:'Mifos Pay'
|
application-label-ms:'Mifos Mobile'
|
||||||
application-label-my:'Mifos Pay'
|
application-label-my:'Mifos Mobile'
|
||||||
application-label-nb:'Mifos Pay'
|
application-label-nb:'Mifos Mobile'
|
||||||
application-label-ne:'Mifos Pay'
|
application-label-ne:'Mifos Mobile'
|
||||||
application-label-nl:'Mifos Pay'
|
application-label-nl:'Mifos Mobile'
|
||||||
application-label-or:'Mifos Pay'
|
application-label-or:'Mifos Mobile'
|
||||||
application-label-pa:'Mifos Pay'
|
application-label-pa:'Mifos Mobile'
|
||||||
application-label-pl:'Mifos Pay'
|
application-label-pl:'Mifos Mobile'
|
||||||
application-label-pt:'Mifos Pay'
|
application-label-pt:'Mifos Mobile'
|
||||||
application-label-pt-BR:'Mifos Pay'
|
application-label-pt-BR:'Mifos Mobile'
|
||||||
application-label-pt-PT:'Mifos Pay'
|
application-label-pt-PT:'Mifos Mobile'
|
||||||
application-label-ro:'Mifos Pay'
|
application-label-ro:'Mifos Mobile'
|
||||||
application-label-ru:'Mifos Pay'
|
application-label-ru:'Mifos Mobile'
|
||||||
application-label-si:'Mifos Pay'
|
application-label-si:'Mifos Mobile'
|
||||||
application-label-sk:'Mifos Pay'
|
application-label-sk:'Mifos Mobile'
|
||||||
application-label-sl:'Mifos Pay'
|
application-label-sl:'Mifos Mobile'
|
||||||
application-label-sq:'Mifos Pay'
|
application-label-sq:'Mifos Mobile'
|
||||||
application-label-sr:'Mifos Pay'
|
application-label-sr:'Mifos Mobile'
|
||||||
application-label-sr-Latn:'Mifos Pay'
|
application-label-sr-Latn:'Mifos Mobile'
|
||||||
application-label-sv:'Mifos Pay'
|
application-label-sv:'Mifos Mobile'
|
||||||
application-label-sw:'Mifos Pay'
|
application-label-sw:'Mifos Mobile'
|
||||||
application-label-ta:'Mifos Pay'
|
application-label-ta:'Mifos Mobile'
|
||||||
application-label-te:'Mifos Pay'
|
application-label-te:'Mifos Mobile'
|
||||||
application-label-th:'Mifos Pay'
|
application-label-th:'Mifos Mobile'
|
||||||
application-label-tl:'Mifos Pay'
|
application-label-tl:'Mifos Mobile'
|
||||||
application-label-tr:'Mifos Pay'
|
application-label-tr:'Mifos Mobile'
|
||||||
application-label-uk:'Mifos Pay'
|
application-label-uk:'Mifos Mobile'
|
||||||
application-label-ur:'Mifos Pay'
|
application-label-ur:'Mifos Mobile'
|
||||||
application-label-uz:'Mifos Pay'
|
application-label-uz:'Mifos Mobile'
|
||||||
application-label-vi:'Mifos Pay'
|
application-label-vi:'Mifos Mobile'
|
||||||
application-label-zh-CN:'Mifos Pay'
|
application-label-zh-CN:'Mifos Mobile'
|
||||||
application-label-zh-HK:'Mifos Pay'
|
application-label-zh-HK:'Mifos Mobile'
|
||||||
application-label-zh-TW:'Mifos Pay'
|
application-label-zh-TW:'Mifos Mobile'
|
||||||
application-label-zu:'Mifos Pay'
|
application-label-zu:'Mifos Mobile'
|
||||||
application-icon-160:'res/mipmap-anydpi-v26/ic_launcher.xml'
|
application-icon-160:'res/mipmap-anydpi-v26/ic_launcher.xml'
|
||||||
application-icon-240:'res/mipmap-anydpi-v26/ic_launcher.xml'
|
application-icon-240:'res/mipmap-anydpi-v26/ic_launcher.xml'
|
||||||
application-icon-320:'res/mipmap-anydpi-v26/ic_launcher.xml'
|
application-icon-320:'res/mipmap-anydpi-v26/ic_launcher.xml'
|
||||||
application-icon-480:'res/mipmap-anydpi-v26/ic_launcher.xml'
|
application-icon-480:'res/mipmap-anydpi-v26/ic_launcher.xml'
|
||||||
application-icon-640:'res/mipmap-anydpi-v26/ic_launcher.xml'
|
application-icon-640:'res/mipmap-anydpi-v26/ic_launcher.xml'
|
||||||
application-icon-65534:'res/mipmap-anydpi-v26/ic_launcher.xml'
|
application-icon-65534:'res/mipmap-anydpi-v26/ic_launcher.xml'
|
||||||
application: label='Mifos Pay' icon='res/mipmap-anydpi-v26/ic_launcher.xml'
|
application: label='Mifos Mobile' icon='res/mipmap-anydpi-v26/ic_launcher.xml'
|
||||||
launchable-activity: name='org.mifospay.MainActivity' label='' icon=''
|
launchable-activity: name='org.mifospay.MainActivity' label='' icon=''
|
||||||
property: name='android.adservices.AD_SERVICES_CONFIG' resource='res/xml/ga_ad_services_config.xml'
|
property: name='android.adservices.AD_SERVICES_CONFIG' resource='res/xml/ga_ad_services_config.xml'
|
||||||
uses-library-not-required:'androidx.window.extensions'
|
uses-library-not-required:'androidx.window.extensions'
|
||||||
|
|||||||
@ -451,12 +451,16 @@ naming:
|
|||||||
active: true
|
active: true
|
||||||
mustBeFirst: true
|
mustBeFirst: true
|
||||||
excludes:
|
excludes:
|
||||||
- "**/*.jvm.kt"
|
[
|
||||||
- "**/*.desktop.kt"
|
"**/*.android.*",
|
||||||
- "**/*.wasmJs.kt"
|
"**/*.desktop.*",
|
||||||
- "**/*.native.kt"
|
"**/*.js.*",
|
||||||
- "**/*.js.kt"
|
"**/*.native.*",
|
||||||
- "**/*.android.kt"
|
"**/*.jvm.*",
|
||||||
|
"**/*.linux.*",
|
||||||
|
"**/*.macos.*",
|
||||||
|
"**/*.wasmJs.*",
|
||||||
|
]
|
||||||
MemberNameEqualsClassName:
|
MemberNameEqualsClassName:
|
||||||
active: true
|
active: true
|
||||||
ignoreOverridden: true
|
ignoreOverridden: true
|
||||||
@ -472,6 +476,10 @@ naming:
|
|||||||
PackageNaming:
|
PackageNaming:
|
||||||
active: true
|
active: true
|
||||||
packagePattern: '[a-z]+(\.[a-z][A-Za-z0-9]*)*'
|
packagePattern: '[a-z]+(\.[a-z][A-Za-z0-9]*)*'
|
||||||
|
excludes:
|
||||||
|
[
|
||||||
|
"**/generated/**",
|
||||||
|
]
|
||||||
TopLevelPropertyNaming:
|
TopLevelPropertyNaming:
|
||||||
active: true
|
active: true
|
||||||
constantPattern: "[A-Z][_A-Z0-9]*"
|
constantPattern: "[A-Z][_A-Z0-9]*"
|
||||||
|
|||||||
1
core-base/analytics/.gitignore
vendored
Normal file
1
core-base/analytics/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/build
|
||||||
343
core-base/analytics/README.md
Normal file
343
core-base/analytics/README.md
Normal file
@ -0,0 +1,343 @@
|
|||||||
|
# :core-base:analytics module
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The base analytics library provides a comprehensive foundation for tracking user interactions,
|
||||||
|
performance metrics, and business events across all platforms in a Kotlin Multiplatform project.
|
||||||
|
This module offers type-safe analytics with extensive validation, testing utilities, and performance
|
||||||
|
tracking capabilities.
|
||||||
|
|
||||||
|
### Enhanced Analytics Events
|
||||||
|
|
||||||
|
- **Type-Safe Parameters**: Automatic validation of parameter keys (≤40 chars) and values (≤100
|
||||||
|
chars)
|
||||||
|
- **Builder Pattern**: Fluent API for event creation with `withParam()` and `withParams()`
|
||||||
|
- **25+ Predefined Event Types**: From navigation to authentication, forms to performance tracking
|
||||||
|
- **Comprehensive Parameter Keys**: 40+ standardized parameter keys for consistent tracking
|
||||||
|
|
||||||
|
### Powerful Analytics Interface
|
||||||
|
|
||||||
|
- **Multiple Convenience Methods**: Simplified logging with `logEvent()` overloads
|
||||||
|
- **Built-in Common Events**: `logScreenView()`, `logButtonClick()`, `logError()`,`logFeatureUsed()`
|
||||||
|
- **User Management**: Support for `setUserProperty()` and `setUserId()`
|
||||||
|
- **Platform Abstraction**: Works seamlessly across Android, iOS, Desktop, and Web
|
||||||
|
|
||||||
|
### Advanced Extension Functions
|
||||||
|
|
||||||
|
- **Event Builders**: Factory methods for creating common events with validation
|
||||||
|
- **Performance Timing**:
|
||||||
|
- `startTiming()` and `timeExecution()` for measuring operation durations
|
||||||
|
- `TimedEvent` class for manual timing control
|
||||||
|
- **Batch Processing**: `AnalyticsBatch` for efficient multiple event logging
|
||||||
|
- **Safe Parameter Creation**: Robust validation helpers for dynamic data
|
||||||
|
|
||||||
|
### Jetpack Compose Integration
|
||||||
|
|
||||||
|
- **Declarative Tracking**: `TrackScreenView()` composable for automatic screen analytics
|
||||||
|
- **Modifier Extensions**: `Modifier.trackClick()` for effortless interaction tracking
|
||||||
|
- **Lifecycle Tracking**: `TrackComposableLifecycle()` for component enter/exit analytics
|
||||||
|
- **Helper Functions**: `rememberAnalytics()` for easy composition local access
|
||||||
|
|
||||||
|
### Performance Monitoring
|
||||||
|
|
||||||
|
- **Operation Timing**: Comprehensive timing utilities with automatic slow operation detection
|
||||||
|
- **Memory Tracking**: Real-time memory usage monitoring with automatic warnings
|
||||||
|
- **App Lifecycle**: Track app launch times, background/foreground transitions
|
||||||
|
- **Performance Statistics**: Percentile-based performance analysis (P95, P99)
|
||||||
|
|
||||||
|
### Testing & Validation
|
||||||
|
|
||||||
|
- **Test Analytics Helper**: Complete event capture and verification for unit tests
|
||||||
|
- **Mock Analytics**: Network delay and failure simulation for robust testing
|
||||||
|
- **Data Validation**: Comprehensive validation against analytics platform constraints
|
||||||
|
- **Sanitization**: Automatic data cleaning for invalid parameters
|
||||||
|
|
||||||
|
### Platform Support
|
||||||
|
|
||||||
|
- ✅ **Android**: Full Firebase Analytics integration
|
||||||
|
- ✅ **iOS**: Firebase Analytics via `nonJsCommonMain`
|
||||||
|
- ✅ **Desktop**: Development-friendly stub implementation
|
||||||
|
- ✅ **Web (JS)**: Configurable Firebase/stub implementation
|
||||||
|
- ✅ **Native**: Firebase Analytics support
|
||||||
|
|
||||||
|
## 📖 Usage Examples
|
||||||
|
|
||||||
|
### Basic Event Logging
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// Simple event
|
||||||
|
analyticsHelper.logEvent("button_clicked", "button_name" to "save")
|
||||||
|
|
||||||
|
// Using convenience methods
|
||||||
|
analyticsHelper.logScreenView("UserProfile")
|
||||||
|
analyticsHelper.logButtonClick("edit_profile", "UserProfile")
|
||||||
|
analyticsHelper.logError("Network error", "NET_001", "UserProfile")
|
||||||
|
|
||||||
|
// Builder pattern
|
||||||
|
val event = AnalyticsEvent("form_submitted")
|
||||||
|
.withParam("form_name", "user_registration")
|
||||||
|
.withParam("field_count", "8")
|
||||||
|
.withParam("completion_time", "120s")
|
||||||
|
analyticsHelper.logEvent(event)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Performance Tracking
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// Time a suspend function
|
||||||
|
val data = analyticsHelper.timePerformance("api_call") {
|
||||||
|
apiService.fetchUserData()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manual timing
|
||||||
|
val timer = analyticsHelper.startTiming("data_processing")
|
||||||
|
processData()
|
||||||
|
timer.complete()
|
||||||
|
|
||||||
|
// Memory monitoring
|
||||||
|
val memoryTracker = analyticsHelper.memoryTracker()
|
||||||
|
memoryTracker.logMemoryUsage("after_data_load")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Compose Integration
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Composable
|
||||||
|
fun UserProfileScreen() {
|
||||||
|
TrackScreenView("UserProfile")
|
||||||
|
|
||||||
|
val analytics = rememberAnalytics()
|
||||||
|
|
||||||
|
Button(
|
||||||
|
modifier = Modifier.trackClick("edit_profile", analytics, "UserProfile"),
|
||||||
|
onClick = { /* edit profile */ }
|
||||||
|
) {
|
||||||
|
Text("Edit Profile")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Batch Processing
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
analyticsHelper.batch()
|
||||||
|
.add("user_registered", "user_id" to "12345")
|
||||||
|
.add("email_verified", "verification_method" to "link")
|
||||||
|
.add("profile_completed", "completion_percentage" to "100")
|
||||||
|
.flush()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Test
|
||||||
|
fun testAnalyticsTracking() {
|
||||||
|
val testAnalytics = createTestAnalyticsHelper()
|
||||||
|
|
||||||
|
// Use your component with test analytics
|
||||||
|
userService.registerUser("john@example.com", testAnalytics)
|
||||||
|
|
||||||
|
// Verify analytics were logged
|
||||||
|
testAnalytics.assertEventLogged(
|
||||||
|
"user_registered",
|
||||||
|
mapOf("email_domain" to "example.com")
|
||||||
|
)
|
||||||
|
testAnalytics.assertEventCount("user_registered", 1)
|
||||||
|
|
||||||
|
// Check specific events
|
||||||
|
assert(testAnalytics.hasEvent("email_verification_sent"))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data Validation
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// Automatic validation and sanitization
|
||||||
|
val validatingAnalytics = analyticsHelper.withValidation(
|
||||||
|
strictMode = false, // Sanitize invalid data instead of throwing
|
||||||
|
logValidationErrors = true
|
||||||
|
)
|
||||||
|
|
||||||
|
// This will be automatically sanitized if invalid
|
||||||
|
validatingAnalytics.logEvent("user-action-with-invalid-chars", "param" to "value")
|
||||||
|
|
||||||
|
// Manual validation
|
||||||
|
val event = AnalyticsEvent("my_event", listOf(Param("key", "value")))
|
||||||
|
val result = event.validate()
|
||||||
|
if (!result.isValid) {
|
||||||
|
println("Validation errors: ${result.errors}")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🏗️ Architecture
|
||||||
|
|
||||||
|
### Core Components
|
||||||
|
|
||||||
|
1. **AnalyticsEvent**: Type-safe event representation with builder pattern
|
||||||
|
2. **AnalyticsHelper**: Platform-agnostic analytics interface
|
||||||
|
3. **Platform Implementations**:
|
||||||
|
- `FirebaseAnalyticsHelper` for production
|
||||||
|
- `StubAnalyticsHelper` for development
|
||||||
|
- `NoOpAnalyticsHelper` for testing
|
||||||
|
4. **Extension Functions**: Utility methods for common operations
|
||||||
|
5. **Validation Layer**: Data quality assurance
|
||||||
|
6. **Testing Utilities**: Comprehensive test support
|
||||||
|
|
||||||
|
### Design Principles
|
||||||
|
|
||||||
|
- **Type Safety**: Compile-time safety for analytics parameters
|
||||||
|
- **Platform Agnostic**: Write once, track everywhere
|
||||||
|
- **Performance Conscious**: Minimal overhead with batch processing
|
||||||
|
- **Developer Friendly**: Rich testing and debugging tools
|
||||||
|
- **Extensible**: Easy to add custom tracking methods
|
||||||
|
|
||||||
|
## 🔧 Integration
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// In your module's build.gradle.kts
|
||||||
|
dependencies {
|
||||||
|
implementation(projects.coreBase.analytics)
|
||||||
|
|
||||||
|
// Platform-specific dependencies are handled automatically
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dependency Injection (Koin)
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
val analyticsModule = module {
|
||||||
|
// The actual implementation is provided by platform-specific modules
|
||||||
|
// Android: FirebaseAnalyticsHelper
|
||||||
|
// Desktop: StubAnalyticsHelper
|
||||||
|
// etc.
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Compose Setup
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Composable
|
||||||
|
fun App() {
|
||||||
|
val analytics: AnalyticsHelper = koinInject()
|
||||||
|
|
||||||
|
CompositionLocalProvider(
|
||||||
|
LocalAnalyticsHelper provides analytics
|
||||||
|
) {
|
||||||
|
// Your app content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📋 Event Types Reference
|
||||||
|
|
||||||
|
### Navigation Events
|
||||||
|
|
||||||
|
- `SCREEN_VIEW`, `SCREEN_TRANSITION`
|
||||||
|
|
||||||
|
### User Interactions
|
||||||
|
|
||||||
|
- `BUTTON_CLICK`, `MENU_ITEM_SELECTED`, `SEARCH_PERFORMED`, `FILTER_APPLIED`
|
||||||
|
|
||||||
|
### Form Events
|
||||||
|
|
||||||
|
- `FORM_STARTED`, `FORM_COMPLETED`, `FORM_ABANDONED`, `FIELD_VALIDATION_ERROR`
|
||||||
|
|
||||||
|
### Content Events
|
||||||
|
|
||||||
|
- `CONTENT_VIEW`, `CONTENT_SHARED`, `CONTENT_LIKED`
|
||||||
|
|
||||||
|
### Error Events
|
||||||
|
|
||||||
|
- `ERROR_OCCURRED`, `API_ERROR`, `NETWORK_ERROR`
|
||||||
|
|
||||||
|
### Performance Events
|
||||||
|
|
||||||
|
- `APP_LAUNCH`, `APP_BACKGROUND`, `APP_FOREGROUND`, `LOADING_TIME`
|
||||||
|
|
||||||
|
### Authentication Events
|
||||||
|
|
||||||
|
- `LOGIN_ATTEMPT`, `LOGIN_SUCCESS`, `LOGIN_FAILURE`, `LOGOUT`, `SIGNUP_ATTEMPT`, `SIGNUP_SUCCESS`
|
||||||
|
|
||||||
|
### Feature Usage
|
||||||
|
|
||||||
|
- `FEATURE_USED`, `TUTORIAL_STARTED`, `TUTORIAL_COMPLETED`, `TUTORIAL_SKIPPED`
|
||||||
|
|
||||||
|
## 🔒 Privacy & Compliance
|
||||||
|
|
||||||
|
- **No PII Logging**: Framework prevents logging of personally identifiable information
|
||||||
|
- **Data Validation**: Automatic parameter validation prevents sensitive data leakage
|
||||||
|
- **Configurable**: Easy to disable or mock for privacy-compliant testing
|
||||||
|
- **Transparent**: All logged data is visible and controllable
|
||||||
|
|
||||||
|
## 🚀 Performance Characteristics
|
||||||
|
|
||||||
|
- **Minimal Overhead**: Event creation is lightweight with lazy validation
|
||||||
|
- **Batch Processing**: Efficient bulk event logging
|
||||||
|
- **Memory Conscious**: Automatic memory usage monitoring and warnings
|
||||||
|
- **Network Optimized**: Platform implementations handle network efficiency
|
||||||
|
|
||||||
|
## 🧪 Testing Features
|
||||||
|
|
||||||
|
- **Complete Event Capture**: Test helpers capture all analytics for verification
|
||||||
|
- **Assertion Helpers**: Rich assertion methods for common verification patterns
|
||||||
|
- **Mock Analytics**: Simulate network conditions and failures
|
||||||
|
- **Debug Output**: Pretty-print analytics events for debugging
|
||||||
|
|
||||||
|
This module provides the foundation for comprehensive analytics tracking while maintaining code
|
||||||
|
quality, performance, and developer experience across all platforms.
|
||||||
|
|
||||||
|
## 📚 API Documentation
|
||||||
|
|
||||||
|
All classes and methods in this module are comprehensively documented with KDoc. The documentation
|
||||||
|
includes:
|
||||||
|
|
||||||
|
### Core API Classes
|
||||||
|
|
||||||
|
- **[AnalyticsEvent](src/commonMain/kotlin/template/core/base/analytics/AnalyticsEvent.kt)**:
|
||||||
|
Type-safe event representation with builder pattern and validation
|
||||||
|
- **[AnalyticsHelper](src/commonMain/kotlin/template/core/base/analytics/AnalyticsHelper.kt)**:
|
||||||
|
Platform-agnostic analytics interface with convenience methods
|
||||||
|
- **[Param](src/commonMain/kotlin/template/core/base/analytics/AnalyticsEvent.kt)**: Validated
|
||||||
|
parameter class with automatic constraint checking
|
||||||
|
- **[Types](src/commonMain/kotlin/template/core/base/analytics/AnalyticsEvent.kt)**: Standard event
|
||||||
|
type constants organized by category
|
||||||
|
- **[ParamKeys](src/commonMain/kotlin/template/core/base/analytics/AnalyticsEvent.kt)**: Standard
|
||||||
|
parameter key constants for consistency
|
||||||
|
|
||||||
|
### Extension Functions
|
||||||
|
|
||||||
|
- **[AnalyticsExtensions](src/commonMain/kotlin/template/core/base/analytics/AnalyticsExtensions.kt)
|
||||||
|
**: Builder functions, timing utilities, and batch processing
|
||||||
|
- **[PerformanceTracker](src/commonMain/kotlin/template/core/base/analytics/PerformanceTracker.kt)
|
||||||
|
**: Advanced performance monitoring and timing utilities
|
||||||
|
- **[UiHelpers](src/commonMain/kotlin/template/core/base/analytics/UiHelpers.kt)**: Jetpack Compose
|
||||||
|
integration helpers
|
||||||
|
|
||||||
|
### Implementations
|
||||||
|
|
||||||
|
- *
|
||||||
|
*[FirebaseAnalyticsHelper](src/nonJsCommonMain/kotlin/template/core/base/analytics/FirebaseAnalyticsHelper.kt)
|
||||||
|
**: Production Firebase Analytics implementation
|
||||||
|
- **[StubAnalyticsHelper](src/commonMain/kotlin/template/core/base/analytics/StubAnalyticsHelper.kt)
|
||||||
|
**: Development implementation with console logging
|
||||||
|
- **[NoOpAnalyticsHelper](src/commonMain/kotlin/template/core/base/analytics/NoOpAnalyticsHelper.kt)
|
||||||
|
**: No-operation implementation for testing
|
||||||
|
|
||||||
|
### Testing & Validation
|
||||||
|
|
||||||
|
- **[TestingUtils](src/commonMain/kotlin/template/core/base/analytics/TestingUtils.kt)**:
|
||||||
|
Comprehensive testing utilities and mock implementations
|
||||||
|
- **[ValidationUtils](src/commonMain/kotlin/template/core/base/analytics/ValidationUtils.kt)**: Data
|
||||||
|
validation and sanitization utilities
|
||||||
|
|
||||||
|
### Documentation Features
|
||||||
|
|
||||||
|
- ✅ **Detailed Descriptions**: Every class and method has comprehensive documentation
|
||||||
|
- ✅ **Parameter Documentation**: All parameters documented with @param tags
|
||||||
|
- ✅ **Usage Examples**: @sample blocks with practical code examples
|
||||||
|
- ✅ **Cross-References**: @see tags linking related functionality
|
||||||
|
- ✅ **Platform Notes**: Platform-specific behavior and constraints documented
|
||||||
|
- ✅ **Error Conditions**: Exception throwing conditions clearly documented
|
||||||
|
- ✅ **Since Tags**: Version information for API tracking
|
||||||
59
core-base/analytics/build.gradle.kts
Normal file
59
core-base/analytics/build.gradle.kts
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.mifos.kmp.library)
|
||||||
|
alias(libs.plugins.jetbrainsCompose)
|
||||||
|
alias(libs.plugins.compose.compiler)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "template.core.base.analytics"
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
sourceSets {
|
||||||
|
commonMain.dependencies {
|
||||||
|
implementation(libs.koin.core)
|
||||||
|
implementation(compose.runtime)
|
||||||
|
implementation(compose.ui)
|
||||||
|
implementation(compose.foundation)
|
||||||
|
implementation(libs.kermit.logging)
|
||||||
|
|
||||||
|
// For timing and performance tracking
|
||||||
|
implementation(libs.kotlinx.datetime)
|
||||||
|
}
|
||||||
|
|
||||||
|
androidMain.dependencies {
|
||||||
|
api(libs.gitlive.firebase.analytics)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonJsCommonMain.dependencies {
|
||||||
|
api(libs.gitlive.firebase.analytics)
|
||||||
|
}
|
||||||
|
|
||||||
|
nativeMain.dependencies {
|
||||||
|
api(libs.gitlive.firebase.analytics)
|
||||||
|
}
|
||||||
|
|
||||||
|
desktopMain.dependencies {
|
||||||
|
api(libs.gitlive.firebase.analytics)
|
||||||
|
}
|
||||||
|
|
||||||
|
mobileMain.dependencies {
|
||||||
|
api(libs.gitlive.firebase.crashlytics)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test dependencies for all platforms
|
||||||
|
commonTest.dependencies {
|
||||||
|
implementation(libs.kotlin.test)
|
||||||
|
implementation(libs.kotlinx.coroutines.test)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
0
core-base/analytics/consumer-rules.pro
Normal file
0
core-base/analytics/consumer-rules.pro
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package template.core.base.analytics.di
|
||||||
|
|
||||||
|
import org.koin.core.module.Module
|
||||||
|
import org.koin.core.module.dsl.singleOf
|
||||||
|
import org.koin.dsl.bind
|
||||||
|
import org.koin.dsl.module
|
||||||
|
import template.core.base.analytics.AnalyticsHelper
|
||||||
|
import template.core.base.analytics.StubAnalyticsHelper
|
||||||
|
|
||||||
|
actual val analyticsModule: Module = module {
|
||||||
|
singleOf(::StubAnalyticsHelper) bind AnalyticsHelper::class
|
||||||
|
}
|
||||||
11
core-base/analytics/src/androidMain/AndroidManifest.xml
Normal file
11
core-base/analytics/src/androidMain/AndroidManifest.xml
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Copyright 2023 Mifos Initiative
|
||||||
|
|
||||||
|
This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0.
|
||||||
|
If a copy of the MPL was not distributed with this file,
|
||||||
|
You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
|
||||||
|
See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
-->
|
||||||
|
<manifest />
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
@file:Suppress("InvalidPackageDeclaration")
|
||||||
|
|
||||||
|
package template.core.base.analytics.di
|
||||||
|
|
||||||
|
import dev.gitlive.firebase.Firebase
|
||||||
|
import dev.gitlive.firebase.analytics.FirebaseAnalytics
|
||||||
|
import dev.gitlive.firebase.analytics.analytics
|
||||||
|
import org.koin.core.module.dsl.singleOf
|
||||||
|
import org.koin.dsl.bind
|
||||||
|
import org.koin.dsl.module
|
||||||
|
import template.core.base.analytics.AnalyticsHelper
|
||||||
|
import template.core.base.analytics.FirebaseAnalyticsHelper
|
||||||
|
|
||||||
|
actual val analyticsModule = module {
|
||||||
|
single<FirebaseAnalytics> { Firebase.analytics }
|
||||||
|
singleOf(::FirebaseAnalyticsHelper) bind AnalyticsHelper::class
|
||||||
|
}
|
||||||
@ -0,0 +1,349 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2023 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package template.core.base.analytics
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents an analytics event with type-safe parameter validation.
|
||||||
|
*
|
||||||
|
* This data class encapsulates all information needed to log an analytics event,
|
||||||
|
* including the event type and associated parameters. It provides a builder pattern
|
||||||
|
* through extension methods for convenient event construction.
|
||||||
|
*
|
||||||
|
* @param type The event type identifier. Use predefined constants from [Types] when possible,
|
||||||
|
* or define custom events that are configured in your analytics backend
|
||||||
|
* (e.g., Firebase Analytics custom events). Must be non-blank and follow
|
||||||
|
* analytics platform naming conventions.
|
||||||
|
* @param extras List of key-value parameters that provide additional context for the event.
|
||||||
|
* Each parameter is validated according to analytics platform constraints
|
||||||
|
* (key ≤ 40 chars, value ≤ 100 chars). See [Param] for details.
|
||||||
|
*
|
||||||
|
* @see Types for standard event type constants
|
||||||
|
* @see ParamKeys for standard parameter key constants
|
||||||
|
* @see Param for parameter validation rules
|
||||||
|
*
|
||||||
|
* @sample
|
||||||
|
* ```kotlin
|
||||||
|
* // Simple event
|
||||||
|
* val event = AnalyticsEvent(Types.BUTTON_CLICK)
|
||||||
|
*
|
||||||
|
* // Event with parameters using builder pattern
|
||||||
|
* val event = AnalyticsEvent(Types.SCREEN_VIEW)
|
||||||
|
* .withParam(ParamKeys.SCREEN_NAME, "UserProfile")
|
||||||
|
* .withParam(ParamKeys.SOURCE_SCREEN, "Dashboard")
|
||||||
|
*
|
||||||
|
* // Event with multiple parameters
|
||||||
|
* val event = AnalyticsEvent(Types.FORM_COMPLETED)
|
||||||
|
* .withParams(
|
||||||
|
* ParamKeys.FORM_NAME to "registration",
|
||||||
|
* ParamKeys.COMPLETION_TIME to "45s",
|
||||||
|
* "field_count" to "8"
|
||||||
|
* )
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
data class AnalyticsEvent(
|
||||||
|
val type: String,
|
||||||
|
val extras: List<Param> = emptyList(),
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* Adds a single parameter to this analytics event using the builder pattern.
|
||||||
|
*
|
||||||
|
* This method creates a new [AnalyticsEvent] instance with the additional parameter,
|
||||||
|
* following immutable design principles. The parameter will be validated according
|
||||||
|
* to analytics platform constraints.
|
||||||
|
*
|
||||||
|
* @param key The parameter key identifier. Must be non-blank, ≤ 40 characters,
|
||||||
|
* and follow valid naming conventions (letters, numbers, underscores).
|
||||||
|
* Use [ParamKeys] constants when possible.
|
||||||
|
* @param value The parameter value. Must be ≤ 100 characters. Can be any string
|
||||||
|
* representing the parameter data.
|
||||||
|
*
|
||||||
|
* @return A new [AnalyticsEvent] instance with the added parameter
|
||||||
|
* @throws IllegalArgumentException if the parameter violates validation constraints
|
||||||
|
*
|
||||||
|
* @see ParamKeys for standard parameter key constants
|
||||||
|
* @see Param for parameter validation details
|
||||||
|
*
|
||||||
|
* @sample
|
||||||
|
* ```kotlin
|
||||||
|
* val event = AnalyticsEvent(Types.BUTTON_CLICK)
|
||||||
|
* .withParam(ParamKeys.BUTTON_NAME, "save")
|
||||||
|
* .withParam(ParamKeys.SCREEN_NAME, "UserProfile")
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
fun withParam(key: String, value: String): AnalyticsEvent {
|
||||||
|
return copy(extras = extras + Param(key, value))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds multiple parameters to this analytics event using vararg syntax.
|
||||||
|
*
|
||||||
|
* This method provides a convenient way to add multiple parameters at once
|
||||||
|
* using Kotlin's vararg feature. Each parameter pair will be converted to
|
||||||
|
* a [Param] instance and validated.
|
||||||
|
*
|
||||||
|
* @param params Variable number of parameter pairs (key to value). Each key
|
||||||
|
* and value must meet the same validation constraints as [withParam].
|
||||||
|
*
|
||||||
|
* @return A new [AnalyticsEvent] instance with all the added parameters
|
||||||
|
* @throws IllegalArgumentException if any parameter violates validation constraints
|
||||||
|
*
|
||||||
|
* @see withParam for single parameter addition
|
||||||
|
* @see ParamKeys for standard parameter key constants
|
||||||
|
*
|
||||||
|
* @sample
|
||||||
|
* ```kotlin
|
||||||
|
* val event = AnalyticsEvent(Types.SEARCH_PERFORMED)
|
||||||
|
* .withParams(
|
||||||
|
* ParamKeys.SEARCH_TERM to "kotlin",
|
||||||
|
* ParamKeys.RESULT_COUNT to "42",
|
||||||
|
* ParamKeys.SCREEN_NAME to "SearchResults"
|
||||||
|
* )
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
fun withParams(vararg params: Pair<String, String>): AnalyticsEvent {
|
||||||
|
val newParams = params.map { Param(it.first, it.second) }
|
||||||
|
return copy(extras = extras + newParams)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds multiple parameters to this analytics event from a Map.
|
||||||
|
*
|
||||||
|
* This method allows adding parameters from an existing Map<String, String>,
|
||||||
|
* which is useful when working with dynamic parameter sets or converting
|
||||||
|
* from other data structures.
|
||||||
|
*
|
||||||
|
* @param params A map containing parameter key-value pairs. Each entry
|
||||||
|
* will be converted to a [Param] instance and validated.
|
||||||
|
*
|
||||||
|
* @return A new [AnalyticsEvent] instance with all the added parameters
|
||||||
|
* @throws IllegalArgumentException if any parameter violates validation constraints
|
||||||
|
*
|
||||||
|
* @see withParam for single parameter addition
|
||||||
|
* @see withParams for vararg parameter addition
|
||||||
|
*
|
||||||
|
* @sample
|
||||||
|
* ```kotlin
|
||||||
|
* val dynamicParams = mapOf(
|
||||||
|
* ParamKeys.USER_TYPE to "premium",
|
||||||
|
* ParamKeys.APP_VERSION to "2.1.0",
|
||||||
|
* "custom_metric" to "enabled"
|
||||||
|
* )
|
||||||
|
* val event = AnalyticsEvent(Types.FEATURE_USED)
|
||||||
|
* .withParams(dynamicParams)
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
fun withParams(params: Map<String, String>): AnalyticsEvent {
|
||||||
|
val newParams = params.map { Param(it.key, it.value) }
|
||||||
|
return copy(extras = extras + newParams)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard analytics event type constants for consistent cross-platform event logging.
|
||||||
|
*
|
||||||
|
* This object provides predefined event type constants that follow analytics platform
|
||||||
|
* best practices and naming conventions. Using these constants ensures consistency
|
||||||
|
* across your application and compatibility with analytics backends like Firebase Analytics.
|
||||||
|
*
|
||||||
|
* Event types are organized into logical categories:
|
||||||
|
* - **Navigation**: Screen views and navigation tracking
|
||||||
|
* - **User Interactions**: Clicks, selections, and user-initiated actions
|
||||||
|
* - **Forms**: Form lifecycle and validation events
|
||||||
|
* - **Content**: Content engagement and interaction
|
||||||
|
* - **Errors**: Error tracking and debugging
|
||||||
|
* - **Performance**: App performance and timing metrics
|
||||||
|
* - **Authentication**: User authentication and session management
|
||||||
|
* - **Feature Usage**: Feature adoption and usage patterns
|
||||||
|
*
|
||||||
|
* @see ParamKeys for corresponding parameter key constants
|
||||||
|
* @see AnalyticsEvent for usage examples
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
object Types {
|
||||||
|
// Navigation events
|
||||||
|
const val SCREEN_VIEW = "screen_view"
|
||||||
|
const val SCREEN_TRANSITION = "screen_transition"
|
||||||
|
|
||||||
|
// User interaction events
|
||||||
|
const val BUTTON_CLICK = "button_click"
|
||||||
|
const val MENU_ITEM_SELECTED = "menu_item_selected"
|
||||||
|
const val SEARCH_PERFORMED = "search_performed"
|
||||||
|
const val FILTER_APPLIED = "filter_applied"
|
||||||
|
|
||||||
|
// Form events
|
||||||
|
const val FORM_STARTED = "form_started"
|
||||||
|
const val FORM_COMPLETED = "form_completed"
|
||||||
|
const val FORM_ABANDONED = "form_abandoned"
|
||||||
|
const val FIELD_VALIDATION_ERROR = "field_validation_error"
|
||||||
|
|
||||||
|
// Content events
|
||||||
|
const val CONTENT_VIEW = "content_view"
|
||||||
|
const val CONTENT_SHARED = "content_shared"
|
||||||
|
const val CONTENT_LIKED = "content_liked"
|
||||||
|
|
||||||
|
// Error events
|
||||||
|
const val ERROR_OCCURRED = "error_occurred"
|
||||||
|
const val API_ERROR = "api_error"
|
||||||
|
const val NETWORK_ERROR = "network_error"
|
||||||
|
|
||||||
|
// Performance events
|
||||||
|
const val APP_LAUNCH = "app_launch"
|
||||||
|
const val APP_BACKGROUND = "app_background"
|
||||||
|
const val APP_FOREGROUND = "app_foreground"
|
||||||
|
const val LOADING_TIME = "loading_time"
|
||||||
|
|
||||||
|
// Authentication events
|
||||||
|
const val LOGIN_ATTEMPT = "login_attempt"
|
||||||
|
const val LOGIN_SUCCESS = "login_success"
|
||||||
|
const val LOGIN_FAILURE = "login_failure"
|
||||||
|
const val LOGOUT = "logout"
|
||||||
|
const val SIGNUP_ATTEMPT = "signup_attempt"
|
||||||
|
const val SIGNUP_SUCCESS = "signup_success"
|
||||||
|
|
||||||
|
// Feature usage
|
||||||
|
const val FEATURE_USED = "feature_used"
|
||||||
|
const val TUTORIAL_STARTED = "tutorial_started"
|
||||||
|
const val TUTORIAL_COMPLETED = "tutorial_completed"
|
||||||
|
const val TUTORIAL_SKIPPED = "tutorial_skipped"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a validated analytics parameter with automatic constraint checking.
|
||||||
|
*
|
||||||
|
* This data class encapsulates a key-value pair for analytics events with built-in
|
||||||
|
* validation that enforces analytics platform constraints. The validation occurs
|
||||||
|
* during object construction to ensure data integrity.
|
||||||
|
*
|
||||||
|
* **Validation Rules:**
|
||||||
|
* - Key must be non-blank
|
||||||
|
* - Key must be ≤ 40 characters (Firebase Analytics constraint)
|
||||||
|
* - Value must be ≤ 100 characters (Firebase Analytics constraint)
|
||||||
|
* - Key should follow naming conventions (letters, numbers, underscores)
|
||||||
|
*
|
||||||
|
* @param key The parameter identifier. Use [ParamKeys] constants when possible
|
||||||
|
* for consistency and to avoid typos.
|
||||||
|
* @param value The parameter value as a string. All values are stored as strings
|
||||||
|
* regardless of their original type.
|
||||||
|
*
|
||||||
|
* @throws IllegalArgumentException if validation constraints are violated
|
||||||
|
*
|
||||||
|
* @see ParamKeys for standard parameter key constants
|
||||||
|
* @see AnalyticsEvent.withParam for usage in event construction
|
||||||
|
* @see createParam for safe parameter creation with validation
|
||||||
|
*
|
||||||
|
* @sample
|
||||||
|
* ```kotlin
|
||||||
|
* // Valid parameter
|
||||||
|
* val param = Param(ParamKeys.SCREEN_NAME, "UserProfile")
|
||||||
|
*
|
||||||
|
* // This would throw IllegalArgumentException (key too long)
|
||||||
|
* // val invalid = Param("this_key_is_way_too_long_and_exceeds_forty_characters", "value")
|
||||||
|
*
|
||||||
|
* // This would throw IllegalArgumentException (value too long)
|
||||||
|
* // val invalid = Param("key", "very long value..." + "x".repeat(100))
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
data class Param(val key: String, val value: String) {
|
||||||
|
init {
|
||||||
|
require(key.isNotBlank()) { "Parameter key cannot be blank" }
|
||||||
|
require(key.length <= 40) { "Parameter key cannot exceed 40 characters" }
|
||||||
|
require(value.length <= 100) { "Parameter value cannot exceed 100 characters" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard parameter key constants for consistent analytics event parameters.
|
||||||
|
*
|
||||||
|
* This object provides predefined parameter key constants that ensure consistency
|
||||||
|
* across analytics events and prevent typos in parameter naming. These keys follow
|
||||||
|
* analytics platform best practices and are organized into logical categories for
|
||||||
|
* easy discovery and usage.
|
||||||
|
*
|
||||||
|
* **Parameter Categories:**
|
||||||
|
* - **Screen & Navigation**: Screen names, navigation context, and flow tracking
|
||||||
|
* - **User Interaction**: UI element identification and interaction context
|
||||||
|
* - **Content**: Content identification, categorization, and engagement
|
||||||
|
* - **Search & Filters**: Search terms, filter states, and result information
|
||||||
|
* - **Forms**: Form identification, field tracking, and completion metrics
|
||||||
|
* - **Performance**: Timing, error tracking, and performance metrics
|
||||||
|
* - **User Attributes**: User identification and characteristic data
|
||||||
|
* - **Feature Usage**: Feature identification and usage patterns
|
||||||
|
* - **General**: Common parameters used across multiple event types
|
||||||
|
*
|
||||||
|
* **Usage Guidelines:**
|
||||||
|
* - Always use these constants instead of hardcoded strings
|
||||||
|
* - Keys are designed to be ≤ 40 characters (analytics platform constraint)
|
||||||
|
* - Values should be kept ≤ 100 characters when possible
|
||||||
|
* - Combine with [Types] constants for consistent event structure
|
||||||
|
*
|
||||||
|
* @see Types for corresponding event type constants
|
||||||
|
* @see Param for parameter validation rules
|
||||||
|
* @see AnalyticsEvent for usage examples
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
object ParamKeys {
|
||||||
|
// Screen and navigation
|
||||||
|
const val SCREEN_NAME = "screen_name"
|
||||||
|
const val SOURCE_SCREEN = "source_screen"
|
||||||
|
const val DESTINATION_SCREEN = "destination_screen"
|
||||||
|
|
||||||
|
// User interaction
|
||||||
|
const val BUTTON_NAME = "button_name"
|
||||||
|
const val ELEMENT_ID = "element_id"
|
||||||
|
const val ELEMENT_TYPE = "element_type"
|
||||||
|
const val ACTION_TYPE = "action_type"
|
||||||
|
|
||||||
|
// Content
|
||||||
|
const val CONTENT_TYPE = "content_type"
|
||||||
|
const val CONTENT_ID = "content_id"
|
||||||
|
const val CONTENT_NAME = "content_name"
|
||||||
|
const val CATEGORY = "category"
|
||||||
|
|
||||||
|
// Search and filters
|
||||||
|
const val SEARCH_TERM = "search_term"
|
||||||
|
const val FILTER_TYPE = "filter_type"
|
||||||
|
const val FILTER_VALUE = "filter_value"
|
||||||
|
const val RESULT_COUNT = "result_count"
|
||||||
|
|
||||||
|
// Forms
|
||||||
|
const val FORM_NAME = "form_name"
|
||||||
|
const val FIELD_NAME = "field_name"
|
||||||
|
const val ERROR_MESSAGE = "error_message"
|
||||||
|
const val COMPLETION_TIME = "completion_time"
|
||||||
|
|
||||||
|
// Performance
|
||||||
|
const val LOADING_TIME_MS = "loading_time_ms"
|
||||||
|
const val ERROR_CODE = "error_code"
|
||||||
|
const val API_ENDPOINT = "api_endpoint"
|
||||||
|
const val NETWORK_TYPE = "network_type"
|
||||||
|
|
||||||
|
// User attributes
|
||||||
|
const val USER_ID = "user_id"
|
||||||
|
const val USER_TYPE = "user_type"
|
||||||
|
const val DEVICE_TYPE = "device_type"
|
||||||
|
const val APP_VERSION = "app_version"
|
||||||
|
|
||||||
|
// Feature usage
|
||||||
|
const val FEATURE_NAME = "feature_name"
|
||||||
|
const val USAGE_COUNT = "usage_count"
|
||||||
|
const val TUTORIAL_STEP = "tutorial_step"
|
||||||
|
|
||||||
|
// Custom
|
||||||
|
const val VALUE = "value"
|
||||||
|
const val TIMESTAMP = "timestamp"
|
||||||
|
const val DURATION = "duration"
|
||||||
|
const val SUCCESS = "success"
|
||||||
|
}
|
||||||
@ -0,0 +1,215 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package template.core.base.analytics
|
||||||
|
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.DurationUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extension functions for enhanced analytics functionality
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a screen view event with builder pattern
|
||||||
|
*/
|
||||||
|
fun AnalyticsEvent.screenView(
|
||||||
|
screenName: String,
|
||||||
|
sourceScreen: String? = null,
|
||||||
|
additionalParams: Map<String, String> = emptyMap(),
|
||||||
|
): AnalyticsEvent {
|
||||||
|
val params = mutableListOf(Param(ParamKeys.SCREEN_NAME, screenName))
|
||||||
|
sourceScreen?.let { params.add(Param(ParamKeys.SOURCE_SCREEN, it)) }
|
||||||
|
additionalParams.forEach { (key, value) -> params.add(Param(key, value)) }
|
||||||
|
return AnalyticsEvent(Types.SCREEN_VIEW, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a button click event with builder pattern
|
||||||
|
*/
|
||||||
|
fun AnalyticsEvent.buttonClick(
|
||||||
|
buttonName: String,
|
||||||
|
screenName: String? = null,
|
||||||
|
elementId: String? = null,
|
||||||
|
): AnalyticsEvent {
|
||||||
|
val params = mutableListOf(Param(ParamKeys.BUTTON_NAME, buttonName))
|
||||||
|
screenName?.let { params.add(Param(ParamKeys.SCREEN_NAME, it)) }
|
||||||
|
elementId?.let { params.add(Param(ParamKeys.ELEMENT_ID, it)) }
|
||||||
|
return AnalyticsEvent(Types.BUTTON_CLICK, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an error event with builder pattern
|
||||||
|
*/
|
||||||
|
fun AnalyticsEvent.error(
|
||||||
|
message: String,
|
||||||
|
errorCode: String? = null,
|
||||||
|
screen: String? = null,
|
||||||
|
apiEndpoint: String? = null,
|
||||||
|
): AnalyticsEvent {
|
||||||
|
val params = mutableListOf(Param(ParamKeys.ERROR_MESSAGE, message))
|
||||||
|
errorCode?.let { params.add(Param(ParamKeys.ERROR_CODE, it)) }
|
||||||
|
screen?.let { params.add(Param(ParamKeys.SCREEN_NAME, it)) }
|
||||||
|
apiEndpoint?.let { params.add(Param(ParamKeys.API_ENDPOINT, it)) }
|
||||||
|
return AnalyticsEvent(Types.ERROR_OCCURRED, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a search event with builder pattern
|
||||||
|
*/
|
||||||
|
fun AnalyticsEvent.search(
|
||||||
|
searchTerm: String,
|
||||||
|
resultCount: Int? = null,
|
||||||
|
screen: String? = null,
|
||||||
|
): AnalyticsEvent {
|
||||||
|
val params = mutableListOf(Param(ParamKeys.SEARCH_TERM, searchTerm))
|
||||||
|
resultCount?.let { params.add(Param(ParamKeys.RESULT_COUNT, it.toString())) }
|
||||||
|
screen?.let { params.add(Param(ParamKeys.SCREEN_NAME, it)) }
|
||||||
|
return AnalyticsEvent(Types.SEARCH_PERFORMED, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a form event with builder pattern
|
||||||
|
*/
|
||||||
|
fun AnalyticsEvent.formEvent(
|
||||||
|
// FORM_STARTED, FORM_COMPLETED, FORM_ABANDONED
|
||||||
|
eventType: String,
|
||||||
|
formName: String,
|
||||||
|
completionTime: Duration? = null,
|
||||||
|
fieldName: String? = null,
|
||||||
|
): AnalyticsEvent {
|
||||||
|
val params = mutableListOf(Param(ParamKeys.FORM_NAME, formName))
|
||||||
|
completionTime?.let { params.add(Param(ParamKeys.COMPLETION_TIME, "${it.toDouble(DurationUnit.SECONDS)}s")) }
|
||||||
|
fieldName?.let { params.add(Param(ParamKeys.FIELD_NAME, it)) }
|
||||||
|
return AnalyticsEvent(eventType, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a loading time event
|
||||||
|
*/
|
||||||
|
fun AnalyticsEvent.loadingTime(
|
||||||
|
screen: String,
|
||||||
|
loadingTimeMs: Long,
|
||||||
|
success: Boolean = true,
|
||||||
|
): AnalyticsEvent {
|
||||||
|
val params = listOf(
|
||||||
|
Param(ParamKeys.SCREEN_NAME, screen),
|
||||||
|
Param(ParamKeys.LOADING_TIME_MS, loadingTimeMs.toString()),
|
||||||
|
Param(ParamKeys.SUCCESS, success.toString()),
|
||||||
|
)
|
||||||
|
return AnalyticsEvent(Types.LOADING_TIME, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extension functions for AnalyticsHelper to add timing functionality
|
||||||
|
*/
|
||||||
|
class TimedEvent internal constructor(
|
||||||
|
private val analytics: AnalyticsHelper,
|
||||||
|
private val eventType: String,
|
||||||
|
private val baseParams: List<Param>,
|
||||||
|
) {
|
||||||
|
private val startTime = Clock.System.now().toEpochMilliseconds()
|
||||||
|
|
||||||
|
fun complete(additionalParams: Map<String, String> = emptyMap()) {
|
||||||
|
val duration = Clock.System.now().toEpochMilliseconds() - startTime
|
||||||
|
val params = baseParams +
|
||||||
|
Param(ParamKeys.DURATION, duration.toString()) +
|
||||||
|
additionalParams.map { Param(it.key, it.value) }
|
||||||
|
analytics.logEvent(AnalyticsEvent(eventType, params))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start timing an event - call complete() when done
|
||||||
|
*/
|
||||||
|
fun AnalyticsHelper.startTiming(eventType: String, vararg params: Pair<String, String>): TimedEvent {
|
||||||
|
val baseParams = params.map { Param(it.first, it.second) }
|
||||||
|
return TimedEvent(this, eventType, baseParams)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Time a block of code execution
|
||||||
|
*/
|
||||||
|
inline fun <T> AnalyticsHelper.timeExecution(
|
||||||
|
eventType: String,
|
||||||
|
vararg params: Pair<String, String>,
|
||||||
|
block: () -> T,
|
||||||
|
): T {
|
||||||
|
val startTime = Clock.System.now().toEpochMilliseconds()
|
||||||
|
return try {
|
||||||
|
val result = block()
|
||||||
|
val duration = Clock.System.now().toEpochMilliseconds() - startTime
|
||||||
|
logEvent(
|
||||||
|
eventType,
|
||||||
|
*params,
|
||||||
|
ParamKeys.DURATION to duration.toString(),
|
||||||
|
ParamKeys.SUCCESS to "true",
|
||||||
|
)
|
||||||
|
result
|
||||||
|
} catch (e: Exception) {
|
||||||
|
val duration = Clock.System.now().toEpochMilliseconds() - startTime
|
||||||
|
logEvent(
|
||||||
|
eventType,
|
||||||
|
*params,
|
||||||
|
ParamKeys.DURATION to duration.toString(),
|
||||||
|
ParamKeys.SUCCESS to "false",
|
||||||
|
ParamKeys.ERROR_MESSAGE to (e.message ?: "Unknown error"),
|
||||||
|
)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch analytics events for better performance
|
||||||
|
*/
|
||||||
|
class AnalyticsBatch internal constructor(private val analytics: AnalyticsHelper) {
|
||||||
|
private val events = mutableListOf<AnalyticsEvent>()
|
||||||
|
|
||||||
|
fun add(event: AnalyticsEvent): AnalyticsBatch {
|
||||||
|
events.add(event)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
fun add(type: String, vararg params: Pair<String, String>): AnalyticsBatch {
|
||||||
|
events.add(AnalyticsEvent(type, params.map { Param(it.first, it.second) }))
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
fun flush() {
|
||||||
|
events.forEach { analytics.logEvent(it) }
|
||||||
|
events.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a batch for logging multiple events efficiently
|
||||||
|
*/
|
||||||
|
fun AnalyticsHelper.batch(): AnalyticsBatch = AnalyticsBatch(this)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safe parameter creation that handles validation
|
||||||
|
*/
|
||||||
|
@Suppress("ReturnCount")
|
||||||
|
fun createParam(key: String, value: Any?): Param? {
|
||||||
|
return try {
|
||||||
|
val stringValue = value?.toString() ?: return null
|
||||||
|
if (key.isBlank() || stringValue.isBlank()) return null
|
||||||
|
Param(key.take(40), stringValue.take(100))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null // Return null for invalid parameters
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create parameters from a map, filtering out invalid ones
|
||||||
|
*/
|
||||||
|
fun createParams(params: Map<String, Any?>): List<Param> {
|
||||||
|
return params.mapNotNull { (key, value) -> createParam(key, value) }
|
||||||
|
}
|
||||||
@ -0,0 +1,357 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2023 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package template.core.base.analytics
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Platform-agnostic interface for logging analytics events with comprehensive utility methods.
|
||||||
|
*
|
||||||
|
* This interface provides the core contract for analytics tracking across all platforms
|
||||||
|
* in a Kotlin Multiplatform project. It abstracts the underlying analytics implementation
|
||||||
|
* (Firebase Analytics, custom backends, etc.) and provides convenient methods for common
|
||||||
|
* analytics operations.
|
||||||
|
*
|
||||||
|
* **Key Features:**
|
||||||
|
* - **Type-Safe Event Logging**: Uses [AnalyticsEvent] for validated event structure
|
||||||
|
* - **Convenience Methods**: Simplified APIs for common events (screen views, button clicks, errors)
|
||||||
|
* - **User Management**: Support for user properties and user ID tracking
|
||||||
|
* - **Platform Abstraction**: Works seamlessly across Android, iOS, Desktop, and Web
|
||||||
|
* - **Flexible Parameter Handling**: Multiple ways to add event parameters
|
||||||
|
*
|
||||||
|
* **Available Implementations:**
|
||||||
|
* - [FirebaseAnalyticsHelper]: Production implementation using Firebase Analytics
|
||||||
|
* - [StubAnalyticsHelper]: Development implementation that logs to console
|
||||||
|
* - [NoOpAnalyticsHelper]: No-operation implementation for testing/previews
|
||||||
|
* - [TestAnalyticsHelper]: Test implementation that captures events for verification
|
||||||
|
*
|
||||||
|
* **Usage Patterns:**
|
||||||
|
* ```kotlin
|
||||||
|
* // Dependency injection (Koin)
|
||||||
|
* val analytics: AnalyticsHelper = koinInject()
|
||||||
|
*
|
||||||
|
* // Simple event logging
|
||||||
|
* analytics.logEvent(Types.BUTTON_CLICK, ParamKeys.BUTTON_NAME to "save")
|
||||||
|
*
|
||||||
|
* // Complex event with builder pattern
|
||||||
|
* val event = AnalyticsEvent(Types.FORM_COMPLETED)
|
||||||
|
* .withParam(ParamKeys.FORM_NAME, "registration")
|
||||||
|
* .withParam(ParamKeys.COMPLETION_TIME, "45s")
|
||||||
|
* analytics.logEvent(event)
|
||||||
|
*
|
||||||
|
* // Convenience methods
|
||||||
|
* analytics.logScreenView("UserProfile", sourceScreen = "Dashboard")
|
||||||
|
* analytics.logButtonClick("edit_profile", screenName = "UserProfile")
|
||||||
|
* analytics.logError("Network timeout", "NET_001", "UserProfile")
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @see AnalyticsEvent for event structure and validation
|
||||||
|
* @see Types for standard event type constants
|
||||||
|
* @see ParamKeys for standard parameter key constants
|
||||||
|
* @see FirebaseAnalyticsHelper for production implementation
|
||||||
|
* @see StubAnalyticsHelper for development implementation
|
||||||
|
* @see TestAnalyticsHelper for testing implementation
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
interface AnalyticsHelper {
|
||||||
|
/**
|
||||||
|
* Logs an analytics event to the underlying analytics platform.
|
||||||
|
*
|
||||||
|
* This is the core method that all other logging methods ultimately call.
|
||||||
|
* The event will be validated and sent to the configured analytics backend
|
||||||
|
* (e.g., Firebase Analytics, custom analytics service).
|
||||||
|
*
|
||||||
|
* @param event The [AnalyticsEvent] to log. Must have a valid event type and
|
||||||
|
* parameters that meet platform constraints.
|
||||||
|
*
|
||||||
|
* @see AnalyticsEvent for event construction and validation
|
||||||
|
* @see Types for standard event types
|
||||||
|
* @see ParamKeys for standard parameter keys
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
fun logEvent(event: AnalyticsEvent)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs a simple analytics event with type and optional parameters using vararg syntax.
|
||||||
|
*
|
||||||
|
* This convenience method provides a more concise way to log events without
|
||||||
|
* explicitly creating an [AnalyticsEvent] instance. The parameters will be
|
||||||
|
* automatically converted to [Param] instances and validated.
|
||||||
|
*
|
||||||
|
* @param type The event type identifier. Use constants from [Types] when possible.
|
||||||
|
* @param params Variable number of parameter pairs (key to value). Each parameter
|
||||||
|
* must meet the same validation constraints as [Param].
|
||||||
|
*
|
||||||
|
* @throws IllegalArgumentException if the event type or any parameter violates
|
||||||
|
* validation constraints
|
||||||
|
*
|
||||||
|
* @see Types for standard event type constants
|
||||||
|
* @see ParamKeys for standard parameter key constants
|
||||||
|
*
|
||||||
|
* @sample
|
||||||
|
* ```kotlin
|
||||||
|
* analytics.logEvent(Types.BUTTON_CLICK,
|
||||||
|
* ParamKeys.BUTTON_NAME to "save",
|
||||||
|
* ParamKeys.SCREEN_NAME to "UserProfile"
|
||||||
|
* )
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
fun logEvent(type: String, vararg params: Pair<String, String>) {
|
||||||
|
val event = AnalyticsEvent(type, params.map { Param(it.first, it.second) })
|
||||||
|
logEvent(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs a simple analytics event with type and parameters from a Map.
|
||||||
|
*
|
||||||
|
* This convenience method allows logging events with parameters from an existing
|
||||||
|
* Map<String, String>, which is useful when working with dynamic parameter sets
|
||||||
|
* or converting from other data structures.
|
||||||
|
*
|
||||||
|
* @param type The event type identifier. Use constants from [Types] when possible.
|
||||||
|
* @param params A map containing parameter key-value pairs. Each entry will be
|
||||||
|
* converted to a [Param] instance and validated.
|
||||||
|
*
|
||||||
|
* @throws IllegalArgumentException if the event type or any parameter violates
|
||||||
|
* validation constraints
|
||||||
|
*
|
||||||
|
* @see Types for standard event type constants
|
||||||
|
* @see ParamKeys for standard parameter key constants
|
||||||
|
*
|
||||||
|
* @sample
|
||||||
|
* ```kotlin
|
||||||
|
* val eventParams = mapOf(
|
||||||
|
* ParamKeys.SEARCH_TERM to "kotlin",
|
||||||
|
* ParamKeys.RESULT_COUNT to "42"
|
||||||
|
* )
|
||||||
|
* analytics.logEvent(Types.SEARCH_PERFORMED, eventParams)
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
fun logEvent(type: String, params: Map<String, String>) {
|
||||||
|
val event = AnalyticsEvent(type, params.map { Param(it.key, it.value) })
|
||||||
|
logEvent(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs a screen view event for navigation tracking.
|
||||||
|
*
|
||||||
|
* This convenience method automatically creates a properly formatted screen view
|
||||||
|
* event, which is essential for understanding user navigation patterns and
|
||||||
|
* screen engagement metrics.
|
||||||
|
*
|
||||||
|
* @param screenName The name/identifier of the screen being viewed. Should be
|
||||||
|
* descriptive and consistent across the app (e.g., "UserProfile",
|
||||||
|
* "Settings", "ProductDetails").
|
||||||
|
* @param sourceScreen Optional name of the previous screen that led to this view.
|
||||||
|
* Useful for understanding navigation flows and user journeys.
|
||||||
|
*
|
||||||
|
* @see Types.SCREEN_VIEW for the generated event type
|
||||||
|
* @see ParamKeys.SCREEN_NAME for the screen name parameter
|
||||||
|
* @see ParamKeys.SOURCE_SCREEN for the source screen parameter
|
||||||
|
*
|
||||||
|
* @sample
|
||||||
|
* ```kotlin
|
||||||
|
* // Simple screen view
|
||||||
|
* analytics.logScreenView("UserProfile")
|
||||||
|
*
|
||||||
|
* // Screen view with navigation context
|
||||||
|
* analytics.logScreenView("ProductDetails", sourceScreen = "ProductList")
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
fun logScreenView(screenName: String, sourceScreen: String? = null) {
|
||||||
|
val params = mutableListOf(Param(ParamKeys.SCREEN_NAME, screenName))
|
||||||
|
sourceScreen?.let { params.add(Param(ParamKeys.SOURCE_SCREEN, it)) }
|
||||||
|
logEvent(AnalyticsEvent(Types.SCREEN_VIEW, params))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs a button click event for user interaction tracking.
|
||||||
|
*
|
||||||
|
* This convenience method tracks user interactions with buttons and other
|
||||||
|
* clickable elements, helping understand feature usage and user engagement
|
||||||
|
* patterns.
|
||||||
|
*
|
||||||
|
* @param buttonName The identifier or label of the button clicked. Should be
|
||||||
|
* descriptive and consistent (e.g., "save", "edit_profile",
|
||||||
|
* "submit_form").
|
||||||
|
* @param screenName Optional name of the screen where the button was clicked.
|
||||||
|
* Provides context for understanding interaction patterns.
|
||||||
|
*
|
||||||
|
* @see Types.BUTTON_CLICK for the generated event type
|
||||||
|
* @see ParamKeys.BUTTON_NAME for the button name parameter
|
||||||
|
* @see ParamKeys.SCREEN_NAME for the screen name parameter
|
||||||
|
*
|
||||||
|
* @sample
|
||||||
|
* ```kotlin
|
||||||
|
* // Simple button click
|
||||||
|
* analytics.logButtonClick("save")
|
||||||
|
*
|
||||||
|
* // Button click with screen context
|
||||||
|
* analytics.logButtonClick("edit_profile", screenName = "UserProfile")
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
fun logButtonClick(buttonName: String, screenName: String? = null) {
|
||||||
|
val params = mutableListOf(Param(ParamKeys.BUTTON_NAME, buttonName))
|
||||||
|
screenName?.let { params.add(Param(ParamKeys.SCREEN_NAME, it)) }
|
||||||
|
logEvent(AnalyticsEvent(Types.BUTTON_CLICK, params))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs an error event for debugging and monitoring.
|
||||||
|
*
|
||||||
|
* This convenience method tracks errors and exceptions that occur in the
|
||||||
|
* application, providing valuable information for debugging, monitoring
|
||||||
|
* app stability, and improving user experience.
|
||||||
|
*
|
||||||
|
* @param errorMessage A descriptive message about the error. Should be clear
|
||||||
|
* and actionable for debugging purposes.
|
||||||
|
* @param errorCode Optional error code or identifier that can help categorize
|
||||||
|
* and track specific types of errors (e.g., "NET_001", "DB_ERROR").
|
||||||
|
* @param screen Optional name of the screen where the error occurred. Helps
|
||||||
|
* identify problematic areas of the app.
|
||||||
|
*
|
||||||
|
* @see Types.ERROR_OCCURRED for the generated event type
|
||||||
|
* @see ParamKeys.ERROR_MESSAGE for the error message parameter
|
||||||
|
* @see ParamKeys.ERROR_CODE for the error code parameter
|
||||||
|
* @see ParamKeys.SCREEN_NAME for the screen name parameter
|
||||||
|
*
|
||||||
|
* @sample
|
||||||
|
* ```kotlin
|
||||||
|
* // Simple error logging
|
||||||
|
* analytics.logError("Network connection failed")
|
||||||
|
*
|
||||||
|
* // Error with code and context
|
||||||
|
* analytics.logError(
|
||||||
|
* errorMessage = "API request timeout",
|
||||||
|
* errorCode = "NET_001",
|
||||||
|
* screen = "UserProfile"
|
||||||
|
* )
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
fun logError(errorMessage: String, errorCode: String? = null, screen: String? = null) {
|
||||||
|
val params = mutableListOf(Param(ParamKeys.ERROR_MESSAGE, errorMessage))
|
||||||
|
errorCode?.let { params.add(Param(ParamKeys.ERROR_CODE, it)) }
|
||||||
|
screen?.let { params.add(Param(ParamKeys.SCREEN_NAME, it)) }
|
||||||
|
logEvent(AnalyticsEvent(Types.ERROR_OCCURRED, params))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs a feature usage event for feature adoption tracking.
|
||||||
|
*
|
||||||
|
* This convenience method tracks when users interact with specific features
|
||||||
|
* or functionality, helping understand feature adoption, usage patterns,
|
||||||
|
* and user engagement with different parts of the application.
|
||||||
|
*
|
||||||
|
* @param featureName The identifier of the feature being used. Should be
|
||||||
|
* descriptive and consistent (e.g., "dark_mode", "export_data",
|
||||||
|
* "voice_input").
|
||||||
|
* @param screen Optional name of the screen where the feature was used.
|
||||||
|
* Provides context for understanding feature usage patterns.
|
||||||
|
*
|
||||||
|
* @see Types.FEATURE_USED for the generated event type
|
||||||
|
* @see ParamKeys.FEATURE_NAME for the feature name parameter
|
||||||
|
* @see ParamKeys.SCREEN_NAME for the screen name parameter
|
||||||
|
*
|
||||||
|
* @sample
|
||||||
|
* ```kotlin
|
||||||
|
* // Simple feature usage
|
||||||
|
* analytics.logFeatureUsed("dark_mode")
|
||||||
|
*
|
||||||
|
* // Feature usage with screen context
|
||||||
|
* analytics.logFeatureUsed("export_data", screen = "Settings")
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
fun logFeatureUsed(featureName: String, screen: String? = null) {
|
||||||
|
val params = mutableListOf(Param(ParamKeys.FEATURE_NAME, featureName))
|
||||||
|
screen?.let { params.add(Param(ParamKeys.SCREEN_NAME, it)) }
|
||||||
|
logEvent(AnalyticsEvent(Types.FEATURE_USED, params))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets a user property for analytics user profiling and segmentation.
|
||||||
|
*
|
||||||
|
* User properties allow you to describe segments of your user base, such as
|
||||||
|
* language preference, geographic location, or user type. These properties
|
||||||
|
* are attached to all subsequent events and can be used for analytics
|
||||||
|
* filtering and audience creation.
|
||||||
|
*
|
||||||
|
* **Note:** This is a default implementation that does nothing. Platform-specific
|
||||||
|
* implementations may override this to provide actual functionality.
|
||||||
|
*
|
||||||
|
* @param name The property name identifier. Must be non-blank and ≤ 24 characters
|
||||||
|
* (Firebase Analytics constraint). Should use consistent naming
|
||||||
|
* conventions across the app.
|
||||||
|
* @param value The property value. Must be ≤ 36 characters (Firebase Analytics
|
||||||
|
* constraint). Should be descriptive and useful for segmentation.
|
||||||
|
*
|
||||||
|
* @see setUserId for setting user identification
|
||||||
|
*
|
||||||
|
* @sample
|
||||||
|
* ```kotlin
|
||||||
|
* // Set user characteristics
|
||||||
|
* analytics.setUserProperty("user_type", "premium")
|
||||||
|
* analytics.setUserProperty("preferred_language", "en")
|
||||||
|
* analytics.setUserProperty("app_theme", "dark")
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
fun setUserProperty(name: String, value: String) {
|
||||||
|
// Default implementation does nothing - can be overridden by implementations that support it
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the user ID for analytics user tracking and identification.
|
||||||
|
*
|
||||||
|
* The user ID is a unique identifier for a user that persists across sessions
|
||||||
|
* and devices. It enables you to connect user behavior across multiple sessions
|
||||||
|
* and understand the user journey more comprehensively.
|
||||||
|
*
|
||||||
|
* **Note:** This is a default implementation that does nothing. Platform-specific
|
||||||
|
* implementations may override this to provide actual functionality.
|
||||||
|
*
|
||||||
|
* **Privacy Considerations:**
|
||||||
|
* - Ensure the user ID does not contain personally identifiable information (PII)
|
||||||
|
* - Consider using hashed or obfuscated identifiers
|
||||||
|
* - Follow privacy regulations and platform guidelines
|
||||||
|
*
|
||||||
|
* @param userId The unique identifier for the user. Must be non-blank and
|
||||||
|
* ≤ 256 characters (Firebase Analytics constraint). Should be
|
||||||
|
* consistent across sessions and not contain PII.
|
||||||
|
*
|
||||||
|
* @see setUserProperty for setting user characteristics
|
||||||
|
*
|
||||||
|
* @sample
|
||||||
|
* ```kotlin
|
||||||
|
* // Set user ID (use hashed or obfuscated IDs for privacy)
|
||||||
|
* analytics.setUserId("user_${hashedUserId}")
|
||||||
|
*
|
||||||
|
* // Clear user ID on logout
|
||||||
|
* analytics.setUserId("")
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
fun setUserId(userId: String) {
|
||||||
|
// Default implementation does nothing - can be overridden by implementations that support it
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,52 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2023 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package template.core.base.analytics
|
||||||
|
|
||||||
|
/**
|
||||||
|
* No-operation implementation of [AnalyticsHelper] that discards all analytics events.
|
||||||
|
*
|
||||||
|
* This implementation provides a complete no-op analytics solution that can be used
|
||||||
|
* in scenarios where analytics tracking should be disabled or is not desired. It's
|
||||||
|
* particularly useful for:
|
||||||
|
*
|
||||||
|
* - **Testing**: Unit tests where analytics calls should not interfere
|
||||||
|
* - **Previews**: Jetpack Compose previews that need an analytics implementation
|
||||||
|
* - **Debug Builds**: Development builds where analytics tracking is disabled
|
||||||
|
* - **Privacy Mode**: Special app modes where analytics is intentionally disabled
|
||||||
|
* - **Fallback**: Default implementation when no specific analytics provider is configured
|
||||||
|
*
|
||||||
|
* All methods in this implementation are safe to call and will not throw exceptions,
|
||||||
|
* making it a reliable fallback option.
|
||||||
|
*
|
||||||
|
* @see AnalyticsHelper for the complete interface contract
|
||||||
|
* @see StubAnalyticsHelper for a development implementation that logs events
|
||||||
|
* @see TestAnalyticsHelper for a testing implementation that captures events
|
||||||
|
*
|
||||||
|
* @sample
|
||||||
|
* ```kotlin
|
||||||
|
* // Use in tests
|
||||||
|
* val analytics: AnalyticsHelper = NoOpAnalyticsHelper()
|
||||||
|
*
|
||||||
|
* // Use as default in CompositionLocal
|
||||||
|
* val LocalAnalyticsHelper = staticCompositionLocalOf<AnalyticsHelper> {
|
||||||
|
* NoOpAnalyticsHelper()
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
class NoOpAnalyticsHelper : AnalyticsHelper {
|
||||||
|
/**
|
||||||
|
* Discards the analytics event without any processing.
|
||||||
|
*
|
||||||
|
* @param event The analytics event to discard (ignored)
|
||||||
|
*/
|
||||||
|
override fun logEvent(event: AnalyticsEvent) = Unit
|
||||||
|
}
|
||||||
@ -0,0 +1,283 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package template.core.base.analytics
|
||||||
|
|
||||||
|
import kotlin.time.Clock
|
||||||
|
|
||||||
|
/** Performance tracking utilities for analytics */
|
||||||
|
|
||||||
|
/** Performance tracker that automatically logs performance metrics */
|
||||||
|
class PerformanceTracker(
|
||||||
|
private val analytics: AnalyticsHelper,
|
||||||
|
private val enableAutomaticLogging: Boolean = true,
|
||||||
|
private val slowThresholdMs: Long = 1000L,
|
||||||
|
private val verySlowThresholdMs: Long = 5000L,
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val activeTimers = mutableMapOf<String, Long>()
|
||||||
|
private val performanceMetrics = mutableMapOf<String, MutableList<Long>>()
|
||||||
|
|
||||||
|
/** Start timing an operation */
|
||||||
|
fun startTimer(operationName: String, context: Map<String, String> = emptyMap()): String {
|
||||||
|
val timerId = "${operationName}_$currentTime"
|
||||||
|
activeTimers[timerId] = currentTime
|
||||||
|
|
||||||
|
if (enableAutomaticLogging) {
|
||||||
|
analytics.logEvent(
|
||||||
|
"performance_timer_started",
|
||||||
|
mapOf("operation" to operationName, "timer_id" to timerId) + context,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return timerId
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop timing an operation and log the result */
|
||||||
|
fun stopTimer(
|
||||||
|
timerId: String,
|
||||||
|
success: Boolean = true,
|
||||||
|
additionalContext: Map<String, String> = emptyMap(),
|
||||||
|
): Long? {
|
||||||
|
val startTime = activeTimers.remove(timerId) ?: return null
|
||||||
|
val duration = currentTime - startTime
|
||||||
|
|
||||||
|
// Extract operation name from timer ID
|
||||||
|
val operationName = timerId.substringBeforeLast("_")
|
||||||
|
|
||||||
|
// Store metric for analysis
|
||||||
|
performanceMetrics.getOrPut(operationName) { mutableListOf() }.add(duration)
|
||||||
|
|
||||||
|
if (enableAutomaticLogging) {
|
||||||
|
val performanceLevel = when {
|
||||||
|
duration > verySlowThresholdMs -> "very_slow"
|
||||||
|
duration > slowThresholdMs -> "slow"
|
||||||
|
else -> "normal"
|
||||||
|
}
|
||||||
|
|
||||||
|
analytics.logEvent(
|
||||||
|
"performance_timer_stopped",
|
||||||
|
mapOf(
|
||||||
|
"operation" to operationName,
|
||||||
|
"timer_id" to timerId,
|
||||||
|
ParamKeys.DURATION to "${duration}ms",
|
||||||
|
ParamKeys.SUCCESS to success.toString(),
|
||||||
|
"performance_level" to performanceLevel,
|
||||||
|
) + additionalContext,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return duration
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Time a suspend function execution */
|
||||||
|
suspend inline fun <T> timeOperation(
|
||||||
|
operationName: String,
|
||||||
|
context: Map<String, String> = emptyMap(),
|
||||||
|
crossinline operation: suspend () -> T,
|
||||||
|
): T {
|
||||||
|
val timerId = startTimer(operationName, context)
|
||||||
|
return try {
|
||||||
|
val result = operation()
|
||||||
|
stopTimer(timerId, success = true)
|
||||||
|
result
|
||||||
|
} catch (e: Exception) {
|
||||||
|
stopTimer(
|
||||||
|
timerId,
|
||||||
|
success = false,
|
||||||
|
mapOf(ParamKeys.ERROR_MESSAGE to (e.message ?: "Unknown error")),
|
||||||
|
)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Time a regular function execution */
|
||||||
|
inline fun <T> timeOperationSync(
|
||||||
|
operationName: String,
|
||||||
|
context: Map<String, String> = emptyMap(),
|
||||||
|
operation: () -> T,
|
||||||
|
): T {
|
||||||
|
val timerId = startTimer(operationName, context)
|
||||||
|
return try {
|
||||||
|
val result = operation()
|
||||||
|
stopTimer(timerId, success = true)
|
||||||
|
result
|
||||||
|
} catch (e: Exception) {
|
||||||
|
stopTimer(
|
||||||
|
timerId,
|
||||||
|
success = false,
|
||||||
|
mapOf(ParamKeys.ERROR_MESSAGE to (e.message ?: "Unknown error")),
|
||||||
|
)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get performance statistics for an operation */
|
||||||
|
@Suppress("ReturnCount")
|
||||||
|
fun getPerformanceStats(operationName: String): PerformanceStats? {
|
||||||
|
val durations = performanceMetrics[operationName] ?: return null
|
||||||
|
if (durations.isEmpty()) return null
|
||||||
|
|
||||||
|
val sorted = durations.sorted()
|
||||||
|
return PerformanceStats(
|
||||||
|
operationName = operationName,
|
||||||
|
count = durations.size,
|
||||||
|
averageMs = durations.average(),
|
||||||
|
medianMs = sorted[sorted.size / 2].toDouble(),
|
||||||
|
p95Ms = sorted[(sorted.size * 0.95).toInt().coerceAtMost(sorted.size - 1)].toDouble(),
|
||||||
|
p99Ms = sorted[(sorted.size * 0.99).toInt().coerceAtMost(sorted.size - 1)].toDouble(),
|
||||||
|
minMs = sorted.first().toDouble(),
|
||||||
|
maxMs = sorted.last().toDouble(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Log performance summary for an operation */
|
||||||
|
fun logPerformanceSummary(operationName: String) {
|
||||||
|
val stats = getPerformanceStats(operationName) ?: return
|
||||||
|
|
||||||
|
analytics.logEvent(
|
||||||
|
"performance_summary",
|
||||||
|
mapOf(
|
||||||
|
"operation" to operationName,
|
||||||
|
"count" to stats.count.toString(),
|
||||||
|
"average_ms" to stats.averageMs.toInt().toString(),
|
||||||
|
"median_ms" to stats.medianMs.toInt().toString(),
|
||||||
|
"p95_ms" to stats.p95Ms.toInt().toString(),
|
||||||
|
"p99_ms" to stats.p99Ms.toInt().toString(),
|
||||||
|
"min_ms" to stats.minMs.toInt().toString(),
|
||||||
|
"max_ms" to stats.maxMs.toInt().toString(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear performance metrics */
|
||||||
|
fun clearMetrics() {
|
||||||
|
performanceMetrics.clear()
|
||||||
|
activeTimers.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get all active timers */
|
||||||
|
fun getActiveTimers(): Map<String, Long> = activeTimers.toMap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Performance statistics for an operation */
|
||||||
|
data class PerformanceStats(
|
||||||
|
val operationName: String,
|
||||||
|
val count: Int,
|
||||||
|
val averageMs: Double,
|
||||||
|
val medianMs: Double,
|
||||||
|
val p95Ms: Double,
|
||||||
|
val p99Ms: Double,
|
||||||
|
val minMs: Double,
|
||||||
|
val maxMs: Double,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** App lifecycle performance tracker */
|
||||||
|
class AppLifecycleTracker(private val analytics: AnalyticsHelper) {
|
||||||
|
|
||||||
|
private var appStartTime: Long? = null
|
||||||
|
private var lastForegroundTime: Long? = null
|
||||||
|
private var backgroundTime: Long? = null
|
||||||
|
|
||||||
|
/** Mark app launch start */
|
||||||
|
fun markAppLaunchStart() {
|
||||||
|
appStartTime = currentTime
|
||||||
|
analytics.logEvent(
|
||||||
|
Types.APP_LAUNCH,
|
||||||
|
mapOf("launch_start_time" to appStartTime.toString()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mark app launch complete */
|
||||||
|
fun markAppLaunchComplete() {
|
||||||
|
val startTime = appStartTime ?: return
|
||||||
|
val launchDuration = currentTime - startTime
|
||||||
|
|
||||||
|
analytics.logEvent(
|
||||||
|
"app_launch_completed",
|
||||||
|
mapOf(
|
||||||
|
"launch_duration_ms" to launchDuration.toString(),
|
||||||
|
"launch_performance" to when {
|
||||||
|
launchDuration < 1000 -> "fast"
|
||||||
|
launchDuration < 3000 -> "normal"
|
||||||
|
else -> "slow"
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mark app going to background */
|
||||||
|
fun markAppBackground() {
|
||||||
|
backgroundTime = currentTime
|
||||||
|
val foregroundTime = lastForegroundTime
|
||||||
|
|
||||||
|
analytics.logEvent(
|
||||||
|
Types.APP_BACKGROUND,
|
||||||
|
if (foregroundTime != null) {
|
||||||
|
mapOf("foreground_duration_ms" to (backgroundTime!! - foregroundTime).toString())
|
||||||
|
} else {
|
||||||
|
emptyMap()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mark app coming to foreground */
|
||||||
|
fun markAppForeground() {
|
||||||
|
val currentTime = currentTime
|
||||||
|
lastForegroundTime = currentTime
|
||||||
|
val bgTime = backgroundTime
|
||||||
|
|
||||||
|
analytics.logEvent(
|
||||||
|
Types.APP_FOREGROUND,
|
||||||
|
if (bgTime != null) {
|
||||||
|
mapOf("background_duration_ms" to (currentTime - bgTime).toString())
|
||||||
|
} else {
|
||||||
|
emptyMap()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extension functions for AnalyticsHelper to add performance tracking */
|
||||||
|
|
||||||
|
/** Create a performance tracker */
|
||||||
|
fun AnalyticsHelper.performanceTracker(
|
||||||
|
enableAutomaticLogging: Boolean = true,
|
||||||
|
slowThresholdMs: Long = 1000L,
|
||||||
|
verySlowThresholdMs: Long = 5000L,
|
||||||
|
): PerformanceTracker =
|
||||||
|
PerformanceTracker(
|
||||||
|
analytics = this,
|
||||||
|
enableAutomaticLogging = enableAutomaticLogging,
|
||||||
|
slowThresholdMs = slowThresholdMs,
|
||||||
|
verySlowThresholdMs = verySlowThresholdMs,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Create an app lifecycle tracker */
|
||||||
|
fun AnalyticsHelper.lifecycleTracker(): AppLifecycleTracker = AppLifecycleTracker(this)
|
||||||
|
|
||||||
|
private val currentTime = Clock.System.now().toEpochMilliseconds()
|
||||||
|
|
||||||
|
/** Quick performance timing for suspend functions */
|
||||||
|
suspend inline fun <T> AnalyticsHelper.timePerformance(
|
||||||
|
operationName: String,
|
||||||
|
context: Map<String, String> = emptyMap(),
|
||||||
|
crossinline operation: suspend () -> T,
|
||||||
|
): T {
|
||||||
|
return performanceTracker().timeOperation(operationName, context, operation)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Quick performance timing for regular functions */
|
||||||
|
inline fun <T> AnalyticsHelper.timePerformanceSync(
|
||||||
|
operationName: String,
|
||||||
|
context: Map<String, String> = emptyMap(),
|
||||||
|
operation: () -> T,
|
||||||
|
): T {
|
||||||
|
return performanceTracker().timeOperationSync(operationName, context, operation)
|
||||||
|
}
|
||||||
@ -0,0 +1,62 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2023 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package template.core.base.analytics
|
||||||
|
|
||||||
|
import co.touchlab.kermit.Logger
|
||||||
|
|
||||||
|
private const val TAG = "StubAnalyticsHelper"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Development implementation of [AnalyticsHelper] that logs events to console output.
|
||||||
|
*
|
||||||
|
* This implementation provides a lightweight analytics solution for development and
|
||||||
|
* debugging purposes. Instead of sending events to a remote analytics service, it
|
||||||
|
* logs all events to the console using Kermit logging, making it easy to verify
|
||||||
|
* that analytics events are being generated correctly during development.
|
||||||
|
*
|
||||||
|
* **Use Cases:**
|
||||||
|
* - **Development Builds**: Debug app builds where you want to see analytics events
|
||||||
|
* - **Local Testing**: Manual testing where you want to verify event generation
|
||||||
|
* - **Debugging**: Troubleshooting analytics implementation issues
|
||||||
|
* - **Offline Development**: Working without network connectivity to analytics services
|
||||||
|
*
|
||||||
|
* **Output Format:**
|
||||||
|
* Events are logged with the full event structure including type and parameters,
|
||||||
|
* making it easy to verify the correct data is being tracked.
|
||||||
|
*
|
||||||
|
* @see AnalyticsHelper for the complete interface contract
|
||||||
|
* @see NoOpAnalyticsHelper for a no-operation implementation
|
||||||
|
* @see FirebaseAnalyticsHelper for the production implementation
|
||||||
|
* @see TestAnalyticsHelper for a testing implementation with capture capabilities
|
||||||
|
*
|
||||||
|
* @sample
|
||||||
|
* ```kotlin
|
||||||
|
* // Typically used in platform-specific DI modules for debug builds
|
||||||
|
* val analyticsModule = module {
|
||||||
|
* single<AnalyticsHelper> { StubAnalyticsHelper() }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
internal class StubAnalyticsHelper : AnalyticsHelper {
|
||||||
|
/**
|
||||||
|
* Logs the analytics event to console output using Kermit logger.
|
||||||
|
*
|
||||||
|
* The event is logged at ERROR level to ensure visibility in most logging
|
||||||
|
* configurations. The log includes the complete event structure with type
|
||||||
|
* and all parameters.
|
||||||
|
*
|
||||||
|
* @param event The analytics event to log to console
|
||||||
|
*/
|
||||||
|
override fun logEvent(event: AnalyticsEvent) {
|
||||||
|
Logger.e(TAG, null, "Received analytics event: $event")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,234 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package template.core.base.analytics
|
||||||
|
|
||||||
|
/** Testing utilities for analytics functionality */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test implementation of AnalyticsHelper that captures events for
|
||||||
|
* verification
|
||||||
|
*/
|
||||||
|
class TestAnalyticsHelper : AnalyticsHelper {
|
||||||
|
private val _loggedEvents = mutableListOf<AnalyticsEvent>()
|
||||||
|
private val _userProperties = mutableMapOf<String, String>()
|
||||||
|
private var _userId: String? = null
|
||||||
|
|
||||||
|
/** Get all logged events */
|
||||||
|
val loggedEvents: List<AnalyticsEvent> get() = _loggedEvents.toList()
|
||||||
|
|
||||||
|
/** Get all set user properties */
|
||||||
|
val userProperties: Map<String, String> get() = _userProperties.toMap()
|
||||||
|
|
||||||
|
/** Get the current user ID */
|
||||||
|
val userId: String? get() = _userId
|
||||||
|
|
||||||
|
override fun logEvent(event: AnalyticsEvent) {
|
||||||
|
_loggedEvents.add(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setUserProperty(name: String, value: String) {
|
||||||
|
_userProperties[name] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setUserId(userId: String) {
|
||||||
|
_userId = userId
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear all captured data */
|
||||||
|
fun clear() {
|
||||||
|
_loggedEvents.clear()
|
||||||
|
_userProperties.clear()
|
||||||
|
_userId = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get events by type */
|
||||||
|
fun getEventsByType(type: String): List<AnalyticsEvent> {
|
||||||
|
return _loggedEvents.filter { it.type == type }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the last logged event */
|
||||||
|
fun getLastEvent(): AnalyticsEvent? = _loggedEvents.lastOrNull()
|
||||||
|
|
||||||
|
/** Get events containing a specific parameter */
|
||||||
|
fun getEventsWithParam(key: String, value: String? = null): List<AnalyticsEvent> {
|
||||||
|
return _loggedEvents.filter { event ->
|
||||||
|
event.extras.any { param ->
|
||||||
|
param.key == key && (value == null || param.value == value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verify that an event was logged */
|
||||||
|
fun hasEvent(type: String, params: Map<String, String> = emptyMap()): Boolean {
|
||||||
|
return _loggedEvents.any { event ->
|
||||||
|
event.type == type && params.all { (key, value) ->
|
||||||
|
event.extras.any { it.key == key && it.value == value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get count of events by type */
|
||||||
|
fun getEventCount(type: String): Int {
|
||||||
|
return _loggedEvents.count { it.type == type }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get all unique event types logged */
|
||||||
|
fun getUniqueEventTypes(): Set<String> {
|
||||||
|
return _loggedEvents.map { it.type }.toSet()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verify screen view was logged */
|
||||||
|
fun hasScreenView(screenName: String): Boolean {
|
||||||
|
return hasEvent(Types.SCREEN_VIEW, mapOf(ParamKeys.SCREEN_NAME to screenName))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verify button click was logged */
|
||||||
|
fun hasButtonClick(buttonName: String): Boolean {
|
||||||
|
return hasEvent(Types.BUTTON_CLICK, mapOf(ParamKeys.BUTTON_NAME to buttonName))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verify error was logged */
|
||||||
|
fun hasError(errorMessage: String): Boolean {
|
||||||
|
return hasEvent(Types.ERROR_OCCURRED, mapOf(ParamKeys.ERROR_MESSAGE to errorMessage))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get all parameters for a specific event type */
|
||||||
|
fun getParametersForEventType(type: String): List<Map<String, String>> {
|
||||||
|
return _loggedEvents.filter { it.type == type }
|
||||||
|
.map { event -> event.extras.associate { it.key to it.value } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Print all logged events (useful for debugging) */
|
||||||
|
fun printEvents() {
|
||||||
|
if (_loggedEvents.isEmpty()) {
|
||||||
|
println("No analytics events logged")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
println("Analytics Events Logged:")
|
||||||
|
_loggedEvents.forEachIndexed { index, event ->
|
||||||
|
println("${index + 1}. ${event.type}")
|
||||||
|
event.extras.forEach { param ->
|
||||||
|
println(" ${param.key}: ${param.value}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_userProperties.isNotEmpty()) {
|
||||||
|
println("\nUser Properties:")
|
||||||
|
_userProperties.forEach { (key, value) ->
|
||||||
|
println(" $key: $value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_userId?.let {
|
||||||
|
println("\nUser ID: $it")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a test analytics helper for testing */
|
||||||
|
fun createTestAnalyticsHelper(): TestAnalyticsHelper = TestAnalyticsHelper()
|
||||||
|
|
||||||
|
/** Extension for asserting events in tests */
|
||||||
|
fun TestAnalyticsHelper.assertEventLogged(
|
||||||
|
type: String,
|
||||||
|
params: Map<String, String> = emptyMap(),
|
||||||
|
message: String? = null,
|
||||||
|
) {
|
||||||
|
val found = hasEvent(type, params)
|
||||||
|
if (!found) {
|
||||||
|
val errorMessage = message ?: "Expected event '$type' with params $params was not logged"
|
||||||
|
val actualEvents = getEventsByType(type)
|
||||||
|
if (actualEvents.isEmpty()) {
|
||||||
|
throw AssertionError("$errorMessage. No events of type '$type' were logged.")
|
||||||
|
} else {
|
||||||
|
throw AssertionError(
|
||||||
|
"$errorMessage. Events of type '$type' found: ${
|
||||||
|
actualEvents.map {
|
||||||
|
it.extras.associate { p -> p.key to p.value }
|
||||||
|
}
|
||||||
|
}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extension for asserting event count */
|
||||||
|
fun TestAnalyticsHelper.assertEventCount(
|
||||||
|
type: String,
|
||||||
|
expectedCount: Int,
|
||||||
|
message: String? = null,
|
||||||
|
) {
|
||||||
|
val actualCount = getEventCount(type)
|
||||||
|
if (actualCount != expectedCount) {
|
||||||
|
val errorMessage =
|
||||||
|
message ?: "Expected $expectedCount events of type '$type', but found $actualCount"
|
||||||
|
throw AssertionError(errorMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extension for asserting user property was set */
|
||||||
|
fun TestAnalyticsHelper.assertUserProperty(
|
||||||
|
name: String,
|
||||||
|
expectedValue: String,
|
||||||
|
message: String? = null,
|
||||||
|
) {
|
||||||
|
val actualValue = userProperties[name]
|
||||||
|
if (actualValue != expectedValue) {
|
||||||
|
val errorMessage = message
|
||||||
|
?: "Expected user property '$name' to be '$expectedValue', but was '$actualValue'"
|
||||||
|
throw AssertionError(errorMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mock analytics helper that simulates network delays and failures */
|
||||||
|
class MockAnalyticsHelper(
|
||||||
|
private val simulateFailures: Boolean = false,
|
||||||
|
private val failureRate: Float = 0.1f,
|
||||||
|
) : AnalyticsHelper {
|
||||||
|
|
||||||
|
private val testHelper = TestAnalyticsHelper()
|
||||||
|
private var eventCount = 0
|
||||||
|
|
||||||
|
override fun logEvent(event: AnalyticsEvent) {
|
||||||
|
eventCount++
|
||||||
|
|
||||||
|
if (simulateFailures && (eventCount * failureRate).toInt() > 0 && eventCount % (1 / failureRate).toInt() == 0) {
|
||||||
|
// Simulate failure - don't log the event
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// if (simulateNetworkDelay) {
|
||||||
|
// // Simulate network delay (in a real implementation, this would be async)
|
||||||
|
// delay((50..200).random().toLong())
|
||||||
|
// }
|
||||||
|
|
||||||
|
testHelper.logEvent(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setUserProperty(name: String, value: String) {
|
||||||
|
testHelper.setUserProperty(name, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setUserId(userId: String) {
|
||||||
|
testHelper.setUserId(userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delegate test helper methods
|
||||||
|
val loggedEvents: List<AnalyticsEvent> get() = testHelper.loggedEvents
|
||||||
|
val userProperties: Map<String, String> get() = testHelper.userProperties
|
||||||
|
val userId: String? get() = testHelper.userId
|
||||||
|
|
||||||
|
fun clear() = testHelper.clear()
|
||||||
|
fun hasEvent(type: String, params: Map<String, String> = emptyMap()) =
|
||||||
|
testHelper.hasEvent(type, params)
|
||||||
|
|
||||||
|
fun getEventCount(type: String) = testHelper.getEventCount(type)
|
||||||
|
}
|
||||||
@ -0,0 +1,106 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2023 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package template.core.base.analytics
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global key used to obtain access to the AnalyticsHelper through a CompositionLocal.
|
||||||
|
*/
|
||||||
|
val LocalAnalyticsHelper = staticCompositionLocalOf<AnalyticsHelper> {
|
||||||
|
// Provide a default AnalyticsHelper which does nothing. This is so that tests and previews
|
||||||
|
// do not have to provide one. For real app builds provide a different implementation.
|
||||||
|
NoOpAnalyticsHelper()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable function to track screen views automatically
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
@Suppress("SpreadOperator")
|
||||||
|
fun TrackScreenView(
|
||||||
|
screenName: String,
|
||||||
|
sourceScreen: String? = null,
|
||||||
|
additionalParams: Map<String, String> = emptyMap(),
|
||||||
|
) {
|
||||||
|
val analytics = LocalAnalyticsHelper.current
|
||||||
|
|
||||||
|
LaunchedEffect(screenName) {
|
||||||
|
analytics.logScreenView(screenName, sourceScreen)
|
||||||
|
// Log additional params if provided
|
||||||
|
if (additionalParams.isNotEmpty()) {
|
||||||
|
analytics.logEvent(
|
||||||
|
Types.SCREEN_VIEW,
|
||||||
|
mapOf(
|
||||||
|
ParamKeys.SCREEN_NAME to screenName,
|
||||||
|
*additionalParams.toList().toTypedArray(),
|
||||||
|
).plus(sourceScreen?.let { mapOf(ParamKeys.SOURCE_SCREEN to it) } ?: emptyMap()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modifier extension for tracking button clicks
|
||||||
|
*/
|
||||||
|
fun Modifier.trackClick(
|
||||||
|
buttonName: String,
|
||||||
|
analytics: AnalyticsHelper,
|
||||||
|
screenName: String? = null,
|
||||||
|
additionalParams: Map<String, String> = emptyMap(),
|
||||||
|
): Modifier = this.clickable {
|
||||||
|
analytics.logButtonClick(buttonName, screenName)
|
||||||
|
if (additionalParams.isNotEmpty()) {
|
||||||
|
analytics.logEvent(Types.BUTTON_CLICK, additionalParams)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remember analytics helper from composition local
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun rememberAnalyticsHelper(): AnalyticsHelper = LocalAnalyticsHelper.current
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Effect for tracking when a composable enters/exits composition
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun TrackComposableLifecycle(
|
||||||
|
name: String,
|
||||||
|
trackEntry: Boolean = true,
|
||||||
|
trackExit: Boolean = false,
|
||||||
|
) {
|
||||||
|
val analytics = LocalAnalyticsHelper.current
|
||||||
|
|
||||||
|
if (trackEntry) {
|
||||||
|
LaunchedEffect(name) {
|
||||||
|
analytics.logEvent(
|
||||||
|
"composable_entered",
|
||||||
|
ParamKeys.CONTENT_NAME to name,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trackExit) {
|
||||||
|
DisposableEffect(name) {
|
||||||
|
onDispose {
|
||||||
|
analytics.logEvent(
|
||||||
|
"composable_exited",
|
||||||
|
ParamKeys.CONTENT_NAME to name,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,403 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package template.core.base.analytics
|
||||||
|
|
||||||
|
/** Data validation utilities for analytics events and parameters */
|
||||||
|
|
||||||
|
/** Analytics data validator */
|
||||||
|
class AnalyticsValidator {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
// Analytics platform constraints
|
||||||
|
const val MAX_EVENT_NAME_LENGTH = 40
|
||||||
|
const val MAX_PARAM_KEY_LENGTH = 40
|
||||||
|
const val MAX_PARAM_VALUE_LENGTH = 100
|
||||||
|
const val MAX_USER_PROPERTY_NAME_LENGTH = 24
|
||||||
|
const val MAX_USER_PROPERTY_VALUE_LENGTH = 36
|
||||||
|
const val MAX_USER_ID_LENGTH = 256
|
||||||
|
const val MAX_PARAMS_PER_EVENT = 25
|
||||||
|
|
||||||
|
// Validation patterns
|
||||||
|
private val VALID_EVENT_NAME_PATTERN = Regex("^[a-zA-Z][a-zA-Z0-9_]*$")
|
||||||
|
private val VALID_PARAM_KEY_PATTERN = Regex("^[a-zA-Z][a-zA-Z0-9_]*$")
|
||||||
|
private val RESERVED_PREFIXES = setOf("firebase_", "google_", "ga_")
|
||||||
|
private val RESERVED_EVENT_NAMES = setOf(
|
||||||
|
"ad_activeview", "ad_click", "ad_exposure", "ad_impression", "ad_query",
|
||||||
|
"adunit_exposure", "app_clear_data", "app_exception", "app_remove", "app_update",
|
||||||
|
"error", "first_open", "first_visit", "in_app_purchase", "notification_dismiss",
|
||||||
|
"notification_foreground", "notification_open", "notification_receive",
|
||||||
|
"os_update", "screen_view", "session_start", "user_engagement",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validation result */
|
||||||
|
sealed class ValidationResult {
|
||||||
|
object Valid : ValidationResult()
|
||||||
|
data class Invalid(val errors: List<String>) : ValidationResult()
|
||||||
|
|
||||||
|
val isValid: Boolean get() = this is Valid
|
||||||
|
val errorMessages: List<String> get() = if (this is Invalid) errors else emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validate an analytics event */
|
||||||
|
fun validateEvent(event: AnalyticsEvent): ValidationResult {
|
||||||
|
val errors = mutableListOf<String>()
|
||||||
|
|
||||||
|
// Validate event type
|
||||||
|
errors.addAll(validateEventName(event.type))
|
||||||
|
|
||||||
|
// Validate parameter count
|
||||||
|
if (event.extras.size > MAX_PARAMS_PER_EVENT) {
|
||||||
|
errors.add("Event has ${event.extras.size} parameters, maximum allowed is $MAX_PARAMS_PER_EVENT")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate each parameter
|
||||||
|
event.extras.forEach { param ->
|
||||||
|
errors.addAll(validateParameter(param))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for duplicate parameter keys
|
||||||
|
val duplicateKeys = event.extras.groupBy { it.key }
|
||||||
|
.filter { it.value.size > 1 }
|
||||||
|
.keys
|
||||||
|
if (duplicateKeys.isNotEmpty()) {
|
||||||
|
errors.add("Event has duplicate parameter keys: ${duplicateKeys.joinToString(", ")}")
|
||||||
|
}
|
||||||
|
|
||||||
|
return if (errors.isEmpty()) ValidationResult.Valid else ValidationResult.Invalid(errors)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validate event name */
|
||||||
|
fun validateEventName(eventName: String): List<String> {
|
||||||
|
val errors = mutableListOf<String>()
|
||||||
|
|
||||||
|
if (eventName.isBlank()) {
|
||||||
|
errors.add("Event name cannot be blank")
|
||||||
|
return errors
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventName.length > MAX_EVENT_NAME_LENGTH) {
|
||||||
|
errors.add("Event name '$eventName' exceeds maximum length of $MAX_EVENT_NAME_LENGTH characters")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!VALID_EVENT_NAME_PATTERN.matches(eventName)) {
|
||||||
|
errors.add(
|
||||||
|
"Event name '$eventName' contains invalid characters." +
|
||||||
|
" Must start with letter and contain only letters, numbers, and underscores",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (RESERVED_PREFIXES.any { eventName.startsWith(it) }) {
|
||||||
|
errors.add("Event name '$eventName' uses reserved prefix")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (RESERVED_EVENT_NAMES.contains(eventName)) {
|
||||||
|
errors.add("Event name '$eventName' is reserved")
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validate parameter */
|
||||||
|
fun validateParameter(param: Param): List<String> {
|
||||||
|
val errors = mutableListOf<String>()
|
||||||
|
|
||||||
|
// Validate key
|
||||||
|
if (param.key.isBlank()) {
|
||||||
|
errors.add("Parameter key cannot be blank")
|
||||||
|
} else {
|
||||||
|
if (param.key.length > MAX_PARAM_KEY_LENGTH) {
|
||||||
|
errors.add(
|
||||||
|
"Parameter key '${param.key}' " +
|
||||||
|
"exceeds maximum length of $MAX_PARAM_KEY_LENGTH characters",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!VALID_PARAM_KEY_PATTERN.matches(param.key)) {
|
||||||
|
errors.add(
|
||||||
|
"Parameter key '${param.key}' contains invalid characters. Must start " +
|
||||||
|
"with letter and contain only letters, numbers, and underscores",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (RESERVED_PREFIXES.any { param.key.startsWith(it) }) {
|
||||||
|
errors.add("Parameter key '${param.key}' uses reserved prefix")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate value
|
||||||
|
if (param.value.length > MAX_PARAM_VALUE_LENGTH) {
|
||||||
|
errors.add(
|
||||||
|
"Parameter value for key '${param.key}' exceeds maximum" +
|
||||||
|
" length of $MAX_PARAM_VALUE_LENGTH characters",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validate user property */
|
||||||
|
fun validateUserProperty(name: String, value: String): List<String> {
|
||||||
|
val errors = mutableListOf<String>()
|
||||||
|
|
||||||
|
if (name.isBlank()) {
|
||||||
|
errors.add("User property name cannot be blank")
|
||||||
|
} else {
|
||||||
|
if (name.length > MAX_USER_PROPERTY_NAME_LENGTH) {
|
||||||
|
errors.add(
|
||||||
|
"User property name '$name' exceeds maximum length " +
|
||||||
|
"of $MAX_USER_PROPERTY_NAME_LENGTH characters",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!VALID_PARAM_KEY_PATTERN.matches(name)) {
|
||||||
|
errors.add("User property name '$name' contains invalid characters")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (RESERVED_PREFIXES.any { name.startsWith(it) }) {
|
||||||
|
errors.add("User property name '$name' uses reserved prefix")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.length > MAX_USER_PROPERTY_VALUE_LENGTH) {
|
||||||
|
errors.add(
|
||||||
|
"User property value for '$name' exceeds maximum " +
|
||||||
|
"length of $MAX_USER_PROPERTY_VALUE_LENGTH characters",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validate user ID */
|
||||||
|
fun validateUserId(userId: String): List<String> {
|
||||||
|
val errors = mutableListOf<String>()
|
||||||
|
|
||||||
|
if (userId.isBlank()) {
|
||||||
|
errors.add("User ID cannot be blank")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userId.length > MAX_USER_ID_LENGTH) {
|
||||||
|
errors.add("User ID exceeds maximum length of $MAX_USER_ID_LENGTH characters")
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sanitize event name to make it valid */
|
||||||
|
fun sanitizeEventName(eventName: String): String {
|
||||||
|
if (eventName.isBlank()) return "unknown_event"
|
||||||
|
|
||||||
|
// Remove invalid characters and ensure it starts with letter
|
||||||
|
var sanitized = eventName.replace(Regex("[^a-zA-Z0-9_]"), "_")
|
||||||
|
.take(MAX_EVENT_NAME_LENGTH)
|
||||||
|
|
||||||
|
// Ensure it starts with a letter
|
||||||
|
if (!sanitized.first().isLetter()) {
|
||||||
|
sanitized = "event_$sanitized"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Avoid reserved names
|
||||||
|
if (RESERVED_EVENT_NAMES.contains(sanitized) || RESERVED_PREFIXES.any {
|
||||||
|
sanitized.startsWith(
|
||||||
|
it,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
sanitized = "custom_$sanitized"
|
||||||
|
}
|
||||||
|
|
||||||
|
return sanitized.take(MAX_EVENT_NAME_LENGTH)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sanitize parameter key to make it valid */
|
||||||
|
fun sanitizeParameterKey(key: String): String {
|
||||||
|
if (key.isBlank()) return "unknown_param"
|
||||||
|
|
||||||
|
var sanitized = key.replace(Regex("[^a-zA-Z0-9_]"), "_")
|
||||||
|
.take(MAX_PARAM_KEY_LENGTH)
|
||||||
|
|
||||||
|
if (!sanitized.first().isLetter()) {
|
||||||
|
sanitized = "param_$sanitized"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (RESERVED_PREFIXES.any { sanitized.startsWith(it) }) {
|
||||||
|
sanitized = "custom_$sanitized"
|
||||||
|
}
|
||||||
|
|
||||||
|
return sanitized.take(MAX_PARAM_KEY_LENGTH)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sanitize parameter value to make it valid */
|
||||||
|
fun sanitizeParameterValue(value: String): String {
|
||||||
|
return value.take(MAX_PARAM_VALUE_LENGTH)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a safe parameter with validation and sanitization */
|
||||||
|
fun createSafeParam(key: String, value: String): Param {
|
||||||
|
val sanitizedKey = sanitizeParameterKey(key)
|
||||||
|
val sanitizedValue = sanitizeParameterValue(value)
|
||||||
|
return Param(sanitizedKey, sanitizedValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a safe event with validation and sanitization */
|
||||||
|
fun createSafeEvent(type: String, params: List<Param> = emptyList()): AnalyticsEvent {
|
||||||
|
val sanitizedType = sanitizeEventName(type)
|
||||||
|
val sanitizedParams = params.map { createSafeParam(it.key, it.value) }
|
||||||
|
.take(MAX_PARAMS_PER_EVENT)
|
||||||
|
|
||||||
|
return AnalyticsEvent(sanitizedType, sanitizedParams)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validating analytics helper that wraps another helper and validates
|
||||||
|
* events
|
||||||
|
*/
|
||||||
|
class ValidatingAnalyticsHelper(
|
||||||
|
private val delegate: AnalyticsHelper,
|
||||||
|
private val validator: AnalyticsValidator = AnalyticsValidator(),
|
||||||
|
// If true, throws on validation errors; if false, sanitizes
|
||||||
|
private val strictMode: Boolean = false,
|
||||||
|
private val logValidationErrors: Boolean = true,
|
||||||
|
) : AnalyticsHelper {
|
||||||
|
|
||||||
|
override fun logEvent(event: AnalyticsEvent) {
|
||||||
|
val validationResult = validator.validateEvent(event)
|
||||||
|
|
||||||
|
when {
|
||||||
|
validationResult.isValid -> {
|
||||||
|
delegate.logEvent(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
strictMode -> {
|
||||||
|
throw IllegalArgumentException(
|
||||||
|
"Invalid analytics event: ${
|
||||||
|
validationResult.errorMessages.joinToString(
|
||||||
|
", ",
|
||||||
|
)
|
||||||
|
}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
// Sanitize and log
|
||||||
|
val safeEvent = validator.createSafeEvent(event.type, event.extras)
|
||||||
|
delegate.logEvent(safeEvent)
|
||||||
|
|
||||||
|
if (logValidationErrors) {
|
||||||
|
delegate.logEvent(
|
||||||
|
AnalyticsEvent(
|
||||||
|
"analytics_validation_error",
|
||||||
|
listOf(
|
||||||
|
Param(key = "original_event_type", value = event.type),
|
||||||
|
Param(
|
||||||
|
key = "errors",
|
||||||
|
value = validationResult.errorMessages.joinToString("; "),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setUserProperty(name: String, value: String) {
|
||||||
|
val errors = validator.validateUserProperty(name, value)
|
||||||
|
|
||||||
|
when {
|
||||||
|
errors.isEmpty() -> {
|
||||||
|
delegate.setUserProperty(name, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
strictMode -> {
|
||||||
|
throw IllegalArgumentException(
|
||||||
|
"Invalid user property: ${errors.joinToString(", ")}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
val sanitizedName = validator.sanitizeParameterKey(name)
|
||||||
|
.take(AnalyticsValidator.MAX_USER_PROPERTY_NAME_LENGTH)
|
||||||
|
val sanitizedValue = value.take(AnalyticsValidator.MAX_USER_PROPERTY_VALUE_LENGTH)
|
||||||
|
delegate.setUserProperty(sanitizedName, sanitizedValue)
|
||||||
|
|
||||||
|
if (logValidationErrors && errors.isNotEmpty()) {
|
||||||
|
delegate.logEvent(
|
||||||
|
AnalyticsEvent(
|
||||||
|
"user_property_validation_error",
|
||||||
|
listOf(
|
||||||
|
Param("property_name", name),
|
||||||
|
Param("errors", errors.joinToString("; ")),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setUserId(userId: String) {
|
||||||
|
val errors = validator.validateUserId(userId)
|
||||||
|
|
||||||
|
when {
|
||||||
|
errors.isEmpty() -> {
|
||||||
|
delegate.setUserId(userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
strictMode -> {
|
||||||
|
throw IllegalArgumentException("Invalid user ID: ${errors.joinToString(", ")}")
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
val sanitizedUserId = userId.take(AnalyticsValidator.MAX_USER_ID_LENGTH)
|
||||||
|
delegate.setUserId(sanitizedUserId)
|
||||||
|
|
||||||
|
if (logValidationErrors && errors.isNotEmpty()) {
|
||||||
|
delegate.logEvent(
|
||||||
|
AnalyticsEvent(
|
||||||
|
"user_id_validation_error",
|
||||||
|
listOf(
|
||||||
|
Param("errors", errors.joinToString("; ")),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extension to wrap any analytics helper with validation */
|
||||||
|
fun AnalyticsHelper.withValidation(
|
||||||
|
strictMode: Boolean = false,
|
||||||
|
logValidationErrors: Boolean = true,
|
||||||
|
validator: AnalyticsValidator = AnalyticsValidator(),
|
||||||
|
): AnalyticsHelper = ValidatingAnalyticsHelper(this, validator, strictMode, logValidationErrors)
|
||||||
|
|
||||||
|
/** Extension to validate an event without logging it */
|
||||||
|
fun AnalyticsEvent.validate(
|
||||||
|
validator: AnalyticsValidator = AnalyticsValidator(),
|
||||||
|
): AnalyticsValidator.ValidationResult {
|
||||||
|
return validator.validateEvent(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extension to check if an event is valid */
|
||||||
|
fun AnalyticsEvent.isValid(validator: AnalyticsValidator = AnalyticsValidator()): Boolean {
|
||||||
|
return validator.validateEvent(this).isValid
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extension to sanitize an event */
|
||||||
|
fun AnalyticsEvent.sanitize(
|
||||||
|
validator: AnalyticsValidator = AnalyticsValidator(),
|
||||||
|
): AnalyticsEvent {
|
||||||
|
return validator.createSafeEvent(this.type, this.extras)
|
||||||
|
}
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package template.core.base.analytics.di
|
||||||
|
|
||||||
|
import org.koin.core.module.Module
|
||||||
|
|
||||||
|
expect val analyticsModule: Module
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package template.core.base.analytics.di
|
||||||
|
|
||||||
|
import org.koin.core.module.Module
|
||||||
|
import org.koin.core.module.dsl.singleOf
|
||||||
|
import org.koin.dsl.bind
|
||||||
|
import org.koin.dsl.module
|
||||||
|
import template.core.base.analytics.AnalyticsHelper
|
||||||
|
import template.core.base.analytics.StubAnalyticsHelper
|
||||||
|
|
||||||
|
actual val analyticsModule: Module
|
||||||
|
get() = module {
|
||||||
|
singleOf(::StubAnalyticsHelper) bind AnalyticsHelper::class
|
||||||
|
}
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
@file:Suppress("InvalidPackageDeclaration")
|
||||||
|
|
||||||
|
package template.core.base.analytics.di
|
||||||
|
|
||||||
|
import org.koin.core.module.Module
|
||||||
|
import org.koin.core.module.dsl.singleOf
|
||||||
|
import org.koin.dsl.bind
|
||||||
|
import org.koin.dsl.module
|
||||||
|
import template.core.base.analytics.AnalyticsHelper
|
||||||
|
import template.core.base.analytics.StubAnalyticsHelper
|
||||||
|
|
||||||
|
actual val analyticsModule: Module
|
||||||
|
get() = module {
|
||||||
|
|
||||||
|
// Enable this when Firebase Project is set up
|
||||||
|
// single<FirebaseAnalytics> { Firebase.analytics }
|
||||||
|
// singleOf(::FirebaseAnalyticsHelper) bind AnalyticsHelper::class
|
||||||
|
|
||||||
|
singleOf(::StubAnalyticsHelper) bind AnalyticsHelper::class
|
||||||
|
}
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
@file:Suppress("InvalidPackageDeclaration")
|
||||||
|
|
||||||
|
package template.core.base.analytics.di
|
||||||
|
|
||||||
|
import dev.gitlive.firebase.Firebase
|
||||||
|
import dev.gitlive.firebase.analytics.FirebaseAnalytics
|
||||||
|
import dev.gitlive.firebase.analytics.analytics
|
||||||
|
import org.koin.core.module.Module
|
||||||
|
import org.koin.core.module.dsl.singleOf
|
||||||
|
import org.koin.dsl.bind
|
||||||
|
import org.koin.dsl.module
|
||||||
|
import template.core.base.analytics.AnalyticsHelper
|
||||||
|
import template.core.base.analytics.FirebaseAnalyticsHelper
|
||||||
|
|
||||||
|
actual val analyticsModule: Module
|
||||||
|
get() = module {
|
||||||
|
single<FirebaseAnalytics> { Firebase.analytics }
|
||||||
|
singleOf(::FirebaseAnalyticsHelper) bind AnalyticsHelper::class
|
||||||
|
}
|
||||||
@ -0,0 +1,117 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package template.core.base.analytics
|
||||||
|
|
||||||
|
import dev.gitlive.firebase.analytics.FirebaseAnalytics
|
||||||
|
import dev.gitlive.firebase.analytics.logEvent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Production implementation of [AnalyticsHelper] that sends events to Firebase Analytics.
|
||||||
|
*
|
||||||
|
* This implementation provides a complete analytics solution using Firebase Analytics
|
||||||
|
* as the backend service. It handles automatic parameter validation, length truncation
|
||||||
|
* according to Firebase constraints, and provides full user tracking capabilities.
|
||||||
|
*
|
||||||
|
* **Features:**
|
||||||
|
* - **Automatic Truncation**: Parameters are automatically truncated to Firebase limits
|
||||||
|
* - **User Tracking**: Full support for user properties and user ID tracking
|
||||||
|
* - **Cross-Platform**: Works on Android, iOS, and other supported Firebase platforms
|
||||||
|
* - **Real-time Processing**: Events are sent to Firebase for real-time analytics
|
||||||
|
* - **Integration**: Seamlessly integrates with Firebase Console and other Firebase services
|
||||||
|
*
|
||||||
|
* **Firebase Analytics Constraints:**
|
||||||
|
* - Event names: ≤ 40 characters
|
||||||
|
* - Parameter keys: ≤ 40 characters
|
||||||
|
* - Parameter values: ≤ 100 characters
|
||||||
|
* - User property names: ≤ 24 characters
|
||||||
|
* - User property values: ≤ 36 characters
|
||||||
|
* - Maximum parameters per event: 25
|
||||||
|
*
|
||||||
|
* **Setup Requirements:**
|
||||||
|
* - Firebase project configured with Analytics enabled
|
||||||
|
* - Platform-specific Firebase SDK configuration
|
||||||
|
* - Google Services configuration files (google-services.json, GoogleService-Info.plist)
|
||||||
|
*
|
||||||
|
* @param firebaseAnalytics The Firebase Analytics instance to use for logging events.
|
||||||
|
* This should be properly configured for the target platform.
|
||||||
|
*
|
||||||
|
* @see AnalyticsHelper for the complete interface contract
|
||||||
|
* @see StubAnalyticsHelper for development/debugging implementation
|
||||||
|
* @see NoOpAnalyticsHelper for no-operation implementation
|
||||||
|
*
|
||||||
|
* @sample
|
||||||
|
* ```kotlin
|
||||||
|
* // Typical DI setup for production builds
|
||||||
|
* val analyticsModule = module {
|
||||||
|
* single<AnalyticsHelper> {
|
||||||
|
* FirebaseAnalyticsHelper(Firebase.analytics)
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
internal class FirebaseAnalyticsHelper(
|
||||||
|
private val firebaseAnalytics: FirebaseAnalytics,
|
||||||
|
) : AnalyticsHelper {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs an analytics event to Firebase Analytics with automatic parameter truncation.
|
||||||
|
*
|
||||||
|
* This method sends the event to Firebase Analytics, automatically truncating
|
||||||
|
* parameter keys and values to meet Firebase's length constraints. The event
|
||||||
|
* will appear in the Firebase Console within a few hours for standard events.
|
||||||
|
*
|
||||||
|
* @param event The analytics event to log. Parameters will be automatically
|
||||||
|
* truncated if they exceed Firebase limits.
|
||||||
|
*/
|
||||||
|
override fun logEvent(event: AnalyticsEvent) {
|
||||||
|
firebaseAnalytics.logEvent(event.type) {
|
||||||
|
for (extra in event.extras) {
|
||||||
|
// Truncate parameter keys and values according to firebase maximum length values.
|
||||||
|
param(
|
||||||
|
key = extra.key.take(40),
|
||||||
|
value = extra.value.take(100),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets a user property in Firebase Analytics with automatic length truncation.
|
||||||
|
*
|
||||||
|
* User properties are attributes you define to describe segments of your user base.
|
||||||
|
* They're useful for creating audiences and can be used as filters in Firebase reports.
|
||||||
|
* Properties are automatically truncated to Firebase's length limits.
|
||||||
|
*
|
||||||
|
* @param name The user property name (≤ 24 characters after truncation)
|
||||||
|
* @param value The user property value (≤ 36 characters after truncation)
|
||||||
|
*/
|
||||||
|
override fun setUserProperty(name: String, value: String) {
|
||||||
|
firebaseAnalytics.setUserProperty(name.take(24), value.take(36))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the user ID in Firebase Analytics for cross-session user tracking.
|
||||||
|
*
|
||||||
|
* The user ID enables you to associate events with specific users across
|
||||||
|
* sessions and devices. This helps create a more complete picture of user
|
||||||
|
* behavior and enables advanced analytics features.
|
||||||
|
*
|
||||||
|
* **Privacy Note**: Ensure the user ID doesn't contain personally identifiable
|
||||||
|
* information and complies with privacy regulations.
|
||||||
|
*
|
||||||
|
* @param userId The unique user identifier. Should not contain PII and must
|
||||||
|
* be consistent across sessions for the same user.
|
||||||
|
*/
|
||||||
|
override fun setUserId(userId: String) {
|
||||||
|
firebaseAnalytics.setUserId(userId)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package template.core.base.analytics.di
|
||||||
|
|
||||||
|
import org.koin.core.module.Module
|
||||||
|
import org.koin.core.module.dsl.singleOf
|
||||||
|
import org.koin.dsl.bind
|
||||||
|
import org.koin.dsl.module
|
||||||
|
import template.core.base.analytics.AnalyticsHelper
|
||||||
|
import template.core.base.analytics.StubAnalyticsHelper
|
||||||
|
|
||||||
|
actual val analyticsModule: Module
|
||||||
|
get() = module {
|
||||||
|
singleOf(::StubAnalyticsHelper) bind AnalyticsHelper::class
|
||||||
|
}
|
||||||
1
core-base/common/.gitignore
vendored
Normal file
1
core-base/common/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/build
|
||||||
1
core-base/common/README.md
Normal file
1
core-base/common/README.md
Normal file
@ -0,0 +1 @@
|
|||||||
|
# :core:common module
|
||||||
47
core-base/common/build.gradle.kts
Normal file
47
core-base/common/build.gradle.kts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.mifos.kmp.library)
|
||||||
|
alias(libs.plugins.kotlin.parcelize)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "template.core.base.common"
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
sourceSets {
|
||||||
|
commonMain.dependencies {
|
||||||
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
|
api(libs.kermit.logging)
|
||||||
|
api(libs.squareup.okio)
|
||||||
|
api(libs.jb.kotlin.stdlib)
|
||||||
|
api(libs.kotlinx.datetime)
|
||||||
|
}
|
||||||
|
|
||||||
|
androidMain.dependencies {
|
||||||
|
implementation(libs.kotlinx.coroutines.android)
|
||||||
|
}
|
||||||
|
commonTest.dependencies {
|
||||||
|
implementation(libs.kotlinx.coroutines.test)
|
||||||
|
}
|
||||||
|
iosMain.dependencies {
|
||||||
|
api(libs.kermit.simple)
|
||||||
|
}
|
||||||
|
desktopMain.dependencies {
|
||||||
|
implementation(libs.kotlinx.coroutines.swing)
|
||||||
|
implementation(libs.kotlin.reflect)
|
||||||
|
}
|
||||||
|
jsMain.dependencies {
|
||||||
|
api(libs.jb.kotlin.stdlib.js)
|
||||||
|
api(libs.jb.kotlin.dom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
0
core-base/common/consumer-rules.pro
Normal file
0
core-base/common/consumer-rules.pro
Normal file
13
core-base/common/src/androidMain/AndroidManifest.xml
Normal file
13
core-base/common/src/androidMain/AndroidManifest.xml
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Copyright 2025 Mifos Initiative
|
||||||
|
|
||||||
|
This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0.
|
||||||
|
If a copy of the MPL was not distributed with this file,
|
||||||
|
You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
|
||||||
|
See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
-->
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
</manifest>
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package template.core.base.common
|
||||||
|
|
||||||
|
import android.os.Parcel
|
||||||
|
import android.os.Parcelable
|
||||||
|
import kotlinx.parcelize.IgnoredOnParcel
|
||||||
|
import kotlinx.parcelize.Parceler
|
||||||
|
import kotlinx.parcelize.Parcelize
|
||||||
|
import kotlinx.parcelize.TypeParceler
|
||||||
|
|
||||||
|
actual typealias Parcelize = Parcelize
|
||||||
|
|
||||||
|
actual typealias Parcelable = Parcelable
|
||||||
|
|
||||||
|
actual typealias IgnoredOnParcel = IgnoredOnParcel
|
||||||
|
|
||||||
|
actual typealias Parceler<P> = Parceler<P>
|
||||||
|
|
||||||
|
actual typealias TypeParceler<T, P> = TypeParceler<T, P>
|
||||||
|
|
||||||
|
actual typealias Parcel = Parcel
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package template.core.base.common.di
|
||||||
|
|
||||||
|
import org.koin.core.module.Module
|
||||||
|
import org.koin.dsl.module
|
||||||
|
import template.core.base.common.manager.DispatcherManager
|
||||||
|
import template.core.base.common.manager.DispatcherManagerImpl
|
||||||
|
|
||||||
|
actual val dispatcherManagerModule: Module
|
||||||
|
get() = module {
|
||||||
|
single<DispatcherManager> { DispatcherManagerImpl() }
|
||||||
|
}
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
@file:Suppress("ktlint:standard:filename", "MatchingDeclarationName")
|
||||||
|
|
||||||
|
package template.core.base.common.manager
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.MainCoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
|
||||||
|
class DispatcherManagerImpl : DispatcherManager {
|
||||||
|
override val default: CoroutineDispatcher = Dispatchers.IO
|
||||||
|
|
||||||
|
override val main: MainCoroutineDispatcher = Dispatchers.Main
|
||||||
|
|
||||||
|
override val io: CoroutineDispatcher = Dispatchers.Default
|
||||||
|
|
||||||
|
override val unconfined: CoroutineDispatcher = Dispatchers.Unconfined
|
||||||
|
|
||||||
|
override val appScope: CoroutineScope
|
||||||
|
get() = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
}
|
||||||
@ -0,0 +1,52 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package template.core.base.common
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.catch
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.onStart
|
||||||
|
|
||||||
|
sealed class DataState<out T> {
|
||||||
|
|
||||||
|
/** Data that is being wrapped by [DataState]. */
|
||||||
|
abstract val data: T?
|
||||||
|
|
||||||
|
/** Loading state that has no data is available. */
|
||||||
|
data object Loading : DataState<Nothing>() {
|
||||||
|
override val data: Nothing? get() = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Loaded state that has data available. */
|
||||||
|
data class Success<T>(
|
||||||
|
override val data: T,
|
||||||
|
) : DataState<T>()
|
||||||
|
|
||||||
|
/** Pending state that has data available. */
|
||||||
|
data class Pending<T>(
|
||||||
|
override val data: T,
|
||||||
|
) : DataState<T>()
|
||||||
|
|
||||||
|
/** Error state that may have data available. */
|
||||||
|
data class Error<T>(
|
||||||
|
val error: Throwable,
|
||||||
|
override val data: T? = null,
|
||||||
|
) : DataState<T>()
|
||||||
|
|
||||||
|
/** No network state that may have data is available. */
|
||||||
|
data class NoNetwork<T>(
|
||||||
|
override val data: T? = null,
|
||||||
|
) : DataState<T>()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <T> Flow<T>.asDataStateFlow(): Flow<DataState<T>> =
|
||||||
|
map<T, DataState<T>> { DataState.Success(it) }
|
||||||
|
.onStart { emit(DataState.Loading) }
|
||||||
|
.catch { emit(DataState.Error(it, null)) }
|
||||||
@ -0,0 +1,129 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package template.core.base.common
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.transformWhile
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
|
||||||
|
inline fun <T : Any?, R : Any?> DataState<T>.map(
|
||||||
|
transform: (T) -> R,
|
||||||
|
): DataState<R> = when (this) {
|
||||||
|
is DataState.Success -> DataState.Success(transform(data))
|
||||||
|
is DataState.Loading -> DataState.Loading
|
||||||
|
is DataState.Pending -> DataState.Pending(transform(data))
|
||||||
|
is DataState.Error -> DataState.Error(error, data?.let(transform))
|
||||||
|
is DataState.NoNetwork -> DataState.NoNetwork(data?.let(transform))
|
||||||
|
}
|
||||||
|
|
||||||
|
inline fun <T : Any?, R : Any?> DataState<T>.mapNullable(
|
||||||
|
transform: (T?) -> R,
|
||||||
|
): DataState<R> = when (this) {
|
||||||
|
is DataState.Success -> DataState.Success(data = transform(data))
|
||||||
|
is DataState.Loading -> DataState.Loading
|
||||||
|
is DataState.Pending -> DataState.Pending(data = transform(data))
|
||||||
|
is DataState.Error -> DataState.Error(error = error, data = transform(data))
|
||||||
|
is DataState.NoNetwork -> DataState.NoNetwork(data = transform(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <T : Any?> Flow<DataState<T>>.takeUntilSuccess(): Flow<DataState<T>> = transformWhile {
|
||||||
|
emit(it)
|
||||||
|
it !is DataState.Success
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <T : Any?> MutableStateFlow<DataState<T>>.updateToPendingOrLoading() {
|
||||||
|
update { dataState ->
|
||||||
|
dataState.data
|
||||||
|
?.let { data -> DataState.Pending(data = data) }
|
||||||
|
?: DataState.Loading
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <T1, T2, R> combineDataStates(
|
||||||
|
dataState1: DataState<T1>,
|
||||||
|
dataState2: DataState<T2>,
|
||||||
|
transform: (t1: T1, t2: T2) -> R,
|
||||||
|
): DataState<R> {
|
||||||
|
// Wraps the `transform` lambda to allow null data to be passed in. If either of the passed in
|
||||||
|
// values are null, the regular transform will not be invoked and null is returned.
|
||||||
|
val nullableTransform: (T1?, T2?) -> R? = { t1, t2 ->
|
||||||
|
if (t1 != null && t2 != null) transform(t1, t2) else null
|
||||||
|
}
|
||||||
|
return when {
|
||||||
|
// Error states have highest priority, fail fast.
|
||||||
|
dataState1 is DataState.Error -> {
|
||||||
|
DataState.Error(
|
||||||
|
error = dataState1.error,
|
||||||
|
data = nullableTransform(dataState1.data, dataState2.data),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
dataState2 is DataState.Error -> {
|
||||||
|
DataState.Error(
|
||||||
|
error = dataState2.error,
|
||||||
|
data = nullableTransform(dataState1.data, dataState2.data),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
dataState1 is DataState.NoNetwork || dataState2 is DataState.NoNetwork -> {
|
||||||
|
DataState.NoNetwork(nullableTransform(dataState1.data, dataState2.data))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Something is still loading, we will wait for all the data.
|
||||||
|
dataState1 is DataState.Loading || dataState2 is DataState.Loading -> DataState.Loading
|
||||||
|
|
||||||
|
// Pending state for everything while any one piece of data is updating.
|
||||||
|
dataState1 is DataState.Pending || dataState2 is DataState.Pending -> {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
DataState.Pending(transform(dataState1.data as T1, dataState2.data as T2))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both states are Success and have data
|
||||||
|
else -> {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
DataState.Success(transform(dataState1.data as T1, dataState2.data as T2))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <T1, T2, T3, R> combineDataStates(
|
||||||
|
dataState1: DataState<T1>,
|
||||||
|
dataState2: DataState<T2>,
|
||||||
|
dataState3: DataState<T3>,
|
||||||
|
transform: (t1: T1, t2: T2, t3: T3) -> R,
|
||||||
|
): DataState<R> =
|
||||||
|
dataState1
|
||||||
|
.combineDataStatesWith(dataState2) { t1, t2 -> t1 to t2 }
|
||||||
|
.combineDataStatesWith(dataState3) { t1t2Pair, t3 ->
|
||||||
|
transform(t1t2Pair.first, t1t2Pair.second, t3)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <T1, T2, T3, T4, R> combineDataStates(
|
||||||
|
dataState1: DataState<T1>,
|
||||||
|
dataState2: DataState<T2>,
|
||||||
|
dataState3: DataState<T3>,
|
||||||
|
dataState4: DataState<T4>,
|
||||||
|
transform: (t1: T1, t2: T2, t3: T3, t4: T4) -> R,
|
||||||
|
): DataState<R> =
|
||||||
|
dataState1
|
||||||
|
.combineDataStatesWith(dataState2) { t1, t2 -> t1 to t2 }
|
||||||
|
.combineDataStatesWith(dataState3) { t1t2Pair, t3 ->
|
||||||
|
Triple(t1t2Pair.first, t1t2Pair.second, t3)
|
||||||
|
}
|
||||||
|
.combineDataStatesWith(dataState4) { t1t2t3Triple, t3 ->
|
||||||
|
transform(t1t2t3Triple.first, t1t2t3Triple.second, t1t2t3Triple.third, t3)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <T1, T2, R> DataState<T1>.combineDataStatesWith(
|
||||||
|
dataState2: DataState<T2>,
|
||||||
|
transform: (t1: T1, t2: T2) -> R,
|
||||||
|
): DataState<R> =
|
||||||
|
combineDataStates(this, dataState2, transform)
|
||||||
@ -0,0 +1,102 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package template.core.base.common
|
||||||
|
|
||||||
|
import kotlin.io.encoding.Base64
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extension function to convert ByteArray to Base64 string
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalEncodingApi::class)
|
||||||
|
fun ByteArray.toBase64(): String {
|
||||||
|
return Base64.encode(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extension function to convert ByteArray to Base64 string with data URI prefix
|
||||||
|
* @param mimeType The MIME type of the data (e.g., "image/png", "image/jpeg")
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalEncodingApi::class)
|
||||||
|
fun ByteArray.toBase64DataUri(mimeType: String = "application/octet-stream"): String {
|
||||||
|
return "data:$mimeType;base64,${Base64.encode(this)}"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extension function to convert Base64 string to ByteArray
|
||||||
|
* @throws IllegalArgumentException if the string is not valid Base64
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalEncodingApi::class)
|
||||||
|
fun String.fromBase64(): ByteArray {
|
||||||
|
return Base64.decode(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extension function to safely convert Base64 string to ByteArray
|
||||||
|
* @return ByteArray if successful, null if the string is not valid Base64
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalEncodingApi::class)
|
||||||
|
fun String.fromBase64OrNull(): ByteArray? {
|
||||||
|
return try {
|
||||||
|
Base64.decode(this)
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extension function to convert Base64 data URI to ByteArray
|
||||||
|
* Handles data URIs in format: "data:mime/type;base64,actualBase64Data"
|
||||||
|
* @return ByteArray of the decoded data
|
||||||
|
* @throws IllegalArgumentException if the data URI format is invalid
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalEncodingApi::class)
|
||||||
|
fun String.fromBase64DataUri(): ByteArray {
|
||||||
|
val dataUriPrefix = "data:"
|
||||||
|
val base64Prefix = ";base64,"
|
||||||
|
|
||||||
|
require(this.startsWith(dataUriPrefix)) {
|
||||||
|
"Invalid data URI: must start with 'data:'"
|
||||||
|
}
|
||||||
|
|
||||||
|
val base64Index = this.indexOf(base64Prefix)
|
||||||
|
require(base64Index != -1) {
|
||||||
|
"Invalid data URI: missing ';base64,' separator"
|
||||||
|
}
|
||||||
|
|
||||||
|
val base64Data = this.substring(base64Index + base64Prefix.length)
|
||||||
|
return Base64.decode(base64Data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extension function to safely convert Base64 data URI to ByteArray
|
||||||
|
* @return ByteArray if successful, null if the data URI format is invalid
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalEncodingApi::class)
|
||||||
|
fun String.fromBase64DataUriOrNull(): ByteArray? {
|
||||||
|
return try {
|
||||||
|
fromBase64DataUri()
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extension function to extract MIME type from Base64 data URI
|
||||||
|
* @return MIME type string or null if not a valid data URI
|
||||||
|
*/
|
||||||
|
fun String.extractMimeTypeFromDataUri(): String? {
|
||||||
|
val dataUriPrefix = "data:"
|
||||||
|
val base64Prefix = ";base64,"
|
||||||
|
|
||||||
|
return takeIf { it.startsWith(dataUriPrefix) && it.contains(base64Prefix) }
|
||||||
|
?.substringAfter(dataUriPrefix)
|
||||||
|
?.substringBefore(base64Prefix)
|
||||||
|
}
|
||||||
@ -0,0 +1,41 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package template.core.base.common
|
||||||
|
|
||||||
|
expect annotation class Parcelize()
|
||||||
|
|
||||||
|
expect interface Parcelable
|
||||||
|
|
||||||
|
expect annotation class IgnoredOnParcel()
|
||||||
|
|
||||||
|
expect interface Parceler<P> {
|
||||||
|
fun create(parcel: Parcel): P
|
||||||
|
|
||||||
|
fun P.write(parcel: Parcel, flags: Int)
|
||||||
|
}
|
||||||
|
|
||||||
|
expect annotation class TypeParceler<T, P : Parceler<in T>>()
|
||||||
|
|
||||||
|
expect class Parcel {
|
||||||
|
fun readByte(): Byte
|
||||||
|
fun readInt(): Int
|
||||||
|
|
||||||
|
fun readFloat(): Float
|
||||||
|
fun readDouble(): Double
|
||||||
|
fun readString(): String?
|
||||||
|
|
||||||
|
fun writeByte(value: Byte)
|
||||||
|
fun writeInt(value: Int)
|
||||||
|
|
||||||
|
fun writeFloat(value: Float)
|
||||||
|
|
||||||
|
fun writeDouble(value: Double)
|
||||||
|
fun writeString(value: String?)
|
||||||
|
}
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package template.core.base.common.di
|
||||||
|
|
||||||
|
import org.koin.core.module.Module
|
||||||
|
import org.koin.dsl.module
|
||||||
|
|
||||||
|
val CommonModule = module {
|
||||||
|
includes(dispatcherManagerModule)
|
||||||
|
}
|
||||||
|
|
||||||
|
expect val dispatcherManagerModule: Module
|
||||||
@ -0,0 +1,38 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package template.core.base.common.manager
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.MainCoroutineDispatcher
|
||||||
|
|
||||||
|
interface DispatcherManager {
|
||||||
|
/**
|
||||||
|
* The default [CoroutineDispatcher] for the app.
|
||||||
|
*/
|
||||||
|
val default: CoroutineDispatcher
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The [MainCoroutineDispatcher] for the app.
|
||||||
|
*/
|
||||||
|
val main: MainCoroutineDispatcher
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The IO [CoroutineDispatcher] for the app.
|
||||||
|
*/
|
||||||
|
val io: CoroutineDispatcher
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The unconfined [CoroutineDispatcher] for the app.
|
||||||
|
*/
|
||||||
|
val unconfined: CoroutineDispatcher
|
||||||
|
|
||||||
|
val appScope: CoroutineScope
|
||||||
|
}
|
||||||
@ -0,0 +1,46 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package template.core.base.common
|
||||||
|
|
||||||
|
actual interface Parcelable
|
||||||
|
actual annotation class IgnoredOnParcel
|
||||||
|
actual annotation class Parcelize
|
||||||
|
actual interface Parceler<P> {
|
||||||
|
actual fun create(parcel: Parcel): P
|
||||||
|
actual fun P.write(parcel: Parcel, flags: Int)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual annotation class TypeParceler<T, P : Parceler<in T>>
|
||||||
|
|
||||||
|
actual class Parcel {
|
||||||
|
actual fun readString(): String? = null
|
||||||
|
actual fun readByte(): Byte = 1
|
||||||
|
|
||||||
|
actual fun readInt(): Int = 1
|
||||||
|
|
||||||
|
actual fun readFloat(): Float = 1f
|
||||||
|
|
||||||
|
actual fun readDouble(): Double = 1.0
|
||||||
|
|
||||||
|
actual fun writeByte(value: Byte) {
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun writeInt(value: Int) {
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun writeFloat(value: Float) {
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun writeDouble(value: Double) {
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun writeString(value: String?) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package template.core.base.common.di
|
||||||
|
|
||||||
|
import org.koin.core.module.Module
|
||||||
|
import org.koin.dsl.module
|
||||||
|
import template.core.base.common.manager.DispatcherManager
|
||||||
|
import template.core.base.common.manager.DispatcherManagerImpl
|
||||||
|
|
||||||
|
actual val dispatcherManagerModule: Module
|
||||||
|
get() = module {
|
||||||
|
single<DispatcherManager> { DispatcherManagerImpl() }
|
||||||
|
}
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
@file:Suppress("ktlint:standard:filename", "MatchingDeclarationName")
|
||||||
|
|
||||||
|
package template.core.base.common.manager
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.MainCoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
|
||||||
|
class DispatcherManagerImpl : DispatcherManager {
|
||||||
|
override val default: CoroutineDispatcher = Dispatchers.Default
|
||||||
|
|
||||||
|
override val main: MainCoroutineDispatcher = Dispatchers.Main
|
||||||
|
|
||||||
|
override val io: CoroutineDispatcher = Dispatchers.Default
|
||||||
|
|
||||||
|
override val unconfined: CoroutineDispatcher = Dispatchers.Unconfined
|
||||||
|
|
||||||
|
override val appScope: CoroutineScope
|
||||||
|
get() = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
}
|
||||||
1
core-base/database/.gitignore
vendored
Normal file
1
core-base/database/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/build
|
||||||
220
core-base/database/README.md
Normal file
220
core-base/database/README.md
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
# Core Base Database Module
|
||||||
|
|
||||||
|
A Kotlin Multiplatform library that provides cross-platform database abstractions using Room
|
||||||
|
database for Android, Desktop, and Native platforms.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This module serves as a foundational database layer for the Mifos Initiative applications, enabling
|
||||||
|
consistent database operations across Android, Desktop (JVM), and Native (iOS/macOS) platforms using
|
||||||
|
the Room persistence library.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The module follows the Kotlin Multiplatform expect/actual pattern to provide platform-specific
|
||||||
|
implementations while maintaining a common interface:
|
||||||
|
|
||||||
|
### Common Module (`commonMain`)
|
||||||
|
|
||||||
|
- **Room.kt**: Defines expect declarations for Room annotations (`@Dao`, `@Entity`, `@Query`, etc.)
|
||||||
|
- **TypeConverter.kt**: Defines expect declaration for `@TypeConverter` annotation
|
||||||
|
- **OnConflictStrategy**: Platform-agnostic constants for database conflict resolution
|
||||||
|
|
||||||
|
### Platform-Specific Modules
|
||||||
|
|
||||||
|
- **Android (`androidMain`)**: Uses androidx.room directly with Android Context
|
||||||
|
- **Desktop (`desktopMain`)**: Uses androidx.room with file-based database storage
|
||||||
|
- **Native (`nativeMain`)**: Uses androidx.room with iOS/macOS document directory storage
|
||||||
|
|
||||||
|
## Key Components
|
||||||
|
|
||||||
|
### AppDatabaseFactory
|
||||||
|
|
||||||
|
Platform-specific factory classes that handle database creation and configuration:
|
||||||
|
|
||||||
|
#### Android Implementation
|
||||||
|
|
||||||
|
- Requires Android `Context` for database creation
|
||||||
|
- Uses `Room.databaseBuilder()` with application context
|
||||||
|
- Stores databases in standard Android app data directory
|
||||||
|
|
||||||
|
#### Desktop Implementation
|
||||||
|
|
||||||
|
- Creates databases in platform-appropriate directories:
|
||||||
|
- **Windows**: `%APPDATA%/MifosDatabase`
|
||||||
|
- **macOS**: `~/Library/Application Support/MifosDatabase`
|
||||||
|
- **Linux**: `~/.local/share/MifosDatabase`
|
||||||
|
- Uses inline reified generics for type-safe database instantiation
|
||||||
|
|
||||||
|
#### Native Implementation
|
||||||
|
|
||||||
|
- Stores databases in iOS/macOS document directory
|
||||||
|
- Uses platform-specific file system APIs
|
||||||
|
- Leverages Kotlin/Native interop for Foundation framework access
|
||||||
|
|
||||||
|
### Room Annotations
|
||||||
|
|
||||||
|
Cross-platform type aliases for Room annotations that ensure consistent API across all platforms:
|
||||||
|
|
||||||
|
- `@Dao` - Data Access Object annotation
|
||||||
|
- `@Entity` - Database entity annotation
|
||||||
|
- `@Query` - SQL query annotation
|
||||||
|
- `@Insert` - Insert operation annotation
|
||||||
|
- `@PrimaryKey` - Primary key annotation
|
||||||
|
- `@ForeignKey` - Foreign key constraint annotation
|
||||||
|
- `@Index` - Database index annotation
|
||||||
|
- `@TypeConverter` - Type conversion annotation
|
||||||
|
|
||||||
|
## Usage Examples
|
||||||
|
|
||||||
|
### Basic Setup
|
||||||
|
|
||||||
|
#### Android
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
class MyApplication : Application() {
|
||||||
|
val databaseFactory = AppDatabaseFactory(this)
|
||||||
|
|
||||||
|
val database = databaseFactory
|
||||||
|
.createDatabase(MyDatabase::class.java, "my_database.db")
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Desktop
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
class DesktopApp {
|
||||||
|
val databaseFactory = AppDatabaseFactory()
|
||||||
|
|
||||||
|
val database = databaseFactory
|
||||||
|
.createDatabase<MyDatabase>("my_database.db")
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Native (iOS/macOS)
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
class IOSApp {
|
||||||
|
val databaseFactory = AppDatabaseFactory()
|
||||||
|
|
||||||
|
val database = databaseFactory
|
||||||
|
.createDatabase<MyDatabase>("my_database.db")
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Defining Entities
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Entity(tableName = "users")
|
||||||
|
data class User(
|
||||||
|
@PrimaryKey(autoGenerate = true)
|
||||||
|
val id: Long = 0,
|
||||||
|
val name: String,
|
||||||
|
val email: String
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Creating DAOs
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Dao
|
||||||
|
interface UserDao {
|
||||||
|
@Query("SELECT * FROM users")
|
||||||
|
suspend fun getAllUsers(): List<User>
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertUser(user: User)
|
||||||
|
|
||||||
|
@Query("DELETE FROM users WHERE id = :userId")
|
||||||
|
suspend fun deleteUser(userId: Long)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Definition
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Database(
|
||||||
|
entities = [User::class],
|
||||||
|
version = 1,
|
||||||
|
exportSchema = false
|
||||||
|
)
|
||||||
|
abstract class MyDatabase : RoomDatabase() {
|
||||||
|
abstract fun userDao(): UserDao
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
The module relies on the following key dependencies:
|
||||||
|
|
||||||
|
- **androidx.room.runtime**: Core Room database functionality
|
||||||
|
- **Kotlin Multiplatform**: Cross-platform code sharing
|
||||||
|
- **Platform-specific APIs**: Context (Android), File system (Desktop), Foundation (Native)
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Gradle Setup
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
kotlin {
|
||||||
|
sourceSets {
|
||||||
|
androidMain.dependencies {
|
||||||
|
implementation(libs.androidx.room.runtime)
|
||||||
|
}
|
||||||
|
desktopMain.dependencies {
|
||||||
|
implementation(libs.androidx.room.runtime)
|
||||||
|
}
|
||||||
|
nativeMain.dependencies {
|
||||||
|
implementation(libs.androidx.room.runtime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Platform Considerations
|
||||||
|
|
||||||
|
### Android
|
||||||
|
|
||||||
|
- Requires minimum API level compatible with Room
|
||||||
|
- Database files stored in internal app storage
|
||||||
|
- Supports all Room features including migrations and type converters
|
||||||
|
|
||||||
|
### Desktop
|
||||||
|
|
||||||
|
- Cross-platform directory selection ensures proper database placement
|
||||||
|
- Supports full Room functionality on JVM
|
||||||
|
- Automatic directory creation for database storage
|
||||||
|
|
||||||
|
### Native (iOS/macOS)
|
||||||
|
|
||||||
|
- Uses iOS/macOS document directory for database storage
|
||||||
|
- Leverages Kotlin/Native C interop for platform APIs
|
||||||
|
- Requires iOS/macOS specific Room dependencies
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Database Versioning**: Always increment version numbers when changing schema
|
||||||
|
2. **Migration Strategy**: Implement proper Room migrations for schema changes
|
||||||
|
3. **Type Converters**: Use `@TypeConverter` for complex data types
|
||||||
|
4. **Conflict Resolution**: Choose appropriate `OnConflictStrategy` for your use case
|
||||||
|
5. **Testing**: Test database operations on all target platforms
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
When contributing to this module:
|
||||||
|
|
||||||
|
- Maintain expect/actual pattern consistency
|
||||||
|
- Test changes across all supported platforms
|
||||||
|
- Update documentation for any API changes
|
||||||
|
- Follow Kotlin coding conventions
|
||||||
|
- Ensure proper license headers on all files
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of
|
||||||
|
the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
|
||||||
|
See https://github.com/openMF/kmp-project-template/blob/main/LICENSE for more details.
|
||||||
47
core-base/database/build.gradle.kts
Normal file
47
core-base/database/build.gradle.kts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
import org.jetbrains.compose.compose
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/kmp-project-template/blob/main/LICENSE
|
||||||
|
*/
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.mifos.kmp.library)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "template.core.base.database"
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
sourceSets {
|
||||||
|
androidMain.dependencies {
|
||||||
|
implementation(libs.androidx.room.runtime)
|
||||||
|
}
|
||||||
|
|
||||||
|
desktopMain.dependencies {
|
||||||
|
implementation(libs.androidx.room.runtime)
|
||||||
|
}
|
||||||
|
|
||||||
|
nativeMain.dependencies {
|
||||||
|
implementation(libs.androidx.room.runtime)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonJsCommonMain.dependencies {
|
||||||
|
implementation(libs.androidx.room.runtime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,86 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package template.core.base.database
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.room.Room
|
||||||
|
import androidx.room.RoomDatabase
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Android-specific implementation of the database factory for creating Room database instances.
|
||||||
|
*
|
||||||
|
* This factory class provides a standardized approach to database creation on Android platforms,
|
||||||
|
* ensuring proper context handling and consistent database configuration across the application.
|
||||||
|
* The factory leverages Android's application context to prevent memory leaks and maintain
|
||||||
|
* database accessibility throughout the application lifecycle.
|
||||||
|
*
|
||||||
|
* Key features:
|
||||||
|
* - Automatic application context usage to prevent memory leaks
|
||||||
|
* - Type-safe database creation with compile-time verification
|
||||||
|
* - Consistent database naming and configuration
|
||||||
|
* - Integration with Android's storage systems
|
||||||
|
*
|
||||||
|
* @param context The Android context used for database creation, typically an Application or Activity context
|
||||||
|
*
|
||||||
|
* @see androidx.room.Room
|
||||||
|
* @see androidx.room.RoomDatabase
|
||||||
|
*/
|
||||||
|
class AppDatabaseFactory(
|
||||||
|
private val context: Context,
|
||||||
|
) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a Room database builder configured for Android environments.
|
||||||
|
*
|
||||||
|
* This method constructs a RoomDatabase.Builder instance that can be further customized
|
||||||
|
* with additional configuration options such as migrations, type converters, or callback
|
||||||
|
* handlers before building the final database instance.
|
||||||
|
*
|
||||||
|
* The method automatically uses the application context to ensure the database remains
|
||||||
|
* accessible throughout the application lifecycle while preventing potential memory leaks
|
||||||
|
* that could occur when using activity or service contexts.
|
||||||
|
*
|
||||||
|
* @param T The type of RoomDatabase to create, must extend RoomDatabase
|
||||||
|
* @param databaseClass The Class object representing the database type to instantiate
|
||||||
|
* @param databaseName The name of the database file to create or access
|
||||||
|
* @return A RoomDatabase.Builder instance ready for additional configuration and building
|
||||||
|
*
|
||||||
|
* @throws IllegalArgumentException if the database class is invalid or cannot be instantiated
|
||||||
|
* @throws SQLiteException if there are issues with database creation or access
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* class MyApplication : Application() {
|
||||||
|
* private val databaseFactory = AppDatabaseFactory(this)
|
||||||
|
*
|
||||||
|
* val userDatabase: UserDatabase by lazy {
|
||||||
|
* databaseFactory
|
||||||
|
* .createDatabase(UserDatabase::class.java, "user_database.db")
|
||||||
|
* .addMigrations(MIGRATION_1_2, MIGRATION_2_3)
|
||||||
|
* .addTypeConverter(DateConverters())
|
||||||
|
* .build()
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Configuration recommendations:
|
||||||
|
* - Use descriptive database names that reflect their purpose
|
||||||
|
* - Consider implementing proper migration strategies for schema changes
|
||||||
|
* - Add appropriate type converters for complex data types
|
||||||
|
* - Configure database callbacks for initialization or validation logic
|
||||||
|
*/
|
||||||
|
fun <T : RoomDatabase> createDatabase(databaseClass: Class<T>, databaseName: String): RoomDatabase.Builder<T> {
|
||||||
|
return Room.databaseBuilder(
|
||||||
|
context.applicationContext,
|
||||||
|
databaseClass,
|
||||||
|
databaseName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,236 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package template.core.base.database
|
||||||
|
|
||||||
|
import kotlin.reflect.KClass
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cross-platform annotation for marking interfaces as Data Access Objects (DAOs).
|
||||||
|
*
|
||||||
|
* This annotation is used to mark interfaces that contain database access methods.
|
||||||
|
* The Room persistence library will generate implementations of these interfaces
|
||||||
|
* at compile time.
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
* ```kotlin
|
||||||
|
* @Dao
|
||||||
|
* interface UserDao {
|
||||||
|
* @Query("SELECT * FROM users")
|
||||||
|
* suspend fun getAllUsers(): List<User>
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
@Suppress("NO_ACTUAL_FOR_EXPECT")
|
||||||
|
expect annotation class Dao()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cross-platform annotation for defining SQL queries on DAO methods.
|
||||||
|
*
|
||||||
|
* This annotation is used to define raw SQL queries that will be executed
|
||||||
|
* when the annotated method is called. The query can contain parameters
|
||||||
|
* that correspond to method parameters.
|
||||||
|
*
|
||||||
|
* @param value The SQL query string to execute
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
* ```kotlin
|
||||||
|
* @Query("SELECT * FROM users WHERE age > :minAge")
|
||||||
|
* suspend fun getUsersOlderThan(minAge: Int): List<User>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
@Suppress("NO_ACTUAL_FOR_EXPECT")
|
||||||
|
@Target(AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY_GETTER)
|
||||||
|
@Retention(AnnotationRetention.BINARY)
|
||||||
|
expect annotation class Query(
|
||||||
|
val value: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cross-platform annotation for marking DAO methods that insert entities into the database.
|
||||||
|
*
|
||||||
|
* This annotation defines how the method should behave when inserting entities.
|
||||||
|
* It can handle single entities, lists of entities, or arrays of entities.
|
||||||
|
*
|
||||||
|
* @param entity The entity class that this method inserts (used for type checking)
|
||||||
|
* @param onConflict Strategy to use when there's a conflict during insertion
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
* ```kotlin
|
||||||
|
* @Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
* suspend fun insertUser(user: User): Long
|
||||||
|
*
|
||||||
|
* @Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||||
|
* suspend fun insertUsers(users: List<User>): List<Long>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
@Suppress("NO_ACTUAL_FOR_EXPECT")
|
||||||
|
@Target(AnnotationTarget.FUNCTION)
|
||||||
|
@Retention(AnnotationRetention.BINARY)
|
||||||
|
expect annotation class Insert(
|
||||||
|
val entity: KClass<*>,
|
||||||
|
val onConflict: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cross-platform annotation for marking entity fields as primary keys.
|
||||||
|
*
|
||||||
|
* This annotation identifies which field(s) serve as the primary key for the entity.
|
||||||
|
* Primary keys uniquely identify each row in the database table.
|
||||||
|
*
|
||||||
|
* @param autoGenerate Whether the database should automatically generate values for this primary key
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
* ```kotlin
|
||||||
|
* @Entity
|
||||||
|
* data class User(
|
||||||
|
* @PrimaryKey(autoGenerate = true)
|
||||||
|
* val id: Long = 0,
|
||||||
|
* val name: String
|
||||||
|
* )
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
@Suppress("NO_ACTUAL_FOR_EXPECT")
|
||||||
|
@Target(AnnotationTarget.FIELD, AnnotationTarget.FUNCTION)
|
||||||
|
@Retention(AnnotationRetention.BINARY)
|
||||||
|
expect annotation class PrimaryKey(
|
||||||
|
val autoGenerate: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cross-platform annotation for defining foreign key constraints.
|
||||||
|
*
|
||||||
|
* This annotation is used within the @Entity annotation to define relationships
|
||||||
|
* between entities through foreign key constraints. It ensures referential integrity
|
||||||
|
* between related tables.
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
* ```kotlin
|
||||||
|
* @Entity(
|
||||||
|
* foreignKeys = [ForeignKey(
|
||||||
|
* entity = User::class,
|
||||||
|
* parentColumns = ["id"],
|
||||||
|
* childColumns = ["userId"],
|
||||||
|
* onDelete = ForeignKey.CASCADE
|
||||||
|
* )]
|
||||||
|
* )
|
||||||
|
* data class Post(
|
||||||
|
* @PrimaryKey val id: Long,
|
||||||
|
* val userId: Long,
|
||||||
|
* val content: String
|
||||||
|
* )
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
@Suppress("NO_ACTUAL_FOR_EXPECT")
|
||||||
|
@Target(allowedTargets = [])
|
||||||
|
@Retention(AnnotationRetention.BINARY)
|
||||||
|
expect annotation class ForeignKey
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cross-platform annotation for defining database indexes.
|
||||||
|
*
|
||||||
|
* Indexes improve query performance by creating optimized data structures
|
||||||
|
* for faster data retrieval. This annotation is used within the @Entity
|
||||||
|
* annotation to define indexes on one or more columns.
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
* ```kotlin
|
||||||
|
* @Entity(
|
||||||
|
* indices = [
|
||||||
|
* Index(value = ["email"], unique = true),
|
||||||
|
* Index(value = ["firstName", "lastName"])
|
||||||
|
* ]
|
||||||
|
* )
|
||||||
|
* data class User(
|
||||||
|
* @PrimaryKey val id: Long,
|
||||||
|
* val email: String,
|
||||||
|
* val firstName: String,
|
||||||
|
* val lastName: String
|
||||||
|
* )
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
@Suppress("NO_ACTUAL_FOR_EXPECT")
|
||||||
|
@Target(allowedTargets = [])
|
||||||
|
@Retention(AnnotationRetention.BINARY)
|
||||||
|
expect annotation class Index
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cross-platform annotation for marking classes as database entities.
|
||||||
|
*
|
||||||
|
* This annotation transforms a Kotlin class into a database table.
|
||||||
|
* Each instance of the class represents a row in the table, and each
|
||||||
|
* property represents a column.
|
||||||
|
*
|
||||||
|
* @param tableName Custom name for the database table (defaults to class name)
|
||||||
|
* @param indices Array of indexes to create on this table
|
||||||
|
* @param inheritSuperIndices Whether to inherit indexes from parent classes
|
||||||
|
* @param primaryKeys Array of column names that form the composite primary key
|
||||||
|
* @param foreignKeys Array of foreign key constraints for this table
|
||||||
|
* @param ignoredColumns Array of property names to exclude from the table
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
* ```kotlin
|
||||||
|
* @Entity(
|
||||||
|
* tableName = "user_profiles",
|
||||||
|
* indices = [Index(value = ["email"], unique = true)]
|
||||||
|
* )
|
||||||
|
* data class UserProfile(
|
||||||
|
* @PrimaryKey(autoGenerate = true)
|
||||||
|
* val id: Long = 0,
|
||||||
|
* val email: String,
|
||||||
|
* val displayName: String
|
||||||
|
* )
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
@Suppress("NO_ACTUAL_FOR_EXPECT")
|
||||||
|
@Target(AnnotationTarget.CLASS)
|
||||||
|
@Retention(AnnotationRetention.BINARY)
|
||||||
|
expect annotation class Entity(
|
||||||
|
val tableName: String,
|
||||||
|
val indices: Array<Index>,
|
||||||
|
val inheritSuperIndices: Boolean,
|
||||||
|
val primaryKeys: Array<String>,
|
||||||
|
val foreignKeys: Array<ForeignKey>,
|
||||||
|
val ignoredColumns: Array<String>,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cross-platform constants for handling database conflicts during insert operations.
|
||||||
|
*
|
||||||
|
* These constants define the behavior when inserting data that conflicts with
|
||||||
|
* existing constraints (such as primary key or unique constraints).
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* @Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
* suspend fun insertUser(user: User)
|
||||||
|
*
|
||||||
|
* @Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||||
|
* suspend fun insertUserIfNotExists(user: User)
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
object OnConflictStrategy {
|
||||||
|
/** No conflict resolution strategy specified (may cause exceptions) */
|
||||||
|
const val NONE = 0
|
||||||
|
|
||||||
|
/** Replace the existing data with the new data when conflicts occur */
|
||||||
|
const val REPLACE = 1
|
||||||
|
|
||||||
|
/** Rollback the transaction when conflicts occur */
|
||||||
|
const val ROLLBACK = 2
|
||||||
|
|
||||||
|
/** Abort the current operation when conflicts occur */
|
||||||
|
const val ABORT = 3
|
||||||
|
|
||||||
|
/** Fail the operation and throw an exception when conflicts occur */
|
||||||
|
const val FAIL = 4
|
||||||
|
|
||||||
|
/** Ignore the new data when conflicts occur (keep existing data) */
|
||||||
|
const val IGNORE = 5
|
||||||
|
}
|
||||||
@ -0,0 +1,60 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package template.core.base.database
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cross-platform annotation for marking methods as database type converters.
|
||||||
|
*
|
||||||
|
* Type converters enable Room to store and retrieve complex data types that are not
|
||||||
|
* natively supported by SQLite. This annotation marks methods that convert between
|
||||||
|
* custom types and primitive types that SQLite can understand.
|
||||||
|
*
|
||||||
|
* Type converters must be static methods (or methods in an object class) and should
|
||||||
|
* come in pairs: one method to convert from the custom type to a primitive type,
|
||||||
|
* and another to convert back from the primitive type to the custom type.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* object DateConverters {
|
||||||
|
* @TypeConverter
|
||||||
|
* fun fromTimestamp(value: Long?): Date? {
|
||||||
|
* return value?.let { Date(it) }
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* @TypeConverter
|
||||||
|
* fun dateToTimestamp(date: Date?): Long? {
|
||||||
|
* return date?.time
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* // Register converters in your database
|
||||||
|
* @Database(...)
|
||||||
|
* @TypeConverters(DateConverters::class)
|
||||||
|
* abstract class MyDatabase : RoomDatabase() {
|
||||||
|
* // Database implementation
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Common use cases for type converters include:
|
||||||
|
* - Converting Date objects to Long timestamps
|
||||||
|
* - Converting enums to String or Int values
|
||||||
|
* - Converting complex objects to JSON strings
|
||||||
|
* - Converting lists or arrays to comma-separated strings
|
||||||
|
*
|
||||||
|
* Performance considerations:
|
||||||
|
* - Type converters are called frequently during database operations
|
||||||
|
* - Keep conversion logic simple and efficient
|
||||||
|
* - Consider caching expensive conversions when appropriate
|
||||||
|
* - Avoid complex object creation in frequently-called converters
|
||||||
|
*/
|
||||||
|
@Suppress("NO_ACTUAL_FOR_EXPECT")
|
||||||
|
@Target(AnnotationTarget.FUNCTION)
|
||||||
|
@Retention(AnnotationRetention.BINARY)
|
||||||
|
expect annotation class TypeConverter()
|
||||||
@ -0,0 +1,123 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package template.core.base.database
|
||||||
|
|
||||||
|
import androidx.room.Room
|
||||||
|
import androidx.room.RoomDatabase
|
||||||
|
import androidx.room.util.findAndInstantiateDatabaseImpl
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Desktop-specific implementation of the database factory for creating Room database instances.
|
||||||
|
*
|
||||||
|
* This factory class provides cross-platform desktop database creation capabilities,
|
||||||
|
* automatically selecting appropriate storage locations based on the operating system.
|
||||||
|
* The implementation ensures databases are stored in platform-conventional directories
|
||||||
|
* that provide appropriate persistence and user access patterns.
|
||||||
|
*
|
||||||
|
* Platform-specific storage locations:
|
||||||
|
* - Windows: %APPDATA%/MifosDatabase
|
||||||
|
* - macOS: ~/Library/Application Support/MifosDatabase
|
||||||
|
* - Linux: ~/.local/share/MifosDatabase
|
||||||
|
*
|
||||||
|
* Key features:
|
||||||
|
* - Automatic platform detection and directory selection
|
||||||
|
* - Cross-platform file system compatibility
|
||||||
|
* - Type-safe database creation using inline reified generics
|
||||||
|
* - Automatic directory creation when required
|
||||||
|
* - Integration with JVM-based Room implementations
|
||||||
|
*
|
||||||
|
* @see androidx.room.Room
|
||||||
|
* @see androidx.room.RoomDatabase
|
||||||
|
*/
|
||||||
|
class AppDatabaseFactory {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a Room database builder configured for desktop environments.
|
||||||
|
*
|
||||||
|
* This method constructs a RoomDatabase.Builder instance specifically configured
|
||||||
|
* for desktop applications, with automatic platform-appropriate storage location
|
||||||
|
* selection. The method leverages inline reified generics to provide type safety
|
||||||
|
* while maintaining flexibility in database instantiation.
|
||||||
|
*
|
||||||
|
* The implementation automatically detects the current operating system and
|
||||||
|
* selects the conventional application data directory for that platform,
|
||||||
|
* ensuring databases are stored in locations that align with user expectations
|
||||||
|
* and system conventions.
|
||||||
|
*
|
||||||
|
* @param T The type of RoomDatabase to create, must extend RoomDatabase
|
||||||
|
* @param databaseName The name of the database file to create or access
|
||||||
|
* @param factory Optional factory function for database instantiation, defaults to Room's automatic discovery
|
||||||
|
* @return A RoomDatabase.Builder instance ready for additional configuration and building
|
||||||
|
*
|
||||||
|
* @throws SecurityException if the application lacks permission to create directories or files
|
||||||
|
* @throws IOException if there are file system issues during directory or database creation
|
||||||
|
* @throws ClassNotFoundException if the database implementation class cannot be located
|
||||||
|
*
|
||||||
|
* Directory creation behavior:
|
||||||
|
* - Automatically creates the MifosDatabase directory if it does not exist
|
||||||
|
* - Respects existing directory permissions and structure
|
||||||
|
* - Uses platform-appropriate path separators and naming conventions
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* class DesktopApplication {
|
||||||
|
* private val databaseFactory = AppDatabaseFactory()
|
||||||
|
*
|
||||||
|
* val transactionDatabase: TransactionDatabase by lazy {
|
||||||
|
* databaseFactory
|
||||||
|
* .createDatabase<TransactionDatabase>("transactions.db")
|
||||||
|
* .addMigrations(MIGRATION_1_2)
|
||||||
|
* .enableMultiInstanceInvalidation()
|
||||||
|
* .build()
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* val userDatabase: UserDatabase by lazy {
|
||||||
|
* databaseFactory
|
||||||
|
* .createDatabase<UserDatabase>("users.db") {
|
||||||
|
* // Custom factory implementation if needed
|
||||||
|
* UserDatabase_Impl()
|
||||||
|
* }
|
||||||
|
* .addTypeConverter(CustomConverters())
|
||||||
|
* .build()
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Platform considerations:
|
||||||
|
* - Windows installations should ensure %APPDATA% is accessible
|
||||||
|
* - macOS applications may require appropriate entitlements for file system access
|
||||||
|
* - Linux environments should verify user home directory permissions
|
||||||
|
* - Consider backup and synchronization implications of chosen storage locations
|
||||||
|
*/
|
||||||
|
inline fun <reified T : RoomDatabase> createDatabase(
|
||||||
|
databaseName: String,
|
||||||
|
noinline factory: () -> T = { findAndInstantiateDatabaseImpl(T::class.java) },
|
||||||
|
): RoomDatabase.Builder<T> {
|
||||||
|
val os = System.getProperty("os.name").lowercase()
|
||||||
|
val userHome = System.getProperty("user.home")
|
||||||
|
val appDataDir = when {
|
||||||
|
os.contains("win") -> File(System.getenv("APPDATA"), "MifosDatabase")
|
||||||
|
os.contains("mac") -> File(userHome, "Library/Application Support/MifosDatabase")
|
||||||
|
else -> File(userHome, ".local/share/MifosDatabase")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!appDataDir.exists()) {
|
||||||
|
appDataDir.mkdirs()
|
||||||
|
}
|
||||||
|
|
||||||
|
val dbFile = File(appDataDir, databaseName)
|
||||||
|
|
||||||
|
return Room.databaseBuilder(
|
||||||
|
name = dbFile.absolutePath,
|
||||||
|
factory = factory,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,160 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package template.core.base.database
|
||||||
|
|
||||||
|
import androidx.room.Room
|
||||||
|
import androidx.room.RoomDatabase
|
||||||
|
import androidx.room.util.findDatabaseConstructorAndInitDatabaseImpl
|
||||||
|
import kotlinx.cinterop.ExperimentalForeignApi
|
||||||
|
import platform.Foundation.NSDocumentDirectory
|
||||||
|
import platform.Foundation.NSFileManager
|
||||||
|
import platform.Foundation.NSUserDomainMask
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Native platform implementation of the database factory for iOS and macOS applications.
|
||||||
|
*
|
||||||
|
* This factory class provides database creation capabilities specifically designed for
|
||||||
|
* iOS and macOS platforms using Kotlin/Native interoperability with Foundation framework APIs.
|
||||||
|
* The implementation ensures databases are stored in the standard document directory,
|
||||||
|
* providing appropriate persistence characteristics and compliance with platform guidelines.
|
||||||
|
*
|
||||||
|
* Platform integration features:
|
||||||
|
* - Direct integration with iOS/macOS Foundation framework
|
||||||
|
* - Standard document directory storage following Apple guidelines
|
||||||
|
* - Type-safe database creation using inline reified generics
|
||||||
|
* - Kotlin/Native C interop for optimal platform performance
|
||||||
|
* - Compliance with iOS app sandbox requirements
|
||||||
|
*
|
||||||
|
* Storage characteristics:
|
||||||
|
* - Databases stored in application's document directory
|
||||||
|
* - Automatic backup eligibility through iTunes/iCloud (configurable)
|
||||||
|
* - Persistent across application updates and device restores
|
||||||
|
* - Accessible through Files app on iOS (when appropriate)
|
||||||
|
*
|
||||||
|
* @see androidx.room.Room
|
||||||
|
* @see androidx.room.RoomDatabase
|
||||||
|
* @see platform.Foundation.NSFileManager
|
||||||
|
*/
|
||||||
|
class AppDatabaseFactory {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a Room database builder configured for iOS/macOS native environments.
|
||||||
|
*
|
||||||
|
* This method constructs a RoomDatabase.Builder instance specifically optimized
|
||||||
|
* for native iOS and macOS applications. The implementation leverages Foundation
|
||||||
|
* framework APIs through Kotlin/Native interop to ensure proper platform integration
|
||||||
|
* and adherence to Apple's storage guidelines.
|
||||||
|
*
|
||||||
|
* The database file is automatically placed in the application's document directory,
|
||||||
|
* which provides appropriate persistence characteristics and follows Apple's
|
||||||
|
* recommended storage patterns for user-generated content and application data.
|
||||||
|
*
|
||||||
|
* @param T The type of RoomDatabase to create, must extend RoomDatabase
|
||||||
|
* @param databaseName The name of the database file to create within the document directory
|
||||||
|
* @param factory Optional factory function for database instantiation, defaults to Room's constructor discovery
|
||||||
|
* @return A RoomDatabase.Builder instance ready for additional configuration and building
|
||||||
|
*
|
||||||
|
* @throws NSException if document directory access fails or is unavailable
|
||||||
|
* @throws RuntimeException if the database constructor cannot be located or instantiated
|
||||||
|
* @throws SecurityException if the application lacks required file system permissions
|
||||||
|
*
|
||||||
|
* Platform considerations:
|
||||||
|
* - Database files are eligible for iCloud backup by default
|
||||||
|
* - Files may be accessible through the Files app depending on configuration
|
||||||
|
* - Storage location complies with App Store Review Guidelines
|
||||||
|
* - Automatic cleanup may occur during low storage conditions
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* class IOSApp {
|
||||||
|
* private val databaseFactory = AppDatabaseFactory()
|
||||||
|
*
|
||||||
|
* val coreDatabase: CoreDatabase by lazy {
|
||||||
|
* databaseFactory
|
||||||
|
* .createDatabase<CoreDatabase>("core_data.db")
|
||||||
|
* .addMigrations(MIGRATION_VERSIONS)
|
||||||
|
* .setJournalMode(RoomDatabase.JournalMode.WAL)
|
||||||
|
* .build()
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* val cacheDatabase: CacheDatabase by lazy {
|
||||||
|
* databaseFactory
|
||||||
|
* .createDatabase<CacheDatabase>("cache.db") {
|
||||||
|
* CacheDatabase_Impl()
|
||||||
|
* }
|
||||||
|
* .addCallback(object : RoomDatabase.Callback() {
|
||||||
|
* override fun onCreate(db: SupportSQLiteDatabase) {
|
||||||
|
* // Initialize cache tables
|
||||||
|
* }
|
||||||
|
* })
|
||||||
|
* .build()
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Configuration recommendations:
|
||||||
|
* - Consider WAL mode for improved concurrent access performance
|
||||||
|
* - Implement proper migration strategies for iOS app updates
|
||||||
|
* - Configure backup exclusion for cache or temporary databases
|
||||||
|
* - Monitor storage usage in compliance with platform guidelines
|
||||||
|
*/
|
||||||
|
inline fun <reified T : RoomDatabase> createDatabase(
|
||||||
|
databaseName: String,
|
||||||
|
noinline factory: () -> T = { findDatabaseConstructorAndInitDatabaseImpl(T::class) },
|
||||||
|
): RoomDatabase.Builder<T> {
|
||||||
|
val dbFilePath = documentDirectory() + "/$databaseName"
|
||||||
|
return Room.databaseBuilder(
|
||||||
|
name = dbFilePath,
|
||||||
|
factory = factory,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the path to the application's document directory using Foundation framework APIs.
|
||||||
|
*
|
||||||
|
* This method provides access to the standard iOS/macOS document directory through
|
||||||
|
* Kotlin/Native interop with the Foundation framework. The document directory serves
|
||||||
|
* as the primary location for storing user-generated content and application data
|
||||||
|
* that should persist across application launches and system updates.
|
||||||
|
*
|
||||||
|
* The implementation uses NSFileManager to locate the document directory within
|
||||||
|
* the user domain, ensuring proper sandboxing compliance and platform integration.
|
||||||
|
* This approach guarantees that database files are stored in locations that align
|
||||||
|
* with Apple's storage guidelines and user expectations.
|
||||||
|
*
|
||||||
|
* @return The absolute file system path to the application's document directory
|
||||||
|
*
|
||||||
|
* @throws RuntimeException if the document directory cannot be located or accessed
|
||||||
|
* @throws NSException if Foundation framework calls fail due to system restrictions
|
||||||
|
*
|
||||||
|
* Directory characteristics:
|
||||||
|
* - Persistent across application updates and device restores
|
||||||
|
* - Included in iTunes and iCloud backups by default
|
||||||
|
* - Accessible through document provider extensions when configured
|
||||||
|
* - Subject to iOS storage management and optimization
|
||||||
|
*
|
||||||
|
* Implementation notes:
|
||||||
|
* - Uses NSUserDomainMask to ensure user-specific directory access
|
||||||
|
* - Leverages NSDocumentDirectory constant for standard directory location
|
||||||
|
* - Employs Kotlin/Native C interop for optimal performance and integration
|
||||||
|
* - Handles potential nil responses from Foundation framework appropriately
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
|
fun documentDirectory(): String {
|
||||||
|
val documentDirectory = NSFileManager.defaultManager.URLForDirectory(
|
||||||
|
directory = NSDocumentDirectory,
|
||||||
|
inDomain = NSUserDomainMask,
|
||||||
|
appropriateForURL = null,
|
||||||
|
create = false,
|
||||||
|
error = null,
|
||||||
|
)
|
||||||
|
return requireNotNull(documentDirectory?.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,71 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package template.core.base.database
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.ForeignKey
|
||||||
|
import androidx.room.Index
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
import androidx.room.Query
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Multiplatform typealiases for Room database annotations and interfaces.
|
||||||
|
*
|
||||||
|
* This file provides `actual` typealiases for common Room annotations and interfaces, allowing
|
||||||
|
* shared code to use Room database features in a platform-agnostic way. These typealiases map
|
||||||
|
* to the corresponding Android Room components, enabling code sharing across platforms in a
|
||||||
|
* Kotlin Multiplatform project.
|
||||||
|
*
|
||||||
|
* @see <a href="https://developer.android.com/training/data-storage/room">Room Persistence Library</a>
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Typealias for the Room `@Dao` annotation/interface.
|
||||||
|
* Used to mark Data Access Objects in shared code.
|
||||||
|
*/
|
||||||
|
actual typealias Dao = Dao
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Typealias for the Room `@Query` annotation.
|
||||||
|
* Used to annotate methods in DAOs for SQL queries.
|
||||||
|
*/
|
||||||
|
actual typealias Query = Query
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Typealias for the Room `@Insert` annotation.
|
||||||
|
* Used to annotate methods in DAOs for insert operations.
|
||||||
|
*/
|
||||||
|
actual typealias Insert = Insert
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Typealias for the Room `@PrimaryKey` annotation.
|
||||||
|
* Used to mark primary key fields in entities.
|
||||||
|
*/
|
||||||
|
actual typealias PrimaryKey = PrimaryKey
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Typealias for the Room `@ForeignKey` annotation.
|
||||||
|
* Used to define foreign key relationships in entities.
|
||||||
|
*/
|
||||||
|
actual typealias ForeignKey = ForeignKey
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Typealias for the Room `@Index` annotation.
|
||||||
|
* Used to define indices on entity fields.
|
||||||
|
*/
|
||||||
|
actual typealias Index = Index
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Typealias for the Room `@Entity` annotation.
|
||||||
|
* Used to mark classes as database entities.
|
||||||
|
*/
|
||||||
|
actual typealias Entity = Entity
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package template.core.base.database
|
||||||
|
|
||||||
|
import androidx.room.TypeConverter
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type alias for Room's [TypeConverter] annotation in non-JS common code.
|
||||||
|
*
|
||||||
|
* This type alias maps the Room [TypeConverter] annotation to be used in the non-JS common module.
|
||||||
|
* The [TypeConverter] annotation is used to define custom type conversions for Room database entities.
|
||||||
|
*
|
||||||
|
* @see androidx.room.TypeConverter
|
||||||
|
*/
|
||||||
|
actual typealias TypeConverter = TypeConverter
|
||||||
282
core-base/datastore/README.md
Normal file
282
core-base/datastore/README.md
Normal file
@ -0,0 +1,282 @@
|
|||||||
|
# Core Datastore Module [Deprecated]
|
||||||
|
|
||||||
|
> \[!Important]
|
||||||
|
> This module serves as a demonstration of Architecture & SOLID Pattern principles with comprehensive unit testing. The
|
||||||
|
> settings library already provides the same functionality, and under the hood it'd use that library, consider directly
|
||||||
|
> using that and this will be removed soon from this project.
|
||||||
|
|
||||||
|
A robust, type-safe, and reactive data storage solution for Kotlin Multiplatform projects, built on
|
||||||
|
top of [Multiplatform Settings](https://github.com/russhwolf/multiplatform-settings). This module
|
||||||
|
provides a flexible API for managing persistent data with support for caching, validation, and
|
||||||
|
reactive observation using Kotlin Flows.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Type-safe data storage for primitive and serializable types
|
||||||
|
- In-memory caching with LRU implementation for performance
|
||||||
|
- Data validation for keys and values
|
||||||
|
- JSON serialization/deserialization using `kotlinx.serialization`
|
||||||
|
- Reactive data observation using Kotlin Coroutines Flow
|
||||||
|
- Dedicated support for user preferences management
|
||||||
|
- Comprehensive exception handling
|
||||||
|
- Flexible instance creation and dependency management via a Factory
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The `core-base/datastore` module is designed with a layered and component-based architecture to
|
||||||
|
ensure modularity, testability, and flexibility. It separates concerns into distinct layers and
|
||||||
|
components, allowing for customization and easier maintenance.
|
||||||
|
|
||||||
|
**Core Layers:**
|
||||||
|
|
||||||
|
1. **Contracts:** Defines the fundamental interfaces that outline the capabilities of different data
|
||||||
|
store implementations. This layer provides abstractions for basic storage operations, key-value
|
||||||
|
storage, caching, and reactive observation.
|
||||||
|
2. **Stores:** Contains the concrete implementations of the data storage logic. These classes
|
||||||
|
interact with the underlying `Multiplatform Settings` library and integrate supporting components
|
||||||
|
like caching, serialization, and validation.
|
||||||
|
3. **Repositories:** Offers a higher-level, more use-case oriented API built on top of the data
|
||||||
|
stores. The reactive repository provides convenient methods for common preference management
|
||||||
|
tasks and reactive observation.
|
||||||
|
|
||||||
|
**Supporting Components:**
|
||||||
|
|
||||||
|
- **Factory:** The `DataStoreFactory` provides a builder pattern for creating and configuring
|
||||||
|
instances of the reactive data store and repository. It simplifies the process of assembling the
|
||||||
|
various components with desired settings, dispatchers, cache configurations, validators, and
|
||||||
|
serialization strategies.
|
||||||
|
- **Serialization:** Defines strategies for converting complex data objects to and from storable
|
||||||
|
formats, primarily using `kotlinx.serialization` for JSON.
|
||||||
|
- **Validation:** Implements rules for validating keys and values before they are stored, ensuring
|
||||||
|
data integrity.
|
||||||
|
- **Cache:** Provides an optional in-memory caching layer (`LRUCacheManager`) to improve read
|
||||||
|
performance for frequently accessed data.
|
||||||
|
- **Reactive:** Includes components (`ChangeNotifier`, `ValueObserver`) that enable the reactive
|
||||||
|
capabilities of the data store, allowing observers to react to data changes via Kotlin Flows.
|
||||||
|
- **Type Handling:** Manages the specific logic required to store and retrieve different primitive
|
||||||
|
data types using the underlying settings mechanism.
|
||||||
|
- **Exceptions:** Defines custom exception classes for specific error conditions within the module.
|
||||||
|
|
||||||
|
**Architecture Flow:**
|
||||||
|
|
||||||
|
The typical interaction flow involves the **Repository** layer calling methods on the **Store**
|
||||||
|
layer. The **Store** layer then utilizes **Serialization**, **Validation**, **Type Handling**, and *
|
||||||
|
*Cache** components as needed before interacting with the underlying **Settings** implementation. *
|
||||||
|
*Reactive** components are integrated within the **Store** layer to provide Flow-based observation.
|
||||||
|
The **Factory** is responsible for assembling and configuring these components.
|
||||||
|
|
||||||
|
**Diagram:**
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
A[User/Application Code] --> B[DataStoreFactory]
|
||||||
|
B --> C[ReactivePreferencesRepository]
|
||||||
|
C --> D[ReactiveDataStore]
|
||||||
|
D --> E[CachedPreferencesStore]
|
||||||
|
E --> F[Settings-Multiplatform Settings]
|
||||||
|
E --> G[CacheManager]
|
||||||
|
E --> H[SerializationStrategy]
|
||||||
|
E --> I[PreferencesValidator]
|
||||||
|
E --> J[TypeHandler]
|
||||||
|
D --> K[ChangeNotifier]
|
||||||
|
D --> L[ValueObserver]
|
||||||
|
|
||||||
|
subgraph Layers
|
||||||
|
C:::repository
|
||||||
|
D:::store
|
||||||
|
E:::store
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Supporting Components
|
||||||
|
B:::factory
|
||||||
|
G:::cache
|
||||||
|
H:::serialization
|
||||||
|
I:::validation
|
||||||
|
J:::typehandler
|
||||||
|
K:::reactive
|
||||||
|
L:::reactive
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Underlying Technology
|
||||||
|
F:::settings
|
||||||
|
end
|
||||||
|
|
||||||
|
classDef repository fill: #f9f, stroke: #333, stroke-width: 2;
|
||||||
|
classDef store fill: #ccf, stroke: #333, stroke-width: 2;
|
||||||
|
classDef factory fill: #cfc, stroke: #333, stroke-width: 2;
|
||||||
|
classDef cache fill: #ffc, stroke: #333, stroke-width: 2;
|
||||||
|
classDef serialization fill: #fcf, stroke: #333, stroke-width: 2;
|
||||||
|
classDef validation fill: #cff, stroke: #333, stroke-width: 2;
|
||||||
|
classDef typehandler fill: #cc9, stroke: #333, stroke-width: 2;
|
||||||
|
classDef reactive fill: #9cf, stroke: #333, stroke-width: 2;
|
||||||
|
classDef settings fill: #f99, stroke: #333, stroke-width: 2;
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Basic Usage
|
||||||
|
|
||||||
|
This section demonstrates how to use the basic `DataStore` for a custom type.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// Define your data class with serialization annotation
|
||||||
|
@Serializable
|
||||||
|
data class UserData(val name: String, val age: Int)
|
||||||
|
|
||||||
|
// Create serializer and validator instances
|
||||||
|
// Using JsonSerializationStrategy requires a KSerializer for your data type
|
||||||
|
val serializer = JsonSerializationStrategy(UserData.serializer())
|
||||||
|
// Use DefaultDataStoreValidator if no specific validation is needed
|
||||||
|
val validator = DefaultDataStoreValidator<UserData>()
|
||||||
|
|
||||||
|
// Obtain DataStore instance from a factory (assuming a DataStoreFactory is provided via DI or elsewhere)
|
||||||
|
// The factory is responsible for creating instances based on the desired configuration
|
||||||
|
val dataStore: DataStore<UserData> = dataStoreFactory.createDataStore(serializer, validator)
|
||||||
|
|
||||||
|
// Store data asynchronously
|
||||||
|
dataStore.setData(UserData("John", 30))
|
||||||
|
|
||||||
|
// Get data as a Coroutines Flow. The flow emits the current data and subsequent changes.
|
||||||
|
dataStore.getData().collect { userData ->
|
||||||
|
println("User: ${userData.name}, Age: ${userData.age}")
|
||||||
|
}
|
||||||
|
|
||||||
|
// To get a snapshot of the current data without observing future changes, you might use:
|
||||||
|
// val currentUserData = dataStore.getData().first() // Requires importing kotlinx.coroutines.flow.first
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using Typed DataStore
|
||||||
|
|
||||||
|
`TypedDataStore` is suitable for scenarios where you need to store multiple pieces of data of the
|
||||||
|
same type, identified by a key.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// Assuming UserData, serializer, and validator are defined as above
|
||||||
|
|
||||||
|
// Create a typed data store. The second type parameter defines the type of the key.
|
||||||
|
val typedDataStore: TypedDataStore<UserData, String> =
|
||||||
|
dataStoreFactory.createTypedDataStore<UserData, String>(
|
||||||
|
serializer,
|
||||||
|
validator
|
||||||
|
)
|
||||||
|
|
||||||
|
// Store data with a specific key
|
||||||
|
typedDataStore.setDataForKey("user1", UserData("John", 30))
|
||||||
|
typedDataStore.setDataForKey("user2", UserData("Jane", 25))
|
||||||
|
|
||||||
|
// Get data by key as a Flow
|
||||||
|
typedDataStore.getDataForKey("user1").collect { userData ->
|
||||||
|
// userData will be null if no data is stored for this key
|
||||||
|
println("User for key user1: ${userData?.name}, Age: ${userData?.age}")
|
||||||
|
}
|
||||||
|
|
||||||
|
// To get a snapshot for a specific key:
|
||||||
|
// val user1Snapshot = typedDataStore.getDataForKey("user1").first()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using Cacheable DataStore
|
||||||
|
|
||||||
|
`CacheableDataStore` adds an in-memory caching layer to a `TypedDataStore`, improving read
|
||||||
|
performance for frequently accessed data.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// Assuming UserData, serializer, and validator are defined as above
|
||||||
|
|
||||||
|
// Create a CacheManager. LRUCacheManager is a common choice.
|
||||||
|
// The cache stores data in memory, mapping keys to data objects.
|
||||||
|
val cacheManager = LRUCacheManager<String, UserData>()
|
||||||
|
|
||||||
|
// Create a cacheable data store, combining TypedDataStore functionality with caching.
|
||||||
|
val cacheableDataStore: CacheableDataStore<UserData, String> =
|
||||||
|
dataStoreFactory.createCacheableDataStore(
|
||||||
|
serializer,
|
||||||
|
validator,
|
||||||
|
cacheManager
|
||||||
|
)
|
||||||
|
|
||||||
|
// Store and cache data. Storing data through CacheableDataStore will also update the cache.
|
||||||
|
cacheableDataStore.setDataForKey("user1", UserData("John", 30))
|
||||||
|
|
||||||
|
// Get data, potentially served from the cache if available and not invalidated.
|
||||||
|
cacheableDataStore.getCachedData("user1").collect { userData ->
|
||||||
|
println("User from cacheable store (key user1): ${userData?.name}, Age: ${userData?.age}")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: The underlying storage is still used to persist data. The cache is an optimization layer.
|
||||||
|
// Cache invalidation strategies or manual cache management might be needed for complex scenarios.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using User Preferences
|
||||||
|
|
||||||
|
The `UserPreferencesRepository` provides a simple key-value store for basic user settings, typically
|
||||||
|
backed by Multiplatform Settings.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// Obtain UserPreferencesRepository instance, usually through Dependency Injection
|
||||||
|
// Assuming 'get<UserPreferencesRepository>()' is available in your context (e.g., Koin)
|
||||||
|
val userPreferences: UserPreferencesRepository = get<UserPreferencesRepository>()
|
||||||
|
|
||||||
|
// Store preferences for various primitive types
|
||||||
|
userPreferences.setString("theme", "dark")
|
||||||
|
userPreferences.setBoolean("notifications", true)
|
||||||
|
userPreferences.setInt("fontSize", 14)
|
||||||
|
// ... other primitive types supported by Multiplatform Settings
|
||||||
|
|
||||||
|
// Get preferences as Flows
|
||||||
|
userPreferences.getString("theme", defaultValue = "light").collect { theme ->
|
||||||
|
println("Current theme: $theme")
|
||||||
|
}
|
||||||
|
|
||||||
|
userPreferences.getBoolean("notifications", defaultValue = false).collect { enabled ->
|
||||||
|
println("Notifications enabled: $enabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: Use appropriate default values when retrieving preferences to handle cases where a preference is not set.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Guidelines
|
||||||
|
|
||||||
|
- **Choosing the Right DataStore:**
|
||||||
|
- Use `DataStore` for storing a single instance of a complex data object (e.g., user profile
|
||||||
|
data).
|
||||||
|
- Use `TypedDataStore` for storing multiple instances of a data object, addressable by a unique
|
||||||
|
key (e.g., cached API responses, individual settings for different features).
|
||||||
|
- Use `CacheableDataStore` when you need the functionality of `TypedDataStore` and want to add
|
||||||
|
an in-memory caching layer for performance.
|
||||||
|
- Use `UserPreferencesRepository` for simple key-value storage of primitive types, suitable for
|
||||||
|
user settings and flags.
|
||||||
|
|
||||||
|
- **Key Naming:** When using `TypedDataStore` or `UserPreferencesRepository`, use clear and
|
||||||
|
consistent key names, perhaps following a convention like `featureName_dataType_identifier`.
|
||||||
|
|
||||||
|
- **Serialization:** Always use `@Serializable` annotation from `kotlinx.serialization` for data
|
||||||
|
classes stored using `DataStore`, `TypedDataStore`, or `CacheableDataStore`. Ensure you have the
|
||||||
|
necessary serializer instances.
|
||||||
|
|
||||||
|
- **Validation:** Implement `DataStoreValidator` if your data requires specific validation rules
|
||||||
|
before storage or after retrieval.
|
||||||
|
|
||||||
|
- **Error Handling:** The DataStore operations typically use Kotlin Result or throw exceptions for
|
||||||
|
failures during serialization, deserialization, or storage. Implement appropriate error handling
|
||||||
|
in your data flows or suspending functions.
|
||||||
|
|
||||||
|
- **Schema Evolution:** The current module does not include built-in support for schema migrations.
|
||||||
|
If your data structure changes in future versions of your application, you will need to handle
|
||||||
|
data migration manually, for example, by checking a version flag in your preferences or
|
||||||
|
implementing a migration logic during app startup.
|
||||||
|
|
||||||
|
- **Dependency Injection:** It is recommended to obtain instances of `DataStoreFactory` and
|
||||||
|
`UserPreferencesRepository` through dependency injection (e.g., using Koin) rather than creating
|
||||||
|
them directly in your application code. This promotes testability and modularity.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
The module includes comprehensive test cases for all components. Run the tests using:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew :core-base:datastore:test
|
||||||
|
```
|
||||||
|
|
||||||
|
Ensure you write tests for your specific DataStore implementations and validators when using this
|
||||||
|
module.
|
||||||
36
core-base/datastore/build.gradle.kts
Normal file
36
core-base/datastore/build.gradle.kts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.mifos.kmp.library)
|
||||||
|
id("kotlinx-serialization")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "template.core.base.datastore"
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
sourceSets {
|
||||||
|
commonMain.dependencies {
|
||||||
|
implementation(libs.multiplatform.settings)
|
||||||
|
implementation(libs.multiplatform.settings.serialization)
|
||||||
|
implementation(libs.multiplatform.settings.coroutines)
|
||||||
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
implementation(libs.kotlinx.datetime)
|
||||||
|
}
|
||||||
|
|
||||||
|
commonTest.dependencies {
|
||||||
|
implementation(libs.multiplatform.settings.test)
|
||||||
|
implementation(libs.kotlinx.coroutines.test)
|
||||||
|
implementation(libs.turbine)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
76
core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/cache/CacheManager.kt
vendored
Normal file
76
core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/cache/CacheManager.kt
vendored
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package template.core.base.datastore.cache
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for managing in-memory caching of key-value pairs in the data store.
|
||||||
|
*
|
||||||
|
* Implementations of this interface provide methods for storing, retrieving, removing,
|
||||||
|
* and clearing cached entries, as well as checking cache size and key existence.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* val cache: CacheManager<String, Any> = LruCacheManager(100)
|
||||||
|
* cache.put("theme", "dark")
|
||||||
|
* val value = cache.get("theme")
|
||||||
|
* cache.remove("theme")
|
||||||
|
* cache.clear()
|
||||||
|
* val size = cache.size()
|
||||||
|
* val exists = cache.containsKey("theme")
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @param K The type of the cache key.
|
||||||
|
* @param V The type of the cached value.
|
||||||
|
*/
|
||||||
|
interface CacheManager<K, V> {
|
||||||
|
/**
|
||||||
|
* Stores a value in the cache associated with the specified key.
|
||||||
|
*
|
||||||
|
* @param key The key to associate with the value.
|
||||||
|
* @param value The value to store.
|
||||||
|
*/
|
||||||
|
fun put(key: K, value: V)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves a value from the cache associated with the specified key.
|
||||||
|
*
|
||||||
|
* @param key The key to retrieve.
|
||||||
|
* @return The cached value, or `null` if not present.
|
||||||
|
*/
|
||||||
|
fun get(key: K): V?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes the value associated with the specified key from the cache.
|
||||||
|
*
|
||||||
|
* @param key The key to remove.
|
||||||
|
* @return The removed value, or `null` if not present.
|
||||||
|
*/
|
||||||
|
fun remove(key: K): V?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears all entries from the cache.
|
||||||
|
*/
|
||||||
|
fun clear()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the number of entries currently stored in the cache.
|
||||||
|
*
|
||||||
|
* @return The cache size.
|
||||||
|
*/
|
||||||
|
fun size(): Int
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether the cache contains the specified key.
|
||||||
|
*
|
||||||
|
* @param key The key to check.
|
||||||
|
* @return `true` if the cache contains the key, `false` otherwise.
|
||||||
|
*/
|
||||||
|
fun containsKey(key: K): Boolean
|
||||||
|
}
|
||||||
132
core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/cache/LruCacheManager.kt
vendored
Normal file
132
core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/cache/LruCacheManager.kt
vendored
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package template.core.base.datastore.cache
|
||||||
|
|
||||||
|
import template.core.base.datastore.exceptions.CacheException
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Least Recently Used (LRU) cache implementation for managing in-memory key-value pairs.
|
||||||
|
*
|
||||||
|
* This cache automatically evicts the least recently used entry when the maximum size is exceeded.
|
||||||
|
* It provides thread-unsafe, fast access for use cases where cache contention is not a concern.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* val cache = LruCacheManager<String, Any>(maxSize = 100)
|
||||||
|
* cache.put("theme", "dark")
|
||||||
|
* val value = cache.get("theme")
|
||||||
|
* cache.remove("theme")
|
||||||
|
* cache.clear()
|
||||||
|
* val size = cache.size()
|
||||||
|
* val exists = cache.containsKey("theme")
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @param K The type of the cache key.
|
||||||
|
* @param V The type of the cached value.
|
||||||
|
* @property maxSize The maximum number of entries the cache can hold.
|
||||||
|
*/
|
||||||
|
class LruCacheManager<K, V>(
|
||||||
|
private val maxSize: Int = 100,
|
||||||
|
) : CacheManager<K, V> {
|
||||||
|
|
||||||
|
private val cache = LinkedHashMap<K, V>(maxSize, 0.75f)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds or updates a value in the cache for the specified key.
|
||||||
|
* If the cache exceeds its maximum size, the least recently used entry is evicted.
|
||||||
|
*
|
||||||
|
* @param key The key to associate with the value.
|
||||||
|
* @param value The value to store in the cache.
|
||||||
|
* @throws CacheException if the operation fails.
|
||||||
|
*/
|
||||||
|
override fun put(key: K, value: V) {
|
||||||
|
try {
|
||||||
|
cache[key] = value
|
||||||
|
if (cache.size > maxSize) {
|
||||||
|
val eldest = cache.keys.first()
|
||||||
|
cache.remove(eldest)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw CacheException("Failed to put value in cache for key: $key", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the value associated with the specified key from the cache.
|
||||||
|
*
|
||||||
|
* @param key The key to look up.
|
||||||
|
* @return The value associated with the key, or null if the key is not present.
|
||||||
|
* @throws CacheException if the operation fails.
|
||||||
|
*/
|
||||||
|
override fun get(key: K): V? {
|
||||||
|
return try {
|
||||||
|
cache[key]
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw CacheException("Failed to get value from cache for key: $key", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes the value associated with the specified key from the cache.
|
||||||
|
*
|
||||||
|
* @param key The key to remove.
|
||||||
|
* @return The value that was associated with the key, or null if the key was not present.
|
||||||
|
* @throws CacheException if the operation fails.
|
||||||
|
*/
|
||||||
|
override fun remove(key: K): V? {
|
||||||
|
return try {
|
||||||
|
cache.remove(key)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw CacheException("Failed to remove value from cache for key: $key", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes all entries from the cache.
|
||||||
|
*
|
||||||
|
* @throws CacheException if the operation fails.
|
||||||
|
*/
|
||||||
|
override fun clear() {
|
||||||
|
try {
|
||||||
|
cache.clear()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw CacheException("Failed to clear cache", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the current number of entries in the cache.
|
||||||
|
*
|
||||||
|
* @return The number of key-value pairs currently stored in the cache.
|
||||||
|
* @throws CacheException if the operation fails.
|
||||||
|
*/
|
||||||
|
override fun size(): Int {
|
||||||
|
return try {
|
||||||
|
cache.size
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw CacheException("Failed to get cache size", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the cache contains an entry for the specified key.
|
||||||
|
*
|
||||||
|
* @param key The key to check.
|
||||||
|
* @return true if the cache contains an entry for the key, false otherwise.
|
||||||
|
* @throws CacheException if the operation fails.
|
||||||
|
*/
|
||||||
|
override fun containsKey(key: K): Boolean {
|
||||||
|
return try {
|
||||||
|
cache.containsKey(key)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw CacheException("Failed to check if cache contains key: $key", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,98 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package template.core.base.datastore.contracts
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for data storage implementations that support caching.
|
||||||
|
*
|
||||||
|
* This interface extends the base [DataStore] interface, adding operations for managing
|
||||||
|
* an in-memory cache associated with the stored data.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* val cacheableDataStore: CacheableDataStore = ...
|
||||||
|
* cacheableDataStore.putValue("settings_cache", "enabled")
|
||||||
|
* val value = cacheableDataStore.getValue("settings_cache", "disabled")
|
||||||
|
* cacheableDataStore.invalidateCache("settings_cache")
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
interface CacheableDataStore : DataStore {
|
||||||
|
/**
|
||||||
|
* Stores a value associated with the specified key in the data store and updates the cache.
|
||||||
|
*
|
||||||
|
* @param key The key to associate with the value.
|
||||||
|
* @param value The value to store.
|
||||||
|
* @return [Result.success] if the operation succeeds, or [Result.failure] if an error occurs.
|
||||||
|
*/
|
||||||
|
suspend fun <T> putValue(key: String, value: T): Result<Unit>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves a value associated with the specified key, checking the cache first.
|
||||||
|
*
|
||||||
|
* @param key The key to retrieve.
|
||||||
|
* @param default The default value to return if the key does not exist in the data store.
|
||||||
|
* @return [Result.success] with the value, or [Result.failure] if an error occurs.
|
||||||
|
*/
|
||||||
|
suspend fun <T> getValue(key: String, default: T): Result<T>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores a serializable value using the provided serializer and updates the cache.
|
||||||
|
*
|
||||||
|
* @param key The key to associate with the value.
|
||||||
|
* @param value The value to store.
|
||||||
|
* @param serializer The serializer for the value type.
|
||||||
|
* @return [Result.success] if the operation succeeds, or [Result.failure] if an error occurs.
|
||||||
|
*/
|
||||||
|
suspend fun <T> putSerializableValue(
|
||||||
|
key: String,
|
||||||
|
value: T,
|
||||||
|
serializer: kotlinx.serialization.KSerializer<T>,
|
||||||
|
): Result<Unit>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves a serializable value using the provided serializer, checking the cache first.
|
||||||
|
*
|
||||||
|
* @param key The key to retrieve.
|
||||||
|
* @param default The default value to return if the key does not exist in the data store.
|
||||||
|
* @param serializer The serializer for the value type.
|
||||||
|
* @return [Result.success] with the value, or [Result.failure] if an error occurs.
|
||||||
|
*/
|
||||||
|
suspend fun <T> getSerializableValue(
|
||||||
|
key: String,
|
||||||
|
default: T,
|
||||||
|
serializer: kotlinx.serialization.KSerializer<T>,
|
||||||
|
): Result<T>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidates the cache entry for the specified key.
|
||||||
|
*
|
||||||
|
* This forces the next retrieval for this key to read from the underlying data store.
|
||||||
|
*
|
||||||
|
* @param key The key whose cache entry should be invalidated.
|
||||||
|
* @return [Result.success] if the operation succeeds, or [Result.failure] if an error occurs.
|
||||||
|
*/
|
||||||
|
suspend fun invalidateCache(key: String): Result<Unit>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidates all entries in the cache.
|
||||||
|
*
|
||||||
|
* This forces the next retrieval for any key to read from the underlying data store.
|
||||||
|
*
|
||||||
|
* @return [Result.success] if the operation succeeds, or [Result.failure] if an error occurs.
|
||||||
|
*/
|
||||||
|
suspend fun invalidateAllCache(): Result<Unit>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the current number of entries in the cache.
|
||||||
|
*
|
||||||
|
* @return The size of the cache.
|
||||||
|
*/
|
||||||
|
fun getCacheSize(): Int
|
||||||
|
}
|
||||||
@ -0,0 +1,64 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package template.core.base.datastore.contracts
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base interface defining fundamental data storage operations.
|
||||||
|
*
|
||||||
|
* Implementations provide methods for checking key existence, removing values,
|
||||||
|
* clearing the store, and retrieving keys and size.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* val dataStore: DataStore = ...
|
||||||
|
* dataStore.hasKey("config")
|
||||||
|
* dataStore.removeValue("temp_data")
|
||||||
|
* dataStore.getAllKeys()
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
interface DataStore {
|
||||||
|
/**
|
||||||
|
* Checks if the specified key exists in the data store.
|
||||||
|
*
|
||||||
|
* @param key The key to check.
|
||||||
|
* @return [Result.success] with `true` if the key exists, `false` otherwise,
|
||||||
|
* or [Result.failure] if an error occurs.
|
||||||
|
*/
|
||||||
|
suspend fun hasKey(key: String): Result<Boolean>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes the value associated with the specified key from the data store.
|
||||||
|
*
|
||||||
|
* @param key The key to remove.
|
||||||
|
* @return [Result.success] if the operation succeeds, or [Result.failure] if an error occurs.
|
||||||
|
*/
|
||||||
|
suspend fun removeValue(key: String): Result<Unit>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears all stored data from the data store.
|
||||||
|
*
|
||||||
|
* @return [Result.success] if the operation succeeds, or [Result.failure] if an error occurs.
|
||||||
|
*/
|
||||||
|
suspend fun clearAll(): Result<Unit>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves a set of all keys currently stored in the data store.
|
||||||
|
*
|
||||||
|
* @return [Result.success] with the set of keys, or [Result.failure] if an error occurs.
|
||||||
|
*/
|
||||||
|
suspend fun getAllKeys(): Result<Set<String>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the total number of key-value pairs stored in the data store.
|
||||||
|
*
|
||||||
|
* @return [Result.success] with the count, or [Result.failure] if an error occurs.
|
||||||
|
*/
|
||||||
|
suspend fun getSize(): Result<Int>
|
||||||
|
}
|
||||||
@ -0,0 +1,96 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package template.core.base.datastore.contracts
|
||||||
|
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.ExperimentalTime
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a change event that occurs in the data store.
|
||||||
|
*
|
||||||
|
* This sealed class defines different types of events, such as value additions, updates,
|
||||||
|
* removals, and the clearing of the entire store.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* dataStore.observeChanges().collect { event ->
|
||||||
|
* when (event) {
|
||||||
|
* is DataStoreChangeEvent.ValueAdded -> println("Value added for key ${event.key}")
|
||||||
|
* is DataStoreChangeEvent.ValueUpdated -> println("Value for key ${event.key} updated")
|
||||||
|
* is DataStoreChangeEvent.ValueRemoved -> println("Value for key ${event.key} removed")
|
||||||
|
* is DataStoreChangeEvent.StoreCleared -> println("Data store cleared")
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalTime::class)
|
||||||
|
sealed class DataStoreChangeEvent {
|
||||||
|
/**
|
||||||
|
* The key associated with the change event. For [StoreCleared], this is typically "*".
|
||||||
|
*/
|
||||||
|
abstract val key: String
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The timestamp (in milliseconds since the epoch) when the event occurred.
|
||||||
|
*/
|
||||||
|
abstract val timestamp: Long
|
||||||
|
|
||||||
|
data class ValueAdded constructor(
|
||||||
|
/**
|
||||||
|
* Represents the addition of a new value to the data store.
|
||||||
|
*
|
||||||
|
* @property key The key of the added value.
|
||||||
|
* @property value The value that was added.
|
||||||
|
* @property timestamp The timestamp of the event.
|
||||||
|
*/
|
||||||
|
override val key: String,
|
||||||
|
val value: Any?,
|
||||||
|
override val timestamp: Long = Clock.System.now().toEpochMilliseconds(),
|
||||||
|
) : DataStoreChangeEvent()
|
||||||
|
|
||||||
|
data class ValueUpdated(
|
||||||
|
/**
|
||||||
|
* Represents the update of an existing value in the data store.
|
||||||
|
*
|
||||||
|
* @property key The key of the updated value.
|
||||||
|
* @property oldValue The previous value before the update.
|
||||||
|
* @property newValue The new value after the update.
|
||||||
|
* @property timestamp The timestamp of the event.
|
||||||
|
*/
|
||||||
|
override val key: String,
|
||||||
|
val oldValue: Any?,
|
||||||
|
val newValue: Any?,
|
||||||
|
override val timestamp: Long = Clock.System.now().toEpochMilliseconds(),
|
||||||
|
) : DataStoreChangeEvent()
|
||||||
|
|
||||||
|
data class ValueRemoved(
|
||||||
|
/**
|
||||||
|
* Represents the removal of a value from the data store.
|
||||||
|
*
|
||||||
|
* @property key The key of the removed value.
|
||||||
|
* @property oldValue The value that was removed.
|
||||||
|
* @property timestamp The timestamp of the event.
|
||||||
|
*/
|
||||||
|
override val key: String,
|
||||||
|
val oldValue: Any?,
|
||||||
|
override val timestamp: Long = Clock.System.now().toEpochMilliseconds(),
|
||||||
|
) : DataStoreChangeEvent()
|
||||||
|
|
||||||
|
data class StoreCleared(
|
||||||
|
/**
|
||||||
|
* Represents the clearing of the entire data store.
|
||||||
|
*
|
||||||
|
* @property key The key is typically "*" for store cleared events.
|
||||||
|
* @property timestamp The timestamp of the event.
|
||||||
|
*/
|
||||||
|
override val key: String = "*",
|
||||||
|
override val timestamp: Long = Clock.System.now().toEpochMilliseconds(),
|
||||||
|
) : DataStoreChangeEvent()
|
||||||
|
}
|
||||||
@ -0,0 +1,81 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package template.core.base.datastore.contracts
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.serialization.KSerializer
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A reactive interface for observing changes in a data store.
|
||||||
|
*
|
||||||
|
* This interface extends [CacheableDataStore] and provides methods to observe changes in the data store
|
||||||
|
* using Kotlin Flow. It enables reactive programming patterns by emitting updates whenever the underlying
|
||||||
|
* data changes.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* // Observe a simple value
|
||||||
|
* dataStore.observeValue("theme", "light")
|
||||||
|
* .collect { theme -> println("Theme changed to: $theme") }
|
||||||
|
*
|
||||||
|
* // Observe a serializable value
|
||||||
|
* dataStore.observeSerializableValue("user", defaultUser, User.serializer())
|
||||||
|
* .collect { user -> println("User updated: $user") }
|
||||||
|
*
|
||||||
|
* // Observe all keys
|
||||||
|
* dataStore.observeKeys()
|
||||||
|
* .collect { keys -> println("Available keys: $keys") }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
interface ReactiveDataStore : CacheableDataStore {
|
||||||
|
/**
|
||||||
|
* Observes changes to a value associated with the specified key.
|
||||||
|
*
|
||||||
|
* @param key The key to observe.
|
||||||
|
* @param default The default value to emit if the key does not exist.
|
||||||
|
* @return A [Flow] that emits the current value and subsequent updates.
|
||||||
|
*/
|
||||||
|
fun <T> observeValue(key: String, default: T): Flow<T>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observes changes to a serializable value associated with the specified key.
|
||||||
|
*
|
||||||
|
* @param key The key to observe.
|
||||||
|
* @param default The default value to emit if the key does not exist.
|
||||||
|
* @param serializer The serializer for the value type.
|
||||||
|
* @return A [Flow] that emits the current value and subsequent updates.
|
||||||
|
*/
|
||||||
|
fun <T> observeSerializableValue(
|
||||||
|
key: String,
|
||||||
|
default: T,
|
||||||
|
serializer: KSerializer<T>,
|
||||||
|
): Flow<T>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observes changes to the set of keys in the data store.
|
||||||
|
*
|
||||||
|
* @return A [Flow] that emits the current set of keys and subsequent updates.
|
||||||
|
*/
|
||||||
|
fun observeKeys(): Flow<Set<String>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observes changes to the total number of entries in the data store.
|
||||||
|
*
|
||||||
|
* @return A [Flow] that emits the current size and subsequent updates.
|
||||||
|
*/
|
||||||
|
fun observeSize(): Flow<Int>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observes all changes that occur in the data store.
|
||||||
|
*
|
||||||
|
* @return A [Flow] that emits [DataStoreChangeEvent] instances for all changes.
|
||||||
|
*/
|
||||||
|
fun observeChanges(): Flow<DataStoreChangeEvent>
|
||||||
|
}
|
||||||
@ -0,0 +1,83 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package template.core.base.datastore.contracts
|
||||||
|
|
||||||
|
import kotlinx.serialization.KSerializer
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A type-safe interface for managing persistent data storage operations.
|
||||||
|
*
|
||||||
|
* This interface extends [DataStore] and provides methods for storing and retrieving typed data
|
||||||
|
* with proper serialization support. It ensures type safety through generic parameters and
|
||||||
|
* serialization mechanisms.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* // Store a simple value
|
||||||
|
* dataStore.putValue("theme", "dark")
|
||||||
|
*
|
||||||
|
* // Store a serializable object
|
||||||
|
* dataStore.putSerializableValue("user", user, User.serializer())
|
||||||
|
*
|
||||||
|
* // Retrieve values
|
||||||
|
* val theme = dataStore.getValue("theme", "light")
|
||||||
|
* val user = dataStore.getSerializableValue("user", defaultUser, User.serializer())
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @param T The generic type parameter representing the data type to be stored
|
||||||
|
* @param K The generic type parameter representing the key type used for data identification
|
||||||
|
*/
|
||||||
|
interface TypedDataStore : DataStore {
|
||||||
|
/**
|
||||||
|
* Stores a value of type [T] associated with the specified key.
|
||||||
|
*
|
||||||
|
* @param key The unique identifier for the value
|
||||||
|
* @param value The value to be stored
|
||||||
|
* @return [Result.success] if the operation succeeds, or [Result.failure] if an error occurs
|
||||||
|
*/
|
||||||
|
suspend fun <T> putValue(key: String, value: T): Result<Unit>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves a value of type [T] associated with the specified key.
|
||||||
|
*
|
||||||
|
* @param key The unique identifier for the value
|
||||||
|
* @param default The default value to return if the key does not exist
|
||||||
|
* @return [Result.success] containing the retrieved value, or [Result.failure] if an error occurs
|
||||||
|
*/
|
||||||
|
suspend fun <T> getValue(key: String, default: T): Result<T>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores a serializable value of type [T] using the provided serializer.
|
||||||
|
*
|
||||||
|
* @param key The unique identifier for the value
|
||||||
|
* @param value The serializable value to be stored
|
||||||
|
* @param serializer The [KSerializer] for type [T]
|
||||||
|
* @return [Result.success] if the operation succeeds, or [Result.failure] if an error occurs
|
||||||
|
*/
|
||||||
|
suspend fun <T> putSerializableValue(
|
||||||
|
key: String,
|
||||||
|
value: T,
|
||||||
|
serializer: KSerializer<T>,
|
||||||
|
): Result<Unit>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves a serializable value of type [T] using the provided serializer.
|
||||||
|
*
|
||||||
|
* @param key The unique identifier for the value
|
||||||
|
* @param default The default value to return if the key does not exist
|
||||||
|
* @param serializer The [KSerializer] for type [T]
|
||||||
|
* @return [Result.success] containing the deserialized value, or [Result.failure] if an error occurs
|
||||||
|
*/
|
||||||
|
suspend fun <T> getSerializableValue(
|
||||||
|
key: String,
|
||||||
|
default: T,
|
||||||
|
serializer: KSerializer<T>,
|
||||||
|
): Result<T>
|
||||||
|
}
|
||||||
@ -0,0 +1,57 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package template.core.base.datastore.di
|
||||||
|
|
||||||
|
import com.russhwolf.settings.Settings
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import org.koin.dsl.module
|
||||||
|
import template.core.base.datastore.contracts.ReactiveDataStore
|
||||||
|
import template.core.base.datastore.factory.DataStoreFactory
|
||||||
|
import template.core.base.datastore.reactive.PreferenceFlowOperators
|
||||||
|
import template.core.base.datastore.repository.ReactivePreferencesRepository
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Koin module for providing core datastore dependencies.
|
||||||
|
*
|
||||||
|
* Usage Example:
|
||||||
|
* ```kotlin
|
||||||
|
* startKoin {
|
||||||
|
* modules(CoreDatastoreModule)
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
val CoreDatastoreModule = module {
|
||||||
|
|
||||||
|
// Platform-specific Settings instance
|
||||||
|
single<Settings> { Settings() }
|
||||||
|
|
||||||
|
// Main reactive datastore repository (recommended for most use cases)
|
||||||
|
single<ReactivePreferencesRepository> {
|
||||||
|
DataStoreFactory()
|
||||||
|
.settings(get())
|
||||||
|
.dispatcher(Dispatchers.Unconfined)
|
||||||
|
.cacheSize(200)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Direct access to reactive datastore (if needed for specific use cases)
|
||||||
|
single<ReactiveDataStore> {
|
||||||
|
DataStoreFactory()
|
||||||
|
.settings(get())
|
||||||
|
.dispatcher(Dispatchers.Main)
|
||||||
|
.cacheSize(200)
|
||||||
|
.buildDataStore()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flow operators for advanced reactive operations
|
||||||
|
single<PreferenceFlowOperators> {
|
||||||
|
PreferenceFlowOperators(get<ReactivePreferencesRepository>())
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,91 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package template.core.base.datastore.exceptions
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base exception for all preference-related errors in the data store.
|
||||||
|
*
|
||||||
|
* @param message The error message.
|
||||||
|
* @param cause The cause of the exception, if any.
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
* ```kotlin
|
||||||
|
* throw PreferencesException("Something went wrong")
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
sealed class PreferencesException(message: String, cause: Throwable? = null) :
|
||||||
|
Exception(message, cause)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when an invalid key is used in the data store.
|
||||||
|
*
|
||||||
|
* @param key The invalid key.
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
* ```kotlin
|
||||||
|
* throw InvalidKeyException("invalid-key")
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
class InvalidKeyException(key: String) : PreferencesException("Invalid key: $key")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when serialization of a value fails.
|
||||||
|
*
|
||||||
|
* @param message The error message.
|
||||||
|
* @param cause The cause of the exception, if any.
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
* ```kotlin
|
||||||
|
* throw SerializationException("Failed to serialize value")
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
class SerializationException(message: String, cause: Throwable? = null) :
|
||||||
|
PreferencesException("Serialization failed: $message", cause)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when deserialization of a value fails.
|
||||||
|
*
|
||||||
|
* @param message The error message.
|
||||||
|
* @param cause The cause of the exception, if any.
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
* ```kotlin
|
||||||
|
* throw DeserializationException("Failed to deserialize value")
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
class DeserializationException(message: String, cause: Throwable? = null) :
|
||||||
|
PreferencesException("Deserialization failed: $message", cause)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when an unsupported type is used in the data store.
|
||||||
|
*
|
||||||
|
* @param type The unsupported type.
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
* ```kotlin
|
||||||
|
* throw UnsupportedTypeException("CustomType")
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
class UnsupportedTypeException(type: String) :
|
||||||
|
PreferencesException("Unsupported type: $type")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when a cache operation fails in the data store.
|
||||||
|
*
|
||||||
|
* @param message The error message.
|
||||||
|
* @param cause The cause of the exception, if any.
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
* ```kotlin
|
||||||
|
* throw CacheException("Failed to cache value")
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
class CacheException(message: String, cause: Throwable? = null) :
|
||||||
|
PreferencesException("Cache operation failed: $message", cause)
|
||||||
@ -0,0 +1,162 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package template.core.base.datastore.extensions
|
||||||
|
|
||||||
|
import kotlinx.coroutines.FlowPreview
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.catch
|
||||||
|
import kotlinx.coroutines.flow.debounce
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.filter
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import template.core.base.datastore.contracts.DataStoreChangeEvent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides extension functions for Kotlin [Flow] related to data store operations.
|
||||||
|
*
|
||||||
|
* These extensions offer convenient ways to work with preference flows,
|
||||||
|
* such as mapping values with default handling, filtering by change type, and logging changes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps values emitted by the flow using the [transform] function and catches any errors,
|
||||||
|
* emitting the [default] value in case of failure.
|
||||||
|
*
|
||||||
|
* @param default The default value to emit if the transformation or upstream flow encounters an error.
|
||||||
|
* @param transform The function to apply to each value emitted by the flow.
|
||||||
|
* @return A [Flow] emitting the transformed values or the default value on error.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* flowOf("1", "2", "abc") // Example flow of strings
|
||||||
|
* .mapWithDefault(0) { it.toInt() } // Transform to Int, use 0 if parsing fails
|
||||||
|
* .collect { value -> println(value) } // Prints 1, 2, 0
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
fun <T, R> Flow<T>.mapWithDefault(default: R, transform: (T) -> R): Flow<R> {
|
||||||
|
return this.map { transform(it) }
|
||||||
|
.catch { emit(default) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters a flow of [DataStoreChangeEvent] instances to emit only events of a specific type [T].
|
||||||
|
*
|
||||||
|
* This is useful for reacting only to specific changes like value additions or removals.
|
||||||
|
*
|
||||||
|
* @param T The specific type of [DataStoreChangeEvent] to filter for.
|
||||||
|
* @return A [Flow] emitting only [DataStoreChangeEvent] instances of type [T].
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* dataStore.observeChanges()
|
||||||
|
* .filterChangeType<DataStoreChangeEvent.ValueAdded>()
|
||||||
|
* .collect { event -> println("New value added: ${event.key}") }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
inline fun <reified T : DataStoreChangeEvent> Flow<DataStoreChangeEvent>.filterChangeType(): Flow<T> {
|
||||||
|
return this.map { it as? T }.filter { it != null }.map { it!! }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters a flow of [DataStoreChangeEvent] instances to emit only [ValueAdded] events.
|
||||||
|
*
|
||||||
|
* This is a convenience function equivalent to `filterChangeType<DataStoreChangeEvent.ValueAdded>()`.
|
||||||
|
*
|
||||||
|
* @return A [Flow] emitting only [DataStoreChangeEvent.ValueAdded] instances.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* dataStore.observeChanges()
|
||||||
|
* .onlyAdditions()
|
||||||
|
* .collect { event -> println("Value added: ${event.key}") }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
fun Flow<DataStoreChangeEvent>.onlyAdditions(): Flow<DataStoreChangeEvent.ValueAdded> {
|
||||||
|
return filterChangeType()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters a flow of [DataStoreChangeEvent] instances to emit only [ValueUpdated] events.
|
||||||
|
*
|
||||||
|
* This is a convenience function equivalent to `filterChangeType<DataStoreChangeEvent.ValueUpdated>()`.
|
||||||
|
*
|
||||||
|
* @return A [Flow] emitting only [DataStoreChangeEvent.ValueUpdated] instances.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* dataStore.observeChanges()
|
||||||
|
* .onlyUpdates()
|
||||||
|
* .collect { event -> println("Value updated: ${event.key}") }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
fun Flow<DataStoreChangeEvent>.onlyUpdates(): Flow<DataStoreChangeEvent.ValueUpdated> {
|
||||||
|
return filterChangeType()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters a flow of [DataStoreChangeEvent] instances to emit only [ValueRemoved] events.
|
||||||
|
*
|
||||||
|
* This is a convenience function equivalent to `filterChangeType<DataStoreChangeEvent.ValueRemoved>()`.
|
||||||
|
*
|
||||||
|
* @return A [Flow] emitting only [DataStoreChangeEvent.ValueRemoved] instances.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* dataStore.observeChanges()
|
||||||
|
* .onlyRemovals()
|
||||||
|
* .collect { event -> println("Value removed: ${event.key}") }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
fun Flow<DataStoreChangeEvent>.onlyRemovals(): Flow<DataStoreChangeEvent.ValueRemoved> {
|
||||||
|
return filterChangeType()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debounces a flow of preference changes to avoid excessive emissions.
|
||||||
|
*
|
||||||
|
* This uses [distinctUntilChanged] as a simple debouncing mechanism. For true time-based debouncing,
|
||||||
|
* consider using a library like `kotlinx-coroutines-core` with its `debounce` operator.
|
||||||
|
*
|
||||||
|
* @param timeoutMillis The timeout duration in milliseconds (currently not used for time-based debouncing).
|
||||||
|
* @return A [Flow] that suppresses consecutive duplicate emissions.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* preferencesFlow
|
||||||
|
* .debouncePreferences(300) // Suppress rapid identical emissions
|
||||||
|
* .collect { value -> println("Debounced value: $value") }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
@OptIn(FlowPreview::class)
|
||||||
|
fun <T> Flow<T>.debouncePreferences(timeoutMillis: Long = 300): Flow<T> {
|
||||||
|
// Note: This would require kotlinx-coroutines-core with debounce support
|
||||||
|
// For now, we'll use distinctUntilChanged as a simple approach
|
||||||
|
return this.debounce(timeoutMillis)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs each value emitted by the flow for debugging purposes.
|
||||||
|
*
|
||||||
|
* @param tag A tag to include in the log output.
|
||||||
|
* @return The original [Flow].
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* preferencesFlow
|
||||||
|
* .logChanges("MyAppPrefs")
|
||||||
|
* .collect { value -> // Process value }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
fun <T> Flow<T>.logChanges(tag: String = "DataStore"): Flow<T> {
|
||||||
|
return this.onEach { value ->
|
||||||
|
println("[$tag] Value changed: $value")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,282 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package template.core.base.datastore.factory
|
||||||
|
|
||||||
|
import com.russhwolf.settings.Settings
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import template.core.base.datastore.cache.CacheManager
|
||||||
|
import template.core.base.datastore.cache.LruCacheManager
|
||||||
|
import template.core.base.datastore.contracts.ReactiveDataStore
|
||||||
|
import template.core.base.datastore.handlers.BooleanTypeHandler
|
||||||
|
import template.core.base.datastore.handlers.DoubleTypeHandler
|
||||||
|
import template.core.base.datastore.handlers.FloatTypeHandler
|
||||||
|
import template.core.base.datastore.handlers.IntTypeHandler
|
||||||
|
import template.core.base.datastore.handlers.LongTypeHandler
|
||||||
|
import template.core.base.datastore.handlers.StringTypeHandler
|
||||||
|
import template.core.base.datastore.handlers.TypeHandler
|
||||||
|
import template.core.base.datastore.reactive.ChangeNotifier
|
||||||
|
import template.core.base.datastore.reactive.DefaultChangeNotifier
|
||||||
|
import template.core.base.datastore.reactive.DefaultValueObserver
|
||||||
|
import template.core.base.datastore.repository.DefaultReactivePreferencesRepository
|
||||||
|
import template.core.base.datastore.repository.ReactivePreferencesRepository
|
||||||
|
import template.core.base.datastore.serialization.JsonSerializationStrategy
|
||||||
|
import template.core.base.datastore.serialization.SerializationStrategy
|
||||||
|
import template.core.base.datastore.store.ReactiveUserPreferencesDataStore
|
||||||
|
import template.core.base.datastore.validation.DefaultPreferencesValidator
|
||||||
|
import template.core.base.datastore.validation.PreferencesValidator
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory for constructing reactive data store repositories and data stores with customizable configuration.
|
||||||
|
*
|
||||||
|
* This class uses the builder pattern to allow flexible configuration of settings, dispatcher,
|
||||||
|
* cache size, validator, serialization strategy, and change notifier.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* // Simple usage with defaults
|
||||||
|
* val repository = DataStoreFactory.create()
|
||||||
|
*
|
||||||
|
* // Custom configuration
|
||||||
|
* val repository = DataStoreFactory()
|
||||||
|
* .cacheSize(500)
|
||||||
|
* .dispatcher(Dispatchers.IO)
|
||||||
|
* .settings(MyCustomSettings())
|
||||||
|
* .build()
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
@Deprecated("Use Settings Library instead")
|
||||||
|
class DataStoreFactory {
|
||||||
|
private var settings: Settings? = null
|
||||||
|
private var dispatcher: CoroutineDispatcher = Dispatchers.Default
|
||||||
|
private var cacheSize: Int = 200
|
||||||
|
private var validator: PreferencesValidator? = null
|
||||||
|
private var serializationStrategy: SerializationStrategy? = null
|
||||||
|
private var changeNotifier: ChangeNotifier? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets a custom [Settings] implementation for the data store.
|
||||||
|
*
|
||||||
|
* If not provided, the default [Settings] implementation will be used.
|
||||||
|
*
|
||||||
|
* @param settings The [Settings] instance to use.
|
||||||
|
* @return This [DataStoreFactory] instance for chaining.
|
||||||
|
*/
|
||||||
|
fun settings(settings: Settings) = apply {
|
||||||
|
this.settings = settings
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the coroutine [dispatcher] for data store operations.
|
||||||
|
*
|
||||||
|
* The default is [Dispatchers.Default].
|
||||||
|
*
|
||||||
|
* @param dispatcher The [CoroutineDispatcher] to use.
|
||||||
|
* @return This [DataStoreFactory] instance for chaining.
|
||||||
|
*/
|
||||||
|
fun dispatcher(dispatcher: CoroutineDispatcher) = apply {
|
||||||
|
this.dispatcher = dispatcher
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the cache size for the LRU cache.
|
||||||
|
*
|
||||||
|
* The default is 200 entries.
|
||||||
|
*
|
||||||
|
* @param size The maximum number of entries in the cache.
|
||||||
|
* @return This [DataStoreFactory] instance for chaining.
|
||||||
|
*/
|
||||||
|
fun cacheSize(size: Int) = apply {
|
||||||
|
this.cacheSize = size
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets a custom [PreferencesValidator] for validating keys and values.
|
||||||
|
*
|
||||||
|
* If not provided, the default validator will be used.
|
||||||
|
*
|
||||||
|
* @param validator The [PreferencesValidator] to use.
|
||||||
|
* @return This [DataStoreFactory] instance for chaining.
|
||||||
|
*/
|
||||||
|
fun validator(validator: PreferencesValidator) = apply {
|
||||||
|
this.validator = validator
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets a custom [SerializationStrategy] for serializing and deserializing values.
|
||||||
|
*
|
||||||
|
* If not provided, the default [JsonSerializationStrategy] will be used.
|
||||||
|
*
|
||||||
|
* @param strategy The [SerializationStrategy] to use.
|
||||||
|
* @return This [DataStoreFactory] instance for chaining.
|
||||||
|
*/
|
||||||
|
fun serializationStrategy(strategy: SerializationStrategy) = apply {
|
||||||
|
this.serializationStrategy = strategy
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets a custom [ChangeNotifier] for broadcasting change events.
|
||||||
|
*
|
||||||
|
* If not provided, the default [DefaultChangeNotifier] will be used.
|
||||||
|
*
|
||||||
|
* @param notifier The [ChangeNotifier] to use.
|
||||||
|
* @return This [DataStoreFactory] instance for chaining.
|
||||||
|
*/
|
||||||
|
fun changeNotifier(notifier: ChangeNotifier) = apply {
|
||||||
|
this.changeNotifier = notifier
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds and returns a [ReactivePreferencesRepository] with the current configuration.
|
||||||
|
*
|
||||||
|
* @return A fully configured [ReactivePreferencesRepository].
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* val repository = DataStoreFactory().build()
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
fun build(): ReactivePreferencesRepository {
|
||||||
|
val finalSettings = settings ?: Settings()
|
||||||
|
val finalValidator = validator ?: DefaultPreferencesValidator()
|
||||||
|
val finalSerializationStrategy = serializationStrategy ?: JsonSerializationStrategy()
|
||||||
|
val finalChangeNotifier = changeNotifier ?: DefaultChangeNotifier()
|
||||||
|
|
||||||
|
val cacheManager: CacheManager<String, Any> = LruCacheManager(maxSize = cacheSize)
|
||||||
|
val valueObserver = DefaultValueObserver(finalChangeNotifier)
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val typeHandlers: List<TypeHandler<Any>> = listOf(
|
||||||
|
IntTypeHandler(),
|
||||||
|
StringTypeHandler(),
|
||||||
|
BooleanTypeHandler(),
|
||||||
|
LongTypeHandler(),
|
||||||
|
FloatTypeHandler(),
|
||||||
|
DoubleTypeHandler(),
|
||||||
|
) as List<TypeHandler<Any>>
|
||||||
|
|
||||||
|
val reactiveDataStore = ReactiveUserPreferencesDataStore(
|
||||||
|
settings = finalSettings,
|
||||||
|
dispatcher = dispatcher,
|
||||||
|
typeHandlers = typeHandlers,
|
||||||
|
serializationStrategy = finalSerializationStrategy,
|
||||||
|
validator = finalValidator,
|
||||||
|
cacheManager = cacheManager,
|
||||||
|
changeNotifier = finalChangeNotifier,
|
||||||
|
valueObserver = valueObserver,
|
||||||
|
)
|
||||||
|
|
||||||
|
return DefaultReactivePreferencesRepository(reactiveDataStore)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds and returns a [ReactiveDataStore] with the current configuration, without the repository wrapper.
|
||||||
|
*
|
||||||
|
* Use this if you need direct access to data store methods.
|
||||||
|
*
|
||||||
|
* @return A fully configured [ReactiveDataStore].
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* val dataStore = DataStoreFactory().buildDataStore()
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
fun buildDataStore(): ReactiveDataStore {
|
||||||
|
val finalSettings = settings ?: Settings()
|
||||||
|
val finalValidator = validator ?: DefaultPreferencesValidator()
|
||||||
|
val finalSerializationStrategy = serializationStrategy ?: JsonSerializationStrategy()
|
||||||
|
val finalChangeNotifier = changeNotifier ?: DefaultChangeNotifier()
|
||||||
|
|
||||||
|
val cacheManager: CacheManager<String, Any> = LruCacheManager(maxSize = cacheSize)
|
||||||
|
val valueObserver = DefaultValueObserver(finalChangeNotifier)
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val typeHandlers: List<TypeHandler<Any>> = listOf(
|
||||||
|
IntTypeHandler(),
|
||||||
|
StringTypeHandler(),
|
||||||
|
BooleanTypeHandler(),
|
||||||
|
LongTypeHandler(),
|
||||||
|
FloatTypeHandler(),
|
||||||
|
DoubleTypeHandler(),
|
||||||
|
) as List<TypeHandler<Any>>
|
||||||
|
|
||||||
|
return ReactiveUserPreferencesDataStore(
|
||||||
|
settings = finalSettings,
|
||||||
|
dispatcher = dispatcher,
|
||||||
|
typeHandlers = typeHandlers,
|
||||||
|
serializationStrategy = finalSerializationStrategy,
|
||||||
|
validator = finalValidator,
|
||||||
|
cacheManager = cacheManager,
|
||||||
|
changeNotifier = finalChangeNotifier,
|
||||||
|
valueObserver = valueObserver,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* Creates a [ReactivePreferencesRepository] with default configuration.
|
||||||
|
*
|
||||||
|
* This is the simplest way to obtain a working data store repository.
|
||||||
|
*
|
||||||
|
* @return A [ReactivePreferencesRepository] with default settings.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* val repository = DataStoreFactory.create()
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
fun create(): ReactivePreferencesRepository {
|
||||||
|
return DataStoreFactory().build()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a [ReactivePreferencesRepository] with a custom [Settings] instance.
|
||||||
|
*
|
||||||
|
* Useful for providing platform-specific settings.
|
||||||
|
*
|
||||||
|
* @param settings The [Settings] instance to use.
|
||||||
|
* @return A [ReactivePreferencesRepository] with the specified settings.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* val repository = DataStoreFactory.create(customSettings)
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
fun create(settings: Settings): ReactivePreferencesRepository {
|
||||||
|
return DataStoreFactory()
|
||||||
|
.settings(settings)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a [ReactivePreferencesRepository] with a custom [Settings] instance and [CoroutineDispatcher].
|
||||||
|
*
|
||||||
|
* Useful for platform-specific configurations (e.g., Android/iOS).
|
||||||
|
*
|
||||||
|
* @param settings The [Settings] instance to use.
|
||||||
|
* @param dispatcher The [CoroutineDispatcher] to use.
|
||||||
|
* @return A [ReactivePreferencesRepository] with the specified settings and dispatcher.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* val repository = DataStoreFactory.create(customSettings, Dispatchers.IO)
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
fun create(
|
||||||
|
settings: Settings,
|
||||||
|
dispatcher: CoroutineDispatcher,
|
||||||
|
): ReactivePreferencesRepository {
|
||||||
|
return DataStoreFactory()
|
||||||
|
.settings(settings)
|
||||||
|
.dispatcher(dispatcher)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,162 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package template.core.base.datastore.handlers
|
||||||
|
|
||||||
|
import com.russhwolf.settings.Settings
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler for storing and retrieving [Int] values in the data store.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* val handler = IntTypeHandler()
|
||||||
|
* handler.put(settings, "count", 42)
|
||||||
|
* val value = handler.get(settings, "count", 0)
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
class IntTypeHandler : TypeHandler<Int> {
|
||||||
|
override suspend fun put(settings: Settings, key: String, value: Int): Result<Unit> {
|
||||||
|
println("[IntTypeHandler] put: key=$key, value=$value")
|
||||||
|
return runCatching { settings.putInt(key, value) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun get(settings: Settings, key: String, default: Int): Result<Int> {
|
||||||
|
val result = runCatching { settings.getInt(key, default) }
|
||||||
|
println("[IntTypeHandler] get: key=$key, result=$result")
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun canHandle(value: Any?): Boolean = value is Int
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler for storing and retrieving [String] values in the data store.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* val handler = StringTypeHandler()
|
||||||
|
* handler.put(settings, "username", "admin")
|
||||||
|
* val value = handler.get(settings, "username", "default")
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
class StringTypeHandler : TypeHandler<String> {
|
||||||
|
override suspend fun put(settings: Settings, key: String, value: String): Result<Unit> {
|
||||||
|
println("[StringTypeHandler] put: key=$key, value=$value")
|
||||||
|
return runCatching { settings.putString(key, value) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun get(settings: Settings, key: String, default: String): Result<String> {
|
||||||
|
val result = runCatching { settings.getString(key, default) }
|
||||||
|
println("[StringTypeHandler] get: key=$key, result=$result")
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun canHandle(value: Any?): Boolean = value is String
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler for storing and retrieving [Boolean] values in the data store.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* val handler = BooleanTypeHandler()
|
||||||
|
* handler.put(settings, "enabled", true)
|
||||||
|
* val value = handler.get(settings, "enabled", false)
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
class BooleanTypeHandler : TypeHandler<Boolean> {
|
||||||
|
override suspend fun put(settings: Settings, key: String, value: Boolean): Result<Unit> {
|
||||||
|
println("[BooleanTypeHandler] put: key=$key, value=$value")
|
||||||
|
return runCatching { settings.putBoolean(key, value) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun get(settings: Settings, key: String, default: Boolean): Result<Boolean> {
|
||||||
|
val result = runCatching { settings.getBoolean(key, default) }
|
||||||
|
println("[BooleanTypeHandler] get: key=$key, result=$result")
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun canHandle(value: Any?): Boolean = value is Boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler for storing and retrieving [Long] values in the data store.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* val handler = LongTypeHandler()
|
||||||
|
* handler.put(settings, "timestamp", 123456789L)
|
||||||
|
* val value = handler.get(settings, "timestamp", 0L)
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
class LongTypeHandler : TypeHandler<Long> {
|
||||||
|
override suspend fun put(settings: Settings, key: String, value: Long): Result<Unit> {
|
||||||
|
println("[LongTypeHandler] put: key=$key, value=$value")
|
||||||
|
return runCatching { settings.putLong(key, value) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun get(settings: Settings, key: String, default: Long): Result<Long> {
|
||||||
|
val result = runCatching { settings.getLong(key, default) }
|
||||||
|
println("[LongTypeHandler] get: key=$key, result=$result")
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun canHandle(value: Any?): Boolean = value is Long
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler for storing and retrieving [Float] values in the data store.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* val handler = FloatTypeHandler()
|
||||||
|
* handler.put(settings, "ratio", 0.5f)
|
||||||
|
* val value = handler.get(settings, "ratio", 0.0f)
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
class FloatTypeHandler : TypeHandler<Float> {
|
||||||
|
override suspend fun put(settings: Settings, key: String, value: Float): Result<Unit> {
|
||||||
|
println("[FloatTypeHandler] put: key=$key, value=$value")
|
||||||
|
return runCatching { settings.putFloat(key, value) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun get(settings: Settings, key: String, default: Float): Result<Float> {
|
||||||
|
val result = runCatching { settings.getFloat(key, default) }
|
||||||
|
println("[FloatTypeHandler] get: key=$key, result=$result")
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun canHandle(value: Any?): Boolean = value is Float
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler for storing and retrieving [Double] values in the data store.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* val handler = DoubleTypeHandler()
|
||||||
|
* handler.put(settings, "score", 99.9)
|
||||||
|
* val value = handler.get(settings, "score", 0.0)
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
class DoubleTypeHandler : TypeHandler<Double> {
|
||||||
|
override suspend fun put(settings: Settings, key: String, value: Double): Result<Unit> {
|
||||||
|
println("[DoubleTypeHandler] put: key=$key, value=$value")
|
||||||
|
return runCatching { settings.putDouble(key, value) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun get(settings: Settings, key: String, default: Double): Result<Double> {
|
||||||
|
val result = runCatching { settings.getDouble(key, default) }
|
||||||
|
println("[DoubleTypeHandler] get: key=$key, result=$result")
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun canHandle(value: Any?): Boolean = value is Double
|
||||||
|
}
|
||||||
@ -0,0 +1,57 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package template.core.base.datastore.handlers
|
||||||
|
|
||||||
|
import com.russhwolf.settings.Settings
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for handling type conversions and storage operations in the data store.
|
||||||
|
*
|
||||||
|
* Implementations of this interface provide methods for storing, retrieving,
|
||||||
|
* and checking support for specific types in the underlying settings storage.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* val handler: TypeHandler<Int> = IntTypeHandler()
|
||||||
|
* handler.put(settings, "count", 42)
|
||||||
|
* val value = handler.get(settings, "count", 0)
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @param T The type to be handled by this handler.
|
||||||
|
*/
|
||||||
|
interface TypeHandler<T> {
|
||||||
|
/**
|
||||||
|
* Stores a value of type [T] in the settings storage.
|
||||||
|
*
|
||||||
|
* @param settings The settings storage instance.
|
||||||
|
* @param key The key to associate with the value.
|
||||||
|
* @param value The value to store.
|
||||||
|
* @return [Result.success] if the operation succeeds, or [Result.failure] if an error occurs.
|
||||||
|
*/
|
||||||
|
suspend fun put(settings: Settings, key: String, value: T): Result<Unit>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves a value of type [T] from the settings storage.
|
||||||
|
*
|
||||||
|
* @param settings The settings storage instance.
|
||||||
|
* @param key The key to retrieve.
|
||||||
|
* @param default The default value to return if the key does not exist.
|
||||||
|
* @return [Result.success] with the value, or [Result.failure] if an error occurs.
|
||||||
|
*/
|
||||||
|
suspend fun get(settings: Settings, key: String, default: T): Result<T>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines whether this handler can process the given value.
|
||||||
|
*
|
||||||
|
* @param value The value to check.
|
||||||
|
* @return `true` if this handler can process the value, `false` otherwise.
|
||||||
|
*/
|
||||||
|
fun canHandle(value: Any?): Boolean
|
||||||
|
}
|
||||||
@ -0,0 +1,57 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package template.core.base.datastore.reactive
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import template.core.base.datastore.contracts.DataStoreChangeEvent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for notifying and observing changes in the data store.
|
||||||
|
*
|
||||||
|
* Implementations of this interface allow for broadcasting change events
|
||||||
|
* and subscribing to them, enabling reactive updates throughout the application.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* val notifier: ChangeNotifier = DefaultChangeNotifier()
|
||||||
|
* notifier.notifyChange(DataStoreChangeEvent.ValueAdded("key", "value"))
|
||||||
|
* notifier.observeChanges().collect { event -> println(event) }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
interface ChangeNotifier {
|
||||||
|
/**
|
||||||
|
* Notifies listeners of a change event in the data store.
|
||||||
|
*
|
||||||
|
* @param change The event describing the change.
|
||||||
|
*/
|
||||||
|
fun notifyChange(change: DataStoreChangeEvent)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observes all change events in the data store.
|
||||||
|
*
|
||||||
|
* @return A [Flow] emitting [DataStoreChangeEvent] instances as changes occur.
|
||||||
|
*/
|
||||||
|
fun observeChanges(): Flow<DataStoreChangeEvent>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observes change events for a specific key in the data store.
|
||||||
|
*
|
||||||
|
* @param key The key to observe for changes.
|
||||||
|
* @return A [Flow] emitting [DataStoreChangeEvent] instances related to the specified key.
|
||||||
|
*/
|
||||||
|
fun observeKeyChanges(key: String): Flow<DataStoreChangeEvent>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears all listeners and resources associated with this notifier.
|
||||||
|
*
|
||||||
|
* This should be called to release resources when the notifier is no longer needed.
|
||||||
|
*/
|
||||||
|
fun clear()
|
||||||
|
}
|
||||||
@ -0,0 +1,232 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
@file:OptIn(ExperimentalAtomicApi::class)
|
||||||
|
|
||||||
|
package template.core.base.datastore.reactive
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.TimeoutCancellationException
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.channels.BufferOverflow
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.filter
|
||||||
|
import kotlinx.coroutines.flow.onCompletion
|
||||||
|
import kotlinx.coroutines.flow.onStart
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withTimeout
|
||||||
|
import template.core.base.datastore.contracts.DataStoreChangeEvent
|
||||||
|
import kotlin.concurrent.atomics.AtomicBoolean
|
||||||
|
import kotlin.concurrent.atomics.AtomicInt
|
||||||
|
import kotlin.concurrent.atomics.ExperimentalAtomicApi
|
||||||
|
import kotlin.concurrent.atomics.decrementAndFetch
|
||||||
|
import kotlin.concurrent.atomics.incrementAndFetch
|
||||||
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Production-ready implementation of [ChangeNotifier] using SharedFlow for true event broadcasting.
|
||||||
|
*
|
||||||
|
* This implementation ensures all observers receive all events, with configurable buffering and
|
||||||
|
* overflow strategies. It's thread-safe and follows structured concurrency principles.
|
||||||
|
*
|
||||||
|
* Key improvements over Channel-based implementation:
|
||||||
|
* - Multiple observers receive ALL events (not just one)
|
||||||
|
* - Non-blocking event emission
|
||||||
|
* - Configurable replay for late subscribers
|
||||||
|
* - Better memory management with overflow strategies
|
||||||
|
* - Proper structured concurrency (no GlobalScope)
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* val notifier = DefaultChangeNotifier()
|
||||||
|
*
|
||||||
|
* // Multiple observers all receive the same events
|
||||||
|
* val job1 = launch { notifier.observeChanges().collect { println("Observer 1: $it") } }
|
||||||
|
* val job2 = launch { notifier.observeChanges().collect { println("Observer 2: $it") } }
|
||||||
|
*
|
||||||
|
* notifier.notifyChange(DataStoreChangeEvent.ValueAdded("key", "value"))
|
||||||
|
* // Both observers receive the event
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @property replay Number of events to replay to new subscribers (default: 0)
|
||||||
|
* @property extraBufferCapacity Extra buffer capacity beyond replay (default: 64)
|
||||||
|
* @property onBufferOverflow Strategy when buffer is full (default: DROP_OLDEST)
|
||||||
|
* @property scope Optional coroutine scope for handling suspended emissions
|
||||||
|
*/
|
||||||
|
@Suppress("MaxLineLength")
|
||||||
|
class DefaultChangeNotifier(
|
||||||
|
private val replay: Int = 0,
|
||||||
|
private val extraBufferCapacity: Int = 64,
|
||||||
|
private val onBufferOverflow: BufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||||
|
private val scope: CoroutineScope? = null,
|
||||||
|
) : ChangeNotifier {
|
||||||
|
|
||||||
|
private val changeFlow = MutableSharedFlow<DataStoreChangeEvent>(
|
||||||
|
replay = replay,
|
||||||
|
extraBufferCapacity = extraBufferCapacity,
|
||||||
|
onBufferOverflow = onBufferOverflow,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Track active observers for debugging/monitoring
|
||||||
|
private val activeObservers = AtomicInt(0)
|
||||||
|
|
||||||
|
// Track if the notifier has been cleared
|
||||||
|
private val isCleared = AtomicBoolean(false)
|
||||||
|
|
||||||
|
// Internal scope for SUSPEND operations if no external scope provided
|
||||||
|
private val internalScope = scope ?: CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notifies all active listeners of a change event.
|
||||||
|
*
|
||||||
|
* This method is non-blocking and thread-safe. If the buffer is full,
|
||||||
|
* the behavior depends on the configured [onBufferOverflow] strategy.
|
||||||
|
*
|
||||||
|
* For SUSPEND strategy without a provided scope, events will be dropped with a warning.
|
||||||
|
*
|
||||||
|
* @param change The event describing the change.
|
||||||
|
*/
|
||||||
|
override fun notifyChange(change: DataStoreChangeEvent) {
|
||||||
|
if (isCleared.load()) {
|
||||||
|
println("[ChangeNotifier] Attempted to notify after clear: $change")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val emitted = changeFlow.tryEmit(change)
|
||||||
|
|
||||||
|
if (!emitted) {
|
||||||
|
when (onBufferOverflow) {
|
||||||
|
BufferOverflow.DROP_OLDEST -> {
|
||||||
|
// Already handled by SharedFlow internally
|
||||||
|
println("[ChangeNotifier] Buffer full, dropped oldest event for: $change")
|
||||||
|
}
|
||||||
|
|
||||||
|
BufferOverflow.DROP_LATEST -> {
|
||||||
|
println("[ChangeNotifier] Buffer full, dropping event: $change")
|
||||||
|
}
|
||||||
|
|
||||||
|
BufferOverflow.SUSPEND -> {
|
||||||
|
// Only attempt suspended emission if we have a scope
|
||||||
|
if (scope != null || !isCleared.load()) {
|
||||||
|
internalScope.launch {
|
||||||
|
try {
|
||||||
|
// 5 second timeout to prevent indefinite blocking
|
||||||
|
withTimeout(5000) {
|
||||||
|
changeFlow.emit(change)
|
||||||
|
}
|
||||||
|
} catch (e: TimeoutCancellationException) {
|
||||||
|
println("[ChangeNotifier] Timeout emitting event: $change")
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
// Scope was cancelled, ignore
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("[ChangeNotifier] Failed to emit suspended event: $change, error: $e")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
println("[ChangeNotifier] Buffer full, cannot emit with SUSPEND strategy without scope: $change")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suspending version of notifyChange for use within coroutines.
|
||||||
|
* This will suspend until the event can be emitted.
|
||||||
|
*
|
||||||
|
* @param change The event describing the change.
|
||||||
|
* @throws CancellationException if the coroutine is cancelled
|
||||||
|
*/
|
||||||
|
suspend fun notifyChangeSuspend(change: DataStoreChangeEvent) {
|
||||||
|
if (!isCleared.load()) {
|
||||||
|
changeFlow.emit(change)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tries to notify with a result indicating success.
|
||||||
|
*
|
||||||
|
* @param change The event describing the change.
|
||||||
|
* @return true if the event was emitted successfully, false otherwise
|
||||||
|
*/
|
||||||
|
fun tryNotifyChange(change: DataStoreChangeEvent): Boolean {
|
||||||
|
return if (!isCleared.load()) {
|
||||||
|
changeFlow.tryEmit(change)
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observes all change events emitted to this notifier.
|
||||||
|
*
|
||||||
|
* Each collector receives ALL events independently. Late subscribers
|
||||||
|
* will receive replayed events based on the [replay] configuration.
|
||||||
|
*
|
||||||
|
* @return A [Flow] emitting [DataStoreChangeEvent] instances as changes occur.
|
||||||
|
*/
|
||||||
|
override fun observeChanges(): Flow<DataStoreChangeEvent> {
|
||||||
|
return changeFlow
|
||||||
|
.onStart {
|
||||||
|
val count = activeObservers.incrementAndFetch()
|
||||||
|
println("[ChangeNotifier] New observer connected. Total: $count")
|
||||||
|
}
|
||||||
|
.onCompletion {
|
||||||
|
val count = activeObservers.decrementAndFetch()
|
||||||
|
println("[ChangeNotifier] Observer disconnected. Total: $count")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observes change events for a specific key.
|
||||||
|
*
|
||||||
|
* @param key The key to observe for changes.
|
||||||
|
* @return A [Flow] emitting [DataStoreChangeEvent] instances related to the specified key.
|
||||||
|
*/
|
||||||
|
override fun observeKeyChanges(key: String): Flow<DataStoreChangeEvent> {
|
||||||
|
return observeChanges().filter { event ->
|
||||||
|
event.key == key || event.key == "*"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears the notifier and releases resources.
|
||||||
|
*
|
||||||
|
* After calling clear, no new events should be emitted.
|
||||||
|
*/
|
||||||
|
override fun clear() {
|
||||||
|
if (isCleared.compareAndSet(false, true)) {
|
||||||
|
// Cancel internal scope if we created it
|
||||||
|
if (scope == null) {
|
||||||
|
internalScope.cancel()
|
||||||
|
}
|
||||||
|
println("[ChangeNotifier] Cleared. Active observers: ${activeObservers.load()}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current number of active observers.
|
||||||
|
* Useful for debugging and monitoring.
|
||||||
|
*/
|
||||||
|
fun loadActiveObserverCount(): Int = activeObservers.load()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the notifier has been cleared.
|
||||||
|
*/
|
||||||
|
fun isCleared(): Boolean = isCleared.load()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current number of buffered events.
|
||||||
|
* Useful for monitoring buffer usage.
|
||||||
|
*/
|
||||||
|
fun getBufferedEventCount(): Int = changeFlow.subscriptionCount.value
|
||||||
|
}
|
||||||
@ -0,0 +1,79 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package template.core.base.datastore.reactive
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.onStart
|
||||||
|
import template.core.base.datastore.contracts.DataStoreChangeEvent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default implementation of [ValueObserver] for observing preference value changes.
|
||||||
|
*
|
||||||
|
* This observer uses a [ChangeNotifier] to emit value changes as flows, supporting both initial and distinct emissions.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* val observer = DefaultValueObserver(changeNotifier)
|
||||||
|
* observer.createValueFlow("theme", "light") { getTheme() }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @property changeNotifier The notifier used to observe key changes.
|
||||||
|
*/
|
||||||
|
class DefaultValueObserver(
|
||||||
|
private val changeNotifier: ChangeNotifier,
|
||||||
|
) : ValueObserver {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a flow that emits the value for the specified key, starting with an initial emission.
|
||||||
|
*
|
||||||
|
* @param key The preference key to observe.
|
||||||
|
* @param default The default value to emit if retrieval fails.
|
||||||
|
* @param getter A suspend function to retrieve the value.
|
||||||
|
* @return A [Flow] emitting the value for the key.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* observer.createValueFlow("theme", "light") { getTheme() }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
override fun <T> createValueFlow(
|
||||||
|
key: String,
|
||||||
|
default: T,
|
||||||
|
getter: suspend () -> Result<T>,
|
||||||
|
): Flow<T> {
|
||||||
|
return changeNotifier.observeKeyChanges(key)
|
||||||
|
// Trigger initial emission
|
||||||
|
.onStart { emit(DataStoreChangeEvent.ValueAdded(key, null)) }
|
||||||
|
.map { getter().getOrElse { default } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a flow that emits distinct values for the specified key, suppressing duplicates.
|
||||||
|
*
|
||||||
|
* @param key The preference key to observe.
|
||||||
|
* @param default The default value to emit if retrieval fails.
|
||||||
|
* @param getter A suspend function to retrieve the value.
|
||||||
|
* @return A [Flow] emitting only distinct values for the key.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* observer.createDistinctValueFlow("theme", "light") { getTheme() }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
override fun <T> createDistinctValueFlow(
|
||||||
|
key: String,
|
||||||
|
default: T,
|
||||||
|
getter: suspend () -> Result<T>,
|
||||||
|
): Flow<T> {
|
||||||
|
return createValueFlow(key, default, getter).distinctUntilChanged()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,130 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
@file:Suppress("MaxLineLength")
|
||||||
|
|
||||||
|
package template.core.base.datastore.reactive
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.filter
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import template.core.base.datastore.repository.ReactivePreferencesRepository
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides advanced operations for combining, mapping, and observing user preference flows.
|
||||||
|
*
|
||||||
|
* This class enables the combination of multiple preference flows, observation of key changes,
|
||||||
|
* and transformation of observed values.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* val operators = PreferenceFlowOperators(repository)
|
||||||
|
* val combinedFlow = operators.combinePreferences("key1", 0, "key2", "", transform = { a, b -> "$a-$b" })
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @property repository The [ReactivePreferencesRepository] used to observe and manipulate preference flows.
|
||||||
|
*/
|
||||||
|
class PreferenceFlowOperators(
|
||||||
|
private val repository: ReactivePreferencesRepository,
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* Combines two preference flows and emits a value produced by the [transform] function.
|
||||||
|
*
|
||||||
|
* @param key1 The first preference key.
|
||||||
|
* @param default1 The default value for the first preference.
|
||||||
|
* @param key2 The second preference key.
|
||||||
|
* @param default2 The default value for the second preference.
|
||||||
|
* @param transform The function to combine the two values.
|
||||||
|
* @return A [Flow] emitting the combined value.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* operators.combinePreferences("theme", "light", "fontSize", 12) { theme, size -> "$theme-$size" }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
fun <T1, T2, R> combinePreferences(
|
||||||
|
key1: String,
|
||||||
|
default1: T1,
|
||||||
|
key2: String,
|
||||||
|
default2: T2,
|
||||||
|
transform: suspend (T1, T2) -> R,
|
||||||
|
): Flow<R> = combine(
|
||||||
|
repository.observePreference(key1, default1),
|
||||||
|
repository.observePreference(key2, default2),
|
||||||
|
transform,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combines three preference flows and emits a value produced by the [transform] function.
|
||||||
|
*
|
||||||
|
* @param key1 The first preference key.
|
||||||
|
* @param default1 The default value for the first preference.
|
||||||
|
* @param key2 The second preference key.
|
||||||
|
* @param default2 The default value for the second preference.
|
||||||
|
* @param key3 The third preference key.
|
||||||
|
* @param default3 The default value for the third preference.
|
||||||
|
* @param transform The function to combine the three values.
|
||||||
|
* @return A [Flow] emitting the combined value.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* operators.combinePreferences("theme", "light", "fontSize", 12, "lang", "en") { t, s, l -> "$t-$s-$l" }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
fun <T1, T2, T3, R> combinePreferences(
|
||||||
|
key1: String,
|
||||||
|
default1: T1,
|
||||||
|
key2: String,
|
||||||
|
default2: T2,
|
||||||
|
key3: String,
|
||||||
|
default3: T3,
|
||||||
|
transform: suspend (T1, T2, T3) -> R,
|
||||||
|
): Flow<R> = combine(
|
||||||
|
repository.observePreference(key1, default1),
|
||||||
|
repository.observePreference(key2, default2),
|
||||||
|
repository.observePreference(key3, default3),
|
||||||
|
transform,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observes changes to any of the specified keys and emits the key that changed.
|
||||||
|
*
|
||||||
|
* @param keys The keys to observe for changes.
|
||||||
|
* @return A [Flow] emitting the key that changed.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* operators.observeAnyKeyChange("theme", "fontSize").collect { key -> println("Changed: $key") }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
fun observeAnyKeyChange(vararg keys: String): Flow<String> =
|
||||||
|
repository.observePreferenceChanges()
|
||||||
|
.map { it.key }
|
||||||
|
.filter { it in keys }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observes a preference and maps its value using the provided [transform] function.
|
||||||
|
*
|
||||||
|
* @param key The preference key to observe.
|
||||||
|
* @param default The default value for the preference.
|
||||||
|
* @param transform The function to map the preference value.
|
||||||
|
* @return A [Flow] emitting the mapped value.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* operators.observeMappedPreference("theme", "light") { it.uppercase() }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
fun <T, R> observeMappedPreference(
|
||||||
|
key: String,
|
||||||
|
default: T,
|
||||||
|
transform: suspend (T) -> R,
|
||||||
|
): Flow<R> = repository.observePreference(key, default).map(transform)
|
||||||
|
}
|
||||||
@ -0,0 +1,54 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package template.core.base.datastore.reactive
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for observing value changes in the data store as flows.
|
||||||
|
*
|
||||||
|
* Implementations of this interface provide mechanisms to create flows
|
||||||
|
* that emit values for specific keys, supporting both initial and distinct emissions.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* val observer: ValueObserver = DefaultValueObserver(changeNotifier)
|
||||||
|
* observer.createValueFlow("theme", "light") { getTheme() }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
interface ValueObserver {
|
||||||
|
/**
|
||||||
|
* Creates a flow that emits the value for the specified key.
|
||||||
|
*
|
||||||
|
* @param key The preference key to observe.
|
||||||
|
* @param default The default value to emit if retrieval fails.
|
||||||
|
* @param getter A suspend function to retrieve the value.
|
||||||
|
* @return A [Flow] emitting the value for the key.
|
||||||
|
*/
|
||||||
|
fun <T> createValueFlow(
|
||||||
|
key: String,
|
||||||
|
default: T,
|
||||||
|
getter: suspend () -> Result<T>,
|
||||||
|
): Flow<T>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a flow that emits only distinct values for the specified key, suppressing duplicates.
|
||||||
|
*
|
||||||
|
* @param key The preference key to observe.
|
||||||
|
* @param default The default value to emit if retrieval fails.
|
||||||
|
* @param getter A suspend function to retrieve the value.
|
||||||
|
* @return A [Flow] emitting only distinct values for the key.
|
||||||
|
*/
|
||||||
|
fun <T> createDistinctValueFlow(
|
||||||
|
key: String,
|
||||||
|
default: T,
|
||||||
|
getter: suspend () -> Result<T>,
|
||||||
|
): Flow<T>
|
||||||
|
}
|
||||||
@ -0,0 +1,152 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package template.core.base.datastore.repository
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.filter
|
||||||
|
import kotlinx.serialization.KSerializer
|
||||||
|
import template.core.base.datastore.contracts.DataStoreChangeEvent
|
||||||
|
import template.core.base.datastore.contracts.ReactiveDataStore
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default implementation of [ReactivePreferencesRepository] that delegates operations
|
||||||
|
* to an underlying [ReactiveDataStore].
|
||||||
|
*
|
||||||
|
* This class provides the reactive repository interface by interacting with
|
||||||
|
* a reactive data store instance.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* // Obtain an instance, e.g., from DataStoreFactory().buildDataStore()
|
||||||
|
* val dataStore: ReactiveDataStore = ...
|
||||||
|
* val repository = DefaultReactivePreferencesRepository(dataStore)
|
||||||
|
*
|
||||||
|
* // Use repository methods
|
||||||
|
* repository.savePreference("user_id", "123")
|
||||||
|
* repository.observePreference("user_id", "").collect { id -> println(id) }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @property reactiveDataStore The underlying [ReactiveDataStore] instance.
|
||||||
|
*/
|
||||||
|
class DefaultReactivePreferencesRepository(
|
||||||
|
private val reactiveDataStore: ReactiveDataStore,
|
||||||
|
) : ReactivePreferencesRepository {
|
||||||
|
|
||||||
|
// Delegate base operations
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
override suspend fun <T> savePreference(key: String, value: T): Result<Unit> {
|
||||||
|
println("[Repository] savePreference: key=$key, value=$value")
|
||||||
|
return reactiveDataStore.putValue(key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
override suspend fun <T> getPreference(key: String, default: T): Result<T> {
|
||||||
|
return reactiveDataStore.getValue(key, default)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
override suspend fun <T> saveSerializablePreference(
|
||||||
|
key: String,
|
||||||
|
value: T,
|
||||||
|
serializer: KSerializer<T>,
|
||||||
|
): Result<Unit> {
|
||||||
|
return reactiveDataStore.putSerializableValue(key, value, serializer)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
override suspend fun <T> getSerializablePreference(
|
||||||
|
key: String,
|
||||||
|
default: T,
|
||||||
|
serializer: KSerializer<T>,
|
||||||
|
): Result<T> {
|
||||||
|
return reactiveDataStore.getSerializableValue(key, default, serializer)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
override suspend fun removePreference(key: String): Result<Unit> {
|
||||||
|
println("[Repository] removePreference: key=$key")
|
||||||
|
return reactiveDataStore.removeValue(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
override suspend fun clearAllPreferences(): Result<Unit> {
|
||||||
|
println("[Repository] clearAllPreferences")
|
||||||
|
return reactiveDataStore.clearAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
override suspend fun hasPreference(key: String): Boolean {
|
||||||
|
return reactiveDataStore.hasKey(key).getOrDefault(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reactive operations
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
override fun <T> observePreference(key: String, default: T): Flow<T> {
|
||||||
|
return reactiveDataStore.observeValue(key, default)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
override fun <T> observeSerializablePreference(
|
||||||
|
key: String,
|
||||||
|
default: T,
|
||||||
|
serializer: KSerializer<T>,
|
||||||
|
): Flow<T> {
|
||||||
|
return reactiveDataStore.observeSerializableValue(key, default, serializer)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
override fun observeAllKeys(): Flow<Set<String>> {
|
||||||
|
println("[Repository] observeAllKeys: flow created")
|
||||||
|
return reactiveDataStore.observeKeys()
|
||||||
|
.also { println("[Repository] observeAllKeys: flow returned") }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
override fun observePreferenceCount(): Flow<Int> {
|
||||||
|
return reactiveDataStore.observeSize()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
override fun observePreferenceChanges(): Flow<DataStoreChangeEvent> {
|
||||||
|
return reactiveDataStore.observeChanges()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
override fun observePreferenceChanges(key: String): Flow<DataStoreChangeEvent> {
|
||||||
|
return reactiveDataStore.observeChanges()
|
||||||
|
.filter { it.key == key || it.key == "*" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,99 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package template.core.base.datastore.repository
|
||||||
|
|
||||||
|
import kotlinx.serialization.KSerializer
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for managing user preferences in a type-safe and coroutine-friendly manner.
|
||||||
|
*
|
||||||
|
* Implementations of this interface provide methods for saving, retrieving,
|
||||||
|
* and removing preferences, supporting both primitive and serializable types.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* val repository: PreferencesRepository = ...
|
||||||
|
* repository.savePreference("theme", "dark")
|
||||||
|
* val theme = repository.getPreference("theme", "light")
|
||||||
|
* repository.removePreference("theme")
|
||||||
|
* repository.clearAllPreferences()
|
||||||
|
* val exists = repository.hasPreference("theme")
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
interface PreferencesRepository {
|
||||||
|
/**
|
||||||
|
* Saves a value associated with the specified key in the preferences.
|
||||||
|
*
|
||||||
|
* @param key The key to associate with the value.
|
||||||
|
* @param value The value to store.
|
||||||
|
* @return [Result.success] if the operation succeeds, or [Result.failure] if an error occurs.
|
||||||
|
*/
|
||||||
|
suspend fun <T> savePreference(key: String, value: T): Result<Unit>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves a value associated with the specified key from the preferences.
|
||||||
|
*
|
||||||
|
* @param key The key to retrieve.
|
||||||
|
* @param default The default value to return if the key does not exist.
|
||||||
|
* @return [Result.success] with the value, or [Result.failure] if an error occurs.
|
||||||
|
*/
|
||||||
|
suspend fun <T> getPreference(key: String, default: T): Result<T>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves a serializable value using the provided serializer.
|
||||||
|
*
|
||||||
|
* @param key The key to associate with the value.
|
||||||
|
* @param value The value to store.
|
||||||
|
* @param serializer The serializer for the value type.
|
||||||
|
* @return [Result.success] if the operation succeeds, or [Result.failure] if an error occurs.
|
||||||
|
*/
|
||||||
|
suspend fun <T> saveSerializablePreference(
|
||||||
|
key: String,
|
||||||
|
value: T,
|
||||||
|
serializer: KSerializer<T>,
|
||||||
|
): Result<Unit>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves a serializable value using the provided serializer.
|
||||||
|
*
|
||||||
|
* @param key The key to retrieve.
|
||||||
|
* @param default The default value to return if the key does not exist.
|
||||||
|
* @param serializer The serializer for the value type.
|
||||||
|
* @return [Result.success] with the value, or [Result.failure] if an error occurs.
|
||||||
|
*/
|
||||||
|
suspend fun <T> getSerializablePreference(
|
||||||
|
key: String,
|
||||||
|
default: T,
|
||||||
|
serializer: KSerializer<T>,
|
||||||
|
): Result<T>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes the value associated with the specified key from the preferences.
|
||||||
|
*
|
||||||
|
* @param key The key to remove.
|
||||||
|
* @return [Result.success] if the operation succeeds, or [Result.failure] if an error occurs.
|
||||||
|
*/
|
||||||
|
suspend fun removePreference(key: String): Result<Unit>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears all stored preferences.
|
||||||
|
*
|
||||||
|
* @return [Result.success] if the operation succeeds, or [Result.failure] if an error occurs.
|
||||||
|
*/
|
||||||
|
suspend fun clearAllPreferences(): Result<Unit>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the specified key exists in the preferences.
|
||||||
|
*
|
||||||
|
* @param key The key to check.
|
||||||
|
* @return `true` if the key exists, `false` otherwise.
|
||||||
|
*/
|
||||||
|
suspend fun hasPreference(key: String): Boolean
|
||||||
|
}
|
||||||
@ -0,0 +1,82 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package template.core.base.datastore.repository
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.serialization.KSerializer
|
||||||
|
import template.core.base.datastore.contracts.DataStoreChangeEvent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for managing user preferences with reactive (Flow-based) observation capabilities.
|
||||||
|
*
|
||||||
|
* This interface extends [PreferencesRepository] and adds methods for observing preference values,
|
||||||
|
* keys, and change events as Kotlin Flows, enabling reactive programming patterns.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* val repository: ReactivePreferencesRepository = ...
|
||||||
|
* repository.observePreference("theme", "light").collect { value -> println(value) }
|
||||||
|
* repository.observeAllKeys().collect { keys -> println(keys) }
|
||||||
|
* repository.observePreferenceChanges().collect { event -> println(event) }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
interface ReactivePreferencesRepository : PreferencesRepository {
|
||||||
|
/**
|
||||||
|
* Observes the value for the specified key as a [Flow], emitting updates as they occur.
|
||||||
|
*
|
||||||
|
* @param key The key to observe.
|
||||||
|
* @param default The default value to emit if the key does not exist.
|
||||||
|
* @return A [Flow] emitting the value for the key.
|
||||||
|
*/
|
||||||
|
fun <T> observePreference(key: String, default: T): Flow<T>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observes a serializable value for the specified key as a [Flow].
|
||||||
|
*
|
||||||
|
* @param key The key to observe.
|
||||||
|
* @param default The default value to emit if the key does not exist.
|
||||||
|
* @param serializer The serializer for the value type.
|
||||||
|
* @return A [Flow] emitting the value for the key.
|
||||||
|
*/
|
||||||
|
fun <T> observeSerializablePreference(
|
||||||
|
key: String,
|
||||||
|
default: T,
|
||||||
|
serializer: KSerializer<T>,
|
||||||
|
): Flow<T>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observes all keys in the preferences as a [Flow], emitting updates as they occur.
|
||||||
|
*
|
||||||
|
* @return A [Flow] emitting the set of all keys.
|
||||||
|
*/
|
||||||
|
fun observeAllKeys(): Flow<Set<String>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observes the number of preferences as a [Flow], emitting updates as they occur.
|
||||||
|
*
|
||||||
|
* @return A [Flow] emitting the number of key-value pairs in the preferences.
|
||||||
|
*/
|
||||||
|
fun observePreferenceCount(): Flow<Int>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observes all change events in the preferences as a [Flow].
|
||||||
|
*
|
||||||
|
* @return A [Flow] emitting [DataStoreChangeEvent] instances as changes occur.
|
||||||
|
*/
|
||||||
|
fun observePreferenceChanges(): Flow<DataStoreChangeEvent>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observes change events for a specific key as a [Flow].
|
||||||
|
*
|
||||||
|
* @param key The key to observe for changes.
|
||||||
|
* @return A [Flow] emitting [DataStoreChangeEvent] instances related to the specified key.
|
||||||
|
*/
|
||||||
|
fun observePreferenceChanges(key: String): Flow<DataStoreChangeEvent>
|
||||||
|
}
|
||||||
@ -0,0 +1,109 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package template.core.base.datastore.serialization
|
||||||
|
|
||||||
|
import kotlinx.serialization.KSerializer
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import template.core.base.datastore.exceptions.DeserializationException
|
||||||
|
import template.core.base.datastore.exceptions.SerializationException
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implementation of [SerializationStrategy] that uses kotlinx.serialization's [Json]
|
||||||
|
* for serializing and deserializing data.
|
||||||
|
*
|
||||||
|
* This strategy provides a convenient way to store and retrieve complex data objects in the
|
||||||
|
* data store by converting them to and from JSON strings.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* // Define a serializable data class
|
||||||
|
* @Serializable
|
||||||
|
* data class Settings(val theme: String, val fontSize: Int)
|
||||||
|
*
|
||||||
|
* // Create a JsonSerializationStrategy instance
|
||||||
|
* val serializationStrategy = JsonSerializationStrategy()
|
||||||
|
* val settingsSerializer = Settings.serializer()
|
||||||
|
*
|
||||||
|
* // Serialize an object
|
||||||
|
* val settings = Settings("dark", 14)
|
||||||
|
* val serializedData = serializationStrategy.serialize(settings, settingsSerializer)
|
||||||
|
* println("Serialized data: $serializedData")
|
||||||
|
* // Example output: Result.success("{"theme":"dark","fontSize":14}")
|
||||||
|
*
|
||||||
|
* // Deserialize a string
|
||||||
|
* val dataString = "{"theme":"light","fontSize":12}"
|
||||||
|
* val deserializedSettings = serializationStrategy.deserialize(dataString, settingsSerializer)
|
||||||
|
* println("Deserialized settings: $deserializedSettings")
|
||||||
|
* // Example output: Result.success(Settings(theme=light, fontSize=12))
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @property json The [Json] instance used for serialization and deserialization.
|
||||||
|
* Configurable with default lenient settings.
|
||||||
|
*/
|
||||||
|
class JsonSerializationStrategy(
|
||||||
|
private val json: Json = Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
encodeDefaults = true
|
||||||
|
isLenient = true
|
||||||
|
},
|
||||||
|
) : SerializationStrategy {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serializes the given [value] of type [T] to a JSON [String] using the provided [serializer]
|
||||||
|
* and the configured [Json] instance.
|
||||||
|
*
|
||||||
|
* @param value The object to serialize.
|
||||||
|
* @param serializer The [KSerializer] for type [T].
|
||||||
|
* @return A [Result.success] containing the JSON string, or [Result.failure]
|
||||||
|
* if serialization fails (e.g., due to serialization errors).
|
||||||
|
*/
|
||||||
|
override suspend fun <T> serialize(value: T, serializer: KSerializer<T>): Result<String> {
|
||||||
|
return try {
|
||||||
|
val result = json.encodeToString(serializer, value)
|
||||||
|
Result.success(result)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(
|
||||||
|
SerializationException(
|
||||||
|
"Failed to serialize value of type ${value?.let { it::class.simpleName }}",
|
||||||
|
e,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deserializes the given JSON [data] string back into an object of type [T] using
|
||||||
|
* the provided [serializer] and the configured [Json] instance.
|
||||||
|
*
|
||||||
|
* @param data The JSON string data to deserialize.
|
||||||
|
* @param serializer The [KSerializer] for type [T].
|
||||||
|
* @return A [Result.success] containing the deserialized object, or [Result.failure] if
|
||||||
|
* deserialization fails (e.g., due to invalid JSON format or deserialization errors).
|
||||||
|
*/
|
||||||
|
override suspend fun <T> deserialize(data: String, serializer: KSerializer<T>): Result<T> {
|
||||||
|
return try {
|
||||||
|
if (data.isBlank()) {
|
||||||
|
return Result.failure(
|
||||||
|
DeserializationException("Cannot deserialize blank string"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = json.decodeFromString(serializer, data)
|
||||||
|
Result.success(result)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(
|
||||||
|
DeserializationException(
|
||||||
|
"Failed to deserialize data: ${data.take(100)}${if (data.length > 100) "..." else ""}",
|
||||||
|
e,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package template.core.base.datastore.serialization
|
||||||
|
|
||||||
|
import kotlinx.serialization.KSerializer
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strategy for handling serialization operations.
|
||||||
|
* Follows Single Responsibility Principle.
|
||||||
|
*/
|
||||||
|
interface SerializationStrategy {
|
||||||
|
suspend fun <T> serialize(value: T, serializer: KSerializer<T>): Result<String>
|
||||||
|
suspend fun <T> deserialize(data: String, serializer: KSerializer<T>): Result<T>
|
||||||
|
}
|
||||||
@ -0,0 +1,208 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package template.core.base.datastore.store
|
||||||
|
|
||||||
|
import com.russhwolf.settings.ExperimentalSettingsApi
|
||||||
|
import com.russhwolf.settings.Settings
|
||||||
|
import com.russhwolf.settings.serialization.decodeValue
|
||||||
|
import com.russhwolf.settings.serialization.encodeValue
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.ExperimentalSerializationApi
|
||||||
|
import kotlinx.serialization.KSerializer
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Basic implementation of a user preferences data store using a settings storage mechanism.
|
||||||
|
*
|
||||||
|
* This class is deprecated. Use [ReactiveUserPreferencesDataStore]
|
||||||
|
* via [template.core.base.datastore.factory.DataStoreFactory] for advanced features such
|
||||||
|
* as reactive flows, caching, and validation.
|
||||||
|
*
|
||||||
|
* Example migration:
|
||||||
|
* ```kotlin
|
||||||
|
* // Old way
|
||||||
|
* val dataStore = BasicPreferencesStore(settings, dispatcher)
|
||||||
|
*
|
||||||
|
* // New way
|
||||||
|
* val repository = DataStoreFactory.create(settings, dispatcher)
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @property settings The underlying settings storage implementation.
|
||||||
|
* @property dispatcher The coroutine dispatcher for executing operations.
|
||||||
|
*/
|
||||||
|
@Deprecated(
|
||||||
|
message = "Use ReactiveUserPreferencesRepository through DataStoreFactory instead",
|
||||||
|
replaceWith = ReplaceWith(
|
||||||
|
"DataStoreFactory.create(settings, dispatcher)",
|
||||||
|
"template.core.base.datastore.factory.DataStoreFactory",
|
||||||
|
),
|
||||||
|
level = DeprecationLevel.WARNING,
|
||||||
|
)
|
||||||
|
class BasicPreferencesStore(
|
||||||
|
private val settings: Settings,
|
||||||
|
private val dispatcher: CoroutineDispatcher,
|
||||||
|
) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores a value associated with the specified key in the data store.
|
||||||
|
* Supports primitive types directly and custom types via a provided [KSerializer].
|
||||||
|
*
|
||||||
|
* @param key The key to associate with the value.
|
||||||
|
* @param value The value to store.
|
||||||
|
* @param serializer The serializer for the value type, if needed.
|
||||||
|
* @return [Result.success] if the operation succeeds, or [Result.failure] if an error occurs.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* dataStore.putValue("theme", "dark")
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalSerializationApi::class, ExperimentalSettingsApi::class)
|
||||||
|
suspend fun <T> putValue(
|
||||||
|
key: String,
|
||||||
|
value: T,
|
||||||
|
serializer: KSerializer<T>? = null,
|
||||||
|
): Result<Unit> = withContext(dispatcher) {
|
||||||
|
runCatching {
|
||||||
|
when (value) {
|
||||||
|
is Int -> settings.putInt(key, value)
|
||||||
|
is Long -> settings.putLong(key, value)
|
||||||
|
is Float -> settings.putFloat(key, value)
|
||||||
|
is Double -> settings.putDouble(key, value)
|
||||||
|
is String -> settings.putString(key, value)
|
||||||
|
is Boolean -> settings.putBoolean(key, value)
|
||||||
|
else -> {
|
||||||
|
require(serializer != null) {
|
||||||
|
"Unsupported type or no serializer provided for ${value?.let { it::class } ?: "null"}"
|
||||||
|
}
|
||||||
|
settings.encodeValue(
|
||||||
|
serializer = serializer,
|
||||||
|
value = value,
|
||||||
|
key = key,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves a value associated with the specified key from the data store.
|
||||||
|
*
|
||||||
|
* @param key The key to retrieve.
|
||||||
|
* @param default The default value to return if the key does not exist.
|
||||||
|
* @param serializer The serializer for the value type, if needed.
|
||||||
|
* @return [Result.success] with the value, or [Result.failure] if an error occurs.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* dataStore.getValue("theme", "light")
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
@OptIn(ExperimentalSerializationApi::class, ExperimentalSettingsApi::class)
|
||||||
|
suspend fun <T> getValue(
|
||||||
|
key: String,
|
||||||
|
default: T,
|
||||||
|
serializer: KSerializer<T>? = null,
|
||||||
|
): Result<T> = withContext(dispatcher) {
|
||||||
|
runCatching {
|
||||||
|
when (default) {
|
||||||
|
is Int -> settings.getInt(key, default) as T
|
||||||
|
is Long -> settings.getLong(key, default) as T
|
||||||
|
is Float -> settings.getFloat(key, default) as T
|
||||||
|
is Double -> settings.getDouble(key, default) as T
|
||||||
|
is String -> settings.getString(key, default) as T
|
||||||
|
is Boolean -> settings.getBoolean(key, default) as T
|
||||||
|
else -> {
|
||||||
|
require(serializer != null) {
|
||||||
|
"Unsupported type or no serializer provided for ${default?.let { it::class } ?: "null"}"
|
||||||
|
}
|
||||||
|
settings.decodeValue(
|
||||||
|
serializer = serializer,
|
||||||
|
key = key,
|
||||||
|
defaultValue = default,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the specified key exists in the data store.
|
||||||
|
*
|
||||||
|
* @param key The key to check.
|
||||||
|
* @return [Result.success] with true if the key exists, or [Result.failure] if an error occurs.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* dataStore.hasKey("theme")
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
suspend fun hasKey(key: String): Result<Boolean> = withContext(dispatcher) {
|
||||||
|
runCatching { settings.hasKey(key) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes the value associated with the specified key from the data store.
|
||||||
|
*
|
||||||
|
* @param key The key to remove.
|
||||||
|
* @return [Result.success] if the operation succeeds, or [Result.failure] if an error occurs.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* dataStore.removeValue("theme")
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
suspend fun removeValue(key: String): Result<Unit> = withContext(dispatcher) {
|
||||||
|
runCatching { settings.remove(key) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears all stored preferences in the data store.
|
||||||
|
*
|
||||||
|
* @return [Result.success] if the operation succeeds, or [Result.failure] if an error occurs.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* dataStore.clearAll()
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
suspend fun clearAll(): Result<Unit> = withContext(dispatcher) {
|
||||||
|
runCatching { settings.clear() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves all keys currently stored in the data store.
|
||||||
|
*
|
||||||
|
* @return [Result.success] with the set of keys, or [Result.failure] if an error occurs.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* dataStore.getAllKeys()
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
suspend fun getAllKeys(): Result<Set<String>> = withContext(dispatcher) {
|
||||||
|
runCatching { settings.keys }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the total number of key-value pairs stored in the data store.
|
||||||
|
*
|
||||||
|
* @return [Result.success] with the count, or [Result.failure] if an error occurs.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* dataStore.getSize()
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
suspend fun getSize(): Result<Int> = withContext(dispatcher) {
|
||||||
|
runCatching { settings.size }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,371 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package template.core.base.datastore.store
|
||||||
|
|
||||||
|
import com.russhwolf.settings.Settings
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.KSerializer
|
||||||
|
import template.core.base.datastore.cache.CacheManager
|
||||||
|
import template.core.base.datastore.contracts.CacheableDataStore
|
||||||
|
import template.core.base.datastore.exceptions.CacheException
|
||||||
|
import template.core.base.datastore.exceptions.UnsupportedTypeException
|
||||||
|
import template.core.base.datastore.handlers.TypeHandler
|
||||||
|
import template.core.base.datastore.serialization.SerializationStrategy
|
||||||
|
import template.core.base.datastore.validation.PreferencesValidator
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implementation of a cache-enabled user preferences data store.
|
||||||
|
*
|
||||||
|
* This class provides coroutine-based, type-safe, and observable access to user preferences,
|
||||||
|
* supporting both primitive and serializable types, with in-memory caching for improved performance.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* val store = CachedPreferencesStore(
|
||||||
|
* settings = Settings(),
|
||||||
|
* dispatcher = Dispatchers.IO,
|
||||||
|
* typeHandlers = listOf(IntTypeHandler(), StringTypeHandler()),
|
||||||
|
* serializationStrategy = JsonSerializationStrategy(),
|
||||||
|
* validator = DefaultPreferencesValidator(),
|
||||||
|
* cacheManager = LruCacheManager(200)
|
||||||
|
* )
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @property settings The underlying settings storage implementation.
|
||||||
|
* @property dispatcher The coroutine dispatcher for executing operations.
|
||||||
|
* @property typeHandlers The list of type handlers for supported types.
|
||||||
|
* @property serializationStrategy The strategy for serializing and deserializing values.
|
||||||
|
* @property validator The validator for keys and values.
|
||||||
|
* @property cacheManager The cache manager for in-memory caching.
|
||||||
|
*/
|
||||||
|
@Suppress("MaxLineLength")
|
||||||
|
class CachedPreferencesStore(
|
||||||
|
private val settings: Settings,
|
||||||
|
private val dispatcher: CoroutineDispatcher,
|
||||||
|
private val typeHandlers: List<TypeHandler<Any>>,
|
||||||
|
private val serializationStrategy: SerializationStrategy,
|
||||||
|
private val validator: PreferencesValidator,
|
||||||
|
private val cacheManager: CacheManager<String, Any>,
|
||||||
|
) : CacheableDataStore {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores a value associated with the specified key in the data store.
|
||||||
|
*
|
||||||
|
* @param key The key to associate with the value.
|
||||||
|
* @param value The value to store.
|
||||||
|
* @return [Result.success] if the operation succeeds, or [Result.failure] if an error occurs.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* store.putValue("theme", "dark")
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
override suspend fun <T> putValue(key: String, value: T): Result<Unit> =
|
||||||
|
withContext(dispatcher) {
|
||||||
|
runCatching {
|
||||||
|
// Validate inputs
|
||||||
|
validator.validateKey(key).getOrThrow()
|
||||||
|
validator.validateValue(value).getOrThrow()
|
||||||
|
|
||||||
|
// Find and use type handler
|
||||||
|
val handler = findTypeHandler(value) ?: throw UnsupportedTypeException(
|
||||||
|
"No handler found for type: ${value?.let { it::class.simpleName } ?: "null"}",
|
||||||
|
)
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val typedHandler = handler as TypeHandler<T>
|
||||||
|
val result = typedHandler.put(settings, key, value)
|
||||||
|
|
||||||
|
if (result.isSuccess) {
|
||||||
|
cacheValue(key, value as Any)
|
||||||
|
}
|
||||||
|
|
||||||
|
result.getOrThrow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves a value associated with the specified key from the data store.
|
||||||
|
*
|
||||||
|
* @param key The key to retrieve.
|
||||||
|
* @param default The default value to return if the key does not exist.
|
||||||
|
* @return [Result.success] with the value, or [Result.failure] if an error occurs.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* store.getValue("theme", "light")
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
override suspend fun <T> getValue(key: String, default: T): Result<T> =
|
||||||
|
withContext(dispatcher) {
|
||||||
|
runCatching {
|
||||||
|
validator.validateKey(key).getOrThrow()
|
||||||
|
|
||||||
|
// Check cache first
|
||||||
|
getCachedValue<T>(key)?.let { return@runCatching it }
|
||||||
|
|
||||||
|
// Find and use type handler
|
||||||
|
val handler = findTypeHandler(default)
|
||||||
|
?: throw UnsupportedTypeException(
|
||||||
|
"No handler found for type: ${default?.let { it::class.simpleName } ?: "null"}",
|
||||||
|
)
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val typedHandler = handler as TypeHandler<T>
|
||||||
|
val result = typedHandler.get(settings, key, default)
|
||||||
|
|
||||||
|
if (result.isSuccess) {
|
||||||
|
cacheValue(key, result.getOrThrow() as Any)
|
||||||
|
}
|
||||||
|
|
||||||
|
result.getOrThrow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores a serializable value using the provided serializer.
|
||||||
|
*
|
||||||
|
* @param key The key to associate with the value.
|
||||||
|
* @param value The value to store.
|
||||||
|
* @param serializer The serializer for the value type.
|
||||||
|
* @return [Result.success] if the operation succeeds, or [Result.failure] if an error occurs.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* store.putSerializableValue("user", user, User.serializer())
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
override suspend fun <T> putSerializableValue(
|
||||||
|
key: String,
|
||||||
|
value: T,
|
||||||
|
serializer: KSerializer<T>,
|
||||||
|
): Result<Unit> = withContext(dispatcher) {
|
||||||
|
runCatching {
|
||||||
|
validator.validateKey(key).getOrThrow()
|
||||||
|
validator.validateValue(value).getOrThrow()
|
||||||
|
|
||||||
|
val serializedResult = serializationStrategy.serialize(value, serializer)
|
||||||
|
serializedResult.fold(
|
||||||
|
onSuccess = { serializedData ->
|
||||||
|
val result = runCatching { settings.putString(key, serializedData) }
|
||||||
|
if (result.isSuccess) {
|
||||||
|
cacheValue(key, value as Any)
|
||||||
|
}
|
||||||
|
result.getOrThrow()
|
||||||
|
},
|
||||||
|
onFailure = { throw it },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves a serializable value using the provided serializer.
|
||||||
|
*
|
||||||
|
* @param key The key to retrieve.
|
||||||
|
* @param default The default value to return if the key does not exist.
|
||||||
|
* @param serializer The serializer for the value type.
|
||||||
|
* @return [Result.success] with the value, or [Result.failure] if an error occurs.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* store.getSerializableValue("user", defaultUser, User.serializer())
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
override suspend fun <T> getSerializableValue(
|
||||||
|
key: String,
|
||||||
|
default: T,
|
||||||
|
serializer: KSerializer<T>,
|
||||||
|
): Result<T> = withContext(dispatcher) {
|
||||||
|
runCatching {
|
||||||
|
validator.validateKey(key).getOrThrow()
|
||||||
|
|
||||||
|
// Check cache first
|
||||||
|
getCachedValue<T>(key)?.let { return@runCatching it }
|
||||||
|
|
||||||
|
if (!settings.hasKey(key)) {
|
||||||
|
cacheValue(key, default as Any)
|
||||||
|
return@runCatching default
|
||||||
|
}
|
||||||
|
|
||||||
|
val serializedData = runCatching { settings.getString(key, "") }
|
||||||
|
.getOrElse { throw it }
|
||||||
|
|
||||||
|
if (serializedData.isEmpty()) {
|
||||||
|
return@runCatching default
|
||||||
|
}
|
||||||
|
|
||||||
|
serializationStrategy.deserialize(serializedData, serializer).fold(
|
||||||
|
onSuccess = { value ->
|
||||||
|
cacheValue(key, value as Any)
|
||||||
|
value
|
||||||
|
},
|
||||||
|
onFailure = {
|
||||||
|
// Return default on deserialization failure but don't cache it
|
||||||
|
default
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the specified key exists in the data store or cache.
|
||||||
|
*
|
||||||
|
* @param key The key to check.
|
||||||
|
* @return [Result.success] with true if the key exists, or [Result.failure] if an error occurs.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* store.hasKey("theme")
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
override suspend fun hasKey(key: String): Result<Boolean> = withContext(dispatcher) {
|
||||||
|
runCatching {
|
||||||
|
validator.validateKey(key).getOrThrow()
|
||||||
|
settings.hasKey(key) || cacheManager.containsKey(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes the value associated with the specified key from the data store and cache.
|
||||||
|
*
|
||||||
|
* @param key The key to remove.
|
||||||
|
* @return [Result.success] if the operation succeeds, or [Result.failure] if an error occurs.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* store.removeValue("theme")
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
override suspend fun removeValue(key: String): Result<Unit> = withContext(dispatcher) {
|
||||||
|
runCatching {
|
||||||
|
validator.validateKey(key).getOrThrow()
|
||||||
|
settings.remove(key)
|
||||||
|
cacheManager.remove(key)
|
||||||
|
Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears all stored preferences in the data store and cache.
|
||||||
|
*
|
||||||
|
* @return [Result.success] if the operation succeeds, or [Result.failure] if an error occurs.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* store.clearAll()
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
override suspend fun clearAll(): Result<Unit> = withContext(dispatcher) {
|
||||||
|
runCatching {
|
||||||
|
settings.clear()
|
||||||
|
cacheManager.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves all keys currently stored in the data store.
|
||||||
|
*
|
||||||
|
* @return [Result.success] with the set of keys, or [Result.failure] if an error occurs.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* store.getAllKeys()
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
override suspend fun getAllKeys(): Result<Set<String>> = withContext(dispatcher) {
|
||||||
|
runCatching { settings.keys }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the total number of key-value pairs stored in the data store.
|
||||||
|
*
|
||||||
|
* @return [Result.success] with the count, or [Result.failure] if an error occurs.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* store.getSize()
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
override suspend fun getSize(): Result<Int> = withContext(dispatcher) {
|
||||||
|
runCatching { settings.size }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidates the cache for the specified key.
|
||||||
|
*
|
||||||
|
* @param key The key whose cache should be invalidated.
|
||||||
|
* @return [Result.success] if the operation succeeds, or [Result.failure] if an error occurs.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* store.invalidateCache("theme")
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
override suspend fun invalidateCache(key: String): Result<Unit> = withContext(dispatcher) {
|
||||||
|
runCatching {
|
||||||
|
validator.validateKey(key).getOrThrow()
|
||||||
|
cacheManager.remove(key)
|
||||||
|
Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidates all cache entries in the data store.
|
||||||
|
*
|
||||||
|
* @return [Result.success] if the operation succeeds, or [Result.failure] if an error occurs.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* store.invalidateAllCache()
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
override suspend fun invalidateAllCache(): Result<Unit> = withContext(dispatcher) {
|
||||||
|
runCatching {
|
||||||
|
cacheManager.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the current size of the cache.
|
||||||
|
*
|
||||||
|
* @return The number of entries in the cache.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* val cacheSize = store.getCacheSize()
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
override fun getCacheSize(): Int = cacheManager.size()
|
||||||
|
|
||||||
|
// Private helper methods to reduce duplication
|
||||||
|
private fun findTypeHandler(value: Any?): TypeHandler<Any>? {
|
||||||
|
return typeHandlers.find { it.canHandle(value) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
private fun <T> getCachedValue(key: String): T? {
|
||||||
|
return try {
|
||||||
|
cacheManager.get(key) as? T
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// Log cache retrieval error but don't fail the operation
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cacheValue(key: String, value: Any) {
|
||||||
|
try {
|
||||||
|
cacheManager.put(key, value)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Cache operation failed - log but don't fail the main operation
|
||||||
|
throw CacheException("Failed to cache value for key: $key", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,414 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package template.core.base.datastore.store
|
||||||
|
|
||||||
|
import com.russhwolf.settings.Settings
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.onStart
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.KSerializer
|
||||||
|
import template.core.base.datastore.cache.CacheManager
|
||||||
|
import template.core.base.datastore.contracts.DataStoreChangeEvent
|
||||||
|
import template.core.base.datastore.contracts.ReactiveDataStore
|
||||||
|
import template.core.base.datastore.handlers.TypeHandler
|
||||||
|
import template.core.base.datastore.reactive.ChangeNotifier
|
||||||
|
import template.core.base.datastore.reactive.ValueObserver
|
||||||
|
import template.core.base.datastore.serialization.SerializationStrategy
|
||||||
|
import template.core.base.datastore.validation.PreferencesValidator
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reactive implementation of a user preferences data store with support for caching,
|
||||||
|
* validation, and change notifications.
|
||||||
|
*
|
||||||
|
* This class provides coroutine-based, type-safe, and observable access to user preferences,
|
||||||
|
* supporting both primitive and serializable types.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* val dataStore = ReactiveUserPreferencesDataStore(
|
||||||
|
* settings = Settings(),
|
||||||
|
* dispatcher = Dispatchers.IO,
|
||||||
|
* typeHandlers = listOf(IntTypeHandler(), StringTypeHandler()),
|
||||||
|
* serializationStrategy = JsonSerializationStrategy(),
|
||||||
|
* validator = DefaultPreferencesValidator(),
|
||||||
|
* cacheManager = LruCacheManager(200),
|
||||||
|
* changeNotifier = DefaultChangeNotifier(),
|
||||||
|
* valueObserver = DefaultValueObserver(DefaultChangeNotifier())
|
||||||
|
* )
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @property settings The underlying settings storage implementation.
|
||||||
|
* @property dispatcher The coroutine dispatcher for executing operations.
|
||||||
|
* @property typeHandlers The list of type handlers for supported types.
|
||||||
|
* @property serializationStrategy The strategy for serializing and deserializing values.
|
||||||
|
* @property validator The validator for keys and values.
|
||||||
|
* @property cacheManager The cache manager for in-memory caching.
|
||||||
|
* @property changeNotifier The notifier for broadcasting change events.
|
||||||
|
* @property valueObserver The observer for value changes.
|
||||||
|
*/
|
||||||
|
class ReactiveUserPreferencesDataStore(
|
||||||
|
private val settings: Settings,
|
||||||
|
private val dispatcher: CoroutineDispatcher,
|
||||||
|
private val typeHandlers: List<TypeHandler<Any>>,
|
||||||
|
private val serializationStrategy: SerializationStrategy,
|
||||||
|
private val validator: PreferencesValidator,
|
||||||
|
private val cacheManager: CacheManager<String, Any>,
|
||||||
|
private val changeNotifier: ChangeNotifier,
|
||||||
|
private val valueObserver: ValueObserver,
|
||||||
|
) : ReactiveDataStore {
|
||||||
|
|
||||||
|
// Delegate to the base enhanced implementation
|
||||||
|
private val enhancedDataStore = CachedPreferencesStore(
|
||||||
|
settings,
|
||||||
|
dispatcher,
|
||||||
|
typeHandlers,
|
||||||
|
serializationStrategy,
|
||||||
|
validator,
|
||||||
|
cacheManager,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores a value associated with the specified key in the data store.
|
||||||
|
*
|
||||||
|
* @param key The key to associate with the value.
|
||||||
|
* @param value The value to store.
|
||||||
|
* @return [Result.success] if the operation succeeds, or [Result.failure] if an error occurs.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* dataStore.putValue("theme", "dark")
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
override suspend fun <T> putValue(key: String, value: T): Result<Unit> {
|
||||||
|
return withContext(dispatcher) {
|
||||||
|
val oldValue = if (hasKey(key).getOrDefault(false)) {
|
||||||
|
enhancedDataStore.getValue(key, value).getOrNull()
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = enhancedDataStore.putValue(key, value)
|
||||||
|
|
||||||
|
if (result.isSuccess) {
|
||||||
|
val change = if (oldValue != null) {
|
||||||
|
DataStoreChangeEvent.ValueUpdated(key, oldValue, value)
|
||||||
|
} else {
|
||||||
|
DataStoreChangeEvent.ValueAdded(key, value)
|
||||||
|
}
|
||||||
|
changeNotifier.notifyChange(change)
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves a value associated with the specified key from the data store.
|
||||||
|
*
|
||||||
|
* @param key The key to retrieve.
|
||||||
|
* @param default The default value to return if the key does not exist.
|
||||||
|
* @return [Result.success] with the value, or [Result.failure] if an error occurs.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* dataStore.getValue("theme", "light")
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
override suspend fun <T> getValue(key: String, default: T): Result<T> {
|
||||||
|
return enhancedDataStore.getValue(key, default)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores a serializable value using the provided serializer.
|
||||||
|
*
|
||||||
|
* @param key The key to associate with the value.
|
||||||
|
* @param value The value to store.
|
||||||
|
* @param serializer The serializer for the value type.
|
||||||
|
* @return [Result.success] if the operation succeeds, or [Result.failure] if an error occurs.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* dataStore.putSerializableValue("user", user, User.serializer())
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
override suspend fun <T> putSerializableValue(
|
||||||
|
key: String,
|
||||||
|
value: T,
|
||||||
|
serializer: KSerializer<T>,
|
||||||
|
): Result<Unit> {
|
||||||
|
return withContext(dispatcher) {
|
||||||
|
val oldValue = if (hasKey(key).getOrDefault(false)) {
|
||||||
|
enhancedDataStore.getSerializableValue(key, value, serializer).getOrNull()
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = enhancedDataStore.putSerializableValue(key, value, serializer)
|
||||||
|
|
||||||
|
if (result.isSuccess) {
|
||||||
|
val change = if (oldValue != null) {
|
||||||
|
DataStoreChangeEvent.ValueUpdated(key, oldValue, value)
|
||||||
|
} else {
|
||||||
|
DataStoreChangeEvent.ValueAdded(key, value)
|
||||||
|
}
|
||||||
|
changeNotifier.notifyChange(change)
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves a serializable value using the provided serializer.
|
||||||
|
*
|
||||||
|
* @param key The key to retrieve.
|
||||||
|
* @param default The default value to return if the key does not exist.
|
||||||
|
* @param serializer The serializer for the value type.
|
||||||
|
* @return [Result.success] with the value, or [Result.failure] if an error occurs.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* dataStore.getSerializableValue("user", defaultUser, User.serializer())
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
override suspend fun <T> getSerializableValue(
|
||||||
|
key: String,
|
||||||
|
default: T,
|
||||||
|
serializer: KSerializer<T>,
|
||||||
|
): Result<T> {
|
||||||
|
return enhancedDataStore.getSerializableValue(key, default, serializer)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes the value associated with the specified key from the data store.
|
||||||
|
*
|
||||||
|
* @param key The key to remove.
|
||||||
|
* @return [Result.success] if the operation succeeds, or [Result.failure] if an error occurs.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* dataStore.removeValue("theme")
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
override suspend fun removeValue(key: String): Result<Unit> {
|
||||||
|
return withContext(dispatcher) {
|
||||||
|
val oldValue = if (hasKey(key).getOrDefault(false)) {
|
||||||
|
runCatching { settings.getString(key, "") }.getOrNull()
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = enhancedDataStore.removeValue(key)
|
||||||
|
|
||||||
|
if (result.isSuccess) {
|
||||||
|
changeNotifier.notifyChange(
|
||||||
|
DataStoreChangeEvent.ValueRemoved(key, oldValue),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears all stored preferences in the data store.
|
||||||
|
*
|
||||||
|
* @return [Result.success] if the operation succeeds, or [Result.failure] if an error occurs.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* dataStore.clearAll()
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
override suspend fun clearAll(): Result<Unit> {
|
||||||
|
return withContext(dispatcher) {
|
||||||
|
val result = enhancedDataStore.clearAll()
|
||||||
|
|
||||||
|
if (result.isSuccess) {
|
||||||
|
changeNotifier.notifyChange(DataStoreChangeEvent.StoreCleared())
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observes the value for the specified key as a flow, emitting updates as they occur.
|
||||||
|
*
|
||||||
|
* @param key The key to observe.
|
||||||
|
* @param default The default value to emit if the key does not exist.
|
||||||
|
* @return A [Flow] emitting the value for the key.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* dataStore.observeValue("theme", "light").collect { value -> println(value) }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
override fun <T> observeValue(key: String, default: T): Flow<T> {
|
||||||
|
return valueObserver.createDistinctValueFlow(key, default) {
|
||||||
|
enhancedDataStore.getValue(key, default)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observes a serializable value for the specified key as a flow.
|
||||||
|
*
|
||||||
|
* @param key The key to observe.
|
||||||
|
* @param default The default value to emit if the key does not exist.
|
||||||
|
* @param serializer The serializer for the value type.
|
||||||
|
* @return A [Flow] emitting the value for the key.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* dataStore
|
||||||
|
* .observeSerializableValue("user", defaultUser, User.serializer())
|
||||||
|
* .collect { user -> println(user) }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
override fun <T> observeSerializableValue(
|
||||||
|
key: String,
|
||||||
|
default: T,
|
||||||
|
serializer: KSerializer<T>,
|
||||||
|
): Flow<T> {
|
||||||
|
return valueObserver.createDistinctValueFlow(key, default) {
|
||||||
|
enhancedDataStore.getSerializableValue(key, default, serializer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observes all keys in the data store as a flow, emitting updates as they occur.
|
||||||
|
*
|
||||||
|
* @return A [Flow] emitting the set of all keys.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* dataStore.observeKeys().collect { keys -> println(keys) }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
override fun observeKeys(): Flow<Set<String>> {
|
||||||
|
return changeNotifier.observeChanges()
|
||||||
|
.onStart { emit(DataStoreChangeEvent.ValueAdded("", null)) } // Trigger initial emission
|
||||||
|
.map { settings.keys }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observes the size of the data store as a flow, emitting updates as they occur.
|
||||||
|
*
|
||||||
|
* @return A [Flow] emitting the number of key-value pairs in the data store.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* dataStore.observeSize().collect { size -> println(size) }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
override fun observeSize(): Flow<Int> {
|
||||||
|
return changeNotifier.observeChanges()
|
||||||
|
.onStart { emit(DataStoreChangeEvent.ValueAdded("", null)) } // Trigger initial emission
|
||||||
|
.map { getSize().getOrDefault(0) }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observes all change events in the data store as a flow.
|
||||||
|
*
|
||||||
|
* @return A [Flow] emitting [DataStoreChangeEvent] instances as changes occur.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* dataStore.observeChanges().collect { event -> println(event) }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
override fun observeChanges(): Flow<DataStoreChangeEvent> {
|
||||||
|
return changeNotifier.observeChanges()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the specified key exists in the data store.
|
||||||
|
*
|
||||||
|
* @param key The key to check.
|
||||||
|
* @return [Result.success] with true if the key exists, or [Result.failure] if an error occurs.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* dataStore.hasKey("theme")
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
override suspend fun hasKey(key: String): Result<Boolean> = enhancedDataStore.hasKey(key)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves all keys currently stored in the data store.
|
||||||
|
*
|
||||||
|
* @return [Result.success] with the set of keys, or [Result.failure] if an error occurs.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* dataStore.getAllKeys()
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
override suspend fun getAllKeys(): Result<Set<String>> = enhancedDataStore.getAllKeys()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the total number of key-value pairs stored in the data store.
|
||||||
|
*
|
||||||
|
* @return [Result.success] with the count, or [Result.failure] if an error occurs.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* dataStore.getSize()
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
override suspend fun getSize(): Result<Int> = enhancedDataStore.getSize()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidates the cache entry for the specified key.
|
||||||
|
*
|
||||||
|
* This forces the next retrieval for this key to read from the underlying data store.
|
||||||
|
*
|
||||||
|
* @param key The key whose cache entry should be invalidated.
|
||||||
|
* @return [Result.success] if the operation succeeds, or [Result.failure] if an error occurs.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* dataStore.invalidateCache("user_preferences")
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
override suspend fun invalidateCache(key: String): Result<Unit> =
|
||||||
|
enhancedDataStore.invalidateCache(key)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidates all entries in the cache.
|
||||||
|
*
|
||||||
|
* This forces the next retrieval for any key to read from the underlying data store.
|
||||||
|
*
|
||||||
|
* @return [Result.success] if the operation succeeds, or [Result.failure] if an error occurs.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* dataStore.invalidateAllCache()
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
override suspend fun invalidateAllCache(): Result<Unit> = enhancedDataStore.invalidateAllCache()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the current number of entries in the cache.
|
||||||
|
*
|
||||||
|
* @return The size of the cache.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* val cacheSize = dataStore.getCacheSize()
|
||||||
|
* println("Cache contains $cacheSize entries")
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
override fun getCacheSize(): Int = enhancedDataStore.getCacheSize()
|
||||||
|
}
|
||||||
@ -0,0 +1,71 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package template.core.base.datastore.validation
|
||||||
|
|
||||||
|
import template.core.base.datastore.exceptions.InvalidKeyException
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default implementation of [PreferencesValidator] for validating keys and values in the data store.
|
||||||
|
*
|
||||||
|
* This implementation enforces constraints such as non-blank keys, maximum key length, and value size limits.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* val validator = DefaultPreferencesValidator()
|
||||||
|
* validator.validateKey("theme")
|
||||||
|
* validator.validateValue("dark")
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
class DefaultPreferencesValidator : PreferencesValidator {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
override fun validateKey(key: String): Result<Unit> {
|
||||||
|
return when {
|
||||||
|
key.isBlank() -> Result.failure(
|
||||||
|
InvalidKeyException("Key cannot be blank"),
|
||||||
|
)
|
||||||
|
|
||||||
|
key.length > 255 -> Result.failure(
|
||||||
|
InvalidKeyException("Key length cannot exceed 255 characters: '$key'"),
|
||||||
|
)
|
||||||
|
|
||||||
|
key.contains('\u0000') -> Result.failure(
|
||||||
|
InvalidKeyException("Key cannot contain null characters: '$key'"),
|
||||||
|
)
|
||||||
|
|
||||||
|
else -> Result.success(Unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
override fun <T> validateValue(value: T): Result<Unit> {
|
||||||
|
return when (value) {
|
||||||
|
null -> Result.failure(
|
||||||
|
IllegalArgumentException("Value cannot be null"),
|
||||||
|
)
|
||||||
|
|
||||||
|
is String -> {
|
||||||
|
if (value.length > 10000) {
|
||||||
|
Result.failure(
|
||||||
|
IllegalArgumentException("String value too large: ${value.length} characters"),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Result.success(Unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> Result.success(Unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,41 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package template.core.base.datastore.validation
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for validating keys and values used in the data store.
|
||||||
|
*
|
||||||
|
* Implementations of this interface provide validation logic to ensure that keys and values meet
|
||||||
|
* required constraints before being stored or retrieved.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```kotlin
|
||||||
|
* val validator: PreferencesValidator = DefaultPreferencesValidator()
|
||||||
|
* validator.validateKey("theme")
|
||||||
|
* validator.validateValue("dark")
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
interface PreferencesValidator {
|
||||||
|
/**
|
||||||
|
* Validates the provided key for use in the data store.
|
||||||
|
*
|
||||||
|
* @param key The key to validate.
|
||||||
|
* @return [Result.success] if the key is valid, or [Result.failure] with an exception if invalid.
|
||||||
|
*/
|
||||||
|
fun validateKey(key: String): Result<Unit>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the provided value for use in the data store.
|
||||||
|
*
|
||||||
|
* @param value The value to validate.
|
||||||
|
* @return [Result.success] if the value is valid, or [Result.failure] with an exception if invalid.
|
||||||
|
*/
|
||||||
|
fun <T> validateValue(value: T): Result<Unit>
|
||||||
|
}
|
||||||
@ -0,0 +1,872 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package template.core.base.datastore
|
||||||
|
|
||||||
|
import app.cash.turbine.test
|
||||||
|
import com.russhwolf.settings.MapSettings
|
||||||
|
import com.russhwolf.settings.Settings
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlinx.datetime.Clock.System
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import org.koin.core.context.startKoin
|
||||||
|
import org.koin.core.context.stopKoin
|
||||||
|
import org.koin.core.qualifier.named
|
||||||
|
import org.koin.dsl.module
|
||||||
|
import org.koin.test.KoinTest
|
||||||
|
import org.koin.test.inject
|
||||||
|
import template.core.base.datastore.cache.LruCacheManager
|
||||||
|
import template.core.base.datastore.contracts.DataStoreChangeEvent
|
||||||
|
import template.core.base.datastore.di.CoreDatastoreModule
|
||||||
|
import template.core.base.datastore.extensions.onlyAdditions
|
||||||
|
import template.core.base.datastore.extensions.onlyRemovals
|
||||||
|
import template.core.base.datastore.extensions.onlyUpdates
|
||||||
|
import template.core.base.datastore.factory.DataStoreFactory
|
||||||
|
import template.core.base.datastore.handlers.IntTypeHandler
|
||||||
|
import template.core.base.datastore.reactive.PreferenceFlowOperators
|
||||||
|
import template.core.base.datastore.repository.ReactivePreferencesRepository
|
||||||
|
import template.core.base.datastore.serialization.JsonSerializationStrategy
|
||||||
|
import template.core.base.datastore.validation.DefaultPreferencesValidator
|
||||||
|
import kotlin.test.AfterTest
|
||||||
|
import kotlin.test.BeforeTest
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
import kotlin.time.measureTime
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Comprehensive feature test for the Core DataStore Module.
|
||||||
|
* Tests all functionality including basic operations, reactive features,
|
||||||
|
* caching, serialization, validation, and performance characteristics.
|
||||||
|
*/
|
||||||
|
@ExperimentalCoroutinesApi
|
||||||
|
class DataStoreComprehensiveFeatureTest : KoinTest {
|
||||||
|
|
||||||
|
private val testDispatcher = StandardTestDispatcher()
|
||||||
|
private val repository: ReactivePreferencesRepository by inject()
|
||||||
|
private val operators: PreferenceFlowOperators by inject()
|
||||||
|
|
||||||
|
// Test data models
|
||||||
|
@Serializable
|
||||||
|
data class UserProfile(
|
||||||
|
val id: Long = 0,
|
||||||
|
val name: String = "",
|
||||||
|
val email: String = "",
|
||||||
|
val age: Int = 0,
|
||||||
|
val isActive: Boolean = true,
|
||||||
|
val preferences: UserPreferences = UserPreferences(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class UserPreferences(
|
||||||
|
val theme: String = "light",
|
||||||
|
val language: String = "en",
|
||||||
|
val notifications: Boolean = true,
|
||||||
|
val fontSize: Float = 14.0f,
|
||||||
|
val autoSave: Boolean = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AppConfig(
|
||||||
|
val version: String,
|
||||||
|
val features: List<String>,
|
||||||
|
val settings: Map<String, String>,
|
||||||
|
)
|
||||||
|
|
||||||
|
@BeforeTest
|
||||||
|
fun setup() {
|
||||||
|
startKoin {
|
||||||
|
modules(
|
||||||
|
CoreDatastoreModule,
|
||||||
|
module {
|
||||||
|
single<Settings> { MapSettings() }
|
||||||
|
single<CoroutineDispatcher>(
|
||||||
|
qualifier = named("IO"),
|
||||||
|
) { testDispatcher }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterTest
|
||||||
|
fun tearDown() {
|
||||||
|
stopKoin()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test 1: Basic DataStore Operations
|
||||||
|
* Tests fundamental CRUD operations for all primitive types
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun test01_BasicDataStoreOperations() = runTest(testDispatcher) {
|
||||||
|
println("=== Testing Basic DataStore Operations ===")
|
||||||
|
|
||||||
|
// Test all primitive types
|
||||||
|
val primitiveTests = mapOf(
|
||||||
|
"int_key" to 42,
|
||||||
|
"long_key" to 123456789L,
|
||||||
|
"float_key" to 3.14f,
|
||||||
|
"double_key" to 2.71828,
|
||||||
|
"string_key" to "Hello DataStore!",
|
||||||
|
"boolean_key" to true,
|
||||||
|
)
|
||||||
|
|
||||||
|
primitiveTests.forEach { (key, value) ->
|
||||||
|
println("Testing $key with value: $value")
|
||||||
|
|
||||||
|
// Save preference
|
||||||
|
val saveResult = repository.savePreference(key, value)
|
||||||
|
assertTrue(saveResult.isSuccess, "Failed to save $key")
|
||||||
|
|
||||||
|
// Retrieve preference
|
||||||
|
val retrieveResult = repository.getPreference(key, getDefaultForType(value))
|
||||||
|
assertTrue(retrieveResult.isSuccess, "Failed to retrieve $key")
|
||||||
|
assertEquals(value, retrieveResult.getOrThrow(), "Value mismatch for $key")
|
||||||
|
|
||||||
|
// Check if key exists
|
||||||
|
assertTrue(repository.hasPreference(key), "Key $key should exist")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test key retrieval
|
||||||
|
val allKeys = repository.observeAllKeys().first()
|
||||||
|
assertTrue(allKeys.containsAll(primitiveTests.keys), "Not all keys found")
|
||||||
|
|
||||||
|
println("✅ Basic operations test passed")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test 2: Serializable Object Storage
|
||||||
|
* Tests complex object serialization and deserialization
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun test02_SerializableObjectStorage() = runTest(testDispatcher) {
|
||||||
|
println("=== Testing Serializable Object Storage ===")
|
||||||
|
|
||||||
|
// Test UserProfile storage
|
||||||
|
val userProfile = UserProfile(
|
||||||
|
id = 12345,
|
||||||
|
name = "John Doe",
|
||||||
|
email = "john.doe@example.com",
|
||||||
|
age = 30,
|
||||||
|
isActive = true,
|
||||||
|
preferences = UserPreferences(
|
||||||
|
theme = "dark",
|
||||||
|
language = "es",
|
||||||
|
notifications = false,
|
||||||
|
fontSize = 16.0f,
|
||||||
|
autoSave = true,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Save complex object
|
||||||
|
val saveResult = repository.saveSerializablePreference(
|
||||||
|
"user_profile",
|
||||||
|
userProfile,
|
||||||
|
UserProfile.serializer(),
|
||||||
|
)
|
||||||
|
assertTrue(saveResult.isSuccess, "Failed to save UserProfile")
|
||||||
|
|
||||||
|
// Retrieve complex object
|
||||||
|
val retrieveResult = repository.getSerializablePreference(
|
||||||
|
"user_profile",
|
||||||
|
UserProfile(),
|
||||||
|
UserProfile.serializer(),
|
||||||
|
)
|
||||||
|
assertTrue(retrieveResult.isSuccess, "Failed to retrieve UserProfile")
|
||||||
|
assertEquals(userProfile, retrieveResult.getOrThrow(), "UserProfile mismatch")
|
||||||
|
|
||||||
|
// Test AppConfig with collections
|
||||||
|
val appConfig = AppConfig(
|
||||||
|
version = "1.2.3",
|
||||||
|
features = listOf("feature1", "feature2", "feature3"),
|
||||||
|
settings = mapOf(
|
||||||
|
"timeout" to "30",
|
||||||
|
"retries" to "3",
|
||||||
|
"debug" to "false",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val configSaveResult = repository.saveSerializablePreference(
|
||||||
|
"app_config",
|
||||||
|
appConfig,
|
||||||
|
AppConfig.serializer(),
|
||||||
|
)
|
||||||
|
assertTrue(configSaveResult.isSuccess, "Failed to save AppConfig")
|
||||||
|
|
||||||
|
val configRetrieveResult = repository.getSerializablePreference(
|
||||||
|
"app_config",
|
||||||
|
AppConfig("", emptyList(), emptyMap()),
|
||||||
|
AppConfig.serializer(),
|
||||||
|
)
|
||||||
|
assertTrue(configRetrieveResult.isSuccess, "Failed to retrieve AppConfig")
|
||||||
|
assertEquals(appConfig, configRetrieveResult.getOrThrow(), "AppConfig mismatch")
|
||||||
|
|
||||||
|
println("✅ Serializable object storage test passed")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test 3: Reactive Functionality
|
||||||
|
* Tests reactive flows, change notifications, and observers
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun test03_ReactiveFunctionality() = runTest(testDispatcher) {
|
||||||
|
println("=== Testing Reactive Functionality ===")
|
||||||
|
|
||||||
|
// Test preference observation
|
||||||
|
repository.observePreference("reactive_key", "default").test {
|
||||||
|
// Initial value
|
||||||
|
assertEquals("default", awaitItem())
|
||||||
|
|
||||||
|
// Update value
|
||||||
|
repository.savePreference("reactive_key", "updated")
|
||||||
|
assertEquals("updated", awaitItem())
|
||||||
|
|
||||||
|
// Update again
|
||||||
|
repository.savePreference("reactive_key", "final")
|
||||||
|
assertEquals("final", awaitItem())
|
||||||
|
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test change notifications
|
||||||
|
repository.observePreferenceChanges().test {
|
||||||
|
// Add a new preference
|
||||||
|
repository.savePreference("change_test", "value1")
|
||||||
|
val addChange = awaitItem()
|
||||||
|
assertTrue(addChange is DataStoreChangeEvent.ValueAdded)
|
||||||
|
assertEquals("change_test", addChange.key)
|
||||||
|
|
||||||
|
// Update the preference
|
||||||
|
repository.savePreference("change_test", "value2")
|
||||||
|
val updateChange = awaitItem()
|
||||||
|
assertTrue(updateChange is DataStoreChangeEvent.ValueUpdated)
|
||||||
|
assertEquals("change_test", updateChange.key)
|
||||||
|
assertEquals("value1", updateChange.oldValue)
|
||||||
|
assertEquals("value2", updateChange.newValue)
|
||||||
|
|
||||||
|
// Remove the preference
|
||||||
|
repository.removePreference("change_test")
|
||||||
|
val removeChange = awaitItem()
|
||||||
|
assertTrue(removeChange is DataStoreChangeEvent.ValueRemoved)
|
||||||
|
assertEquals("change_test", removeChange.key)
|
||||||
|
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test serializable object observation
|
||||||
|
val defaultProfile = UserProfile()
|
||||||
|
repository.observeSerializablePreference(
|
||||||
|
"profile_reactive",
|
||||||
|
defaultProfile,
|
||||||
|
UserProfile.serializer(),
|
||||||
|
).test {
|
||||||
|
assertEquals(defaultProfile, awaitItem())
|
||||||
|
|
||||||
|
val newProfile = UserProfile(id = 999, name = "Jane")
|
||||||
|
repository.saveSerializablePreference(
|
||||||
|
"profile_reactive",
|
||||||
|
newProfile,
|
||||||
|
UserProfile.serializer(),
|
||||||
|
)
|
||||||
|
assertEquals(newProfile, awaitItem())
|
||||||
|
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
|
||||||
|
println("✅ Reactive functionality test passed")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test 4: Preference Flow Operators
|
||||||
|
* Tests combining, mapping, and advanced flow operations
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun test04_PreferenceFlowOperators() = runTest(testDispatcher) {
|
||||||
|
println("=== Testing Preference Flow Operators ===")
|
||||||
|
|
||||||
|
// Test combining two preferences
|
||||||
|
operators.combinePreferences(
|
||||||
|
"username",
|
||||||
|
"",
|
||||||
|
"is_premium",
|
||||||
|
false,
|
||||||
|
) { username, isPremium ->
|
||||||
|
"User: $username, Premium: $isPremium"
|
||||||
|
}.test {
|
||||||
|
assertEquals("User: , Premium: false", awaitItem())
|
||||||
|
|
||||||
|
repository.savePreference("username", "alice")
|
||||||
|
assertEquals("User: alice, Premium: false", awaitItem())
|
||||||
|
|
||||||
|
repository.savePreference("is_premium", true)
|
||||||
|
assertEquals("User: alice, Premium: true", awaitItem())
|
||||||
|
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test combining three preferences
|
||||||
|
operators.combinePreferences(
|
||||||
|
"theme",
|
||||||
|
"light",
|
||||||
|
"language",
|
||||||
|
"en",
|
||||||
|
"notifications",
|
||||||
|
true,
|
||||||
|
) { theme, lang, notifs ->
|
||||||
|
Triple(theme, lang, notifs)
|
||||||
|
}.test {
|
||||||
|
assertEquals(Triple("light", "en", true), awaitItem())
|
||||||
|
|
||||||
|
repository.savePreference("theme", "dark")
|
||||||
|
assertEquals(Triple("dark", "en", true), awaitItem())
|
||||||
|
|
||||||
|
repository.savePreference("language", "es")
|
||||||
|
assertEquals(Triple("dark", "es", true), awaitItem())
|
||||||
|
|
||||||
|
repository.savePreference("notifications", false)
|
||||||
|
assertEquals(Triple("dark", "es", false), awaitItem())
|
||||||
|
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test mapped preference observation
|
||||||
|
operators.observeMappedPreference("counter", 0) { count ->
|
||||||
|
"Count: $count"
|
||||||
|
}.test {
|
||||||
|
assertEquals("Count: 0", awaitItem())
|
||||||
|
|
||||||
|
repository.savePreference("counter", 5)
|
||||||
|
assertEquals("Count: 5", awaitItem())
|
||||||
|
|
||||||
|
repository.savePreference("counter", 10)
|
||||||
|
assertEquals("Count: 10", awaitItem())
|
||||||
|
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test key change observation
|
||||||
|
operators.observeAnyKeyChange("monitored1", "monitored2").test {
|
||||||
|
repository.savePreference("monitored1", "value1")
|
||||||
|
assertEquals("monitored1", awaitItem())
|
||||||
|
|
||||||
|
repository.savePreference("unmonitored", "value")
|
||||||
|
// Should not emit
|
||||||
|
|
||||||
|
repository.savePreference("monitored2", "value2")
|
||||||
|
assertEquals("monitored2", awaitItem())
|
||||||
|
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
|
||||||
|
println("✅ Preference flow operators test passed")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test 5: Caching Functionality
|
||||||
|
* Tests LRU cache behavior and cache management
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun test05_CachingFunctionality() = runTest(testDispatcher) {
|
||||||
|
println("=== Testing Caching Functionality ===")
|
||||||
|
|
||||||
|
// Create a small cache to test eviction
|
||||||
|
val cache = LruCacheManager<String, String>(maxSize = 3)
|
||||||
|
|
||||||
|
// Test basic cache operations
|
||||||
|
cache.put("key1", "value1")
|
||||||
|
cache.put("key2", "value2")
|
||||||
|
cache.put("key3", "value3")
|
||||||
|
|
||||||
|
assertEquals(3, cache.size())
|
||||||
|
assertEquals("value1", cache.get("key1"))
|
||||||
|
assertEquals("value2", cache.get("key2"))
|
||||||
|
assertEquals("value3", cache.get("key3"))
|
||||||
|
|
||||||
|
// Test LRU eviction
|
||||||
|
cache.put("key4", "value4") // Should evict key1 (least recently used)
|
||||||
|
assertEquals(3, cache.size())
|
||||||
|
assertNull(cache.get("key1"), "key1 should have been evicted")
|
||||||
|
assertEquals("value4", cache.get("key4"))
|
||||||
|
|
||||||
|
// Test cache removal
|
||||||
|
cache.remove("key2")
|
||||||
|
assertEquals(2, cache.size())
|
||||||
|
assertNull(cache.get("key2"))
|
||||||
|
|
||||||
|
// Test cache clear
|
||||||
|
cache.clear()
|
||||||
|
assertEquals(0, cache.size())
|
||||||
|
|
||||||
|
// Test with datastore factory
|
||||||
|
val settings = MapSettings()
|
||||||
|
val cacheableDataStore = DataStoreFactory()
|
||||||
|
.settings(settings)
|
||||||
|
.cacheSize(5)
|
||||||
|
.dispatcher(testDispatcher)
|
||||||
|
.buildDataStore()
|
||||||
|
|
||||||
|
// Test cache hit/miss behavior
|
||||||
|
val putResult = cacheableDataStore.putValue("cached_key", "cached_value")
|
||||||
|
assertTrue(putResult.isSuccess)
|
||||||
|
|
||||||
|
val getResult = cacheableDataStore.getValue("cached_key", "default")
|
||||||
|
assertTrue(getResult.isSuccess)
|
||||||
|
assertEquals("cached_value", getResult.getOrThrow())
|
||||||
|
|
||||||
|
// Verify cache contains the value
|
||||||
|
assertTrue(cacheableDataStore.getCacheSize() > 0)
|
||||||
|
|
||||||
|
// Test cache invalidation
|
||||||
|
val invalidateResult = cacheableDataStore.invalidateCache("cached_key")
|
||||||
|
assertTrue(invalidateResult.isSuccess)
|
||||||
|
|
||||||
|
println("✅ Caching functionality test passed")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test 6: Validation and Error Handling
|
||||||
|
* Tests input validation and error scenarios
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun test06_ValidationAndErrorHandling() = runTest(testDispatcher) {
|
||||||
|
println("=== Testing Validation and Error Handling ===")
|
||||||
|
|
||||||
|
val validator = DefaultPreferencesValidator()
|
||||||
|
|
||||||
|
// Test key validation
|
||||||
|
assertTrue(validator.validateKey("valid_key").isSuccess)
|
||||||
|
assertTrue(validator.validateKey("").isFailure)
|
||||||
|
assertTrue(validator.validateKey(" ".repeat(256)).isFailure)
|
||||||
|
|
||||||
|
// Test value validation
|
||||||
|
assertTrue(validator.validateValue("valid_value").isSuccess)
|
||||||
|
assertTrue(validator.validateValue(123).isSuccess)
|
||||||
|
assertTrue(validator.validateValue(null).isFailure)
|
||||||
|
|
||||||
|
val longString = "x".repeat(20000)
|
||||||
|
assertTrue(validator.validateValue(longString).isFailure)
|
||||||
|
|
||||||
|
// Test serialization error handling
|
||||||
|
val strategy = JsonSerializationStrategy()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class TestData(val value: String)
|
||||||
|
|
||||||
|
val validData = TestData("test")
|
||||||
|
val serializeResult = strategy.serialize(validData, TestData.serializer())
|
||||||
|
assertTrue(serializeResult.isSuccess)
|
||||||
|
|
||||||
|
val deserializeResult = strategy.deserialize(
|
||||||
|
serializeResult.getOrThrow(),
|
||||||
|
TestData.serializer(),
|
||||||
|
)
|
||||||
|
assertTrue(deserializeResult.isSuccess)
|
||||||
|
assertEquals(validData, deserializeResult.getOrThrow())
|
||||||
|
|
||||||
|
// Test deserializing invalid JSON
|
||||||
|
val invalidDeserializeResult = strategy.deserialize(
|
||||||
|
"invalid json",
|
||||||
|
TestData.serializer(),
|
||||||
|
)
|
||||||
|
assertTrue(invalidDeserializeResult.isFailure)
|
||||||
|
|
||||||
|
// Test type handler error scenarios
|
||||||
|
val intHandler = IntTypeHandler()
|
||||||
|
assertTrue(intHandler.canHandle(42))
|
||||||
|
assertFalse(intHandler.canHandle("not an int"))
|
||||||
|
assertFalse(intHandler.canHandle(null))
|
||||||
|
|
||||||
|
println("✅ Validation and error handling test passed")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test 7: Flow Extensions
|
||||||
|
* Tests custom flow extension functions
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun test07_FlowExtensions() = runTest(testDispatcher) {
|
||||||
|
println("=== Testing Flow Extensions ===")
|
||||||
|
|
||||||
|
// Test change filtering extensions
|
||||||
|
repository.observePreferenceChanges().onlyAdditions().test {
|
||||||
|
repository.savePreference("add_test", "value1")
|
||||||
|
val addition = awaitItem()
|
||||||
|
assertTrue(addition is DataStoreChangeEvent.ValueAdded)
|
||||||
|
assertEquals("add_test", addition.key)
|
||||||
|
|
||||||
|
// Update should not appear in additions
|
||||||
|
repository.savePreference("add_test", "value2")
|
||||||
|
|
||||||
|
repository.savePreference("add_test2", "value2")
|
||||||
|
val addition2 = awaitItem()
|
||||||
|
assertTrue(addition2 is DataStoreChangeEvent.ValueAdded)
|
||||||
|
assertEquals("add_test2", addition2.key)
|
||||||
|
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
|
||||||
|
repository.observePreferenceChanges().onlyUpdates().test {
|
||||||
|
// First save (addition) should not appear
|
||||||
|
repository.savePreference("update_test", "initial")
|
||||||
|
|
||||||
|
// Second save (update) should appear
|
||||||
|
repository.savePreference("update_test", "updated")
|
||||||
|
val update = awaitItem()
|
||||||
|
assertTrue(update is DataStoreChangeEvent.ValueUpdated)
|
||||||
|
assertEquals("update_test", update.key)
|
||||||
|
assertEquals("initial", update.oldValue)
|
||||||
|
assertEquals("updated", update.newValue)
|
||||||
|
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
|
||||||
|
repository.observePreferenceChanges().onlyRemovals().test {
|
||||||
|
repository.savePreference("remove_test", "value")
|
||||||
|
|
||||||
|
repository.removePreference("remove_test")
|
||||||
|
val removal = awaitItem()
|
||||||
|
assertTrue(removal is DataStoreChangeEvent.ValueRemoved)
|
||||||
|
assertEquals("remove_test", removal.key)
|
||||||
|
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
|
||||||
|
println("✅ Flow extensions test passed")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test 8: Performance and Stress Testing
|
||||||
|
* Tests performance characteristics under load
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun test08_PerformanceAndStressTesting() = runTest(testDispatcher) {
|
||||||
|
println("=== Testing Performance and Stress ===")
|
||||||
|
|
||||||
|
val operationCount = 100
|
||||||
|
|
||||||
|
// Test rapid sequential operations
|
||||||
|
val sequentialDuration = measureTime {
|
||||||
|
repeat(operationCount) { i ->
|
||||||
|
repository.savePreference("perf_key_$i", "value_$i")
|
||||||
|
}
|
||||||
|
advanceUntilIdle()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify all values were saved
|
||||||
|
repeat(operationCount) { i ->
|
||||||
|
val result = repository.getPreference("perf_key_$i", "")
|
||||||
|
assertEquals("value_$i", result.getOrThrow())
|
||||||
|
}
|
||||||
|
|
||||||
|
println("Sequential operations ($operationCount): ${sequentialDuration.inWholeMilliseconds}ms")
|
||||||
|
|
||||||
|
// Test rapid updates to same key
|
||||||
|
repository.observePreference("rapid_update", 0).test {
|
||||||
|
assertEquals(0, awaitItem()) // Initial value
|
||||||
|
|
||||||
|
val updateDuration = measureTime {
|
||||||
|
repeat(50) { i ->
|
||||||
|
repository.savePreference("rapid_update", i + 1)
|
||||||
|
advanceUntilIdle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should receive all updates
|
||||||
|
repeat(50) { i ->
|
||||||
|
assertEquals(i + 1, awaitItem())
|
||||||
|
}
|
||||||
|
|
||||||
|
println("Rapid updates (50): ${updateDuration.inWholeMilliseconds}ms")
|
||||||
|
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test large object serialization
|
||||||
|
val largeConfig = AppConfig(
|
||||||
|
version = "1.0.0",
|
||||||
|
features = (1..100).map { "feature_$it" },
|
||||||
|
settings = (1..50).associate { "setting_$it" to "value_$it" },
|
||||||
|
)
|
||||||
|
|
||||||
|
val serializationDuration = measureTime {
|
||||||
|
repeat(10) {
|
||||||
|
repository.saveSerializablePreference(
|
||||||
|
"large_config_$it",
|
||||||
|
largeConfig,
|
||||||
|
AppConfig.serializer(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
advanceUntilIdle()
|
||||||
|
}
|
||||||
|
|
||||||
|
println("Large object serialization (10): ${serializationDuration.inWholeMilliseconds}ms")
|
||||||
|
|
||||||
|
// Verify large objects were saved correctly
|
||||||
|
repeat(10) {
|
||||||
|
val result = repository.getSerializablePreference(
|
||||||
|
"large_config_$it",
|
||||||
|
AppConfig("", emptyList(), emptyMap()),
|
||||||
|
AppConfig.serializer(),
|
||||||
|
)
|
||||||
|
assertEquals(largeConfig, result.getOrThrow())
|
||||||
|
}
|
||||||
|
|
||||||
|
println("✅ Performance and stress test passed")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test 9: Complete Integration Scenario
|
||||||
|
* Tests realistic app usage patterns
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun test09_CompleteIntegrationScenario() = runTest(testDispatcher) {
|
||||||
|
println("=== Testing Complete Integration Scenario ===")
|
||||||
|
|
||||||
|
// Simulate complete app onboarding and usage
|
||||||
|
|
||||||
|
// 1. Initial app setup
|
||||||
|
val appConfig = AppConfig(
|
||||||
|
version = "2.1.0",
|
||||||
|
features = listOf("dark_mode", "notifications", "analytics"),
|
||||||
|
settings = mapOf(
|
||||||
|
"api_timeout" to "30000",
|
||||||
|
"cache_size" to "100",
|
||||||
|
"log_level" to "info",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
repository.saveSerializablePreference("app_config", appConfig, AppConfig.serializer())
|
||||||
|
|
||||||
|
// 2. User profile creation
|
||||||
|
val userProfile = UserProfile(
|
||||||
|
id = 12345,
|
||||||
|
name = "Integration Test User",
|
||||||
|
email = "test@example.com",
|
||||||
|
age = 25,
|
||||||
|
preferences = UserPreferences(
|
||||||
|
theme = "auto",
|
||||||
|
language = "en",
|
||||||
|
notifications = true,
|
||||||
|
fontSize = 15.0f,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
repository.saveSerializablePreference("user_profile", userProfile, UserProfile.serializer())
|
||||||
|
|
||||||
|
// 3. Session preferences
|
||||||
|
repository.savePreference("session_id", "sess_abc123")
|
||||||
|
repository.savePreference("login_timestamp", System.now().toEpochMilliseconds())
|
||||||
|
repository.savePreference("device_id", "device_xyz789")
|
||||||
|
|
||||||
|
// 4. Feature flags and settings
|
||||||
|
val featureFlags = mapOf(
|
||||||
|
"new_ui" to true,
|
||||||
|
"beta_features" to false,
|
||||||
|
"experimental_api" to true,
|
||||||
|
)
|
||||||
|
|
||||||
|
featureFlags.forEach { (flag, enabled) ->
|
||||||
|
repository.savePreference("feature_$flag", enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Observe combined user state
|
||||||
|
operators.combinePreferences(
|
||||||
|
"user_profile",
|
||||||
|
UserProfile(),
|
||||||
|
"session_id",
|
||||||
|
"",
|
||||||
|
) { profile, sessionId ->
|
||||||
|
"User: ${profile.name} (${profile.email}), Session: $sessionId"
|
||||||
|
}.test {
|
||||||
|
val expectedState =
|
||||||
|
"User: ${userProfile.name} (${userProfile.email}), Session: sess_abc123"
|
||||||
|
assertEquals(expectedState, awaitItem())
|
||||||
|
|
||||||
|
// Update user profile
|
||||||
|
val updatedProfile = userProfile.copy(name = "Updated User")
|
||||||
|
repository.saveSerializablePreference(
|
||||||
|
"user_profile",
|
||||||
|
updatedProfile,
|
||||||
|
UserProfile.serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val expectedUpdatedState =
|
||||||
|
"User: Updated User (${userProfile.email}), Session: sess_abc123"
|
||||||
|
assertEquals(expectedUpdatedState, awaitItem())
|
||||||
|
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Verify all data persistence
|
||||||
|
val retrievedConfig = repository.getSerializablePreference(
|
||||||
|
"app_config",
|
||||||
|
AppConfig("", emptyList(), emptyMap()),
|
||||||
|
AppConfig.serializer(),
|
||||||
|
).getOrThrow()
|
||||||
|
assertEquals(appConfig, retrievedConfig)
|
||||||
|
|
||||||
|
val retrievedProfile = repository.getSerializablePreference(
|
||||||
|
"user_profile",
|
||||||
|
UserProfile(),
|
||||||
|
UserProfile.serializer(),
|
||||||
|
).getOrThrow()
|
||||||
|
assertEquals("Updated User", retrievedProfile.name)
|
||||||
|
|
||||||
|
// 7. Test bulk operations
|
||||||
|
val bulkClearDuration = measureTime {
|
||||||
|
repository.clearAllPreferences()
|
||||||
|
advanceUntilIdle()
|
||||||
|
}
|
||||||
|
|
||||||
|
println("Bulk clear operation: ${bulkClearDuration.inWholeMilliseconds}ms")
|
||||||
|
|
||||||
|
// Verify everything was cleared
|
||||||
|
val keysAfterClear = repository.observeAllKeys().first()
|
||||||
|
assertTrue(keysAfterClear.isEmpty(), "All keys should be cleared")
|
||||||
|
|
||||||
|
println("✅ Complete integration scenario test passed")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test 10: Edge Cases and Boundary Conditions
|
||||||
|
* Tests unusual scenarios and edge cases
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun test10_EdgeCasesAndBoundaryConditions() = runTest(testDispatcher) {
|
||||||
|
println("=== Testing Edge Cases and Boundary Conditions ===")
|
||||||
|
|
||||||
|
// Test empty string handling
|
||||||
|
repository.savePreference("empty_string", "")
|
||||||
|
assertEquals("", repository.getPreference("empty_string", "default").getOrThrow())
|
||||||
|
|
||||||
|
// Test special characters in keys and values
|
||||||
|
val specialKey = "key_with_special_chars_!@#$%^&*()"
|
||||||
|
val specialValue = "Value with émojis 🚀💻 and spëcial chars: <>?/|\\`~"
|
||||||
|
repository.savePreference(specialKey, specialValue)
|
||||||
|
assertEquals(specialValue, repository.getPreference(specialKey, "").getOrThrow())
|
||||||
|
|
||||||
|
// Test very long strings (within limits)
|
||||||
|
val longValue = "x".repeat(9999) // Just under the 10000 limit
|
||||||
|
repository.savePreference("long_value", longValue)
|
||||||
|
assertEquals(longValue, repository.getPreference("long_value", "").getOrThrow())
|
||||||
|
|
||||||
|
// Test numeric edge cases
|
||||||
|
repository.savePreference("max_int", Int.MAX_VALUE)
|
||||||
|
repository.savePreference("min_int", Int.MIN_VALUE)
|
||||||
|
repository.savePreference("max_long", Long.MAX_VALUE)
|
||||||
|
repository.savePreference("min_long", Long.MIN_VALUE)
|
||||||
|
repository.savePreference("max_float", Float.MAX_VALUE)
|
||||||
|
repository.savePreference("min_float", Float.MIN_VALUE)
|
||||||
|
repository.savePreference("max_double", Double.MAX_VALUE)
|
||||||
|
repository.savePreference("min_double", Double.MIN_VALUE)
|
||||||
|
|
||||||
|
assertEquals(Int.MAX_VALUE, repository.getPreference("max_int", 0).getOrThrow())
|
||||||
|
assertEquals(Int.MIN_VALUE, repository.getPreference("min_int", 0).getOrThrow())
|
||||||
|
assertEquals(Long.MAX_VALUE, repository.getPreference("max_long", 0L).getOrThrow())
|
||||||
|
assertEquals(Long.MIN_VALUE, repository.getPreference("min_long", 0L).getOrThrow())
|
||||||
|
assertEquals(Float.MAX_VALUE, repository.getPreference("max_float", 0f).getOrThrow())
|
||||||
|
assertEquals(Float.MIN_VALUE, repository.getPreference("min_float", 0f).getOrThrow())
|
||||||
|
assertEquals(Double.MAX_VALUE, repository.getPreference("max_double", 0.0).getOrThrow())
|
||||||
|
assertEquals(Double.MIN_VALUE, repository.getPreference("min_double", 0.0).getOrThrow())
|
||||||
|
|
||||||
|
// Test rapid key creation and deletion
|
||||||
|
repeat(20) { i ->
|
||||||
|
repository.savePreference("temp_key_$i", "temp_value_$i")
|
||||||
|
}
|
||||||
|
|
||||||
|
repeat(20) { i ->
|
||||||
|
assertTrue(repository.hasPreference("temp_key_$i"))
|
||||||
|
repository.removePreference("temp_key_$i")
|
||||||
|
assertFalse(repository.hasPreference("temp_key_$i"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test observing non-existent keys
|
||||||
|
repository.observePreference("non_existent", "default_value").test {
|
||||||
|
assertEquals("default_value", awaitItem())
|
||||||
|
|
||||||
|
// Create the key
|
||||||
|
repository.savePreference("non_existent", "now_exists")
|
||||||
|
assertEquals("now_exists", awaitItem())
|
||||||
|
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test multiple observers on same key
|
||||||
|
val observers = List(5) {
|
||||||
|
repository.observePreference("shared_key", "initial")
|
||||||
|
}
|
||||||
|
|
||||||
|
observers.forEach { flow ->
|
||||||
|
flow.test {
|
||||||
|
assertEquals("initial", awaitItem())
|
||||||
|
expectNoEvents() // Should not have additional events yet
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
println("✅ Edge cases and boundary conditions test passed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to get default values for different types
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
private fun <T> getDefaultForType(value: T): T = when (value) {
|
||||||
|
is Int -> 0 as T
|
||||||
|
is Long -> 0L as T
|
||||||
|
is Float -> 0f as T
|
||||||
|
is Double -> 0.0 as T
|
||||||
|
is String -> "" as T
|
||||||
|
is Boolean -> false as T
|
||||||
|
else -> throw IllegalArgumentException("Unsupported type: ${value?.let { it::class }}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs all tests in sequence and provides a comprehensive report
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun runAllFeatureTests() = runTest(testDispatcher) {
|
||||||
|
println("🚀 Starting Comprehensive DataStore Feature Test Suite")
|
||||||
|
println("=" * 60)
|
||||||
|
|
||||||
|
val totalDuration = measureTime {
|
||||||
|
try {
|
||||||
|
test01_BasicDataStoreOperations()
|
||||||
|
test02_SerializableObjectStorage()
|
||||||
|
test03_ReactiveFunctionality()
|
||||||
|
test04_PreferenceFlowOperators()
|
||||||
|
test05_CachingFunctionality()
|
||||||
|
test06_ValidationAndErrorHandling()
|
||||||
|
test07_FlowExtensions()
|
||||||
|
test08_PerformanceAndStressTesting()
|
||||||
|
test09_CompleteIntegrationScenario()
|
||||||
|
test10_EdgeCasesAndBoundaryConditions()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("❌ Test suite failed with error: ${e.message}")
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
println("=" * 60)
|
||||||
|
println("🎉 ALL FEATURE TESTS PASSED!")
|
||||||
|
println("⏱️ Total execution time: ${totalDuration.inWholeMilliseconds}ms")
|
||||||
|
println("✅ DataStore module is fully functional and ready for production")
|
||||||
|
println("=" * 60)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extension function for string repetition (Kotlin doesn't have this built-in)
|
||||||
|
*/
|
||||||
|
private operator fun String.times(count: Int): String = repeat(count)
|
||||||
@ -0,0 +1,133 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package template.core.base.datastore
|
||||||
|
|
||||||
|
import com.russhwolf.settings.MapSettings
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import template.core.base.datastore.cache.LruCacheManager
|
||||||
|
import template.core.base.datastore.handlers.BooleanTypeHandler
|
||||||
|
import template.core.base.datastore.handlers.IntTypeHandler
|
||||||
|
import template.core.base.datastore.handlers.StringTypeHandler
|
||||||
|
import template.core.base.datastore.handlers.TypeHandler
|
||||||
|
import template.core.base.datastore.serialization.JsonSerializationStrategy
|
||||||
|
import template.core.base.datastore.store.CachedPreferencesStore
|
||||||
|
import template.core.base.datastore.validation.DefaultPreferencesValidator
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
@ExperimentalCoroutinesApi
|
||||||
|
class EnhancedUserPreferencesDataStoreTest {
|
||||||
|
private val testDispatcher = StandardTestDispatcher()
|
||||||
|
private val settings = MapSettings()
|
||||||
|
private val cacheManager = LruCacheManager<String, Any>(maxSize = 2)
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
private val dataStore = CachedPreferencesStore(
|
||||||
|
settings = settings,
|
||||||
|
dispatcher = testDispatcher,
|
||||||
|
typeHandlers = listOf(
|
||||||
|
IntTypeHandler(),
|
||||||
|
StringTypeHandler(),
|
||||||
|
BooleanTypeHandler(),
|
||||||
|
) as List<TypeHandler<Any>>,
|
||||||
|
serializationStrategy = JsonSerializationStrategy(),
|
||||||
|
validator = DefaultPreferencesValidator(),
|
||||||
|
cacheManager = cacheManager,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Custom(val id: Int, val name: String)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun putAndGet_PrimitiveTypes() = runTest(testDispatcher) {
|
||||||
|
assertTrue(dataStore.putValue("int", 1).isSuccess)
|
||||||
|
assertEquals(1, dataStore.getValue("int", 0).getOrThrow())
|
||||||
|
assertTrue(dataStore.putValue("bool", true).isSuccess)
|
||||||
|
assertEquals(true, dataStore.getValue("bool", false).getOrThrow())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun putAndGet_SerializableType() = runTest(testDispatcher) {
|
||||||
|
val custom = Custom(1, "abc")
|
||||||
|
assertTrue(dataStore.putSerializableValue("custom", custom, Custom.serializer()).isSuccess)
|
||||||
|
assertEquals(custom, dataStore.getSerializableValue("custom", Custom(0, ""), Custom.serializer()).getOrThrow())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun getValue_ReturnsDefaultIfMissingOrCorrupt() = runTest(testDispatcher) {
|
||||||
|
assertEquals(99, dataStore.getValue("missing", 99).getOrThrow())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun hasKey_WorksWithCache() = runTest(testDispatcher) {
|
||||||
|
assertTrue(dataStore.hasKey("nope").getOrThrow() == false)
|
||||||
|
assertTrue(dataStore.putValue("exists", 1).isSuccess)
|
||||||
|
assertTrue(dataStore.hasKey("exists").getOrThrow())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun removeValue_RemovesFromCacheAndSettings() = runTest(testDispatcher) {
|
||||||
|
assertTrue(dataStore.putValue("toremove", 5).isSuccess)
|
||||||
|
assertTrue(dataStore.removeValue("toremove").isSuccess)
|
||||||
|
assertEquals(0, dataStore.getValue("toremove", 0).getOrThrow())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun clearAll_RemovesEverything() = runTest(testDispatcher) {
|
||||||
|
assertTrue(dataStore.putValue("a", 1).isSuccess)
|
||||||
|
assertTrue(dataStore.putValue("b", 2).isSuccess)
|
||||||
|
assertTrue(dataStore.clearAll().isSuccess)
|
||||||
|
assertEquals(0, dataStore.getValue("a", 0).getOrThrow())
|
||||||
|
assertEquals(0, dataStore.getValue("b", 0).getOrThrow())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun getAllKeysAndSize() = runTest(testDispatcher) {
|
||||||
|
assertTrue(dataStore.putValue("k1", 1).isSuccess)
|
||||||
|
assertTrue(dataStore.putValue("k2", 2).isSuccess)
|
||||||
|
assertEquals(setOf("k1", "k2"), dataStore.getAllKeys().getOrThrow())
|
||||||
|
assertEquals(2, dataStore.getSize().getOrThrow())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun cacheEviction_WorksAsExpected() = runTest(testDispatcher) {
|
||||||
|
assertTrue(dataStore.putValue("k1", 1).isSuccess)
|
||||||
|
assertTrue(dataStore.putValue("k2", 2).isSuccess)
|
||||||
|
assertTrue(dataStore.putValue("k3", 3).isSuccess) // Should evict k1 if maxSize=2
|
||||||
|
assertEquals(2, dataStore.getCacheSize())
|
||||||
|
assertTrue(!cacheManager.containsKey("k1"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun invalidateCache_RemovesSpecificKey() = runTest(testDispatcher) {
|
||||||
|
assertTrue(dataStore.putValue("k1", 1).isSuccess)
|
||||||
|
assertTrue(dataStore.invalidateCache("k1").isSuccess)
|
||||||
|
assertTrue(!cacheManager.containsKey("k1"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun invalidateAllCache_RemovesAll() = runTest(testDispatcher) {
|
||||||
|
assertTrue(dataStore.putValue("k1", 1).isSuccess)
|
||||||
|
assertTrue(dataStore.putValue("k2", 2).isSuccess)
|
||||||
|
assertTrue(dataStore.invalidateAllCache().isSuccess)
|
||||||
|
assertEquals(0, dataStore.getCacheSize())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun putValue_FailsForUnsupportedType() = runTest(testDispatcher) {
|
||||||
|
class Unsupported
|
||||||
|
val result = dataStore.putValue("bad", Unsupported())
|
||||||
|
assertTrue(result.isFailure)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,220 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package template.core.base.datastore
|
||||||
|
|
||||||
|
import app.cash.turbine.test
|
||||||
|
import com.russhwolf.settings.MapSettings
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import template.core.base.datastore.cache.LruCacheManager
|
||||||
|
import template.core.base.datastore.contracts.DataStoreChangeEvent
|
||||||
|
import template.core.base.datastore.handlers.BooleanTypeHandler
|
||||||
|
import template.core.base.datastore.handlers.DoubleTypeHandler
|
||||||
|
import template.core.base.datastore.handlers.FloatTypeHandler
|
||||||
|
import template.core.base.datastore.handlers.IntTypeHandler
|
||||||
|
import template.core.base.datastore.handlers.LongTypeHandler
|
||||||
|
import template.core.base.datastore.handlers.StringTypeHandler
|
||||||
|
import template.core.base.datastore.handlers.TypeHandler
|
||||||
|
import template.core.base.datastore.reactive.DefaultChangeNotifier
|
||||||
|
import template.core.base.datastore.reactive.DefaultValueObserver
|
||||||
|
import template.core.base.datastore.serialization.JsonSerializationStrategy
|
||||||
|
import template.core.base.datastore.store.ReactiveUserPreferencesDataStore
|
||||||
|
import template.core.base.datastore.validation.DefaultPreferencesValidator
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
@ExperimentalCoroutinesApi
|
||||||
|
class ReactiveUserPreferencesDataStoreTest {
|
||||||
|
|
||||||
|
private val testDispatcher = StandardTestDispatcher()
|
||||||
|
private val settings = MapSettings()
|
||||||
|
private val changeNotifier = DefaultChangeNotifier()
|
||||||
|
|
||||||
|
private val reactiveDataStore = ReactiveUserPreferencesDataStore(
|
||||||
|
settings = settings,
|
||||||
|
dispatcher = testDispatcher,
|
||||||
|
typeHandlers = listOf(
|
||||||
|
IntTypeHandler(),
|
||||||
|
StringTypeHandler(),
|
||||||
|
BooleanTypeHandler(),
|
||||||
|
LongTypeHandler(),
|
||||||
|
FloatTypeHandler(),
|
||||||
|
DoubleTypeHandler(),
|
||||||
|
) as List<TypeHandler<Any>>,
|
||||||
|
serializationStrategy = JsonSerializationStrategy(),
|
||||||
|
validator = DefaultPreferencesValidator(),
|
||||||
|
cacheManager = LruCacheManager(maxSize = 10),
|
||||||
|
changeNotifier = changeNotifier,
|
||||||
|
valueObserver = DefaultValueObserver(changeNotifier),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class TestUser(
|
||||||
|
val id: Long,
|
||||||
|
val name: String,
|
||||||
|
val age: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun observeValue_EmitsInitialValueAndUpdates() = runTest(testDispatcher) {
|
||||||
|
// Initially store a value
|
||||||
|
assertTrue(reactiveDataStore.putValue("test_key", "initial").isSuccess)
|
||||||
|
|
||||||
|
reactiveDataStore.observeValue("test_key", "default").test {
|
||||||
|
// Should emit initial value
|
||||||
|
assertEquals("initial", awaitItem())
|
||||||
|
|
||||||
|
// Update the value
|
||||||
|
assertTrue(reactiveDataStore.putValue("test_key", "updated").isSuccess)
|
||||||
|
assertEquals("updated", awaitItem())
|
||||||
|
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun observeValue_EmitsDefaultWhenKeyNotExists() = runTest(testDispatcher) {
|
||||||
|
reactiveDataStore.observeValue("non_existent", "default").test {
|
||||||
|
assertEquals("default", awaitItem())
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun observeSerializableValue_WorksWithCustomObjects() = runTest(testDispatcher) {
|
||||||
|
val defaultUser = TestUser(0, "", 0)
|
||||||
|
val testUser = TestUser(1, "John", 25)
|
||||||
|
|
||||||
|
reactiveDataStore.observeSerializableValue(
|
||||||
|
"user",
|
||||||
|
defaultUser,
|
||||||
|
TestUser.serializer(),
|
||||||
|
).test {
|
||||||
|
// Should emit default initially
|
||||||
|
assertEquals(defaultUser, awaitItem())
|
||||||
|
|
||||||
|
// Update with new user
|
||||||
|
assertTrue(
|
||||||
|
reactiveDataStore.putSerializableValue(
|
||||||
|
"user",
|
||||||
|
testUser,
|
||||||
|
TestUser.serializer(),
|
||||||
|
).isSuccess,
|
||||||
|
)
|
||||||
|
assertEquals(testUser, awaitItem())
|
||||||
|
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun observeChanges_EmitsCorrectChangeTypes() = runTest(testDispatcher) {
|
||||||
|
reactiveDataStore.observeChanges().test {
|
||||||
|
// Add a value
|
||||||
|
assertTrue(reactiveDataStore.putValue("key1", "value1").isSuccess)
|
||||||
|
val addChange = awaitItem()
|
||||||
|
assertTrue(addChange is DataStoreChangeEvent.ValueAdded)
|
||||||
|
assertEquals("key1", addChange.key)
|
||||||
|
assertEquals("value1", addChange.value)
|
||||||
|
|
||||||
|
// Update the value
|
||||||
|
assertTrue(reactiveDataStore.putValue("key1", "value2").isSuccess)
|
||||||
|
val updateChange = awaitItem()
|
||||||
|
assertTrue(updateChange is DataStoreChangeEvent.ValueUpdated)
|
||||||
|
assertEquals("key1", updateChange.key)
|
||||||
|
assertEquals("value1", updateChange.oldValue)
|
||||||
|
assertEquals("value2", updateChange.newValue)
|
||||||
|
|
||||||
|
// Remove the value
|
||||||
|
assertTrue(reactiveDataStore.removeValue("key1").isSuccess)
|
||||||
|
val removeChange = awaitItem()
|
||||||
|
assertTrue(removeChange is DataStoreChangeEvent.ValueRemoved)
|
||||||
|
assertEquals("key1", removeChange.key)
|
||||||
|
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun observeKeys_EmitsUpdatedKeySets() = runTest(context = testDispatcher) {
|
||||||
|
reactiveDataStore.observeKeys().test {
|
||||||
|
// Initial empty set (from onStart emission)
|
||||||
|
assertEquals(emptySet(), awaitItem())
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
// Add first key
|
||||||
|
assertTrue(reactiveDataStore.putValue("key1", "value1").isSuccess)
|
||||||
|
delay(300) // Allow time for initial emission
|
||||||
|
|
||||||
|
assertEquals(setOf("key1"), awaitItem())
|
||||||
|
|
||||||
|
// Add second key
|
||||||
|
assertTrue(reactiveDataStore.putValue("key2", "value2").isSuccess)
|
||||||
|
assertEquals(setOf("key1", "key2"), awaitItem())
|
||||||
|
|
||||||
|
// Remove first key
|
||||||
|
assertTrue(reactiveDataStore.removeValue("key1").isSuccess)
|
||||||
|
assertEquals(setOf("key2"), awaitItem())
|
||||||
|
|
||||||
|
// Remove second key
|
||||||
|
assertTrue(reactiveDataStore.removeValue("key2").isSuccess)
|
||||||
|
assertEquals(emptySet(), awaitItem())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun observeSize_EmitsCorrectCounts() = runTest(testDispatcher) {
|
||||||
|
reactiveDataStore.observeSize().test {
|
||||||
|
// Initial size should be 0 (from onStart emission)
|
||||||
|
assertEquals(0, awaitItem())
|
||||||
|
|
||||||
|
// Add items
|
||||||
|
assertTrue(reactiveDataStore.putValue("key1", "value1").isSuccess)
|
||||||
|
assertEquals(1, awaitItem())
|
||||||
|
|
||||||
|
assertTrue(reactiveDataStore.putValue("key2", "value2").isSuccess)
|
||||||
|
assertEquals(2, awaitItem())
|
||||||
|
|
||||||
|
// Clear all
|
||||||
|
assertTrue(reactiveDataStore.clearAll().isSuccess)
|
||||||
|
assertEquals(0, awaitItem())
|
||||||
|
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun observeValue_DistinctUntilChanged() = runTest(testDispatcher) {
|
||||||
|
reactiveDataStore.observeValue("key", "default").test {
|
||||||
|
// Initial emission
|
||||||
|
assertEquals("default", awaitItem())
|
||||||
|
|
||||||
|
// Set same value - should not emit
|
||||||
|
assertTrue(reactiveDataStore.putValue("key", "default").isSuccess)
|
||||||
|
|
||||||
|
// Set different value - should emit
|
||||||
|
assertTrue(reactiveDataStore.putValue("key", "new_value").isSuccess)
|
||||||
|
assertEquals("new_value", awaitItem())
|
||||||
|
|
||||||
|
// Set same value again - should not emit
|
||||||
|
assertTrue(reactiveDataStore.putValue("key", "new_value").isSuccess)
|
||||||
|
|
||||||
|
// Verify no more emissions
|
||||||
|
expectNoEvents()
|
||||||
|
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,113 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package template.core.base.datastore
|
||||||
|
|
||||||
|
import com.russhwolf.settings.MapSettings
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import template.core.base.datastore.store.BasicPreferencesStore
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
@ExperimentalCoroutinesApi
|
||||||
|
class UserPreferencesDataStoreTest {
|
||||||
|
private val testDispatcher = StandardTestDispatcher()
|
||||||
|
private val settings = MapSettings()
|
||||||
|
private val dataStore = BasicPreferencesStore(settings, testDispatcher)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class CustomData(val id: Int, val name: String)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun putAndGet_PrimitiveTypes() = runTest(testDispatcher) {
|
||||||
|
assertTrue(dataStore.putValue("int", 42).isSuccess)
|
||||||
|
assertEquals(42, dataStore.getValue("int", 0).getOrThrow())
|
||||||
|
|
||||||
|
assertTrue(dataStore.putValue("long", 123L).isSuccess)
|
||||||
|
assertEquals(123L, dataStore.getValue("long", 0L).getOrThrow())
|
||||||
|
|
||||||
|
assertTrue(dataStore.putValue("float", 3.14f).isSuccess)
|
||||||
|
assertEquals(3.14f, dataStore.getValue("float", 0f).getOrThrow())
|
||||||
|
|
||||||
|
assertTrue(dataStore.putValue("double", 2.71).isSuccess)
|
||||||
|
assertEquals(2.71, dataStore.getValue("double", 0.0).getOrThrow())
|
||||||
|
|
||||||
|
assertTrue(dataStore.putValue("string", "hello").isSuccess)
|
||||||
|
assertEquals("hello", dataStore.getValue("string", "").getOrThrow())
|
||||||
|
|
||||||
|
assertTrue(dataStore.putValue("boolean", true).isSuccess)
|
||||||
|
assertEquals(true, dataStore.getValue("boolean", false).getOrThrow())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun putAndGet_CustomType_WithSerializer() = runTest(testDispatcher) {
|
||||||
|
val custom = CustomData(1, "test")
|
||||||
|
assertTrue(dataStore.putValue("custom", custom, CustomData.serializer()).isSuccess)
|
||||||
|
assertEquals(custom, dataStore.getValue("custom", CustomData(0, ""), CustomData.serializer()).getOrThrow())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun putValue_ThrowsWithoutSerializer() = runTest(testDispatcher) {
|
||||||
|
val custom = CustomData(2, "fail")
|
||||||
|
val result = dataStore.putValue("fail", custom)
|
||||||
|
assertTrue(result.isFailure)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun getValue_ThrowsWithoutSerializer() = runTest(testDispatcher) {
|
||||||
|
val result = dataStore.getValue("fail", CustomData(0, ""))
|
||||||
|
assertTrue(result.isFailure)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun getValue_ReturnsDefaultIfKeyMissing() = runTest(testDispatcher) {
|
||||||
|
assertEquals(99, dataStore.getValue("missing", 99).getOrThrow())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun hasKey_WorksCorrectly() = runTest(testDispatcher) {
|
||||||
|
assertTrue(dataStore.hasKey("nope").getOrThrow() == false)
|
||||||
|
assertTrue(dataStore.putValue("exists", 1).isSuccess)
|
||||||
|
assertTrue(dataStore.hasKey("exists").getOrThrow())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun removeValue_RemovesKey() = runTest(testDispatcher) {
|
||||||
|
assertTrue(dataStore.putValue("toremove", 5).isSuccess)
|
||||||
|
assertTrue(dataStore.removeValue("toremove").isSuccess)
|
||||||
|
assertEquals(0, dataStore.getValue("toremove", 0).getOrThrow())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun clearAll_RemovesAllKeys() = runTest(testDispatcher) {
|
||||||
|
assertTrue(dataStore.putValue("a", 1).isSuccess)
|
||||||
|
assertTrue(dataStore.putValue("b", 2).isSuccess)
|
||||||
|
assertTrue(dataStore.clearAll().isSuccess)
|
||||||
|
assertEquals(0, dataStore.getValue("a", 0).getOrThrow())
|
||||||
|
assertEquals(0, dataStore.getValue("b", 0).getOrThrow())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun getAllKeys_ReturnsAllKeys() = runTest(testDispatcher) {
|
||||||
|
assertTrue(dataStore.putValue("k1", 1).isSuccess)
|
||||||
|
assertTrue(dataStore.putValue("k2", 2).isSuccess)
|
||||||
|
assertEquals(setOf("k1", "k2"), dataStore.getAllKeys().getOrThrow())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun getSize_ReturnsCorrectCount() = runTest(testDispatcher) {
|
||||||
|
assertTrue(dataStore.putValue("k1", 1).isSuccess)
|
||||||
|
assertTrue(dataStore.putValue("k2", 2).isSuccess)
|
||||||
|
assertEquals(2, dataStore.getSize().getOrThrow())
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,60 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package template.core.base.datastore.cache
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class LRUCacheManagerTest {
|
||||||
|
@Test
|
||||||
|
fun putAndGet_WorksCorrectly() {
|
||||||
|
val cache = LruCacheManager<String, Int>(maxSize = 2)
|
||||||
|
cache.put("a", 1)
|
||||||
|
assertEquals(1, cache.get("a"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun remove_RemovesKey() {
|
||||||
|
val cache = LruCacheManager<String, Int>(maxSize = 2)
|
||||||
|
cache.put("a", 1)
|
||||||
|
cache.remove("a")
|
||||||
|
assertNull(cache.get("a"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun clear_RemovesAll() {
|
||||||
|
val cache = LruCacheManager<String, Int>(maxSize = 2)
|
||||||
|
cache.put("a", 1)
|
||||||
|
cache.put("b", 2)
|
||||||
|
cache.clear()
|
||||||
|
assertEquals(0, cache.size())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun eviction_EvictsLeastRecentlyUsed() {
|
||||||
|
val cache = LruCacheManager<String, Int>(maxSize = 2)
|
||||||
|
cache.put("a", 1)
|
||||||
|
cache.put("b", 2)
|
||||||
|
cache.put("c", 3) // Should evict "a"
|
||||||
|
assertNull(cache.get("a"))
|
||||||
|
assertEquals(2, cache.size())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun containsKey_Works() {
|
||||||
|
val cache = LruCacheManager<String, Int>(maxSize = 2)
|
||||||
|
cache.put("a", 1)
|
||||||
|
assertTrue(cache.containsKey("a"))
|
||||||
|
cache.remove("a")
|
||||||
|
assertTrue(!cache.containsKey("a"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,99 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package template.core.base.datastore.extension
|
||||||
|
|
||||||
|
import app.cash.turbine.test
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import template.core.base.datastore.contracts.DataStoreChangeEvent
|
||||||
|
import template.core.base.datastore.extensions.mapWithDefault
|
||||||
|
import template.core.base.datastore.extensions.onlyAdditions
|
||||||
|
import template.core.base.datastore.extensions.onlyRemovals
|
||||||
|
import template.core.base.datastore.extensions.onlyUpdates
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class FlowExtensionsTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun mapWithDefault_HandlesErrors() = runTest {
|
||||||
|
val flow = flowOf(1, 2, 3)
|
||||||
|
.mapWithDefault("default") {
|
||||||
|
if (it == 2) throw RuntimeException("Error")
|
||||||
|
"value_$it"
|
||||||
|
}
|
||||||
|
|
||||||
|
flow.test {
|
||||||
|
assertEquals("value_1", awaitItem())
|
||||||
|
assertEquals("default", awaitItem()) // Error case
|
||||||
|
awaitComplete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun filterChangeType_FiltersCorrectTypes() = runTest {
|
||||||
|
val changes = flowOf(
|
||||||
|
DataStoreChangeEvent.ValueAdded("key1", "value1"),
|
||||||
|
DataStoreChangeEvent.ValueUpdated("key2", "old", "new"),
|
||||||
|
DataStoreChangeEvent.ValueAdded("key3", "value3"),
|
||||||
|
DataStoreChangeEvent.ValueRemoved("key4", "value4"),
|
||||||
|
)
|
||||||
|
|
||||||
|
changes.onlyAdditions().test {
|
||||||
|
val first = awaitItem()
|
||||||
|
assertTrue(true)
|
||||||
|
assertEquals("key1", first.key)
|
||||||
|
|
||||||
|
val second = awaitItem()
|
||||||
|
assertTrue(true)
|
||||||
|
assertEquals("key3", second.key)
|
||||||
|
|
||||||
|
awaitComplete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun onlyUpdates_FiltersUpdateChanges() = runTest {
|
||||||
|
val changes = flowOf(
|
||||||
|
DataStoreChangeEvent.ValueAdded("key1", "value1"),
|
||||||
|
DataStoreChangeEvent.ValueUpdated("key2", "old", "new"),
|
||||||
|
DataStoreChangeEvent.ValueRemoved("key3", "value3"),
|
||||||
|
)
|
||||||
|
|
||||||
|
changes.onlyUpdates().test {
|
||||||
|
val update = awaitItem()
|
||||||
|
assertTrue(true)
|
||||||
|
assertEquals("key2", update.key)
|
||||||
|
assertEquals("old", update.oldValue)
|
||||||
|
assertEquals("new", update.newValue)
|
||||||
|
|
||||||
|
awaitComplete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun onlyRemovals_FiltersRemovalChanges() = runTest {
|
||||||
|
val changes = flowOf(
|
||||||
|
DataStoreChangeEvent.ValueAdded("key1", "value1"),
|
||||||
|
DataStoreChangeEvent.ValueRemoved("key2", "value2"),
|
||||||
|
DataStoreChangeEvent.ValueUpdated("key3", "old", "new"),
|
||||||
|
)
|
||||||
|
|
||||||
|
changes.onlyRemovals().test {
|
||||||
|
val removal = awaitItem()
|
||||||
|
assertTrue(true)
|
||||||
|
assertEquals("key2", removal.key)
|
||||||
|
assertEquals("value2", removal.oldValue)
|
||||||
|
|
||||||
|
awaitComplete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,155 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package template.core.base.datastore.integration
|
||||||
|
|
||||||
|
import app.cash.turbine.test
|
||||||
|
import com.russhwolf.settings.MapSettings
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import org.koin.core.context.startKoin
|
||||||
|
import org.koin.core.context.stopKoin
|
||||||
|
import org.koin.core.qualifier.named
|
||||||
|
import org.koin.dsl.module
|
||||||
|
import org.koin.test.KoinTest
|
||||||
|
import org.koin.test.inject
|
||||||
|
import template.core.base.datastore.di.CoreDatastoreModule
|
||||||
|
import template.core.base.datastore.reactive.PreferenceFlowOperators
|
||||||
|
import template.core.base.datastore.repository.ReactivePreferencesRepository
|
||||||
|
import kotlin.test.AfterTest
|
||||||
|
import kotlin.test.BeforeTest
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
|
@ExperimentalCoroutinesApi
|
||||||
|
class ReactiveIntegrationTest : KoinTest {
|
||||||
|
|
||||||
|
private val repository: ReactivePreferencesRepository by inject()
|
||||||
|
private val operators: PreferenceFlowOperators by inject()
|
||||||
|
private val testDispatcher = StandardTestDispatcher()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class UserProfile(
|
||||||
|
val name: String,
|
||||||
|
val email: String,
|
||||||
|
val theme: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@BeforeTest
|
||||||
|
fun setup() {
|
||||||
|
startKoin {
|
||||||
|
modules(
|
||||||
|
CoreDatastoreModule,
|
||||||
|
module {
|
||||||
|
// Override dispatcher for testing
|
||||||
|
single<CoroutineDispatcher>(named("IO")) { testDispatcher }
|
||||||
|
single { PreferenceFlowOperators(get()) }
|
||||||
|
single<com.russhwolf.settings.Settings> { MapSettings() }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterTest
|
||||||
|
fun tearDown() {
|
||||||
|
stopKoin()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun endToEndReactiveTest_ComplexWorkflow() = runTest(testDispatcher) {
|
||||||
|
val defaultProfile = UserProfile("", "", "light")
|
||||||
|
|
||||||
|
// Start observing user profile
|
||||||
|
repository.observeSerializablePreference(
|
||||||
|
"user_profile",
|
||||||
|
defaultProfile,
|
||||||
|
UserProfile.serializer(),
|
||||||
|
).test {
|
||||||
|
// Initial default profile
|
||||||
|
assertEquals(defaultProfile, awaitItem())
|
||||||
|
|
||||||
|
// Save initial profile
|
||||||
|
val initialProfile = UserProfile("John", "john@example.com", "light")
|
||||||
|
repository.saveSerializablePreference(
|
||||||
|
"user_profile",
|
||||||
|
initialProfile,
|
||||||
|
UserProfile.serializer(),
|
||||||
|
)
|
||||||
|
assertEquals(initialProfile, awaitItem())
|
||||||
|
|
||||||
|
// Update theme
|
||||||
|
val darkProfile = initialProfile.copy(theme = "dark")
|
||||||
|
repository.saveSerializablePreference(
|
||||||
|
"user_profile",
|
||||||
|
darkProfile,
|
||||||
|
UserProfile.serializer(),
|
||||||
|
)
|
||||||
|
assertEquals(darkProfile, awaitItem())
|
||||||
|
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun combinedPreferencesWorkflow() = runTest(testDispatcher) {
|
||||||
|
// Observe combined user state
|
||||||
|
operators.combinePreferences(
|
||||||
|
"username",
|
||||||
|
"",
|
||||||
|
"is_premium",
|
||||||
|
false,
|
||||||
|
"login_count",
|
||||||
|
0,
|
||||||
|
) { username, isPremium, loginCount ->
|
||||||
|
"User: $username, Premium: $isPremium, Logins: $loginCount"
|
||||||
|
}.test {
|
||||||
|
// Initial state
|
||||||
|
assertEquals("User: , Premium: false, Logins: 0", awaitItem())
|
||||||
|
|
||||||
|
// Set username
|
||||||
|
repository.savePreference("username", "alice")
|
||||||
|
assertEquals("User: alice, Premium: false, Logins: 0", awaitItem())
|
||||||
|
|
||||||
|
// Upgrade to premium
|
||||||
|
repository.savePreference("is_premium", true)
|
||||||
|
assertEquals("User: alice, Premium: true, Logins: 0", awaitItem())
|
||||||
|
|
||||||
|
// Increment login count
|
||||||
|
repository.savePreference("login_count", 1)
|
||||||
|
assertEquals("User: alice, Premium: true, Logins: 1", awaitItem())
|
||||||
|
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun saveAndRetrieveAllPrimitiveTypes() = runTest(testDispatcher) {
|
||||||
|
// Int
|
||||||
|
repository.savePreference("intKey", 42)
|
||||||
|
assertEquals(42, repository.getPreference("intKey", 0).getOrThrow())
|
||||||
|
// String
|
||||||
|
repository.savePreference("stringKey", "hello")
|
||||||
|
assertEquals("hello", repository.getPreference("stringKey", "").getOrThrow())
|
||||||
|
// Boolean
|
||||||
|
repository.savePreference("boolKey", true)
|
||||||
|
assertEquals(true, repository.getPreference("boolKey", false).getOrThrow())
|
||||||
|
// Long
|
||||||
|
repository.savePreference("longKey", 123456789L)
|
||||||
|
assertEquals(123456789L, repository.getPreference("longKey", 0L).getOrThrow())
|
||||||
|
// Float
|
||||||
|
repository.savePreference("floatKey", 3.14f)
|
||||||
|
assertEquals(3.14f, repository.getPreference("floatKey", 0f).getOrThrow())
|
||||||
|
// Double
|
||||||
|
repository.savePreference("doubleKey", 2.718)
|
||||||
|
assertEquals(2.718, repository.getPreference("doubleKey", 0.0).getOrThrow())
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,61 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package template.core.base.datastore.notification
|
||||||
|
|
||||||
|
import app.cash.turbine.test
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import template.core.base.datastore.contracts.DataStoreChangeEvent
|
||||||
|
import template.core.base.datastore.reactive.DefaultChangeNotifier
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
|
class DefaultChangeNotifierTest {
|
||||||
|
|
||||||
|
private val changeNotifier = DefaultChangeNotifier()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun observeChanges_EmitsNotifiedChanges() = runTest {
|
||||||
|
changeNotifier.observeChanges().test {
|
||||||
|
val change = DataStoreChangeEvent.ValueAdded("key", "value")
|
||||||
|
changeNotifier.notifyChange(change)
|
||||||
|
|
||||||
|
assertEquals(change, awaitItem())
|
||||||
|
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun observeKeyChanges_FiltersCorrectKey() = runTest {
|
||||||
|
changeNotifier.observeKeyChanges("target_key").test {
|
||||||
|
// Send change for different key - should not emit
|
||||||
|
changeNotifier.notifyChange(DataStoreChangeEvent.ValueAdded("other_key", "value"))
|
||||||
|
|
||||||
|
// Send change for target key - should emit
|
||||||
|
val targetChange = DataStoreChangeEvent.ValueAdded("target_key", "value")
|
||||||
|
changeNotifier.notifyChange(targetChange)
|
||||||
|
assertEquals(targetChange, awaitItem())
|
||||||
|
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun observeKeyChanges_EmitsGlobalChanges() = runTest {
|
||||||
|
changeNotifier.observeKeyChanges("specific_key").test {
|
||||||
|
// Global change (clear all) should be emitted regardless of key
|
||||||
|
val globalChange = DataStoreChangeEvent.StoreCleared()
|
||||||
|
changeNotifier.notifyChange(globalChange)
|
||||||
|
assertEquals(globalChange, awaitItem())
|
||||||
|
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,203 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package template.core.base.datastore.operators
|
||||||
|
|
||||||
|
import app.cash.turbine.test
|
||||||
|
import com.russhwolf.settings.MapSettings
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import template.core.base.datastore.cache.LruCacheManager
|
||||||
|
import template.core.base.datastore.handlers.BooleanTypeHandler
|
||||||
|
import template.core.base.datastore.handlers.IntTypeHandler
|
||||||
|
import template.core.base.datastore.handlers.StringTypeHandler
|
||||||
|
import template.core.base.datastore.handlers.TypeHandler
|
||||||
|
import template.core.base.datastore.reactive.DefaultChangeNotifier
|
||||||
|
import template.core.base.datastore.reactive.DefaultValueObserver
|
||||||
|
import template.core.base.datastore.reactive.PreferenceFlowOperators
|
||||||
|
import template.core.base.datastore.repository.DefaultReactivePreferencesRepository
|
||||||
|
import template.core.base.datastore.serialization.JsonSerializationStrategy
|
||||||
|
import template.core.base.datastore.store.ReactiveUserPreferencesDataStore
|
||||||
|
import template.core.base.datastore.validation.DefaultPreferencesValidator
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test suite for [template.core.base.datastore.reactive.PreferenceFlowOperators].
|
||||||
|
* Verifies combining, mapping, and observing preference flows, including edge cases.
|
||||||
|
*/
|
||||||
|
@ExperimentalCoroutinesApi
|
||||||
|
class PreferenceFlowOperatorsTest {
|
||||||
|
|
||||||
|
private val testDispatcher = StandardTestDispatcher()
|
||||||
|
private val changeNotifier = DefaultChangeNotifier()
|
||||||
|
|
||||||
|
private val reactiveDataStore = ReactiveUserPreferencesDataStore(
|
||||||
|
settings = MapSettings(),
|
||||||
|
dispatcher = testDispatcher,
|
||||||
|
typeHandlers = listOf(
|
||||||
|
IntTypeHandler(),
|
||||||
|
StringTypeHandler(),
|
||||||
|
BooleanTypeHandler(),
|
||||||
|
) as List<TypeHandler<Any>>,
|
||||||
|
serializationStrategy = JsonSerializationStrategy(),
|
||||||
|
validator = DefaultPreferencesValidator(),
|
||||||
|
cacheManager = LruCacheManager(),
|
||||||
|
changeNotifier = changeNotifier,
|
||||||
|
valueObserver = DefaultValueObserver(changeNotifier),
|
||||||
|
)
|
||||||
|
|
||||||
|
private val repository = DefaultReactivePreferencesRepository(reactiveDataStore)
|
||||||
|
private val operators = PreferenceFlowOperators(repository)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests combining two preference flows and verifies correct emission order.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun combinePreferences_TwoValues_CombinesCorrectly() = runTest(testDispatcher) {
|
||||||
|
operators.combinePreferences(
|
||||||
|
"key1",
|
||||||
|
"default1",
|
||||||
|
"key2",
|
||||||
|
"default2",
|
||||||
|
) { value1, value2 ->
|
||||||
|
"$value1-$value2"
|
||||||
|
}.test {
|
||||||
|
// Initial combined value
|
||||||
|
assertEquals("default1-default2", awaitItem())
|
||||||
|
|
||||||
|
// Update first preference
|
||||||
|
repository.savePreference("key1", "new1")
|
||||||
|
assertEquals("new1-default2", awaitItem())
|
||||||
|
|
||||||
|
// Update second preference
|
||||||
|
repository.savePreference("key2", "new2")
|
||||||
|
assertEquals("new1-new2", awaitItem())
|
||||||
|
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests combining three preference flows and verifies correct emission order.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun combinePreferences_ThreeValues_CombinesCorrectly() = runTest(testDispatcher) {
|
||||||
|
operators.combinePreferences(
|
||||||
|
"theme",
|
||||||
|
"light",
|
||||||
|
"language",
|
||||||
|
"en",
|
||||||
|
"notifications",
|
||||||
|
true,
|
||||||
|
) { theme, language, notifications ->
|
||||||
|
Triple(theme, language, notifications)
|
||||||
|
}.test {
|
||||||
|
advanceUntilIdle()
|
||||||
|
delay(10)
|
||||||
|
|
||||||
|
// Initial combined value
|
||||||
|
assertEquals(Triple("light", "en", true), awaitItem())
|
||||||
|
|
||||||
|
// Give time for initial emission
|
||||||
|
kotlinx.coroutines.delay(10)
|
||||||
|
|
||||||
|
// Update theme
|
||||||
|
repository.savePreference("theme", "dark")
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertEquals(Triple("dark", "en", true), awaitItem())
|
||||||
|
|
||||||
|
// Update notifications
|
||||||
|
repository.savePreference("notifications", false)
|
||||||
|
delay(10)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertEquals(Triple("dark", "en", false), awaitItem())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests observing changes to any of the specified keys.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun observeAnyKeyChange_EmitsOnSpecifiedKeys() = runTest(testDispatcher) {
|
||||||
|
operators.observeAnyKeyChange("key1", "key2").test {
|
||||||
|
// Change to key1 - should emit
|
||||||
|
repository.savePreference("key1", "value1")
|
||||||
|
assertEquals("key1", awaitItem())
|
||||||
|
|
||||||
|
// Change to key3 - should not emit (not in watched keys)
|
||||||
|
repository.savePreference("key3", "value3")
|
||||||
|
|
||||||
|
// Change to key2 - should emit
|
||||||
|
repository.savePreference("key2", "value2")
|
||||||
|
assertEquals("key2", awaitItem())
|
||||||
|
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests mapping a preference value using a transform function.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun observeMappedPreference_TransformsValues() = runTest(testDispatcher) {
|
||||||
|
operators.observeMappedPreference("count", 0) { count ->
|
||||||
|
"Count is: $count"
|
||||||
|
}.test {
|
||||||
|
// Initial mapped value
|
||||||
|
assertEquals("Count is: 0", awaitItem())
|
||||||
|
|
||||||
|
// Update preference
|
||||||
|
repository.savePreference("count", 5)
|
||||||
|
assertEquals("Count is: 5", awaitItem())
|
||||||
|
|
||||||
|
// Update again
|
||||||
|
repository.savePreference("count", 10)
|
||||||
|
assertEquals("Count is: 10", awaitItem())
|
||||||
|
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests rapid updates to preferences and ensures all changes are emitted.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun rapidUpdates_AreHandledCorrectly() = runTest(testDispatcher) {
|
||||||
|
operators.observeMappedPreference("rapid", 0) { it }.test {
|
||||||
|
for (i in 1..5) {
|
||||||
|
repository.savePreference("rapid", i)
|
||||||
|
assertEquals(i, awaitItem())
|
||||||
|
}
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests combining preferences with default/null values.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun combinePreferences_DefaultValues() = runTest(testDispatcher) {
|
||||||
|
operators.combinePreferences(
|
||||||
|
"missing1",
|
||||||
|
"",
|
||||||
|
"missing2",
|
||||||
|
"",
|
||||||
|
) { v1, v2 ->
|
||||||
|
v1 to v2
|
||||||
|
}.test {
|
||||||
|
assertEquals("" to "", awaitItem())
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,121 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package template.core.base.datastore.performance
|
||||||
|
|
||||||
|
import app.cash.turbine.test
|
||||||
|
import app.cash.turbine.turbineScope
|
||||||
|
import com.russhwolf.settings.MapSettings
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlinx.coroutines.yield
|
||||||
|
import template.core.base.datastore.cache.LruCacheManager
|
||||||
|
import template.core.base.datastore.handlers.BooleanTypeHandler
|
||||||
|
import template.core.base.datastore.handlers.IntTypeHandler
|
||||||
|
import template.core.base.datastore.handlers.StringTypeHandler
|
||||||
|
import template.core.base.datastore.handlers.TypeHandler
|
||||||
|
import template.core.base.datastore.reactive.DefaultChangeNotifier
|
||||||
|
import template.core.base.datastore.reactive.DefaultValueObserver
|
||||||
|
import template.core.base.datastore.serialization.JsonSerializationStrategy
|
||||||
|
import template.core.base.datastore.store.ReactiveUserPreferencesDataStore
|
||||||
|
import template.core.base.datastore.validation.DefaultPreferencesValidator
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
import kotlin.time.measureTime
|
||||||
|
|
||||||
|
@ExperimentalCoroutinesApi
|
||||||
|
class ReactivePerformanceTest {
|
||||||
|
|
||||||
|
private val testDispatcher = StandardTestDispatcher()
|
||||||
|
private val changeNotifier = DefaultChangeNotifier()
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
private val reactiveDataStore = ReactiveUserPreferencesDataStore(
|
||||||
|
settings = MapSettings(),
|
||||||
|
dispatcher = testDispatcher,
|
||||||
|
typeHandlers = listOf(
|
||||||
|
IntTypeHandler(),
|
||||||
|
StringTypeHandler(),
|
||||||
|
BooleanTypeHandler(),
|
||||||
|
) as List<TypeHandler<Any>>,
|
||||||
|
serializationStrategy = JsonSerializationStrategy(),
|
||||||
|
validator = DefaultPreferencesValidator(),
|
||||||
|
cacheManager = LruCacheManager(maxSize = 1000),
|
||||||
|
changeNotifier = changeNotifier,
|
||||||
|
valueObserver = DefaultValueObserver(changeNotifier),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rapidUpdates_HandledEfficiently() = runTest(testDispatcher) {
|
||||||
|
val updateCount = 100
|
||||||
|
|
||||||
|
reactiveDataStore.observeValue("counter", 0).test {
|
||||||
|
// Initial value
|
||||||
|
assertEquals(0, awaitItem())
|
||||||
|
|
||||||
|
// Give time for initial emission
|
||||||
|
delay(10)
|
||||||
|
|
||||||
|
val duration = measureTime {
|
||||||
|
repeat(updateCount) { i ->
|
||||||
|
reactiveDataStore.putValue("counter", i + 1)
|
||||||
|
advanceUntilIdle()
|
||||||
|
}
|
||||||
|
yield()
|
||||||
|
advanceUntilIdle()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should receive all updates in order
|
||||||
|
val received = mutableListOf<Int>()
|
||||||
|
repeat(updateCount) {
|
||||||
|
received.add(awaitItem())
|
||||||
|
}
|
||||||
|
assertEquals((1..updateCount).toList(), received)
|
||||||
|
|
||||||
|
// Verify performance is reasonable (this is a rough check)
|
||||||
|
assertTrue(
|
||||||
|
duration.inWholeMilliseconds < 5000,
|
||||||
|
"Updates took too long: \\${duration.inWholeMilliseconds}ms",
|
||||||
|
)
|
||||||
|
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun multipleObservers_ShareNotifications() = runTest(testDispatcher) {
|
||||||
|
turbineScope {
|
||||||
|
val observer1 = reactiveDataStore
|
||||||
|
.observeValue("shared_key", "default").testIn(backgroundScope)
|
||||||
|
|
||||||
|
val observer2 =
|
||||||
|
reactiveDataStore.observeValue("shared_key", "default").testIn(backgroundScope)
|
||||||
|
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
// Both should get initial value
|
||||||
|
assertEquals("default", observer1.awaitItem())
|
||||||
|
assertEquals("default", observer2.awaitItem())
|
||||||
|
|
||||||
|
// Give time for initial emission
|
||||||
|
delay(10)
|
||||||
|
|
||||||
|
// Update the value
|
||||||
|
reactiveDataStore.putValue("shared_key", "updated")
|
||||||
|
|
||||||
|
// Both should get updated value
|
||||||
|
assertEquals("updated", observer1.awaitItem())
|
||||||
|
assertEquals("updated", observer2.awaitItem())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,169 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package template.core.base.datastore.repository
|
||||||
|
|
||||||
|
import app.cash.turbine.test
|
||||||
|
import com.russhwolf.settings.MapSettings
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import template.core.base.datastore.cache.LruCacheManager
|
||||||
|
import template.core.base.datastore.contracts.DataStoreChangeEvent
|
||||||
|
import template.core.base.datastore.handlers.BooleanTypeHandler
|
||||||
|
import template.core.base.datastore.handlers.IntTypeHandler
|
||||||
|
import template.core.base.datastore.handlers.StringTypeHandler
|
||||||
|
import template.core.base.datastore.handlers.TypeHandler
|
||||||
|
import template.core.base.datastore.reactive.DefaultChangeNotifier
|
||||||
|
import template.core.base.datastore.reactive.DefaultValueObserver
|
||||||
|
import template.core.base.datastore.serialization.JsonSerializationStrategy
|
||||||
|
import template.core.base.datastore.store.ReactiveUserPreferencesDataStore
|
||||||
|
import template.core.base.datastore.validation.DefaultPreferencesValidator
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
@ExperimentalCoroutinesApi
|
||||||
|
class DefaultReactiveUserPreferencesRepositoryTest {
|
||||||
|
|
||||||
|
private val testDispatcher = StandardTestDispatcher()
|
||||||
|
private val changeNotifier = DefaultChangeNotifier()
|
||||||
|
|
||||||
|
private val reactiveDataStore = ReactiveUserPreferencesDataStore(
|
||||||
|
settings = MapSettings(),
|
||||||
|
dispatcher = testDispatcher,
|
||||||
|
typeHandlers = listOf(
|
||||||
|
IntTypeHandler(),
|
||||||
|
StringTypeHandler(),
|
||||||
|
BooleanTypeHandler(),
|
||||||
|
) as List<TypeHandler<Any>>,
|
||||||
|
serializationStrategy = JsonSerializationStrategy(),
|
||||||
|
validator = DefaultPreferencesValidator(),
|
||||||
|
cacheManager = LruCacheManager(),
|
||||||
|
changeNotifier = changeNotifier,
|
||||||
|
valueObserver = DefaultValueObserver(changeNotifier),
|
||||||
|
)
|
||||||
|
|
||||||
|
private val repository = DefaultReactivePreferencesRepository(reactiveDataStore)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AppSettings(
|
||||||
|
val theme: String,
|
||||||
|
val language: String,
|
||||||
|
val notifications: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun observePreference_ReactsToChanges() = runTest(testDispatcher) {
|
||||||
|
repository.observePreference("theme", "light").test {
|
||||||
|
// Initial value
|
||||||
|
assertEquals("light", awaitItem())
|
||||||
|
|
||||||
|
// Save new preference
|
||||||
|
repository.savePreference("theme", "dark")
|
||||||
|
assertEquals("dark", awaitItem())
|
||||||
|
|
||||||
|
// Save another value
|
||||||
|
repository.savePreference("theme", "auto")
|
||||||
|
assertEquals("auto", awaitItem())
|
||||||
|
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun observeSerializablePreference_ReactsToComplexObjects() = runTest(testDispatcher) {
|
||||||
|
val defaultSettings = AppSettings("light", "en", true)
|
||||||
|
|
||||||
|
repository.observeSerializablePreference(
|
||||||
|
"app_settings",
|
||||||
|
defaultSettings,
|
||||||
|
AppSettings.serializer(),
|
||||||
|
).test {
|
||||||
|
// Initial default
|
||||||
|
assertEquals(defaultSettings, awaitItem())
|
||||||
|
|
||||||
|
// Update settings
|
||||||
|
val newSettings = AppSettings("dark", "es", false)
|
||||||
|
repository.saveSerializablePreference(
|
||||||
|
"app_settings",
|
||||||
|
newSettings,
|
||||||
|
AppSettings.serializer(),
|
||||||
|
)
|
||||||
|
assertEquals(newSettings, awaitItem())
|
||||||
|
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun observePreferenceChanges_FiltersCorrectly() = runTest(testDispatcher) {
|
||||||
|
repository.observePreferenceChanges("specific_key").test {
|
||||||
|
// Save to different key - should not emit
|
||||||
|
repository.savePreference("other_key", "value")
|
||||||
|
|
||||||
|
// Save to specific key - should emit
|
||||||
|
repository.savePreference("specific_key", "value")
|
||||||
|
val change = awaitItem()
|
||||||
|
assertTrue(change is DataStoreChangeEvent.ValueAdded)
|
||||||
|
assertEquals("specific_key", change.key)
|
||||||
|
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun observeAllKeys_ReactsToKeyChanges() = runTest(testDispatcher) {
|
||||||
|
repository.observeAllKeys().test {
|
||||||
|
// Initial empty set (from onStart emission)
|
||||||
|
assertEquals(emptySet(), awaitItem())
|
||||||
|
advanceUntilIdle()
|
||||||
|
delay(100) // Ensure we wait for any initial emissions
|
||||||
|
|
||||||
|
// Add preferences
|
||||||
|
repository.savePreference("key1", "value1")
|
||||||
|
assertEquals(setOf("key1"), awaitItem())
|
||||||
|
|
||||||
|
repository.savePreference("key2", "value2")
|
||||||
|
assertEquals(setOf("key1", "key2"), awaitItem())
|
||||||
|
|
||||||
|
// Remove preference
|
||||||
|
repository.removePreference("key1")
|
||||||
|
assertEquals(setOf("key2"), awaitItem())
|
||||||
|
|
||||||
|
// Clear all
|
||||||
|
repository.clearAllPreferences()
|
||||||
|
assertEquals(emptySet(), awaitItem())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun observePreferenceCount_ReactsToSizeChanges() = runTest(testDispatcher) {
|
||||||
|
repository.observePreferenceCount().test {
|
||||||
|
// Initial count (from onStart emission)
|
||||||
|
assertEquals(0, awaitItem())
|
||||||
|
|
||||||
|
// Add preferences
|
||||||
|
repository.savePreference("key1", "value1")
|
||||||
|
assertEquals(1, awaitItem())
|
||||||
|
|
||||||
|
repository.savePreference("key2", "value2")
|
||||||
|
assertEquals(2, awaitItem())
|
||||||
|
|
||||||
|
// Clear all
|
||||||
|
repository.clearAllPreferences()
|
||||||
|
assertEquals(0, awaitItem())
|
||||||
|
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,39 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package template.core.base.datastore.serialization
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class JsonSerializationStrategyTest {
|
||||||
|
private val strategy = JsonSerializationStrategy(Json { encodeDefaults = true })
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Data(val id: Int, val name: String)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun serializeAndDeserialize_Success() = kotlinx.coroutines.test.runTest {
|
||||||
|
val data = Data(1, "abc")
|
||||||
|
val serialized = strategy.serialize(data, Data.serializer())
|
||||||
|
assertTrue(serialized.isSuccess)
|
||||||
|
val deserialized = strategy.deserialize(serialized.getOrThrow(), Data.serializer())
|
||||||
|
assertTrue(deserialized.isSuccess)
|
||||||
|
assertEquals(data, deserialized.getOrThrow())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deserialize_FailureOnCorruptData() = kotlinx.coroutines.test.runTest {
|
||||||
|
val result = strategy.deserialize("not a json", Data.serializer())
|
||||||
|
assertTrue(result.isFailure)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,41 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package template.core.base.datastore.validation
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class DefaultPreferencesValidatorTest {
|
||||||
|
private val validator = DefaultPreferencesValidator()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun validateKey_AcceptsValidKey() {
|
||||||
|
val result = validator.validateKey("validKey")
|
||||||
|
assertTrue(result.isSuccess)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun validateKey_RejectsEmptyKey() {
|
||||||
|
val result = validator.validateKey("")
|
||||||
|
assertTrue(result.isFailure)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun validateValue_AcceptsNonNull() {
|
||||||
|
val result = validator.validateValue(123)
|
||||||
|
assertTrue(result.isSuccess)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun validateValue_RejectsNull() {
|
||||||
|
val result = validator.validateValue(null)
|
||||||
|
assertTrue(result.isFailure)
|
||||||
|
}
|
||||||
|
}
|
||||||
1
core-base/designsystem/.gitignore
vendored
Normal file
1
core-base/designsystem/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/build
|
||||||
341
core-base/designsystem/README.md
Normal file
341
core-base/designsystem/README.md
Normal file
@ -0,0 +1,341 @@
|
|||||||
|
# KPT Design System
|
||||||
|
|
||||||
|
A comprehensive, Kotlin Multiplatform design system built on top of Material3, providing reusable UI components, theming
|
||||||
|
capabilities, and layout primitives for building consistent user interfaces across platforms.
|
||||||
|
|
||||||
|
## 🌟 Overview
|
||||||
|
|
||||||
|
The KPT Design System offers a robust foundation for building modern applications with:
|
||||||
|
|
||||||
|
- **Consistent theming** across all platforms
|
||||||
|
- **Responsive layouts** that adapt to different screen sizes
|
||||||
|
- **Material3 integration** with custom design tokens
|
||||||
|
- **Component composition** with flexible configuration
|
||||||
|
- **Type-safe APIs** with Kotlin DSL builders
|
||||||
|
|
||||||
|
## 🎯 Key Features
|
||||||
|
|
||||||
|
- **🎨 Comprehensive Theming**: Complete design token system with color, typography, spacing, shapes, and elevation
|
||||||
|
- **📱 Responsive Design**: Adaptive layouts that work across phones, tablets, and desktop
|
||||||
|
- **🔧 Material3 Integration**: Seamless integration with Material3 components
|
||||||
|
- **⚡ Type Safety**: Kotlin DSL builders with compile-time safety
|
||||||
|
- **🎭 Consistent Animations**: Material Motion compliant animation specifications
|
||||||
|
- **🧩 Composable Architecture**: Flexible component composition with configuration objects
|
||||||
|
- **🌗 Dark Mode Support**: Built-in support for light and dark themes
|
||||||
|
- **♿ Accessibility**: Semantic properties and content descriptions throughout
|
||||||
|
- **🧪 Testing Support**: Test tags and testing utilities included
|
||||||
|
|
||||||
|
## 📦 Module Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
designsystem/
|
||||||
|
├── component/ # UI Components
|
||||||
|
│ ├── KptTopAppBar.kt
|
||||||
|
│ ├── KptAnimationSpecs.kt
|
||||||
|
│ └── ...
|
||||||
|
├── core/ # Core abstractions
|
||||||
|
│ ├── KptComponent.kt
|
||||||
|
│ ├── ComponentStateHolder.kt
|
||||||
|
│ └── ...
|
||||||
|
├── layout/ # Layout components
|
||||||
|
│ ├── KptResponsiveLayout.kt
|
||||||
|
│ ├── KptGrid.kt
|
||||||
|
│ └── ...
|
||||||
|
├── theme/ # Theme implementation
|
||||||
|
│ └── KptColorSchemeImpl.kt
|
||||||
|
├── KptTheme.kt # Main theme composable
|
||||||
|
├── KptMaterialTheme.kt # Material3 integration
|
||||||
|
└── KptThemeExtensions.kt # Utility extensions
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🏗️ Architecture
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
subgraph "KPT Design System Architecture"
|
||||||
|
Core[Core Interfaces & Abstractions]
|
||||||
|
Theme[Theme System]
|
||||||
|
Components[UI Components]
|
||||||
|
Layout[Layout System]
|
||||||
|
Extensions[Material3 Extensions]
|
||||||
|
Core --> Theme
|
||||||
|
Core --> Components
|
||||||
|
Core --> Layout
|
||||||
|
Theme --> Components
|
||||||
|
Theme --> Extensions
|
||||||
|
Components --> Layout
|
||||||
|
Extensions --> Components
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "Theme System"
|
||||||
|
Colors[KptColorScheme]
|
||||||
|
Typography[KptTypography]
|
||||||
|
Shapes[KptShapes]
|
||||||
|
Spacing[KptSpacing]
|
||||||
|
Elevation[KptElevation]
|
||||||
|
Provider[KptThemeProvider]
|
||||||
|
Provider --> Colors
|
||||||
|
Provider --> Typography
|
||||||
|
Provider --> Shapes
|
||||||
|
Provider --> Spacing
|
||||||
|
Provider --> Elevation
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "Component System"
|
||||||
|
BaseComponent[KptComponent]
|
||||||
|
TopAppBar[KptTopAppBar]
|
||||||
|
Animation[Animation Components]
|
||||||
|
Loading[Loading States]
|
||||||
|
BaseComponent --> Scaffold
|
||||||
|
BaseComponent --> TopAppBar
|
||||||
|
BaseComponent --> Animation
|
||||||
|
BaseComponent --> Loading
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎨 Theme System
|
||||||
|
|
||||||
|
The KPT Design System provides a comprehensive theming solution that extends Material3 design tokens:
|
||||||
|
|
||||||
|
### Color Scheme
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
val customTheme = kptTheme {
|
||||||
|
colors {
|
||||||
|
primary = Color(0xFF6750A4)
|
||||||
|
onPrimary = Color.White
|
||||||
|
background = Color(0xFFFFFBFE)
|
||||||
|
// ... other colors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Typography Scale
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
kptTheme {
|
||||||
|
typography {
|
||||||
|
titleLarge = TextStyle(
|
||||||
|
fontSize = 22.sp,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
// ... other text styles
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Spacing System
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// Predefined spacing scale
|
||||||
|
KptTheme.spacing.xs // 4.dp
|
||||||
|
KptTheme.spacing.sm // 8.dp
|
||||||
|
KptTheme.spacing.md // 16.dp
|
||||||
|
KptTheme.spacing.lg // 24.dp
|
||||||
|
KptTheme.spacing.xl // 32.dp
|
||||||
|
KptTheme.spacing.xxl // 64.dp
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 Setup & Integration
|
||||||
|
|
||||||
|
### Basic Setup
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Composable
|
||||||
|
fun App() {
|
||||||
|
KptMaterialTheme {
|
||||||
|
// Your app content
|
||||||
|
NavHost(navController, startDestination = "home") {
|
||||||
|
composable("home") { HomeScreen() }
|
||||||
|
// ... other destinations
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom Theme Setup
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Composable
|
||||||
|
fun App() {
|
||||||
|
val customTheme = kptTheme {
|
||||||
|
colors {
|
||||||
|
primary = Color(0xFF1976D2)
|
||||||
|
onPrimary = Color.White
|
||||||
|
}
|
||||||
|
typography {
|
||||||
|
titleLarge = titleLarge.copy(fontSize = 24.sp)
|
||||||
|
}
|
||||||
|
spacing {
|
||||||
|
md = 20.dp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
KptMaterialTheme(theme = customTheme) {
|
||||||
|
// App content with custom theme
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dark Theme Support
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Composable
|
||||||
|
fun App() {
|
||||||
|
val lightTheme = kptTheme { /* light theme config */ }
|
||||||
|
val darkTheme = kptTheme { /* dark theme config */ }
|
||||||
|
|
||||||
|
KptMaterialTheme(
|
||||||
|
lightTheme = lightTheme,
|
||||||
|
darkThemeProvider = darkTheme
|
||||||
|
) {
|
||||||
|
// Automatically switches based on system preference
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🧩 Components(Demo)
|
||||||
|
|
||||||
|
### KptTopAppBar
|
||||||
|
|
||||||
|
Flexible top app bar with multiple variants:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// Simple top app bar
|
||||||
|
KptTopAppBar(title = "Title")
|
||||||
|
|
||||||
|
// With navigation and actions
|
||||||
|
KptTopAppBar(
|
||||||
|
title = "Title",
|
||||||
|
onNavigationIconClick = { navController.navigateUp() },
|
||||||
|
actionIcon = Icons.Default.Search,
|
||||||
|
onActionClick = { openSearch() }
|
||||||
|
)
|
||||||
|
|
||||||
|
// Using configuration builder
|
||||||
|
KptTopAppBar(
|
||||||
|
kptTopAppBar {
|
||||||
|
title = "Settings"
|
||||||
|
variant = TopAppBarVariant.Large
|
||||||
|
navigationIcon = Icons.AutoMirrored.Filled.ArrowBack
|
||||||
|
onNavigationClick = { navController.navigateUp() }
|
||||||
|
|
||||||
|
action(Icons.Default.Search, "Search") { openSearch() }
|
||||||
|
action(Icons.Default.MoreVert, "More") { openMenu() }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📱 Responsive Layout System
|
||||||
|
|
||||||
|
The design system includes responsive layout components that adapt to different screen sizes:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
subgraph "Screen Size Breakpoints"
|
||||||
|
Compact["Compact < 600dp"]
|
||||||
|
Medium["Medium 600-840dp"]
|
||||||
|
Expanded["Expanded ≥ 840dp"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "Layout Components"
|
||||||
|
ResponsiveLayout[KptResponsiveLayout]
|
||||||
|
Grid[KptGrid]
|
||||||
|
FlowRow[KptFlowRow]
|
||||||
|
SplitPane[KptSplitPane]
|
||||||
|
SidebarLayout[KptSidebarLayout]
|
||||||
|
end
|
||||||
|
|
||||||
|
Compact --> ResponsiveLayout
|
||||||
|
Medium --> ResponsiveLayout
|
||||||
|
Expanded --> ResponsiveLayout
|
||||||
|
ResponsiveLayout --> Grid
|
||||||
|
ResponsiveLayout --> FlowRow
|
||||||
|
ResponsiveLayout --> SplitPane
|
||||||
|
ResponsiveLayout --> SidebarLayout
|
||||||
|
```
|
||||||
|
|
||||||
|
### Usage Example
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
KptResponsiveLayout(
|
||||||
|
compact = {
|
||||||
|
// Single column layout for phones
|
||||||
|
LazyColumn { /* items */ }
|
||||||
|
},
|
||||||
|
medium = {
|
||||||
|
// Two column layout for tablets
|
||||||
|
Row {
|
||||||
|
LazyColumn(modifier = Modifier.weight(1f)) { /* left */ }
|
||||||
|
LazyColumn(modifier = Modifier.weight(1f)) { /* right */ }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
expanded = {
|
||||||
|
// Three column layout for desktop
|
||||||
|
KptSidebarLayout {
|
||||||
|
sidebar { NavigationRail() }
|
||||||
|
content {
|
||||||
|
Row {
|
||||||
|
LazyColumn(modifier = Modifier.weight(2f)) { /* main */ }
|
||||||
|
LazyColumn(modifier = Modifier.weight(1f)) { /* aside */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎭 Animation System
|
||||||
|
|
||||||
|
Consistent animation specifications following Material Motion guidelines:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
object KptAnimationSpecs {
|
||||||
|
val fast = tween<Float>(150, FastOutSlowInEasing)
|
||||||
|
val medium = tween<Float>(300, FastOutSlowInEasing)
|
||||||
|
val slow = tween<Float>(500, FastOutSlowInEasing)
|
||||||
|
|
||||||
|
// Material Motion easing curves
|
||||||
|
val emphasizedEasing = CubicBezierEasing(0.2f, 0.0f, 0.0f, 1.0f)
|
||||||
|
val standardEasing = CubicBezierEasing(0.2f, 0.0f, 0.0f, 1.0f)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🧪 Component State Management
|
||||||
|
|
||||||
|
The design system provides utilities for managing component state:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Composable
|
||||||
|
fun MyComponent() {
|
||||||
|
val state = rememberComponentState(initialValue = false)
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = { state.update(!state.value) }
|
||||||
|
) {
|
||||||
|
Text(if (state.value) "Enabled" else "Disabled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🤝 Contributing
|
||||||
|
|
||||||
|
1. Follow the existing code style and patterns
|
||||||
|
2. Add comprehensive KDoc documentation to new components
|
||||||
|
3. Include usage examples in component documentation
|
||||||
|
4. Test components across different screen sizes
|
||||||
|
5. Ensure accessibility compliance with semantic properties
|
||||||
|
|
||||||
|
## 📄 License
|
||||||
|
|
||||||
|
```
|
||||||
|
Copyright 2025 Mifos Initiative
|
||||||
|
|
||||||
|
This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Built with ❤️ for the Kotlin Multiplatform community**
|
||||||
49
core-base/designsystem/build.gradle.kts
Normal file
49
core-base/designsystem/build.gradle.kts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.mifos.kmp.library)
|
||||||
|
alias(libs.plugins.jetbrainsCompose)
|
||||||
|
alias(libs.plugins.compose.compiler)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "template.core.base.designsystem"
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
sourceSets{
|
||||||
|
androidMain.dependencies {
|
||||||
|
implementation(libs.androidx.compose.ui.tooling)
|
||||||
|
}
|
||||||
|
commonMain.dependencies {
|
||||||
|
implementation(compose.ui)
|
||||||
|
implementation(compose.material3)
|
||||||
|
implementation(compose.foundation)
|
||||||
|
implementation(compose.components.resources)
|
||||||
|
implementation(compose.components.uiToolingPreview)
|
||||||
|
implementation(compose.materialIconsExtended)
|
||||||
|
|
||||||
|
api(compose.material3AdaptiveNavigationSuite)
|
||||||
|
api(libs.jetbrains.compose.material3.adaptive)
|
||||||
|
api(libs.jetbrains.compose.material3.adaptive.layout)
|
||||||
|
api(libs.jetbrains.compose.material3.adaptive.navigation)
|
||||||
|
|
||||||
|
implementation(libs.jb.lifecycleViewmodel)
|
||||||
|
implementation(libs.window.size)
|
||||||
|
implementation(libs.ui.backhandler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compose.resources {
|
||||||
|
publicResClass = true
|
||||||
|
generateResClass = always
|
||||||
|
packageOfResClass = "template.core.base.designsystem.generated.resources"
|
||||||
|
}
|
||||||
@ -0,0 +1,184 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
package template.core.base.designsystem
|
||||||
|
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.shape.CornerSize
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Shapes
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import template.core.base.designsystem.core.KptThemeProvider
|
||||||
|
import template.core.base.designsystem.theme.KptTheme
|
||||||
|
import template.core.base.designsystem.theme.KptThemeProviderImpl
|
||||||
|
import template.core.base.designsystem.theme.LocalKptColors
|
||||||
|
import template.core.base.designsystem.theme.LocalKptElevation
|
||||||
|
import template.core.base.designsystem.theme.LocalKptShapes
|
||||||
|
import template.core.base.designsystem.theme.LocalKptSpacing
|
||||||
|
import template.core.base.designsystem.theme.LocalKptTypography
|
||||||
|
import template.core.base.designsystem.theme.kptTheme
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KptMaterialTheme provides Material3 integration for KptTheme.
|
||||||
|
* This composable applies KptTheme values to MaterialTheme automatically,
|
||||||
|
* making all Material3 components use KptTheme design tokens.
|
||||||
|
*
|
||||||
|
* @param theme KptThemeProvider instance containing design tokens
|
||||||
|
* @param content The composable content that will have access to both KptTheme and MaterialTheme
|
||||||
|
*
|
||||||
|
* @sample KptMaterialThemeUsageExample
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun KptMaterialTheme(
|
||||||
|
theme: KptThemeProvider = KptThemeProviderImpl(),
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
// Convert KptTheme values to Material3 equivalents
|
||||||
|
val materialColorScheme = theme.colors.toMaterial3ColorScheme()
|
||||||
|
val materialTypography = theme.typography.toMaterial3Typography()
|
||||||
|
val materialShapes = theme.shapes.toMaterial3Shapes()
|
||||||
|
|
||||||
|
// Provide both KptTheme composition locals and MaterialTheme
|
||||||
|
CompositionLocalProvider(
|
||||||
|
LocalKptColors provides theme.colors,
|
||||||
|
LocalKptTypography provides theme.typography,
|
||||||
|
LocalKptShapes provides theme.shapes,
|
||||||
|
LocalKptSpacing provides theme.spacing,
|
||||||
|
LocalKptElevation provides theme.elevation,
|
||||||
|
) {
|
||||||
|
MaterialTheme(
|
||||||
|
colorScheme = materialColorScheme,
|
||||||
|
typography = materialTypography,
|
||||||
|
shapes = materialShapes,
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KptMaterialTheme with dark theme support.
|
||||||
|
* Provides automatic light/dark theme switching with Material3 integration.
|
||||||
|
*
|
||||||
|
* @param darkTheme Whether to use dark theme. Defaults to system preference.
|
||||||
|
* @param lightTheme KptThemeProvider for light theme
|
||||||
|
* @param darkTheme KptThemeProvider for dark theme
|
||||||
|
* @param content The composable content that will have access to both KptTheme and MaterialTheme
|
||||||
|
*
|
||||||
|
* @sample KptMaterialThemeWithDarkModeExample
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun KptMaterialTheme(
|
||||||
|
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||||
|
lightTheme: KptThemeProvider = KptThemeProviderImpl(),
|
||||||
|
darkThemeProvider: KptThemeProvider = KptThemeProviderImpl(),
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
val selectedTheme = if (darkTheme) darkThemeProvider else lightTheme
|
||||||
|
KptMaterialTheme(
|
||||||
|
theme = selectedTheme,
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DSL builder for creating KptMaterialTheme with custom configuration
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun KptMaterialTheme(
|
||||||
|
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||||
|
themeBuilder: @Composable (Boolean) -> KptThemeProvider,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
val theme = themeBuilder(darkTheme)
|
||||||
|
KptMaterialTheme(
|
||||||
|
theme = theme,
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// region Usage Examples (for documentation)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example of basic KptMaterialTheme usage
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun KptMaterialThemeUsageExample() {
|
||||||
|
KptMaterialTheme {
|
||||||
|
// All Material3 components will use KptTheme values
|
||||||
|
MaterialTheme.colorScheme.primary // = KptTheme.colorScheme.primary
|
||||||
|
MaterialTheme.typography.titleLarge // = KptTheme.typography.titleLarge
|
||||||
|
MaterialTheme.shapes.medium // = KptTheme.shapes.medium
|
||||||
|
|
||||||
|
// KptTheme values are also available directly
|
||||||
|
KptTheme.spacing.md
|
||||||
|
KptTheme.elevation.level2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example of KptMaterialTheme with dark mode support
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun KptMaterialThemeWithDarkModeExample() {
|
||||||
|
val lightTheme = kptTheme {
|
||||||
|
colors {
|
||||||
|
primary = Color.Blue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val darkTheme = kptTheme {
|
||||||
|
colors {
|
||||||
|
primary = Color.Cyan
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
KptMaterialTheme(
|
||||||
|
lightTheme = lightTheme,
|
||||||
|
darkThemeProvider = darkTheme,
|
||||||
|
) {
|
||||||
|
// Theme automatically switches based on system preference
|
||||||
|
// Material3 components inherit the appropriate theme
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example of KptMaterialTheme with DSL builder
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun KptMaterialThemeBuilderExample() {
|
||||||
|
KptMaterialTheme(
|
||||||
|
themeBuilder = { isDark ->
|
||||||
|
kptTheme {
|
||||||
|
colors {
|
||||||
|
if (isDark) {
|
||||||
|
primary = Color.Cyan
|
||||||
|
background = Color.Black
|
||||||
|
} else {
|
||||||
|
primary = Color.Blue
|
||||||
|
background = Color.White
|
||||||
|
}
|
||||||
|
}
|
||||||
|
typography {
|
||||||
|
titleLarge = titleLarge.copy(fontSize = 24.sp)
|
||||||
|
}
|
||||||
|
shapes {
|
||||||
|
medium = Shapes().medium.copy(all = CornerSize(16.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
// Dynamic theme based on dark mode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// endregion
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user