diff --git a/PRIVACY.md b/PRIVACY.md
index 312f85eb..1f714d4f 100644
--- a/PRIVACY.md
+++ b/PRIVACY.md
@@ -1,6 +1,6 @@
## Privacy Policy
-Quickly-Use-Jetpack-Compose (hereinafter referred to as "this project") is an open-source project developed by Cheng-Kun-Liu.
+Quickly-Use-Jetpack-Compose (hereinafter referred to as "this project") is an open-source project developed by zzjcode-com.
**Data Collection**
diff --git a/README.md b/README.md
index 4c9a8b85..19852159 100644
--- a/README.md
+++ b/README.md
@@ -1,109 +1,115 @@
# Quickly-Use-Jetpack-Compose
-这是一个立足于 **商业级开发标准** 的个人 Jetpack Compose 实战型工程模板,我会持续维护并同步最新的实战沉淀。项目深度集成 **多模块插件化管理**、**自定义设计系统**、**离线可用数据仓库**及**启动性能优化**等现代化工程化标准;同时涵盖了**系统能力深度封装**、**三方 SDK 集成示例**及**交互体验优化**等实战方案。旨在为开发者提供一套标准化、可复用的工程底座,跳过基础设施搭建,直接进入高质量业务开发。[](https://deepwiki.com/Cheng-Kun-Liu/Quickly-Use-Jetpack-Compose)
-
+This is a personal Jetpack Compose practical project template based on **commercial-grade development standards**. I will continuously maintain and sync the latest practical insights. The project deeply integrates modern engineering standards such as **multi-module plugin-based management**, **custom design systems**, **offline-ready data repositories**, and **startup performance optimization**. It also covers practical solutions like **deep system capability encapsulation**, **third-party SDK integration examples**, and **interaction experience optimization**. It aims to provide developers with a standardized, reusable engineering base, skipping infrastructure setup and directly entering high-quality business development. [](https://deepwiki.com/zzjcode-com/Quickly-Use-Jetpack-Compose)
[](https://github.com/Cheng-Kun-Liu/Quickly-Use-Jetpack-Compose/blob/main/LICENSE)
[](https://kotlinlang.org)
[](https://developer.android.com/studio/releases/gradle-plugin)
[](https://developer.android.com/jetpack/compose)
-# 📜 协议
+[中文版 (Chinese Version)](README_CN.md)
+
+# 📬 Contact Me
+
+If you need help developing an app, feel free to email me anytime. I'm from China and offer great cost-effectiveness.
-[查看隐私协议](PRIVACY.md)
+- **Email**: [zzjcode@gmail.com](mailto:zzjcode@gmail.com)
-# 🏗️ 架构
+# 📜 Protocol
-Quickly-Use-Jetpack-Compose 的架构参考 Android 官方最佳实践项目 [Now in Android App](https://github.com/android/nowinandroid)。
+[View Privacy Policy](PRIVACY.md)
-## 架构组件
+# 🏗️ Architecture
-+ **模块化**:按 app、core、feature、flavor、res 等模块组织代码,降低耦合。
-+ **依赖注入**:使用 Hilt 管理全局和局部依赖。
-+ **数据层**:采用 Repository 模式,集成 **Room** 数据库和 **Ktor (OkHttp)** 网络请求。
-+ **UI 驱动**:单 Activity 架构,使用 Navigation 管理页面跳转,结合 Compose + ViewModel + Flow 实现响应式 UI。
+The architecture of Quickly-Use-Jetpack-Compose refers to the Android official best practice project [Now in Android App](https://github.com/android/nowinandroid).
-## 体验优化
+## Architectural Components
-+ **任务栈调度**:内置 `SchemeActivity` 中转层,解决外部回跳(如支付回调、OAuth 登录)时的任务栈错乱问题,确保应用无缝恢复至离开前的状态。
-+ **最近任务清理**:封装 `finishAndCleanupTask` 扩展函数,针对独立任务栈(如 WebView)提供自动销毁机制,确保 Activity 退出后及时清理“最近任务列表”中的空白残留。
-+ **视频边播边存**:基于 Media3 封装 LRU 缓存机制,实现视频数据在播放时本地同步持久化,实现二次打开秒开并显著节省流量消耗。
-+ **图片磁盘缓存**:深度配置 Coil 磁盘缓存策略,确保网络图片在离线状态下依然可见,并大幅提升图片重载速度与滚动流畅度。
++ **Modularization**: Code organized by app, core, feature, flavor, res, etc., to reduce coupling.
++ **Dependency Injection**: Uses Hilt to manage global and local dependencies.
++ **Data Layer**: Adopts the Repository pattern, integrating **Room** database and **Ktor (OkHttp)** for network requests.
++ **UI Driven**: Single Activity architecture, using Navigation to manage page jumps, combined with Compose + ViewModel + Flow for reactive UI.
-# 🎨 设计系统
+## Experience Optimization
-项目包含一套自定义 Compose 设计系统,偏微信风格,不直接套用 Material 3 的视觉样式。
++ **Task Stack Scheduling**: Built-in `SchemeActivity` intermediate layer to solve task stack disorder when jumping back from external sources (e.g., payment callbacks, OAuth login), ensuring the app seamlessly restores to the state before leaving.
++ **Recent Task Cleanup**: Encapsulated `finishAndCleanupTask` extension function for independent task stacks (e.g., WebView), providing an automatic destruction mechanism to ensure the Activity is cleared from the "Recent Tasks List" after exit.
++ **Video Play-while-Saving**: LRU caching mechanism based on Media3 for real-time local synchronization and persistence of video data during playback, achieving instant second-time opening and significant traffic savings.
++ **Image Disk Cache**: Deep configuration of Coil disk caching strategy to ensure network images remain visible offline and significantly improve image reloading speed and scrolling smoothness.
-+ **WeTheme**:替代 MaterialTheme,竖屏按 375dp 设计宽度适配。
-+ **WeColorScheme**:定义颜色体系,支持系统、动态、浅色、深色和蓝色主题。
-+ **WeTypography**:定义字体大小体系。
-+ **WeIndication**:定义触摸、悬停和焦点反馈。
-+ **WeDimen**:定义尺寸规范。
-+ **WeIcons**:使用 ImageVector 绘制图标。
-+ **WeWidget**:顶部栏、底部栏、Button、Toast、ActionSheet、单选、多选、开关等通用组件。
-+ **View**:可点击富文本 (ClickableAnnotatedText)、拖拽排序 (DragList)、错误页 (ErrorView)、Loading、禁止截屏 (SecureComposeView) 等常用 Compose 组件。
+# 🎨 Design System
-# 📦 Module 目录简介
+The project includes a custom Compose design system, WeChat-style, not directly using Material 3 visual styles.
-+ **app**:应用入口,汇总各 feature 并统一处理 Navigation。
-+ **build-logic**:自定义 Gradle Convention 插件中心,负责构建逻辑的复用与模块化管控。
-+ **core-logic**:
- - `repository` / `network` / `database`:核心数据链路,提供离线优先的数据仓库与网络拦截机制。
- - `authenticate` / `notification` / `location` / `language`:核心领域能力封装,包括生物识别、FCM 通知、定位及多语言切换。
-+ **core-ui** & **core-launcher**:
- - `core-ui`:WeDesign 设计系统核心实现。
- - `core-launcher`:系统交互封装,提供一行代码调用相机、相册及权限申请的能力。
-+ **feature**:
- - `main`:应用主框架,包含首页 Pager 容器及“功能”页逻辑。
- - `chat` / `video` / `webview`:垂直业务模块,深度集成 AI 聊天、Media3 视频架构及通用 WebView。这些模块被 `explore` 模块引用作为三方集成示例。
- - `explore` / `system`:三方 SDK 集成示例(HTTP、Firebase、Google 登录)与系统原生能力封装(定位、生物识别)。
- - `ui-demo`:UI 交互示例,包含自定义日历、绘画画板、嵌套滚动等。
- - `settings`:应用偏好设置(多语言、主题、字体)。
-+ **flavor**:提供 `gp` (Google Play) 和 `sam` (Samsung) 渠道差异化实现示例。
-+ **res**:统一管理字符串、图片、多语言等资源文件。
-+ **baseline-profile**:配置启动性能优化。
++ **WeTheme**: Replaces MaterialTheme, adapted for 375dp design width in portrait mode.
++ **WeColorScheme**: Defines the color system, supporting system, dynamic, light, dark, and blue themes.
++ **WeTypography**: Defines the font size system.
++ **WeIndication**: Defines touch, hover, and focus feedback.
++ **WeDimen**: Defines dimension standards.
++ **WeIcons**: Uses ImageVector to draw icons.
++ **WeWidget**: Common components like TopBar, BottomBar, Button, Toast, ActionSheet, Radio, Checkbox, Switch, etc.
++ **View**: Common Compose components like Clickable富文本 (ClickableAnnotatedText), Drag-and-Sort (DragList), Error Page (ErrorView), Loading, Screenshot prevention (SecureComposeView), etc.
-# 🚀 开发与运行
+# 📦 Module Directory Overview
-建议使用最新版本 Android Studio 打开项目。运行时切换到 `app` 配置后启动。
++ **app**: Application entry, aggregates features and handles Navigation.
++ **build-logic**: Custom Gradle Convention plugin center, responsible for build logic reuse and modular management.
++ **core-logic**:
+ - `repository` / `network` / `database`: Core data links, providing offline-first data repositories and network interceptors.
+ - `authenticate` / `notification` / `location` / `language`: Core domain capability encapsulation, including biometrics, FCM notifications, location, and multi-language switching.
++ **core-ui** & **core-launcher**:
+ - `core-ui`: Core implementation of WeDesign design system.
+ - `core-launcher`: System interaction encapsulation, providing one-line calls for camera, gallery, and permission requests.
++ **feature**:
+ - `main`: Main app frame, including Home Pager container and "Functions" page logic.
+ - `chat` / `video` / `webview`: Vertical business modules, deeply integrating AI chat, Media3 video architecture, and universal WebView. These modules are referenced by the `explore` module as third-party integration examples.
+ - `explore` / `system`: Third-party SDK integration examples (HTTP, Firebase, Google Login) and system native capability encapsulation (location, biometrics).
+ - `ui-demo`: UI interaction examples, including custom calendar, drawing board, nested scrolling, etc.
+ - `settings`: App preference settings (multi-language, theme, font).
++ **flavor**: Provides examples of channel-differentiated implementations for `gp` (Google Play) and `sam` (Samsung).
++ **res**: Unified management of resources like strings, images, and multi-language files.
++ **baseline-profile**: Configures startup performance optimization.
-## 密钥与签名
+# 🚀 Development and Running
-密钥文件存放在根目录的 `keystore` 目录中。签名相关配置在 `AndroidApplicationConventionPlugin.kt`。
+It is recommended to use the latest version of Android Studio. Switch to the `app` configuration before starting.
+
+## Keys and Signing
+
+Key files are stored in the `keystore` directory at the root. Signing configurations are in `AndroidApplicationConventionPlugin.kt`.
> [!IMPORTANT]
-> **Firebase 配置**:项目中的 `app/google-services.json` 文件仅作为结构参考,**无法直接使用**。在运行项目前,请务必将其替换为您在 Firebase 控制台生成的、属于您自己项目的 `google-services.json` 文件,否则 Firebase 相关功能(如 AI 聊天、崩溃分析等)将无法正常运行。
+> **Firebase Configuration**: The `app/google-services.json` file in the project is for structure reference only and **cannot be used directly**. Before running the project, please replace it with the `google-services.json` file generated in your own Firebase console for your project; otherwise, Firebase-related functions (e.g., AI chat, crash analytics, etc.) will not work properly.
-## 构建变体 (Build Variants)
+## Build Variants
-项目预设了 `gp` 和 `sam` 两个 productFlavors,分别对应不同的 ApplicationId 和签名配置,可在 Android Studio 的 Build Variants 面板中切换。
+The project pre-sets two productFlavors, `gp` and `sam`, corresponding to different ApplicationIds and signing configurations, which can be switched in the Build Variants panel in Android Studio.
-# 📱 运行效果
+# 📱 Running Effects
-### 核心业务能力
+### Core Business Capabilities
-| 示例 | 截图/GIF | 亮点 |
+| Example | Screenshot/GIF | Highlights |
| --- | --- | --- |
-| **AI 聊天 (Gemini)** |
| 集成 Google AI Gemini,展示流式响应与通知集成 |
-| **Media3 视频播放** |
| 封装 Media3,支持边播边存与离线缓存,处理 Lifecycle 与全屏切换 |
+| **AI Chat (Gemini)** |
| Integrates Google AI Gemini, showcasing streaming responses and notification integration |
+| **Media3 Video Playback** |
| Encapsulates Media3, supports play-while-saving and offline caching, handles Lifecycle and full-screen switching |
---
-### 系统能力集成
+### System Capability Integration
-| 示例 | 截图/GIF | 亮点 |
+| Example | Screenshot/GIF | Highlights |
| --- | --- | --- |
-| **动态切换图标** |
| 无需发版,动态更换 App 桌面入口图标 |
-| **网络异常处理** |
| 统一的错误码拦截、Loading 与 Retry 机制 |
-| **系统能力合集** |
| 封装权限申请、定位、相册、联系人选择 |
+| **Dynamic Icon Switch** |
| Change App desktop icon dynamically without re-releasing |
+| **Network Exception Handling** |
| Unified error code interception, Loading, and Retry mechanism |
+| **System Capabilities** |
| Encapsulated permission requests, location, gallery, and contact selection |
---
-### 国际化和UI交互
+### Internationalization and UI Interaction
-| 示例 | 截图/GIF |
+| Example | Screenshot/GIF |
| --- | --- |
-| **多语言与主题** |
|
-| **自定义日历** |
|
-| **绘画画板** |
|
-| **Lazy 列表排序** |
|
-
+| **Multi-language and Themes** |
|
+| **Custom Calendar** |
|
+| **Drawing Board** |
|
+| **Lazy List Sorting** |
|
diff --git a/README_CN.md b/README_CN.md
new file mode 100644
index 00000000..f4a6a454
--- /dev/null
+++ b/README_CN.md
@@ -0,0 +1,115 @@
+# Quickly-Use-Jetpack-Compose
+
+这是一个立足于 **商业级开发标准** 的个人 Jetpack Compose 实战型工程模板,我会持续维护并同步最新的实战沉淀。项目深度集成 **多模块插件化管理**、**自定义设计系统**、**离线可用数据仓库**及**启动性能优化**等现代化工程化标准;同时涵盖了**系统能力深度封装**、**三方 SDK 集成示例**及**交互体验优化**等实战方案。旨在为开发者提供一套标准化、可复用的工程底座,跳过基础设施搭建,直接进入高质量业务开发。[](https://deepwiki.com/zzjcode-com/Quickly-Use-Jetpack-Compose)
+
+
+[](https://github.com/Cheng-Kun-Liu/Quickly-Use-Jetpack-Compose/blob/main/LICENSE)
+[](https://kotlinlang.org)
+[](https://developer.android.com/studio/releases/gradle-plugin)
+[](https://developer.android.com/jetpack/compose)
+
+# 📬 联系我
+
+如果你需要让我帮忙开发 App,随时可以发邮件联系我。我来自中国,性价比很高。
+
+- **Email**: [zzjcode@gmail.com](mailto:zzjcode@gmail.com)
+
+
+# 📜 协议
+
+[查看隐私协议](PRIVACY.md)
+
+# 🏗️ 架构
+
+Quickly-Use-Jetpack-Compose 的架构参考 Android 官方最佳实践项目 [Now in Android App](https://github.com/android/nowinandroid)。
+
+## 架构组件
+
++ **模块化**:按 app、core、feature、flavor、res 等模块组织代码,降低耦合。
++ **依赖注入**:使用 Hilt 管理全局和局部依赖。
++ **数据层**:采用 Repository 模式,集成 **Room** 数据库和 **Ktor (OkHttp)** 网络请求。
++ **UI 驱动**:单 Activity 架构,使用 Navigation 管理页面跳转,结合 Compose + ViewModel + Flow 实现响应式 UI。
+
+## 体验优化
+
++ **任务栈调度**:内置 `SchemeActivity` 中转层,解决外部回跳(如支付回调、OAuth 登录)时的任务栈错乱问题,确保应用无缝恢复至离开前的状态。
++ **最近任务清理**:封装 `finishAndCleanupTask` 扩展函数,针对独立任务栈(如 WebView)提供自动销毁机制,确保 Activity 退出后及时清理“最近任务列表”中的空白残留。
++ **视频边播边存**:基于 Media3 封装 LRU 缓存机制,实现视频数据在播放时本地同步持久化,实现二次打开秒开并显著节省流量消耗。
++ **图片磁盘缓存**:深度配置 Coil 磁盘缓存策略,确保网络图片在离线状态下依然可见,并大幅提升图片重载速度与滚动流畅度。
+
+# 🎨 设计系统
+
+项目包含一套自定义 Compose 设计系统,偏微信风格,不直接套用 Material 3 的视觉样式。
+
++ **WeTheme**:替代 MaterialTheme,竖屏按 375dp 设计宽度适配。
++ **WeColorScheme**:定义颜色体系,支持系统、动态、浅色、深色和蓝色主题。
++ **WeTypography**:定义字体大小体系。
++ **WeIndication**:定义触摸、悬停和焦点反馈。
++ **WeDimen**:定义尺寸规范。
++ **WeIcons**:使用 ImageVector 绘制图标。
++ **WeWidget**:顶部栏、底部栏、Button、Toast、ActionSheet、单选、多选、开关等通用组件。
++ **View**:可点击富文本 (ClickableAnnotatedText)、拖拽排序 (DragList)、错误页 (ErrorView)、Loading、禁止截屏 (SecureComposeView) 等常用 Compose 组件。
+
+# 📦 Module 目录简介
+
++ **app**:应用入口,汇总各 feature 并统一处理 Navigation。
++ **build-logic**:自定义 Gradle Convention 插件中心,负责构建逻辑的复用与模块化管控。
++ **core-logic**:
+ - `repository` / `network` / `database`:核心数据链路,提供离线优先的数据仓库与网络拦截机制。
+ - `authenticate` / `notification` / `location` / `language`:核心领域能力封装,包括生物识别、FCM 通知、定位及多语言切换。
++ **core-ui** & **core-launcher**:
+ - `core-ui`:WeDesign 设计系统核心实现。
+ - `core-launcher`:系统交互封装,提供一行代码调用相机、相册及权限申请的能力。
++ **feature**:
+ - `main`:应用主框架,包含首页 Pager 容器及“功能”页逻辑。
+ - `chat` / `video` / `webview`:垂直业务模块,深度集成 AI 聊天、Media3 视频架构及通用 WebView。这些模块被 `explore` 模块引用作为三方集成示例。
+ - `explore` / `system`:三方 SDK 集成示例(HTTP、Firebase、Google 登录)与系统原生能力封装(定位、生物识别)。
+ - `ui-demo`:UI 交互示例,包含自定义日历、绘画画板、嵌套滚动等。
+ - `settings`:应用偏好设置(多语言、主题、字体)。
++ **flavor**:提供 `gp` (Google Play) 和 `sam` (Samsung) 渠道差异化实现示例。
++ **res**:统一管理字符串、图片、多语言等资源文件。
++ **baseline-profile**:配置启动性能优化。
+
+# 🚀 开发与运行
+
+建议使用最新版本 Android Studio 打开项目。运行时切换到 `app` 配置后启动。
+
+## 密钥与签名
+
+密钥文件存放在根目录的 `keystore` 目录中。签名相关配置在 `AndroidApplicationConventionPlugin.kt`。
+
+> [!IMPORTANT]
+> **Firebase 配置**:项目中的 `app/google-services.json` 文件仅作为结构参考,**无法直接使用**。在运行项目前,请务必将其替换为您在 Firebase 控制台生成的、属于您自己项目的 `google-services.json` 文件,否则 Firebase 相关功能(如 AI 聊天、崩溃分析等)将无法正常运行。
+
+## 构建变体 (Build Variants)
+
+项目预设了 `gp` 和 `sam` 两个 productFlavors,分别对应不同的 ApplicationId 和签名配置,可在 Android Studio 的 Build Variants 面板中切换。
+
+# 📱 运行效果
+
+### 核心业务能力
+
+| 示例 | 截图/GIF | 亮点 |
+| --- | --- | --- |
+| **AI 聊天 (Gemini)** |
| 集成 Google AI Gemini,展示流式响应与通知集成 |
+| **Media3 视频播放** |
| 封装 Media3,支持边播边存与离线缓存,处理 Lifecycle 与全屏切换 |
+
+---
+
+### 系统能力集成
+
+| 示例 | 截图/GIF | 亮点 |
+| --- | --- | --- |
+| **动态切换图标** |
| 无需发版,动态更换 App 桌面入口图标 |
+| **网络异常处理** |
| 统一的错误码拦截、Loading 与 Retry 机制 |
+| **系统能力合集** |
| 封装权限申请、定位、相册、联系人选择 |
+---
+
+### 国际化和UI交互
+
+| 示例 | 截图/GIF |
+| --- | --- |
+| **多语言与主题** |
|
+| **自定义日历** |
|
+| **绘画画板** |
|
+| **Lazy 列表排序** |
|
diff --git a/core-logic/common/build.gradle.kts b/core-logic/common/build.gradle.kts
index 5cc8e926..1e38f07d 100644
--- a/core-logic/common/build.gradle.kts
+++ b/core-logic/common/build.gradle.kts
@@ -6,8 +6,17 @@ plugins {
android {
namespace = "com.laomuji1999.compose.core.logic.common"
+ testFixtures {
+ enable = true
+ }
}
dependencies {
+ testImplementation(libs.junit4)
+ testImplementation(libs.mockk)
+ testImplementation(libs.kotlinx.coroutines.test)
+ testImplementation(testFixtures(project(":core-logic:common")))
+ testFixturesApi(libs.junit4)
+ testFixturesApi(libs.kotlinx.coroutines.test)
}
diff --git a/core-logic/common/src/main/kotlin/com/laomuji1999/compose/core/logic/common/cache/Cache.kt b/core-logic/common/src/main/kotlin/com/laomuji1999/compose/core/logic/common/cache/Cache.kt
index 999ddcc7..a1f2dfb1 100644
--- a/core-logic/common/src/main/kotlin/com/laomuji1999/compose/core/logic/common/cache/Cache.kt
+++ b/core-logic/common/src/main/kotlin/com/laomuji1999/compose/core/logic/common/cache/Cache.kt
@@ -17,6 +17,10 @@ interface Cache {
fun getBoolean(key: String, defValue: Boolean):Boolean
+ fun putDouble(key: String, value: Double)
+
+ fun getDouble(key: String, defValue: Double):Double
+
fun putString(key: String, value: String?)
fun getString(key: String, defValue: String):String
diff --git a/core-logic/common/src/main/kotlin/com/laomuji1999/compose/core/logic/common/cache/CacheUtil.kt b/core-logic/common/src/main/kotlin/com/laomuji1999/compose/core/logic/common/cache/CacheUtil.kt
index 667f9799..a24b8767 100644
--- a/core-logic/common/src/main/kotlin/com/laomuji1999/compose/core/logic/common/cache/CacheUtil.kt
+++ b/core-logic/common/src/main/kotlin/com/laomuji1999/compose/core/logic/common/cache/CacheUtil.kt
@@ -16,24 +16,26 @@ class CacheUtil(
private val dftValue:T,
private val type:KClass,
private val cache: Cache
- ): ReadWriteProperty {
- override fun setValue(thisRef: Any, property: KProperty<*>, value: T) {
+ ): ReadWriteProperty {
+ override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) {
when(type){
Int::class -> cache.putInt(key, value as Int)
Long::class -> cache.putLong(key, value as Long)
Float::class -> cache.putFloat(key, value as Float)
Boolean::class -> cache.putBoolean(key, value as Boolean)
+ Double::class -> cache.putDouble(key, value as Double)
String::class -> cache.putString(key, value as String)
else -> error("type is not support")
}
}
- override fun getValue(thisRef: Any, property: KProperty<*>): T {
+ override fun getValue(thisRef: Any?, property: KProperty<*>): T {
return when(type){
Int::class -> cache.getInt(key, dftValue as Int)
Long::class -> cache.getLong(key, dftValue as Long)
Float::class -> cache.getFloat(key, dftValue as Float)
Boolean::class -> cache.getBoolean(key, dftValue as Boolean)
+ Double::class -> cache.getDouble(key, dftValue as Double)
String::class -> cache.getString(key, dftValue as String)
else -> error("type is not support")
} as T
diff --git a/core-logic/common/src/main/kotlin/com/laomuji1999/compose/core/logic/common/cache/impl/CacheAndroid.kt b/core-logic/common/src/main/kotlin/com/laomuji1999/compose/core/logic/common/cache/impl/CacheAndroid.kt
index 25a6bf49..f7300599 100644
--- a/core-logic/common/src/main/kotlin/com/laomuji1999/compose/core/logic/common/cache/impl/CacheAndroid.kt
+++ b/core-logic/common/src/main/kotlin/com/laomuji1999/compose/core/logic/common/cache/impl/CacheAndroid.kt
@@ -54,6 +54,16 @@ internal class CacheAndroid(
return sharedPreferences.getBoolean(key, defValue)
}
+ override fun putDouble(key: String, value: Double) {
+ val editor = getEditor()
+ editor.putString(key, value.toString())
+ editor.commit()
+ }
+
+ override fun getDouble(key: String, defValue: Double): Double {
+ return sharedPreferences.getString(key, null)?.toDoubleOrNull() ?: defValue
+ }
+
override fun putString(key: String, value: String?) {
val editor = getEditor()
editor.putString(key, value)
diff --git a/core-logic/common/src/test/kotlin/com/laomuji1999/compose/core/logic/common/cache/CacheUtilTest.kt b/core-logic/common/src/test/kotlin/com/laomuji1999/compose/core/logic/common/cache/CacheUtilTest.kt
new file mode 100644
index 00000000..f76990e1
--- /dev/null
+++ b/core-logic/common/src/test/kotlin/com/laomuji1999/compose/core/logic/common/cache/CacheUtilTest.kt
@@ -0,0 +1,122 @@
+package com.laomuji1999.compose.core.logic.common.cache
+
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.runs
+import io.mockk.verify
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+
+class CacheUtilTest {
+
+ private val cache: Cache = mockk()
+ private lateinit var cacheUtil: CacheUtil
+
+ @Before
+ fun setup() {
+ cacheUtil = CacheUtil(cache)
+ }
+
+ @Test
+ fun `test cacheable property read and write`() {
+ // Arrange
+ val key = "test_key"
+ val defaultValue = "default"
+ val newValue = "new_value"
+
+ // Mock behavior
+ every { cache.getString(key, defaultValue) } returns defaultValue
+ every { cache.putString(key, newValue) } just runs
+
+ // Use property delegate
+ var testProperty by cacheUtil.cacheable(key, defaultValue)
+
+ // Act & Assert: Initial Read
+ assertEquals(defaultValue, testProperty)
+
+ // Act: Write
+ testProperty = newValue
+
+ // Assert: Verify cache interaction
+ verify { cache.putString(key, newValue) }
+
+ // Mock get behavior for updated value
+ every { cache.getString(key, defaultValue) } returns newValue
+
+ // Assert: Read updated value
+ assertEquals(newValue, testProperty)
+ }
+
+ @Test
+ fun `test boolean cacheable property`() {
+ val key = "bool_key"
+ every { cache.getBoolean(key, false) } returns false
+ every { cache.putBoolean(key, true) } just runs
+
+ var boolProperty by cacheUtil.cacheable(key, false)
+
+ assertEquals(false, boolProperty)
+
+ boolProperty = true
+ verify { cache.putBoolean(key, true) }
+ }
+
+ @Test
+ fun `test numeric cacheable properties`() {
+ // Int
+ every { cache.getInt("int_key", 0) } returns 0
+ every { cache.putInt("int_key", 10) } just runs
+ var intProp by cacheUtil.cacheable("int_key", 0)
+ assertEquals(0, intProp)
+ intProp = 10
+ verify { cache.putInt("int_key", 10) }
+
+ // Long
+ every { cache.getLong("long_key", 0L) } returns 0L
+ every { cache.putLong("long_key", 100L) } just runs
+ var longProp by cacheUtil.cacheable("long_key", 0L)
+ assertEquals(0L, longProp)
+ longProp = 100L
+ verify { cache.putLong("long_key", 100L) }
+
+ // Float
+ every { cache.getFloat("float_key", 0.0f) } returns 0.0f
+ every { cache.putFloat("float_key", 1.5f) } just runs
+ var floatProp by cacheUtil.cacheable("float_key", 0.0f)
+ assertEquals(0.0f, floatProp)
+ floatProp = 1.5f
+ verify { cache.putFloat("float_key", 1.5f) }
+
+ // Double
+ every { cache.getDouble("double_key", 0.0) } returns 0.0
+ every { cache.putDouble("double_key", 2.5) } just runs
+ var doubleProp by cacheUtil.cacheable("double_key", 0.0)
+ assertEquals(0.0, doubleProp, 1e-10)
+ doubleProp = 2.5
+ verify { cache.putDouble("double_key", 2.5) }
+ }
+
+ @Test(expected = IllegalStateException::class)
+ fun `test unsupported type should throw error`() {
+ val key = "unsupported_key"
+ val defaultValue = listOf("not", "supported")
+
+ // This should throw error because List::class is not handled in when(type)
+ val unsupportedProp by cacheUtil.cacheable(key, defaultValue)
+
+ // Trigger getValue to hit the error
+ val value = unsupportedProp
+ }
+
+ @Test(expected = IllegalStateException::class)
+ fun `test unsupported type should throw error on write`() {
+ val key = "unsupported_key"
+ val defaultValue = listOf("not", "supported")
+ var unsupportedProp by cacheUtil.cacheable(key, defaultValue)
+
+ // Trigger setValue to hit the error
+ unsupportedProp = listOf("still", "not", "supported")
+ }
+}
diff --git a/core-logic/common/src/testFixtures/kotlin/com/laomuji1999/compose/core/logic/common/test/MainDispatcherRule.kt b/core-logic/common/src/testFixtures/kotlin/com/laomuji1999/compose/core/logic/common/test/MainDispatcherRule.kt
new file mode 100644
index 00000000..fc088b49
--- /dev/null
+++ b/core-logic/common/src/testFixtures/kotlin/com/laomuji1999/compose/core/logic/common/test/MainDispatcherRule.kt
@@ -0,0 +1,23 @@
+package com.laomuji1999.compose.core.logic.common.test
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.TestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.setMain
+import org.junit.rules.TestWatcher
+import org.junit.runner.Description
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class MainDispatcherRule(
+ val testDispatcher: TestDispatcher = StandardTestDispatcher(),
+) : TestWatcher() {
+ override fun starting(description: Description) {
+ Dispatchers.setMain(testDispatcher)
+ }
+
+ override fun finished(description: Description) {
+ Dispatchers.resetMain()
+ }
+}
diff --git a/core-logic/database/build.gradle.kts b/core-logic/database/build.gradle.kts
index f82da823..5ef065da 100644
--- a/core-logic/database/build.gradle.kts
+++ b/core-logic/database/build.gradle.kts
@@ -19,4 +19,11 @@ dependencies {
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler)
+
+ testImplementation(libs.junit4)
+ androidTestImplementation(libs.androidx.junit)
+ androidTestImplementation(libs.androidx.test.core)
+ androidTestImplementation(libs.androidx.espresso.core)
+ androidTestImplementation(libs.kotlinx.coroutines.test)
+ androidTestImplementation(libs.turbine)
}
diff --git a/core-logic/database/src/androidTest/kotlin/com/laomuji1999/compose/core/logic/database/dao/ContactDaoTest.kt b/core-logic/database/src/androidTest/kotlin/com/laomuji1999/compose/core/logic/database/dao/ContactDaoTest.kt
new file mode 100644
index 00000000..eae0a1cb
--- /dev/null
+++ b/core-logic/database/src/androidTest/kotlin/com/laomuji1999/compose/core/logic/database/dao/ContactDaoTest.kt
@@ -0,0 +1,159 @@
+package com.laomuji1999.compose.core.logic.database.dao
+
+import android.content.Context
+import androidx.room.Room
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.laomuji1999.compose.core.logic.database.Database
+import com.laomuji1999.compose.core.logic.model.entity.ContactInfoEntity
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@RunWith(AndroidJUnit4::class)
+class ContactDaoTest {
+ private lateinit var contactDao: ContactDao
+ private lateinit var db: Database
+
+ @Before
+ fun createDb() {
+ val context = ApplicationProvider.getApplicationContext()
+ db = Room.inMemoryDatabaseBuilder(context, Database::class.java).build()
+ contactDao = db.contactDao()
+ }
+
+ @After
+ fun closeDb() {
+ db.close()
+ }
+
+ @Test
+ fun insertAndGetByAccount() = runTest {
+ val contact = ContactInfoEntity(
+ account = 123L,
+ nickname = "Test",
+ category = "Friend",
+ avatar = "url"
+ )
+ contactDao.insert(contact)
+ val byAccount = contactDao.getByAccount(123L)
+ assertEquals(contact, byAccount)
+ }
+
+ @Test
+ fun getByAccount_returnsNull_whenAccountDoesNotExist() = runTest {
+ val result = contactDao.getByAccount(999L)
+ assertEquals(null, result)
+ }
+
+ @Test
+ fun getAll_returnsEmptyList_whenDatabaseIsEmpty() = runTest {
+ val all = contactDao.getAll()
+ assertEquals(0, all.size)
+ }
+
+ @Test
+ fun insertAll_returnsAllContacts() = runTest {
+ val list = listOf(
+ ContactInfoEntity(
+ account = 1L,
+ nickname = "User1",
+ category = "Cat1",
+ avatar = "Avatar1"
+ ),
+ ContactInfoEntity(
+ account = 2L,
+ nickname = "User2",
+ category = "Cat2",
+ avatar = "Avatar2"
+ )
+ )
+ contactDao.insertAll(list)
+ val all = contactDao.getAll()
+ assertEquals(2, all.size)
+ assertEquals("User1", all.find { it.account == 1L }?.nickname)
+ assertEquals("User2", all.find { it.account == 2L }?.nickname)
+ }
+
+ @Test
+ fun getAll_sortsByCategoryThenAccount() = runTest {
+ val list = listOf(
+ ContactInfoEntity(
+ account = 1L,
+ nickname = "User1",
+ category = "B_Category",
+ avatar = "Avatar1"
+ ),
+ ContactInfoEntity(
+ account = 2L,
+ nickname = "User2",
+ category = "A_Category",
+ avatar = "Avatar2"
+ ),
+ ContactInfoEntity(
+ account = 3L,
+ nickname = "User3",
+ category = "C_Category",
+ avatar = "Avatar3"
+ )
+ )
+ contactDao.insertAll(list)
+ val all = contactDao.getAll()
+ assertEquals(3, all.size)
+ assertEquals("A_Category", all[0].category)
+ assertEquals("B_Category", all[1].category)
+ assertEquals("C_Category", all[2].category)
+ }
+
+ @Test
+ fun getAll_sortsByAccount_whenCategoryIsSame() = runTest {
+ val list = listOf(
+ ContactInfoEntity(
+ account = 200L,
+ nickname = "User2",
+ category = "Same_Cat",
+ avatar = "Avatar2"
+ ),
+ ContactInfoEntity(
+ account = 100L,
+ nickname = "User1",
+ category = "Same_Cat",
+ avatar = "Avatar1"
+ )
+ )
+ contactDao.insertAll(list)
+ val all = contactDao.getAll()
+ assertEquals(2, all.size)
+ // Primary sort: category (same), Secondary sort: account ASC
+ assertEquals(100L, all[0].account)
+ assertEquals(200L, all[1].account)
+ }
+
+ @Test
+ fun insert_onConflictReplace() = runTest {
+ val contact1 = ContactInfoEntity(
+ account = 123L,
+ nickname = "Old Name",
+ category = "Friend",
+ avatar = "url1"
+ )
+ contactDao.insert(contact1)
+
+ val contact2 = ContactInfoEntity(
+ account = 123L,
+ nickname = "New Name",
+ category = "Family",
+ avatar = "url2"
+ )
+ contactDao.insert(contact2)
+
+ val result = contactDao.getByAccount(123L)
+ assertEquals("New Name", result?.nickname)
+ assertEquals("Family", result?.category)
+ }
+}
diff --git a/core-logic/database/src/androidTest/kotlin/com/laomuji1999/compose/core/logic/database/dao/MessageDaoTest.kt b/core-logic/database/src/androidTest/kotlin/com/laomuji1999/compose/core/logic/database/dao/MessageDaoTest.kt
new file mode 100644
index 00000000..298168bc
--- /dev/null
+++ b/core-logic/database/src/androidTest/kotlin/com/laomuji1999/compose/core/logic/database/dao/MessageDaoTest.kt
@@ -0,0 +1,114 @@
+package com.laomuji1999.compose.core.logic.database.dao
+
+import android.content.Context
+import androidx.room.Room
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import app.cash.turbine.test
+import com.laomuji1999.compose.core.logic.database.Database
+import com.laomuji1999.compose.core.logic.model.entity.MessageInfoEntity
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@RunWith(AndroidJUnit4::class)
+class MessageDaoTest {
+ private lateinit var messageDao: MessageDao
+ private lateinit var db: Database
+
+ @Before
+ fun createDb() {
+ val context = ApplicationProvider.getApplicationContext()
+ db = Room.inMemoryDatabaseBuilder(context, Database::class.java).build()
+ messageDao = db.messageDao()
+ }
+
+ @After
+ fun closeDb() {
+ db.close()
+ }
+
+ @Test
+ fun getMessageList_returnsEmpty_whenNoMessages() = runTest {
+ messageDao.getMessageList(123L).test {
+ assertEquals(0, awaitItem().size)
+ }
+ }
+
+ @Test
+ fun insertAndGetMessageList() = runTest {
+ val account = 123L
+ val message = MessageInfoEntity(
+ account = account,
+ text = "Hello World",
+ isSend = true
+ )
+
+ messageDao.getMessageList(account).test {
+ assertEquals(0, awaitItem().size)
+ messageDao.insert(message)
+ val list = awaitItem()
+ assertEquals(1, list.size)
+ assertEquals("Hello World", list[0].text)
+ assertEquals(account, list[0].account)
+ }
+ }
+
+ @Test
+ fun getMessageList_filtersByAccount() = runTest {
+ val account1 = 1L
+ val account2 = 2L
+ messageDao.insert(MessageInfoEntity(account = account1, text = "Msg 1", isSend = true))
+ messageDao.insert(MessageInfoEntity(account = account2, text = "Msg 2", isSend = true))
+
+ messageDao.getMessageList(account1).test {
+ val list = awaitItem()
+ assertEquals(1, list.size)
+ assertEquals("Msg 1", list[0].text)
+ }
+ }
+
+ @Test
+ fun getMessageList_sortsByTimestampDescending() = runTest {
+ val account = 123L
+ val m1 = MessageInfoEntity(account = account, text = "Oldest", isSend = true, timestamp = 100L)
+ val m2 = MessageInfoEntity(account = account, text = "Newest", isSend = true, timestamp = 300L)
+ val m3 = MessageInfoEntity(account = account, text = "Middle", isSend = true, timestamp = 200L)
+
+ messageDao.insert(m1)
+ messageDao.insert(m2)
+ messageDao.insert(m3)
+
+ messageDao.getMessageList(account).test {
+ val list = awaitItem()
+ assertEquals(3, list.size)
+ assertEquals("Newest", list[0].text)
+ assertEquals("Middle", list[1].text)
+ assertEquals("Oldest", list[2].text)
+ }
+ }
+
+ @Test
+ fun getMessageList_sortsByMessageIdDescending_whenTimestampIsSame() = runTest {
+ val account = 123L
+ val m1 = MessageInfoEntity(account = account, text = "First Insert", isSend = true, timestamp = 100L)
+ val m2 = MessageInfoEntity(account = account, text = "Second Insert", isSend = true, timestamp = 100L)
+
+ messageDao.insert(m1)
+ messageDao.insert(m2)
+
+ messageDao.getMessageList(account).test {
+ val list = awaitItem()
+ assertEquals(2, list.size)
+ // m2 was inserted later, so it should have a larger messageId
+ // Sorting is timestamp DESC, messageId DESC
+ assertEquals("Second Insert", list[0].text)
+ assertEquals("First Insert", list[1].text)
+ }
+ }
+}
diff --git a/core-logic/database/src/main/kotlin/com/laomuji1999/compose/core/logic/database/dao/ContactDao.kt b/core-logic/database/src/main/kotlin/com/laomuji1999/compose/core/logic/database/dao/ContactDao.kt
index fabd4b98..5becaf40 100644
--- a/core-logic/database/src/main/kotlin/com/laomuji1999/compose/core/logic/database/dao/ContactDao.kt
+++ b/core-logic/database/src/main/kotlin/com/laomuji1999/compose/core/logic/database/dao/ContactDao.kt
@@ -8,12 +8,15 @@ import com.laomuji1999.compose.core.logic.model.entity.ContactInfoEntity
@Dao
interface ContactDao {
- @Query("SELECT * FROM ContactInfoEntity ORDER BY category ASC")
- fun getAll(): List
+ @Query("SELECT * FROM ContactInfoEntity ORDER BY category ASC, account ASC")
+ suspend fun getAll(): List
@Query("SELECT * FROM ContactInfoEntity WHERE account = :account")
- fun getByAccount(account: Long): ContactInfoEntity
+ suspend fun getByAccount(account: Long): ContactInfoEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
- fun insertAll(list: List)
+ suspend fun insert(contact: ContactInfoEntity)
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insertAll(list: List)
}
\ No newline at end of file
diff --git a/core-logic/database/src/main/kotlin/com/laomuji1999/compose/core/logic/database/dao/MessageDao.kt b/core-logic/database/src/main/kotlin/com/laomuji1999/compose/core/logic/database/dao/MessageDao.kt
index 7ffe5807..694c5c49 100644
--- a/core-logic/database/src/main/kotlin/com/laomuji1999/compose/core/logic/database/dao/MessageDao.kt
+++ b/core-logic/database/src/main/kotlin/com/laomuji1999/compose/core/logic/database/dao/MessageDao.kt
@@ -8,9 +8,9 @@ import kotlinx.coroutines.flow.Flow
@Dao
interface MessageDao {
- @Query("SELECT * FROM MessageInfoEntity WHERE account = :account ORDER BY timestamp DESC")
+ @Query("SELECT * FROM MessageInfoEntity WHERE account = :account ORDER BY timestamp DESC, messageId DESC")
fun getMessageList(account:Long): Flow>
@Insert
- fun insert(messageInfoEntity: MessageInfoEntity)
+ suspend fun insert(messageInfoEntity: MessageInfoEntity)
}
\ No newline at end of file
diff --git a/core-logic/repository/build.gradle.kts b/core-logic/repository/build.gradle.kts
index 63e8a66b..c1edc625 100644
--- a/core-logic/repository/build.gradle.kts
+++ b/core-logic/repository/build.gradle.kts
@@ -22,4 +22,12 @@ dependencies {
//google ai
implementation(libs.firebase.ai)
+
+ testImplementation(project(":core-logic:common"))
+ testImplementation(testFixtures(project(":core-logic:common")))
+ testImplementation(libs.junit4)
+ testImplementation(libs.mockk)
+ testImplementation(libs.kotlinx.coroutines.test)
+ testImplementation(libs.turbine)
+ testImplementation(libs.ktor.client.mock)
}
diff --git a/core-logic/repository/src/main/kotlin/com/laomuji1999/compose/core/logic/repository/chat/ChatRepository.kt b/core-logic/repository/src/main/kotlin/com/laomuji1999/compose/core/logic/repository/chat/ChatRepository.kt
index 8e028c14..7025956f 100644
--- a/core-logic/repository/src/main/kotlin/com/laomuji1999/compose/core/logic/repository/chat/ChatRepository.kt
+++ b/core-logic/repository/src/main/kotlin/com/laomuji1999/compose/core/logic/repository/chat/ChatRepository.kt
@@ -6,7 +6,7 @@ import javax.inject.Singleton
@Singleton
interface ChatRepository {
- fun sendMessage(
+ suspend fun sendMessage(
account: Long,
text: String,
nickname: String
diff --git a/core-logic/repository/src/main/kotlin/com/laomuji1999/compose/core/logic/repository/chat/impl/GoogleAiChat.kt b/core-logic/repository/src/main/kotlin/com/laomuji1999/compose/core/logic/repository/chat/impl/GoogleAiChat.kt
index 7170cfd6..b0969de0 100644
--- a/core-logic/repository/src/main/kotlin/com/laomuji1999/compose/core/logic/repository/chat/impl/GoogleAiChat.kt
+++ b/core-logic/repository/src/main/kotlin/com/laomuji1999/compose/core/logic/repository/chat/impl/GoogleAiChat.kt
@@ -12,12 +12,8 @@ import com.laomuji1999.compose.core.logic.database.dao.MessageDao
import com.laomuji1999.compose.core.logic.model.entity.MessageInfoEntity
import com.laomuji1999.compose.core.logic.notification.NotificationHelper
import com.laomuji1999.compose.core.logic.repository.chat.ChatRepository
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
-import kotlinx.coroutines.launch
-import java.util.concurrent.Executors
internal class GoogleAiChat(
private val contactDao: ContactDao,
@@ -25,9 +21,6 @@ internal class GoogleAiChat(
private val notificationHelper: NotificationHelper
) : ChatRepository {
- private val coroutineScope =
- CoroutineScope(Executors.newSingleThreadExecutor().asCoroutineDispatcher())
-
private suspend fun getHistory(account: Long) =
messageDao.getMessageList(account = account).first().map { message ->
content(
@@ -41,7 +34,7 @@ internal class GoogleAiChat(
}
}
- override fun sendMessage(
+ override suspend fun sendMessage(
account: Long,
text: String,
nickname: String
@@ -65,26 +58,26 @@ internal class GoogleAiChat(
SafetySetting(HarmCategory.DANGEROUS_CONTENT, HarmBlockThreshold.NONE)
)
)
- coroutineScope.launch {
- try {
- generativeModel.startChat(history = getHistory(account = account))
- .sendMessage(text).text
- } catch (e: Exception) {
- e.message
- }?.let {
- val filterText = if (it.endsWith("\n")) {
- it.substring(0, it.length - 1)
- } else {
- it
- }
- val messageInfoEntity = MessageInfoEntity(
- account = account,
- text = filterText,
- isSend = false
- )
- messageDao.insert(messageInfoEntity)
+ try {
+ generativeModel.startChat(history = getHistory(account = account))
+ .sendMessage(text).text
+ } catch (e: Exception) {
+ e.message
+ }?.let {
+ val filterText = if (it.endsWith("\n")) {
+ it.substring(0, it.length - 1)
+ } else {
+ it
+ }
+ val messageInfoEntity = MessageInfoEntity(
+ account = account,
+ text = filterText,
+ isSend = false
+ )
+ messageDao.insert(messageInfoEntity)
+ contactDao.getByAccount(account)?.let { contact ->
notificationHelper.showNotification(
- contactInfoEntity = contactDao.getByAccount(account),
+ contactInfoEntity = contact,
messageInfoEntity = messageInfoEntity
)
}
diff --git a/core-logic/repository/src/main/kotlin/com/laomuji1999/compose/core/logic/repository/contacts/ContactRepository.kt b/core-logic/repository/src/main/kotlin/com/laomuji1999/compose/core/logic/repository/contacts/ContactRepository.kt
index c6ae4ecf..e0dff873 100644
--- a/core-logic/repository/src/main/kotlin/com/laomuji1999/compose/core/logic/repository/contacts/ContactRepository.kt
+++ b/core-logic/repository/src/main/kotlin/com/laomuji1999/compose/core/logic/repository/contacts/ContactRepository.kt
@@ -2,7 +2,6 @@ package com.laomuji1999.compose.core.logic.repository.contacts
import com.laomuji1999.compose.core.logic.database.dao.ContactDao
import com.laomuji1999.compose.core.logic.model.entity.ContactInfoEntity
-import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.flow
import javax.inject.Inject
@@ -17,11 +16,9 @@ class ContactRepository @Inject constructor(
val cacheContact = contactDao.getAll()
emit(cacheContact)
- //这里用假数据代替,实际应该不管是否为空都要从后台获取
- if(cacheContact.isNotEmpty()){
- delay(1000)
- }
- val contacts = fakeRequestContacts() + fakeRandomContacts()
+ val contacts = (fakeRequestContacts() + fakeRandomContacts()).sortedWith(
+ compareBy { it.category }.thenBy { it.account }
+ )
contactDao.insertAll(contacts)
emit(contacts)
}.catch {
diff --git a/core-logic/repository/src/main/kotlin/com/laomuji1999/compose/core/logic/repository/product/ProductRepository.kt b/core-logic/repository/src/main/kotlin/com/laomuji1999/compose/core/logic/repository/product/ProductRepository.kt
index 582d6a3f..f583d4b1 100644
--- a/core-logic/repository/src/main/kotlin/com/laomuji1999/compose/core/logic/repository/product/ProductRepository.kt
+++ b/core-logic/repository/src/main/kotlin/com/laomuji1999/compose/core/logic/repository/product/ProductRepository.kt
@@ -8,7 +8,6 @@ import io.ktor.client.request.get
import io.ktor.client.request.parameter
import io.ktor.http.ContentType
import io.ktor.http.contentType
-import kotlinx.coroutines.delay
import javax.inject.Inject
import javax.inject.Singleton
@@ -25,7 +24,6 @@ class ProductRepository @Inject constructor(
parameter("limit", 10)
parameter("skip", page * 10)
}
- delay(1000)
response.body()
}
}
diff --git a/core-logic/repository/src/test/kotlin/com/laomuji1999/compose/core/logic/repository/contacts/ContactRepositoryTest.kt b/core-logic/repository/src/test/kotlin/com/laomuji1999/compose/core/logic/repository/contacts/ContactRepositoryTest.kt
new file mode 100644
index 00000000..15984faa
--- /dev/null
+++ b/core-logic/repository/src/test/kotlin/com/laomuji1999/compose/core/logic/repository/contacts/ContactRepositoryTest.kt
@@ -0,0 +1,83 @@
+package com.laomuji1999.compose.core.logic.repository.contacts
+
+import app.cash.turbine.test
+import com.laomuji1999.compose.core.logic.database.dao.ContactDao
+import com.laomuji1999.compose.core.logic.model.entity.ContactInfoEntity
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.mockk
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+
+class ContactRepositoryTest {
+
+ private val contactDao: ContactDao = mockk()
+ private lateinit var repository: ContactRepository
+
+ @Before
+ fun setup() {
+ repository = ContactRepository(contactDao)
+ }
+
+ @Test
+ fun `contactsList should emit cached contacts then new contacts`() = runTest {
+ val cached = listOf(
+ ContactInfoEntity(1L, "Cached", "Cat", "url")
+ )
+ coEvery { contactDao.getAll() } returns cached
+ coEvery { contactDao.insertAll(any()) } returns Unit
+
+ repository.contactsList().test {
+ // First emission: cached data
+ val firstItem = awaitItem()
+ assertEquals(cached, firstItem)
+
+ // Second emission: new data (fakeRequestContacts + fakeRandomContacts)
+ val secondItem = awaitItem()
+ val expectedFakeSize = 15 // Based on fakeRequestContacts() implementation
+ val expectedRandomSize = 999 // Based on fakeRandomContacts() implementation
+ assertEquals(expectedFakeSize + expectedRandomSize, secondItem.size)
+ assertTrue(secondItem.any { it.nickname == "Ragdoll cat" })
+
+ coVerify { contactDao.insertAll(any()) }
+ awaitComplete()
+ }
+ }
+
+ @Test
+ fun `contactsList should emit empty list when dao throws exception`() = runTest {
+ coEvery { contactDao.getAll() } throws Exception("DB Error")
+
+ repository.contactsList().test {
+ val item = awaitItem()
+ assertTrue(item.isEmpty())
+ awaitComplete()
+ }
+ }
+
+ @Test
+ fun `contactsList should emit sorted contacts`() = runTest {
+ coEvery { contactDao.getAll() } returns emptyList()
+ coEvery { contactDao.insertAll(any()) } returns Unit
+
+ repository.contactsList().test {
+ awaitItem() // Empty cache
+ val secondItem = awaitItem()
+
+ // Verify sorting: category ASC, then account ASC
+ for (i in 0 until secondItem.size - 1) {
+ val current = secondItem[i]
+ val next = secondItem[i+1]
+ if (current.category == next.category) {
+ assertTrue("Account should be sorted ASC when category is same", current.account <= next.account)
+ } else {
+ assertTrue("Category should be sorted ASC", current.category < next.category)
+ }
+ }
+ awaitComplete()
+ }
+ }
+}
diff --git a/core-logic/repository/src/test/kotlin/com/laomuji1999/compose/core/logic/repository/product/ProductRepositoryTest.kt b/core-logic/repository/src/test/kotlin/com/laomuji1999/compose/core/logic/repository/product/ProductRepositoryTest.kt
new file mode 100644
index 00000000..485fed33
--- /dev/null
+++ b/core-logic/repository/src/test/kotlin/com/laomuji1999/compose/core/logic/repository/product/ProductRepositoryTest.kt
@@ -0,0 +1,116 @@
+package com.laomuji1999.compose.core.logic.repository.product
+
+import com.laomuji1999.compose.core.logic.network.http.HttpService
+import io.ktor.client.HttpClient
+import io.ktor.client.engine.mock.MockEngine
+import io.ktor.client.engine.mock.respond
+import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
+import io.ktor.http.ContentType
+import io.ktor.http.HttpHeaders
+import io.ktor.http.HttpStatusCode
+import io.ktor.http.headersOf
+import io.ktor.serialization.kotlinx.json.json
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.coroutines.test.runTest
+import kotlinx.serialization.json.Json
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class ProductRepositoryTest {
+
+ private val httpService: HttpService = mockk()
+ private lateinit var repository: ProductRepository
+
+ private fun setupRepository(content: String = "", status: HttpStatusCode = HttpStatusCode.OK) {
+ val mockEngine = MockEngine { _ ->
+ respond(
+ content = content,
+ status = status,
+ headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString())
+ )
+ }
+ val mockClient = HttpClient(mockEngine) {
+ install(ContentNegotiation) {
+ json(Json { ignoreUnknownKeys = true })
+ }
+ }
+ every { httpService.client } returns mockClient
+ repository = ProductRepository(httpService)
+ }
+
+ @Test
+ fun `getProducts should return success result when server returns 200`() = runTest {
+ setupRepository(
+ content = """{"products": [], "total": 0, "skip": 0, "limit": 0}""",
+ status = HttpStatusCode.OK
+ )
+
+ val result = repository.getProducts(0)
+
+ assertTrue(result.isSuccess)
+ val response = result.getOrNull()
+ assertEquals(0, response?.total)
+ }
+
+ @Test
+ fun `getProducts should use correct skip parameter for pagination`() = runTest {
+ var capturedSkip: String? = null
+ val mockEngine = MockEngine { request ->
+ capturedSkip = request.url.parameters["skip"]
+ respond(
+ content = """{"products": [], "total": 0, "skip": 10, "limit": 10}""",
+ status = HttpStatusCode.OK,
+ headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString())
+ )
+ }
+ val mockClient = HttpClient(mockEngine) {
+ install(ContentNegotiation) {
+ json(Json { ignoreUnknownKeys = true })
+ }
+ }
+ every { httpService.client } returns mockClient
+ repository = ProductRepository(httpService)
+
+ repository.getProducts(1) // Page 1 should be skip 10
+
+ assertEquals("10", capturedSkip)
+ }
+
+ @Test
+ fun `getProducts should return failure result when server returns 404`() = runTest {
+ setupRepository(content = "Not Found", status = HttpStatusCode.NotFound)
+
+ val result = repository.getProducts(0)
+
+ assertTrue(result.isFailure)
+ }
+
+ @Test
+ fun `getProducts should return failure result when network error occurs`() = runTest {
+ val mockEngine = MockEngine { _ ->
+ throw Exception("Network Error")
+ }
+ val mockClient = HttpClient(mockEngine)
+ every { httpService.client } returns mockClient
+ repository = ProductRepository(httpService)
+
+ val result = repository.getProducts(0)
+
+ assertTrue(result.isFailure)
+ assertEquals("Network Error", result.exceptionOrNull()?.message)
+ }
+
+ @Test
+ fun `getProducts should return failure result when json parsing fails`() = runTest {
+ setupRepository(
+ content = """{"products": "invalid_type_should_be_array"}""",
+ status = HttpStatusCode.OK
+ )
+
+ val result = repository.getProducts(0)
+
+ assertTrue(result.isFailure)
+ }
+}
diff --git a/feature/chat/build.gradle.kts b/feature/chat/build.gradle.kts
index f95a7e4b..bcbab05a 100644
--- a/feature/chat/build.gradle.kts
+++ b/feature/chat/build.gradle.kts
@@ -15,4 +15,11 @@ dependencies {
implementation(project(":core-logic:common"))
implementation(project(":core-logic:notification"))
implementation(project(":core-launcher"))
+
+ testImplementation(project(":core-logic:common"))
+ testImplementation(testFixtures(project(":core-logic:common")))
+ testImplementation(libs.junit4)
+ testImplementation(libs.mockk)
+ testImplementation(libs.kotlinx.coroutines.test)
+ testImplementation(libs.turbine)
}
diff --git a/feature/chat/src/main/kotlin/com/laomuji1999/compose/feature/chat/chat/ChatScreenViewModel.kt b/feature/chat/src/main/kotlin/com/laomuji1999/compose/feature/chat/chat/ChatScreenViewModel.kt
index bd93f7c6..86fe678e 100644
--- a/feature/chat/src/main/kotlin/com/laomuji1999/compose/feature/chat/chat/ChatScreenViewModel.kt
+++ b/feature/chat/src/main/kotlin/com/laomuji1999/compose/feature/chat/chat/ChatScreenViewModel.kt
@@ -42,8 +42,12 @@ class ChatScreenViewModel @AssistedInject constructor(
private val _messageList = MutableStateFlow>(emptyList())
init {
- ioCoroutineScope.launch {
- _contactInfo.value = contactDao.getByAccount(account)
+ viewModelScope.launch {
+ try {
+ _contactInfo.value = contactDao.getByAccount(account)
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
chatRepository.getMessageList(account).collect {
_messageList.value = it
}
@@ -77,12 +81,17 @@ class ChatScreenViewModel @AssistedInject constructor(
private fun sendInputText() {
val contactInfo = _contactInfo.value ?: return
- if (_inputText.value.isEmpty()) {
+ val text = _inputText.value
+ if (text.isEmpty()) {
return
}
+ _inputText.value = ""
ioCoroutineScope.launch {
- chatRepository.sendMessage(contactInfo.account, _inputText.value, contactInfo.nickname)
- _inputText.value = ""
+ try {
+ chatRepository.sendMessage(contactInfo.account, text, contactInfo.nickname)
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
}
}
diff --git a/feature/chat/src/test/kotlin/com/laomuji1999/compose/feature/chat/chat/ChatScreenViewModelTest.kt b/feature/chat/src/test/kotlin/com/laomuji1999/compose/feature/chat/chat/ChatScreenViewModelTest.kt
new file mode 100644
index 00000000..9192d247
--- /dev/null
+++ b/feature/chat/src/test/kotlin/com/laomuji1999/compose/feature/chat/chat/ChatScreenViewModelTest.kt
@@ -0,0 +1,161 @@
+package com.laomuji1999.compose.feature.chat.chat
+
+import app.cash.turbine.test
+import com.laomuji1999.compose.core.logic.common.test.MainDispatcherRule
+import com.laomuji1999.compose.core.logic.database.dao.ContactDao
+import com.laomuji1999.compose.core.logic.model.entity.ContactInfoEntity
+import com.laomuji1999.compose.core.logic.model.entity.MessageInfoEntity
+import com.laomuji1999.compose.core.logic.notification.NotificationHelper
+import com.laomuji1999.compose.core.logic.repository.chat.ChatRepository
+import com.laomuji1999.compose.feature.chat.ChatGraph
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class ChatScreenViewModelTest {
+
+ @get:Rule
+ val mainDispatcherRule = MainDispatcherRule()
+
+ private val account = 123L
+ private val contactDao: ContactDao = mockk()
+ private val chatRepository: ChatRepository = mockk()
+ private val notificationHelper: NotificationHelper = mockk()
+
+ private lateinit var viewModel: ChatScreenViewModel
+
+ private val fakeContact = ContactInfoEntity(
+ account = account,
+ nickname = "Test User",
+ category = "Friend",
+ avatar = "avatar_url"
+ )
+
+ private val fakeMessages = listOf(
+ MessageInfoEntity(account = account, text = "Hello", isSend = true),
+ MessageInfoEntity(account = account, text = "Hi", isSend = false)
+ )
+
+ @Before
+ fun setup() {
+ coEvery { contactDao.getByAccount(account) } returns fakeContact
+ every { chatRepository.getMessageList(account) } returns flowOf(fakeMessages)
+ }
+
+ private fun TestScope.createViewModel() = ChatScreenViewModel(
+ account = account,
+ contactDao = contactDao,
+ chatRepository = chatRepository,
+ notificationHelper = notificationHelper,
+ ioCoroutineScope = backgroundScope
+ )
+
+ @Test
+ fun `init should load contact info and message list`() = runTest {
+ viewModel = createViewModel()
+
+ viewModel.uiState.test {
+ // Skip initial state
+ assertEquals("", awaitItem().nickname)
+
+ // Allow init block to run
+ runCurrent()
+
+ val state = awaitItem()
+ assertEquals("Test User", state.nickname)
+ assertEquals("avatar_url", state.receiveAvatar)
+ assertEquals(fakeMessages, state.messageList)
+ }
+ }
+
+ @Test
+ fun `init should handle error when contact loading fails`() = runTest {
+ coEvery { contactDao.getByAccount(account) } throws Exception("DB Error")
+
+ viewModel = createViewModel()
+
+ viewModel.uiState.test {
+ assertEquals("", awaitItem().nickname) // Initial
+ runCurrent()
+ // In this case, it might not emit again if the error happens and state doesn't change from default
+ // But getMessageList collection should still trigger an update to messageList
+ val state = awaitItem()
+ assertEquals("", state.nickname)
+ assertEquals(fakeMessages, state.messageList)
+ }
+ }
+
+ @Test
+ fun `onAction SetInputText should update inputText`() = runTest {
+ viewModel = createViewModel()
+
+ viewModel.uiState.test {
+ awaitItem() // Initial
+ runCurrent() // After init
+ awaitItem() // State after init (Test User)
+
+ val newText = "New Message"
+ viewModel.onAction(ChatScreenAction.SetInputText(newText))
+
+ assertEquals(newText, awaitItem().inputText)
+ }
+ }
+
+ @Test
+ fun `onAction SendInputText should call repository and clear input immediately`() = runTest {
+ viewModel = createViewModel()
+
+ viewModel.uiState.test {
+ awaitItem() // Initial
+ runCurrent() // After init
+ awaitItem() // State after init
+
+ val textToSend = "Hello World"
+ viewModel.onAction(ChatScreenAction.SetInputText(textToSend))
+ awaitItem() // Input text update
+
+ coEvery { chatRepository.sendMessage(account, textToSend, "Test User") } returns Unit
+
+ viewModel.onAction(ChatScreenAction.SendInputText)
+
+ // Advance to trigger ioCoroutineScope.launch
+ runCurrent()
+
+ coVerify { chatRepository.sendMessage(account, textToSend, "Test User") }
+ assertEquals("", awaitItem().inputText)
+ }
+ }
+
+ @Test
+ fun `onAction DismissNotification should call notificationHelper`() = runTest {
+ viewModel = createViewModel()
+ runCurrent()
+
+ every { notificationHelper.dismissNotification(fakeContact) } returns Unit
+
+ viewModel.onAction(ChatScreenAction.DismissNotification)
+
+ verify { notificationHelper.dismissNotification(fakeContact) }
+ }
+
+ @Test
+ fun `onAction OnClickBack should emit Back to graph`() = runTest {
+ viewModel = createViewModel()
+ viewModel.graph.test {
+ viewModel.onAction(ChatScreenAction.OnClickBack)
+ assertEquals(ChatGraph.Back, awaitItem())
+ }
+ }
+}
diff --git a/feature/chat/src/test/kotlin/com/laomuji1999/compose/feature/chat/me/MeScreenViewModelTest.kt b/feature/chat/src/test/kotlin/com/laomuji1999/compose/feature/chat/me/MeScreenViewModelTest.kt
new file mode 100644
index 00000000..27e48fab
--- /dev/null
+++ b/feature/chat/src/test/kotlin/com/laomuji1999/compose/feature/chat/me/MeScreenViewModelTest.kt
@@ -0,0 +1,56 @@
+package com.laomuji1999.compose.feature.chat.me
+
+import app.cash.turbine.test
+import com.laomuji1999.compose.core.logic.common.cache.Cache
+import com.laomuji1999.compose.core.logic.common.cache.CacheUtil
+import com.laomuji1999.compose.core.logic.common.test.MainDispatcherRule
+import com.laomuji1999.compose.core.logic.notification.NotificationHelper
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class MeScreenViewModelTest {
+
+ @get:Rule
+ val mainDispatcherRule = MainDispatcherRule()
+
+ private val cache: Cache = mockk()
+ private lateinit var cacheUtil: CacheUtil
+ private lateinit var viewModel: MeScreenViewModel
+
+ @Before
+ fun setup() {
+ cacheUtil = CacheUtil(cache)
+ // Initial value from cache
+ every { cache.getBoolean(NotificationHelper.ENABLE_NOTIFICATION, false) } returns false
+
+ viewModel = MeScreenViewModel(cacheUtil)
+ }
+
+ @Test
+ fun `init should load enableNotification from cache`() = runTest(mainDispatcherRule.testDispatcher) {
+ viewModel.uiState.test {
+ assertEquals(false, awaitItem().enableNotification)
+ }
+ }
+
+ @Test
+ fun `onAction SwitchEnableNotification should toggle value and update cache`() = runTest(mainDispatcherRule.testDispatcher) {
+ every { cache.putBoolean(NotificationHelper.ENABLE_NOTIFICATION, true) } returns Unit
+
+ viewModel.uiState.test {
+ awaitItem() // Initial false
+ viewModel.onAction(MeScreenAction.SwitchEnableNotification)
+ assertEquals(true, awaitItem().enableNotification)
+ }
+
+ verify { cache.putBoolean(NotificationHelper.ENABLE_NOTIFICATION, true) }
+ }
+}
diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties
new file mode 100644
index 00000000..6c1139ec
--- /dev/null
+++ b/gradle/gradle-daemon-jvm.properties
@@ -0,0 +1,12 @@
+#This file is generated by updateDaemonJvm
+toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
+toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
+toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
+toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
+toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/73bcfb608d1fde9fb62e462f834a3299/redirect
+toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/846ee0d876d26a26f37aa1ce8de73224/redirect
+toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
+toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
+toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/9482ddec596298c84656d31d16652665/redirect
+toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/39701d92e1756bb2f141eb67cd4c660e/redirect
+toolchainVersion=21
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index f0187120..0b2a8371 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -26,6 +26,10 @@ playServicesLocation = "21.3.0"
appcompat = "1.7.1"
documentfile = "1.1.0"
junit = "1.3.0"
+junit4 = "4.13.2"
+mockk = "1.13.17"
+kotlinxCoroutinesTest = "1.10.1"
+turbine = "1.2.0"
espressoCore = "3.7.0"
uiautomator = "2.3.0"
baselineprofile = "1.5.0-alpha06"
@@ -53,6 +57,7 @@ ksp-gradlePlugin = { group = "com.google.devtools.ksp", name = "com.google.devto
compose-gradlePlugin = { module = "org.jetbrains.kotlin:compose-compiler-gradle-plugin", version.ref = "kotlin" }
# baseline profile, Used by build logic
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junit" }
+androidx-test-core = { group = "androidx.test", name = "core", version.ref = "junit" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", version.ref = "uiautomator" }
androidx-benchmark-macro-junit4 = { group = "androidx.benchmark", name = "benchmark-macro-junit4", version.ref = "baselineprofile" }
@@ -89,6 +94,7 @@ ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "k
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
+ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" }
# google credentials
androidx-credentials = { module = "androidx.credentials:credentials", version.ref = "credentials" }
androidx-credentials-play-services-auth = { module = "androidx.credentials:credentials-play-services-auth", version.ref = "credentials" }
@@ -98,6 +104,11 @@ play-services-location = { group = "com.google.android.gms", name = "play-servic
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
# documentfile
androidx-documentfile = { group = "androidx.documentfile", name = "documentfile", version.ref = "documentfile" }
+junit4 = { group = "junit", name = "junit", version.ref = "junit4" }
+mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
+mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" }
+kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesTest" }
+turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" }
[plugins]