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 集成示例**及**交互体验优化**等实战方案。旨在为开发者提供一套标准化、可复用的工程底座,跳过基础设施搭建,直接进入高质量业务开发。[![Ask DeepWiki](https://deepwiki.com/badge.svg)](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. [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/zzjcode-com/Quickly-Use-Jetpack-Compose) [![GitHub License](https://img.shields.io/badge/license-Apache--2.0-blue.svg?style=flat)](https://github.com/Cheng-Kun-Liu/Quickly-Use-Jetpack-Compose/blob/main/LICENSE) [![Kotlin](https://img.shields.io/badge/Kotlin-2.x-blue.svg?style=flat&logo=kotlin)](https://kotlinlang.org) [![AGP](https://img.shields.io/badge/AGP-9.x-blue.svg?style=flat&logo=android)](https://developer.android.com/studio/releases/gradle-plugin) [![Compose](https://img.shields.io/badge/Compose-Latest-orange.svg?style=flat&logo=jetpackcompose)](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 集成示例**及**交互体验优化**等实战方案。旨在为开发者提供一套标准化、可复用的工程底座,跳过基础设施搭建,直接进入高质量业务开发。[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/zzjcode-com/Quickly-Use-Jetpack-Compose) + + +[![GitHub License](https://img.shields.io/badge/license-Apache--2.0-blue.svg?style=flat)](https://github.com/Cheng-Kun-Liu/Quickly-Use-Jetpack-Compose/blob/main/LICENSE) +[![Kotlin](https://img.shields.io/badge/Kotlin-2.x-blue.svg?style=flat&logo=kotlin)](https://kotlinlang.org) +[![AGP](https://img.shields.io/badge/AGP-9.x-blue.svg?style=flat&logo=android)](https://developer.android.com/studio/releases/gradle-plugin) +[![Compose](https://img.shields.io/badge/Compose-Latest-orange.svg?style=flat&logo=jetpackcompose)](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]