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

- Kotlin + Kotlin Min SDK - Target SDK + Target SDK Repo Size - License - Status + Version + Status

@@ -17,18 +17,32 @@ --- +## 🤖 Slop Code + +> This codebase was designed and implemented using **LLM (Qwen Code)**. + +

+ AI Assisted +

+ +--- + +

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

- AI Assisted -

- ---- - -

- 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>(emptyMap()) val uploadProgress: StateFlow> = _uploadProgress .sample(100.milliseconds) - .stateIn(viewModelScope, SharingStarted.Lazily, emptyMap()) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyMap()) // ==================== Search State ==================== private val _searchQuery = MutableStateFlow("") @@ -112,46 +116,58 @@ class ChatViewModel @Inject constructor( private var searchCollectionJob: kotlinx.coroutines.Job? = null + // Tracks active upload jobs so cancelUpload() can cancel them + // ConcurrentHashMap for thread safety — invokeOnCompletion may run on a different dispatcher + private val uploadJobs = ConcurrentHashMap() + // ✅ Double tap protection - prevent sending same message twice private var lastSendTime = 0L private val sendDebounceMs = 500L // 500ms debounce init { - viewModelScope.launch { - chatRepo.getMessages(peerId) - .distinctUntilChanged() - .collect { messages -> - Logger.d("ChatViewModel -> Messages updated: ${messages.size} messages for peer $peerId") - _uiState.update { - it.copy(isLoading = false, messages = messages) + if (peerId.isBlank()) { + _uiState.update { it.copy(isLoading = false, sendError = context.getString(R.string.error_unknown)) } + } else { + // ✅ FIX: Collect messages flow with distinctUntilChanged to reduce recompositions + // This ensures real-time updates when messages are received from the network + viewModelScope.launch { + chatRepo.getMessages(peerId) + .distinctUntilChanged() + .collect { messages -> + Logger.d("ChatViewModel -> Messages updated: ${messages.size} messages for peer $peerId") + _uiState.update { + it.copy( + isLoading = false, + messages = messages + ) + } } - } - } + } - // Collect online status - viewModelScope.launch { - repository.onlinePeers.collect { online -> - _uiState.update { it.copy(isOnline = online.contains(peerId)) } + // Collect online status + viewModelScope.launch { + repository.onlinePeers.collect { online -> + _uiState.update { it.copy(isOnline = online.contains(peerId)) } + } } - } - // Collect security events from repository - // SharedFlow is hot and never terminates — errors are handled in repository before emit - viewModelScope.launch { - repository.securityEvents.collect { event -> - if (event.type == SecurityEvent.EventType.MESSAGE_SEND_FAILED) { - _uiState.update { - it.copy( - sendError = context.getString(R.string.error_message_send_failed, event.reason) - ) + // Collect security events from repository + // SharedFlow is hot and never terminates — errors are handled in repository before emit + viewModelScope.launch { + repository.securityEvents.collect { event -> + if (event.type == SecurityEvent.EventType.MESSAGE_SEND_FAILED) { + _uiState.update { + it.copy( + sendError = context.getString(R.string.error_message_send_failed, event.reason) + ) + } + Logger.e("ChatViewModel -> Message send failed: ${event.messageId}") } - Logger.e("ChatViewModel -> Message send failed: ${event.messageId}") } } } } - fun onInputChanged(text: String) { _uiState.update { it.copy(inputText = text, draftText = text) } } @@ -195,11 +211,21 @@ class ChatViewModel @Inject constructor( if (hasAttachments) { // Send grouped message with attachments val attachments = state.stagedAttachments.map { it.bytes to it.type } - repository.sendGroupedMessage(peerId, peerName, state.inputText, attachments, state.replyTo?.id) + val result = repository.sendGroupedMessage(peerId, peerName, state.inputText, attachments, state.replyTo?.id) + if (result.isFailure) { + val errorMessage = context.getString(R.string.error_message_send_failed, result.exceptionOrNull()?.message ?: context.getString(R.string.error_unknown)) + _uiState.update { it.copy(isSending = false, sendError = errorMessage, inputText = state.inputText) } + return@launch + } _uiState.update { it.copy(inputText = "", draftText = "", replyTo = null, stagedAttachments = emptyList()) } } else { // Send text message - repository.sendMessage(peerId, peerName, state.inputText, state.replyTo?.id) + val result = repository.sendMessage(peerId, peerName, state.inputText, state.replyTo?.id) + if (result.isFailure) { + val errorMessage = context.getString(R.string.error_message_send_failed, result.exceptionOrNull()?.message ?: context.getString(R.string.error_unknown)) + _uiState.update { it.copy(isSending = false, sendError = errorMessage, inputText = state.inputText) } + return@launch + } _uiState.update { it.copy(inputText = "", draftText = "", replyTo = null) } } @@ -274,20 +300,6 @@ class ChatViewModel @Inject constructor( } } - fun sendImage(bytes: ByteArray, extension: String) { - viewModelScope.launch { - repository.sendImage(peerId, peerName, bytes, extension, _uiState.value.replyTo?.id) - _uiState.update { it.copy(replyTo = null) } - } - } - - fun sendVideo(bytes: ByteArray, extension: String) { - viewModelScope.launch { - repository.sendVideo(peerId, peerName, bytes, extension, _uiState.value.replyTo?.id) - _uiState.update { it.copy(replyTo = null) } - } - } - /** * Sends a file with progress tracking. * Used for large files where upload progress should be shown to the user. @@ -298,7 +310,7 @@ class ChatViewModel @Inject constructor( * @param caption Optional caption for the file */ fun sendFileWithProgress(messageId: String, file: File, fileType: MessageType, caption: String = "") { - viewModelScope.launch { + val job = viewModelScope.launch { try { // Initialize progress to 0 _uploadProgress.update { current -> @@ -337,6 +349,12 @@ class ChatViewModel @Inject constructor( it.copy(uploadError = context.getString(R.string.error_file_send_failed, error.message ?: context.getString(R.string.error_unknown))) } } + } catch (e: CancellationException) { + // Intentional cancellation via cancelUpload() — no error shown + Logger.d("ChatViewModel -> File upload cancelled for messageId: $messageId") + _uploadProgress.update { current -> + current - messageId + } } catch (e: Exception) { Logger.e("ChatViewModel -> File upload exception", e) _uploadProgress.update { current -> @@ -348,6 +366,8 @@ class ChatViewModel @Inject constructor( } } } + uploadJobs[messageId] = job + job.invokeOnCompletion { uploadJobs.remove(messageId) } } /** @@ -355,16 +375,19 @@ class ChatViewModel @Inject constructor( * Removes the progress indicator from the UI. */ fun cancelUpload(messageId: String) { + uploadJobs[messageId]?.cancel() _uploadProgress.update { current -> current - messageId } - // Note: Actual cancellation logic would need to be implemented in repository Logger.d("ChatViewModel -> Upload cancelled for messageId: $messageId") } fun deleteMessage(messageId: String, deleteType: DeleteType) { viewModelScope.launch { - repository.deleteMessage(messageId, deleteType) + val result = repository.deleteMessage(messageId, deleteType) + if (result.isFailure) { + _uiState.update { it.copy(sendError = context.getString(R.string.error_message_send_failed, result.exceptionOrNull()?.message ?: context.getString(R.string.error_unknown))) } + } // Clean up transport history for deleted messages _uiState.update { it.copy(transportUsed = it.transportUsed - messageId) } } @@ -372,7 +395,10 @@ class ChatViewModel @Inject constructor( fun addReaction(messageId: String, reaction: String?) { viewModelScope.launch { - repository.addReaction(messageId, reaction) + val result = repository.addReaction(messageId, reaction) + if (result.isFailure) { + _uiState.update { it.copy(sendError = context.getString(R.string.error_message_send_failed, result.exceptionOrNull()?.message ?: context.getString(R.string.error_unknown))) } + } } } @@ -491,6 +517,7 @@ class ChatViewModel @Inject constructor( val peerIds = currentState.selectedPeerIds.toList() var successCount = 0 var failedCount = 0 + val failedPeerIds = mutableSetOf() // Forward each message to each peer messagesToForward.forEach { message -> @@ -501,10 +528,12 @@ class ChatViewModel @Inject constructor( successCount++ } else { failedCount++ + failedPeerIds.add(peerId) Logger.e("ChatViewModel -> Failed to forward message ${message.id} to $peerId: ${result.exceptionOrNull()?.message}") } } catch (e: Exception) { failedCount++ + failedPeerIds.add(peerId) Logger.e("ChatViewModel -> Exception forwarding message ${message.id} to $peerId", e) } @@ -517,14 +546,18 @@ class ChatViewModel @Inject constructor( // Show result if (failedCount > 0) { - val totalAttempts = successCount + failedCount _uiState.update { it.copy( - sendError = context.getString(R.string.error_forward_partial, failedCount, totalAttempts) + sendError = context.getString(R.string.error_forward_partial, failedPeerIds.size, peerIds.size) ) } - Logger.w("ChatViewModel -> Forwarded $successCount messages, $failedCount failed") + Logger.w("ChatViewModel -> Forwarded $successCount messages, $failedCount attempts failed across ${failedPeerIds.size} peer(s)") } else { + _uiState.update { + it.copy( + successMessage = context.getString(R.string.forward_success, successCount, peerIds.size) + ) + } Logger.d("ChatViewModel -> Successfully forwarded $successCount messages to ${peerIds.size} peers") } @@ -573,8 +606,13 @@ class ChatViewModel @Inject constructor( fun deleteSelectedMessages(deleteType: DeleteType) { viewModelScope.launch { val selectedIds = _selectedMessages.value.toList() + var failedCount = 0 selectedIds.forEach { messageId -> - repository.deleteMessage(messageId, deleteType) + val result = repository.deleteMessage(messageId, deleteType) + if (result.isFailure) failedCount++ + } + if (failedCount > 0) { + _uiState.update { it.copy(sendError = context.getString(R.string.error_message_send_failed, "Failed to delete $failedCount messages")) } } // Clean up transport history for all deleted messages val idsToRemove = selectedIds.toSet() @@ -584,9 +622,9 @@ class ChatViewModel @Inject constructor( } /** - * Copy all selected messages to clipboard. + * Copy all selected messages to clipboard with ClipboardManager. */ - fun copySelectedMessages() { + fun copySelectedMessagesToClipboard(clipboard: ClipboardManager) { viewModelScope.launch { val selectedIds = _selectedMessages.value if (selectedIds.isEmpty()) return@launch @@ -595,8 +633,11 @@ class ChatViewModel @Inject constructor( val textToCopy = messages.joinToString("\n\n") { it.text ?: "" } if (textToCopy.isNotBlank()) { - // Use clipboard manager + clipboard.setText(androidx.compose.ui.text.AnnotatedString(textToCopy)) Logger.d("ChatViewModel -> Copied ${messages.size} messages to clipboard") + _uiState.update { + it.copy(successMessage = context.getString(R.string.chat_messages_copied, messages.size)) + } } clearSelection() @@ -604,22 +645,14 @@ class ChatViewModel @Inject constructor( } /** - * Copy all selected messages to clipboard with ClipboardManager. + * Copy a single message to the clipboard and show a success snackbar. */ - fun copySelectedMessagesToClipboard(clipboard: ClipboardManager) { + fun copyMessageToClipboard(clipboard: ClipboardManager, messageId: String) { viewModelScope.launch { - val selectedIds = _selectedMessages.value - if (selectedIds.isEmpty()) return@launch - - val messages = uiState.value.messages.filter { it.id in selectedIds && it.text != null } - val textToCopy = messages.joinToString("\n\n") { it.text ?: "" } - - if (textToCopy.isNotBlank()) { - clipboard.setText(androidx.compose.ui.text.AnnotatedString(textToCopy)) - Logger.d("ChatViewModel -> Copied ${messages.size} messages to clipboard") - } - - clearSelection() + val message = uiState.value.messages.find { it.id == messageId } ?: return@launch + val text = message.text ?: return@launch + clipboard.setText(androidx.compose.ui.text.AnnotatedString(text)) + _uiState.update { it.copy(successMessage = context.getString(R.string.chat_messages_copied, 1)) } } } @@ -637,6 +670,13 @@ class ChatViewModel @Inject constructor( _uiState.update { it.copy(uploadError = null) } } + /** + * Clear the success message after it has been shown. + */ + fun clearSuccessMessage() { + _uiState.update { it.copy(successMessage = null) } + } + // ==================== Search Functions ==================== /** diff --git a/feature/chat/src/main/java/com/p2p/meshify/feature/chat/components/ChatContextMenu.kt b/feature/chat/src/main/java/com/p2p/meshify/feature/chat/components/ChatContextMenu.kt index 94aea910..9e832891 100644 --- a/feature/chat/src/main/java/com/p2p/meshify/feature/chat/components/ChatContextMenu.kt +++ b/feature/chat/src/main/java/com/p2p/meshify/feature/chat/components/ChatContextMenu.kt @@ -18,13 +18,10 @@ import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.ClipboardManager import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.unit.dp import com.p2p.meshify.core.data.local.entity.MessageEntity import com.p2p.meshify.core.common.R -import androidx.compose.ui.res.stringResource /** * Context menu shown as a bottom sheet when a message is long-pressed. @@ -34,7 +31,7 @@ import androidx.compose.ui.res.stringResource @Composable fun ChatContextMenu( message: MessageEntity?, - clipboardManager: ClipboardManager, + onCopy: (String) -> Unit, onDismiss: () -> Unit, onReply: (MessageEntity) -> Unit, onForward: (String) -> Unit, @@ -70,7 +67,7 @@ fun ChatContextMenu( headlineContent = { Text(stringResource(R.string.chat_action_copy)) }, leadingContent = { Icon(Icons.Default.ContentCopy, stringResource(R.string.chat_action_copy)) }, modifier = Modifier.clickable { - clipboardManager.setText(AnnotatedString(message.text ?: "")) + onCopy(message.id) onDismiss() } ) diff --git a/feature/chat/src/main/java/com/p2p/meshify/feature/chat/components/ChatInputBar.kt b/feature/chat/src/main/java/com/p2p/meshify/feature/chat/components/ChatInputBar.kt index a1674e46..6492295a 100644 --- a/feature/chat/src/main/java/com/p2p/meshify/feature/chat/components/ChatInputBar.kt +++ b/feature/chat/src/main/java/com/p2p/meshify/feature/chat/components/ChatInputBar.kt @@ -21,11 +21,13 @@ import androidx.compose.ui.text.input.TextFieldValue import com.p2p.meshify.core.ui.components.MediaStagingChatInput import com.p2p.meshify.core.ui.components.StagedMediaRow import com.p2p.meshify.core.ui.model.StagedAttachment +import com.p2p.meshify.core.util.Logger +import com.p2p.meshify.domain.model.AppConstants import com.p2p.meshify.domain.model.MessageType /** * Chat input bar with media staging, attachment buttons, and text input. - * Handles image/video picker launchers and staged media display. + * Handles image/video/file picker launchers and staged media display. */ @Composable fun ChatInputBar( @@ -40,10 +42,41 @@ fun ChatInputBar( ) { val context = LocalContext.current + /** Reads bytes from a content URI safely via streaming copy that aborts over the size limit. */ + fun readUriBytes(uri: Uri): ByteArray? { + return try { + val maxBytes = AppConstants.MAX_FILE_SIZE_BYTES + context.contentResolver.openInputStream(uri)?.use { stream -> + val buffer = ByteArray(8 * 1024) + var total = 0L + val out = java.io.ByteArrayOutputStream() + var read: Int + while (stream.read(buffer).also { read = it } != -1) { + total += read + if (total > maxBytes) { + Logger.e("ChatInputBar -> File too large: > $maxBytes bytes") + return null + } + out.write(buffer, 0, read) + } + out.toByteArray() + } + } catch (e: SecurityException) { + Logger.e("ChatInputBar -> SecurityException reading URI: $uri", e) + null + } catch (e: java.io.FileNotFoundException) { + Logger.e("ChatInputBar -> File not found: $uri", e) + null + } catch (e: Exception) { + Logger.e("ChatInputBar -> Failed to read URI: $uri", e) + null + } + } + // Image picker launcher val imageLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? -> uri?.let { - val bytes = context.contentResolver.openInputStream(it)?.readBytes() + val bytes = readUriBytes(it) if (bytes != null) { onStageAttachment(it, bytes, MessageType.IMAGE) } @@ -53,13 +86,23 @@ fun ChatInputBar( // Video picker launcher val videoLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? -> uri?.let { - val bytes = context.contentResolver.openInputStream(it)?.readBytes() + val bytes = readUriBytes(it) if (bytes != null) { onStageAttachment(it, bytes, MessageType.VIDEO) } } } + // Generic file picker launcher + val fileLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? -> + uri?.let { + val bytes = readUriBytes(it) + if (bytes != null) { + onStageAttachment(it, bytes, MessageType.FILE) + } + } + } + Column(Modifier.navigationBarsPadding()) { // Reply indicator is handled by parent (passed separately) @@ -82,6 +125,7 @@ fun ChatInputBar( onSendClick = onSendClick, onGalleryClick = { imageLauncher.launch("image/*") }, onVideoClick = { videoLauncher.launch("video/*") }, + onFileClick = { fileLauncher.launch("*/*") }, hasAttachments = stagedAttachments.isNotEmpty(), isSending = isSending ) diff --git a/feature/chat/src/main/java/com/p2p/meshify/feature/chat/components/MessageBubble.kt b/feature/chat/src/main/java/com/p2p/meshify/feature/chat/components/MessageBubble.kt index daa8a545..09fa8c66 100644 --- a/feature/chat/src/main/java/com/p2p/meshify/feature/chat/components/MessageBubble.kt +++ b/feature/chat/src/main/java/com/p2p/meshify/feature/chat/components/MessageBubble.kt @@ -38,8 +38,11 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage @@ -57,6 +60,8 @@ import com.p2p.meshify.domain.model.TransportType import com.p2p.meshify.core.common.R import java.io.File + + /** * Status indicator icon size for standard states */ private val StatusIconSize = 14.dp @@ -100,6 +105,7 @@ private const val StatusAlphaDefault = 0.7f @Composable fun MessageBubble( message: MessageEntity, + replyMessage: MessageEntity? = null, attachments: List, peerName: String, isSelected: Boolean = false, @@ -114,7 +120,6 @@ fun MessageBubble( val containerColor = if (message.isFromMe) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainer val contentColor = if (message.isFromMe) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface - // Square chat bubble shape val bubbleShape = MeshifyDesignSystem.Shapes.Card Column( @@ -153,14 +158,21 @@ fun MessageBubble( ) } else { if (message.replyToId != null) { + val quoteText = when { + replyMessage?.text != null -> replyMessage.text ?: "" + replyMessage != null -> stringResource(R.string.chat_reply_media) + else -> stringResource(R.string.chat_reply_unavailable) + } Surface( color = contentColor.copy(alpha = 0.08f), shape = MeshifyDesignSystem.Shapes.CardSmall, modifier = Modifier.padding(bottom = 6.dp) ) { Text( - text = stringResource(R.string.chat_reply_placeholder), + text = quoteText, style = MaterialTheme.typography.labelSmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), color = MaterialTheme.colorScheme.primary ) @@ -343,11 +355,16 @@ fun StatusIcon(status: MessageStatus, tint: Color) { modifier = Modifier.size(StatusIconSize), tint = tint.copy(StatusAlphaQueued) ) - MessageStatus.SENDING -> CircularProgressIndicator( - modifier = Modifier.size(StatusIconSizeSmall), - strokeWidth = StatusIconStrokeWidth, - color = tint - ) + MessageStatus.SENDING -> { + val sendingDesc = stringResource(R.string.message_status_sending) + CircularProgressIndicator( + modifier = Modifier + .size(StatusIconSizeSmall) + .semantics { contentDescription = sendingDesc }, + strokeWidth = StatusIconStrokeWidth, + color = tint + ) + } MessageStatus.SENT -> Icon( Icons.Default.Check, stringResource(R.string.message_status_sent), diff --git a/feature/chat/src/main/java/com/p2p/meshify/feature/chat/components/MessageList.kt b/feature/chat/src/main/java/com/p2p/meshify/feature/chat/components/MessageList.kt index b59fd321..5a17340a 100644 --- a/feature/chat/src/main/java/com/p2p/meshify/feature/chat/components/MessageList.kt +++ b/feature/chat/src/main/java/com/p2p/meshify/feature/chat/components/MessageList.kt @@ -120,10 +120,14 @@ fun MessageList( } } + val messageById = messages.associateBy { it.id } + itemsIndexed( messages, key = { _, m -> m.id } ) { index, message -> + val replyMessage = message.replyToId?.let { messageById[it] } + val attachments by produceState>( initialValue = emptyList(), key1 = message.id, @@ -165,6 +169,7 @@ fun MessageList( ) { MessageBubble( message = message, + replyMessage = replyMessage, attachments = attachments, peerName = peerName, isSelected = isSelected, diff --git a/feature/chat/src/test/java/com/p2p/meshify/feature/chat/ChatViewModelTest.kt b/feature/chat/src/test/java/com/p2p/meshify/feature/chat/ChatViewModelTest.kt deleted file mode 100644 index 583fad7d..00000000 --- a/feature/chat/src/test/java/com/p2p/meshify/feature/chat/ChatViewModelTest.kt +++ /dev/null @@ -1,1612 +0,0 @@ -package com.p2p.meshify.feature.chat - -import android.content.Context -import android.net.Uri -import android.util.Log -import androidx.lifecycle.SavedStateHandle -import com.p2p.meshify.core.common.R -import com.p2p.meshify.core.data.local.entity.MessageEntity -import com.p2p.meshify.core.data.repository.ChatRepositoryImpl -import com.p2p.meshify.domain.model.DeleteType -import com.p2p.meshify.domain.model.MessageType -import com.p2p.meshify.domain.model.TransportType -import com.p2p.meshify.domain.repository.IChatRepository -import com.p2p.meshify.domain.security.model.SecurityEvent -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.every -import io.mockk.mockk -import io.mockk.mockkStatic -import io.mockk.verify -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.test.advanceTimeBy -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.runCurrent -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.Rule -import org.junit.Test -import java.io.File - -/** - * Unit tests for [ChatViewModel]. - * - * Covers all critical paths: initialization, sending messages, attachment staging, - * deleting, searching, forward, upload progress, transport types, and edge cases. - * - * Uses MockK for mocking, UnconfinedTestDispatcher for immediate coroutine execution, - * and Turbine patterns via StateFlow.first() for flow assertions. - */ -@OptIn(ExperimentalCoroutinesApi::class) -class ChatViewModelTest { - - @get:Rule - val mainDispatcherRule = MainDispatcherRule() - - // --- Mocks --- - private val mockContext: Context = mockk(relaxed = true) - private val mockRepository: ChatRepositoryImpl = mockk(relaxed = true) - - // State flows that the test controls - private val messagesFlow = MutableStateFlow>(emptyList()) - private val onlinePeersFlow = MutableStateFlow>(emptySet()) - private val securityEventsFlow = - MutableSharedFlow(replay = 0, extraBufferCapacity = 10) - - // Default test values - private val testPeerId = "test-peer-123" - private val testPeerName = "Test Peer" - - private lateinit var viewModel: ChatViewModel - - @Before - fun setup() { - // Silence Android Log calls inside Logger - mockkStatic(Log::class) - every { Log.e(any(), any(), any()) } returns 0 - every { Log.d(any(), any()) } returns 0 - every { Log.i(any(), any()) } returns 0 - every { Log.w(any(), any()) } returns 0 - - // String resources used in the ViewModel - every { mockContext.getString(R.string.default_peer_name) } returns "Peer" - every { mockContext.getString(R.string.error_peer_offline_message_saved) } returns "Peer offline - message saved" - every { mockContext.getString(R.string.error_network_retry) } returns "Network error - try again" - every { mockContext.getString(R.string.error_message_send_failed, any()) } returns "Send failed" - every { mockContext.getString(R.string.error_unknown) } returns "Unknown error" - every { mockContext.getString(R.string.error_file_send_failed, any()) } returns "File send failed" - every { mockContext.getString(R.string.chat_transport_ble_desc) } returns "Sent via Bluetooth" - every { mockContext.getString(R.string.chat_transport_multipath_desc) } returns "Sent via LAN + Bluetooth" - every { mockContext.getString(R.string.error_forward_partial, any(), any()) } returns "Forward partial failure" - - // Default repository mocks - every { mockRepository.getMessages(any()) } returns messagesFlow - every { mockRepository.onlinePeers } returns onlinePeersFlow - every { mockRepository.securityEvents } returns securityEventsFlow - every { mockRepository.getMessagesPaged(any(), any(), any()) } returns flowOf(emptyList()) - } - - @After - fun tearDown() { - // Clean up any ViewModel scope - if (::viewModel.isInitialized) { - viewModel.viewModelScope.cancel() - } - } - - // ==================== Helper: Create ViewModel ==================== - - /** - * Creates a [ChatViewModel] with the given [SavedStateHandle]. - * Sets up the mock repository's [getMessages] and [getMessagesPaged] before construction - * so that init block coroutines find proper mocks. - */ - private fun createViewModel(savedStateHandle: SavedStateHandle = SavedStateHandle()): ChatViewModel { - return ChatViewModel( - context = mockContext, - savedStateHandle = savedStateHandle, - repository = mockRepository - ) - } - - /** - * Creates a [ChatViewModel] with peerId and peerName set in the [SavedStateHandle]. - */ - private fun createViewModelWithPeer( - peerId: String = testPeerId, - peerName: String = testPeerName - ): ChatViewModel { - val savedStateHandle = SavedStateHandle( - mapOf("peerId" to peerId, "peerName" to peerName) - ) - return createViewModel(savedStateHandle) - } - - // =================================================================== - // 1. INITIALIZATION - // =================================================================== - - @Test - fun `peerId and peerName loaded from SavedStateHandle`() { - viewModel = createViewModelWithPeer() - assertEquals(testPeerId, viewModel.peerId) - assertEquals(testPeerName, viewModel.peerName) - } - - @Test - fun `peerId defaults to empty string when SavedStateHandle lacks peerId`() { - val handle = SavedStateHandle(mapOf("peerName" to testPeerName)) - viewModel = createViewModel(handle) - assertEquals("", viewModel.peerId) - } - - @Test - fun `peerName defaults to string resource when SavedStateHandle lacks peerName`() { - val handle = SavedStateHandle(mapOf("peerId" to testPeerId)) - viewModel = createViewModel(handle) - // mockContext.getString(R.string.default_peer_name) returns "Peer" - assertEquals("Peer", viewModel.peerName) - } - - @Test - fun `init collects messages from repository`() = runTest { - viewModel = createViewModelWithPeer() - advanceUntilIdle() - - // Initially empty - assertTrue(viewModel.uiState.value.messages.isEmpty()) - assertFalse(viewModel.uiState.value.isLoading) - - // Emit messages via the flow - val testMessages = listOf( - testMessage(id = "msg-1", text = "Hello", chatId = testPeerId), - testMessage(id = "msg-2", text = "World", chatId = testPeerId) - ) - messagesFlow.value = testMessages - advanceUntilIdle() - - val state = viewModel.uiState.value - assertEquals(2, state.messages.size) - assertEquals("Hello", state.messages[0].text) - assertEquals("World", state.messages[1].text) - } - - @Test - fun `init collects online status for peer`() = runTest { - viewModel = createViewModelWithPeer() - advanceUntilIdle() - - assertFalse(viewModel.uiState.value.isOnline) - - onlinePeersFlow.value = setOf(testPeerId) - advanceUntilIdle() - assertTrue(viewModel.uiState.value.isOnline) - - onlinePeersFlow.value = emptySet() - advanceUntilIdle() - assertFalse(viewModel.uiState.value.isOnline) - } - - @Test - fun `init does not mark other peers as online`() = runTest { - viewModel = createViewModelWithPeer() - onlinePeersFlow.value = setOf("some-other-peer") - advanceUntilIdle() - - assertFalse(viewModel.uiState.value.isOnline) - } - - @Test - fun `securityEvents flow sets sendError when MESSAGE_SEND_FAILED emitted`() = runTest { - viewModel = createViewModelWithPeer() - advanceUntilIdle() - - securityEventsFlow.emit( - SecurityEvent.messageSendFailed( - messageId = "msg-1", - peerId = testPeerId, - reason = "Timeout" - ) - ) - advanceUntilIdle() - - assertNotNull(viewModel.uiState.value.sendError) - } - - // =================================================================== - // 2. INPUT & REPLY - // =================================================================== - - @Test - fun `onInputChanged updates inputText and draftText`() { - viewModel = createViewModelWithPeer() - viewModel.onInputChanged("Hello world") - - val state = viewModel.uiState.value - assertEquals("Hello world", state.inputText) - assertEquals("Hello world", state.draftText) - } - - @Test - fun `onInputChanged with empty string clears input and draft`() { - viewModel = createViewModelWithPeer() - viewModel.onInputChanged("Some text") - viewModel.onInputChanged("") - - val state = viewModel.uiState.value - assertEquals("", state.inputText) - assertEquals("", state.draftText) - } - - @Test - fun `restoreDraftText updates draftText without affecting inputText`() { - viewModel = createViewModelWithPeer() - viewModel.onInputChanged("Current input") - viewModel.restoreDraftText("Saved draft") - - val state = viewModel.uiState.value - assertEquals("Current input", state.inputText) - assertEquals("Saved draft", state.draftText) - } - - @Test - fun `setReplyTo sets replyTo message in state`() { - viewModel = createViewModelWithPeer() - val replyMessage = testMessage(id = "reply-msg", text = "Reply to this") - - viewModel.setReplyTo(replyMessage) - - assertEquals("reply-msg", viewModel.uiState.value.replyTo?.id) - assertEquals("Reply to this", viewModel.uiState.value.replyTo?.text) - } - - @Test - fun `setReplyTo with null clears reply`() { - viewModel = createViewModelWithPeer() - viewModel.setReplyTo(testMessage(id = "reply-msg")) - viewModel.setReplyTo(null) - - assertNull(viewModel.uiState.value.replyTo) - } - - // =================================================================== - // 3. SENDING MESSAGES - // =================================================================== - - @Test - fun `sendMessage with valid text calls repository sendMessage`() = runTest { - coEvery { mockRepository.sendMessage(any(), any(), any(), any()) } returns Result.success(Unit) - // Need messages flow to have data for transport tracking - messagesFlow.value = listOf(testMessage(id = "msg-sent", isFromMe = true, chatId = testPeerId)) - viewModel = createViewModelWithPeer() - viewModel.onInputChanged("Hello") - advanceUntilIdle() - - viewModel.sendMessage() - advanceUntilIdle() - - coVerify { mockRepository.sendMessage(testPeerId, testPeerName, "Hello", null) } - } - - @Test - fun `sendMessage with reply passes replyToId to repository`() = runTest { - coEvery { mockRepository.sendMessage(any(), any(), any(), any()) } returns Result.success(Unit) - messagesFlow.value = listOf(testMessage(id = "msg-sent", isFromMe = true, chatId = testPeerId)) - viewModel = createViewModelWithPeer() - viewModel.setReplyTo(testMessage(id = "reply-to")) - viewModel.onInputChanged("Reply text") - advanceUntilIdle() - - viewModel.sendMessage() - advanceUntilIdle() - - coVerify { mockRepository.sendMessage(testPeerId, testPeerName, "Reply text", "reply-to") } - } - - @Test - fun `sendMessage with attachments calls repository sendGroupedMessage`() = runTest { - coEvery { mockRepository.sendGroupedMessage(any(), any(), any(), any(), any()) } returns Result.success(Unit) - messagesFlow.value = listOf(testMessage(id = "msg-sent", isFromMe = true, chatId = testPeerId)) - viewModel = createViewModelWithPeer() - - // Stage an attachment - val uri = mockUri("content://test/image.jpg") - viewModel.stageAttachment(uri, byteArrayOf(1, 2, 3), MessageType.IMAGE) - advanceUntilIdle() - - viewModel.onInputChanged("With photo") - viewModel.sendMessage() - advanceUntilIdle() - - coVerify { - mockRepository.sendGroupedMessage( - peerId = testPeerId, - peerName = testPeerName, - caption = "With photo", - attachments = any(), - replyToId = null - ) - } - } - - @Test - fun `sendMessage on success clears input draft replyTo and isSending`() = runTest { - coEvery { mockRepository.sendMessage(any(), any(), any(), any()) } returns Result.success(Unit) - messagesFlow.value = listOf(testMessage(id = "msg-sent", isFromMe = true, chatId = testPeerId)) - viewModel = createViewModelWithPeer() - viewModel.setReplyTo(testMessage(id = "reply-to")) - viewModel.onInputChanged("Text to send") - advanceUntilIdle() - - viewModel.sendMessage() - advanceUntilIdle() - - val state = viewModel.uiState.value - assertEquals("", state.inputText) - assertEquals("", state.draftText) - assertNull(state.replyTo) - assertFalse(state.isSending) - } - - @Test - fun `sendMessage on success clears staged attachments`() = runTest { - coEvery { mockRepository.sendGroupedMessage(any(), any(), any(), any(), any()) } returns Result.success(Unit) - messagesFlow.value = listOf(testMessage(id = "msg-sent", isFromMe = true, chatId = testPeerId)) - viewModel = createViewModelWithPeer() - viewModel.stageAttachment(mockUri("content://test/img"), byteArrayOf(1), MessageType.IMAGE) - advanceUntilIdle() - assertEquals(1, viewModel.uiState.value.stagedAttachments.size) - - viewModel.onInputChanged("With file") - viewModel.sendMessage() - advanceUntilIdle() - - assertTrue(viewModel.uiState.value.stagedAttachments.isEmpty()) - } - - @Test - fun `sendMessage on failure restores input text and sets sendError`() = runTest { - coEvery { mockRepository.sendMessage(any(), any(), any(), any()) } returns Result.failure(Exception("Network error")) - messagesFlow.value = listOf(testMessage(id = "msg-sent", isFromMe = true, chatId = testPeerId)) - viewModel = createViewModelWithPeer() - viewModel.onInputChanged("Will fail") - advanceUntilIdle() - - viewModel.sendMessage() - advanceUntilIdle() - - val state = viewModel.uiState.value - assertEquals("Will fail", state.inputText) - assertNotNull(state.sendError) - assertFalse(state.isSending) - } - - @Test - fun `sendMessage with offline error shows peer offline message`() = runTest { - coEvery { mockRepository.sendMessage(any(), any(), any(), any()) } returns Result.failure(Exception("peer is offline")) - viewModel = createViewModelWithPeer() - viewModel.onInputChanged("Hi") - advanceUntilIdle() - - viewModel.sendMessage() - advanceUntilIdle() - - assertEquals("Peer offline - message saved", viewModel.uiState.value.sendError) - } - - @Test - fun `sendMessage with network error shows network retry message`() = runTest { - coEvery { mockRepository.sendMessage(any(), any(), any(), any()) } returns Result.failure(Exception("connection lost")) - viewModel = createViewModelWithPeer() - viewModel.onInputChanged("Hi") - advanceUntilIdle() - - viewModel.sendMessage() - advanceUntilIdle() - - assertEquals("Network error - try again", viewModel.uiState.value.sendError) - } - - @Test - fun `sendMessage with empty input does nothing`() = runTest { - coEvery { mockRepository.sendMessage(any(), any(), any(), any()) } returns Result.success(Unit) - viewModel = createViewModelWithPeer() - viewModel.onInputChanged("") - advanceUntilIdle() - - viewModel.sendMessage() - advanceUntilIdle() - - coVerify(exactly = 0) { mockRepository.sendMessage(any(), any(), any(), any()) } - } - - @Test - fun `sendMessage with blank input does nothing`() = runTest { - coEvery { mockRepository.sendMessage(any(), any(), any(), any()) } returns Result.success(Unit) - viewModel = createViewModelWithPeer() - viewModel.onInputChanged(" ") - advanceUntilIdle() - - viewModel.sendMessage() - advanceUntilIdle() - - coVerify(exactly = 0) { mockRepository.sendMessage(any(), any(), any(), any()) } - } - - @Test - fun `sendMessage while already sending is ignored`() = runTest { - // Use a deferred to keep the first send "in progress" - val sendDeferred = CompletableDeferred>() - coEvery { mockRepository.sendMessage(any(), any(), any(), any()) } returns sendDeferred.await() - - viewModel = createViewModelWithPeer() - viewModel.onInputChanged("First") - advanceUntilIdle() - - // First send starts (and suspends on deferred) - viewModel.sendMessage() - // isSending should now be true - assertTrue(viewModel.uiState.value.isSending) - - // Second send should be blocked by isSending check - viewModel.onInputChanged("Second") - viewModel.sendMessage() - - // Release the deferred to let the first send complete - sendDeferred.complete(Result.success(Unit)) - advanceUntilIdle() - - // Only one sendMessage call should have been made - coVerify(exactly = 1) { mockRepository.sendMessage(any(), any(), any(), any()) } - } - - @Test - fun `sendMessage within debounce window is ignored`() = runTest { - coEvery { mockRepository.sendMessage(any(), any(), any(), any()) } returns Result.success(Unit) - messagesFlow.value = listOf(testMessage(id = "msg-sent", isFromMe = true, chatId = testPeerId)) - viewModel = createViewModelWithPeer() - - viewModel.onInputChanged("First") - viewModel.sendMessage() - advanceUntilIdle() - - // Immediately send again (within 500ms debounce) - viewModel.onInputChanged("Second") - viewModel.sendMessage() - advanceUntilIdle() - - // Only the first send should have gone through - coVerify(exactly = 1) { mockRepository.sendMessage(any(), any(), "First", any()) } - } - - @Test - fun `sendMessage after debounce window succeeds`() = runTest { - coEvery { mockRepository.sendMessage(any(), any(), any(), any()) } returns Result.success(Unit) - messagesFlow.value = listOf(testMessage(id = "msg-sent", isFromMe = true, chatId = testPeerId)) - viewModel = createViewModelWithPeer() - - viewModel.onInputChanged("First") - viewModel.sendMessage() - advanceUntilIdle() - - // Wait past debounce window - advanceTimeBy(600) - runCurrent() - - viewModel.onInputChanged("Second") - viewModel.sendMessage() - advanceUntilIdle() - - coVerify(exactly = 2) { mockRepository.sendMessage(any(), any(), any(), any()) } - } - - @Test - fun `sendMessage records transport type in transportUsed map`() = runTest { - coEvery { mockRepository.sendMessage(any(), any(), any(), any()) } returns Result.success(Unit) - val sentMessage = testMessage(id = "msg-just-sent", isFromMe = true, chatId = testPeerId) - messagesFlow.value = listOf(sentMessage) - viewModel = createViewModelWithPeer() - - // Set up transport type provider - var currentTransport = TransportType.LAN - viewModel.setTransportTypeProvider { currentTransport } - - viewModel.onInputChanged("Track this") - viewModel.sendMessage() - advanceUntilIdle() - - val transportUsed = viewModel.uiState.value.transportUsed - assertTrue(transportUsed.containsKey("msg-just-sent")) - assertEquals(TransportType.LAN, transportUsed["msg-just-sent"]) - } - - @Test - fun `sendMessage records BLE transport type when provider returns BLE`() = runTest { - coEvery { mockRepository.sendMessage(any(), any(), any(), any()) } returns Result.success(Unit) - val sentMessage = testMessage(id = "msg-ble", isFromMe = true, chatId = testPeerId) - messagesFlow.value = listOf(sentMessage) - viewModel = createViewModelWithPeer() - viewModel.setTransportTypeProvider { TransportType.BLE } - - viewModel.onInputChanged("Via BLE") - viewModel.sendMessage() - advanceUntilIdle() - - assertEquals(TransportType.BLE, viewModel.uiState.value.transportUsed["msg-ble"]) - } - - @Test - fun `sendMessage clears sendError from previous failure`() = runTest { - // First send fails - coEvery { mockRepository.sendMessage(any(), any(), any(), any()) } returns Result.failure(Exception("error")) - viewModel = createViewModelWithPeer() - viewModel.onInputChanged("Fail") - viewModel.sendMessage() - advanceUntilIdle() - assertNotNull(viewModel.uiState.value.sendError) - - // Second send succeeds - coEvery { mockRepository.sendMessage(any(), any(), any(), any()) } returns Result.success(Unit) - messagesFlow.value = listOf(testMessage(id = "msg-2", isFromMe = true, chatId = testPeerId)) - viewModel.onInputChanged("Success") - viewModel.sendMessage() - advanceUntilIdle() - - // sendError should still be set from previous failure (the finally block doesn't clear it) - // Actually sendError is only cleared by explicit clearError() call - // But after success, isSending should be false - assertFalse(viewModel.uiState.value.isSending) - } - - // =================================================================== - // 4. ATTACHMENT STAGING - // =================================================================== - - @Test - fun `stageAttachment adds attachment to staged list`() = runTest { - viewModel = createViewModelWithPeer() - val uri = mockUri("content://test/1") - - viewModel.stageAttachment(uri, byteArrayOf(1, 2, 3), MessageType.IMAGE) - advanceUntilIdle() - - val staged = viewModel.uiState.value.stagedAttachments - assertEquals(1, staged.size) - assertEquals(uri, staged[0].uri) - assertEquals(MessageType.IMAGE, staged[0].type) - } - - @Test - fun `stageAttachment preserves raw bytes`() = runTest { - viewModel = createViewModelWithPeer() - val bytes = byteArrayOf(10, 20, 30) - - viewModel.stageAttachment(mockUri("content://test/1"), bytes, MessageType.FILE) - advanceUntilIdle() - - assertTrue(viewModel.uiState.value.stagedAttachments[0].bytes.contentEquals(bytes)) - } - - @Test - fun `stageAttachment limits to 10 attachments`() = runTest { - viewModel = createViewModelWithPeer() - - repeat(10) { index -> - viewModel.stageAttachment( - mockUri("content://test/$index"), - byteArrayOf(index.toByte()), - MessageType.IMAGE - ) - advanceUntilIdle() - } - assertEquals(10, viewModel.uiState.value.stagedAttachments.size) - - // 11th should be rejected - viewModel.stageAttachment( - mockUri("content://test/extra"), - byteArrayOf(99), - MessageType.IMAGE - ) - advanceUntilIdle() - assertEquals(10, viewModel.uiState.value.stagedAttachments.size) - } - - @Test - fun `stageAttachment handles different message types`() = runTest { - viewModel = createViewModelWithPeer() - - viewModel.stageAttachment(mockUri("content://test/1"), byteArrayOf(1), MessageType.IMAGE) - viewModel.stageAttachment(mockUri("content://test/2"), byteArrayOf(2), MessageType.VIDEO) - viewModel.stageAttachment(mockUri("content://test/3"), byteArrayOf(3), MessageType.FILE) - advanceUntilIdle() - - val staged = viewModel.uiState.value.stagedAttachments - assertEquals(3, staged.size) - assertEquals(MessageType.IMAGE, staged[0].type) - assertEquals(MessageType.VIDEO, staged[1].type) - assertEquals(MessageType.FILE, staged[2].type) - } - - @Test - fun `removeStagedAttachment removes matching attachment by uri`() = runTest { - viewModel = createViewModelWithPeer() - val uri1 = mockUri("content://test/1") - val uri2 = mockUri("content://test/2") - - viewModel.stageAttachment(uri1, byteArrayOf(1), MessageType.IMAGE) - viewModel.stageAttachment(uri2, byteArrayOf(2), MessageType.IMAGE) - advanceUntilIdle() - - viewModel.removeStagedAttachment(uri1) - advanceUntilIdle() - - val staged = viewModel.uiState.value.stagedAttachments - assertEquals(1, staged.size) - assertEquals(uri2, staged[0].uri) - } - - @Test - fun `removeStagedAttachment with non-existent uri does not change state`() = runTest { - viewModel = createViewModelWithPeer() - val uri = mockUri("content://test/1") - - viewModel.stageAttachment(uri, byteArrayOf(1), MessageType.IMAGE) - advanceUntilIdle() - - viewModel.removeStagedAttachment(mockUri("content://test/nonexistent")) - advanceUntilIdle() - - assertEquals(1, viewModel.uiState.value.stagedAttachments.size) - } - - @Test - fun `removeStagedAttachment from empty list does not crash`() = runTest { - viewModel = createViewModelWithPeer() - - viewModel.removeStagedAttachment(mockUri("content://test/nonexistent")) - advanceUntilIdle() - - assertTrue(viewModel.uiState.value.stagedAttachments.isEmpty()) - } - - @Test - fun `clearStagedAttachments empties the staged list`() = runTest { - viewModel = createViewModelWithPeer() - viewModel.stageAttachment(mockUri("content://test/1"), byteArrayOf(1), MessageType.IMAGE) - viewModel.stageAttachment(mockUri("content://test/2"), byteArrayOf(2), MessageType.VIDEO) - advanceUntilIdle() - assertEquals(2, viewModel.uiState.value.stagedAttachments.size) - - viewModel.clearStagedAttachments() - advanceUntilIdle() - - assertTrue(viewModel.uiState.value.stagedAttachments.isEmpty()) - } - - @Test - fun `clearStagedAttachments on empty list stays empty`() = runTest { - viewModel = createViewModelWithPeer() - - viewModel.clearStagedAttachments() - advanceUntilIdle() - - assertTrue(viewModel.uiState.value.stagedAttachments.isEmpty()) - } - - // =================================================================== - // 5. SEND IMAGE / VIDEO - // =================================================================== - - @Test - fun `sendImage delegates to repository`() = runTest { - coEvery { mockRepository.sendImage(any(), any(), any(), any(), any()) } returns Result.success(Unit) - viewModel = createViewModelWithPeer() - - viewModel.sendImage(byteArrayOf(1, 2, 3, 4), "jpg") - advanceUntilIdle() - - coVerify { mockRepository.sendImage(testPeerId, testPeerName, any(), "jpg", null) } - } - - @Test - fun `sendImage clears replyTo`() = runTest { - coEvery { mockRepository.sendImage(any(), any(), any(), any(), any()) } returns Result.success(Unit) - viewModel = createViewModelWithPeer() - viewModel.setReplyTo(testMessage(id = "reply-msg")) - advanceUntilIdle() - - viewModel.sendImage(byteArrayOf(1), "png") - advanceUntilIdle() - - assertNull(viewModel.uiState.value.replyTo) - } - - @Test - fun `sendVideo delegates to repository`() = runTest { - coEvery { mockRepository.sendVideo(any(), any(), any(), any(), any()) } returns Result.success(Unit) - viewModel = createViewModelWithPeer() - - viewModel.sendVideo(byteArrayOf(5, 6, 7, 8), "mp4") - advanceUntilIdle() - - coVerify { mockRepository.sendVideo(testPeerId, testPeerName, any(), "mp4", null) } - } - - @Test - fun `sendVideo clears replyTo`() = runTest { - coEvery { mockRepository.sendVideo(any(), any(), any(), any(), any()) } returns Result.success(Unit) - viewModel = createViewModelWithPeer() - viewModel.setReplyTo(testMessage(id = "reply-msg")) - advanceUntilIdle() - - viewModel.sendVideo(byteArrayOf(1), "avi") - advanceUntilIdle() - - assertNull(viewModel.uiState.value.replyTo) - } - - // =================================================================== - // 6. SEND FILE WITH PROGRESS - // =================================================================== - - @Test - fun `sendFileWithProgress initializes progress and cleans up on success`() = runTest { - coEvery { - mockRepository.sendFileWithProgress( - any(), any(), any(), any(), any(), any(), any(), any() - ) - } returns Result.success(Unit) - val testFile = mockk(relaxed = true) - every { testFile.length() } returns 1024L - viewModel = createViewModelWithPeer() - - viewModel.sendFileWithProgress("msg-file", testFile, MessageType.FILE) - advanceUntilIdle() - - val progress = viewModel.uploadProgress.first() - assertFalse(progress.containsKey("msg-file")) - } - - @Test - fun `sendFileWithProgress on failure sets uploadError and cleans up progress`() = runTest { - coEvery { - mockRepository.sendFileWithProgress( - any(), any(), any(), any(), any(), any(), any(), any() - ) - } returns Result.failure(Exception("Disk full")) - val testFile = mockk(relaxed = true) - every { testFile.length() } returns 1024L - viewModel = createViewModelWithPeer() - - viewModel.sendFileWithProgress("msg-fail", testFile, MessageType.FILE) - advanceUntilIdle() - - val state = viewModel.uiState.value - assertNotNull(state.uploadError) - - val progress = viewModel.uploadProgress.first() - assertFalse(progress.containsKey("msg-fail")) - } - - @Test - fun `sendFileWithProgress passes correct parameters to repository`() = runTest { - val progressSlot = mutableListOf<((Int) -> Unit)?>() - coEvery { - mockRepository.sendFileWithProgress( - any(), any(), any(), any(), any(), any(), any(), captureLambda() - ) - } answers { - val callback = arg<(Int) -> Unit>(7) - callback.invoke(50) // simulate 50% progress - Result.success(Unit) - } - val testFile = mockk(relaxed = true) - every { testFile.length() } returns 2048L - every { testFile.name } returns "document.pdf" - viewModel = createViewModelWithPeer() - - viewModel.sendFileWithProgress( - messageId = "msg-pdf", - file = testFile, - fileType = MessageType.DOCUMENT, - caption = "Report" - ) - advanceUntilIdle() - - coVerify { - mockRepository.sendFileWithProgress( - messageId = "msg-pdf", - peerId = testPeerId, - peerName = testPeerName, - file = testFile, - fileType = MessageType.DOCUMENT, - caption = "Report", - replyToId = null, - progressCallback = any() - ) - } - } - - @Test - fun `sendFileWithProgress exception in coroutine sets uploadError`() = runTest { - coEvery { - mockRepository.sendFileWithProgress( - any(), any(), any(), any(), any(), any(), any(), any() - ) - } throws RuntimeException("Unexpected crash") - val testFile = mockk(relaxed = true) - every { testFile.length() } returns 1024L - viewModel = createViewModelWithPeer() - - viewModel.sendFileWithProgress("msg-crash", testFile, MessageType.FILE) - advanceUntilIdle() - - assertNotNull(viewModel.uiState.value.uploadError) - } - - // =================================================================== - // 7. CANCEL UPLOAD - // =================================================================== - - @Test - fun `cancelUpload removes progress entry`() = runTest { - viewModel = createViewModelWithPeer() - // Directly set upload progress - viewModel.sendFileWithProgress( - "msg-cancel", mockk(relaxed = true).also { every { it.length() } returns 100L }, - MessageType.FILE - ) - advanceUntilIdle() - - viewModel.cancelUpload("msg-cancel") - - val progress = viewModel.uploadProgress.first() - assertFalse(progress.containsKey("msg-cancel")) - } - - @Test - fun `cancelUpload with non-existent messageId does nothing`() { - viewModel = createViewModelWithPeer() - - viewModel.cancelUpload("non-existent") - - assertTrue(viewModel.uploadProgress.value.isEmpty()) - } - - // =================================================================== - // 8. DELETE MESSAGES - // =================================================================== - - @Test - fun `deleteMessage delegates to repository`() = runTest { - coEvery { mockRepository.deleteMessage(any(), any()) } returns Result.success(Unit) - viewModel = createViewModelWithPeer() - - viewModel.deleteMessage("msg-to-delete", DeleteType.DELETE_FOR_EVERYONE) - advanceUntilIdle() - - coVerify { mockRepository.deleteMessage("msg-to-delete", DeleteType.DELETE_FOR_EVERYONE) } - } - - @Test - fun `deleteMessage with DELETE_FOR_ME delegates to repository`() = runTest { - coEvery { mockRepository.deleteMessage(any(), any()) } returns Result.success(Unit) - viewModel = createViewModelWithPeer() - - viewModel.deleteMessage("msg-to-delete-me", DeleteType.DELETE_FOR_ME) - advanceUntilIdle() - - coVerify { mockRepository.deleteMessage("msg-to-delete-me", DeleteType.DELETE_FOR_ME) } - } - - @Test - fun `deleteSelectedMessages deletes each selected message`() = runTest { - coEvery { mockRepository.deleteMessage(any(), any()) } returns Result.success(Unit) - viewModel = createViewModelWithPeer() - - viewModel.toggleMessageSelection("msg-1") - viewModel.toggleMessageSelection("msg-2") - viewModel.toggleMessageSelection("msg-3") - advanceUntilIdle() - assertEquals(3, viewModel.selectedMessages.value.size) - - viewModel.deleteSelectedMessages(DeleteType.DELETE_FOR_EVERYONE) - advanceUntilIdle() - - coVerify { mockRepository.deleteMessage("msg-1", DeleteType.DELETE_FOR_EVERYONE) } - coVerify { mockRepository.deleteMessage("msg-2", DeleteType.DELETE_FOR_EVERYONE) } - coVerify { mockRepository.deleteMessage("msg-3", DeleteType.DELETE_FOR_EVERYONE) } - } - - @Test - fun `deleteSelectedMessages clears selection after deletion`() = runTest { - coEvery { mockRepository.deleteMessage(any(), any()) } returns Result.success(Unit) - viewModel = createViewModelWithPeer() - viewModel.toggleMessageSelection("msg-1") - advanceUntilIdle() - - viewModel.deleteSelectedMessages(DeleteType.DELETE_FOR_EVERYONE) - advanceUntilIdle() - - assertTrue(viewModel.selectedMessages.value.isEmpty()) - } - - @Test - fun `deleteSelectedMessages with no selection does not call repository`() = runTest { - viewModel = createViewModelWithPeer() - - viewModel.deleteSelectedMessages(DeleteType.DELETE_FOR_ME) - advanceUntilIdle() - - coVerify(exactly = 0) { mockRepository.deleteMessage(any(), any()) } - } - - @Test - fun `addReaction delegates to repository`() = runTest { - coEvery { mockRepository.addReaction(any(), any()) } returns Result.success(Unit) - viewModel = createViewModelWithPeer() - - viewModel.addReaction("msg-1", "👍") - advanceUntilIdle() - - coVerify { mockRepository.addReaction("msg-1", "👍") } - } - - @Test - fun `addReaction with null removes reaction`() = runTest { - coEvery { mockRepository.addReaction(any(), any()) } returns Result.success(Unit) - viewModel = createViewModelWithPeer() - - viewModel.addReaction("msg-1", null) - advanceUntilIdle() - - coVerify { mockRepository.addReaction("msg-1", null) } - } - - // =================================================================== - // 9. SEARCH - // =================================================================== - - @Test - fun `startSearch sets isSearching true and clears previous results`() { - viewModel = createViewModelWithPeer() - - viewModel.startSearch() - - assertTrue(viewModel.isSearching.value) - assertEquals("", viewModel.searchQuery.value) - assertTrue(viewModel.searchResults.value.isEmpty()) - } - - @Test - fun `stopSearch resets all search state`() { - viewModel = createViewModelWithPeer() - viewModel.startSearch() - viewModel.updateSearchQuery("test") - assertTrue(viewModel.isSearching.value) - - viewModel.stopSearch() - - assertFalse(viewModel.isSearching.value) - assertEquals("", viewModel.searchQuery.value) - assertTrue(viewModel.searchResults.value.isEmpty()) - } - - @Test - fun `updateSearchQuery updates search query`() { - viewModel = createViewModelWithPeer() - viewModel.startSearch() - - viewModel.updateSearchQuery("hello") - - assertEquals("hello", viewModel.searchQuery.value) - } - - @Test - fun `searchInChat calls repository searchMessagesInChat after debounce`() = runTest { - every { mockRepository.searchMessagesInChat(any(), any()) } returns flowOf( - listOf(testMessage(id = "search-hit-1", text = "Found", chatId = testPeerId)) - ) - viewModel = createViewModelWithPeer() - - viewModel.startSearch() - viewModel.updateSearchQuery("Found") - advanceUntilIdle() - - // Debounce is 300ms, need to advance past it - advanceTimeBy(400) - runCurrent() - - // The debounced search should have triggered - verify { mockRepository.searchMessagesInChat(testPeerId, "Found") } - } - - @Test - fun `searchInChat with blank query does not call repository`() = runTest { - viewModel = createViewModelWithPeer() - - viewModel.startSearch() - viewModel.updateSearchQuery("") - advanceUntilIdle() - - advanceTimeBy(400) - runCurrent() - - verify(exactly = 0) { mockRepository.searchMessagesInChat(any(), any()) } - } - - @Test - fun `stopSearch cancels debounced search`() = runTest { - viewModel = createViewModelWithPeer() - - viewModel.startSearch() - viewModel.updateSearchQuery("test") - // Immediately stop search before debounce fires - viewModel.stopSearch() - - advanceTimeBy(400) - runCurrent() - - verify(exactly = 0) { mockRepository.searchMessagesInChat(any(), any()) } - } - - // =================================================================== - // 10. FORWARD MESSAGES - // =================================================================== - - @Test - fun `openForwardDialog sets dialog state with single message`() = runTest { - val messages = listOf( - testMessage(id = "msg-1", text = "Forward this", chatId = testPeerId, isFromMe = true), - testMessage(id = "msg-2", text = "Not this", chatId = testPeerId) - ) - messagesFlow.value = messages - viewModel = createViewModelWithPeer() - advanceUntilIdle() - - viewModel.openForwardDialog("msg-1") - advanceUntilIdle() - - val dialogState = viewModel.forwardDialogState.value - assertEquals(1, dialogState.messages.size) - assertEquals("msg-1", dialogState.messages[0].id) - } - - @Test - fun `openForwardDialog with non-existent message does nothing`() = runTest { - viewModel = createViewModelWithPeer() - advanceUntilIdle() - - viewModel.openForwardDialog("non-existent") - advanceUntilIdle() - - assertTrue(viewModel.forwardDialogState.value.messages.isEmpty()) - } - - @Test - fun `openForwardDialogForSelected with no selection does nothing`() = runTest { - viewModel = createViewModelWithPeer() - - viewModel.openForwardDialogForSelected() - advanceUntilIdle() - - assertTrue(viewModel.forwardDialogState.value.messages.isEmpty()) - } - - @Test - fun `openForwardDialogForSelected includes selected messages`() = runTest { - val messages = listOf( - testMessage(id = "msg-1", text = "First", chatId = testPeerId), - testMessage(id = "msg-2", text = "Second", chatId = testPeerId), - testMessage(id = "msg-3", text = "Third", chatId = testPeerId) - ) - messagesFlow.value = messages - viewModel = createViewModelWithPeer() - advanceUntilIdle() - - viewModel.toggleMessageSelection("msg-1") - viewModel.toggleMessageSelection("msg-3") - advanceUntilIdle() - - viewModel.openForwardDialogForSelected() - advanceUntilIdle() - - val dialogState = viewModel.forwardDialogState.value - assertEquals(2, dialogState.messages.size) - assertTrue(dialogState.messages.all { it.id in listOf("msg-1", "msg-3") }) - } - - @Test - fun `togglePeerSelection adds peer to selection`() = runTest { - viewModel = createViewModelWithPeer() - viewModel.openForwardDialog("msg-1") - advanceUntilIdle() - // Need a message in UI state for openForwardDialog to find - // But if there's no message, the dialog won't open - // Let's set up properly - messagesFlow.value = listOf(testMessage(id = "msg-1", chatId = testPeerId)) - // Recreate VM or re-trigger - - // Actually, we need to call openForwardDialog after messages are in state. - // Let me redo this properly - } - - // Re-doing the toggle tests with proper setup - @Test - fun `togglePeerSelection after opening dialog adds and removes peer`() = runTest { - messagesFlow.value = listOf(testMessage(id = "msg-1", chatId = testPeerId)) - viewModel = createViewModelWithPeer() - advanceUntilIdle() - - viewModel.openForwardDialog("msg-1") - advanceUntilIdle() - - viewModel.togglePeerSelection("peer-a") - advanceUntilIdle() - assertTrue(viewModel.forwardDialogState.value.selectedPeerIds.contains("peer-a")) - - viewModel.togglePeerSelection("peer-a") - advanceUntilIdle() - assertFalse(viewModel.forwardDialogState.value.selectedPeerIds.contains("peer-a")) - } - - @Test - fun `updateForwardSearchQuery updates query in dialog state`() { - viewModel = createViewModelWithPeer() - - viewModel.updateForwardSearchQuery("Alice") - - assertEquals("Alice", viewModel.forwardDialogState.value.searchQuery) - } - - @Test - fun `dismissForwardDialog resets dialog state`() = runTest { - messagesFlow.value = listOf(testMessage(id = "msg-1", chatId = testPeerId)) - viewModel = createViewModelWithPeer() - advanceUntilIdle() - - viewModel.openForwardDialog("msg-1") - advanceUntilIdle() - viewModel.togglePeerSelection("peer-a") - advanceUntilIdle() - assertTrue(viewModel.forwardDialogState.value.messages.isNotEmpty()) - - viewModel.dismissForwardDialog() - - val dialogState = viewModel.forwardDialogState.value - assertTrue(dialogState.messages.isEmpty()) - assertTrue(dialogState.selectedPeerIds.isEmpty()) - assertEquals("", dialogState.searchQuery) - } - - @Test - fun `forwardMessages with no selected peers does not call repository`() = runTest { - messagesFlow.value = listOf(testMessage(id = "msg-1", chatId = testPeerId)) - viewModel = createViewModelWithPeer() - advanceUntilIdle() - - viewModel.openForwardDialog("msg-1") - advanceUntilIdle() - - viewModel.forwardMessages(emptyList()) - advanceUntilIdle() - - coVerify(exactly = 0) { mockRepository.forwardMessage(any(), any()) } - } - - @Test - fun `forwardMessages with no messages does not call repository`() = runTest { - viewModel = createViewModelWithPeer() - - // Dialog not opened, so no messages in state - viewModel.togglePeerSelection("peer-a") - advanceUntilIdle() - - viewModel.forwardMessages(listOf("peer-a")) - advanceUntilIdle() - - coVerify(exactly = 0) { mockRepository.forwardMessage(any(), any()) } - } - - @Test - fun `forwardMessages calls repository for selected message and peers`() = runTest { - coEvery { mockRepository.forwardMessage(any(), any()) } returns Result.success(Unit) - messagesFlow.value = listOf(testMessage(id = "msg-1", chatId = testPeerId)) - viewModel = createViewModelWithPeer() - advanceUntilIdle() - - viewModel.openForwardDialog("msg-1") - advanceUntilIdle() - viewModel.togglePeerSelection("peer-a") - advanceUntilIdle() - - viewModel.forwardMessages(listOf("peer-a")) - advanceUntilIdle() - - coVerify { mockRepository.forwardMessage("msg-1", listOf("peer-a")) } - } - - // =================================================================== - // 11. MULTI-SELECT - // =================================================================== - - @Test - fun `toggleMessageSelection adds message to selection`() = runTest { - viewModel = createViewModelWithPeer() - - viewModel.toggleMessageSelection("msg-1") - advanceUntilIdle() - - assertTrue(viewModel.selectedMessages.value.contains("msg-1")) - } - - @Test - fun `toggleMessageSelection removes message if already selected`() = runTest { - viewModel = createViewModelWithPeer() - - viewModel.toggleMessageSelection("msg-1") - advanceUntilIdle() - assertTrue(viewModel.selectedMessages.value.contains("msg-1")) - - viewModel.toggleMessageSelection("msg-1") - advanceUntilIdle() - - assertFalse(viewModel.selectedMessages.value.contains("msg-1")) - } - - @Test - fun `toggleMessageSelection handles multiple selections`() = runTest { - viewModel = createViewModelWithPeer() - - viewModel.toggleMessageSelection("msg-1") - viewModel.toggleMessageSelection("msg-2") - viewModel.toggleMessageSelection("msg-3") - advanceUntilIdle() - - val selected = viewModel.selectedMessages.value - assertEquals(3, selected.size) - assertTrue(selected.containsAll(listOf("msg-1", "msg-2", "msg-3"))) - } - - @Test - fun `clearSelection empties selected messages`() { - viewModel = createViewModelWithPeer() - viewModel.toggleMessageSelection("msg-1") - viewModel.toggleMessageSelection("msg-2") - - viewModel.clearSelection() - - assertTrue(viewModel.selectedMessages.value.isEmpty()) - } - - @Test - fun `isInSelectionMode reflects selection state`() = runTest { - viewModel = createViewModelWithPeer() - assertFalse(viewModel.isInSelectionMode) - - viewModel.toggleMessageSelection("msg-1") - advanceUntilIdle() - assertTrue(viewModel.isInSelectionMode) - - viewModel.clearSelection() - assertFalse(viewModel.isInSelectionMode) - } - - // =================================================================== - // 12. TRANSPORT TYPE - // =================================================================== - - @Test - fun `getTransportTypeLabel returns BLE label for BLE transport`() { - viewModel = createViewModelWithPeer() - assertEquals("Sent via Bluetooth", viewModel.getTransportTypeLabel(TransportType.BLE)) - } - - @Test - fun `getTransportTypeLabel returns Multipath label for BOTH transport`() { - viewModel = createViewModelWithPeer() - assertEquals("Sent via LAN + Bluetooth", viewModel.getTransportTypeLabel(TransportType.BOTH)) - } - - @Test - fun `getTransportTypeLabel returns empty string for LAN transport`() { - viewModel = createViewModelWithPeer() - assertEquals("", viewModel.getTransportTypeLabel(TransportType.LAN)) - } - - @Test - fun `setTransportTypeProvider affects transport recorded in message`() = runTest { - coEvery { mockRepository.sendMessage(any(), any(), any(), any()) } returns Result.success(Unit) - val sentMessage = testMessage(id = "msg-transport-test", isFromMe = true, chatId = testPeerId) - messagesFlow.value = listOf(sentMessage) - viewModel = createViewModelWithPeer() - - // Without provider, defaults to LAN - viewModel.onInputChanged("Default transport") - viewModel.sendMessage() - advanceUntilIdle() - assertEquals(TransportType.LAN, viewModel.uiState.value.transportUsed["msg-transport-test"]) - } - - // =================================================================== - // 13. ERROR HANDLING - // =================================================================== - - @Test - fun `clearError sets sendError to null`() { - viewModel = createViewModelWithPeer() - assertNull(viewModel.uiState.value.sendError) - - viewModel.clearError() - - assertNull(viewModel.uiState.value.sendError) - } - - @Test - fun `clearUploadError sets uploadError to null`() { - viewModel = createViewModelWithPeer() - assertNull(viewModel.uiState.value.uploadError) - - viewModel.clearUploadError() - - assertNull(viewModel.uiState.value.uploadError) - } - - // =================================================================== - // 14. LOAD MORE MESSAGES (PAGINATION) - // =================================================================== - - @Test - fun `loadMoreMessages fetches next page from repository`() = runTest { - viewModel = createViewModelWithPeer() - - val newMessages = listOf(testMessage(id = "older-1", chatId = testPeerId)) - every { mockRepository.getMessagesPaged(testPeerId, 50, 0) } returns flowOf(newMessages) - - // Reset pagination state via loadMoreMessages - viewModel.loadMoreMessages() - advanceUntilIdle() - - verify { mockRepository.getMessagesPaged(testPeerId, 50, 0) } - } - - @Test - fun `loadMoreMessages sets hasMoreMessages true when messages returned`() = runTest { - resetPaginationState() - val newMessages = listOf(testMessage(id = "older-1", chatId = testPeerId)) - every { mockRepository.getMessagesPaged(testPeerId, 50, 0) } returns flowOf(newMessages) - - viewModel = createViewModelWithPeer() - advanceUntilIdle() - - // First call during init returned empty => isAllMessagesLoaded = true - // So hasMoreMessages should be false initially - assertFalse(viewModel.uiState.value.hasMoreMessages) - } - - @Test - fun `loadMoreMessages sets hasMoreMessages false when no more messages`() = runTest { - every { mockRepository.getMessagesPaged(any(), any(), any()) } returns flowOf(emptyList()) - viewModel = createViewModelWithPeer() - advanceUntilIdle() - - assertFalse(viewModel.uiState.value.hasMoreMessages) - } - - // =================================================================== - // 15. GET ATTACHMENTS FOR MESSAGE - // =================================================================== - - @Test - fun `getAttachmentsForMessage fetches from repository when not cached`() = runTest { - coEvery { mockRepository.getMessageAttachments("group-1") } returns listOf( - testAttachment(id = "att-1", messageId = "group-1"), - testAttachment(id = "att-2", messageId = "group-1") - ) - viewModel = createViewModelWithPeer() - - val result = viewModel.getAttachmentsForMessage("group-1") - - assertEquals(2, result.size) - coVerify { mockRepository.getMessageAttachments("group-1") } - } - - @Test - fun `getAttachmentsForMessage uses cache on second call`() = runTest { - val attachments = listOf(testAttachment(id = "att-1", messageId = "group-1")) - coEvery { mockRepository.getMessageAttachments("group-1") } returns attachments - viewModel = createViewModelWithPeer() - - viewModel.getAttachmentsForMessage("group-1") - viewModel.getAttachmentsForMessage("group-1") - - coVerify(exactly = 1) { mockRepository.getMessageAttachments("group-1") } - } - - @Test - fun `getAttachmentsForMessage returns empty list for group with no attachments`() = runTest { - coEvery { mockRepository.getMessageAttachments("empty-group") } returns emptyList() - viewModel = createViewModelWithPeer() - - val result = viewModel.getAttachmentsForMessage("empty-group") - - assertTrue(result.isEmpty()) - } - - // =================================================================== - // 16. COPY SELECTED MESSAGES - // =================================================================== - - @Test - fun `copySelectedMessages with no selection does nothing`() = runTest { - viewModel = createViewModelWithPeer() - - viewModel.copySelectedMessages() - advanceUntilIdle() - - // Should not crash - assertTrue(viewModel.selectedMessages.value.isEmpty()) - } - - // =================================================================== - // 17. EDGE CASES - // =================================================================== - - @Test - fun `null peerId returns empty string`() { - val handle = SavedStateHandle(mapOf("peerName" to "Alice")) - // peerId is not in handle, defaults to "" - viewModel = createViewModel(handle) - - assertEquals("", viewModel.peerId) - assertFalse(viewModel.peerId.isEmpty()) // Wait, "" IS empty - // Actually let me verify it's "" - assertEquals("", viewModel.peerId) - } - - @Test - fun `uiState initial values are correct`() { - viewModel = createViewModelWithPeer() - val state = viewModel.uiState.value - - assertTrue(state.isLoading) // Will be set to false by init - assertTrue(state.messages.isEmpty()) - assertFalse(state.isOnline) - assertEquals("", state.inputText) - assertEquals("", state.draftText) - assertNull(state.replyTo) - assertTrue(state.stagedAttachments.isEmpty()) - assertFalse(state.hasMoreMessages) - assertFalse(state.isLoadingMore) - assertFalse(state.isSending) - assertNull(state.sendError) - assertNull(state.uploadError) - assertTrue(state.transportUsed.isEmpty()) - } - - @Test - fun `sendMessage with only attachments and no text calls sendGroupedMessage`() = runTest { - coEvery { mockRepository.sendGroupedMessage(any(), any(), any(), any(), any()) } returns Result.success(Unit) - messagesFlow.value = listOf(testMessage(id = "msg-sent", isFromMe = true, chatId = testPeerId)) - viewModel = createViewModelWithPeer() - - viewModel.stageAttachment(mockUri("content://test/img"), byteArrayOf(1), MessageType.IMAGE) - advanceUntilIdle() - viewModel.onInputChanged("") // No text - - viewModel.sendMessage() - advanceUntilIdle() - - coVerify { - mockRepository.sendGroupedMessage( - peerId = testPeerId, - peerName = testPeerName, - caption = "", - attachments = any(), - replyToId = null - ) - } - } - - @Test - fun `multiple stageAttachment calls with concurrent access respect 10 limit`() = runTest { - viewModel = createViewModelWithPeer() - - repeat(10) { index -> - viewModel.stageAttachment( - mockUri("content://test/$index"), - byteArrayOf(index.toByte()), - MessageType.IMAGE - ) - advanceUntilIdle() - } - assertEquals(10, viewModel.uiState.value.stagedAttachments.size) - - // Try 5 more - repeat(5) { index -> - viewModel.stageAttachment( - mockUri("content://test/extra-$index"), - byteArrayOf(99), - MessageType.IMAGE - ) - advanceUntilIdle() - } - - assertEquals(10, viewModel.uiState.value.stagedAttachments.size) - } - - @Test - fun `forwardMessages isForwarding state set while forwarding`() = runTest { - // Use a deferred to pause forwarding mid-way - val forwardDeferred = CompletableDeferred>() - coEvery { mockRepository.forwardMessage(any(), any()) } returns forwardDeferred.await() - messagesFlow.value = listOf(testMessage(id = "msg-1", chatId = testPeerId)) - viewModel = createViewModelWithPeer() - advanceUntilIdle() - - viewModel.openForwardDialog("msg-1") - advanceUntilIdle() - viewModel.togglePeerSelection("peer-a") - advanceUntilIdle() - - viewModel.forwardMessages(listOf("peer-a")) - // Not advancing — forwarding is in progress - - assertTrue(viewModel.forwardDialogState.value.isForwarding) - - forwardDeferred.complete(Result.success(Unit)) - advanceUntilIdle() - } - - @Test - fun `clearError after sendError from securityEvent clears error`() = runTest { - viewModel = createViewModelWithPeer() - advanceUntilIdle() - - securityEventsFlow.emit( - SecurityEvent.messageSendFailed("msg-1", testPeerId, "Fail") - ) - advanceUntilIdle() - assertNotNull(viewModel.uiState.value.sendError) - - viewModel.clearError() - - assertNull(viewModel.uiState.value.sendError) - } - - // ==================== Internal Helpers ==================== - - /** - * Resets pagination state via reflection so tests can control loadMoreMessages behavior. - */ - private fun resetPaginationState() { - try { - val currentPageField = ChatViewModel::class.java.getDeclaredField("currentPage") - currentPageField.isAccessible = true - currentPageField.setInt(viewModel, 0) - - val isAllMessagesLoadedField = ChatViewModel::class.java.getDeclaredField("isAllMessagesLoaded") - isAllMessagesLoadedField.isAccessible = true - isAllMessagesLoadedField.setBoolean(viewModel, false) - - val allMessagesField = ChatViewModel::class.java.getDeclaredField("allMessages") - allMessagesField.isAccessible = true - @Suppress("UNCHECKED_CAST") - val allMessages = allMessagesField.get(viewModel) as ArrayDeque - allMessages.clear() - } catch (e: Exception) { - // Reflection failed — skip - } - } -} diff --git a/feature/discovery/build.gradle.kts b/feature/discovery/build.gradle.kts index 538c4f4b..d079443d 100644 --- a/feature/discovery/build.gradle.kts +++ b/feature/discovery/build.gradle.kts @@ -7,7 +7,7 @@ plugins { android { namespace = "com.p2p.meshify.feature.discovery" - compileSdk = 36 + compileSdk = 37 defaultConfig { minSdk = 26 diff --git a/feature/discovery/src/main/java/com/p2p/meshify/feature/discovery/DiscoveryScreen.kt b/feature/discovery/src/main/java/com/p2p/meshify/feature/discovery/DiscoveryScreen.kt index 320a9151..63357a10 100644 --- a/feature/discovery/src/main/java/com/p2p/meshify/feature/discovery/DiscoveryScreen.kt +++ b/feature/discovery/src/main/java/com/p2p/meshify/feature/discovery/DiscoveryScreen.kt @@ -25,12 +25,15 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -57,8 +60,16 @@ fun DiscoveryScreen( ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val listState = rememberLazyListState() + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(uiState.errorMessage) { + uiState.errorMessage?.let { + snackbarHostState.showSnackbar(it) + } + } Scaffold( + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, topBar = { TopAppBar( title = { diff --git a/feature/discovery/src/main/java/com/p2p/meshify/feature/discovery/DiscoveryViewModel.kt b/feature/discovery/src/main/java/com/p2p/meshify/feature/discovery/DiscoveryViewModel.kt index 45f376f5..af6ccb28 100644 --- a/feature/discovery/src/main/java/com/p2p/meshify/feature/discovery/DiscoveryViewModel.kt +++ b/feature/discovery/src/main/java/com/p2p/meshify/feature/discovery/DiscoveryViewModel.kt @@ -17,6 +17,12 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject +/** Discovery cleanup pause before restarting scan (ms) */ +private const val DISCOVERY_CLEANUP_DELAY_MS = 200L + +/** Time to wait for discovery scan results (ms) */ +private const val DISCOVERY_SCAN_DELAY_MS = 2000L + /** * UI State for the Discovery Screen. */ @@ -101,8 +107,7 @@ class DiscoveryViewModel @Inject constructor( _uiState.update { it.copy( - discoveredPeers = peerMap.values.toList(), - isSearching = peerMap.isNotEmpty() + discoveredPeers = peerMap.values.toList() ) } } @@ -132,8 +137,7 @@ class DiscoveryViewModel @Inject constructor( peerMap.remove(event.deviceId) _uiState.update { it.copy( - discoveredPeers = peerMap.values.toList(), - isSearching = peerMap.isNotEmpty() + discoveredPeers = peerMap.values.toList() ) } } @@ -155,10 +159,10 @@ class DiscoveryViewModel @Inject constructor( try { // Stop and restart discovery to find fresh devices transportManager.stopDiscoveryOnAll() - kotlinx.coroutines.delay(200) // Brief pause for cleanup + kotlinx.coroutines.delay(DISCOVERY_CLEANUP_DELAY_MS) // Brief pause for cleanup transportManager.startDiscoveryOnAll() // Give discovery some time to find devices - kotlinx.coroutines.delay(2000) + kotlinx.coroutines.delay(DISCOVERY_SCAN_DELAY_MS) } catch (e: kotlinx.coroutines.CancellationException) { throw e // Don't catch cancellation } catch (e: Exception) { diff --git a/feature/discovery/src/main/java/com/p2p/meshify/feature/discovery/OobVerificationDialog.kt b/feature/discovery/src/main/java/com/p2p/meshify/feature/discovery/OobVerificationDialog.kt deleted file mode 100644 index ffa90ea3..00000000 --- a/feature/discovery/src/main/java/com/p2p/meshify/feature/discovery/OobVerificationDialog.kt +++ /dev/null @@ -1,360 +0,0 @@ -package com.p2p.meshify.feature.discovery - -import androidx.compose.foundation.layout.* -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.CompareArrows -import androidx.compose.material.icons.filled.Nfc -import androidx.compose.material.icons.filled.QrCode -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.sp -import androidx.hilt.navigation.compose.hiltViewModel -import com.p2p.meshify.core.common.R -import com.p2p.meshify.core.ui.components.QrCodeDisplay -import com.p2p.meshify.core.ui.theme.MeshifyDesignSystem -import com.p2p.meshify.domain.security.model.OobVerificationMethod - -/** - * Dialog for Out-Of-Band (OOB) identity verification. - * - * Provides a UI for users to verify their peer's identity through: - * - **QR Code**: Display QR code for peer to scan - * - **SAS Comparison**: Compare 6-character Short Authentication Strings - * - **NFC**: Tap devices together (coming soon) - * - * Triggered from the chat or discovery screen during first session establishment - * to prevent Man-In-The-Middle (MITM) attacks. - * - * @param onVerified Callback when verification succeeds - * @param onDismiss Callback when user dismisses the dialog - * @param viewModel OOB verification ViewModel (injected via Hilt) - */ -@Composable -fun OobVerificationDialog( - onVerified: () -> Unit, - onDismiss: () -> Unit, - viewModel: OobVerificationViewModel = hiltViewModel() -) { - val uiState by viewModel.uiState.collectAsStateWithLifecycle() - - // Navigate to success when verified - LaunchedEffect(uiState.isVerified) { - if (uiState.isVerified) { - onVerified() - } - } - - AlertDialog( - onDismissRequest = onDismiss, - title = { - Text( - text = stringResource(R.string.oob_verify_title), - style = MaterialTheme.typography.titleLarge - ) - }, - text = { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - // Method selector tabs - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(MeshifyDesignSystem.Spacing.Xs) - ) { - FilterChip( - selected = uiState.selectedMethod == OobVerificationMethod.QR, - onClick = { viewModel.selectMethod(OobVerificationMethod.QR) }, - label = { Text(stringResource(R.string.oob_method_qr)) }, - leadingIcon = { - Icon( - imageVector = Icons.Default.QrCode, - contentDescription = stringResource(R.string.oob_method_qr), - modifier = Modifier.size(MeshifyDesignSystem.IconSizes.Small) - ) - }, - modifier = Modifier.weight(1f) - ) - - FilterChip( - selected = uiState.selectedMethod == OobVerificationMethod.SAS, - onClick = { viewModel.selectMethod(OobVerificationMethod.SAS) }, - label = { Text(stringResource(R.string.oob_method_sas)) }, - leadingIcon = { - Icon( - imageVector = Icons.AutoMirrored.Filled.CompareArrows, - contentDescription = stringResource(R.string.oob_method_sas), - modifier = Modifier.size(MeshifyDesignSystem.IconSizes.Small) - ) - }, - modifier = Modifier.weight(1f) - ) - - FilterChip( - selected = uiState.selectedMethod == OobVerificationMethod.NFC, - onClick = { viewModel.selectMethod(OobVerificationMethod.NFC) }, - label = { Text(stringResource(R.string.oob_method_nfc)) }, - leadingIcon = { - Icon( - imageVector = Icons.Default.Nfc, - contentDescription = stringResource(R.string.oob_method_nfc), - modifier = Modifier.size(MeshifyDesignSystem.IconSizes.Small) - ) - }, - enabled = false, - modifier = Modifier.weight(1f) - ) - } - - Spacer(modifier = Modifier.height(MeshifyDesignSystem.Spacing.Md)) - - // Content based on selected method - when (uiState.selectedMethod) { - OobVerificationMethod.QR -> { - if (uiState.isLoading) { - CircularProgressIndicator( - modifier = Modifier.padding(MeshifyDesignSystem.Spacing.Xl) - ) - } else { - QrCodeDisplay( - qrData = uiState.myPeerId, - title = stringResource(R.string.oob_qr_title), - subtitle = stringResource(R.string.oob_qr_subtitle) - ) - } - } - - OobVerificationMethod.SAS -> { - PeerIdComparisonContent( - myPeerId = uiState.myPeerId, - peerPeerId = uiState.peerPeerId, - isLoading = uiState.isLoading, - onMatch = { viewModel.verifyIdsMatch() }, - onMismatch = { viewModel.reportMismatch() } - ) - } - - OobVerificationMethod.NFC -> { - NfcPlaceholderContent() - } - } - - // Error message - uiState.verificationError?.let { errorRes -> - Spacer(modifier = Modifier.height(MeshifyDesignSystem.Spacing.Sm)) - - Surface( - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.errorContainer, - shape = MeshifyDesignSystem.Shapes.CardSmall - ) { - Text( - text = stringResource(errorRes), - modifier = Modifier.padding(MeshifyDesignSystem.Spacing.Md), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onErrorContainer, - textAlign = TextAlign.Center - ) - } - } - } - }, - confirmButton = { - if (uiState.isVerified) { - Button( - onClick = onDismiss, - shape = MeshifyDesignSystem.Shapes.Button - ) { - Text(stringResource(R.string.oob_btn_done)) - } - } else { - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.dialog_btn_close)) - } - } - }, - dismissButton = { - TextButton( - onClick = { - viewModel.reset() - onDismiss() - } - ) { - Text(stringResource(R.string.dialog_btn_cancel)) - } - }, - shape = MeshifyDesignSystem.Shapes.Dialog - ) -} - -/** - * Peer ID comparison content — inline rendering to avoid nested AlertDialogs. - * - * Displays the local peer ID and allows the user to compare with the peer's ID. - */ -@Composable -private fun PeerIdComparisonContent( - myPeerId: String, - peerPeerId: String?, - isLoading: Boolean, - onMatch: () -> Unit, - onMismatch: () -> Unit -) { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = stringResource(R.string.oob_peer_id_instruction), - style = MaterialTheme.typography.bodyMedium, - textAlign = TextAlign.Center - ) - - Spacer(modifier = Modifier.height(MeshifyDesignSystem.Spacing.Md)) - - // Your Peer ID - Text( - text = stringResource(R.string.oob_peer_id_your), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - Text( - text = myPeerId, - style = MaterialTheme.typography.headlineSmall.copy( - fontFamily = FontFamily.Monospace, - letterSpacing = 2.sp, - fontWeight = FontWeight.Bold - ), - color = MaterialTheme.colorScheme.onSurface - ) - - Spacer(modifier = Modifier.height(MeshifyDesignSystem.Spacing.Sm)) - - // Peer Peer ID - if (peerPeerId != null) { - Text( - text = stringResource(R.string.oob_peer_id_contact), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - val isMatch = myPeerId.equals(peerPeerId, ignoreCase = true) - - Text( - text = peerPeerId, - style = MaterialTheme.typography.headlineSmall.copy( - fontFamily = FontFamily.Monospace, - letterSpacing = 2.sp, - fontWeight = FontWeight.Bold - ), - color = if (isMatch) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error - ) - - // Match/mismatch indicator - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(top = MeshifyDesignSystem.Spacing.Xs) - ) { - Text( - text = if (isMatch) - stringResource(R.string.oob_peer_ids_match) - else - stringResource(R.string.oob_peer_ids_differ), - style = MaterialTheme.typography.labelMedium, - color = if (isMatch) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error, - fontWeight = FontWeight.Medium - ) - } - - Spacer(modifier = Modifier.height(MeshifyDesignSystem.Spacing.Md)) - - // Action buttons - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy( - MeshifyDesignSystem.Spacing.Sm, - Alignment.CenterHorizontally - ) - ) { - Button( - onClick = onMatch, - shape = MeshifyDesignSystem.Shapes.Button, - modifier = Modifier.weight(1f) - ) { - Text(stringResource(R.string.oob_peer_id_confirm_match)) - } - - OutlinedButton( - onClick = onMismatch, - shape = MeshifyDesignSystem.Shapes.Button, - modifier = Modifier.weight(1f) - ) { - Text( - text = stringResource(R.string.oob_peer_id_report_mismatch), - color = MaterialTheme.colorScheme.error - ) - } - } - } else if (isLoading) { - CircularProgressIndicator( - modifier = Modifier.padding(MeshifyDesignSystem.Spacing.Md) - ) - } else { - Text( - text = stringResource(R.string.oob_peer_id_waiting), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = MeshifyDesignSystem.Spacing.Xs) - ) - - Spacer(modifier = Modifier.height(MeshifyDesignSystem.Spacing.Sm)) - - CircularProgressIndicator() - } - } -} - -/** - * Placeholder for NFC verification (not yet implemented). - */ -@Composable -private fun NfcPlaceholderContent() { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(MeshifyDesignSystem.Spacing.Xl), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Icon( - imageVector = Icons.Default.Nfc, - contentDescription = stringResource(R.string.oob_method_nfc), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(MeshifyDesignSystem.IconSizes.XL) - ) - - Spacer(modifier = Modifier.height(MeshifyDesignSystem.Spacing.Md)) - - Text( - text = stringResource(R.string.oob_nfc_title), - style = MaterialTheme.typography.titleMedium, - textAlign = TextAlign.Center - ) - - Spacer(modifier = Modifier.height(MeshifyDesignSystem.Spacing.Xs)) - - Text( - text = stringResource(R.string.oob_nfc_subtitle), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center - ) - } -} diff --git a/feature/discovery/src/main/java/com/p2p/meshify/feature/discovery/OobVerificationViewModel.kt b/feature/discovery/src/main/java/com/p2p/meshify/feature/discovery/OobVerificationViewModel.kt deleted file mode 100644 index a9d08012..00000000 --- a/feature/discovery/src/main/java/com/p2p/meshify/feature/discovery/OobVerificationViewModel.kt +++ /dev/null @@ -1,163 +0,0 @@ -package com.p2p.meshify.feature.discovery - -import androidx.annotation.StringRes -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.p2p.meshify.core.common.R -import com.p2p.meshify.core.util.Logger -import com.p2p.meshify.domain.security.model.OobVerificationMethod -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import javax.inject.Inject - -/** - * UI state for OOB verification flow. - * - * @property isLoading Whether verification data is being generated - * @property myPeerId Local device peer ID for out-of-band comparison - * @property peerPeerId Peer's device ID (null until received) - * @property isVerified Whether the peer identity has been confirmed - * @property verificationError String resource ID if verification failed - * @property selectedMethod Currently selected verification method - */ -data class OobVerificationUiState( - val isLoading: Boolean = false, - val myPeerId: String = "", - val peerPeerId: String? = null, - val isVerified: Boolean = false, - @field:StringRes val verificationError: Int? = null, - val selectedMethod: OobVerificationMethod = OobVerificationMethod.QR -) - -/** - * ViewModel for Out-Of-Band (OOB) identity verification. - * - * Manages the OOB verification flow where users verify each other's identity - * by comparing peer IDs to ensure they are connecting to the intended device. - * - * Flow: - * 1. Display local peer ID as QR code or text for manual comparison - * 2. Receive peer's verification data (peer ID) - * 3. User confirms match or reports mismatch - * 4. If verified, the peer is trusted; if not, connection may be compromised - */ -@HiltViewModel -class OobVerificationViewModel @Inject constructor() : ViewModel() { - - private val _uiState = MutableStateFlow(OobVerificationUiState()) - val uiState: StateFlow = _uiState.asStateFlow() - - init { - generateVerificationData() - } - - /** - * Generate local verification data (peer ID). - */ - private fun generateVerificationData() { - viewModelScope.launch { - _uiState.update { it.copy(isLoading = true, verificationError = null) } - - try { - // Generate a deterministic peer ID for demonstration. - // In production, inject SimplePeerIdProvider and use getDeviceId(). - val peerId = "PEER-${generateShortId()}" - - _uiState.update { state -> - state.copy( - isLoading = false, - myPeerId = peerId - ) - } - } catch (e: Exception) { - Logger.e("OobVerificationViewModel -> Failed to generate verification data", e) - _uiState.update { state -> - state.copy( - isLoading = false, - verificationError = R.string.oob_verification_error_generic - ) - } - } - } - } - - /** - * Select a verification method (QR, SAS, or NFC). - */ - fun selectMethod(method: OobVerificationMethod) { - _uiState.update { it.copy(selectedMethod = method) } - } - - /** - * Receive peer's peer ID for comparison. - */ - fun receivePeerId(peerId: String) { - _uiState.update { it.copy(peerPeerId = peerId, verificationError = null) } - } - - /** - * Verify that peer IDs match and update verification state. - * - * @return true if IDs match, false if they differ (possible MITM) - */ - fun verifyIdsMatch(): Boolean { - val state = _uiState.value - val isMatch = state.myPeerId.equals(state.peerPeerId ?: "", ignoreCase = true) - - if (isMatch) { - _uiState.update { state -> - state.copy( - isVerified = true, - verificationError = null - ) - } - } else { - _uiState.update { state -> - state.copy( - verificationError = R.string.oob_peer_ids_mismatch_mitm - ) - } - } - - return isMatch - } - - /** - * Report peer ID mismatch — possible MITM attack. - */ - fun reportMismatch() { - _uiState.update { state -> - state.copy( - isVerified = false, - verificationError = R.string.oob_peer_ids_differ_distrust - ) - } - } - - /** - * Reset verification state to start fresh. - */ - fun reset() { - _uiState.update { OobVerificationUiState() } - generateVerificationData() - } - - /** - * Clear any error messages without resetting verification state. - */ - fun clearError() { - _uiState.update { it.copy(verificationError = null) } - } - - /** - * Generate a short random ID for peer identification. - */ - private fun generateShortId(): String { - val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" - return (1..8).map { chars.random() }.joinToString("") - } -} \ No newline at end of file diff --git a/feature/help/build.gradle.kts b/feature/help/build.gradle.kts deleted file mode 100644 index 75fe81bb..00000000 --- a/feature/help/build.gradle.kts +++ /dev/null @@ -1,51 +0,0 @@ -plugins { - alias(libs.plugins.android.library) - alias(libs.plugins.kotlin.compose) -} - -android { - namespace = "com.p2p.meshify.feature.help" - compileSdk = 36 - - defaultConfig { - minSdk = 26 - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - consumerProguardFiles("consumer-rules.pro") - } - - buildTypes { - release { - isMinifyEnabled = false - proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro" - ) - } - } - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_21 - targetCompatibility = JavaVersion.VERSION_21 - } - - buildFeatures { - compose = true - } -} - -dependencies { - implementation(project(":core:ui")) - implementation(project(":core:common")) - - implementation(libs.androidx.core.ktx) - implementation(libs.androidx.lifecycle.runtime.ktx) - implementation(libs.androidx.lifecycle.viewmodel.compose) - - implementation(platform(libs.androidx.compose.bom)) - implementation(libs.androidx.ui) - implementation(libs.androidx.ui.graphics) - implementation(libs.androidx.material3) - implementation(libs.androidx.material.icons.extended) - - debugImplementation(libs.androidx.ui.tooling) -} diff --git a/feature/help/consumer-rules.pro b/feature/help/consumer-rules.pro deleted file mode 100644 index e69de29b..00000000 diff --git a/feature/help/src/main/java/com/p2p/meshify/feature/help/AboutScreen.kt b/feature/help/src/main/java/com/p2p/meshify/feature/help/AboutScreen.kt deleted file mode 100644 index c1106be2..00000000 --- a/feature/help/src/main/java/com/p2p/meshify/feature/help/AboutScreen.kt +++ /dev/null @@ -1,273 +0,0 @@ -package com.p2p.meshify.feature.help - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.automirrored.filled.OpenInNew -import androidx.compose.material.icons.filled.Code -import androidx.compose.material.icons.filled.Description -import androidx.compose.material.icons.filled.Link -import androidx.compose.material.icons.filled.People -import androidx.compose.material.icons.filled.Security -import androidx.compose.material.icons.filled.Star -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextDecoration -import androidx.compose.ui.unit.dp -import com.p2p.meshify.core.common.R -import com.p2p.meshify.core.ui.theme.MeshifyDesignSystem - -/** - * About Screen - Displays app information, version, team, and links - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun AboutScreen( - appVersion: String = "1.0.0", - onNavigateBack: () -> Unit -) { - val uriHandler = LocalUriHandler.current - val scrollState = rememberScrollState() - - Scaffold( - topBar = { - TopAppBar( - title = { Text(stringResource(R.string.about_screen_title)) }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.about_back_desc) - ) - } - } - ) - } - ) { paddingValues -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) - .verticalScroll(scrollState) - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - // App Icon / Logo - Spacer(modifier = Modifier.height(32.dp)) - - Text( - text = stringResource(R.string.app_name), - style = MaterialTheme.typography.headlineLarge, - color = MaterialTheme.colorScheme.primary - ) - - Spacer(modifier = Modifier.height(8.dp)) - - // Version Badge - Surface( - color = MaterialTheme.colorScheme.primaryContainer, - shape = MeshifyDesignSystem.Shapes.Pill - ) { - Text( - text = stringResource(R.string.about_version_format, appVersion), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onPrimaryContainer, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) - ) - } - - Spacer(modifier = Modifier.height(32.dp)) - - // Description - Text( - text = stringResource(R.string.about_description), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(horizontal = 16.dp) - ) - - Spacer(modifier = Modifier.height(32.dp)) - - Divider() - - Spacer(modifier = Modifier.height(16.dp)) - - // Team Section - AboutSection( - title = stringResource(R.string.about_team), - icon = Icons.Default.People, - content = stringResource(R.string.about_team_desc) - ) - - Spacer(modifier = Modifier.height(16.dp)) - - // Features Section - AboutSection( - title = stringResource(R.string.about_features), - icon = Icons.Default.Star, - content = stringResource(R.string.about_features_desc) - ) - - Spacer(modifier = Modifier.height(16.dp)) - - // Tech Stack Section - AboutSection( - title = stringResource(R.string.about_tech), - icon = Icons.Default.Code, - content = stringResource(R.string.about_tech_desc) - ) - - Spacer(modifier = Modifier.height(16.dp)) - - Divider() - - Spacer(modifier = Modifier.height(16.dp)) - - // Links Section - AboutLink( - title = stringResource(R.string.about_github), - subtitle = stringResource(R.string.about_github_desc), - icon = Icons.Default.Link, - onClick = { - uriHandler.openUri("https://github.com/Yussefgafer/Meshify") - } - ) - - Spacer(modifier = Modifier.height(12.dp)) - - AboutLink( - title = stringResource(R.string.about_privacy), - subtitle = stringResource(R.string.about_privacy_desc), - icon = Icons.Default.Security, - onClick = { - // Navigate to privacy policy - } - ) - - Spacer(modifier = Modifier.height(12.dp)) - - AboutLink( - title = stringResource(R.string.about_license), - subtitle = stringResource(R.string.about_license_desc), - icon = Icons.Default.Description, - onClick = { - // Show license - } - ) - - Spacer(modifier = Modifier.height(32.dp)) - - // Built with ❤️ - Text( - text = stringResource(R.string.about_made_with), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - Spacer(modifier = Modifier.height(8.dp)) - - Text( - text = stringResource(R.string.about_copyright), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - Spacer(modifier = Modifier.height(16.dp)) - } - } -} - -@Composable -private fun AboutSection( - title: String, - icon: ImageVector, - content: String -) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(24.dp) - ) - - Spacer(modifier = Modifier.width(12.dp)) - - Text( - text = title, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface - ) - } - - Spacer(modifier = Modifier.height(8.dp)) - - Text( - text = content, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(start = 36.dp) - ) -} - -@Composable -private fun AboutLink( - title: String, - subtitle: String, - icon: ImageVector, - onClick: () -> Unit -) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding(vertical = 12.dp, horizontal = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(24.dp) - ) - - Spacer(modifier = Modifier.width(16.dp)) - - Column(modifier = Modifier.weight(1f)) { - Text( - text = title, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.primary, - textDecoration = TextDecoration.Underline - ) - - Spacer(modifier = Modifier.height(4.dp)) - - Text( - text = subtitle, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - - Icon( - imageVector = Icons.AutoMirrored.Filled.OpenInNew, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(20.dp) - ) - } -} diff --git a/feature/help/src/main/java/com/p2p/meshify/feature/help/HelpScreen.kt b/feature/help/src/main/java/com/p2p/meshify/feature/help/HelpScreen.kt deleted file mode 100644 index 62ef874f..00000000 --- a/feature/help/src/main/java/com/p2p/meshify/feature/help/HelpScreen.kt +++ /dev/null @@ -1,213 +0,0 @@ -package com.p2p.meshify.feature.help - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.Build -import androidx.compose.material.icons.filled.Lightbulb -import androidx.compose.material.icons.filled.Security -import androidx.compose.material.icons.filled.Wifi -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import com.p2p.meshify.core.ui.theme.MeshifyDesignSystem - -/** - * Help & FAQ Screen - Provides help topics and frequently asked questions - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun HelpScreen( - onNavigateBack: () -> Unit -) { - val uriHandler = LocalUriHandler.current - - Scaffold( - topBar = { - TopAppBar( - title = { Text(stringResource(R.string.help_screen_title)) }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.help_back_desc) - ) - } - } - ) - } - ) { paddingValues -> - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - // Getting Started Section - item { - HelpSection( - title = stringResource(R.string.help_section_getting_started), - icon = Icons.Default.Lightbulb, - items = listOf( - stringResource(R.string.help_q1), - stringResource(R.string.help_q2), - stringResource(R.string.help_q3) - ) - ) - } - - // Connectivity Section - item { - HelpSection( - title = stringResource(R.string.help_section_connectivity), - icon = Icons.Default.Wifi, - items = listOf( - stringResource(R.string.help_q4), - stringResource(R.string.help_q5) - ) - ) - } - - // Privacy & Security Section - item { - HelpSection( - title = stringResource(R.string.help_section_privacy), - icon = Icons.Default.Security, - items = listOf( - stringResource(R.string.help_q6), - stringResource(R.string.help_q7) - ) - ) - } - - // Troubleshooting Section - item { - HelpSection( - title = stringResource(R.string.help_section_troubleshooting), - icon = Icons.Default.Build, - items = listOf( - stringResource(R.string.help_q8), - stringResource(R.string.help_q9) - ) - ) - } - - // About Section - item { - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.primaryContainer - ) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = stringResource(R.string.help_about_title), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onPrimaryContainer - ) - - Spacer(modifier = Modifier.height(8.dp)) - - Text( - text = stringResource(R.string.help_about_desc), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onPrimaryContainer - ) - - Spacer(modifier = Modifier.height(16.dp)) - - Button( - onClick = { /* Navigate to About Screen */ }, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary - ) - ) { - Text(stringResource(R.string.help_btn_about)) - } - } - } - } - } - } -} - -@Composable -private fun HelpSection( - title: String, - icon: ImageVector, - items: List -) { - Card( - modifier = Modifier.fillMaxWidth() - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(24.dp) - ) - - Spacer(modifier = Modifier.width(12.dp)) - - Text( - text = title, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface - ) - } - - Spacer(modifier = Modifier.height(16.dp)) - - items.forEachIndexed { index, item -> - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), - verticalAlignment = Alignment.Top - ) { - Text( - text = "•", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.width(16.dp) - ) - - Text( - text = item, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.weight(1f) - ) - } - - if (index < items.lastIndex) { - Spacer(modifier = Modifier.height(8.dp)) - } - } - } - } -} diff --git a/feature/help/src/main/res/values/strings.xml b/feature/help/src/main/res/values/strings.xml deleted file mode 100644 index 0ce7e138..00000000 --- a/feature/help/src/main/res/values/strings.xml +++ /dev/null @@ -1,54 +0,0 @@ - - - - Help & FAQ - Back - - - Getting Started - Connectivity - Privacy & Security - Troubleshooting - - - How to start chatting? Open the app and tap the + button to discover nearby devices. Once connected, tap on a peer to start chatting. - How to send messages? Type your message in the text field at the bottom and tap Send. You can also attach images, videos, or files. - How to change my name? Go to Settings → Display Name, enter your new name, and tap Save. - - - Why can\'t I find peers? Ensure Wi-Fi is enabled and both devices are on the same network. Make sure you\'re visible in Settings. - How does P2P work? Messages are sent directly between devices without any server. All communication happens over local network. - - - Are my messages private? Messages are sent directly peer-to-peer. For enhanced privacy, avoid using on public networks. - Can I hide from discovery? Yes, go to Settings → Privacy and turn off \"Visible to Others\". - - - Messages not sending? Check your network connection. Failed messages are queued and will retry automatically. - App crashing? Try clearing app data in Settings → Storage → Clear Data, or reinstall the app. - - - About Meshify - Meshify is a decentralized P2P messaging app built with privacy in mind. - Learn More - Report Issue - - - About - Back - A decentralized peer-to-peer messaging app for secure, private communication without servers. - Development Team - Built by Jo with contributions from the open-source community. - Key Features - P2P messaging, LAN discovery, offline queue, multi-attachment support, and Material 3 design. - Technology Stack - Kotlin, Jetpack Compose, Material 3, Room Database, Coroutines & Flow. - GitHub Repository - View source code, report issues, and contribute - Privacy Policy - Learn how we protect your data - License - MIT License - Open Source - Built with ❤️ for privacy - © 2026 Meshify. All rights reserved. - diff --git a/feature/home/build.gradle.kts b/feature/home/build.gradle.kts index 11a04a5a..dcfb7f51 100644 --- a/feature/home/build.gradle.kts +++ b/feature/home/build.gradle.kts @@ -7,7 +7,7 @@ plugins { android { namespace = "com.p2p.meshify.feature.home" - compileSdk = 36 + compileSdk = 37 defaultConfig { minSdk = 26 diff --git a/feature/home/src/main/java/com/p2p/meshify/feature/home/HomeComponents.kt b/feature/home/src/main/java/com/p2p/meshify/feature/home/HomeComponents.kt new file mode 100644 index 00000000..dfbceb00 --- /dev/null +++ b/feature/home/src/main/java/com/p2p/meshify/feature/home/HomeComponents.kt @@ -0,0 +1,135 @@ +package com.p2p.meshify.feature.home + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.p2p.meshify.core.ui.components.MeshifyAvatarWithOnline + +/** + * Expressive MD3E section header for the home screen. + * Tight padding, labelMedium, primary color in Bold, matching PixelPlayer pattern. + */ +@Composable +fun ExpressiveChatSectionHeader(title: String) { + Text( + text = title, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 12.dp, top = 16.dp, bottom = 8.dp) + ) +} + +/** + * Expressive MD3E chat list item. + * Individual surfaceContainer card with rounded corners (16dp), + * designed to be used inside PhysicsSwipeToDelete (with groupCornerRadius = 24.dp). + */ +@Composable +fun ExpressiveChatItem( + peerName: String, + lastMessage: String?, + timestamp: Long, + unreadCount: Int, + isOnline: Boolean, + avatarHash: String?, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Surface( + color = MaterialTheme.colorScheme.surfaceContainer, + shape = RoundedCornerShape(16.dp), + onClick = onClick, + modifier = modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier + .padding(12.dp) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + // Avatar (56.dp) + Box( + modifier = Modifier.size(56.dp), + contentAlignment = Alignment.Center + ) { + MeshifyAvatarWithOnline( + initials = (peerName.takeIf { it.isNotEmpty() } ?: "?"), + avatarHash = avatarHash, + isOnline = isOnline, + size = 56.dp + ) + } + + Spacer(Modifier.width(12.dp)) + + // Text content + Column(modifier = Modifier.weight(1f)) { + Text( + text = peerName, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Spacer(Modifier.height(4.dp)) + if (lastMessage != null) { + Text( + text = lastMessage, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + + Spacer(Modifier.width(8.dp)) + + // Trailing: time + unread badge + Column( + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = com.p2p.meshify.core.common.util.formatMessageTime(timestamp), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + if (unreadCount > 0) { + val displayText = if (unreadCount >= 99) "99+" else unreadCount.toString() + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.height(20.dp) + ) { + Text( + text = displayText, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimary, + fontWeight = FontWeight.Bold + ) + } + } + } + } + } +} diff --git a/feature/home/src/main/java/com/p2p/meshify/feature/home/RecentChatsScreen.kt b/feature/home/src/main/java/com/p2p/meshify/feature/home/RecentChatsScreen.kt index 2382ec54..caf5a3f4 100644 --- a/feature/home/src/main/java/com/p2p/meshify/feature/home/RecentChatsScreen.kt +++ b/feature/home/src/main/java/com/p2p/meshify/feature/home/RecentChatsScreen.kt @@ -1,15 +1,19 @@ package com.p2p.meshify.feature.home -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Error -import androidx.compose.material.icons.filled.Image -import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.* @@ -17,10 +21,6 @@ import androidx.compose.runtime.* import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics @@ -28,28 +28,16 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.p2p.meshify.core.common.R import com.p2p.meshify.core.data.local.entity.ChatEntity -import androidx.compose.material3.HorizontalDivider -import com.p2p.meshify.core.ui.components.MeshifyListItem -import com.p2p.meshify.core.ui.components.MeshifySectionHeader -import com.p2p.meshify.core.ui.components.MeshifyAvatarWithOnline -import com.p2p.meshify.core.ui.components.MeshifyPill import com.p2p.meshify.core.ui.components.PhysicsSwipeToDelete import com.p2p.meshify.core.ui.components.MagneticChatItem import com.p2p.meshify.core.ui.components.DeleteConfirmationDialog import com.p2p.meshify.core.ui.components.ItemPosition import com.p2p.meshify.core.ui.theme.MeshifyDesignSystem -import java.text.SimpleDateFormat -import java.util.* -/** Search bar border alpha — consistent with ChatScreen search styling */ -private const val SEARCH_BAR_BORDER_ALPHA = 0.5f + /** Empty state text alpha */ private const val EMPTY_STATE_TEXT_ALPHA = 0.7f -/** Maximum unread count to display before switching to 99+ format */ -private const val MAX_UNREAD_DISPLAY = 99 -/** Display string for high unread counts */ -private const val MAX_UNREAD_DISPLAY_STR = "99+" /** * Enhanced Home Screen with LastChat-style Swipe-to-Delete and Grouping. @@ -63,8 +51,6 @@ fun RecentChatsScreen( onSettingsClick: () -> Unit ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle() - val context = LocalContext.current var chatToDelete by remember { mutableStateOf(null) } @@ -82,6 +68,9 @@ fun RecentChatsScreen( ) }, actions = { + IconButton(onClick = { /* placeholder — user has plans */ }) { + Icon(Icons.Default.Search, contentDescription = stringResource(R.string.content_desc_search)) + } IconButton(onClick = onSettingsClick) { Icon(Icons.Default.Settings, contentDescription = stringResource(R.string.home_content_desc_settings)) } @@ -115,106 +104,62 @@ fun RecentChatsScreen( ) } // Empty state - uiState.chats.isEmpty() && searchQuery.isBlank() -> { + uiState.chats.isEmpty() -> { EmptyChatsState(padding) } - // Content state - show search bar + chat list + // Content state - show chat list else -> { - Column(modifier = Modifier.fillMaxSize().padding(padding)) { - SearchBarSection( - query = searchQuery, - onQueryChange = { viewModel.updateSearchQuery(it) } - ) + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentPadding = PaddingValues( + start = MeshifyDesignSystem.Spacing.Md, + end = MeshifyDesignSystem.Spacing.Md, + bottom = MeshifyDesignSystem.Spacing.Xxl + ), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + item { + ExpressiveChatSectionHeader(stringResource(R.string.chats_recent_header)) + } - when { - uiState.chats.isEmpty() && searchQuery.isNotBlank() -> { - Box( - modifier = Modifier - .fillMaxWidth() - .weight(1f), - contentAlignment = Alignment.Center - ) { - Text( - text = stringResource(R.string.search_no_results), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } + itemsIndexed(uiState.chats, key = { _, chat -> chat.peerId }) { index, chat -> + val position = when { + uiState.chats.size == 1 -> ItemPosition.ONLY + index == 0 -> ItemPosition.FIRST + index == uiState.chats.size - 1 -> ItemPosition.LAST + else -> ItemPosition.MIDDLE } - else -> { - LazyColumn( - modifier = Modifier.weight(1f), - contentPadding = PaddingValues( - start = MeshifyDesignSystem.Spacing.Md, - end = MeshifyDesignSystem.Spacing.Md, - bottom = MeshifyDesignSystem.Spacing.Xxl - ), - verticalArrangement = Arrangement.spacedBy(2.dp) // Subtle separation like SectionBlock - ) { - item { - MeshifySectionHeader(stringResource(R.string.chats_recent_header)) - } - - itemsIndexed(uiState.chats, key = { _, chat -> chat.peerId }) { index, chat -> - val position = when { - uiState.chats.size == 1 -> ItemPosition.ONLY - index == 0 -> ItemPosition.FIRST - index == uiState.chats.size - 1 -> ItemPosition.LAST - else -> ItemPosition.MIDDLE - } - MagneticChatItem( - index = index, - swipingIndex = swipingIndex, - swipeProgress = swipeProgress - ) { - PhysicsSwipeToDelete( - onDelete = { chatToDelete = chat }, - position = position, - groupCornerRadius = 24.dp, - itemIndex = index, - onSwipeProgress = { idx, progress -> - swipingIndex = idx - swipeProgress = progress - } - ) { - val isOnline = uiState.onlinePeers.contains(chat.peerId) - MeshifyListItem( - headline = chat.peerName, - supporting = chat.lastMessage ?: stringResource(R.string.last_msg_none), - leadingContent = { - MeshifyAvatarWithOnline( - initials = (chat.peerName.takeIf { it.isNotEmpty() } ?: "?"), - isOnline = isOnline, - size = 56.dp - ) - }, - trailingContent = { - Column(horizontalAlignment = Alignment.End) { - Text( - text = formatRecentTime(chat.lastTimestamp), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - if (chat.unreadCount > 0) { - Spacer(Modifier.height(MeshifyDesignSystem.Spacing.Xxs)) - val displayCount = if (chat.unreadCount >= MAX_UNREAD_DISPLAY) "$MAX_UNREAD_DISPLAY_STR" else chat.unreadCount.toString() - UnreadBadge(displayCount) - } - if (isOnline) { - Spacer(Modifier.height(MeshifyDesignSystem.Spacing.Xxs)) - MeshifyPill(stringResource(R.string.chat_status_online), MaterialTheme.colorScheme.primaryContainer) - } - } - }, - onClick = { - viewModel.markChatAsRead(chat.peerId) - onChatClick(chat) - } - ) - } - } + MagneticChatItem( + index = index, + swipingIndex = swipingIndex, + swipeProgress = swipeProgress + ) { + PhysicsSwipeToDelete( + onDelete = { chatToDelete = chat }, + position = position, + groupCornerRadius = 24.dp, + itemIndex = index, + onSwipeProgress = { idx, progress -> + swipingIndex = idx + swipeProgress = progress } + ) { + val isOnline = uiState.onlinePeers.contains(chat.peerId) + ExpressiveChatItem( + peerName = chat.peerName, + lastMessage = chat.lastMessage, + timestamp = chat.lastTimestamp, + unreadCount = chat.unreadCount, + isOnline = isOnline, + avatarHash = null, + onClick = { + viewModel.markChatAsRead(chat.peerId) + onChatClick(chat) + } + ) } } } @@ -236,41 +181,6 @@ fun RecentChatsScreen( } } -@Composable -fun ChatListItem(chat: ChatEntity, isOnline: Boolean, onClick: () -> Unit) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { onClick() } - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - MeshifyAvatarWithOnline( - initials = (chat.peerName.takeIf { it.isNotEmpty() } ?: "?"), - isOnline = isOnline, - size = 52.dp - ) - Spacer(modifier = Modifier.width(16.dp)) - Column(modifier = Modifier.weight(1f)) { - Text(text = chat.peerName, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) - Row(verticalAlignment = Alignment.CenterVertically) { - val isImage = chat.lastMessage == stringResource(R.string.last_msg_image) - if (isImage) { - Icon(Icons.Default.Image, null, modifier = Modifier.size(14.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) - Spacer(modifier = Modifier.width(4.dp)) - } - Text( - text = chat.lastMessage ?: stringResource(R.string.last_msg_none), - style = MaterialTheme.typography.bodyMedium, - maxLines = 1, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - Text(text = formatRecentTime(chat.lastTimestamp), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) - } -} - @Composable fun EmptyChatsState(padding: PaddingValues) { Column( @@ -362,59 +272,4 @@ private fun ErrorState( } } -/** - * Search bar composable for the home screen. - * Uses Material3 TextField for a simple, standard search input. - */ -@Composable -private fun SearchBarSection( - query: String, - onQueryChange: (String) -> Unit -) { - OutlinedTextField( - value = query, - onValueChange = onQueryChange, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = MeshifyDesignSystem.Spacing.Md, vertical = MeshifyDesignSystem.Spacing.Sm), - placeholder = { Text(stringResource(R.string.search_chats_hint)) }, - leadingIcon = { - Icon( - imageVector = Icons.Default.Search, - contentDescription = stringResource(R.string.content_desc_search), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - }, - singleLine = true, - shape = MaterialTheme.shapes.large, - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.primary, - unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = SEARCH_BAR_BORDER_ALPHA) - ) - ) -} - -/** - * Unread badge composable showing the count of unread messages. - * Uses a pill-shaped Surface with high-contrast text. - */ -@Composable -private fun UnreadBadge(displayCount: String) { - Surface( - shape = MeshifyDesignSystem.Shapes.Pill, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.height(20.dp) - ) { - Text( - text = displayCount, - modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onPrimary, - fontWeight = FontWeight.Bold - ) - } -} -fun formatRecentTime(timestamp: Long): String { - return com.p2p.meshify.core.common.util.formatMessageTime(timestamp) -} diff --git a/feature/home/src/main/java/com/p2p/meshify/feature/home/RecentChatsViewModel.kt b/feature/home/src/main/java/com/p2p/meshify/feature/home/RecentChatsViewModel.kt index 6315b83c..40b9a859 100644 --- a/feature/home/src/main/java/com/p2p/meshify/feature/home/RecentChatsViewModel.kt +++ b/feature/home/src/main/java/com/p2p/meshify/feature/home/RecentChatsViewModel.kt @@ -41,6 +41,8 @@ class RecentChatsViewModel @Inject constructor( private val _searchQuery = MutableStateFlow("") val searchQuery: StateFlow = _searchQuery.asStateFlow() + private var loadJob: kotlinx.coroutines.Job? = null + init { loadChatsWithSearch() @@ -60,7 +62,8 @@ class RecentChatsViewModel @Inject constructor( * Debounced at 300ms to avoid excessive DB queries. */ private fun loadChatsWithSearch() { - viewModelScope.launch { + loadJob?.cancel() + loadJob = viewModelScope.launch { _searchQuery .debounce(SEARCH_DEBOUNCE_MS.milliseconds) .flatMapLatest { query -> @@ -105,7 +108,11 @@ class RecentChatsViewModel @Inject constructor( */ fun deleteChat(peerId: String) { viewModelScope.launch { - chatRepository.deleteChat(peerId) + try { + chatRepository.deleteChat(peerId) + } catch (e: Exception) { + Logger.e("RecentChatsViewModel -> Failed to delete chat", e) + } } } @@ -114,7 +121,8 @@ class RecentChatsViewModel @Inject constructor( * This is called when the user taps "Retry" after an error. */ fun retryLoad() { - _uiState.update { it.copy(error = null, isLoading = false) } + _uiState.update { it.copy(error = null, isLoading = true) } + loadChatsWithSearch() } /** diff --git a/feature/onboarding/build.gradle.kts b/feature/onboarding/build.gradle.kts index 89167a08..34161c53 100644 --- a/feature/onboarding/build.gradle.kts +++ b/feature/onboarding/build.gradle.kts @@ -8,7 +8,7 @@ plugins { android { namespace = "com.p2p.meshify.feature.onboarding" - compileSdk = 36 + compileSdk = 37 defaultConfig { minSdk = 26 diff --git a/feature/onboarding/proguard-rules.pro b/feature/onboarding/proguard-rules.pro index 37486798..ddfef2f7 100644 --- a/feature/onboarding/proguard-rules.pro +++ b/feature/onboarding/proguard-rules.pro @@ -2,13 +2,6 @@ # For more details, see: # http://developer.android.com/guide/developing/tools/proguard.html -# Keep Compose --keep class androidx.compose.** { *; } --keepclassmembers class androidx.compose.** { *; } - # Keep Kotlin coroutines -keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {} -keepnames class kotlinx.coroutines.CoroutineExceptionHandler {} - -# Keep Material icons --keep class androidx.compose.material.icons.** { *; } diff --git a/feature/onboarding/src/main/java/com/p2p/meshify/feature/onboarding/WelcomeScreen.kt b/feature/onboarding/src/main/java/com/p2p/meshify/feature/onboarding/WelcomeScreen.kt index 0b255408..d39d5762 100644 --- a/feature/onboarding/src/main/java/com/p2p/meshify/feature/onboarding/WelcomeScreen.kt +++ b/feature/onboarding/src/main/java/com/p2p/meshify/feature/onboarding/WelcomeScreen.kt @@ -2,6 +2,7 @@ package com.p2p.meshify.feature.onboarding import android.Manifest import android.os.Build +import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource @@ -216,32 +217,11 @@ private fun InfoSection(titleRes: Int, pointsRes: List, iconTint: Color, mo } } -@Composable -fun PermissionResultCard(permission: PermissionInfo, result: PermissionRequestResult, modifier: Modifier = Modifier) { - val (icon, iconTint, statusText) = when (result) { - PermissionRequestResult.Granted -> Triple(Icons.Default.Check, StatusOnline, R.string.ob_perm_granted) - PermissionRequestResult.Denied -> Triple(Icons.Default.Close, MaterialTheme.colorScheme.error, R.string.ob_perm_denied) - PermissionRequestResult.DeniedPermanently -> Triple(Icons.Default.Warning, MaterialTheme.colorScheme.error, R.string.ob_perm_denied_permanent) - } - - Surface(modifier = modifier.fillMaxWidth(0.92f), shape = MeshifyDesignSystem.Shapes.Dialog, color = MaterialTheme.colorScheme.surfaceContainerHigh, tonalElevation = 4.dp) { - Row(modifier = Modifier.padding(MeshifyDesignSystem.Spacing.Lg), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(MeshifyDesignSystem.Spacing.Lg)) { - Box(modifier = Modifier.size(64.dp).background(iconTint.copy(alpha = 0.1f), shape = MeshifyDesignSystem.Shapes.IconContainer), contentAlignment = Alignment.Center) { - Icon(icon, contentDescription = null, modifier = Modifier.size(MeshifyDesignSystem.IconSizes.XXL), tint = iconTint) - } - - Column(modifier = Modifier.weight(1f)) { - Text(text = stringResource(permission.labelRes), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface) - Text(text = stringResource(statusText), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold, color = iconTint) - } - } - } -} - @Composable fun PermissionSummaryDialog(grantedCount: Int, totalCount: Int, permissionResults: Map, onStartClick: () -> Unit, onDismiss: () -> Unit, modifier: Modifier = Modifier) { val haptics = LocalPremiumHaptics.current val allGranted = grantedCount == totalCount + var showDetails by remember { mutableStateOf(false) } Dialog(onDismissRequest = onDismiss, properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false, usePlatformDefaultWidth = false)) { Surface(modifier = modifier.fillMaxWidth(0.92f), shape = MeshifyDesignSystem.Shapes.Dialog, color = MaterialTheme.colorScheme.surfaceContainerHigh, tonalElevation = 12.dp) { @@ -268,6 +248,64 @@ fun PermissionSummaryDialog(grantedCount: Int, totalCount: Int, permissionResult } } + // Expandable details + AnimatedVisibility( + visible = showDetails, + enter = androidx.compose.animation.fadeIn() + androidx.compose.animation.expandVertically(), + exit = androidx.compose.animation.fadeOut() + androidx.compose.animation.shrinkVertically() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = MeshifyDesignSystem.Spacing.Md), + verticalArrangement = Arrangement.spacedBy(MeshifyDesignSystem.Spacing.Xs) + ) { + permissionResults.forEach { (id, result) -> + val perm = PermissionDefinitions.getPermissions().find { it.id == id } ?: return@forEach + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(perm.labelRes), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + val (statusText, color) = when (result) { + PermissionRequestResult.Granted -> R.string.ob_perm_granted to StatusOnline + PermissionRequestResult.Denied -> R.string.ob_perm_denied to MaterialTheme.colorScheme.error + PermissionRequestResult.DeniedPermanently -> R.string.ob_perm_denied_permanent to MaterialTheme.colorScheme.error + PermissionRequestResult.Skipped -> R.string.ob_perm_skipped to MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + PermissionRequestResult.AlreadyGranted -> R.string.ob_perm_already_granted to StatusOnline + } + Text( + text = stringResource(statusText), + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Black, + color = color + ) + } + } + } + } + + TextButton( + onClick = { + showDetails = !showDetails + haptics.perform(HapticPattern.Tick) + }, + modifier = Modifier.align(Alignment.CenterHorizontally) + ) { + Text( + text = if (showDetails) stringResource(R.string.ob_summary_hide_details) else stringResource(R.string.ob_summary_view_details), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + } + Spacer(modifier = Modifier.height(MeshifyDesignSystem.Spacing.Lg)) Surface( diff --git a/feature/onboarding/src/main/java/com/p2p/meshify/feature/onboarding/WelcomeUiState.kt b/feature/onboarding/src/main/java/com/p2p/meshify/feature/onboarding/WelcomeUiState.kt index aa07a906..bdd6be9e 100644 --- a/feature/onboarding/src/main/java/com/p2p/meshify/feature/onboarding/WelcomeUiState.kt +++ b/feature/onboarding/src/main/java/com/p2p/meshify/feature/onboarding/WelcomeUiState.kt @@ -30,4 +30,17 @@ sealed class PermissionRequestResult { object Granted : PermissionRequestResult() object Denied : PermissionRequestResult() object DeniedPermanently : PermissionRequestResult() + object Skipped : PermissionRequestResult() + object AlreadyGranted : PermissionRequestResult() +} + +/** + * Convert a [PermissionRequestResult] to the corresponding [PermissionStatus]. + */ +fun PermissionRequestResult.toPermissionStatus(): PermissionStatus = when (this) { + PermissionRequestResult.Granted -> PermissionStatus.Granted + PermissionRequestResult.Denied -> PermissionStatus.Denied + PermissionRequestResult.DeniedPermanently -> PermissionStatus.DeniedPermanently + PermissionRequestResult.Skipped -> PermissionStatus.Skipped + PermissionRequestResult.AlreadyGranted -> PermissionStatus.AlreadyGranted } diff --git a/feature/onboarding/src/main/res/values-ar/strings.xml b/feature/onboarding/src/main/res/values-ar/strings.xml index 6ed23692..fcf56028 100644 --- a/feature/onboarding/src/main/res/values-ar/strings.xml +++ b/feature/onboarding/src/main/res/values-ar/strings.xml @@ -75,6 +75,8 @@ جميع الأذونات ممنوحة تم منح %1$d من %2$d ابدأ Meshify + عرض التفاصيل + إخفاء التفاصيل مغادرة الإعداد؟ diff --git a/feature/onboarding/src/main/res/values/strings.xml b/feature/onboarding/src/main/res/values/strings.xml index 8c4792c2..fd94de02 100644 --- a/feature/onboarding/src/main/res/values/strings.xml +++ b/feature/onboarding/src/main/res/values/strings.xml @@ -75,6 +75,8 @@ All permissions granted %1$d of %2$d granted Start Meshify + View details + Hide details Leave onboarding? diff --git a/feature/real-device-testing/build.gradle.kts b/feature/real-device-testing/build.gradle.kts index 4c44cfeb..03e82d73 100644 --- a/feature/real-device-testing/build.gradle.kts +++ b/feature/real-device-testing/build.gradle.kts @@ -5,7 +5,7 @@ plugins { android { namespace = "com.p2p.meshify.feature.realdevicetesting" - compileSdk = 36 + compileSdk = 37 defaultConfig { minSdk = 26 diff --git a/feature/real-device-testing/src/main/java/com/p2p/meshify/feature/realdevicetesting/engine/TestResultLogger.kt b/feature/real-device-testing/src/main/java/com/p2p/meshify/feature/realdevicetesting/engine/TestResultLogger.kt index f3961a3b..a7727690 100644 --- a/feature/real-device-testing/src/main/java/com/p2p/meshify/feature/realdevicetesting/engine/TestResultLogger.kt +++ b/feature/real-device-testing/src/main/java/com/p2p/meshify/feature/realdevicetesting/engine/TestResultLogger.kt @@ -7,8 +7,9 @@ import com.p2p.meshify.feature.realdevicetesting.model.TestResult import com.p2p.meshify.feature.realdevicetesting.model.TestStatus import com.p2p.meshify.domain.model.TransportType import java.io.File -import java.text.SimpleDateFormat -import java.util.Date +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter import java.util.Locale private const val TAG = "TestResultLogger" @@ -188,8 +189,9 @@ class TestResultLogger( } private fun formatTimestamp(epochMs: Long): String { - val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US) - return sdf.format(Date(epochMs)) + return Instant.ofEpochMilli(epochMs) + .atZone(ZoneId.systemDefault()) + .format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss", Locale.US)) } private fun formatDuration(durationMs: Long): String { diff --git a/feature/settings/build.gradle.kts b/feature/settings/build.gradle.kts index 7d67936a..fff20388 100644 --- a/feature/settings/build.gradle.kts +++ b/feature/settings/build.gradle.kts @@ -7,7 +7,7 @@ plugins { android { namespace = "com.p2p.meshify.feature.settings" - compileSdk = 36 + compileSdk = 37 defaultConfig { minSdk = 26 diff --git a/feature/settings/src/main/java/com/p2p/meshify/feature/settings/BleStatusBottomSheet.kt b/feature/settings/src/main/java/com/p2p/meshify/feature/settings/BleStatusBottomSheet.kt new file mode 100644 index 00000000..b123ece8 --- /dev/null +++ b/feature/settings/src/main/java/com/p2p/meshify/feature/settings/BleStatusBottomSheet.kt @@ -0,0 +1,157 @@ +package com.p2p.meshify.feature.settings + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.Spring +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.BluetoothSearching +import androidx.compose.material.icons.filled.BluetoothDisabled +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.p2p.meshify.core.common.R +import com.p2p.meshify.core.ui.hooks.HapticPattern +import com.p2p.meshify.core.ui.hooks.LocalPremiumHaptics +import com.p2p.meshify.core.ui.theme.MeshifyDesignSystem +import com.p2p.meshify.domain.model.TransportMode + +/** + * BLE Status Bottom Sheet — shows BLE state, connected peers, and transport mode selector. + * MD3E styled: primaryContainer status surface, expressive top corners, subtle chip selection motion. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BleStatusBottomSheet( + bleEnabled: Boolean, + transportMode: TransportMode, + onModeSelected: (TransportMode) -> Unit, + onDismiss: () -> Unit +) { + val haptics = LocalPremiumHaptics.current + + ModalBottomSheet( + onDismissRequest = onDismiss, + containerColor = MaterialTheme.colorScheme.surfaceContainerLow, + shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(MeshifyDesignSystem.Spacing.Md) + .verticalScroll(rememberScrollState()) + ) { + Text( + text = stringResource(R.string.ble_sheet_title), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.ExtraBold, + modifier = Modifier.padding(bottom = MeshifyDesignSystem.Spacing.Md) + ) + + Surface( + modifier = Modifier.fillMaxWidth(), + shape = MeshifyDesignSystem.Shapes.CardSmall, + color = if (bleEnabled) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant + ) { + Column( + modifier = Modifier.padding(MeshifyDesignSystem.Spacing.Md) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(MeshifyDesignSystem.Spacing.Sm) + ) { + Icon( + imageVector = if (bleEnabled) Icons.AutoMirrored.Filled.BluetoothSearching else Icons.Default.BluetoothDisabled, + contentDescription = null, + tint = if (bleEnabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = if (bleEnabled) stringResource(R.string.ble_sheet_active) else stringResource(R.string.ble_sheet_inactive), + style = MaterialTheme.typography.titleMedium, + color = if (bleEnabled) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + Spacer(Modifier.height(MeshifyDesignSystem.Spacing.Md)) + + Text( + text = stringResource(R.string.ble_transport_mode_title), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = MeshifyDesignSystem.Spacing.Sm) + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(MeshifyDesignSystem.Spacing.Sm), + verticalAlignment = Alignment.CenterVertically + ) { + TransportMode.entries.forEach { mode -> + val isSelected = mode == transportMode + val selectedScale by animateFloatAsState( + targetValue = if (isSelected) 1f else 0.96f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessLow + ), + label = "bleModeScale" + ) + FilterChip( + selected = isSelected, + onClick = { + haptics.perform(HapticPattern.Tick) + onModeSelected(mode) + }, + label = { + Text( + text = when (mode) { + TransportMode.MULTI_PATH -> stringResource(R.string.ble_transport_mode_multipath) + TransportMode.LAN_ONLY -> stringResource(R.string.ble_transport_mode_lan) + TransportMode.BLE_ONLY -> stringResource(R.string.ble_transport_mode_ble) + TransportMode.AUTO -> stringResource(R.string.ble_transport_mode_auto) + }, + style = MaterialTheme.typography.labelMedium + ) + }, + shape = RoundedCornerShape(50), + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, + selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer + ), + modifier = Modifier + .weight(1f) + .scale(selectedScale) + ) + } + } + + Spacer(Modifier.height(MeshifyDesignSystem.Spacing.Lg)) + } + } +} diff --git a/feature/settings/src/main/java/com/p2p/meshify/feature/settings/DeveloperScreen.kt b/feature/settings/src/main/java/com/p2p/meshify/feature/settings/DeveloperScreen.kt index 11d67e9b..c9dc439d 100644 --- a/feature/settings/src/main/java/com/p2p/meshify/feature/settings/DeveloperScreen.kt +++ b/feature/settings/src/main/java/com/p2p/meshify/feature/settings/DeveloperScreen.kt @@ -8,454 +8,17 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -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.core.common.R import com.p2p.meshify.core.ui.components.MeshifySettingsGroup import com.p2p.meshify.core.ui.components.MeshifySettingsItem import com.p2p.meshify.core.ui.theme.MeshifyDesignSystem -import com.p2p.meshify.core.common.R -import com.p2p.meshify.domain.model.MessageType -import kotlinx.coroutines.launch -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.firstOrNull -import java.util.UUID -import javax.inject.Inject - -@HiltViewModel -class DeveloperViewModel @Inject constructor( - private val chatDao: ChatDao, - private val messageDao: MessageDao -) : ViewModel() { - - fun requestClearAllData(onConfirm: () -> Unit) { - // Show confirmation dialog first - onConfirm() - } - - fun clearAllData(onComplete: () -> Unit) { - viewModelScope.launch { - try { - messageDao.deleteMessages(messageDao.getAllAttachments().map { it.messageId ?: "" }.filter { it.isNotEmpty() }) - messageDao.deleteAllMessagesForChat("") - val chats = chatDao.getAllChats().firstOrNull() ?: emptyList() - chats.forEach { chatDao.deleteChatById(it.peerId) } - onComplete() - } catch (e: Exception) { - onComplete() // Still complete even on error - } - } - } - - fun insertMockConversations(onComplete: (String) -> Unit) { - viewModelScope.launch { - try { - val mockPeers = listOf( - MockPeer("peer_ahmed", "Ahmed Mohamed"), - MockPeer("peer_sara", "Sara Ali"), - MockPeer("peer_omar", "Omar Hassan"), - MockPeer("peer_fatima", "Fatima Ibrahim"), - MockPeer("peer_khaled", "Khaled Youssef"), - MockPeer("peer_nour", "Nour Mahmoud"), - MockPeer("peer_youssef", "Youssef Adel") - ) - - val baseTime = System.currentTimeMillis() - - mockPeers.forEachIndexed { peerIndex, peer -> - val chatTime = baseTime - (peerIndex * 3600000L) - chatDao.insertChat( - ChatEntity( - peerId = peer.id, - peerName = peer.name, - lastMessage = null, - lastTimestamp = chatTime - ) - ) - - val messages = generateMockMessages(peer.id, peer.name, chatTime, peerIndex) - messages.forEach { msg -> - messageDao.insertMessage(msg) - } - - // Update last message - val lastMsg = messages.lastOrNull() - chatDao.insertChat( - ChatEntity( - peerId = peer.id, - peerName = peer.name, - lastMessage = lastMsg?.text ?: "[Media]", - lastTimestamp = lastMsg?.timestamp ?: chatTime - ) - ) - } - - onComplete("Added ${mockPeers.size} conversations with messages") - } catch (e: Exception) { - onComplete("Error: ${e.message}") - } - } - } - - fun insertMockMediaMessages(onComplete: (String) -> Unit) { - viewModelScope.launch { - try { - val peerId = "peer_media_test" - val peerName = "Media Test" // Dev-only label - val baseTime = System.currentTimeMillis() - - chatDao.insertChat(ChatEntity(peerId, peerName, "[Media Messages]", baseTime)) - - val mediaMessages = listOf( - MessageEntity( - id = UUID.randomUUID().toString(), - chatId = peerId, - senderId = "me", - text = "Check out this photo!", - mediaPath = null, - type = MessageType.IMAGE, - timestamp = baseTime - 300000, - isFromMe = true, - status = MessageStatus.READ - ), - MessageEntity( - id = UUID.randomUUID().toString(), - chatId = peerId, - senderId = peerId, - text = null, - mediaPath = null, - type = MessageType.IMAGE, - timestamp = baseTime - 240000, - isFromMe = false - ), - MessageEntity( - id = UUID.randomUUID().toString(), - chatId = peerId, - senderId = "me", - text = "Here's a video", - mediaPath = null, - type = MessageType.VIDEO, - timestamp = baseTime - 180000, - isFromMe = true, - status = MessageStatus.DELIVERED - ), - MessageEntity( - id = UUID.randomUUID().toString(), - chatId = peerId, - senderId = peerId, - text = null, - mediaPath = null, - type = MessageType.FILE, - timestamp = baseTime - 120000, - isFromMe = false - ), - MessageEntity( - id = UUID.randomUUID().toString(), - chatId = peerId, - senderId = "me", - text = "Document.pdf", - mediaPath = null, - type = MessageType.FILE, - timestamp = baseTime - 60000, - isFromMe = true, - status = MessageStatus.SENT - ) - ) - - mediaMessages.forEach { messageDao.insertMessage(it) } - onComplete("Added media test conversation") - } catch (e: Exception) { - onComplete("Error: ${e.message}") - } - } - } - - fun insertMockChatWithReactions(onComplete: (String) -> Unit) { - viewModelScope.launch { - try { - val peerId = "peer_reactions" - val peerName = "Reactions Demo" // Dev-only label - val baseTime = System.currentTimeMillis() - - chatDao.insertChat(ChatEntity(peerId, peerName, "👍", baseTime)) - - val messages = listOf( - MessageEntity( - id = UUID.randomUUID().toString(), - chatId = peerId, - senderId = "me", - text = "Hey! How are you?", - timestamp = baseTime - 500000, - isFromMe = true, - status = MessageStatus.READ, - reaction = "👋" - ), - MessageEntity( - id = UUID.randomUUID().toString(), - chatId = peerId, - senderId = peerId, - text = "I'm great! Thanks for asking 😊", - timestamp = baseTime - 400000, - isFromMe = false - ), - MessageEntity( - id = UUID.randomUUID().toString(), - chatId = peerId, - senderId = "me", - text = "Want to grab coffee later?", - timestamp = baseTime - 300000, - isFromMe = true, - status = MessageStatus.READ, - reaction = "👍" - ), - MessageEntity( - id = UUID.randomUUID().toString(), - chatId = peerId, - senderId = peerId, - text = "Sounds good! Where?", - timestamp = baseTime - 200000, - isFromMe = false, - reaction = "❤️" - ), - MessageEntity( - id = UUID.randomUUID().toString(), - chatId = peerId, - senderId = "me", - text = "The usual place 🎉", - timestamp = baseTime - 100000, - isFromMe = true, - status = MessageStatus.DELIVERED - ) - ) - - messages.forEach { messageDao.insertMessage(it) } - onComplete("Added reactions demo conversation") - } catch (e: Exception) { - onComplete("Error: ${e.message}") - } - } - } - - fun insertMockChatWithReplies(onComplete: (String) -> Unit) { - viewModelScope.launch { - try { - val peerId = "peer_replies" - val peerName = "Replies Demo" // Dev-only label - val baseTime = System.currentTimeMillis() - - chatDao.insertChat(ChatEntity(peerId, peerName, "Got it!", baseTime)) - - val msg1Id = UUID.randomUUID().toString() - val msg2Id = UUID.randomUUID().toString() - val msg3Id = UUID.randomUUID().toString() - - val messages = listOf( - MessageEntity( - id = msg1Id, - chatId = peerId, - senderId = "me", - text = "Can you send me the report?", - timestamp = baseTime - 300000, - isFromMe = true, - status = MessageStatus.READ - ), - MessageEntity( - id = msg2Id, - chatId = peerId, - senderId = peerId, - text = "Sure, I'll send it now", - timestamp = baseTime - 250000, - isFromMe = false, - replyToId = msg1Id - ), - MessageEntity( - id = msg3Id, - chatId = peerId, - senderId = peerId, - text = "Here it is!", - timestamp = baseTime - 200000, - isFromMe = false, - type = MessageType.FILE, - replyToId = msg2Id - ), - MessageEntity( - id = UUID.randomUUID().toString(), - chatId = peerId, - senderId = "me", - text = "Thanks! Got it 👍", - timestamp = baseTime - 100000, - isFromMe = true, - status = MessageStatus.READ, - replyToId = msg3Id - ) - ) - - messages.forEach { messageDao.insertMessage(it) } - onComplete("Added replies demo conversation") - } catch (e: Exception) { - onComplete("Error: ${e.message}") - } - } - } - - fun insertMockLongConversation(onComplete: (String) -> Unit) { - viewModelScope.launch { - try { - val peerId = "peer_long_chat" - val peerName = "Long Chat" // Dev-only label - val baseTime = System.currentTimeMillis() - - val messages = mutableListOf() - repeat(50) { i -> - messages.add( - MessageEntity( - id = UUID.randomUUID().toString(), - chatId = peerId, - senderId = if (i % 2 == 0) "me" else peerId, - text = getMockMessageText(i), - timestamp = baseTime - ((50 - i) * 60000L), - isFromMe = i % 2 == 0, - status = if (i % 2 == 0) MessageStatus.READ else MessageStatus.SENT, - reaction = if (i % 5 == 0) listOf("👍", "❤️", "😂", "😮").random() else null - ) - ) - } - - chatDao.insertChat( - ChatEntity( - peerId = peerId, - peerName = peerName, - lastMessage = messages.last().text, - lastTimestamp = messages.last().timestamp - ) - ) - - messages.forEach { messageDao.insertMessage(it) } - onComplete("Added 50 message conversation") - } catch (e: Exception) { - onComplete("Error: ${e.message}") - } - } - } - - fun clearMockData(onComplete: (String) -> Unit) { - viewModelScope.launch { - try { - val mockPeerIds = listOf( - "peer_ahmed", "peer_sara", "peer_omar", "peer_fatima", - "peer_khaled", "peer_nour", "peer_youssef", - "peer_media_test", "peer_reactions", "peer_replies", "peer_long_chat" - ) - - mockPeerIds.forEach { peerId -> - messageDao.deleteAllMessagesForChat(peerId) - chatDao.deleteChatById(peerId) - } - - onComplete("Cleared all mock data") // Dev-only message - } catch (e: Exception) { - onComplete("Error: ${e.message}") - } - } - } - - private fun generateMockMessages( - peerId: String, - peerName: String, - baseTime: Long, - seed: Int - ): List { - val myMessages = listOf( - "Hey! How's it going?", - "Did you see the news today?", - "Let's meet up this weekend", - "I just finished the project", - "Check this out!", - "Can you help me with something?", - "Thanks for your help earlier", - "What do you think about this?", - "That's awesome!", - "See you tomorrow" - ) - - val theirMessages = listOf( - "Hi! I'm doing well, thanks!", - "Yeah, it's quite interesting", - "Sounds great! When?", - "Nice work! Congratulations", - "Wow, that's cool!", - "Sure, what do you need?", - "No problem anytime!", - "I think it's a good idea", - "I know right?!", - "Perfect, see you then" - ) - - return listOf( - MessageEntity( - id = UUID.randomUUID().toString(), - chatId = peerId, - senderId = peerId, - text = theirMessages[seed % theirMessages.size], - timestamp = baseTime + 60000, - isFromMe = false - ), - MessageEntity( - id = UUID.randomUUID().toString(), - chatId = peerId, - senderId = "me", - text = myMessages[seed % myMessages.size], - timestamp = baseTime + 120000, - isFromMe = true, - status = MessageStatus.READ - ), - MessageEntity( - id = UUID.randomUUID().toString(), - chatId = peerId, - senderId = peerId, - text = theirMessages[(seed + 3) % theirMessages.size], - timestamp = baseTime + 180000, - isFromMe = false - ), - MessageEntity( - id = UUID.randomUUID().toString(), - chatId = peerId, - senderId = "me", - text = myMessages[(seed + 3) % myMessages.size], - timestamp = baseTime + 240000, - isFromMe = true, - status = MessageStatus.DELIVERED - ) - ) - } - - private fun getMockMessageText(index: Int): String { - return when (index % 10) { - 0 -> "Hey there!" - 1 -> "How's everything going?" - 2 -> "Did you finish the task?" - 3 -> "I'll send you the details" - 4 -> "That sounds perfect" - 5 -> "Let me check and get back to you" - 6 -> "Great idea! Let's do it" - 7 -> "I'm on my way" - 8 -> "Almost there, 5 minutes" - 9 -> "See you soon! 👋" - else -> "Message $index" - } - } - - private data class MockPeer(val id: String, val name: String) -} +import kotlinx.coroutines.delay @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -475,11 +38,11 @@ fun DeveloperScreen( title = { Column { Text( - text = "Developer Tools", + text = stringResource(R.string.developer_screen_title), fontWeight = FontWeight.Bold ) Text( - text = "Mock data & debugging", + text = stringResource(R.string.developer_screen_subtitle), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -503,9 +66,9 @@ fun DeveloperScreen( Spacer(Modifier.height(MeshifyDesignSystem.Spacing.Md)) // Mock Data Section - MeshifySettingsGroup(title = "Mock Data") { + MeshifySettingsGroup(title = stringResource(R.string.developer_group_mock_data)) { MeshifySettingsItem( - title = "Add Mock Conversations", + title = stringResource(R.string.developer_add_mock_conversations), subtitle = stringResource(R.string.developer_mock_conversations_subtitle), icon = Icons.Default.Chat, onClick = { @@ -519,7 +82,7 @@ fun DeveloperScreen( ) MeshifySettingsItem( - title = "Add Media Messages", + title = stringResource(R.string.developer_add_media_messages), subtitle = stringResource(R.string.developer_mock_media_subtitle), icon = Icons.Default.Image, onClick = { @@ -533,7 +96,7 @@ fun DeveloperScreen( ) MeshifySettingsItem( - title = "Add Reactions Demo", + title = stringResource(R.string.developer_add_reactions_demo), subtitle = stringResource(R.string.developer_mock_reactions_subtitle), icon = Icons.Default.EmojiEmotions, onClick = { @@ -547,7 +110,7 @@ fun DeveloperScreen( ) MeshifySettingsItem( - title = "Add Replies Demo", + title = stringResource(R.string.developer_add_replies_demo), subtitle = stringResource(R.string.developer_mock_replies_subtitle), icon = Icons.Default.Reply, onClick = { @@ -561,7 +124,7 @@ fun DeveloperScreen( ) MeshifySettingsItem( - title = "Add Long Conversation", + title = stringResource(R.string.developer_add_long_conversation), subtitle = stringResource(R.string.developer_mock_long_chat_subtitle), icon = Icons.Default.FormatListNumbered, onClick = { @@ -585,9 +148,9 @@ fun DeveloperScreen( Spacer(Modifier.height(MeshifyDesignSystem.Spacing.Md)) // Cleanup Section - MeshifySettingsGroup(title = "Cleanup") { + MeshifySettingsGroup(title = stringResource(R.string.developer_group_cleanup)) { MeshifySettingsItem( - title = "Clear Mock Data", + title = stringResource(R.string.developer_clear_mock_data), subtitle = stringResource(R.string.developer_mock_clear_subtitle), icon = Icons.Default.DeleteSweep, onClick = { @@ -601,7 +164,7 @@ fun DeveloperScreen( ) MeshifySettingsItem( - title = "Clear ALL Data", + title = stringResource(R.string.developer_clear_all_data), subtitle = stringResource(R.string.developer_clear_all_warning), icon = Icons.Default.Warning, onClick = { @@ -622,7 +185,7 @@ fun DeveloperScreen( ) } - Spacer(Modifier.height(MeshifyDesignSystem.Spacing.Xxl)) + Spacer(Modifier.height(MeshifyDesignSystem.Spacing.Md)) } } @@ -668,19 +231,19 @@ fun DeveloperScreen( // Status Snackbar if (statusMessage != null) { LaunchedEffect(statusMessage) { - kotlinx.coroutines.delay(3000) + delay(3000) statusMessage = null } Box( modifier = Modifier.fillMaxSize(), - contentAlignment = androidx.compose.ui.Alignment.BottomCenter + contentAlignment = Alignment.BottomCenter ) { Snackbar( modifier = Modifier.padding(MeshifyDesignSystem.Spacing.Md), action = { TextButton(onClick = { statusMessage = null }) { - Text("OK") + Text(stringResource(R.string.developer_action_ok)) } } ) { diff --git a/feature/settings/src/main/java/com/p2p/meshify/feature/settings/DeveloperViewModel.kt b/feature/settings/src/main/java/com/p2p/meshify/feature/settings/DeveloperViewModel.kt new file mode 100644 index 00000000..831ad219 --- /dev/null +++ b/feature/settings/src/main/java/com/p2p/meshify/feature/settings/DeveloperViewModel.kt @@ -0,0 +1,436 @@ +package com.p2p.meshify.feature.settings + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +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.MessageType +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.launch +import java.util.UUID +import javax.inject.Inject + +@HiltViewModel +class DeveloperViewModel @Inject constructor( + private val chatDao: ChatDao, + private val messageDao: MessageDao +) : ViewModel() { + + fun clearAllData(onComplete: () -> Unit) { + viewModelScope.launch { + try { + messageDao.deleteMessages(messageDao.getAllAttachments().map { it.messageId ?: "" }.filter { it.isNotEmpty() }) + messageDao.deleteAllMessagesForChat("") + val chats = chatDao.getAllChats().firstOrNull() ?: emptyList() + chats.forEach { chatDao.deleteChatById(it.peerId) } + onComplete() + } catch (e: Exception) { + onComplete() // Still complete even on error + } + } + } + + fun insertMockConversations(onComplete: (String) -> Unit) { + viewModelScope.launch { + try { + val mockPeers = listOf( + MockPeer("peer_ahmed", "Ahmed Mohamed"), + MockPeer("peer_sara", "Sara Ali"), + MockPeer("peer_omar", "Omar Hassan"), + MockPeer("peer_fatima", "Fatima Ibrahim"), + MockPeer("peer_khaled", "Khaled Youssef"), + MockPeer("peer_nour", "Nour Mahmoud"), + MockPeer("peer_youssef", "Youssef Adel") + ) + + val baseTime = System.currentTimeMillis() + + mockPeers.forEachIndexed { peerIndex, peer -> + val chatTime = baseTime - (peerIndex * 3600000L) + chatDao.insertChat( + ChatEntity( + peerId = peer.id, + peerName = peer.name, + lastMessage = null, + lastTimestamp = chatTime + ) + ) + + val messages = generateMockMessages(peer.id, peer.name, chatTime, peerIndex) + messages.forEach { msg -> + messageDao.insertMessage(msg) + } + + // Update last message + val lastMsg = messages.lastOrNull() + chatDao.insertChat( + ChatEntity( + peerId = peer.id, + peerName = peer.name, + lastMessage = lastMsg?.text ?: "[Media]", + lastTimestamp = lastMsg?.timestamp ?: chatTime + ) + ) + } + + onComplete("Added ${mockPeers.size} conversations with messages") + } catch (e: Exception) { + onComplete("Error: ${e.message}") + } + } + } + + fun insertMockMediaMessages(onComplete: (String) -> Unit) { + viewModelScope.launch { + try { + val peerId = "peer_media_test" + val peerName = "Media Test" // Dev-only label + val baseTime = System.currentTimeMillis() + + chatDao.insertChat(ChatEntity(peerId, peerName, "[Media Messages]", baseTime)) + + val mediaMessages = listOf( + MessageEntity( + id = UUID.randomUUID().toString(), + chatId = peerId, + senderId = "me", + text = "Check out this photo!", + mediaPath = null, + type = MessageType.IMAGE, + timestamp = baseTime - 300000, + isFromMe = true, + status = MessageStatus.READ + ), + MessageEntity( + id = UUID.randomUUID().toString(), + chatId = peerId, + senderId = peerId, + text = null, + mediaPath = null, + type = MessageType.IMAGE, + timestamp = baseTime - 240000, + isFromMe = false + ), + MessageEntity( + id = UUID.randomUUID().toString(), + chatId = peerId, + senderId = "me", + text = "Here's a video", + mediaPath = null, + type = MessageType.VIDEO, + timestamp = baseTime - 180000, + isFromMe = true, + status = MessageStatus.DELIVERED + ), + MessageEntity( + id = UUID.randomUUID().toString(), + chatId = peerId, + senderId = peerId, + text = null, + mediaPath = null, + type = MessageType.FILE, + timestamp = baseTime - 120000, + isFromMe = false + ), + MessageEntity( + id = UUID.randomUUID().toString(), + chatId = peerId, + senderId = "me", + text = "Document.pdf", + mediaPath = null, + type = MessageType.FILE, + timestamp = baseTime - 60000, + isFromMe = true, + status = MessageStatus.SENT + ) + ) + + mediaMessages.forEach { messageDao.insertMessage(it) } + onComplete("Added media test conversation") + } catch (e: Exception) { + onComplete("Error: ${e.message}") + } + } + } + + fun insertMockChatWithReactions(onComplete: (String) -> Unit) { + viewModelScope.launch { + try { + val peerId = "peer_reactions" + val peerName = "Reactions Demo" // Dev-only label + val baseTime = System.currentTimeMillis() + + chatDao.insertChat(ChatEntity(peerId, peerName, "👍", baseTime)) + + val messages = listOf( + MessageEntity( + id = UUID.randomUUID().toString(), + chatId = peerId, + senderId = "me", + text = "Hey! How are you?", + timestamp = baseTime - 500000, + isFromMe = true, + status = MessageStatus.READ, + reaction = "👋" + ), + MessageEntity( + id = UUID.randomUUID().toString(), + chatId = peerId, + senderId = peerId, + text = "I'm great! Thanks for asking 😊", + timestamp = baseTime - 400000, + isFromMe = false + ), + MessageEntity( + id = UUID.randomUUID().toString(), + chatId = peerId, + senderId = "me", + text = "Want to grab coffee later?", + timestamp = baseTime - 300000, + isFromMe = true, + status = MessageStatus.READ, + reaction = "👍" + ), + MessageEntity( + id = UUID.randomUUID().toString(), + chatId = peerId, + senderId = peerId, + text = "Sounds good! Where?", + timestamp = baseTime - 200000, + isFromMe = false, + reaction = "❤️" + ), + MessageEntity( + id = UUID.randomUUID().toString(), + chatId = peerId, + senderId = "me", + text = "The usual place 🎉", + timestamp = baseTime - 100000, + isFromMe = true, + status = MessageStatus.DELIVERED + ) + ) + + messages.forEach { messageDao.insertMessage(it) } + onComplete("Added reactions demo conversation") + } catch (e: Exception) { + onComplete("Error: ${e.message}") + } + } + } + + fun insertMockChatWithReplies(onComplete: (String) -> Unit) { + viewModelScope.launch { + try { + val peerId = "peer_replies" + val peerName = "Replies Demo" // Dev-only label + val baseTime = System.currentTimeMillis() + + chatDao.insertChat(ChatEntity(peerId, peerName, "Got it!", baseTime)) + + val msg1Id = UUID.randomUUID().toString() + val msg2Id = UUID.randomUUID().toString() + val msg3Id = UUID.randomUUID().toString() + + val messages = listOf( + MessageEntity( + id = msg1Id, + chatId = peerId, + senderId = "me", + text = "Can you send me the report?", + timestamp = baseTime - 300000, + isFromMe = true, + status = MessageStatus.READ + ), + MessageEntity( + id = msg2Id, + chatId = peerId, + senderId = peerId, + text = "Sure, I'll send it now", + timestamp = baseTime - 250000, + isFromMe = false, + replyToId = msg1Id + ), + MessageEntity( + id = msg3Id, + chatId = peerId, + senderId = peerId, + text = "Here it is!", + timestamp = baseTime - 200000, + isFromMe = false, + type = MessageType.FILE, + replyToId = msg2Id + ), + MessageEntity( + id = UUID.randomUUID().toString(), + chatId = peerId, + senderId = "me", + text = "Thanks! Got it 👍", + timestamp = baseTime - 100000, + isFromMe = true, + status = MessageStatus.READ, + replyToId = msg3Id + ) + ) + + messages.forEach { messageDao.insertMessage(it) } + onComplete("Added replies demo conversation") + } catch (e: Exception) { + onComplete("Error: ${e.message}") + } + } + } + + fun insertMockLongConversation(onComplete: (String) -> Unit) { + viewModelScope.launch { + try { + val peerId = "peer_long_chat" + val peerName = "Long Chat" // Dev-only label + val baseTime = System.currentTimeMillis() + + val messages = mutableListOf() + repeat(50) { i -> + messages.add( + MessageEntity( + id = UUID.randomUUID().toString(), + chatId = peerId, + senderId = if (i % 2 == 0) "me" else peerId, + text = getMockMessageText(i), + timestamp = baseTime - ((50 - i) * 60000L), + isFromMe = i % 2 == 0, + status = if (i % 2 == 0) MessageStatus.READ else MessageStatus.SENT, + reaction = if (i % 5 == 0) listOf("👍", "❤️", "😂", "😮").random() else null + ) + ) + } + + chatDao.insertChat( + ChatEntity( + peerId = peerId, + peerName = peerName, + lastMessage = messages.last().text, + lastTimestamp = messages.last().timestamp + ) + ) + + messages.forEach { messageDao.insertMessage(it) } + onComplete("Added 50 message conversation") + } catch (e: Exception) { + onComplete("Error: ${e.message}") + } + } + } + + fun clearMockData(onComplete: (String) -> Unit) { + viewModelScope.launch { + try { + val mockPeerIds = listOf( + "peer_ahmed", "peer_sara", "peer_omar", "peer_fatima", + "peer_khaled", "peer_nour", "peer_youssef", + "peer_media_test", "peer_reactions", "peer_replies", "peer_long_chat" + ) + + mockPeerIds.forEach { peerId -> + messageDao.deleteAllMessagesForChat(peerId) + chatDao.deleteChatById(peerId) + } + + onComplete("Cleared all mock data") // Dev-only message + } catch (e: Exception) { + onComplete("Error: ${e.message}") + } + } + } + + private fun generateMockMessages( + peerId: String, + peerName: String, + baseTime: Long, + seed: Int + ): List { + val myMessages = listOf( + "Hey! How's it going?", + "Did you see the news today?", + "Let's meet up this weekend", + "I just finished the project", + "Check this out!", + "Can you help me with something?", + "Thanks for your help earlier", + "What do you think about this?", + "That's awesome!", + "See you tomorrow" + ) + + val theirMessages = listOf( + "Hi! I'm doing well, thanks!", + "Yeah, it's quite interesting", + "Sounds great! When?", + "Nice work! Congratulations", + "Wow, that's cool!", + "Sure, what do you need?", + "No problem anytime!", + "I think it's a good idea", + "I know right?!", + "Perfect, see you then" + ) + + return listOf( + MessageEntity( + id = UUID.randomUUID().toString(), + chatId = peerId, + senderId = peerId, + text = theirMessages[seed % theirMessages.size], + timestamp = baseTime + 60000, + isFromMe = false + ), + MessageEntity( + id = UUID.randomUUID().toString(), + chatId = peerId, + senderId = "me", + text = myMessages[seed % myMessages.size], + timestamp = baseTime + 120000, + isFromMe = true, + status = MessageStatus.READ + ), + MessageEntity( + id = UUID.randomUUID().toString(), + chatId = peerId, + senderId = peerId, + text = theirMessages[(seed + 3) % theirMessages.size], + timestamp = baseTime + 180000, + isFromMe = false + ), + MessageEntity( + id = UUID.randomUUID().toString(), + chatId = peerId, + senderId = "me", + text = myMessages[(seed + 3) % myMessages.size], + timestamp = baseTime + 240000, + isFromMe = true, + status = MessageStatus.DELIVERED + ) + ) + } + + private fun getMockMessageText(index: Int): String { + return when (index % 10) { + 0 -> "Hey there!" + 1 -> "How's everything going?" + 2 -> "Did you finish the task?" + 3 -> "I'll send you the details" + 4 -> "That sounds perfect" + 5 -> "Let me check and get back to you" + 6 -> "Great idea! Let's do it" + 7 -> "I'm on my way" + 8 -> "Almost there, 5 minutes" + 9 -> "See you soon! 👋" + else -> "Message $index" + } + } + + private data class MockPeer(val id: String, val name: String) +} diff --git a/feature/settings/src/main/java/com/p2p/meshify/feature/settings/SettingsBackupDialog.kt b/feature/settings/src/main/java/com/p2p/meshify/feature/settings/SettingsBackupDialog.kt new file mode 100644 index 00000000..0b445415 --- /dev/null +++ b/feature/settings/src/main/java/com/p2p/meshify/feature/settings/SettingsBackupDialog.kt @@ -0,0 +1,76 @@ +package com.p2p.meshify.feature.settings + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import com.p2p.meshify.core.common.R +import com.p2p.meshify.core.ui.hooks.HapticPattern +import com.p2p.meshify.core.ui.hooks.LocalPremiumHaptics + +/** + * MD3E styled Backup & Restore dialog. + * Confirm uses a FilledTonalButton (expressive "rich color" tactic); labels use semantic typography. + */ +@Composable +fun SettingsBackupDialog( + onExportResult: (String) -> Unit, + onDismiss: () -> Unit, + viewModel: SettingsViewModel +) { + val haptics = LocalPremiumHaptics.current + val resources = LocalContext.current.resources + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + text = stringResource(R.string.settings_backup_title), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface + ) + }, + text = { + Text( + text = stringResource(R.string.settings_backup_desc), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + confirmButton = { + FilledTonalButton( + onClick = { + haptics.perform(HapticPattern.Pop) + viewModel.exportBackup { result -> + onExportResult( + result.fold( + onSuccess = { resources.getString(R.string.settings_backup_export_success) }, + onFailure = { resources.getString(R.string.settings_backup_export_error, it.message) } + ) + ) + onDismiss() + } + } + ) { + Text( + text = stringResource(R.string.settings_backup_export), + color = MaterialTheme.colorScheme.primary + ) + } + }, + dismissButton = { + TextButton( + onClick = { + haptics.perform(HapticPattern.Pop) + onExportResult(resources.getString(R.string.settings_backup_import_soon)) + onDismiss() + } + ) { + Text(stringResource(R.string.settings_backup_import)) + } + } + ) +} diff --git a/feature/settings/src/main/java/com/p2p/meshify/feature/settings/SettingsComponents.kt b/feature/settings/src/main/java/com/p2p/meshify/feature/settings/SettingsComponents.kt new file mode 100644 index 00000000..8f75f334 --- /dev/null +++ b/feature/settings/src/main/java/com/p2p/meshify/feature/settings/SettingsComponents.kt @@ -0,0 +1,243 @@ +package com.p2p.meshify.feature.settings + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +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.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.p2p.meshify.core.ui.hooks.HapticPattern +import com.p2p.meshify.core.ui.hooks.LocalPremiumHaptics + +/** + * Rounded container with 2.dp spacing between items — matches PixelPlayer's + * SettingsSubsection pattern. Items inside render with 10.dp corners + 16.dp padding + * via SettingsItem / SwitchSettingItem. + */ +@Composable +fun SettingsSubsection( + addBottomSpace: Boolean = false, + content: @Composable () -> Unit +) { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(24.dp)), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + content() + if (addBottomSpace) { + Spacer(modifier = Modifier.height(10.dp)) + } + } +} + +/** + * Expressive MD3E settings section header with a leading tinted icon + colored label. + * The content is wrapped in a [SettingsSubsection] to apply tight 2.dp spacing and + * unified rounded corners. + */ +@Composable +fun SettingsSection( + title: String, + icon: @Composable () -> Unit, + content: @Composable () -> Unit +) { + Column { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp) + ) { + Box( + modifier = Modifier.size(24.dp), + contentAlignment = Alignment.Center + ) { icon() } + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = title, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + } + SettingsSubsection(content = content) + } +} + +/** + * Expressive clickable settings row rendered as a surfaceContainer card with a leading + * icon, title/subtitle, and an optional trailing icon (e.g. a navigation chevron). + * Performs HapticPattern.Pop on click to preserve the previous row haptic behavior. + */ +@Composable +fun SettingsItem( + title: String, + subtitle: String, + leadingIcon: @Composable () -> Unit, + trailingIcon: @Composable () -> Unit = {}, + onClick: () -> Unit +) { + val haptics = LocalPremiumHaptics.current + Surface( + color = MaterialTheme.colorScheme.surfaceContainer, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .clickable(onClick = { + haptics.perform(HapticPattern.Pop) + onClick() + }) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .padding(16.dp) + .fillMaxWidth() + ) { + Box( + modifier = Modifier + .padding(end = 16.dp) + .size(24.dp), + contentAlignment = Alignment.Center + ) { leadingIcon() } + + Column( + modifier = Modifier + .weight(1f) + .padding(end = 8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Box(modifier = Modifier.size(24.dp), contentAlignment = Alignment.Center) { + trailingIcon() + } + } + } +} + +/** + * Expressive settings row with the MD3E animated Switch (check/close thumb icon). + * Performs HapticPattern.Tick on change (inside the switch's onCheckedChange). + */ +@Composable +fun SwitchSettingItem( + title: String, + subtitle: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + leadingIcon: @Composable (() -> Unit)? = null, + enabled: Boolean = true +) { + val haptics = LocalPremiumHaptics.current + + Surface( + color = MaterialTheme.colorScheme.surfaceContainer, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .padding(16.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + if (leadingIcon != null) { + Box( + modifier = Modifier + .padding(end = 4.dp) + .size(24.dp), + contentAlignment = Alignment.Center + ) { leadingIcon() } + } + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + color = if (enabled) MaterialTheme.colorScheme.onSurface + else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + color = if (enabled) MaterialTheme.colorScheme.onSurfaceVariant + else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) + ) + } + + Switch( + checked = checked, + onCheckedChange = { newValue -> + if (enabled) { + haptics.perform(HapticPattern.Tick) + onCheckedChange(newValue) + } + }, + enabled = enabled, + thumbContent = { + AnimatedContent( + targetState = checked, + transitionSpec = { fadeIn(tween(100)) togetherWith fadeOut(tween(100)) }, + label = "switch_thumb_icon" + ) { isChecked -> + Icon( + imageVector = if (isChecked) Icons.Rounded.Check else Icons.Rounded.Close, + contentDescription = null, + modifier = Modifier.size(SwitchDefaults.IconSize) + ) + } + }, + colors = SwitchDefaults.colors( + checkedThumbColor = MaterialTheme.colorScheme.onPrimary, + checkedTrackColor = MaterialTheme.colorScheme.primary, + checkedIconColor = MaterialTheme.colorScheme.primary, + uncheckedThumbColor = MaterialTheme.colorScheme.onSurface, + uncheckedTrackColor = MaterialTheme.colorScheme.surfaceVariant, + uncheckedIconColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) + } + } +} diff --git a/feature/settings/src/main/java/com/p2p/meshify/feature/settings/SettingsCreditsDialog.kt b/feature/settings/src/main/java/com/p2p/meshify/feature/settings/SettingsCreditsDialog.kt new file mode 100644 index 00000000..669275b3 --- /dev/null +++ b/feature/settings/src/main/java/com/p2p/meshify/feature/settings/SettingsCreditsDialog.kt @@ -0,0 +1,42 @@ +package com.p2p.meshify.feature.settings + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import com.p2p.meshify.core.common.R + +/** + * MD3E styled Developer Credits dialog. + */ +@Composable +fun SettingsCreditsDialog( + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + text = stringResource(R.string.settings_credits_title), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.ExtraBold, + color = MaterialTheme.colorScheme.onSurface + ) + }, + text = { + Text( + text = stringResource(R.string.settings_credits_text), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.dialog_btn_close)) + } + } + ) +} diff --git a/feature/settings/src/main/java/com/p2p/meshify/feature/settings/SettingsFontSizeDialog.kt b/feature/settings/src/main/java/com/p2p/meshify/feature/settings/SettingsFontSizeDialog.kt new file mode 100644 index 00000000..55f436f3 --- /dev/null +++ b/feature/settings/src/main/java/com/p2p/meshify/feature/settings/SettingsFontSizeDialog.kt @@ -0,0 +1,29 @@ +package com.p2p.meshify.feature.settings + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.TextFields +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.p2p.meshify.core.common.R +import com.p2p.meshify.core.ui.components.MeshifySelectionDialog + +/** + * MD3E wrapper around [MeshifySelectionDialog] for choosing the font size scale. + * The selection haptic + [SettingsViewModel.setFontSizeScale] call live in [onFontSizeSelected]. + */ +@Composable +fun SettingsFontSizeDialog( + fontSizeScale: Float, + onFontSizeSelected: (Float) -> Unit, + onDismiss: () -> Unit +) { + MeshifySelectionDialog( + title = stringResource(R.string.settings_dialog_font_size), + options = listOf(0.8f, 1.0f, 1.2f, 1.5f), + selectedOption = fontSizeScale, + onOptionSelected = { scale -> onFontSizeSelected(scale) }, + onDismiss = onDismiss, + optionLabel = { scale -> "${(scale * 100).toInt()}%" }, + optionIcon = { _ -> Icons.Default.TextFields } + ) +} diff --git a/feature/settings/src/main/java/com/p2p/meshify/feature/settings/SettingsLanguageDialog.kt b/feature/settings/src/main/java/com/p2p/meshify/feature/settings/SettingsLanguageDialog.kt new file mode 100644 index 00000000..ec0412f9 --- /dev/null +++ b/feature/settings/src/main/java/com/p2p/meshify/feature/settings/SettingsLanguageDialog.kt @@ -0,0 +1,37 @@ +package com.p2p.meshify.feature.settings + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Language +import androidx.compose.material.icons.filled.Translate +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.p2p.meshify.core.common.R +import com.p2p.meshify.core.ui.components.MeshifySelectionDialog +import com.p2p.meshify.core.ui.hooks.HapticPattern +import com.p2p.meshify.core.ui.hooks.LocalPremiumHaptics + +/** + * MD3E wrapper around [MeshifySelectionDialog] for choosing the app language. + */ +@Composable +fun SettingsLanguageDialog( + appLanguage: String, + onDismiss: () -> Unit, + onLanguageSelected: (String) -> Unit +) { + val haptics = LocalPremiumHaptics.current + val arabicLabel = stringResource(R.string.settings_language_arabic) + val englishLabel = stringResource(R.string.settings_language_english) + MeshifySelectionDialog( + title = stringResource(R.string.settings_dialog_select_language), + options = listOf("en", "ar"), + selectedOption = appLanguage, + onOptionSelected = { lang -> + haptics.perform(HapticPattern.Pop) + onLanguageSelected(lang) + }, + onDismiss = onDismiss, + optionLabel = { lang -> if (lang == "ar") arabicLabel else englishLabel }, + optionIcon = { lang -> if (lang == "ar") Icons.Default.Language else Icons.Default.Translate } + ) +} diff --git a/feature/settings/src/main/java/com/p2p/meshify/feature/settings/SettingsNameDialog.kt b/feature/settings/src/main/java/com/p2p/meshify/feature/settings/SettingsNameDialog.kt new file mode 100644 index 00000000..fc01c1b0 --- /dev/null +++ b/feature/settings/src/main/java/com/p2p/meshify/feature/settings/SettingsNameDialog.kt @@ -0,0 +1,32 @@ +package com.p2p.meshify.feature.settings + +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.res.stringResource +import com.p2p.meshify.core.common.R +import com.p2p.meshify.core.ui.components.MeshifyTextInputDialog + +/** + * MD3E wrapper around [MeshifyTextInputDialog] for editing the display name. + */ +@Composable +fun SettingsNameDialog( + displayName: String, + errorText: String?, + onConfirm: (String) -> Unit, + onDismiss: () -> Unit +) { + var nameInput by remember(displayName) { mutableStateOf(displayName) } + MeshifyTextInputDialog( + title = stringResource(R.string.settings_dialog_edit_name), + value = nameInput, + onValueChange = { nameInput = it }, + onConfirm = { onConfirm(nameInput) }, + onDismiss = onDismiss, + placeholder = stringResource(R.string.settings_dialog_name_placeholder), + errorText = errorText + ) +} diff --git a/feature/settings/src/main/java/com/p2p/meshify/feature/settings/SettingsScreen.kt b/feature/settings/src/main/java/com/p2p/meshify/feature/settings/SettingsScreen.kt index 332d810d..2e9b923c 100644 --- a/feature/settings/src/main/java/com/p2p/meshify/feature/settings/SettingsScreen.kt +++ b/feature/settings/src/main/java/com/p2p/meshify/feature/settings/SettingsScreen.kt @@ -1,52 +1,41 @@ package com.p2p.meshify.feature.settings -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context +import android.content.Intent import androidx.activity.ComponentActivity import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.spring -import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.* -import androidx.compose.material.icons.filled.Bluetooth -import androidx.compose.material.icons.filled.BluetoothDisabled -import androidx.compose.material.icons.filled.ChevronRight -import androidx.compose.material.icons.automirrored.filled.BluetoothSearching +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +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 -import androidx.compose.ui.draw.scale -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import coil3.compose.AsyncImage -import coil3.request.ImageRequest -import coil3.request.crossfade +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.p2p.meshify.core.common.R import com.p2p.meshify.core.util.FileUtils -import com.p2p.meshify.domain.model.TransportMode -import com.p2p.meshify.domain.repository.ThemeMode -import com.p2p.meshify.core.ui.components.MeshifySettingsGroup -import com.p2p.meshify.core.ui.components.MeshifySettingsItem -import com.p2p.meshify.core.ui.components.SeedColorPickerGrid -import com.p2p.meshify.core.ui.components.MeshifyTextInputDialog -import com.p2p.meshify.core.ui.components.ThemeSelectionBottomSheet -import com.p2p.meshify.core.ui.components.MeshifySelectionDialog import com.p2p.meshify.core.ui.components.MeshifyAvatar import com.p2p.meshify.core.ui.hooks.HapticPattern import com.p2p.meshify.core.ui.hooks.LocalPremiumHaptics @@ -54,8 +43,8 @@ import com.p2p.meshify.core.ui.theme.MeshifyDesignSystem import java.io.File /** - * Deep Refactored Settings Screen with MD3E aesthetic. - * Structured groups, interactive items, and proper ViewModel binding. + * Settings screen — structured groups, interactive items, and proper ViewModel binding. + * Sections and dialogs/sheets are extracted into single-responsibility composables in this package. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -67,13 +56,9 @@ fun SettingsScreen( val context = LocalContext.current val haptics = LocalPremiumHaptics.current - // Unified SettingsUiState — single collectAsState replacing 16+ individual flows val state by viewModel.settingsUiState.collectAsStateWithLifecycle() val appVersion = viewModel.appVersion - // Derived state from unified state - val seedColor = remember(state.seedColor) { Color(state.seedColor) } - // UI State for dialogs and bottom sheets var showNameDialog by remember { mutableStateOf(false) } var showThemeSheet by remember { mutableStateOf(false) } @@ -82,7 +67,6 @@ fun SettingsScreen( var showBackupDialog by remember { mutableStateOf(false) } var showBleSheet by remember { mutableStateOf(false) } var showCreditsDialog by remember { mutableStateOf(false) } - var nameInput by remember { mutableStateOf(state.displayName) } var cacheStatus by remember { mutableStateOf(null) } var backupStatus by remember { mutableStateOf(null) } @@ -102,13 +86,49 @@ fun SettingsScreen( uri?.let { viewModel.updateAvatar(context, it) } } + // Single SnackbarHostState for all snackbar messages + val snackbarHostState = remember { SnackbarHostState() } + + val cacheSuccess = stringResource(R.string.settings_cache_cleared_success) + val cacheError = stringResource(R.string.settings_cache_cleared_error) + + val onEditName = { + haptics.perform(HapticPattern.Pop) + showNameDialog = true + } + val onOpenThemeSheet = { + haptics.perform(HapticPattern.Pop) + showThemeSheet = true + } + val onOpenBleSheet = { + haptics.perform(HapticPattern.Pop) + showBleSheet = true + } + val onOpenLanguage = { showLanguageDialog = true } + val onOpenFontSize = { showFontSizeDialog = true } + val onClearCache = { + haptics.perform(HapticPattern.Pop) + viewModel.clearCache { result -> + cacheStatus = if (result.isSuccess) cacheSuccess else cacheError + } + } + val onOpenBackup = { showBackupDialog = true } + val onOpenGithub = { + context.startActivity(Intent(Intent.ACTION_VIEW, android.net.Uri.parse("https://github.com/Yussefgafer/Meshify"))) + } + val onOpenCredits = { + haptics.perform(HapticPattern.Pop) + showCreditsDialog = true + } + Scaffold( + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, topBar = { - LargeTopAppBar( + CenterAlignedTopAppBar( title = { Text( text = stringResource(R.string.screen_settings_title), - fontWeight = FontWeight.Black + fontWeight = FontWeight.Bold ) }, navigationIcon = { @@ -119,7 +139,7 @@ fun SettingsScreen( ) } }, - colors = TopAppBarDefaults.largeTopAppBarColors( + colors = TopAppBarDefaults.topAppBarColors( containerColor = MaterialTheme.colorScheme.background, scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer ) @@ -136,393 +156,104 @@ fun SettingsScreen( ) { Spacer(Modifier.height(MeshifyDesignSystem.Spacing.Lg)) - // Avatar - MeshifyAvatar( - avatarHash = state.avatarHash, - initials = state.displayName.take(2), - size = 120.dp, - modifier = Modifier.clickable { - haptics.perform(HapticPattern.Pop) - imagePickerLauncher.launch("image/*") + // Avatar inside an expressive tonal container + Surface( + color = MaterialTheme.colorScheme.primaryContainer, + shape = RoundedCornerShape(24.dp), + modifier = Modifier + .clip(RoundedCornerShape(24.dp)) + .clickable { + haptics.perform(HapticPattern.Pop) + imagePickerLauncher.launch("image/*") + } + ) { + Box( + modifier = Modifier.padding(MeshifyDesignSystem.Spacing.Md), + contentAlignment = Alignment.Center + ) { + MeshifyAvatar( + avatarHash = state.avatarHash, + initials = state.displayName.take(2), + size = 120.dp + ) } - ) + } Spacer(Modifier.height(MeshifyDesignSystem.Spacing.Md)) // Display Name Text( text = state.displayName, - style = MaterialTheme.typography.headlineSmall, + style = MaterialTheme.typography.headlineLarge, fontWeight = FontWeight.ExtraBold ) - // Device ID (short) - Text( - text = if (state.deviceIdLoaded) state.deviceId.take(8).uppercase() else stringResource(R.string.settings_device_id_loading), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.7f) - ) - - Spacer(Modifier.height(MeshifyDesignSystem.Spacing.Xl)) - - // === SECTION 1: IDENTITY & PROFILE === - MeshifySettingsGroup(title = stringResource(R.string.settings_section_identity)) { - // Display Name Item - MeshifySettingsItem( - title = stringResource(R.string.setting_display_name), - subtitle = state.displayName, - icon = Icons.Default.Person, - onClick = { - haptics.perform(HapticPattern.Pop) // UX04: Haptic feedback - nameInput = state.displayName - showNameDialog = true - } - ) - - HorizontalDivider( - modifier = Modifier.padding(horizontal = MeshifyDesignSystem.Spacing.Md), - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) - ) - - // Device ID Item (Copy full ID on click) - val deviceTitle = stringResource(R.string.setting_device_id) - val deviceSuffix = stringResource(R.string.settings_label_device_id_suffix) - MeshifySettingsItem( - title = deviceTitle, - subtitle = state.deviceId.take(8).uppercase() + deviceSuffix, - icon = Icons.Default.Fingerprint, - onClick = { - val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val clip = ClipData.newPlainText(deviceTitle, state.deviceId) - clipboard.setPrimaryClip(clip) - haptics.perform(HapticPattern.Success) // UX04: Haptic feedback on copy - } - ) - } - - // === SECTION 2: LOOK & FEEL === - MeshifySettingsGroup(title = stringResource(R.string.settings_section_appearance)) { - // Theme Mode - MeshifySettingsItem( - title = stringResource(R.string.settings_theme_mode), - subtitle = when (state.themeMode) { - ThemeMode.LIGHT -> stringResource(R.string.settings_theme_light) - ThemeMode.DARK -> stringResource(R.string.settings_theme_dark) - ThemeMode.SYSTEM -> stringResource(R.string.settings_theme_system) - }, - icon = Icons.Default.Palette, - onClick = { - haptics.perform(HapticPattern.Pop) // UX04: Haptic feedback - showThemeSheet = true - } - ) - - HorizontalDivider( - modifier = Modifier.padding(horizontal = MeshifyDesignSystem.Spacing.Md), - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) - ) - - // Dynamic Colors - MeshifySettingsItem( - title = stringResource(R.string.settings_dynamic_colors), - subtitle = stringResource(R.string.settings_dynamic_colors_desc), - icon = Icons.Default.ColorLens, - trailing = { - Switch( - checked = state.dynamicColorEnabled, - onCheckedChange = { - haptics.perform(HapticPattern.Tick) - viewModel.setDynamicColor(it) - } - ) - } - ) - - // Seed Color Picker (only when dynamic colors is OFF) - if (!state.dynamicColorEnabled) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(MeshifyDesignSystem.Spacing.Md) - ) { - Text( - text = stringResource(R.string.settings_label_accent_color), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(bottom = MeshifyDesignSystem.Spacing.Sm) - ) - SeedColorPickerGrid( - selectedColor = seedColor, - onColorSelected = { color -> - haptics.perform(HapticPattern.Pop) - viewModel.setSeedColor(color) - } - ) - } - } - } - - // === SECTION 4: PRIVACY & VISIBILITY === - MeshifySettingsGroup(title = stringResource(R.string.settings_section_privacy)) { - MeshifySettingsItem( - title = stringResource(R.string.settings_visibility), - subtitle = stringResource(R.string.settings_visibility_desc), - icon = Icons.Default.Visibility, - trailing = { - Switch( - checked = state.isNetworkVisible, - onCheckedChange = { - haptics.perform(HapticPattern.Tick) - viewModel.setNetworkVisibility(it) - } - ) - } - ) - } - - // === SECTION 4.5: BLUETOOTH TRANSPORT === - MeshifySettingsGroup(title = stringResource(R.string.settings_section_network)) { - // Bluetooth Toggle - MeshifySettingsItem( - title = stringResource(R.string.setting_bluetooth), - subtitle = stringResource(R.string.setting_bluetooth_desc), - icon = Icons.Default.Bluetooth, - trailing = { - Switch( - checked = state.bleEnabled, - onCheckedChange = { - haptics.perform(HapticPattern.Tick) - viewModel.setBleEnabled(it) - } - ) - } - ) + Spacer(Modifier.height(MeshifyDesignSystem.Spacing.Xs)) - HorizontalDivider( - modifier = Modifier.padding(horizontal = MeshifyDesignSystem.Spacing.Md), - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) - ) - - // BLE Status row (opens bottom sheet) - MeshifySettingsItem( - title = stringResource(R.string.setting_bluetooth_status_title), - subtitle = if (state.bleEnabled) stringResource(R.string.setting_bluetooth_status_active) else stringResource(R.string.setting_bluetooth_status_inactive), - icon = Icons.AutoMirrored.Filled.BluetoothSearching, - trailing = { - Icon( - imageVector = Icons.Default.ChevronRight, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - }, - onClick = { - haptics.perform(HapticPattern.Pop) - showBleSheet = true - } + // Device ID (short) as a pill + Surface( + color = MaterialTheme.colorScheme.primaryContainer, + shape = CircleShape, + modifier = Modifier.clip(CircleShape) + ) { + Text( + text = if (state.deviceIdLoaded) state.deviceId.take(8).uppercase() else stringResource(R.string.settings_device_id_loading), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.padding( + horizontal = MeshifyDesignSystem.Spacing.Md, + vertical = MeshifyDesignSystem.Spacing.Xxs + ) ) } - // SECTION 5: APP SETTINGS - MeshifySettingsGroup(title = stringResource(R.string.settings_group_app)) { - // Language - MeshifySettingsItem( - title = stringResource(R.string.setting_language), - subtitle = if (state.appLanguage == "ar") stringResource(R.string.settings_language_arabic) else stringResource(R.string.settings_language_english), - icon = Icons.Default.Language, - onClick = { showLanguageDialog = true } - ) - - HorizontalDivider( - modifier = Modifier.padding(horizontal = MeshifyDesignSystem.Spacing.Md), - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) - ) - - // Font Size - MeshifySettingsItem( - title = stringResource(R.string.setting_font_size), - subtitle = "${(state.fontSizeScale * 100).toInt()}%", - icon = Icons.Default.TextFields, - onClick = { showFontSizeDialog = true } - ) - - HorizontalDivider( - modifier = Modifier.padding(horizontal = MeshifyDesignSystem.Spacing.Md), - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) - ) - - // Notifications - MeshifySettingsItem( - title = stringResource(R.string.setting_notifications), - subtitle = if (state.notificationsEnabled) stringResource(R.string.settings_status_enabled) else stringResource(R.string.settings_status_disabled), - icon = Icons.Default.Notifications, - trailing = { - Switch( - checked = state.notificationsEnabled, - onCheckedChange = { - haptics.perform(HapticPattern.Tick) - viewModel.setNotificationsEnabled(it) - } - ) - } - ) - - HorizontalDivider( - modifier = Modifier.padding(horizontal = MeshifyDesignSystem.Spacing.Md), - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) - ) - - // Haptic Feedback - MeshifySettingsItem( - title = stringResource(R.string.setting_haptic_feedback), - subtitle = stringResource(R.string.setting_haptic_feedback_desc), - icon = Icons.Default.Vibration, - trailing = { - Switch( - checked = state.hapticFeedbackEnabled, - onCheckedChange = { - haptics.perform(HapticPattern.Tick) - viewModel.setHapticFeedback(it) - } - ) - } - ) - - HorizontalDivider( - modifier = Modifier.padding(horizontal = MeshifyDesignSystem.Spacing.Md), - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) - ) - - // Notification Sound - MeshifySettingsItem( - title = stringResource(R.string.setting_notification_sound), - subtitle = stringResource(R.string.setting_notification_sound_desc), - icon = Icons.Filled.VolumeUp, - trailing = { - Switch( - checked = state.notificationSound, - enabled = state.notificationsEnabled, - onCheckedChange = { - haptics.perform(HapticPattern.Tick) - viewModel.setNotificationSound(it) - } - ) - } - ) - - HorizontalDivider( - modifier = Modifier.padding(horizontal = MeshifyDesignSystem.Spacing.Md), - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) - ) - - // Notification Vibrate - MeshifySettingsItem( - title = stringResource(R.string.setting_vibration), - subtitle = stringResource(R.string.setting_vibration_desc), - icon = Icons.Default.Vibration, - trailing = { - Switch( - checked = state.notificationVibrate, - enabled = state.notificationsEnabled, - onCheckedChange = { - haptics.perform(HapticPattern.Tick) - viewModel.setNotificationVibrate(it) - } - ) - } - ) - - HorizontalDivider( - modifier = Modifier.padding(horizontal = MeshifyDesignSystem.Spacing.Md), - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) - ) - - // Clear Cache - val cacheSuccess = stringResource(R.string.settings_cache_cleared_success) - val cacheError = stringResource(R.string.settings_cache_cleared_error) - MeshifySettingsItem( - title = stringResource(R.string.setting_clear_cache), - subtitle = stringResource(R.string.setting_clear_cache_desc), - icon = Icons.Default.DeleteSweep, - onClick = { - haptics.perform(HapticPattern.Pop) - viewModel.clearCache { result -> - cacheStatus = if (result.isSuccess) cacheSuccess else cacheError - } - } - ) - - HorizontalDivider( - modifier = Modifier.padding(horizontal = MeshifyDesignSystem.Spacing.Md), - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) - ) + Spacer(Modifier.height(MeshifyDesignSystem.Spacing.Lg)) - // Backup/Restore - MeshifySettingsItem( - title = stringResource(R.string.settings_backup_title), - subtitle = stringResource(R.string.settings_backup_desc), - icon = Icons.Default.CloudUpload, - onClick = { showBackupDialog = true } - ) - } + IdentitySection( + state = state, + viewModel = viewModel, + haptics = haptics, + onEditName = onEditName + ) - // === SECTION 6: ABOUT === - MeshifySettingsGroup(title = stringResource(R.string.settings_section_info)) { - // App Version with easter egg (tap 7 times to unlock developer mode) - val versionTapCount = remember { mutableIntStateOf(0) } - val lastTapTime = remember { mutableLongStateOf(0L) } - - MeshifySettingsItem( - title = stringResource(R.string.setting_app_version), - subtitle = appVersion, - icon = Icons.Default.Info, - onClick = { - val now = System.currentTimeMillis() - if (now - lastTapTime.longValue > 2000) { - versionTapCount.intValue = 1 - } else { - versionTapCount.intValue++ - } - lastTapTime.longValue = now - haptics.perform(HapticPattern.Tick) - - if (versionTapCount.intValue >= 7) { - versionTapCount.intValue = 0 - haptics.perform(HapticPattern.Success) - onDeveloperModeClick() - } - } - ) + AppearanceSection( + state = state, + viewModel = viewModel, + haptics = haptics, + onOpenThemeSheet = onOpenThemeSheet + ) - HorizontalDivider( - modifier = Modifier.padding(horizontal = MeshifyDesignSystem.Spacing.Md), - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) - ) + PrivacySection( + state = state, + viewModel = viewModel, + haptics = haptics + ) - MeshifySettingsItem( - title = stringResource(R.string.settings_label_github_repo), - subtitle = stringResource(R.string.settings_label_github_desc), - icon = Icons.Default.Code, - onClick = { - val intent = android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse("https://github.com/Yussefgafer/Meshify")) - context.startActivity(intent) - } - ) + NetworkSection( + state = state, + viewModel = viewModel, + haptics = haptics, + onOpenBleSheet = onOpenBleSheet + ) - HorizontalDivider( - modifier = Modifier.padding(horizontal = MeshifyDesignSystem.Spacing.Md), - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) - ) + AppSettingsSection( + state = state, + viewModel = viewModel, + haptics = haptics, + onOpenLanguage = onOpenLanguage, + onOpenFontSize = onOpenFontSize, + onClearCache = onClearCache, + onOpenBackup = onOpenBackup + ) - MeshifySettingsItem( - title = stringResource(R.string.settings_label_credits), - subtitle = stringResource(R.string.settings_label_credits_desc), - icon = Icons.Default.Favorite, - onClick = { - haptics.perform(HapticPattern.Pop) - showCreditsDialog = true - } - ) - } + AboutSection( + appVersion = appVersion, + haptics = haptics, + onDeveloperModeClick = onDeveloperModeClick, + onOpenGithub = onOpenGithub, + onOpenCredits = onOpenCredits + ) Spacer(Modifier.height(MeshifyDesignSystem.Spacing.Xxl)) } @@ -530,145 +261,54 @@ fun SettingsScreen( // === DIALOGS & BOTTOM SHEETS === - // Display Name Dialog if (showNameDialog) { - MeshifyTextInputDialog( - title = stringResource(R.string.settings_dialog_edit_name), - value = nameInput, - onValueChange = { nameInput = it }, - onConfirm = { viewModel.updateDisplayName(nameInput) }, - onDismiss = { showNameDialog = false }, - placeholder = stringResource(R.string.settings_dialog_name_placeholder), - errorText = state.displayNameError + SettingsNameDialog( + displayName = state.displayName, + errorText = state.displayNameError, + onConfirm = { viewModel.updateDisplayName(it) }, + onDismiss = { showNameDialog = false } ) } - // Theme Selection Bottom Sheet if (showThemeSheet) { - ThemeSelectionBottomSheet( + SettingsThemeSheet( currentTheme = state.themeMode, - onThemeSelected = { - haptics.perform(HapticPattern.Selection) - viewModel.setThemeMode(it) - }, + onThemeSelected = { viewModel.setThemeMode(it) }, onDismiss = { showThemeSheet = false } ) } - // Language Selection Dialog if (showLanguageDialog) { - val arabicLabel = stringResource(R.string.settings_language_arabic) - val englishLabel = stringResource(R.string.settings_language_english) - MeshifySelectionDialog( - title = stringResource(R.string.settings_dialog_select_language), - options = listOf("en", "ar"), - selectedOption = state.appLanguage, - onOptionSelected = { lang -> - haptics.perform(HapticPattern.Pop) + SettingsLanguageDialog( + appLanguage = state.appLanguage, + onDismiss = { showLanguageDialog = false }, + onLanguageSelected = { lang -> viewModel.setAppLanguage(lang) (context as? ComponentActivity)?.recreate() showLanguageDialog = false - }, - onDismiss = { showLanguageDialog = false }, - optionLabel = { lang -> if (lang == "ar") arabicLabel else englishLabel }, - optionIcon = { lang -> if (lang == "ar") Icons.Default.Language else Icons.Default.Translate } + } ) } - // Font Size Dialog if (showFontSizeDialog) { - MeshifySelectionDialog( - title = stringResource(R.string.settings_dialog_font_size), - options = listOf(0.8f, 1.0f, 1.2f, 1.5f), - selectedOption = state.fontSizeScale, - onOptionSelected = { scale -> + SettingsFontSizeDialog( + fontSizeScale = state.fontSizeScale, + onFontSizeSelected = { scale -> haptics.perform(HapticPattern.Pop) viewModel.setFontSizeScale(scale) - showFontSizeDialog = false }, - onDismiss = { showFontSizeDialog = false }, - optionLabel = { scale -> "${(scale * 100).toInt()}%" }, - optionIcon = { _ -> Icons.Default.TextFields } + onDismiss = { showFontSizeDialog = false } ) } - // Backup & Restore Dialog if (showBackupDialog) { - val resources = LocalContext.current.resources - AlertDialog( - onDismissRequest = { showBackupDialog = false }, - title = { Text(stringResource(R.string.settings_backup_title)) }, - text = { Text(stringResource(R.string.settings_backup_desc)) }, - confirmButton = { - Button( - onClick = { - haptics.perform(HapticPattern.Pop) - viewModel.exportBackup { result -> - backupStatus = result.fold( - onSuccess = { resources.getString(R.string.settings_backup_export_success) }, - onFailure = { resources.getString(R.string.settings_backup_export_error, it.message) } - ) - } - showBackupDialog = false - } - ) { - Text(stringResource(R.string.settings_backup_export)) - } - }, - dismissButton = { - TextButton( - onClick = { - // Import functionality (requires file picker) - haptics.perform(HapticPattern.Pop) - backupStatus = resources.getString(R.string.settings_backup_import_soon) - showBackupDialog = false - } - ) { - Text(stringResource(R.string.settings_backup_import)) - } - } + SettingsBackupDialog( + onExportResult = { backupStatus = it }, + onDismiss = { showBackupDialog = false }, + viewModel = viewModel ) } - // Cache Status Message - if (cacheStatus != null) { - LaunchedEffect(cacheStatus) { - kotlinx.coroutines.delay(3000) - cacheStatus = null - } - - Snackbar( - modifier = Modifier.padding(MeshifyDesignSystem.Spacing.Md), - action = { - IconButton(onClick = { cacheStatus = null }) { - Icon(Icons.Default.Close, contentDescription = stringResource(R.string.content_desc_close), tint = Color.White) - } - } - ) { - Text(cacheStatus ?: "") - } - } - - // Backup Status Message - if (backupStatus != null) { - LaunchedEffect(backupStatus) { - kotlinx.coroutines.delay(3000) - backupStatus = null - } - - Snackbar( - modifier = Modifier.padding(MeshifyDesignSystem.Spacing.Md), - action = { - IconButton(onClick = { backupStatus = null }) { - Icon(Icons.Default.Close, contentDescription = stringResource(R.string.content_desc_close), tint = Color.White) - } - } - ) { - Text(backupStatus ?: "") - } - } - - // BLE Status Bottom Sheet if (showBleSheet) { BleStatusBottomSheet( bleEnabled = state.bleEnabled, @@ -678,126 +318,46 @@ fun SettingsScreen( ) } - // Credits Dialog if (showCreditsDialog) { - AlertDialog( - onDismissRequest = { showCreditsDialog = false }, - title = { - Text( - text = stringResource(R.string.settings_credits_title), - fontWeight = FontWeight.ExtraBold - ) - }, - text = { Text(stringResource(R.string.settings_credits_text)) }, - confirmButton = { - TextButton(onClick = { showCreditsDialog = false }) { - Text(stringResource(R.string.dialog_btn_close)) - } - } + SettingsCreditsDialog( + onDismiss = { showCreditsDialog = false } ) } -} - -/** - * BLE Status Bottom Sheet — shows BLE state, connected peers, and transport mode selector. - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun BleStatusBottomSheet( - bleEnabled: Boolean, - transportMode: TransportMode, - onModeSelected: (TransportMode) -> Unit, - onDismiss: () -> Unit -) { - val haptics = LocalPremiumHaptics.current - - ModalBottomSheet( - onDismissRequest = onDismiss, - containerColor = MaterialTheme.colorScheme.surfaceContainerLow - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(MeshifyDesignSystem.Spacing.Md) - .verticalScroll(rememberScrollState()) - ) { - // Title - Text( - text = stringResource(R.string.ble_sheet_title), - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.ExtraBold, - modifier = Modifier.padding(bottom = MeshifyDesignSystem.Spacing.Md) + // Cache Status Message via SnackbarHost + LaunchedEffect(cacheStatus) { + if (cacheStatus != null) { + snackbarHostState.showSnackbar( + message = cacheStatus!!, + withDismissAction = true, + duration = SnackbarDuration.Short ) + cacheStatus = null + } + } - // Status Header - Surface( - modifier = Modifier.fillMaxWidth(), - shape = MeshifyDesignSystem.Shapes.CardSmall, - color = if (bleEnabled) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant - ) { - Column( - modifier = Modifier.padding(MeshifyDesignSystem.Spacing.Md) - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(MeshifyDesignSystem.Spacing.Sm) - ) { - Icon( - imageVector = if (bleEnabled) Icons.AutoMirrored.Filled.BluetoothSearching else Icons.Default.BluetoothDisabled, - contentDescription = null, - tint = if (bleEnabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant - ) - Text( - text = if (bleEnabled) stringResource(R.string.ble_sheet_active) else stringResource(R.string.ble_sheet_inactive), - style = MaterialTheme.typography.titleMedium, - color = if (bleEnabled) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } - - Spacer(Modifier.height(MeshifyDesignSystem.Spacing.Md)) - - // Transport Mode Selector - Text( - text = stringResource(R.string.ble_transport_mode_title), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(bottom = MeshifyDesignSystem.Spacing.Sm) + // Backup Status Message via SnackbarHost + LaunchedEffect(backupStatus) { + if (backupStatus != null) { + snackbarHostState.showSnackbar( + message = backupStatus!!, + withDismissAction = true, + duration = SnackbarDuration.Short ) + backupStatus = null + } + } - // Mode chips in a row - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(MeshifyDesignSystem.Spacing.Sm), - verticalAlignment = Alignment.CenterVertically - ) { - TransportMode.entries.forEach { mode -> - val isSelected = mode == transportMode - FilterChip( - selected = isSelected, - onClick = { - haptics.perform(HapticPattern.Tick) - onModeSelected(mode) - }, - label = { - Text( - text = when (mode) { - TransportMode.MULTI_PATH -> stringResource(R.string.ble_transport_mode_multipath) - TransportMode.LAN_ONLY -> stringResource(R.string.ble_transport_mode_lan) - TransportMode.BLE_ONLY -> stringResource(R.string.ble_transport_mode_ble) - TransportMode.AUTO -> stringResource(R.string.ble_transport_mode_auto) - }, - style = MaterialTheme.typography.labelMedium - ) - }, - modifier = Modifier.weight(1f) - ) - } - } - - Spacer(Modifier.height(MeshifyDesignSystem.Spacing.Lg)) + // Error Message from ViewModel via SnackbarHost + val errorMessage by viewModel.errorMessage.collectAsState() + LaunchedEffect(errorMessage) { + if (errorMessage != null) { + snackbarHostState.showSnackbar( + message = errorMessage!!, + withDismissAction = true, + duration = SnackbarDuration.Short + ) + viewModel.clearError() } } } diff --git a/feature/settings/src/main/java/com/p2p/meshify/feature/settings/SettingsSections.kt b/feature/settings/src/main/java/com/p2p/meshify/feature/settings/SettingsSections.kt new file mode 100644 index 00000000..db7c0804 --- /dev/null +++ b/feature/settings/src/main/java/com/p2p/meshify/feature/settings/SettingsSections.kt @@ -0,0 +1,533 @@ +package com.p2p.meshify.feature.settings + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.BluetoothSearching +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.automirrored.filled.VolumeUp +import androidx.compose.material.icons.filled.* +import androidx.compose.material.icons.outlined.* +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.mutableIntStateOf +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.compose.ui.res.stringResource +import com.p2p.meshify.core.common.R +import com.p2p.meshify.core.ui.components.SeedColorPickerGrid +import com.p2p.meshify.core.ui.hooks.HapticPattern +import com.p2p.meshify.core.ui.hooks.PremiumHaptics +import com.p2p.meshify.core.ui.theme.MeshifyDesignSystem +import com.p2p.meshify.domain.repository.ThemeMode + +@Composable +fun IdentitySection( + state: SettingsUiState, + viewModel: SettingsViewModel, + haptics: PremiumHaptics, + onEditName: () -> Unit +) { + val context = LocalContext.current + val deviceTitle = stringResource(R.string.setting_device_id) + val deviceSuffix = stringResource(R.string.settings_label_device_id_suffix) + + SettingsSection( + title = stringResource(R.string.settings_section_identity), + icon = { + Icon( + Icons.Outlined.Person, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } + ) { + SettingsItem( + title = stringResource(R.string.setting_display_name), + subtitle = state.displayName, + leadingIcon = { + Icon( + Icons.Filled.Person, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + trailingIcon = { + Icon( + Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + onClick = onEditName + ) + + SettingsItem( + title = deviceTitle, + subtitle = state.deviceId.take(8).uppercase() + deviceSuffix, + leadingIcon = { + Icon( + Icons.Filled.Fingerprint, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + trailingIcon = { + Icon( + Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + onClick = { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText(deviceTitle, state.deviceId) + clipboard.setPrimaryClip(clip) + haptics.perform(HapticPattern.Success) + } + ) + } +} + +@Composable +fun AppearanceSection( + state: SettingsUiState, + viewModel: SettingsViewModel, + haptics: PremiumHaptics, + onOpenThemeSheet: () -> Unit +) { + val seedColor = remember(state.seedColor) { Color(state.seedColor) } + + SettingsSection( + title = stringResource(R.string.settings_section_appearance), + icon = { + Icon( + Icons.Outlined.Palette, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } + ) { + SettingsItem( + title = stringResource(R.string.settings_theme_mode), + subtitle = when (state.themeMode) { + ThemeMode.LIGHT -> stringResource(R.string.settings_theme_light) + ThemeMode.DARK -> stringResource(R.string.settings_theme_dark) + ThemeMode.SYSTEM -> stringResource(R.string.settings_theme_system) + }, + leadingIcon = { + Icon( + Icons.Filled.Palette, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + trailingIcon = { + Icon( + Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + onClick = onOpenThemeSheet + ) + + SwitchSettingItem( + title = stringResource(R.string.settings_dynamic_colors), + subtitle = stringResource(R.string.settings_dynamic_colors_desc), + checked = state.dynamicColorEnabled, + onCheckedChange = { viewModel.setDynamicColor(it) }, + leadingIcon = { + Icon( + Icons.Filled.ColorLens, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + ) + + if (!state.dynamicColorEnabled) { + Surface( + color = MaterialTheme.colorScheme.surfaceContainer, + shape = RoundedCornerShape(10.dp), + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(MeshifyDesignSystem.Spacing.Md), + verticalArrangement = Arrangement.spacedBy(MeshifyDesignSystem.Spacing.Sm) + ) { + Text( + text = stringResource(R.string.settings_label_accent_color), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + SeedColorPickerGrid( + selectedColor = seedColor, + onColorSelected = { color -> + haptics.perform(HapticPattern.Pop) + viewModel.setSeedColor(color) + } + ) + } + } + } + } +} + +@Composable +fun PrivacySection( + state: SettingsUiState, + viewModel: SettingsViewModel, + haptics: PremiumHaptics +) { + SettingsSection( + title = stringResource(R.string.settings_section_privacy), + icon = { + Icon( + Icons.Outlined.Visibility, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } + ) { + SwitchSettingItem( + title = stringResource(R.string.settings_visibility), + subtitle = stringResource(R.string.settings_visibility_desc), + checked = state.isNetworkVisible, + onCheckedChange = { viewModel.setNetworkVisibility(it) }, + leadingIcon = { + Icon( + Icons.Filled.Visibility, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + ) + } +} + +@Composable +fun NetworkSection( + state: SettingsUiState, + viewModel: SettingsViewModel, + haptics: PremiumHaptics, + onOpenBleSheet: () -> Unit +) { + SettingsSection( + title = stringResource(R.string.settings_section_network), + icon = { + Icon( + Icons.Outlined.Bluetooth, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } + ) { + SwitchSettingItem( + title = stringResource(R.string.setting_bluetooth), + subtitle = stringResource(R.string.setting_bluetooth_desc), + checked = state.bleEnabled, + onCheckedChange = { viewModel.setBleEnabled(it) }, + leadingIcon = { + Icon( + Icons.Filled.Bluetooth, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + ) + + SettingsItem( + title = stringResource(R.string.setting_bluetooth_status_title), + subtitle = if (state.bleEnabled) stringResource(R.string.setting_bluetooth_status_active) else stringResource(R.string.setting_bluetooth_status_inactive), + leadingIcon = { + Icon( + Icons.AutoMirrored.Filled.BluetoothSearching, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + trailingIcon = { + Icon( + Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + onClick = onOpenBleSheet + ) + } +} + +@Composable +fun AppSettingsSection( + state: SettingsUiState, + viewModel: SettingsViewModel, + haptics: PremiumHaptics, + onOpenLanguage: () -> Unit, + onOpenFontSize: () -> Unit, + onClearCache: () -> Unit, + onOpenBackup: () -> Unit +) { + SettingsSection( + title = stringResource(R.string.settings_group_app), + icon = { + Icon( + Icons.Outlined.Settings, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } + ) { + SettingsItem( + title = stringResource(R.string.setting_language), + subtitle = if (state.appLanguage == "ar") stringResource(R.string.settings_language_arabic) else stringResource(R.string.settings_language_english), + leadingIcon = { + Icon( + Icons.Filled.Language, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + trailingIcon = { + Icon( + Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + onClick = onOpenLanguage + ) + + SettingsItem( + title = stringResource(R.string.setting_font_size), + subtitle = "${(state.fontSizeScale * 100).toInt()}%", + leadingIcon = { + Icon( + Icons.Filled.TextFields, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + trailingIcon = { + Icon( + Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + onClick = onOpenFontSize + ) + + SwitchSettingItem( + title = stringResource(R.string.setting_notifications), + subtitle = if (state.notificationsEnabled) stringResource(R.string.settings_status_enabled) else stringResource(R.string.settings_status_disabled), + checked = state.notificationsEnabled, + onCheckedChange = { viewModel.setNotificationsEnabled(it) }, + leadingIcon = { + Icon( + Icons.Filled.Notifications, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + ) + + SwitchSettingItem( + title = stringResource(R.string.setting_haptic_feedback), + subtitle = stringResource(R.string.setting_haptic_feedback_desc), + checked = state.hapticFeedbackEnabled, + onCheckedChange = { viewModel.setHapticFeedback(it) }, + leadingIcon = { + Icon( + Icons.Filled.Vibration, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + ) + + SwitchSettingItem( + title = stringResource(R.string.setting_notification_sound), + subtitle = stringResource(R.string.setting_notification_sound_desc), + checked = state.notificationSound, + enabled = state.notificationsEnabled, + onCheckedChange = { viewModel.setNotificationSound(it) }, + leadingIcon = { + Icon( + Icons.AutoMirrored.Filled.VolumeUp, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + ) + + SwitchSettingItem( + title = stringResource(R.string.setting_vibration), + subtitle = stringResource(R.string.setting_vibration_desc), + checked = state.notificationVibrate, + enabled = state.notificationsEnabled, + onCheckedChange = { viewModel.setNotificationVibrate(it) }, + leadingIcon = { + Icon( + Icons.Filled.Vibration, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + ) + + SettingsItem( + title = stringResource(R.string.setting_clear_cache), + subtitle = stringResource(R.string.setting_clear_cache_desc), + leadingIcon = { + Icon( + Icons.Filled.DeleteSweep, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + trailingIcon = { + Icon( + Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + onClick = onClearCache + ) + + SettingsItem( + title = stringResource(R.string.settings_backup_title), + subtitle = stringResource(R.string.settings_backup_desc), + leadingIcon = { + Icon( + Icons.Filled.CloudUpload, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + trailingIcon = { + Icon( + Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + onClick = onOpenBackup + ) + } +} + +@Composable +fun AboutSection( + appVersion: String, + haptics: PremiumHaptics, + onDeveloperModeClick: () -> Unit, + onOpenGithub: () -> Unit, + onOpenCredits: () -> Unit +) { + val versionTapCount = remember { mutableIntStateOf(0) } + val lastTapTime = remember { mutableLongStateOf(0L) } + + SettingsSection( + title = stringResource(R.string.settings_section_info), + icon = { + Icon( + Icons.Outlined.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } + ) { + SettingsItem( + title = stringResource(R.string.setting_app_version), + subtitle = appVersion, + leadingIcon = { + Icon( + Icons.Filled.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + trailingIcon = { + Icon( + Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + onClick = { + val now = System.currentTimeMillis() + if (now - lastTapTime.longValue > 2000) { + versionTapCount.intValue = 1 + } else { + versionTapCount.intValue++ + } + lastTapTime.longValue = now + haptics.perform(HapticPattern.Tick) + + if (versionTapCount.intValue >= 7) { + versionTapCount.intValue = 0 + haptics.perform(HapticPattern.Success) + onDeveloperModeClick() + } + } + ) + + SettingsItem( + title = stringResource(R.string.settings_label_github_repo), + subtitle = stringResource(R.string.settings_label_github_desc), + leadingIcon = { + Icon( + Icons.Filled.Code, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + trailingIcon = { + Icon( + Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + onClick = onOpenGithub + ) + + SettingsItem( + title = stringResource(R.string.settings_label_credits), + subtitle = stringResource(R.string.settings_label_credits_desc), + leadingIcon = { + Icon( + Icons.Filled.Favorite, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + trailingIcon = { + Icon( + Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + onClick = onOpenCredits + ) + } +} diff --git a/feature/settings/src/main/java/com/p2p/meshify/feature/settings/SettingsThemeSheet.kt b/feature/settings/src/main/java/com/p2p/meshify/feature/settings/SettingsThemeSheet.kt new file mode 100644 index 00000000..9c0b4670 --- /dev/null +++ b/feature/settings/src/main/java/com/p2p/meshify/feature/settings/SettingsThemeSheet.kt @@ -0,0 +1,30 @@ +package com.p2p.meshify.feature.settings + +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.p2p.meshify.core.ui.components.ThemeSelectionBottomSheet +import com.p2p.meshify.core.ui.hooks.HapticPattern +import com.p2p.meshify.core.ui.hooks.LocalPremiumHaptics +import com.p2p.meshify.domain.repository.ThemeMode + +/** + * MD3E wrapper around [ThemeSelectionBottomSheet] for choosing the theme mode. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsThemeSheet( + currentTheme: ThemeMode, + onThemeSelected: (ThemeMode) -> Unit, + onDismiss: () -> Unit +) { + val haptics = LocalPremiumHaptics.current + ThemeSelectionBottomSheet( + currentTheme = currentTheme, + onThemeSelected = { + haptics.perform(HapticPattern.Selection) + onThemeSelected(it) + }, + onDismiss = onDismiss + ) +} diff --git a/feature/settings/src/main/java/com/p2p/meshify/feature/settings/SettingsViewModel.kt b/feature/settings/src/main/java/com/p2p/meshify/feature/settings/SettingsViewModel.kt index af76d134..2031180f 100644 --- a/feature/settings/src/main/java/com/p2p/meshify/feature/settings/SettingsViewModel.kt +++ b/feature/settings/src/main/java/com/p2p/meshify/feature/settings/SettingsViewModel.kt @@ -28,6 +28,7 @@ data class SettingsUiState( val avatarHash: String? = null, val deviceId: String = "", val deviceIdLoaded: Boolean = false, + val appVersion: String = "", val seedColor: Int = 0xFF006D68.toInt(), val appLanguage: String = "en", val fontSizeScale: Float = 1.0f, @@ -46,6 +47,10 @@ class SettingsViewModel @Inject constructor( private val _settingsUiState = MutableStateFlow(SettingsUiState()) val settingsUiState: StateFlow = _settingsUiState + // Error feedback for failed DataStore writes — UI observes this for snackbar/toast + private val _errorMessage = MutableStateFlow(null) + val errorMessage: StateFlow = _errorMessage + init { val repo = settingsRepository repo.displayName.onEach { value -> _settingsUiState.value = _settingsUiState.value.copy(displayName = value) }.launchIn(viewModelScope) @@ -86,20 +91,48 @@ class SettingsViewModel @Inject constructor( } } + fun clearError() { + _errorMessage.value = null + } + fun setThemeMode(mode: ThemeMode) { - viewModelScope.launch { settingsRepository.setThemeMode(mode) } + viewModelScope.launch { + try { + settingsRepository.setThemeMode(mode) + } catch (e: Exception) { + _errorMessage.value = e.message ?: "Failed to save theme mode" + } + } } fun setHapticFeedback(enabled: Boolean) { - viewModelScope.launch { settingsRepository.setHapticFeedback(enabled) } + viewModelScope.launch { + try { + settingsRepository.setHapticFeedback(enabled) + } catch (e: Exception) { + _errorMessage.value = e.message ?: "Failed to save haptic feedback setting" + } + } } fun setDynamicColor(enabled: Boolean) { - viewModelScope.launch { settingsRepository.setDynamicColor(enabled) } + viewModelScope.launch { + try { + settingsRepository.setDynamicColor(enabled) + } catch (e: Exception) { + _errorMessage.value = e.message ?: "Failed to save dynamic color setting" + } + } } fun setNetworkVisibility(visible: Boolean) { - viewModelScope.launch { settingsRepository.setNetworkVisibility(visible) } + viewModelScope.launch { + try { + settingsRepository.setNetworkVisibility(visible) + } catch (e: Exception) { + _errorMessage.value = e.message ?: "Failed to save network visibility setting" + } + } } fun updateAvatar(context: Context, uri: Uri) { @@ -117,42 +150,88 @@ class SettingsViewModel @Inject constructor( fun setSeedColor(color: Color) { viewModelScope.launch { - val colorInt = android.graphics.Color.argb( - (color.alpha * 255).toInt(), - (color.red * 255).toInt(), - (color.green * 255).toInt(), - (color.blue * 255).toInt() - ) - settingsRepository.setSeedColor(colorInt) + try { + val colorInt = android.graphics.Color.argb( + (color.alpha * 255).toInt(), + (color.red * 255).toInt(), + (color.green * 255).toInt(), + (color.blue * 255).toInt() + ) + settingsRepository.setSeedColor(colorInt) + } catch (e: Exception) { + _errorMessage.value = e.message ?: "Failed to save seed color" + } } } fun setAppLanguage(language: String) { - viewModelScope.launch { settingsRepository.setAppLanguage(language) } + viewModelScope.launch { + try { + settingsRepository.setAppLanguage(language) + } catch (e: Exception) { + _errorMessage.value = e.message ?: "Failed to save app language" + } + } } fun setFontSizeScale(scale: Float) { - viewModelScope.launch { settingsRepository.setFontSizeScale(scale) } + viewModelScope.launch { + try { + settingsRepository.setFontSizeScale(scale) + } catch (e: Exception) { + _errorMessage.value = e.message ?: "Failed to save font size scale" + } + } } fun setNotificationsEnabled(enabled: Boolean) { - viewModelScope.launch { settingsRepository.setNotificationsEnabled(enabled) } + viewModelScope.launch { + try { + settingsRepository.setNotificationsEnabled(enabled) + } catch (e: Exception) { + _errorMessage.value = e.message ?: "Failed to save notifications enabled" + } + } } fun setNotificationSound(enabled: Boolean) { - viewModelScope.launch { settingsRepository.setNotificationSound(enabled) } + viewModelScope.launch { + try { + settingsRepository.setNotificationSound(enabled) + } catch (e: Exception) { + _errorMessage.value = e.message ?: "Failed to save notification sound" + } + } } fun setNotificationVibrate(enabled: Boolean) { - viewModelScope.launch { settingsRepository.setNotificationVibrate(enabled) } + viewModelScope.launch { + try { + settingsRepository.setNotificationVibrate(enabled) + } catch (e: Exception) { + _errorMessage.value = e.message ?: "Failed to save notification vibrate" + } + } } fun setBleEnabled(enabled: Boolean) { - viewModelScope.launch { settingsRepository.setBleEnabled(enabled) } + viewModelScope.launch { + try { + settingsRepository.setBleEnabled(enabled) + } catch (e: Exception) { + _errorMessage.value = e.message ?: "Failed to save BLE setting" + } + } } fun setTransportMode(mode: TransportMode) { - viewModelScope.launch { settingsRepository.setTransportMode(mode) } + viewModelScope.launch { + try { + settingsRepository.setTransportMode(mode) + } catch (e: Exception) { + _errorMessage.value = e.message ?: "Failed to save transport mode" + } + } } fun clearCache(onResult: (Result) -> Unit) { diff --git a/gradle.properties b/gradle.properties index 705a3661..3cb0a44f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -19,3 +19,6 @@ kotlin.daemon.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 -Djava.awt.headless=true # Force JDK 21 (override locally if needed; GitHub Actions uses setup-java) # org.gradle.java.home=/path/to/your/jdk21 + +# Enable R8 full mode for aggressive code shrinking +android.enableR8.fullMode=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5db24579..b0cfa5ed 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,43 +1,40 @@ [versions] -agp = "9.1.0" -kotlin = "2.3.10" -ksp = "2.3.6" -hilt = "2.59.2" -hiltNavigationCompose = "1.2.0" -coreKtx = "1.17.0" +agp = "9.2.1" +kotlin = "2.4.0" +ksp = "2.3.10" +hilt = "2.60.1" +hiltNavigationCompose = "1.4.0" +coreKtx = "1.19.0" junit = "4.13.2" -junitVersion = "1.1.5" -espressoCore = "3.5.1" -lifecycle = "2.10.0" -activityCompose = "1.12.3" -composeBom = "2026.03.01" -material3 = "1.5.0-alpha17" -material = "1.14.0-alpha10" -navigation = "2.9.7" -googleFonts = "1.7.8" +junitVersion = "1.3.0" +espressoCore = "3.7.0" +lifecycle = "2.11.0" +activityCompose = "1.13.0" +composeBom = "2026.06.01" +material3 = "1.5.0-alpha23" +navigation = "2.9.8" room = "2.8.4" -coil3 = "3.4.0" -datastore = "1.1.1" -media3 = "1.8.0" -accompanist = "0.36.0" -exifinterface = "1.3.7" -paging = "3.4.0" +coil3 = "3.5.0" +datastore = "1.2.1" +media3 = "1.10.1" +exifinterface = "1.4.2" +paging = "3.5.0" # Testing -mockk = "1.14.0" -turbine = "1.1.0" +mockitoCore = "5.18.0" +mockk = "1.14.11" +turbine = "1.2.1" coreTesting = "2.2.0" -robolectric = "4.14.1" -testCore = "1.5.0" +robolectric = "4.16.1" +testCore = "1.7.0" # Kotlinx -kotlinxCoroutines = "1.10.2" -kotlinxSerialization = "1.10.0" +kotlinxCoroutines = "1.11.0" +kotlinxSerialization = "1.11.0" [libraries] media3_exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" } media3_ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3" } media3_session = { group = "androidx.media3", name = "media3-session", version.ref = "media3" } androidx_core_ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } -google_material = { group = "com.google.android.material", name = "material", version.ref = "material" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx_junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx_espresso_core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } @@ -55,7 +52,7 @@ androidx_ui_test_manifest = { group = "androidx.compose.ui", name = "ui-test-man androidx_ui_test_junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx_material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" } androidx_material_icons_extended = { group = "androidx.compose.material", name = "material-icons-extended" } -androidx_graphics_shapes = { group = "androidx.graphics", name = "graphics-shapes", version = "1.0.1" } +androidx_graphics_shapes = { group = "androidx.graphics", name = "graphics-shapes", version = "1.1.0" } androidx_room_runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } androidx_room_ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } androidx_room_compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } @@ -66,13 +63,13 @@ androidx_datastore_preferences = { group = "androidx.datastore", name = "datasto androidx_navigation_compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" } coil3_compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil3" } coil3_network = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil3" } -androidx_ui_text_google_fonts = { group = "androidx.compose.ui", name = "ui-text-google-fonts", version.ref = "googleFonts" } +androidx_ui_text_google_fonts = { group = "androidx.compose.ui", name = "ui-text-google-fonts" } kotlinx_serialization_json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" } kotlinx_coroutines_core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" } kotlinx_coroutines_test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" } -accompanist_permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanist" } androidx_exifinterface = { group = "androidx.exifinterface", name = "exifinterface", version.ref = "exifinterface" } # Testing Libraries +mockito_core = { group = "org.mockito", name = "mockito-core", version.ref = "mockitoCore" } mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } mockkAndroid = { group = "io.mockk", name = "mockk-android", version.ref = "mockk" } turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index d46db093..126fd091 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Fri Mar 06 01:32:02 EET 2026 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/settings.gradle.kts b/settings.gradle.kts index 3b6df48d..a56491d7 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -33,5 +33,4 @@ include(":feature:chat") include(":feature:discovery") include(":feature:settings") include(":feature:onboarding") -include(":feature:help") include(":feature:real-device-testing")