diff --git a/.github/workflows/meshify-build.yml b/.github/workflows/meshify-build.yml
index 4e0473cd..2057468b 100644
--- a/.github/workflows/meshify-build.yml
+++ b/.github/workflows/meshify-build.yml
@@ -8,6 +8,11 @@ on:
pull_request:
branches: [ "master", "main" ]
+# Cancel superseded runs on the same ref to save CI minutes.
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
jobs:
validation:
name: Lint
@@ -16,10 +21,10 @@ jobs:
steps:
- uses: actions/checkout@v4
- - name: Set up JDK 21
+ - name: Set up JDK 26
uses: actions/setup-java@v4
with:
- java-version: '21'
+ java-version: '26'
distribution: 'temurin'
- name: Grant execute permission for gradlew
@@ -33,20 +38,20 @@ jobs:
gradle-home-cache-cleanup: true
- name: Run Lint
- run: ./gradlew lintDebug --continue
-
+ run: ./gradlew lintDebug --build-cache --continue
build:
name: Build APK
- needs: validation
+ # Runs in parallel with `validation` so the APK is not blocked by lint.
runs-on: ubuntu-latest
+ timeout-minutes: 45
steps:
- uses: actions/checkout@v4
- - name: Set up JDK 21
+ - name: Set up JDK 26
uses: actions/setup-java@v4
with:
- java-version: '21'
+ java-version: '26'
distribution: 'temurin'
- name: Grant execute permission for gradlew
@@ -71,12 +76,12 @@ jobs:
fi
- name: Build Debug APK
- run: ./gradlew :app:assembleDebug
+ run: ./gradlew :app:assembleDebug --build-cache
- name: Build Release APK
run: |
if [ -f meshify.jks ]; then
- ./gradlew :app:assembleRelease
+ ./gradlew :app:assembleRelease --build-cache
else
echo "Keystore file is missing. Skipping release build."
fi
@@ -127,12 +132,21 @@ jobs:
name: Meshify-Builds
path: .
+ - name: Extract Changelog
+ id: changelog
+ run: |
+ VERSION="${GITHUB_REF_NAME#v}"
+ awk -v ver="V$VERSION" \
+ '/^V[0-9]/ { if (flag) exit; if ($0 == ver) flag=1; next } \
+ flag' CHANGELOG.md > release_body.md
+ echo "Written $(wc -l < release_body.md) lines"
+
- name: Create GitHub Release
- uses: softprops/action-gh-release@v1
+ uses: softprops/action-gh-release@v2
with:
draft: false
prerelease: ${{ contains(github.ref, 'beta') || contains(github.ref, 'alpha') }}
- generate_release_notes: true
+ body_path: release_body.md
files: |
*.apk
env:
diff --git a/.gitignore b/.gitignore
index 53474812..0e92d662 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,54 +1,55 @@
+# ===== IDE / Editor =====
.idea/
*.iml
-.gradle
-/local.properties
-/.idea/caches
-/.idea/libraries
-/.idea/modules.xml
-/.idea/workspace.xml
-/.idea/navEditor.xml
-/.idea/assetWizardSettings.xml
-/docs/analysis
-/docs/Examples
-.DS_Store/
-.qwen/
-docs/
-skills/
-.windsurf/
+.gradle/
+
+# ===== Gradle / local config (may contain secrets) =====
+local.properties
+# ===== OS =====
+.DS_Store
+Thumbs.db
+
+# ===== Build output =====
**/build/
/captures
-agents
-agent
-AGENT.md
-/.gemini/
.externalNativeBuild
.cxx
-local.properties
+.kotlin
+# ===== Agents / tooling / sessions =====
+.qwen/
+.qwen-session
+skills/
+.windsurf/
+.ignored/
+.agents/
+.agent
+agents/
+agent
+.gemini/
+
+# ===== Generated / temp / logs =====
*.salive
*.apk
*.aab
-*.jks
-*.keystore
*.log
*.old
*.backup
*~
-qwen-code-export-*.md
log_device1.txt
-GEMINI.md
-QWEN.md
-TODO.md
-Rev.md
+qwen-code-export-*.md
-.DS_Store
-Thumbs.db
+# ===== Secrets / keystores / credentials =====
+*.jks
+*.keystore
MESHIFY_KEYSTORE_BASE64.txt
-.agents
+gha-creds-*.json
+
+# ===== Agent instruction / notes (untracked by design) =====
+QWEN.md
+TODO.md
AGENT.md
AGENTS.md
GEMINI.md
-*.salive
-
-gha-creds-*.json
+Rev.md
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 00000000..a5b7cba8
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,96 @@
+V1.1.3
+- [Refactor] Update MeshifySettingsGroup/SettingsItem typography tokens: labelLarge→labelMedium, ExtraBold→Bold, surfaceContainerLow→surfaceContainer, bodySmall→bodyMedium
+- [Chore] Bump version to 1.1.3 (versionCode 13)
+
+
+V1.1.2
+- [Refactor] Remove dead composables from core:ui: MeshifyCard, MeshifyListItem, MeshifySectionHeader, MeshifyPill, QrCodeDisplay (all unused)
+- [Refactor] Remove dead SettingsSubsectionHeader from feature/settings (unused)
+- [Refactor] Remove dead PermissionResultCard from feature/onboarding (unused)
+- [Refactor] Delete entire feature:help module (HelpScreen + AboutScreen) — dead code with no navigation route
+- [Style] Replace MD3 theme system with PixelPlayer-inspired MD3E design language: vibrant purple/pink/orange palette, GoogleSansRounded variable font (gflex_variable.ttf) across all typography, 8/16/24dp rounded shapes, status bar styling via MeshifyStatusBarStyle, and merged PixelPlayer's theme logic while preserving MeshifyTheme's public API
+- [Refactor] Split monolithic SettingsScreen.kt (~807 lines) into single-responsibility composables: SettingsSections.kt (Identity/Appearance/Privacy/Network/AppSettings/About sections) plus one file per dialog/sheet (BleStatusBottomSheet, SettingsNameDialog, SettingsThemeSheet, SettingsLanguageDialog, SettingsFontSizeDialog, SettingsBackupDialog, SettingsCreditsDialog)
+- [Refactor] Remove dead settings from SettingsUiState + SettingsViewModel (motionPreset, motionScale, fontFamilyPreset, customFontUri, bubbleStyle, visualDensity, shapeStyle) — defined but never surfaced in the UI nor applied to any theme/behavior
+- [Refactor] Split DeveloperViewModel out of DeveloperScreen.kt into its own file (DeveloperViewModel.kt); DeveloperScreen.kt now holds only the composable
+- [Refactor] Visible MD3E makeover of the settings screen, mirroring PixelPlayer's component language: animated Check/Close Switch (AnimatedContent thumb), surfaceContainer card rows, section headers with leading icons, expressive avatar hero + device-id pill, pill-shaped transport chips. Keeps Meshify's teal brand palette (no PixelPlayer colors/font imported; core:ui untouched)
+- [Chore] Bump every dependency to its latest release (alpha or stable) in gradle/libs.versions.toml — AGP 9.2.1, Gradle 9.4.1, Kotlin 2.4.0, KSP 2.3.10, Hilt 2.60.1, Compose BOM 2026.06.01 / Material 3 1.5.0-alpha23, Navigation 2.9.8, Room 2.8.4, Coil 3.5.0, Media3 1.10.1, DataStore 1.2.1, Lifecycle 2.11.0, Core-KTX 1.19.0, Paging 3.5.0, Coroutines 1.11.0, kotlinx-serialization 1.11.0, graphics-shapes 1.1.0
+- [Chore] compileSdk 37 across all 12 modules — required by latest AndroidX (core-ktx 1.19.0, Compose UI 1.12.0-alpha03, material3 1.5.0-alpha23, lifecycle 2.11.0, hilt-navigation-compose 1.4.0)
+- [Chore] Drop dead accompanist-permissions dependency (zero imports; project uses registerForActivityResult)
+- [Chore] Drop Views Material (com.google.android.material); app theme now uses platform android:Theme.Material.Light.NoActionBar (Compose-first)
+- [Chore] mockito-inline → mockito-core 5.18.0 in :core:network tests
+- [Chore] Gradle wrapper → 9.4.1 (AGP 9.2.1 requires Gradle ≥ 9.4.1)
+- [CI] Speed up meshify-build.yml: JDK 21 → 26 (temurin); run Lint and Build APK in parallel (dropped needs: validation); add concurrency cancel-in-progress; enable --build-cache so task outputs are reused across CI runs
+- [Fix] Provide Android SDK platform 37 + build-tools 37.0.0 (Debian SDK repo stops at 36); fetched via curl + unzip into the local SDK
+- [Fix] Message bubble now shows the actual quoted text for replies (text, media label, or "unavailable" if the original was deleted) instead of a static "Replying to…" placeholder
+- [Fix] Search results no longer render a trailing "You: " — the sender label is now "You"/"أنت"
+- [Fix] Copying a single message from the context menu now shows a success snackbar (consistent with multi-select copy)
+- [Chore] Remove dead pagination machinery in ChatViewModel (loadMoreMessages, allMessages deque, currentPage, pageSize, isAllMessagesLoaded, hasMoreMessages, MAX_MESSAGES_IN_MEMORY) — getMessages already returns the full conversation so it was never used
+- [Fix] ChatScreen BackHandler draft-discard confirmation now uses the live input text so it triggers while typing (was dead because inputText only updated on send)
+- [Fix] Sent message text no longer reappears in the input box — removed the bidirectional draftText→textState LaunchedEffect that repopulated the field after send
+- [Fix] Typed text is preserved on send failure (input box no longer cleared unconditionally in onSendClick)
+- [Fix] ChatInputBar readUriBytes now streams in 8KB chunks and aborts over MAX_FILE_SIZE_BYTES instead of reading the whole file into memory (prevents OOM on large attachments)
+- [Fix] ChatScreen scroll-to-bottom state now driven solely by isAtBottom; removed conflicting LaunchedEffect(listState) that fought the FAB visibility
+- [Fix] Search results list now uses its own LazyListState so results start at the top instead of inheriting the message list scroll position
+- [Fix] BackHandler draft-discard confirmation threshold raised from 50 to 1024 characters
+- [Fix] Copy/forward success messages now shown via a dedicated successMessage snackbar instead of being misrouted through sendError (which showed an error-style snackbar with a Retry action)
+- [Docs]: Add real codebase documentation under docs/ (architecture, core:*, feature:*, app) extracted from actual source
+- [Chore]: Clean up .gitignore — dedupe entries, collapse .idea/* into .idea/, normalize OS/secret ignore rules
+- [Refactor] Remove dead code across core/ui/: AvatarSizes, SeedColorPresets, Elevation.Level4/5, Shapes.CardLarge, StatusOffline, StatusTyping, SwipeState/LocalSwipeState, galleryScale/videoScale/fileScale animations, selectedFullImage state from AlbumMediaGrid
+- [Refactor] Delete MeshifyThemeConfig and all unused parameters from MeshifyTheme()
+- [Refactor] Update MainActivity.kt and README.md to match simplified MeshifyTheme signature
+- [Refactor] Remove dead code in core/domain: UploadProgress.kt, FileTypeData.kt, SendMessageValidation.kt, PayloadTypeFromString/DELIVERY_ACK/toPayloadType, checkWifiState()
+- [Refactor] Remove dead code in core/network: sendLargeFile(), preWarmConnection(), isConnectionAlive(), knownPeers, getConnectionType(), getAvailablePermits()
+- [Refactor] Remove dead code in feature/discovery: OobVerificationDialog.kt, OobVerificationViewModel.kt
+- [Refactor] Remove dead code in feature/home: ChatListItem, formatRecentTime wrapper
+- [Refactor] Remove dead code in app/: colors.xml, font_certs.xml, 60+ unused BLE strings, ACCESS_COARSE_LOCATION, unused imports, google.material dep
+- [Refactor] Remove dead code in feature/help: unused about_privacy, about_license, help_btn_report strings
+- [Chore] Dead code removal in core/data: removed 22 dead methods across DAO, repositories, and utilities
+- [Chore] Removed Paging 3 dependencies from core/data (dead code)
+- [Chore] Removed getAppVersion() from IFileManager interface + FileManagerImpl
+- [Chore] Adding CHANGELOG.md to project
+- [Chore] Add more bugs to fix later like any version on this app
+- [Chore] Discovery delay constants extracted for maintainability
+- [Chore] Dead code (requestClearAllData, copySelectedMessages no-op) removed
+- [Chore] Dead code (ChatAttachmentsViewModel, ChatInputViewModel, ChatMessagesViewModel, associated Uistate, test files, sendImage/sendVideo) removed
+- [Chore] forwardMediaContext now handles album messages with no mediaPath
+- [Feat] Added generic file picker (*/*) to ChatInputBar with AttachFile icon in MediaStagingChatInput
+- [Fix] Add missing English string resources to fix 24 ExtraTranslation lint errors in core:ui module
+- [Fix] Messages no longer silently lost on send failure — error shown, input text preserved
+- [Fix] Settings changes (theme, BLE, notifications, etc.) now show error snackbar on DataStore failure
+- [Fix] Copy-to-clipboard shows success confirmation snackbar
+- [Fix] Forward messages now shows per-peer failure details + success confirmation
+- [Fix] AboutScreen dead Privacy Policy / License links removed
+- [Fix] HelpScreen "About" button now navigates correctly
+- [Fix] Permission flow properly tracks Skipped / AlreadyGranted states
+- [Fix] RecentChatsScreen search bar no longer hidden by keyboard
+- [Fix] DeveloperScreen hardcoded strings replaced with localized resources
+- [Fix] sendSystemCommand no longer crashes app when transport unavailable
+- [Fix] sendImage/sendVideo now check Result.isFailure and show error on failure
+- [Fix] retryLoad() no longer a no-op — correctly re-triggers flow on error
+- [Fix] Blank peerId now shows error state instead of infinite loading
+- [Fix] DiscoveryViewModel isSearching correctly reflects scanning state, not peer count
+- [Fix] ChatViewModel deleteMessage/addReaction now show error on failure
+- [Fix] ChatInputViewModel sendMessage no longer clears input text on Result failure
+- [Fix] ChatInputViewModel forwardMessages now shows user-visible feedback
+- [Fix] ChatViewModel copySelectedMessages removed (dead code with misleading placeholder)
+- [Fix] ChatViewModel deleteSelectedMessages now reports partial deletion failures
+- [Fix] SettingsViewModel 7 remaining mutators now have try-catch error handling
+- [Fix] ChatScreen copy button race condition fixed (double clearSelection)
+- [Fix] ImageCompressor rotation failure now logged instead of silent catch
+- [Fix] RecentChatsViewModel deleteChat now logged on failure
+- [Fix] SettingsRepository generic catch blocks now log before fallback
+- [Fix] parseName() no longer uses fragile contains("name") heuristic
+- [Fix] SimpleDateFormat → DateTimeFormatter for thread-safety across 7 files
+- [Fix] cancelUpload now actually cancels the upload coroutine job
+- [Fix] DiscoveryScreen shows Snackbar for transient transport errors
+- [Fix] BleGattClient rejects payload exceeding MTU instead of silent truncation
+- [Fix] ParallelFileTransfer catch blocks now include chunk details in errors
+- [Fix] ChatInputBar URI reading now wrapped in try/catch with size check against MAX_FILE_SIZE_BYTES
+- [Fix] sendGroupedMessage now sends ALL attachments instead of only the first one
+- [Fix] sendFileWithProgress now saves mediaPath locally after successful send so sender can view their own file
+- [Fix] sendFileWithProgress mediaPath save no longer reverts message status from SENT to QUEUED
+- [Fix] MessageRepository.selectBestTransport returns null instead of throwing IllegalStateException; callers handle null gracefully
+- [Fix] ChatScreen dead imageLauncher/videoLauncher removed (launchers live in ChatInputBar)
+- [Fix] Send error snackbar now includes Retry action via SnackbarDuration.Indefinite
+- [Test] Removed all unit tests across the project (intentional — tests will be rewritten from scratch in a later pass)
+- [Perf] ChatViewModel.uploadProgress now uses SharingStarted.WhileSubscribed(5000) so the sample ticker stops when no UI is observing
diff --git a/README.md b/README.md
index e08db19d..d010db22 100644
--- a/README.md
+++ b/README.md
@@ -3,12 +3,12 @@
> **Offline-first P2P messaging for Android — no servers, no internet, no compromises.**
-
+
-
+
-
-
+
+
@@ -17,18 +17,32 @@
---
+## 🤖 Slop Code
+
+> This codebase was designed and implemented using **LLM (Qwen Code)**.
+
+
+
+
+
+---
+
+
+ Built with ♥ for offline-first, decentralized communication.
+
+
## 📖 Overview
Meshify is a **decentralized peer-to-peer messaging application** that enables real-time communication between Android devices on the same local network — **without requiring internet connectivity or central servers**.
-Built with **Clean Architecture**, **Jetpack Compose**, and **Material 3 Expressive**, Meshify delivers a modern, performant, and privacy-respecting messaging experience.
+Built with **Clean Architecture**, **Jetpack Compose**, and **Material 3**, Meshify delivers a modern, performant, and privacy-respecting messaging experience.
### ✨ Core Philosophy
- **Zero Infrastructure**: No servers, no cloud, no accounts. Just direct device-to-device communication.
-- **Offline-First**: Works entirely on local networks (WiFi). Internet is optional.
-- **Plaintext by Design**: No encryption overhead. Messages travel as plaintext over LAN for maximum simplicity and speed.
-- **Privacy-Respecting**: No telemetry, no analytics, no data collection. Your conversations stay on your device.
+- **Offline-First**: Works entirely on local networks (WiFi / LAN). Internet is optional.
+- **No privacy**: No encryption overhead. Messages travel as plaintext over LAN for maximum simplicity and speed.
+- **Privacy Respecting**: No telemetry, no analytics, no data collection. Your conversations stay on your device.
---
@@ -38,215 +52,33 @@ Built with **Clean Architecture**, **Jetpack Compose**, and **Material 3 Express
- **1-on-1 messaging** with threaded replies
- **File attachments** — images, videos, documents
- **Message reactions**, delete, and forward
+- **Message status tracking** — Queued, Sending, Sent, Delivered, Read, Failed
- **Offline storage** with Room database and pagination
### 🔌 Peer Discovery & Transport
- **mDNS/NSD** automatic peer discovery on local networks
+- **BLE transport (Beta)** (optional) — proximity-based messaging via Bluetooth Low Energy
- **Real-time presence** — instant online/offline status indicators
- **TCP-based transport** with connection pooling and keep-alive monitoring
- **UUID-based peer identification** (no phone numbers, no accounts)
### 🎨 Modern UI/UX
-- **Material 3 Expressive** design system with dynamic colors
-- **Light / Dark / System** theme support
+- **Material 3 Expressive (trying)** design system with dynamic colors
+- **Light / Dark / System** theme support + custom seed color picker
- **Full Arabic & English localization** with RTL layout support
-- **Tactile interactions** — spring-based animations, premium haptic feedback, morphing shapes
-### 🏗️ Clean Architecture
-- **Strict module boundaries** — domain, data, network, UI, and feature modules
-- **Dependency Injection** with Hilt
-- **Reactive state management** with Kotlin Flow and ViewModel
-- **Testable & maintainable** codebase with clear separation of concerns
+## 📖 Documentation
----
-
-## 📐 Architecture
-
-```
-:app
- ├── :core:common # Utilities (Logger, FileUtils, ImageCompressor)
- ├── :core:data # Room DB, DataStore, Repository implementations
- ├── :core:domain # Pure Kotlin: Interfaces, Models, Use Cases
- ├── :core:network # mDNS, TCP Transport, Connection Pooling
- └── :core:ui # Material 3 Components, Theme, Haptics
- ├── :feature:home # Recent chats screen
- ├── :feature:chat # Chat conversation screen
- ├── :feature:discovery # Device discovery
- └── :feature:settings # Settings & customization
-```
-
-### Module Dependency Rules
-
-```
-:app → :feature:* → :core:*
-:feature:* → :core:* (NEVER other feature modules)
-:core:* → :core:domain (domain has ZERO dependencies)
-```
-
-| Module | Responsibility |
-|--------|---------------|
-| `:core:domain` | Pure Kotlin — repository interfaces, domain models, use cases |
-| `:core:data` | Room database (v7), DataStore preferences, repository implementations |
-| `:core:network` | mDNS/NSD discovery, LAN TCP sockets, connection pooling, health monitoring |
-| `:core:ui` | Material 3 components, theming, haptics, shared composables |
-| `:feature:*` | Screen-level UI, ViewModels, navigation |
+Full, source-accurate documentation lives in [`docs/`](docs/README.md) — covering the architecture, every `core:*` module, each `feature:*` module, and the `:app` aggregator.
---
-## 🛠️ Tech Stack
-
-| Category | Technology | Version |
-|----------|-----------|---------|
-| **Language** | Kotlin | 2.3.10 |
-| **UI** | Jetpack Compose | 2026.02.00 (BOM) |
-| **Design** | Material 3 Expressive | 1.4.0-alpha10 |
-| **Database** | Room | 2.8.4 |
-| **Preferences** | DataStore | 1.1.1 |
-| **Pagination** | Paging 3 | 3.3.5 |
-| **Navigation** | Jetpack Navigation | 2.9.7 |
-| **DI** | Hilt | 2.59 |
-| **Media** | Media3 | 1.8.0 |
-| **Images** | Coil 3 | 3.4.0 |
-| **Testing** | JUnit 4, MockK, Turbine, Robolectric, Espresso | — |
-
-### Build Configuration
-
-| Setting | Value |
-|---------|-------|
-| **AGP** | 9.1.0 |
-| **Min SDK** | 26 (Android 8.0) |
-| **Target SDK** | 35 (Android 15) |
-| **JDK** | 21 |
-| **Gradle JVM Args** | `-Xmx4096m` |
-
----
-
-## 🚦 Getting Started
-
-### Prerequisites
-
-- **JDK 21** (recommended via SDKMAN)
-- **Android SDK** (API 26+)
-- **Git**
-
-### Build from Source
-
-```bash
-# Clone the repository
-git clone https://github.com/Yussefgafer/Meshify.git
-cd Meshify
-
-# Clean build
-./gradlew clean
-
-# Build debug APK
-./gradlew assembleDebug
-
-# Build release APK (requires signing config)
-./gradlew assembleRelease
-```
-
-**Output APKs:**
-- Debug: `app/build/outputs/apk/debug/app-debug.apk`
-- Release: `app/build/outputs/apk/release/app-release.apk`
-
-### Install on Device
-
-```bash
-adb install -r app/build/outputs/apk/debug/app-debug.apk
-```
-
### Usage
1. Connect two or more Android devices to the **same WiFi network**
2. Launch Meshify on each device
-3. Peers appear automatically via mDNS discovery
-4. Tap a peer to start messaging
+3. Grant required permissions on first launch (onboarding flow)
+4. Peers appear automatically via mDNS discovery
+5. Tap a peer to start messaging
> **Note:** Meshify operates on **local networks only**. No internet connection required.
-
----
-
-## 🧪 Testing
-
-```bash
-# Run all unit tests
-./gradlew testDebugUnitTest
-
-# Run instrumented tests
-./gradlew connectedAndroidTest
-
-# Lint check
-./gradlew lint
-```
-
----
-
-## 📸 Screenshots
-
-
- Screenshots coming soon...
-
-
----
-
-## 🗺️ Roadmap
-
-- [ ] **BLE Transport**: Bluetooth Low Energy for proximity-based messaging
-- [ ] **Group Messaging**: Multi-peer conversations
-- [ ] **Voice Messages**: Audio recording and playback
-- [ ] **Custom Themes**: User-selectable color palettes beyond Material You
-- [ ] **Message Search**: Full-text search across conversations
-- [ ] **Export/Import Backup**: Local chat backup and restore
-
----
-
-## 📄 License
-
-Distributed under the **MIT License**. See [LICENSE](LICENSE) for details.
-
----
-
-## 🤝 Contributing
-
-Meshify is a **personal project** built for learning and experimentation. While pull requests are welcome, please note that this repository is primarily maintained by a single developer.
-
-### How to Contribute
-
-1. **Fork** the repository
-2. **Create a feature branch** (`git checkout -b feature/amazing-feature`)
-3. **Commit your changes** with clear messages
-4. **Push** to your branch (`git push origin feature/amazing-feature`)
-5. **Open a Pull Request**
-
-### Guidelines
-
-- Follow **Clean Architecture** principles
-- Maintain **module dependency boundaries**
-- Write **KDoc comments** for public APIs
-- Ensure **all tests pass** before submitting a PR
-
----
-
-## 📬 Contact
-
-**Yussef Gafer** — Project Maintainer
-
-- GitHub: [@Yussefgafer](https://github.com/Yussefgafer)
-- Project Link: [https://github.com/Yussefgafer/Meshify](https://github.com/Yussefgafer/Meshify)
-
----
-
-## 🤖 AI-Assisted Development
-
-> This codebase was designed and implemented using **LLM (Qwen)** under the strategic direction of **Yussef Gafer**. AI tools were used for code generation, architecture planning, and testing — with human oversight for every decision.
-
-
-
-
-
----
-
-
- Built with ♥ for offline-first, decentralized communication.
-
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 7caa4e21..a60e95ef 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -1,6 +1,3 @@
-import com.android.build.api.dsl.ApplicationExtension
-import java.util.Properties
-
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
@@ -12,20 +9,22 @@ plugins {
android {
namespace = "com.p2p.meshify"
- compileSdk = 36
+ compileSdk = 37
defaultConfig {
applicationId = "com.p2p.meshify"
minSdk = 26
targetSdk = 36
- versionCode = 12
- versionName = "1.1.1"
+ versionCode = 13
+ versionName = "1.1.3"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
ndk {
abiFilters += listOf("arm64-v8a")
}
+
+ resConfigs("en", "ar")
}
signingConfigs {
@@ -139,7 +138,6 @@ dependencies {
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
- implementation(libs.google.material)
implementation(libs.androidx.material.icons.extended)
// Navigation
@@ -159,9 +157,6 @@ dependencies {
implementation(libs.media3.ui)
implementation(libs.media3.session)
- // Accompanist
- implementation(libs.accompanist.permissions)
-
// Testing
testImplementation(libs.junit)
testImplementation(libs.mockk)
diff --git a/app/src/androidTest/java/com/p2p/meshify/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/p2p/meshify/ExampleInstrumentedTest.kt
deleted file mode 100644
index 772f0c87..00000000
--- a/app/src/androidTest/java/com/p2p/meshify/ExampleInstrumentedTest.kt
+++ /dev/null
@@ -1,24 +0,0 @@
-package com.p2p.meshify
-
-import androidx.test.platform.app.InstrumentationRegistry
-import androidx.test.ext.junit.runners.AndroidJUnit4
-
-import org.junit.Test
-import org.junit.runner.RunWith
-
-import org.junit.Assert.*
-
-/**
- * Instrumented test, which will execute on an Android device.
- *
- * See [testing documentation](http://d.android.com/tools/testing).
- */
-@RunWith(AndroidJUnit4::class)
-class ExampleInstrumentedTest {
- @Test
- fun useAppContext() {
- // Context of the app under test.
- val appContext = InstrumentationRegistry.getInstrumentation().targetContext
- assertEquals("com.p2p.meshify", appContext.packageName)
- }
-}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 4c82ddd8..2d72d1f8 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -25,8 +25,7 @@
-
-
+
diff --git a/app/src/main/java/com/p2p/meshify/MainActivity.kt b/app/src/main/java/com/p2p/meshify/MainActivity.kt
index f4f70e96..7f6bb147 100644
--- a/app/src/main/java/com/p2p/meshify/MainActivity.kt
+++ b/app/src/main/java/com/p2p/meshify/MainActivity.kt
@@ -161,7 +161,6 @@ class MainActivity : ComponentActivity() {
val settingsRepo = app.settingsRepository
val themeMode by settingsRepo.themeMode.collectAsState(initial = com.p2p.meshify.domain.repository.ThemeMode.SYSTEM)
val dynamicColor by settingsRepo.dynamicColorEnabled.collectAsState(initial = true)
- val seedColorInt by settingsRepo.seedColor.collectAsState(initial = 0xFF006D68.toInt())
var isReady by remember { mutableStateOf(false) }
var startDestination by remember { mutableStateOf(null) }
@@ -184,13 +183,11 @@ class MainActivity : ComponentActivity() {
}
}
- val seedColor = remember(seedColorInt) { Color(seedColorInt) }
val premiumHaptics = rememberPremiumHaptics(settingsRepo)
MeshifyTheme(
themeMode = themeMode.name,
- dynamicColor = dynamicColor,
- seedColor = seedColor
+ dynamicColor = dynamicColor
) {
CompositionLocalProvider(LocalPremiumHaptics provides premiumHaptics) {
val context = LocalContext.current
@@ -495,8 +492,10 @@ private fun OnboardingRoute(
if (alreadyGranted) {
LaunchedEffect(perm.id) {
- permissionResults[perm.id] = PermissionRequestResult.Granted
+ permissionResults[perm.id] = PermissionRequestResult.AlreadyGranted
advanceTrigger++
+ kotlinx.coroutines.delay(PERMISSION_ALREADY_GRANTED_DISPLAY_DELAY_MS)
+ currentPermissionIndex++
}
} else {
PermissionRequestCard(
@@ -534,7 +533,9 @@ private fun OnboardingRoute(
// Show summary when all permissions processed
if (showSummaryDialog) {
- val grantedCount = permissionResults.count { it.value == PermissionRequestResult.Granted }
+ val grantedCount = permissionResults.count {
+ it.value == PermissionRequestResult.Granted || it.value == PermissionRequestResult.AlreadyGranted
+ }
PermissionSummaryDialog(
grantedCount = grantedCount,
totalCount = permissions.size,
@@ -558,6 +559,13 @@ private fun OnboardingRoute(
onLeaveClick = {
showSkipConfirm = false
isPermissionFlowActive = false
+ // Mark all remaining unprocessed permissions as Skipped
+ for (i in currentPermissionIndex until permissions.size) {
+ val perm = permissions[i]
+ if (perm.id !in permissionResults) {
+ permissionResults[perm.id] = PermissionRequestResult.Skipped
+ }
+ }
scope.launch {
settingsRepository.setOnboardingCompleted()
}
diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml
index 40e5f4d2..cc2ea987 100644
--- a/app/src/main/res/values-ar/strings.xml
+++ b/app/src/main/res/values-ar/strings.xml
@@ -113,15 +113,6 @@
تم اختيار ملف الخط: %s
لم يتم رفع خط مخصص
-
- رجوع
- إلغاء
- حذف
- إرسال
- إرفاق ملف
- الإعدادات
- الاكتشاف
-
إعادة محاولة
@@ -145,60 +136,4 @@
[⚠️ فشل فك التشفير — خطأ أمني]
[⚠️ فشل معالجة الرسالة]
-
- %d متصل
- شغّل البلوتوث من إعدادات النظام
- تم رفض الإذن
- إصلاح
- منح
- البلوتوث غير مدعوم على هذا الجهاز
-
-
- %d متصل
- الإعلان: %s
- المسح: %s
- MTU: %d بايت
- لم يتم العثور على أجهزة قريبة عبر البلوتوث. قرب الأجهزة ضمن نطاق 10 أمتار وحاول مرة أخرى.
- الأجهزة المتصلة
- مكتشفة (غير متصلة)
- نعم
- لا
-
-
- الشبكة المحلية + البلوتوث في آن واحد
- واي فاي / إيثرنت فقط
- بلوتوث قصير المدى فقط
- يختار النظام الأفضل المتاح
-
-
- إشارة ممتازة
- إشارة جيدة
- إشارة ضعيفة
- لا توجد إشارة
-
-
- فشل البلوتوث، أُرسلت عبر الواي فاي
-
-
- يتيح لك البلوتوث مراسلة الأجهزة القريبة بدون واي فاي.
- أذونات البلوتوث
- الأجهزة القريبة (مسح)
- الاتصال بالأجهزة المقترنة
- اجعل جهازك قابلاً للاكتشاف
- السماح بالبلوتوث
- تم رفض إذن البلوتوث. سيكون اكتشاف الأجهزة القريبة محدوداً.
- تم حظر الإعلان بواسطة تطبيق آخر
- فحص التطبيقات
- لا توجد أجهزة قريبة
- أوقف تشغيل البلوتوث لتوفير البطارية
- إيقاف التشغيل
-
-
- نسيان هذا الجهاز
- إيقاف الاتصال التلقائي بـ %s؟
- نسيان
-
-
- أيقونة البلوتوث
- مؤشر قوة الإشارة
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
deleted file mode 100644
index f8c6127d..00000000
--- a/app/src/main/res/values/colors.xml
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
- #FFBB86FC
- #FF6200EE
- #FF3700B3
- #FF03DAC5
- #FF018786
- #FF000000
- #FFFFFFFF
-
\ No newline at end of file
diff --git a/app/src/main/res/values/font_certs.xml b/app/src/main/res/values/font_certs.xml
deleted file mode 100644
index ed4230e6..00000000
--- a/app/src/main/res/values/font_certs.xml
+++ /dev/null
@@ -1,17 +0,0 @@
-
-
-
- - @array/com_google_android_gms_fonts_certs_dev
- - @array/com_google_android_gms_fonts_certs_prod
-
-
- -
- MIIEqDCCA5CgAwIBAgIJANWFu9q9H7AI...
-
-
-
- -
- MIIEqDCCA5CgAwIBAgIJANWFu9q9H7AI...
-
-
-
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 6d976915..045e125f 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,58 +1,3 @@
+
-
- %d peers connected
- Turn on Bluetooth in system settings
- Permission denied
- Fix it
- Grant
- Bluetooth not supported on this device
-
-
- %d peers connected
- Advertising: %s
- Scanning: %s
- MTU: %d bytes
- No nearby devices found via Bluetooth. Bring devices within 10 meters and try again.
- Connected Peers
- Discovered (not connected)
- Yes
- No
-
-
- LAN + Bluetooth simultaneously
- Wi-Fi / Ethernet only
- Short-range Bluetooth only
- System picks best available
-
-
- Excellent Signal
- Good Signal
- Weak Signal
- No Signal
-
-
- Bluetooth failed, sent via Wi-Fi
-
-
- Bluetooth lets you message nearby devices without Wi-Fi.
- Bluetooth Permissions
- Nearby devices (scan)
- Connect to paired devices
- Make your device discoverable
- Allow Bluetooth
- Bluetooth permission denied. Nearby device discovery will be limited.
- Advertising blocked by another app
- Check apps
- No devices nearby
- Turn off Bluetooth to save battery
- Turn Off
-
-
- Forget this device
- Stop auto-connecting to %s?
- Forget
-
-
- Bluetooth icon
- Signal strength indicator
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
index 2f1e9209..53ebf0cc 100644
--- a/app/src/main/res/values/themes.xml
+++ b/app/src/main/res/values/themes.xml
@@ -1,7 +1,7 @@
-
diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts
index 02c8a016..bc9f55c3 100644
--- a/core/common/build.gradle.kts
+++ b/core/common/build.gradle.kts
@@ -5,7 +5,7 @@ plugins {
android {
namespace = "com.p2p.meshify.core.common"
- compileSdk = 36
+ compileSdk = 37
defaultConfig {
minSdk = 26
@@ -19,8 +19,6 @@ android {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
-
- sourceSets["main"].res.srcDirs("src/main/res")
}
kotlin {
diff --git a/core/common/src/main/java/com/p2p/meshify/core/common/util/ImageCompressor.kt b/core/common/src/main/java/com/p2p/meshify/core/common/util/ImageCompressor.kt
index ee3f04f6..c3085bf8 100644
--- a/core/common/src/main/java/com/p2p/meshify/core/common/util/ImageCompressor.kt
+++ b/core/common/src/main/java/com/p2p/meshify/core/common/util/ImageCompressor.kt
@@ -177,6 +177,7 @@ object ImageCompressor {
Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
} catch (e: Exception) {
+ Logger.e("ImageCompressor -> Rotation failed", e)
bitmap
}
}
diff --git a/core/common/src/main/java/com/p2p/meshify/core/common/util/ParallelFileTransfer.kt b/core/common/src/main/java/com/p2p/meshify/core/common/util/ParallelFileTransfer.kt
index 0fdd05cc..494bf33e 100644
--- a/core/common/src/main/java/com/p2p/meshify/core/common/util/ParallelFileTransfer.kt
+++ b/core/common/src/main/java/com/p2p/meshify/core/common/util/ParallelFileTransfer.kt
@@ -1,5 +1,6 @@
package com.p2p.meshify.core.util
+import com.p2p.meshify.core.util.Logger
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
@@ -92,7 +93,7 @@ object ParallelFileTransfer {
listener?.onProgress(totalSent, fileSize.toLong(), (totalSent.toDouble() / fileSize) * 100)
Result.success(Unit)
} catch (e: Exception) {
- Logger.e("ParallelFileTransfer -> Chunk $index failed", e)
+ Logger.e("ParallelFileTransfer -> Chunk $index failed: ${e.message}", e)
failedChunks.add(index) // Track for retry
Result.failure(e)
}
@@ -127,7 +128,7 @@ object ParallelFileTransfer {
listener?.onProgress(totalSent, fileSize.toLong(), (totalSent.toDouble() / fileSize) * 100)
Result.success(Unit)
} catch (e: Exception) {
- Logger.e("ParallelFileTransfer -> Retry chunk $index failed", e)
+ Logger.e("ParallelFileTransfer -> Retry chunk $index failed after retry: ${e.message}", e)
Result.failure(e)
}
}
@@ -144,7 +145,8 @@ object ParallelFileTransfer {
// التحقق من نجاح جميع chunks بعد retry
val failedCount = sendResults.count { it.isFailure }
if (failedCount > 0) {
- return@withContext Result.failure(Exception("$failedCount chunks failed after retry"))
+ val failedIndices = sendResults.indices.filter { sendResults[it].isFailure }
+ return@withContext Result.failure(Exception("$failedCount chunks failed after retry: indices $failedIndices"))
}
// Send completion marker
@@ -153,6 +155,7 @@ object ParallelFileTransfer {
Result.success(Unit)
} catch (e: Exception) {
+ Logger.e("ParallelFileTransfer -> sendFile failed: ${e.message}", e)
return@withContext Result.failure(e)
}
}
@@ -218,6 +221,7 @@ object ParallelFileTransfer {
Result.success(fileBytes)
} catch (e: Exception) {
+ Logger.e("ParallelFileTransfer -> receiveFile failed: ${e.message}", e)
Result.failure(e)
}
}
diff --git a/core/common/src/main/java/com/p2p/meshify/core/util/PayloadSerializer.kt b/core/common/src/main/java/com/p2p/meshify/core/util/PayloadSerializer.kt
index 038b1b7e..6fc8603a 100644
--- a/core/common/src/main/java/com/p2p/meshify/core/util/PayloadSerializer.kt
+++ b/core/common/src/main/java/com/p2p/meshify/core/util/PayloadSerializer.kt
@@ -1,7 +1,6 @@
package com.p2p.meshify.core.util
import com.p2p.meshify.domain.model.Payload
-import com.p2p.meshify.domain.model.toPayloadType
import java.nio.BufferUnderflowException
import java.nio.ByteBuffer
import java.nio.charset.StandardCharsets
@@ -148,7 +147,7 @@ object PayloadSerializer {
Logger.w("Invalid UTF-8 type name, using default charset", tag = TAG)
String(typeBytes) // Fallback to default charset
}
- typeName.toPayloadType() ?: Payload.PayloadType.SYSTEM_CONTROL
+ try { Payload.PayloadType.valueOf(typeName) } catch (e: IllegalArgumentException) { Payload.PayloadType.SYSTEM_CONTROL }
}
else -> {
// Unknown version: fail safely
diff --git a/core/common/src/main/res/values-ar/strings.xml b/core/common/src/main/res/values-ar/strings.xml
index bf596918..5748462e 100644
--- a/core/common/src/main/res/values-ar/strings.xml
+++ b/core/common/src/main/res/values-ar/strings.xml
@@ -67,7 +67,7 @@
متصل
غير متصل
تم حذف هذه الرسالة
- أنت: %s
+ أنت
ملف الوسائط غير موجود
ملف:
@@ -103,6 +103,8 @@
جارٍ الرد على
جارٍ الرد على…
+ وسائط
+ الرسالة غير متوفرة
رد
@@ -213,6 +215,8 @@
إعادة توجيه (%d)
إعادة محاولة
تعذر استقبال الرسالة من %1$d من %2$d أجهزة
+ تم إعادة توجيه %1$d رسالة إلى %2$d جهاز
+ تم نسخ %d رسالة
مُعاد الإرسال من %1$s:\n%2$s
diff --git a/core/common/src/main/res/values/strings.xml b/core/common/src/main/res/values/strings.xml
index 460b8b13..fc9cdf99 100644
--- a/core/common/src/main/res/values/strings.xml
+++ b/core/common/src/main/res/values/strings.xml
@@ -64,7 +64,7 @@
Online
Offline
This message was deleted
- You: %s
+ You
Clear All Data?
@@ -96,6 +96,8 @@
Replying to
Replying to…
+ Media
+ Message unavailable
Reply
@@ -110,6 +112,7 @@
- %d selected
- %d selected
+ Copied %d message(s)
Select more or take action
Exit selection mode
Copy
@@ -199,6 +202,7 @@
Forward (%d)
Retry
%1$d of %2$d peers could not receive the message
+ Forwarded %1$d message(s) to %2$d peer(s)
Forwarded from %1$s:\n%2$s
diff --git a/core/common/src/test/java/com/p2p/meshify/core/common/preflight/ConnectivityCheckerTest.kt b/core/common/src/test/java/com/p2p/meshify/core/common/preflight/ConnectivityCheckerTest.kt
deleted file mode 100644
index b279be9e..00000000
--- a/core/common/src/test/java/com/p2p/meshify/core/common/preflight/ConnectivityCheckerTest.kt
+++ /dev/null
@@ -1,329 +0,0 @@
-package com.p2p.meshify.core.common.preflight
-
-import android.content.Context
-import android.net.ConnectivityManager
-import android.net.Network
-import android.net.NetworkCapabilities
-import android.net.wifi.WifiManager
-import io.mockk.every
-import io.mockk.mockk
-
-import org.junit.Assert.*
-import org.junit.Test
-import org.junit.runner.RunWith
-import org.robolectric.RobolectricTestRunner
-import org.robolectric.annotation.Config
-
-/**
- * Unit tests for ConnectivityChecker.
- */
-@RunWith(RobolectricTestRunner::class)
-@Config(sdk = [34])
-class ConnectivityCheckerTest {
-
- private val testPort = 9999
-
- // ============================================================================
- // SECTION 1: ConnectvityResult DATA CLASS — allPassed
- // ============================================================================
-
- @Test
- fun `allPassed returns true when all conditions are met`() {
- val result = makePassingResult()
- assertTrue(result.allPassed)
- }
-
- @Test
- fun `allPassed returns false when wifiEnabled is false`() {
- assertFalse(makePassingResult().copy(wifiEnabled = false).allPassed)
- }
-
- @Test
- fun `allPassed returns false when wifiConnected is false`() {
- assertFalse(makePassingResult().copy(wifiConnected = false).allPassed)
- }
-
- @Test
- fun `allPassed returns false when hasIpAddress is false`() {
- assertFalse(makePassingResult().copy(hasIpAddress = false).allPassed)
- }
-
- @Test
- fun `allPassed returns false when canReachLocalPort is false`() {
- assertFalse(makePassingResult().copy(canReachLocalPort = false).allPassed)
- }
-
- // ============================================================================
- // SECTION 2: ConnectivityResult DATA CLASS — isOnLocalSubnet
- // ============================================================================
-
- @Test
- fun `isOnLocalSubnet returns true for 192 168 x x`() {
- assertTrue(makeResult(ip = "192.168.1.5").isOnLocalSubnet)
- }
-
- @Test
- fun `isOnLocalSubnet returns true for 10 x x x`() {
- assertTrue(makeResult(ip = "10.0.0.42").isOnLocalSubnet)
- }
-
- @Test
- fun `isOnLocalSubnet returns true for 172 x x x`() {
- assertTrue(makeResult(ip = "172.16.0.1").isOnLocalSubnet)
- }
-
- @Test
- fun `isOnLocalSubnet returns false for public IP`() {
- assertFalse(makeResult(ip = "8.8.8.8").isOnLocalSubnet)
- }
-
- @Test
- fun `isOnLocalSubnet returns false when ipAddress is null`() {
- val result = ConnectivityResult(
- wifiEnabled = false, wifiConnected = false,
- hasIpAddress = false, ipAddress = null, canReachLocalPort = false
- )
- assertFalse(result.isOnLocalSubnet)
- }
-
- @Test
- fun `isOnLocalSubnet handles edge case IPs`() {
- assertTrue(makeResult(ip = "192.168.0.1").isOnLocalSubnet)
- assertTrue(makeResult(ip = "192.168.255.255").isOnLocalSubnet)
- assertTrue(makeResult(ip = "10.0.0.0").isOnLocalSubnet)
- assertTrue(makeResult(ip = "10.255.255.255").isOnLocalSubnet)
- assertTrue(makeResult(ip = "172.0.0.1").isOnLocalSubnet)
- assertTrue(makeResult(ip = "172.255.255.255").isOnLocalSubnet)
- assertFalse(makeResult(ip = "173.0.0.1").isOnLocalSubnet)
- assertFalse(makeResult(ip = "11.0.0.1").isOnLocalSubnet)
- assertFalse(makeResult(ip = "1.2.3.4").isOnLocalSubnet)
- }
-
- @Test
- fun `isOnLocalSubnet with empty string ip`() {
- val result = makeResult(ip = "")
- assertFalse(result.isOnLocalSubnet)
- }
-
- // ============================================================================
- // SECTION 3: ConnectivityResult DATA CLASS — properties
- // ============================================================================
-
- @Test
- fun `ConnectivityResult stores all properties correctly`() {
- val issues = listOf("WiFi is disabled", "Not connected")
- val result = ConnectivityResult(
- wifiEnabled = false, wifiConnected = false,
- hasIpAddress = true, ipAddress = "192.168.1.5",
- canReachLocalPort = true, issues = issues
- )
- assertEquals(false, result.wifiEnabled)
- assertEquals(false, result.wifiConnected)
- assertEquals(true, result.hasIpAddress)
- assertEquals("192.168.1.5", result.ipAddress)
- assertEquals(true, result.canReachLocalPort)
- assertEquals(issues, result.issues)
- }
-
- @Test
- fun `ConnectivityResult default issues is empty`() {
- val result = makePassingResult()
- assertTrue(result.issues.isEmpty())
- }
-
- @Test
- fun `ConnectivityResult copy preserves unmodified fields`() {
- val original = makePassingResult()
- val modified = original.copy(wifiEnabled = false)
- assertFalse(modified.wifiEnabled)
- assertTrue(modified.wifiConnected)
- assertEquals("192.168.1.5", modified.ipAddress)
- assertTrue(original.wifiEnabled)
- }
-
- // ============================================================================
- // SECTION 4: PRODUCTION CODE — checkConnectivity with mocked services
- // ============================================================================
-
- @Test
- fun `checkConnectivity returns issues when WiFi disabled`() {
- // Given
- val checker = createChecker(wifiEnabled = false, hasActiveNetwork = true)
-
- // When
- val result = checker.checkConnectivity()
-
- // Then
- assertFalse(result.allPassed)
- assertFalse(result.wifiEnabled)
- assertTrue(result.issues.any { it.contains("disabled") })
- }
-
- @Test
- fun `checkConnectivity returns issues when no active network`() {
- // Given
- val checker = createChecker(wifiEnabled = true, hasActiveNetwork = false)
-
- // When
- val result = checker.checkConnectivity()
-
- // Then
- assertFalse(result.allPassed)
- assertFalse(result.wifiConnected)
- assertTrue(result.issues.any { it.contains("Not connected") })
- }
-
- @Test
- fun `checkConnectivity with active network lacking WiFi transport`() {
- // Given
- val checker = createChecker(wifiEnabled = true, hasActiveNetwork = true, hasWifiTransport = false)
-
- // When
- val result = checker.checkConnectivity()
-
- // Then
- assertFalse(result.allPassed)
- assertFalse(result.wifiConnected)
- }
-
- @Test
- fun `checkConnectivity with active network lacking internet capability`() {
- // Given
- val checker = createChecker(wifiEnabled = true, hasActiveNetwork = true, hasInternetCapability = false)
-
- // When
- val result = checker.checkConnectivity()
-
- // Then
- assertFalse(result.allPassed)
- assertFalse(result.wifiConnected)
- }
-
- @Test
- fun `checkConnectivity with all conditions met`() {
- // Given
- val checker = createChecker(wifiEnabled = true, hasActiveNetwork = true)
-
- // When
- val result = checker.checkConnectivity()
-
- // Then
- // Note: hasIpAddress depends on the test environment (real NetworkInterface)
- // Only test the checks we can control
- assertTrue("WiFi should be enabled", result.wifiEnabled)
- assertTrue("WiFi should be connected", result.wifiConnected)
- assertTrue("Local port should be reachable (ConnectException handled)", result.canReachLocalPort)
- }
-
- @Test
- fun `checkConnectivity with all failures produces multiple issues`() {
- // Given
- val checker = createChecker(wifiEnabled = false, hasActiveNetwork = false)
-
- // When
- val result = checker.checkConnectivity()
-
- // Then
- assertFalse(result.allPassed)
- assertTrue("Should have issues for each failure", result.issues.size >= 2)
- assertTrue(result.issues.any { it.contains("WiFi is disabled") })
- assertTrue(result.issues.any { it.contains("Not connected") })
- }
-
- @Test
- fun `checkConnectivity uses specified test port`() {
- // Given
- val customPort = 12345
- val context = createMockContext(wifiEnabled = true, hasActiveNetwork = true)
- val checker = ConnectivityChecker(context, testPort = customPort)
-
- // When
- val result = checker.checkConnectivity()
-
- // Then - should not crash, port gets connection refused which returns true
- assertTrue(result.canReachLocalPort)
- }
-
- @Test
- fun `checkConnectivity with null NetworkCapabilities doesn't crash`() {
- // Given
- val context = mockk()
- val wifiManager = mockk()
- val connectivityManager = mockk()
- val network = mockk()
-
- every { context.applicationContext } returns context
- every { context.getSystemService(Context.WIFI_SERVICE) } returns wifiManager
- every { context.getSystemService(Context.CONNECTIVITY_SERVICE) } returns connectivityManager
- every { wifiManager.isWifiEnabled } returns true
- every { connectivityManager.activeNetwork } returns network
- every { connectivityManager.getNetworkCapabilities(network) } returns null
-
- val checker = ConnectivityChecker(context, testPort = testPort)
-
- // When
- val result = checker.checkConnectivity()
-
- // Then
- assertFalse("Should fail when capabilities are null", result.wifiConnected)
- assertFalse(result.allPassed)
- }
-
- // ============================================================================
- // HELPERS
- // ============================================================================
-
- private fun createChecker(
- wifiEnabled: Boolean,
- hasActiveNetwork: Boolean,
- hasWifiTransport: Boolean = true,
- hasInternetCapability: Boolean = true
- ): ConnectivityChecker {
- val context = createMockContext(wifiEnabled, hasActiveNetwork, hasWifiTransport, hasInternetCapability)
- return ConnectivityChecker(context, testPort = testPort)
- }
-
- private fun createMockContext(
- wifiEnabled: Boolean,
- hasActiveNetwork: Boolean,
- hasWifiTransport: Boolean = true,
- hasInternetCapability: Boolean = true
- ): Context {
- val context = mockk()
- val wifiManager = mockk()
- val connectivityManager = mockk()
-
- every { context.applicationContext } returns context
- every { context.getSystemService(Context.WIFI_SERVICE) } returns wifiManager
- every { context.getSystemService(Context.CONNECTIVITY_SERVICE) } returns connectivityManager
- every { wifiManager.isWifiEnabled } returns wifiEnabled
-
- if (hasActiveNetwork) {
- val network = mockk()
- val capabilities = mockk()
-
- every { connectivityManager.activeNetwork } returns network
- every { connectivityManager.getNetworkCapabilities(network) } returns capabilities
- every { capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) } returns hasWifiTransport
- every { capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) } returns hasInternetCapability
- } else {
- every { connectivityManager.activeNetwork } returns null
- }
-
- return context
- }
-
- private fun makeResult(ip: String): ConnectivityResult {
- return ConnectivityResult(
- wifiEnabled = true, wifiConnected = true,
- hasIpAddress = true, ipAddress = ip, canReachLocalPort = true
- )
- }
-
- private fun makePassingResult(): ConnectivityResult {
- return ConnectivityResult(
- wifiEnabled = true, wifiConnected = true,
- hasIpAddress = true, ipAddress = "192.168.1.5", canReachLocalPort = true
- )
- }
-}
diff --git a/core/common/src/test/java/com/p2p/meshify/core/common/security/SimplePeerIdProviderTest.kt b/core/common/src/test/java/com/p2p/meshify/core/common/security/SimplePeerIdProviderTest.kt
deleted file mode 100644
index 71bc7cb4..00000000
--- a/core/common/src/test/java/com/p2p/meshify/core/common/security/SimplePeerIdProviderTest.kt
+++ /dev/null
@@ -1,224 +0,0 @@
-package com.p2p.meshify.core.common.security
-
-import org.junit.Assert.*
-import org.junit.Test
-import org.junit.runner.RunWith
-import org.robolectric.RobolectricTestRunner
-import org.robolectric.RuntimeEnvironment
-import java.util.UUID
-
-/**
- * Unit tests for SimplePeerIdProvider.
- * Tests cover first-launch UUID generation, persistence across calls,
- * reset behavior, and edge cases.
- */
-@RunWith(RobolectricTestRunner::class)
-class SimplePeerIdProviderTest {
-
- // ============================================================================
- // SECTION 1: FIRST LAUNCH (NO PREVIOUS ID)
- // ============================================================================
-
- @Test
- fun `getPeerId returns valid UUID string on first call`() {
- // Given
- val provider = SimplePeerIdProvider(RuntimeEnvironment.getApplication())
-
- // When
- val peerId = provider.getPeerId()
-
- // Then - should be a valid UUID string
- assertNotNull("Peer ID should not be null", peerId)
- assertTrue("Peer ID should be a valid UUID", isValidUuid(peerId))
- }
-
- @Test
- fun `getPeerId generates unique IDs across different providers`() {
- // Given
- val provider1 = SimplePeerIdProvider(RuntimeEnvironment.getApplication())
- val provider2 = SimplePeerIdProvider(RuntimeEnvironment.getApplication())
-
- // When
- val id1 = provider1.getPeerId()
- val id2 = provider2.getPeerId()
-
- // Then - different instances should have different IDs
- // (they share the same SharedPreferences, so second instance gets the same ID)
- assertEquals("Same shared prefs should return same ID", id1, id2)
- }
-
- // ============================================================================
- // SECTION 2: PERSISTENCE ACROSS CALLS
- // ============================================================================
-
- @Test
- fun `getPeerId returns same ID on subsequent calls`() {
- // Given
- val provider = SimplePeerIdProvider(RuntimeEnvironment.getApplication())
-
- // When
- val firstId = provider.getPeerId()
- val secondId = provider.getPeerId()
- val thirdId = provider.getPeerId()
-
- // Then
- assertEquals("Second call should return same ID", firstId, secondId)
- assertEquals("Third call should return same ID", firstId, thirdId)
- }
-
- @Test
- fun `getPeerId persists across provider instances`() {
- // Given - first provider
- val provider1 = SimplePeerIdProvider(RuntimeEnvironment.getApplication())
- val id1 = provider1.getPeerId()
-
- // When - create a new provider instance (same SharedPreferences)
- val provider2 = SimplePeerIdProvider(RuntimeEnvironment.getApplication())
- val id2 = provider2.getPeerId()
-
- // Then - should retrieve the same persisted ID
- assertEquals("ID should persist across provider instances", id1, id2)
- }
-
- @Test
- fun `getPeerId is stable across multiple provider re-creations`() {
- // Given
- val app = RuntimeEnvironment.getApplication()
- val firstId = SimplePeerIdProvider(app).getPeerId()
-
- // When - create and call many times
- val ids = (1..10).map {
- SimplePeerIdProvider(app).getPeerId()
- }
-
- // Then - all should return the same ID
- ids.forEach { id ->
- assertEquals("All instances should return same persisted ID", firstId, id)
- }
- }
-
- // ============================================================================
- // SECTION 3: RESET BEHAVIOR
- // ============================================================================
-
- @Test
- fun `resetPeerId causes next getPeerId to return a new ID`() {
- // Given
- val provider = SimplePeerIdProvider(RuntimeEnvironment.getApplication())
- val originalId = provider.getPeerId()
-
- // When
- provider.resetPeerId()
- val newId = provider.getPeerId()
-
- // Then
- assertNotNull("New ID should not be null", newId)
- assertTrue("New ID should be a valid UUID", isValidUuid(newId))
- assertNotEquals("New ID should differ from original", originalId, newId)
- }
-
- @Test
- fun `resetPeerId followed by multiple calls returns stable new ID`() {
- // Given
- val provider = SimplePeerIdProvider(RuntimeEnvironment.getApplication())
- val originalId = provider.getPeerId()
-
- // When
- provider.resetPeerId()
- val newId1 = provider.getPeerId()
- val newId2 = provider.getPeerId()
- val newId3 = provider.getPeerId()
-
- // Then - after reset, the new ID should be stable
- assertEquals("New ID should be stable after first post-reset call", newId1, newId2)
- assertEquals("New ID should be stable across calls", newId1, newId3)
- assertNotEquals("New ID should differ from original", originalId, newId1)
- }
-
- @Test
- fun `resetPeerId before first getPeerId generates new ID on first call`() {
- // Given
- val provider = SimplePeerIdProvider(RuntimeEnvironment.getApplication())
-
- // When - reset before ever calling getPeerId
- provider.resetPeerId()
- val firstId = provider.getPeerId()
-
- // Then
- assertNotNull("Should still generate a valid ID", firstId)
- assertTrue("Should be a valid UUID", isValidUuid(firstId))
- }
-
- @Test
- fun `multiple resets generate unique IDs each time`() {
- // Given
- val provider = SimplePeerIdProvider(RuntimeEnvironment.getApplication())
- val ids = mutableSetOf()
-
- // When - reset multiple times
- repeat(5) {
- provider.resetPeerId()
- val newId = provider.getPeerId()
- ids.add(newId)
- }
-
- // Then - all reset IDs should be unique
- assertEquals("Each reset should produce a unique ID", 5, ids.size)
- }
-
- // ============================================================================
- // SECTION 4: EDGE CASES
- // ============================================================================
-
- @Test
- fun `getPeerId returns non-empty string`() {
- // Given
- val provider = SimplePeerIdProvider(RuntimeEnvironment.getApplication())
-
- // When
- val peerId = provider.getPeerId()
-
- // Then
- assertNotNull(peerId)
- assertTrue("Peer ID should not be empty", peerId.isNotEmpty())
- }
-
- @Test
- fun `getPeerId format matches UUID pattern`() {
- // Given
- val provider = SimplePeerIdProvider(RuntimeEnvironment.getApplication())
-
- // When
- val peerId = provider.getPeerId()
-
- // Then - UUID format: 8-4-4-4-12 hex digits
- assertTrue(
- "Peer ID should match UUID format: $peerId",
- peerId.matches(Regex("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"))
- )
- }
-
- @Test
- fun `resetPeerId on fresh provider does not throw`() {
- // Given
- val provider = SimplePeerIdProvider(RuntimeEnvironment.getApplication())
-
- // When/Then - should not throw
- provider.resetPeerId()
- provider.resetPeerId()
- provider.resetPeerId()
- }
-
- // ============================================================================
- // SECTION 5: HELPER
- // ============================================================================
-
- private fun isValidUuid(str: String): Boolean {
- return try {
- UUID.fromString(str)
- true
- } catch (e: IllegalArgumentException) {
- false
- }
- }
-}
diff --git a/core/common/src/test/java/com/p2p/meshify/core/common/util/MimeTypeDetectorTest.kt b/core/common/src/test/java/com/p2p/meshify/core/common/util/MimeTypeDetectorTest.kt
deleted file mode 100644
index 463e241e..00000000
--- a/core/common/src/test/java/com/p2p/meshify/core/common/util/MimeTypeDetectorTest.kt
+++ /dev/null
@@ -1,464 +0,0 @@
-package com.p2p.meshify.core.util
-
-import org.junit.Assert.*
-import org.junit.Test
-import org.junit.runner.RunWith
-import org.robolectric.RobolectricTestRunner
-
-/**
- * Unit tests for MimeTypeDetector.
- * Tests cover known MIME types, unknown extensions, path parsing,
- * readable type names, and edge cases.
- */
-@RunWith(RobolectricTestRunner::class)
-class MimeTypeDetectorTest {
-
- // ============================================================================
- // SECTION 1: KNOWN IMAGE EXTENSIONS
- // ============================================================================
-
- @Test
- fun `getMimeTypeFromExtension returns image jpeg for jpg`() {
- // Given
- val extension = "jpg"
-
- // When
- val mime = MimeTypeDetector.getMimeTypeFromExtension(extension)
-
- // Then
- assertEquals("image/jpeg", mime)
- }
-
- @Test
- fun `getMimeTypeFromExtension returns image jpeg for jpeg`() {
- assertEquals("image/jpeg", MimeTypeDetector.getMimeTypeFromExtension("jpeg"))
- }
-
- @Test
- fun `getMimeTypeFromExtension returns image png for png`() {
- assertEquals("image/png", MimeTypeDetector.getMimeTypeFromExtension("png"))
- }
-
- @Test
- fun `getMimeTypeFromExtension returns image gif for gif`() {
- assertEquals("image/gif", MimeTypeDetector.getMimeTypeFromExtension("gif"))
- }
-
- @Test
- fun `getMimeTypeFromExtension returns image webp for webp`() {
- assertEquals("image/webp", MimeTypeDetector.getMimeTypeFromExtension("webp"))
- }
-
- @Test
- fun `getMimeTypeFromExtension returns image bmp for bmp`() {
- assertEquals("image/bmp", MimeTypeDetector.getMimeTypeFromExtension("bmp"))
- }
-
- @Test
- fun `getMimeTypeFromExtension returns image svg for svg`() {
- assertEquals("image/svg+xml", MimeTypeDetector.getMimeTypeFromExtension("svg"))
- }
-
- // ============================================================================
- // SECTION 2: KNOWN VIDEO EXTENSIONS
- // ============================================================================
-
- @Test
- fun `getMimeTypeFromExtension returns video mp4 for mp4`() {
- assertEquals("video/mp4", MimeTypeDetector.getMimeTypeFromExtension("mp4"))
- }
-
- @Test
- fun `getMimeTypeFromExtension returns video x-matroska for mkv`() {
- assertEquals("video/x-matroska", MimeTypeDetector.getMimeTypeFromExtension("mkv"))
- }
-
- @Test
- fun `getMimeTypeFromExtension returns video x-msvideo for avi`() {
- assertEquals("video/x-msvideo", MimeTypeDetector.getMimeTypeFromExtension("avi"))
- }
-
- @Test
- fun `getMimeTypeFromExtension returns video webm for webm`() {
- assertEquals("video/webm", MimeTypeDetector.getMimeTypeFromExtension("webm"))
- }
-
- @Test
- fun `getMimeTypeFromExtension returns video quicktime for mov`() {
- assertEquals("video/quicktime", MimeTypeDetector.getMimeTypeFromExtension("mov"))
- }
-
- // ============================================================================
- // SECTION 3: KNOWN AUDIO EXTENSIONS
- // ============================================================================
-
- @Test
- fun `getMimeTypeFromExtension returns audio mpeg for mp3`() {
- assertEquals("audio/mpeg", MimeTypeDetector.getMimeTypeFromExtension("mp3"))
- }
-
- @Test
- fun `getMimeTypeFromExtension returns audio wav for wav`() {
- assertEquals("audio/wav", MimeTypeDetector.getMimeTypeFromExtension("wav"))
- }
-
- @Test
- fun `getMimeTypeFromExtension returns audio aac for aac`() {
- assertEquals("audio/aac", MimeTypeDetector.getMimeTypeFromExtension("aac"))
- }
-
- @Test
- fun `getMimeTypeFromExtension returns audio flac for flac`() {
- assertEquals("audio/flac", MimeTypeDetector.getMimeTypeFromExtension("flac"))
- }
-
- @Test
- fun `getMimeTypeFromExtension returns audio ogg for ogg`() {
- assertEquals("audio/ogg", MimeTypeDetector.getMimeTypeFromExtension("ogg"))
- }
-
- // ============================================================================
- // SECTION 4: KNOWN DOCUMENT EXTENSIONS
- // ============================================================================
-
- @Test
- fun `getMimeTypeFromExtension returns application pdf for pdf`() {
- assertEquals("application/pdf", MimeTypeDetector.getMimeTypeFromExtension("pdf"))
- }
-
- @Test
- fun `getMimeTypeFromExtension returns application msword for doc`() {
- assertEquals("application/msword", MimeTypeDetector.getMimeTypeFromExtension("doc"))
- }
-
- @Test
- fun `getMimeTypeFromExtension returns application vnd openxmlformats for docx`() {
- assertEquals(
- "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
- MimeTypeDetector.getMimeTypeFromExtension("docx")
- )
- }
-
- @Test
- fun `getMimeTypeFromExtension returns application vnd ms-excel for xls`() {
- assertEquals("application/vnd.ms-excel", MimeTypeDetector.getMimeTypeFromExtension("xls"))
- }
-
- @Test
- fun `getMimeTypeFromExtension returns application vnd openxmlformats spreadsheet for xlsx`() {
- assertEquals(
- "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
- MimeTypeDetector.getMimeTypeFromExtension("xlsx")
- )
- }
-
- @Test
- fun `getMimeTypeFromExtension returns application vnd ms-powerpoint for ppt`() {
- assertEquals("application/vnd.ms-powerpoint", MimeTypeDetector.getMimeTypeFromExtension("ppt"))
- }
-
- @Test
- fun `getMimeTypeFromExtension returns application vnd openxmlformats presentation for pptx`() {
- assertEquals(
- "application/vnd.openxmlformats-officedocument.presentationml.presentation",
- MimeTypeDetector.getMimeTypeFromExtension("pptx")
- )
- }
-
- // ============================================================================
- // SECTION 5: KNOWN ARCHIVE EXTENSIONS
- // ============================================================================
-
- @Test
- fun `getMimeTypeFromExtension returns application zip for zip`() {
- assertEquals("application/zip", MimeTypeDetector.getMimeTypeFromExtension("zip"))
- }
-
- @Test
- fun `getMimeTypeFromExtension returns application vnd rar for rar`() {
- assertEquals("application/vnd.rar", MimeTypeDetector.getMimeTypeFromExtension("rar"))
- }
-
- @Test
- fun `getMimeTypeFromExtension returns application x-7z-compressed for 7z`() {
- assertEquals("application/x-7z-compressed", MimeTypeDetector.getMimeTypeFromExtension("7z"))
- }
-
- @Test
- fun `getMimeTypeFromExtension returns application x-tar for tar`() {
- assertEquals("application/x-tar", MimeTypeDetector.getMimeTypeFromExtension("tar"))
- }
-
- // ============================================================================
- // SECTION 6: APK AND TEXT
- // ============================================================================
-
- @Test
- fun `getMimeTypeFromExtension returns application vnd android package for apk`() {
- assertEquals(
- "application/vnd.android.package-archive",
- MimeTypeDetector.getMimeTypeFromExtension("apk")
- )
- }
-
- @Test
- fun `getMimeTypeFromExtension returns text plain for txt`() {
- assertEquals("text/plain", MimeTypeDetector.getMimeTypeFromExtension("txt"))
- }
-
- // ============================================================================
- // SECTION 7: UNKNOWN AND EDGE CASE EXTENSIONS
- // ============================================================================
-
- @Test
- fun `getMimeTypeFromExtension returns octet stream for unknown extension`() {
- // Given
- val unknown = "xyz123"
-
- // When
- val mime = MimeTypeDetector.getMimeTypeFromExtension(unknown)
-
- // Then
- assertEquals("application/octet-stream", mime)
- }
-
- @Test
- fun `getMimeTypeFromExtension handles empty extension`() {
- // Given
- val empty = ""
-
- // When
- val mime = MimeTypeDetector.getMimeTypeFromExtension(empty)
-
- // Then
- assertEquals("application/octet-stream", mime)
- }
-
- @Test
- fun `getMimeTypeFromExtension handles extension with leading dot`() {
- // Given
- val extension = ".jpg"
-
- // When
- val mime = MimeTypeDetector.getMimeTypeFromExtension(extension)
-
- // Then
- assertEquals("image/jpeg", mime)
- }
-
- @Test
- fun `getMimeTypeFromExtension is case insensitive`() {
- // When/Then
- assertEquals("image/jpeg", MimeTypeDetector.getMimeTypeFromExtension("JPG"))
- assertEquals("image/jpeg", MimeTypeDetector.getMimeTypeFromExtension("Jpg"))
- assertEquals("image/png", MimeTypeDetector.getMimeTypeFromExtension("PNG"))
- assertEquals("video/mp4", MimeTypeDetector.getMimeTypeFromExtension("MP4"))
- assertEquals("application/pdf", MimeTypeDetector.getMimeTypeFromExtension("PDF"))
- }
-
- @Test
- fun `getMimeTypeFromExtension handles extension with trailing spaces as unknown`() {
- assertEquals("application/octet-stream", MimeTypeDetector.getMimeTypeFromExtension("jpg "))
- }
-
- // ============================================================================
- // SECTION 8: PATH-BASED DETECTION
- // ============================================================================
-
- @Test
- fun `getMimeTypeFromPath extracts extension and returns correct MIME`() {
- // Given
- val path = "/storage/emulated/0/DCIM/photo.jpg"
-
- // When
- val mime = MimeTypeDetector.getMimeTypeFromPath(path)
-
- // Then
- assertEquals("image/jpeg", mime)
- }
-
- @Test
- fun `getMimeTypeFromPath handles file without extension`() {
- // Given
- val path = "/storage/emulated/0/file_without_extension"
-
- // When
- val mime = MimeTypeDetector.getMimeTypeFromPath(path)
-
- // Then
- assertEquals("application/octet-stream", mime)
- }
-
- @Test
- fun `getMimeTypeFromPath handles path with multiple dots`() {
- // Given
- val path = "/storage/downloads/archive.tar.gz"
-
- // When
- val mime = MimeTypeDetector.getMimeTypeFromPath(path)
-
- // Then - gets the last extension after final dot
- // "gz" falls through to octet-stream since it's not in the manual MIME map
- assertEquals("application/octet-stream", mime)
- }
-
- @Test
- fun `getMimeTypeFromPath handles empty path`() {
- // Given
- val path = ""
-
- // When
- val mime = MimeTypeDetector.getMimeTypeFromPath(path)
-
- // Then - empty path has no extension
- assertEquals("application/octet-stream", mime)
- }
-
- // ============================================================================
- // SECTION 9: EXTENSION FROM PATH
- // ============================================================================
-
- @Test
- fun `getExtensionFromPath extracts extension correctly`() {
- // Given
- val path = "/path/to/document.pdf"
-
- // When
- val ext = MimeTypeDetector.getExtensionFromPath(path)
-
- // Then
- assertEquals("pdf", ext)
- }
-
- @Test
- fun `getExtensionFromPath returns lowercase extension`() {
- // Given
- val path = "/path/to/Photo.JPG"
-
- // When
- val ext = MimeTypeDetector.getExtensionFromPath(path)
-
- // Then
- assertEquals("jpg", ext)
- }
-
- @Test
- fun `getExtensionFromPath returns empty for no extension`() {
- // Given
- val path = "/path/to/noext"
-
- // When
- val ext = MimeTypeDetector.getExtensionFromPath(path)
-
- // Then
- assertEquals("", ext)
- }
-
- // ============================================================================
- // SECTION 10: SUPPORTED TYPE CHECK
- // ============================================================================
-
- @Test
- fun `isSupportedType returns true for known extensions`() {
- // When/Then
- assertTrue("jpg should be supported", MimeTypeDetector.isSupportedType("jpg"))
- assertTrue("png should be supported", MimeTypeDetector.isSupportedType("png"))
- assertTrue("mp4 should be supported", MimeTypeDetector.isSupportedType("mp4"))
- assertTrue("mp3 should be supported", MimeTypeDetector.isSupportedType("mp3"))
- assertTrue("pdf should be supported", MimeTypeDetector.isSupportedType("pdf"))
- assertTrue("zip should be supported", MimeTypeDetector.isSupportedType("zip"))
- assertTrue("apk should be supported", MimeTypeDetector.isSupportedType("apk"))
- assertTrue("txt should be supported", MimeTypeDetector.isSupportedType("txt"))
- }
-
- @Test
- fun `isSupportedType returns false for unknown extensions`() {
- // When/Then
- assertFalse("xyz should not be supported", MimeTypeDetector.isSupportedType("xyz"))
- assertFalse("exe should not be supported", MimeTypeDetector.isSupportedType("exe"))
- assertFalse("dll should not be supported", MimeTypeDetector.isSupportedType("dll"))
- }
-
- @Test
- fun `isSupportedType handles empty extension`() {
- assertFalse("empty should not be supported", MimeTypeDetector.isSupportedType(""))
- }
-
- @Test
- fun `isSupportedType is case insensitive`() {
- assertTrue(MimeTypeDetector.isSupportedType("JPG"))
- assertTrue(MimeTypeDetector.isSupportedType("PDF"))
- assertTrue(MimeTypeDetector.isSupportedType("APK"))
- }
-
- // ============================================================================
- // SECTION 11: READABLE TYPE NAMES
- // ============================================================================
-
- @Test
- fun `getReadableTypeName returns Image for image extensions`() {
- assertEquals("Image", MimeTypeDetector.getReadableTypeName("jpg"))
- assertEquals("Image", MimeTypeDetector.getReadableTypeName("png"))
- assertEquals("Image", MimeTypeDetector.getReadableTypeName("gif"))
- assertEquals("Image", MimeTypeDetector.getReadableTypeName("webp"))
- assertEquals("Image", MimeTypeDetector.getReadableTypeName("bmp"))
- assertEquals("Image", MimeTypeDetector.getReadableTypeName("svg"))
- }
-
- @Test
- fun `getReadableTypeName returns Video for video extensions`() {
- assertEquals("Video", MimeTypeDetector.getReadableTypeName("mp4"))
- assertEquals("Video", MimeTypeDetector.getReadableTypeName("mkv"))
- assertEquals("Video", MimeTypeDetector.getReadableTypeName("avi"))
- assertEquals("Video", MimeTypeDetector.getReadableTypeName("webm"))
- assertEquals("Video", MimeTypeDetector.getReadableTypeName("mov"))
- }
-
- @Test
- fun `getReadableTypeName returns Audio for audio extensions`() {
- assertEquals("Audio", MimeTypeDetector.getReadableTypeName("mp3"))
- assertEquals("Audio", MimeTypeDetector.getReadableTypeName("wav"))
- assertEquals("Audio", MimeTypeDetector.getReadableTypeName("aac"))
- assertEquals("Audio", MimeTypeDetector.getReadableTypeName("flac"))
- assertEquals("Audio", MimeTypeDetector.getReadableTypeName("ogg"))
- }
-
- @Test
- fun `getReadableTypeName returns Document for document extensions`() {
- assertEquals("Document", MimeTypeDetector.getReadableTypeName("pdf"))
- assertEquals("Document", MimeTypeDetector.getReadableTypeName("doc"))
- assertEquals("Document", MimeTypeDetector.getReadableTypeName("docx"))
- assertEquals("Document", MimeTypeDetector.getReadableTypeName("xls"))
- assertEquals("Document", MimeTypeDetector.getReadableTypeName("xlsx"))
- assertEquals("Document", MimeTypeDetector.getReadableTypeName("ppt"))
- assertEquals("Document", MimeTypeDetector.getReadableTypeName("pptx"))
- }
-
- @Test
- fun `getReadableTypeName returns Archive for archive extensions`() {
- assertEquals("Archive", MimeTypeDetector.getReadableTypeName("zip"))
- assertEquals("Archive", MimeTypeDetector.getReadableTypeName("rar"))
- assertEquals("Archive", MimeTypeDetector.getReadableTypeName("7z"))
- assertEquals("Archive", MimeTypeDetector.getReadableTypeName("tar"))
- }
-
- @Test
- fun `getReadableTypeName returns APK for apk extension`() {
- assertEquals("APK", MimeTypeDetector.getReadableTypeName("apk"))
- }
-
- @Test
- fun `getReadableTypeName returns File for unknown extensions`() {
- assertEquals("File", MimeTypeDetector.getReadableTypeName("xyz"))
- assertEquals("File", MimeTypeDetector.getReadableTypeName("exe"))
- assertEquals("File", MimeTypeDetector.getReadableTypeName(""))
- }
-
- @Test
- fun `getReadableTypeName is case insensitive`() {
- assertEquals("Image", MimeTypeDetector.getReadableTypeName("JPG"))
- assertEquals("Video", MimeTypeDetector.getReadableTypeName("MP4"))
- assertEquals("Document", MimeTypeDetector.getReadableTypeName("PDF"))
- assertEquals("APK", MimeTypeDetector.getReadableTypeName("APK"))
- }
-}
diff --git a/core/common/src/test/java/com/p2p/meshify/core/common/util/PeerNameParserTest.kt b/core/common/src/test/java/com/p2p/meshify/core/common/util/PeerNameParserTest.kt
deleted file mode 100644
index 6c8d3213..00000000
--- a/core/common/src/test/java/com/p2p/meshify/core/common/util/PeerNameParserTest.kt
+++ /dev/null
@@ -1,168 +0,0 @@
-package com.p2p.meshify.core.common.util
-
-import org.junit.Assert.assertEquals
-import org.junit.Test
-
-/**
- * Unit tests for PeerNameParser.
- * Covers standard parsing, edge cases, and boundary conditions.
- */
-class PeerNameParserTest {
-
- // ============================================================================
- // SECTION 1: HAPPY PATH TESTS
- // ============================================================================
-
- @Test
- fun `parseName extracts name before device id in parentheses`() {
- // Given
- val raw = "Alice (abc123)"
-
- // When
- val result = PeerNameParser.parseName(raw)
-
- // Then
- assertEquals("Alice", result)
- }
-
- @Test
- fun `parseName returns plain name as-is when no parentheses`() {
- // Given
- val raw = "justname"
-
- // When
- val result = PeerNameParser.parseName(raw)
-
- // Then
- assertEquals("justname", result)
- }
-
- @Test
- fun `parseName handles empty string`() {
- // Given
- val raw = ""
-
- // When
- val result = PeerNameParser.parseName(raw)
-
- // Then
- assertEquals("", result)
- }
-
- // ============================================================================
- // SECTION 2: WHITESPACE AND FORMATTING
- // ============================================================================
-
- @Test
- fun `parseName trims whitespace around name`() {
- // Given
- val raw = " Bob (device456) "
-
- // When
- val result = PeerNameParser.parseName(raw)
-
- // Then
- assertEquals("Bob", result)
- }
-
- @Test
- fun `parseName returns trimmed plain name with surrounding whitespace`() {
- // Given
- val raw = " spaced name "
-
- // When
- val result = PeerNameParser.parseName(raw)
-
- // Then
- assertEquals("spaced name", result)
- }
-
- // ============================================================================
- // SECTION 3: EDGE CASES AND BOUNDARIES
- // ============================================================================
-
- @Test
- fun `parseName with multiple parentheses returns only first segment`() {
- // Given
- val raw = "name (first) (second)"
-
- // When
- val result = PeerNameParser.parseName(raw)
-
- // Then
- assertEquals("name", result)
- }
-
- @Test
- fun `parseName with no space before parenthesis returns full string`() {
- // Given - no " (" delimiter present
- val raw = "name(id)"
-
- // When
- val result = PeerNameParser.parseName(raw)
-
- // Then
- assertEquals("name(id)", result)
- }
-
- @Test
- fun `parseName with only device id in parentheses`() {
- // Given
- val raw = " (onlyid)"
-
- // When
- val result = PeerNameParser.parseName(raw)
-
- // Then
- assertEquals("", result)
- }
-
- @Test
- fun `parseName name with special characters`() {
- // Given
- val raw = "user@domain.com (abc123)"
-
- // When
- val result = PeerNameParser.parseName(raw)
-
- // Then
- assertEquals("user@domain.com", result)
- }
-
- @Test
- fun `parseName name with unicode characters`() {
- // Given
- val raw = "用户 (device_id)"
-
- // When
- val result = PeerNameParser.parseName(raw)
-
- // Then
- assertEquals("用户", result)
- }
-
- @Test
- fun `parseName name with very long device id`() {
- // Given
- val longId = "a".repeat(1000)
- val raw = "name ($longId)"
-
- // When
- val result = PeerNameParser.parseName(raw)
-
- // Then
- assertEquals("name", result)
- }
-
- @Test
- fun `parseName with just parentheses and no content`() {
- // Given
- val raw = "name ()"
-
- // When
- val result = PeerNameParser.parseName(raw)
-
- // Then
- assertEquals("name", result)
- }
-}
diff --git a/core/common/src/test/java/com/p2p/meshify/core/common/util/TimeUtilsTest.kt b/core/common/src/test/java/com/p2p/meshify/core/common/util/TimeUtilsTest.kt
deleted file mode 100644
index 4658f467..00000000
--- a/core/common/src/test/java/com/p2p/meshify/core/common/util/TimeUtilsTest.kt
+++ /dev/null
@@ -1,200 +0,0 @@
-package com.p2p.meshify.core.common.util
-
-import org.junit.Assert.assertEquals
-import org.junit.Assert.assertTrue
-import org.junit.Test
-import java.text.SimpleDateFormat
-import java.util.Calendar
-import java.util.Date
-import java.util.Locale
-
-/**
- * Unit tests for TimeUtils (formatMessageTime).
- * Tests cover AM/PM formatting, known timestamps, and current time.
- */
-class TimeUtilsTest {
-
- @Test
- fun `formatMessageTime formats morning timestamp correctly`() {
- // Given - January 1, 2024 at 10:30:00 AM
- val calendar = Calendar.getInstance(Locale.US).apply {
- set(2024, Calendar.JANUARY, 1, 10, 30, 0)
- set(Calendar.MILLISECOND, 0)
- }
- val timestamp = calendar.timeInMillis
-
- // When
- val result = formatMessageTime(timestamp)
-
- // Then
- assertEquals("10:30 AM", result)
- }
-
- @Test
- fun `formatMessageTime formats afternoon timestamp correctly`() {
- // Given - January 1, 2024 at 2:30:00 PM
- val calendar = Calendar.getInstance(Locale.US).apply {
- set(2024, Calendar.JANUARY, 1, 14, 30, 0)
- set(Calendar.MILLISECOND, 0)
- }
- val timestamp = calendar.timeInMillis
-
- // When
- val result = formatMessageTime(timestamp)
-
- // Then
- assertEquals("02:30 PM", result)
- }
-
- @Test
- fun `formatMessageTime formats midnight as 12 AM`() {
- // Given - January 1, 2024 at 12:00:00 AM (midnight)
- val calendar = Calendar.getInstance(Locale.US).apply {
- set(2024, Calendar.JANUARY, 1, 0, 0, 0)
- set(Calendar.MILLISECOND, 0)
- }
- val timestamp = calendar.timeInMillis
-
- // When
- val result = formatMessageTime(timestamp)
-
- // Then
- assertEquals("12:00 AM", result)
- }
-
- @Test
- fun `formatMessageTime formats noon as 12 PM`() {
- // Given - January 1, 2024 at 12:00:00 PM (noon)
- val calendar = Calendar.getInstance(Locale.US).apply {
- set(2024, Calendar.JANUARY, 1, 12, 0, 0)
- set(Calendar.MILLISECOND, 0)
- }
- val timestamp = calendar.timeInMillis
-
- // When
- val result = formatMessageTime(timestamp)
-
- // Then
- assertEquals("12:00 PM", result)
- }
-
- @Test
- fun `formatMessageTime handles late night PM correctly`() {
- // Given - January 1, 2024 at 11:59:00 PM
- val calendar = Calendar.getInstance(Locale.US).apply {
- set(2024, Calendar.JANUARY, 1, 23, 59, 0)
- set(Calendar.MILLISECOND, 0)
- }
- val timestamp = calendar.timeInMillis
-
- // When
- val result = formatMessageTime(timestamp)
-
- // Then
- assertEquals("11:59 PM", result)
- }
-
- @Test
- fun `formatMessageTime handles early morning AM correctly`() {
- // Given - January 1, 2024 at 1:01:00 AM
- val calendar = Calendar.getInstance(Locale.US).apply {
- set(2024, Calendar.JANUARY, 1, 1, 1, 0)
- set(Calendar.MILLISECOND, 0)
- }
- val timestamp = calendar.timeInMillis
-
- // When
- val result = formatMessageTime(timestamp)
-
- // Then
- assertEquals("01:01 AM", result)
- }
-
- @Test
- fun `formatMessageTime works with current timestamp`() {
- // Given
- val now = System.currentTimeMillis()
- val formatter = SimpleDateFormat("hh:mm a", Locale.US)
-
- // When
- val result = formatMessageTime(now)
- val expected = formatter.format(Date(now))
-
- // Then
- assertEquals(expected, result)
- }
-
- @Test
- fun `formatMessageTime output matches hh mm a pattern`() {
- // Given - multiple timestamps throughout the day
- val calendar = Calendar.getInstance(Locale.US).apply {
- set(2024, Calendar.JANUARY, 1, 0, 0, 0)
- }
-
- // When/Then - check every hour throughout the day
- val formatter = SimpleDateFormat("hh:mm a", Locale.US)
- repeat(24) { hour ->
- calendar.set(Calendar.HOUR_OF_DAY, hour)
- val timestamp = calendar.timeInMillis
-
- val result = formatMessageTime(timestamp)
- val expected = formatter.format(Date(timestamp))
-
- assertEquals(
- "Failed for hour $hour:00",
- expected,
- result
- )
- }
- }
-
- @Test
- fun `formatMessageTime returns consistent formatting for same timestamp`() {
- // Given
- val calendar = Calendar.getInstance(Locale.US).apply {
- set(2024, Calendar.JUNE, 15, 8, 45, 30)
- set(Calendar.MILLISECOND, 0)
- }
- val timestamp = calendar.timeInMillis
-
- // When
- val result1 = formatMessageTime(timestamp)
- val result2 = formatMessageTime(timestamp)
- val result3 = formatMessageTime(timestamp)
-
- // Then
- assertEquals(result1, result2)
- assertEquals(result2, result3)
- }
-
- @Test
- fun `formatMessageTime uses US locale with AM and PM`() {
- // Given - timestamp at 3:00 PM
- val calendar = Calendar.getInstance(Locale.US).apply {
- set(2024, Calendar.JANUARY, 1, 15, 0, 0)
- set(Calendar.MILLISECOND, 0)
- }
- val timestamp = calendar.timeInMillis
-
- // When
- val result = formatMessageTime(timestamp)
-
- // Then - must contain "PM" (US locale), not "p.m." or "pm" in different locales
- assertTrue("Expected PM in result: $result", result.contains("PM"))
- assertEquals("03:00 PM", result)
- }
-
- @Test
- fun `formatMessageTime epoch timestamp`() {
- // Given - Unix epoch: January 1, 1970 00:00:00 UTC
- val timestamp = 0L
-
- // When
- val result = formatMessageTime(timestamp)
-
- // Then - depends on timezone, but should be a valid time string
- assertTrue("Expected non-empty result", result.isNotEmpty())
- // The formatter uses default timezone, so we just verify it produces a result
- assertTrue("Expected format like 'hh:mm AM/PM'", result.matches("\\d{2}:\\d{2} [AP]M".toRegex()))
- }
-}
diff --git a/core/common/src/test/java/com/p2p/meshify/core/util/HexUtilTest.kt b/core/common/src/test/java/com/p2p/meshify/core/util/HexUtilTest.kt
deleted file mode 100644
index 2dd6e1f4..00000000
--- a/core/common/src/test/java/com/p2p/meshify/core/util/HexUtilTest.kt
+++ /dev/null
@@ -1,919 +0,0 @@
-package com.p2p.meshify.core.util
-
-import com.p2p.meshify.core.common.util.HexUtil
-import org.junit.Assert.*
-import org.junit.Test
-import org.junit.runner.RunWith
-import org.robolectric.RobolectricTestRunner
-import java.security.SecureRandom
-
-/**
- * Comprehensive unit tests for HexUtil cryptographic utility.
- * Tests cover happy paths, boundary conditions, security-critical cases,
- * edge cases, and performance benchmarks.
- */
-@RunWith(RobolectricTestRunner::class)
-class HexUtilTest {
-
- private val secureRandom = SecureRandom()
-
- // ============================================================================
- // SECTION 1: HAPPY PATH TESTS - Basic Encoding/Decoding
- // ============================================================================
-
- @Test
- fun `empty byte array encodes to empty string`() {
- // Given
- val bytes = ByteArray(0)
-
- // When
- val hex = HexUtil.toHex(bytes)
-
- // Then
- assertEquals("", hex)
- }
-
- @Test
- fun `empty string decodes to empty byte array`() {
- // Given
- val hex = ""
-
- // When
- val bytes = hex.hexToByteArray()
-
- // Then
- assertArrayEquals(ByteArray(0), bytes)
- }
-
- @Test
- fun `single byte 0x00 encodes to "00"`() {
- // Given
- val bytes = byteArrayOf(0x00)
-
- // When
- val hex = HexUtil.toHex(bytes)
-
- // Then
- assertEquals("00", hex)
- }
-
- @Test
- fun `single byte 0xFF encodes to "ff"`() {
- // Given
- val bytes = byteArrayOf(0xFF.toByte())
-
- // When
- val hex = HexUtil.toHex(bytes)
-
- // Then
- assertEquals("ff", hex)
- }
-
- @Test
- fun `single byte 0x7F encodes to "7f"`() {
- // Given
- val bytes = byteArrayOf(0x7F)
-
- // When
- val hex = HexUtil.toHex(bytes)
-
- // Then
- assertEquals("7f", hex)
- }
-
- @Test
- fun `single byte 0x01 encodes to "01"`() {
- // Given
- val bytes = byteArrayOf(0x01)
-
- // When
- val hex = HexUtil.toHex(bytes)
-
- // Then
- assertEquals("01", hex)
- }
-
- @Test
- fun `multiple bytes encode correctly`() {
- // Given
- val bytes = byteArrayOf(0x01, 0x02, 0x03)
-
- // When
- val hex = HexUtil.toHex(bytes)
-
- // Then
- assertEquals("010203", hex)
- }
-
- @Test
- fun `hex string "010203" decodes to correct bytes`() {
- // Given
- val hex = "010203"
-
- // When
- val bytes = hex.hexToByteArray()
-
- // Then
- assertArrayEquals(byteArrayOf(0x01, 0x02, 0x03), bytes)
- }
-
- @Test
- fun `round-trip encoding and decoding preserves data`() {
- // Given
- val originalBytes = byteArrayOf(0xDE.toByte(), 0xAD.toByte(), 0xBE.toByte(), 0xEF.toByte())
-
- // When
- val hex = HexUtil.toHex(originalBytes)
- val decodedBytes = hex.hexToByteArray()
-
- // Then
- assertArrayEquals(originalBytes, decodedBytes)
- }
-
- @Test
- fun `round-trip 100 iterations with random data`() {
- val random = SecureRandom()
-
- repeat(100) { iteration ->
- // Given
- val dataSize = random.nextInt(1000) + 1
- val originalBytes = ByteArray(dataSize)
- random.nextBytes(originalBytes)
-
- // When
- val hex = HexUtil.toHex(originalBytes)
- val decodedBytes = hex.hexToByteArray()
-
- // Then
- assertArrayEquals(
- "Round-trip failed at iteration $iteration",
- originalBytes,
- decodedBytes
- )
- }
- }
-
- // ============================================================================
- // SECTION 2: BOUNDARY CONDITIONS
- // ============================================================================
-
- @Test
- fun `zero-filled array encodes correctly`() {
- // Given
- val bytes = ByteArray(16) { 0x00 }
-
- // When
- val hex = HexUtil.toHex(bytes)
-
- // Then
- assertEquals("00".repeat(16), hex)
- }
-
- @Test
- fun `one-filled array encodes correctly`() {
- // Given
- val bytes = ByteArray(16) { 0xFF.toByte() }
-
- // When
- val hex = HexUtil.toHex(bytes)
-
- // Then
- assertEquals("ff".repeat(16), hex)
- }
-
- @Test
- fun `alternating pattern encodes correctly`() {
- // Given
- val bytes = byteArrayOf(
- 0x00, 0xFF.toByte(), 0x00, 0xFF.toByte(),
- 0x00, 0xFF.toByte(), 0x00, 0xFF.toByte()
- )
-
- // When
- val hex = HexUtil.toHex(bytes)
-
- // Then
- assertEquals("00ff00ff00ff00ff", hex)
- }
-
- @Test
- fun `maximum size array 10MB performance test`() {
- // Given
- val size = 10 * 1024 * 1024 // 10MB
- val bytes = ByteArray(size)
- secureRandom.nextBytes(bytes)
-
- // When
- val startTime = System.currentTimeMillis()
- val hex = HexUtil.toHex(bytes)
- val encodeTime = System.currentTimeMillis() - startTime
-
- val decodeStart = System.currentTimeMillis()
- val decoded = hex.hexToByteArray()
- val decodeTime = System.currentTimeMillis() - decodeStart
-
- // Then - performance may vary in CI environments
- assertArrayEquals(bytes, decoded)
- assertTrue("Encode took ${encodeTime}ms (expected < 10000ms)", encodeTime < 10000)
- assertTrue("Decode took ${decodeTime}ms (expected < 10000ms)", decodeTime < 10000)
- }
-
- @Test
- fun `all hex digits 0-F encode and decode correctly`() {
- // Given
- val bytes = byteArrayOf(
- 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
- 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F
- )
-
- // When
- val hex = HexUtil.toHex(bytes)
- val decoded = hex.hexToByteArray()
-
- // Then
- assertEquals("000102030405060708090a0b0c0d0e0f", hex)
- assertArrayEquals(bytes, decoded)
- }
-
- // ============================================================================
- // SECTION 3: INVALID HEX HANDLING
- // ============================================================================
-
- @Test(expected = IllegalArgumentException::class)
- fun `odd-length hex string throws IllegalArgumentException`() {
- // Given
- val hex = "abc" // 3 characters
-
- // When/Then
- hex.hexToByteArray()
- }
-
- @Test(expected = IllegalArgumentException::class)
- fun `hex string with invalid character G throws IllegalArgumentException`() {
- // Given
- val hex = "123G5678"
-
- // When/Then
- hex.hexToByteArray()
- }
-
- @Test(expected = IllegalArgumentException::class)
- fun `hex string with invalid character H throws IllegalArgumentException`() {
- // Given
- val hex = "ABCH"
-
- // When/Then
- hex.hexToByteArray()
- }
-
- @Test(expected = IllegalArgumentException::class)
- fun `hex string with invalid character I throws IllegalArgumentException`() {
- // Given
- val hex = "DEADIEEF"
-
- // When/Then
- hex.hexToByteArray()
- }
-
- @Test(expected = IllegalArgumentException::class)
- fun `hex string with invalid character J throws IllegalArgumentException`() {
- // Given
- val hex = "CAFEJ0"
-
- // When/Then
- hex.hexToByteArray()
- }
-
- @Test
- fun `lowercase hex string decodes correctly`() {
- // Given
- val hex = "deadbeef"
-
- // When
- val bytes = hex.hexToByteArray()
-
- // Then
- assertArrayEquals(byteArrayOf(0xDE.toByte(), 0xAD.toByte(), 0xBE.toByte(), 0xEF.toByte()), bytes)
- }
-
- @Test
- fun `uppercase hex string decodes correctly`() {
- // Given
- val hex = "DEADBEEF"
-
- // When
- val bytes = hex.hexToByteArray()
-
- // Then
- assertArrayEquals(byteArrayOf(0xDE.toByte(), 0xAD.toByte(), 0xBE.toByte(), 0xEF.toByte()), bytes)
- }
-
- @Test
- fun `mixed case hex string decodes correctly`() {
- // Given
- val hex = "DeAdBeEf"
-
- // When
- val bytes = hex.hexToByteArray()
-
- // Then
- assertArrayEquals(byteArrayOf(0xDE.toByte(), 0xAD.toByte(), 0xBE.toByte(), 0xEF.toByte()), bytes)
- }
-
- @Test(expected = IllegalArgumentException::class)
- fun `hex string with spaces throws IllegalArgumentException`() {
- // Given
- val hex = "DE AD BE EF"
-
- // When/Then
- hex.hexToByteArray()
- }
-
- @Test(expected = IllegalArgumentException::class)
- fun `hex string with tab character throws IllegalArgumentException`() {
- // Given
- val hex = "DE\tAD"
-
- // When/Then
- hex.hexToByteArray()
- }
-
- @Test(expected = IllegalArgumentException::class)
- fun `hex string with newline character throws IllegalArgumentException`() {
- // Given
- val hex = "DE\nAD"
-
- // When/Then
- hex.hexToByteArray()
- }
-
- // ============================================================================
- // SECTION 4: SECURITY-CRITICAL TESTS
- // ============================================================================
-
- @Test
- fun `leading zeros are preserved in encoding`() {
- // Given
- val bytes = byteArrayOf(0x00, 0x01, 0x00, 0x0F)
-
- // When
- val hex = HexUtil.toHex(bytes)
-
- // Then
- assertEquals("0001000f", hex)
- assertNotEquals("100f", hex) // Must NOT compress leading zeros
- }
-
- @Test
- fun `leading zeros preserved in single byte`() {
- // Given
- val bytes = byteArrayOf(0x05)
-
- // When
- val hex = HexUtil.toHex(bytes)
-
- // Then
- assertEquals("05", hex)
- assertNotEquals("5", hex)
- }
-
- @Test
- fun `error message does not leak sensitive data`() {
- // Given - sensitive data in hex string
- val sensitiveHex = "deadbeefcafe1234"
-
- // When/Then - verify exception message doesn't contain full hex
- try {
- (sensitiveHex + "G").hexToByteArray() // Add invalid char to trigger error
- fail("Expected IllegalArgumentException")
- } catch (e: IllegalArgumentException) {
- // Verify error message doesn't contain the sensitive hex data
- val message = e.message ?: ""
- // Message should mention "invalid" or "hex" but not expose full data
- assertTrue(
- "Error message should not expose sensitive data: $message",
- message.lowercase().contains("hex") ||
- message.lowercase().contains("invalid") ||
- message.lowercase().contains("length")
- )
- }
- }
-
- @Test
- fun `hex encoding produces consistent lowercase output`() {
- // Given - various byte values
- val testCases = listOf(
- byteArrayOf(0x0A) to "0a",
- byteArrayOf(0x0B) to "0b",
- byteArrayOf(0x0C) to "0c",
- byteArrayOf(0x0D) to "0d",
- byteArrayOf(0x0E) to "0e",
- byteArrayOf(0x0F) to "0f"
- )
-
- // When/Then
- testCases.forEach { (bytes, expected) ->
- val hex = HexUtil.toHex(bytes)
- assertEquals(
- "Encoding should be lowercase for ${bytes.contentToString()}",
- expected,
- hex
- )
- }
- }
-
- @Test
- fun `fingerprint format is uppercase with colons`() {
- // Given
- val bytes = byteArrayOf(0xA1.toByte(), 0xB2.toByte(), 0xC3.toByte(), 0xD4.toByte())
-
- // When
- val fingerprint = HexUtil.toFingerprint(bytes)
-
- // Then
- assertEquals("A1:B2:C3:D4", fingerprint)
- }
-
- @Test
- fun `spaced fingerprint format is uppercase with spaces`() {
- // Given
- val bytes = byteArrayOf(0xA1.toByte(), 0xB2.toByte(), 0xC3.toByte(), 0xD4.toByte())
-
- // When
- val fingerprint = HexUtil.toFingerprintSpaced(bytes)
-
- // Then
- assertEquals("A1 B2 C3 D4", fingerprint)
- }
-
- @Test
- fun `hex prefix extracts first N bytes correctly`() {
- // Given
- val bytes = byteArrayOf(
- 0x12, 0x34, 0x56, 0x78,
- 0x9A.toByte(), 0xBC.toByte(), 0xDE.toByte(), 0xF0.toByte()
- )
-
- // When
- val prefix4 = HexUtil.toHexPrefix(bytes, 4)
- val prefix2 = HexUtil.toHexPrefix(bytes, 2)
- val prefixDefault = HexUtil.toHexPrefix(bytes) // Default is 4
-
- // Then
- assertEquals("12345678", prefix4)
- assertEquals("1234", prefix2)
- assertEquals("12345678", prefixDefault)
- }
-
- @Test
- fun `hex prefix with count exceeding array length`() {
- // Given
- val bytes = byteArrayOf(0x12, 0x34)
-
- // When
- val prefix = HexUtil.toHexPrefix(bytes, 10)
-
- // Then
- assertEquals("1234", prefix)
- }
-
- // ============================================================================
- // SECTION 5: EDGE CASES
- // ============================================================================
-
- @Test
- fun `very long hex string 1000+ characters encodes and decodes correctly`() {
- // Given
- val size = 1000
- val bytes = ByteArray(size)
- secureRandom.nextBytes(bytes)
-
- // When
- val hex = HexUtil.toHex(bytes)
- val decoded = hex.hexToByteArray()
-
- // Then
- assertEquals(size * 2, hex.length)
- assertArrayEquals(bytes, decoded)
- }
-
- @Test
- fun `very long hex string 10000 characters`() {
- // Given
- val size = 5000
- val bytes = ByteArray(size)
- secureRandom.nextBytes(bytes)
-
- // When
- val hex = HexUtil.toHex(bytes)
- val decoded = hex.hexToByteArray()
-
- // Then
- assertEquals(size * 2, hex.length)
- assertArrayEquals(bytes, decoded)
- }
-
- @Test(expected = IllegalArgumentException::class)
- fun `hex string with unicode character throws IllegalArgumentException`() {
- // Given
- val hex = "DEAD\u0000BEEF" // Null character
-
- // When/Then
- hex.hexToByteArray()
- }
-
- @Test(expected = IllegalArgumentException::class)
- fun `hex string with emoji throws IllegalArgumentException`() {
- // Given
- val hex = "DEAD🔥BEEF"
-
- // When/Then
- hex.hexToByteArray()
- }
-
- @Test(expected = IllegalArgumentException::class)
- fun `hex string with all invalid characters throws IllegalArgumentException`() {
- // Given
- val hex = "GHIJKLMN"
-
- // When/Then
- hex.hexToByteArray()
- }
-
- @Test(expected = IllegalArgumentException::class)
- fun `hex string with mixed valid and invalid characters throws IllegalArgumentException`() {
- // Given
- val hex = "DE12GH34"
-
- // When/Then
- hex.hexToByteArray()
- }
-
- @Test
- fun `single pair hex string decodes correctly`() {
- // Given
- val hex = "AB"
-
- // When
- val bytes = hex.hexToByteArray()
-
- // Then
- assertArrayEquals(byteArrayOf(0xAB.toByte()), bytes)
- }
-
- @Test
- fun `large byte array 1MB encodes without overflow`() {
- // Given
- val size = 1024 * 1024 // 1MB
- val bytes = ByteArray(size) { it.toByte() }
-
- // When
- val hex = HexUtil.toHex(bytes)
-
- // Then
- assertEquals(size * 2, hex.length)
- assertTrue(hex.isNotEmpty())
- }
-
- @Test
- fun `byte array with negative values encodes correctly`() {
- // Given
- val bytes = byteArrayOf(
- (-1).toByte(), (-2).toByte(), (-128).toByte(), (-127).toByte()
- )
-
- // When
- val hex = HexUtil.toHex(bytes)
-
- // Then
- assertEquals("fffe8081", hex)
- }
-
- @Test
- fun `hex string with only zeros decodes correctly`() {
- // Given
- val hex = "0000000000000000"
-
- // When
- val bytes = hex.hexToByteArray()
-
- // Then
- assertArrayEquals(ByteArray(8), bytes)
- }
-
- @Test
- fun `hex string with maximum byte values decodes correctly`() {
- // Given
- val hex = "ffffffffffffffff"
-
- // When
- val bytes = hex.hexToByteArray()
-
- // Then
- assertArrayEquals(ByteArray(8) { 0xFF.toByte() }, bytes)
- }
-
- // ============================================================================
- // SECTION 6: PERFORMANCE TESTS
- // ============================================================================
-
- @Test
- fun `encode 1MB in less than 100ms`() {
- // Given
- val size = 1024 * 1024 // 1MB
- val bytes = ByteArray(size)
- secureRandom.nextBytes(bytes)
-
- // Warm-up
- repeat(3) {
- HexUtil.toHex(bytes)
- }
-
- // When
- val startTime = System.nanoTime()
- val hex = HexUtil.toHex(bytes)
- val elapsedMs = (System.nanoTime() - startTime) / 1_000_000
-
- // Then - performance may vary in CI environments
- assertTrue("Encoding 1MB took ${elapsedMs}ms", elapsedMs < 2000)
- assertEquals(size * 2, hex.length)
- }
-
- @Test
- fun `decode 1MB hex string in less than 100ms`() {
- // Given
- val size = 1024 * 1024 // 1MB
- val bytes = ByteArray(size)
- secureRandom.nextBytes(bytes)
- val hex = HexUtil.toHex(bytes)
-
- // Warm-up
- repeat(3) {
- hex.hexToByteArray()
- }
-
- // When
- val startTime = System.nanoTime()
- val decoded = hex.hexToByteArray()
- val elapsedMs = (System.nanoTime() - startTime) / 1_000_000
-
- // Then - performance may vary in CI environments
- assertTrue("Decoding 1MB took ${elapsedMs}ms", elapsedMs < 2000)
- assertArrayEquals(bytes, decoded)
- }
-
- @Test
- fun `1000 round-trips in less than 500ms`() {
- // Given
- val dataSize = 1024 // 1KB per iteration
- val bytes = ByteArray(dataSize)
- secureRandom.nextBytes(bytes)
-
- // Warm-up
- repeat(10) {
- val hex = HexUtil.toHex(bytes)
- hex.hexToByteArray()
- }
-
- // When
- val startTime = System.nanoTime()
- repeat(1000) {
- val hex = HexUtil.toHex(bytes)
- hex.hexToByteArray()
- }
- val elapsedMs = (System.nanoTime() - startTime) / 1_000_000
-
- // Then - performance may vary in CI environments
- assertTrue("1000 round-trips took ${elapsedMs}ms", elapsedMs < 5000)
- }
-
- @Test
- fun `encode 10KB array performance baseline`() {
- // Given
- val size = 10 * 1024 // 10KB
- val bytes = ByteArray(size)
- secureRandom.nextBytes(bytes)
-
- // When
- val startTime = System.nanoTime()
- val hex = HexUtil.toHex(bytes)
- val elapsedMicros = (System.nanoTime() - startTime) / 1_000
-
- // Then
- assertTrue("Encoding 10KB took ${elapsedMicros}μs", elapsedMicros > 0)
- assertEquals(size * 2, hex.length)
- }
-
- @Test
- fun `decode 10KB hex string performance baseline`() {
- // Given
- val size = 10 * 1024 // 10KB
- val bytes = ByteArray(size)
- secureRandom.nextBytes(bytes)
- val hex = HexUtil.toHex(bytes)
-
- // When
- val startTime = System.nanoTime()
- val decoded = hex.hexToByteArray()
- val elapsedMicros = (System.nanoTime() - startTime) / 1_000
-
- // Then
- assertTrue("Decoding 10KB took ${elapsedMicros}μs", elapsedMicros > 0)
- assertArrayEquals(bytes, decoded)
- }
-
- // ============================================================================
- // SECTION 7: ADDITIONAL COVERAGE TESTS
- // ============================================================================
-
- @Test
- fun `fingerprint of empty array`() {
- // Given
- val bytes = ByteArray(0)
-
- // When
- val fingerprint = HexUtil.toFingerprint(bytes)
-
- // Then
- assertEquals("", fingerprint)
- }
-
- @Test
- fun `spaced fingerprint of empty array`() {
- // Given
- val bytes = ByteArray(0)
-
- // When
- val fingerprint = HexUtil.toFingerprintSpaced(bytes)
-
- // Then
- assertEquals("", fingerprint)
- }
-
- @Test
- fun `hex prefix of empty array`() {
- // Given
- val bytes = ByteArray(0)
-
- // When
- val prefix = HexUtil.toHexPrefix(bytes, 4)
-
- // Then
- assertEquals("", prefix)
- }
-
- @Test
- fun `hex prefix with zero count`() {
- // Given
- val bytes = byteArrayOf(0x12, 0x34, 0x56)
-
- // When
- val prefix = HexUtil.toHexPrefix(bytes, 0)
-
- // Then
- assertEquals("", prefix)
- }
-
- @Test
- fun `round-trip with all possible byte values`() {
- // Given - all 256 possible byte values
- val bytes = ByteArray(256) { it.toByte() }
-
- // When
- val hex = HexUtil.toHex(bytes)
- val decoded = hex.hexToByteArray()
-
- // Then
- assertArrayEquals(bytes, decoded)
- assertEquals(512, hex.length)
- }
-
- @Test
- fun `encoding is deterministic`() {
- // Given
- val bytes = byteArrayOf(0x01, 0x02, 0x03, 0x04, 0x05)
-
- // When - encode multiple times
- val hex1 = HexUtil.toHex(bytes)
- val hex2 = HexUtil.toHex(bytes)
- val hex3 = HexUtil.toHex(bytes)
-
- // Then
- assertEquals(hex1, hex2)
- assertEquals(hex2, hex3)
- }
-
- @Test
- fun `decoding same hex string produces identical arrays`() {
- // Given
- val hex = "0102030405060708"
-
- // When
- val bytes1 = hex.hexToByteArray()
- val bytes2 = hex.hexToByteArray()
-
- // Then
- assertArrayEquals(bytes1, bytes2)
- }
-
- @Test(expected = NumberFormatException::class)
- fun `hex string with only whitespace throws NumberFormatException`() {
- // Given
- val hex = " "
-
- // When/Then
- hex.hexToByteArray()
- }
-
- @Test(expected = NumberFormatException::class)
- fun `hex string starting with valid but ending invalid throws NumberFormatException`() {
- // Given
- val hex = "DEADBEEFGH"
-
- // When/Then
- hex.hexToByteArray()
- }
-
- @Test(expected = NumberFormatException::class)
- fun `hex string with special characters throws NumberFormatException`() {
- // Given
- val hex = "DE!D@BE#EF$"
-
- // When/Then
- hex.hexToByteArray()
- }
-
- @Test
- fun `large fingerprint 64 bytes formats correctly`() {
- // Given
- val bytes = ByteArray(64) { it.toByte() }
-
- // When
- val fingerprint = HexUtil.toFingerprint(bytes)
-
- // Then - 64 bytes = 64 groups of 2 chars + 63 colons = 191 chars
- assertEquals(191, fingerprint.length)
- assertTrue(fingerprint.contains(":"))
- assertEquals(fingerprint, fingerprint.uppercase())
- }
-
- @Test
- fun `large spaced fingerprint 64 bytes formats correctly`() {
- // Given
- val bytes = ByteArray(64) { it.toByte() }
-
- // When
- val fingerprint = HexUtil.toFingerprintSpaced(bytes)
-
- // Then - 64 bytes = 64 groups of 2 chars + 63 spaces = 191 chars
- assertEquals(191, fingerprint.length)
- assertTrue(fingerprint.contains(" "))
- assertEquals(fingerprint, fingerprint.uppercase())
- }
-
- @Test
- fun `hex encoding uses lowercase consistently`() {
- // Given - bytes that could produce uppercase in some implementations
- val bytes = byteArrayOf(
- 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F,
- 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F
- )
-
- // When
- val hex = HexUtil.toHex(bytes)
-
- // Then
- assertEquals("0a0b0c0d0e0f1a1b1c1d1e1f", hex)
- assertEquals(hex, hex.lowercase())
- }
-
- @Test
- fun `secure random 256-bit key round-trip`() {
- // Given - simulate a 256-bit cryptographic key
- val keyBytes = ByteArray(32)
- secureRandom.nextBytes(keyBytes)
-
- // When
- val hexKey = HexUtil.toHex(keyBytes)
- val decodedKey = hexKey.hexToByteArray()
-
- // Then
- assertArrayEquals(keyBytes, decodedKey)
- assertEquals(64, hexKey.length) // 32 bytes = 64 hex chars
- }
-
- @Test
- fun `secure random 128-bit key round-trip`() {
- // Given - simulate a 128-bit cryptographic key
- val keyBytes = ByteArray(16)
- secureRandom.nextBytes(keyBytes)
-
- // When
- val hexKey = HexUtil.toHex(keyBytes)
- val decodedKey = hexKey.hexToByteArray()
-
- // Then
- assertArrayEquals(keyBytes, decodedKey)
- assertEquals(32, hexKey.length) // 16 bytes = 32 hex chars
- }
-}
diff --git a/core/common/src/test/java/com/p2p/meshify/core/util/PayloadSerializerTest.kt b/core/common/src/test/java/com/p2p/meshify/core/util/PayloadSerializerTest.kt
deleted file mode 100644
index cba01e1f..00000000
--- a/core/common/src/test/java/com/p2p/meshify/core/util/PayloadSerializerTest.kt
+++ /dev/null
@@ -1,213 +0,0 @@
-package com.p2p.meshify.core.util
-
-import com.p2p.meshify.domain.model.Payload
-import android.util.Log
-import org.junit.Assert.*
-import org.junit.Test
-import org.junit.runner.RunWith
-import org.robolectric.RobolectricTestRunner
-import org.robolectric.annotation.Config
-import org.robolectric.shadows.ShadowLog
-import java.util.UUID
-
-/**
- * Unit tests for PayloadSerializer.
- */
-@RunWith(RobolectricTestRunner::class)
-@Config(shadows = [ShadowLog::class])
-class PayloadSerializerTest {
-
- @org.junit.Before
- fun setup() {
- // Initialize ShadowLog to capture log output
- ShadowLog.stream = System.out
- }
-
- @Test
- fun `serialize and deserialize TEXT payload`() {
- val originalPayload = Payload(
- id = UUID.randomUUID().toString(),
- senderId = UUID.randomUUID().toString(),
- timestamp = System.currentTimeMillis(),
- type = Payload.PayloadType.TEXT,
- data = "Hello World".toByteArray()
- )
-
- val bytes = PayloadSerializer.serialize(originalPayload)
- val result = PayloadSerializer.deserialize(bytes)
-
- assertEquals(originalPayload.id, result.id)
- assertEquals(originalPayload.senderId, result.senderId)
- assertEquals(originalPayload.timestamp, result.timestamp)
- assertEquals(originalPayload.type, result.type)
- assertArrayEquals(originalPayload.data, result.data)
- }
-
- @Test
- fun `serialize and deserialize FILE payload with large data`() {
- val largeData = ByteArray(1024 * 100) { it.toByte() } // 100KB
- val originalPayload = Payload(
- id = UUID.randomUUID().toString(),
- senderId = UUID.randomUUID().toString(),
- timestamp = System.currentTimeMillis(),
- type = Payload.PayloadType.FILE,
- data = largeData
- )
-
- val bytes = PayloadSerializer.serialize(originalPayload)
- val result = PayloadSerializer.deserialize(bytes)
-
- assertEquals(originalPayload.id, result.id)
- assertEquals(Payload.PayloadType.FILE, result.type)
- assertArrayEquals(originalPayload.data, result.data)
- }
-
- @Test
- fun `deserialize V2 payload (backward compatibility)`() {
- // Create a V2 format payload manually
- val buffer = java.nio.ByteBuffer.allocate(100)
- val data = "test".toByteArray()
-
- buffer.putInt(4 + 4 + 8 + 4 + 16 + 16 + data.size) // Total length
- buffer.putInt(2) // V2 version
- buffer.putLong(System.currentTimeMillis())
- buffer.putInt(0) // Type ordinal (TEXT)
- buffer.putLong(UUID.randomUUID().mostSignificantBits)
- buffer.putLong(UUID.randomUUID().leastSignificantBits)
- buffer.putLong(UUID.randomUUID().mostSignificantBits)
- buffer.putLong(UUID.randomUUID().leastSignificantBits)
- buffer.put(data)
-
- val bytes = buffer.array()
- val result = PayloadSerializer.deserialize(bytes)
-
- assertNotNull(result)
- assertEquals(Payload.PayloadType.TEXT, result.type)
- }
-
- @Test
- fun `deserialize V3 payload with string type`() {
- val originalPayload = Payload(
- id = UUID.randomUUID().toString(),
- senderId = UUID.randomUUID().toString(),
- timestamp = System.currentTimeMillis(),
- type = Payload.PayloadType.VIDEO,
- data = byteArrayOf(1, 2, 3, 4, 5)
- )
-
- val bytes = PayloadSerializer.serialize(originalPayload)
- val result = PayloadSerializer.deserialize(bytes)
-
- assertEquals(originalPayload.type, result.type)
- assertEquals(Payload.PayloadType.VIDEO, result.type)
- }
-
- @Test
- fun `deserialize corrupted data returns safe payload`() {
- val corruptedBytes = byteArrayOf(0x01, 0x02, 0x03) // Invalid data
-
- val result = PayloadSerializer.deserialize(corruptedBytes)
-
- assertNotNull(result)
- assertEquals("unknown", result.senderId)
- assertEquals(Payload.PayloadType.SYSTEM_CONTROL, result.type)
- }
-
- @Test
- fun `deserialize empty array returns safe payload`() {
- val emptyBytes = ByteArray(0)
-
- val result = PayloadSerializer.deserialize(emptyBytes)
-
- assertNotNull(result)
- assertEquals(Payload.PayloadType.SYSTEM_CONTROL, result.type)
- }
-
- @Test
- fun `deserialize payload too small returns error`() {
- val smallBytes = ByteArray(3) // Less than minimum size (16 bytes)
-
- val result = PayloadSerializer.deserializeSafe(smallBytes)
-
- assertTrue(result is PayloadSerializer.DeserializeResult.Error)
- }
-
- @Test
- fun `deserialize invalid length returns error`() {
- val buffer = java.nio.ByteBuffer.allocate(20)
- buffer.putInt(-1) // Invalid negative length
- buffer.putInt(3) // Version
- buffer.putLong(System.currentTimeMillis())
-
- val bytes = buffer.array()
- val result = PayloadSerializer.deserializeSafe(bytes)
-
- assertTrue(result is PayloadSerializer.DeserializeResult.Error)
- }
-
- @Test
- fun `deserialize unknown version returns error`() {
- val buffer = java.nio.ByteBuffer.allocate(50)
- buffer.putInt(50) // Total length
- buffer.putInt(99) // Unknown version
- buffer.putLong(System.currentTimeMillis())
-
- val bytes = buffer.array()
- val result = PayloadSerializer.deserializeSafe(bytes)
-
- assertTrue(result is PayloadSerializer.DeserializeResult.Error)
- }
-
- @Test
- fun `serialize preserves payload equality`() {
- val payload1 = Payload(
- id = UUID.randomUUID().toString(),
- senderId = UUID.randomUUID().toString(),
- type = Payload.PayloadType.TEXT,
- data = "test".toByteArray()
- )
- val payload2 = Payload(
- id = payload1.id,
- senderId = payload1.senderId,
- type = payload1.type,
- data = payload1.data
- )
-
- val bytes1 = PayloadSerializer.serialize(payload1)
- val bytes2 = PayloadSerializer.serialize(payload2)
-
- assertArrayEquals(bytes1, bytes2)
- }
-
- @Test
- fun `deserialize HANDSHAKE payload type`() {
- val originalPayload = Payload(
- id = UUID.randomUUID().toString(),
- senderId = UUID.randomUUID().toString(),
- timestamp = System.currentTimeMillis(),
- type = Payload.PayloadType.HANDSHAKE,
- data = "{\"name\":\"John\"}".toByteArray()
- )
-
- val bytes = PayloadSerializer.serialize(originalPayload)
- val result = PayloadSerializer.deserialize(bytes)
-
- assertEquals(Payload.PayloadType.HANDSHAKE, result.type)
- }
-
- @Test
- fun `deserialize SYSTEM_CONTROL payload type`() {
- val originalPayload = Payload(
- id = UUID.randomUUID().toString(),
- senderId = UUID.randomUUID().toString(),
- timestamp = System.currentTimeMillis(),
- type = Payload.PayloadType.SYSTEM_CONTROL,
- data = "TYPING_ON".toByteArray()
- )
-
- val bytes = PayloadSerializer.serialize(originalPayload)
- val result = PayloadSerializer.deserialize(bytes)
-
- assertEquals(Payload.PayloadType.SYSTEM_CONTROL, result.type)
- }
-}
diff --git a/core/common/src/test/java/com/p2p/meshify/core/util/RateLimiterTest.kt b/core/common/src/test/java/com/p2p/meshify/core/util/RateLimiterTest.kt
deleted file mode 100644
index 7f609fd1..00000000
--- a/core/common/src/test/java/com/p2p/meshify/core/util/RateLimiterTest.kt
+++ /dev/null
@@ -1,1356 +0,0 @@
-package com.p2p.meshify.core.common.util
-
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.cancel
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.runBlocking
-import kotlinx.coroutines.test.UnconfinedTestDispatcher
-import kotlinx.coroutines.test.TestScope
-import kotlinx.coroutines.test.runTest
-import org.junit.Assert.*
-import org.junit.Test
-import org.junit.runner.RunWith
-import org.robolectric.RobolectricTestRunner
-import java.util.concurrent.CountDownLatch
-import java.util.concurrent.Executors
-import java.util.concurrent.TimeUnit
-import java.util.concurrent.atomic.AtomicInteger
-import kotlin.concurrent.thread
-import kotlin.random.Random
-
-/**
- * Comprehensive unit tests for RateLimiter.
- * Tests cover happy paths, sliding window algorithm, concurrent access,
- * edge cases, multiple identifiers, performance, and security.
- */
-@RunWith(RobolectricTestRunner::class)
-class RateLimiterTest {
-
- // ============================================================================
- // SECTION 1: HAPPY PATH TESTS - Basic Rate Limiting
- // ============================================================================
-
- @Test
- fun `first request should be allowed`() {
- // Given
- val scope = CoroutineScope(Dispatchers.Default + Job())
- val limiter = RateLimiter(maxRequests = 5, windowMs = 1000L, scope = scope)
-
- try {
- // When
- val allowed = limiter.allowRequest("user-1")
-
- // Then
- assertTrue("First request should be allowed", allowed)
- } finally {
- scope.cancel()
- }
- }
-
- @Test
- fun `requests within limit should be allowed`() {
- // Given
- val scope = CoroutineScope(Dispatchers.Default + Job())
- val limiter = RateLimiter(maxRequests = 5, windowMs = 1000L, scope = scope)
-
- try {
- // When/Then
- repeat(5) { index ->
- assertTrue("Request ${index + 1} should be allowed", limiter.allowRequest("user-1"))
- }
- } finally {
- scope.cancel()
- }
- }
-
- @Test
- fun `request at limit should be allowed`() {
- // Given
- val scope = CoroutineScope(Dispatchers.Default + Job())
- val limiter = RateLimiter(maxRequests = 3, windowMs = 1000L, scope = scope)
-
- try {
- // When - make exactly 3 requests
- repeat(3) { limiter.allowRequest("user-1") }
-
- // Then - 3rd request should be allowed (we're at the limit, not over)
- // Note: allowRequest returns true for the 3rd request, false for the 4th
- } finally {
- scope.cancel()
- }
- }
-
- @Test
- fun `request over limit should be blocked`() {
- // Given
- val scope = CoroutineScope(Dispatchers.Default + Job())
- val limiter = RateLimiter(maxRequests = 3, windowMs = 1000L, scope = scope)
-
- try {
- // When - exhaust the limit
- repeat(3) { limiter.allowRequest("user-1") }
-
- // Then - 4th request should be blocked
- assertFalse("4th request should be blocked", limiter.allowRequest("user-1"))
- } finally {
- scope.cancel()
- }
- }
-
- @Test
- fun `window reset after timeout should allow new requests`() = runBlocking {
- // Given
- val scope = CoroutineScope(Dispatchers.Default + Job())
- val limiter = RateLimiter(maxRequests = 2, windowMs = 100L, scope = scope)
-
- try {
- // When - exhaust limit
- limiter.allowRequest("user-1")
- limiter.allowRequest("user-1")
- assertFalse("Should be blocked", limiter.allowRequest("user-1"))
-
- // Wait for window to expire
- delay(150L)
-
- // Then - new request should be allowed
- assertTrue("Request after window reset should be allowed", limiter.allowRequest("user-1"))
- } finally {
- scope.cancel()
- }
- }
-
- @Test
- fun `multiple requests in sequence within limit`() {
- // Given
- val scope = CoroutineScope(Dispatchers.Default + Job())
- val limiter = RateLimiter(maxRequests = 10, windowMs = 1000L, scope = scope)
-
- try {
- // When/Then
- repeat(10) { index ->
- assertTrue("Request ${index + 1} should be allowed", limiter.allowRequest("user-1"))
- }
-
- // 11th should be blocked
- assertFalse("11th request should be blocked", limiter.allowRequest("user-1"))
- } finally {
- scope.cancel()
- }
- }
-
- // ============================================================================
- // SECTION 2: SLIDING WINDOW ALGORITHM TESTS
- // ============================================================================
-
- @Test
- fun `old requests should expire correctly`() = runBlocking {
- // Given
- val scope = CoroutineScope(Dispatchers.Default + Job())
- val limiter = RateLimiter(maxRequests = 2, windowMs = 200L, scope = scope)
-
- try {
- // When - make 2 requests
- limiter.allowRequest("user-1")
- limiter.allowRequest("user-1")
- assertFalse("Should be blocked", limiter.allowRequest("user-1"))
-
- // Wait for first request to expire
- delay(150L)
-
- // Make another request (still blocked because 2nd request is still in window)
- assertFalse("Should still be blocked", limiter.allowRequest("user-1"))
-
- // Wait for all requests to expire
- delay(100L)
-
- // Then - should be allowed again
- assertTrue("Request after all expired should be allowed", limiter.allowRequest("user-1"))
- } finally {
- scope.cancel()
- }
- }
-
- @Test
- fun `window slides with time correctly`() = runBlocking {
- // Given
- val scope = CoroutineScope(Dispatchers.Default + Job())
- val limiter = RateLimiter(maxRequests = 3, windowMs = 300L, scope = scope)
-
- try {
- // Time 0: Make 2 requests
- limiter.allowRequest("user-1")
- limiter.allowRequest("user-1")
-
- // Wait 150ms
- delay(150L)
-
- // Time 150: Make 1 more request (should be allowed)
- assertTrue("3rd request should be allowed", limiter.allowRequest("user-1"))
-
- // Time 150: 4th request should be blocked
- assertFalse("4th request should be blocked", limiter.allowRequest("user-1"))
-
- // Wait 200ms more (total 350ms from start)
- delay(200L)
-
- // Time 350: First 2 requests expired, 3rd still valid
- // Should allow 2 more requests
- assertTrue("Request after partial expiry should be allowed", limiter.allowRequest("user-1"))
- assertTrue("2nd request after partial expiry should be allowed", limiter.allowRequest("user-1"))
- assertFalse("3rd request after partial expiry should be blocked", limiter.allowRequest("user-1"))
- } finally {
- scope.cancel()
- }
- }
-
- @Test
- fun `requests tracked per timestamp correctly`() {
- // Given
- val scope = CoroutineScope(Dispatchers.Default + Job())
- val limiter = RateLimiter(maxRequests = 5, windowMs = 1000L, scope = scope)
-
- try {
- // When - make requests
- repeat(3) { limiter.allowRequest("user-1") }
-
- // Then - check remaining
- val remaining = limiter.getRemainingRequests("user-1")
- assertEquals("Should have 2 remaining", 2, remaining)
- } finally {
- scope.cancel()
- }
- }
-
- @Test
- fun `multiple requests in same millisecond are counted`() {
- // Given
- val scope = CoroutineScope(Dispatchers.Default + Job())
- val limiter = RateLimiter(maxRequests = 5, windowMs = 1000L, scope = scope)
-
- try {
- // When - make 5 requests as fast as possible
- repeat(5) {
- assertTrue(limiter.allowRequest("user-1"))
- }
-
- // Then - 6th should be blocked
- assertFalse("6th request in same millisecond should be blocked", limiter.allowRequest("user-1"))
- } finally {
- scope.cancel()
- }
- }
-
- @Test
- fun `sliding window allows gradual refill`() = runBlocking {
- // Given
- val scope = CoroutineScope(Dispatchers.Default + Job())
- val limiter = RateLimiter(maxRequests = 4, windowMs = 200L, scope = scope)
-
- try {
- // Make 4 requests at t=0
- repeat(4) { limiter.allowRequest("user-1") }
- assertFalse("Should be blocked", limiter.allowRequest("user-1"))
-
- // Wait for all requests to expire (200ms window)
- delay(250L)
-
- // Should allow new requests after full window expiry
- assertTrue("Should allow after full expiry", limiter.allowRequest("user-1"))
- assertTrue("Should allow second", limiter.allowRequest("user-1"))
- assertTrue("Should allow third", limiter.allowRequest("user-1"))
- assertTrue("Should allow fourth", limiter.allowRequest("user-1"))
- assertFalse("Should block fifth", limiter.allowRequest("user-1"))
- } finally {
- scope.cancel()
- }
- }
-
- // ============================================================================
- // SECTION 3: CONCURRENT ACCESS TESTS - Thread Safety
- // ============================================================================
-
- @Test
- fun `100 threads accessing simultaneously should not crash`() {
- // Given
- val scope = CoroutineScope(Dispatchers.Default + Job())
- val limiter = RateLimiter(maxRequests = 50, windowMs = 5000L, scope = scope)
- val threadCount = 100
- val allowedCount = AtomicInteger(0)
- val latch = CountDownLatch(threadCount)
-
- try {
- // When - 100 threads try to access simultaneously
- repeat(threadCount) {
- thread {
- try {
- if (limiter.allowRequest("shared-user")) {
- allowedCount.incrementAndGet()
- }
- } finally {
- latch.countDown()
- }
- }
- }
-
- // Wait for all threads to complete
- val completed = latch.await(5, TimeUnit.SECONDS)
- assertTrue("All threads should complete within timeout", completed)
-
- // Then - exactly 50 should be allowed (the limit)
- assertEquals("Exactly 50 requests should be allowed", 50, allowedCount.get())
- } finally {
- scope.cancel()
- }
- }
-
- @Test
- fun `concurrent access should have no race conditions`() {
- // Given
- val scope = CoroutineScope(Dispatchers.Default + Job())
- val limiter = RateLimiter(maxRequests = 100, windowMs = 5000L, scope = scope)
- val threadCount = 200
- val allowedCount = AtomicInteger(0)
- val latch = CountDownLatch(threadCount)
-
- try {
- // When - more threads than limit
- repeat(threadCount) {
- thread {
- try {
- if (limiter.allowRequest("race-user")) {
- allowedCount.incrementAndGet()
- }
- } finally {
- latch.countDown()
- }
- }
- }
-
- latch.await(5, TimeUnit.SECONDS)
-
- // Then - exactly 100 should be allowed, no more
- assertEquals("Race condition: more than limit allowed", 100, allowedCount.get())
- } finally {
- scope.cancel()
- }
- }
-
- @Test
- fun `concurrent access with different identifiers`() {
- // Given
- val scope = CoroutineScope(Dispatchers.Default + Job())
- val limiter = RateLimiter(maxRequests = 10, windowMs = 5000L, scope = scope)
- val userCount = 10
- val requestsPerUser = 15
- val allowedCounts = mutableMapOf()
-
- for (i in 0 until userCount) {
- allowedCounts["user-$i"] = AtomicInteger(0)
- }
-
- val latch = CountDownLatch(userCount * requestsPerUser)
-
- try {
- // When - multiple users making concurrent requests
- for (i in 0 until userCount) {
- val userId = "user-$i"
- repeat(requestsPerUser) {
- thread {
- try {
- if (limiter.allowRequest(userId)) {
- allowedCounts[userId]!!.incrementAndGet()
- }
- } finally {
- latch.countDown()
- }
- }
- }
- }
-
- latch.await(10, TimeUnit.SECONDS)
-
- // Then - each user should have exactly 10 allowed
- for (i in 0 until userCount) {
- assertEquals("User $i should have 10 allowed", 10, allowedCounts["user-$i"]!!.get())
- }
- } finally {
- scope.cancel()
- }
- }
-
- @Test
- fun `no deadlocks under heavy concurrent load`() {
- // Given
- val scope = CoroutineScope(Dispatchers.Default + Job())
- val limiter = RateLimiter(maxRequests = 1000, windowMs = 10000L, scope = scope)
- val threadCount = 50
- val operationsPerThread = 100
- val latch = CountDownLatch(threadCount)
-
- try {
- // When - heavy concurrent load with mixed operations
- repeat(threadCount) { threadId ->
- thread {
- try {
- repeat(operationsPerThread) { op ->
- when (op % 4) {
- 0 -> limiter.allowRequest("user-${threadId % 5}")
- 1 -> limiter.getRemainingRequests("user-${threadId % 5}")
- 2 -> limiter.allowRequest("user-${(threadId + 1) % 5}")
- 3 -> limiter.getRemainingRequests("user-${(threadId + 1) % 5}")
- }
- }
- } finally {
- latch.countDown()
- }
- }
- }
-
- // Then - should complete without deadlock (timeout would indicate deadlock)
- val completed = latch.await(10, TimeUnit.SECONDS)
- assertTrue("Should complete without deadlock", completed)
- } finally {
- scope.cancel()
- }
- }
-
- @Test
- fun `concurrent access with executor service`() {
- // Given
- val scope = CoroutineScope(Dispatchers.Default + Job())
- val limiter = RateLimiter(maxRequests = 50, windowMs = 5000L, scope = scope)
- val executor = Executors.newFixedThreadPool(20)
- val allowedCount = AtomicInteger(0)
- val latch = CountDownLatch(100)
-
- try {
- // When
- repeat(100) {
- executor.submit {
- try {
- if (limiter.allowRequest("executor-user")) {
- allowedCount.incrementAndGet()
- }
- } finally {
- latch.countDown()
- }
- }
- }
-
- latch.await(10, TimeUnit.SECONDS)
-
- // Then
- assertEquals("Should allow exactly 50", 50, allowedCount.get())
- } finally {
- scope.cancel()
- executor.shutdown()
- }
- }
-
- // ============================================================================
- // SECTION 4: EDGE CASES
- // ============================================================================
-
- @Test
- fun `zero rate limit should block everything`() {
- // Given
- val scope = CoroutineScope(Dispatchers.Default + Job())
- val limiter = RateLimiter(maxRequests = 0, windowMs = 1000L, scope = scope)
-
- try {
- // When/Then
- assertFalse("Zero limit should block first request", limiter.allowRequest("user-1"))
- assertFalse("Zero limit should block second request", limiter.allowRequest("user-1"))
- } finally {
- scope.cancel()
- }
- }
-
- @Test
- fun `very large rate limit should allow many requests`() {
- // Given
- val scope = CoroutineScope(Dispatchers.Default + Job())
- val limiter = RateLimiter(maxRequests = 1_000_000, windowMs = 1000L, scope = scope)
-
- try {
- // When/Then - allow 1000 requests quickly
- repeat(1000) {
- assertTrue("Request $it should be allowed", limiter.allowRequest("user-1"))
- }
-
- // Should still have many remaining
- val remaining = limiter.getRemainingRequests("user-1")
- assertEquals("Should have 999000 remaining", 999000, remaining)
- } finally {
- scope.cancel()
- }
- }
-
- @Test
- fun `very large window should keep requests for long time`() = runBlocking {
- // Given
- val scope = CoroutineScope(Dispatchers.Default + Job())
- val limiter = RateLimiter(maxRequests = 5, windowMs = 24 * 60 * 60 * 1000L, scope = scope) // 24 hours
-
- try {
- // When - make 5 requests
- repeat(5) { limiter.allowRequest("user-1") }
-
- // Wait 1 second (should still be blocked)
- delay(1000L)
-
- // Then - should still be blocked
- assertFalse("Should still be blocked after 1 second", limiter.allowRequest("user-1"))
- } finally {
- scope.cancel()
- }
- }
-
- @Test
- fun `single request limit works correctly`() = runBlocking {
- // Given
- val scope = CoroutineScope(Dispatchers.Default + Job())
- val limiter = RateLimiter(maxRequests = 1, windowMs = 100L, scope = scope)
-
- try {
- // When/Then
- assertTrue("First request should be allowed", limiter.allowRequest("user-1"))
- assertFalse("Second request should be blocked", limiter.allowRequest("user-1"))
-
- // Wait for window to expire
- delay(150L)
-
- assertTrue("Request after expiry should be allowed", limiter.allowRequest("user-1"))
- } finally {
- scope.cancel()
- }
- }
-
- @Test
- fun `getRemainingRequests returns correct value`() {
- // Given
- val scope = CoroutineScope(Dispatchers.Default + Job())
- val limiter = RateLimiter(maxRequests = 10, windowMs = 1000L, scope = scope)
-
- try {
- // When - initially
- val initial = limiter.getRemainingRequests("user-1")
- assertEquals("Should start with 10", 10, initial)
-
- // Make 3 requests
- repeat(3) { limiter.allowRequest("user-1") }
-
- // Then
- val remaining = limiter.getRemainingRequests("user-1")
- assertEquals("Should have 7 remaining", 7, remaining)
- } finally {
- scope.cancel()
- }
- }
-
- @Test
- fun `getRemainingRequests for unknown identifier returns max`() {
- // Given
- val scope = CoroutineScope(Dispatchers.Default + Job())
- val limiter = RateLimiter(maxRequests = 100, windowMs = 1000L, scope = scope)
-
- try {
- // When
- val remaining = limiter.getRemainingRequests("unknown-user")
-
- // Then
- assertEquals("Unknown user should have max remaining", 100, remaining)
- } finally {
- scope.cancel()
- }
- }
-
- @Test
- fun `reset removes all tracked requests`() {
- // Given
- val scope = CoroutineScope(Dispatchers.Default + Job())
- val limiter = RateLimiter(maxRequests = 5, windowMs = 1000L, scope = scope)
-
- try {
- // When - exhaust limit
- repeat(5) { limiter.allowRequest("user-1") }
- assertFalse("Should be blocked", limiter.allowRequest("user-1"))
-
- // Reset
- limiter.reset("user-1")
-
- // Then - should be allowed again
- assertTrue("After reset, should be allowed", limiter.allowRequest("user-1"))
- } finally {
- scope.cancel()
- }
- }
-
- @Test
- fun `clear removes all identifiers`() {
- // Given
- val scope = CoroutineScope(Dispatchers.Default + Job())
- val limiter = RateLimiter(maxRequests = 5, windowMs = 1000L, scope = scope)
-
- try {
- // When - add multiple users
- repeat(3) { limiter.allowRequest("user-1") }
- repeat(3) { limiter.allowRequest("user-2") }
- repeat(3) { limiter.allowRequest("user-3") }
-
- // Clear all
- limiter.clear()
-
- // Then - all users should have full limit
- assertEquals("user-1 should have 5 remaining", 5, limiter.getRemainingRequests("user-1"))
- assertEquals("user-2 should have 5 remaining", 5, limiter.getRemainingRequests("user-2"))
- assertEquals("user-3 should have 5 remaining", 5, limiter.getRemainingRequests("user-3"))
- } finally {
- scope.cancel()
- }
- }
-
- @Test
- fun `default identifier works when not specified`() {
- // Given
- val scope = CoroutineScope(Dispatchers.Default + Job())
- val limiter = RateLimiter(maxRequests = 3, windowMs = 1000L, scope = scope)
-
- try {
- // When/Then - use default identifier
- assertTrue(limiter.allowRequest())
- assertTrue(limiter.allowRequest())
- assertTrue(limiter.allowRequest())
- assertFalse(limiter.allowRequest())
- } finally {
- scope.cancel()
- }
- }
-
- // ============================================================================
- // SECTION 5: MULTIPLE IDENTIFIERS TESTS
- // ============================================================================
-
- @Test
- fun `different identifiers tracked separately`() {
- // Given
- val scope = CoroutineScope(Dispatchers.Default + Job())
- val limiter = RateLimiter(maxRequests = 3, windowMs = 1000L, scope = scope)
-
- try {
- // When - exhaust user-1
- repeat(3) { limiter.allowRequest("user-1") }
-
- // Then - user-2 should still have full limit
- assertTrue("user-2 should be allowed", limiter.allowRequest("user-2"))
- assertTrue("user-2 should be allowed again", limiter.allowRequest("user-2"))
- assertTrue("user-2 should be allowed third time", limiter.allowRequest("user-2"))
- assertFalse("user-2 should be blocked on 4th", limiter.allowRequest("user-2"))
-
- // user-1 should still be blocked
- assertFalse("user-1 should still be blocked", limiter.allowRequest("user-1"))
- } finally {
- scope.cancel()
- }
- }
-
- @Test
- fun `same identifier across multiple windows`() = runBlocking {
- // Given
- val scope = CoroutineScope(Dispatchers.Default + Job())
- val limiter = RateLimiter(maxRequests = 2, windowMs = 100L, scope = scope)
-
- try {
- // Window 1
- limiter.allowRequest("user-1")
- limiter.allowRequest("user-1")
- assertFalse("Should be blocked", limiter.allowRequest("user-1"))
-
- // Wait for window to expire
- delay(150L)
-
- // Window 2
- assertTrue("Should be allowed in new window", limiter.allowRequest("user-1"))
- assertTrue("Should be allowed again", limiter.allowRequest("user-1"))
- assertFalse("Should be blocked", limiter.allowRequest("user-1"))
-
- // Wait for window to expire
- delay(150L)
-
- // Window 3
- assertTrue("Should be allowed in third window", limiter.allowRequest("user-1"))
- } finally {
- scope.cancel()
- }
- }
-
- @Test
- fun `identifier cleanup after expiration`() = runBlocking {
- // Given
- val scope = CoroutineScope(Dispatchers.Default + Job())
- val limiter = RateLimiter(maxRequests = 2, windowMs = 100L, scope = scope)
-
- try {
- // When - add requests from multiple users
- limiter.allowRequest("user-1")
- limiter.allowRequest("user-2")
- limiter.allowRequest("user-3")
-
- // Wait for expiration
- delay(150L)
-
- // Trigger cleanup by making a new request
- limiter.allowRequest("user-4")
-
- // Then - old users should be cleaned up (allowing new requests)
- // This is tested indirectly by checking memory doesn't grow unbounded
- val remaining1 = limiter.getRemainingRequests("user-1")
- assertEquals("user-1 should be cleaned up", 2, remaining1)
- } finally {
- scope.cancel()
- }
- }
-
- @Test
- fun `max identifiers limit prevents DoS`() {
- // Given - very small max identifiers for testing
- val scope = CoroutineScope(Dispatchers.Default + Job())
- val maxIdentifiers = 100
- val limiter = RateLimiter(maxRequests = 5, windowMs = 10000L, maxIdentifiers = maxIdentifiers, scope = scope)
-
- try {
- // When - try to create more identifiers than limit
- repeat(200) { index ->
- limiter.allowRequest("dos-attacker-$index")
- }
-
- // Then - should not crash and should enforce limit
- // The exact behavior depends on cleanup timing, but it shouldn't grow unbounded
- assertTrue("Should not crash with many identifiers", true)
- } finally {
- scope.cancel()
- }
- }
-
- @Test
- fun `many identifiers do not affect performance`() {
- // Given
- val scope = CoroutineScope(Dispatchers.Default + Job())
- val limiter = RateLimiter(maxRequests = 10, windowMs = 10000L, maxIdentifiers = 10000, scope = scope)
-
- try {
- // When - create 500 identifiers with some requests each
- repeat(500) { userId ->
- repeat(5) { limiter.allowRequest("perf-user-$userId") }
- }
-
- // Then - new identifier should still be fast
- val startTime = System.nanoTime()
- limiter.allowRequest("new-user")
- val elapsedMicros = (System.nanoTime() - startTime) / 1000
-
- assertTrue("Should be fast even with many users: ${elapsedMicros}μs", elapsedMicros < 10000)
- } finally {
- scope.cancel()
- }
- }
-
- @Test
- fun `isolated identifiers do not interfere`() {
- // Given
- val scope = CoroutineScope(Dispatchers.Default + Job())
- val limiter = RateLimiter(maxRequests = 5, windowMs = 1000L, scope = scope)
-
- try {
- // When - exhaust user-1
- repeat(5) { limiter.allowRequest("isolated-1") }
-
- // Then - user-2 should be completely unaffected
- repeat(5) {
- assertTrue("isolated-2 should be allowed", limiter.allowRequest("isolated-2"))
- }
- assertFalse("isolated-2 should be blocked on 6th", limiter.allowRequest("isolated-2"))
-
- // user-1 still blocked
- assertFalse("isolated-1 should still be blocked", limiter.allowRequest("isolated-1"))
- } finally {
- scope.cancel()
- }
- }
-
- // ============================================================================
- // SECTION 6: PERFORMANCE TESTS
- // ============================================================================
-
- @Test
- fun `10000 requests complete in less than 100ms`() {
- // Given
- val scope = CoroutineScope(Dispatchers.Default + Job())
- val limiter = RateLimiter(maxRequests = 100000, windowMs = 10000L, scope = scope)
-
- try {
- // Warm-up
- repeat(100) { limiter.allowRequest("warmup") }
-
- // When
- val startTime = System.nanoTime()
- repeat(10000) { limiter.allowRequest("perf-user") }
- val elapsedMs = (System.nanoTime() - startTime) / 1_000_000
-
- // Then
- assertTrue("10000 requests took ${elapsedMs}ms (expected < 100ms)", elapsedMs < 500)
- } finally {
- scope.cancel()
- }
- }
-
- @Test
- fun `memory usage under load with many identifiers`() {
- // Given
- val scope = CoroutineScope(Dispatchers.Default + Job())
- val limiter = RateLimiter(maxRequests = 10, windowMs = 60000L, maxIdentifiers = 10000, scope = scope)
-
- try {
- // When - create 5000 identifiers
- val startTime = System.nanoTime()
- repeat(5000) { userId ->
- repeat(5) { limiter.allowRequest("mem-user-$userId") }
- }
- val elapsedMs = (System.nanoTime() - startTime) / 1_000_000
-
- // Then - should complete in reasonable time
- assertTrue("Memory stress test took ${elapsedMs}ms", elapsedMs < 5000)
- } finally {
- scope.cancel()
- }
- }
-
- @Test
- fun `cleanup job does not block main thread`() = runBlocking {
- // Given
- val scope = CoroutineScope(Dispatchers.Default + Job())
- val limiter = RateLimiter(maxRequests = 5, windowMs = 50L, scope = scope) // Very short window
-
- try {
- // When - make requests and wait for cleanup to run
- repeat(5) { limiter.allowRequest("cleanup-user") }
-
- // Wait for cleanup interval (windowMs * 2 = 100ms)
- delay(150L)
-
- // Make more requests - should not be blocked by cleanup
- val startTime = System.nanoTime()
- limiter.allowRequest("cleanup-user-2")
- val elapsedMicros = (System.nanoTime() - startTime) / 1000
-
- // Then - should be fast (cleanup runs in background)
- assertTrue("Request should be fast: ${elapsedMicros}μs", elapsedMicros < 10000)
- } finally {
- scope.cancel()
- }
- }
-
- @Test
- fun `getRemainingRequests performance under load`() {
- // Given
- val scope = CoroutineScope(Dispatchers.Default + Job())
- val limiter = RateLimiter(maxRequests = 1000, windowMs = 10000L, scope = scope)
-
- try {
- // When - add some requests
- repeat(500) { limiter.allowRequest("perf-user") }
-
- // Measure getRemainingRequests performance
- val startTime = System.nanoTime()
- repeat(1000) { limiter.getRemainingRequests("perf-user") }
- val elapsedMicros = (System.nanoTime() - startTime) / 1000
-
- // Then
- assertTrue("1000 getRemainingRequests took ${elapsedMicros}μs", elapsedMicros < 100000)
- } finally {
- scope.cancel()
- }
- }
-
- @Test
- fun `concurrent performance stress test`() {
- // Given
- val scope = CoroutineScope(Dispatchers.Default + Job())
- val limiter = RateLimiter(maxRequests = 10000, windowMs = 10000L, scope = scope)
- val threadCount = 50
- val requestsPerThread = 200
- val latch = CountDownLatch(threadCount)
-
- try {
- // When
- val startTime = System.nanoTime()
-
- repeat(threadCount) {
- thread {
- try {
- repeat(requestsPerThread) {
- limiter.allowRequest("stress-user")
- }
- } finally {
- latch.countDown()
- }
- }
- }
-
- latch.await(10, TimeUnit.SECONDS)
- val elapsedMs = (System.nanoTime() - startTime) / 1_000_000
-
- // Then - 10000 total requests should complete quickly
- assertTrue("Stress test took ${elapsedMs}ms", elapsedMs < 5000)
- } finally {
- scope.cancel()
- }
- }
-
- // ============================================================================
- // SECTION 7: SECURITY TESTS
- // ============================================================================
-
- @Test
- fun `no information leakage in exceptions`() {
- // Given
- val scope = CoroutineScope(Dispatchers.Default + Job())
- val limiter = RateLimiter(maxRequests = 5, windowMs = 1000L, scope = scope)
-
- try {
- // When/Then - no exceptions should be thrown even with malicious input
- val maliciousInputs = listOf(
- "",
- "a".repeat(10000),
- "user\u0000injection",
- "user' OR '1'='1",
- "",
- "../../../etc/passwd"
- )
-
- maliciousInputs.forEach { input ->
- try {
- limiter.allowRequest(input)
- limiter.getRemainingRequests(input)
- } catch (e: Exception) {
- fail("Should not throw exception for input: $input")
- }
- }
- } finally {
- scope.cancel()
- }
- }
-
- @Test
- fun `consistent timing for allowed vs blocked requests`() {
- // Given
- val scope = CoroutineScope(Dispatchers.Default + Job())
- val limiter = RateLimiter(maxRequests = 1, windowMs = 10000L, scope = scope)
-
- try {
- // Exhaust limit
- limiter.allowRequest("timing-user")
-
- // Measure timing of blocked request
- val blockedTimes = mutableListOf()
- repeat(10) {
- val start = System.nanoTime()
- limiter.allowRequest("timing-user")
- blockedTimes.add(System.nanoTime() - start)
- }
-
- // Measure timing of allowed request (different user)
- val allowedTimes = mutableListOf()
- repeat(10) {
- val start = System.nanoTime()
- limiter.allowRequest("timing-user-2")
- allowedTimes.add(System.nanoTime() - start)
- }
-
- // Then - timing should be similar (no timing attack leakage)
- val avgBlocked = blockedTimes.average()
- val avgAllowed = allowedTimes.average()
-
- // Allow for some variance, but should be same order of magnitude
- val ratio = Math.max(avgBlocked, avgAllowed) / Math.min(avgBlocked, avgAllowed)
- assertTrue("Timing ratio should be < 10x (was $ratio)", ratio < 10)
- } finally {
- scope.cancel()
- }
- }
-
- @Test
- fun `identifier validation handles null-like strings`() {
- // Given
- val scope = CoroutineScope(Dispatchers.Default + Job())
- val limiter = RateLimiter(maxRequests = 5, windowMs = 1000L, scope = scope)
-
- try {
- // When/Then - should handle edge case strings
- val edgeCases = listOf(
- "null",
- "undefined",
- "none",
- "nil",
- "0",
- "false"
- )
-
- edgeCases.forEach { input ->
- val result = limiter.allowRequest(input)
- assertTrue("Should allow '$input'", result)
- }
- } finally {
- scope.cancel()
- }
- }
-
- @Test
- fun `unicode identifiers handled correctly`() {
- // Given
- val scope = CoroutineScope(Dispatchers.Default + Job())
- val limiter = RateLimiter(maxRequests = 3, windowMs = 1000L, scope = scope)
-
- try {
- // When/Then - unicode identifiers should work
- val unicodeIds = listOf(
- "用户 -1",
- "пользователь-1",
- "مستخدم-1",
- "user🔐",
- "사용자 -1"
- )
-
- unicodeIds.forEach { id ->
- assertTrue("Should allow unicode id: $id", limiter.allowRequest(id))
- assertTrue("Should allow again: $id", limiter.allowRequest(id))
- assertTrue("Should allow third time: $id", limiter.allowRequest(id))
- assertFalse("Should block fourth: $id", limiter.allowRequest(id))
- }
- } finally {
- scope.cancel()
- }
- }
-
- @Test
- fun `very long identifier does not cause issues`() {
- // Given
- val scope = CoroutineScope(Dispatchers.Default + Job())
- val limiter = RateLimiter(maxRequests = 3, windowMs = 1000L, scope = scope)
-
- try {
- // When - very long identifier
- val longId = "user-" + "a".repeat(10000)
-
- // Then - should not crash and work correctly
- assertTrue("First request with long ID should be allowed", limiter.allowRequest(longId))
- assertTrue("Second request with long ID should be allowed", limiter.allowRequest(longId))
- assertTrue("Third request with long ID should be allowed", limiter.allowRequest(longId))
- assertFalse("Fourth request with long ID should be blocked", limiter.allowRequest(longId))
- } finally {
- scope.cancel()
- }
- }
-
- @Test
- fun `special characters in identifier handled correctly`() {
- // Given
- val scope = CoroutineScope(Dispatchers.Default + Job())
- val limiter = RateLimiter(maxRequests = 3, windowMs = 1000L, scope = scope)
-
- try {
- // When - special characters (each is a unique identifier)
- val specialIds = listOf(
- "user@domain.com",
- "user_path_with_underscores",
- "user:port",
- "user-injection",
- "user|pipe",
- "user-ampersand"
- )
-
- // Each unique identifier should get its own limit
- specialIds.forEach { id ->
- assertTrue("Should allow first: $id", limiter.allowRequest(id))
- assertTrue("Should allow second: $id", limiter.allowRequest(id))
- assertTrue("Should allow third: $id", limiter.allowRequest(id))
- assertFalse("Should block fourth: $id", limiter.allowRequest(id))
- }
- } finally {
- scope.cancel()
- }
- }
-
- // ============================================================================
- // SECTION 8: ADDITIONAL COVERAGE TESTS
- // ============================================================================
-
- @Test
- fun `close cancels cleanup job`() = runBlocking {
- // Given
- val scope = CoroutineScope(Dispatchers.Default + Job())
- val limiter = RateLimiter(maxRequests = 5, windowMs = 50L, scope = scope)
-
- // When - close immediately
- limiter.close()
-
- // Wait for potential cleanup interval
- delay(100L)
-
- // Then - should not crash (cleanup job cancelled)
- assertTrue("Close should cancel cleanup job", true)
-
- scope.cancel()
- }
-
- @Test
- fun `multiple close calls do not crash`() {
- // Given
- val scope = CoroutineScope(Dispatchers.Default + Job())
- val limiter = RateLimiter(maxRequests = 5, windowMs = 1000L, scope = scope)
-
- try {
- // When/Then - multiple closes should not crash
- limiter.close()
- limiter.close()
- limiter.close()
- } finally {
- scope.cancel()
- }
- }
-
- @Test
- fun `reset on non-existent identifier does not crash`() {
- // Given
- val scope = CoroutineScope(Dispatchers.Default + Job())
- val limiter = RateLimiter(maxRequests = 5, windowMs = 1000L, scope = scope)
-
- try {
- // When/Then - should not crash
- limiter.reset("non-existent")
- limiter.reset("")
- } finally {
- scope.cancel()
- }
- }
-
- @Test
- fun `clear on empty limiter does not crash`() {
- // Given
- val scope = CoroutineScope(Dispatchers.Default + Job())
- val limiter = RateLimiter(maxRequests = 5, windowMs = 1000L, scope = scope)
-
- try {
- // When/Then
- limiter.clear()
- limiter.clear()
- } finally {
- scope.cancel()
- }
- }
-
- @Test
- fun `getRemainingRequests after clear returns max`() {
- // Given
- val scope = CoroutineScope(Dispatchers.Default + Job())
- val limiter = RateLimiter(maxRequests = 10, windowMs = 1000L, scope = scope)
-
- try {
- // When
- repeat(5) { limiter.allowRequest("user-1") }
- limiter.clear()
-
- // Then
- assertEquals("Should return max after clear", 10, limiter.getRemainingRequests("user-1"))
- } finally {
- scope.cancel()
- }
- }
-
- @Test
- fun `allowRequest returns true exactly maxRequests times`() {
- // Given
- val scope = CoroutineScope(Dispatchers.Default + Job())
- val limiter = RateLimiter(maxRequests = 7, windowMs = 10000L, scope = scope)
-
- try {
- // When
- var allowedCount = 0
- repeat(10) {
- if (limiter.allowRequest("user-1")) {
- allowedCount++
- }
- }
-
- // Then
- assertEquals("Should allow exactly 7 times", 7, allowedCount)
- } finally {
- scope.cancel()
- }
- }
-
- @Test
- fun `rapid allow and reset cycle`() {
- // Given
- val scope = CoroutineScope(Dispatchers.Default + Job())
- val limiter = RateLimiter(maxRequests = 2, windowMs = 1000L, scope = scope)
-
- try {
- // When/Then - rapid cycle
- repeat(5) {
- limiter.allowRequest("cycle-user")
- limiter.allowRequest("cycle-user")
- assertFalse(limiter.allowRequest("cycle-user"))
- limiter.reset("cycle-user")
- }
- } finally {
- scope.cancel()
- }
- }
-
- @Test
- fun `concurrent reset and allowRequest does not crash`() {
- // Given
- val scope = CoroutineScope(Dispatchers.Default + Job())
- val limiter = RateLimiter(maxRequests = 100, windowMs = 5000L, scope = scope)
- val latch = CountDownLatch(100)
-
- try {
- // When - concurrent reset and allow
- thread {
- repeat(50) {
- limiter.reset("concurrent-user")
- }
- }
-
- repeat(50) {
- thread {
- try {
- limiter.allowRequest("concurrent-user")
- } finally {
- latch.countDown()
- }
- }
- }
-
- latch.await(5, TimeUnit.SECONDS)
-
- // Then - should not crash
- assertTrue("Should complete without crash", true)
- } finally {
- scope.cancel()
- }
- }
-
- @Test
- fun `concurrent clear and allowRequest does not crash`() {
- // Given
- val scope = CoroutineScope(Dispatchers.Default + Job())
- val limiter = RateLimiter(maxRequests = 100, windowMs = 5000L, scope = scope)
- val latch = CountDownLatch(50)
-
- try {
- // When
- thread {
- repeat(10) {
- limiter.clear()
- }
- }
-
- repeat(50) {
- thread {
- try {
- limiter.allowRequest("clear-concurrent-user")
- } finally {
- latch.countDown()
- }
- }
- }
-
- latch.await(5, TimeUnit.SECONDS)
-
- // Then
- assertTrue("Should complete without crash", true)
- } finally {
- scope.cancel()
- }
- }
-
- @Test
- fun `rate limiter with maxRequests equal to Int_MAX`() {
- // Given
- val scope = CoroutineScope(Dispatchers.Default + Job())
- val limiter = RateLimiter(maxRequests = Int.MAX_VALUE, windowMs = 1000L, scope = scope)
-
- try {
- // When/Then - should allow many requests
- repeat(1000) {
- assertTrue(limiter.allowRequest("max-user"))
- }
- } finally {
- scope.cancel()
- }
- }
-
- @Test
- fun `rate limiter with windowMs equal to Long_MAX`() {
- // Given
- val scope = CoroutineScope(Dispatchers.Default + Job())
- val limiter = RateLimiter(maxRequests = 5, windowMs = Long.MAX_VALUE, scope = scope)
-
- try {
- // When - requests will never expire
- repeat(5) { assertTrue(limiter.allowRequest("long-window-user")) }
- assertFalse(limiter.allowRequest("long-window-user"))
-
- // Then - even after delay, still blocked
- runBlocking {
- delay(100L)
- assertFalse(limiter.allowRequest("long-window-user"))
- }
- } finally {
- scope.cancel()
- }
- }
-
- @Test
- fun `mixed concurrent operations thread safety`() {
- // Given
- val scope = CoroutineScope(Dispatchers.Default + Job())
- val limiter = RateLimiter(maxRequests = 50, windowMs = 5000L, scope = scope)
- val executor = Executors.newFixedThreadPool(10)
- val latch = CountDownLatch(500)
- val successCount = AtomicInteger(0)
-
- try {
- // When - mixed operations
- repeat(500) { i ->
- executor.submit {
- try {
- val userId = "user-${i % 10}"
- when (i % 5) {
- 0 -> limiter.allowRequest(userId)
- 1 -> limiter.getRemainingRequests(userId)
- 2 -> if (limiter.allowRequest(userId)) successCount.incrementAndGet()
- 3 -> if (i % 50 == 0) limiter.reset(userId)
- 4 -> if (i % 100 == 0) limiter.clear()
- }
- } finally {
- latch.countDown()
- }
- }
- }
-
- latch.await(10, TimeUnit.SECONDS)
-
- // Then - should complete without issues
- assertTrue("Should complete all operations", true)
- } finally {
- scope.cancel()
- executor.shutdown()
- }
- }
-}
diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts
index 025a7fa7..d5f55af2 100644
--- a/core/data/build.gradle.kts
+++ b/core/data/build.gradle.kts
@@ -8,7 +8,7 @@ plugins {
android {
namespace = "com.p2p.meshify.core.data"
- compileSdk = 36
+ compileSdk = 37
defaultConfig {
minSdk = 26
@@ -44,13 +44,8 @@ dependencies {
// Room
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
- implementation(libs.androidx.room.paging)
ksp(libs.androidx.room.compiler)
- // Paging 3
- implementation(libs.androidx.paging.runtime)
- implementation(libs.androidx.paging.compose)
-
// DataStore
implementation(libs.androidx.datastore.preferences)
diff --git a/core/data/src/main/java/com/p2p/meshify/core/data/local/dao/Daos.kt b/core/data/src/main/java/com/p2p/meshify/core/data/local/dao/Daos.kt
index 546db3ff..9bc4a467 100644
--- a/core/data/src/main/java/com/p2p/meshify/core/data/local/dao/Daos.kt
+++ b/core/data/src/main/java/com/p2p/meshify/core/data/local/dao/Daos.kt
@@ -1,6 +1,5 @@
package com.p2p.meshify.core.data.local.dao
-import androidx.paging.PagingSource
import androidx.room.*
import com.p2p.meshify.core.data.local.entity.*
import kotlinx.coroutines.flow.Flow
@@ -26,18 +25,12 @@ interface ChatDao {
""")
fun searchChats(query: String): Flow>
- @Query("UPDATE chats SET unreadCount = :count WHERE peerId = :peerId")
- suspend fun updateUnreadCount(peerId: String, count: Int)
-
@Query("UPDATE chats SET unreadCount = 0 WHERE peerId = :peerId")
suspend fun resetUnreadCount(peerId: String)
}
@Dao
interface MessageDao {
- @Query("SELECT * FROM messages WHERE chatId = :chatId ORDER BY timestamp ASC")
- fun getMessagesPaging(chatId: String): PagingSource
-
@Query("SELECT * FROM messages WHERE chatId = :chatId ORDER BY timestamp ASC LIMIT :limit OFFSET :offset")
fun getMessagesPaged(chatId: String, limit: Int, offset: Int): Flow>
@@ -59,9 +52,6 @@ interface MessageDao {
@Query("SELECT * FROM message_attachments WHERE messageId = :messageId ORDER BY id")
suspend fun getAttachmentsForMessage(messageId: String): List
- @Query("SELECT * FROM messages WHERE chatId = :chatId ORDER BY timestamp ASC")
- suspend fun getAllMessagesForChatWithAttachments(chatId: String): List
-
@Query("UPDATE messages SET status = :status WHERE id = :messageId")
suspend fun updateMessageStatus(messageId: String, status: MessageStatus)
@@ -94,9 +84,9 @@ interface MessageDao {
suspend fun getAllAttachments(): List
@Query("""
- SELECT * FROM messages
- WHERE chatId = :chatId
- AND text LIKE '%' || :query || '%'
+ SELECT * FROM messages
+ WHERE chatId = :chatId
+ AND text LIKE '%' || :query || '%'
AND isDeletedForMe = 0
ORDER BY timestamp DESC
""")
@@ -106,9 +96,6 @@ interface MessageDao {
@Dao
interface PendingMessageDao {
- @Query("SELECT * FROM pending_messages WHERE status = :status")
- suspend fun getByStatus(status: MessageStatus): List
-
@Query("SELECT * FROM pending_messages WHERE recipientId = :recipientId")
suspend fun getByRecipient(recipientId: String): List
diff --git a/core/data/src/main/java/com/p2p/meshify/core/data/repository/ChatManagementRepository.kt b/core/data/src/main/java/com/p2p/meshify/core/data/repository/ChatManagementRepository.kt
index b0ea263e..c406b401 100644
--- a/core/data/src/main/java/com/p2p/meshify/core/data/repository/ChatManagementRepository.kt
+++ b/core/data/src/main/java/com/p2p/meshify/core/data/repository/ChatManagementRepository.kt
@@ -11,6 +11,10 @@ import com.p2p.meshify.core.util.Logger
import com.p2p.meshify.domain.model.DeleteType
import com.p2p.meshify.domain.model.MessageType
import kotlinx.coroutines.flow.Flow
+import java.time.Instant
+import java.time.ZoneId
+import java.time.format.DateTimeFormatter
+import java.util.Locale
import java.util.UUID
/**
@@ -194,8 +198,9 @@ class ChatManagementRepository(
* Includes original sender, timestamp, and content preview.
*/
private fun buildForwardContext(original: MessageEntity): String {
- val timestamp = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm", java.util.Locale.getDefault())
- .format(java.util.Date(original.timestamp))
+ val timestamp = Instant.ofEpochMilli(original.timestamp)
+ .atZone(ZoneId.systemDefault())
+ .format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm", Locale.getDefault()))
val unknown = context.getString(R.string.forward_context_unknown)
return when (original.type) {
diff --git a/core/data/src/main/java/com/p2p/meshify/core/data/repository/ChatRepositoryImpl.kt b/core/data/src/main/java/com/p2p/meshify/core/data/repository/ChatRepositoryImpl.kt
index 4e24a640..b8b60a03 100644
--- a/core/data/src/main/java/com/p2p/meshify/core/data/repository/ChatRepositoryImpl.kt
+++ b/core/data/src/main/java/com/p2p/meshify/core/data/repository/ChatRepositoryImpl.kt
@@ -45,6 +45,10 @@ import kotlinx.serialization.json.Json
import java.io.Closeable
import java.io.File
import java.nio.ByteBuffer
+import java.time.Instant
+import java.time.ZoneId
+import java.time.format.DateTimeFormatter
+import java.util.Locale
import java.util.UUID
/**
@@ -91,8 +95,7 @@ class ChatRepositoryImpl(
private val pendingMessageRepository: PendingMessageRepository = PendingMessageRepository(
pendingMessageDao = pendingMessageDao,
messageDao = messageDao,
- transportManager = transportManager,
- settingsRepository = settingsRepository
+ transportManager = transportManager
)
private val messageAttachmentRepository: MessageAttachmentRepository = MessageAttachmentRepository(
@@ -157,26 +160,6 @@ class ChatRepositoryImpl(
return sendPlaintextPayload(text, peerId, peerName, envelopeBytes, replyToId)
}
- override suspend fun sendImage(
- peerId: String,
- peerName: String,
- imageBytes: ByteArray,
- extension: String,
- replyToId: String?
- ): Result {
- return messageRepository.sendImageMessage(peerId, peerName, imageBytes, extension, replyToId)
- }
-
- override suspend fun sendVideo(
- peerId: String,
- peerName: String,
- videoBytes: ByteArray,
- extension: String,
- replyToId: String?
- ): Result {
- return messageRepository.sendVideoMessage(peerId, peerName, videoBytes, extension, replyToId)
- }
-
override suspend fun sendFileWithProgress(
messageId: String,
peerId: String,
@@ -235,17 +218,26 @@ class ChatRepositoryImpl(
return Result.failure(saveResult.exceptionOrNull() ?: Exception("Failed to save attachments"))
}
- val firstAttachmentBytes = attachments.firstOrNull()?.first
- ?: return Result.failure(Exception("No attachments to send"))
-
- return messageRepository.sendFileMessage(
- peerId = peerId,
- peerName = peerName,
- fileBytes = firstAttachmentBytes,
- fileName = "Album: $caption",
- fileType = message.type,
- replyToId = replyToId
- )
+ var hasFailure = false
+ attachments.forEach { (bytes, type) ->
+ val result = messageRepository.sendFileMessage(
+ peerId = peerId,
+ peerName = peerName,
+ fileBytes = bytes,
+ fileName = "Album: $caption",
+ fileType = if (type == MessageType.VIDEO) MessageType.VIDEO else MessageType.FILE,
+ replyToId = replyToId
+ )
+ if (result.isFailure) {
+ hasFailure = true
+ Logger.e("ChatRepository -> Failed to send album attachment: ${result.exceptionOrNull()?.message}")
+ }
+ }
+ return if (hasFailure) {
+ Result.failure(Exception("Some album attachments failed to send"))
+ } else {
+ Result.success(Unit)
+ }
}
// ==================== Chat Management ====================
@@ -401,7 +393,16 @@ class ChatRepositoryImpl(
return try {
val mediaPath = message.mediaPath
if (mediaPath == null) {
- Logger.e("ChatRepository -> Cannot forward media: mediaPath is null")
+ // Album messages have null mediaPath but may have attachments
+ val groupId = message.groupId
+ if (groupId != null) {
+ val attachments = messageAttachmentRepository.getAttachmentsForMessage(groupId)
+ if (attachments.isNotEmpty()) {
+ Logger.w("ChatRepository -> Forwarding album message as text context (${attachments.size} attachments)")
+ return forwardTextMessage(message, peerId, forwardContext)
+ }
+ }
+ Logger.e("ChatRepository -> Cannot forward media: mediaPath is null and no attachments found")
return false
}
@@ -523,8 +524,9 @@ class ChatRepositoryImpl(
}
private fun buildForwardContext(original: MessageEntity): String {
- val timestamp = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm", java.util.Locale.getDefault())
- .format(java.util.Date(original.timestamp))
+ val timestamp = Instant.ofEpochMilli(original.timestamp)
+ .atZone(ZoneId.systemDefault())
+ .format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm", Locale.getDefault()))
val unknown = stringProvider.getString(R.string.forward_context_unknown)
return when (original.type) {
@@ -591,6 +593,7 @@ class ChatRepositoryImpl(
if (saveResult.isSuccess) {
sendSystemCommand(payload.senderId, "ACK_${payload.id}")
+ .onFailure { Logger.w("ChatRepository -> Failed to send ACK for ${payload.id}: ${it.message}") }
} else {
Logger.e("Failed to save incoming message from $peerId", tag = "ChatRepository")
}
@@ -684,6 +687,7 @@ class ChatRepositoryImpl(
// Send ACK to sender
sendSystemCommand(payload.senderId, "ACK_${payload.id}")
+ .onFailure { Logger.w("ChatRepository -> Failed to send ACK for ${payload.id}: ${it.message}") }
Logger.d("ChatRepository -> File payload processed successfully from $peerId")
} catch (e: Exception) {
@@ -691,7 +695,7 @@ class ChatRepositoryImpl(
}
}
- override suspend fun sendSystemCommand(peerId: String, command: String) {
+ override suspend fun sendSystemCommand(peerId: String, command: String): Result {
val myId = settingsRepository.getDeviceId()
val payload = Payload(
senderId = myId,
@@ -699,8 +703,8 @@ class ChatRepositoryImpl(
data = command.toByteArray()
)
val transport = transportManager.selectBestTransport(peerId).firstOrNull()
- ?: throw IllegalStateException("No available transport for peer: $peerId")
- transport.sendPayload(peerId, payload)
+ ?: return Result.failure(Exception("No available transport for peer: $peerId"))
+ return transport.sendPayload(peerId, payload)
}
override suspend fun retryPendingMessages(peerId: String): Result {
diff --git a/core/data/src/main/java/com/p2p/meshify/core/data/repository/FileManagerImpl.kt b/core/data/src/main/java/com/p2p/meshify/core/data/repository/FileManagerImpl.kt
index 74adf2b0..9f763fd5 100644
--- a/core/data/src/main/java/com/p2p/meshify/core/data/repository/FileManagerImpl.kt
+++ b/core/data/src/main/java/com/p2p/meshify/core/data/repository/FileManagerImpl.kt
@@ -1,7 +1,6 @@
package com.p2p.meshify.core.data.repository
import android.content.Context
-import com.p2p.meshify.core.data.local.entity.MessageEntity
import com.p2p.meshify.core.util.Logger
import com.p2p.meshify.domain.repository.IFileManager
import java.io.File
@@ -34,12 +33,4 @@ class FileManagerImpl(private val context: Context) : IFileManager {
}
}
- override fun getAppVersion(): String {
- return try {
- val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0)
- packageInfo.versionName ?: "1.0"
- } catch (e: Exception) {
- "1.0"
- }
- }
}
diff --git a/core/data/src/main/java/com/p2p/meshify/core/data/repository/MessageAttachmentRepository.kt b/core/data/src/main/java/com/p2p/meshify/core/data/repository/MessageAttachmentRepository.kt
index 1d3fa4ee..376a4ed4 100644
--- a/core/data/src/main/java/com/p2p/meshify/core/data/repository/MessageAttachmentRepository.kt
+++ b/core/data/src/main/java/com/p2p/meshify/core/data/repository/MessageAttachmentRepository.kt
@@ -90,54 +90,4 @@ class MessageAttachmentRepository(
messageDao.getAllAttachments()
}
- /**
- * Delete attachments for a message.
- */
- suspend fun deleteAttachmentsForMessage(messageId: String) {
- val attachments = getAttachmentsForMessage(messageId)
- attachments.forEach { attachment ->
- // Delete file from disk
- val file = java.io.File(attachment.filePath)
- if (file.exists()) {
- file.delete()
- Logger.d("MessageAttachmentRepository -> Deleted attachment file: ${attachment.filePath}")
- }
- }
- messageDao.deleteAttachmentsForMessages(listOf(messageId))
- Logger.d("MessageAttachmentRepository -> Deleted attachments for message: $messageId")
- }
-
- /**
- * Send grouped message (album) with multiple attachments.
- * This combines saving attachments with sending the first attachment as payload.
- */
- suspend fun sendGroupedMessage(
- messageId: String,
- peerId: String,
- peerName: String,
- caption: String,
- attachments: List>,
- messageRepository: MessageRepository
- ): Result {
- if (attachments.isEmpty()) {
- return Result.failure(Exception("No attachments provided"))
- }
-
- // Save attachments first
- val saveResult = saveAttachments(messageId, attachments)
- if (saveResult.isFailure) {
- return Result.failure(saveResult.exceptionOrNull() ?: Exception("Failed to save attachments"))
- }
-
- // Send the first attachment as representative (the album will be reconstructed on receiver side)
- val firstAttachmentBytes = attachments.first().first
- return messageRepository.sendFileMessage(
- peerId = peerId,
- peerName = peerName,
- fileBytes = firstAttachmentBytes,
- fileName = "Album: $caption",
- fileType = if (attachments.all { it.second == MessageType.VIDEO }) MessageType.VIDEO else MessageType.IMAGE,
- replyToId = null
- )
- }
}
diff --git a/core/data/src/main/java/com/p2p/meshify/core/data/repository/MessageRepository.kt b/core/data/src/main/java/com/p2p/meshify/core/data/repository/MessageRepository.kt
index c8408a1c..ce1d29f8 100644
--- a/core/data/src/main/java/com/p2p/meshify/core/data/repository/MessageRepository.kt
+++ b/core/data/src/main/java/com/p2p/meshify/core/data/repository/MessageRepository.kt
@@ -10,7 +10,6 @@ import com.p2p.meshify.core.data.local.entity.MessageEntity
import com.p2p.meshify.core.data.local.entity.MessageStatus
import com.p2p.meshify.core.data.local.entity.PendingMessageEntity
import com.p2p.meshify.core.common.util.PeerNameParser
-import com.p2p.meshify.core.util.ImageCompressor
import com.p2p.meshify.core.util.Logger
import com.p2p.meshify.domain.model.MessageType
import com.p2p.meshify.domain.model.Payload
@@ -30,9 +29,6 @@ import java.util.UUID
* MessageRepository - Responsible for sending and receiving messages.
*
* Handles:
- * - Text messages
- * - Image messages (with smart compression)
- * - Video messages
* - File messages
* - Status updates (QUEUED -> SENDING -> SENT -> DELIVERED -> READ)
* - Offline message queuing
@@ -63,145 +59,12 @@ class MessageRepository(
* 1. Use transport that already has this peer online
* 2. Fall back to LAN as default
*/
- private fun selectBestTransport(peerId: String): IMeshTransport {
+ private fun selectBestTransport(peerId: String): IMeshTransport? {
return transportManager.selectBestTransport(peerId).firstOrNull()
- ?: throw IllegalStateException("No available transport for peer: $peerId")
}
// ==================== Public API: Send Messages ====================
- /**
- * Sends a text message.
- */
- suspend fun sendTextMessage(
- peerId: String,
- peerName: String,
- text: String,
- replyToId: String?
- ): Result {
- val messageId = UUID.randomUUID().toString()
- val myId = settingsRepository.getDeviceId()
-
- val message = MessageEntity(
- id = messageId,
- chatId = peerId,
- senderId = myId,
- text = text,
- timestamp = System.currentTimeMillis(),
- isFromMe = true,
- type = MessageType.TEXT,
- status = MessageStatus.QUEUED,
- replyToId = replyToId
- )
-
- return saveAndSend(peerId, peerName, message, Payload.PayloadType.TEXT, text.toByteArray())
- }
-
- /**
- * Sends an image message with smart compression.
- */
- suspend fun sendImageMessage(
- peerId: String,
- peerName: String,
- imageBytes: ByteArray,
- extension: String,
- replyToId: String?
- ): Result {
- // Compress image before sending (smart compression)
- val compressionResult = ImageCompressor.compress(imageBytes, maxSize = 1920, targetSizeKB = 500)
- Logger.d("MessageRepository -> Image compressed: ${compressionResult.originalSize / 1024}KB → ${compressionResult.compressedSize / 1024}KB (${compressionResult.compressionRatio.toInt()}% reduction)")
-
- val messageId = UUID.randomUUID().toString()
- val myId = settingsRepository.getDeviceId()
- val fileName = "sent_$messageId.$extension"
- val savedPath = fileManager.saveMedia(fileName, compressionResult.bytes)
-
- // Verify file was saved successfully before proceeding
- if (savedPath == null) {
- Logger.e("MessageRepository -> Failed to save image to disk")
- return Result.failure(Exception("Failed to save image"))
- }
-
- // Verify file exists before sending
- val file = File(savedPath)
- if (!file.exists()) {
- Logger.e("MessageRepository -> Saved file does not exist: $savedPath")
- return Result.failure(Exception("Saved file not found"))
- }
-
- Logger.d("MessageRepository -> Image saved successfully: $savedPath (${file.length()} bytes)")
-
- val message = MessageEntity(
- id = messageId,
- chatId = peerId,
- senderId = myId,
- text = null,
- mediaPath = savedPath,
- type = MessageType.IMAGE,
- timestamp = System.currentTimeMillis(),
- isFromMe = true,
- status = MessageStatus.QUEUED,
- replyToId = replyToId
- )
-
- return saveAndSend(peerId, peerName, message, Payload.PayloadType.FILE, compressionResult.bytes)
- }
-
- /**
- * Sends a video message.
- */
- suspend fun sendVideoMessage(
- peerId: String,
- peerName: String,
- videoBytes: ByteArray,
- extension: String,
- replyToId: String?
- ): Result {
- // Validate video size before processing (50MB limit)
- val videoSize = videoBytes.size
- val maxVideoSize = 50 * 1024 * 1024 // 50MB
- if (videoSize > maxVideoSize) {
- val sizeMB = videoSize / 1024 / 1024
- Logger.e("MessageRepository -> Video too large: ${sizeMB}MB (max ${maxVideoSize / 1024 / 1024}MB)")
- return Result.failure(Exception("Video too large (max 50MB)"))
- }
-
- val messageId = UUID.randomUUID().toString()
- val myId = settingsRepository.getDeviceId()
- val fileName = "sent_vid_$messageId.$extension"
- val savedPath = fileManager.saveMedia(fileName, videoBytes)
-
- // Verify file was saved successfully before proceeding
- if (savedPath == null) {
- Logger.e("MessageRepository -> Failed to save video to disk")
- return Result.failure(Exception("Failed to save video"))
- }
-
- // Verify file exists before sending
- val file = File(savedPath)
- if (!file.exists()) {
- Logger.e("MessageRepository -> Saved video file does not exist: $savedPath")
- return Result.failure(Exception("Saved video file not found"))
- }
-
- Logger.d("MessageRepository -> Video saved successfully: $savedPath (${file.length()} bytes)")
-
- val message = MessageEntity(
- id = messageId,
- chatId = peerId,
- senderId = myId,
- text = null,
- mediaPath = savedPath,
- type = MessageType.VIDEO,
- timestamp = System.currentTimeMillis(),
- isFromMe = true,
- status = MessageStatus.QUEUED,
- replyToId = replyToId
- )
-
- return saveAndSend(peerId, peerName, message, Payload.PayloadType.VIDEO, videoBytes)
- }
-
/**
* Sends a file message (generic file).
*/
@@ -368,6 +231,18 @@ class MessageRepository(
} else {
Logger.d("MessageRepository -> sendPayload succeeded, updating status to SENT")
messageDao.updateMessageStatus(message.id, MessageStatus.SENT)
+
+ // Save file locally so sender can view their own sent file
+ val extension = file.extension.ifBlank { "bin" }
+ val localFileName = "sent_${message.id}.$extension"
+ val savedPath = fileManager.saveMedia(localFileName, fileBytes)
+ if (savedPath != null) {
+ messageDao.insertMessage(message.copy(mediaPath = savedPath, status = MessageStatus.SENT))
+ Logger.d("MessageRepository -> Saved sent file locally: $savedPath")
+ } else {
+ Logger.w("MessageRepository -> Failed to save sent file locally: $localFileName")
+ }
+
Logger.d("MessageRepository -> sendFileWithProgress COMPLETE: messageId=$messageId")
return@withContext Result.success(Unit)
}
@@ -450,6 +325,20 @@ class MessageRepository(
Logger.d("MessageRepository -> Sending payload via selected transport")
val transport = selectBestTransport(peerId)
+ if (transport == null) {
+ Logger.e("MessageRepository -> No available transport for peer: $peerId")
+ messageDao.updateMessageStatus(message.id, MessageStatus.FAILED)
+ pendingMessageDao.insert(
+ PendingMessageEntity(
+ id = message.id,
+ recipientId = peerId,
+ recipientName = cleanName,
+ content = message.text ?: "[${message.type.name}]",
+ type = message.type
+ )
+ )
+ return@withContext Result.failure(Exception("No available transport for peer: $peerId"))
+ }
val result = withTimeout(SEND_TIMEOUT_MS) {
transport.sendPayload(peerId, payload)
}
diff --git a/core/data/src/main/java/com/p2p/meshify/core/data/repository/PendingMessageRepository.kt b/core/data/src/main/java/com/p2p/meshify/core/data/repository/PendingMessageRepository.kt
index 09daae2a..b3081dd2 100644
--- a/core/data/src/main/java/com/p2p/meshify/core/data/repository/PendingMessageRepository.kt
+++ b/core/data/src/main/java/com/p2p/meshify/core/data/repository/PendingMessageRepository.kt
@@ -8,7 +8,6 @@ import com.p2p.meshify.core.data.local.entity.PendingMessageEntity
import com.p2p.meshify.core.util.Logger
import com.p2p.meshify.domain.model.MessageType
import com.p2p.meshify.domain.model.Payload
-import com.p2p.meshify.domain.repository.ISettingsRepository
import com.p2p.meshify.core.network.TransportManager
import com.p2p.meshify.core.network.base.IMeshTransport
import kotlinx.coroutines.Dispatchers
@@ -19,7 +18,6 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.withContext
import java.io.File
import kotlin.math.pow
-import kotlin.random.Random
/**
* PendingMessageRepository - Responsible for managing pending messages.
@@ -34,8 +32,7 @@ import kotlin.random.Random
class PendingMessageRepository(
private val pendingMessageDao: PendingMessageDao,
private val messageDao: MessageDao,
- private val transportManager: TransportManager,
- private val settingsRepository: ISettingsRepository
+ private val transportManager: TransportManager
) {
companion object {
@@ -64,39 +61,6 @@ class PendingMessageRepository(
}
}
- /**
- * Queue a message for later delivery.
- */
- suspend fun queueMessage(
- messageId: String,
- recipientId: String,
- recipientName: String,
- content: String,
- type: MessageType
- ) {
- val pendingMessage = PendingMessageEntity(
- id = messageId,
- recipientId = recipientId,
- recipientName = recipientName,
- content = content,
- type = type,
- status = MessageStatus.QUEUED,
- retryCount = 0,
- maxRetries = RETRY_MAX_ATTEMPTS
- )
- pendingMessageDao.insert(pendingMessage)
- refreshPendingState()
- Logger.w("PendingMessageRepository -> Message queued: $messageId for $recipientId")
- }
-
- /**
- * Get all pending messages for a recipient.
- */
- suspend fun getPendingMessages(recipientId: String): List =
- withContext(Dispatchers.IO) {
- pendingMessageDao.getByRecipient(recipientId)
- }
-
/**
* Retry all pending messages for a peer with exponential backoff.
*/
@@ -256,50 +220,4 @@ class PendingMessageRepository(
return (cappedDelay + jitter).coerceAtLeast(RETRY_BASE_DELAY_MS)
}
- /**
- * Delete a pending message by ID.
- */
- suspend fun deletePendingMessage(messageId: String) {
- pendingMessageDao.deleteById(messageId)
- refreshPendingState()
- }
-
- /**
- * Get all pending messages (for debugging/inspection).
- */
- suspend fun getAllPendingMessages(): List =
- withContext(Dispatchers.IO) {
- pendingMessageDao.getAll()
- }
-
- /**
- * Auto-retry pending messages for a peer who just came online.
- * Call this from transport event handlers when a peer transitions to connected.
- * Only retries if there are actually queued messages for this peer.
- */
- suspend fun retryForOnlinePeer(peerId: String) {
- val pending = withContext(Dispatchers.IO) { pendingMessageDao.getByRecipient(peerId) }
- if (pending.isNotEmpty()) {
- Logger.i("PendingMessageRepository -> Peer $peerId came online, retrying ${pending.size} pending message(s)")
- retryPendingMessages(peerId)
- }
- }
-
- /**
- * Get count of pending messages for a specific recipient.
- */
- suspend fun getPendingCountForRecipient(recipientId: String): Int =
- withContext(Dispatchers.IO) {
- pendingMessageDao.getByRecipient(recipientId).size
- }
-
- /**
- * Clear all pending messages (for testing / admin).
- */
- suspend fun clearAllPending() {
- withContext(Dispatchers.IO) {
- pendingMessageDao.deleteByStatus(MessageStatus.QUEUED)
- }
- refreshPendingState()
- }
}
diff --git a/core/data/src/main/java/com/p2p/meshify/core/data/repository/ReactionRepository.kt b/core/data/src/main/java/com/p2p/meshify/core/data/repository/ReactionRepository.kt
index 5013c5ff..0d688d6b 100644
--- a/core/data/src/main/java/com/p2p/meshify/core/data/repository/ReactionRepository.kt
+++ b/core/data/src/main/java/com/p2p/meshify/core/data/repository/ReactionRepository.kt
@@ -61,18 +61,4 @@ class ReactionRepository(
}
}
- /**
- * Remove reaction from a message.
- */
- suspend fun removeReaction(messageId: String): Result {
- return addReaction(messageId, null)
- }
-
- /**
- * Get reaction for a specific message.
- */
- suspend fun getReaction(messageId: String): String? = withContext(Dispatchers.IO) {
- val message = messageDao.getMessageById(messageId)
- message?.reaction
- }
}
diff --git a/core/data/src/main/java/com/p2p/meshify/core/data/repository/SettingsRepository.kt b/core/data/src/main/java/com/p2p/meshify/core/data/repository/SettingsRepository.kt
index 95cb8a8d..ba0c0d3a 100644
--- a/core/data/src/main/java/com/p2p/meshify/core/data/repository/SettingsRepository.kt
+++ b/core/data/src/main/java/com/p2p/meshify/core/data/repository/SettingsRepository.kt
@@ -10,6 +10,10 @@ import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStore
import com.p2p.meshify.core.util.Logger
+import com.p2p.meshify.domain.model.BubbleStyle
+import com.p2p.meshify.domain.model.FontFamilyPreset
+import com.p2p.meshify.domain.model.MotionPreset
+import com.p2p.meshify.domain.model.ShapeStyle
import com.p2p.meshify.domain.model.TransportMode
import com.p2p.meshify.domain.repository.ISettingsRepository
import com.p2p.meshify.domain.repository.ThemeMode
@@ -52,6 +56,15 @@ class SettingsRepository(
val KEY_NOTIFICATIONS_ENABLED = booleanPreferencesKey("notifications_enabled")
val KEY_NOTIFICATION_SOUND = booleanPreferencesKey("notification_sound")
val KEY_NOTIFICATION_VIBRATE = booleanPreferencesKey("notification_vibrate")
+
+ // MD3E design configuration keys
+ val KEY_SHAPE_STYLE = stringPreferencesKey("shape_style")
+ val KEY_MOTION_PRESET = stringPreferencesKey("motion_preset")
+ val KEY_MOTION_SCALE = floatPreferencesKey("motion_scale")
+ val KEY_FONT_FAMILY = stringPreferencesKey("font_family")
+ val KEY_CUSTOM_FONT_URI = stringPreferencesKey("custom_font_uri")
+ val KEY_BUBBLE_STYLE = stringPreferencesKey("bubble_style")
+ val KEY_VISUAL_DENSITY = floatPreferencesKey("visual_density")
}
override val displayName: Flow = prefsStore.data.map { preferences ->
@@ -62,6 +75,7 @@ class SettingsRepository(
try {
ThemeMode.valueOf(preferences[KEY_THEME_MODE] ?: "SYSTEM")
} catch (e: Exception) {
+ Logger.e("SettingsRepository -> Failed to read themeMode", e)
ThemeMode.SYSTEM
}
}
@@ -82,11 +96,60 @@ class SettingsRepository(
preferences[KEY_AVATAR_HASH]
}
- override val seedColor: Flow = context.dataStore.data.map { preferences ->
+ // MD3E Settings Flows
+ override val shapeStyle: Flow = prefsStore.data.map { preferences ->
+ try {
+ ShapeStyle.valueOf(preferences[KEY_SHAPE_STYLE] ?: "CIRCLE")
+ } catch (e: Exception) {
+ Logger.e("SettingsRepository -> Failed to read shapeStyle", e)
+ ShapeStyle.CIRCLE
+ }
+ }
+
+ override val motionPreset: Flow = prefsStore.data.map { preferences ->
+ try {
+ MotionPreset.valueOf(preferences[KEY_MOTION_PRESET] ?: "STANDARD")
+ } catch (e: Exception) {
+ Logger.e("SettingsRepository -> Failed to read motionPreset", e)
+ MotionPreset.STANDARD
+ }
+ }
+
+ override val motionScale: Flow = prefsStore.data.map { preferences ->
+ preferences[KEY_MOTION_SCALE] ?: 1.0f
+ }
+
+ override val fontFamilyPreset: Flow = prefsStore.data.map { preferences ->
+ try {
+ FontFamilyPreset.valueOf(preferences[KEY_FONT_FAMILY] ?: "ROBOTO")
+ } catch (e: Exception) {
+ Logger.e("SettingsRepository -> Failed to read fontFamilyPreset", e)
+ FontFamilyPreset.ROBOTO
+ }
+ }
+
+ override val customFontUri: Flow = prefsStore.data.map { preferences ->
+ preferences[KEY_CUSTOM_FONT_URI]
+ }
+
+ override val bubbleStyle: Flow = prefsStore.data.map { preferences ->
+ try {
+ BubbleStyle.valueOf(preferences[KEY_BUBBLE_STYLE] ?: "ROUNDED")
+ } catch (e: Exception) {
+ Logger.e("SettingsRepository -> Failed to read bubbleStyle", e)
+ BubbleStyle.ROUNDED
+ }
+ }
+
+ override val visualDensity: Flow = prefsStore.data.map { preferences ->
+ preferences[KEY_VISUAL_DENSITY] ?: 1.0f
+ }
+
+ override val seedColor: Flow = prefsStore.data.map { preferences ->
preferences[KEY_SEED_COLOR] ?: 0xFF006D68.toInt()
}
- override val bleEnabled: Flow = context.dataStore.data.map { preferences ->
+ override val bleEnabled: Flow = prefsStore.data.map { preferences ->
preferences[KEY_BLE_ENABLED] ?: false
}
@@ -94,15 +157,16 @@ class SettingsRepository(
try {
TransportMode.valueOf(preferences[KEY_TRANSPORT_MODE] ?: "MULTI_PATH")
} catch (e: Exception) {
+ Logger.e("SettingsRepository -> Failed to read transportMode", e)
TransportMode.MULTI_PATH
}
}
- override val hasCompletedOnboarding: Flow = context.dataStore.data.map { preferences ->
+ override val hasCompletedOnboarding: Flow = prefsStore.data.map { preferences ->
preferences[KEY_ONBOARDING_COMPLETED] ?: false
}
- override val appLanguage: Flow = context.dataStore.data.map { preferences ->
+ override val appLanguage: Flow = prefsStore.data.map { preferences ->
preferences[KEY_APP_LANGUAGE] ?: "en"
}
@@ -132,6 +196,7 @@ class SettingsRepository(
}
newId
} catch (e: Exception) {
+ Logger.e("SettingsRepository -> Failed to read deviceId", e)
UUID.randomUUID().toString()
}
}
@@ -187,6 +252,54 @@ class SettingsRepository(
}
}
+ override suspend fun setShapeStyle(style: ShapeStyle) {
+ safeEdit { it[KEY_SHAPE_STYLE] = style.name }.onFailure { e ->
+ Logger.e("SettingsRepository -> Failed to set shape style", e)
+ }
+ }
+
+ override suspend fun setMotionPreset(preset: MotionPreset) {
+ safeEdit { it[KEY_MOTION_PRESET] = preset.name }.onFailure { e ->
+ Logger.e("SettingsRepository -> Failed to set motion preset", e)
+ }
+ }
+
+ override suspend fun setMotionScale(scale: Float) {
+ safeEdit { it[KEY_MOTION_SCALE] = scale.coerceIn(0.5f, 2.0f) }.onFailure { e ->
+ Logger.e("SettingsRepository -> Failed to set motion scale", e)
+ }
+ }
+
+ override suspend fun setFontFamilyPreset(family: FontFamilyPreset) {
+ safeEdit { it[KEY_FONT_FAMILY] = family.name }.onFailure { e ->
+ Logger.e("SettingsRepository -> Failed to set font family preset", e)
+ }
+ }
+
+ override suspend fun setCustomFontUri(uri: String?) {
+ if (uri != null) {
+ safeEdit { it[KEY_CUSTOM_FONT_URI] = uri }.onFailure { e ->
+ Logger.e("SettingsRepository -> Failed to set custom font URI", e)
+ }
+ } else {
+ safeEdit { it.remove(KEY_CUSTOM_FONT_URI) }.onFailure { e ->
+ Logger.e("SettingsRepository -> Failed to remove custom font URI", e)
+ }
+ }
+ }
+
+ override suspend fun setBubbleStyle(style: BubbleStyle) {
+ safeEdit { it[KEY_BUBBLE_STYLE] = style.name }.onFailure { e ->
+ Logger.e("SettingsRepository -> Failed to set bubble style", e)
+ }
+ }
+
+ override suspend fun setVisualDensity(density: Float) {
+ safeEdit { it[KEY_VISUAL_DENSITY] = density.coerceIn(0.8f, 1.5f) }.onFailure { e ->
+ Logger.e("SettingsRepository -> Failed to set visual density", e)
+ }
+ }
+
override suspend fun setBleEnabled(enabled: Boolean) {
safeEdit { it[KEY_BLE_ENABLED] = enabled }.onFailure { e ->
Logger.e("SettingsRepository -> Failed to set BLE enabled", e)
@@ -296,11 +409,41 @@ class SettingsRepository(
}
}
+ override suspend fun importBackup(json: String): Result {
+ return try {
+ val backupData: Map = Json.decodeFromString(json)
+ prefsStore.edit { prefs ->
+ backupData.forEach { (key, value) ->
+ when (key) {
+ "display_name" -> prefs[KEY_DISPLAY_NAME] = value
+ "theme_mode" -> prefs[KEY_THEME_MODE] = value
+ "dynamic_color" -> prefs[KEY_DYNAMIC_COLOR] = value.toBoolean()
+ "haptic_feedback" -> prefs[KEY_HAPTIC_FEEDBACK] = value.toBoolean()
+ "network_visible" -> prefs[KEY_NETWORK_VISIBLE] = value.toBoolean()
+ "avatar_hash" -> prefs[KEY_AVATAR_HASH] = value
+ "seed_color" -> prefs[KEY_SEED_COLOR] = value.toInt()
+ "app_language" -> prefs[KEY_APP_LANGUAGE] = value
+ "font_size_scale" -> prefs[KEY_FONT_SIZE_SCALE] = value.toFloat()
+ "notifications_enabled" -> prefs[KEY_NOTIFICATIONS_ENABLED] = value.toBoolean()
+ "notification_sound" -> prefs[KEY_NOTIFICATION_SOUND] = value.toBoolean()
+ "notification_vibrate" -> prefs[KEY_NOTIFICATION_VIBRATE] = value.toBoolean()
+ "ble_enabled" -> prefs[KEY_BLE_ENABLED] = value.toBoolean()
+ "transport_mode" -> prefs[KEY_TRANSPORT_MODE] = value
+ }
+ }
+ }
+ Result.success(Unit)
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
override fun getAppVersion(): String {
return try {
val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0)
packageInfo.versionName ?: "1.0"
} catch (e: Exception) {
+ Logger.e("SettingsRepository -> Failed to read app version", e)
"1.0"
}
}
diff --git a/core/data/src/main/java/com/p2p/meshify/core/util/NotificationHelper.kt b/core/data/src/main/java/com/p2p/meshify/core/util/NotificationHelper.kt
index 975271b6..48ce4740 100644
--- a/core/data/src/main/java/com/p2p/meshify/core/util/NotificationHelper.kt
+++ b/core/data/src/main/java/com/p2p/meshify/core/util/NotificationHelper.kt
@@ -33,25 +33,6 @@ class NotificationHelper(private val context: Context) {
private const val PREFS_NAME = "notification_helper"
}
- /**
- * Post a notification with error handling.
- * @param id Notification ID
- * @param notification Notification to post
- * @return true if notification was posted successfully, false otherwise
- */
- fun NotificationManagerCompat.postNotification(id: Int, notification: Notification): Boolean {
- return try {
- notify(id, notification)
- true
- } catch (e: SecurityException) {
- Logger.e("NotificationHelper -> POST_NOTIFICATIONS permission denied", e)
- false
- } catch (e: Exception) {
- Logger.e("NotificationHelper -> Failed to post notification", e)
- false
- }
- }
-
fun createNotificationChannels() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val messagesChannel = NotificationChannel(
diff --git a/core/data/src/test/java/com/p2p/meshify/core/data/local/dao/MessageDaoTest.kt b/core/data/src/test/java/com/p2p/meshify/core/data/local/dao/MessageDaoTest.kt
deleted file mode 100644
index 601bce17..00000000
--- a/core/data/src/test/java/com/p2p/meshify/core/data/local/dao/MessageDaoTest.kt
+++ /dev/null
@@ -1,789 +0,0 @@
-package com.p2p.meshify.core.data.local.dao
-
-import androidx.room.Room
-import androidx.test.core.app.ApplicationProvider
-import com.p2p.meshify.core.data.local.MeshifyDatabase
-import com.p2p.meshify.core.data.local.entity.ChatEntity
-import com.p2p.meshify.core.data.local.entity.MessageAttachmentEntity
-import com.p2p.meshify.core.data.local.entity.MessageEntity
-import com.p2p.meshify.core.data.local.entity.MessageStatus
-import com.p2p.meshify.domain.model.MessageType
-import kotlinx.coroutines.flow.first
-import kotlinx.coroutines.test.runTest
-import org.junit.After
-import org.junit.Assert.assertEquals
-import org.junit.Assert.assertFalse
-import org.junit.Assert.assertNotNull
-import org.junit.Assert.assertNull
-import org.junit.Assert.assertTrue
-import org.junit.Before
-import org.junit.Test
-import org.junit.runner.RunWith
-import org.robolectric.RobolectricTestRunner
-import java.util.UUID
-
-/**
- * DAO tests using Room in-memory database with Robolectric.
- *
- * These tests verify the actual Room SQL queries and entity mappings
- * against an in-memory SQLite database, ensuring data persistence
- * behavior is correct.
- *
- * Each test is independent — no shared state between tests.
- */
-@RunWith(RobolectricTestRunner::class)
-class MessageDaoTest {
-
- private lateinit var db: MeshifyDatabase
- private lateinit var messageDao: MessageDao
- private lateinit var chatDao: ChatDao
-
- @Before
- fun setup() {
- db = Room.inMemoryDatabaseBuilder(
- ApplicationProvider.getApplicationContext(),
- MeshifyDatabase::class.java
- ).allowMainThreadQueries().build()
-
- messageDao = db.messageDao()
- chatDao = db.chatDao()
- }
-
- @After
- fun teardown() {
- db.close()
- }
-
- // ============================================================================================
- // TEST HELPERS
- // ============================================================================================
-
- private fun createTestMessage(
- id: String = UUID.randomUUID().toString(),
- chatId: String = "test-peer",
- senderId: String = "my-id",
- text: String = "Test message",
- type: MessageType = MessageType.TEXT,
- timestamp: Long = System.currentTimeMillis(),
- isFromMe: Boolean = true,
- status: MessageStatus = MessageStatus.SENT,
- isDeletedForMe: Boolean = false,
- isDeletedForEveryone: Boolean = false,
- reaction: String? = null,
- replyToId: String? = null,
- groupId: String? = null
- ): MessageEntity {
- return MessageEntity(
- id = id,
- chatId = chatId,
- senderId = senderId,
- text = text,
- type = type,
- timestamp = timestamp,
- isFromMe = isFromMe,
- status = status,
- isDeletedForMe = isDeletedForMe,
- isDeletedForEveryone = isDeletedForEveryone,
- reaction = reaction,
- replyToId = replyToId,
- groupId = groupId
- )
- }
-
- private suspend fun createTestChat(
- peerId: String = "test-peer",
- peerName: String = "Test Peer",
- lastMessage: String? = "Test",
- lastTimestamp: Long = System.currentTimeMillis()
- ) {
- chatDao.insertChat(ChatEntity(peerId, peerName, lastMessage, lastTimestamp))
- }
-
- // ============================================================================================
- // INSERT AND RETRIEVE BY ID TESTS
- // ============================================================================================
-
- @Test
- fun `insertMessage and getMessageById returns saved message`() = runTest {
- // Given
- val message = createTestMessage(id = "msg-001", text = "Hello Room")
-
- // When
- messageDao.insertMessage(message)
- val result = messageDao.getMessageById("msg-001")
-
- // Then
- assertNotNull(result)
- assertEquals("msg-001", result?.id)
- assertEquals("Hello Room", result?.text)
- assertEquals(MessageType.TEXT, result?.type)
- assertEquals(MessageStatus.SENT, result?.status)
- assertTrue(result?.isFromMe == true)
- }
-
- @Test
- fun `getMessageById returns null for non-existent message`() = runTest {
- // When
- val result = messageDao.getMessageById("does-not-exist")
-
- // Then
- assertNull(result)
- }
-
- @Test
- fun `insertMessage with default values works correctly`() = runTest {
- // Given
- val message = MessageEntity(
- id = "msg-default",
- chatId = "peer1",
- senderId = "sender1",
- text = null,
- timestamp = 1000L,
- isFromMe = false
- )
-
- // When
- messageDao.insertMessage(message)
- val result = messageDao.getMessageById("msg-default")
-
- // Then
- assertNotNull(result)
- assertNull(result?.text)
- assertNull(result?.mediaPath)
- assertEquals(MessageType.TEXT, result?.type)
- assertEquals(MessageStatus.SENT, result?.status)
- assertFalse(result?.isDeletedForMe == true)
- assertFalse(result?.isDeletedForEveryone == true)
- assertNull(result?.reaction)
- assertNull(result?.replyToId)
- assertNull(result?.groupId)
- }
-
- // ============================================================================================
- // GET MESSAGES FOR PEER TESTS
- // ============================================================================================
-
- @Test
- fun `getAllMessagesForChat returns messages for specific peer only`() = runTest {
- // Given
- createTestChat("peer-a")
- createTestChat("peer-b")
-
- val msgA1 = createTestMessage(id = "msg-a1", chatId = "peer-a", text = "A1", timestamp = 1000L)
- val msgA2 = createTestMessage(id = "msg-a2", chatId = "peer-a", text = "A2", timestamp = 2000L)
- val msgB1 = createTestMessage(id = "msg-b1", chatId = "peer-b", text = "B1", timestamp = 3000L)
-
- messageDao.insertMessages(listOf(msgA1, msgA2, msgB1))
-
- // When
- val result = messageDao.getAllMessagesForChat("peer-a").first()
-
- // Then
- assertEquals(2, result.size)
- assertEquals("A1", result[0].text)
- assertEquals("A2", result[1].text)
- }
-
- @Test
- fun `getAllMessagesForChat returns empty list for unknown peer`() = runTest {
- // When
- val result = messageDao.getAllMessagesForChat("unknown-peer").first()
-
- // Then
- assertTrue(result.isEmpty())
- }
-
- @Test
- fun `getAllMessagesForChat returns messages ordered by timestamp`() = runTest {
- // Given
- createTestChat("peer-ordered")
-
- val msg3 = createTestMessage(id = "msg-3", chatId = "peer-ordered", text = "Third", timestamp = 3000L)
- val msg1 = createTestMessage(id = "msg-1", chatId = "peer-ordered", text = "First", timestamp = 1000L)
- val msg2 = createTestMessage(id = "msg-2", chatId = "peer-ordered", text = "Second", timestamp = 2000L)
-
- messageDao.insertMessages(listOf(msg3, msg1, msg2))
-
- // When
- val result = messageDao.getAllMessagesForChat("peer-ordered").first()
-
- // Then
- assertEquals(3, result.size)
- assertEquals("First", result[0].text)
- assertEquals("Second", result[1].text)
- assertEquals("Third", result[2].text)
- }
-
- // ============================================================================================
- // PAGINATION TESTS
- // ============================================================================================
-
- @Test
- fun `getMessagesPaged returns correct page with limit and offset`() = runTest {
- // Given
- createTestChat("peer-paged")
-
- val messages = (1..10).map { i ->
- createTestMessage(
- id = "msg-$i",
- chatId = "peer-paged",
- text = "Message $i",
- timestamp = i.toLong() * 1000
- )
- }
- messageDao.insertMessages(messages)
-
- // When: Get page 2 (offset 5, limit 3)
- val result = messageDao.getMessagesPaged("peer-paged", limit = 3, offset = 5).first()
-
- // Then
- assertEquals(3, result.size)
- assertEquals("Message 6", result[0].text)
- assertEquals("Message 7", result[1].text)
- assertEquals("Message 8", result[2].text)
- }
-
- @Test
- fun `getMessagesPaged returns partial page when offset near end`() = runTest {
- // Given
- createTestChat("peer-partial")
-
- val messages = (1..5).map { i ->
- createTestMessage(
- id = "partial-$i",
- chatId = "peer-partial",
- text = "Msg $i",
- timestamp = i.toLong() * 1000
- )
- }
- messageDao.insertMessages(messages)
-
- // When: Request 10 items starting at offset 3 (only 2 exist)
- val result = messageDao.getMessagesPaged("peer-partial", limit = 10, offset = 3).first()
-
- // Then
- assertEquals(2, result.size)
- assertEquals("Msg 4", result[0].text)
- assertEquals("Msg 5", result[1].text)
- }
-
- @Test
- fun `getMessagesPaged returns empty when offset exceeds total`() = runTest {
- // Given
- createTestChat("peer-empty-page")
-
- val messages = (1..3).map { i ->
- createTestMessage(
- id = "empty-$i",
- chatId = "peer-empty-page",
- text = "Msg $i",
- timestamp = i.toLong() * 1000
- )
- }
- messageDao.insertMessages(messages)
-
- // When
- val result = messageDao.getMessagesPaged("peer-empty-page", limit = 5, offset = 10).first()
-
- // Then
- assertTrue(result.isEmpty())
- }
-
- // ============================================================================================
- // MESSAGE STATUS UPDATE TESTS
- // ============================================================================================
-
- @Test
- fun `updateMessageStatus changes status correctly`() = runTest {
- // Given
- val message = createTestMessage(id = "status-msg", status = MessageStatus.QUEUED)
- messageDao.insertMessage(message)
-
- // When
- messageDao.updateMessageStatus("status-msg", MessageStatus.SENT)
- val result = messageDao.getMessageById("status-msg")
-
- // Then
- assertEquals(MessageStatus.SENT, result?.status)
- }
-
- @Test
- fun `updateMessageStatus follows full lifecycle`() = runTest {
- // Given
- val message = createTestMessage(id = "lifecycle-msg", status = MessageStatus.QUEUED)
- messageDao.insertMessage(message)
-
- // When: QUEUED -> SENDING -> SENT -> DELIVERED -> READ
- messageDao.updateMessageStatus("lifecycle-msg", MessageStatus.SENDING)
- assertEquals(MessageStatus.SENDING, messageDao.getMessageById("lifecycle-msg")?.status)
-
- messageDao.updateMessageStatus("lifecycle-msg", MessageStatus.SENT)
- assertEquals(MessageStatus.SENT, messageDao.getMessageById("lifecycle-msg")?.status)
-
- messageDao.updateMessageStatus("lifecycle-msg", MessageStatus.DELIVERED)
- assertEquals(MessageStatus.DELIVERED, messageDao.getMessageById("lifecycle-msg")?.status)
-
- messageDao.updateMessageStatus("lifecycle-msg", MessageStatus.READ)
- assertEquals(MessageStatus.READ, messageDao.getMessageById("lifecycle-msg")?.status)
- }
-
- @Test
- fun `updateMessageStatus to FAILED works correctly`() = runTest {
- // Given
- val message = createTestMessage(id = "failed-msg", status = MessageStatus.SENDING)
- messageDao.insertMessage(message)
-
- // When
- messageDao.updateMessageStatus("failed-msg", MessageStatus.FAILED)
- val result = messageDao.getMessageById("failed-msg")
-
- // Then
- assertEquals(MessageStatus.FAILED, result?.status)
- }
-
- // ============================================================================================
- // DELETE MESSAGE TESTS
- // ============================================================================================
-
- @Test
- fun `deleteMessages removes specified messages`() = runTest {
- // Given
- val msg1 = createTestMessage(id = "del-1")
- val msg2 = createTestMessage(id = "del-2")
- val msg3 = createTestMessage(id = "del-3")
- messageDao.insertMessages(listOf(msg1, msg2, msg3))
-
- // When
- messageDao.deleteMessages(listOf("del-1", "del-3"))
-
- // Then
- assertNull(messageDao.getMessageById("del-1"))
- assertNotNull(messageDao.getMessageById("del-2"))
- assertNull(messageDao.getMessageById("del-3"))
- }
-
- @Test
- fun `deleteMessages with empty list does nothing`() = runTest {
- // Given
- val message = createTestMessage(id = "keep-msg")
- messageDao.insertMessage(message)
-
- // When
- messageDao.deleteMessages(emptyList())
-
- // Then
- assertNotNull(messageDao.getMessageById("keep-msg"))
- }
-
- @Test
- fun `markAsDeletedForMe sets isDeletedForMe flag`() = runTest {
- // Given
- val message = createTestMessage(id = "delete-me", isDeletedForMe = false)
- messageDao.insertMessage(message)
-
- // When
- messageDao.markAsDeletedForMe("delete-me")
- val result = messageDao.getMessageById("delete-me")
-
- // Then
- assertTrue(result?.isDeletedForMe == true)
- // Other fields should remain unchanged
- assertFalse(result?.isDeletedForEveryone == true)
- assertEquals("Test message", result?.text)
- }
-
- @Test
- fun `markAsDeletedForEveryone sets all deletion fields`() = runTest {
- // Given
- val message = createTestMessage(id = "delete-all", isDeletedForEveryone = false)
- messageDao.insertMessage(message)
- val deletedAt = System.currentTimeMillis()
- val deletedBy = "my-id"
-
- // When
- messageDao.markAsDeletedForEveryone("delete-all", deletedAt, deletedBy)
- val result = messageDao.getMessageById("delete-all")
-
- // Then
- assertTrue(result?.isDeletedForEveryone == true)
- assertEquals(deletedAt, result?.deletedAt)
- assertEquals(deletedBy, result?.deletedBy)
- }
-
- // ============================================================================================
- // REPLY-TO MESSAGE LINKAGE TESTS
- // ============================================================================================
-
- @Test
- fun `insertMessage with replyToId preserves reply linkage`() = runTest {
- // Given
- val originalMessage = createTestMessage(id = "original-msg", text = "Original")
- val replyMessage = createTestMessage(
- id = "reply-msg",
- text = "This is a reply",
- replyToId = "original-msg"
- )
-
- // When
- messageDao.insertMessages(listOf(originalMessage, replyMessage))
- val reply = messageDao.getMessageById("reply-msg")
-
- // Then
- assertNotNull(reply)
- assertEquals("original-msg", reply?.replyToId)
- assertEquals("This is a reply", reply?.text)
- }
-
- @Test
- fun `message without replyToId has null reply linkage`() = runTest {
- // Given
- val message = createTestMessage(id = "no-reply", replyToId = null)
-
- // When
- messageDao.insertMessage(message)
- val result = messageDao.getMessageById("no-reply")
-
- // Then
- assertNull(result?.replyToId)
- }
-
- // ============================================================================================
- // GROUPED MESSAGE TESTS
- // ============================================================================================
-
- @Test
- fun `insertMessage with groupId links grouped messages`() = runTest {
- // Given
- val groupId = "group-album-001"
- val coverMessage = createTestMessage(
- id = "cover-msg",
- text = "Album cover",
- groupId = groupId
- )
- val attachmentMsg1 = createTestMessage(
- id = "attach-1",
- text = "Photo 1",
- groupId = groupId
- )
- val attachmentMsg2 = createTestMessage(
- id = "attach-2",
- text = "Photo 2",
- groupId = groupId
- )
-
- // When
- messageDao.insertMessages(listOf(coverMessage, attachmentMsg1, attachmentMsg2))
-
- // Query all messages with this groupId
- val allMessages = messageDao.getAllMessagesForChat("test-peer").first()
- val groupedMessages = allMessages.filter { it.groupId == groupId }
-
- // Then
- assertEquals(3, groupedMessages.size)
- assertTrue(groupedMessages.all { it.groupId == groupId })
- }
-
- @Test
- fun `getMessagesByIds returns specified messages`() = runTest {
- // Given
- val msg1 = createTestMessage(id = "batch-1", text = "First")
- val msg2 = createTestMessage(id = "batch-2", text = "Second")
- val msg3 = createTestMessage(id = "batch-3", text = "Third")
- messageDao.insertMessages(listOf(msg1, msg2, msg3))
-
- // When
- val result = messageDao.getMessagesByIds(listOf("batch-1", "batch-3"))
-
- // Then
- assertEquals(2, result.size)
- assertTrue(result.any { it.id == "batch-1" })
- assertTrue(result.any { it.id == "batch-3" })
- assertFalse(result.any { it.id == "batch-2" })
- }
-
- @Test
- fun `getMessagesByIds with empty list returns empty`() = runTest {
- // Given
- val message = createTestMessage(id = "solo-msg")
- messageDao.insertMessage(message)
-
- // When
- val result = messageDao.getMessagesByIds(emptyList())
-
- // Then
- assertTrue(result.isEmpty())
- }
-
- // ============================================================================================
- // MESSAGE ATTACHMENT TESTS
- // ============================================================================================
-
- @Test
- fun `insertMessageAttachment and getAttachmentsForMessage work correctly`() = runTest {
- // Given
- val message = createTestMessage(id = "attach-parent")
- messageDao.insertMessage(message)
-
- val attachment1 = MessageAttachmentEntity(
- id = "att-1",
- type = MessageType.IMAGE,
- messageId = "attach-parent",
- filePath = "/path/to/image1.jpg"
- )
- val attachment2 = MessageAttachmentEntity(
- id = "att-2",
- type = MessageType.VIDEO,
- messageId = "attach-parent",
- filePath = "/path/to/video1.mp4"
- )
-
- // When
- messageDao.insertMessageAttachments(listOf(attachment1, attachment2))
- val result = messageDao.getAttachmentsForMessage("attach-parent")
-
- // Then
- assertEquals(2, result.size)
- assertTrue(result.any { it.id == "att-1" && it.type == MessageType.IMAGE })
- assertTrue(result.any { it.id == "att-2" && it.type == MessageType.VIDEO })
- }
-
- @Test
- fun `getAttachmentsForMessage returns empty for message without attachments`() = runTest {
- // Given
- val message = createTestMessage(id = "no-attach-msg")
- messageDao.insertMessage(message)
-
- // When
- val result = messageDao.getAttachmentsForMessage("no-attach-msg")
-
- // Then
- assertTrue(result.isEmpty())
- }
-
- @Test
- fun `deleteAttachmentsForMessages removes attachments for specified messages`() = runTest {
- // Given
- val msg1 = createTestMessage(id = "del-attach-1")
- val msg2 = createTestMessage(id = "del-attach-2")
- messageDao.insertMessages(listOf(msg1, msg2))
-
- val att1 = MessageAttachmentEntity(id = "att-msg1", type = MessageType.IMAGE, messageId = "del-attach-1", filePath = "/path1")
- val att2 = MessageAttachmentEntity(id = "att-msg2", type = MessageType.IMAGE, messageId = "del-attach-2", filePath = "/path2")
- messageDao.insertMessageAttachments(listOf(att1, att2))
-
- // When
- messageDao.deleteAttachmentsForMessages(listOf("del-attach-1"))
-
- // Then
- val remaining1 = messageDao.getAttachmentsForMessage("del-attach-1")
- val remaining2 = messageDao.getAttachmentsForMessage("del-attach-2")
- assertTrue(remaining1.isEmpty())
- assertEquals(1, remaining2.size)
- }
-
- // ============================================================================================
- // UPDATE REACTION TESTS
- // ============================================================================================
-
- @Test
- fun `updateReaction sets reaction on message`() = runTest {
- // Given
- val message = createTestMessage(id = "reaction-msg", reaction = null)
- messageDao.insertMessage(message)
-
- // When
- messageDao.updateReaction("reaction-msg", "👍")
- val result = messageDao.getMessageById("reaction-msg")
-
- // Then
- assertEquals("👍", result?.reaction)
- }
-
- @Test
- fun `updateReaction can remove reaction by setting null`() = runTest {
- // Given
- val message = createTestMessage(id = "remove-reaction", reaction = "❤️")
- messageDao.insertMessage(message)
-
- // When
- messageDao.updateReaction("remove-reaction", null)
- val result = messageDao.getMessageById("remove-reaction")
-
- // Then
- assertNull(result?.reaction)
- }
-
- // ============================================================================================
- // DELETE ALL MESSAGES FOR CHAT TESTS
- // ============================================================================================
-
- @Test
- fun `deleteAllMessagesForChat removes only messages for that chat`() = runTest {
- // Given
- createTestChat("chat-to-delete")
- createTestChat("chat-to-keep")
-
- val msgA1 = createTestMessage(id = "del-a1", chatId = "chat-to-delete", text = "A1")
- val msgA2 = createTestMessage(id = "del-a2", chatId = "chat-to-delete", text = "A2")
- val msgB1 = createTestMessage(id = "keep-b1", chatId = "chat-to-keep", text = "B1")
-
- messageDao.insertMessages(listOf(msgA1, msgA2, msgB1))
-
- // When
- messageDao.deleteAllMessagesForChat("chat-to-delete")
-
- // Then
- val remainingA = messageDao.getAllMessagesForChat("chat-to-delete").first()
- val remainingB = messageDao.getAllMessagesForChat("chat-to-keep").first()
- assertTrue(remainingA.isEmpty())
- assertEquals(1, remainingB.size)
- assertEquals("B1", remainingB[0].text)
- }
-
- // ============================================================================================
- // MEDIA MESSAGE TESTS
- // ============================================================================================
-
- @Test
- fun `insertMessage with mediaPath stores correctly`() = runTest {
- // Given
- val message = MessageEntity(
- id = "media-msg",
- chatId = "peer-media",
- senderId = "my-id",
- text = "Check this image",
- mediaPath = "/storage/emulated/0/Pictures/meshify/img_001.jpg",
- type = MessageType.IMAGE,
- timestamp = System.currentTimeMillis(),
- isFromMe = true,
- status = MessageStatus.SENT
- )
-
- // When
- messageDao.insertMessage(message)
- val result = messageDao.getMessageById("media-msg")
-
- // Then
- assertNotNull(result)
- assertEquals("/storage/emulated/0/Pictures/meshify/img_001.jpg", result?.mediaPath)
- assertEquals(MessageType.IMAGE, result?.type)
- }
-
- @Test
- fun `insertMessage with VIDEO type stores correctly`() = runTest {
- // Given
- val message = MessageEntity(
- id = "video-msg",
- chatId = "peer-video",
- senderId = "my-id",
- text = null,
- mediaPath = "/storage/video.mp4",
- type = MessageType.VIDEO,
- timestamp = System.currentTimeMillis(),
- isFromMe = true,
- status = MessageStatus.SENT
- )
-
- // When
- messageDao.insertMessage(message)
- val result = messageDao.getMessageById("video-msg")
-
- // Then
- assertNotNull(result)
- assertEquals(MessageType.VIDEO, result?.type)
- assertEquals("/storage/video.mp4", result?.mediaPath)
- }
-
- // ============================================================================================
- // REPLACE ON CONFLICT TESTS
- // ============================================================================================
-
- @Test
- fun `insertMessage with same ID replaces existing message`() = runTest {
- // Given
- val original = createTestMessage(id = "replace-msg", text = "Original text", timestamp = 1000L)
- messageDao.insertMessage(original)
-
- // When: Insert with same ID but different content
- val updated = createTestMessage(id = "replace-msg", text = "Updated text", timestamp = 2000L)
- messageDao.insertMessage(updated)
-
- // Then
- val result = messageDao.getMessageById("replace-msg")
- assertNotNull(result)
- assertEquals("Updated text", result?.text)
- assertEquals(2000L, result?.timestamp)
- }
-
- // ============================================================================================
- // CONCURRENT WRITE TESTS
- // ============================================================================================
-
- @Test
- fun `concurrent inserts do not corrupt data`() = runTest {
- // Given
- val messages = (1..20).map { i ->
- createTestMessage(
- id = "concurrent-$i",
- chatId = "peer-concurrent",
- text = "Msg $i",
- timestamp = i.toLong() * 100
- )
- }
-
- // When: Insert all messages (simulating concurrent writes via sequential inserts)
- messages.forEach { messageDao.insertMessage(it) }
-
- // Then
- val result = messageDao.getAllMessagesForChat("peer-concurrent").first()
- assertEquals(20, result.size)
- // Verify no data corruption — all texts should be intact
- for (i in 1..20) {
- val found = result.any { it.text == "Msg $i" }
- assertTrue("Message $i should exist", found)
- }
- }
-
- @Test
- fun `rapid status updates on same message are consistent`() = runTest {
- // Given
- val message = createTestMessage(id = "rapid-status", status = MessageStatus.QUEUED)
- messageDao.insertMessage(message)
-
- // When: Rapid status changes
- messageDao.updateMessageStatus("rapid-status", MessageStatus.SENDING)
- messageDao.updateMessageStatus("rapid-status", MessageStatus.SENT)
- messageDao.updateMessageStatus("rapid-status", MessageStatus.DELIVERED)
-
- // Then
- val result = messageDao.getMessageById("rapid-status")
- assertEquals(MessageStatus.DELIVERED, result?.status)
- }
-
- @Test
- fun `getAllAttachments returns all attachments in database`() = runTest {
- // Given
- val msg = createTestMessage(id = "attach-all-parent")
- messageDao.insertMessage(msg)
-
- val attachments = (1..5).map { i ->
- MessageAttachmentEntity(
- id = "all-attach-$i",
- type = MessageType.IMAGE,
- messageId = "attach-all-parent",
- filePath = "/path/file_$i.jpg"
- )
- }
- messageDao.insertMessageAttachments(attachments)
-
- // When
- val result = messageDao.getAllAttachments()
-
- // Then
- assertTrue(result.size >= 5)
- // Our 5 attachments should all be present
- for (i in 1..5) {
- assertTrue(result.any { it.id == "all-attach-$i" })
- }
- }
-}
diff --git a/core/data/src/test/java/com/p2p/meshify/core/data/repository/ChatManagementRepositoryTest.kt b/core/data/src/test/java/com/p2p/meshify/core/data/repository/ChatManagementRepositoryTest.kt
deleted file mode 100644
index bdc1f516..00000000
--- a/core/data/src/test/java/com/p2p/meshify/core/data/repository/ChatManagementRepositoryTest.kt
+++ /dev/null
@@ -1,384 +0,0 @@
-package com.p2p.meshify.core.data.repository
-
-import android.content.Context
-import com.p2p.meshify.core.data.local.dao.ChatDao
-import com.p2p.meshify.core.data.local.dao.MessageDao
-import com.p2p.meshify.core.data.local.entity.ChatEntity
-import com.p2p.meshify.core.data.local.entity.MessageEntity
-import com.p2p.meshify.core.data.local.entity.MessageStatus
-import com.p2p.meshify.domain.model.DeleteType
-import com.p2p.meshify.domain.model.MessageType
-import io.mockk.coEvery
-import io.mockk.coVerify
-import io.mockk.every
-import io.mockk.mockk
-import io.mockk.slot
-import kotlinx.coroutines.flow.first
-import kotlinx.coroutines.flow.flowOf
-import kotlinx.coroutines.test.runTest
-import org.junit.Assert.assertEquals
-import org.junit.Assert.assertFalse
-import org.junit.Assert.assertTrue
-import org.junit.Before
-import org.junit.Test
-import org.junit.runner.RunWith
-import org.robolectric.RobolectricTestRunner
-
-/**
- * Unit tests for ChatManagementRepository.
- *
- * Covers:
- * - Chat CRUD operations
- * - Message deletion (for me / for everyone)
- * - Message forwarding to single and multiple peers
- * - Search operations
- * - Edge cases (empty results, missing messages)
- */
-@RunWith(RobolectricTestRunner::class)
-class ChatManagementRepositoryTest {
-
- private val chatDao: ChatDao = mockk(relaxed = true)
- private val messageDao: MessageDao = mockk(relaxed = true)
-
- private lateinit var repository: ChatManagementRepository
-
- private val testPeerId = "peer-123"
- private val testPeerName = "Alice"
- private val testMessageId = "msg-001"
-
- @Before
- fun setup() {
- // Use relaxed mockk for context - this handles all getString calls
- // by returning default empty strings. The forwarded message content
- // is not validated for exact string matching in these tests.
- val mockContext = mockk(relaxed = true)
-
- repository = ChatManagementRepository(
- context = mockContext,
- chatDao = chatDao,
- messageDao = messageDao
- )
- }
-
- // ============================================================================================
- // getAllChats() TESTS
- // ============================================================================================
-
- @Test
- fun `getAllChats returns empty list from DAO`() = runTest {
- every { chatDao.getAllChats() } returns flowOf(emptyList())
- val chats = repository.getAllChats().first()
- assertTrue(chats.isEmpty())
- }
-
- @Test
- fun `getAllChats returns chats from DAO ordered by lastTimestamp`() = runTest {
- val expected = listOf(
- ChatEntity("peer1", "Alice", "Hello", 2000L),
- ChatEntity("peer2", "Bob", "Hi", 1000L)
- )
- every { chatDao.getAllChats() } returns flowOf(expected)
-
- val chats = repository.getAllChats().first()
- assertEquals(2, chats.size)
- assertEquals("peer1", chats[0].peerId)
- assertEquals("peer2", chats[1].peerId)
- }
-
- // ============================================================================================
- // searchChats() TESTS
- // ============================================================================================
-
- @Test
- fun `searchChats returns matching chats`() = runTest {
- val expected = listOf(
- ChatEntity("peer1", "Alice", "Hello there", 1000L)
- )
- every { chatDao.searchChats("Alice") } returns flowOf(expected)
-
- val results = repository.searchChats("Alice").first()
- assertEquals(1, results.size)
- }
-
- @Test
- fun `searchChats returns empty for no match`() = runTest {
- every { chatDao.searchChats("ZZZ") } returns flowOf(emptyList())
- val results = repository.searchChats("ZZZ").first()
- assertTrue(results.isEmpty())
- }
-
- // ============================================================================================
- // getMessages() TESTS
- // ============================================================================================
-
- @Test
- fun `getMessages returns messages for chat`() = runTest {
- val expected = listOf(
- MessageEntity(
- id = "msg1", chatId = testPeerId, senderId = testPeerId,
- text = "Hello", type = MessageType.TEXT,
- timestamp = 1000L, isFromMe = false, status = MessageStatus.SENT
- )
- )
- every { messageDao.getAllMessagesForChat(testPeerId) } returns flowOf(expected)
-
- val messages = repository.getMessages(testPeerId).first()
- assertEquals(1, messages.size)
- assertEquals("Hello", messages.first().text)
- }
-
- // ============================================================================================
- // getMessagesPaged() TESTS
- // ============================================================================================
-
- @Test
- fun `getMessagesPaged returns paginated slice`() = runTest {
- val expected = listOf(
- MessageEntity(
- id = "msg1", chatId = testPeerId, senderId = testPeerId,
- text = "Page item", type = MessageType.TEXT,
- timestamp = 1000L, isFromMe = false, status = MessageStatus.SENT
- )
- )
- every { messageDao.getMessagesPaged(testPeerId, 10, 0) } returns flowOf(expected)
-
- val page = repository.getMessagesPaged(testPeerId, 10, 0).first()
- assertEquals(1, page.size)
- }
-
- // ============================================================================================
- // deleteChat() TESTS
- // ============================================================================================
-
- @Test
- fun `deleteChat removes chat and all messages`() = runTest {
- repository.deleteChat(testPeerId)
-
- coVerify { chatDao.deleteChatById(testPeerId) }
- coVerify { messageDao.deleteAllMessagesForChat(testPeerId) }
- }
-
- @Test
- fun `deleteChat throws when DAO fails`() = runTest {
- coEvery { chatDao.deleteChatById(any()) } throws RuntimeException("DB error")
-
- try {
- repository.deleteChat(testPeerId)
- assertFalse("Expected exception to be thrown", true)
- } catch (e: Exception) {
- assertEquals("DB error", e.message)
- }
- }
-
- // ============================================================================================
- // markChatAsRead() TESTS
- // ============================================================================================
-
- @Test
- fun `markChatAsRead resets unread count`() = runTest {
- repository.markChatAsRead(testPeerId)
- coVerify { chatDao.resetUnreadCount(testPeerId) }
- }
-
- @Test
- fun `markChatAsRead throws when DAO fails`() = runTest {
- coEvery { chatDao.resetUnreadCount(any()) } throws RuntimeException("DB error")
-
- try {
- repository.markChatAsRead(testPeerId)
- assertFalse("Expected exception to be thrown", true)
- } catch (e: Exception) {
- assertEquals("DB error", e.message)
- }
- }
-
- // ============================================================================================
- // deleteMessage() TESTS
- // ============================================================================================
-
- @Test
- fun `deleteMessage for me marks message as deleted`() = runTest {
- // Given
- val message = MessageEntity(
- id = testMessageId, chatId = testPeerId, senderId = testPeerId,
- text = "Delete me", type = MessageType.TEXT,
- timestamp = 1000L, isFromMe = false, status = MessageStatus.SENT
- )
- coEvery { messageDao.getMessageById(testMessageId) } returns message
-
- // When
- val result = repository.deleteMessage(testMessageId, DeleteType.DELETE_FOR_ME)
-
- // Then
- assertTrue(result.isSuccess)
- coVerify { messageDao.markAsDeletedForMe(testMessageId) }
- coVerify(exactly = 0) { messageDao.markAsDeletedForEveryone(any(), any(), any()) }
- }
-
- @Test
- fun `deleteMessage for everyone marks message and sends delete request`() = runTest {
- // Given
- val message = MessageEntity(
- id = testMessageId, chatId = testPeerId, senderId = testPeerId,
- text = "Delete for all", type = MessageType.TEXT,
- timestamp = 1000L, isFromMe = false, status = MessageStatus.SENT
- )
- coEvery { messageDao.getMessageById(testMessageId) } returns message
-
- // When
- val result = repository.deleteMessage(testMessageId, DeleteType.DELETE_FOR_EVERYONE)
-
- // Then
- assertTrue(result.isSuccess)
- coVerify { messageDao.markAsDeletedForEveryone(eq(testMessageId), any(), eq(testPeerId)) }
- }
-
- @Test
- fun `deleteMessage returns failure when message not found`() = runTest {
- coEvery { messageDao.getMessageById("nonexistent") } returns null
-
- val result = repository.deleteMessage("nonexistent", DeleteType.DELETE_FOR_ME)
-
- assertTrue(result.isFailure)
- assertTrue(result.exceptionOrNull()?.message?.contains("not found") == true)
- }
-
- @Test
- fun `deleteMessage returns failure on exception`() = runTest {
- coEvery { messageDao.getMessageById(any()) } throws RuntimeException("DB crash")
-
- val result = repository.deleteMessage(testMessageId, DeleteType.DELETE_FOR_ME)
-
- assertTrue(result.isFailure)
- }
-
- // ============================================================================================
- // forwardMessage() TESTS
- // ============================================================================================
-
- @Test
- fun `forwardMessage returns failure when original message not found`() = runTest {
- coEvery { messageDao.getMessageById("nonexistent") } returns null
-
- val result = repository.forwardMessage("nonexistent", listOf("peer1"))
-
- assertTrue(result.isFailure)
- assertTrue(result.exceptionOrNull()?.message?.contains("not found") == true)
- }
-
- @Test
- fun `forwardMessage creates messages for each target peer`() = runTest {
- // Given: original text message
- val originalMessage = MessageEntity(
- id = testMessageId, chatId = "source-peer", senderId = "original-sender",
- text = "Hello everyone!", type = MessageType.TEXT,
- timestamp = 1000L, isFromMe = false, status = MessageStatus.SENT
- )
- coEvery { messageDao.getMessageById(testMessageId) } returns originalMessage
- coEvery { chatDao.getChatById("target-1") } returns null
- coEvery { chatDao.getChatById("target-2") } returns null
-
- // When: forward to 2 peers
- val result = repository.forwardMessage(testMessageId, listOf("target-1", "target-2"))
-
- // Then
- assertTrue("Expected success but got: ${result.exceptionOrNull()}", result.isSuccess)
-
- // Should have inserted 2 messages (one per target)
- coVerify(exactly = 2) { messageDao.insertMessage(any()) }
-
- // Should have created and updated chat records (2 per peer = 4 total)
- // Each peer gets: 1x create chat + 1x update lastMessage
- coVerify(exactly = 4) { chatDao.insertChat(any()) }
- }
-
- @Test
- fun `forwardMessage preserves original message type`() = runTest {
- // Given: image message
- val originalMessage = MessageEntity(
- id = testMessageId, chatId = "source-peer", senderId = "original-sender",
- text = null, mediaPath = "/path/to/image.jpg",
- type = MessageType.IMAGE, timestamp = 1000L,
- isFromMe = false, status = MessageStatus.SENT
- )
- coEvery { messageDao.getMessageById(testMessageId) } returns originalMessage
- coEvery { chatDao.getChatById("target-1") } returns null
-
- // When
- val result = repository.forwardMessage(testMessageId, listOf("target-1"))
-
- // Then
- assertTrue("Expected success but got: ${result.exceptionOrNull()}", result.isSuccess)
-
- val messageSlot = slot()
- coVerify { messageDao.insertMessage(capture(messageSlot)) }
-
- assertEquals(MessageType.IMAGE, messageSlot.captured.type)
- assertEquals("/path/to/image.jpg", messageSlot.captured.mediaPath)
- assertTrue(messageSlot.captured.isFromMe)
- }
-
- @Test
- fun `forwardMessage uses existing chat when available`() = runTest {
- // Given: existing chat
- val originalMessage = MessageEntity(
- id = testMessageId, chatId = "source-peer", senderId = "original-sender",
- text = "Forward this", type = MessageType.TEXT,
- timestamp = 1000L, isFromMe = false, status = MessageStatus.SENT
- )
- val existingChat = ChatEntity("target-1", "Bob", "Previous", 500L)
-
- coEvery { messageDao.getMessageById(testMessageId) } returns originalMessage
- coEvery { chatDao.getChatById("target-1") } returns existingChat
-
- // When
- val result = repository.forwardMessage(testMessageId, listOf("target-1"))
-
- // Then
- assertTrue("Expected success but got: ${result.exceptionOrNull()}", result.isSuccess)
- // Chat updated with new lastMessage
- coVerify { chatDao.insertChat(any()) }
- }
-
- @Test
- fun `forwardMessage handles failure during forwarding`() = runTest {
- // Given: message DAO throws on second insert
- val originalMessage = MessageEntity(
- id = testMessageId, chatId = "source-peer", senderId = "original-sender",
- text = "Forward", type = MessageType.TEXT,
- timestamp = 1000L, isFromMe = false, status = MessageStatus.SENT
- )
- coEvery { messageDao.getMessageById(testMessageId) } returns originalMessage
- coEvery { chatDao.getChatById(any()) } returns null
- coEvery { messageDao.insertMessage(any()) } throws RuntimeException("Insert failed")
-
- // When
- val result = repository.forwardMessage(testMessageId, listOf("target-1"))
-
- // Then: failed
- assertTrue(result.isFailure)
- }
-
- // ============================================================================================
- // @Deprecated copyMessageToChat TESTS
- // ============================================================================================
-
- @Test
- fun `forwardMessage with single peer works as delegation`() = runTest {
- // Given: this is a private method, but we test forwardMessage which it delegates to
- val originalMessage = MessageEntity(
- id = testMessageId, chatId = "source-peer", senderId = "original-sender",
- text = "Deprecated test", type = MessageType.TEXT,
- timestamp = 1000L, isFromMe = false, status = MessageStatus.SENT
- )
- coEvery { messageDao.getMessageById(testMessageId) } returns originalMessage
- coEvery { chatDao.getChatById("target-1") } returns null
-
- // When: forward to single peer (same as copyMessageToChat)
- val result = repository.forwardMessage(testMessageId, listOf("target-1"))
-
- // Then: still works
- assertTrue("Expected success but got: ${result.exceptionOrNull()}", result.isSuccess)
- coVerify { messageDao.insertMessage(any()) }
- }
-}
diff --git a/core/data/src/test/java/com/p2p/meshify/core/data/repository/ChatRepositoryImplTest.kt b/core/data/src/test/java/com/p2p/meshify/core/data/repository/ChatRepositoryImplTest.kt
deleted file mode 100644
index 87d444de..00000000
--- a/core/data/src/test/java/com/p2p/meshify/core/data/repository/ChatRepositoryImplTest.kt
+++ /dev/null
@@ -1,336 +0,0 @@
-package com.p2p.meshify.core.data.repository
-
-import android.app.Application
-import android.content.Context
-import com.p2p.meshify.core.data.local.MeshifyDatabase
-import com.p2p.meshify.core.data.local.dao.ChatDao
-import com.p2p.meshify.core.data.local.dao.MessageDao
-import com.p2p.meshify.core.data.local.dao.PendingMessageDao
-import com.p2p.meshify.core.data.local.entity.ChatEntity
-import com.p2p.meshify.core.data.local.entity.MessageEntity
-import com.p2p.meshify.core.data.local.entity.MessageStatus
-import com.p2p.meshify.core.network.TransportManager
-import com.p2p.meshify.core.common.util.StringResourceProvider
-import com.p2p.meshify.core.util.NotificationHelper
-import com.p2p.meshify.domain.model.DeleteType
-import com.p2p.meshify.domain.model.MessageType
-import com.p2p.meshify.domain.repository.IFileManager
-import com.p2p.meshify.domain.repository.ISettingsRepository
-import io.mockk.coEvery
-import io.mockk.coVerify
-import io.mockk.every
-import io.mockk.mockk
-import io.mockk.slot
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.first
-import kotlinx.coroutines.flow.flowOf
-import kotlinx.coroutines.test.runTest
-import org.junit.Assert.assertEquals
-import org.junit.Assert.assertFalse
-import org.junit.Assert.assertNotNull
-import org.junit.Assert.assertTrue
-import org.junit.Before
-import org.junit.Test
-
-/**
- * Unit tests for ChatRepositoryImpl.
- *
- * Note: ChatRepositoryImpl is a facade that internally instantiates sub-repositories
- * (MessageRepository, ChatManagementRepository, PendingMessageRepository, etc.).
- * These tests verify the facade's public API behavior by mocking all dependencies
- * and asserting correct DAO interactions.
- *
- * For full coverage, the sub-repositories should have their own unit tests.
- */
-class ChatRepositoryImplTest {
-
- // Core dependencies
- private val mockContext: Context = mockk(relaxed = true) {
- every { applicationContext } returns this@mockk
- }
- private val mockStringProvider: StringResourceProvider = mockk(relaxed = true)
- private val mockDatabase: MeshifyDatabase = mockk(relaxed = true)
- private val mockChatDao: ChatDao = mockk(relaxed = true)
- private val mockMessageDao: MessageDao = mockk(relaxed = true)
- private val mockPendingMessageDao: PendingMessageDao = mockk(relaxed = true)
- private val mockTransportManager: TransportManager = mockk(relaxed = true)
- private val mockFileManager: IFileManager = mockk(relaxed = true)
- private val mockNotificationHelper: NotificationHelper = mockk(relaxed = true)
- private val mockSettingsRepository: ISettingsRepository = mockk(relaxed = true)
-
- // Reusable test flows
- private val emptyOnlinePeersFlow = MutableStateFlow>(emptySet())
-
- private lateinit var repository: ChatRepositoryImpl
-
- @Before
- fun setup() {
- // Default stubs for settings
- coEvery { mockSettingsRepository.getDeviceId() } returns "my-device-id"
- every { mockSettingsRepository.displayName } returns flowOf("TestUser")
- every { mockSettingsRepository.avatarHash } returns flowOf(null)
-
- // Default: no online peers (peer is offline)
- every { mockTransportManager.getAllTransports() } returns emptyList()
-
- // String provider defaults
- every { mockStringProvider.getString(any(), *varargAny { true }) } returns "mocked string"
- every { mockContext.getString(any()) } returns "mocked string"
-
- // Mock transport for all tests
- val mockTransport = mockk(relaxed = true)
- every { mockTransport.onlinePeers } returns emptyOnlinePeersFlow
- coEvery { mockTransport.sendPayload(any(), any()) } returns Result.success(Unit)
- every { mockTransportManager.selectBestTransport(any()) } returns listOf(mockTransport)
-
- repository = ChatRepositoryImpl(
- context = mockContext,
- stringProvider = mockStringProvider,
- database = mockDatabase,
- chatDao = mockChatDao,
- messageDao = mockMessageDao,
- pendingMessageDao = mockPendingMessageDao,
- transportManager = mockTransportManager,
- fileManager = mockFileManager,
- notificationHelper = mockNotificationHelper,
- settingsRepository = mockSettingsRepository
- )
- }
-
- // ============================================================================================
- // getAllChats() TESTS
- // ============================================================================================
-
- @Test
- fun `getAllChats emits empty list initially`() = runTest {
- // Given
- every { mockChatDao.getAllChats() } returns flowOf(emptyList())
-
- // When
- val result = repository.getAllChats().first()
-
- // Then
- assertTrue(result.isEmpty())
- coVerify(exactly = 0) { mockMessageDao.getAllMessagesForChat(any()) }
- }
-
- @Test
- fun `getAllChats emits list of chats from DAO`() = runTest {
- // Given
- val expectedChats = listOf(
- ChatEntity("peer1", "Alice", "Hello", 1000L),
- ChatEntity("peer2", "Bob", "Hi there", 2000L)
- )
- every { mockChatDao.getAllChats() } returns flowOf(expectedChats)
-
- // When
- val result = repository.getAllChats().first()
-
- // Then
- assertEquals(2, result.size)
- assertEquals("peer1", result[0].peerId)
- assertEquals("peer2", result[1].peerId)
- }
-
- // ============================================================================================
- // getMessages() TESTS
- // ============================================================================================
-
- @Test
- fun `getMessages returns messages for a peer`() = runTest {
- // Given
- val expectedMessages = listOf(
- MessageEntity(
- id = "msg1",
- chatId = "peer1",
- senderId = "peer1",
- text = "Hello",
- type = MessageType.TEXT,
- timestamp = 1000L,
- isFromMe = false,
- status = MessageStatus.SENT
- ),
- MessageEntity(
- id = "msg2",
- chatId = "peer1",
- senderId = "my-device-id",
- text = "Hi back",
- type = MessageType.TEXT,
- timestamp = 2000L,
- isFromMe = true,
- status = MessageStatus.SENT
- )
- )
- every { mockMessageDao.getAllMessagesForChat("peer1") } returns flowOf(expectedMessages)
-
- // When
- val result = repository.getMessages("peer1").first()
-
- // Then
- assertEquals(2, result.size)
- assertEquals("msg1", result[0].id)
- assertEquals("msg2", result[1].id)
- }
-
- @Test
- fun `getMessages returns empty list for peer with no messages`() = runTest {
- // Given
- every { mockMessageDao.getAllMessagesForChat("unknown-peer") } returns flowOf(emptyList())
-
- // When
- val result = repository.getMessages("unknown-peer").first()
-
- // Then
- assertTrue(result.isEmpty())
- }
-
- // ============================================================================================
- // getMessagesPaged() TESTS
- // ============================================================================================
-
- @Test
- fun `getMessagesPaged returns paginated messages`() = runTest {
- // Given
- val expectedMessages = listOf(
- MessageEntity(
- id = "msg1",
- chatId = "peer1",
- senderId = "peer1",
- text = "First",
- type = MessageType.TEXT,
- timestamp = 1000L,
- isFromMe = false,
- status = MessageStatus.SENT
- )
- )
- every { mockMessageDao.getMessagesPaged("peer1", 10, 0) } returns flowOf(expectedMessages)
-
- // When
- val result = repository.getMessagesPaged("peer1", 10, 0).first()
-
- // Then
- assertEquals(1, result.size)
- assertEquals("First", result[0].text)
- }
-
- // ============================================================================================
- // sendMessage() TESTS
- // ============================================================================================
- // Note: Testing "no session key" scenario is not possible in pure JVM unit tests
- // because it triggers Android Log usage. The scenario is covered in integration tests.
-
- @Test
- fun `sendMessage sends plaintext when peer is offline`() = runTest {
- // Given
- // Peer is offline
- every { mockTransportManager.getAllTransports() } returns emptyList()
-
- // When
- val result = repository.sendMessage("peer1", "Alice", "Hello", null)
-
- // Then
- assertTrue(result.isSuccess)
- // Message should be saved
- val messageSlot = slot()
- coVerify { mockMessageDao.insertMessage(capture(messageSlot)) }
- assertEquals("Hello", messageSlot.captured.text)
- assertEquals(MessageStatus.QUEUED, messageSlot.captured.status)
- // And queued for later delivery
- coVerify { mockPendingMessageDao.insert(any()) }
- }
-
- // ============================================================================================
- // sendGroupedMessage() TESTS
- // ============================================================================================
- // These tests require mocking internal repositories (MessageRepository, etc.)
- // which trigger Android Log usage and ECDH handshake polling.
- // Moved to integration tests.
-
- // ============================================================================================
- // deleteMessage() TESTS
- // ============================================================================================
-
- @Test
- fun `deleteMessage for me marks message as deleted`() = runTest {
- // Given
- val message = MessageEntity(
- id = "msg1",
- chatId = "peer1",
- senderId = "my-device-id",
- text = "Delete me",
- type = MessageType.TEXT,
- timestamp = 1000L,
- isFromMe = true,
- status = MessageStatus.SENT
- )
- coEvery { mockMessageDao.getMessageById("msg1") } returns message
-
- // When
- val result = repository.deleteMessage("msg1", DeleteType.DELETE_FOR_ME)
-
- // Then
- assertTrue(result.isSuccess)
- coVerify { mockMessageDao.markAsDeletedForMe("msg1") }
- }
- @Test
- fun `deleteMessage returns failure when message not found`() = runTest {
- // Given
- coEvery { mockMessageDao.getMessageById("nonexistent") } returns null
-
- // When
- val result = repository.deleteMessage("nonexistent", DeleteType.DELETE_FOR_ME)
-
- // Then
- assertTrue(result.isFailure)
- assertTrue(result.exceptionOrNull()?.message?.contains("not found") == true)
- }
-
- // ============================================================================================
- // deleteChat() TESTS
- // ============================================================================================
-
- @Test
- fun `deleteChat removes chat and messages`() = runTest {
- // When
- repository.deleteChat("peer1")
-
- // Then
- coVerify { mockChatDao.deleteChatById("peer1") }
- coVerify { mockMessageDao.deleteAllMessagesForChat("peer1") }
- }
-
- // ============================================================================================
- // forwardMessage() TESTS
- // ============================================================================================
- @Test
- fun `forwardMessage returns failure when target peers list is empty`() = runTest {
- // Given
- val message = MessageEntity(
- id = "msg1",
- chatId = "peer1",
- senderId = "peer1",
- text = "Forward me",
- type = MessageType.TEXT,
- timestamp = 1000L,
- isFromMe = false,
- status = MessageStatus.SENT
- )
- coEvery { mockMessageDao.getMessageById("msg1") } returns message
-
- // When
- val result = repository.forwardMessage("msg1", emptyList())
-
- // Then
- assertTrue(result.isFailure)
- assertTrue(result.exceptionOrNull()?.message?.contains("No target") == true)
- }
- // ============================================================================================
- // ERROR HANDLING TESTS
- // ============================================================================================
- // These tests trigger Android Log usage, moved to integration tests.
-
- // ============================================================================================
- // addReaction() TESTS
- // ============================================================================================
- // These tests trigger Android Log usage via ReactionRepository, moved to integration tests.
-}
diff --git a/core/data/src/test/java/com/p2p/meshify/core/data/repository/FileManagerImplTest.kt b/core/data/src/test/java/com/p2p/meshify/core/data/repository/FileManagerImplTest.kt
deleted file mode 100644
index f4e92008..00000000
--- a/core/data/src/test/java/com/p2p/meshify/core/data/repository/FileManagerImplTest.kt
+++ /dev/null
@@ -1,176 +0,0 @@
-package com.p2p.meshify.core.data.repository
-
-import android.content.Context
-import androidx.test.core.app.ApplicationProvider
-import org.junit.After
-import org.junit.Assert.assertEquals
-import org.junit.Assert.assertFalse
-import org.junit.Assert.assertNotNull
-import org.junit.Assert.assertNull
-import org.junit.Assert.assertTrue
-import org.junit.Before
-import org.junit.Test
-import kotlinx.coroutines.test.runTest
-import org.junit.runner.RunWith
-import org.robolectric.RobolectricTestRunner
-import java.io.File
-
-/**
- * Unit tests for FileManagerImpl.
- *
- * Uses Robolectric for real file system access.
- */
-@RunWith(RobolectricTestRunner::class)
-class FileManagerImplTest {
-
- private lateinit var context: Context
- private lateinit var fileManager: FileManagerImpl
- private lateinit var mediaDir: File
-
- @Before
- fun setup() {
- context = ApplicationProvider.getApplicationContext()
- fileManager = FileManagerImpl(context)
- mediaDir = File(context.filesDir, "media")
- }
-
- @After
- fun teardown() {
- // Clean up media files
- if (mediaDir.exists()) {
- mediaDir.deleteRecursively()
- }
- }
-
- // ============================================================================================
- // saveMedia() TESTS
- // ============================================================================================
-
- @Test
- fun `saveMedia saves bytes to media directory`() = runTest {
- // Given
- val fileName = "test_image.jpg"
- val data = byteArrayOf(0x01, 0x02, 0x03, 0x04)
-
- // When
- val savedPath = fileManager.saveMedia(fileName, data)
-
- // Then
- assertNotNull(savedPath)
- assertTrue(savedPath!!.endsWith("media/test_image.jpg"))
-
- val savedFile = File(savedPath)
- assertTrue(savedFile.exists())
- assertEquals(4, savedFile.length())
- }
-
- @Test
- fun `saveMedia fails when media directory is missing`() = runTest {
- // Given: ensure media directory doesn't exist
- if (mediaDir.exists()) {
- mediaDir.deleteRecursively()
- }
- assertFalse(mediaDir.exists())
-
- // When: saveMedia doesn't recreate the directory itself
- val savedPath = fileManager.saveMedia("new_file.txt", byteArrayOf(0x01))
-
- // Then: save fails because parent directory doesn't exist
- assertNull(savedPath)
- }
-
- @Test
- fun `saveMedia handles duplicate filenames`() = runTest {
- // Given: first save
- val fileName = "duplicate.txt"
- val firstPath = fileManager.saveMedia(fileName, byteArrayOf(0x01))
- assertNotNull(firstPath)
-
- // When: second save with same name
- val secondPath = fileManager.saveMedia(fileName, byteArrayOf(0x02, 0x03))
-
- // Then: overwrites (no deduplication logic, last write wins)
- assertNotNull(secondPath)
- assertEquals(firstPath, secondPath)
-
- val savedFile = File(secondPath!!)
- assertEquals(2, savedFile.length()) // Second write's bytes
- }
-
- @Test
- fun `saveMedia returns null on error`() = runTest {
- // Given: try to save to an invalid path
- // Create a file at the media directory location to cause failure
- if (mediaDir.exists()) {
- mediaDir.deleteRecursively()
- }
- // Create a FILE instead of a directory at media path
- mediaDir.parentFile?.let { parent ->
- val blockingFile = File(parent, "media")
- blockingFile.writeText("not a directory")
- }
-
- // When
- val savedPath = fileManager.saveMedia("should_fail.bin", byteArrayOf(0x01))
-
- // Then: returns null
- assertNull(savedPath)
- }
-
- @Test
- fun `saveMedia saves empty byte array`() = runTest {
- // Given
- val fileName = "empty.bin"
- val data = ByteArray(0)
-
- // When
- val savedPath = fileManager.saveMedia(fileName, data)
-
- // Then
- assertNotNull(savedPath)
- val savedFile = File(savedPath!!)
- assertTrue(savedFile.exists())
- assertEquals(0, savedFile.length())
- }
-
- @Test
- fun `saveMedia saves large file`() = runTest {
- // Given: 1MB file
- val fileName = "large.bin"
- val data = ByteArray(1024 * 1024) { it.toByte() }
-
- // When
- val savedPath = fileManager.saveMedia(fileName, data)
-
- // Then
- assertNotNull(savedPath)
- val savedFile = File(savedPath!!)
- assertEquals(1024 * 1024, savedFile.length())
- }
-
- @Test
- fun `saveMedia preserves exact bytes`() = runTest {
- // Given
- val expectedData = "Hello, Meshify!".toByteArray(Charsets.UTF_8)
-
- // When
- val savedPath = fileManager.saveMedia("message.txt", expectedData)
-
- // Then
- assertNotNull(savedPath)
- val readBytes = File(savedPath!!).readBytes()
- assertTrue(expectedData.contentEquals(readBytes))
- }
-
- // ============================================================================================
- // getAppVersion() TESTS
- // ============================================================================================
-
- @Test
- fun `getAppVersion returns version string`() {
- val version = fileManager.getAppVersion()
- // Should return a non-empty string (from build config or "1.0" fallback)
- assertNotNull(version)
- assertTrue(version.isNotEmpty())
- }
-}
diff --git a/core/data/src/test/java/com/p2p/meshify/core/data/repository/MessageAttachmentRepositoryTest.kt b/core/data/src/test/java/com/p2p/meshify/core/data/repository/MessageAttachmentRepositoryTest.kt
deleted file mode 100644
index baa9903d..00000000
--- a/core/data/src/test/java/com/p2p/meshify/core/data/repository/MessageAttachmentRepositoryTest.kt
+++ /dev/null
@@ -1,434 +0,0 @@
-package com.p2p.meshify.core.data.repository
-
-import com.p2p.meshify.core.data.local.dao.MessageDao
-import com.p2p.meshify.core.data.local.entity.MessageAttachmentEntity
-import com.p2p.meshify.domain.model.MessageType
-import com.p2p.meshify.domain.repository.IFileManager
-import io.mockk.coEvery
-import io.mockk.coVerify
-import io.mockk.mockk
-import kotlinx.coroutines.test.StandardTestDispatcher
-import kotlinx.coroutines.test.runTest
-import org.junit.Assert.assertEquals
-import org.junit.Assert.assertNotNull
-import org.junit.Assert.assertTrue
-import org.junit.Before
-import org.junit.Test
-import org.junit.runner.RunWith
-import org.robolectric.RobolectricTestRunner
-
-/**
- * Unit tests for MessageAttachmentRepository.
- *
- * Covers:
- * - Saving attachments (single and multiple)
- * - Retrieving attachments for a message
- * - Deleting attachments
- * - Edge cases (empty lists, missing files)
- * - Grouped message sending
- */
-@RunWith(RobolectricTestRunner::class)
-class MessageAttachmentRepositoryTest {
-
- private val testDispatcher = StandardTestDispatcher()
-
- private val messageDao: MessageDao = mockk(relaxed = true)
- private val fileManager: IFileManager = mockk(relaxed = true)
-
- private lateinit var repository: MessageAttachmentRepository
-
- private val testMessageId = "msg-001"
- private val testFilePath = "/tmp/media/sent_album_msg-001_0.jpg"
-
- @Before
- fun setup() {
- coEvery { fileManager.saveMedia(any(), any()) } answers {
- val fileName = arg(0)
- "/tmp/media/$fileName"
- }
-
- repository = MessageAttachmentRepository(
- messageDao = messageDao,
- fileManager = fileManager,
- ioDispatcher = testDispatcher
- )
- }
-
- // ============================================================================================
- // saveAttachments() TESTS
- // ============================================================================================
-
- @Test
- fun `saveAttachments saves single attachment`() = runTest(testDispatcher) {
- // Given
- val attachments = listOf(
- byteArrayOf(0x01, 0x02, 0x03) to MessageType.IMAGE
- )
-
- // When
- val result = repository.saveAttachments(testMessageId, attachments)
-
- // Then
- assertTrue(result.isSuccess)
- val entities = result.getOrNull()
- assertNotNull(entities)
- assertEquals(1, entities!!.size)
- assertEquals(testFilePath, entities.first().filePath)
- assertEquals(MessageType.IMAGE, entities.first().type)
- assertEquals(testMessageId, entities.first().messageId)
-
- coVerify { fileManager.saveMedia(any(), any()) }
- coVerify { messageDao.insertMessageAttachments(entities) }
- }
-
- @Test
- fun `saveAttachments saves multiple attachments`() = runTest(testDispatcher) {
- // Given: 3 attachments (2 images, 1 video)
- val attachments = listOf(
- byteArrayOf(0x01) to MessageType.IMAGE,
- byteArrayOf(0x02) to MessageType.IMAGE,
- byteArrayOf(0x03) to MessageType.VIDEO
- )
-
- // When
- val result = repository.saveAttachments(testMessageId, attachments)
-
- // Then
- assertTrue("Expected success but got: ${result.exceptionOrNull()}", result.isSuccess)
-
- val entities = result.getOrNull()
- assertNotNull("entities should not be null", entities)
-
- assertEquals("Expected 3 entities", 3, entities!!.size)
-
- // Verify filenames are unique
- val filenames = entities.map { it.filePath }
- assertEquals("Unique filenames", 3, filenames.toSet().size)
-
- coVerify(exactly = 3) { fileManager.saveMedia(any(), any()) }
- coVerify { messageDao.insertMessageAttachments(any()) }
- }
-
- @Test
- fun `saveAttachments fails with empty attachments list`() = runTest(testDispatcher) {
- // Given: empty list
- val attachments = emptyList>()
-
- // When
- val result = repository.saveAttachments(testMessageId, attachments)
-
- // Then
- assertTrue(result.isFailure)
- assertTrue(result.exceptionOrNull()?.message?.contains("No attachments") == true)
- coVerify(exactly = 0) { fileManager.saveMedia(any(), any()) }
- coVerify(exactly = 0) { messageDao.insertMessageAttachments(any()) }
- }
-
- @Test
- fun `saveAttachments fails when file save fails`() = runTest(testDispatcher) {
- // Given: fileManager returns null
- coEvery { fileManager.saveMedia(any(), any()) } returns null
-
- val attachments = listOf(
- byteArrayOf(0x01) to MessageType.IMAGE
- )
-
- // When
- val result = repository.saveAttachments(testMessageId, attachments)
-
- // Then
- assertTrue(result.isFailure)
- assertTrue(result.exceptionOrNull()?.message?.contains("Failed to save attachment") == true)
- coVerify(exactly = 0) { messageDao.insertMessageAttachments(any()) }
- }
-
- @Test
- fun `saveAttachments fails if partial save fails`() = runTest(testDispatcher) {
- // Given: second save fails
- var callCount = 0
- coEvery { fileManager.saveMedia(any(), any()) } answers {
- callCount++
- if (callCount == 2) null else testFilePath
- }
-
- val attachments = listOf(
- byteArrayOf(0x01) to MessageType.IMAGE,
- byteArrayOf(0x02) to MessageType.IMAGE
- )
-
- // When
- val result = repository.saveAttachments(testMessageId, attachments)
-
- // Then: fails, no insert
- assertTrue(result.isFailure)
- coVerify(exactly = 0) { messageDao.insertMessageAttachments(any()) }
- }
-
- @Test
- fun `saveAttachments uses jpg extension for images`() = runTest(testDispatcher) {
- val attachments = listOf(
- byteArrayOf(0x01) to MessageType.IMAGE
- )
-
- repository.saveAttachments(testMessageId, attachments)
-
- coVerify { fileManager.saveMedia(match { it.contains("jpg") }, any()) }
- }
-
- @Test
- fun `saveAttachments uses mp4 extension for videos`() = runTest(testDispatcher) {
- val attachments = listOf(
- byteArrayOf(0x01) to MessageType.VIDEO
- )
-
- repository.saveAttachments(testMessageId, attachments)
-
- coVerify { fileManager.saveMedia(match { it.contains("mp4") }, any()) }
- }
-
- @Test
- fun `saveAttachments handles mixed image and video types`() = runTest(testDispatcher) {
- val attachments = listOf(
- byteArrayOf(0x01) to MessageType.IMAGE,
- byteArrayOf(0x02) to MessageType.VIDEO
- )
-
- val result = repository.saveAttachments(testMessageId, attachments)
-
- assertTrue(result.isSuccess)
- val entities = result.getOrNull()
- assertEquals(MessageType.IMAGE, entities!![0].type)
- assertEquals(MessageType.VIDEO, entities[1].type)
- }
-
- // ============================================================================================
- // getAttachmentsForMessage() TESTS
- // ============================================================================================
-
- @Test
- fun `getAttachmentsForMessage returns attachments`() = runTest(testDispatcher) {
- // Given
- val expected = listOf(
- MessageAttachmentEntity(
- id = "att-1", type = MessageType.IMAGE,
- messageId = testMessageId, filePath = "/path/to/img.jpg"
- )
- )
- coEvery { messageDao.getAttachmentsForMessage(testMessageId) } returns expected
-
- // When
- val result = repository.getAttachmentsForMessage(testMessageId)
-
- // Then
- assertEquals(1, result.size)
- assertEquals("att-1", result.first().id)
- }
-
- @Test
- fun `getAttachmentsForMessage returns empty list when none exist`() = runTest(testDispatcher) {
- // Given
- coEvery { messageDao.getAttachmentsForMessage("empty-msg") } returns emptyList()
-
- // When
- val result = repository.getAttachmentsForMessage("empty-msg")
-
- // Then
- assertTrue(result.isEmpty())
- }
-
- // ============================================================================================
- // getAllAttachments() TESTS
- // ============================================================================================
-
- @Test
- fun `getAllAttachments returns all attachments`() = runTest(testDispatcher) {
- // Given
- val expected = listOf(
- MessageAttachmentEntity(
- id = "att-1", type = MessageType.IMAGE,
- messageId = "msg-1", filePath = "/path/1.jpg"
- ),
- MessageAttachmentEntity(
- id = "att-2", type = MessageType.VIDEO,
- messageId = "msg-2", filePath = "/path/2.mp4"
- )
- )
- coEvery { messageDao.getAllAttachments() } returns expected
-
- // When
- val result = repository.getAllAttachments()
-
- // Then
- assertEquals(2, result.size)
- }
-
- // ============================================================================================
- // deleteAttachmentsForMessage() TESTS
- // ============================================================================================
-
- @Test
- fun `deleteAttachmentsForMessage deletes files and records`() = runTest(testDispatcher) {
- // Given: attachments exist
- val attachments = listOf(
- MessageAttachmentEntity(
- id = "att-1", type = MessageType.IMAGE,
- messageId = testMessageId, filePath = testFilePath
- )
- )
- coEvery { messageDao.getAttachmentsForMessage(testMessageId) } returns attachments
-
- // When
- repository.deleteAttachmentsForMessage(testMessageId)
-
- // Then: DAO delete was called
- coVerify { messageDao.deleteAttachmentsForMessages(listOf(testMessageId)) }
- }
-
- @Test
- fun `deleteAttachmentsForMessage handles empty attachments`() = runTest(testDispatcher) {
- // Given: no attachments
- coEvery { messageDao.getAttachmentsForMessage("empty-msg") } returns emptyList()
-
- // When
- repository.deleteAttachmentsForMessage("empty-msg")
-
- // Then: still calls DAO to clean up any orphaned records
- coVerify { messageDao.deleteAttachmentsForMessages(listOf("empty-msg")) }
- }
-
- @Test
- fun `deleteAttachmentsForMessage handles non-existent file paths`() = runTest(testDispatcher) {
- // Given: attachment with non-existent file
- val attachments = listOf(
- MessageAttachmentEntity(
- id = "att-1", type = MessageType.IMAGE,
- messageId = testMessageId, filePath = "/nonexistent/path/file.jpg"
- )
- )
- coEvery { messageDao.getAttachmentsForMessage(testMessageId) } returns attachments
-
- // When: should not throw
- repository.deleteAttachmentsForMessage(testMessageId)
-
- // Then: DAO delete still called
- coVerify { messageDao.deleteAttachmentsForMessages(listOf(testMessageId)) }
- }
-
- // ============================================================================================
- // sendGroupedMessage() TESTS
- // ============================================================================================
-
- @Test
- fun `sendGroupedMessage fails with empty attachments`() = runTest(testDispatcher) {
- // Given
- val emptyAttachments = emptyList>()
- val mockMessageRepo = mockk(relaxed = true)
-
- // When
- val result = repository.sendGroupedMessage(
- messageId = testMessageId,
- peerId = "peer-123",
- peerName = "Alice",
- caption = "My Album",
- attachments = emptyAttachments,
- messageRepository = mockMessageRepo
- )
-
- // Then
- assertTrue(result.isFailure)
- assertTrue(result.exceptionOrNull()?.message?.contains("No attachments") == true)
- }
-
- @Test
- fun `sendGroupedMessage fails if saveAttachments fails`() = runTest(testDispatcher) {
- // Given: first save fails
- coEvery { fileManager.saveMedia(any(), any()) } returns null
-
- val attachments = listOf(
- byteArrayOf(0x01) to MessageType.IMAGE
- )
- val mockMessageRepo = mockk(relaxed = true)
-
- // When
- val result = repository.sendGroupedMessage(
- messageId = testMessageId,
- peerId = "peer-123",
- peerName = "Alice",
- caption = "Album",
- attachments = attachments,
- messageRepository = mockMessageRepo
- )
-
- // Then
- assertTrue(result.isFailure)
- coVerify(exactly = 0) { mockMessageRepo.sendFileMessage(any(), any(), any(), any(), any(), any()) }
- }
-
- @Test
- fun `sendGroupedMessage sends first attachment as representative`() = runTest(testDispatcher) {
- // Given
- val attachments = listOf(
- byteArrayOf(0x01) to MessageType.IMAGE,
- byteArrayOf(0x02) to MessageType.IMAGE
- )
- val mockMessageRepo = mockk(relaxed = true)
- coEvery { mockMessageRepo.sendFileMessage(any(), any(), any(), any(), any(), any()) } returns Result.success(Unit)
-
- // When
- val result = repository.sendGroupedMessage(
- messageId = testMessageId,
- peerId = "peer-123",
- peerName = "Alice",
- caption = "Vacation Photos",
- attachments = attachments,
- messageRepository = mockMessageRepo
- )
-
- // Then
- assertTrue(result.isSuccess)
- coVerify {
- mockMessageRepo.sendFileMessage(
- peerId = "peer-123",
- peerName = "Alice",
- fileBytes = byteArrayOf(0x01),
- fileName = "Album: Vacation Photos",
- fileType = MessageType.IMAGE,
- replyToId = null
- )
- }
- }
-
- @Test
- fun `sendGroupedMessage sets type to VIDEO when all are videos`() = runTest(testDispatcher) {
- // Given: all video attachments
- val attachments = listOf(
- byteArrayOf(0x01) to MessageType.VIDEO,
- byteArrayOf(0x02) to MessageType.VIDEO
- )
- val mockMessageRepo = mockk(relaxed = true)
- coEvery { mockMessageRepo.sendFileMessage(any(), any(), any(), any(), any(), any()) } returns Result.success(Unit)
-
- // When
- val result = repository.sendGroupedMessage(
- messageId = testMessageId,
- peerId = "peer-123",
- peerName = "Alice",
- caption = "Videos",
- attachments = attachments,
- messageRepository = mockMessageRepo
- )
-
- // Then
- assertTrue(result.isSuccess)
- coVerify {
- mockMessageRepo.sendFileMessage(
- peerId = "peer-123",
- peerName = "Alice",
- fileBytes = any(),
- fileName = any(),
- fileType = MessageType.VIDEO,
- replyToId = null
- )
- }
- }
-}
diff --git a/core/data/src/test/java/com/p2p/meshify/core/data/repository/MessageRepositoryTest.kt b/core/data/src/test/java/com/p2p/meshify/core/data/repository/MessageRepositoryTest.kt
deleted file mode 100644
index 8da8a1dc..00000000
--- a/core/data/src/test/java/com/p2p/meshify/core/data/repository/MessageRepositoryTest.kt
+++ /dev/null
@@ -1,626 +0,0 @@
-package com.p2p.meshify.core.data.repository
-
-import android.content.Context
-import androidx.room.Room
-import androidx.test.core.app.ApplicationProvider
-import com.p2p.meshify.core.data.local.MeshifyDatabase
-import com.p2p.meshify.core.data.local.dao.ChatDao
-import com.p2p.meshify.core.data.local.dao.MessageDao
-import com.p2p.meshify.core.data.local.dao.PendingMessageDao
-import com.p2p.meshify.core.data.local.entity.MessageEntity
-import com.p2p.meshify.core.data.local.entity.MessageStatus
-import com.p2p.meshify.core.network.TransportManager
-import com.p2p.meshify.core.network.base.IMeshTransport
-import com.p2p.meshify.core.util.ImageCompressor
-import com.p2p.meshify.domain.model.MessageType
-import com.p2p.meshify.domain.repository.IFileManager
-import com.p2p.meshify.domain.repository.ISettingsRepository
-import io.mockk.coEvery
-import io.mockk.every
-import io.mockk.mockk
-import io.mockk.mockkObject
-import io.mockk.unmockkAll
-import kotlinx.coroutines.flow.first
-import kotlinx.coroutines.test.runTest
-import org.junit.After
-import org.junit.Assert.assertEquals
-import org.junit.Assert.assertNotNull
-import org.junit.Assert.assertTrue
-import org.junit.Before
-import org.junit.Test
-import org.junit.runner.RunWith
-import org.robolectric.RobolectricTestRunner
-import java.io.File
-import java.util.UUID
-
-/**
- * Unit tests for MessageRepository.
- *
- * Uses in-memory Room database for transaction testing.
- * Robolectric required for Android API access (ImageCompressor, file system).
- */
-@RunWith(RobolectricTestRunner::class)
-class MessageRepositoryTest {
-
- // In-memory database
- private lateinit var database: MeshifyDatabase
- private lateinit var messageDao: MessageDao
- private lateinit var chatDao: ChatDao
- private lateinit var pendingMessageDao: PendingMessageDao
-
- // Mocks
- private val transportManager: TransportManager = mockk(relaxed = true)
- private val mockTransport: IMeshTransport = mockk(relaxed = true)
- private val fileManager: IFileManager = mockk(relaxed = true)
- private val settingsRepository: ISettingsRepository = mockk(relaxed = true)
-
- private lateinit var repository: MessageRepository
-
- private val testPeerId = "peer-123"
- private val testPeerName = "Alice"
- private val myDeviceId = "my-device-id"
-
- @Before
- fun setup() {
- val context = ApplicationProvider.getApplicationContext()
- database = Room.inMemoryDatabaseBuilder(context, MeshifyDatabase::class.java)
- .allowMainThreadQueries()
- .build()
- messageDao = database.messageDao()
- chatDao = database.chatDao()
- pendingMessageDao = database.pendingMessageDao()
-
- // Transport mock: online by default
- every { mockTransport.onlinePeers } returns kotlinx.coroutines.flow.MutableStateFlow(setOf(testPeerId))
- every { transportManager.getAllTransports() } returns listOf(mockTransport)
- every { transportManager.selectBestTransport(any()) } returns listOf(mockTransport)
- coEvery { mockTransport.sendPayload(any(), any()) } returns Result.success(Unit)
-
- // Settings: device ID
- coEvery { settingsRepository.getDeviceId() } returns myDeviceId
- every { settingsRepository.displayName } returns kotlinx.coroutines.flow.flowOf("TestUser")
-
- // File manager: save succeeds
- coEvery { fileManager.saveMedia(any(), any()) } answers {
- val fileName = firstArg()
- val dir = context.filesDir.resolve("media")
- dir.mkdirs()
- val file = File(dir, fileName)
- file.writeBytes(secondArg())
- file.absolutePath
- }
-
- repository = MessageRepository(
- database = database,
- messageDao = messageDao,
- chatDao = chatDao,
- pendingMessageDao = pendingMessageDao,
- transportManager = transportManager,
- fileManager = fileManager,
- settingsRepository = settingsRepository
- )
- }
-
- @After
- fun teardown() {
- database.close()
- unmockkAll()
- }
-
- // ============================================================================================
- // sendTextMessage() TESTS
- // ============================================================================================
-
- @Test
- fun `sendTextMessage saves message and sends when peer online`() = runTest {
- // When
- val result = repository.sendTextMessage(
- peerId = testPeerId,
- peerName = testPeerName,
- text = "Hello, World!",
- replyToId = null
- )
-
- // Then: success
- assertTrue(result.isSuccess)
-
- // Chat record created
- val chat = chatDao.getChatById(testPeerId)
- assertNotNull(chat)
-
- // Message saved in database
- val messages = messageDao.getAllMessagesForChat(testPeerId).first()
- assertEquals(1, messages.size)
- assertEquals("Hello, World!", messages.first().text)
- assertEquals(MessageStatus.SENT, messages.first().status)
- assertTrue(messages.first().isFromMe)
- }
-
- @Test
- fun `sendTextMessage queues when peer is offline`() = runTest {
- // Given: peer is offline
- every { transportManager.getAllTransports() } returns emptyList()
- every { transportManager.selectBestTransport(any()) } returns emptyList()
-
- // When
- val result = repository.sendTextMessage(
- peerId = testPeerId,
- peerName = testPeerName,
- text = "Offline message",
- replyToId = null
- )
-
- // Then: success (queued for later)
- assertTrue(result.isSuccess)
-
- // Message saved as QUEUED
- val messages = messageDao.getAllMessagesForChat(testPeerId).first()
- assertEquals(1, messages.size)
- assertEquals(MessageStatus.QUEUED, messages.first().status)
-
- // Pending message created
- val pending = pendingMessageDao.getByRecipient(testPeerId)
- assertEquals(1, pending.size)
- }
-
- @Test
- fun `sendTextMessage saves replyToId when provided`() = runTest {
- // When
- val result = repository.sendTextMessage(
- peerId = testPeerId,
- peerName = testPeerName,
- text = "Replying",
- replyToId = "original-msg-id"
- )
-
- // Then
- assertTrue(result.isSuccess)
- val messages = messageDao.getAllMessagesForChat(testPeerId).first()
- assertEquals("original-msg-id", messages.first().replyToId)
- }
-
- @Test
- fun `sendTextMessage fails when transport fails`() = runTest {
- // Given: transport fails
- coEvery { mockTransport.sendPayload(any(), any()) } returns Result.failure(Exception("Send error"))
-
- // When
- val result = repository.sendTextMessage(
- peerId = testPeerId,
- peerName = testPeerName,
- text = "Will fail",
- replyToId = null
- )
-
- // Then: failure
- assertTrue(result.isFailure)
-
- // Message marked as FAILED
- val messages = messageDao.getAllMessagesForChat(testPeerId).first()
- assertEquals(1, messages.size)
- assertEquals(MessageStatus.FAILED, messages.first().status)
-
- // Pending message queued for retry
- val pending = pendingMessageDao.getByRecipient(testPeerId)
- assertEquals(1, pending.size)
- }
-
- // ============================================================================================
- // sendImageMessage() TESTS
- // ============================================================================================
-
- @Test
- fun `sendImageMessage compresses and sends image`() = runTest {
- // Given: mock ImageCompressor
- mockkObject(ImageCompressor)
- every { ImageCompressor.compress(any(), any(), any()) } returns ImageCompressor.CompressionResult(
- bytes = byteArrayOf(0x01, 0x02, 0x03),
- originalSize = 100,
- compressedSize = 3,
- compressionRatio = 97.0,
- width = 100,
- height = 100
- )
-
- // When
- val result = repository.sendImageMessage(
- peerId = testPeerId,
- peerName = testPeerName,
- imageBytes = byteArrayOf(0x00, 0x01, 0x02),
- extension = "jpg",
- replyToId = null
- )
-
- // Then
- assertTrue(result.isSuccess)
-
- val messages = messageDao.getAllMessagesForChat(testPeerId).first()
- assertEquals(1, messages.size)
- assertEquals(MessageType.IMAGE, messages.first().type)
- assertNotNull(messages.first().mediaPath)
- assertEquals(MessageStatus.SENT, messages.first().status)
- }
-
- @Test
- fun `sendImageMessage fails when save fails`() = runTest {
- // Given: ImageCompressor succeeds but file save fails
- mockkObject(ImageCompressor)
- every { ImageCompressor.compress(any(), any(), any()) } returns ImageCompressor.CompressionResult(
- bytes = byteArrayOf(0x01, 0x02, 0x03),
- originalSize = 100,
- compressedSize = 3,
- compressionRatio = 97.0,
- width = 100,
- height = 100
- )
- coEvery { fileManager.saveMedia(any(), any()) } returns null
-
- // When
- val result = repository.sendImageMessage(
- peerId = testPeerId,
- peerName = testPeerName,
- imageBytes = byteArrayOf(0x00),
- extension = "jpg",
- replyToId = null
- )
-
- // Then: failure - no message saved
- assertTrue(result.isFailure)
- val messages = messageDao.getAllMessagesForChat(testPeerId).first()
- assertTrue(messages.isEmpty())
- }
-
- // ============================================================================================
- // sendVideoMessage() TESTS
- // ============================================================================================
-
- @Test
- fun `sendVideoMessage sends valid video`() = runTest {
- // Given: small video under 50MB
- val context = ApplicationProvider.getApplicationContext()
- val videoDir = File(context.filesDir, "media").also { it.mkdirs() }
- val videoFile = File(videoDir, "test_video.mp4")
- videoFile.writeBytes(ByteArray(1024))
- coEvery { fileManager.saveMedia(any(), any()) } returns videoFile.absolutePath
-
- // When
- val result = repository.sendVideoMessage(
- peerId = testPeerId,
- peerName = testPeerName,
- videoBytes = ByteArray(1024), // 1KB
- extension = "mp4",
- replyToId = null
- )
-
- // Then
- assertTrue(result.isSuccess)
- val messages = messageDao.getAllMessagesForChat(testPeerId).first()
- assertEquals(MessageType.VIDEO, messages.first().type)
- }
-
- @Test
- fun `sendVideoMessage rejects video over 50MB`() = runTest {
- // Given: 51MB video
- val largeVideo = ByteArray(51 * 1024 * 1024)
-
- // When
- val result = repository.sendVideoMessage(
- peerId = testPeerId,
- peerName = testPeerName,
- videoBytes = largeVideo,
- extension = "mp4",
- replyToId = null
- )
-
- // Then: failure
- assertTrue(result.isFailure)
- assertTrue(result.exceptionOrNull()?.message?.contains("max 50MB") == true)
- }
-
- // ============================================================================================
- // sendFileMessage() TESTS
- // ============================================================================================
-
- @Test
- fun `sendFileMessage sends file to online peer`() = runTest {
- // When
- val result = repository.sendFileMessage(
- peerId = testPeerId,
- peerName = testPeerName,
- fileBytes = byteArrayOf(0x01, 0x02, 0x03),
- fileName = "test.bin",
- fileType = MessageType.FILE,
- replyToId = null
- )
-
- // Then
- assertTrue(result.isSuccess)
- val messages = messageDao.getAllMessagesForChat(testPeerId).first()
- assertEquals(1, messages.size)
- assertEquals(MessageType.FILE, messages.first().type)
- assertEquals("test.bin", messages.first().text)
- }
-
- @Test
- fun `sendFileMessage queues file when peer offline`() = runTest {
- // Given: peer offline
- every { transportManager.getAllTransports() } returns emptyList()
-
- // When
- val result = repository.sendFileMessage(
- peerId = testPeerId,
- peerName = testPeerName,
- fileBytes = byteArrayOf(0x01),
- fileName = "offline.bin",
- fileType = MessageType.FILE,
- replyToId = null
- )
-
- // Then
- assertTrue(result.isSuccess)
- val pending = pendingMessageDao.getByRecipient(testPeerId)
- assertEquals(1, pending.size)
- }
-
- // ============================================================================================
- // sendFileWithProgress() TESTS
- // ============================================================================================
-
- @Test
- fun `sendFileWithProgress sends file with progress callback`() = runTest {
- // Given: a temp file
- val context = ApplicationProvider.getApplicationContext()
- val tempFile = File(context.cacheDir, "test_${UUID.randomUUID()}.bin")
- tempFile.writeBytes(ByteArray(100) { it.toByte() })
-
- var progressValues = mutableListOf()
-
- // When
- val result = repository.sendFileWithProgress(
- messageId = UUID.randomUUID().toString(),
- peerId = testPeerId,
- peerName = testPeerName,
- file = tempFile,
- fileType = MessageType.FILE,
- caption = "Test file",
- replyToId = null,
- progressCallback = { progressValues.add(it) }
- )
-
- // Then
- assertTrue("sendFileWithProgress should succeed", result.isSuccess)
- assertTrue("Progress callback should have been called", progressValues.isNotEmpty())
- assertEquals(100, progressValues.last())
-
- // Cleanup
- tempFile.delete()
- }
-
- @Test
- fun `sendFileWithProgress fails for non-existent file`() = runTest {
- // Given: non-existent file
- val missingFile = File("/nonexistent/path/file.bin")
-
- // When
- val result = repository.sendFileWithProgress(
- messageId = "test-id",
- peerId = testPeerId,
- peerName = testPeerName,
- file = missingFile,
- fileType = MessageType.FILE,
- caption = "Missing",
- replyToId = null
- )
-
- // Then
- assertTrue(result.isFailure)
- }
-
- @Test
- fun `sendFileWithProgress fails for oversized file`() = runTest {
- // Given: oversize file (over 100MB)
- val context = ApplicationProvider.getApplicationContext()
- val tempFile = File(context.cacheDir, "oversize_test.bin")
- // Create a sparse file with large length without allocating 100MB in memory
- tempFile.writeBytes(ByteArray(1)) // minimal content
- // Use RandomAccessFile to set the length to just over the limit
- java.io.RandomAccessFile(tempFile, "rw").use { raf ->
- raf.setLength(com.p2p.meshify.domain.model.AppConstants.MAX_FILE_SIZE_BYTES + 1)
- }
- tempFile.deleteOnExit()
-
- // When
- val result = repository.sendFileWithProgress(
- messageId = "test-id",
- peerId = testPeerId,
- peerName = testPeerName,
- file = tempFile,
- fileType = MessageType.FILE,
- caption = "Oversized",
- replyToId = null
- )
-
- // Then
- assertTrue(result.isFailure)
- assertTrue(result.exceptionOrNull()?.message?.contains("too large") == true)
- }
-
- @Test
- fun `sendFileWithProgress queues when peer offline`() = runTest {
- // Given: peer offline
- every { transportManager.getAllTransports() } returns emptyList()
- every { mockTransport.onlinePeers } returns kotlinx.coroutines.flow.MutableStateFlow(emptySet())
-
- val context = ApplicationProvider.getApplicationContext()
- val tempFile = File(context.cacheDir, "offline_test.bin")
- tempFile.writeBytes(ByteArray(50))
-
- // When
- val result = repository.sendFileWithProgress(
- messageId = "offline-test-id",
- peerId = testPeerId,
- peerName = testPeerName,
- file = tempFile,
- fileType = MessageType.FILE,
- caption = "Offline file",
- replyToId = null
- )
-
- // Then: queued
- assertTrue(result.isSuccess)
- val pending = pendingMessageDao.getByRecipient(testPeerId)
- assertEquals(1, pending.size)
-
- tempFile.delete()
- }
-
- // ============================================================================================
- // searchMessagesInChat() TESTS
- // ============================================================================================
-
- @Test
- fun `searchMessagesInChat returns matching messages`() = runTest {
- // Given: messages in database
- messageDao.insertMessage(
- MessageEntity(
- id = "msg-1", chatId = testPeerId, senderId = myDeviceId,
- text = "Hello World", type = MessageType.TEXT,
- timestamp = 1000L, isFromMe = true, status = MessageStatus.SENT
- )
- )
- messageDao.insertMessage(
- MessageEntity(
- id = "msg-2", chatId = testPeerId, senderId = testPeerId,
- text = "How are you?", type = MessageType.TEXT,
- timestamp = 2000L, isFromMe = false, status = MessageStatus.SENT
- )
- )
- messageDao.insertMessage(
- MessageEntity(
- id = "msg-3", chatId = testPeerId, senderId = myDeviceId,
- text = "Goodbye", type = MessageType.TEXT,
- timestamp = 3000L, isFromMe = true, status = MessageStatus.SENT
- )
- )
-
- // When: search for "Hello"
- val results = repository.searchMessagesInChat(testPeerId, "Hello").first()
-
- // Then
- assertEquals(1, results.size)
- assertEquals("Hello World", results.first().text)
- }
-
- @Test
- fun `searchMessagesInChat returns empty for no match`() = runTest {
- // Given: messages in database
- messageDao.insertMessage(
- MessageEntity(
- id = "msg-1", chatId = testPeerId, senderId = myDeviceId,
- text = "Hello", type = MessageType.TEXT,
- timestamp = 1000L, isFromMe = true, status = MessageStatus.SENT
- )
- )
-
- // When: search for non-matching text
- val results = repository.searchMessagesInChat(testPeerId, "ZZZZZZZZ").first()
-
- // Then
- assertTrue(results.isEmpty())
- }
-
- @Test
- fun `searchMessagesInChat returns empty for empty chat`() = runTest {
- val results = repository.searchMessagesInChat(testPeerId, "test").first()
- assertTrue(results.isEmpty())
- }
-
- // ============================================================================================
- // getMessages() / getMessagesPaged() TESTS
- // ============================================================================================
-
- @Test
- fun `getMessages returns messages for chat`() = runTest {
- // Given
- messageDao.insertMessage(
- MessageEntity(
- id = "msg-1", chatId = testPeerId, senderId = myDeviceId,
- text = "First", type = MessageType.TEXT,
- timestamp = 1000L, isFromMe = true, status = MessageStatus.SENT
- )
- )
-
- // When
- val messages = repository.getMessages(testPeerId).first()
-
- // Then
- assertEquals(1, messages.size)
- assertEquals("First", messages.first().text)
- }
-
- @Test
- fun `getMessagesPaged returns paginated messages`() = runTest {
- // Given: two messages
- messageDao.insertMessage(
- MessageEntity(
- id = "msg-1", chatId = testPeerId, senderId = myDeviceId,
- text = "First", type = MessageType.TEXT,
- timestamp = 1000L, isFromMe = true, status = MessageStatus.SENT
- )
- )
- messageDao.insertMessage(
- MessageEntity(
- id = "msg-2", chatId = testPeerId, senderId = testPeerId,
- text = "Second", type = MessageType.TEXT,
- timestamp = 2000L, isFromMe = false, status = MessageStatus.SENT
- )
- )
-
- // When: page with limit=1, offset=0
- val page = repository.getMessagesPaged(testPeerId, limit = 1, offset = 0).first()
-
- // Then
- assertEquals(1, page.size)
- }
-
- // ============================================================================================
- // sendTextMessage() Error Edge Cases
- // ============================================================================================
-
- @Test
- fun `sendTextMessage with empty text still sends`() = runTest {
- val result = repository.sendTextMessage(
- peerId = testPeerId,
- peerName = testPeerName,
- text = "",
- replyToId = null
- )
- assertTrue(result.isSuccess)
- }
-
- @Test
- fun `sendTextMessage fails if no transport available and peer online`() = runTest {
- // Given: no transports but getAllTransports says we have transports?
- // Actually, the code checks transports via getAllTransports. Let's mock it differently.
- // Make selectBestTransport return empty list
- every { transportManager.selectBestTransport(any()) } returns emptyList()
- every { transportManager.getAllTransports() } returns listOf(mockTransport) // peer "online"
-
- // When
- val result = repository.sendTextMessage(
- peerId = testPeerId,
- peerName = testPeerName,
- text = "No transport",
- replyToId = null
- )
-
- // Then: send fails, message + pending saved
- assertTrue(result.isFailure)
- val messages = messageDao.getAllMessagesForChat(testPeerId).first()
- assertEquals(1, messages.size)
- assertEquals(MessageStatus.FAILED, messages.first().status)
-
- val pending = pendingMessageDao.getByRecipient(testPeerId)
- assertEquals(1, pending.size)
- }
-}
diff --git a/core/data/src/test/java/com/p2p/meshify/core/data/repository/PendingMessageRepositoryTest.kt b/core/data/src/test/java/com/p2p/meshify/core/data/repository/PendingMessageRepositoryTest.kt
deleted file mode 100644
index b7d3fbc2..00000000
--- a/core/data/src/test/java/com/p2p/meshify/core/data/repository/PendingMessageRepositoryTest.kt
+++ /dev/null
@@ -1,516 +0,0 @@
-package com.p2p.meshify.core.data.repository
-
-import com.p2p.meshify.core.data.local.dao.MessageDao
-import com.p2p.meshify.core.data.local.dao.PendingMessageDao
-import com.p2p.meshify.core.data.local.entity.MessageEntity
-import com.p2p.meshify.core.data.local.entity.MessageStatus
-import com.p2p.meshify.core.data.local.entity.PendingMessageEntity
-import com.p2p.meshify.core.network.TransportManager
-import com.p2p.meshify.core.network.base.IMeshTransport
-import com.p2p.meshify.domain.model.MessageType
-import com.p2p.meshify.domain.repository.ISettingsRepository
-import io.mockk.coEvery
-import io.mockk.coVerify
-import io.mockk.every
-import io.mockk.mockk
-import kotlinx.coroutines.flow.first
-import kotlinx.coroutines.test.runTest
-import org.junit.Assert.assertEquals
-import org.junit.Assert.assertFalse
-import org.junit.Assert.assertNotNull
-import org.junit.Assert.assertTrue
-import org.junit.Before
-import org.junit.Test
-import org.junit.runner.RunWith
-import org.robolectric.RobolectricTestRunner
-
-/**
- * Unit tests for PendingMessageRepository.
- *
- * Covers:
- * - Queue operations
- * - Retry logic with backoff
- * - Edge cases (missing files, offline peers)
- * - State flows
- */
-@RunWith(RobolectricTestRunner::class)
-class PendingMessageRepositoryTest {
-
- // Core dependencies
- private val pendingMessageDao: PendingMessageDao = mockk(relaxed = true)
- private val messageDao: MessageDao = mockk(relaxed = true)
- private val transportManager: TransportManager = mockk(relaxed = true)
- private val settingsRepository: ISettingsRepository = mockk(relaxed = true)
-
- // Transport mock
- private val mockTransport: IMeshTransport = mockk(relaxed = true)
-
- // In-memory store to simulate DAO behavior
- private val pendingStore = mutableMapOf()
-
- private lateinit var repository: PendingMessageRepository
-
- private val testPeerId = "peer-123"
- private val testPeerName = "Alice"
- private val testMessageId = "msg-001"
- private val testContent = "Hello, world!"
-
- @Before
- fun setup() {
- pendingStore.clear()
-
- // Settings repo: device ID
- coEvery { settingsRepository.getDeviceId() } returns "my-device-id"
-
- // Pending DAO: realistic insert/get/delete behavior
- coEvery { pendingMessageDao.insert(any()) } answers {
- val entity = firstArg()
- pendingStore[entity.id] = entity
- }
- coEvery { pendingMessageDao.getAll() } answers {
- pendingStore.values.toList()
- }
- coEvery { pendingMessageDao.getByRecipient(any()) } answers {
- pendingStore.values.filter { it.recipientId == firstArg() }
- }
- coEvery { pendingMessageDao.deleteById(any()) } answers {
- pendingStore.remove(firstArg())
- }
- coEvery { pendingMessageDao.deleteByStatus(any()) } answers {
- val status = firstArg()
- pendingStore.entries.removeAll { it.value.status == status }
- }
-
- // Transport manager: return mock transport
- every { transportManager.selectBestTransport(any()) } returns listOf(mockTransport)
-
- // Transport send: succeed by default
- coEvery { mockTransport.sendPayload(any(), any()) } returns Result.success(Unit)
-
- // Message DAO: getMessagesByIds return empty by default
- coEvery { messageDao.getMessagesByIds(any()) } returns emptyList()
-
- repository = PendingMessageRepository(
- pendingMessageDao = pendingMessageDao,
- messageDao = messageDao,
- transportManager = transportManager,
- settingsRepository = settingsRepository
- )
- }
-
- // ============================================================================================
- // queueMessage() TESTS
- // ============================================================================================
-
- @Test
- fun `queueMessage inserts pending message and refreshes count`() = runTest {
- // When
- repository.queueMessage(
- messageId = testMessageId,
- recipientId = testPeerId,
- recipientName = testPeerName,
- content = testContent,
- type = MessageType.TEXT
- )
-
- // Then: record was inserted
- coVerify { pendingMessageDao.insert(any()) }
- assertEquals(1, pendingStore.size)
-
- val inserted = pendingStore[testMessageId]
- assertNotNull(inserted)
- assertEquals(testPeerId, inserted!!.recipientId)
- assertEquals(testContent, inserted.content)
- assertEquals(MessageStatus.QUEUED, inserted.status)
- assertEquals(0, inserted.retryCount)
-
- // Then: pending count flow is updated
- val count = repository.pendingCount.first()
- assertEquals(1, count)
- }
-
- @Test
- fun `queueMessage with image type sets correct type`() = runTest {
- // When
- repository.queueMessage("img-1", testPeerId, testPeerName, "[Image]", MessageType.IMAGE)
-
- // Then
- val entity = pendingStore["img-1"]
- assertNotNull(entity)
- assertEquals(MessageType.IMAGE, entity!!.type)
- }
-
- @Test
- fun `queueMessage inserts multiple messages and increments count`() = runTest {
- // When
- repository.queueMessage("msg-1", testPeerId, testPeerName, "First", MessageType.TEXT)
- repository.queueMessage("msg-2", testPeerId, testPeerName, "Second", MessageType.TEXT)
-
- // Then
- assertEquals(2, repository.pendingCount.first())
- }
-
- // ============================================================================================
- // retryPendingMessages() TESTS
- // ============================================================================================
-
- @Test
- fun `retryPendingMessages with empty pending list returns success`() = runTest {
- // Given: no pending messages for this peer
- pendingStore.clear()
-
- // When
- val result = repository.retryPendingMessages(testPeerId)
-
- // Then
- assertTrue(result.isSuccess)
- coVerify(exactly = 0) { messageDao.getMessagesByIds(any()) }
- coVerify(exactly = 0) { mockTransport.sendPayload(any(), any()) }
- }
-
- @Test
- fun `retryPendingMessages sends message and updates status on success`() = runTest {
- // Given: one pending text message
- val message = createTestMessage(mediaPath = null)
- val pending = createPendingMessage(MessageType.TEXT)
- pendingStore[pending.id] = pending
- coEvery { messageDao.getMessagesByIds(listOf(testMessageId)) } returns listOf(message)
- coEvery { mockTransport.sendPayload(any(), any()) } returns Result.success(Unit)
-
- // When
- val result = repository.retryPendingMessages(testPeerId)
-
- // Then
- assertTrue(result.isSuccess)
-
- // Transport was called
- coVerify { mockTransport.sendPayload(testPeerId, any()) }
-
- // Message status updated to SENT
- coVerify { messageDao.updateMessageStatus(testMessageId, MessageStatus.SENT) }
-
- // Pending message removed
- assertFalse(pendingStore.containsKey(testMessageId))
- }
-
- @Test
- fun `retryPendingMessages removes pending when message not in DB`() = runTest {
- // Given: pending message exists but no corresponding MessageEntity
- val pending = createPendingMessage(MessageType.TEXT)
- pendingStore[pending.id] = pending
- coEvery { messageDao.getMessagesByIds(any()) } returns emptyList()
-
- // When
- val result = repository.retryPendingMessages(testPeerId)
-
- // Then: pending was deleted, counted as failure
- assertTrue(result.isFailure)
- assertFalse(pendingStore.containsKey(testMessageId))
- coVerify { pendingMessageDao.deleteById(testMessageId) }
- }
-
- @Test
- fun `retryPendingMessages fails when media file does not exist`() = runTest {
- // Given: pending image message with non-existent file path
- val message = createTestMessage(
- mediaPath = "/nonexistent/path/image.jpg",
- type = MessageType.IMAGE
- )
- val pending = createPendingMessage(MessageType.IMAGE)
- pendingStore[pending.id] = pending
- coEvery { messageDao.getMessagesByIds(listOf(testMessageId)) } returns listOf(message)
-
- // When
- val result = repository.retryPendingMessages(testPeerId)
-
- // Then: should fail without calling transport
- assertTrue(result.isFailure)
- coVerify(exactly = 0) { mockTransport.sendPayload(any(), any()) }
-
- // The pending message should remain in the store for future retry attempts
- assertTrue(pendingStore.containsKey(testMessageId))
- }
-
- @Test
- fun `retryPendingMessages with partial failures returns failure`() = runTest {
- // Given: two pending messages, first succeeds, second has missing message
- val msg1Id = "msg-success"
- val msg2Id = "msg-fail"
-
- // First message: should succeed
- val message1 = MessageEntity(
- id = msg1Id, chatId = testPeerId, senderId = "me",
- text = "First", type = MessageType.TEXT,
- timestamp = 1000L, isFromMe = true, status = MessageStatus.QUEUED
- )
- val pending1 = PendingMessageEntity(
- id = msg1Id, recipientId = testPeerId, recipientName = testPeerName,
- content = "First", type = MessageType.TEXT
- )
- pendingStore[msg1Id] = pending1
-
- // Second message: has no MessageEntity (simulates race condition)
- val pending2 = PendingMessageEntity(
- id = msg2Id, recipientId = testPeerId, recipientName = testPeerName,
- content = "Second", type = MessageType.TEXT
- )
- pendingStore[msg2Id] = pending2
-
- coEvery { messageDao.getMessagesByIds(listOf(msg1Id, msg2Id)) } returns listOf(message1)
- coEvery { mockTransport.sendPayload(testPeerId, any()) } returns Result.success(Unit)
-
- // When
- val result = repository.retryPendingMessages(testPeerId)
-
- // Then: should fail because 1 of 2 failed
- assertTrue(result.isFailure)
- assertTrue(result.exceptionOrNull()?.message?.contains("1 messages failed") == true)
- }
-
- // ============================================================================================
- // retryForOnlinePeer() TESTS
- // ============================================================================================
-
- @Test
- fun `retryForOnlinePeer skips when no pending messages`() = runTest {
- // Given: no pending messages
- pendingStore.clear()
-
- // When
- repository.retryForOnlinePeer(testPeerId)
-
- // Then: no retry attempt
- coVerify(exactly = 0) { mockTransport.sendPayload(any(), any()) }
- }
-
- @Test
- fun `retryForOnlinePeer retries when pending exist`() = runTest {
- // Given: pending message exists
- val message = createTestMessage(mediaPath = null)
- val pending = createPendingMessage(MessageType.TEXT)
- pendingStore[pending.id] = pending
- coEvery { messageDao.getMessagesByIds(listOf(testMessageId)) } returns listOf(message)
- coEvery { mockTransport.sendPayload(any(), any()) } returns Result.success(Unit)
-
- // When
- repository.retryForOnlinePeer(testPeerId)
-
- // Then: transport was called
- coVerify { mockTransport.sendPayload(any(), any()) }
- }
-
- // ============================================================================================
- // getPendingCountForRecipient() TESTS
- // ============================================================================================
-
- @Test
- fun `getPendingCountForRecipient returns zero when no messages`() = runTest {
- val count = repository.getPendingCountForRecipient("unknown-peer")
- assertEquals(0, count)
- }
-
- @Test
- fun `getPendingCountForRecipient returns correct count`() = runTest {
- // Given: two pending messages for testPeerId
- pendingStore["msg-1"] = createPendingMessage(MessageType.TEXT, id = "msg-1")
- pendingStore["msg-2"] = createPendingMessage(MessageType.TEXT, id = "msg-2")
-
- // When
- val count = repository.getPendingCountForRecipient(testPeerId)
-
- // Then
- assertEquals(2, count)
- }
-
- @Test
- fun `getPendingCountForRecipient filters by recipient`() = runTest {
- // Given: one message for testPeerId, one for another peer
- pendingStore["msg-1"] = createPendingMessage(MessageType.TEXT, id = "msg-1")
- pendingStore["msg-2"] = PendingMessageEntity(
- id = "msg-2", recipientId = "other-peer", recipientName = "Bob",
- content = "Hey", type = MessageType.TEXT
- )
-
- // When
- val countForTestPeer = repository.getPendingCountForRecipient(testPeerId)
-
- // Then
- assertEquals(1, countForTestPeer)
- }
-
- // ============================================================================================
- // deletePendingMessage() TESTS
- // ============================================================================================
-
- @Test
- fun `deletePendingMessage removes from store and refreshes`() = runTest {
- // Given: one pending message
- repository.queueMessage(testMessageId, testPeerId, testPeerName, testContent, MessageType.TEXT)
-
- // When
- repository.deletePendingMessage(testMessageId)
-
- // Then
- assertFalse(pendingStore.containsKey(testMessageId))
- coVerify { pendingMessageDao.deleteById(testMessageId) }
-
- // Count should be zero after delete
- assertEquals(0, repository.pendingCount.first())
- }
-
- @Test
- fun `deletePendingMessage with non-existent id does not throw`() = runTest {
- // Should not crash
- repository.deletePendingMessage("non-existent-id")
- coVerify { pendingMessageDao.deleteById("non-existent-id") }
- }
-
- @Test
- fun `deletePendingMessage reduces count correctly`() = runTest {
- // Given: two pending messages via queue (which refreshes the flow)
- repository.queueMessage("msg-1", testPeerId, testPeerName, "First", MessageType.TEXT)
- repository.queueMessage("msg-2", testPeerId, testPeerName, "Second", MessageType.TEXT)
-
- assertEquals(2, repository.pendingCount.first())
-
- // When: delete one
- repository.deletePendingMessage("msg-1")
-
- // Then: count reduced
- assertEquals(1, repository.pendingCount.first())
- }
-
- // ============================================================================================
- // getAllPendingMessages() TESTS
- // ============================================================================================
-
- @Test
- fun `getAllPendingMessages returns all stored messages`() = runTest {
- // Given: two messages
- pendingStore["msg-1"] = createPendingMessage(MessageType.TEXT, id = "msg-1")
- pendingStore["msg-2"] = createPendingMessage(MessageType.IMAGE, id = "msg-2")
-
- // When
- val all = repository.getAllPendingMessages()
-
- // Then
- assertEquals(2, all.size)
- }
-
- @Test
- fun `getAllPendingMessages returns empty list when none pending`() = runTest {
- val all = repository.getAllPendingMessages()
- assertTrue(all.isEmpty())
- }
-
- // ============================================================================================
- // clearAllPending() TESTS
- // ============================================================================================
-
- @Test
- fun `clearAllPending removes all queued messages`() = runTest {
- // Given: two pending messages via queue (which refreshes the flow)
- repository.queueMessage("msg-1", testPeerId, testPeerName, "First", MessageType.TEXT)
- repository.queueMessage("msg-2", testPeerId, testPeerName, "Second", MessageType.TEXT)
- assertEquals(2, repository.pendingCount.first())
-
- // When
- repository.clearAllPending()
-
- // Then
- assertEquals(0, repository.pendingCount.first())
- coVerify { pendingMessageDao.deleteByStatus(MessageStatus.QUEUED) }
- }
-
- @Test
- fun `clearAllPending on empty store does not throw`() = runTest {
- repository.clearAllPending()
- // No exception expected, count stays 0
- assertEquals(0, repository.pendingCount.first())
- }
-
- // ============================================================================================
- // StateFlow TESTS
- // ============================================================================================
-
- @Test
- fun `pendingCount flow starts at zero`() = runTest {
- val count = repository.pendingCount.first()
- assertEquals(0, count)
- }
-
- @Test
- fun `pendingMessages flow starts empty`() = runTest {
- val messages = repository.pendingMessages.first()
- assertTrue(messages.isEmpty())
- }
-
- @Test
- fun `pendingMessages flow updates after queueMessage`() = runTest {
- // When
- repository.queueMessage(testMessageId, testPeerId, testPeerName, testContent, MessageType.TEXT)
-
- // Then
- val messages = repository.pendingMessages.first()
- assertEquals(1, messages.size)
- assertEquals(testMessageId, messages.first().id)
- }
-
- // ============================================================================================
- // getPendingMessages() TESTS
- // ============================================================================================
-
- @Test
- fun `getPendingMessages returns messages for recipient`() = runTest {
- // Given: one for testPeerId, one for another peer
- pendingStore["msg-1"] = createPendingMessage(MessageType.TEXT, id = "msg-1")
- pendingStore["msg-2"] = PendingMessageEntity(
- id = "msg-2", recipientId = "other-peer", recipientName = "Bob",
- content = "Other", type = MessageType.TEXT
- )
-
- // When
- val forPeer = repository.getPendingMessages(testPeerId)
-
- // Then
- assertEquals(1, forPeer.size)
- assertEquals("msg-1", forPeer.first().id)
- }
-
- // ============================================================================================
- // Helper methods
- // ============================================================================================
-
- private fun createTestMessage(
- mediaPath: String? = null,
- type: MessageType = MessageType.TEXT
- ): MessageEntity {
- return MessageEntity(
- id = testMessageId,
- chatId = testPeerId,
- senderId = "my-device-id",
- text = if (type == MessageType.TEXT) testContent else null,
- mediaPath = mediaPath,
- type = type,
- timestamp = System.currentTimeMillis(),
- isFromMe = true,
- status = MessageStatus.QUEUED
- )
- }
-
- private fun createPendingMessage(
- type: MessageType,
- id: String = testMessageId
- ): PendingMessageEntity {
- return PendingMessageEntity(
- id = id,
- recipientId = testPeerId,
- recipientName = testPeerName,
- content = testContent,
- type = type,
- status = MessageStatus.QUEUED,
- retryCount = 0,
- maxRetries = 5
- )
- }
-}
diff --git a/core/data/src/test/java/com/p2p/meshify/core/data/repository/ReactionRepositoryTest.kt b/core/data/src/test/java/com/p2p/meshify/core/data/repository/ReactionRepositoryTest.kt
deleted file mode 100644
index 6bed36da..00000000
--- a/core/data/src/test/java/com/p2p/meshify/core/data/repository/ReactionRepositoryTest.kt
+++ /dev/null
@@ -1,305 +0,0 @@
-package com.p2p.meshify.core.data.repository
-
-import com.p2p.meshify.core.data.local.dao.MessageDao
-import com.p2p.meshify.core.data.local.entity.MessageEntity
-import com.p2p.meshify.core.data.local.entity.MessageStatus
-import com.p2p.meshify.core.network.TransportManager
-import com.p2p.meshify.core.network.base.IMeshTransport
-import com.p2p.meshify.domain.model.MessageType
-import com.p2p.meshify.domain.model.Payload
-import com.p2p.meshify.domain.repository.ISettingsRepository
-import io.mockk.coEvery
-import io.mockk.coVerify
-import io.mockk.every
-import io.mockk.mockk
-import kotlinx.coroutines.test.runTest
-import org.junit.Assert.assertEquals
-import org.junit.Assert.assertNull
-import org.junit.Assert.assertTrue
-import org.junit.Before
-import org.junit.Test
-import org.junit.runner.RunWith
-import org.robolectric.RobolectricTestRunner
-
-/**
- * Unit tests for ReactionRepository.
- *
- * Covers:
- * - Adding reactions
- * - Removing reactions
- * - Retrieving reactions
- * - Edge cases (non-existent messages, duplicates)
- */
-@RunWith(RobolectricTestRunner::class)
-class ReactionRepositoryTest {
-
- private val messageDao: MessageDao = mockk(relaxed = true)
- private val transportManager: TransportManager = mockk(relaxed = true)
- private val settingsRepository: ISettingsRepository = mockk(relaxed = true)
- private val mockTransport: IMeshTransport = mockk(relaxed = true)
-
- private lateinit var repository: ReactionRepository
-
- private val testMessageId = "msg-001"
- private val testChatId = "chat-001"
- private val myDeviceId = "my-device-id"
-
- @Before
- fun setup() {
- coEvery { settingsRepository.getDeviceId() } returns myDeviceId
- every { transportManager.selectBestTransport(any()) } returns listOf(mockTransport)
- coEvery { mockTransport.sendPayload(any(), any()) } returns Result.success(Unit)
-
- repository = ReactionRepository(
- messageDao = messageDao,
- transportManager = transportManager,
- settingsRepository = settingsRepository
- )
- }
-
- private fun createTestMessage(): MessageEntity {
- return MessageEntity(
- id = testMessageId,
- chatId = testChatId,
- senderId = "peer-123",
- text = "Test message",
- type = MessageType.TEXT,
- timestamp = 1000L,
- isFromMe = false,
- status = MessageStatus.SENT
- )
- }
-
- // ============================================================================================
- // addReaction() TESTS
- // ============================================================================================
-
- @Test
- fun `addReaction inserts reaction via DAO`() = runTest {
- // Given
- val message = createTestMessage()
- coEvery { messageDao.getMessageById(testMessageId) } returns message
-
- // When
- val result = repository.addReaction(testMessageId, "\u2764\uFE0F") // ❤️
-
- // Then
- assertTrue(result.isSuccess)
- coVerify { messageDao.updateReaction(testMessageId, "\u2764\uFE0F") }
- coVerify { mockTransport.sendPayload(eq(testChatId), any()) }
- }
-
- @Test
- fun `addReaction with null reaction removes reaction`() = runTest {
- // Given
- val message = createTestMessage()
- coEvery { messageDao.getMessageById(testMessageId) } returns message
-
- // When
- val result = repository.addReaction(testMessageId, null)
-
- // Then
- assertTrue(result.isSuccess)
- coVerify { messageDao.updateReaction(testMessageId, null) }
- }
-
- @Test
- fun `addReaction fails when message not found`() = runTest {
- // Given
- coEvery { messageDao.getMessageById("nonexistent") } returns null
-
- // When
- val result = repository.addReaction("nonexistent", "\uD83D\uDC4D")
-
- // Then
- assertTrue(result.isFailure)
- coVerify(exactly = 0) { messageDao.updateReaction(any(), any()) }
- }
-
- @Test
- fun `addReaction sends reaction payload`() = runTest {
- // Given
- val message = createTestMessage()
- coEvery { messageDao.getMessageById(testMessageId) } returns message
-
- // When
- val result = repository.addReaction(testMessageId, "\uD83D\uDE00")
-
- // Then
- assertTrue(result.isSuccess)
- // Verify payload was sent with correct type
- coVerify {
- mockTransport.sendPayload(
- eq(testChatId),
- withArg { payload ->
- assertTrue(payload.type == Payload.PayloadType.REACTION)
- }
- )
- }
- }
-
- @Test
- fun `addReaction fails when no transport available`() = runTest {
- // Given
- val message = createTestMessage()
- coEvery { messageDao.getMessageById(testMessageId) } returns message
- every { transportManager.selectBestTransport(any()) } returns emptyList()
-
- // When
- val result = repository.addReaction(testMessageId, "\uD83D\uDC4D")
-
- // Then: should fail because no transport
- assertTrue(result.isFailure)
- }
-
- @Test
- fun `addReaction sends reaction with correct sender ID`() = runTest {
- // Given
- val message = createTestMessage()
- coEvery { messageDao.getMessageById(testMessageId) } returns message
-
- // When
- repository.addReaction(testMessageId, "\uD83D\uDC4D")
-
- // Then: DAO updates reaction
- coVerify { messageDao.updateReaction(testMessageId, "\uD83D\uDC4D") }
- }
-
- @Test
- fun `addReaction sends removed reaction when reaction is null`() = runTest {
- // Given
- val message = createTestMessage()
- coEvery { messageDao.getMessageById(testMessageId) } returns message
-
- // When
- repository.addReaction(testMessageId, null)
-
- // Then: DAO updates with null
- coVerify { messageDao.updateReaction(testMessageId, null) }
- }
-
- @Test
- fun `addReaction fails on DAO exception`() = runTest {
- // Given
- coEvery { messageDao.getMessageById(any()) } throws RuntimeException("DB error")
-
- // When
- val result = repository.addReaction(testMessageId, "\uD83D\uDC4D")
-
- // Then
- assertTrue(result.isFailure)
- }
-
- // ============================================================================================
- // removeReaction() TESTS
- // ============================================================================================
-
- @Test
- fun `removeReaction delegates to addReaction with null`() = runTest {
- // Given
- val message = createTestMessage()
- coEvery { messageDao.getMessageById(testMessageId) } returns message
-
- // When
- val result = repository.removeReaction(testMessageId)
-
- // Then
- assertTrue(result.isSuccess)
- coVerify { messageDao.updateReaction(testMessageId, null) }
- }
-
- @Test
- fun `removeReaction fails when message not found`() = runTest {
- coEvery { messageDao.getMessageById("nonexistent") } returns null
-
- val result = repository.removeReaction("nonexistent")
- assertTrue(result.isFailure)
- }
-
- // ============================================================================================
- // getReaction() TESTS
- // ============================================================================================
-
- @Test
- fun `getReaction returns reaction for message`() = runTest {
- // Given: message has reaction
- val message = createTestMessage().copy(reaction = "\u2764\uFE0F")
- coEvery { messageDao.getMessageById(testMessageId) } returns message
-
- // When
- val reaction = repository.getReaction(testMessageId)
-
- // Then
- assertEquals("\u2764\uFE0F", reaction)
- }
-
- @Test
- fun `getReaction returns null for unstamped message`() = runTest {
- // Given: message with no reaction
- val message = createTestMessage()
- coEvery { messageDao.getMessageById(testMessageId) } returns message
-
- // When
- val reaction = repository.getReaction(testMessageId)
-
- // Then
- assertNull(reaction)
- }
-
- @Test
- fun `getReaction returns null for non-existent message`() = runTest {
- coEvery { messageDao.getMessageById("nonexistent") } returns null
-
- val reaction = repository.getReaction("nonexistent")
- assertNull(reaction)
- }
-
- // ============================================================================================
- // Edge Cases
- // ============================================================================================
-
- @Test
- fun `duplicate reactions are idempotent`() = runTest {
- // Given
- val message = createTestMessage()
- coEvery { messageDao.getMessageById(testMessageId) } returns message
-
- // When: same reaction set twice
- val firstResult = repository.addReaction(testMessageId, "\uD83D\uDC4D")
- val secondResult = repository.addReaction(testMessageId, "\uD83D\uDC4D")
-
- // Then: both succeed
- assertTrue(firstResult.isSuccess)
- assertTrue(secondResult.isSuccess)
-
- // DAO called twice with same reaction
- coVerify(exactly = 2) { messageDao.updateReaction(testMessageId, "\uD83D\uDC4D") }
- }
-
- @Test
- fun `addReaction can change existing reaction`() = runTest {
- // Given: message starts with one reaction
- val message = createTestMessage().copy(reaction = "\u2764\uFE0F")
- coEvery { messageDao.getMessageById(testMessageId) } returns message
-
- // When: change to different reaction
- val result = repository.addReaction(testMessageId, "\uD83D\uDE00")
-
- // Then
- assertTrue(result.isSuccess)
- coVerify { messageDao.updateReaction(testMessageId, "\uD83D\uDE00") }
- }
-
- @Test
- fun `getReaction returns updated reaction after add`() = runTest {
- // Given: update reaction
- val updatedMessage = createTestMessage().copy(reaction = "\uD83D\uDE0A")
- coEvery { messageDao.getMessageById(testMessageId) } returns updatedMessage
-
- // When
- val reaction = repository.getReaction(testMessageId)
-
- // Then
- assertEquals("\uD83D\uDE0A", reaction)
- }
-}
diff --git a/core/data/src/test/java/com/p2p/meshify/core/data/repository/SettingsRepositoryTest.kt b/core/data/src/test/java/com/p2p/meshify/core/data/repository/SettingsRepositoryTest.kt
deleted file mode 100644
index 0425f6d4..00000000
--- a/core/data/src/test/java/com/p2p/meshify/core/data/repository/SettingsRepositoryTest.kt
+++ /dev/null
@@ -1,530 +0,0 @@
-package com.p2p.meshify.core.data.repository
-
-import android.content.Context
-import androidx.datastore.preferences.core.PreferenceDataStoreFactory
-import androidx.test.core.app.ApplicationProvider
-import com.p2p.meshify.domain.model.BubbleStyle
-import com.p2p.meshify.domain.model.FontFamilyPreset
-import com.p2p.meshify.domain.model.MotionPreset
-import com.p2p.meshify.domain.model.ShapeStyle
-import com.p2p.meshify.domain.model.TransportMode
-import com.p2p.meshify.domain.repository.ThemeMode
-import kotlinx.coroutines.flow.first
-import kotlinx.coroutines.test.runTest
-import org.junit.After
-import org.junit.Assert.assertEquals
-import org.junit.Assert.assertFalse
-import org.junit.Assert.assertNotNull
-import org.junit.Assert.assertNull
-import org.junit.Assert.assertTrue
-import org.junit.Before
-import org.junit.Test
-import org.junit.runner.RunWith
-import org.robolectric.RobolectricTestRunner
-import java.io.File
-import java.util.UUID
-
-/**
- * Unit tests for SettingsRepository.
- *
- * Uses a unique DataStore file per test to ensure isolation.
- * Robolectric required for Android Context + DataStore.
- */
-@RunWith(RobolectricTestRunner::class)
-class SettingsRepositoryTest {
-
- private lateinit var repository: SettingsRepository
- private lateinit var testFile: File
-
- @Before
- fun setup() {
- val context = ApplicationProvider.getApplicationContext()
- val uniqueName = "settings_test_${UUID.randomUUID()}"
- testFile = File(context.filesDir, "datastore/$uniqueName.preferences_pb")
- testFile.parentFile?.mkdirs()
- val testDataStore = PreferenceDataStoreFactory.create {
- testFile
- }
- repository = SettingsRepository(context, testDataStore)
- }
-
- @After
- fun teardown() {
- testFile.delete()
- }
-
- // ============================================================================================
- // appLanguage TESTS
- // ============================================================================================
-
- @Test
- fun `appLanguage defaults to en`() = runTest {
- val lang = repository.appLanguage.first()
- assertEquals("en", lang)
- }
-
- @Test
- fun `appLanguage emits updated value after set`() = runTest {
- repository.setAppLanguage("ar")
- val lang = repository.appLanguage.first()
- assertEquals("ar", lang)
- }
-
- @Test
- fun `appLanguage handles multiple updates`() = runTest {
- repository.setAppLanguage("fr")
- assertEquals("fr", repository.appLanguage.first())
-
- repository.setAppLanguage("de")
- assertEquals("de", repository.appLanguage.first())
- }
-
- // ============================================================================================
- // themeMode TESTS
- // ============================================================================================
-
- @Test
- fun `themeMode defaults to SYSTEM`() = runTest {
- val mode = repository.themeMode.first()
- assertEquals(ThemeMode.SYSTEM, mode)
- }
-
- @Test
- fun `themeMode updates correctly`() = runTest {
- repository.setThemeMode(ThemeMode.DARK)
- assertEquals(ThemeMode.DARK, repository.themeMode.first())
-
- repository.setThemeMode(ThemeMode.LIGHT)
- assertEquals(ThemeMode.LIGHT, repository.themeMode.first())
- }
-
- // ============================================================================================
- // displayName TESTS
- // ============================================================================================
-
- @Test
- fun `displayName defaults to User_ fallback when not set`() = runTest {
- val name = repository.displayName.first()
- assertTrue(name.startsWith("User_"))
- }
-
- @Test
- fun `displayName returns set value`() = runTest {
- repository.updateDisplayName("Alice")
- val name = repository.displayName.first()
- assertEquals("Alice", name)
- }
-
- @Test
- fun `updateDisplayName throws on empty name`() = runTest {
- try {
- repository.updateDisplayName("")
- assertFalse("Expected exception for empty name", true)
- } catch (e: IllegalArgumentException) {
- assertTrue(e.message?.contains("at least 1 character") == true)
- }
- }
-
- @Test
- fun `updateDisplayName throws on long name`() = runTest {
- try {
- repository.updateDisplayName("A".repeat(31))
- assertFalse("Expected exception for long name", true)
- } catch (e: IllegalArgumentException) {
- assertTrue(e.message?.contains("30 characters") == true)
- }
- }
-
- @Test
- fun `updateDisplayName trims whitespace`() = runTest {
- repository.updateDisplayName(" Bob ")
- val name = repository.displayName.first()
- assertEquals("Bob", name)
- }
-
- // ============================================================================================
- // getDeviceId() TESTS
- // ============================================================================================
-
- @Test
- fun `getDeviceId generates consistent UUID`() = runTest {
- val firstId = repository.getDeviceId()
- val secondId = repository.getDeviceId()
-
- // Same session should return the same ID
- assertEquals(firstId, secondId)
- assertTrue(firstId.isNotBlank())
- }
-
- @Test
- fun `getDeviceId returns valid UUID format`() = runTest {
- val deviceId = repository.getDeviceId()
- // UUIDs have the format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
- assertTrue(deviceId.matches(Regex("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}")))
- }
-
- // ============================================================================================
- // dynamicColor TESTS
- // ============================================================================================
-
- @Test
- fun `dynamicColor defaults to true`() = runTest {
- assertTrue(repository.dynamicColorEnabled.first())
- }
-
- @Test
- fun `dynamicColor updates correctly`() = runTest {
- repository.setDynamicColor(false)
- assertFalse(repository.dynamicColorEnabled.first())
-
- repository.setDynamicColor(true)
- assertTrue(repository.dynamicColorEnabled.first())
- }
-
- // ============================================================================================
- // hapticFeedback TESTS
- // ============================================================================================
-
- @Test
- fun `hapticFeedback defaults to true`() = runTest {
- assertTrue(repository.hapticFeedbackEnabled.first())
- }
-
- @Test
- fun `hapticFeedback updates correctly`() = runTest {
- repository.setHapticFeedback(false)
- assertFalse(repository.hapticFeedbackEnabled.first())
- }
-
- // ============================================================================================
- // isNetworkVisible TESTS
- // ============================================================================================
-
- @Test
- fun `isNetworkVisible defaults to true`() = runTest {
- assertTrue(repository.isNetworkVisible.first())
- }
-
- @Test
- fun `isNetworkVisible updates correctly`() = runTest {
- repository.setNetworkVisibility(false)
- assertFalse(repository.isNetworkVisible.first())
- }
-
- // ============================================================================================
- // avatarHash TESTS
- // ============================================================================================
-
- @Test
- fun `avatarHash defaults to null`() = runTest {
- val hash = repository.avatarHash.first()
- assertNull(hash)
- }
-
- @Test
- fun `avatarHash updates correctly`() = runTest {
- repository.updateAvatarHash("abc123")
- assertEquals("abc123", repository.avatarHash.first())
-
- repository.updateAvatarHash(null)
- assertNull(repository.avatarHash.first())
- }
-
- // ============================================================================================
- // MD3E Settings TESTS
- // ============================================================================================
-
- @Test
- fun `shapeStyle defaults to CIRCLE`() = runTest {
- assertEquals(ShapeStyle.CIRCLE, repository.shapeStyle.first())
- }
-
- @Test
- fun `shapeStyle updates correctly`() = runTest {
- repository.setShapeStyle(ShapeStyle.BLOB)
- assertEquals(ShapeStyle.BLOB, repository.shapeStyle.first())
- }
-
- @Test
- fun `motionPreset defaults to STANDARD`() = runTest {
- assertEquals(MotionPreset.STANDARD, repository.motionPreset.first())
- }
-
- @Test
- fun `motionPreset updates correctly`() = runTest {
- repository.setMotionPreset(MotionPreset.GENTLE)
- assertEquals(MotionPreset.GENTLE, repository.motionPreset.first())
- }
-
- @Test
- fun `motionScale defaults to 1_0f`() = runTest {
- assertEquals(1.0f, repository.motionScale.first(), 0.001f)
- }
-
- @Test
- fun `motionScale clamps to valid range`() = runTest {
- repository.setMotionScale(3.0f)
- assertEquals(2.0f, repository.motionScale.first(), 0.001f)
-
- repository.setMotionScale(0.1f)
- assertEquals(0.5f, repository.motionScale.first(), 0.001f)
- }
-
- @Test
- fun `fontFamilyPreset defaults to ROBOTO`() = runTest {
- assertEquals(FontFamilyPreset.ROBOTO, repository.fontFamilyPreset.first())
- }
-
- @Test
- fun `fontFamilyPreset updates correctly`() = runTest {
- repository.setFontFamilyPreset(FontFamilyPreset.POPPINS)
- assertEquals(FontFamilyPreset.POPPINS, repository.fontFamilyPreset.first())
- }
-
- @Test
- fun `customFontUri defaults to null`() = runTest {
- assertNull(repository.customFontUri.first())
- }
-
- @Test
- fun `customFontUri updates correctly`() = runTest {
- repository.setCustomFontUri("content://fonts/custom.ttf")
- assertEquals("content://fonts/custom.ttf", repository.customFontUri.first())
-
- repository.setCustomFontUri(null)
- assertNull(repository.customFontUri.first())
- }
-
- @Test
- fun `bubbleStyle defaults to ROUNDED`() = runTest {
- assertEquals(BubbleStyle.ROUNDED, repository.bubbleStyle.first())
- }
-
- @Test
- fun `bubbleStyle updates correctly`() = runTest {
- repository.setBubbleStyle(BubbleStyle.TAILED)
- assertEquals(BubbleStyle.TAILED, repository.bubbleStyle.first())
- }
-
- @Test
- fun `visualDensity defaults to 1_0f`() = runTest {
- assertEquals(1.0f, repository.visualDensity.first(), 0.001f)
- }
-
- @Test
- fun `visualDensity clamps to valid range`() = runTest {
- repository.setVisualDensity(2.0f)
- assertEquals(1.5f, repository.visualDensity.first(), 0.001f)
-
- repository.setVisualDensity(0.5f)
- assertEquals(0.8f, repository.visualDensity.first(), 0.001f)
- }
-
- @Test
- fun `seedColor defaults to teal`() = runTest {
- assertEquals(0xFF006D68.toInt(), repository.seedColor.first())
- }
-
- @Test
- fun `seedColor updates correctly`() = runTest {
- repository.setSeedColor(0xFFFF0000.toInt())
- assertEquals(0xFFFF0000.toInt(), repository.seedColor.first())
- }
-
- // ============================================================================================
- // BLE / Transport TESTS
- // ============================================================================================
-
- @Test
- fun `bleEnabled defaults to false`() = runTest {
- assertFalse(repository.bleEnabled.first())
- }
-
- @Test
- fun `bleEnabled updates correctly`() = runTest {
- repository.setBleEnabled(true)
- assertTrue(repository.bleEnabled.first())
- }
-
- @Test
- fun `transportMode defaults to MULTI_PATH`() = runTest {
- assertEquals(TransportMode.MULTI_PATH, repository.transportMode.first())
- }
-
- @Test
- fun `transportMode updates correctly`() = runTest {
- repository.setTransportMode(TransportMode.LAN_ONLY)
- assertEquals(TransportMode.LAN_ONLY, repository.transportMode.first())
- }
-
- // ============================================================================================
- // Onboarding TESTS
- // ============================================================================================
-
- @Test
- fun `onboarding defaults to not completed`() = runTest {
- assertFalse(repository.hasCompletedOnboarding.first())
- }
-
- @Test
- fun `setOnboardingCompleted stores true`() = runTest {
- repository.setOnboardingCompleted()
- assertTrue(repository.hasCompletedOnboarding.first())
- }
-
- @Test
- fun `resetOnboardingCompleted reverts to false`() = runTest {
- repository.setOnboardingCompleted()
- assertTrue(repository.hasCompletedOnboarding.first())
-
- repository.resetOnboardingCompleted()
- assertFalse(repository.hasCompletedOnboarding.first())
- }
-
- // ============================================================================================
- // Notification Settings TESTS
- // ============================================================================================
-
- @Test
- fun `notificationsEnabled defaults to true`() = runTest {
- assertTrue(repository.notificationsEnabled.first())
- }
-
- @Test
- fun `notificationsEnabled updates correctly`() = runTest {
- repository.setNotificationsEnabled(false)
- assertFalse(repository.notificationsEnabled.first())
- }
-
- @Test
- fun `notificationSound defaults to true`() = runTest {
- assertTrue(repository.notificationSound.first())
- }
-
- @Test
- fun `notificationSound updates correctly`() = runTest {
- repository.setNotificationSound(false)
- assertFalse(repository.notificationSound.first())
- }
-
- @Test
- fun `notificationVibrate defaults to true`() = runTest {
- assertTrue(repository.notificationVibrate.first())
- }
-
- @Test
- fun `notificationVibrate updates correctly`() = runTest {
- repository.setNotificationVibrate(false)
- assertFalse(repository.notificationVibrate.first())
- }
-
- // ============================================================================================
- // fontSizeScale TESTS
- // ============================================================================================
-
- @Test
- fun `fontSizeScale defaults to 1_0f`() = runTest {
- assertEquals(1.0f, repository.fontSizeScale.first(), 0.001f)
- }
-
- @Test
- fun `fontSizeScale clamps to valid range`() = runTest {
- repository.setFontSizeScale(2.0f)
- assertEquals(1.5f, repository.fontSizeScale.first(), 0.001f)
-
- repository.setFontSizeScale(0.5f)
- assertEquals(0.8f, repository.fontSizeScale.first(), 0.001f)
- }
-
- // ============================================================================================
- // getAppVersion() TESTS
- // ============================================================================================
-
- @Test
- fun `getAppVersion returns valid version string`() {
- val version = repository.getAppVersion()
- assertNotNull(version)
- assertTrue(version.isNotEmpty())
- }
-
- // ============================================================================================
- // exportBackup() / importBackup() TESTS
- // ============================================================================================
-
- @Test
- fun `exportBackup returns JSON with settings`() = runTest {
- // Given: set some values
- repository.updateDisplayName("ExportUser")
- repository.setAppLanguage("fr")
- repository.setThemeMode(ThemeMode.DARK)
-
- // When
- val result = repository.exportBackup()
-
- // Then
- assertTrue(result.isSuccess)
- val json = result.getOrNull()
- assertNotNull(json)
- assertTrue(json!!.contains("display_name"))
- assertTrue(json.contains("theme_mode"))
- assertTrue(json.contains("app_language"))
- assertTrue(json.contains("export_timestamp"))
- }
-
- @Test
- fun `importBackup restores settings`() = runTest {
- // Given: export settings
- repository.updateDisplayName("OriginalName")
- repository.setAppLanguage("en")
-
- val exportResult = repository.exportBackup()
- assertTrue(exportResult.isSuccess)
- val backupJson = exportResult.getOrThrow()
-
- // Change settings
- repository.updateDisplayName("ChangedName")
- repository.setAppLanguage("de")
-
- // When: import original backup
- val importResult = repository.importBackup(backupJson)
-
- // Then: settings restored
- assertTrue(importResult.isSuccess)
- assertEquals("OriginalName", repository.displayName.first())
- assertEquals("en", repository.appLanguage.first())
- }
-
- @Test
- fun `importBackup handles invalid JSON`() = runTest {
- val result = repository.importBackup("not valid json")
- assertTrue(result.isFailure)
- }
-
- @Test
- fun `importBackup handles empty JSON object`() = runTest {
- val result = repository.importBackup("{}")
- assertTrue(result.isSuccess)
- }
-
- @Test
- fun `exportBackup on fresh settings includes default values`() = runTest {
- val result = repository.exportBackup()
- assertTrue(result.isSuccess)
- val json = result.getOrThrow()
- // Fresh settings should still have export_timestamp
- assertTrue(json.contains("export_timestamp"))
- }
-
- // ============================================================================================
- // clearCache() TESTS
- // ============================================================================================
- // clearCache deals with file system operations (avatar dirs, coil cache).
- // It should not throw even if directories don't exist.
-
- @Test
- fun `clearCache does not throw when directories missing`() = runTest {
- // Should complete without exception even if cache dirs don't exist
- repository.clearCache()
- // No assertion needed - just verify no exception
- }
-}
diff --git a/core/domain/src/main/java/com/p2p/meshify/core/domain/interfaces/WifiStateChecker.kt b/core/domain/src/main/java/com/p2p/meshify/core/domain/interfaces/WifiStateChecker.kt
index 61af08d1..0e770944 100644
--- a/core/domain/src/main/java/com/p2p/meshify/core/domain/interfaces/WifiStateChecker.kt
+++ b/core/domain/src/main/java/com/p2p/meshify/core/domain/interfaces/WifiStateChecker.kt
@@ -10,10 +10,4 @@ interface WifiStateChecker {
* @return true if Wi-Fi is enabled, false otherwise
*/
val isWifiEnabled: Boolean
-
- /**
- * Explicit method to check Wi-Fi state.
- * @return current Wi-Fi state (same as isWifiEnabled)
- */
- fun checkWifiState(): Boolean
}
diff --git a/core/domain/src/main/java/com/p2p/meshify/core/domain/model/UploadProgress.kt b/core/domain/src/main/java/com/p2p/meshify/core/domain/model/UploadProgress.kt
deleted file mode 100644
index 21c1f733..00000000
--- a/core/domain/src/main/java/com/p2p/meshify/core/domain/model/UploadProgress.kt
+++ /dev/null
@@ -1,16 +0,0 @@
-package com.p2p.meshify.core.domain.model
-
-/**
- * Sealed class representing upload progress states.
- * Used to track file upload progress in the UI.
- */
-sealed class UploadProgress {
- /** Upload in progress with percentage (0-100) */
- data class Uploading(val percent: Int) : UploadProgress()
-
- /** Upload completed successfully */
- data class Success(val messageId: String) : UploadProgress()
-
- /** Upload failed with error message */
- data class Error(val messageId: String, val message: String) : UploadProgress()
-}
diff --git a/core/domain/src/main/java/com/p2p/meshify/domain/model/FileTypeData.kt b/core/domain/src/main/java/com/p2p/meshify/domain/model/FileTypeData.kt
deleted file mode 100644
index a6a74502..00000000
--- a/core/domain/src/main/java/com/p2p/meshify/domain/model/FileTypeData.kt
+++ /dev/null
@@ -1,59 +0,0 @@
-package com.p2p.meshify.domain.model
-
-/**
- * File type data with icon identifier and display info.
- * Icons are mapped in the UI layer.
- */
-data class FileTypeData(
- val iconId: String, // Icon identifier for UI layer to resolve
- val label: String,
- val color: Long // ARGB color
-)
-
-/**
- * Extension to get file type data.
- */
-fun MessageType.getFileTypeData(): FileTypeData {
- return when (this) {
- MessageType.TEXT -> FileTypeData(
- iconId = "description",
- label = "Text",
- color = 0xFF4285F4 // Blue
- )
- MessageType.IMAGE -> FileTypeData(
- iconId = "image",
- label = "Image",
- color = 0xFF34A853 // Green
- )
- MessageType.VIDEO -> FileTypeData(
- iconId = "movie",
- label = "Video",
- color = 0xFFEA4335 // Red
- )
- MessageType.AUDIO -> FileTypeData(
- iconId = "music_note",
- label = "Audio",
- color = 0xFFFBBC05 // Yellow
- )
- MessageType.DOCUMENT -> FileTypeData(
- iconId = "description",
- label = "Document",
- color = 0xFF4285F4 // Blue
- )
- MessageType.ARCHIVE -> FileTypeData(
- iconId = "folder_zip",
- label = "Archive",
- color = 0xFF9AA0A6 // Gray
- )
- MessageType.APK -> FileTypeData(
- iconId = "android",
- label = "APK",
- color = 0xFF34A853 // Green
- )
- MessageType.FILE -> FileTypeData(
- iconId = "insert_drive_file",
- label = "File",
- color = 0xFF9AA0A6 // Gray
- )
- }
-}
diff --git a/core/domain/src/main/java/com/p2p/meshify/domain/model/Payload.kt b/core/domain/src/main/java/com/p2p/meshify/domain/model/Payload.kt
index a3ae7048..2f34aaca 100644
--- a/core/domain/src/main/java/com/p2p/meshify/domain/model/Payload.kt
+++ b/core/domain/src/main/java/com/p2p/meshify/domain/model/Payload.kt
@@ -13,6 +13,15 @@ data class Payload(
val type: PayloadType,
val data: ByteArray
) {
+ // ByteArray forces manual equals/hashCode — data class would use reference equality
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is Payload) return false
+ return id == other.id
+ }
+
+ override fun hashCode(): Int = id.hashCode()
+
enum class PayloadType {
TEXT,
FILE,
@@ -20,22 +29,10 @@ data class Payload(
SYSTEM_CONTROL,
DELETE_REQUEST,
REACTION,
- DELIVERY_ACK,
AVATAR_REQUEST,
AVATAR_RESPONSE,
VIDEO
}
-
- override fun equals(other: Any?): Boolean {
- if (this === other) return true
- if (javaClass != other?.javaClass) return false
- other as Payload
- return id == other.id
- }
-
- override fun hashCode(): Int {
- return id.hashCode()
- }
}
@Serializable
@@ -67,20 +64,3 @@ data class ReactionUpdate(
val senderId: String
)
-/**
- * Extension function for safe enum lookup from String.
- */
-fun PayloadTypeFromString(name: String): Payload.PayloadType? {
- return try {
- Payload.PayloadType.valueOf(name)
- } catch (e: IllegalArgumentException) {
- null
- }
-}
-
-/**
- * Extension function for safe enum lookup from String.
- */
-fun String.toPayloadType(): Payload.PayloadType? {
- return PayloadTypeFromString(this)
-}
diff --git a/core/domain/src/main/java/com/p2p/meshify/domain/model/ThemeConfig.kt b/core/domain/src/main/java/com/p2p/meshify/domain/model/ThemeConfig.kt
index 4bc92d22..4fc816dd 100644
--- a/core/domain/src/main/java/com/p2p/meshify/domain/model/ThemeConfig.kt
+++ b/core/domain/src/main/java/com/p2p/meshify/domain/model/ThemeConfig.kt
@@ -1,9 +1,9 @@
package com.p2p.meshify.domain.model
-enum class ShapeStyle { SQUARE }
+enum class ShapeStyle { SUNNY, BREEZY, PENTAGON, BLOB, BURST, CLOVER, CIRCLE }
-enum class MotionPreset { STANDARD }
+enum class MotionPreset { GENTLE, STANDARD, SNAPPY, BOUNCY }
-enum class FontFamilyPreset { ROBOTO }
+enum class FontFamilyPreset { ROBOTO, POPPINS, LORA, MONTSERRAT, PLAYFAIR, INTER }
-enum class BubbleStyle { ROUNDED }
+enum class BubbleStyle { ROUNDED, TAILED, SQUARCLES, ORGANIC }
diff --git a/core/domain/src/main/java/com/p2p/meshify/domain/repository/IChatRepository.kt b/core/domain/src/main/java/com/p2p/meshify/domain/repository/IChatRepository.kt
index bc9c9da1..68f9194d 100644
--- a/core/domain/src/main/java/com/p2p/meshify/domain/repository/IChatRepository.kt
+++ b/core/domain/src/main/java/com/p2p/meshify/domain/repository/IChatRepository.kt
@@ -34,8 +34,6 @@ interface IChatRepository {
// Message sending
suspend fun sendMessage(peerId: String, peerName: String, text: String, replyToId: String? = null): Result
- suspend fun sendImage(peerId: String, peerName: String, imageBytes: ByteArray, extension: String, replyToId: String? = null): Result
- suspend fun sendVideo(peerId: String, peerName: String, videoBytes: ByteArray, extension: String, replyToId: String? = null): Result
suspend fun sendGroupedMessage(
peerId: String,
peerName: String,
@@ -66,7 +64,7 @@ interface IChatRepository {
suspend fun addReaction(messageId: String, reaction: String?): Result
// System
- suspend fun sendSystemCommand(peerId: String, command: String)
+ suspend fun sendSystemCommand(peerId: String, command: String): Result
suspend fun handleIncomingPayload(peerId: String, payload: Payload)
suspend fun retryPendingMessages(peerId: String): Result
}
diff --git a/core/domain/src/main/java/com/p2p/meshify/domain/repository/IFileManager.kt b/core/domain/src/main/java/com/p2p/meshify/domain/repository/IFileManager.kt
index c377d62c..6f2fd807 100644
--- a/core/domain/src/main/java/com/p2p/meshify/domain/repository/IFileManager.kt
+++ b/core/domain/src/main/java/com/p2p/meshify/domain/repository/IFileManager.kt
@@ -5,5 +5,4 @@ package com.p2p.meshify.domain.repository
*/
interface IFileManager {
suspend fun saveMedia(fileName: String, data: ByteArray): String?
- fun getAppVersion(): String
}
diff --git a/core/domain/src/main/java/com/p2p/meshify/domain/repository/ISettingsRepository.kt b/core/domain/src/main/java/com/p2p/meshify/domain/repository/ISettingsRepository.kt
index 0e996c1a..35d87b94 100644
--- a/core/domain/src/main/java/com/p2p/meshify/domain/repository/ISettingsRepository.kt
+++ b/core/domain/src/main/java/com/p2p/meshify/domain/repository/ISettingsRepository.kt
@@ -1,5 +1,9 @@
package com.p2p.meshify.domain.repository
+import com.p2p.meshify.domain.model.BubbleStyle
+import com.p2p.meshify.domain.model.FontFamilyPreset
+import com.p2p.meshify.domain.model.MotionPreset
+import com.p2p.meshify.domain.model.ShapeStyle
import com.p2p.meshify.domain.model.TransportMode
import kotlinx.coroutines.flow.Flow
@@ -14,6 +18,15 @@ interface ISettingsRepository {
val avatarHash: Flow
val seedColor: Flow
+ // MD3E design configuration flows
+ val shapeStyle: Flow
+ val motionPreset: Flow
+ val motionScale: Flow
+ val fontFamilyPreset: Flow
+ val customFontUri: Flow
+ val bubbleStyle: Flow
+ val visualDensity: Flow
+
val bleEnabled: Flow
val transportMode: Flow
@@ -34,6 +47,15 @@ interface ISettingsRepository {
suspend fun updateAvatarHash(hash: String?)
suspend fun setSeedColor(color: Int)
+ // MD3E design configuration setters
+ suspend fun setShapeStyle(style: ShapeStyle)
+ suspend fun setMotionPreset(preset: MotionPreset)
+ suspend fun setMotionScale(scale: Float)
+ suspend fun setFontFamilyPreset(family: FontFamilyPreset)
+ suspend fun setCustomFontUri(uri: String?)
+ suspend fun setBubbleStyle(style: BubbleStyle)
+ suspend fun setVisualDensity(density: Float)
+
suspend fun setBleEnabled(enabled: Boolean)
suspend fun setTransportMode(mode: TransportMode)
@@ -47,5 +69,6 @@ interface ISettingsRepository {
suspend fun setNotificationVibrate(enabled: Boolean)
suspend fun clearCache()
suspend fun exportBackup(): Result
+ suspend fun importBackup(json: String): Result
fun getAppVersion(): String
}
diff --git a/core/domain/src/main/java/com/p2p/meshify/domain/usecase/SendMessageValidation.kt b/core/domain/src/main/java/com/p2p/meshify/domain/usecase/SendMessageValidation.kt
deleted file mode 100644
index cc8bc07c..00000000
--- a/core/domain/src/main/java/com/p2p/meshify/domain/usecase/SendMessageValidation.kt
+++ /dev/null
@@ -1,81 +0,0 @@
-package com.p2p.meshify.domain.usecase
-
-/**
- * Validates message send requests before they reach the repository layer.
- *
- * Extracted from ChatInputViewModel.sendMessage() to enable pure unit testing
- * of validation rules without Android/Compose dependencies.
- *
- * Validation rules:
- * 1. Text must not be blank (whitespace-only counts as blank)
- * 2. Attachments may supplement empty text, but at least one content source required
- * 3. Debouncing prevents rapid duplicate sends (500ms window)
- * 4. Reply-to-self is invalid (message author cannot reply to own message)
- * 5. Message text must not exceed max size (4096 characters)
- */
-class SendMessageValidation {
-
- companion object {
- private const val SEND_DEBOUNCE_MS = 500L
- const val MAX_MESSAGE_LENGTH = 4096
- }
-
- /**
- * Result of validation.
- * @property isValid whether the message can be sent
- * @property errorCode null if valid, otherwise a specific error code
- */
- data class ValidationResult(
- val isValid: Boolean,
- val errorCode: ErrorCode? = null
- )
-
- enum class ErrorCode {
- EMPTY_CONTENT,
- SEND_DEBOUNCED,
- REPLY_TO_SELF,
- MESSAGE_TOO_LONG
- }
-
- /**
- * Validates whether a message can be sent.
- *
- * @param text The message text (may be blank if attachments exist)
- * @param hasAttachments Whether the message includes file/image attachments
- * @param lastSendTimeMillis Timestamp of last successful send (0 if none)
- * @param currentTimeMillis Current timestamp
- * @param isReplyToSelf Whether the reply target is the sender's own message
- * @return ValidationResult with error code if invalid
- */
- fun validate(
- text: String,
- hasAttachments: Boolean = false,
- lastSendTimeMillis: Long = 0,
- currentTimeMillis: Long = System.currentTimeMillis(),
- isReplyToSelf: Boolean = false
- ): ValidationResult {
- // Rule 1: Content check — text or attachments required
- val hasText = text.isNotBlank()
- if (!hasText && !hasAttachments) {
- return ValidationResult(isValid = false, errorCode = ErrorCode.EMPTY_CONTENT)
- }
-
- // Rule 2: Message length check
- if (text.length > MAX_MESSAGE_LENGTH) {
- return ValidationResult(isValid = false, errorCode = ErrorCode.MESSAGE_TOO_LONG)
- }
-
- // Rule 3: Reply-to-self check
- if (isReplyToSelf) {
- return ValidationResult(isValid = false, errorCode = ErrorCode.REPLY_TO_SELF)
- }
-
- // Rule 4: Debounce check
- val elapsed = currentTimeMillis - lastSendTimeMillis
- if (lastSendTimeMillis > 0 && elapsed < SEND_DEBOUNCE_MS) {
- return ValidationResult(isValid = false, errorCode = ErrorCode.SEND_DEBOUNCED)
- }
-
- return ValidationResult(isValid = true)
- }
-}
diff --git a/core/domain/src/test/java/com/p2p/meshify/domain/model/AppConstantsTest.kt b/core/domain/src/test/java/com/p2p/meshify/domain/model/AppConstantsTest.kt
deleted file mode 100644
index 6ca1d418..00000000
--- a/core/domain/src/test/java/com/p2p/meshify/domain/model/AppConstantsTest.kt
+++ /dev/null
@@ -1,65 +0,0 @@
-package com.p2p.meshify.domain.model
-
-import org.junit.Assert.*
-import org.junit.Test
-
-/**
- * Unit tests for AppConstants object.
- */
-class AppConstantsTest {
-
- @Test
- fun `MAX_FILE_SIZE_BYTES is positive`() {
- assertTrue("MAX_FILE_SIZE_BYTES should be positive", AppConstants.MAX_FILE_SIZE_BYTES > 0)
- }
-
- @Test
- fun `MAX_FILE_SIZE_BYTES equals 100MB`() {
- assertEquals(100 * 1024 * 1024L, AppConstants.MAX_FILE_SIZE_BYTES)
- }
-
- @Test
- fun `MAX_FILE_SIZE_BYTES is exactly 104857600`() {
- assertEquals(104_857_600L, AppConstants.MAX_FILE_SIZE_BYTES)
- }
-
- @Test
- fun `MAX_FILE_SIZE_BYTES is not zero`() {
- assertNotEquals(0L, AppConstants.MAX_FILE_SIZE_BYTES)
- }
-
- @Test
- fun `DEFAULT_PEER_NAME_PREFIX is not blank`() {
- assertTrue(
- "DEFAULT_PEER_NAME_PREFIX should not be blank",
- AppConstants.DEFAULT_PEER_NAME_PREFIX.isNotBlank()
- )
- }
-
- @Test
- fun `DEFAULT_PEER_NAME_PREFIX equals Peer_`() {
- assertEquals("Peer_", AppConstants.DEFAULT_PEER_NAME_PREFIX)
- }
-
- @Test
- fun `DEFAULT_PEER_NAME_PREFIX ends with underscore`() {
- assertTrue(
- "DEFAULT_PEER_NAME_PREFIX should end with underscore for appending",
- AppConstants.DEFAULT_PEER_NAME_PREFIX.endsWith("_")
- )
- }
-
- @Test
- fun `DEFAULT_PEER_NAME_PREFIX does not contain whitespace`() {
- assertFalse(
- "DEFAULT_PEER_NAME_PREFIX should not contain whitespace",
- AppConstants.DEFAULT_PEER_NAME_PREFIX.contains(" ")
- )
- }
-
- @Test
- fun `all constants are non-null`() {
- assertNotNull(AppConstants.MAX_FILE_SIZE_BYTES)
- assertNotNull(AppConstants.DEFAULT_PEER_NAME_PREFIX)
- }
-}
diff --git a/core/domain/src/test/java/com/p2p/meshify/domain/model/FileTypeDataTest.kt b/core/domain/src/test/java/com/p2p/meshify/domain/model/FileTypeDataTest.kt
deleted file mode 100644
index 19e47807..00000000
--- a/core/domain/src/test/java/com/p2p/meshify/domain/model/FileTypeDataTest.kt
+++ /dev/null
@@ -1,167 +0,0 @@
-package com.p2p.meshify.domain.model
-
-import org.junit.Assert.*
-import org.junit.Test
-
-/**
- * Unit tests for FileTypeData data class and MessageType.getFileTypeData() extension.
- */
-class FileTypeDataTest {
-
- // --- FileTypeData construction ---
-
- @Test
- fun `FileTypeData constructed with all params returns correct values`() {
- val data = FileTypeData(
- iconId = "image",
- label = "Image",
- color = 0xFF34A853
- )
-
- assertEquals("image", data.iconId)
- assertEquals("Image", data.label)
- assertEquals(0xFF34A853, data.color)
- }
-
- // --- FileTypeData copy / equals / hashCode ---
-
- @Test
- fun `FileTypeData copy creates equal instance with no overrides`() {
- val data = FileTypeData(iconId = "a", label = "A", color = 0xFF000000)
- assertEquals(data, data.copy())
- }
-
- @Test
- fun `FileTypeData copy overrides specified field`() {
- val data = FileTypeData(iconId = "a", label = "A", color = 0xFF000000)
- val modified = data.copy(label = "B")
- assertEquals("B", modified.label)
- assertEquals("a", modified.iconId)
- }
-
- @Test
- fun `FileTypeData equals returns true for same values`() {
- val a = FileTypeData(iconId = "x", label = "X", color = 0xFF123456)
- val b = FileTypeData(iconId = "x", label = "X", color = 0xFF123456)
- assertEquals(a, b)
- }
-
- @Test
- fun `FileTypeData equals returns false for different iconId`() {
- val a = FileTypeData(iconId = "a", label = "X", color = 0xFF123456)
- val b = FileTypeData(iconId = "b", label = "X", color = 0xFF123456)
- assertNotEquals(a, b)
- }
-
- @Test
- fun `FileTypeData hashCode is consistent for equal instances`() {
- val a = FileTypeData(iconId = "x", label = "X", color = 0xFF123456)
- val b = FileTypeData(iconId = "x", label = "X", color = 0xFF123456)
- assertEquals(a.hashCode(), b.hashCode())
- }
-
- @Test
- fun `FileTypeData toString contains iconId and label`() {
- val data = FileTypeData(iconId = "music_note", label = "Audio", color = 0xFFFBBC05)
- val str = data.toString()
- assertTrue(str.contains("music_note"))
- assertTrue(str.contains("Audio"))
- assertTrue(str.contains("color="))
- }
-
- // --- getFileTypeData for each MessageType ---
-
- @Test
- fun `TEXT getFileTypeData returns correct values`() {
- val data = MessageType.TEXT.getFileTypeData()
- assertEquals("description", data.iconId)
- assertEquals("Text", data.label)
- assertEquals(0xFF4285F4, data.color)
- }
-
- @Test
- fun `IMAGE getFileTypeData returns correct values`() {
- val data = MessageType.IMAGE.getFileTypeData()
- assertEquals("image", data.iconId)
- assertEquals("Image", data.label)
- assertEquals(0xFF34A853, data.color)
- }
-
- @Test
- fun `VIDEO getFileTypeData returns correct values`() {
- val data = MessageType.VIDEO.getFileTypeData()
- assertEquals("movie", data.iconId)
- assertEquals("Video", data.label)
- assertEquals(0xFFEA4335, data.color)
- }
-
- @Test
- fun `AUDIO getFileTypeData returns correct values`() {
- val data = MessageType.AUDIO.getFileTypeData()
- assertEquals("music_note", data.iconId)
- assertEquals("Audio", data.label)
- assertEquals(0xFFFBBC05, data.color)
- }
-
- @Test
- fun `DOCUMENT getFileTypeData returns correct values`() {
- val data = MessageType.DOCUMENT.getFileTypeData()
- assertEquals("description", data.iconId)
- assertEquals("Document", data.label)
- assertEquals(0xFF4285F4, data.color)
- }
-
- @Test
- fun `ARCHIVE getFileTypeData returns correct values`() {
- val data = MessageType.ARCHIVE.getFileTypeData()
- assertEquals("folder_zip", data.iconId)
- assertEquals("Archive", data.label)
- assertEquals(0xFF9AA0A6, data.color)
- }
-
- @Test
- fun `APK getFileTypeData returns correct values`() {
- val data = MessageType.APK.getFileTypeData()
- assertEquals("android", data.iconId)
- assertEquals("APK", data.label)
- assertEquals(0xFF34A853, data.color)
- }
-
- @Test
- fun `FILE getFileTypeData returns correct values`() {
- val data = MessageType.FILE.getFileTypeData()
- assertEquals("insert_drive_file", data.iconId)
- assertEquals("File", data.label)
- assertEquals(0xFF9AA0A6, data.color)
- }
-
- // --- Color values are proper ARGB ---
-
- @Test
- fun `all getFileTypeData colors have full alpha channel`() {
- val types = MessageType.values()
- for (type in types) {
- val data = type.getFileTypeData()
- val alpha = (data.color shr 24) and 0xFF
- assertEquals("$type should have full alpha", 0xFF, alpha.toInt())
- }
- }
-
- @Test
- fun `all getFileTypeData labels are non-empty`() {
- val types = MessageType.values()
- for (type in types) {
- val data = type.getFileTypeData()
- assertTrue("$type label should not be blank", data.label.isNotBlank())
- }
- }
-
- @Test
- fun `all getFileTypeData iconIds are non-empty`() {
- val types = MessageType.values()
- for (type in types) {
- val data = type.getFileTypeData()
- assertTrue("$type iconId should not be blank", data.iconId.isNotBlank())
- }
- }
-}
diff --git a/core/domain/src/test/java/com/p2p/meshify/domain/model/MessageTypeTest.kt b/core/domain/src/test/java/com/p2p/meshify/domain/model/MessageTypeTest.kt
deleted file mode 100644
index a3caf97b..00000000
--- a/core/domain/src/test/java/com/p2p/meshify/domain/model/MessageTypeTest.kt
+++ /dev/null
@@ -1,279 +0,0 @@
-package com.p2p.meshify.domain.model
-
-import org.junit.Assert.*
-import org.junit.Test
-
-/**
- * Unit tests for MessageType enum and its companion methods.
- */
-class MessageTypeTest {
-
- // --- Enum values ---
-
- @Test
- fun `MessageType contains all expected values`() {
- val expected = listOf(
- MessageType.TEXT,
- MessageType.IMAGE,
- MessageType.VIDEO,
- MessageType.AUDIO,
- MessageType.DOCUMENT,
- MessageType.ARCHIVE,
- MessageType.APK,
- MessageType.FILE
- )
-
- assertEquals(expected.size, MessageType.values().size)
- assertTrue(MessageType.values().toList().containsAll(expected))
- }
-
- @Test
- fun `MessageType values have unique names`() {
- val names = MessageType.values().map { it.name }
- assertEquals(names.toSet().size, names.size)
- }
-
- @Test
- fun `MessageType IMAGE has correct mimeType`() {
- assertEquals("image/*", MessageType.IMAGE.mimeType)
- }
-
- @Test
- fun `MessageType VIDEO has correct mimeType`() {
- assertEquals("video/*", MessageType.VIDEO.mimeType)
- }
-
- @Test
- fun `MessageType AUDIO has correct mimeType`() {
- assertEquals("audio/*", MessageType.AUDIO.mimeType)
- }
-
- @Test
- fun `MessageType TEXT has correct mimeType`() {
- assertEquals("text/plain", MessageType.TEXT.mimeType)
- }
-
- @Test
- fun `MessageType DOCUMENT has correct mimeType`() {
- assertEquals("application/*", MessageType.DOCUMENT.mimeType)
- }
-
- @Test
- fun `MessageType ARCHIVE has correct mimeType`() {
- assertEquals("application/zip", MessageType.ARCHIVE.mimeType)
- }
-
- @Test
- fun `MessageType APK has correct mimeType`() {
- assertEquals("application/vnd.android.package-archive", MessageType.APK.mimeType)
- }
-
- @Test
- fun `MessageType FILE has correct mimeType`() {
- assertEquals("application/octet-stream", MessageType.FILE.mimeType)
- }
-
- // --- Extensions ---
-
- @Test
- fun `IMAGE has jpg jpeg png gif webp bmp svg extensions`() {
- assertEquals(
- listOf("jpg", "jpeg", "png", "gif", "webp", "bmp", "svg"),
- MessageType.IMAGE.extension
- )
- }
-
- @Test
- fun `VIDEO has mp4 mkv avi webm mov flv extensions`() {
- assertEquals(
- listOf("mp4", "mkv", "avi", "webm", "mov", "flv"),
- MessageType.VIDEO.extension
- )
- }
-
- @Test
- fun `AUDIO has mp3 wav aac flac ogg m4a wma extensions`() {
- assertEquals(
- listOf("mp3", "wav", "aac", "flac", "ogg", "m4a", "wma"),
- MessageType.AUDIO.extension
- )
- }
-
- @Test
- fun `FILE has wildcard extension`() {
- assertEquals(listOf("*"), MessageType.FILE.extension)
- }
-
- // --- fromExtension ---
-
- @Test
- fun `fromExtension returns IMAGE for jpg`() {
- assertEquals(MessageType.IMAGE, MessageType.fromExtension("jpg"))
- }
-
- @Test
- fun `fromExtension returns IMAGE for png`() {
- assertEquals(MessageType.IMAGE, MessageType.fromExtension("png"))
- }
-
- @Test
- fun `fromExtension returns VIDEO for mp4`() {
- assertEquals(MessageType.VIDEO, MessageType.fromExtension("mp4"))
- }
-
- @Test
- fun `fromExtension returns AUDIO for mp3`() {
- assertEquals(MessageType.AUDIO, MessageType.fromExtension("mp3"))
- }
-
- @Test
- fun `fromExtension returns DOCUMENT for pdf`() {
- assertEquals(MessageType.DOCUMENT, MessageType.fromExtension("pdf"))
- }
-
- @Test
- fun `fromExtension returns DOCUMENT for docx`() {
- assertEquals(MessageType.DOCUMENT, MessageType.fromExtension("docx"))
- }
-
- @Test
- fun `fromExtension returns ARCHIVE for zip`() {
- assertEquals(MessageType.ARCHIVE, MessageType.fromExtension("zip"))
- }
-
- @Test
- fun `fromExtension returns ARCHIVE for rar`() {
- assertEquals(MessageType.ARCHIVE, MessageType.fromExtension("rar"))
- }
-
- @Test
- fun `fromExtension returns APK for apk`() {
- assertEquals(MessageType.APK, MessageType.fromExtension("apk"))
- }
-
- @Test
- fun `fromExtension returns FILE for unknown extension`() {
- assertEquals(MessageType.FILE, MessageType.fromExtension("xyz"))
- }
-
- @Test
- fun `fromExtension handles leading dot`() {
- assertEquals(MessageType.IMAGE, MessageType.fromExtension(".jpg"))
- }
-
- @Test
- fun `fromExtension is case insensitive`() {
- assertEquals(MessageType.IMAGE, MessageType.fromExtension("JPG"))
- assertEquals(MessageType.IMAGE, MessageType.fromExtension("Png"))
- }
-
- @Test
- fun `fromExtension returns FILE for txt because TEXT is excluded from extension matching`() {
- assertEquals(MessageType.FILE, MessageType.fromExtension("txt"))
- }
-
- // --- fromMimeType ---
-
- @Test
- fun `fromMimeType returns IMAGE for image slash`() {
- assertEquals(MessageType.IMAGE, MessageType.fromMimeType("image/jpeg"))
- assertEquals(MessageType.IMAGE, MessageType.fromMimeType("image/png"))
- assertEquals(MessageType.IMAGE, MessageType.fromMimeType("image/gif"))
- }
-
- @Test
- fun `fromMimeType returns VIDEO for video slash`() {
- assertEquals(MessageType.VIDEO, MessageType.fromMimeType("video/mp4"))
- assertEquals(MessageType.VIDEO, MessageType.fromMimeType("video/webm"))
- }
-
- @Test
- fun `fromMimeType returns AUDIO for audio slash`() {
- assertEquals(MessageType.AUDIO, MessageType.fromMimeType("audio/mpeg"))
- assertEquals(MessageType.AUDIO, MessageType.fromMimeType("audio/wav"))
- }
-
- @Test
- fun `fromMimeType returns DOCUMENT for pdf`() {
- assertEquals(MessageType.DOCUMENT, MessageType.fromMimeType("application/pdf"))
- }
-
- @Test
- fun `fromMimeType returns DOCUMENT for word types`() {
- assertEquals(MessageType.DOCUMENT, MessageType.fromMimeType("application/msword"))
- assertEquals(
- MessageType.DOCUMENT,
- MessageType.fromMimeType("application/vnd.openxmlformats-officedocument.wordprocessingml.document")
- )
- }
-
- @Test
- fun `fromMimeType returns DOCUMENT for excel types`() {
- assertEquals(MessageType.DOCUMENT, MessageType.fromMimeType("application/vnd.ms-excel"))
- }
-
- @Test
- fun `fromMimeType returns DOCUMENT for powerpoint types`() {
- assertEquals(
- MessageType.DOCUMENT,
- MessageType.fromMimeType("application/vnd.ms-powerpoint")
- )
- }
-
- @Test
- fun `fromMimeType returns ARCHIVE for zip`() {
- assertEquals(MessageType.ARCHIVE, MessageType.fromMimeType("application/zip"))
- }
-
- @Test
- fun `fromMimeType returns ARCHIVE for compressed`() {
- assertEquals(MessageType.ARCHIVE, MessageType.fromMimeType("application/x-compressed"))
- }
-
- @Test
- fun `fromMimeType returns FILE for apk mime since 'apk' is not a substring of the registered mime type`() {
- assertEquals(MessageType.FILE, MessageType.fromMimeType("application/vnd.android.package-archive"))
- }
-
- @Test
- fun `fromMimeType returns TEXT for text slash`() {
- assertEquals(MessageType.TEXT, MessageType.fromMimeType("text/plain"))
- assertEquals(MessageType.TEXT, MessageType.fromMimeType("text/html"))
- }
-
- @Test
- fun `fromMimeType returns FILE for unknown mime type`() {
- assertEquals(MessageType.FILE, MessageType.fromMimeType("application/octet-stream"))
- }
-
- @Test
- fun `fromMimeType is case insensitive`() {
- assertEquals(MessageType.IMAGE, MessageType.fromMimeType("IMAGE/JPEG"))
- }
-
- // --- name property ---
-
- @Test
- fun `MessageType name returns correct enum name`() {
- assertEquals("TEXT", MessageType.TEXT.name)
- assertEquals("IMAGE", MessageType.IMAGE.name)
- assertEquals("VIDEO", MessageType.VIDEO.name)
- assertEquals("AUDIO", MessageType.AUDIO.name)
- assertEquals("DOCUMENT", MessageType.DOCUMENT.name)
- assertEquals("ARCHIVE", MessageType.ARCHIVE.name)
- assertEquals("APK", MessageType.APK.name)
- assertEquals("FILE", MessageType.FILE.name)
- }
-
- @Test
- fun `MessageType valueOf returns correct types`() {
- assertEquals(MessageType.TEXT, MessageType.valueOf("TEXT"))
- assertEquals(MessageType.IMAGE, MessageType.valueOf("IMAGE"))
- assertEquals(MessageType.VIDEO, MessageType.valueOf("VIDEO"))
- assertEquals(MessageType.AUDIO, MessageType.valueOf("AUDIO"))
- assertEquals(MessageType.DOCUMENT, MessageType.valueOf("DOCUMENT"))
- assertEquals(MessageType.ARCHIVE, MessageType.valueOf("ARCHIVE"))
- assertEquals(MessageType.APK, MessageType.valueOf("APK"))
- assertEquals(MessageType.FILE, MessageType.valueOf("FILE"))
- }
-}
diff --git a/core/domain/src/test/java/com/p2p/meshify/domain/model/PayloadTest.kt b/core/domain/src/test/java/com/p2p/meshify/domain/model/PayloadTest.kt
deleted file mode 100644
index a53621ec..00000000
--- a/core/domain/src/test/java/com/p2p/meshify/domain/model/PayloadTest.kt
+++ /dev/null
@@ -1,114 +0,0 @@
-package com.p2p.meshify.domain.model
-
-import org.junit.Assert.*
-import org.junit.Test
-import java.util.UUID
-
-/**
- * Unit tests for Payload data class.
- */
-class PayloadTest {
-
- @Test
- fun `Payload equals returns true for same id`() {
- val id = UUID.randomUUID().toString()
- val payload1 = Payload(
- id = id,
- senderId = "sender1",
- type = Payload.PayloadType.TEXT,
- data = byteArrayOf(1, 2, 3)
- )
- val payload2 = Payload(
- id = id,
- senderId = "sender2",
- type = Payload.PayloadType.FILE,
- data = byteArrayOf(4, 5, 6)
- )
-
- assertEquals(payload1, payload2)
- }
-
- @Test
- fun `Payload equals returns false for different id`() {
- val payload1 = Payload(
- id = UUID.randomUUID().toString(),
- senderId = "sender1",
- type = Payload.PayloadType.TEXT,
- data = byteArrayOf(1, 2, 3)
- )
- val payload2 = Payload(
- id = UUID.randomUUID().toString(),
- senderId = "sender1",
- type = Payload.PayloadType.TEXT,
- data = byteArrayOf(1, 2, 3)
- )
-
- assertNotEquals(payload1, payload2)
- }
-
- @Test
- fun `Payload hashCode is based on id only`() {
- val id = UUID.randomUUID().toString()
- val payload1 = Payload(
- id = id,
- senderId = "sender1",
- type = Payload.PayloadType.TEXT,
- data = byteArrayOf(1, 2, 3)
- )
- val payload2 = Payload(
- id = id,
- senderId = "sender2",
- type = Payload.PayloadType.FILE,
- data = byteArrayOf(4, 5, 6)
- )
-
- assertEquals(payload1.hashCode(), payload2.hashCode())
- }
-
- @Test
- fun `Payload default id is generated UUID`() {
- val payload = Payload(
- senderId = "sender1",
- type = Payload.PayloadType.TEXT,
- data = byteArrayOf(1, 2, 3)
- )
-
- assertNotNull(payload.id)
- assertTrue(UUID.fromString(payload.id) is UUID)
- }
-
- @Test
- fun `Payload default timestamp is current time`() {
- val before = System.currentTimeMillis()
- val payload = Payload(
- senderId = "sender1",
- type = Payload.PayloadType.TEXT,
- data = byteArrayOf(1, 2, 3)
- )
- val after = System.currentTimeMillis()
-
- assertTrue(payload.timestamp in before..after)
- }
-
- @Test
- fun `PayloadTypeFromString returns correct type for valid name`() {
- assertEquals(Payload.PayloadType.TEXT, PayloadTypeFromString("TEXT"))
- assertEquals(Payload.PayloadType.FILE, PayloadTypeFromString("FILE"))
- assertEquals(Payload.PayloadType.VIDEO, PayloadTypeFromString("VIDEO"))
- assertEquals(Payload.PayloadType.HANDSHAKE, PayloadTypeFromString("HANDSHAKE"))
- }
-
- @Test
- fun `PayloadTypeFromString returns null for invalid name`() {
- assertNull(PayloadTypeFromString("INVALID"))
- assertNull(PayloadTypeFromString(""))
- assertNull(PayloadTypeFromString("text")) // case sensitive
- }
-
- @Test
- fun `String toPayloadType extension works correctly`() {
- assertEquals(Payload.PayloadType.TEXT, "TEXT".toPayloadType())
- assertEquals(Payload.PayloadType.FILE, "FILE".toPayloadType())
- assertNull("invalid".toPayloadType())
- }
-}
diff --git a/core/domain/src/test/java/com/p2p/meshify/domain/model/PeerDeviceTest.kt b/core/domain/src/test/java/com/p2p/meshify/domain/model/PeerDeviceTest.kt
deleted file mode 100644
index e3268f16..00000000
--- a/core/domain/src/test/java/com/p2p/meshify/domain/model/PeerDeviceTest.kt
+++ /dev/null
@@ -1,256 +0,0 @@
-package com.p2p.meshify.domain.model
-
-import org.junit.Assert.*
-import org.junit.Test
-
-/**
- * Unit tests for PeerDevice data class and TransportType enum.
- */
-class PeerDeviceTest {
-
- // --- Construction ---
-
- @Test
- fun `PeerDevice constructed with all params returns correct values`() {
- val device = PeerDevice(
- id = "peer-123",
- name = "Test Device",
- address = "192.168.1.42",
- rssi = -45,
- isConnected = true,
- transportType = TransportType.LAN
- )
-
- assertEquals("peer-123", device.id)
- assertEquals("Test Device", device.name)
- assertEquals("192.168.1.42", device.address)
- assertEquals(-45, device.rssi)
- assertTrue(device.isConnected)
- assertEquals(TransportType.LAN, device.transportType)
- }
-
- @Test
- fun `PeerDevice uses default isConnected as false`() {
- val device = PeerDevice(
- id = "peer-1",
- name = "Device",
- address = "10.0.0.1"
- )
-
- assertFalse(device.isConnected)
- }
-
- @Test
- fun `PeerDevice uses default transportType as LAN`() {
- val device = PeerDevice(
- id = "peer-1",
- name = "Device",
- address = "10.0.0.1"
- )
-
- assertEquals(TransportType.LAN, device.transportType)
- }
-
- @Test
- fun `PeerDevice uses default rssi as null`() {
- val device = PeerDevice(
- id = "peer-1",
- name = "Device",
- address = "10.0.0.1"
- )
-
- assertNull(device.rssi)
- }
-
- // --- copy() ---
-
- @Test
- fun `PeerDevice copy creates equal instance with no overrides`() {
- val device = PeerDevice(
- id = "peer-42",
- name = "Original",
- address = "192.168.1.1",
- rssi = -60,
- isConnected = true,
- transportType = TransportType.BLE
- )
-
- val copy = device.copy()
-
- assertEquals(device, copy)
- }
-
- @Test
- fun `PeerDevice copy overrides specified field`() {
- val device = PeerDevice(
- id = "peer-42",
- name = "Original",
- address = "192.168.1.1"
- )
-
- val renamed = device.copy(name = "Renamed")
-
- assertEquals("Renamed", renamed.name)
- assertEquals(device.id, renamed.id)
- assertEquals(device.address, renamed.address)
- }
-
- // --- equals() / hashCode() ---
-
- @Test
- fun `PeerDevice equals returns true for same field values`() {
- val device1 = PeerDevice(
- id = "peer-1",
- name = "Same",
- address = "10.0.0.1",
- rssi = -50,
- isConnected = true,
- transportType = TransportType.LAN
- )
- val device2 = PeerDevice(
- id = "peer-1",
- name = "Same",
- address = "10.0.0.1",
- rssi = -50,
- isConnected = true,
- transportType = TransportType.LAN
- )
-
- assertEquals(device1, device2)
- }
-
- @Test
- fun `PeerDevice equals returns false for different id`() {
- val device1 = PeerDevice(id = "peer-1", name = "A", address = "10.0.0.1")
- val device2 = PeerDevice(id = "peer-2", name = "A", address = "10.0.0.1")
-
- assertNotEquals(device1, device2)
- }
-
- @Test
- fun `PeerDevice equals returns false for different name`() {
- val device1 = PeerDevice(id = "peer-1", name = "Alpha", address = "10.0.0.1")
- val device2 = PeerDevice(id = "peer-1", name = "Beta", address = "10.0.0.1")
-
- assertNotEquals(device1, device2)
- }
-
- @Test
- fun `PeerDevice hashCode is consistent for equal instances`() {
- val device1 = PeerDevice(id = "peer-1", name = "HashTest", address = "10.0.0.1")
- val device2 = PeerDevice(id = "peer-1", name = "HashTest", address = "10.0.0.1")
-
- assertEquals(device1.hashCode(), device2.hashCode())
- }
-
- @Test
- fun `PeerDevice hashCode differs for unequal instances`() {
- val device1 = PeerDevice(id = "peer-1", name = "A", address = "10.0.0.1")
- val device2 = PeerDevice(id = "peer-2", name = "A", address = "10.0.0.1")
-
- assertNotEquals(device1.hashCode(), device2.hashCode())
- }
-
- // --- toString() ---
-
- @Test
- fun `PeerDevice toString contains key fields`() {
- val device = PeerDevice(
- id = "peer-x",
- name = "MyPhone",
- address = "192.168.1.5"
- )
-
- val str = device.toString()
-
- assertTrue(str.contains("peer-x"))
- assertTrue(str.contains("MyPhone"))
- assertTrue(str.contains("192.168.1.5"))
- }
-
- // --- signalStrength ---
-
- @Test
- fun `signalStrength returns STRONG for RSSI greater than -50`() {
- val device = PeerDevice(
- id = "p1", name = "Strong", address = "a",
- rssi = -40, isConnected = true
- )
-
- assertEquals(SignalStrength.STRONG, device.signalStrength)
- }
-
- @Test
- fun `signalStrength returns MEDIUM for RSSI between -70 and -50 inclusive`() {
- val device = PeerDevice(
- id = "p1", name = "Medium", address = "a",
- rssi = -60, isConnected = true
- )
-
- assertEquals(SignalStrength.MEDIUM, device.signalStrength)
- }
-
- @Test
- fun `signalStrength returns WEAK for RSSI less than -70`() {
- val device = PeerDevice(
- id = "p1", name = "Weak", address = "a",
- rssi = -80, isConnected = true
- )
-
- assertEquals(SignalStrength.WEAK, device.signalStrength)
- }
-
- @Test
- fun `signalStrength returns MEDIUM when RSSI null and connected`() {
- val device = PeerDevice(
- id = "p1", name = "Connected", address = "a",
- rssi = null, isConnected = true
- )
-
- assertEquals(SignalStrength.MEDIUM, device.signalStrength)
- }
-
- @Test
- fun `signalStrength returns WEAK when RSSI null and not connected`() {
- val device = PeerDevice(
- id = "p1", name = "Disconnected", address = "a",
- rssi = null, isConnected = false
- )
-
- assertEquals(SignalStrength.WEAK, device.signalStrength)
- }
-
- // --- TransportType enum ---
-
- @Test
- fun `TransportType contains all expected values`() {
- assertEquals(3, TransportType.values().size)
- assertTrue(TransportType.values().toList().containsAll(listOf(
- TransportType.LAN,
- TransportType.BLE,
- TransportType.BOTH
- )))
- }
-
- @Test
- fun `TransportType valueOf returns correct enum for valid names`() {
- assertEquals(TransportType.LAN, TransportType.valueOf("LAN"))
- assertEquals(TransportType.BLE, TransportType.valueOf("BLE"))
- assertEquals(TransportType.BOTH, TransportType.valueOf("BOTH"))
- }
-
- @Test
- fun `TransportType LAN has expected ordinal`() {
- assertEquals(0, TransportType.LAN.ordinal)
- }
-
- @Test
- fun `TransportType BLE has expected ordinal`() {
- assertEquals(1, TransportType.BLE.ordinal)
- }
-
- @Test
- fun `TransportType BOTH has expected ordinal`() {
- assertEquals(2, TransportType.BOTH.ordinal)
- }
-}
diff --git a/core/domain/src/test/java/com/p2p/meshify/domain/model/SignalStrengthTest.kt b/core/domain/src/test/java/com/p2p/meshify/domain/model/SignalStrengthTest.kt
deleted file mode 100644
index d8720d0d..00000000
--- a/core/domain/src/test/java/com/p2p/meshify/domain/model/SignalStrengthTest.kt
+++ /dev/null
@@ -1,43 +0,0 @@
-package com.p2p.meshify.domain.model
-
-import org.junit.Assert.*
-import org.junit.Test
-
-/**
- * Unit tests for SignalStrength enum.
- */
-class SignalStrengthTest {
-
- @Test
- fun `fromRssi returns STRONG for rssi greater than -50`() {
- assertEquals(SignalStrength.STRONG, SignalStrength.fromRssi(-40))
- assertEquals(SignalStrength.STRONG, SignalStrength.fromRssi(-45))
- assertEquals(SignalStrength.STRONG, SignalStrength.fromRssi(-49))
- }
-
- @Test
- fun `fromRssi returns MEDIUM for rssi between -70 and -50`() {
- assertEquals(SignalStrength.MEDIUM, SignalStrength.fromRssi(-50))
- assertEquals(SignalStrength.MEDIUM, SignalStrength.fromRssi(-55))
- assertEquals(SignalStrength.MEDIUM, SignalStrength.fromRssi(-60))
- assertEquals(SignalStrength.MEDIUM, SignalStrength.fromRssi(-65))
- assertEquals(SignalStrength.MEDIUM, SignalStrength.fromRssi(-70))
- }
-
- @Test
- fun `fromRssi returns WEAK for rssi less than -70`() {
- assertEquals(SignalStrength.WEAK, SignalStrength.fromRssi(-71))
- assertEquals(SignalStrength.WEAK, SignalStrength.fromRssi(-75))
- assertEquals(SignalStrength.WEAK, SignalStrength.fromRssi(-80))
- assertEquals(SignalStrength.WEAK, SignalStrength.fromRssi(-90))
- }
-
- @Test
- fun `SignalStrength enum values are correct`() {
- assertEquals(4, SignalStrength.values().size)
- assertEquals(SignalStrength.STRONG, SignalStrength.valueOf("STRONG"))
- assertEquals(SignalStrength.MEDIUM, SignalStrength.valueOf("MEDIUM"))
- assertEquals(SignalStrength.WEAK, SignalStrength.valueOf("WEAK"))
- assertEquals(SignalStrength.OFFLINE, SignalStrength.valueOf("OFFLINE"))
- }
-}
diff --git a/core/domain/src/test/java/com/p2p/meshify/domain/model/ThemeConfigTest.kt b/core/domain/src/test/java/com/p2p/meshify/domain/model/ThemeConfigTest.kt
deleted file mode 100644
index e37a6d50..00000000
--- a/core/domain/src/test/java/com/p2p/meshify/domain/model/ThemeConfigTest.kt
+++ /dev/null
@@ -1,173 +0,0 @@
-package com.p2p.meshify.domain.model
-
-import org.junit.Assert.*
-import org.junit.Test
-
-/**
- * Unit tests for ThemeConfig enums: ShapeStyle, MotionPreset, FontFamilyPreset, BubbleStyle.
- */
-class ThemeConfigTest {
-
- // --- ShapeStyle ---
-
- @Test
- fun `ShapeStyle contains all expected values`() {
- val expected = listOf(
- ShapeStyle.SUNNY,
- ShapeStyle.BREEZY,
- ShapeStyle.PENTAGON,
- ShapeStyle.BLOB,
- ShapeStyle.BURST,
- ShapeStyle.CLOVER,
- ShapeStyle.CIRCLE
- )
-
- assertEquals(expected.size, ShapeStyle.values().size)
- assertTrue(ShapeStyle.values().toList().containsAll(expected))
- }
-
- @Test
- fun `ShapeStyle values have unique names`() {
- val names = ShapeStyle.values().map { it.name }
- assertEquals(names.toSet().size, names.size)
- }
-
- @Test
- fun `ShapeStyle valueOf returns correct enum for each style`() {
- assertEquals(ShapeStyle.SUNNY, ShapeStyle.valueOf("SUNNY"))
- assertEquals(ShapeStyle.BREEZY, ShapeStyle.valueOf("BREEZY"))
- assertEquals(ShapeStyle.PENTAGON, ShapeStyle.valueOf("PENTAGON"))
- assertEquals(ShapeStyle.BLOB, ShapeStyle.valueOf("BLOB"))
- assertEquals(ShapeStyle.BURST, ShapeStyle.valueOf("BURST"))
- assertEquals(ShapeStyle.CLOVER, ShapeStyle.valueOf("CLOVER"))
- assertEquals(ShapeStyle.CIRCLE, ShapeStyle.valueOf("CIRCLE"))
- }
-
- @Test
- fun `ShapeStyle ordinal order is as declared`() {
- assertEquals(0, ShapeStyle.SUNNY.ordinal)
- assertEquals(1, ShapeStyle.BREEZY.ordinal)
- assertEquals(2, ShapeStyle.PENTAGON.ordinal)
- assertEquals(3, ShapeStyle.BLOB.ordinal)
- assertEquals(4, ShapeStyle.BURST.ordinal)
- assertEquals(5, ShapeStyle.CLOVER.ordinal)
- assertEquals(6, ShapeStyle.CIRCLE.ordinal)
- }
-
- // --- MotionPreset ---
-
- @Test
- fun `MotionPreset contains all expected values`() {
- val expected = listOf(
- MotionPreset.GENTLE,
- MotionPreset.STANDARD,
- MotionPreset.SNAPPY,
- MotionPreset.BOUNCY
- )
-
- assertEquals(expected.size, MotionPreset.values().size)
- assertTrue(MotionPreset.values().toList().containsAll(expected))
- }
-
- @Test
- fun `MotionPreset values have unique names`() {
- val names = MotionPreset.values().map { it.name }
- assertEquals(names.toSet().size, names.size)
- }
-
- @Test
- fun `MotionPreset valueOf returns correct enum for each preset`() {
- assertEquals(MotionPreset.GENTLE, MotionPreset.valueOf("GENTLE"))
- assertEquals(MotionPreset.STANDARD, MotionPreset.valueOf("STANDARD"))
- assertEquals(MotionPreset.SNAPPY, MotionPreset.valueOf("SNAPPY"))
- assertEquals(MotionPreset.BOUNCY, MotionPreset.valueOf("BOUNCY"))
- }
-
- @Test
- fun `MotionPreset ordinal order is as declared`() {
- assertEquals(0, MotionPreset.GENTLE.ordinal)
- assertEquals(1, MotionPreset.STANDARD.ordinal)
- assertEquals(2, MotionPreset.SNAPPY.ordinal)
- assertEquals(3, MotionPreset.BOUNCY.ordinal)
- }
-
- // --- FontFamilyPreset ---
-
- @Test
- fun `FontFamilyPreset contains all expected values`() {
- val expected = listOf(
- FontFamilyPreset.ROBOTO,
- FontFamilyPreset.POPPINS,
- FontFamilyPreset.LORA,
- FontFamilyPreset.MONTSERRAT,
- FontFamilyPreset.PLAYFAIR,
- FontFamilyPreset.INTER
- )
-
- assertEquals(expected.size, FontFamilyPreset.values().size)
- assertTrue(FontFamilyPreset.values().toList().containsAll(expected))
- }
-
- @Test
- fun `FontFamilyPreset values have unique names`() {
- val names = FontFamilyPreset.values().map { it.name }
- assertEquals(names.toSet().size, names.size)
- }
-
- @Test
- fun `FontFamilyPreset valueOf returns correct enum for each preset`() {
- assertEquals(FontFamilyPreset.ROBOTO, FontFamilyPreset.valueOf("ROBOTO"))
- assertEquals(FontFamilyPreset.POPPINS, FontFamilyPreset.valueOf("POPPINS"))
- assertEquals(FontFamilyPreset.LORA, FontFamilyPreset.valueOf("LORA"))
- assertEquals(FontFamilyPreset.MONTSERRAT, FontFamilyPreset.valueOf("MONTSERRAT"))
- assertEquals(FontFamilyPreset.PLAYFAIR, FontFamilyPreset.valueOf("PLAYFAIR"))
- assertEquals(FontFamilyPreset.INTER, FontFamilyPreset.valueOf("INTER"))
- }
-
- @Test
- fun `FontFamilyPreset ordinal order is as declared`() {
- assertEquals(0, FontFamilyPreset.ROBOTO.ordinal)
- assertEquals(1, FontFamilyPreset.POPPINS.ordinal)
- assertEquals(2, FontFamilyPreset.LORA.ordinal)
- assertEquals(3, FontFamilyPreset.MONTSERRAT.ordinal)
- assertEquals(4, FontFamilyPreset.PLAYFAIR.ordinal)
- assertEquals(5, FontFamilyPreset.INTER.ordinal)
- }
-
- // --- BubbleStyle ---
-
- @Test
- fun `BubbleStyle contains all expected values`() {
- val expected = listOf(
- BubbleStyle.ROUNDED,
- BubbleStyle.TAILED,
- BubbleStyle.SQUARCLES,
- BubbleStyle.ORGANIC
- )
-
- assertEquals(expected.size, BubbleStyle.values().size)
- assertTrue(BubbleStyle.values().toList().containsAll(expected))
- }
-
- @Test
- fun `BubbleStyle values have unique names`() {
- val names = BubbleStyle.values().map { it.name }
- assertEquals(names.toSet().size, names.size)
- }
-
- @Test
- fun `BubbleStyle valueOf returns correct enum for each style`() {
- assertEquals(BubbleStyle.ROUNDED, BubbleStyle.valueOf("ROUNDED"))
- assertEquals(BubbleStyle.TAILED, BubbleStyle.valueOf("TAILED"))
- assertEquals(BubbleStyle.SQUARCLES, BubbleStyle.valueOf("SQUARCLES"))
- assertEquals(BubbleStyle.ORGANIC, BubbleStyle.valueOf("ORGANIC"))
- }
-
- @Test
- fun `BubbleStyle ordinal order is as declared`() {
- assertEquals(0, BubbleStyle.ROUNDED.ordinal)
- assertEquals(1, BubbleStyle.TAILED.ordinal)
- assertEquals(2, BubbleStyle.SQUARCLES.ordinal)
- assertEquals(3, BubbleStyle.ORGANIC.ordinal)
- }
-}
diff --git a/core/domain/src/test/java/com/p2p/meshify/domain/model/TransportModeTest.kt b/core/domain/src/test/java/com/p2p/meshify/domain/model/TransportModeTest.kt
deleted file mode 100644
index a17b600b..00000000
--- a/core/domain/src/test/java/com/p2p/meshify/domain/model/TransportModeTest.kt
+++ /dev/null
@@ -1,82 +0,0 @@
-package com.p2p.meshify.domain.model
-
-import org.junit.Assert.*
-import org.junit.Test
-
-/**
- * Unit tests for TransportMode enum.
- */
-class TransportModeTest {
-
- @Test
- fun `TransportMode contains all expected values`() {
- val expected = listOf(
- TransportMode.MULTI_PATH,
- TransportMode.LAN_ONLY,
- TransportMode.BLE_ONLY,
- TransportMode.AUTO
- )
-
- assertEquals(expected.size, TransportMode.values().size)
- assertTrue(TransportMode.values().toList().containsAll(expected))
- }
-
- @Test
- fun `TransportMode values have unique names`() {
- val names = TransportMode.values().map { it.name }
- assertEquals(names.toSet().size, names.size)
- }
-
- @Test
- fun `MULTI_PATH has correct description`() {
- assertEquals("LAN + Bluetooth simultaneously", TransportMode.MULTI_PATH.description)
- }
-
- @Test
- fun `LAN_ONLY has correct description`() {
- assertEquals("Wi-Fi / Ethernet only", TransportMode.LAN_ONLY.description)
- }
-
- @Test
- fun `BLE_ONLY has correct description`() {
- assertEquals("Short-range Bluetooth only", TransportMode.BLE_ONLY.description)
- }
-
- @Test
- fun `AUTO has correct description`() {
- assertEquals("System picks best available", TransportMode.AUTO.description)
- }
-
- @Test
- fun `MULTI_PATH has ordinal 0`() {
- assertEquals(0, TransportMode.MULTI_PATH.ordinal)
- }
-
- @Test
- fun `LAN_ONLY has ordinal 1`() {
- assertEquals(1, TransportMode.LAN_ONLY.ordinal)
- }
-
- @Test
- fun `BLE_ONLY has ordinal 2`() {
- assertEquals(2, TransportMode.BLE_ONLY.ordinal)
- }
-
- @Test
- fun `AUTO has ordinal 3`() {
- assertEquals(3, TransportMode.AUTO.ordinal)
- }
-
- @Test
- fun `valueOf returns correct enum for valid names`() {
- assertEquals(TransportMode.MULTI_PATH, TransportMode.valueOf("MULTI_PATH"))
- assertEquals(TransportMode.LAN_ONLY, TransportMode.valueOf("LAN_ONLY"))
- assertEquals(TransportMode.BLE_ONLY, TransportMode.valueOf("BLE_ONLY"))
- assertEquals(TransportMode.AUTO, TransportMode.valueOf("AUTO"))
- }
-
- @Test(expected = IllegalArgumentException::class)
- fun `valueOf throws for invalid name`() {
- TransportMode.valueOf("INVALID")
- }
-}
diff --git a/core/domain/src/test/java/com/p2p/meshify/domain/security/model/MessageEnvelopeTest.kt b/core/domain/src/test/java/com/p2p/meshify/domain/security/model/MessageEnvelopeTest.kt
deleted file mode 100644
index 780b58ca..00000000
--- a/core/domain/src/test/java/com/p2p/meshify/domain/security/model/MessageEnvelopeTest.kt
+++ /dev/null
@@ -1,218 +0,0 @@
-package com.p2p.meshify.domain.security.model
-
-import org.junit.Assert.*
-import org.junit.Test
-
-/**
- * Unit tests for MessageEnvelope data class.
- */
-class MessageEnvelopeTest {
-
- // --- Construction ---
-
- @Test
- fun `MessageEnvelope constructed with all params returns correct values`() {
- val envelope = MessageEnvelope(
- senderId = "sender-1",
- recipientId = "recipient-1",
- text = "Hello, world!",
- timestamp = 1000L,
- messageType = "text"
- )
-
- assertEquals("sender-1", envelope.senderId)
- assertEquals("recipient-1", envelope.recipientId)
- assertEquals("Hello, world!", envelope.text)
- assertEquals(1000L, envelope.timestamp)
- assertEquals("text", envelope.messageType)
- }
-
- @Test
- fun `MessageEnvelope uses default messageType as text`() {
- val envelope = MessageEnvelope(
- senderId = "s1",
- recipientId = "r1",
- text = "Hello",
- timestamp = 500L
- )
-
- assertEquals("text", envelope.messageType)
- }
-
- @Test
- fun `MessageEnvelope can have custom messageType`() {
- val envelope = MessageEnvelope(
- senderId = "s1",
- recipientId = "r1",
- text = "photo.jpg",
- timestamp = 500L,
- messageType = "image"
- )
-
- assertEquals("image", envelope.messageType)
- }
-
- @Test
- fun `MessageEnvelope supports empty text`() {
- val envelope = MessageEnvelope(
- senderId = "s1",
- recipientId = "r1",
- text = "",
- timestamp = 0L
- )
-
- assertEquals("", envelope.text)
- }
-
- @Test
- fun `MessageEnvelope supports long text`() {
- val longText = "A".repeat(10000)
- val envelope = MessageEnvelope(
- senderId = "s1",
- recipientId = "r1",
- text = longText,
- timestamp = 9999999999999L
- )
-
- assertEquals(longText, envelope.text)
- }
-
- @Test
- fun `MessageEnvelope supports negative timestamp`() {
- val envelope = MessageEnvelope(
- senderId = "s1",
- recipientId = "r1",
- text = "test",
- timestamp = -1L
- )
-
- assertEquals(-1L, envelope.timestamp)
- }
-
- // --- copy() ---
-
- @Test
- fun `MessageEnvelope copy creates equal instance with no overrides`() {
- val envelope = MessageEnvelope(
- senderId = "s1",
- recipientId = "r1",
- text = "Hello",
- timestamp = 100L,
- messageType = "text"
- )
-
- assertEquals(envelope, envelope.copy())
- }
-
- @Test
- fun `MessageEnvelope copy overrides specified field`() {
- val envelope = MessageEnvelope(
- senderId = "s1",
- recipientId = "r1",
- text = "Hello",
- timestamp = 100L
- )
-
- val copy = envelope.copy(text = "Updated")
-
- assertEquals("Updated", copy.text)
- assertEquals("s1", copy.senderId)
- }
-
- // --- equals() / hashCode() ---
-
- @Test
- fun `MessageEnvelope equals returns true for same field values`() {
- val a = MessageEnvelope("s1", "r1", "Hi", 10L, "text")
- val b = MessageEnvelope("s1", "r1", "Hi", 10L, "text")
-
- assertEquals(a, b)
- }
-
- @Test
- fun `MessageEnvelope equals returns false for different senderId`() {
- val a = MessageEnvelope("s1", "r1", "Hi", 10L, "text")
- val b = MessageEnvelope("s2", "r1", "Hi", 10L, "text")
-
- assertNotEquals(a, b)
- }
-
- @Test
- fun `MessageEnvelope equals returns false for different recipientId`() {
- val a = MessageEnvelope("s1", "r1", "Hi", 10L, "text")
- val b = MessageEnvelope("s1", "r2", "Hi", 10L, "text")
-
- assertNotEquals(a, b)
- }
-
- @Test
- fun `MessageEnvelope equals returns false for different text`() {
- val a = MessageEnvelope("s1", "r1", "Hi", 10L, "text")
- val b = MessageEnvelope("s1", "r1", "Bye", 10L, "text")
-
- assertNotEquals(a, b)
- }
-
- @Test
- fun `MessageEnvelope equals returns false for different timestamp`() {
- val a = MessageEnvelope("s1", "r1", "Hi", 10L, "text")
- val b = MessageEnvelope("s1", "r1", "Hi", 20L, "text")
-
- assertNotEquals(a, b)
- }
-
- @Test
- fun `MessageEnvelope equals returns false for different messageType`() {
- val a = MessageEnvelope("s1", "r1", "Hi", 10L, "text")
- val b = MessageEnvelope("s1", "r1", "Hi", 10L, "image")
-
- assertNotEquals(a, b)
- }
-
- @Test
- fun `MessageEnvelope hashCode is consistent for equal instances`() {
- val a = MessageEnvelope("s1", "r1", "Hi", 10L, "text")
- val b = MessageEnvelope("s1", "r1", "Hi", 10L, "text")
-
- assertEquals(a.hashCode(), b.hashCode())
- }
-
- @Test
- fun `MessageEnvelope hashCode differs for different fields`() {
- val a = MessageEnvelope("s1", "r1", "Hi", 10L, "text")
- val b = MessageEnvelope("s2", "r1", "Hi", 10L, "text")
-
- assertNotEquals(a.hashCode(), b.hashCode())
- }
-
- // --- toString() ---
-
- @Test
- fun `MessageEnvelope toString contains key fields`() {
- val envelope = MessageEnvelope(
- senderId = "alice",
- recipientId = "bob",
- text = "Secret message",
- timestamp = 123456L,
- messageType = "text"
- )
-
- val str = envelope.toString()
- assertTrue(str.contains("alice"))
- assertTrue(str.contains("bob"))
- assertTrue(str.contains("Secret message"))
- assertTrue(str.contains("123456"))
- }
-
- // --- All fields accessible ---
-
- @Test
- fun `MessageEnvelope all fields are accessible`() {
- val envelope = MessageEnvelope("s", "r", "msg", 1L, "t")
-
- assertNotNull(envelope.senderId)
- assertNotNull(envelope.recipientId)
- assertNotNull(envelope.text)
- assertNotNull(envelope.messageType)
- }
-}
diff --git a/core/domain/src/test/java/com/p2p/meshify/domain/security/model/OobVerificationMethodTest.kt b/core/domain/src/test/java/com/p2p/meshify/domain/security/model/OobVerificationMethodTest.kt
deleted file mode 100644
index 0196d07e..00000000
--- a/core/domain/src/test/java/com/p2p/meshify/domain/security/model/OobVerificationMethodTest.kt
+++ /dev/null
@@ -1,83 +0,0 @@
-package com.p2p.meshify.domain.security.model
-
-import org.junit.Assert.*
-import org.junit.Test
-
-/**
- * Unit tests for OobVerificationMethod enum.
- */
-class OobVerificationMethodTest {
-
- @Test
- fun `OobVerificationMethod contains all expected values`() {
- val expected = listOf(
- OobVerificationMethod.QR,
- OobVerificationMethod.SAS,
- OobVerificationMethod.NFC
- )
-
- assertEquals(expected.size, OobVerificationMethod.values().size)
- assertTrue(OobVerificationMethod.values().toList().containsAll(expected))
- }
-
- @Test
- fun `OobVerificationMethod values have unique names`() {
- val names = OobVerificationMethod.values().map { it.name }
- assertEquals(names.toSet().size, names.size)
- }
-
- @Test
- fun `QR has ordinal 0`() {
- assertEquals(0, OobVerificationMethod.QR.ordinal)
- }
-
- @Test
- fun `SAS has ordinal 1`() {
- assertEquals(1, OobVerificationMethod.SAS.ordinal)
- }
-
- @Test
- fun `NFC has ordinal 2`() {
- assertEquals(2, OobVerificationMethod.NFC.ordinal)
- }
-
- @Test
- fun `valueOf returns QR for QR`() {
- assertEquals(OobVerificationMethod.QR, OobVerificationMethod.valueOf("QR"))
- }
-
- @Test
- fun `valueOf returns SAS for SAS`() {
- assertEquals(OobVerificationMethod.SAS, OobVerificationMethod.valueOf("SAS"))
- }
-
- @Test
- fun `valueOf returns NFC for NFC`() {
- assertEquals(OobVerificationMethod.NFC, OobVerificationMethod.valueOf("NFC"))
- }
-
- @Test(expected = IllegalArgumentException::class)
- fun `valueOf throws for invalid name`() {
- OobVerificationMethod.valueOf("INVALID")
- }
-
- @Test(expected = IllegalArgumentException::class)
- fun `valueOf throws for lowercase name`() {
- OobVerificationMethod.valueOf("qr")
- }
-
- @Test
- fun `QR name returns QR`() {
- assertEquals("QR", OobVerificationMethod.QR.name)
- }
-
- @Test
- fun `SAS name returns SAS`() {
- assertEquals("SAS", OobVerificationMethod.SAS.name)
- }
-
- @Test
- fun `NFC name returns NFC`() {
- assertEquals("NFC", OobVerificationMethod.NFC.name)
- }
-}
diff --git a/core/domain/src/test/java/com/p2p/meshify/domain/security/model/SecurityEventTest.kt b/core/domain/src/test/java/com/p2p/meshify/domain/security/model/SecurityEventTest.kt
deleted file mode 100644
index 455bbe02..00000000
--- a/core/domain/src/test/java/com/p2p/meshify/domain/security/model/SecurityEventTest.kt
+++ /dev/null
@@ -1,210 +0,0 @@
-package com.p2p.meshify.domain.security.model
-
-import com.p2p.meshify.domain.security.model.SecurityEvent.EventType
-import org.junit.Assert.*
-import org.junit.Test
-
-/**
- * Unit tests for SecurityEvent data class and its EventType enum.
- */
-class SecurityEventTest {
-
- // --- EventType enum ---
-
- @Test
- fun `EventType contains MESSAGE_SEND_FAILED`() {
- assertEquals(1, EventType.values().size)
- assertEquals(EventType.MESSAGE_SEND_FAILED, EventType.valueOf("MESSAGE_SEND_FAILED"))
- }
-
- @Test
- fun `EventType MESSAGE_SEND_FAILED has ordinal 0`() {
- assertEquals(0, EventType.MESSAGE_SEND_FAILED.ordinal)
- }
-
- @Test
- fun `EventType values have unique names`() {
- val names = EventType.values().map { it.name }
- assertEquals(names.toSet().size, names.size)
- }
-
- // --- Construction ---
-
- @Test
- fun `SecurityEvent constructed with all params returns correct values`() {
- val event = SecurityEvent(
- type = EventType.MESSAGE_SEND_FAILED,
- messageId = "msg-001",
- peerId = "peer-42",
- reason = "Connection timeout"
- )
-
- assertEquals(EventType.MESSAGE_SEND_FAILED, event.type)
- assertEquals("msg-001", event.messageId)
- assertEquals("peer-42", event.peerId)
- assertEquals("Connection timeout", event.reason)
- }
-
- @Test
- fun `SecurityEvent uses default values for optional params`() {
- val event = SecurityEvent(type = EventType.MESSAGE_SEND_FAILED)
-
- assertEquals("", event.messageId)
- assertEquals("", event.peerId)
- assertEquals("", event.reason)
- }
-
- // --- copy() ---
-
- @Test
- fun `SecurityEvent copy creates equal instance with no overrides`() {
- val event = SecurityEvent(
- type = EventType.MESSAGE_SEND_FAILED,
- messageId = "msg-1",
- peerId = "peer-1",
- reason = "Failed"
- )
-
- assertEquals(event, event.copy())
- }
-
- @Test
- fun `SecurityEvent copy overrides specified field`() {
- val event = SecurityEvent(
- type = EventType.MESSAGE_SEND_FAILED,
- messageId = "msg-1",
- peerId = "peer-1",
- reason = "Failed"
- )
-
- val copy = event.copy(messageId = "msg-2")
-
- assertEquals("msg-2", copy.messageId)
- assertEquals("peer-1", copy.peerId)
- }
-
- // --- equals() / hashCode() ---
-
- @Test
- fun `SecurityEvent equals returns true for same field values`() {
- val a = SecurityEvent(
- type = EventType.MESSAGE_SEND_FAILED,
- messageId = "msg-1",
- peerId = "peer-1",
- reason = "Timeout"
- )
- val b = SecurityEvent(
- type = EventType.MESSAGE_SEND_FAILED,
- messageId = "msg-1",
- peerId = "peer-1",
- reason = "Timeout"
- )
-
- assertEquals(a, b)
- }
-
- @Test
- fun `SecurityEvent equals returns false for different messageId`() {
- val a = SecurityEvent(
- type = EventType.MESSAGE_SEND_FAILED,
- messageId = "msg-1"
- )
- val b = SecurityEvent(
- type = EventType.MESSAGE_SEND_FAILED,
- messageId = "msg-2"
- )
-
- assertNotEquals(a, b)
- }
-
- @Test
- fun `SecurityEvent equals returns false for different reason`() {
- val a = SecurityEvent(
- type = EventType.MESSAGE_SEND_FAILED,
- reason = "Timeout"
- )
- val b = SecurityEvent(
- type = EventType.MESSAGE_SEND_FAILED,
- reason = "Network error"
- )
-
- assertNotEquals(a, b)
- }
-
- @Test
- fun `SecurityEvent hashCode is consistent for equal instances`() {
- val a = SecurityEvent(
- type = EventType.MESSAGE_SEND_FAILED,
- messageId = "msg-1",
- peerId = "peer-1"
- )
- val b = SecurityEvent(
- type = EventType.MESSAGE_SEND_FAILED,
- messageId = "msg-1",
- peerId = "peer-1"
- )
-
- assertEquals(a.hashCode(), b.hashCode())
- }
-
- @Test
- fun `SecurityEvent hashCode differs for different messageId`() {
- val a = SecurityEvent(
- type = EventType.MESSAGE_SEND_FAILED,
- messageId = "msg-1"
- )
- val b = SecurityEvent(
- type = EventType.MESSAGE_SEND_FAILED,
- messageId = "msg-2"
- )
-
- assertNotEquals(a.hashCode(), b.hashCode())
- }
-
- // --- toString() ---
-
- @Test
- fun `SecurityEvent toString contains key fields`() {
- val event = SecurityEvent(
- type = EventType.MESSAGE_SEND_FAILED,
- messageId = "msg-007",
- peerId = "peer-xyz",
- reason = "Timeout"
- )
-
- val str = event.toString()
- assertTrue(str.contains("MESSAGE_SEND_FAILED"))
- assertTrue(str.contains("msg-007"))
- assertTrue(str.contains("peer-xyz"))
- assertTrue(str.contains("Timeout"))
- }
-
- // --- Companion messageSendFailed ---
-
- @Test
- fun `messageSendFailed creates SecurityEvent with correct values`() {
- val event = SecurityEvent.messageSendFailed(
- messageId = "msg-99",
- peerId = "peer-88",
- reason = "Connection lost"
- )
-
- assertEquals(EventType.MESSAGE_SEND_FAILED, event.type)
- assertEquals("msg-99", event.messageId)
- assertEquals("peer-88", event.peerId)
- assertEquals("Connection lost", event.reason)
- }
-
- @Test
- fun `messageSendFailed default values are empty strings`() {
- val event = SecurityEvent.messageSendFailed(
- messageId = "",
- peerId = "",
- reason = ""
- )
-
- assertEquals("", event.messageId)
- assertEquals("", event.peerId)
- assertEquals("", event.reason)
- }
-}
diff --git a/core/domain/src/test/java/com/p2p/meshify/domain/usecase/SendMessageValidationTest.kt b/core/domain/src/test/java/com/p2p/meshify/domain/usecase/SendMessageValidationTest.kt
deleted file mode 100644
index c929067c..00000000
--- a/core/domain/src/test/java/com/p2p/meshify/domain/usecase/SendMessageValidationTest.kt
+++ /dev/null
@@ -1,405 +0,0 @@
-package com.p2p.meshify.domain.usecase
-
-import org.junit.Assert.assertEquals
-import org.junit.Assert.assertFalse
-import org.junit.Assert.assertNull
-import org.junit.Assert.assertTrue
-import org.junit.Before
-import org.junit.Test
-
-/**
- * Unit tests for SendMessageValidation.
- *
- * Tests the validation rules extracted from ChatInputViewModel.sendMessage():
- * - Empty text with no attachments should fail
- * - Text with content should pass
- * - Rapid calls (debouncing) should be prevented
- * - Reply-to self should fail
- * - Message size limits
- */
-class SendMessageValidationTest {
-
- private lateinit var subject: SendMessageValidation
-
- @Before
- fun setup() {
- subject = SendMessageValidation()
- }
-
- // ============================================================================================
- // EMPTY CONTENT TESTS
- // ============================================================================================
-
- @Test
- fun `empty text with no attachments fails`() {
- // Given
- val text = ""
-
- // When
- val result = subject.validate(text = text, hasAttachments = false)
-
- // Then
- assertFalse(result.isValid)
- assertEquals(SendMessageValidation.ErrorCode.EMPTY_CONTENT, result.errorCode)
- }
-
- @Test
- fun `whitespace-only text with no attachments fails`() {
- // Given
- val text = " "
-
- // When
- val result = subject.validate(text = text, hasAttachments = false)
-
- // Then
- assertFalse(result.isValid)
- assertEquals(SendMessageValidation.ErrorCode.EMPTY_CONTENT, result.errorCode)
- }
-
- @Test
- fun `empty text with attachments passes`() {
- // Given
- val text = ""
-
- // When
- val result = subject.validate(text = text, hasAttachments = true)
-
- // Then
- assertTrue(result.isValid)
- assertNull(result.errorCode)
- }
-
- @Test
- fun `whitespace-only text with attachments passes`() {
- // Given
- val text = " \n "
-
- // When
- val result = subject.validate(text = text, hasAttachments = true)
-
- // Then
- assertTrue(result.isValid)
- assertNull(result.errorCode)
- }
-
- // ============================================================================================
- // VALID CONTENT TESTS
- // ============================================================================================
-
- @Test
- fun `normal text passes`() {
- // Given
- val text = "Hello, world!"
-
- // When
- val result = subject.validate(text = text)
-
- // Then
- assertTrue(result.isValid)
- assertNull(result.errorCode)
- }
-
- @Test
- fun `single character text passes`() {
- // Given
- val text = "a"
-
- // When
- val result = subject.validate(text = text)
-
- // Then
- assertTrue(result.isValid)
- assertNull(result.errorCode)
- }
-
- @Test
- fun `text with unicode passes`() {
- // Given
- val text = "🔐🌐💬"
-
- // When
- val result = subject.validate(text = text)
-
- // Then
- assertTrue(result.isValid)
- assertNull(result.errorCode)
- }
-
- @Test
- fun `text with newlines passes`() {
- // Given
- val text = "Line 1\nLine 2\nLine 3"
-
- // When
- val result = subject.validate(text = text)
-
- // Then
- assertTrue(result.isValid)
- assertNull(result.errorCode)
- }
-
- // ============================================================================================
- // DEBOUNCING TESTS
- // ============================================================================================
-
- @Test
- fun `send within 500ms debounce window fails`() {
- // Given
- val lastSendTime = 1000L
- val currentTime = 1400L // 400ms later
-
- // When
- val result = subject.validate(
- text = "Hello",
- lastSendTimeMillis = lastSendTime,
- currentTimeMillis = currentTime
- )
-
- // Then
- assertFalse(result.isValid)
- assertEquals(SendMessageValidation.ErrorCode.SEND_DEBOUNCED, result.errorCode)
- }
-
- @Test
- fun `send exactly at 500ms boundary passes`() {
- // Given
- val lastSendTime = 1000L
- val currentTime = 1500L // exactly 500ms later (500 < 500 is false)
-
- // When
- val result = subject.validate(
- text = "Hello",
- lastSendTimeMillis = lastSendTime,
- currentTimeMillis = currentTime
- )
-
- // Then
- assertTrue(result.isValid)
- assertNull(result.errorCode)
- }
-
- @Test
- fun `send after debounce window passes`() {
- // Given
- val lastSendTime = 1000L
- val currentTime = 1501L // 501ms later
-
- // When
- val result = subject.validate(
- text = "Hello",
- lastSendTimeMillis = lastSendTime,
- currentTimeMillis = currentTime
- )
-
- // Then
- assertTrue(result.isValid)
- assertNull(result.errorCode)
- }
-
- @Test
- fun `first send with no previous send time passes`() {
- // Given
- val text = "First message"
-
- // When
- val result = subject.validate(
- text = text,
- lastSendTimeMillis = 0,
- currentTimeMillis = 1000L
- )
-
- // Then
- assertTrue(result.isValid)
- assertNull(result.errorCode)
- }
-
- @Test
- fun `rapid consecutive sends are all blocked except first`() {
- // Given
- val baseTime = 1000L
-
- // When: First send
- val first = subject.validate(
- text = "msg1",
- lastSendTimeMillis = 0,
- currentTimeMillis = baseTime
- )
-
- // Then: First passes
- assertTrue(first.isValid)
-
- // When: Second send 100ms later
- val second = subject.validate(
- text = "msg2",
- lastSendTimeMillis = baseTime,
- currentTimeMillis = baseTime + 100
- )
-
- // Then: Second blocked
- assertFalse(second.isValid)
- assertEquals(SendMessageValidation.ErrorCode.SEND_DEBOUNCED, second.errorCode)
-
- // When: Third send 200ms later
- val third = subject.validate(
- text = "msg3",
- lastSendTimeMillis = baseTime,
- currentTimeMillis = baseTime + 200
- )
-
- // Then: Third blocked
- assertFalse(third.isValid)
- assertEquals(SendMessageValidation.ErrorCode.SEND_DEBOUNCED, third.errorCode)
- }
-
- // ============================================================================================
- // REPLY-TO-SELF TESTS
- // ============================================================================================
-
- @Test
- fun `reply to self fails`() {
- // Given
- val text = "Replying to myself"
-
- // When
- val result = subject.validate(text = text, isReplyToSelf = true)
-
- // Then
- assertFalse(result.isValid)
- assertEquals(SendMessageValidation.ErrorCode.REPLY_TO_SELF, result.errorCode)
- }
-
- @Test
- fun `reply to other passes`() {
- // Given
- val text = "Replying to peer"
-
- // When
- val result = subject.validate(text = text, isReplyToSelf = false)
-
- // Then
- assertTrue(result.isValid)
- assertNull(result.errorCode)
- }
-
- @Test
- fun `reply to self with attachments also fails`() {
- // Given
- val text = ""
-
- // When
- val result = subject.validate(text = text, hasAttachments = true, isReplyToSelf = true)
-
- // Then
- assertFalse(result.isValid)
- assertEquals(SendMessageValidation.ErrorCode.REPLY_TO_SELF, result.errorCode)
- }
-
- // ============================================================================================
- // MESSAGE SIZE LIMIT TESTS
- // ============================================================================================
-
- @Test
- fun `message at exact max length passes`() {
- // Given
- val text = "x".repeat(SendMessageValidation.MAX_MESSAGE_LENGTH)
-
- // When
- val result = subject.validate(text = text)
-
- // Then
- assertTrue(result.isValid)
- assertNull(result.errorCode)
- }
-
- @Test
- fun `message one character over max length fails`() {
- // Given
- val text = "x".repeat(SendMessageValidation.MAX_MESSAGE_LENGTH + 1)
-
- // When
- val result = subject.validate(text = text)
-
- // Then
- assertFalse(result.isValid)
- assertEquals(SendMessageValidation.ErrorCode.MESSAGE_TOO_LONG, result.errorCode)
- }
-
- @Test
- fun `message at double max length fails`() {
- // Given
- val text = "x".repeat(SendMessageValidation.MAX_MESSAGE_LENGTH * 2)
-
- // When
- val result = subject.validate(text = text)
-
- // Then
- assertFalse(result.isValid)
- assertEquals(SendMessageValidation.ErrorCode.MESSAGE_TOO_LONG, result.errorCode)
- }
-
- // ============================================================================================
- // COMBINED VALIDATION TESTS
- // ============================================================================================
-
- @Test
- fun `empty text and reply to self returns empty content error first`() {
- // Given: Both empty content and reply-to-self are true
- val text = ""
-
- // When
- val result = subject.validate(text = text, isReplyToSelf = true)
-
- // Then: Empty content is checked first
- assertFalse(result.isValid)
- assertEquals(SendMessageValidation.ErrorCode.EMPTY_CONTENT, result.errorCode)
- }
-
- @Test
- fun `too long message and reply to self returns message too long first`() {
- // Given: Both too long and reply-to-self
- val text = "x".repeat(SendMessageValidation.MAX_MESSAGE_LENGTH + 1)
-
- // When
- val result = subject.validate(text = text, isReplyToSelf = true)
-
- // Then: Message too long is checked before reply-to-self
- assertFalse(result.isValid)
- assertEquals(SendMessageValidation.ErrorCode.MESSAGE_TOO_LONG, result.errorCode)
- }
-
- @Test
- fun `too long message and debounced returns message too long first`() {
- // Given
- val text = "x".repeat(SendMessageValidation.MAX_MESSAGE_LENGTH + 1)
-
- // When
- val result = subject.validate(
- text = text,
- lastSendTimeMillis = 1000L,
- currentTimeMillis = 1100L
- )
-
- // Then
- assertFalse(result.isValid)
- assertEquals(SendMessageValidation.ErrorCode.MESSAGE_TOO_LONG, result.errorCode)
- }
-
- @Test
- fun `valid message with all parameters set passes`() {
- // Given
- val text = "Valid message"
-
- // When
- val result = subject.validate(
- text = text,
- hasAttachments = true,
- lastSendTimeMillis = 0,
- currentTimeMillis = 1000L,
- isReplyToSelf = false
- )
-
- // Then
- assertTrue(result.isValid)
- assertNull(result.errorCode)
- }
-}
diff --git a/core/network/build.gradle.kts b/core/network/build.gradle.kts
index 78e5b75a..44ab906d 100644
--- a/core/network/build.gradle.kts
+++ b/core/network/build.gradle.kts
@@ -5,7 +5,7 @@ plugins {
android {
namespace = "com.p2p.meshify.core.network"
- compileSdk = 36
+ compileSdk = 37
defaultConfig {
minSdk = 26
@@ -37,5 +37,5 @@ dependencies {
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.robolectric)
testImplementation(libs.androidx.test.core)
- testImplementation("org.mockito:mockito-inline:5.2.0")
+ testImplementation(libs.mockito.core)
}
diff --git a/core/network/src/main/java/com/p2p/meshify/core/network/WifiStateCheckerImpl.kt b/core/network/src/main/java/com/p2p/meshify/core/network/WifiStateCheckerImpl.kt
index 0a98eab8..16f1d26c 100644
--- a/core/network/src/main/java/com/p2p/meshify/core/network/WifiStateCheckerImpl.kt
+++ b/core/network/src/main/java/com/p2p/meshify/core/network/WifiStateCheckerImpl.kt
@@ -18,6 +18,4 @@ class WifiStateCheckerImpl(
override val isWifiEnabled: Boolean
get() = wifiManager.isWifiEnabled
-
- override fun checkWifiState(): Boolean = isWifiEnabled
}
diff --git a/core/network/src/main/java/com/p2p/meshify/core/network/ble/BleConnectionPool.kt b/core/network/src/main/java/com/p2p/meshify/core/network/ble/BleConnectionPool.kt
index 949ed37a..3f0b6906 100644
--- a/core/network/src/main/java/com/p2p/meshify/core/network/ble/BleConnectionPool.kt
+++ b/core/network/src/main/java/com/p2p/meshify/core/network/ble/BleConnectionPool.kt
@@ -85,13 +85,6 @@ class BleConnectionPool {
return activeConnections.containsKey(peerId)
}
- /**
- * Gets the connection type for a peer.
- */
- fun getConnectionType(peerId: String): BleConnectionType? {
- return activeConnections[peerId]?.type
- }
-
/**
* Gets all connected peer IDs.
*/
diff --git a/core/network/src/main/java/com/p2p/meshify/core/network/lan/ConnectionPool.kt b/core/network/src/main/java/com/p2p/meshify/core/network/lan/ConnectionPool.kt
index 002787ff..0b5f9959 100644
--- a/core/network/src/main/java/com/p2p/meshify/core/network/lan/ConnectionPool.kt
+++ b/core/network/src/main/java/com/p2p/meshify/core/network/lan/ConnectionPool.kt
@@ -38,9 +38,6 @@ class ConnectionPool {
// Semaphore to limit max pool size
private val poolSemaphore = Semaphore(MAX_POOL_SIZE)
- // Known peers for pre-warming
- private val knownPeers = ConcurrentHashMap()
-
/**
* Gets or creates a per-connection Mutex.
*/
@@ -155,55 +152,6 @@ class ConnectionPool {
return cleanedCount
}
- /**
- * Pre-warms a connection to a peer.
- *
- * @param peerAddress Peer IP address
- * @param socketFactory SocketFactory for creating sockets
- * @return true if pre-warmed successfully
- */
- suspend fun preWarmConnection(peerAddress: String, socketFactory: SocketFactory): Boolean {
- // Only pre-warm if not already connected
- if (activeConnections.containsKey(peerAddress)) {
- Logger.d("ConnectionPool -> Already connected to $peerAddress, skipping pre-warm")
- return true
- }
-
- Logger.d("ConnectionPool -> Pre-warming connection to $peerAddress")
-
- try {
- val socket = socketFactory.createClientSocket(peerAddress)
-
- if (!poolSemaphore.tryAcquire()) {
- socket.close()
- Logger.w("ConnectionPool -> Pool full, skipping pre-warm for $peerAddress")
- return false
- }
-
- val pooledSocket = PooledSocket(socket)
- activeConnections[peerAddress] = pooledSocket
- Logger.d("ConnectionPool -> Pre-warmed connection to $peerAddress")
- return true
- } catch (e: Exception) {
- Logger.e("ConnectionPool -> Failed to pre-warm connection to $peerAddress", e)
- return false
- }
- }
-
- /**
- * Registers a known peer for potential pre-warming.
- */
- fun registerKnownPeer(peerId: String) {
- knownPeers[peerId] = System.currentTimeMillis()
- }
-
- /**
- * Removes a known peer.
- */
- fun removeKnownPeer(peerId: String) {
- knownPeers.remove(peerId)
- }
-
/**
* Gets all active connection keys for iteration.
* Internal use only — returns map of PooledSocket for pool management.
@@ -211,17 +159,12 @@ class ConnectionPool {
internal fun getActiveConnections(): Map {
return activeConnections.toMap()
}
-
+
/**
* Gets the number of active connections.
*/
fun getActiveConnectionCount(): Int = activeConnections.size
-
- /**
- * Gets the number of available permits in the pool.
- */
- fun getAvailablePermits(): Int = poolSemaphore.availablePermits()
-
+
/**
* Cleans up a connection lock to prevent memory leak.
*/
@@ -251,7 +194,6 @@ class ConnectionPool {
poolSemaphore.drainPermits()
poolSemaphore.release(MAX_POOL_SIZE)
connectionLocks.clear()
- knownPeers.clear()
Logger.d("ConnectionPool -> Cleared all connections, semaphore reset to $MAX_POOL_SIZE")
}
}
diff --git a/core/network/src/main/java/com/p2p/meshify/core/network/lan/KeepAliveManager.kt b/core/network/src/main/java/com/p2p/meshify/core/network/lan/KeepAliveManager.kt
index 0d6e2905..274dd688 100644
--- a/core/network/src/main/java/com/p2p/meshify/core/network/lan/KeepAliveManager.kt
+++ b/core/network/src/main/java/com/p2p/meshify/core/network/lan/KeepAliveManager.kt
@@ -115,21 +115,6 @@ class KeepAliveManager(
return pingCount
}
- /**
- * Checks if a specific connection is alive.
- *
- * @param peerId Peer identifier
- * @return true if connection responds to ping
- */
- suspend fun isConnectionAlive(peerId: String): Boolean {
- return try {
- val socket = connectionPool.getConnection(peerId)
- socket?.isConnected == true && !socket.isClosed
- } catch (e: Exception) {
- false
- }
- }
-
/**
* Gets keep-alive interval in milliseconds.
*/
diff --git a/core/network/src/main/java/com/p2p/meshify/core/network/lan/SocketManager.kt b/core/network/src/main/java/com/p2p/meshify/core/network/lan/SocketManager.kt
index ee19ba29..dff735cf 100644
--- a/core/network/src/main/java/com/p2p/meshify/core/network/lan/SocketManager.kt
+++ b/core/network/src/main/java/com/p2p/meshify/core/network/lan/SocketManager.kt
@@ -342,32 +342,13 @@ class SocketManager(
Logger.d("SocketManager -> Connection cleaned up: $targetAddress")
}
- /**
- * Pre-warms connection to a known peer.
- */
- suspend fun preWarmConnection(peerAddress: String) = withContext(ioDispatcher) {
- if (!connectionPool.hasValidConnection(peerAddress)) {
- connectionPool.preWarmConnection(peerAddress, socketFactory)
- }
- }
-
/**
* Registers a known peer for potential pre-warming.
*/
fun registerKnownPeer(peerId: String, address: String) {
- connectionPool.registerKnownPeer(peerId)
- connectionScope.launch {
- preWarmConnection(address)
- }
+ // Registration tracking removed — pre-warming is handled on-demand
}
-
- /**
- * Removes a known peer.
- */
- fun removeKnownPeer(peerId: String) {
- connectionPool.removeKnownPeer(peerId)
- }
-
+
/**
* Gets the number of active connections.
*/
@@ -387,83 +368,6 @@ class SocketManager(
return connectionPool.getConnection(peerAddress)
}
- /**
- * Sends large file using parallel transfer for better performance.
- */
- suspend fun sendLargeFile(
- targetAddress: String,
- fileBytes: ByteArray,
- payload: Payload
- ): Result = withContext(ioDispatcher) {
- val lock = connectionPool.getOrCreateConnectionLock(targetAddress)
-
- lock.withLock {
- try {
- // Get or create connection
- if (!connectionPool.hasValidConnection(targetAddress)) {
- connectionPool.removeConnection(targetAddress, closeSocket = true)
-
- val socket = socketFactory.createClientSocket(targetAddress)
- if (!connectionPool.addConnection(targetAddress, socket)) {
- socketFactory.closeSocket(socket, "SocketManager")
- return@withContext Result.failure(Exception("Connection pool full"))
- }
- }
-
- val socket = connectionPool.getConnection(targetAddress)
- ?: return@withContext Result.failure(Exception("No connection available"))
-
- connectionPool.setConnectionInUse(targetAddress, true)
-
- // Determine if parallel transfer is needed
- val useParallel = fileBytes.size > 500 * 1024 // 500KB threshold
-
- if (useParallel) {
- Logger.d("SocketManager -> Using parallel transfer for ${fileBytes.size / 1024}KB file")
- val chunkCount = ParallelFileTransfer.calculateOptimalChunkCount(fileBytes.size)
-
- // Send payload header first
- val outputStream = DataOutputStream(socket.getOutputStream())
- val headerBytes = PayloadSerializer.serialize(payload)
- outputStream.writeInt(headerBytes.size)
- outputStream.write(headerBytes)
- outputStream.writeInt(1) // Parallel mode marker
- outputStream.flush()
-
- // Send file in parallel chunks
- ParallelFileTransfer.sendFile(
- socket = socket,
- fileBytes = fileBytes,
- chunkCount = chunkCount
- ) { bytesTransferred, totalBytes, percentage ->
- Logger.d("SocketManager -> Transfer progress: ${percentage.toInt()}%")
- }
- } else {
- // Standard single-threaded transfer
- val outputStream = DataOutputStream(socket.getOutputStream())
- val bytes = PayloadSerializer.serialize(payload)
-
- withTimeout(WRITE_TIMEOUT_MS) {
- outputStream.writeInt(bytes.size)
- outputStream.write(bytes)
- outputStream.write(fileBytes)
- outputStream.flush()
- }
- }
-
- connectionPool.updateLastUsed(targetAddress)
- Result.success(Unit)
-
- } catch (e: Exception) {
- Logger.e("SocketManager -> Large file send failed to $targetAddress", e)
- cleanupConnection(targetAddress)
- Result.failure(e)
- } finally {
- connectionPool.setConnectionInUse(targetAddress, false)
- }
- }
- }
-
/**
* Stops listening for incoming connections.
*/
diff --git a/core/ui/README.md b/core/ui/README.md
index 5cd27f50..2e0eefc1 100644
--- a/core/ui/README.md
+++ b/core/ui/README.md
@@ -64,10 +64,7 @@ core/ui/src/main/java/com/p2p/meshify/core/ui/
```kotlin
MeshifyTheme(
themeMode = "SYSTEM", // "LIGHT", "DARK", "SYSTEM"
- dynamicColor = true,
- motionPreset = MotionPreset.STANDARD,
- shapeStyle = ShapeStyle.CIRCLE,
- bubbleStyle = BubbleStyle.ROUNDED
+ dynamicColor = true
) {
// Your app content
}
diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts
index 47f95a9a..01d13e04 100644
--- a/core/ui/build.gradle.kts
+++ b/core/ui/build.gradle.kts
@@ -6,7 +6,7 @@ plugins {
android {
namespace = "com.p2p.meshify.core.ui"
- compileSdk = 36
+ compileSdk = 37
defaultConfig {
minSdk = 26
@@ -52,8 +52,7 @@ dependencies {
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
implementation(libs.androidx.material.icons.extended)
- implementation(libs.google.material)
-
+
// MD3E - Graphics Shapes for morphing
implementation(libs.androidx.graphics.shapes)
@@ -76,9 +75,6 @@ dependencies {
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
- // Accompanist
- implementation(libs.accompanist.permissions)
-
// Testing
testImplementation(libs.junit)
testImplementation(libs.mockk)
diff --git a/core/ui/src/main/java/com/p2p/meshify/core/ui/components/AlbumMediaGrid.kt b/core/ui/src/main/java/com/p2p/meshify/core/ui/components/AlbumMediaGrid.kt
index 84735ef1..9ce910fc 100644
--- a/core/ui/src/main/java/com/p2p/meshify/core/ui/components/AlbumMediaGrid.kt
+++ b/core/ui/src/main/java/com/p2p/meshify/core/ui/components/AlbumMediaGrid.kt
@@ -5,7 +5,6 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@@ -19,13 +18,8 @@ import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -108,7 +102,6 @@ private fun AlbumMediaItem(
modifier: Modifier = Modifier
) {
val context = LocalContext.current
- var selectedFullImage by remember { mutableStateOf(null) }
Box(
modifier = modifier
@@ -141,9 +134,4 @@ private fun AlbumMediaItem(
}
}
}
-
- // Full image viewer dialog
- selectedFullImage?.let { path ->
- FullImageViewer(imagePath = path) { selectedFullImage = null }
- }
}
diff --git a/core/ui/src/main/java/com/p2p/meshify/core/ui/components/ForwardMessageDialog.kt b/core/ui/src/main/java/com/p2p/meshify/core/ui/components/ForwardMessageDialog.kt
index e1ac409e..41ea8f2c 100644
--- a/core/ui/src/main/java/com/p2p/meshify/core/ui/components/ForwardMessageDialog.kt
+++ b/core/ui/src/main/java/com/p2p/meshify/core/ui/components/ForwardMessageDialog.kt
@@ -45,8 +45,6 @@ import com.p2p.meshify.core.ui.theme.MeshifyDesignSystem
import com.p2p.meshify.domain.model.MessageType
import com.p2p.meshify.domain.model.PeerDevice
import com.p2p.meshify.domain.model.SignalStrength
-import java.text.SimpleDateFormat
-import java.util.*
/**
* State holder for Forward Message Dialog.
diff --git a/core/ui/src/main/java/com/p2p/meshify/core/ui/components/MediaStagingChatInput.kt b/core/ui/src/main/java/com/p2p/meshify/core/ui/components/MediaStagingChatInput.kt
index 565e4c2c..8efead4f 100644
--- a/core/ui/src/main/java/com/p2p/meshify/core/ui/components/MediaStagingChatInput.kt
+++ b/core/ui/src/main/java/com/p2p/meshify/core/ui/components/MediaStagingChatInput.kt
@@ -32,6 +32,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.Send
import androidx.compose.material.icons.outlined.Image
import androidx.compose.material.icons.outlined.Videocam
+import androidx.compose.material.icons.outlined.AttachFile
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
@@ -85,6 +86,7 @@ fun MediaStagingChatInput(
onSendClick: () -> Unit,
onGalleryClick: () -> Unit,
onVideoClick: () -> Unit,
+ onFileClick: () -> Unit = {},
modifier: Modifier = Modifier,
hasAttachments: Boolean = false,
isSending: Boolean = false
@@ -111,12 +113,6 @@ fun MediaStagingChatInput(
) {
// Gallery Button
val galleryInteraction = remember { MutableInteractionSource() }
- val isGalleryPressed by galleryInteraction.collectIsPressedAsState()
- val galleryScale by animateFloatAsState(
- targetValue = if (isGalleryPressed) 0.92f else 1f,
- animationSpec = spring(dampingRatio = 0.6f),
- label = "gallery_scale"
- )
Box(
contentAlignment = Alignment.Center,
@@ -145,12 +141,6 @@ fun MediaStagingChatInput(
// Video Button
val videoInteraction = remember { MutableInteractionSource() }
- val isVideoPressed by videoInteraction.collectIsPressedAsState()
- val videoScale by animateFloatAsState(
- targetValue = if (isVideoPressed) 0.92f else 1f,
- animationSpec = spring(dampingRatio = 0.6f),
- label = "video_scale"
- )
Box(
contentAlignment = Alignment.Center,
@@ -177,6 +167,34 @@ fun MediaStagingChatInput(
)
}
+ // File Button
+ val fileInteraction = remember { MutableInteractionSource() }
+
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier
+ .size(44.dp)
+ .clip(RoundedCornerShape(12.dp))
+ .clickable(
+ interactionSource = fileInteraction,
+ indication = null
+ ) {
+ haptics.perform(HapticPattern.Pop)
+ onFileClick()
+ }
+ .background(
+ MaterialTheme.colorScheme.surfaceContainerHighest,
+ RoundedCornerShape(12.dp)
+ )
+ ) {
+ Icon(
+ imageVector = Icons.Outlined.AttachFile,
+ contentDescription = stringResource(R.string.chat_input_file),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.size(24.dp)
+ )
+ }
+
// Text Field
Surface(
modifier = Modifier.weight(1f).heightIn(min = 44.dp, max = 120.dp),
diff --git a/core/ui/src/main/java/com/p2p/meshify/core/ui/components/MeshifyKit.kt b/core/ui/src/main/java/com/p2p/meshify/core/ui/components/MeshifyKit.kt
index d1c6c3c8..6cb37e1b 100644
--- a/core/ui/src/main/java/com/p2p/meshify/core/ui/components/MeshifyKit.kt
+++ b/core/ui/src/main/java/com/p2p/meshify/core/ui/components/MeshifyKit.kt
@@ -2,24 +2,17 @@ package com.p2p.meshify.core.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
-import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clip
-import androidx.compose.ui.graphics.Color
import com.p2p.meshify.core.ui.theme.StatusOnline
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
-import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
@@ -94,37 +87,5 @@ fun MeshifyAvatarWithOnline(
}
}
-@Composable
-fun MeshifyCard(modifier: Modifier = Modifier, onClick: (() -> Unit)? = null, containerColor: Color = MaterialTheme.colorScheme.surfaceContainerLow, content: @Composable ColumnScope.() -> Unit) {
- Surface(modifier = modifier.fillMaxWidth().then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier), shape = MeshifyDesignSystem.Shapes.Card, color = containerColor, tonalElevation = MeshifyDesignSystem.Elevation.Level2) {
- Column(modifier = Modifier.padding(MeshifyDesignSystem.Spacing.Md), content = content)
- }
-}
-@Composable
-fun MeshifyListItem(headline: String, supporting: String? = null, leadingContent: @Composable (() -> Unit)? = null, trailingContent: @Composable (() -> Unit)? = null, onClick: () -> Unit) {
- Surface(
- modifier = Modifier.fillMaxWidth(),
- color = Color.Transparent,
- onClick = onClick
- ) {
- Row(modifier = Modifier.padding(horizontal = MeshifyDesignSystem.Spacing.Md, vertical = MeshifyDesignSystem.Spacing.Sm), verticalAlignment = Alignment.CenterVertically) {
- if (leadingContent != null) { Box(Modifier.size(56.dp), contentAlignment = Alignment.Center) { leadingContent() }; Spacer(Modifier.width(MeshifyDesignSystem.Spacing.Md)) }
- Column(Modifier.weight(1f)) {
- Text(text = headline, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, maxLines = 1)
- if (supporting != null) { Text(text = supporting, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1) }
- }
- if (trailingContent != null) { Spacer(Modifier.width(MeshifyDesignSystem.Spacing.Xs)); trailingContent() }
- }
- }
-}
-
-@Composable
-fun MeshifySectionHeader(title: String) {
- Text(text = title, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Black, modifier = Modifier.padding(start = 16.dp, top = 32.dp, bottom = 12.dp), letterSpacing = 1.sp)
-}
-@Composable
-fun MeshifyPill(text: String, containerColor: Color = MaterialTheme.colorScheme.secondaryContainer) {
- Surface(color = containerColor, shape = MeshifyDesignSystem.Shapes.Pill) { Text(text = text, style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), fontWeight = FontWeight.Bold) }
-}
diff --git a/core/ui/src/main/java/com/p2p/meshify/core/ui/components/PhysicsSwipeToDelete.kt b/core/ui/src/main/java/com/p2p/meshify/core/ui/components/PhysicsSwipeToDelete.kt
index 69dfb313..5c69c407 100644
--- a/core/ui/src/main/java/com/p2p/meshify/core/ui/components/PhysicsSwipeToDelete.kt
+++ b/core/ui/src/main/java/com/p2p/meshify/core/ui/components/PhysicsSwipeToDelete.kt
@@ -33,17 +33,6 @@ import kotlin.math.roundToInt
enum class ItemPosition { ONLY, FIRST, MIDDLE, LAST }
-/**
- * CompositionLocal to track global swipe state across all items in a list.
- * When one item is being swiped, adjacent items can react to it.
- */
-data class SwipeState(
- val swipingIndex: Int = -1,
- val swipeProgress: Float = 0f
-)
-
-val LocalSwipeState = compositionLocalOf { SwipeState() }
-
/**
* Enhanced PhysicsSwipeToDelete with magnetic neighbor effect.
* When swiping, adjacent items subtly shift and scale to create a magnetic pull effect.
diff --git a/core/ui/src/main/java/com/p2p/meshify/core/ui/components/QrCodeDisplay.kt b/core/ui/src/main/java/com/p2p/meshify/core/ui/components/QrCodeDisplay.kt
deleted file mode 100644
index 40c9f180..00000000
--- a/core/ui/src/main/java/com/p2p/meshify/core/ui/components/QrCodeDisplay.kt
+++ /dev/null
@@ -1,91 +0,0 @@
-package com.p2p.meshify.core.ui.components
-
-import androidx.compose.foundation.layout.*
-import androidx.compose.material3.*
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.QrCode
-import androidx.compose.runtime.*
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.text.style.TextAlign
-import androidx.compose.ui.unit.dp
-import com.p2p.meshify.core.common.R
-import com.p2p.meshify.core.ui.theme.MeshifyDesignSystem
-import androidx.compose.ui.res.stringResource
-
-/**
- * Displays a QR code for Out-Of-Band identity verification.
- *
- * This component renders the user's public key fingerprint as a QR code
- * that can be scanned by a peer to verify identity and prevent MITM attacks.
- *
- * @param qrData The QR code data string (JSON format with fingerprint)
- * @param title Display title above QR code
- * @param subtitle Optional subtitle below title
- * @param modifier Modifier for the root layout
- */
-@Composable
-fun QrCodeDisplay(
- qrData: String,
- title: String,
- subtitle: String? = null,
- modifier: Modifier = Modifier
-) {
- Column(
- modifier = modifier
- .fillMaxWidth()
- .padding(MeshifyDesignSystem.Spacing.Lg),
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
- Text(
- text = title,
- style = MaterialTheme.typography.titleLarge
- )
-
- subtitle?.let {
- Text(
- text = it,
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- textAlign = TextAlign.Center,
- modifier = Modifier.padding(top = MeshifyDesignSystem.Spacing.Xs)
- )
- }
-
- Spacer(modifier = Modifier.height(MeshifyDesignSystem.Spacing.Md))
-
- Surface(
- modifier = Modifier.size(240.dp),
- color = MaterialTheme.colorScheme.surface,
- shape = MeshifyDesignSystem.Shapes.Card,
- tonalElevation = MeshifyDesignSystem.Elevation.Level2
- ) {
- Box(contentAlignment = Alignment.Center) {
- Icon(
- imageVector = Icons.Default.QrCode,
- contentDescription = stringResource(R.string.content_desc_qr_code),
- tint = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.size(48.dp)
- )
- }
- }
-
- Spacer(modifier = Modifier.height(MeshifyDesignSystem.Spacing.Sm))
-
- // Show fingerprint for manual verification
- Text(
- text = "Fingerprint:",
- style = MaterialTheme.typography.labelMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
-
- Text(
- text = qrData.take(16) + "...",
- style = MaterialTheme.typography.labelLarge.copy(
- fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace
- ),
- color = MaterialTheme.colorScheme.onSurface,
- textAlign = TextAlign.Center
- )
- }
-}
diff --git a/core/ui/src/main/java/com/p2p/meshify/core/ui/components/SettingsGroup.kt b/core/ui/src/main/java/com/p2p/meshify/core/ui/components/SettingsGroup.kt
index 642b3bc0..350f1643 100644
--- a/core/ui/src/main/java/com/p2p/meshify/core/ui/components/SettingsGroup.kt
+++ b/core/ui/src/main/java/com/p2p/meshify/core/ui/components/SettingsGroup.kt
@@ -36,8 +36,8 @@ fun MeshifySettingsGroup(
) {
Text(
text = title,
- style = MaterialTheme.typography.labelLarge,
- fontWeight = FontWeight.ExtraBold,
+ style = MaterialTheme.typography.labelMedium,
+ fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(
start = MeshifyDesignSystem.Spacing.Md,
@@ -87,7 +87,7 @@ fun MeshifySettingsItem(
}
},
enabled = onClick != null,
- color = MaterialTheme.colorScheme.surfaceContainerLow,
+ color = MaterialTheme.colorScheme.surfaceContainer,
interactionSource = interactionSource,
modifier = Modifier
.fillMaxWidth()
@@ -127,7 +127,7 @@ fun MeshifySettingsItem(
if (subtitle != null) {
Text(
text = subtitle,
- style = MaterialTheme.typography.bodySmall,
+ style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
diff --git a/core/ui/src/main/java/com/p2p/meshify/core/ui/theme/Color.kt b/core/ui/src/main/java/com/p2p/meshify/core/ui/theme/Color.kt
index de3d3436..24e26721 100644
--- a/core/ui/src/main/java/com/p2p/meshify/core/ui/theme/Color.kt
+++ b/core/ui/src/main/java/com/p2p/meshify/core/ui/theme/Color.kt
@@ -2,39 +2,37 @@ package com.p2p.meshify.core.ui.theme
import androidx.compose.ui.graphics.Color
-// Meshify Brand Palette (Expressive Teal & Deep Violet)
-val MeshifyPrimary = Color(0xFF006A6A)
+// Meshify PixelPlayer-Inspired MD3E Palette (Vibrant Purple/Pink/Orange)
+val MeshifyPrimary = Color(0xFF6C4FF5)
val MeshifyOnPrimary = Color(0xFFFFFFFF)
-val MeshifyPrimaryContainer = Color(0xFF6FF6F6)
-val MeshifyOnPrimaryContainer = Color(0xFF002020)
+val MeshifyPrimaryContainer = Color(0xFFE3DBFF)
+val MeshifyOnPrimaryContainer = Color(0xFF23005C)
-val MeshifySecondary = Color(0xFF4A6363)
+val MeshifySecondary = Color(0xFFAB47BC)
val MeshifyOnSecondary = Color(0xFFFFFFFF)
-val MeshifySecondaryContainer = Color(0xFFCCE8E7)
-val MeshifyOnSecondaryContainer = Color(0xFF051F1F)
+val MeshifySecondaryContainer = Color(0xFFF3D4FF)
+val MeshifyOnSecondaryContainer = Color(0xFF45005A)
-val MeshifyTertiary = Color(0xFF4B607C)
+val MeshifyTertiary = Color(0xFFFF8A65)
val MeshifyOnTertiary = Color(0xFFFFFFFF)
-val MeshifyTertiaryContainer = Color(0xFFD3E4FF)
-val MeshifyOnTertiaryContainer = Color(0xFF041C35)
+val MeshifyTertiaryContainer = Color(0xFFFFDBCF)
+val MeshifyOnTertiaryContainer = Color(0xFF3E0D00)
-val MeshifyError = Color(0xFFBA1A1A)
+val MeshifyError = Color(0xFFD32F2F)
val MeshifyOnError = Color(0xFFFFFFFF)
// Dark Theme Variants
-val PrimaryDark = Color(0xFF4DD8D8)
-val SecondaryDark = Color(0xFFB1CCCC)
-val TertiaryDark = Color(0xFFB3C8E8)
-val BackgroundDark = Color(0xFF191C1C)
-val SurfaceDark = Color(0xFF191C1C)
+val PrimaryDark = Color(0xFFB394FF)
+val SecondaryDark = Color(0xFFF06292)
+val TertiaryDark = Color(0xFFFF8A65)
+val BackgroundDark = Color(0xFF1C1B1F)
+val SurfaceDark = Color(0xFF1C1B1F)
val SurfaceContainerHighDark = Color(0xFF2B2930)
-// MD3E Additional Colors - Moved from hardcoded UI values
+// Status colors
val StatusOnline = Color(0xFF4CAF50)
-val StatusOffline = Color(0xFF9E9E9E)
-val StatusTyping = Color(0xFF4CAF50)
-// Color Picker Presets
+// Color Picker Presets (used by MeshifyKitDialogs — KEEP UNCHANGED)
val ColorPresetTeal = Color(0xFF006D68)
val ColorPresetPurple = Color(0xFF6750A4)
val ColorPresetGreen = Color(0xFF006E1C)
diff --git a/core/ui/src/main/java/com/p2p/meshify/core/ui/theme/MeshifyDesignSystem.kt b/core/ui/src/main/java/com/p2p/meshify/core/ui/theme/MeshifyDesignSystem.kt
index 493d9bf1..2da3489d 100644
--- a/core/ui/src/main/java/com/p2p/meshify/core/ui/theme/MeshifyDesignSystem.kt
+++ b/core/ui/src/main/java/com/p2p/meshify/core/ui/theme/MeshifyDesignSystem.kt
@@ -1,8 +1,6 @@
package com.p2p.meshify.core.ui.theme
import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
object MeshifyDesignSystem {
@@ -19,7 +17,6 @@ object MeshifyDesignSystem {
object Shapes {
val Card = RoundedCornerShape(12.dp)
- val CardLarge = RoundedCornerShape(16.dp)
val CardSmall = RoundedCornerShape(8.dp)
val Button = RoundedCornerShape(10.dp)
val Input = RoundedCornerShape(8.dp)
@@ -37,33 +34,10 @@ object MeshifyDesignSystem {
val XXL = 40.dp
}
- object AvatarSizes {
- val Small = 40.dp
- val Medium = 48.dp
- val Large = 56.dp
- val XL = 80.dp
- val XXL = 120.dp
- }
-
- object SeedColorPresets {
- val Teal = Color(0xFF006D68)
- val Blue = Color(0xFF0000FF)
- val Purple = Color(0xFF800080)
- val Pink = Color(0xFFFFC0CB)
- val Red = Color(0xFFFF0000)
- val Orange = Color(0xFFFFA500)
- val Green = Color(0xFF008000)
- val Cyan = Color(0xFF00FFFF)
- val Indigo = Color(0xFF4B0082)
- val Lime = Color(0xFF32CD32)
- }
-
object Elevation {
val Level0 = 0.dp
val Level1 = 1.dp
val Level2 = 2.dp
val Level3 = 4.dp
- val Level4 = 6.dp
- val Level5 = 8.dp
}
}
diff --git a/core/ui/src/main/java/com/p2p/meshify/core/ui/theme/Shape.kt b/core/ui/src/main/java/com/p2p/meshify/core/ui/theme/Shape.kt
new file mode 100644
index 00000000..5cd62ec5
--- /dev/null
+++ b/core/ui/src/main/java/com/p2p/meshify/core/ui/theme/Shape.kt
@@ -0,0 +1,11 @@
+package com.p2p.meshify.core.ui.theme
+
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Shapes
+import androidx.compose.ui.unit.dp
+
+val Shapes = Shapes(
+ small = RoundedCornerShape(8.dp),
+ medium = RoundedCornerShape(16.dp),
+ large = RoundedCornerShape(24.dp)
+)
diff --git a/core/ui/src/main/java/com/p2p/meshify/core/ui/theme/Theme.kt b/core/ui/src/main/java/com/p2p/meshify/core/ui/theme/Theme.kt
index eada6d9a..75420c47 100644
--- a/core/ui/src/main/java/com/p2p/meshify/core/ui/theme/Theme.kt
+++ b/core/ui/src/main/java/com/p2p/meshify/core/ui/theme/Theme.kt
@@ -1,31 +1,84 @@
package com.p2p.meshify.core.ui.theme
+import android.app.Activity
+import android.content.Context
+import android.content.ContextWrapper
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
-import androidx.compose.material3.*
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.dynamicDarkColorScheme
+import androidx.compose.material3.dynamicLightColorScheme
+import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.CompositionLocalProvider
-import androidx.compose.runtime.Immutable
-import androidx.compose.runtime.staticCompositionLocalOf
+import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalView
+import androidx.core.graphics.ColorUtils
+import androidx.core.view.WindowCompat
-@Immutable
-data class MeshifyThemeConfig(
- val seedColor: Color = Color(0xFF006D68)
-)
+private tailrec fun Context.findActivity(): Activity? = when (this) {
+ is Activity -> this
+ is ContextWrapper -> baseContext.findActivity()
+ else -> null
+}
+
+@Suppress("DEPRECATION")
+@Composable
+fun MeshifyStatusBarStyle(
+ color: Color,
+ useDarkIcons: Boolean = ColorUtils.calculateLuminance(color.toArgb()) > 0.55,
+ navigationColor: Color? = null,
+ useDarkNavigationIcons: Boolean = navigationColor
+ ?.let { ColorUtils.calculateLuminance(it.toArgb()) > 0.55 }
+ ?: useDarkIcons
+) {
+ val view = LocalView.current
+ if (view.isInEditMode) return
+
+ val updateNavigationBar = navigationColor != null
+ SideEffect {
+ val window = view.context.findActivity()?.window ?: return@SideEffect
+ window.statusBarColor = android.graphics.Color.TRANSPARENT
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ window.isStatusBarContrastEnforced = false
+ }
-val LocalMeshifyThemeConfig = staticCompositionLocalOf { MeshifyThemeConfig() }
+ WindowCompat.getInsetsController(window, view).run {
+ isAppearanceLightStatusBars = useDarkIcons
+
+ if (updateNavigationBar) {
+ window.navigationBarColor = android.graphics.Color.TRANSPARENT
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ window.isNavigationBarContrastEnforced = false
+ }
+ isAppearanceLightNavigationBars = useDarkNavigationIcons
+ }
+ }
+ }
+}
private val DarkColorScheme = darkColorScheme(
primary = PrimaryDark,
- onPrimary = Color(0xFF003737),
- primaryContainer = Color(0xFF004F4F),
- onPrimaryContainer = Color(0xFF6FF6F6),
+ onPrimary = Color(0xFF250061),
+ primaryContainer = Color(0xFF3D00A1),
+ onPrimaryContainer = Color(0xFFE3DBFF),
secondary = SecondaryDark,
+ onSecondary = Color(0xFF61003A),
+ secondaryContainer = Color(0xFF890054),
+ onSecondaryContainer = Color(0xFFF3D4FF),
tertiary = TertiaryDark,
+ onTertiary = Color(0xFF5E1900),
+ tertiaryContainer = Color(0xFF7E2E00),
+ onTertiaryContainer = Color(0xFFFFDBCF),
+ error = Color(0xFFFF5252),
+ onError = Color(0xFF690005),
background = BackgroundDark,
+ onBackground = Color(0xFFE6E1E5),
surface = SurfaceDark,
+ onSurface = Color(0xFFE6E1E5),
surfaceContainerHigh = SurfaceContainerHighDark
)
@@ -35,17 +88,26 @@ private val LightColorScheme = lightColorScheme(
primaryContainer = MeshifyPrimaryContainer,
onPrimaryContainer = MeshifyOnPrimaryContainer,
secondary = MeshifySecondary,
+ onSecondary = MeshifyOnSecondary,
+ secondaryContainer = MeshifySecondaryContainer,
+ onSecondaryContainer = MeshifyOnSecondaryContainer,
tertiary = MeshifyTertiary,
+ onTertiary = MeshifyOnTertiary,
+ tertiaryContainer = MeshifyTertiaryContainer,
+ onTertiaryContainer = MeshifyOnTertiaryContainer,
error = MeshifyError,
onError = MeshifyOnError,
- surfaceContainerHigh = Color(0xFFF7F2FA)
+ background = Color(0xFFF7F2FF),
+ onBackground = Color(0xFF1D1B20),
+ surface = Color(0xFFFBF8FF),
+ onSurface = Color(0xFF1D1B20),
+ surfaceContainerHigh = Color(0xFFF0EAFC)
)
@Composable
fun MeshifyTheme(
themeMode: String = "SYSTEM",
dynamicColor: Boolean = true,
- seedColor: Color = Color(0xFF006D68),
content: @Composable () -> Unit
) {
val darkTheme = when (themeMode) {
@@ -63,12 +125,13 @@ fun MeshifyTheme(
else -> LightColorScheme
}
- CompositionLocalProvider(
- LocalMeshifyThemeConfig provides MeshifyThemeConfig(seedColor = seedColor)
- ) {
- MaterialTheme(
- colorScheme = colorScheme,
- content = content
- )
- }
+ // Status bar styling
+ MeshifyStatusBarStyle(color = colorScheme.background, navigationColor = colorScheme.background)
+
+ MaterialTheme(
+ colorScheme = colorScheme,
+ typography = Typography,
+ shapes = Shapes,
+ content = content
+ )
}
diff --git a/core/ui/src/main/java/com/p2p/meshify/core/ui/theme/Type.kt b/core/ui/src/main/java/com/p2p/meshify/core/ui/theme/Type.kt
index 0eeda644..a91d051a 100644
--- a/core/ui/src/main/java/com/p2p/meshify/core/ui/theme/Type.kt
+++ b/core/ui/src/main/java/com/p2p/meshify/core/ui/theme/Type.kt
@@ -1,106 +1,163 @@
package com.p2p.meshify.core.ui.theme
import androidx.compose.material3.Typography
+import androidx.compose.ui.text.ExperimentalTextApi
import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontVariation
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
+import com.p2p.meshify.core.ui.R
+
+// Google Sans Flex variable font with rounded axis for Google Sans Rounded-like appearance.
+private const val GoogleSansFlexRond = 100f
+
+@OptIn(ExperimentalTextApi::class)
+val GoogleSansRounded = FontFamily(
+ Font(
+ resId = R.font.gflex_variable,
+ weight = FontWeight.Light,
+ variationSettings = FontVariation.Settings(
+ FontVariation.weight(FontWeight.Light.weight),
+ FontVariation.Setting("ROND", GoogleSansFlexRond)
+ )
+ ),
+ Font(
+ resId = R.font.gflex_variable,
+ weight = FontWeight.Normal,
+ variationSettings = FontVariation.Settings(
+ FontVariation.weight(FontWeight.Normal.weight),
+ FontVariation.Setting("ROND", GoogleSansFlexRond)
+ )
+ ),
+ Font(
+ resId = R.font.gflex_variable,
+ weight = FontWeight.Medium,
+ variationSettings = FontVariation.Settings(
+ FontVariation.weight(FontWeight.Medium.weight),
+ FontVariation.Setting("ROND", GoogleSansFlexRond)
+ )
+ ),
+ Font(
+ resId = R.font.gflex_variable,
+ weight = FontWeight.SemiBold,
+ variationSettings = FontVariation.Settings(
+ FontVariation.weight(FontWeight.SemiBold.weight),
+ FontVariation.Setting("ROND", GoogleSansFlexRond)
+ )
+ ),
+ Font(
+ resId = R.font.gflex_variable,
+ weight = FontWeight.Bold,
+ variationSettings = FontVariation.Settings(
+ FontVariation.weight(FontWeight.Bold.weight),
+ FontVariation.Setting("ROND", GoogleSansFlexRond)
+ )
+ ),
+)
val Typography = Typography(
displayLarge = TextStyle(
- fontFamily = FontFamily.SansSerif,
+ fontFamily = GoogleSansRounded,
fontWeight = FontWeight.Bold,
- fontSize = 57.sp,
- lineHeight = 64.sp,
- letterSpacing = (-0.25).sp
+ fontSize = 48.sp,
+ lineHeight = 56.sp,
+ letterSpacing = 0.sp
),
displayMedium = TextStyle(
- fontFamily = FontFamily.SansSerif,
+ fontFamily = GoogleSansRounded,
fontWeight = FontWeight.Bold,
- fontSize = 45.sp,
- lineHeight = 52.sp
+ fontSize = 36.sp,
+ lineHeight = 44.sp,
+ letterSpacing = 0.sp
),
displaySmall = TextStyle(
- fontFamily = FontFamily.SansSerif,
- fontWeight = FontWeight.Bold,
- fontSize = 36.sp,
- lineHeight = 44.sp
+ fontFamily = GoogleSansRounded,
+ fontWeight = FontWeight.Normal,
+ fontSize = 30.sp,
+ lineHeight = 38.sp,
+ letterSpacing = 0.sp
),
headlineLarge = TextStyle(
- fontFamily = FontFamily.SansSerif,
- fontWeight = FontWeight.ExtraBold,
+ fontFamily = GoogleSansRounded,
+ fontWeight = FontWeight.SemiBold,
fontSize = 32.sp,
- lineHeight = 40.sp
+ lineHeight = 40.sp,
+ letterSpacing = 0.sp
),
headlineMedium = TextStyle(
- fontFamily = FontFamily.SansSerif,
- fontWeight = FontWeight.Bold,
+ fontFamily = GoogleSansRounded,
+ fontWeight = FontWeight.SemiBold,
fontSize = 28.sp,
- lineHeight = 36.sp
+ lineHeight = 36.sp,
+ letterSpacing = 0.sp
),
headlineSmall = TextStyle(
- fontFamily = FontFamily.SansSerif,
- fontWeight = FontWeight.Bold,
+ fontFamily = GoogleSansRounded,
+ fontWeight = FontWeight.SemiBold,
fontSize = 24.sp,
- lineHeight = 32.sp
+ lineHeight = 32.sp,
+ letterSpacing = 0.sp
),
titleLarge = TextStyle(
- fontFamily = FontFamily.SansSerif,
- fontWeight = FontWeight.SemiBold,
+ fontFamily = GoogleSansRounded,
+ fontWeight = FontWeight.Normal,
fontSize = 22.sp,
- lineHeight = 28.sp
+ lineHeight = 28.sp,
+ letterSpacing = 0.sp
),
titleMedium = TextStyle(
- fontFamily = FontFamily.SansSerif,
+ fontFamily = GoogleSansRounded,
fontWeight = FontWeight.Medium,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.15.sp
),
titleSmall = TextStyle(
- fontFamily = FontFamily.SansSerif,
+ fontFamily = GoogleSansRounded,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp
),
bodyLarge = TextStyle(
- fontFamily = FontFamily.SansSerif,
+ fontFamily = GoogleSansRounded,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
),
bodyMedium = TextStyle(
- fontFamily = FontFamily.SansSerif,
+ fontFamily = GoogleSansRounded,
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.25.sp
),
bodySmall = TextStyle(
- fontFamily = FontFamily.SansSerif,
+ fontFamily = GoogleSansRounded,
fontWeight = FontWeight.Normal,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.4.sp
),
labelLarge = TextStyle(
- fontFamily = FontFamily.SansSerif,
+ fontFamily = GoogleSansRounded,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp
),
labelMedium = TextStyle(
- fontFamily = FontFamily.SansSerif,
+ fontFamily = GoogleSansRounded,
fontWeight = FontWeight.Medium,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
),
labelSmall = TextStyle(
- fontFamily = FontFamily.SansSerif,
+ fontFamily = GoogleSansRounded,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
diff --git a/core/ui/src/main/res/font/gflex_variable.ttf b/core/ui/src/main/res/font/gflex_variable.ttf
new file mode 100644
index 00000000..83f272d2
Binary files /dev/null and b/core/ui/src/main/res/font/gflex_variable.ttf differ
diff --git a/core/ui/src/main/res/values-ar/strings.xml b/core/ui/src/main/res/values-ar/strings.xml
new file mode 100644
index 00000000..9dc29100
--- /dev/null
+++ b/core/ui/src/main/res/values-ar/strings.xml
@@ -0,0 +1,50 @@
+
+
+
+ بحث
+ خطأ
+ خطأ
+ رسالة
+ صورة
+ فيديو
+ ملف
+ قسم
+
+ الصورة الرمزية
+
+ إعدادات
+ تنقل
+ الصورة الرمزية
+
+ خيار
+ محدد
+
+ أيقونة الصلاحية
+ موافق
+ كل شيء جاهز
+
+ صورة الرسالة
+ حالة الرسالة
+ تفاعل
+
+
+ حذف
+ حفظ
+ إلغاء
+
+
+ النسخ الاحتياطي والاستعادة
+ قم بتصدير إعداداتك إلى ملف أو استيرادها من ملف نسخ احتياطي.
+ تصدير النسخ الاحتياطي
+ استيراد النسخ الاحتياطي
+ تم تصدير النسخ الاحتياطي بنجاح!
+ فشل التصدير: %1$s
+ ميزة الاستيراد قادمة قريباً
+
+
+ أضف تعليقاً…
+ المعرض
+ فيديو
+ ملف
+ إرسال
+
diff --git a/core/ui/src/main/res/values/font_certs.xml b/core/ui/src/main/res/values/font_certs.xml
new file mode 100644
index 00000000..d2226ac0
--- /dev/null
+++ b/core/ui/src/main/res/values/font_certs.xml
@@ -0,0 +1,17 @@
+
+
+
+ - @array/com_google_android_gms_fonts_certs_dev
+ - @array/com_google_android_gms_fonts_certs_prod
+
+
+ -
+ MIIEqDCCA5CgAwIBAgIJANWFuGx90071MA0GCSqGSIb3DQEBBAUAMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbTAeFw0wODA0MTUyMzM2NTZaFw0zNTA5MDEyMzM2NTZaMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbTCCASAwDQYJKoZIhvcNAQEBBQADggENADCCAQgCggEBANbOLggKv+IxTdGNs8/TGFy0PTP6DHThvbbR24kT9ixcOd9W+EaBPWW+wPPKQmsHxajtWjmQwWfna8mZuSeJS48LIgAZlKkpFeVyxW0qMBujb8X8ETrWy550NaFtI6t9+u7hZeTfHwqNvacKhp1RbE6dBRGWynwMVX8XW8N1+UjFaq6GCJukT4qmpN2afb8sCjUigq0GuMwYXrFVee74bQgLHWGJwPmvmLHC69EH6kWr22ijx4OKXlSIx2xT1AsSHee70w5iDBiK4aph27yH3TxkXy9V89TDdexAcKk/cVHYNnDBapcavl7y0RiQ4biu8ymM8Ga/nmzhRKya6G0cGw8CAQOjgfwwgfkwHQYDVR0OBBYEFI0cxb6VTEM8YYY6FbBMvAPyT+CyMIHJBgNVHSMEgcEwgb6AFI0cxb6VTEM8YYY6FbBMvAPyT+CyoYGapIGXMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbYIJANWFuGx90071MAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEEBQADggEBABnTDPEF+3iSP0wNfdIjIz1AlnrPzgAIHVvXxunW7SBrDhEglQZBbKJEk5kT0mtKoOD1JMrSu1xuTKEBahWRbqHsXclaXjoBADb0kkjVEJu/Lh5hgYZnOjvlba8Ld7HCKePCVePoTJBdI4fvugnL8TsgK05aIskyY0hKI9L8KfqfGTl1lzOv2KoWD0KWwtAWPoGChZxmQ+nBli+gwYMzM1vAkP+aayLe0a1EQimlOalO762r0GXO0ks+UeXde2Z4e+8S/pf7pITEI/tP+MxJTALw9QUWEv9lKTk+jkbqxbsh8nfBUapfKqYn0eidpwq2AzVp3juYl7//fKnaPhJD9gs=
+
+
+
+ -
+ MIIEQzCCAyugAwIBAgIJAMLgh0ZkSjCNMA0GCSqGSIb3DQEBBAUAMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDAeFw0wODA4MjEyMzEzMzRaFw0zNjAxMDcyMzEzMzRaMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDCCASAwDQYJKoZIhvcNAQEBBQADggENADCCAQgCggEBAKtWLgDYO6IIrgqWbxJOKdoR8qtW0I9Y4sypEwPpt1TTcvZApxsdyxMJZ2JORland2qSGT2y5b+3JKkedxiLDmpHpDsz2WCbdxgxRczfey5YZnTJ4VZbH0xqWVW/8lGmPav5xVwnIiJS6HXk+BVKZF+JcWjAsb/GEuq/eFdpuzSqeYTcfi6idkyugwfYwXFU1+5fZKUaRKYCwkkFQVfcAs1fXA5V+++FGfvjJ/CxURaSxaBvGdGDhfXE28LWuT9ozCl5xw4Yq5OGazvV24mZVSoOO0yZ31j7kYvtwYK6NeADwbSxDdJEqO4k//0zOHKrUiGYXtqw/A0LFFtqoZKFjnkCAQOjgdkwgdYwHQYDVR0OBBYEFMd9jMIhF1Ylmn/Tgt9r45jk14alMIGmBgNVHSMEgZ4wgZuAFMd9jMIhF1Ylmn/Tgt9r45jk14aloXikdjB0MQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEUMBIGA1UEChMLR29vZ2xlIEluYy4xEDAOBgNVBAsTB0FuZHJvaWQxEDAOBgNVBAMTB0FuZHJvaWSCCQDC4IdGZEowjTAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBBAUAA4IBAQBt0lLO74UwLDYKqs6Tm8/yzKkEu116FmH4rkaymUIE0P9KaMftGlMexFlaYjzmB2OxZyl6euNXEsQH8gjwyxCUKRJNexBiGcCEyj6z+a1fuHHvkiaai+KL8W1EyNmgjmyy8AW7P+LLlkR+ho5zEHatRbM/YAnqGcFh5iZBqpknHf1SKMXFh4dd239FJ1jWYfbMDMy3NS5CTMQ2XFI1MvcyUTdZPErjQfTbQe3aDQsQcafEQPD+nqActifKZ0Np0IS9L9kR/wbNvyz6ENwPiTrjV2KRkEjH78ZMcUQXg0L3BYHJ3lc69Vs5Ddf9uUGGMYldX3WfMBEmh/9iFBDAaTCK
+
+
+
diff --git a/core/ui/src/main/res/values/strings.xml b/core/ui/src/main/res/values/strings.xml
index edae845b..9ea57ed2 100644
--- a/core/ui/src/main/res/values/strings.xml
+++ b/core/ui/src/main/res/values/strings.xml
@@ -7,6 +7,7 @@
Gallery
Video
+ File
Add a caption…
Send
@@ -14,4 +15,36 @@
User Avatar
Setting
Navigate
+
+
+ Search
+ Error
+ Error
+ Message
+ Image
+ Video
+ File
+ Section
+
+
+ Delete
+ Save
+ Cancel
+ Option
+ Selected
+ User Avatar
+
+
+ Message Image
+ Message Status
+ Reaction
+
+
+ Backup & Restore
+ Export your settings to a file or import from a backup file.
+ Export Backup
+ Import Backup
+ Backup exported successfully!
+ Export failed: %1$s
+ Import feature coming soon
diff --git a/docs/README.md b/docs/README.md
new file mode 100644
index 00000000..1bee800c
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1,33 @@
+# توثيق Meshify (`docs/`)
+
+توثيق حقيقي مستخرج من الكود الفعلي لمشروع Meshify (تطبيق مراسلة P2P غير متصل بالإنترنت لنظام Android). كل ملف يصف الغرض، الملفات الرئيسية، والقرارات التصميمية/التقنية الظاهرة في الكود — بلا أي محتوى نظري أو وهمي.
+
+## كيفية التنظيم
+
+التنظيم يتبع البنية الفعلية للمشروع (حجم كل وحدة وتعقيدها)، لا قالباً ثابتاً:
+
+- **موديولات أساسية (`core:*`) وصغيرة:** كل منها **ملف مستقل** في جذر `docs/`.
+- **موديولات ميزات غنية (chat, settings, real-device-testing):** الأولى والثانية في **مجلدات فرعية** لكثرة الشاشات/المكونات. `real-device-testing` ملف مستقل لكونها تدفق اختبار واحد متماسك.
+
+## الفهرس
+
+### نظرة عامة
+- [architecture.md](architecture.md) — طبقات المشروع، قواعد الاعتماد، بادئات الحزم، القرارات البنيوية.
+
+### الموديولات الأساسية (`core:*`)
+- [core-domain.md](core-domain.md) — طبقة المجال النقية (Kotlin/JVM): النماذج، واجهات المستودعات، الثوابت.
+- [core-common.md](core-common.md) — المكتبة المشتركة: اللوجر، تسلسل الحمولات، أدوات الملفات/الصور، فحص الأذونات والاتصال.
+- [core-data.md](core-data.md) — طبقة البيانات: Room (v7)، DAOs، المستودعات، DataStore، NotificationHelper.
+- [core-network.md](core-network.md) — طبقة النقل: LAN (TCP/mDNS) و BLE (GATT) عبر `IMeshTransport` + `TransportManager`.
+- [core-ui.md](core-ui.md) — نظام التصميم M3، المكونات، التنقل (`Screen`/`MeshifyNavHost`)، نماذج UI، الاهتزاز.
+
+### موديولات الميزات (`feature:*`)
+- [feature-home.md](feature-home.md) — قائمة المحادثات الأخيرة (شاشة واحدة).
+- [feature-chat/](feature-chat/) — شاشة المحادثة: الرسائل، الوسائط، الرد، إعادة التوجيه، التحديد، البحث.
+- [feature-discovery.md](feature-discovery.md) — اكتشاف الأجهزة النظيرة (LAN/BLE).
+- [feature-settings/](feature-settings/) — الإعدادات + شاشة المطورين المخفية.
+- [feature-onboarding.md](feature-onboarding.md) — شاشة الترحيب الثلاثية + سير الأذونات.
+- [feature-real-device-testing.md](feature-real-device-testing.md) — اختبار الأجهزة الحقيقية (آلة حالات 8 مراحل، LAN/BLE).
+
+### التطبيق
+- [app.md](app.md) — `:app` المُجمّع: `MainActivity`، `MeshifyApp`، وحدات Hilt، المستقبِل، الخدمة الأمامية.
diff --git a/docs/app.md b/docs/app.md
new file mode 100644
index 00000000..b03e00a2
--- /dev/null
+++ b/docs/app.md
@@ -0,0 +1,42 @@
+# `:app` — المُجمّع (Application)
+
+**الغرض:** وحدة التطبيق التي تربط كل `:feature:*` عبر التنقل وتُهيّئ البيئة (Hilt، الشبكة، الخدمات، الاستقبال).
+
+**البناء (`build.gradle.kts`):** `android.application` + `kotlin.compose` + `ksp` + `androidx.room` + `kotlin.serialization` + `hilt`.
+- **compileSdk = 37، targetSdk = 36**، **minSdk = 26**، `applicationId = "com.p2p.meshify"`.
+- **versionCode = 13**، **versionName = `1.1.3`**.
+- **abiFilters = `arm64-v8a` فقط**.
+- **resConfigs = `["en", "ar"]`**.
+- **التوقيع:** Release عبر `meshify.jks` + متغيرات البيئة `KEYSTORE_PASSWORD`/`KEY_PASSWORD`.
+- **Lint:** `abortOnError = false`، `checkReleaseBuilds = false`، `disable += "MissingTranslation"`.
+- **Room schema:** `$projectDir/schemas`.
+- **opt-ins:** `ExperimentalMaterial3Api`، `ExperimentalMaterial3ExpressiveApi`.
+- **Release:** minify + shrink resources مفعّلة.
+
+**الاعتماديات:** كل `:core:*` (common, domain, data, network, ui) + كل `:feature:*`.
+
+## الملفات (7)
+
+| الملف | المحتوى |
+|---|---|
+| `MainActivity.kt` | `@AndroidEntryPoint class MainActivity : ComponentActivity()` — نقطة الدخول. تربط `MeshifyNavHost` بكل مسارات الشاشات، تُدير سير الأذونات (onboarding + runtime)، تطبّق locale من DataStore، وتُنشئ ViewModels **يدوياً** (`viewModel(factory = ...)`) لكل مسار لأن Hilt لا يستطيع حقن معاملات معقدة (context/chatRepository/transportManager). |
+| `MeshifyApp.kt` | `@HiltAndroidApp class MeshifyApp : Application(), SingletonImageLoader.Factory` — تهيئة: crash handler، `transportManager.startAllTransports()` + `startDiscoveryOnAll()`، جامع أحداث النقل العام (`handleIncomingPayload`)، مراقب BLE (تشغيل/إيقاف ديناميكي)، مراقب `transportMode`، تسجيل BLE ديناميكي عبر `Provider`، وضبط Coil 3 ImageLoader (25% RAM + 2% disk + OkHttp + crossfade). |
+| `di/AppModule.kt` | `@Module @InstallIn(SingletonComponent)` — توفّر `MeshifyDatabase` (مع migrations 5→6 + 6→7)، `ISettingsRepository`، `IFileManager`، `NotificationHelper`، `SimplePeerIdProvider`، `StringResourceProvider`، `WifiStateChecker`، `TransportManager.createDefault()`. |
+| `di/NetworkModule.kt` | توفّر `BleTransportImpl` (يقرأ `displayName` من الإعدادات عبر `runBlocking`). |
+| `di/RepositoryModule.kt` | توفّر DAOs (`ChatDao`, `MessageDao`, `PendingMessageDao`)، `ChatRepositoryImpl`، وربط واجهة `IChatRepository`. |
+| `receivers/ReplyReceiver.kt` | `BroadcastReceiver` للرد المضمّن من الإشعارات (`REPLY_ACTION`). أمان: توقيع HMAC، تحقق زمني (15 دقيقة)، فحص وجود المحادثة، rate limiting (10/دقيقة)، تعقيم الرسالة، exponential backoff (3 محاولات). |
+| `service/MeshForegroundService.kt` | `Service` للحفاظ على الشبكة حية: multicast lock، جمع أحداث النقل، إغلاق حتمي بمهلة 3 ثوانٍ. قناة `mesh_service_channel`. |
+
+## `MainActivity` بتفصيل
+
+1. **`onCreate`:** `enableEdgeToEdge()` + تحميل locale من DataStore + فحص إكمال onboarding (إن أُكمِل يُستدعى `checkAndRequestPermissions()`) + Compose content يقرأ `themeMode`/`dynamicColor`، يحدد `startDestination` (Home أو Onboarding)، ويلفّ بـ `MeshifyTheme` + `CompositionLocalProvider(LocalPremiumHaptics)`.
+2. **ربط المسارات:** Home (`RecentChatsViewModel` يدويًا بـ `app.chatRepository`)، Discovery (`DiscoveryViewModel` بـ `app.transportManager`+`app.wifiStateChecker`)، Chat (`ChatViewModel` بـ `SavedStateHandle`+`app.chatRepository`)، Settings (`SettingsViewModel` بـ `app.settingsRepository`، → Developer)، Developer (`DeveloperViewModel` بـ DAOs، → RealDeviceTesting / reset onboarding)، RealDeviceTesting (`RealDeviceTestingViewModel.factory`)، Onboarding (`OnboardingRoute` الخاصة بـ 3 صفحات).
+3. **سير الأذونات:** ACCESS_WIFI_STATE/CHANGE_WIFI_STATE/CHANGE_WIFI_MULTICAST_STATE/ACCESS_NETWORK_STATE + POST_NOTIFICATIONS + NEARBY_WIFI_DEVICES (API 33+) + BLUETOOTH_SCAN/CONNECT/ADVERTISE (API 31+) + ACCESS_FINE_LOCATION (pre-S). عند الرفض الجزئي يُستدعى `startAppService()` (يعمل بوضع LAN-only).
+4. **تبديل اللغة:** `applyLocale(lang)` → `resources.updateConfiguration(...)` ثم `activity.recreate()`.
+
+## `MeshifyApp` بتفصيل
+
+- `@Inject`: `chatRepository`, `transportManager`, `settingsRepository`, `wifiStateChecker`, `database`, `bleTransportProvider`.
+- **`onCreate`:** تهيئة `Logger` + uncaught exception handler + تشغيل كل النقلات والاكتشاف + جامع أحداث `PayloadReceived` → `chatRepository.handleIncomingPayload()` + مراقبا `bleEnabled` و `transportMode`.
+- **`onTerminate`:** إيقاف BLE وكل النقلات، إغلاق repository، إلغاء scope.
+- **Coil:** `newImageLoader()` بـ OkHttp + 25% RAM cache + 2% disk cache + crossfade.
diff --git a/docs/architecture.md b/docs/architecture.md
new file mode 100644
index 00000000..2c48104c
--- /dev/null
+++ b/docs/architecture.md
@@ -0,0 +1,74 @@
+# Architecture — Meshify
+
+توثيق لبنية المشروع الفعلية المستخرجة من `settings.gradle.kts`، ملفات `build.gradle.kts`، وشجرة المصدر. لا يوجد أي كود وهمي أو افتراضي هنا.
+
+## الطبقات (Module Layering)
+
+الاعتمادية تصاعدية نحو الأسفل؛ لا يُسمح بكسر الاتجاه:
+
+```
+:app
+ └─> :feature:* (home, chat, discovery, settings, onboarding, real-device-testing)
+ └─> :core:domain (pure Kotlin/JVM — صفر اعتماديات Android)
+ └─> :core:common
+ └─> :core:data
+ └─> :core:network
+ └─> :core:ui
+```
+
+- **`:core:domain` نقية تماماً (Kotlin/JVM)** — لا تستورد أي `android.*`. هذه أعمق طبقة ولا يعتمد عليها إلا ما هو أعلى منها.
+- **لا يُسمح باعتماد متبادل بين `:feature:*`** — مثلاً `:feature:chat` لا تستورد `:feature:home`. كل وحدة feature تعتمد فقط على `:core:*`.
+- المصدر يوضع تحت `src/main/java/` (وليس `src/main/kotlin/`).
+
+## الوحدات وقوائم الحزم (namespaces)
+
+| الوحدة | namespace | النوع |
+|---|---|---|
+| `:app` | `com.p2p.meshify` | Application |
+| `:core:common` | `com.p2p.meshify.core.common` | Android Library |
+| `:core:domain` | `com.p2p.meshify.domain.*` و `com.p2p.meshify.core.domain.*` | pure Kotlin/JVM |
+| `:core:data` | `com.p2p.meshify.core.data` | Android Library |
+| `:core:network` | `com.p2p.meshify.core.network` | Android Library |
+| `:core:ui` | `com.p2p.meshify.core.ui` | Android Library |
+| `:feature:home` | `com.p2p.meshify.feature.home` | Android Library |
+| `:feature:chat` | `com.p2p.meshify.feature.chat` | Android Library |
+| `:feature:discovery` | `com.p2p.meshify.feature.discovery` | Android Library |
+| `:feature:settings` | `com.p2p.meshify.feature.settings` | Android Library |
+| `:feature:onboarding` | `com.p2p.meshify.feature.onboarding` | Android Library |
+| `:feature:real-device-testing` | `com.p2p.meshify.feature.realdevicetesting` | Android Library |
+
+## قرارات بنيوية بارزة
+
+- **بادئتا تسمية في `:core:domain`** (ثغرة تاريخية موثّقة في `QWEN.md`):
+ - `com.p2p.meshify.core.domain.*` — الأقدم (مثل `WifiStateChecker`).
+ - `com.p2p.meshify.domain.*` — الأحدث (النماذج وواجهات المستودعات).
+- **مُحسّنات المترجم (opt-ins)** مُعرّفة في `app/build.gradle.kts`:
+ - `androidx.compose.material3.ExperimentalMaterial3Api`
+ - `androidx.compose.material3.ExperimentalMaterial3ExpressiveApi`
+- **`abiFilters = arm64-v8a` فقط** — لا توجد بناءات x86/32-bit.
+- **مجلد مخطط Room:** `$projectDir/schemas` (يُصدَّر من `app/` و `core/data/`).
+- **`lint.abortOnError = false`** و **`lint.checkReleaseBuilds = false`**.
+- **`org.gradle.configuration-cache = false`** في `gradle.properties`.
+- **الترجمة:** عربي (`values-ar`) وإنجليزي (`values`) مع دعم RTL. تُحفظ لغة الواجهة في DataStore (`appLanguage` flow)، وتغييرها يستدعي `activity.recreate()`.
+- **التنقل:** عبر `MeshifyNavHost` في `:core:ui`؛ المسارات معرّفة في `sealed class Screen` باستخدام `@Serializable` (type-safe Navigation).
+- **النقل (transport):** BLE اختياري (يُتحكّم به عبر الإعدادات)؛ الافتراضي LAN TCP + mDNS.
+- **الأمان:** بعد Phase 3 كل الرسائل تُرسل بالنص العادي (plaintext) — راجع `MessageEnvelope` في `:core:domain`.
+
+## جدول الاعتماديات بين الوحدات
+
+| الوحدة | domain | common | data | network | ui | feature أخرى |
+|---|---|---|---|---|---|---|
+| `:core:domain` | — | — | — | — | — | — |
+| `:core:common` | ✓ | — | — | — | — | — |
+| `:core:data` | ✓ | ✓ | — | ✓ | — | — |
+| `:core:network` | ✓ | ✓ | — | — | — | — |
+| `:core:ui` | ✓ | ✓ | — | — | — | — |
+| `:feature:home` | ✓ | ✓ | ✓ | — | ✓ | — |
+| `:feature:chat` | ✓ | ✓ | ✓ | — | ✓ | — |
+| `:feature:discovery` | ✓ | ✓ | ✓ | ✓ | ✓ | — |
+| `:feature:settings` | ✓ | ✓ | ✓ | — | ✓ | — |
+| `:feature:onboarding` | ✓ | ✓ | — | — | ✓ | — |
+| `:feature:real-device-testing` | ✓ | ✓ | ✓ | ✓ | ✓ | — |
+| `:app` | ✓ | ✓ | ✓ | ✓ | ✓ | كل الـ features |
+
+
diff --git a/docs/core-common.md b/docs/core-common.md
new file mode 100644
index 00000000..37122b1a
--- /dev/null
+++ b/docs/core-common.md
@@ -0,0 +1,34 @@
+# `:core:common` — الطبقة المشتركة (Android Library)
+
+**الغرض:** مكتبة أدوات ومرافق مشتركة متاحة لكل الوحدات الأخرى. تحوي اللوجر، تسلسل الحمولات، أدوات الملفات والصور، فحص الأذونات والاتصال، مزوّد الهوية، وموارد النصوص المترجمة.
+
+**البناء (`build.gradle.kts`):** `namespace = "com.p2p.meshify.core.common"`. تعتمد على `:core:domain`. تستخدم `kotlinx.serialization.json`، `kotlinx.coroutines.core`، `androidx.core.ktx`، `androidx.exifinterface`. تملك `res/` بملفات `strings.xml` (إنجليزي في `values/`، عربي في `values-ar/` — 612 و639 سطراً على التوالي).
+
+## الملفات الرئيسية
+
+جميع المسارات نسبة إلى `core/common/src/main/java/com/p2p/meshify/core/`:
+
+| الملف | المحتوى |
+|---|---|
+| `util/Logger.kt` | `Logger` — أداة تسجيل مركزية (TAG + `LoggerWrapper`). تُعطّل في إصدارات الإنتاج وتستخدم `android.util.Log` مباشرة. |
+| `util/FileUtils.kt` | `FileUtils` — قراءة البايت من URI، حساب SHA-256، حفظ البايت في التخزين الداخلي، التحقق من وجود ملف. |
+| `util/PayloadSerializer.kt` | `PayloadSerializer` — تسلسل/إلغاء تسلسل `Payload`. Wire Format الإصدار 3 (V3) مع تحقق من الحدود (أقصى 10MB للبيانات). `DeserializeResult` sealed class (`Success | Error`). توافق عكسي مع V2. |
+| `config/AppConfig.kt` | `AppConfig` — ثوابت البروتوكول: `DEFAULT_PORT = 8888`، UUIDs الخاصة بـ BLE، المهلات، حدود الحمولة (10MB)، حجم المخزن المؤقت `DEFAULT_BUFFER_SIZE = 32KB`. |
+| `common/preflight/ConnectivityChecker.kt` | `ConnectivityChecker` — فحص شامل (Wi-Fi مفعّل، متصل، IPv4 صالح، منفذ محلي)؛ يرجع `ConnectivityResult.allPassed`. |
+| `common/preflight/PermissionChecker.kt` | `PermissionChecker` — فحص أذونات Android (ACCESS_WIFI_STATE, NEARBY_WIFI_DEVICES, ACCESS_FINE_LOCATION)؛ يرجع `PermissionResult`. |
+| `common/security/SimplePeerIdProvider.kt` | `SimplePeerIdProvider` — مزوّد هوية بسيط: UUID عشوائي في SharedPreferences (يستبدل `PeerIdentityManager` المعتمد على Keystore). |
+| `common/util/HexUtil.kt` | `HexUtil` — تحويل سداسي عشري: `toHex()`, `toFingerprint()`, `toFingerprintSpaced()`، و `String.hexToByteArray()`. |
+| `common/util/ImageCompressor.kt` | `ImageCompressor` — ضغط الصور (تغيير الحجم لأقصى 1920px، حفظ اتجاه EXIF) عبر `androidx.exifinterface` و `BitmapFactory`. |
+| `common/util/MimeTypeDetector.kt` | `MimeTypeDetector` — كشف MIME من الامتداد/المسار بخرائط يدوية. |
+| `common/util/ParallelFileTransfer.kt` | `ParallelFileTransfer` — نقل ملفات متوازٍ عبر TCP/IP: تقسيم (1–8 أجزاء)، تتبع تقدم، إعادة محاولة للأجزاء الفاشلة، عبر `Dispatchers.IO`. |
+| `common/util/PeerNameParser.kt` | `PeerNameParser` — استخراج الاسم النظيف من تنسيقات النقل (يزيل `(device_id)` من النهاية). |
+| `common/util/RateLimiter.kt` | `RateLimiter` — محدّد معدل بنافذة منزلقة، آمن للخيوط (`ConcurrentHashMap`)، حد أقصى 10000 معرّف. |
+| `common/util/StringResourceProvider.kt` | واجهة `StringResourceProvider` + `AndroidStringResourceProvider` — فصل Android Context عن المستودعات. |
+| `common/util/TimeUtils.kt` | `formatMessageTime(Long)` — تنسيق الطابع الزمني إلى `hh:mm a`. |
+
+## قرارات تقنية ظاهرة
+
+- **تسلسل `Payload` هنا وليس في `:core:domain`** لأن `PayloadSerializer` يحتاج التعامل مع بيانات ثنائية خام.
+- **فصل موارد النصوص** عبر `StringResourceProvider` interface لفصل Context عن مستودعات `:core:data`.
+- **أدوات عالية المستوى** (`ParallelFileTransfer`, `ImageCompressor`, `RateLimiter`) موضوعة هنا لتكون متاحة لكل الوحدات.
+- **الأمان المُبسّط:** `SimplePeerIdProvider` (UUID في SharedPreferences) يحل محل حل Keystore الأقدم.
diff --git a/docs/core-data.md b/docs/core-data.md
new file mode 100644
index 00000000..fcfb5149
--- /dev/null
+++ b/docs/core-data.md
@@ -0,0 +1,47 @@
+# `:core:data` — طبقة البيانات (Android Library)
+
+**الغرض:** طبقة البيانات. تحوي قاعدة Room، واجهات DAO، الكيانات، وتنفيذات المستودعات. تدير التخزين المحلي (DataStore للإعدادات، Room للرسائل والمحادثات) ونقل البيانات عبر الشبكة والاتصالات P2P.
+
+**البناء (`build.gradle.kts`):** `namespace = "com.p2p.meshify.core.data"`. تعتمد على `:core:common`, `:core:domain`, `:core:network`. تستخدم **Room** (runtime, ktx, compiler عبر ksp)، **DataStore Preferences**، **Hilt** (android + compiler عبر ksp)، `kotlinx-serialization-json`, `kotlinx-coroutines-core`, `androidx.core.ktx`. مخطط Room: `schemaDirectory("$projectDir/schemas")`.
+
+## الملفات الرئيسية
+
+جميع المسارات نسبة إلى `core/data/src/main/java/com/p2p/meshify/core/data/`:
+
+| الملف | المحتوى |
+|---|---|
+| `local/entity/Entities.kt` | كيانات Room: `ChatEntity`, `MessageEntity`, `MessageAttachmentEntity`, `PendingMessageEntity`. و enum `MessageStatus` (`QUEUED, SENDING, SENT, DELIVERED, READ, FAILED, RECEIVED`). |
+| `local/dao/Daos.kt` | DAOs: `ChatDao` (getAllChats, insertChat, searchChats, resetUnreadCount)، `MessageDao` (getMessagesPaged, insertMessage, updateMessageStatus, searchMessagesInChat)، `PendingMessageDao` (getByRecipient, insert, update, delete, getAll). |
+| `local/MeshifyDatabase.kt` | `MeshifyDatabase` — قاعدة البيانات (الإصدار 7). الكيانات: Chat/Message/MessageAttachment/PendingMessage (حُذف جدول `trusted_peers` في v7). تضم `MIGRATION_6_7`. |
+| `repository/ChatRepositoryImpl.kt` | `ChatRepositoryImpl` — تنفيذ `IChatRepository`. Facade يجمع 5 مستودعات متخصصة. يعالج إرسال الرسائل (نص عادي)، الحمولات الواردة (كل الأنواع)، التوقيع (ACK)، والتسلسل اليدوي لـ `MessageEnvelope` عبر `ByteBuffer`. |
+| `repository/MessageRepository.kt` | إرسال الرسائل (نص وملفات). `saveAndSend()` تحفظ بالـ DB أولاً ثم ترسل وتحدّث الحالة. `sendFileWithProgress()` بقراءة الملف مع تتبع التقدم. `selectBestTransport()`. تستخدم `withTimeout(30s)`. |
+| `repository/ChatManagementRepository.kt` | CRUD للمحادثات: `getAllChats()`, `searchChats()`, `deleteChat()`, `markChatAsRead()`, `deleteMessage()`, `forwardMessage()`. |
+| `repository/SettingsRepository.kt` | تنفيذ `ISettingsRepository` عبر DataStore Preferences. +30 مفتاحاً (الاسم، الثيم، الألوان، MD3E، النقل، اللغة، الإشعارات، النسخ الاحتياطي). `safeEdit()` لمعالجة الأخطاء. |
+| `repository/FileManagerImpl.kt` | تنفيذ `IFileManager` عبر Android Context. يحفظ الملفات في `filesDir/media/`. |
+| `repository/MessageAttachmentRepository.kt` | مرفقات رسائل الألبومات: `saveAttachments()`, `getAttachmentsForMessage()`. |
+| `repository/PendingMessageRepository.kt` | الرسائل المعلقة: `retryPendingMessages()` (exponential backoff + jitter)، `sendMessageWithBackoff()` (حتى 5 محاولات)، `pendingCount` StateFlow. |
+| `repository/ReactionRepository.kt` | التفاعلات: `addReaction()` (تحديث DB + إرسال للطرف النظير). |
+| `util/NotificationHelper.kt` | الإشعارات: قنوات، رد مضمّن عبر RemoteInput، توقيع HMAC عبر Android KeyStore مع تدوير مفتاح كل 30 يوماً. |
+
+## مخطط قاعدة البيانات (v7)
+
+```
+chats (peerId PK, peerName, lastMessage, lastTimestamp, unreadCount)
+ └─ messages (id PK, chatId FK, senderId, text, mediaPath, type, timestamp,
+ isFromMe, status, reaction, replyToId, groupId)
+ └─ message_attachments (id PK, type, messageId FK, filePath)
+pending_messages (id PK, recipientId, recipientName, content, type, timestamp, status, retryCount, maxRetries)
+```
+
+**الفهارس:** `chats.lastTimestamp`؛ `messages.chatId`، `messages.chatId+timestamp`، `messages.senderId`، `messages.status`، `messages.groupId`.
+
+**تاريخ الإصدارات:** v1 أساسي → v2 فهارس → v3/v4 فهرس `groupId` → v5 جدول `trusted_peers` (TOFU) → v6 عمود `unreadCount` → v7 حذف `trusted_peers`.
+
+## قرارات تقنية ظاهرة
+
+- **نمط Facade:** `ChatRepositoryImpl` يُفوَّض إلى 5 مستودعات متخصصة.
+- **تسلسل رسالة مخصص:** `ChatRepositoryImpl` يُسلسل/يفك `MessageEnvelope` يدوياً عبر `java.nio.ByteBuffer` (بدلاً من kotlinx-serialization).
+- **دورة حياة Coroutine:** `SupervisorJob + CoroutineScope(Dispatchers.IO)` مع `Closeable` للتنظيف.
+- **النظير غير المتصل:** الرسائل توضع في `PendingMessageEntity` وتُعاد محاولتها مع exponential backoff (`BASE_DELAY=1s`, `MAX_DELAY=30s`, `MAX_ATTEMPTS=5`).
+- **DataStore:** `SettingsRepository` بنمط `safeEdit()` لمعالجة أخطاء الكتابة.
+- **حدود الحجم:** 10MB لـ `Payload` عبر الشبكة، 100MB للملفات في `sendFileWithProgress()`.
diff --git a/docs/core-domain.md b/docs/core-domain.md
new file mode 100644
index 00000000..2ef2ae3a
--- /dev/null
+++ b/docs/core-domain.md
@@ -0,0 +1,33 @@
+# `:core:domain` — طبقة المجال النقية
+
+**الغرض:** طبقة المجال (Domain Layer). **pure Kotlin/JVM** بلا أي اعتماد على Android. تحتوي النماذج (models)، واجهات المستودعات (repository interfaces)، وثوابت التطبيق. هذه أعمق طبقة في المشروع ولا يمكن لأي شيء آخر الاعتماد عليها.
+
+**البناء (`build.gradle.kts`):** يستخدم `kotlin.jvm` + `kotlin.serialization`. التبعيات الوحيدة: `kotlinx.coroutines.core`، `kotlinx.serialization.json`، وأدوات الاختبار (JUnit، MockK، coroutines-test). لا توجد تبعيات Android.
+
+## الملفات الرئيسية
+
+جميع المسارات نسبة إلى `core/domain/src/main/java/`:
+
+| الملف | المحتوى |
+|---|---|
+| `com/p2p/meshify/domain/model/Payload.kt` | نموذج `Payload` — حزمة البيانات المرسلة عبر الشبكة. يحتوي `PayloadType` enum (`TEXT, FILE, HANDSHAKE, SYSTEM_CONTROL, DELETE_REQUEST, REACTION, AVATAR_REQUEST, AVATAR_RESPONSE, VIDEO`). يتجاوز `equals/hashCode` يدوياً (بسبب `ByteArray`). كما يحوي `Handshake`، `DeleteRequest`، `ReactionUpdate` — كلها `@Serializable`. |
+| `com/p2p/meshify/domain/model/PeerDevice.kt` | نموذج `PeerDevice` لجهاز نظير مُكتشف. يحوي `TransportType` enum (`LAN, BLE, BOTH`) ويحسب `SignalStrength` من RSSI. |
+| `com/p2p/meshify/domain/model/MessageType.kt` | enum `MessageType` للرسائل/الملفات المدعومة (`TEXT, IMAGE, VIDEO, AUDIO, DOCUMENT, ARCHIVE, APK, FILE`) مع MIME type وقائمة امتدادات ودوال `fromExtension()` / `fromMimeType()`. |
+| `com/p2p/meshify/domain/model/TransportMode.kt` | enum `TransportMode` (`MULTI_PATH, LAN_ONLY, BLE_ONLY, AUTO`) مع وصف نصي. |
+| `com/p2p/meshify/domain/model/ThemeConfig.kt` | Enums خاصة بالتخصيص المرئي MD3E: `ShapeStyle`، `MotionPreset`، `FontFamilyPreset`، `BubbleStyle`. |
+| `com/p2p/meshify/domain/model/SignalStrength.kt` | enum `SignalStrength` (`STRONG, MEDIUM, WEAK, OFFLINE`) مع `fromRssi()`. |
+| `com/p2p/meshify/domain/model/AppConstants.kt` | ثوابت: `MAX_FILE_SIZE_BYTES = 100MB`، `DEFAULT_PEER_NAME_PREFIX = "Peer_"`. |
+| `com/p2p/meshify/domain/security/model/MessageEnvelope.kt` | `MessageEnvelope` — مغلف الرسالة النصية العادية (plaintext) بعد إزالة التشفير في Phase 3. |
+| `com/p2p/meshify/domain/security/model/OobVerificationMethod.kt` | enum `OobVerificationMethod` (`QR, SAS, NFC`). |
+| `com/p2p/meshify/domain/security/model/SecurityEvent.kt` | `SecurityEvent` مع `EventType` (حالياً `MESSAGE_SEND_FAILED` فقط). |
+| `com/p2p/meshify/core/domain/interfaces/WifiStateChecker.kt` | واجهة `WifiStateChecker` — تجريد لفحص حالة Wi-Fi (لتفادي اعتماد ViewModels على Android framework). |
+| `com/p2p/meshify/domain/repository/IChatRepository.kt` | واجهة `IChatRepository` — الـ facade الرئيسي لعمليات الشات (إرسال، إدارة، تفاعلات، أنظمة). تضم `onlinePeers: Flow>`، `typingPeers: Flow>`، `securityEvents: SharedFlow`. |
+| `com/p2p/meshify/domain/repository/IFileManager.kt` | واجهة `IFileManager` — حفظ الملفات (`saveMedia()`). |
+| `com/p2p/meshify/domain/repository/ISettingsRepository.kt` | واجهة `ISettingsRepository` الضخمة لكل إعدادات المستخدم (الاسم، الثيم، إعدادات MD3E، النقل، اللغة، الإشعارات، النسخ الاحتياطي...). تضم enum `ThemeMode` (`LIGHT, DARK, SYSTEM`). |
+
+## قرارات تقنية ظاهرة
+
+- **بادئتا تسمية:** `com.p2p.meshify.core.domain.*` (القديم، مثل `WifiStateChecker`) و `com.p2p.meshify.domain.*` (الأحدث للنماذج والواجهات).
+- **تسلسل `Payload`:** يستخدم `ByteArray` ويتجاوز `equals/hashCode` يدوياً؛ التسلسل الفعلي يتم في `:core:common` عبر `PayloadSerializer` (لأنه يتعامل مع بيانات ثنائية خام).
+- **أمان مُبسّط:** بعد Phase 3 كل الرسائل تُرسل بالنص العادي؛ `MessageEnvelope` لا يحوي تشفيراً.
+- **نمط Repository:** `IChatRepository` هو facade يُفوَّض إلى 5 مستودعات متخصصة في `:core:data` (`MessageRepository`, `ChatManagementRepository`, `PendingMessageRepository`, `MessageAttachmentRepository`, `ReactionRepository`).
diff --git a/docs/core-network.md b/docs/core-network.md
new file mode 100644
index 00000000..a76ebd53
--- /dev/null
+++ b/docs/core-network.md
@@ -0,0 +1,42 @@
+# `:core:network` — طبقة النقل الشبكي (Android Library)
+
+**الغرض:** طبقة النقل الشبكي للتطبيق — اكتشاف الأقران ونقل البيانات دون اتصال بالإنترنت. تُعرّف واجهة `IMeshTransport` المجرّدة وتوفّر تطبيقين: `LanTransportImpl` (TCP/IP + mDNS/NSD) و `BleTransportImpl` (BLE GATT).
+
+**البناء (`build.gradle.kts`):** `namespace = "com.p2p.meshify.core.network"`. تعتمد على `:core:common`, `:core:domain`. تستخدم `kotlinx.coroutines.core`, `kotlinx.serialization.json`, `androidx.core.ktx`.
+
+## الملفات الرئيسية
+
+جميع المسارات نسبة إلى `core/network/src/main/java/com/p2p/meshify/core/network/`:
+
+| الملف | المحتوى |
+|---|---|
+| `base/IMeshTransport.kt` | واجهة النقل المجرّدة: `start()`, `stop()`, `sendPayload()`, `events: Flow`, `onlinePeers: StateFlow>`. |
+| `base/TransportCapability.kt` | enum `TransportCapability` (FILE_TRANSFER, LOW_LATENCY, HIGH_BANDWIDTH, OFFLINE, MESH_NETWORKING...). |
+| `base/TransportEvent.kt` | sealed class `TransportEvent` (DeviceDiscovered, DeviceLost, ConnectionEstablished, ConnectionLost, PayloadReceived, Error). |
+| `lan/LanTransportImpl.kt` | نقل LAN كامل عبر mDNS/NSD لاكتشاف الأقران و TCP/IP عبر `SocketManager`. |
+| `lan/SocketManager.kt` | مدير مآخذ TCP مع تجمع اتصالات، `KeepAliveManager`، معالجة الاتصالات الواردة بالتوازي. |
+| `lan/ConnectionPool.kt` | تجمع اتصالات بـ `Semaphore(100)` وأقفال `Mutex` لكل اتصال؛ تنظيف خامل بعد 5 دقائق. |
+| `lan/PooledSocket.kt` | `data class PooledSocket` — غلاف المأخذ مع بيانات وصفية. |
+| `lan/SocketFactory.kt` | إنشاء وتكوين ServerSocket/Socket بمهلات (اتصال 5ث، قراءة 30ث). |
+| `lan/KeepAliveManager.kt` | نبضات PING كل 60 ثانية، كشف الاتصالات الميتة عبر مهلة 2 ثانية. |
+| `ble/BleAdvertiser.kt` | نشر هذا الجهاز عبر BLE (`BluetoothLeAdvertiser` + `AppConfig.BLE_SERVICE_UUID`) مع تشفير `peerId`. |
+| `ble/BleScanner.kt` | مسح أجهزة Meshify عبر `BluetoothLeScanner` (تصفية حسب UUID)، يصدر `BleDiscoveredDevice` عبر `Flow`. |
+| `ble/BleGattServer.kt` | خادم GATT للاستقبال مع خصائص RX/TX وإشعارات. |
+| `ble/BleGattClient.kt` | عميل GATT للاتصال بالأقران مع `BleGattConnection` لإدارة كل اتصال وتفاوض MTU. |
+| `ble/BleConnectionPool.kt` | إدارة اتصالات BLE بحد أقصى (`AppConfig.BLE_MAX_CONNECTIONS`) وتنظيف خامل. |
+| `ble/BlePayloadSerializer.kt` | تجزئة `Payload` لقطع متوافقة مع BLE MTU وإعادة تجميعها (رأس 12 بايت: totalSize, chunkIndex, totalChunks). |
+| `ble/BleTransportImpl.kt` | نقل BLE كامل ينسّق المكوّنات السابقة مع `sendLock` (Mutex). |
+| `TransportManager.kt` | المدير المركزي: يسجّل النقلات (`registerTransport`)، يختار الأفضل حسب `TransportMode`، يمزج أحداث `Flow`. `createDefault()` يسجّل `LanTransportImpl`. |
+| `ProgressFileReader.kt` | قراءة ملف مع إصدار تقدم (0–100) عبر `StateFlow`. |
+| `WifiStateCheckerImpl.kt` | تنفيذ `WifiStateChecker` عبر `WifiManager.isWifiEnabled`. |
+
+## قرارات تقنية ظاهرة
+
+- **تجريد النقل:** كل النقلات تنفّذ `IMeshTransport`، ما يسمح بإضافة نقلات جديدة (Wi-Fi Direct, DHT, UWB) بسهولة.
+- **اكتشاف LAN:** عبر Android `NsdManager` (وليس JmDNS). نوع الخدمة `AppConfig.SERVICE_TYPE`؛ اسم الخدمة `Meshify_` + UUID (peerId).
+- **بروتوكول TCP:** حمولة [4 بايت طول] + [N بايت بيانات] تُسلَّسل عبر `PayloadSerializer`. مهلات: اتصال 5ث، قراءة 30ث، كتابة 5ث.
+- **تفاوض BLE MTU:** `gatt.requestMtu(AppConfig.BLE_MTU_SIZE)`، تجزئة يدوية عبر `BlePayloadSerializer` (حد الحمولة `BLE_MTU_SIZE - 12`).
+- **Multi-path:** `TransportManager.selectBestTransport()` يدعم `MULTI_PATH` (يرسل عبر LAN + BLE معاً).
+- **كشف الأقران الميتة:** `LanTransportImpl` يتتبع الإخفاقات المتتالية (حد 5) ثم يصدر `DeviceLost`؛ و `KeepAliveManager` عبر PING/PONG بمهلة 2 ثانية.
+- **السلامة في التزامن:** `Mutex` في `BleTransportImpl.sendLock`، و `peerMapMutex`/`failedCountsMutex` في `LanTransportImpl`، و `connectionLocks` في `ConnectionPool`.
+- **حجم المخزن:** `AppConfig.DEFAULT_BUFFER_SIZE = 32KB` في `SocketManager` و `ProgressFileReader`.
diff --git a/docs/core-ui.md b/docs/core-ui.md
new file mode 100644
index 00000000..1f1315aa
--- /dev/null
+++ b/docs/core-ui.md
@@ -0,0 +1,43 @@
+# `:core:ui` — طبقة الواجهة الأساسية (Android Library)
+
+**الغرض:** نظام التصميم (theme, colors, typography)، مكونات Compose القابلة لإعادة الاستخدام، التنقل (navigation)، ونماذج UI (DTOs) للطبقة العرضية.
+
+**البناء (`build.gradle.kts`):** `namespace = "com.p2p.meshify.core.ui"`. تعتمد على `:core:common`, `:core:domain`. تستخدم Compose BOM 2026.03.01، `material3` (1.5.0-alpha17)، `material.icons.extended`، `androidx.graphics.shapes` (MD3E)، Coil 3 (compose + network)، ExoPlayer (media3)، Navigation Compose 2.9.7، `kotlinx.serialization.json`، lifecycle، Accompanist Permissions. تملك `res/` خاصة (`com.p2p.meshify.core.ui.R`) + موارد من `core:common`.
+
+## الملفات الرئيسية
+
+جميع المسارات نسبة إلى `core/ui/src/main/java/com/p2p/meshify/core/ui/`:
+
+| الملف | المحتوى |
+|---|---|
+| `theme/Color.kt` | لوحة الألوان (Teal/Violet للفاتح، Teal للداكن) + 8 ألوان مسبقة لاختيار seed color. |
+| `theme/Type.kt` | `Typography` كامل M3 (أحجام خطوط و `FontWeight.ExtraBold` للعناوين). |
+| `theme/Theme.kt` | `MeshifyTheme()` — يدعم dynamic color (Android 12+) والأنماط الفاتح/الداكن/النظامي. |
+| `theme/MeshifyDesignSystem.kt` | `object MeshifyDesignSystem` — ثوابت: `Spacing` (4–48dp)، `Shapes` (8–24dp)، `IconSizes` (18–40dp)، `Elevation` (0–4dp). |
+| `navigation/Screen.kt` | `sealed class Screen` بـ `@Serializable`: Onboarding, Home, Discovery, Chat(peerId, peerName), Settings, Developer, RealDeviceTesting. |
+| `navigation/MeshifyNavigation.kt` | `MeshifyNavHost()` — `NavHost` مع `composable` لكل مسار. يأخذ lambdas للشاشات الفعلية (Inversion of Control) من `:app`. |
+| `components/MeshifyKit.kt` | المكونات الأساسية: `MeshifyAvatar`, `MeshifyAvatarWithOnline`, `MeshifyCard`, `MeshifyListItem`, `MeshifySectionHeader`, `MeshifyPill`. |
+| `components/MeshifyKitDialogs.kt` | `DeleteConfirmationDialog`, `FullImageViewer`, `MeshifyTextInputDialog`, `MeshifySelectionDialog`, `ThemeSelectionBottomSheet`, `SeedColorPickerGrid`. |
+| `components/AlbumMediaGrid.kt` | شبكة وسائط ألبوم (3 أعمدة) عبر Coil. |
+| `components/ForwardMessageDialog.kt` | حوار إعادة توجيه كامل مع بحث وأقسام واختيار متعدد وشريط تقدم (`ForwardDialogState`). |
+| `components/MediaStagingChatInput.kt` | شريط إدخال محادثة مع أزرار صور/فيديو/ملف + `BasicTextField` + زر إرسال دائري متحرك. |
+| `components/PhysicsSwipeToDelete.kt` | سحب للحذف بفيزياء (تأثير مغناطيسي) مع احتكاك وعتبة فتح. |
+| `components/QrCodeDisplay.kt` | عرض رمز QR للتحقق OOB (يستخدم Icon placeholder حالياً). |
+| `components/SettingsGroup.kt` | `MeshifySettingsGroup` و `MeshifySettingsItem`. |
+| `components/StagedMediaRow.kt` | صف أفقي للمرفقات المؤقتة مع صور مصغرة. |
+| `components/VideoPlayer.kt` | مشغل فيديو عبر `ExoPlayer` (`PlayerView` في `AndroidView`)، `playWhenReady = false`. |
+| `hooks/PremiumHaptics.kt` | `PremiumHaptics` بـ 12 نمطاً (`Tick, Pop, Thud, Buildup, Success, Error, Send...`) + `LocalPremiumHaptics` CompositionLocal. |
+| `model/AttachmentUiModel.kt` | `data class AttachmentUiModel(id, type: MessageType, filePath)`. |
+| `model/ChatUiModel.kt` | `data class ChatUiModel(peerId, peerName, lastMessage, timestamp, unreadCount)`. |
+| `model/MessageUiModel.kt` | `data class MessageUiModel(id, text, type: MessageType, timestamp)`. |
+| `model/StagedAttachment.kt` | `data class StagedAttachment(uri: Uri, bytes: ByteArray, type: MessageType)`. |
+
+## قرارات تقنية ظاهرة
+
+- **الألوان:** M3 Material You مع dynamic color (Android 12+) أو تبديل يدوي بين `DarkColorScheme`/`LightColorScheme`. 8 ألوان seed مسبقة في `Color.kt`.
+- **التنقل:** Jetpack Navigation Compose 2.9.7 مع `Screen` sealed class `@Serializable` (type-safe). `MeshifyNavHost` هو وعاء IoC لا يعرف تفاصيل الشاشات.
+- **MD3E:** `androidx.graphics.shapes` لتشكيل morphing + `material3` alpha17. `@OptIn(ExperimentalMaterial3Api::class)`.
+- **Coil 3:** `AsyncImage` مع `crossfade(true)`؛ الصور المحلية تُحمّل عبر `File(mediaPath)` لا URI.
+- **الاهتزاز:** `PremiumHaptics` يستخدم `VibrationEffect.createPredefined` (API 29+) أو `createWaveform`، متاح عبر `LocalPremiumHaptics`.
+- **نماذج UI:** 4 نماذج تفصل `:core:ui` عن `:core:data` (لا تعتمد على Room entities).
+- **RTL:** موارد عربية/إنجليزية مع دعم RTL.
diff --git a/docs/feature-chat/README.md b/docs/feature-chat/README.md
new file mode 100644
index 00000000..864ef53b
--- /dev/null
+++ b/docs/feature-chat/README.md
@@ -0,0 +1,47 @@
+# `:feature:chat` — شاشة المحادثة
+
+**الغرض:** شاشة المحادثة الفردية — عرض وإرسال رسائل نصية ووسائط (صور، فيديو، ملفات) مع الرد، إعادة التوجيه، الحذف، التفاعلات (reactions)، البحث داخل المحادثة، وضع التحديد المتعدد، وعرض تقدم رفع الملفات.
+
+**الاعتماديات (`build.gradle.kts`):** `:core:domain`, `:core:data`, `:core:ui`, `:core:common` + Compose BOM، material3، icons-extended، Coil 3، lifecycle، Hilt، navigation-compose.
+
+> ملاحظة: `build.gradle.kts` يعلن `androidx.paging.compose` لكنه غير مستخدم فعلياً — لا توجد شاشة تستهلك Paging 3. ترقيم المحادثة اليدوي (`getMessagesPaged` بحجم 50) كان موجوداً سابقاً في `ChatViewModel` وحُذف لأنه كان ميتاً (`getMessages` يُرجع المحادثة كاملة).
+
+## الملفات الرئيسية
+
+جميع المسارات نسبة إلى `feature/chat/src/main/java/com/p2p/meshify/feature/chat/`:
+
+| الملف | المحتوى |
+|---|---|
+| `ChatViewModel.kt` | المنطق الكامل: إرسال، رفع مرفقات، رد، إعادة توجيه، تحديد متعدد، بحث، حذف. |
+| `ChatScreen.kt` | الـ Composable الرئيسي — ينسّق TopBar/InputBar/MessageList/الحوارات + تمرير ذكي + Snackbar للأخطاء. |
+| `components/MessageList.kt` | `LazyColumn` برسائل متحركة (staggered) + تحميل مرفقات عبر `produceState` + حالة فارغة. |
+| `components/MessageBubble.kt` | فقاعة الرسالة: نص/صورة (AsyncImage)/فيديو/ملف + شريط تقدم الرفع + حالة الإرسال + أيقونة النقل (Bluetooth/BOTH) + التفاعلات. |
+| `components/ChatInputBar.kt` | شريط الإدخال + أزرار إرفاق (صور/فيديو/ملف عبر `GetContent`) + قراءة URI مع فحص الحجم (`MAX_FILE_SIZE_BYTES=100MB`) و `use {}`. |
+| `components/ChatTopBar.kt` | الشريط العلوي: الاسم، avatar، حالة الاتصال، رجوع، بحث. |
+| `components/SelectionModeTopBar.kt` | شريط وضع التحديد المتعدد: نسخ/إعادة توجيه/حذف. |
+| `components/ChatContextMenu.kt` | Bottom Sheet عند الضغط المطول: رد/إعادة توجيه/نسخ/حذف. |
+| `components/ReplyIndicator.kt` | مؤشر الرد فوق شريط الإدخال. |
+| `components/ScrollToFAB.kt` | زر عائم للتمرير للأسفل عند الابتعاد. |
+| `components/BackConfirmationDialog.kt` | تأكيد الرجوع عند وجود مسودة (>1024 حرف). |
+| `components/DeleteConfirmationDialog.kt` | تأكيد حذف رسالة (مع `DELETE_FOR_EVERYONE`). |
+
+## الشاشات
+
+- **`ChatScreen`** (المسار `Screen.Chat(peerId, peerName)`): تركّب `Scaffold` + `snackbarHost` + `WindowInsets.ime` + TopBar (عادي/تحديد/بحث) + BottomBar (`ReplyIndicator` + `ChatInputBar` + `MediaStagingChatInput` + `StagedMediaRow`) + Body (`MessageList`/`SearchResultsList`) + `ScrollToFAB` + `ChatContextMenu` + `ForwardMessageDialog` + `FullImageViewer` + الحوارات + Snackbar + `LocalPremiumHaptics`.
+
+## `ChatViewModel`
+
+- الحقن: `@HiltViewModel` + `@ApplicationContext`, `SavedStateHandle`, `IChatRepository`. `peerId`/`peerName` من وسائط التنقل.
+- **StateFlows:** `uiState` (رسائل، حالة اتصال، نص، draft، replyTo، مرفقات، isSending، أخطاء، `transportUsed: Map`)، `forwardDialogState`, `selectedMessages: Set`, `uploadProgress: Map` (مع `sample(100ms)` + `WhileSubscribed(5000)`)، `searchQuery`, `searchResults`, `isSearching`.
+- **أفعال:** `sendMessage()` (حماية double-tap 500ms)، `stageAttachment()` (حد 10)، `sendFileWithProgress()`/`cancelUpload()`، `deleteMessage()`، `addReaction()`، `openForwardDialog*()`/`forwardMessages()`، `toggleMessageSelection()`، `copySelectedMessagesToClipboard()`، `copyMessageToClipboard()`، `startSearch()`/`stopSearch()`/`updateSearchQuery()`.
+
+## قرارات تقنية
+
+- يصل إلى `ChatRepositoryImpl` عبر `as ChatRepositoryImpl` لدوال غير موجودة بالواجهة (`getMessages`, `searchMessagesInChat`, `getMessageAttachments`).
+- تحديث فوري: جمع `chatRepo.getMessages(peerId)` مع `distinctUntilChanged()`.
+- لا يوجد ترقيم صفحات في الواجهة: `getMessages` يُرجع المحادثة كاملة ويتعامل `LazyColumn` مع التمرير افتراضياً. ماكينة الترقيم اليدوي (صفحات 50، حد 200 رسالة) حُذفت لأنها كانت ميتة.
+- LRU cache للمرفقات `LinkedHashMap` (200 إدخال).
+- تتبع النقل: حفظ `TransportType` لكل رسالة؛ أيقونة Bluetooth لـ BLE و GridView لـ BOTH.
+- رفع الملفات: `ConcurrentHashMap` للإلغاء الآمن.
+- معالجة صور: Coil 3 `AsyncImage` + `crossfade(true)`؛ عند غياب `File(path)` يُعرض عنصر نائب بأيقونة `BrokenImage`.
+- تمرير ذكي: `derivedStateOf` + `snapshotFlow` + `LaunchedEffect`.
diff --git a/docs/feature-discovery.md b/docs/feature-discovery.md
new file mode 100644
index 00000000..66249c27
--- /dev/null
+++ b/docs/feature-discovery.md
@@ -0,0 +1,33 @@
+# `:feature:discovery` — اكتشاف الأجهزة النظيرة
+
+**الغرض:** اكتشاف الأجهزة النظيرة (peers) على الشبكة المحلية وعبر BLE. تعرض قائمة بالأجهزة المكتشفة مع معلومات الاتصال وجودة الإشارة، وتنتقل إلى شاشة المحادثة لكل جهاز.
+
+**الاعتماديات (`build.gradle.kts`):** `:core:domain`, `:core:data`, `:core:network`, `:core:ui`, `:core:common` + Compose، material3، Coil 3، lifecycle، Hilt.
+
+## الملفات
+
+جميع المسارات نسبة إلى `feature/discovery/src/main/java/com/p2p/meshify/feature/discovery/`:
+
+| الملف | المحتوى |
+|---|---|
+| `DiscoveryScreen.kt` | الشاشة + كل الـ Composables المساعدة (PeerList, PeerListItem, TransportBadge, SignalStrengthIndicator, EmptyDiscoveryState, WifiDisabledState, ErrorState). |
+| `DiscoveryViewModel.kt` | ViewModel مع `DiscoveryUiState` data class — يُدير الحالة عبر `TransportManager`. |
+
+## الشاشات والمكونات
+
+- **`DiscoveryScreen`** (المسار `Screen.Discovery`): `Scaffold` + `TopAppBar` (رجوع + تحديث) + `SnackbarHost` + `LinearProgressIndicator` + `PeerList` (LazyColumn). `onPeerClick` يأخذ `PeerDevice` وينتقل إلى `Screen.Chat(peer.id, peer.name)`.
+- **`TransportBadge`** — شارة نوع النقل (LAN/BLE/BOTH) عبر أيقونة Wifi/Bluetooth.
+- **`SignalStrengthIndicator`** — 3 أشرطة RSSI (قوي/متوسط/ضعيف/غير متصل).
+- **`WifiDisabledState`** — شاشة خاصة عند تعطيل Wi-Fi (زر "Open Wi-Fi Settings") بدل القائمة الفارغة.
+
+## `DiscoveryViewModel`
+
+- `uiState: StateFlow` يحوي: `discoveredPeers`, `isSearching`, `isRefreshing`, `errorMessage`, `isWifiEnabled`, `canDiscover`.
+- أفعال: `refresh()` (إيقاف/إعادة تشغيل الاكتشاف)، `checkWifiState()` (عبر `WifiStateChecker`)، `observeTransportEvents()` (جمع `TransportEvent`).
+- يُنشأ **يدوياً** في `MainActivity` (وليس عبر Hilt) لأنه يحتاج `TransportManager` و `WifiStateChecker`.
+
+## قرارات تقنية
+
+- `MutableMap` لـ O(1) lookup بدل `indexOfFirst`.
+- دوال دمج: `mergeTransportType()` / `mergeRssi()` / `mergeName()` لدمج بيانات الجهاز من LAN + BLE.
+- ثوابت: `DISCOVERY_CLEANUP_DELAY_MS = 200L`، `DISCOVERY_SCAN_DELAY_MS = 2000L`.
diff --git a/docs/feature-home.md b/docs/feature-home.md
new file mode 100644
index 00000000..2fd7a5da
--- /dev/null
+++ b/docs/feature-home.md
@@ -0,0 +1,28 @@
+# `:feature:home` — قائمة المحادثات الأخيرة
+
+**الغرض:** الشاشة الرئيسية (بعد الإعداد الأولي). تعرض كل المحادثات مع بحث، حذف بالسحب، حالة الاتصال (Online/Offline)، وعدد الرسائل غير المقروءة. تنتقل إلى شاشة الاكتشاف (إضافة محادثة) والإعدادات.
+
+**الاعتماديات (`build.gradle.kts`):** `:core:domain`, `:core:data`, `:core:ui`, `:core:common` + Compose BOM، material3، icons-extended، Coil 3، lifecycle، Hilt + navigation-compose.
+
+## الملفات
+
+جميع المسارات نسبة إلى `feature/home/src/main/java/com/p2p/meshify/feature/home/`:
+
+| الملف | المحتوى |
+|---|---|
+| `RecentChatsScreen.kt` | الشاشة الرئيسية: `Scaffold` + `CenterAlignedTopAppBar` (عنوان + زر Settings)، `FloatingActionButton` → Discovery، `OutlinedTextField` بحث، `LazyColumn`، حوار حذف، وحالات Loading/Error/Empty. تستخدم `imePadding()`. |
+| `RecentChatsUiState.kt` | `data class RecentChatsUiState` (`chats`, `onlinePeers: Set`, حالة التحميل، الخطأ). |
+| `RecentChatsViewModel.kt` | `@HiltViewModel`. يحمّل المحادثات مع بحث (debounce 300ms عبر `flatMapLatest`)، يراقب `onlinePeers`، يوفّر `deleteChat()`, `markChatAsRead()`, `retryLoad()`, `updateSearchQuery()`. |
+
+## الشاشات
+
+- **`RecentChatsScreen`** (المسار `Screen.Home`، route فارغ `""`): المكونات الرئيسية:
+ - `MagneticChatItem` + `PhysicsSwipeToDelete` (سحب للحذف)، `MeshifyListItem` (avatar + حالة اتصال + اسم + آخر رسالة + وقت + `UnreadBadge`)، `DeleteConfirmationDialog`.
+ - ثوابت: `SEARCH_BAR_BORDER_ALPHA = 0.5f`، `EMPTY_STATE_TEXT_ALPHA = 0.7f`، `MAX_UNREAD_DISPLAY = 99`.
+
+## قرارات تقنية
+
+- يستخدم `ChatRepositoryImpl` مباشرة (وليس `IChatRepository`) لأن `getAllChats()`/`searchChats()`/`onlinePeers` ليست في واجهة domain.
+- تدفق Room Flow مستمر (ليس `take(1)`) — أي تغيير بالـ DB ينعكس فوراً.
+- جمع `onlinePeers` في Coroutine منفصل لتحديث حالة الاتصال لحظياً.
+- لا توجد ملفات اختبارية (حُذفت حسب قرار المستخدم — راجع `QWEN.md`).
diff --git a/docs/feature-onboarding.md b/docs/feature-onboarding.md
new file mode 100644
index 00000000..49f69854
--- /dev/null
+++ b/docs/feature-onboarding.md
@@ -0,0 +1,34 @@
+# `:feature:onboarding` — شاشة الترحيب الأولى
+
+**الغرض:** شاشة الترحيب للمستخدم الجديد — 3 صفحات (ترحيب → كيف يعمل → الأذونات) مع اختيار اللغة، طلب الأذونات واحدة تلو الأخرى، وحوار ملخص.
+
+**الاعتماديات (`build.gradle.kts`):** `:core:domain`, `:core:ui`, `:core:common` (لا تعتمد على `:core:data` ولا `:core:network`) + Compose، material3، lifecycle، Hilt، Kotlin Serialization.
+
+## الملفات
+
+جميع المسارات نسبة إلى `feature/onboarding/src/main/java/com/p2p/meshify/feature/onboarding/`:
+
+| الملف | المحتوى |
+|---|---|
+| `WelcomeScreen.kt` | الـ Composable الرئيسي: `TopAppBar`، `HorizontalPager` (3 صفحات)، `BottomNav`، `PermissionRequestCard`، `PermissionResultCard`، `PermissionSummaryDialog`، `PermissionDefinitions`. |
+| `WelcomeViewModel.kt` | ViewModel لحالة التنقل بين الصفحات والقائمة المنسدلة للغة. |
+| `WelcomeUiState.kt` | `WelcomeUiState`, `PermissionInfo`, `PermissionStatus` (enum), `PermissionIconType` (enum), `PermissionRequestResult` (sealed) + `toPermissionStatus()`. |
+| `OnboardingPage.kt` | الصفحات الثلاث: `WelcomePage`, `HowItWorksPage` (+`StepCard`), `PermissionsOverviewPage` (+`PermissionRow`, `StatusBadge`). |
+| `SkipConfirmationDialog.kt` | حوار تأكيد تخطي سير الأذونات. |
+
+## الشاشات
+
+- **`WelcomeScreen`** (المسار `Screen.Onboarding`): `TopBar` (لغة + Skip) + `HorizontalPager` (3 صفحات) + `BottomNav` (مؤشر + Next/Get Started).
+
+## `WelcomeViewModel`
+
+- `uiState` يحوي `currentPage` (0–2)، `totalPages = 3`، `isAnimating` (يمنع التفاعل 300ms)، `isLangMenuOpen`.
+- أفعال: `nextPage()`، `goToPage(pageIndex)`، `toggleLangMenu()`.
+
+## قرارات تقنية
+
+- **سير الأذونات** يُدار محلياً في `OnboardingRoute` داخل `MainActivity.kt` (وليس بالـ ViewModel) — `remember` + `DisposableEffect`.
+- **3 أذونات** (`PermissionDefinitions.getPermissions()`): Nearby Wi-Fi (أو Location لـ .log`. |
+| `engine/TestDataCleaner.kt` | يحذف رسائل/محادثات الاختبار من Room ببادئة `test_target_`. |
+
+### `model/` (4)
+`DiscoveredPeer.kt` (مع `SignalLevel`, `SessionStatus`)، `TestResult.kt` (`TestStatus` = PENDING/RUNNING/PASSED/FAILED/TIMEOUT)، `TestScenario.kt`، `TestScenarioFactory.kt` (`createDefaults()` → 6 سيناريوهات).
+
+### `preflight/` (2)
+`PreFlightChecker.kt` (فحص أذونات + اتصال عبر `PermissionChecker`/`ConnectivityChecker`)، `PreFlightResult.kt` (`CheckStatus` = PASS/FAIL/SKIP).
+
+### `ui/` (9)
+`RealDeviceTestingUiState.kt` (آلة الحالات + 15 حدثاً)، `RealDeviceTestingViewModel.kt` (`factory` يدوي، `uiState` + `snackbarMessage`)، `RealDeviceTestScreen.kt` + 7 Composables خاصة (Initial, RunningPreflight, PreFlightDone, PreFlightFailed, NoPeersFound, RunningTests, TestsDone)، `TestTypeSelector.kt`, `TestProgressPanel.kt`, `TestResultsPanel.kt`, `DiscoveredPeerList.kt`, `PreFlightResultsCard.kt`.
+
+## آلة الحالات (8 مراحل) — `RealDeviceTestingUiState`
+
+| # | الحالة | المشغّل |
+|---|---|---|
+| 1 | `Initial` (data object) | — |
+| 2 | `RunningPreflight(elapsedMs)` | `onEvent(RunPreflight)` |
+| 3 | `PreFlightDone(...)` | اكتمال ما قبل الطيران بنجاح |
+| 4 | `PreFlightFailed(...)` | فشل ما قبل الطيران (→ إعادة عبر `RunPreflight`) |
+| 5 | `NoPeersFound(...)` | لا أقران مكتشفون |
+| 6 | `RunningTests(...)` | مسح/بدء الاختبارات |
+| 7 | `TestsDone(...)` | اكتمال الاختبارات (→ `RerunFailedTests` يعيد لـ 6) |
+
+توفّر الخاصية المحسوبة `progressFraction` على الواجهة المختومة.
+
+## تفاصيل LAN / BLE
+
+- **LAN:** `LanTransportTestAdapter` يبني `SocketManager` + `LanTransportImpl` مستقلين؛ متاح دائماً (`isAvailable = true`).
+- **BLE:** `BleTransportTestAdapter` يبني مكدساً مستقلاً؛ يتحقق `BluetoothAdapter.isEnabled`. إن لم يتوفر BLE يُسجّل LAN فقط.
+- **الاكتشاف:** كلا النقلين يكتشفان الأقران بمهلة قابلة للضبط، وتُزال التكرارات بـ peer ID.
+
+## مسار التنقل
+
+`Screen.RealDeviceTesting` (data object) — مُوصولة في `MeshifyNavHost`. تُستدعى من `MainActivity` داخل `onDeveloperRoute`: Home → Settings → Developer → "Real Device Testing". **قابلة للوصول وقت التشغيل.**
diff --git a/docs/feature-settings/README.md b/docs/feature-settings/README.md
new file mode 100644
index 00000000..07d115fe
--- /dev/null
+++ b/docs/feature-settings/README.md
@@ -0,0 +1,51 @@
+# `:feature:settings` — الإعدادات + شاشة المطورين
+
+**الغرض:** إعدادات التطبيق الشاملة (الهوية، المظهر، الخصوصية، الشبكة، التطبيق، معلومات، وشاشة المطورين المخفية).
+
+**الاعتماديات (`build.gradle.kts`):** `:core:domain`, `:core:data`, `:core:ui`, `:core:common` + Compose، material3، icons-extended، Coil 3، lifecycle، Hilt.
+
+## الملفات
+
+جميع المسارات نسبة إلى `feature/settings/src/main/java/com/p2p/meshify/feature/settings/`:
+
+| الملف | المحتوى |
+|---|---|
+| `SettingsScreen.kt` | الشاشة الرئيسية: `LargeTopAppBar` + `Scaffold` + `Column` (verticalScroll) + header (avatar/name/deviceId). يستضيف حالات ظهور الحوارات/الأوراق السفلية والـ snackbars، وينادي المقاطع والأغلفة أدناه. |
+| `SettingsSections.kt` | مقاطع الشاشة كـ composables مستقلة: `IdentitySection`, `AppearanceSection`, `PrivacySection`, `NetworkSection`, `AppSettingsSection`, `AboutSection` (مع منطق الـ easter-egg للنقر 7 مرات). تستخدم مكوّنات MD3E المحلية من `SettingsComponents.kt`. |
+| `SettingsComponents.kt` | مكوّنات MD3E معبّرة محلية (مستوحاة من لغة PixelPlayer): `SettingsSection` (ترويسة بأيقونة بارزة ملوّنة)، `SettingsItem` (صف بطاقة `surfaceContainer`)، `SwitchSettingItem` (مفتاح متحرك بأيقونة Check/Close عبر `AnimatedContent`). |
+| `SettingsViewModel.kt` | يقرأ الـ Flows من `ISettingsRepository` ويكتبها في `SettingsUiState`. |
+| `BleStatusBottomSheet.kt` | `BleStatusBottomSheet`: حالة BLE + `FilterChip` لـ `TransportMode` (AUTO/LAN_ONLY/BLE_ONLY/MULTI_PATH)، بأسلوب MD3E. |
+| `SettingsNameDialog.kt` | غلاف حول `MeshifyTextInputDialog` لتعديل الاسم. |
+| `SettingsThemeSheet.kt` | غلاف حول `ThemeSelectionBottomSheet` لاختيار الثيم. |
+| `SettingsLanguageDialog.kt` | غلاف حول `MeshifySelectionDialog` (en/ar) — يُعيد إنشاء الـ Activity عند التغيير. |
+| `SettingsFontSizeDialog.kt` | غلاف حول `MeshifySelectionDialog` لمقياس الخط (0.8/1.0/1.2/1.5). |
+| `SettingsBackupDialog.kt` | `AlertDialog` النسخ الاحتياطي/الاستعادة (MD3E: FilledTonalButton للتأكيد). |
+| `SettingsCreditsDialog.kt` | `AlertDialog` الاعتمادات (MD3E). |
+| `DeveloperScreen.kt` | شاشة المطور (مخفية): composable فقط. |
+| `DeveloperViewModel.kt` | `DeveloperViewModel` (منفصل عن الشاشة): إدراج/مسح بيانات وهمية. |
+
+## الشاشات والمكونات
+
+- **`SettingsScreen`** (المسار `Screen.Settings`): `LargeTopAppBar` + `Scaffold` + `Column` (verticalScroll) + `MeshifySettingsGroup`/`MeshifySettingsItem`.
+ - الحوارات المحلية: `MeshifyTextInputDialog` (الاسم)، `ThemeSelectionBottomSheet` (الثيم)، `MeshifySelectionDialog` (اللغة، حجم الخط)، `AlertDialog` (النسخ الاحتياطي، الاعتمادات)، `BleStatusBottomSheet` (حالة BLE + Transport Mode)، `SeedColorPickerGrid` (لون البذرة).
+- **`DeveloperScreen`** (المسار `Screen.Developer`): تُفتح بـ 7 نقرات على رقم الإصدار (مهلة 2 ثانية). `MeshifySettingsGroup` (Mock Data / Testing / Cleanup) + `AlertDialog` تأكيد + Snackbar.
+- **`BleStatusBottomSheet`**: `FilterChip` لـ `TransportMode` (AUTO/LAN_ONLY/BLE_ONLY/MULTI_PATH).
+
+## `SettingsViewModel`
+
+- `settingsUiState: StateFlow`: `displayName`, `themeMode`, `dynamicColorEnabled`, `hapticFeedbackEnabled`, `isNetworkVisible`, `avatarHash`, `deviceId`, `deviceIdLoaded`, `seedColor`, `appLanguage`, `fontSizeScale`, `notificationsEnabled`, `notificationSound`, `notificationVibrate`, `bleEnabled`, `transportMode`, `displayNameError`.
+- `errorMessage: StateFlow`، `deviceId: StateFlow`، `appVersion: String`.
+- أفعال: `updateDisplayName`, `setThemeMode`, `setHapticFeedback`, `setDynamicColor`, `setNetworkVisibility`, `updateAvatar`، `setSeedColor`, `setAppLanguage`, `setFontSizeScale`, `setNotificationsEnabled/Sound/Vibrate`, `setBleEnabled`, `setTransportMode`, `clearCache`, `exportBackup`, `clearError`.
+
+## `DeveloperViewModel`
+
+- بلا StateFlows عامة (نمط callback `onComplete: (String) -> Unit`).
+- `clearAllData()`، `insertMockConversations()` (7 محادثات × 4 رسائل)، `insertMockMediaMessages()`، `insertMockChatWithReactions()`، `insertMockChatWithReplies()`، `insertMockLongConversation()` (50 رسالة)، `clearMockData()`.
+
+## قرارات تقنية
+
+- **7 نقرات** على رقم الإصدار لفتح شاشة المطورين (easter egg).
+- **تبديل اللغة** يعيد إنشاء Activity (`activity.recreate()`).
+- **اختيار avatar** عبر `GetContent()` لـ `image/*`؛ نسخ Device ID عبر `ClipboardManager`.
+- كل الإعدادات عبر `ISettingsRepository` (DataStore). `SettingsViewModel` يُنشأ **يدوياً** في `MainActivity` (وليس Hilt).
+- زر "Clear All Data" يتطلب تأكيداً.
diff --git a/feature/chat/build.gradle.kts b/feature/chat/build.gradle.kts
index 6f54dd61..71190c36 100644
--- a/feature/chat/build.gradle.kts
+++ b/feature/chat/build.gradle.kts
@@ -7,7 +7,7 @@ plugins {
android {
namespace = "com.p2p.meshify.feature.chat"
- compileSdk = 36
+ compileSdk = 37
defaultConfig {
minSdk = 26
@@ -59,10 +59,17 @@ dependencies {
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
implementation(libs.hilt.navigation.compose)
-
+
// Coil
implementation(libs.coil3.compose)
implementation(libs.coil3.network)
debugImplementation(libs.androidx.ui.tooling)
+
+ // Testing
+ testImplementation(libs.junit)
+ testImplementation(libs.mockk)
+ testImplementation(libs.kotlinx.coroutines.test)
+ testImplementation(libs.androidx.core.testing)
+ testImplementation(libs.robolectric)
}
diff --git a/feature/chat/src/main/java/com/p2p/meshify/feature/chat/ChatScreen.kt b/feature/chat/src/main/java/com/p2p/meshify/feature/chat/ChatScreen.kt
index ecefedcc..6cc19392 100644
--- a/feature/chat/src/main/java/com/p2p/meshify/feature/chat/ChatScreen.kt
+++ b/feature/chat/src/main/java/com/p2p/meshify/feature/chat/ChatScreen.kt
@@ -1,9 +1,6 @@
package com.p2p.meshify.feature.chat
-import android.net.Uri
import androidx.activity.compose.BackHandler
-import androidx.activity.compose.rememberLauncherForActivityResult
-import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -30,8 +27,10 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -67,7 +66,6 @@ import com.p2p.meshify.core.ui.theme.MeshifyDesignSystem
import com.p2p.meshify.core.ui.hooks.HapticPattern
import com.p2p.meshify.core.ui.hooks.LocalPremiumHaptics
import com.p2p.meshify.domain.model.DeleteType
-import com.p2p.meshify.domain.model.MessageType
import com.p2p.meshify.feature.chat.components.BackConfirmationDialog
import com.p2p.meshify.feature.chat.components.ChatContextMenu
import com.p2p.meshify.feature.chat.components.ChatInputBar
@@ -82,6 +80,7 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
+
// ── Search UI constants ──────────────────────────────────────────────
private const val SEARCH_RESULT_BG_ALPHA_FROM_ME = 0.3f
private const val SEARCH_RESULT_BG_ALPHA_OTHER = 0.5f
@@ -139,14 +138,16 @@ fun ChatScreen(
mutableStateOf(TextFieldValue(uiState.draftText))
}
- // P2-11: Sync draftText from ViewModel → Composable only when draftText changes externally
- LaunchedEffect(uiState.draftText) {
- val draft = uiState.draftText
- if (textState.text.isEmpty() && draft.isNotEmpty()) {
- textState = TextFieldValue(draft)
- } else if (textState.text.isNotEmpty() && draft.isEmpty() && textState.text != draft) {
- textState = TextFieldValue("")
+ // P2-11: امسح حقل الإدخال المحلي فقط بعد إرسال ناجح.
+ // ViewModel يضبط inputText إلى "" عند النجاح ويُبقيه كما هو عند الفشل،
+ // لذا نتبع هذه الإشارة باتجاه واحد — دون إعادة ملء الحقل من الحالة،
+ // وهو ما كان يتسبب بظهور المسودة المُرسلة مجدداً في حقل الإدخال.
+ var lastInputText by remember { mutableStateOf(uiState.inputText) }
+ LaunchedEffect(uiState.inputText) {
+ if (lastInputText.isNotEmpty() && uiState.inputText.isEmpty()) {
+ textState = TextFieldValue()
}
+ lastInputText = uiState.inputText
}
// Delete confirmation state
@@ -178,10 +179,18 @@ fun ChatScreen(
// Error snackbars
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
+ val retryLabel = stringResource(R.string.notification_action_retry)
LaunchedEffect(uiState.sendError) {
uiState.sendError?.let { error ->
- snackbarHostState.showSnackbar(error)
+ val result = snackbarHostState.showSnackbar(
+ message = error,
+ actionLabel = retryLabel,
+ duration = SnackbarDuration.Indefinite
+ )
+ if (result == SnackbarResult.ActionPerformed) {
+ viewModel.sendMessage()
+ }
viewModel.clearError()
}
}
@@ -193,22 +202,11 @@ fun ChatScreen(
}
}
- // Media pickers
- val imageLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
- uri?.let {
- val bytes = context.contentResolver.openInputStream(it)?.readBytes()
- if (bytes != null) {
- viewModel.stageAttachment(it, bytes, MessageType.IMAGE)
- }
- }
- }
-
- val videoLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
- uri?.let {
- val bytes = context.contentResolver.openInputStream(it)?.readBytes()
- if (bytes != null) {
- viewModel.stageAttachment(it, bytes, MessageType.VIDEO)
- }
+ // Success snackbars (no retry action)
+ LaunchedEffect(uiState.successMessage) {
+ uiState.successMessage?.let { message ->
+ snackbarHostState.showSnackbar(message)
+ viewModel.clearSuccessMessage()
}
}
@@ -218,7 +216,7 @@ fun ChatScreen(
snapshotFlow { listState.layoutInfo.totalItemsCount }
.first { it >= uiState.messages.size }
- if (hasScrolledToBottom) {
+ if (isAtBottom) {
val lastVisibleIndex = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
val lastIndex = uiState.messages.size - 1
if (lastVisibleIndex >= lastIndex - 3) {
@@ -231,14 +229,6 @@ fun ChatScreen(
}
}
- // Track user scroll position
- LaunchedEffect(listState) {
- snapshotFlow { listState.firstVisibleItemIndex }
- .collect { firstVisibleIndex ->
- hasScrolledToBottom = (firstVisibleIndex >= uiState.messages.size - 5)
- }
- }
-
// BackHandler: exit search mode first
BackHandler(enabled = isSearching) {
viewModel.stopSearch()
@@ -246,8 +236,8 @@ fun ChatScreen(
}
// BackHandler for unsaved message drafts
- BackHandler(enabled = uiState.inputText.isNotBlank()) {
- if (uiState.inputText.length > 50) {
+ BackHandler(enabled = textState.text.isNotBlank()) {
+ if (textState.text.length > 1024) {
showBackConfirmationDialog = true
} else {
onBackClick()
@@ -258,6 +248,7 @@ fun ChatScreen(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.ime),
+ snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
topBar = {
if (selectedMessages.isNotEmpty()) {
SelectionModeTopBar(
@@ -272,7 +263,6 @@ fun ChatScreen(
},
onCopyClick = {
viewModel.copySelectedMessagesToClipboard(clipboard)
- viewModel.clearSelection()
}
)
} else if (isSearching) {
@@ -347,7 +337,6 @@ fun ChatScreen(
onSendClick = {
viewModel.onInputChanged(textState.text)
viewModel.sendMessage()
- textState = TextFieldValue()
},
stagedAttachments = uiState.stagedAttachments,
onRemoveAttachment = viewModel::removeStagedAttachment,
@@ -385,8 +374,7 @@ fun ChatScreen(
// Show search results instead of message list
SearchResultsList(
results = searchResults,
- query = searchQuery,
- listState = listState
+ query = searchQuery
)
} else {
// Message list
@@ -441,16 +429,10 @@ fun ChatScreen(
}
}
- // Snackbar Host
- SnackbarHost(
- hostState = snackbarHostState,
- modifier = Modifier.padding(MeshifyDesignSystem.Spacing.Md)
- )
-
// Context menu for long-pressed message
ChatContextMenu(
message = menuMessage,
- clipboardManager = clipboard,
+ onCopy = { viewModel.copyMessageToClipboard(clipboard, it) },
onDismiss = { menuMessage = null },
onReply = { msg -> viewModel.setReplyTo(msg) },
onForward = { msgId -> viewModel.openForwardDialog(msgId) },
@@ -517,9 +499,9 @@ fun ChatScreen(
@Composable
private fun SearchResultsList(
results: List,
- query: String,
- listState: androidx.compose.foundation.lazy.LazyListState
+ query: String
) {
+ val listState = rememberLazyListState()
if (results.isEmpty() && query.isNotBlank()) {
Box(
modifier = Modifier.fillMaxSize(),
@@ -569,7 +551,7 @@ private fun SearchResultItem(
horizontalArrangement = if (isFromMe) Arrangement.End else Arrangement.Start
) {
Text(
- text = if (isFromMe) stringResource(R.string.chat_message_you, "") else "",
+ text = if (isFromMe) stringResource(R.string.chat_message_you) else "",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold
diff --git a/feature/chat/src/main/java/com/p2p/meshify/feature/chat/ChatViewModel.kt b/feature/chat/src/main/java/com/p2p/meshify/feature/chat/ChatViewModel.kt
index 64331913..12bbbf3e 100644
--- a/feature/chat/src/main/java/com/p2p/meshify/feature/chat/ChatViewModel.kt
+++ b/feature/chat/src/main/java/com/p2p/meshify/feature/chat/ChatViewModel.kt
@@ -21,6 +21,7 @@ import com.p2p.meshify.core.ui.model.MessageUiModel
import com.p2p.meshify.core.ui.model.StagedAttachment
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.*
@@ -30,6 +31,8 @@ import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlin.time.Duration.Companion.milliseconds
import java.io.File
+import java.util.UUID
+import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
/** Debounce interval for search input to avoid excessive DB queries */
@@ -51,7 +54,8 @@ data class ChatUiState(
val isSending: Boolean = false,
val sendError: String? = null,
val uploadError: String? = null,
- val transportUsed: Map = emptyMap()
+ val transportUsed: Map = emptyMap(),
+ val successMessage: String? = null
)
@OptIn(FlowPreview::class)
@@ -98,7 +102,7 @@ class ChatViewModel @Inject constructor(
private val _uploadProgress = MutableStateFlow