chore: sync missing root files and folders from KMP project template (#2965)

This commit is contained in:
Nagarjuna 2025-09-03 11:35:53 +05:30 committed by GitHub
parent a6713db018
commit a98b87a5cc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
191 changed files with 22575 additions and 97 deletions

View File

@ -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)
}

View File

@ -9,7 +9,7 @@ import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
@OptIn(ExperimentalWasmDsl::class, ExperimentalKotlinGradlePluginApi::class)
internal fun Project.configureKotlinMultiplatform() {
extensions.configure<KotlinMultiplatformExtension> {
applyDefaultHierarchyTemplate()
applyProjectHierarchyTemplate()
jvm("desktop")
androidTarget()

View File

@ -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'
targetSdkVersion:'34'
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='android.permission.ACCESS_ADSERVICES_ATTRIBUTION'
uses-permission: name='android.permission.ACCESS_ADSERVICES_AD_ID'
uses-permission: name='org.mifospay.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION'
application-label:'Mifos Pay'
application-label-af:'Mifos Pay'
application-label-am:'Mifos Pay'
application-label-ar:'Mifos Pay'
application-label-as:'Mifos Pay'
application-label-az:'Mifos Pay'
application-label-be:'Mifos Pay'
application-label-bg:'Mifos Pay'
application-label-bn:'Mifos Pay'
application-label-bs:'Mifos Pay'
application-label-ca:'Mifos Pay'
application-label-cs:'Mifos Pay'
application-label-da:'Mifos Pay'
application-label-de:'Mifos Pay'
application-label-el:'Mifos Pay'
application-label-en-AU:'Mifos Pay'
application-label-en-CA:'Mifos Pay'
application-label-en-GB:'Mifos Pay'
application-label-en-IN:'Mifos Pay'
application-label-en-XC:'Mifos Pay'
application-label-es:'Mifos Pay'
application-label-es-US:'Mifos Pay'
application-label-et:'Mifos Pay'
application-label-eu:'Mifos Pay'
application-label-fa:'Mifos Pay'
application-label-fi:'Mifos Pay'
application-label-fr:'Mifos Pay'
application-label-fr-CA:'Mifos Pay'
application-label-gl:'Mifos Pay'
application-label-gu:'Mifos Pay'
application-label-hi:'Mifos Pay'
application-label-hr:'Mifos Pay'
application-label-hu:'Mifos Pay'
application-label-hy:'Mifos Pay'
application-label-in:'Mifos Pay'
application-label-is:'Mifos Pay'
application-label-it:'Mifos Pay'
application-label-iw:'Mifos Pay'
application-label-ja:'Mifos Pay'
application-label-ka:'Mifos Pay'
application-label-kk:'Mifos Pay'
application-label-km:'Mifos Pay'
application-label-kn:'Mifos Pay'
application-label-ko:'Mifos Pay'
application-label-ky:'Mifos Pay'
application-label-lo:'Mifos Pay'
application-label-lt:'Mifos Pay'
application-label-lv:'Mifos Pay'
application-label-mk:'Mifos Pay'
application-label-ml:'Mifos Pay'
application-label-mn:'Mifos Pay'
application-label-mr:'Mifos Pay'
application-label-ms:'Mifos Pay'
application-label-my:'Mifos Pay'
application-label-nb:'Mifos Pay'
application-label-ne:'Mifos Pay'
application-label-nl:'Mifos Pay'
application-label-or:'Mifos Pay'
application-label-pa:'Mifos Pay'
application-label-pl:'Mifos Pay'
application-label-pt:'Mifos Pay'
application-label-pt-BR:'Mifos Pay'
application-label-pt-PT:'Mifos Pay'
application-label-ro:'Mifos Pay'
application-label-ru:'Mifos Pay'
application-label-si:'Mifos Pay'
application-label-sk:'Mifos Pay'
application-label-sl:'Mifos Pay'
application-label-sq:'Mifos Pay'
application-label-sr:'Mifos Pay'
application-label-sr-Latn:'Mifos Pay'
application-label-sv:'Mifos Pay'
application-label-sw:'Mifos Pay'
application-label-ta:'Mifos Pay'
application-label-te:'Mifos Pay'
application-label-th:'Mifos Pay'
application-label-tl:'Mifos Pay'
application-label-tr:'Mifos Pay'
application-label-uk:'Mifos Pay'
application-label-ur:'Mifos Pay'
application-label-uz:'Mifos Pay'
application-label-vi:'Mifos Pay'
application-label-zh-CN:'Mifos Pay'
application-label-zh-HK:'Mifos Pay'
application-label-zh-TW:'Mifos Pay'
application-label-zu:'Mifos Pay'
uses-permission: name='org.mifos.mobile.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION'
application-label:'Mifos Mobile'
application-label-af:'Mifos Mobile'
application-label-am:'Mifos Mobile'
application-label-ar:'Mifos Mobile'
application-label-as:'Mifos Mobile'
application-label-az:'Mifos Mobile'
application-label-be:'Mifos Mobile'
application-label-bg:'Mifos Mobile'
application-label-bn:'Mifos Mobile'
application-label-bs:'Mifos Mobile'
application-label-ca:'Mifos Mobile'
application-label-cs:'Mifos Mobile'
application-label-da:'Mifos Mobile'
application-label-de:'Mifos Mobile'
application-label-el:'Mifos Mobile'
application-label-en-AU:'Mifos Mobile'
application-label-en-CA:'Mifos Mobile'
application-label-en-GB:'Mifos Mobile'
application-label-en-IN:'Mifos Mobile'
application-label-en-XC:'Mifos Mobile'
application-label-es:'Mifos Mobile'
application-label-es-US:'Mifos Mobile'
application-label-et:'Mifos Mobile'
application-label-eu:'Mifos Mobile'
application-label-fa:'Mifos Mobile'
application-label-fi:'Mifos Mobile'
application-label-fr:'Mifos Mobile'
application-label-fr-CA:'Mifos Mobile'
application-label-gl:'Mifos Mobile'
application-label-gu:'Mifos Mobile'
application-label-hi:'Mifos Mobile'
application-label-hr:'Mifos Mobile'
application-label-hu:'Mifos Mobile'
application-label-hy:'Mifos Mobile'
application-label-in:'Mifos Mobile'
application-label-is:'Mifos Mobile'
application-label-it:'Mifos Mobile'
application-label-iw:'Mifos Mobile'
application-label-ja:'Mifos Mobile'
application-label-ka:'Mifos Mobile'
application-label-kk:'Mifos Mobile'
application-label-km:'Mifos Mobile'
application-label-kn:'Mifos Mobile'
application-label-ko:'Mifos Mobile'
application-label-ky:'Mifos Mobile'
application-label-lo:'Mifos Mobile'
application-label-lt:'Mifos Mobile'
application-label-lv:'Mifos Mobile'
application-label-mk:'Mifos Mobile'
application-label-ml:'Mifos Mobile'
application-label-mn:'Mifos Mobile'
application-label-mr:'Mifos Mobile'
application-label-ms:'Mifos Mobile'
application-label-my:'Mifos Mobile'
application-label-nb:'Mifos Mobile'
application-label-ne:'Mifos Mobile'
application-label-nl:'Mifos Mobile'
application-label-or:'Mifos Mobile'
application-label-pa:'Mifos Mobile'
application-label-pl:'Mifos Mobile'
application-label-pt:'Mifos Mobile'
application-label-pt-BR:'Mifos Mobile'
application-label-pt-PT:'Mifos Mobile'
application-label-ro:'Mifos Mobile'
application-label-ru:'Mifos Mobile'
application-label-si:'Mifos Mobile'
application-label-sk:'Mifos Mobile'
application-label-sl:'Mifos Mobile'
application-label-sq:'Mifos Mobile'
application-label-sr:'Mifos Mobile'
application-label-sr-Latn:'Mifos Mobile'
application-label-sv:'Mifos Mobile'
application-label-sw:'Mifos Mobile'
application-label-ta:'Mifos Mobile'
application-label-te:'Mifos Mobile'
application-label-th:'Mifos Mobile'
application-label-tl:'Mifos Mobile'
application-label-tr:'Mifos Mobile'
application-label-uk:'Mifos Mobile'
application-label-ur:'Mifos Mobile'
application-label-uz:'Mifos Mobile'
application-label-vi:'Mifos Mobile'
application-label-zh-CN:'Mifos Mobile'
application-label-zh-HK:'Mifos Mobile'
application-label-zh-TW:'Mifos Mobile'
application-label-zu:'Mifos Mobile'
application-icon-160:'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-480:'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: 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=''
property: name='android.adservices.AD_SERVICES_CONFIG' resource='res/xml/ga_ad_services_config.xml'
uses-library-not-required:'androidx.window.extensions'

View File

@ -451,12 +451,16 @@ naming:
active: true
mustBeFirst: true
excludes:
- "**/*.jvm.kt"
- "**/*.desktop.kt"
- "**/*.wasmJs.kt"
- "**/*.native.kt"
- "**/*.js.kt"
- "**/*.android.kt"
[
"**/*.android.*",
"**/*.desktop.*",
"**/*.js.*",
"**/*.native.*",
"**/*.jvm.*",
"**/*.linux.*",
"**/*.macos.*",
"**/*.wasmJs.*",
]
MemberNameEqualsClassName:
active: true
ignoreOverridden: true
@ -472,6 +476,10 @@ naming:
PackageNaming:
active: true
packagePattern: '[a-z]+(\.[a-z][A-Za-z0-9]*)*'
excludes:
[
"**/generated/**",
]
TopLevelPropertyNaming:
active: true
constantPattern: "[A-Z][_A-Z0-9]*"

1
core-base/analytics/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build

View 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

View 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)
}
}
}

View File

View 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
}

View 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 />

View File

@ -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
}

View File

@ -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"
}

View File

@ -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) }
}

View File

@ -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
}
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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")
}
}

View File

@ -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)
}

View File

@ -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,
)
}
}
}
}

View File

@ -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)
}

View File

@ -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

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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
View File

@ -0,0 +1 @@
/build

View File

@ -0,0 +1 @@
# :core:common module

View 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)
}
}
}

View File

View 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>

View File

@ -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

View File

@ -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() }
}

View File

@ -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)
}

View File

@ -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)) }

View File

@ -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)

View File

@ -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)
}

View File

@ -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?)
}

View File

@ -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

View File

@ -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
}

View File

@ -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?) {
}
}

View File

@ -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() }
}

View File

@ -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
View File

@ -0,0 +1 @@
/build

View 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.

View 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)
}
}
}

View File

@ -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,
)
}
}

View File

@ -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
}

View File

@ -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()

View File

@ -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,
)
}
}

View File

@ -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)
}
}

View File

@ -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

View File

@ -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

View 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.

View 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)
}
}
}

View 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
}

View 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)
}
}
}

View File

@ -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
}

View File

@ -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>
}

View File

@ -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()
}

View File

@ -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>
}

View File

@ -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>
}

View File

@ -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>())
}
}

View File

@ -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)

View File

@ -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")
}
}

View File

@ -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()
}
}
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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()
}

View File

@ -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
}

View File

@ -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()
}
}

View File

@ -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)
}

View File

@ -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>
}

View File

@ -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 == "*" }
}
}

View File

@ -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
}

View File

@ -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>
}

View File

@ -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,
),
)
}
}
}

View 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.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>
}

View File

@ -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 }
}
}

View File

@ -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)
}
}
}

View File

@ -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()
}

View File

@ -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)
}
}
}

View File

@ -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>
}

View File

@ -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)

View File

@ -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)
}
}

View File

@ -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()
}
}
}

View File

@ -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())
}
}

View File

@ -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"))
}
}

View File

@ -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()
}
}
}

View File

@ -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())
}
}

View File

@ -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()
}
}
}

View File

@ -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()
}
}
}

View File

@ -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())
}
}
}

View File

@ -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()
}
}
}

View File

@ -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)
}
}

View File

@ -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
View File

@ -0,0 +1 @@
/build

View 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**

View 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"
}

View File

@ -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