diff --git a/.github/workflows/android_build.yml b/.github/workflows/android_build.yml deleted file mode 100644 index afdc61c..0000000 --- a/.github/workflows/android_build.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: Android Build - -on: [pull_request, push] - -jobs: - build: - runs-on: ubuntu-latest - - steps: - - name: Checkout the code - uses: actions/checkout@v2 - - - name: Set Up JDK - uses: actions/setup-java@v1 - with: - java-version: 17 - - - name: Run Tests - run: ./gradlew test - - - name: Build the app - run: ./gradlew build \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..fa56880 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,45 @@ +name: Build & Test + +on: + push: + branches: [main, master, develop] + pull_request: + branches: [main, master, develop] + +jobs: + build: + name: Build & Test + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + with: + cache-read-only: ${{ github.ref != 'refs/heads/main' }} + + # Runs commonTest + androidHostTest for core module + - name: Run Core Module Tests + run: ./gradlew :core:testAndroidHostTest + + # Runs commonTest + Android unit tests for app module + - name: Run App Module Tests + run: ./gradlew :app:testDebugUnitTest + + - name: Build Debug APK + run: ./gradlew :app:assembleDebug + + - name: Upload APK + uses: actions/upload-artifact@v4 + with: + name: debug-apk + path: app/build/outputs/apk/debug/*.apk + retention-days: 7 \ No newline at end of file diff --git a/.idea/ChatHistory_schema_v4.xml b/.idea/ChatHistory_schema_v4.xml new file mode 100644 index 0000000..7880fd1 --- /dev/null +++ b/.idea/ChatHistory_schema_v4.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/artifacts/core.xml b/.idea/artifacts/core.xml new file mode 100644 index 0000000..88ce0c8 --- /dev/null +++ b/.idea/artifacts/core.xml @@ -0,0 +1,8 @@ + + + $PROJECT_DIR$/core/build/libs + + + + + \ No newline at end of file diff --git a/.idea/firebender/chat_history.db b/.idea/firebender/chat_history.db new file mode 100644 index 0000000..09968d7 Binary files /dev/null and b/.idea/firebender/chat_history.db differ diff --git a/.idea/firebender/chat_history.db-shm b/.idea/firebender/chat_history.db-shm new file mode 100644 index 0000000..b5612b9 Binary files /dev/null and b/.idea/firebender/chat_history.db-shm differ diff --git a/.idea/firebender/chat_history.db-wal b/.idea/firebender/chat_history.db-wal new file mode 100644 index 0000000..e7a8bfd Binary files /dev/null and b/.idea/firebender/chat_history.db-wal differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.annotation-annotation-1.9.1-commonMain-WmoUwA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.annotation-annotation-1.9.1-commonMain-WmoUwA.klib new file mode 100644 index 0000000..a580fff Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.annotation-annotation-1.9.1-commonMain-WmoUwA.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.annotation-annotation-1.9.1-nonJvmMain-WmoUwA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.annotation-annotation-1.9.1-nonJvmMain-WmoUwA.klib new file mode 100644 index 0000000..9a6b924 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.annotation-annotation-1.9.1-nonJvmMain-WmoUwA.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.collection-collection-1.5.0-commonMain-j7f1lg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.collection-collection-1.5.0-commonMain-j7f1lg.klib new file mode 100644 index 0000000..b4bbb52 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.collection-collection-1.5.0-commonMain-j7f1lg.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.collection-collection-1.5.0-darwinMain-1oCDtg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.collection-collection-1.5.0-darwinMain-1oCDtg.klib new file mode 100644 index 0000000..9d7c42d Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.collection-collection-1.5.0-darwinMain-1oCDtg.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.collection-collection-1.5.0-nativeMain-j7f1lg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.collection-collection-1.5.0-nativeMain-j7f1lg.klib new file mode 100644 index 0000000..feac87a Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.collection-collection-1.5.0-nativeMain-j7f1lg.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.collection-collection-1.5.0-nonJvmMain-j7f1lg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.collection-collection-1.5.0-nonJvmMain-j7f1lg.klib new file mode 100644 index 0000000..7cfdd44 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.collection-collection-1.5.0-nonJvmMain-j7f1lg.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.collection-collection-1.5.0-unixMain-j7f1lg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.collection-collection-1.5.0-unixMain-j7f1lg.klib new file mode 100644 index 0000000..0fbe7dc Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.collection-collection-1.5.0-unixMain-j7f1lg.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.compose.runtime-runtime-1.10.0-commonMain-jL1lig.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.compose.runtime-runtime-1.10.0-commonMain-jL1lig.klib new file mode 100644 index 0000000..8a3f255 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.compose.runtime-runtime-1.10.0-commonMain-jL1lig.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.compose.runtime-runtime-1.10.0-darwinMain-LBH16w.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.compose.runtime-runtime-1.10.0-darwinMain-LBH16w.klib new file mode 100644 index 0000000..22c67e1 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.compose.runtime-runtime-1.10.0-darwinMain-LBH16w.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.compose.runtime-runtime-1.10.0-nativeMain-jL1lig.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.compose.runtime-runtime-1.10.0-nativeMain-jL1lig.klib new file mode 100644 index 0000000..d91958b Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.compose.runtime-runtime-1.10.0-nativeMain-jL1lig.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.compose.runtime-runtime-1.10.0-nonAndroidMain-jL1lig.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.compose.runtime-runtime-1.10.0-nonAndroidMain-jL1lig.klib new file mode 100644 index 0000000..7d2f402 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.compose.runtime-runtime-1.10.0-nonAndroidMain-jL1lig.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.compose.runtime-runtime-1.10.0-nonJvmMain-jL1lig.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.compose.runtime-runtime-1.10.0-nonJvmMain-jL1lig.klib new file mode 100644 index 0000000..5bafcc4 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.compose.runtime-runtime-1.10.0-nonJvmMain-jL1lig.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.compose.runtime-runtime-1.10.0-unixMain-jL1lig.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.compose.runtime-runtime-1.10.0-unixMain-jL1lig.klib new file mode 100644 index 0000000..cf168af Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.compose.runtime-runtime-1.10.0-unixMain-jL1lig.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.compose.runtime-runtime-annotation-1.10.0-commonMain-stVLwg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.compose.runtime-runtime-annotation-1.10.0-commonMain-stVLwg.klib new file mode 100644 index 0000000..f24c9db Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.compose.runtime-runtime-annotation-1.10.0-commonMain-stVLwg.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.compose.runtime-runtime-retain-1.10.0-commonMain-gAgqGg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.compose.runtime-runtime-retain-1.10.0-commonMain-gAgqGg.klib new file mode 100644 index 0000000..9203d6f Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.compose.runtime-runtime-retain-1.10.0-commonMain-gAgqGg.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.compose.runtime-runtime-retain-1.10.0-nonJvmMain-gAgqGg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.compose.runtime-runtime-retain-1.10.0-nonJvmMain-gAgqGg.klib new file mode 100644 index 0000000..423be95 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.compose.runtime-runtime-retain-1.10.0-nonJvmMain-gAgqGg.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.compose.runtime-runtime-saveable-1.10.0-commonMain-2vxcdQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.compose.runtime-runtime-saveable-1.10.0-commonMain-2vxcdQ.klib new file mode 100644 index 0000000..97fe739 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.compose.runtime-runtime-saveable-1.10.0-commonMain-2vxcdQ.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-common-2.10.0-commonMain-ENdyRA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-common-2.10.0-commonMain-ENdyRA.klib new file mode 100644 index 0000000..4caba4a Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-common-2.10.0-commonMain-ENdyRA.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-common-2.10.0-nonJvmMain-ENdyRA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-common-2.10.0-nonJvmMain-ENdyRA.klib new file mode 100644 index 0000000..401ac46 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-common-2.10.0-nonJvmMain-ENdyRA.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-runtime-2.10.0-commonMain-MSpfvg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-runtime-2.10.0-commonMain-MSpfvg.klib new file mode 100644 index 0000000..92a5aac Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-runtime-2.10.0-commonMain-MSpfvg.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-runtime-2.10.0-nativeMain-MSpfvg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-runtime-2.10.0-nativeMain-MSpfvg.klib new file mode 100644 index 0000000..3eac374 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-runtime-2.10.0-nativeMain-MSpfvg.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-runtime-2.10.0-nonJvmMain-MSpfvg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-runtime-2.10.0-nonJvmMain-MSpfvg.klib new file mode 100644 index 0000000..e96d244 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-runtime-2.10.0-nonJvmMain-MSpfvg.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-runtime-compose-2.10.0-commonMain-UUsvAg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-runtime-compose-2.10.0-commonMain-UUsvAg.klib new file mode 100644 index 0000000..51265b6 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-runtime-compose-2.10.0-commonMain-UUsvAg.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-runtime-compose-2.10.0-nonAndroidMain-UUsvAg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-runtime-compose-2.10.0-nonAndroidMain-UUsvAg.klib new file mode 100644 index 0000000..916ced1 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-runtime-compose-2.10.0-nonAndroidMain-UUsvAg.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-viewmodel-2.10.0-commonMain-1eFh5A.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-viewmodel-2.10.0-commonMain-1eFh5A.klib new file mode 100644 index 0000000..03c0b6d Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-viewmodel-2.10.0-commonMain-1eFh5A.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-viewmodel-2.10.0-darwinMain-DPL3kg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-viewmodel-2.10.0-darwinMain-DPL3kg.klib new file mode 100644 index 0000000..2b1e298 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-viewmodel-2.10.0-darwinMain-DPL3kg.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-viewmodel-2.10.0-nativeMain-1eFh5A.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-viewmodel-2.10.0-nativeMain-1eFh5A.klib new file mode 100644 index 0000000..e30060b Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-viewmodel-2.10.0-nativeMain-1eFh5A.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-viewmodel-2.10.0-nonJvmMain-1eFh5A.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-viewmodel-2.10.0-nonJvmMain-1eFh5A.klib new file mode 100644 index 0000000..e3493e8 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-viewmodel-2.10.0-nonJvmMain-1eFh5A.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-viewmodel-2.10.0-unixMain-1eFh5A.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-viewmodel-2.10.0-unixMain-1eFh5A.klib new file mode 100644 index 0000000..9e0c384 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-viewmodel-2.10.0-unixMain-1eFh5A.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-viewmodel-savedstate-2.10.0-commonMain-bNK2hQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-viewmodel-savedstate-2.10.0-commonMain-bNK2hQ.klib new file mode 100644 index 0000000..b90d172 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-viewmodel-savedstate-2.10.0-commonMain-bNK2hQ.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-viewmodel-savedstate-2.10.0-nativeMain-bNK2hQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-viewmodel-savedstate-2.10.0-nativeMain-bNK2hQ.klib new file mode 100644 index 0000000..4c60494 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-viewmodel-savedstate-2.10.0-nativeMain-bNK2hQ.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-viewmodel-savedstate-2.10.0-nonAndroidMain-bNK2hQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-viewmodel-savedstate-2.10.0-nonAndroidMain-bNK2hQ.klib new file mode 100644 index 0000000..28aa0c4 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-viewmodel-savedstate-2.10.0-nonAndroidMain-bNK2hQ.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.navigation3-navigation3-runtime-1.1.0-alpha01-commonMain-Ia37Wg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.navigation3-navigation3-runtime-1.1.0-alpha01-commonMain-Ia37Wg.klib new file mode 100644 index 0000000..4a32b5a Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.navigation3-navigation3-runtime-1.1.0-alpha01-commonMain-Ia37Wg.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.navigationevent-navigationevent-1.0.1-commonMain-CVCQsg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.navigationevent-navigationevent-1.0.1-commonMain-CVCQsg.klib new file mode 100644 index 0000000..efd5910 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.navigationevent-navigationevent-1.0.1-commonMain-CVCQsg.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.navigationevent-navigationevent-1.0.1-darwinMain-v-PyAQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.navigationevent-navigationevent-1.0.1-darwinMain-v-PyAQ.klib new file mode 100644 index 0000000..621464c Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.navigationevent-navigationevent-1.0.1-darwinMain-v-PyAQ.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.navigationevent-navigationevent-1.0.1-nativeMain-CVCQsg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.navigationevent-navigationevent-1.0.1-nativeMain-CVCQsg.klib new file mode 100644 index 0000000..f80260c Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.navigationevent-navigationevent-1.0.1-nativeMain-CVCQsg.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.navigationevent-navigationevent-1.0.1-unixMain-CVCQsg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.navigationevent-navigationevent-1.0.1-unixMain-CVCQsg.klib new file mode 100644 index 0000000..86cfa5e Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.navigationevent-navigationevent-1.0.1-unixMain-CVCQsg.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.room-room-common-2.8.4-commonMain-FV9dPA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.room-room-common-2.8.4-commonMain-FV9dPA.klib new file mode 100644 index 0000000..d1fa591 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.room-room-common-2.8.4-commonMain-FV9dPA.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.room-room-common-2.8.4-nativeMain-FV9dPA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.room-room-common-2.8.4-nativeMain-FV9dPA.klib new file mode 100644 index 0000000..622bac5 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.room-room-common-2.8.4-nativeMain-FV9dPA.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.room-room-runtime-2.8.4-commonMain-oKhOvw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.room-room-runtime-2.8.4-commonMain-oKhOvw.klib new file mode 100644 index 0000000..b97b17b Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.room-room-runtime-2.8.4-commonMain-oKhOvw.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.room-room-runtime-2.8.4-jvmNativeMain-oKhOvw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.room-room-runtime-2.8.4-jvmNativeMain-oKhOvw.klib new file mode 100644 index 0000000..0be6098 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.room-room-runtime-2.8.4-jvmNativeMain-oKhOvw.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.room-room-runtime-2.8.4-nativeMain-oKhOvw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.room-room-runtime-2.8.4-nativeMain-oKhOvw.klib new file mode 100644 index 0000000..32c6269 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.room-room-runtime-2.8.4-nativeMain-oKhOvw.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.savedstate-savedstate-1.4.0-commonMain-JkDvJw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.savedstate-savedstate-1.4.0-commonMain-JkDvJw.klib new file mode 100644 index 0000000..88b04f5 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.savedstate-savedstate-1.4.0-commonMain-JkDvJw.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.savedstate-savedstate-1.4.0-darwinMain-PeDSBw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.savedstate-savedstate-1.4.0-darwinMain-PeDSBw.klib new file mode 100644 index 0000000..42e7ec2 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.savedstate-savedstate-1.4.0-darwinMain-PeDSBw.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.savedstate-savedstate-1.4.0-nativeMain-JkDvJw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.savedstate-savedstate-1.4.0-nativeMain-JkDvJw.klib new file mode 100644 index 0000000..6daa445 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.savedstate-savedstate-1.4.0-nativeMain-JkDvJw.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.savedstate-savedstate-1.4.0-nonAndroidMain-JkDvJw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.savedstate-savedstate-1.4.0-nonAndroidMain-JkDvJw.klib new file mode 100644 index 0000000..d1a18db Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.savedstate-savedstate-1.4.0-nonAndroidMain-JkDvJw.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.savedstate-savedstate-1.4.0-unixMain-JkDvJw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.savedstate-savedstate-1.4.0-unixMain-JkDvJw.klib new file mode 100644 index 0000000..e197e3b Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.savedstate-savedstate-1.4.0-unixMain-JkDvJw.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.savedstate-savedstate-compose-1.4.0-commonMain-YfHBdg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.savedstate-savedstate-compose-1.4.0-commonMain-YfHBdg.klib new file mode 100644 index 0000000..f7218c1 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.savedstate-savedstate-compose-1.4.0-commonMain-YfHBdg.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.savedstate-savedstate-compose-1.4.0-nonAndroidMain-YfHBdg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.savedstate-savedstate-compose-1.4.0-nonAndroidMain-YfHBdg.klib new file mode 100644 index 0000000..33ae7b6 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.savedstate-savedstate-compose-1.4.0-nonAndroidMain-YfHBdg.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.sqlite-sqlite-2.6.2-commonMain-Y3bhRQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.sqlite-sqlite-2.6.2-commonMain-Y3bhRQ.klib new file mode 100644 index 0000000..964098d Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.sqlite-sqlite-2.6.2-commonMain-Y3bhRQ.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.sqlite-sqlite-2.6.2-nativeMain-Y3bhRQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.sqlite-sqlite-2.6.2-nativeMain-Y3bhRQ.klib new file mode 100644 index 0000000..3260e06 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.sqlite-sqlite-2.6.2-nativeMain-Y3bhRQ.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.sqlite-sqlite-bundled-2.6.2-commonMain-dCYVRw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.sqlite-sqlite-bundled-2.6.2-commonMain-dCYVRw.klib new file mode 100644 index 0000000..1f49c9a Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.sqlite-sqlite-bundled-2.6.2-commonMain-dCYVRw.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.sqlite-sqlite-bundled-2.6.2-nativeMain-dCYVRw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.sqlite-sqlite-bundled-2.6.2-nativeMain-dCYVRw.klib new file mode 100644 index 0000000..26ee413 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.sqlite-sqlite-bundled-2.6.2-nativeMain-dCYVRw.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.sqlite-sqlite-framework-2.6.2-nativeMain-5Ubkcg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.sqlite-sqlite-framework-2.6.2-nativeMain-5Ubkcg.klib new file mode 100644 index 0000000..a02f4cd Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.sqlite-sqlite-framework-2.6.2-nativeMain-5Ubkcg.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/app.cash.turbine-turbine-1.2.1-commonMain-3FY4Jw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/app.cash.turbine-turbine-1.2.1-commonMain-3FY4Jw.klib new file mode 100644 index 0000000..af8183b Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/app.cash.turbine-turbine-1.2.1-commonMain-3FY4Jw.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/cafe.adriel.lyricist-lyricist-1.7.0-commonMain-E3zqSA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/cafe.adriel.lyricist-lyricist-1.7.0-commonMain-E3zqSA.klib new file mode 100644 index 0000000..7c803ec Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/cafe.adriel.lyricist-lyricist-1.7.0-commonMain-E3zqSA.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/cafe.adriel.lyricist-lyricist-core-1.7.0-commonMain-jbtC-Q.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/cafe.adriel.lyricist-lyricist-core-1.7.0-commonMain-jbtC-Q.klib new file mode 100644 index 0000000..5797a4d Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/cafe.adriel.lyricist-lyricist-core-1.7.0-commonMain-jbtC-Q.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab-kermit-2.0.8-commonMain-rUoOlA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab-kermit-2.0.8-commonMain-rUoOlA.klib new file mode 100644 index 0000000..0a07300 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab-kermit-2.0.8-commonMain-rUoOlA.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab-kermit-2.0.8-nativeMain-rUoOlA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab-kermit-2.0.8-nativeMain-rUoOlA.klib new file mode 100644 index 0000000..b2199d3 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab-kermit-2.0.8-nativeMain-rUoOlA.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab-kermit-core-2.0.8-appleMain-Mv5SxQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab-kermit-core-2.0.8-appleMain-Mv5SxQ.klib new file mode 100644 index 0000000..04946d4 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab-kermit-core-2.0.8-appleMain-Mv5SxQ.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab-kermit-core-2.0.8-commonMain-3AHZ4A.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab-kermit-core-2.0.8-commonMain-3AHZ4A.klib new file mode 100644 index 0000000..dc43258 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab-kermit-core-2.0.8-commonMain-3AHZ4A.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab-kermit-core-2.0.8-nativeMain-3AHZ4A.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab-kermit-core-2.0.8-nativeMain-3AHZ4A.klib new file mode 100644 index 0000000..0d7aae1 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab-kermit-core-2.0.8-nativeMain-3AHZ4A.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab-kermit-crashlytics-2.0.8-commonMain-zvpb6Q.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab-kermit-crashlytics-2.0.8-commonMain-zvpb6Q.klib new file mode 100644 index 0000000..df227a5 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab-kermit-crashlytics-2.0.8-commonMain-zvpb6Q.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab-stately-concurrency-2.1.0-appleMain-oa7GPg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab-stately-concurrency-2.1.0-appleMain-oa7GPg.klib new file mode 100644 index 0000000..fc7216e Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab-stately-concurrency-2.1.0-appleMain-oa7GPg.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab-stately-concurrency-2.1.0-commonMain-t1ZQYw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab-stately-concurrency-2.1.0-commonMain-t1ZQYw.klib new file mode 100644 index 0000000..fab96b5 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab-stately-concurrency-2.1.0-commonMain-t1ZQYw.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab-stately-concurrency-2.1.0-nativeMain-t1ZQYw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab-stately-concurrency-2.1.0-nativeMain-t1ZQYw.klib new file mode 100644 index 0000000..eb3f933 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab-stately-concurrency-2.1.0-nativeMain-t1ZQYw.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab-stately-concurrent-collections-2.1.0-commonMain-jSCQOA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab-stately-concurrent-collections-2.1.0-commonMain-jSCQOA.klib new file mode 100644 index 0000000..3a38149 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab-stately-concurrent-collections-2.1.0-commonMain-jSCQOA.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab-stately-strict-2.1.0-commonMain-dKdBGA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab-stately-strict-2.1.0-commonMain-dKdBGA.klib new file mode 100644 index 0000000..01c5195 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab-stately-strict-2.1.0-commonMain-dKdBGA.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab-stately-strict-2.1.0-nativeMain-dKdBGA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab-stately-strict-2.1.0-nativeMain-dKdBGA.klib new file mode 100644 index 0000000..42afcd0 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab-stately-strict-2.1.0-nativeMain-dKdBGA.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab.crashkios-core-0.8.5-commonMain-KN4_Hg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab.crashkios-core-0.8.5-commonMain-KN4_Hg.klib new file mode 100644 index 0000000..4af1a12 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab.crashkios-core-0.8.5-commonMain-KN4_Hg.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab.crashkios-core-0.8.5-darwinMain-FcyXVA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab.crashkios-core-0.8.5-darwinMain-FcyXVA.klib new file mode 100644 index 0000000..264896e Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab.crashkios-core-0.8.5-darwinMain-FcyXVA.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab.crashkios-crashlytics-0.8.5-commonMain-0LMh8A.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab.crashkios-crashlytics-0.8.5-commonMain-0LMh8A.klib new file mode 100644 index 0000000..7e095ad Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab.crashkios-crashlytics-0.8.5-commonMain-0LMh8A.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab.crashkios-crashlytics-0.8.5-darwinMain-Pbfr_A.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab.crashkios-crashlytics-0.8.5-darwinMain-Pbfr_A.klib new file mode 100644 index 0000000..0f4a971 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab.crashkios-crashlytics-0.8.5-darwinMain-Pbfr_A.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.rickclephas.kmp-nsexception-kt-core-0.1.10-commonMain-Z_lGpw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.rickclephas.kmp-nsexception-kt-core-0.1.10-commonMain-Z_lGpw.klib new file mode 100644 index 0000000..44acfe6 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.rickclephas.kmp-nsexception-kt-core-0.1.10-commonMain-Z_lGpw.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.russhwolf-multiplatform-settings-1.3.0-apple64Main-YvI2jQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.russhwolf-multiplatform-settings-1.3.0-apple64Main-YvI2jQ.klib new file mode 100644 index 0000000..276e9fc Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.russhwolf-multiplatform-settings-1.3.0-apple64Main-YvI2jQ.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.russhwolf-multiplatform-settings-1.3.0-appleMain-YvI2jQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.russhwolf-multiplatform-settings-1.3.0-appleMain-YvI2jQ.klib new file mode 100644 index 0000000..8939240 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.russhwolf-multiplatform-settings-1.3.0-appleMain-YvI2jQ.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.russhwolf-multiplatform-settings-1.3.0-commonMain-oQmQdg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.russhwolf-multiplatform-settings-1.3.0-commonMain-oQmQdg.klib new file mode 100644 index 0000000..2f56da0 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.russhwolf-multiplatform-settings-1.3.0-commonMain-oQmQdg.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.russhwolf-multiplatform-settings-no-arg-1.3.0-appleMain-ckY_5A.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.russhwolf-multiplatform-settings-no-arg-1.3.0-appleMain-ckY_5A.klib new file mode 100644 index 0000000..1326a79 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.russhwolf-multiplatform-settings-no-arg-1.3.0-appleMain-ckY_5A.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.russhwolf-multiplatform-settings-no-arg-1.3.0-commonMain-QEsy7A.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.russhwolf-multiplatform-settings-no-arg-1.3.0-commonMain-QEsy7A.klib new file mode 100644 index 0000000..81a635b Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.russhwolf-multiplatform-settings-no-arg-1.3.0-commonMain-QEsy7A.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.squareup.okio-okio-3.15.0-appleMain-oxtp1g.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.squareup.okio-okio-3.15.0-appleMain-oxtp1g.klib new file mode 100644 index 0000000..f661264 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.squareup.okio-okio-3.15.0-appleMain-oxtp1g.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.squareup.okio-okio-3.15.0-commonMain-6b-baw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.squareup.okio-okio-3.15.0-commonMain-6b-baw.klib new file mode 100644 index 0000000..797e70d Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.squareup.okio-okio-3.15.0-commonMain-6b-baw.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.squareup.okio-okio-3.15.0-hashFunctions-6b-baw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.squareup.okio-okio-3.15.0-hashFunctions-6b-baw.klib new file mode 100644 index 0000000..083bc76 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.squareup.okio-okio-3.15.0-hashFunctions-6b-baw.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.squareup.okio-okio-3.15.0-nativeMain-6b-baw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.squareup.okio-okio-3.15.0-nativeMain-6b-baw.klib new file mode 100644 index 0000000..cc7f6b9 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.squareup.okio-okio-3.15.0-nativeMain-6b-baw.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.squareup.okio-okio-3.15.0-nonJsMain-6b-baw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.squareup.okio-okio-3.15.0-nonJsMain-6b-baw.klib new file mode 100644 index 0000000..95c09c1 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.squareup.okio-okio-3.15.0-nonJsMain-6b-baw.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.squareup.okio-okio-3.15.0-nonJvmMain-6b-baw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.squareup.okio-okio-3.15.0-nonJvmMain-6b-baw.klib new file mode 100644 index 0000000..d958153 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.squareup.okio-okio-3.15.0-nonJvmMain-6b-baw.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.squareup.okio-okio-3.15.0-systemFileSystemMain-6b-baw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.squareup.okio-okio-3.15.0-systemFileSystemMain-6b-baw.klib new file mode 100644 index 0000000..292cdc0 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.squareup.okio-okio-3.15.0-systemFileSystemMain-6b-baw.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.squareup.okio-okio-3.15.0-unixMain-6b-baw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.squareup.okio-okio-3.15.0-unixMain-6b-baw.klib new file mode 100644 index 0000000..493225c Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.squareup.okio-okio-3.15.0-unixMain-6b-baw.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.squareup.okio-okio-3.15.0-zlibMain-6b-baw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.squareup.okio-okio-3.15.0-zlibMain-6b-baw.klib new file mode 100644 index 0000000..d0a6c95 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.squareup.okio-okio-3.15.0-zlibMain-6b-baw.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.github.alexzhirkevich-compottie-core-2.0.2-commonMain-lMMlqw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.github.alexzhirkevich-compottie-core-2.0.2-commonMain-lMMlqw.klib new file mode 100644 index 0000000..b1f7158 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.github.alexzhirkevich-compottie-core-2.0.2-commonMain-lMMlqw.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.github.alexzhirkevich-compottie-core-2.0.2-jvmNativeMain-lMMlqw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.github.alexzhirkevich-compottie-core-2.0.2-jvmNativeMain-lMMlqw.klib new file mode 100644 index 0000000..1972880 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.github.alexzhirkevich-compottie-core-2.0.2-jvmNativeMain-lMMlqw.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.github.alexzhirkevich-compottie-core-2.0.2-skikoMain-lMMlqw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.github.alexzhirkevich-compottie-core-2.0.2-skikoMain-lMMlqw.klib new file mode 100644 index 0000000..00b9ff2 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.github.alexzhirkevich-compottie-core-2.0.2-skikoMain-lMMlqw.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.github.alexzhirkevich-compottie-lite-2.0.2-commonMain-f_1qIA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.github.alexzhirkevich-compottie-lite-2.0.2-commonMain-f_1qIA.klib new file mode 100644 index 0000000..881578c Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.github.alexzhirkevich-compottie-lite-2.0.2-commonMain-f_1qIA.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.github.alexzhirkevich-keight-core-0.0.04-commonMain-aCo-QQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.github.alexzhirkevich-keight-core-0.0.04-commonMain-aCo-QQ.klib new file mode 100644 index 0000000..646e95e Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.github.alexzhirkevich-keight-core-0.0.04-commonMain-aCo-QQ.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.insert-koin-koin-compose-4.1.1-commonMain-0NT22Q.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.insert-koin-koin-compose-4.1.1-commonMain-0NT22Q.klib new file mode 100644 index 0000000..24e8cd9 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.insert-koin-koin-compose-4.1.1-commonMain-0NT22Q.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.insert-koin-koin-compose-4.1.1-nativeMain-Txoobw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.insert-koin-koin-compose-4.1.1-nativeMain-Txoobw.klib new file mode 100644 index 0000000..5e757d4 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.insert-koin-koin-compose-4.1.1-nativeMain-Txoobw.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.insert-koin-koin-compose-viewmodel-4.1.1-commonMain-0POyBw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.insert-koin-koin-compose-viewmodel-4.1.1-commonMain-0POyBw.klib new file mode 100644 index 0000000..d0d9631 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.insert-koin-koin-compose-viewmodel-4.1.1-commonMain-0POyBw.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.insert-koin-koin-core-4.1.1-commonMain-hXs0VA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.insert-koin-koin-core-4.1.1-commonMain-hXs0VA.klib new file mode 100644 index 0000000..3eccdb3 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.insert-koin-koin-core-4.1.1-commonMain-hXs0VA.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.insert-koin-koin-core-4.1.1-nativeMain-hXs0VA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.insert-koin-koin-core-4.1.1-nativeMain-hXs0VA.klib new file mode 100644 index 0000000..60cd907 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.insert-koin-koin-core-4.1.1-nativeMain-hXs0VA.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.insert-koin-koin-core-viewmodel-4.1.1-commonMain-EFzrFQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.insert-koin-koin-core-viewmodel-4.1.1-commonMain-EFzrFQ.klib new file mode 100644 index 0000000..e59c384 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.insert-koin-koin-core-viewmodel-4.1.1-commonMain-EFzrFQ.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.kotest-kotest-assertions-core-6.0.7-commonMain-Gm0JHA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.kotest-kotest-assertions-core-6.0.7-commonMain-Gm0JHA.klib new file mode 100644 index 0000000..3a16003 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.kotest-kotest-assertions-core-6.0.7-commonMain-Gm0JHA.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.kotest-kotest-assertions-core-6.0.7-nonjvmMain-Gm0JHA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.kotest-kotest-assertions-core-6.0.7-nonjvmMain-Gm0JHA.klib new file mode 100644 index 0000000..dabec71 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.kotest-kotest-assertions-core-6.0.7-nonjvmMain-Gm0JHA.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.kotest-kotest-assertions-shared-6.0.7-commonMain-Jfng_w.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.kotest-kotest-assertions-shared-6.0.7-commonMain-Jfng_w.klib new file mode 100644 index 0000000..ef70344 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.kotest-kotest-assertions-shared-6.0.7-commonMain-Jfng_w.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.kotest-kotest-assertions-shared-6.0.7-nonjvmMain-Jfng_w.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.kotest-kotest-assertions-shared-6.0.7-nonjvmMain-Jfng_w.klib new file mode 100644 index 0000000..8363099 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.kotest-kotest-assertions-shared-6.0.7-nonjvmMain-Jfng_w.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.kotest-kotest-common-6.0.7-commonMain-BC0QFg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.kotest-kotest-common-6.0.7-commonMain-BC0QFg.klib new file mode 100644 index 0000000..00351b5 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.kotest-kotest-common-6.0.7-commonMain-BC0QFg.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.kotest-kotest-common-6.0.7-nativeMain-BC0QFg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.kotest-kotest-common-6.0.7-nativeMain-BC0QFg.klib new file mode 100644 index 0000000..45af768 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.kotest-kotest-common-6.0.7-nativeMain-BC0QFg.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.kotest-kotest-common-6.0.7-nonjvmMain-BC0QFg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.kotest-kotest-common-6.0.7-nonjvmMain-BC0QFg.klib new file mode 100644 index 0000000..7e046da Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.kotest-kotest-common-6.0.7-nonjvmMain-BC0QFg.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.lifecycle-lifecycle-common-2.10.0-alpha07-commonMain-F7wFOg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.lifecycle-lifecycle-common-2.10.0-alpha07-commonMain-F7wFOg.klib new file mode 100644 index 0000000..03f48a6 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.lifecycle-lifecycle-common-2.10.0-alpha07-commonMain-F7wFOg.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.lifecycle-lifecycle-runtime-2.10.0-alpha06-commonMain-sW-D8A.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.lifecycle-lifecycle-runtime-2.10.0-alpha06-commonMain-sW-D8A.klib new file mode 100644 index 0000000..7acebb5 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.lifecycle-lifecycle-runtime-2.10.0-alpha06-commonMain-sW-D8A.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.lifecycle-lifecycle-runtime-compose-2.10.0-alpha06-commonMain-JomyRw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.lifecycle-lifecycle-runtime-compose-2.10.0-alpha06-commonMain-JomyRw.klib new file mode 100644 index 0000000..71555b3 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.lifecycle-lifecycle-runtime-compose-2.10.0-alpha06-commonMain-JomyRw.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.lifecycle-lifecycle-viewmodel-2.10.0-alpha07-commonMain-3MD3hQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.lifecycle-lifecycle-viewmodel-2.10.0-alpha07-commonMain-3MD3hQ.klib new file mode 100644 index 0000000..7b4dbe5 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.lifecycle-lifecycle-viewmodel-2.10.0-alpha07-commonMain-3MD3hQ.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.lifecycle-lifecycle-viewmodel-compose-2.10.0-alpha07-commonMain-1J6zNw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.lifecycle-lifecycle-viewmodel-compose-2.10.0-alpha07-commonMain-1J6zNw.klib new file mode 100644 index 0000000..0d25488 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.lifecycle-lifecycle-viewmodel-compose-2.10.0-alpha07-commonMain-1J6zNw.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.lifecycle-lifecycle-viewmodel-compose-2.10.0-alpha07-nativeMain-P2UNFA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.lifecycle-lifecycle-viewmodel-compose-2.10.0-alpha07-nativeMain-P2UNFA.klib new file mode 100644 index 0000000..7a9db05 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.lifecycle-lifecycle-viewmodel-compose-2.10.0-alpha07-nativeMain-P2UNFA.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.lifecycle-lifecycle-viewmodel-compose-2.10.0-alpha07-nonAndroidMain-1J6zNw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.lifecycle-lifecycle-viewmodel-compose-2.10.0-alpha07-nonAndroidMain-1J6zNw.klib new file mode 100644 index 0000000..def6201 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.lifecycle-lifecycle-viewmodel-compose-2.10.0-alpha07-nonAndroidMain-1J6zNw.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.lifecycle-lifecycle-viewmodel-navigation3-2.10.0-alpha07-commonMain-lOMNYw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.lifecycle-lifecycle-viewmodel-navigation3-2.10.0-alpha07-commonMain-lOMNYw.klib new file mode 100644 index 0000000..68a2438 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.lifecycle-lifecycle-viewmodel-navigation3-2.10.0-alpha07-commonMain-lOMNYw.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.lifecycle-lifecycle-viewmodel-navigation3-2.10.0-alpha07-nonAndroidMain-lOMNYw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.lifecycle-lifecycle-viewmodel-navigation3-2.10.0-alpha07-nonAndroidMain-lOMNYw.klib new file mode 100644 index 0000000..096f8f1 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.lifecycle-lifecycle-viewmodel-navigation3-2.10.0-alpha07-nonAndroidMain-lOMNYw.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.lifecycle-lifecycle-viewmodel-savedstate-2.10.0-alpha07-commonMain-aoKHeg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.lifecycle-lifecycle-viewmodel-savedstate-2.10.0-alpha07-commonMain-aoKHeg.klib new file mode 100644 index 0000000..440a11b Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.lifecycle-lifecycle-viewmodel-savedstate-2.10.0-alpha07-commonMain-aoKHeg.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.navigation3-navigation3-ui-1.0.0-alpha06-commonMain-LvU4Gg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.navigation3-navigation3-ui-1.0.0-alpha06-commonMain-LvU4Gg.klib new file mode 100644 index 0000000..263b9ab Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.navigation3-navigation3-ui-1.0.0-alpha06-commonMain-LvU4Gg.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.navigation3-navigation3-ui-1.0.0-alpha06-uikitMain-ZuYz1Q.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.navigation3-navigation3-ui-1.0.0-alpha06-uikitMain-ZuYz1Q.klib new file mode 100644 index 0000000..67d1236 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.navigation3-navigation3-ui-1.0.0-alpha06-uikitMain-ZuYz1Q.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.navigationevent-navigationevent-compose-1.0.0-rc01-commonMain-8XaMeA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.navigationevent-navigationevent-compose-1.0.0-rc01-commonMain-8XaMeA.klib new file mode 100644 index 0000000..00bdec8 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.navigationevent-navigationevent-compose-1.0.0-rc01-commonMain-8XaMeA.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.navigationevent-navigationevent-compose-1.0.0-rc01-nonAndroidMain-8XaMeA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.navigationevent-navigationevent-compose-1.0.0-rc01-nonAndroidMain-8XaMeA.klib new file mode 100644 index 0000000..e756dd8 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.navigationevent-navigationevent-compose-1.0.0-rc01-nonAndroidMain-8XaMeA.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.savedstate-savedstate-1.3.6-commonMain-Gx5ULw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.savedstate-savedstate-1.3.6-commonMain-Gx5ULw.klib new file mode 100644 index 0000000..4bdf70f Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.savedstate-savedstate-1.3.6-commonMain-Gx5ULw.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.savedstate-savedstate-compose-1.3.6-commonMain-Fw7d1w.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.savedstate-savedstate-compose-1.3.6-commonMain-Fw7d1w.klib new file mode 100644 index 0000000..7dd18e3 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.savedstate-savedstate-compose-1.3.6-commonMain-Fw7d1w.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.animation-animation-1.10.0-rc02-commonMain-XCRBBA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.animation-animation-1.10.0-rc02-commonMain-XCRBBA.klib new file mode 100644 index 0000000..368a0f6 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.animation-animation-1.10.0-rc02-commonMain-XCRBBA.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.animation-animation-1.10.0-rc02-nonAndroidMain-XCRBBA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.animation-animation-1.10.0-rc02-nonAndroidMain-XCRBBA.klib new file mode 100644 index 0000000..3526df8 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.animation-animation-1.10.0-rc02-nonAndroidMain-XCRBBA.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.animation-animation-1.10.0-rc02-nonJvmMain-XCRBBA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.animation-animation-1.10.0-rc02-nonJvmMain-XCRBBA.klib new file mode 100644 index 0000000..7d48cf6 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.animation-animation-1.10.0-rc02-nonJvmMain-XCRBBA.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.animation-animation-core-1.10.0-rc02-commonMain-Y18Qzg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.animation-animation-core-1.10.0-rc02-commonMain-Y18Qzg.klib new file mode 100644 index 0000000..2d6a00f Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.animation-animation-core-1.10.0-rc02-commonMain-Y18Qzg.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.animation-animation-core-1.10.0-rc02-nonJvmMain-Y18Qzg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.animation-animation-core-1.10.0-rc02-nonJvmMain-Y18Qzg.klib new file mode 100644 index 0000000..4699c8d Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.animation-animation-core-1.10.0-rc02-nonJvmMain-Y18Qzg.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.annotation-internal-annotation-1.10.0-rc02-commonMain-jG1XTg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.annotation-internal-annotation-1.10.0-rc02-commonMain-jG1XTg.klib new file mode 100644 index 0000000..280db72 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.annotation-internal-annotation-1.10.0-rc02-commonMain-jG1XTg.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.collection-internal-collection-1.10.0-rc02-commonMain-PdmhIQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.collection-internal-collection-1.10.0-rc02-commonMain-PdmhIQ.klib new file mode 100644 index 0000000..42e411a Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.collection-internal-collection-1.10.0-rc02-commonMain-PdmhIQ.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.components-components-resources-1.10.0-rc02-blockingMain-OSNkTw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.components-components-resources-1.10.0-rc02-blockingMain-OSNkTw.klib new file mode 100644 index 0000000..f254840 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.components-components-resources-1.10.0-rc02-blockingMain-OSNkTw.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.components-components-resources-1.10.0-rc02-commonMain-OSNkTw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.components-components-resources-1.10.0-rc02-commonMain-OSNkTw.klib new file mode 100644 index 0000000..424d4ae Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.components-components-resources-1.10.0-rc02-commonMain-OSNkTw.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.components-components-resources-1.10.0-rc02-iosMain-_aNPIA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.components-components-resources-1.10.0-rc02-iosMain-_aNPIA.klib new file mode 100644 index 0000000..751b9a3 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.components-components-resources-1.10.0-rc02-iosMain-_aNPIA.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.components-components-resources-1.10.0-rc02-nativeMain-_aNPIA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.components-components-resources-1.10.0-rc02-nativeMain-_aNPIA.klib new file mode 100644 index 0000000..fd1d139 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.components-components-resources-1.10.0-rc02-nativeMain-_aNPIA.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.components-components-resources-1.10.0-rc02-skikoMain-OSNkTw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.components-components-resources-1.10.0-rc02-skikoMain-OSNkTw.klib new file mode 100644 index 0000000..266e902 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.components-components-resources-1.10.0-rc02-skikoMain-OSNkTw.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.foundation-foundation-1.10.0-rc02-commonMain-buy1Tw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.foundation-foundation-1.10.0-rc02-commonMain-buy1Tw.klib new file mode 100644 index 0000000..5c44f5c Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.foundation-foundation-1.10.0-rc02-commonMain-buy1Tw.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.foundation-foundation-1.10.0-rc02-darwinMain-BSwZaQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.foundation-foundation-1.10.0-rc02-darwinMain-BSwZaQ.klib new file mode 100644 index 0000000..b486799 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.foundation-foundation-1.10.0-rc02-darwinMain-BSwZaQ.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.foundation-foundation-1.10.0-rc02-nativeMain-BSwZaQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.foundation-foundation-1.10.0-rc02-nativeMain-BSwZaQ.klib new file mode 100644 index 0000000..61dd533 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.foundation-foundation-1.10.0-rc02-nativeMain-BSwZaQ.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.foundation-foundation-1.10.0-rc02-nonJvmMain-buy1Tw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.foundation-foundation-1.10.0-rc02-nonJvmMain-buy1Tw.klib new file mode 100644 index 0000000..c3b0dad Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.foundation-foundation-1.10.0-rc02-nonJvmMain-buy1Tw.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.foundation-foundation-1.10.0-rc02-skikoMain-buy1Tw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.foundation-foundation-1.10.0-rc02-skikoMain-buy1Tw.klib new file mode 100644 index 0000000..10fbf76 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.foundation-foundation-1.10.0-rc02-skikoMain-buy1Tw.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.foundation-foundation-1.10.0-rc02-uikitMain-BSwZaQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.foundation-foundation-1.10.0-rc02-uikitMain-BSwZaQ.klib new file mode 100644 index 0000000..e872a72 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.foundation-foundation-1.10.0-rc02-uikitMain-BSwZaQ.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.foundation-foundation-layout-1.10.0-rc02-commonMain-pSMAXg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.foundation-foundation-layout-1.10.0-rc02-commonMain-pSMAXg.klib new file mode 100644 index 0000000..2a1e4ad Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.foundation-foundation-layout-1.10.0-rc02-commonMain-pSMAXg.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.foundation-foundation-layout-1.10.0-rc02-nonJvmMain-pSMAXg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.foundation-foundation-layout-1.10.0-rc02-nonJvmMain-pSMAXg.klib new file mode 100644 index 0000000..ac48842 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.foundation-foundation-layout-1.10.0-rc02-nonJvmMain-pSMAXg.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.foundation-foundation-layout-1.10.0-rc02-skikoMain-pSMAXg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.foundation-foundation-layout-1.10.0-rc02-skikoMain-pSMAXg.klib new file mode 100644 index 0000000..9c02de3 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.foundation-foundation-layout-1.10.0-rc02-skikoMain-pSMAXg.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.material-material-ripple-1.9.1-commonMain-tnX3iQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.material-material-ripple-1.9.1-commonMain-tnX3iQ.klib new file mode 100644 index 0000000..ada7df9 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.material-material-ripple-1.9.1-commonMain-tnX3iQ.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.material-material-ripple-1.9.1-nonAndroidMain-tnX3iQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.material-material-ripple-1.9.1-nonAndroidMain-tnX3iQ.klib new file mode 100644 index 0000000..9918195 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.material-material-ripple-1.9.1-nonAndroidMain-tnX3iQ.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.material3-material3-1.9.0-commonMain-PrP0Cw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.material3-material3-1.9.0-commonMain-PrP0Cw.klib new file mode 100644 index 0000000..9ba43d2 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.material3-material3-1.9.0-commonMain-PrP0Cw.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.material3-material3-1.9.0-darwinMain-ZkaPNQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.material3-material3-1.9.0-darwinMain-ZkaPNQ.klib new file mode 100644 index 0000000..e1db80f Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.material3-material3-1.9.0-darwinMain-ZkaPNQ.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.material3-material3-1.9.0-nativeMain-ZkaPNQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.material3-material3-1.9.0-nativeMain-ZkaPNQ.klib new file mode 100644 index 0000000..7c4a4bf Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.material3-material3-1.9.0-nativeMain-ZkaPNQ.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.material3-material3-1.9.0-nonJvmMain-PrP0Cw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.material3-material3-1.9.0-nonJvmMain-PrP0Cw.klib new file mode 100644 index 0000000..07f52ef Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.material3-material3-1.9.0-nonJvmMain-PrP0Cw.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.material3-material3-1.9.0-skikoMain-PrP0Cw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.material3-material3-1.9.0-skikoMain-PrP0Cw.klib new file mode 100644 index 0000000..cae7d47 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.material3-material3-1.9.0-skikoMain-PrP0Cw.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.runtime-runtime-1.10.0-rc02-commonMain-y8wUOA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.runtime-runtime-1.10.0-rc02-commonMain-y8wUOA.klib new file mode 100644 index 0000000..a593f06 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.runtime-runtime-1.10.0-rc02-commonMain-y8wUOA.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.runtime-runtime-saveable-1.10.0-rc02-commonMain-uS-nIg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.runtime-runtime-saveable-1.10.0-rc02-commonMain-uS-nIg.klib new file mode 100644 index 0000000..7be9555 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.runtime-runtime-saveable-1.10.0-rc02-commonMain-uS-nIg.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-1.10.0-rc02-commonMain-Ikk6iA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-1.10.0-rc02-commonMain-Ikk6iA.klib new file mode 100644 index 0000000..f6ffa69 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-1.10.0-rc02-commonMain-Ikk6iA.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-1.10.0-rc02-nativeMain-NIMPcw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-1.10.0-rc02-nativeMain-NIMPcw.klib new file mode 100644 index 0000000..ac4d84f Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-1.10.0-rc02-nativeMain-NIMPcw.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-1.10.0-rc02-nonJvmMain-Ikk6iA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-1.10.0-rc02-nonJvmMain-Ikk6iA.klib new file mode 100644 index 0000000..cc6dd80 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-1.10.0-rc02-nonJvmMain-Ikk6iA.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-1.10.0-rc02-skikoMain-Ikk6iA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-1.10.0-rc02-skikoMain-Ikk6iA.klib new file mode 100644 index 0000000..511f703 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-1.10.0-rc02-skikoMain-Ikk6iA.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-1.10.0-rc02-uikitMain-NIMPcw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-1.10.0-rc02-uikitMain-NIMPcw.klib new file mode 100644 index 0000000..f4e4008 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-1.10.0-rc02-uikitMain-NIMPcw.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-backhandler-1.10.0-rc02-commonMain-CRGTfA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-backhandler-1.10.0-rc02-commonMain-CRGTfA.klib new file mode 100644 index 0000000..0e97b7c Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-backhandler-1.10.0-rc02-commonMain-CRGTfA.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-backhandler-1.10.0-rc02-jbMain-CRGTfA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-backhandler-1.10.0-rc02-jbMain-CRGTfA.klib new file mode 100644 index 0000000..39e6459 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-backhandler-1.10.0-rc02-jbMain-CRGTfA.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-geometry-1.10.0-rc02-commonMain-qqJ-Hg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-geometry-1.10.0-rc02-commonMain-qqJ-Hg.klib new file mode 100644 index 0000000..572f423 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-geometry-1.10.0-rc02-commonMain-qqJ-Hg.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-graphics-1.10.0-rc02-commonMain-DCwFSQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-graphics-1.10.0-rc02-commonMain-DCwFSQ.klib new file mode 100644 index 0000000..57ffbba Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-graphics-1.10.0-rc02-commonMain-DCwFSQ.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-graphics-1.10.0-rc02-nativeMain-SQidgQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-graphics-1.10.0-rc02-nativeMain-SQidgQ.klib new file mode 100644 index 0000000..b94deed Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-graphics-1.10.0-rc02-nativeMain-SQidgQ.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-graphics-1.10.0-rc02-nonJvmMain-DCwFSQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-graphics-1.10.0-rc02-nonJvmMain-DCwFSQ.klib new file mode 100644 index 0000000..ea1c08f Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-graphics-1.10.0-rc02-nonJvmMain-DCwFSQ.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-graphics-1.10.0-rc02-skikoExcludingWebMain-DCwFSQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-graphics-1.10.0-rc02-skikoExcludingWebMain-DCwFSQ.klib new file mode 100644 index 0000000..8b630bd Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-graphics-1.10.0-rc02-skikoExcludingWebMain-DCwFSQ.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-graphics-1.10.0-rc02-skikoMain-DCwFSQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-graphics-1.10.0-rc02-skikoMain-DCwFSQ.klib new file mode 100644 index 0000000..4e34ac0 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-graphics-1.10.0-rc02-skikoMain-DCwFSQ.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-text-1.10.0-rc02-commonMain-kU2cPg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-text-1.10.0-rc02-commonMain-kU2cPg.klib new file mode 100644 index 0000000..05e93f5 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-text-1.10.0-rc02-commonMain-kU2cPg.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-text-1.10.0-rc02-darwinMain-PRSAaQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-text-1.10.0-rc02-darwinMain-PRSAaQ.klib new file mode 100644 index 0000000..5674e9c Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-text-1.10.0-rc02-darwinMain-PRSAaQ.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-text-1.10.0-rc02-nativeMain-PRSAaQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-text-1.10.0-rc02-nativeMain-PRSAaQ.klib new file mode 100644 index 0000000..dab9950 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-text-1.10.0-rc02-nativeMain-PRSAaQ.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-text-1.10.0-rc02-nonJvmMain-kU2cPg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-text-1.10.0-rc02-nonJvmMain-kU2cPg.klib new file mode 100644 index 0000000..c791e1e Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-text-1.10.0-rc02-nonJvmMain-kU2cPg.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-text-1.10.0-rc02-skikoMain-kU2cPg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-text-1.10.0-rc02-skikoMain-kU2cPg.klib new file mode 100644 index 0000000..49daffa Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-text-1.10.0-rc02-skikoMain-kU2cPg.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-text-1.10.0-rc02-uikitMain-PRSAaQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-text-1.10.0-rc02-uikitMain-PRSAaQ.klib new file mode 100644 index 0000000..bee395d Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-text-1.10.0-rc02-uikitMain-PRSAaQ.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-tooling-preview-1.10.0-rc02-commonMain-8Expeg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-tooling-preview-1.10.0-rc02-commonMain-8Expeg.klib new file mode 100644 index 0000000..03ae69f Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-tooling-preview-1.10.0-rc02-commonMain-8Expeg.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-tooling-preview-1.10.0-rc02-nonJvmMain-8Expeg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-tooling-preview-1.10.0-rc02-nonJvmMain-8Expeg.klib new file mode 100644 index 0000000..2b29a20 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-tooling-preview-1.10.0-rc02-nonJvmMain-8Expeg.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-uikit-1.10.0-rc02-uikitMain-Q1SrDw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-uikit-1.10.0-rc02-uikitMain-Q1SrDw.klib new file mode 100644 index 0000000..e41f926 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-uikit-1.10.0-rc02-uikitMain-Q1SrDw.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-unit-1.10.0-rc02-commonMain-JGR4Tw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-unit-1.10.0-rc02-commonMain-JGR4Tw.klib new file mode 100644 index 0000000..6106981 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-unit-1.10.0-rc02-commonMain-JGR4Tw.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-unit-1.10.0-rc02-nonAndroidMain-JGR4Tw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-unit-1.10.0-rc02-nonAndroidMain-JGR4Tw.klib new file mode 100644 index 0000000..787a5e7 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-unit-1.10.0-rc02-nonAndroidMain-JGR4Tw.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-unit-1.10.0-rc02-nonJvmMain-JGR4Tw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-unit-1.10.0-rc02-nonJvmMain-JGR4Tw.klib new file mode 100644 index 0000000..bb881b6 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-unit-1.10.0-rc02-nonJvmMain-JGR4Tw.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-util-1.10.0-rc02-commonMain-WTwl9g.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-util-1.10.0-rc02-commonMain-WTwl9g.klib new file mode 100644 index 0000000..218c7a3 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-util-1.10.0-rc02-commonMain-WTwl9g.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-util-1.10.0-rc02-nonJvmMain-WTwl9g.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-util-1.10.0-rc02-nonJvmMain-WTwl9g.klib new file mode 100644 index 0000000..4ea2627 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-util-1.10.0-rc02-nonJvmMain-WTwl9g.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-util-1.10.0-rc02-uikitMain-sNYMHg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-util-1.10.0-rc02-uikitMain-sNYMHg.klib new file mode 100644 index 0000000..822e00e Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-util-1.10.0-rc02-uikitMain-sNYMHg.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlin-kotlin-stdlib-2.3.0-commonMain-1ooqQQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlin-kotlin-stdlib-2.3.0-commonMain-1ooqQQ.klib new file mode 100644 index 0000000..c0cccdf Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlin-kotlin-stdlib-2.3.0-commonMain-1ooqQQ.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlin-kotlin-test-2.2.0-annotationsCommonMain-4o4N4g.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlin-kotlin-test-2.2.0-annotationsCommonMain-4o4N4g.klib new file mode 100644 index 0000000..e38964f Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlin-kotlin-test-2.2.0-annotationsCommonMain-4o4N4g.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlin-kotlin-test-2.2.0-assertionsCommonMain-4o4N4g.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlin-kotlin-test-2.2.0-assertionsCommonMain-4o4N4g.klib new file mode 100644 index 0000000..82b8e68 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlin-kotlin-test-2.2.0-assertionsCommonMain-4o4N4g.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-atomicfu-0.23.1-commonMain-wFq7cg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-atomicfu-0.23.1-commonMain-wFq7cg.klib new file mode 100644 index 0000000..2cc3cd8 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-atomicfu-0.23.1-commonMain-wFq7cg.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-atomicfu-0.23.1-nativeMain-wFq7cg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-atomicfu-0.23.1-nativeMain-wFq7cg.klib new file mode 100644 index 0000000..bed0f0d Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-atomicfu-0.23.1-nativeMain-wFq7cg.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-atomicfu-0.27.0-commonMain-7K06eQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-atomicfu-0.27.0-commonMain-7K06eQ.klib new file mode 100644 index 0000000..7940a04 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-atomicfu-0.27.0-commonMain-7K06eQ.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-atomicfu-0.27.0-nativeMain-7K06eQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-atomicfu-0.27.0-nativeMain-7K06eQ.klib new file mode 100644 index 0000000..ca1adc5 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-atomicfu-0.27.0-nativeMain-7K06eQ.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-atomicfu-0.27.0-nativeUnixLikeMain-7K06eQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-atomicfu-0.27.0-nativeUnixLikeMain-7K06eQ.klib new file mode 100644 index 0000000..0657f2b Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-atomicfu-0.27.0-nativeUnixLikeMain-7K06eQ.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-coroutines-core-1.10.2-commonMain-wJOvIw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-coroutines-core-1.10.2-commonMain-wJOvIw.klib new file mode 100644 index 0000000..c50afea Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-coroutines-core-1.10.2-commonMain-wJOvIw.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-coroutines-core-1.10.2-concurrentMain-wJOvIw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-coroutines-core-1.10.2-concurrentMain-wJOvIw.klib new file mode 100644 index 0000000..f3bdf1c Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-coroutines-core-1.10.2-concurrentMain-wJOvIw.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-coroutines-core-1.10.2-nativeDarwinMain--jzUgA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-coroutines-core-1.10.2-nativeDarwinMain--jzUgA.klib new file mode 100644 index 0000000..295f171 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-coroutines-core-1.10.2-nativeDarwinMain--jzUgA.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-coroutines-core-1.10.2-nativeMain-wJOvIw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-coroutines-core-1.10.2-nativeMain-wJOvIw.klib new file mode 100644 index 0000000..b2db0ee Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-coroutines-core-1.10.2-nativeMain-wJOvIw.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-coroutines-test-1.10.2-commonMain-vXmODA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-coroutines-test-1.10.2-commonMain-vXmODA.klib new file mode 100644 index 0000000..308bdb0 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-coroutines-test-1.10.2-commonMain-vXmODA.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-coroutines-test-1.10.2-nativeMain-vXmODA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-coroutines-test-1.10.2-nativeMain-vXmODA.klib new file mode 100644 index 0000000..69fa1e6 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-coroutines-test-1.10.2-nativeMain-vXmODA.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-datetime-0.7.1-commonKotlinMain-uTxUqg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-datetime-0.7.1-commonKotlinMain-uTxUqg.klib new file mode 100644 index 0000000..9f01ebc Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-datetime-0.7.1-commonKotlinMain-uTxUqg.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-datetime-0.7.1-commonMain-uTxUqg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-datetime-0.7.1-commonMain-uTxUqg.klib new file mode 100644 index 0000000..bc9c565 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-datetime-0.7.1-commonMain-uTxUqg.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-datetime-0.7.1-darwinMain-HeEIcg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-datetime-0.7.1-darwinMain-HeEIcg.klib new file mode 100644 index 0000000..82ded14 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-datetime-0.7.1-darwinMain-HeEIcg.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-datetime-0.7.1-tzdbOnFilesystemMain-uTxUqg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-datetime-0.7.1-tzdbOnFilesystemMain-uTxUqg.klib new file mode 100644 index 0000000..f4db381 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-datetime-0.7.1-tzdbOnFilesystemMain-uTxUqg.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-datetime-0.7.1-tzfileMain-uTxUqg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-datetime-0.7.1-tzfileMain-uTxUqg.klib new file mode 100644 index 0000000..b517675 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-datetime-0.7.1-tzfileMain-uTxUqg.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-serialization-core-1.6.2-commonMain-0z2eOA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-serialization-core-1.6.2-commonMain-0z2eOA.klib new file mode 100644 index 0000000..c4a8446 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-serialization-core-1.6.2-commonMain-0z2eOA.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-serialization-core-1.6.2-nativeMain-0z2eOA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-serialization-core-1.6.2-nativeMain-0z2eOA.klib new file mode 100644 index 0000000..68b2ea6 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-serialization-core-1.6.2-nativeMain-0z2eOA.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-serialization-core-1.9.0-commonMain-DVGtaQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-serialization-core-1.9.0-commonMain-DVGtaQ.klib new file mode 100644 index 0000000..951a26c Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-serialization-core-1.9.0-commonMain-DVGtaQ.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-serialization-core-1.9.0-nativeMain-DVGtaQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-serialization-core-1.9.0-nativeMain-DVGtaQ.klib new file mode 100644 index 0000000..c77ed23 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-serialization-core-1.9.0-nativeMain-DVGtaQ.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-serialization-json-1.9.0-commonMain-_ulzXg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-serialization-json-1.9.0-commonMain-_ulzXg.klib new file mode 100644 index 0000000..17e7b9f Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-serialization-json-1.9.0-commonMain-_ulzXg.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-serialization-json-1.9.0-nativeMain-_ulzXg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-serialization-json-1.9.0-nativeMain-_ulzXg.klib new file mode 100644 index 0000000..ffbb93d Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-serialization-json-1.9.0-nativeMain-_ulzXg.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.skiko-skiko-0.9.37.3-commonMain-aAAseA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.skiko-skiko-0.9.37.3-commonMain-aAAseA.klib new file mode 100644 index 0000000..ab89de3 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.skiko-skiko-0.9.37.3-commonMain-aAAseA.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.skiko-skiko-0.9.37.3-darwinMain-TtyIcw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.skiko-skiko-0.9.37.3-darwinMain-TtyIcw.klib new file mode 100644 index 0000000..9a555d5 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.skiko-skiko-0.9.37.3-darwinMain-TtyIcw.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.skiko-skiko-0.9.37.3-iosMain-TtyIcw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.skiko-skiko-0.9.37.3-iosMain-TtyIcw.klib new file mode 100644 index 0000000..8620cfa Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.skiko-skiko-0.9.37.3-iosMain-TtyIcw.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.skiko-skiko-0.9.37.3-nativeJsMain-aAAseA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.skiko-skiko-0.9.37.3-nativeJsMain-aAAseA.klib new file mode 100644 index 0000000..4497fcd Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.skiko-skiko-0.9.37.3-nativeJsMain-aAAseA.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.skiko-skiko-0.9.37.3-nativeMain-aAAseA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.skiko-skiko-0.9.37.3-nativeMain-aAAseA.klib new file mode 100644 index 0000000..115ac39 Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.skiko-skiko-0.9.37.3-nativeMain-aAAseA.klib differ diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.skiko-skiko-0.9.37.3-uikitMain-TtyIcw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.skiko-skiko-0.9.37.3-uikitMain-TtyIcw.klib new file mode 100644 index 0000000..ec4ca1e Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.skiko-skiko-0.9.37.3-uikitMain-TtyIcw.klib differ diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e94453f..625450a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,4 +1,7 @@ +@file:OptIn(ExperimentalSwiftExportDsl::class) + import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.swiftexport.ExperimentalSwiftExportDsl plugins { alias(libs.plugins.android.gradle) @@ -174,6 +177,7 @@ android { testOptions { unitTests.isReturnDefaultValues = true + unitTests.isIncludeAndroidResources = true } } @@ -182,6 +186,8 @@ dependencies { testImplementation(libs.junit) testImplementation(libs.coroutines.test) + testImplementation(libs.robolectric) + testImplementation(libs.androidx.core) androidTestImplementation(libs.androidx.test.ext.junit) androidTestImplementation(libs.androidx.test.espresso.core) testImplementation(libs.mockk) diff --git a/app/src/androidMain/AndroidManifest.xml b/app/src/androidMain/AndroidManifest.xml index 6110523..02183f5 100644 --- a/app/src/androidMain/AndroidManifest.xml +++ b/app/src/androidMain/AndroidManifest.xml @@ -6,6 +6,7 @@ + @@ -50,6 +51,10 @@ + diff --git a/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/AlarmReceiver.kt b/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/AlarmReceiver.kt index ef21413..cec22b1 100644 --- a/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/AlarmReceiver.kt +++ b/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/AlarmReceiver.kt @@ -8,10 +8,8 @@ import android.content.Context import android.content.Intent import android.os.PowerManager import co.touchlab.kermit.Logger +import com.timilehinaregbesola.mathalarm.coroutines.AppCoroutineScope import com.timilehinaregbesola.mathalarm.framework.Usecases -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent import org.koin.core.component.inject @@ -19,9 +17,9 @@ import org.koin.core.component.inject * [BroadcastReceiver] to be notified by the [android.app.AlarmManager]. */ class AlarmReceiver : BroadcastReceiver(), KoinComponent { - val usecases: Usecases by inject() + private val usecases: Usecases by inject() + private val appScope: AppCoroutineScope by inject() - @OptIn(DelicateCoroutinesApi::class) override fun onReceive(context: Context, intent: Intent) { Logger.d("onReceive() - intent ${intent.action}") @@ -33,21 +31,27 @@ class AlarmReceiver : BroadcastReceiver(), KoinComponent { ) wakelock.acquire(3000) } - GlobalScope.launch { + + appScope.launch { handleIntent(intent) } } - private suspend fun handleIntent(intent: Intent?): Unit? { - return when (intent?.action) { + private suspend fun handleIntent(intent: Intent?) { + when (intent?.action) { ALARM_ACTION -> getAlarmId(intent)?.let { usecases.showAlarm(it) } COMPLETE_ACTION -> getAlarmId(intent)?.let { usecases.completeAlarm(it) } SNOOZE_ACTION -> getAlarmId(intent)?.let { usecases.snoozeAlarm(it) } + DISMISS_ACTION -> { + // User swiped away the notification - immediately re-show it + Logger.d("Notification dismissed by user, re-showing alarm") + getAlarmId(intent)?.let { usecases.showAlarm(it) } + } Intent.ACTION_BOOT_COMPLETED, "android.intent.action.QUICKBOOT_POWERON", "android.intent.action.MY_PACKAGE_REPLACED", AlarmManager.ACTION_SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED -> { - Logger.d("Reboot Reboot!!") + Logger.d("Rescheduling alarms after system event") usecases.rescheduleFutureAlarms() } else -> { @@ -68,5 +72,7 @@ class AlarmReceiver : BroadcastReceiver(), KoinComponent { const val COMPLETE_ACTION = "com.timilehinaregbesola.mathalarm.SET_COMPLETE" const val SNOOZE_ACTION = "com.timilehinaregbesola.mathalarm.SNOOZE" + + const val DISMISS_ACTION = "com.timilehinaregbesola.mathalarm.NOTIFICATION_DISMISSED" } } diff --git a/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/framework/app/di/AppModule.kt b/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/framework/app/di/AppModule.kt index be55f45..bd489ba 100644 --- a/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/framework/app/di/AppModule.kt +++ b/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/framework/app/di/AppModule.kt @@ -16,6 +16,10 @@ import com.timilehinaregbesola.mathalarm.framework.app.permission.AlarmPermissio import com.timilehinaregbesola.mathalarm.framework.app.permission.AlarmPermissionImpl import com.timilehinaregbesola.mathalarm.framework.app.permission.AndroidVersion import com.timilehinaregbesola.mathalarm.framework.app.permission.AndroidVersionImpl +import com.timilehinaregbesola.mathalarm.framework.app.permission.PermissionChecker +import com.timilehinaregbesola.mathalarm.framework.app.permission.PermissionCheckerImpl +import com.timilehinaregbesola.mathalarm.framework.app.permission.ScreenNavigator +import com.timilehinaregbesola.mathalarm.framework.app.permission.ScreenNavigatorImpl import com.timilehinaregbesola.mathalarm.framework.database.AlarmDatabase import com.timilehinaregbesola.mathalarm.framework.database.MIGRATION_2_3 import com.timilehinaregbesola.mathalarm.framework.database.MIGRATION_3_4 @@ -28,6 +32,7 @@ import com.timilehinaregbesola.mathalarm.interactors.PlayerWrapper import com.timilehinaregbesola.mathalarm.notification.AlarmNotificationScheduler import com.timilehinaregbesola.mathalarm.notification.MathAlarmNotification import com.timilehinaregbesola.mathalarm.notification.MathAlarmNotificationChannel +import com.timilehinaregbesola.mathalarm.notification.PendingIntentIdGenerator import com.timilehinaregbesola.mathalarm.utils.getAlarmManager import kotlinx.coroutines.InternalCoroutinesApi import org.koin.android.ext.koin.androidApplication @@ -59,6 +64,10 @@ val androidModule = module { single { get().alarmDatabaseDao } + single { PendingIntentIdGenerator() } + + single { AlarmNotificationScheduler(androidContext(), getWith("AlarmNotificationScheduler"), get()) } + // Android Alarm Interactor single { AlarmInteractorImpl(get(), getWith("AlarmInteractorImpl")) } @@ -75,8 +84,6 @@ val androidModule = module { single { MathAlarmNotificationChannel(androidContext()) } - single { AlarmNotificationScheduler(androidContext(), getWith("AlarmNotificationScheduler")) } - @OptIn( ExperimentalAnimationApi::class, InternalCoroutinesApi::class, @@ -86,7 +93,7 @@ val androidModule = module { ) single { NotificationInteractorImpl( - get(), + androidContext(), getWith("NotificationInteractorImpl") ) } @@ -94,11 +101,17 @@ val androidModule = module { // Android Audio Player single { PlayerWrapper(androidContext(), getWith("PlayerWrapper")) } - // Android Version and Permission + // Permission abstractions single { AndroidVersionImpl() } + single { ScreenNavigatorImpl(androidContext()) } + single { PermissionCheckerImpl(androidContext().getAlarmManager()) } single { - AlarmPermissionImpl(androidContext().getAlarmManager(), get()) + AlarmPermissionImpl( + screenNavigator = get(), + permissionChecker = get(), + androidVersion = get() + ) } // Platform Logger - Android-specific with Crashlytics integration diff --git a/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/framework/app/permission/AlarmPermission.kt b/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/framework/app/permission/AlarmPermission.kt index 0a967cc..82d65cf 100644 --- a/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/framework/app/permission/AlarmPermission.kt +++ b/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/framework/app/permission/AlarmPermission.kt @@ -1,11 +1,13 @@ package com.timilehinaregbesola.mathalarm.framework.app.permission -import android.annotation.SuppressLint -import android.app.AlarmManager import android.os.Build +/** + * Android implementation of [AlarmPermission] using abstracted dependencies. + */ class AlarmPermissionImpl( - private val alarmManager: AlarmManager?, + private val screenNavigator: ScreenNavigator, + private val permissionChecker: PermissionChecker, private val androidVersion: AndroidVersion ) : AlarmPermission { @@ -14,14 +16,19 @@ class AlarmPermissionImpl( * * @return `true` if the permission is granted, `false` otherwise */ - @SuppressLint("NewApi") override fun hasExactAlarmPermission(): Boolean { - if (alarmManager == null) return false - return if (androidVersion.currentVersion >= Build.VERSION_CODES.S) { - alarmManager.canScheduleExactAlarms() + permissionChecker.canScheduleExactAlarms() } else { true } } + + override fun openExactAlarmPermissionScreen() { + screenNavigator.openExactAlarmPermissionScreen() + } + + override fun openAppSettings() { + screenNavigator.openAppSettings() + } } diff --git a/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/framework/app/permission/PermissionChecker.kt b/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/framework/app/permission/PermissionChecker.kt new file mode 100644 index 0000000..c65c1c2 --- /dev/null +++ b/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/framework/app/permission/PermissionChecker.kt @@ -0,0 +1,13 @@ +package com.timilehinaregbesola.mathalarm.framework.app.permission + +/** + * Abstraction for checking system permissions. + * Extracted for better testability. + */ +interface PermissionChecker { + /** + * Checks if the app can schedule exact alarms. + * @return true if exact alarms can be scheduled + */ + fun canScheduleExactAlarms(): Boolean +} diff --git a/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/framework/app/permission/PermissionCheckerImpl.kt b/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/framework/app/permission/PermissionCheckerImpl.kt new file mode 100644 index 0000000..5fc5d58 --- /dev/null +++ b/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/framework/app/permission/PermissionCheckerImpl.kt @@ -0,0 +1,24 @@ +package com.timilehinaregbesola.mathalarm.framework.app.permission + +import android.annotation.SuppressLint +import android.app.AlarmManager +import android.os.Build + +/** + * Android implementation of [PermissionChecker]. + */ +class PermissionCheckerImpl( + private val alarmManager: AlarmManager? +) : PermissionChecker { + + @SuppressLint("NewApi") + override fun canScheduleExactAlarms(): Boolean { + if (alarmManager == null) return false + + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + alarmManager.canScheduleExactAlarms() + } else { + true + } + } +} diff --git a/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/framework/app/permission/ScreenNavigator.kt b/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/framework/app/permission/ScreenNavigator.kt new file mode 100644 index 0000000..a272209 --- /dev/null +++ b/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/framework/app/permission/ScreenNavigator.kt @@ -0,0 +1,17 @@ +package com.timilehinaregbesola.mathalarm.framework.app.permission + +/** + * Abstraction for navigating to system screens. + * Extracted for better testability. + */ +interface ScreenNavigator { + /** + * Opens the exact alarm permission screen (Android S+). + */ + fun openExactAlarmPermissionScreen() + + /** + * Opens the app settings screen. + */ + fun openAppSettings() +} diff --git a/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/framework/app/permission/ScreenNavigatorImpl.kt b/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/framework/app/permission/ScreenNavigatorImpl.kt new file mode 100644 index 0000000..945b362 --- /dev/null +++ b/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/framework/app/permission/ScreenNavigatorImpl.kt @@ -0,0 +1,41 @@ +package com.timilehinaregbesola.mathalarm.framework.app.permission + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.provider.Settings +import androidx.core.net.toUri + +/** + * Android implementation of [ScreenNavigator]. + */ +class ScreenNavigatorImpl( + private val context: Context +) : ScreenNavigator { + + override fun openExactAlarmPermissionScreen() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val intent = Intent( + Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM, + "package:${context.packageName}".toUri() + ).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + } else { + // On older versions, open app settings instead + openAppSettings() + } + } + + override fun openAppSettings() { + val intent = Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + "package:${context.packageName}".toUri() + ).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + } +} diff --git a/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/interactors/AlarmInteractorImpl.kt b/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/interactors/AlarmInteractorImpl.kt index 98b91dc..a8e6be0 100644 --- a/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/interactors/AlarmInteractorImpl.kt +++ b/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/interactors/AlarmInteractorImpl.kt @@ -3,26 +3,24 @@ package com.timilehinaregbesola.mathalarm.interactors import co.touchlab.kermit.Logger import com.timilehinaregbesola.mathalarm.domain.model.Alarm import com.timilehinaregbesola.mathalarm.notification.AlarmNotificationScheduler -import org.koin.core.component.KoinComponent -import org.koin.core.component.inject -import org.koin.core.parameter.parametersOf class AlarmInteractorImpl( private val alarmManager: AlarmNotificationScheduler, private val logger: Logger -) : - AlarmInteractor, KoinComponent { +) : AlarmInteractor { - override fun schedule(alarm: Alarm, reschedule: Boolean): Boolean { - logger.d("AlarmInteractorImpl.schedule called: alarmId=${alarm.alarmId}, time=${alarm.hour}:${alarm.minute}, repeat=${alarm.repeat}, repeatDays=${alarm.repeatDays}, reschedule=$reschedule") - val result = alarmManager.scheduleAlarm(alarm, reschedule) - logger.d("AlarmInteractorImpl.schedule result for alarmId=${alarm.alarmId}: $result") - return result + override fun schedule(alarm: Alarm, timeInMillis: Long) { + logger.d("AlarmInteractorImpl.schedule: alarmId=${alarm.alarmId}, timeInMillis=$timeInMillis") + alarmManager.scheduleAlarm(alarm, timeInMillis) } override fun cancel(alarm: Alarm) { - logger.d("AlarmInteractorImpl.cancel called: alarmId=${alarm.alarmId}, time=${alarm.hour}:${alarm.minute}, repeat=${alarm.repeat}, repeatDays=${alarm.repeatDays}") + logger.d("AlarmInteractorImpl.cancel: alarmId=${alarm.alarmId}") alarmManager.cancelAlarm(alarm) - logger.d("AlarmInteractorImpl.cancel completed for alarmId=${alarm.alarmId}") + } + + override fun update(alarm: Alarm) { + logger.d("AlarmInteractorImpl.update: alarmId=${alarm.alarmId}") + alarmManager.updateAlarm(alarm) } } diff --git a/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/interactors/AudioPlayer.kt b/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/interactors/AudioPlayer.kt index 1eabd94..aa2309f 100644 --- a/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/interactors/AudioPlayer.kt +++ b/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/interactors/AudioPlayer.kt @@ -10,6 +10,7 @@ actual interface AudioPlayer { actual val currentPosition: Int actual val duration: Int + actual val isPlaying: Boolean actual fun init() actual fun startAlarmAudio() @@ -36,6 +37,8 @@ class PlayerWrapper( get() = player?.currentPosition?: 0 override val duration: Int get() = player?.duration?: 0 + override val isPlaying: Boolean + get() = player?.isPlaying == true override fun init() { player = MediaPlayer().apply { diff --git a/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/interactors/NotificationInteractorImpl.kt b/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/interactors/NotificationInteractorImpl.kt index 530d8de..a1a891a 100644 --- a/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/interactors/NotificationInteractorImpl.kt +++ b/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/interactors/NotificationInteractorImpl.kt @@ -1,12 +1,13 @@ package com.timilehinaregbesola.mathalarm.interactors +import android.content.Context import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.ui.ExperimentalComposeUiApi import co.touchlab.kermit.Logger import com.timilehinaregbesola.mathalarm.domain.model.Alarm -import com.timilehinaregbesola.mathalarm.notification.MathAlarmNotification +import com.timilehinaregbesola.mathalarm.notification.AlarmService import kotlinx.coroutines.InternalCoroutinesApi @ExperimentalFoundationApi @@ -15,21 +16,17 @@ import kotlinx.coroutines.InternalCoroutinesApi @InternalCoroutinesApi @ExperimentalAnimationApi internal class NotificationInteractorImpl( - private val alarmNotification: MathAlarmNotification, + private val context: Context, private val logger: Logger ) : NotificationInteractor { override fun show(alarm: Alarm) { - logger.d("show - alarmId = ${alarm.alarmId}") - if (alarm.repeat) { - alarmNotification.showRepeating(alarm) - } else { - alarmNotification.show(alarm) - } + logger.d("show - alarmId = ${alarm.alarmId}, starting AlarmService") + AlarmService.startAlarm(context, alarm) } override fun dismiss(notificationId: Long) { - logger.d("dismiss - alarmId = $notificationId") - alarmNotification.dismiss(notificationId) + logger.d("dismiss - alarmId = $notificationId, stopping AlarmService") + AlarmService.stopAlarm(context) } } diff --git a/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/notification/ActiveAlarmManager.kt b/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/notification/ActiveAlarmManager.kt new file mode 100644 index 0000000..e0a0328 --- /dev/null +++ b/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/notification/ActiveAlarmManager.kt @@ -0,0 +1,35 @@ +package com.timilehinaregbesola.mathalarm.notification + +/** + * Singleton to track the currently active alarm being displayed on the math screen. + * Used to re-show the alarm notification if the user force-closes the app without + * solving the math problem. + */ +object ActiveAlarmManager { + + /** + * The ID of the currently active alarm, or null if no alarm is active. + */ + @Volatile + var activeAlarmId: Long? = null + private set + + /** + * Sets the active alarm when the math screen is displayed. + */ + fun setActiveAlarm(alarmId: Long) { + activeAlarmId = alarmId + } + + /** + * Clears the active alarm when it's properly dismissed (solved or snoozed). + */ + fun clearActiveAlarm() { + activeAlarmId = null + } + + /** + * Checks if there's an active alarm that wasn't properly dismissed. + */ + fun hasActiveAlarm(): Boolean = activeAlarmId != null +} diff --git a/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/notification/AlarmNotificationScheduler.kt b/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/notification/AlarmNotificationScheduler.kt index a120199..a143865 100644 --- a/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/notification/AlarmNotificationScheduler.kt +++ b/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/notification/AlarmNotificationScheduler.kt @@ -1,258 +1,100 @@ package com.timilehinaregbesola.mathalarm.notification -import android.annotation.SuppressLint import android.app.PendingIntent -import android.app.PendingIntent.FLAG_MUTABLE -import android.app.PendingIntent.FLAG_UPDATE_CURRENT import android.content.Context import android.content.Intent -import android.os.Build import co.touchlab.kermit.Logger import com.timilehinaregbesola.mathalarm.AlarmReceiver import com.timilehinaregbesola.mathalarm.AlarmReceiver.Companion.ALARM_ACTION import com.timilehinaregbesola.mathalarm.AlarmReceiver.Companion.EXTRA_TASK import com.timilehinaregbesola.mathalarm.domain.model.Alarm -import com.timilehinaregbesola.mathalarm.utils.SAT -import com.timilehinaregbesola.mathalarm.utils.SUN import com.timilehinaregbesola.mathalarm.utils.cancelAlarm import com.timilehinaregbesola.mathalarm.utils.fullDays -import com.timilehinaregbesola.mathalarm.utils.initLocalDateTimeInSystemZone import com.timilehinaregbesola.mathalarm.utils.setExactAlarm -import com.timilehinaregbesola.mathalarm.utils.toIndex -import kotlinx.datetime.DatePeriod -import kotlinx.datetime.LocalDate -import kotlinx.datetime.LocalDateTime -import kotlinx.datetime.LocalTime -import kotlinx.datetime.TimeZone -import kotlinx.datetime.plus -import kotlinx.datetime.toInstant -import kotlinx.datetime.toLocalDateTime -import kotlin.time.Clock -import kotlin.time.ExperimentalTime -import kotlin.time.Instant /** * Alarm manager to schedule an event based on the time from a Alarm. */ -class AlarmNotificationScheduler(private val context: Context, private val logger: Logger) { +class AlarmNotificationScheduler( + private val context: Context, + private val logger: Logger, + private val idGenerator: PendingIntentIdGenerator = PendingIntentIdGenerator() +) { /** - * Schedules all the alarm of the object at once including repeating ones + * Schedules a single alarm notification at the specified time. * - * @param passedAlarm alarm to be scheduled - * @param reschedule whether alarm is repeating + * @param alarm the alarm to schedule + * @param timeInMillis the exact time to trigger the alarm */ - @OptIn(ExperimentalTime::class) - @SuppressLint("UnspecifiedImmutableFlag") - fun scheduleAlarm(passedAlarm: Alarm, reschedule: Boolean): Boolean { - logger.d("Schedule alarm for id=${passedAlarm.alarmId}, time=${passedAlarm.hour}:${passedAlarm.minute}, repeat=${passedAlarm.repeat}, repeatDays=${passedAlarm.repeatDays}, reschedule=$reschedule") - val alarmIntent = Intent(context, AlarmReceiver::class.java).apply { - action = ALARM_ACTION - putExtra(EXTRA_TASK, passedAlarm.alarmId) - } - val alarmIntentList: MutableList = ArrayList() - val timeInstants: MutableList = ArrayList() - val tz = TimeZone.currentSystemDefault() - var hasExistingAlarms = false - - // If there is no day set, set the alarm on the closest possible date - if (passedAlarm.repeatDays == "FFFFFFF") { - logger.d("No repeat days set, determining closest possible date") - val dateTime = passedAlarm.initLocalDateTimeInSystemZone() - val instant = dateTime.toInstant(tz) - val nowInstant = Clock.System.now() - logger.d("Alarm datetime: $dateTime, instant: $instant, now: $nowInstant") - - var dayOfTheWeek = dateTime.date.dayOfWeek.toIndex() - logger.d("Current day of week: $dayOfTheWeek") - - if (instant > nowInstant) { // set it today - val sb = StringBuilder("FFFFFFF") - sb.setCharAt(dayOfTheWeek, 'T') - passedAlarm.repeatDays = sb.toString() - logger.d("Alarm time is in the future, setting for today. New repeatDays: ${passedAlarm.repeatDays}") - } else { // alarm time already passed for the day so set it tomorrow - val sb = StringBuilder("FFFFFFF") - if (dayOfTheWeek == SAT) { // if it is saturday - dayOfTheWeek = SUN - } else { - dayOfTheWeek++ - } - sb.setCharAt(dayOfTheWeek, 'T') - passedAlarm.repeatDays = sb.toString() - logger.d("Alarm time already passed, setting for tomorrow (day $dayOfTheWeek). New repeatDays: ${passedAlarm.repeatDays}") - } - } - - for (i in SUN..SAT) { - if (passedAlarm.repeatDays[i] == 'T') { - logger.d("Processing day $i (${fullDays[i]}) which is set to true") - val nowInstant = Clock.System.now() - val localNow = nowInstant.toLocalDateTime(tz) - val todayDate = localNow.date - - val currentDay = todayDate.dayOfWeek.toIndex() - - logger.d("Current day: $currentDay (${fullDays[currentDay]})") - - val daysUntilAlarm: Int - val targetDate: LocalDate - - val alarmTimeToday = passedAlarm.initLocalDateTimeInSystemZone() - val alarmInstantToday = alarmTimeToday.toInstant(tz) - logger.d("Alarm time today would be: $alarmTimeToday (${alarmInstantToday})") - logger.d("Current time is: $localNow (${nowInstant})") - - val isPastToday = alarmInstantToday < nowInstant - logger.d("Is alarm time past for today? $isPastToday") - - if (currentDay > i || (currentDay == i && isPastToday)) { - // days left till end of week(sat) + the day of the week of the alarm - // EX: alarm = i = tues = 2; current = wed = 3; end of week = sat = 6 - // end - current = 6 - 3 = 3 -> 3 days till saturday/end of week - // end of week + 1 (to sunday) + day of week alarm is on = 3 + 1 + 2 = 6 - daysUntilAlarm = SAT - currentDay + 1 + i - targetDate = todayDate.plus(DatePeriod(days = daysUntilAlarm)) - logger.d("Current day ($currentDay) > alarm day ($i) or same day but time passed, scheduling for next week") - logger.d("Days until alarm: $daysUntilAlarm, target date: $targetDate") - } else { - daysUntilAlarm = i - currentDay - targetDate = todayDate.plus(DatePeriod(days = daysUntilAlarm)) - logger.d("Current day ($currentDay) <= alarm day ($i) and time not passed, scheduling for this week") - logger.d("Days until alarm: $daysUntilAlarm, target date: $targetDate") - } - - val targetDateTime = LocalDateTime( - date = targetDate, - time = LocalTime(passedAlarm.hour, passedAlarm.minute, 0) - ) - val targetInstant = targetDateTime.toInstant(tz) - - val stringId: StringBuilder = StringBuilder().append(passedAlarm.alarmId).append(i) - .append(passedAlarm.hour).append(passedAlarm.minute) - val id = stringId.toString().split("-").joinToString("") - val intentId = id.toInt() - logger.d("Generated intent ID: $intentId for alarm ID: ${passedAlarm.alarmId}, day: $i, time: ${passedAlarm.hour}:${passedAlarm.minute}") - - // Check if a previous alarm has been set - logger.d("Checking if a previous alarm with this ID already exists") - val isSet = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - PendingIntent.getBroadcast( - context, - intentId, - alarmIntent, - PendingIntent.FLAG_NO_CREATE or FLAG_MUTABLE, - ) - } else { - PendingIntent.getBroadcast(context, intentId, alarmIntent, PendingIntent.FLAG_NO_CREATE) - } - - if (isSet != null) { - logger.d("An alarm with ID $intentId already exists") - hasExistingAlarms = true - if (!reschedule) { - logger.d("Not rescheduling because reschedule flag is false") - // context.showToast(R.string.alarm_duplicate_toast_text) - } else { - // If reschedule is true, cancel the existing alarm and create a new one - logger.d("Canceling existing alarm because reschedule flag is true") - context.cancelAlarm(isSet) - isSet.cancel() - } - } - - // If reschedule is true or no existing alarm was found, create a new one - if (isSet == null || reschedule) { - logger.d("Proceeding to create new alarm") - - val pendingIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - PendingIntent.getBroadcast( - context, - intentId, - alarmIntent, - PendingIntent.FLAG_CANCEL_CURRENT or FLAG_MUTABLE, - ) - } else { - PendingIntent.getBroadcast( - context, - intentId, - alarmIntent, - PendingIntent.FLAG_CANCEL_CURRENT, - ) - } - - alarmIntentList.add(pendingIntent) - timeInstants.add(targetInstant) - } - } - } - - // Return true if we scheduled new alarms OR if there were existing alarms - if (alarmIntentList.isEmpty() && !hasExistingAlarms) { - logger.w("No alarms were scheduled and no existing alarms found") - return false - } - - logger.d("Scheduling ${alarmIntentList.size} alarms") - for (i in alarmIntentList.indices) { - val pendingIntent = alarmIntentList[i] - val instant = timeInstants[i] - logger.d("Scheduling alarm #${i+1}/${alarmIntentList.size} for time: ${instant}") - context.setExactAlarm(instant.toEpochMilliseconds(), pendingIntent) - logger.d("Alarm #${i+1} scheduled successfully") - } + fun scheduleAlarm(alarm: Alarm, timeInMillis: Long) { + logger.d("Scheduling alarm: id=${alarm.alarmId}, time=$timeInMillis") + + val alarmIntent = createAlarmIntent(alarm) + val intentId = idGenerator.generateSimpleId(alarm.alarmId) + + val pendingIntent = PendingIntent.getBroadcast( + context, + intentId, + alarmIntent, + PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + + context.setExactAlarm(timeInMillis, pendingIntent) + logger.d("Alarm scheduled successfully: id=${alarm.alarmId} at $timeInMillis") + } - logger.d("All ${alarmIntentList.size} alarms scheduled successfully, returning true") - return true + /** + * Updates an existing alarm notification. + * + * @param alarm the alarm to update + */ + fun updateAlarm(alarm: Alarm) { + logger.d("Update alarm called for id=${alarm.alarmId} - no action needed on Android") + // On Android, the notification will trigger a BroadcastReceiver which will always get the + // most recent Alarm data from the database, so no action needed here. } /** - * Cancels an alarm - Called when an alarm is turned off, deleted, and rescheduled + * Cancels all scheduled notifications for an alarm. * * @param alarm alarm to be canceled */ fun cancelAlarm(alarm: Alarm) { - logger.d("AlarmNotificationScheduler.cancelAlarm called: alarmId=${alarm.alarmId}, time=${alarm.hour}:${alarm.minute}, repeat=${alarm.repeat}, repeatDays=${alarm.repeatDays}") + logger.d("Canceling alarm: id=${alarm.alarmId}") - val receiverIntent = Intent(context, AlarmReceiver::class.java) - receiverIntent.action = ALARM_ACTION - receiverIntent.putExtra(EXTRA_TASK, alarm.alarmId) + val receiverIntent = createAlarmIntent(alarm) - var canceledCount = 0 - for (i in 0..6) { // For each day of the week - if (alarm.repeatDays.getOrNull(i) == 'T') { - logger.d("Canceling alarm for day $i (${fullDays[i]})") + cancelAlarmWithId(receiverIntent, idGenerator.generateSimpleId(alarm.alarmId)) - val stringId: StringBuilder = StringBuilder().append(alarm.alarmId).append(i) - .append(alarm.hour).append(alarm.minute) - val id = stringId.toString().split("-").joinToString("") - val intentId = id.toInt() - logger.d("Generated intent ID: $intentId for alarm ID: ${alarm.alarmId}, day: $i, time: ${alarm.hour}:${alarm.minute}") + // Also cancel any day-specific alarms for repeating alarms + for (i in 0..6) { + if (alarm.repeatDays.getOrNull(i) == 'T') { + val intentId = idGenerator.generateId(alarm, i) + cancelAlarmWithId(receiverIntent, intentId) + logger.d("Canceled alarm for day $i (${fullDays[i]})") + } + } - val cancelPendingIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - PendingIntent.getBroadcast( - context, - intentId, - receiverIntent, - FLAG_UPDATE_CURRENT or FLAG_MUTABLE, - ) - } else { - PendingIntent.getBroadcast( - context, - intentId, - receiverIntent, - FLAG_UPDATE_CURRENT, - ) - } + logger.d("Alarm canceled: id=${alarm.alarmId}") + } - logger.d("Calling context.cancelAlarm for intent ID: $intentId") - context.cancelAlarm(cancelPendingIntent) - cancelPendingIntent.cancel() - logger.d("Alarm canceled for day $i (${fullDays[i]})") - canceledCount++ - } + private fun createAlarmIntent(alarm: Alarm): Intent { + return Intent(context, AlarmReceiver::class.java).apply { + action = ALARM_ACTION + putExtra(EXTRA_TASK, alarm.alarmId) } + } + + private fun cancelAlarmWithId(intent: Intent, intentId: Int) { + val pendingIntent = PendingIntent.getBroadcast( + context, + intentId, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) - logger.d("AlarmNotificationScheduler.cancelAlarm completed for alarmId=${alarm.alarmId}, canceled $canceledCount alarms") + context.cancelAlarm(pendingIntent) + pendingIntent.cancel() } } diff --git a/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/notification/AlarmService.kt b/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/notification/AlarmService.kt new file mode 100644 index 0000000..aeec317 --- /dev/null +++ b/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/notification/AlarmService.kt @@ -0,0 +1,411 @@ +package com.timilehinaregbesola.mathalarm.notification + +import android.app.PendingIntent +import android.app.Service +import android.app.TaskStackBuilder +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.graphics.BitmapFactory +import android.media.AudioAttributes +import android.media.MediaPlayer +import android.media.RingtoneManager +import android.os.Build +import android.os.Handler +import android.os.IBinder +import android.os.Looper +import android.os.PowerManager +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.core.app.NotificationCompat +import androidx.core.app.ServiceCompat +import androidx.core.net.toUri +import co.touchlab.kermit.Logger +import com.timilehinaregbesola.mathalarm.AlarmReceiver +import com.timilehinaregbesola.mathalarm.R +import com.timilehinaregbesola.mathalarm.domain.model.Alarm +import com.timilehinaregbesola.mathalarm.framework.database.AlarmEntity +import com.timilehinaregbesola.mathalarm.framework.database.AlarmMapper +import com.timilehinaregbesola.mathalarm.presentation.MainActivity +import kotlinx.coroutines.InternalCoroutinesApi +import kotlinx.serialization.json.Json +import org.koin.android.ext.android.inject +import java.io.InputStream +import java.net.URLEncoder + +/** + * Foreground service that handles alarm playback independently of the app lifecycle. + * This ensures the alarm keeps ringing even if the user force-closes the app. + */ +@ExperimentalAnimationApi +@InternalCoroutinesApi +@ExperimentalComposeUiApi +@ExperimentalMaterial3Api +@ExperimentalFoundationApi +class AlarmService : Service() { + + private val channel: MathAlarmNotificationChannel by inject() + private val logger = Logger.withTag("AlarmService") + + private var mediaPlayer: MediaPlayer? = null + private var wakeLock: PowerManager.WakeLock? = null + private var currentAlarm: Alarm? = null + private val timeoutHandler = Handler(Looper.getMainLooper()) + + // Timing controller for ring/pause/restart cycle + private var timingController: AlarmTimingController? = null + + // Handler-based scheduler for the timing controller + private val handlerScheduler = object : AlarmTimingController.TimingScheduler { + override fun scheduleDelayed(task: Runnable, delayMillis: Long) { + timeoutHandler.postDelayed(task, delayMillis) + } + + override fun cancel(task: Runnable) { + timeoutHandler.removeCallbacks(task) + } + } + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onCreate() { + super.onCreate() + logger.d("AlarmService created") + + // Acquire wake lock to keep CPU running + val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager + wakeLock = powerManager.newWakeLock( + PowerManager.PARTIAL_WAKE_LOCK, + "mathalarm:alarmservice" + ).apply { + // Acquire for max 1 hour - service will be stopped when user dismisses + acquire(MAX_WAKE_LOCK_MILLIS) + } + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + logger.d("onStartCommand: action=${intent?.action}") + + when (intent?.action) { + ACTION_START_ALARM -> { + val alarmJson = intent.getStringExtra(EXTRA_ALARM_JSON) + if (alarmJson != null) { + try { + val alarmEntity = Json.decodeFromString(alarmJson) + val alarm = AlarmMapper().mapToDomainModel(alarmEntity) + startAlarm(alarm) + } catch (e: Exception) { + logger.e("Failed to parse alarm JSON", e) + stopSelf() + } + } else { + logger.e("No alarm JSON provided") + stopSelf() + } + } + ACTION_STOP_ALARM -> { + logger.d("Stopping alarm service") + stopSelf() + } + ACTION_REFRESH_NOTIFICATION -> { + // Refresh notification to trigger full-screen intent again + currentAlarm?.let { alarm -> + logger.d("Refreshing notification for alarm: ${alarm.title}") + refreshNotificationForAlarm(alarm) + } + } + else -> { + logger.w("Unknown action: ${intent?.action}") + } + } + + return START_NOT_STICKY + } + + private fun startAlarm(alarm: Alarm) { + logger.d("Starting alarm: ${alarm.title}") + currentAlarm = alarm + + ActiveAlarmManager.setActiveAlarm(alarm.alarmId) + + // Initialize timing controller with callbacks + timingController = AlarmTimingController( + ringDurationMillis = RING_DURATION_MILLIS, + silencePeriodMillis = SILENCE_PERIOD_MILLIS, + onStartRinging = { onStartRinging(alarm) }, + onPauseRinging = { onPauseRinging(alarm) }, + scheduler = handlerScheduler + ) + + // Show initial notification + showForegroundNotification(alarm, isPaused = false) + + // Start the timing controller (will call onStartRinging) + timingController?.start() + } + + private fun onStartRinging(alarm: Alarm) { + logger.d("Starting/Restarting alarm audio") + showForegroundNotification(alarm, isPaused = false) + startAudioPlayback(alarm) + } + + private fun onPauseRinging(alarm: Alarm) { + logger.d("Pausing alarm audio, will restart in ${SILENCE_PERIOD_MILLIS / 1000}s") + stopAudioPlayback() + showForegroundNotification(alarm, isPaused = true) + } + + private fun showForegroundNotification(alarm: Alarm, isPaused: Boolean) { + val notification = buildNotification(alarm, isPaused = isPaused) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + ServiceCompat.startForeground( + this, + alarm.alarmId.toInt(), + notification.build(), + ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK + ) + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground( + alarm.alarmId.toInt(), + notification.build(), + ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK + ) + } else { + startForeground(alarm.alarmId.toInt(), notification.build()) + } + } + + private fun refreshNotificationForAlarm(alarm: Alarm) { + val isPaused = timingController?.currentState == AlarmTimingController.State.PAUSED + showForegroundNotification(alarm, isPaused = isPaused) + } + + private fun startAudioPlayback(alarm: Alarm) { + try { + stopAudioPlayback() + + val toneUri = alarm.alarmTone.toUri() + var uriExists = false + + try { + val inputStream: InputStream? = contentResolver.openInputStream(toneUri) + inputStream?.close() + uriExists = true + } catch (_: Exception) { + logger.w("Alarm tone URI does not exist: $toneUri") + } + + val toneString = if (uriExists) { + alarm.alarmTone + } else { + RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM).toString() + } + + mediaPlayer = MediaPlayer().apply { + setAudioAttributes( + AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) + .setUsage(AudioAttributes.USAGE_ALARM) + .build() + ) + setDataSource(this@AlarmService, toneString.toUri()) + isLooping = true + setOnErrorListener { mp, _, _ -> + logger.e("MediaPlayer error") + mp.release() + mediaPlayer = null + true + } + prepare() + start() + } + + logger.d("Audio playback started") + } catch (e: Exception) { + logger.e("Failed to start audio playback", e) + } + } + + private fun stopAudioPlayback() { + try { + mediaPlayer?.run { + if (isPlaying) stop() + release() + } + } catch (e: Exception) { + logger.w("Error stopping media player", e) + } finally { + mediaPlayer = null + } + } + + private fun buildNotification(alarm: Alarm, isPaused: Boolean = false): NotificationCompat.Builder { + val alarmImage = BitmapFactory.decodeResource(resources, R.drawable.icon) + val vibratePattern = if (isPaused) null else longArrayOf(0, 100, 200, 300) + val bigPicStyle = NotificationCompat.BigPictureStyle() + .bigPicture(alarmImage) + + val contentText = if (isPaused) { + "Paused - will ring again soon. Tap to dismiss." + } else { + alarm.title + } + + return NotificationCompat.Builder(this, channel.getAlarmChannelId()).apply { + setContentIntent(buildPendingIntent(alarm)) + setSmallIcon(R.drawable.icon) + setContentTitle(getString(R.string.notification_title)) + setContentText(contentText) + setStyle(bigPicStyle) + setSound(null) // We handle audio separately + setLargeIcon(alarmImage) + setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + setCategory(NotificationCompat.CATEGORY_ALARM) + if (vibratePattern != null) { + setVibrate(vibratePattern) + } + setPriority(NotificationCompat.PRIORITY_HIGH) + setOngoing(true) // Cannot be dismissed by swiping + setAutoCancel(false) + addAction(getSnoozeAction(alarm)) + // Only set full-screen intent when actively ringing + if (!isPaused) { + setFullScreenIntent(buildPendingIntent(alarm), true) + } + // Re-show notification immediately if user somehow manages to dismiss it + setDeleteIntent(getDismissIntent(alarm)) + } + } + + private fun getDismissIntent(alarm: Alarm): PendingIntent { + return getActionIntent(alarm, AlarmReceiver.DISMISS_ACTION, REQUEST_CODE_ACTION_DISMISS) + } + + private fun buildPendingIntent(alarm: Alarm): PendingIntent { + val alarmEntity = AlarmMapper().mapFromDomainModel(alarm) + val json = Json.encodeToString(alarmEntity) + val alarmJson = URLEncoder.encode(json, "utf-8") + val notificationIntent = Intent( + Intent.ACTION_VIEW, + "https://timilehinaregbesola.com/alarmId=$alarmJson".toUri(), + this, + MainActivity::class.java, + ) + return TaskStackBuilder.create(this).run { + addNextIntentWithParentStack(notificationIntent) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + getPendingIntent(REQUEST_CODE_OPEN_TASK, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE) + } else { + getPendingIntent(REQUEST_CODE_OPEN_TASK, PendingIntent.FLAG_UPDATE_CURRENT) + } + }!! + } + + private fun getSnoozeAction(alarm: Alarm): NotificationCompat.Action { + val actionTitle = getString(R.string.notification_action_snooze) + val intent = getActionIntent(alarm, AlarmReceiver.SNOOZE_ACTION, REQUEST_CODE_ACTION_SNOOZE) + return NotificationCompat.Action(0, actionTitle, intent) + } + + private fun getActionIntent( + alarm: Alarm, + intentAction: String, + requestCode: Int, + ): PendingIntent { + val receiverIntent = Intent(this, AlarmReceiver::class.java).apply { + action = intentAction + putExtra(AlarmReceiver.EXTRA_TASK, alarm.alarmId) + } + + return PendingIntent.getBroadcast( + this, + requestCode, + receiverIntent, + PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + } + + override fun onDestroy() { + logger.d("AlarmService destroyed") + + ActiveAlarmManager.clearActiveAlarm() + + // Stop timing controller (cancels all scheduled callbacks) + timingController?.stop() + timingController = null + + stopAudioPlayback() + + try { + wakeLock?.let { + if (it.isHeld) it.release() + } + } catch (e: Exception) { + logger.w("Error releasing wake lock", e) + } + wakeLock = null + + super.onDestroy() + } + + companion object { + const val ACTION_START_ALARM = "com.timilehinaregbesola.mathalarm.START_ALARM" + const val ACTION_STOP_ALARM = "com.timilehinaregbesola.mathalarm.STOP_ALARM" + const val ACTION_REFRESH_NOTIFICATION = "com.timilehinaregbesola.mathalarm.REFRESH_NOTIFICATION" + const val EXTRA_ALARM_JSON = "extra_alarm_json" + + private const val REQUEST_CODE_OPEN_TASK = 1_121_111 + private const val REQUEST_CODE_ACTION_SNOOZE = 4_321 + private const val REQUEST_CODE_ACTION_DISMISS = 5_678 + + // Ring for 10 minutes before auto-pausing + private const val RING_DURATION_MILLIS = 10 * 60 * 1000L + + // Silence period before ringing again (1 minute) + private const val SILENCE_PERIOD_MILLIS = 1 * 60 * 1000L + + // Maximum wake lock duration (1 hour) - service stops when user dismisses + private const val MAX_WAKE_LOCK_MILLIS = 60 * 60 * 1000L + + /** + * Start the alarm service with the given alarm. + */ + fun startAlarm(context: Context, alarm: Alarm) { + val alarmEntity = AlarmMapper().mapFromDomainModel(alarm) + val alarmJson = Json.encodeToString(alarmEntity) + + val intent = Intent(context, AlarmService::class.java).apply { + action = ACTION_START_ALARM + putExtra(EXTRA_ALARM_JSON, alarmJson) + } + + context.startForegroundService(intent) + } + + /** + * Stop the alarm service. + */ + fun stopAlarm(context: Context) { + val intent = Intent(context, AlarmService::class.java).apply { + action = ACTION_STOP_ALARM + } + context.stopService(intent) + } + + /** + * Refresh the notification to trigger full-screen intent again. + * Call this when the user closes the app while alarm is active. + */ + fun refreshNotification(context: Context) { + val intent = Intent(context, AlarmService::class.java).apply { + action = ACTION_REFRESH_NOTIFICATION + } + + context.startForegroundService(intent) + } + } +} diff --git a/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/notification/AlarmTimingController.kt b/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/notification/AlarmTimingController.kt new file mode 100644 index 0000000..252d367 --- /dev/null +++ b/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/notification/AlarmTimingController.kt @@ -0,0 +1,104 @@ +package com.timilehinaregbesola.mathalarm.notification + +/** + * Controls the timing state machine for alarm ringing behavior. + * Extracted from AlarmService for testability. + * + * State flow: + * IDLE -> RINGING (start) -> PAUSED (after ringDuration) -> RINGING (after silencePeriod) -> ... + */ +class AlarmTimingController( + private val ringDurationMillis: Long = DEFAULT_RING_DURATION_MILLIS, + private val silencePeriodMillis: Long = DEFAULT_SILENCE_PERIOD_MILLIS, + private val onStartRinging: () -> Unit, + private val onPauseRinging: () -> Unit, + private val scheduler: TimingScheduler +) { + + enum class State { + IDLE, + RINGING, + PAUSED + } + + var currentState: State = State.IDLE + private set + + private var ringTimeoutTask: Runnable? = null + private var restartTask: Runnable? = null + + /** + * Start the alarm - begins ringing and schedules auto-pause. + */ + fun start() { + if (currentState != State.IDLE) { + // Already running, just restart the ringing + restartRinging() + return + } + + currentState = State.RINGING + onStartRinging() + scheduleAutoPause() + } + + /** + * Stop the alarm completely - cancels all scheduled tasks. + */ + fun stop() { + cancelAllTasks() + currentState = State.IDLE + } + + /** + * Called when ring duration expires - pauses audio and schedules restart. + */ + private fun pauseRinging() { + if (currentState != State.RINGING) return + + currentState = State.PAUSED + onPauseRinging() + scheduleRestart() + } + + /** + * Called after silence period - restarts audio and schedules next pause. + */ + private fun restartRinging() { + currentState = State.RINGING + onStartRinging() + scheduleAutoPause() + } + + private fun scheduleAutoPause() { + ringTimeoutTask?.let { scheduler.cancel(it) } + ringTimeoutTask = Runnable { pauseRinging() } + scheduler.scheduleDelayed(ringTimeoutTask!!, ringDurationMillis) + } + + private fun scheduleRestart() { + restartTask?.let { scheduler.cancel(it) } + restartTask = Runnable { restartRinging() } + scheduler.scheduleDelayed(restartTask!!, silencePeriodMillis) + } + + private fun cancelAllTasks() { + ringTimeoutTask?.let { scheduler.cancel(it) } + restartTask?.let { scheduler.cancel(it) } + ringTimeoutTask = null + restartTask = null + } + + /** + * Interface for scheduling - allows injection of test scheduler. + */ + interface TimingScheduler { + fun scheduleDelayed(task: Runnable, delayMillis: Long) + fun cancel(task: Runnable) + } + + companion object { + const val DEFAULT_RING_DURATION_MILLIS = 10 * 60 * 1000L // 10 minutes + const val DEFAULT_SILENCE_PERIOD_MILLIS = 1 * 60 * 1000L // 1 minute + } +} diff --git a/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/notification/MathAlarmNotification.kt b/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/notification/MathAlarmNotification.kt index 0ecc908..2cfea33 100644 --- a/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/notification/MathAlarmNotification.kt +++ b/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/notification/MathAlarmNotification.kt @@ -49,26 +49,30 @@ class MathAlarmNotification( logger.d("Showing notification for '${alarm.title}'") val builder = buildNotification(alarm) // builder.addAction(getCompleteAction(alarm)) - var uriExists: Boolean - val toneUri = alarm.alarmTone.toUri() - player.apply { - init() - reset() - try { - val inputStream: InputStream? = context.contentResolver.openInputStream(toneUri) - inputStream?.close() - uriExists = true - } catch (e: Exception) { - uriExists = false - logger.w("File corresponding to the uri does not exist $toneUri") + + // Only start audio if not already playing (to avoid duplicate sounds on notification re-show) + if (!player.isPlaying) { + var uriExists: Boolean + val toneUri = alarm.alarmTone.toUri() + player.apply { + init() + reset() + try { + val inputStream: InputStream? = context.contentResolver.openInputStream(toneUri) + inputStream?.close() + uriExists = true + } catch (e: Exception) { + uriExists = false + logger.w("File corresponding to the uri does not exist $toneUri") + } + val toneString = if (uriExists) { + alarm.alarmTone + } else { + RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM).toString() + } + setDataSourceFromString(toneString) + startAlarmAudio() } - val toneString = if (uriExists) { - alarm.alarmTone - } else { - RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM).toString() - } - setDataSourceFromString(toneString) - startAlarmAudio() } context.getNotificationManager()?.notify(alarm.alarmId.toInt(), builder.build()) } @@ -116,6 +120,8 @@ class MathAlarmNotification( setAutoCancel(true) addAction(getSnoozeAction(alarm)) setFullScreenIntent(buildPendingIntent(alarm), true) + // Re-show notification immediately if user tries to dismiss it + setDeleteIntent(getDismissIntent(alarm)) } private fun buildPendingIntent(alarm: Alarm): PendingIntent { @@ -150,6 +156,10 @@ class MathAlarmNotification( return NotificationCompat.Action(ACTION_NO_ICON, actionTitle, intent) } + private fun getDismissIntent(alarm: Alarm): PendingIntent { + return getIntent(alarm, AlarmReceiver.DISMISS_ACTION, REQUEST_CODE_ACTION_DISMISS) + } + private fun getIntent( alarm: Alarm, intentAction: String, @@ -177,6 +187,8 @@ class MathAlarmNotification( private const val REQUEST_CODE_ACTION_SNOOZE = 4_321 + private const val REQUEST_CODE_ACTION_DISMISS = 5_678 + private const val ACTION_NO_ICON = 0 } } diff --git a/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/notification/MathAlarmNotificationChannel.kt b/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/notification/MathAlarmNotificationChannel.kt index 91583c5..9938924 100644 --- a/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/notification/MathAlarmNotificationChannel.kt +++ b/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/notification/MathAlarmNotificationChannel.kt @@ -18,6 +18,8 @@ class MathAlarmNotificationChannel(context: Context) { val alarmDescription = context.getString(R.string.channel_alarm_description) NotificationChannel(ALARM_CHANNEL_ID, alarmName, NotificationManager.IMPORTANCE_HIGH).apply { description = alarmDescription + setBypassDnd(true) + lockscreenVisibility = android.app.Notification.VISIBILITY_PUBLIC context.getNotificationManager()?.createNotificationChannel(this) } diff --git a/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/notification/PendingIntentIdGenerator.kt b/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/notification/PendingIntentIdGenerator.kt new file mode 100644 index 0000000..bec8978 --- /dev/null +++ b/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/notification/PendingIntentIdGenerator.kt @@ -0,0 +1,37 @@ +package com.timilehinaregbesola.mathalarm.notification + +import com.timilehinaregbesola.mathalarm.domain.model.Alarm + +/** + * Generates unique PendingIntent IDs for alarms. + */ +class PendingIntentIdGenerator { + + /** + * Generates a unique PendingIntent ID for a specific alarm and day combination. + * + * The ID is generated from: alarmId + dayIndex + hour + minute + * This ensures each alarm/day combination has a unique ID. + * + * @param alarm the alarm + * @param dayIndex the day index (0-6, Sunday to Saturday) + * @return unique integer ID for the PendingIntent + */ + fun generateId(alarm: Alarm, dayIndex: Int): Int { + val stringId = StringBuilder() + .append(alarm.alarmId) + .append(dayIndex) + .append(alarm.hour) + .append(alarm.minute) + return stringId.toString().replace("-", "").toInt() + } + + /** + * Generates a simple PendingIntent ID using just the alarm ID. + * Useful for one-time alarms or snooze operations. + * + * @param alarmId the alarm ID + * @return integer ID for the PendingIntent + */ + fun generateSimpleId(alarmId: Long): Int = alarmId.toInt() +} diff --git a/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/presentation/MainActivity.kt b/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/presentation/MainActivity.kt index 118ce43..3918470 100644 --- a/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/presentation/MainActivity.kt +++ b/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/presentation/MainActivity.kt @@ -1,10 +1,17 @@ package com.timilehinaregbesola.mathalarm.presentation +import android.app.KeyguardManager +import android.content.Context import android.content.Intent import android.graphics.Color +import android.os.Build import android.os.Bundle +import android.view.WindowManager import androidx.activity.compose.setContent import androidx.appcompat.app.AppCompatActivity +import co.touchlab.kermit.Logger +import com.timilehinaregbesola.mathalarm.notification.ActiveAlarmManager +import com.timilehinaregbesola.mathalarm.notification.AlarmService import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.material3.ExperimentalMaterial3Api @@ -37,11 +44,14 @@ import java.nio.charset.StandardCharsets class MainActivity : AppCompatActivity() { val preferences: AlarmPreferencesImpl by inject() private lateinit var lyricist: Lyricist + private val logger = Logger.withTag("MainActivity") override fun onCreate(savedInstanceState: Bundle?) { installSplashScreen() super.onCreate(savedInstanceState) + setupLockScreenFlags() + deeplinkInfo = intent.extractAlarmJson() setContent { @@ -56,6 +66,22 @@ class MainActivity : AppCompatActivity() { } } + private fun setupLockScreenFlags() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + setShowWhenLocked(true) + setTurnScreenOn(true) + val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager + keyguardManager.requestDismissKeyguard(this, null) + } else { + @Suppress("DEPRECATION") + window.addFlags( + WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or + WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON or + WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD + ) + } + } + private fun updateTheme(darkTheme: Boolean) { window.apply { statusBarColor = if (darkTheme) darkPrimary.toArgb() else Color.WHITE @@ -65,6 +91,16 @@ class MainActivity : AppCompatActivity() { } } + override fun onStop() { + super.onStop() + // If user closes the app while alarm is active, refresh the notification + // This will trigger the full-screen intent to pop up again + if (ActiveAlarmManager.hasActiveAlarm()) { + logger.d("App stopped with active alarm - refreshing notification") + AlarmService.refreshNotification(this) + } + } + private fun Intent.extractAlarmJson(): String? { return data?.lastPathSegment ?.takeIf { it.startsWith("$PARAM=") } diff --git a/app/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/di/CommonModule.kt b/app/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/di/CommonModule.kt index 93f3d59..db622e5 100644 --- a/app/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/di/CommonModule.kt +++ b/app/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/di/CommonModule.kt @@ -1,6 +1,7 @@ package com.timilehinaregbesola.mathalarm.di import com.russhwolf.settings.Settings +import com.timilehinaregbesola.mathalarm.coroutines.AppCoroutineScope import com.timilehinaregbesola.mathalarm.data.AlarmDataSource import com.timilehinaregbesola.mathalarm.data.AlarmRepository import com.timilehinaregbesola.mathalarm.framework.RoomAlarmDataSource @@ -11,6 +12,8 @@ import com.timilehinaregbesola.mathalarm.presentation.alarmmath.AlarmMathViewMod import com.timilehinaregbesola.mathalarm.presentation.alarmsettings.AlarmSettingsViewModel import com.timilehinaregbesola.mathalarm.presentation.appsettings.AlarmPreferencesImpl import com.timilehinaregbesola.mathalarm.presentation.appsettings.AppThemeOptionsMapper +import com.timilehinaregbesola.mathalarm.provider.AlarmTimeCalculator +import com.timilehinaregbesola.mathalarm.provider.AlarmTimeCalculatorImpl import com.timilehinaregbesola.mathalarm.provider.DateTimeProvider import com.timilehinaregbesola.mathalarm.provider.DateTimeProviderImpl import com.timilehinaregbesola.mathalarm.usecases.AddAlarm @@ -36,11 +39,17 @@ val commonModule = module { single { AlarmMapper() } single { AppThemeOptionsMapper() } + // Coroutine Scope - replaces GlobalScope usage + single { AppCoroutineScope() } + // DateTime Provider single { DateTimeProviderImpl() } - // Schedule Next Alarm - single { ScheduleNextAlarm(get()) } + // Alarm Time Calculator - moves time calculation to domain layer + single { AlarmTimeCalculatorImpl() } + + // Schedule Next Alarm (now depends on AlarmTimeCalculator) + single { ScheduleNextAlarm(get(), get()) } // Data Source and Repository single { RoomAlarmDataSource(get(), get()) } @@ -65,9 +74,9 @@ val commonModule = module { findAlarm = FindAlarm(get()), getSavedAlarms = GetSavedAlarms(get()), updateAlarm = UpdateAlarm(get()), - scheduleAlarm = ScheduleAlarm(get(), get()), + scheduleAlarm = ScheduleAlarm(get(), get(), get()), completeAlarm = CompleteAlarm(get(), get(), get()), - rescheduleFutureAlarms = RescheduleFutureAlarms(get(), get()), + rescheduleFutureAlarms = RescheduleFutureAlarms(get(), get(), get(), get()), scheduleNextAlarm = get(), showAlarm = ShowAlarm(get(), get(), get()), snoozeAlarm = SnoozeAlarm(get(), get(), get(), get()), diff --git a/app/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/framework/app/permission/AlarmPermission.kt b/app/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/framework/app/permission/AlarmPermission.kt index 7ef7133..de6d763 100644 --- a/app/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/framework/app/permission/AlarmPermission.kt +++ b/app/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/framework/app/permission/AlarmPermission.kt @@ -1,7 +1,7 @@ package com.timilehinaregbesola.mathalarm.framework.app.permission /** - * Platform-abstracted interface for checking alarm permissions. + * Platform-abstracted interface for checking and managing alarm permissions. */ interface AlarmPermission { /** @@ -10,4 +10,16 @@ interface AlarmPermission { * @return `true` if the permission is granted, `false` otherwise */ fun hasExactAlarmPermission(): Boolean + + /** + * Opens the system settings screen for exact alarm permission. + * On Android S+, this opens the SCHEDULE_EXACT_ALARM permission screen. + */ + fun openExactAlarmPermissionScreen() + + /** + * Opens the app settings screen. + * Useful for directing users to manually enable permissions. + */ + fun openAppSettings() } diff --git a/app/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/interactors/AudioPlayer.kt b/app/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/interactors/AudioPlayer.kt index 185e8ad..a69baf7 100644 --- a/app/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/interactors/AudioPlayer.kt +++ b/app/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/interactors/AudioPlayer.kt @@ -5,6 +5,7 @@ package com.timilehinaregbesola.mathalarm.interactors expect interface AudioPlayer { val currentPosition: Int val duration: Int + val isPlaying: Boolean fun init() fun startAlarmAudio() diff --git a/app/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/presentation/alarmsettings/components/LabelTextField.kt b/app/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/presentation/alarmsettings/components/LabelTextField.kt index 3dc1751..e65d932 100644 --- a/app/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/presentation/alarmsettings/components/LabelTextField.kt +++ b/app/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/presentation/alarmsettings/components/LabelTextField.kt @@ -2,6 +2,7 @@ package com.timilehinaregbesola.mathalarm.presentation.alarmsettings.components import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -10,6 +11,7 @@ import androidx.compose.material3.TextFieldDefaults.colors import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color.Companion.Transparent +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.tooling.preview.Preview import com.timilehinaregbesola.mathalarm.presentation.ui.icon.Label @@ -33,6 +35,9 @@ fun LabelTextField( label = label, placeholder = placeholder, singleLine = true, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Done + ), colors = colors( unfocusedContainerColor = Transparent, focusedContainerColor = Transparent, diff --git a/app/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/fake/AlarmInteractorFake.kt b/app/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/fake/AlarmInteractorFake.kt index d470666..1851e5e 100644 --- a/app/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/fake/AlarmInteractorFake.kt +++ b/app/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/fake/AlarmInteractorFake.kt @@ -2,26 +2,44 @@ package com.timilehinaregbesola.mathalarm.fake import com.timilehinaregbesola.mathalarm.domain.model.Alarm import com.timilehinaregbesola.mathalarm.interactors.AlarmInteractor -import com.timilehinaregbesola.mathalarm.utils.initLocalDateTimeInSystemZone import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import kotlin.time.Instant class AlarmInteractorFake : AlarmInteractor { private val alarmMap: MutableMap = mutableMapOf() - override fun schedule(alarm: Alarm, reschedule: Boolean): Boolean { - alarmMap[alarm.alarmId] = FakeData(reschedule, alarm.initLocalDateTimeInSystemZone()) - return true + + override fun schedule(alarm: Alarm, timeInMillis: Long) { + val instant = Instant.fromEpochMilliseconds(timeInMillis) + val dateTime = instant.toLocalDateTime(TimeZone.currentSystemDefault()) + alarmMap[alarm.alarmId] = FakeData(timeInMillis, dateTime) } override fun cancel(alarm: Alarm) { alarmMap.remove(alarm.alarmId) } + override fun update(alarm: Alarm) { + val existing = alarmMap[alarm.alarmId] + if (existing != null) { + alarmMap[alarm.alarmId] = existing.copy(updated = true) + } + } + fun isAlarmScheduled(alarm: Alarm): Boolean = alarmMap.contains(alarm.alarmId) fun clear() = alarmMap.clear() - fun getAlarmTime(alarmId: Long): LocalDateTime? = - alarmMap[alarmId]?.time + fun getAlarmTimeMillis(alarmId: Long): Long? = alarmMap[alarmId]?.timeInMillis + + fun getAlarmTime(alarmId: Long): LocalDateTime? = alarmMap[alarmId]?.dateTime + + fun getScheduledAlarms(): Map = alarmMap.toMap() } -data class FakeData(val reschedule: Boolean, val time: LocalDateTime) +data class FakeData( + val timeInMillis: Long, + val dateTime: LocalDateTime, + val updated: Boolean = false +) diff --git a/app/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/fake/AlarmPermissionFake.kt b/app/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/fake/AlarmPermissionFake.kt index f211591..d31fa97 100644 --- a/app/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/fake/AlarmPermissionFake.kt +++ b/app/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/fake/AlarmPermissionFake.kt @@ -5,9 +5,27 @@ import com.timilehinaregbesola.mathalarm.framework.app.permission.AlarmPermissio class AlarmPermissionFake( private var hasPermission: Boolean = true ) : AlarmPermission { + var exactAlarmPermissionScreenOpened = false + private set + var appSettingsOpened = false + private set + override fun hasExactAlarmPermission(): Boolean = hasPermission + override fun openExactAlarmPermissionScreen() { + exactAlarmPermissionScreenOpened = true + } + + override fun openAppSettings() { + appSettingsOpened = true + } + fun setPermission(granted: Boolean) { hasPermission = granted } + + fun reset() { + exactAlarmPermissionScreenOpened = false + appSettingsOpened = false + } } diff --git a/app/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/fake/AlarmTimeCalculatorFake.kt b/app/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/fake/AlarmTimeCalculatorFake.kt new file mode 100644 index 0000000..da73594 --- /dev/null +++ b/app/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/fake/AlarmTimeCalculatorFake.kt @@ -0,0 +1,35 @@ +package com.timilehinaregbesola.mathalarm.fake + +import com.timilehinaregbesola.mathalarm.domain.model.Alarm +import com.timilehinaregbesola.mathalarm.provider.AlarmTimeCalculator +import kotlin.time.Clock +import kotlin.time.ExperimentalTime + +/** + * Fake implementation of AlarmTimeCalculator for testing. + */ +@OptIn(ExperimentalTime::class) +class AlarmTimeCalculatorFake : AlarmTimeCalculator { + + private var currentTimeMillis: Long = Clock.System.now().toEpochMilliseconds() + + /** + * Set a custom current time for testing. + */ + fun setCurrentTime(timeMillis: Long) { + currentTimeMillis = timeMillis + } + + override fun calculateAlarmTimes(alarm: Alarm): List { + // Return a simple future time for testing (1 hour from "now") + return listOf(currentTimeMillis + 3600_000L) + } + + override fun calculateNextAlarmTime(alarm: Alarm): Long? { + return currentTimeMillis + 3600_000L + } + + override fun isInFuture(timeInMillis: Long): Boolean { + return timeInMillis > currentTimeMillis + } +} diff --git a/app/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/fake/AudioPlayerFake.kt b/app/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/fake/AudioPlayerFake.kt index 78f5585..433c600 100644 --- a/app/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/fake/AudioPlayerFake.kt +++ b/app/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/fake/AudioPlayerFake.kt @@ -6,7 +6,8 @@ class AudioPlayerFake : AudioPlayer { var isInitialized = false var isReset = false var dataSource: String? = null - var isPlaying = false + private var _isPlaying = false + override val isPlaying: Boolean get() = _isPlaying var isStopped = false var volume: Float = 1.0f @@ -27,13 +28,13 @@ class AudioPlayerFake : AudioPlayer { } override fun startAlarmAudio() { - isPlaying = true + _isPlaying = true isStopped = false } override fun stop() { isStopped = true - isPlaying = false + _isPlaying = false } override fun setPerceivedVolume(perceived: Float) { diff --git a/app/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/presentation/alarmlist/AlarmListViewModelTest.kt b/app/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/presentation/alarmlist/AlarmListViewModelTest.kt index 4051947..f2d6103 100644 --- a/app/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/presentation/alarmlist/AlarmListViewModelTest.kt +++ b/app/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/presentation/alarmlist/AlarmListViewModelTest.kt @@ -42,21 +42,22 @@ class AlarmListViewModelTest { dateTimeProvider = DateTimeProviderFake() permission = AlarmPermissionFake() - val scheduleNextAlarm = ScheduleNextAlarm(alarmInteractor) + val alarmTimeCalculator = AlarmTimeCalculatorFake() + val scheduleNextAlarm = ScheduleNextAlarm(alarmInteractor, alarmTimeCalculator) usecases = Usecases( addAlarm = AddAlarm(repository), findAlarm = FindAlarm(repository), deleteAlarm = DeleteAlarm(repository, alarmInteractor), getSavedAlarms = GetSavedAlarms(repository), - scheduleAlarm = ScheduleAlarm(repository, alarmInteractor), + scheduleAlarm = ScheduleAlarm(repository, alarmInteractor, alarmTimeCalculator), showAlarm = ShowAlarm(repository, notificationInteractor, scheduleNextAlarm), completeAlarm = CompleteAlarm(repository, alarmInteractor, notificationInteractor), updateAlarm = UpdateAlarm(repository), cancelAlarm = CancelAlarm(alarmInteractor), clearAlarms = ClearAlarms(repository, alarmInteractor), scheduleNextAlarm = scheduleNextAlarm, - rescheduleFutureAlarms = RescheduleFutureAlarms(repository, alarmInteractor), + rescheduleFutureAlarms = RescheduleFutureAlarms(repository, alarmInteractor, alarmTimeCalculator, scheduleNextAlarm), snoozeAlarm = SnoozeAlarm(dateTimeProvider, notificationInteractor, alarmInteractor, repository) ) diff --git a/app/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/presentation/alarmmath/AlarmMathViewModelTest.kt b/app/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/presentation/alarmmath/AlarmMathViewModelTest.kt index e0b4e9e..278a594 100644 --- a/app/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/presentation/alarmmath/AlarmMathViewModelTest.kt +++ b/app/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/presentation/alarmmath/AlarmMathViewModelTest.kt @@ -6,6 +6,7 @@ import com.timilehinaregbesola.mathalarm.data.AlarmRepository import com.timilehinaregbesola.mathalarm.domain.model.Alarm import com.timilehinaregbesola.mathalarm.fake.AlarmInteractorFake import com.timilehinaregbesola.mathalarm.fake.AlarmRepositoryFake +import com.timilehinaregbesola.mathalarm.fake.AlarmTimeCalculatorFake import com.timilehinaregbesola.mathalarm.fake.AudioPlayerFake import com.timilehinaregbesola.mathalarm.fake.DateTimeProviderFake import com.timilehinaregbesola.mathalarm.fake.NotificationInteractorFake @@ -60,21 +61,22 @@ class AlarmMathViewModelTest { notificationInteractor = NotificationInteractorFake() dateTimeProvider = DateTimeProviderFake() - val scheduleNextAlarm = ScheduleNextAlarm(alarmInteractor) + val alarmTimeCalculator = AlarmTimeCalculatorFake() + val scheduleNextAlarm = ScheduleNextAlarm(alarmInteractor, alarmTimeCalculator) usecases = Usecases( addAlarm = AddAlarm(repository), findAlarm = FindAlarm(repository), deleteAlarm = DeleteAlarm(repository, alarmInteractor), getSavedAlarms = GetSavedAlarms(repository), - scheduleAlarm = ScheduleAlarm(repository, alarmInteractor), + scheduleAlarm = ScheduleAlarm(repository, alarmInteractor, alarmTimeCalculator), showAlarm = ShowAlarm(repository, notificationInteractor, scheduleNextAlarm), completeAlarm = CompleteAlarm(repository, alarmInteractor, notificationInteractor), updateAlarm = UpdateAlarm(repository), cancelAlarm = CancelAlarm(alarmInteractor), clearAlarms = ClearAlarms(repository, alarmInteractor), scheduleNextAlarm = scheduleNextAlarm, - rescheduleFutureAlarms = RescheduleFutureAlarms(repository, alarmInteractor), + rescheduleFutureAlarms = RescheduleFutureAlarms(repository, alarmInteractor, alarmTimeCalculator, scheduleNextAlarm), snoozeAlarm = SnoozeAlarm(dateTimeProvider, notificationInteractor, alarmInteractor, repository) ) diff --git a/app/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/presentation/alarmsettings/AlarmSettingsViewModelTest.kt b/app/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/presentation/alarmsettings/AlarmSettingsViewModelTest.kt index 4b97e11..21fe85b 100644 --- a/app/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/presentation/alarmsettings/AlarmSettingsViewModelTest.kt +++ b/app/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/presentation/alarmsettings/AlarmSettingsViewModelTest.kt @@ -38,21 +38,22 @@ class AlarmSettingsViewModelTest { notificationInteractor = NotificationInteractorFake() dateTimeProvider = DateTimeProviderFake() - val scheduleNextAlarm = ScheduleNextAlarm(alarmInteractor) + val alarmTimeCalculator = AlarmTimeCalculatorFake() + val scheduleNextAlarm = ScheduleNextAlarm(alarmInteractor, alarmTimeCalculator) usecases = Usecases( addAlarm = AddAlarm(repository), findAlarm = FindAlarm(repository), deleteAlarm = DeleteAlarm(repository, alarmInteractor), getSavedAlarms = GetSavedAlarms(repository), - scheduleAlarm = ScheduleAlarm(repository, alarmInteractor), + scheduleAlarm = ScheduleAlarm(repository, alarmInteractor, alarmTimeCalculator), showAlarm = ShowAlarm(repository, notificationInteractor, scheduleNextAlarm), completeAlarm = CompleteAlarm(repository, alarmInteractor, notificationInteractor), updateAlarm = UpdateAlarm(repository), cancelAlarm = CancelAlarm(alarmInteractor), clearAlarms = ClearAlarms(repository, alarmInteractor), scheduleNextAlarm = scheduleNextAlarm, - rescheduleFutureAlarms = RescheduleFutureAlarms(repository, alarmInteractor), + rescheduleFutureAlarms = RescheduleFutureAlarms(repository, alarmInteractor, alarmTimeCalculator, scheduleNextAlarm), snoozeAlarm = SnoozeAlarm(dateTimeProvider, notificationInteractor, alarmInteractor, repository) ) diff --git a/app/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/MainViewController.kt b/app/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/MainViewController.kt index 2ac245e..42206f3 100644 --- a/app/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/MainViewController.kt +++ b/app/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/MainViewController.kt @@ -10,19 +10,67 @@ import androidx.compose.ui.window.ComposeUIViewController import cafe.adriel.lyricist.ProvideStrings import cafe.adriel.lyricist.rememberStrings import com.timilehinaregbesola.mathalarm.di.initKoin +import com.timilehinaregbesola.mathalarm.di.prewarmDatabase import com.timilehinaregbesola.mathalarm.navigation.NavGraph -import com.timilehinaregbesola.mathalarm.notification.IosNotificationSetup import com.timilehinaregbesola.mathalarm.notification.NotificationDeeplinkHolder import com.timilehinaregbesola.mathalarm.presentation.appsettings.AlarmPreferencesImpl import com.timilehinaregbesola.mathalarm.presentation.appsettings.shouldUseDarkColors import com.timilehinaregbesola.mathalarm.presentation.ui.MathAlarmTheme +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.InternalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent import platform.UIKit.UIViewController +/** + * Initialize Koin early - called from Swift App init() before UI loads. + */ +fun doInitKoin() { + initKoin() +} + +/** + * Prewarm the database in background - called after Koin init + * This initializes Room in background so it's ready when UI needs it + */ +fun prewarmDatabaseInBackground() { + prewarmDatabase() +} + +/** + * Request notification permissions in a non-blocking way. + * Call this after the UI is shown to avoid blocking startup. + * + * The permission request is deferred to not block app launch, + * following best practices for permission timing. + */ +fun requestNotificationPermissionsDeferred() { + CoroutineScope(Dispatchers.Main).launch { + // Small delay to ensure UI is fully rendered first + delay(500) + + try { + val koinComponent = object : KoinComponent {} + val scheduler: com.timilehinaregbesola.mathalarm.notification.IosAlarmScheduler = + koinComponent.getKoin().get() + + scheduler.requestPermissions { granted -> + println("requestNotificationPermissionsDeferred: granted = $granted") + } + } catch (e: Exception) { + println("requestNotificationPermissionsDeferred: error = ${e.message}") + } + } +} + /** * iOS Main View Controller - Entry point for the Compose Multiplatform UI - * Note: Notification delegate is set up in Swift AppDelegate for proper timing + * + * Note: Koin is initialized earlier via doInitKoin() from Swift's App init(). + * Notification categories are registered via IosAlarmScheduler on first access. + * Notification delegate is set up in Swift AppDelegate for proper timing. */ @OptIn( ExperimentalAnimationApi::class, @@ -32,18 +80,7 @@ import platform.UIKit.UIViewController InternalCoroutinesApi::class ) fun MainViewController(): UIViewController { - println("MainViewController: Initializing...") - - // Initialize Koin DI - initKoin() - - // Setup notification categories for alarm actions - IosNotificationSetup.setupNotificationCategories() - - // Request notification permissions - IosNotificationSetup.requestPermissions { granted -> - println("MainViewController: Notification permissions granted = $granted") - } + println("MainViewController: Creating Compose UI...") return ComposeUIViewController(configure = { enforceStrictPlistSanityCheck = false }) { val preferences = rememberKoinInject() diff --git a/app/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/di/IosModule.kt b/app/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/di/IosModule.kt index cff6fbc..b5c1675 100644 --- a/app/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/di/IosModule.kt +++ b/app/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/di/IosModule.kt @@ -16,8 +16,16 @@ import com.timilehinaregbesola.mathalarm.interactors.AudioPlayer import com.timilehinaregbesola.mathalarm.interactors.IosAudioPlayer import com.timilehinaregbesola.mathalarm.interactors.NotificationInteractor import com.timilehinaregbesola.mathalarm.interactors.NotificationInteractorImpl +import com.timilehinaregbesola.mathalarm.notification.IosAlarmNotification import com.timilehinaregbesola.mathalarm.notification.IosAlarmScheduler +import com.timilehinaregbesola.mathalarm.notification.NotificationActionDelegate import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.launch +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject import org.koin.core.context.startKoin import org.koin.dsl.module import platform.Foundation.NSDocumentDirectory @@ -28,8 +36,10 @@ import platform.Foundation.NSUserDomainMask * iOS-specific Koin module */ val iosModule = module { - // Room Database for iOS + // Room Database for iOS - uses lazy initialization + // The database will only be built when first injected single { + println("IosModule: Building Room database (lazy init)") val dbFile = documentDirectory() + "/alarm_history_database.db" Room.databaseBuilder( name = dbFile @@ -41,9 +51,21 @@ val iosModule = module { single { get().alarmDatabaseDao } - // iOS Alarm Scheduler + // iOS Alarm Scheduler - schedules notifications single { IosAlarmScheduler(getWith("IosAlarmScheduler")) } + // iOS Alarm Notification - handles showing/dismissing delivered notifications + single { IosAlarmNotification(getWith("IosAlarmNotification")) } + + // Notification Action Delegate - handles snooze/dismiss actions from notifications + single { + NotificationActionDelegate( + appCoroutineScope = get(), + usecases = get(), + logger = getWith("NotificationActionDelegate") + ) + } + // iOS Audio Player single { IosAudioPlayer(getWith("IosAudioPlayer")) } @@ -70,14 +92,39 @@ val iosModule = module { } /** - * Initialize Koin for iOS + * Initialize Koin for iOS. + * Called once from Swift's App init() before UI loads. */ fun initKoin() { + println("IosModule: Initializing Koin") startKoin { modules(commonModule, iosModule) } } +/** + * Prewarm the database in background. + * Call this after Koin init to initialize Room on a background thread, + * so it's ready when the UI needs it. + * + */ +fun prewarmDatabase() { + CoroutineScope(Dispatchers.IO).launch { + try { + println("IosModule: Prewarming database in background") + val helper = object : KoinComponent { + val database: AlarmDatabase by inject() + } + // Trigger lazy initialization by accessing the database + // This runs the Room builder and migrations off the main thread + helper.database.alarmDatabaseDao + println("IosModule: Database prewarmed successfully") + } catch (e: Exception) { + println("IosModule: Database prewarm failed: ${e.message}") + } + } +} + /** * Get iOS document directory path */ diff --git a/app/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/framework/app/permission/AlarmPermissionImpl.kt b/app/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/framework/app/permission/AlarmPermissionImpl.kt index 19d7e38..261f7c9 100644 --- a/app/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/framework/app/permission/AlarmPermissionImpl.kt +++ b/app/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/framework/app/permission/AlarmPermissionImpl.kt @@ -1,5 +1,9 @@ package com.timilehinaregbesola.mathalarm.framework.app.permission +import platform.Foundation.NSURL +import platform.UIKit.UIApplication +import platform.UIKit.UIApplicationOpenSettingsURLString + /** * iOS implementation of AlarmPermission. * iOS doesn't require explicit exact alarm permissions - local notifications @@ -13,4 +17,22 @@ class AlarmPermissionImpl : AlarmPermission { * @return Always returns true on iOS */ override fun hasExactAlarmPermission(): Boolean = true + + /** + * On iOS, opens the app settings screen. + * iOS doesn't have a separate exact alarm permission screen. + */ + override fun openExactAlarmPermissionScreen() { + openAppSettings() + } + + /** + * Opens the iOS Settings app to this app's settings page. + */ + override fun openAppSettings() { + val url = NSURL.URLWithString(UIApplicationOpenSettingsURLString) + if (url != null) { + UIApplication.sharedApplication.openURL(url) + } + } } diff --git a/app/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/interactors/AlarmInteractorImpl.kt b/app/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/interactors/AlarmInteractorImpl.kt index fcc7ffd..edde3c3 100644 --- a/app/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/interactors/AlarmInteractorImpl.kt +++ b/app/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/interactors/AlarmInteractorImpl.kt @@ -13,13 +13,22 @@ class AlarmInteractorImpl( private val scheduler = IosAlarmScheduler(logger) - override fun schedule(alarm: Alarm, reschedule: Boolean): Boolean { - logger.d { "AlarmInteractorImpl.schedule called: alarmId=${alarm.alarmId}, time=${alarm.hour}:${alarm.minute}, reschedule=$reschedule" } - return scheduler.scheduleAlarm(alarm, reschedule) + override fun schedule(alarm: Alarm, timeInMillis: Long) { + logger.d { "AlarmInteractorImpl.schedule: alarmId=${alarm.alarmId}, timeInMillis=$timeInMillis" } + // iOS scheduler handles time internally via UNCalendarNotificationTrigger + // We pass the alarm and it schedules based on alarm's hour/minute + scheduler.scheduleAlarm(alarm, reschedule = false) } override fun cancel(alarm: Alarm) { - logger.d { "AlarmInteractorImpl.cancel called: alarmId=${alarm.alarmId}" } + logger.d { "AlarmInteractorImpl.cancel: alarmId=${alarm.alarmId}" } scheduler.cancelAlarm(alarm) } + + override fun update(alarm: Alarm) { + logger.d { "AlarmInteractorImpl.update: alarmId=${alarm.alarmId}" } + // On iOS, we need to cancel and reschedule to update + scheduler.cancelAlarm(alarm) + scheduler.scheduleAlarm(alarm, reschedule = true) + } } diff --git a/app/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/interactors/AudioPlayer.kt b/app/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/interactors/AudioPlayer.kt index 990dfe8..4b45d3b 100644 --- a/app/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/interactors/AudioPlayer.kt +++ b/app/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/interactors/AudioPlayer.kt @@ -12,6 +12,7 @@ import platform.Foundation.NSURL actual interface AudioPlayer { actual val currentPosition: Int actual val duration: Int + actual val isPlaying: Boolean actual fun init() actual fun startAlarmAudio() @@ -38,6 +39,9 @@ class IosAudioPlayer( override val duration: Int get() = (audioPlayer?.duration?.times(1000))?.toInt() ?: 0 + override val isPlaying: Boolean + get() = audioPlayer?.isPlaying() == true + override fun init() { try { // Configure audio session for playback diff --git a/app/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/notification/IosAlarmNotification.kt b/app/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/notification/IosAlarmNotification.kt new file mode 100644 index 0000000..7a0e2da --- /dev/null +++ b/app/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/notification/IosAlarmNotification.kt @@ -0,0 +1,78 @@ +package com.timilehinaregbesola.mathalarm.notification + +import co.touchlab.kermit.Logger +import platform.UserNotifications.UNUserNotificationCenter + +/** + * iOS implementation for showing and dismissing alarm notifications. + * + * Following Alkaa's pattern of separating notification display concerns + * from scheduling concerns for better code organization. + * + * On iOS, the notification scheduler handles showing notifications via triggers, + * so the show methods are no-ops. This class primarily handles dismissing + * delivered notifications. + */ +class IosAlarmNotification( + private val logger: Logger +) { + private val notificationCenter = UNUserNotificationCenter.currentNotificationCenter() + + /** + * Show an alarm notification. + * On iOS, notifications are shown by the system when the trigger fires, + * so this is a no-op. + */ + fun show(alarmId: Long) { + logger.d { "IosAlarmNotification.show - no-op on iOS (handled by trigger)" } + // On iOS, the notification scheduler is responsible for showing notifications + // via UNCalendarNotificationTrigger. This method exists for API consistency. + } + + /** + * Show a repeating alarm notification. + * On iOS, repeating notifications are handled by the trigger configuration, + * so this is a no-op. + */ + fun showRepeating(alarmId: Long) { + logger.d { "IosAlarmNotification.showRepeating - no-op on iOS (handled by trigger)" } + // Repeating notifications are configured in the trigger, not shown manually + } + + /** + * Dismiss a delivered notification for the given alarm. + * + * @param alarmId the alarm ID to dismiss notifications for + */ + fun dismiss(alarmId: Long) { + logger.d { "IosAlarmNotification.dismiss - alarmId: $alarmId" } + + val identifiers = buildNotificationIdentifiers(alarmId) + notificationCenter.removeDeliveredNotificationsWithIdentifiers(identifiers) + + logger.d { "IosAlarmNotification.dismiss - removed ${identifiers.size} delivered notifications" } + } + + /** + * Dismiss all delivered notifications. + */ + fun dismissAll() { + logger.d { "IosAlarmNotification.dismissAll" } + notificationCenter.removeAllDeliveredNotifications() + } + + /** + * Build list of notification identifiers for an alarm. + * Includes main alarm identifier and day-specific identifiers for repeating alarms. + */ + private fun buildNotificationIdentifiers(alarmId: Long): List { + val identifiers = mutableListOf("alarm_$alarmId") + + // Include day-specific identifiers for repeating alarms + for (i in 0..6) { + identifiers.add("alarm_${alarmId}_day_$i") + } + + return identifiers + } +} diff --git a/app/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/notification/IosAlarmScheduler.kt b/app/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/notification/IosAlarmScheduler.kt index c1856e8..049108b 100644 --- a/app/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/notification/IosAlarmScheduler.kt +++ b/app/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/notification/IosAlarmScheduler.kt @@ -5,43 +5,88 @@ import com.timilehinaregbesola.mathalarm.alarm.AlarmScheduleRequest import com.timilehinaregbesola.mathalarm.alarm.AlarmSchedulerBridge import com.timilehinaregbesola.mathalarm.domain.model.Alarm import kotlinx.cinterop.ExperimentalForeignApi -import kotlinx.datetime.Clock -import kotlinx.datetime.DateTimeUnit -import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.DatePeriod import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.LocalTime import kotlinx.datetime.TimeZone import kotlinx.datetime.plus +import kotlinx.datetime.toInstant import kotlinx.datetime.toLocalDateTime import platform.Foundation.NSCalendar import platform.Foundation.NSCalendarUnitDay import platform.Foundation.NSCalendarUnitHour import platform.Foundation.NSCalendarUnitMinute import platform.Foundation.NSCalendarUnitMonth -import platform.Foundation.NSCalendarUnitSecond -import platform.Foundation.NSCalendarUnitWeekday import platform.Foundation.NSCalendarUnitYear +import platform.Foundation.NSDate import platform.Foundation.NSDateComponents +import platform.Foundation.dateWithTimeIntervalSince1970 import platform.UserNotifications.UNAuthorizationOptionAlert import platform.UserNotifications.UNAuthorizationOptionBadge import platform.UserNotifications.UNAuthorizationOptionSound import platform.UserNotifications.UNCalendarNotificationTrigger import platform.UserNotifications.UNMutableNotificationContent +import platform.UserNotifications.UNNotificationAction +import platform.UserNotifications.UNNotificationActionOptionDestructive +import platform.UserNotifications.UNNotificationActionOptionNone +import platform.UserNotifications.UNNotificationCategory +import platform.UserNotifications.UNNotificationCategoryOptionNone import platform.UserNotifications.UNNotificationRequest import platform.UserNotifications.UNNotificationSound import platform.UserNotifications.UNUserNotificationCenter import kotlin.time.ExperimentalTime +import kotlin.time.Instant /** * iOS Alarm Scheduler - Hybrid Implementation * * Uses AlarmKit on iOS 26+ for native alarm experience, * falls back to UNUserNotificationCenter on iOS 15-25. + * + * Following Alkaa's patterns: + * - Registers notification categories with action buttons on init + * - Uses NSDate for cleaner time conversion + * - Separates notification display concerns to IosAlarmNotification */ class IosAlarmScheduler( private val logger: Logger ) { private val notificationCenter = UNUserNotificationCenter.currentNotificationCenter() + init { + registerNotificationCategories() + } + + /** + * Register notification categories with Snooze and Dismiss action buttons. + * This enables interactive buttons on the alarm notification. + */ + private fun registerNotificationCategories() { + logger.d { "Registering notification categories with actions" } + + val snoozeAction = UNNotificationAction.actionWithIdentifier( + identifier = IosNotificationConstants.ACTION_IDENTIFIER_SNOOZE, + title = "Snooze", + options = UNNotificationActionOptionNone + ) + + val dismissAction = UNNotificationAction.actionWithIdentifier( + identifier = IosNotificationConstants.ACTION_IDENTIFIER_DISMISS, + title = "Dismiss", + options = UNNotificationActionOptionDestructive + ) + + val alarmCategory = UNNotificationCategory.categoryWithIdentifier( + identifier = IosNotificationConstants.CATEGORY_IDENTIFIER_ALARM, + actions = listOf(snoozeAction, dismissAction), + intentIdentifiers = emptyList(), + options = UNNotificationCategoryOptionNone + ) + + notificationCenter.setNotificationCategories(setOf(alarmCategory)) + logger.d { "Notification categories registered successfully" } + } + /** * Check if AlarmKit is available (iOS 26+) */ @@ -146,18 +191,18 @@ class IosAlarmScheduler( } /** - * Schedule a one-time alarm that fires at the next occurrence of the specified time + * Schedule a one-time alarm that fires at the next occurrence of the specified time. + * Uses NSDate for cleaner time conversion following Alkaa's pattern. */ @OptIn(ExperimentalForeignApi::class, ExperimentalTime::class) private fun scheduleOneTimeAlarm(alarm: Alarm): Boolean { val content = createNotificationContent(alarm) - // Create date components for the alarm time - val dateComponents = NSDateComponents().apply { - hour = alarm.hour.toLong() - minute = alarm.minute.toLong() - second = 0 - } + // Calculate the next occurrence time in milliseconds + val timeInMillis = calculateNextAlarmTimeMillis(alarm.hour, alarm.minute) + + // Convert to NSDate and extract components (cleaner than manual NSDateComponents) + val dateComponents = dateComponentsFromMillis(timeInMillis) val trigger = UNCalendarNotificationTrigger.triggerWithDateMatchingComponents( dateComponents = dateComponents, @@ -170,6 +215,7 @@ class IosAlarmScheduler( trigger = trigger ) + logger.d { "Scheduling one-time alarm: id=${alarm.alarmId} at $timeInMillis" } notificationCenter.addNotificationRequest(request) { error -> if (error != null) { logger.e { "Failed to schedule one-time alarm: ${error.localizedDescription}" } @@ -181,6 +227,50 @@ class IosAlarmScheduler( return true } + /** + * Convert milliseconds timestamp to NSDateComponents. + * Following Alkaa's cleaner approach using NSDate. + */ + @OptIn(ExperimentalForeignApi::class) + private fun dateComponentsFromMillis(timeInMillis: Long): NSDateComponents { + val nsDate = NSDate.dateWithTimeIntervalSince1970(timeInMillis / 1000.0) + return NSCalendar.currentCalendar.components( + NSCalendarUnitYear or NSCalendarUnitMonth or NSCalendarUnitDay + or NSCalendarUnitHour or NSCalendarUnitMinute, + fromDate = nsDate + ) + } + + /** + * Calculate the next occurrence of the given hour:minute in milliseconds. + */ + @OptIn(ExperimentalTime::class) + private fun calculateNextAlarmTimeMillis(hour: Int, minute: Int): Long { + val now = Instant.fromEpochMilliseconds(kotlin.time.Clock.System.now().toEpochMilliseconds()) + val tz = TimeZone.currentSystemDefault() + val localNow = now.toLocalDateTime(tz) + val today = localNow.date + + // Create alarm time for today + val alarmTimeToday = LocalDateTime( + date = today, + time = LocalTime(hour, minute, 0) + ) + val alarmInstantToday = alarmTimeToday.toInstant(tz) + + // If alarm time has passed today, schedule for tomorrow + return if (alarmInstantToday > now) { + alarmInstantToday.toEpochMilliseconds() + } else { + val tomorrow = today.plus(DatePeriod(days = 1)) + val alarmTimeTomorrow = LocalDateTime( + date = tomorrow, + time = LocalTime(hour, minute, 0) + ) + alarmTimeTomorrow.toInstant(tz).toEpochMilliseconds() + } + } + /** * Schedule a repeating alarm for specific days of the week */ @@ -237,50 +327,57 @@ class IosAlarmScheduler( } /** - * Create notification content for an alarm + * Create notification content for an alarm. + * Uses constants from IosNotificationConstants for consistency. + * + * Note: Critical alerts require special Apple entitlement (not available to most apps). + * Instead, we use: + * - TimeSensitive interruption level (breaks through Focus modes) + * - Default sound for notification + * - AlarmAudioController plays full alarm sound when notification arrives/is tapped */ private fun createNotificationContent(alarm: Alarm): UNMutableNotificationContent { return UNMutableNotificationContent().apply { setTitle("Math Alarm") setBody(alarm.title.ifEmpty { "Time to wake up! Solve the math problem to dismiss." }) - // Use critical sound with max volume for alarm - // This bypasses Do Not Disturb and silent mode - // Note: Critical alerts require special entitlement from Apple for App Store - // For development, we use the default critical sound + // Use default sound or custom sound if bundled + // The actual alarm sound is played by AlarmAudioController in Swift + // when the notification arrives (foreground) or is tapped (background) val alarmSound = if (alarm.alarmTone.isNotEmpty()) { - // Try to use custom sound file if specified - UNNotificationSound.criticalSoundNamed(alarm.alarmTone, withAudioVolume = 1.0f) + // Try to use custom sound from bundle (must be .caf, .wav, .aiff, max 30 seconds) + UNNotificationSound.soundNamed(alarm.alarmTone) } else { - // Use default critical sound at max volume - UNNotificationSound.defaultCriticalSoundWithAudioVolume(1.0f) + // Use default notification sound - AlarmAudioController handles full alarm + UNNotificationSound.defaultSound } setSound(alarmSound) // Set relevance score to maximum for alarm notifications setRelevanceScore(1.0) - // Mark as interruptive (iOS 15+) - setInterruptionLevel(platform.UserNotifications.UNNotificationInterruptionLevel.UNNotificationInterruptionLevelCritical) + // Use TimeSensitive interruption level (iOS 15+) + // This breaks through Focus modes without requiring critical alerts entitlement + setInterruptionLevel(platform.UserNotifications.UNNotificationInterruptionLevel.UNNotificationInterruptionLevelTimeSensitive) - // Add ALL alarm data to userInfo for handling when notification is tapped - // This data is used to navigate to the MathScreen with the alarm info + // Add alarm data to userInfo using constants for key names + // This data is used by NotificationActionDelegate and for navigating to MathScreen setUserInfo(mapOf( - "alarmId" to alarm.alarmId, - "hour" to alarm.hour, - "minute" to alarm.minute, - "repeat" to alarm.repeat, - "repeatDays" to alarm.repeatDays, - "difficulty" to alarm.difficulty, - "snooze" to alarm.snooze, - "vibrate" to alarm.vibrate, - "alarmTone" to alarm.alarmTone, - "title" to alarm.title, - "isOn" to alarm.isOn + IosNotificationConstants.USER_INFO_ALARM_ID to alarm.alarmId, + IosNotificationConstants.USER_INFO_HOUR to alarm.hour, + IosNotificationConstants.USER_INFO_MINUTE to alarm.minute, + IosNotificationConstants.USER_INFO_REPEAT to alarm.repeat, + IosNotificationConstants.USER_INFO_REPEAT_DAYS to alarm.repeatDays, + IosNotificationConstants.USER_INFO_DIFFICULTY to alarm.difficulty, + IosNotificationConstants.USER_INFO_SNOOZE to alarm.snooze, + IosNotificationConstants.USER_INFO_VIBRATE to alarm.vibrate, + IosNotificationConstants.USER_INFO_ALARM_TONE to alarm.alarmTone, + IosNotificationConstants.USER_INFO_TITLE to alarm.title, + IosNotificationConstants.USER_INFO_IS_ON to alarm.isOn )) - // Set category for action buttons - setCategoryIdentifier("ALARM_CATEGORY") + // Set category for action buttons (registered in init) + setCategoryIdentifier(IosNotificationConstants.CATEGORY_IDENTIFIER_ALARM) } } diff --git a/app/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/notification/IosNotificationConstants.kt b/app/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/notification/IosNotificationConstants.kt new file mode 100644 index 0000000..8daf700 --- /dev/null +++ b/app/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/notification/IosNotificationConstants.kt @@ -0,0 +1,79 @@ +package com.timilehinaregbesola.mathalarm.notification + +/** + * Constants for iOS notification configuration. + * Centralized to avoid magic strings and ensure consistency. + */ +object IosNotificationConstants { + + /** + * Category identifier for alarm notifications. + * Used to associate actions with the notification. + */ + const val CATEGORY_IDENTIFIER_ALARM = "ALARM_CATEGORY" + + /** + * Action identifier for snooze button. + */ + const val ACTION_IDENTIFIER_SNOOZE = "SNOOZE_ACTION" + + /** + * Action identifier for dismiss button. + */ + const val ACTION_IDENTIFIER_DISMISS = "DISMISS_ACTION" + + /** + * Key for storing alarm ID in notification userInfo. + */ + const val USER_INFO_ALARM_ID = "alarmId" + + /** + * Key for storing alarm hour in notification userInfo. + */ + const val USER_INFO_HOUR = "hour" + + /** + * Key for storing alarm minute in notification userInfo. + */ + const val USER_INFO_MINUTE = "minute" + + /** + * Key for storing repeat flag in notification userInfo. + */ + const val USER_INFO_REPEAT = "repeat" + + /** + * Key for storing repeat days in notification userInfo. + */ + const val USER_INFO_REPEAT_DAYS = "repeatDays" + + /** + * Key for storing difficulty in notification userInfo. + */ + const val USER_INFO_DIFFICULTY = "difficulty" + + /** + * Key for storing snooze duration in notification userInfo. + */ + const val USER_INFO_SNOOZE = "snooze" + + /** + * Key for storing vibrate flag in notification userInfo. + */ + const val USER_INFO_VIBRATE = "vibrate" + + /** + * Key for storing alarm tone in notification userInfo. + */ + const val USER_INFO_ALARM_TONE = "alarmTone" + + /** + * Key for storing alarm title in notification userInfo. + */ + const val USER_INFO_TITLE = "title" + + /** + * Key for storing isOn flag in notification userInfo. + */ + const val USER_INFO_IS_ON = "isOn" +} diff --git a/app/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/notification/IosNotificationSetup.kt b/app/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/notification/IosNotificationSetup.kt index 0335f07..bede9da 100644 --- a/app/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/notification/IosNotificationSetup.kt +++ b/app/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/notification/IosNotificationSetup.kt @@ -2,7 +2,6 @@ package com.timilehinaregbesola.mathalarm.notification import platform.UserNotifications.UNAuthorizationOptionAlert import platform.UserNotifications.UNAuthorizationOptionBadge -import platform.UserNotifications.UNAuthorizationOptionCriticalAlert import platform.UserNotifications.UNAuthorizationOptionSound import platform.UserNotifications.UNNotificationAction import platform.UserNotifications.UNNotificationActionOptionDestructive @@ -61,16 +60,20 @@ object IosNotificationSetup { /** * Request notification permissions * Call this when appropriate in the app flow + * + * Note: Critical alerts require special Apple entitlement (not available to most apps). + * We use standard notification permissions - actual alarm sound is played by + * AlarmAudioController when the notification arrives. */ fun requestPermissions(onResult: (Boolean) -> Unit) { val notificationCenter = UNUserNotificationCenter.currentNotificationCenter() - // Request authorization including critical alerts for alarm sounds + // Request standard notification authorization + // Critical alerts require Apple approval - we handle alarm sounds via AlarmAudioController notificationCenter.requestAuthorizationWithOptions( options = UNAuthorizationOptionAlert or UNAuthorizationOptionSound or - UNAuthorizationOptionBadge or - UNAuthorizationOptionCriticalAlert + UNAuthorizationOptionBadge ) { granted, error -> onResult(granted) } diff --git a/app/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/notification/NotificationActionDelegate.kt b/app/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/notification/NotificationActionDelegate.kt new file mode 100644 index 0000000..0fee5c5 --- /dev/null +++ b/app/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/notification/NotificationActionDelegate.kt @@ -0,0 +1,84 @@ +package com.timilehinaregbesola.mathalarm.notification + +import co.touchlab.kermit.Logger +import com.timilehinaregbesola.mathalarm.coroutines.AppCoroutineScope +import com.timilehinaregbesola.mathalarm.framework.Usecases +import platform.UserNotifications.UNNotificationResponse + +/** + * Delegate class to handle iOS notification actions. + * + * Following Alkaa's pattern of separating notification action handling + * from the scheduler for cleaner code organization. + */ +class NotificationActionDelegate( + private val appCoroutineScope: AppCoroutineScope, + private val usecases: Usecases, + private val logger: Logger +) { + + /** + * Handles the notification response when user interacts with notification actions. + * + * @param response the notification response from iOS + * @param onCompletion callback to execute after handling is complete + */ + fun handleNotificationResponse(response: UNNotificationResponse, onCompletion: () -> Unit) { + logger.d { "NotificationActionDelegate - handling response" } + + val content = response.notification.request.content + val userInfo = content.userInfo + + val alarmId = (userInfo[IosNotificationConstants.USER_INFO_ALARM_ID] as? Long) ?: run { + logger.e { "NotificationActionDelegate - alarmId not found in userInfo" } + onCompletion() + return + } + + logger.d { "NotificationActionDelegate - alarmId: $alarmId, action: ${response.actionIdentifier}" } + + when (response.actionIdentifier) { + IosNotificationConstants.ACTION_IDENTIFIER_SNOOZE -> { + snoozeAlarm(alarmId, onCompletion) + } + IosNotificationConstants.ACTION_IDENTIFIER_DISMISS -> { + dismissAlarm(alarmId, onCompletion) + } + // Default action when user taps the notification itself + "com.apple.UNNotificationDefaultActionIdentifier" -> { + logger.d { "NotificationActionDelegate - default action (notification tapped)" } + onCompletion() + } + else -> { + logger.w { "NotificationActionDelegate - Unknown action: ${response.actionIdentifier}" } + onCompletion() + } + } + } + + private fun snoozeAlarm(alarmId: Long, onCompletion: () -> Unit) { + logger.d { "NotificationActionDelegate - snoozing alarm $alarmId" } + appCoroutineScope.launch { + try { + usecases.snoozeAlarm(alarmId) + logger.d { "NotificationActionDelegate - alarm $alarmId snoozed successfully" } + } catch (e: Exception) { + logger.e { "NotificationActionDelegate - failed to snooze alarm: ${e.message}" } + } + onCompletion() + } + } + + private fun dismissAlarm(alarmId: Long, onCompletion: () -> Unit) { + logger.d { "NotificationActionDelegate - dismissing alarm $alarmId" } + appCoroutineScope.launch { + try { + usecases.completeAlarm(alarmId) + logger.d { "NotificationActionDelegate - alarm $alarmId dismissed successfully" } + } catch (e: Exception) { + logger.e { "NotificationActionDelegate - failed to dismiss alarm: ${e.message}" } + } + onCompletion() + } + } +} diff --git a/app/src/test/java/com/timilehinaregbesola/mathalarm/AlarmReceiverTest.kt b/app/src/test/java/com/timilehinaregbesola/mathalarm/AlarmReceiverTest.kt new file mode 100644 index 0000000..2e9fb29 --- /dev/null +++ b/app/src/test/java/com/timilehinaregbesola/mathalarm/AlarmReceiverTest.kt @@ -0,0 +1,120 @@ +package com.timilehinaregbesola.mathalarm + +import android.app.AlarmManager +import android.app.Application +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.test.core.app.ApplicationProvider +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.R], application = TestApplication::class) +class AlarmReceiverTest { + + private lateinit var context: Context + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + } + + @Test + fun `ALARM_ACTION intent should have correct extras`() { + val alarmId = 123L + val intent = createAlarmIntent(AlarmReceiver.ALARM_ACTION, alarmId) + + assertEquals("Action should be ALARM_ACTION", AlarmReceiver.ALARM_ACTION, intent.action) + assertEquals("Should have correct alarm ID", alarmId, intent.getLongExtra(AlarmReceiver.EXTRA_TASK, 0)) + } + + @Test + fun `COMPLETE_ACTION intent should have correct extras`() { + val alarmId = 456L + val intent = createAlarmIntent(AlarmReceiver.COMPLETE_ACTION, alarmId) + + assertEquals("Action should be COMPLETE_ACTION", AlarmReceiver.COMPLETE_ACTION, intent.action) + assertEquals("Should have correct alarm ID", alarmId, intent.getLongExtra(AlarmReceiver.EXTRA_TASK, 0)) + } + + @Test + fun `SNOOZE_ACTION intent should have correct extras`() { + val alarmId = 789L + val intent = createAlarmIntent(AlarmReceiver.SNOOZE_ACTION, alarmId) + + assertEquals("Action should be SNOOZE_ACTION", AlarmReceiver.SNOOZE_ACTION, intent.action) + assertEquals("Should have correct alarm ID", alarmId, intent.getLongExtra(AlarmReceiver.EXTRA_TASK, 0)) + } + + @Test + fun `DISMISS_ACTION intent should have correct extras`() { + val alarmId = 111L + val intent = createAlarmIntent(AlarmReceiver.DISMISS_ACTION, alarmId) + + assertEquals("Action should be DISMISS_ACTION", AlarmReceiver.DISMISS_ACTION, intent.action) + assertEquals("Should have correct alarm ID", alarmId, intent.getLongExtra(AlarmReceiver.EXTRA_TASK, 0)) + } + + // ==================== Boot Event Intent Tests ==================== + + @Test + fun `BOOT_COMPLETED intent should have correct action`() { + val intent = Intent(Intent.ACTION_BOOT_COMPLETED) + + assertEquals("Action should be BOOT_COMPLETED", Intent.ACTION_BOOT_COMPLETED, intent.action) + } + + @Test + fun `QUICKBOOT_POWERON intent should have correct action`() { + val intent = Intent("android.intent.action.QUICKBOOT_POWERON") + + assertEquals("Action should be QUICKBOOT_POWERON", + "android.intent.action.QUICKBOOT_POWERON", + intent.action) + } + + @Test + fun `MY_PACKAGE_REPLACED intent should have correct action`() { + val intent = Intent("android.intent.action.MY_PACKAGE_REPLACED") + + assertEquals("Action should be MY_PACKAGE_REPLACED", + "android.intent.action.MY_PACKAGE_REPLACED", + intent.action) + } + + @Test + fun `SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED intent should have correct action`() { + val intent = Intent(AlarmManager.ACTION_SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED) + + assertEquals("Action should be SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED", + AlarmManager.ACTION_SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED, + intent.action) + } + + @Test + fun `alarm intent with zero ID should work`() { + val intent = createAlarmIntent(AlarmReceiver.ALARM_ACTION, 0L) + + assertEquals("Should have zero alarm ID", 0L, intent.getLongExtra(AlarmReceiver.EXTRA_TASK, -1)) + } + + @Test + fun `alarm intent with large ID should work`() { + val largeId = Long.MAX_VALUE + val intent = createAlarmIntent(AlarmReceiver.ALARM_ACTION, largeId) + + assertEquals("Should have large alarm ID", largeId, intent.getLongExtra(AlarmReceiver.EXTRA_TASK, 0)) + } + + private fun createAlarmIntent(action: String, alarmId: Long): Intent { + return Intent(context, AlarmReceiver::class.java).apply { + this.action = action + putExtra(AlarmReceiver.EXTRA_TASK, alarmId) + } + } +} diff --git a/app/src/test/java/com/timilehinaregbesola/mathalarm/TestApplication.kt b/app/src/test/java/com/timilehinaregbesola/mathalarm/TestApplication.kt new file mode 100644 index 0000000..11d287c --- /dev/null +++ b/app/src/test/java/com/timilehinaregbesola/mathalarm/TestApplication.kt @@ -0,0 +1,14 @@ +package com.timilehinaregbesola.mathalarm + +import android.app.Application + +/** + * Test Application class that does NOT initialize Koin. + * This is used by Robolectric tests to avoid Koin initialization conflicts. + */ +class TestApplication : Application() { + override fun onCreate() { + super.onCreate() + // Do NOT start Koin - tests will manage their own state + } +} diff --git a/app/src/test/java/com/timilehinaregbesola/mathalarm/fake/PermissionCheckerFake.kt b/app/src/test/java/com/timilehinaregbesola/mathalarm/fake/PermissionCheckerFake.kt new file mode 100644 index 0000000..0fe2f33 --- /dev/null +++ b/app/src/test/java/com/timilehinaregbesola/mathalarm/fake/PermissionCheckerFake.kt @@ -0,0 +1,12 @@ +package com.timilehinaregbesola.mathalarm.fake + +import com.timilehinaregbesola.mathalarm.framework.app.permission.PermissionChecker + +/** + * Fake implementation of PermissionChecker for testing. + */ +class PermissionCheckerFake( + var canScheduleExactAlarms: Boolean = true +) : PermissionChecker { + override fun canScheduleExactAlarms(): Boolean = canScheduleExactAlarms +} diff --git a/app/src/test/java/com/timilehinaregbesola/mathalarm/fake/ScreenNavigatorFake.kt b/app/src/test/java/com/timilehinaregbesola/mathalarm/fake/ScreenNavigatorFake.kt new file mode 100644 index 0000000..22f4708 --- /dev/null +++ b/app/src/test/java/com/timilehinaregbesola/mathalarm/fake/ScreenNavigatorFake.kt @@ -0,0 +1,26 @@ +package com.timilehinaregbesola.mathalarm.fake + +import com.timilehinaregbesola.mathalarm.framework.app.permission.ScreenNavigator + +/** + * Fake implementation of ScreenNavigator for testing. + */ +class ScreenNavigatorFake : ScreenNavigator { + var exactAlarmPermissionScreenOpened = false + private set + var appSettingsOpened = false + private set + + override fun openExactAlarmPermissionScreen() { + exactAlarmPermissionScreenOpened = true + } + + override fun openAppSettings() { + appSettingsOpened = true + } + + fun reset() { + exactAlarmPermissionScreenOpened = false + appSettingsOpened = false + } +} diff --git a/app/src/test/java/com/timilehinaregbesola/mathalarm/framework/app/permission/AlarmPermissionTest.kt b/app/src/test/java/com/timilehinaregbesola/mathalarm/framework/app/permission/AlarmPermissionTest.kt index de9b91f..1fbfad9 100644 --- a/app/src/test/java/com/timilehinaregbesola/mathalarm/framework/app/permission/AlarmPermissionTest.kt +++ b/app/src/test/java/com/timilehinaregbesola/mathalarm/framework/app/permission/AlarmPermissionTest.kt @@ -1,23 +1,29 @@ package com.timilehinaregbesola.mathalarm.framework.app.permission -import android.app.AlarmManager import android.os.Build import com.timilehinaregbesola.mathalarm.fake.AndroidVersionFake -import io.mockk.every -import io.mockk.mockk +import com.timilehinaregbesola.mathalarm.fake.PermissionCheckerFake +import com.timilehinaregbesola.mathalarm.fake.ScreenNavigatorFake import org.junit.Assert +import org.junit.Before import org.junit.Test class AlarmPermissionTest { - private val alarmManager = mockk(relaxed = true) - + private val screenNavigator = ScreenNavigatorFake() + private val permissionChecker = PermissionCheckerFake() private val androidVersion = AndroidVersionFake() - private val alarmPermission = AlarmPermissionImpl(alarmManager, androidVersion) + private lateinit var alarmPermission: AlarmPermissionImpl + + @Before + fun setup() { + alarmPermission = AlarmPermissionImpl(screenNavigator, permissionChecker, androidVersion) + screenNavigator.reset() + } @Test fun `test if when permission is granted returns true`() { - every { alarmManager.canScheduleExactAlarms() } returns true + permissionChecker.canScheduleExactAlarms = true androidVersion.version = Build.VERSION_CODES.S val result = alarmPermission.hasExactAlarmPermission() @@ -27,7 +33,7 @@ class AlarmPermissionTest { @Test fun `test if when permission is not granted returns false`() { - every { alarmManager.canScheduleExactAlarms() } returns false + permissionChecker.canScheduleExactAlarms = false androidVersion.version = Build.VERSION_CODES.S val result = alarmPermission.hasExactAlarmPermission() @@ -38,9 +44,24 @@ class AlarmPermissionTest { @Test fun `test if Android below S returns true`() { androidVersion.version = Build.VERSION_CODES.M + permissionChecker.canScheduleExactAlarms = false // Should be ignored val result = alarmPermission.hasExactAlarmPermission() Assert.assertTrue(result) } + + @Test + fun `test openExactAlarmPermissionScreen delegates to navigator`() { + alarmPermission.openExactAlarmPermissionScreen() + + Assert.assertTrue(screenNavigator.exactAlarmPermissionScreenOpened) + } + + @Test + fun `test openAppSettings delegates to navigator`() { + alarmPermission.openAppSettings() + + Assert.assertTrue(screenNavigator.appSettingsOpened) + } } diff --git a/app/src/test/java/com/timilehinaregbesola/mathalarm/notification/AlarmNotificationSchedulerTest.kt b/app/src/test/java/com/timilehinaregbesola/mathalarm/notification/AlarmNotificationSchedulerTest.kt index ce3a5da..b7b242b 100644 --- a/app/src/test/java/com/timilehinaregbesola/mathalarm/notification/AlarmNotificationSchedulerTest.kt +++ b/app/src/test/java/com/timilehinaregbesola/mathalarm/notification/AlarmNotificationSchedulerTest.kt @@ -1,44 +1,253 @@ package com.timilehinaregbesola.mathalarm.notification +import android.app.AlarmManager +import android.app.Application import android.content.Context +import android.os.Build +import androidx.test.core.app.ApplicationProvider import co.touchlab.kermit.Logger +import com.timilehinaregbesola.mathalarm.AlarmReceiver import com.timilehinaregbesola.mathalarm.domain.model.Alarm -import io.mockk.every -import io.mockk.mockk -import io.mockk.mockkStatic -import kotlinx.datetime.LocalDateTime +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 org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowAlarmManager +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.R], application = com.timilehinaregbesola.mathalarm.TestApplication::class) class AlarmNotificationSchedulerTest { - private val mockContext = mockk(relaxed = true) - private val mockLogger = Logger.withTag("AlarmNotificationSchedulerTest") - - private val mockAlarm = mockk() - - private val scheduler = AlarmNotificationScheduler(mockContext, mockLogger) + private lateinit var context: Context + private lateinit var alarmManager: AlarmManager + private lateinit var shadowAlarmManager: ShadowAlarmManager + private lateinit var scheduler: AlarmNotificationScheduler + private lateinit var logger: Logger @Before fun setUp() { - val now = LocalDateTime(2023, 1, 1, 10, 30) // Example LocalDateTime - mockkStatic("com.timilehinaregbesola.mathalarm.utils.extension-context") - every { mockAlarm.repeatDays } returns "FFFTFFF" - every { mockAlarm.alarmId } returns 22L - every { mockAlarm.repeat } returns false - every { mockAlarm.newDateTime } returns now - every { mockAlarm.hour } returns now.hour - every { mockAlarm.minute } returns now.minute - } - -// @Test -// fun `check if alarm scheduled is valid`() { -// scheduler.scheduleAlarm(mockAlarm, false) -// verify { mockContext.setExactAlarm(mockAlarm.newDateTime.time.toNanosecondOfDay(), any()) } -// } -// -// @Test -// fun `check if alarm canceled is valid`() { -// scheduler.cancelAlarm(mockAlarm) -// verify { mockContext.cancelAlarm(any()) } -// } + context = ApplicationProvider.getApplicationContext() + alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + shadowAlarmManager = shadowOf(alarmManager) + logger = Logger.withTag("AlarmNotificationSchedulerTest") + scheduler = AlarmNotificationScheduler(context, logger) + } + + @Test + fun `scheduleAlarm should schedule alarm with correct time`() { + val alarm = createAlarm(id = 1L) + val triggerTime = System.currentTimeMillis() + 60_000L // 1 minute from now + + scheduler.scheduleAlarm(alarm, triggerTime) + + val scheduledAlarms = shadowAlarmManager.scheduledAlarms + assertEquals("Should have one scheduled alarm", 1, scheduledAlarms.size) + assertEquals("Trigger time should match", triggerTime, scheduledAlarms[0].triggerAtTime) + } + + @Test + fun `scheduleAlarm should create correct PendingIntent targeting AlarmReceiver`() { + val alarmId = 123L + val alarm = createAlarm(id = alarmId) + val triggerTime = System.currentTimeMillis() + 60_000L + + scheduler.scheduleAlarm(alarm, triggerTime) + + val scheduledAlarms = shadowAlarmManager.scheduledAlarms + assertNotNull("Should have scheduled alarm", scheduledAlarms.firstOrNull()) + assertEquals("Should use RTC_WAKEUP type", AlarmManager.RTC_WAKEUP, scheduledAlarms[0].type) + + val pendingIntent = scheduledAlarms[0].operation + val shadowPendingIntent = shadowOf(pendingIntent) + val savedIntent = shadowPendingIntent.savedIntent + + assertEquals("Intent should have alarm ID", alarmId, savedIntent.getLongExtra(AlarmReceiver.EXTRA_TASK, -1)) + assertEquals("Intent action should be ALARM_ACTION", AlarmReceiver.ALARM_ACTION, savedIntent.action) + assertEquals("Intent should target AlarmReceiver", AlarmReceiver::class.java.name, savedIntent.component?.className) + assertTrue("Should be a broadcast PendingIntent", shadowPendingIntent.isBroadcastIntent) + } + + @Test + fun `scheduleAlarm with different alarms should schedule multiple alarms`() { + val alarm1 = createAlarm(id = 1L) + val alarm2 = createAlarm(id = 2L) + val alarm3 = createAlarm(id = 3L) + val baseTime = System.currentTimeMillis() + + scheduler.scheduleAlarm(alarm1, baseTime + 60_000L) + scheduler.scheduleAlarm(alarm2, baseTime + 120_000L) + scheduler.scheduleAlarm(alarm3, baseTime + 180_000L) + + val scheduledAlarms = shadowAlarmManager.scheduledAlarms + assertEquals("Should have three scheduled alarms", 3, scheduledAlarms.size) + } + + @Test + fun `scheduleAlarm with same alarm ID should replace previous alarm`() { + val alarm = createAlarm(id = 1L) + val firstTime = System.currentTimeMillis() + 60_000L + val secondTime = System.currentTimeMillis() + 120_000L + + scheduler.scheduleAlarm(alarm, firstTime) + scheduler.scheduleAlarm(alarm, secondTime) + + // Due to FLAG_CANCEL_CURRENT, the second schedule should replace the first + val scheduledAlarms = shadowAlarmManager.scheduledAlarms + // Note: Robolectric may keep both or only the latest depending on version + // The important thing is that when the alarm fires, it uses the latest time + assertTrue("Should have at least one scheduled alarm", scheduledAlarms.isNotEmpty()) + } + + + @Test + fun `cancelAlarm should remove scheduled alarm`() { + val alarm = createAlarm(id = 1L) + val triggerTime = System.currentTimeMillis() + 60_000L + + scheduler.scheduleAlarm(alarm, triggerTime) + assertEquals("Should have one scheduled alarm before cancel", 1, shadowAlarmManager.scheduledAlarms.size) + + scheduler.cancelAlarm(alarm) + + val scheduledAlarms = shadowAlarmManager.scheduledAlarms + assertTrue("Scheduled alarms should be empty after cancel", scheduledAlarms.isEmpty()) + } + + @Test + fun `cancelAlarm for repeating alarm should cancel all day-specific alarms`() { + // Repeating alarm on Mon, Wed, Fri (indices 1, 3, 5) + val alarm = createAlarm(id = 1L, repeatDays = "FTFTFTF") + + // Schedule the base alarm + scheduler.scheduleAlarm(alarm, System.currentTimeMillis() + 60_000L) + + // Cancel should attempt to cancel the base alarm and all day-specific ones + scheduler.cancelAlarm(alarm) + + val scheduledAlarms = shadowAlarmManager.scheduledAlarms + assertTrue("All alarms should be canceled", scheduledAlarms.isEmpty()) + } + + @Test + fun `cancelAlarm should not affect other alarms`() { + val alarm1 = createAlarm(id = 1L) + val alarm2 = createAlarm(id = 2L) + val triggerTime = System.currentTimeMillis() + 60_000L + + scheduler.scheduleAlarm(alarm1, triggerTime) + scheduler.scheduleAlarm(alarm2, triggerTime + 60_000L) + assertEquals("Should have two scheduled alarms", 2, shadowAlarmManager.scheduledAlarms.size) + + scheduler.cancelAlarm(alarm1) + + val remainingAlarms = shadowAlarmManager.scheduledAlarms + assertEquals("Should have one remaining alarm", 1, remainingAlarms.size) + + // Verify the remaining alarm is for alarm2 + val pendingIntent = remainingAlarms[0].operation + val shadowPendingIntent = shadowOf(pendingIntent) + val savedIntent = shadowPendingIntent.savedIntent + assertEquals("Remaining alarm should be alarm2", 2L, savedIntent.getLongExtra(AlarmReceiver.EXTRA_TASK, -1)) + } + + @Test + fun `different alarms should have different PendingIntent request codes`() { + val alarm1 = createAlarm(id = 1L) + val alarm2 = createAlarm(id = 2L) + val triggerTime = System.currentTimeMillis() + 60_000L + + scheduler.scheduleAlarm(alarm1, triggerTime) + scheduler.scheduleAlarm(alarm2, triggerTime) + + val scheduledAlarms = shadowAlarmManager.scheduledAlarms + assertEquals("Should have two scheduled alarms", 2, scheduledAlarms.size) + + val requestCode1 = shadowOf(scheduledAlarms[0].operation).requestCode + val requestCode2 = shadowOf(scheduledAlarms[1].operation).requestCode + + assertTrue("Request codes should be different", requestCode1 != requestCode2) + } + + // ==================== Edge Case Tests ==================== + + @Test + fun `scheduleAlarm with zero ID should still work`() { + val alarm = createAlarm(id = 0L) + val triggerTime = System.currentTimeMillis() + 60_000L + + scheduler.scheduleAlarm(alarm, triggerTime) + + val scheduledAlarms = shadowAlarmManager.scheduledAlarms + assertEquals("Should have one scheduled alarm", 1, scheduledAlarms.size) + } + + @Test + fun `scheduleAlarm with very large ID should work`() { + val alarm = createAlarm(id = Long.MAX_VALUE) + val triggerTime = System.currentTimeMillis() + 60_000L + + scheduler.scheduleAlarm(alarm, triggerTime) + + val scheduledAlarms = shadowAlarmManager.scheduledAlarms + assertEquals("Should have one scheduled alarm", 1, scheduledAlarms.size) + } + + @Test + fun `updateAlarm should not throw exception`() { + val alarm = createAlarm(id = 1L) + + // updateAlarm is a no-op on Android but should not throw + scheduler.updateAlarm(alarm) + + // No assertions needed - just verify no exception is thrown + } + + @Test + fun `cancelAlarm with all days enabled should attempt to cancel 7 day-specific alarms`() { + val alarm = createAlarm(id = 1L, repeatDays = "TTTTTTT") + + // Schedule the alarm + scheduler.scheduleAlarm(alarm, System.currentTimeMillis() + 60_000L) + + // Cancel should handle all 7 days + scheduler.cancelAlarm(alarm) + + assertTrue("All alarms should be canceled", shadowAlarmManager.scheduledAlarms.isEmpty()) + } + + @Test + fun `cancelAlarm with no repeat days should only cancel base alarm`() { + val alarm = createAlarm(id = 1L, repeatDays = "FFFFFFF") + + scheduler.scheduleAlarm(alarm, System.currentTimeMillis() + 60_000L) + scheduler.cancelAlarm(alarm) + + assertTrue("Alarm should be canceled", shadowAlarmManager.scheduledAlarms.isEmpty()) + } + + + private fun createAlarm( + id: Long, + hour: Int = 7, + minute: Int = 0, + repeatDays: String = "FFFFFFF", + repeat: Boolean = repeatDays.contains('T'), + title: String = "Test Alarm" + ): Alarm { + return Alarm( + alarmId = id, + hour = hour, + minute = minute, + repeatDays = repeatDays, + repeat = repeat, + title = title, + isOn = true, + isSaved = true + ) + } } diff --git a/app/src/test/java/com/timilehinaregbesola/mathalarm/notification/AlarmTimingControllerTest.kt b/app/src/test/java/com/timilehinaregbesola/mathalarm/notification/AlarmTimingControllerTest.kt new file mode 100644 index 0000000..b5ea068 --- /dev/null +++ b/app/src/test/java/com/timilehinaregbesola/mathalarm/notification/AlarmTimingControllerTest.kt @@ -0,0 +1,193 @@ +package com.timilehinaregbesola.mathalarm.notification + +import io.mockk.mockk +import io.mockk.verify +import io.mockk.verifyOrder +import io.mockk.slot +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +class AlarmTimingControllerTest { + + private lateinit var onStartRinging: () -> Unit + private lateinit var onPauseRinging: () -> Unit + private lateinit var testScheduler: TestTimingScheduler + private lateinit var controller: AlarmTimingController + + // Test scheduler that allows manual time advancement + class TestTimingScheduler : AlarmTimingController.TimingScheduler { + private val scheduledTasks = mutableMapOf() + private var currentTime = 0L + + override fun scheduleDelayed(task: Runnable, delayMillis: Long) { + scheduledTasks[task] = currentTime + delayMillis + } + + override fun cancel(task: Runnable) { + scheduledTasks.remove(task) + } + + fun advanceTimeBy(millis: Long) { + currentTime += millis + // Execute tasks that should have fired + val toExecute = scheduledTasks.filter { it.value <= currentTime }.keys.toList() + toExecute.forEach { task -> + scheduledTasks.remove(task) + task.run() + } + } + + fun hasScheduledTasks(): Boolean = scheduledTasks.isNotEmpty() + + fun getScheduledTaskCount(): Int = scheduledTasks.size + } + + @Before + fun setup() { + onStartRinging = mockk(relaxed = true) + onPauseRinging = mockk(relaxed = true) + testScheduler = TestTimingScheduler() + + controller = AlarmTimingController( + ringDurationMillis = 10_000L, // 10 seconds for testing + silencePeriodMillis = 2_000L, // 2 seconds for testing + onStartRinging = onStartRinging, + onPauseRinging = onPauseRinging, + scheduler = testScheduler + ) + } + + @Test + fun `start should trigger onStartRinging and set state to RINGING`() { + controller.start() + + verify(exactly = 1) { onStartRinging() } + assertEquals(AlarmTimingController.State.RINGING, controller.currentState) + } + + @Test + fun `start should schedule auto-pause task`() { + controller.start() + + assert(testScheduler.hasScheduledTasks()) + } + + @Test + fun `after ring duration, should pause and call onPauseRinging`() { + controller.start() + + // Advance time past ring duration + testScheduler.advanceTimeBy(10_000L) + + verify(exactly = 1) { onPauseRinging() } + assertEquals(AlarmTimingController.State.PAUSED, controller.currentState) + } + + @Test + fun `after silence period, should restart ringing`() { + controller.start() + + // Advance to pause + testScheduler.advanceTimeBy(10_000L) + verify(exactly = 1) { onPauseRinging() } + + // Advance past silence period + testScheduler.advanceTimeBy(2_000L) + + // Should have called onStartRinging twice (initial + restart) + verify(exactly = 2) { onStartRinging() } + assertEquals(AlarmTimingController.State.RINGING, controller.currentState) + } + + @Test + fun `full cycle should repeat - ring, pause, ring, pause`() { + controller.start() + + // First ring cycle + verify(exactly = 1) { onStartRinging() } + + // After 10s - pause + testScheduler.advanceTimeBy(10_000L) + verify(exactly = 1) { onPauseRinging() } + + // After 2s silence - restart + testScheduler.advanceTimeBy(2_000L) + verify(exactly = 2) { onStartRinging() } + + // After another 10s - pause again + testScheduler.advanceTimeBy(10_000L) + verify(exactly = 2) { onPauseRinging() } + + // After 2s silence - restart again + testScheduler.advanceTimeBy(2_000L) + verify(exactly = 3) { onStartRinging() } + } + + @Test + fun `stop should cancel all tasks and set state to IDLE`() { + controller.start() + + controller.stop() + + assertEquals(AlarmTimingController.State.IDLE, controller.currentState) + // Advancing time should not trigger any callbacks + testScheduler.advanceTimeBy(100_000L) + verify(exactly = 1) { onStartRinging() } // Only the initial call + verify(exactly = 0) { onPauseRinging() } + } + + @Test + fun `stop during paused state should cancel restart`() { + controller.start() + + // Advance to pause + testScheduler.advanceTimeBy(10_000L) + assertEquals(AlarmTimingController.State.PAUSED, controller.currentState) + + // Stop during pause + controller.stop() + + // Advancing time should not restart + testScheduler.advanceTimeBy(10_000L) + verify(exactly = 1) { onStartRinging() } // Only initial, no restart + } + + @Test + fun `calling start when already ringing should restart cycle`() { + controller.start() + verify(exactly = 1) { onStartRinging() } + + // Advance partway through ring duration + testScheduler.advanceTimeBy(5_000L) + + // Call start again + controller.start() + + // Should restart ringing + verify(exactly = 2) { onStartRinging() } + assertEquals(AlarmTimingController.State.RINGING, controller.currentState) + } + + @Test + fun `order of callbacks should be correct`() { + controller.start() + + // Full cycle + testScheduler.advanceTimeBy(10_000L) // Pause + testScheduler.advanceTimeBy(2_000L) // Restart + testScheduler.advanceTimeBy(10_000L) // Pause again + + verifyOrder { + onStartRinging() // Initial + onPauseRinging() // After first ring + onStartRinging() // Restart + onPauseRinging() // After second ring + } + } + + @Test + fun `initial state should be IDLE`() { + assertEquals(AlarmTimingController.State.IDLE, controller.currentState) + } +} diff --git a/app/src/test/java/com/timilehinaregbesola/mathalarm/utils/AlarmManagerExtensionsTest.kt b/app/src/test/java/com/timilehinaregbesola/mathalarm/utils/AlarmManagerExtensionsTest.kt new file mode 100644 index 0000000..9234c5d --- /dev/null +++ b/app/src/test/java/com/timilehinaregbesola/mathalarm/utils/AlarmManagerExtensionsTest.kt @@ -0,0 +1,177 @@ +package com.timilehinaregbesola.mathalarm.utils + +import android.app.AlarmManager +import android.app.Application +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.test.core.app.ApplicationProvider +import com.timilehinaregbesola.mathalarm.AlarmReceiver +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowAlarmManager + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.R], application = com.timilehinaregbesola.mathalarm.TestApplication::class) +class AlarmManagerExtensionsTest { + + private lateinit var context: Context + private lateinit var alarmManager: AlarmManager + private lateinit var shadowAlarmManager: ShadowAlarmManager + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + shadowAlarmManager = shadowOf(alarmManager) + } + + @Test + fun `setExactAlarm with future time should schedule alarm`() { + val futureTime = System.currentTimeMillis() + 60_000L // 1 minute from now + val pendingIntent = createTestPendingIntent(1) + + context.setExactAlarm(futureTime, pendingIntent) + + val scheduledAlarms = shadowAlarmManager.scheduledAlarms + assertEquals("Should have one scheduled alarm", 1, scheduledAlarms.size) + assertEquals("Trigger time should match", futureTime, scheduledAlarms[0].triggerAtTime) + } + + @Test + fun `setExactAlarm should use RTC_WAKEUP as default type`() { + val futureTime = System.currentTimeMillis() + 60_000L + val pendingIntent = createTestPendingIntent(1) + + context.setExactAlarm(futureTime, pendingIntent) + + val scheduledAlarms = shadowAlarmManager.scheduledAlarms + assertEquals("Should use RTC_WAKEUP type", AlarmManager.RTC_WAKEUP, scheduledAlarms[0].type) + } + + @Test + fun `setExactAlarm with custom type should use that type`() { + val futureTime = System.currentTimeMillis() + 60_000L + val pendingIntent = createTestPendingIntent(1) + + context.setExactAlarm(futureTime, pendingIntent, AlarmManager.ELAPSED_REALTIME_WAKEUP) + + val scheduledAlarms = shadowAlarmManager.scheduledAlarms + assertEquals("Should use ELAPSED_REALTIME_WAKEUP type", + AlarmManager.ELAPSED_REALTIME_WAKEUP, + scheduledAlarms[0].type) + } + + @Test + fun `setExactAlarm with past time should add one week to trigger time`() { + val pastTime = System.currentTimeMillis() - 60_000L // 1 minute in the past + val pendingIntent = createTestPendingIntent(1) + val oneWeekInMillis = 7 * 24 * 60 * 60 * 1000L + + context.setExactAlarm(pastTime, pendingIntent) + + val scheduledAlarms = shadowAlarmManager.scheduledAlarms + assertEquals("Should have one scheduled alarm", 1, scheduledAlarms.size) + + // The alarm should be scheduled for pastTime + 1 week + val expectedTime = pastTime + oneWeekInMillis + assertEquals("Trigger time should be past time + 1 week", expectedTime, scheduledAlarms[0].triggerAtTime) + } + + @Test + fun `setExactAlarm with null PendingIntent should not schedule alarm`() { + val futureTime = System.currentTimeMillis() + 60_000L + + context.setExactAlarm(futureTime, null) + + val scheduledAlarms = shadowAlarmManager.scheduledAlarms + assertTrue("Should have no scheduled alarms", scheduledAlarms.isEmpty()) + } + + @Test + fun `multiple setExactAlarm calls should schedule multiple alarms`() { + val baseTime = System.currentTimeMillis() + + context.setExactAlarm(baseTime + 60_000L, createTestPendingIntent(1)) + context.setExactAlarm(baseTime + 120_000L, createTestPendingIntent(2)) + context.setExactAlarm(baseTime + 180_000L, createTestPendingIntent(3)) + + val scheduledAlarms = shadowAlarmManager.scheduledAlarms + assertEquals("Should have three scheduled alarms", 3, scheduledAlarms.size) + } + + // ==================== cancelAlarm Tests ==================== + + @Test + fun `cancelAlarm should remove scheduled alarm`() { + val futureTime = System.currentTimeMillis() + 60_000L + val pendingIntent = createTestPendingIntent(1) + + context.setExactAlarm(futureTime, pendingIntent) + assertEquals("Should have one alarm before cancel", 1, shadowAlarmManager.scheduledAlarms.size) + + context.cancelAlarm(pendingIntent) + + val scheduledAlarms = shadowAlarmManager.scheduledAlarms + assertTrue("Should have no alarms after cancel", scheduledAlarms.isEmpty()) + } + + @Test + fun `cancelAlarm with null PendingIntent should not throw`() { + // This should not throw any exception + context.cancelAlarm(null) + + // No assertion needed - just verify no exception is thrown + } + + @Test + fun `cancelAlarm should only cancel matching alarm`() { + val futureTime = System.currentTimeMillis() + 60_000L + val pendingIntent1 = createTestPendingIntent(1) + val pendingIntent2 = createTestPendingIntent(2) + + context.setExactAlarm(futureTime, pendingIntent1) + context.setExactAlarm(futureTime + 60_000L, pendingIntent2) + assertEquals("Should have two alarms before cancel", 2, shadowAlarmManager.scheduledAlarms.size) + + context.cancelAlarm(pendingIntent1) + + val scheduledAlarms = shadowAlarmManager.scheduledAlarms + assertEquals("Should have one alarm after cancel", 1, scheduledAlarms.size) + } + + @Test + fun `cancelAlarm for non-existent alarm should not throw`() { + val pendingIntent = createTestPendingIntent(999) + + // This should not throw any exception even though the alarm was never scheduled + context.cancelAlarm(pendingIntent) + } + + @Test + fun `getAlarmManager should return AlarmManager instance`() { + val alarmManager = context.getAlarmManager() + + assertTrue("Should return AlarmManager", alarmManager is AlarmManager) + } + + private fun createTestPendingIntent(requestCode: Int): PendingIntent { + val intent = Intent(context, AlarmReceiver::class.java).apply { + action = AlarmReceiver.ALARM_ACTION + putExtra(AlarmReceiver.EXTRA_TASK, requestCode.toLong()) + } + return PendingIntent.getBroadcast( + context, + requestCode, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + } +} diff --git a/core/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/coroutines/AppCoroutineScope.kt b/core/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/coroutines/AppCoroutineScope.kt new file mode 100644 index 0000000..d7c262f --- /dev/null +++ b/core/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/coroutines/AppCoroutineScope.kt @@ -0,0 +1,38 @@ +package com.timilehinaregbesola.mathalarm.coroutines + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlin.coroutines.CoroutineContext + +/** + * Application-scoped coroutine scope for background operations. + * + * This replaces GlobalScope usage with a properly managed scope that: + * - Uses SupervisorJob so child failures don't cancel siblings + * - Uses Default dispatcher for CPU-intensive work + * - Can be properly cancelled if needed during app shutdown + * + */ +class AppCoroutineScope : CoroutineScope { + + private val job = SupervisorJob() + + override val coroutineContext: CoroutineContext + get() = Dispatchers.Default + job + + /** + * Launch a coroutine in the application scope. + */ + fun launch(block: suspend CoroutineScope.() -> Unit) = + (this as CoroutineScope).launch(block = block) + + /** + * Cancel all coroutines in this scope. + * Should be called during application shutdown if needed. + */ + fun cancel() { + job.cancel() + } +} diff --git a/core/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/interactors/AlarmInteractor.kt b/core/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/interactors/AlarmInteractor.kt index fc90c20..c1c85ef 100644 --- a/core/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/interactors/AlarmInteractor.kt +++ b/core/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/interactors/AlarmInteractor.kt @@ -11,9 +11,9 @@ interface AlarmInteractor { * Schedules a new alarm. * * @param alarm the alarm - * @param reschedule whether repeating + * @param timeInMillis the time to schedule the alarm in milliseconds */ - fun schedule(alarm: Alarm, reschedule: Boolean): Boolean + fun schedule(alarm: Alarm, timeInMillis: Long) /** * Cancels an alarm. @@ -21,4 +21,13 @@ interface AlarmInteractor { * @param alarm the alarm */ fun cancel(alarm: Alarm) + + /** + * Updates an existing alarm. + * On Android, the notification will trigger a BroadcastReceiver which will always get the + * most recent Alarm data from the database, so this may be a no-op on some platforms. + * + * @param alarm the alarm to be updated + */ + fun update(alarm: Alarm) } diff --git a/core/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/provider/AlarmTimeCalculator.kt b/core/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/provider/AlarmTimeCalculator.kt new file mode 100644 index 0000000..794b14f --- /dev/null +++ b/core/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/provider/AlarmTimeCalculator.kt @@ -0,0 +1,37 @@ +package com.timilehinaregbesola.mathalarm.provider + +import com.timilehinaregbesola.mathalarm.domain.model.Alarm + +/** + * Provides alarm time calculations, respecting Inversion of Control. + */ +interface AlarmTimeCalculator { + + /** + * Calculates all trigger times for an alarm based on its repeat configuration. + * + * For non-repeating alarms, returns a single time. + * For repeating alarms, returns times for each enabled day. + * + * @param alarm the alarm to calculate times for + * @return list of times in milliseconds when the alarm should trigger + */ + fun calculateAlarmTimes(alarm: Alarm): List + + /** + * Calculates the next single trigger time for an alarm. + * Useful for snooze and one-time scheduling. + * + * @param alarm the alarm + * @return the next trigger time in milliseconds, or null if no valid time + */ + fun calculateNextAlarmTime(alarm: Alarm): Long? + + /** + * Checks if the given time is in the future. + * + * @param timeInMillis the time to check + * @return true if the time is in the future + */ + fun isInFuture(timeInMillis: Long): Boolean +} diff --git a/core/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/provider/AlarmTimeCalculatorImpl.kt b/core/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/provider/AlarmTimeCalculatorImpl.kt new file mode 100644 index 0000000..198691b --- /dev/null +++ b/core/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/provider/AlarmTimeCalculatorImpl.kt @@ -0,0 +1,127 @@ +package com.timilehinaregbesola.mathalarm.provider + +import com.timilehinaregbesola.mathalarm.domain.model.Alarm +import kotlinx.datetime.DatePeriod +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.LocalTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.plus +import kotlinx.datetime.toInstant +import kotlin.time.ExperimentalTime + +@OptIn(ExperimentalTime::class) +class AlarmTimeCalculatorImpl( + private val dateTimeProvider: DateTimeProvider = DateTimeProviderImpl() +) : AlarmTimeCalculator { + + companion object { + private const val SUN = 0 + private const val SAT = 6 + } + + override fun calculateAlarmTimes(alarm: Alarm): List { + val tz = TimeZone.currentSystemDefault() + val localNow = dateTimeProvider.getCurrentDateTime() + val nowInstant = localNow.toInstant(tz) + val todayDate = localNow.date + val currentDayIndex = todayDate.dayOfWeek.toIndex() + + val times = mutableListOf() + + // Determine which days to schedule + val repeatDays = if (alarm.repeatDays == "FFFFFFF") { + // No repeat days set - determine the closest possible date + val alarmTimeToday = LocalDateTime( + date = todayDate, + time = LocalTime(alarm.hour, alarm.minute, 0) + ) + val alarmInstantToday = alarmTimeToday.toInstant(tz) + + val dayIndex = if (alarmInstantToday > nowInstant) { + currentDayIndex // Set for today + } else { + // Set for tomorrow + if (currentDayIndex == SAT) SUN else currentDayIndex + 1 + } + + "FFFFFFF".toCharArray().apply { + this[dayIndex] = 'T' + }.concatToString() + } else { + alarm.repeatDays + } + + for (i in SUN..SAT) { + if (repeatDays.getOrNull(i) == 'T') { + val targetDate = calculateTargetDate( + currentDayIndex = currentDayIndex, + targetDayIndex = i, + todayDate = todayDate, + alarmHour = alarm.hour, + alarmMinute = alarm.minute, + nowInstant = nowInstant, + tz = tz + ) + + val targetDateTime = LocalDateTime( + date = targetDate, + time = LocalTime(alarm.hour, alarm.minute, 0) + ) + + times.add(targetDateTime.toInstant(tz).toEpochMilliseconds()) + } + } + + return times + } + + override fun calculateNextAlarmTime(alarm: Alarm): Long? { + val times = calculateAlarmTimes(alarm) + return times.minOrNull() + } + + @OptIn(ExperimentalTime::class) + override fun isInFuture(timeInMillis: Long): Boolean { + val tz = TimeZone.currentSystemDefault() + val currentTime = dateTimeProvider.getCurrentDateTime().toInstant(tz).toEpochMilliseconds() + return timeInMillis > currentTime + } + + private fun calculateTargetDate( + currentDayIndex: Int, + targetDayIndex: Int, + todayDate: kotlinx.datetime.LocalDate, + alarmHour: Int, + alarmMinute: Int, + nowInstant: kotlin.time.Instant, + tz: TimeZone + ): kotlinx.datetime.LocalDate { + val alarmTimeToday = LocalDateTime( + date = todayDate, + time = LocalTime(alarmHour, alarmMinute, 0) + ) + val alarmInstantToday = alarmTimeToday.toInstant(tz) + val isPastToday = alarmInstantToday < nowInstant + + val daysUntilAlarm = if (currentDayIndex > targetDayIndex || (currentDayIndex == targetDayIndex && isPastToday)) { + // Schedule for next week + SAT - currentDayIndex + 1 + targetDayIndex + } else { + // Schedule for this week + targetDayIndex - currentDayIndex + } + + return todayDate.plus(DatePeriod(days = daysUntilAlarm)) + } + + private fun DayOfWeek.toIndex(): Int = when (this) { + DayOfWeek.SUNDAY -> 0 + DayOfWeek.MONDAY -> 1 + DayOfWeek.TUESDAY -> 2 + DayOfWeek.WEDNESDAY -> 3 + DayOfWeek.THURSDAY -> 4 + DayOfWeek.FRIDAY -> 5 + DayOfWeek.SATURDAY -> 6 + } +} diff --git a/core/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/usecases/RescheduleFutureAlarms.kt b/core/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/usecases/RescheduleFutureAlarms.kt index 521ff55..ee256c3 100644 --- a/core/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/usecases/RescheduleFutureAlarms.kt +++ b/core/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/usecases/RescheduleFutureAlarms.kt @@ -3,27 +3,67 @@ package com.timilehinaregbesola.mathalarm.usecases import com.timilehinaregbesola.mathalarm.data.AlarmRepository import com.timilehinaregbesola.mathalarm.domain.model.Alarm import com.timilehinaregbesola.mathalarm.interactors.AlarmInteractor +import com.timilehinaregbesola.mathalarm.provider.AlarmTimeCalculator +import kotlinx.coroutines.flow.first /** * Use case to reschedule tasks scheduled in the future or missing repeating. */ class RescheduleFutureAlarms( private val alarmRepository: AlarmRepository, - private val alarmInteractor: AlarmInteractor + private val alarmInteractor: AlarmInteractor, + private val alarmTimeCalculator: AlarmTimeCalculator, + private val scheduleNextAlarm: ScheduleNextAlarm ) { /** - * Reschedule scheduled and misses repeating tasks. + * Reschedule scheduled and missed repeating alarms. + * + * This separates handling into two categories: + * 1. Future alarms - alarms with next trigger time in the future + * 2. Missed repeating alarms - repeating alarms whose time has passed */ suspend operator fun invoke() { - alarmRepository.getSavedAlarms().collect { list -> - val futureAlarms = list.filter { it.isOn } - futureAlarms.forEach { rescheduleFutureAlarm(it) } - } + val activeAlarms = alarmRepository.getSavedAlarms().first().filter { it.isOn } + + val futureAlarms = activeAlarms.filter { isFutureAlarm(it) } + val missedRepeating = activeAlarms.filter { isMissedRepeating(it) } + + futureAlarms.forEach { rescheduleFutureAlarm(it) } + missedRepeating.forEach { rescheduleRepeatingAlarm(it) } } + /** + * Checks if the alarm's next trigger time is in the future. + */ + private fun isFutureAlarm(alarm: Alarm): Boolean { + val nextTime = alarmTimeCalculator.calculateNextAlarmTime(alarm) ?: return false + return alarmTimeCalculator.isInFuture(nextTime) + } + + /** + * Checks if this is a repeating alarm whose time has already passed. + */ + private fun isMissedRepeating(alarm: Alarm): Boolean { + if (!alarm.repeat) return false + val nextTime = alarmTimeCalculator.calculateNextAlarmTime(alarm) ?: return false + return !alarmTimeCalculator.isInFuture(nextTime) + } + + /** + * Reschedule a future alarm by calculating its times and scheduling each. + */ private fun rescheduleFutureAlarm(alarm: Alarm) { - alarmInteractor.schedule(alarm, alarm.repeat) -// logger.debug("Task '${alarm.title} rescheduled to '${alarm.dueDate}") + val alarmTimes = alarmTimeCalculator.calculateAlarmTimes(alarm) + alarmTimes.forEach { timeInMillis -> + alarmInteractor.schedule(alarm, timeInMillis) + } + } + + /** + * Reschedule a missed repeating alarm by advancing to its next occurrence. + */ + private fun rescheduleRepeatingAlarm(alarm: Alarm) { + scheduleNextAlarm(alarm) } } diff --git a/core/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/usecases/ScheduleAlarm.kt b/core/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/usecases/ScheduleAlarm.kt index 8cbdb55..b6ee0b8 100644 --- a/core/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/usecases/ScheduleAlarm.kt +++ b/core/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/usecases/ScheduleAlarm.kt @@ -3,28 +3,48 @@ package com.timilehinaregbesola.mathalarm.usecases import com.timilehinaregbesola.mathalarm.data.AlarmRepository import com.timilehinaregbesola.mathalarm.domain.model.Alarm import com.timilehinaregbesola.mathalarm.interactors.AlarmInteractor +import com.timilehinaregbesola.mathalarm.provider.AlarmTimeCalculator class ScheduleAlarm( private val alarmRepository: AlarmRepository, - private val alarmInteractor: AlarmInteractor + private val alarmInteractor: AlarmInteractor, + private val alarmTimeCalculator: AlarmTimeCalculator ) { /** * Schedules a new alarm. * * @param alarm the alarm - * @param reschedule whether alarm should reschedule + * @param reschedule whether alarm should reschedule (cancels existing before scheduling) */ suspend operator fun invoke(alarm: Alarm, reschedule: Boolean) { val foundAlarm = if (alarm.alarmId == 0L) { alarmRepository.getLatestAlarm() } else { - val found = alarmRepository.findAlarm(alarm.alarmId) ?: return - found + alarmRepository.findAlarm(alarm.alarmId) ?: return + } ?: return + + // Cancel existing alarm if rescheduling + if (reschedule) { + alarmInteractor.cancel(foundAlarm) + } + + // Calculate all alarm times and schedule each one + val alarmTimes = alarmTimeCalculator.calculateAlarmTimes(foundAlarm) + + if (alarmTimes.isEmpty()) { + // No valid times to schedule + val updatedAlarm = foundAlarm.copy(isOn = false) + alarmRepository.updateAlarm(updatedAlarm) + return } -// val foundAlarm = alarmRepository.findAlarm(alarm.alarmId) ?: return - val isOn = alarmInteractor.schedule(foundAlarm!!, reschedule) - val updatedAlarm = foundAlarm.copy(isOn = isOn) + + // Schedule each alarm time + alarmTimes.forEach { timeInMillis -> + alarmInteractor.schedule(foundAlarm, timeInMillis) + } + + val updatedAlarm = foundAlarm.copy(isOn = true) alarmRepository.updateAlarm(updatedAlarm) alarmRepository.getAlarms() } diff --git a/core/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/usecases/ScheduleNextAlarm.kt b/core/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/usecases/ScheduleNextAlarm.kt index b0e163a..8edf452 100644 --- a/core/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/usecases/ScheduleNextAlarm.kt +++ b/core/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/usecases/ScheduleNextAlarm.kt @@ -2,12 +2,14 @@ package com.timilehinaregbesola.mathalarm.usecases import com.timilehinaregbesola.mathalarm.domain.model.Alarm import com.timilehinaregbesola.mathalarm.interactors.AlarmInteractor +import com.timilehinaregbesola.mathalarm.provider.AlarmTimeCalculator /** * Schedules the next alarm entry or the missing ones in a repeating alarm. */ class ScheduleNextAlarm( private val alarmInteractor: AlarmInteractor, + private val alarmTimeCalculator: AlarmTimeCalculator, ) { /** @@ -19,7 +21,9 @@ class ScheduleNextAlarm( require(alarm.repeat) { "Alarm is not repeating" } require(alarm.isOn) { "Alarm is not on" } - alarmInteractor.schedule(alarm, alarm.repeat) -// logger.debug("ScheduleNextAlarm = Task = '${alarm.title}' at ${alarm.dueDate.time} ") + val alarmTimes = alarmTimeCalculator.calculateAlarmTimes(alarm) + alarmTimes.forEach { timeInMillis -> + alarmInteractor.schedule(alarm, timeInMillis) + } } } diff --git a/core/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/usecases/SnoozeAlarm.kt b/core/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/usecases/SnoozeAlarm.kt index cb427ed..5e810d0 100644 --- a/core/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/usecases/SnoozeAlarm.kt +++ b/core/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/usecases/SnoozeAlarm.kt @@ -35,11 +35,15 @@ class SnoozeAlarm( val alarm = alarmRepository.findAlarm(alarmId) ?: return val snoozedTime = getSnoozedDateTime(dateTimeProvider.getCurrentDateTime(), minutes) - alarm.apply { - hour = snoozedTime.hour + val updatedAlarm = alarm.copy( + hour = snoozedTime.hour, minute = snoozedTime.minute - } - alarmInteractor.schedule(alarm, alarm.repeat) + ) + + // Calculate snooze time in millis and schedule + val tz = TimeZone.currentSystemDefault() + val timeInMillis = snoozedTime.toInstant(tz).toEpochMilliseconds() + alarmInteractor.schedule(updatedAlarm, timeInMillis) notificationInteractor.dismiss(alarmId) } diff --git a/core/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/fake/AlarmInteractorFake.kt b/core/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/fake/AlarmInteractorFake.kt index 50b3fd0..36a8132 100644 --- a/core/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/fake/AlarmInteractorFake.kt +++ b/core/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/fake/AlarmInteractorFake.kt @@ -5,38 +5,40 @@ import com.timilehinaregbesola.mathalarm.interactors.AlarmInteractor import kotlinx.datetime.LocalDateTime import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime -import kotlin.time.Clock -import kotlin.time.ExperimentalTime +import kotlin.time.Instant class AlarmInteractorFake : AlarmInteractor { private val alarmMap: MutableMap = mutableMapOf() - override fun schedule(alarm: Alarm, reschedule: Boolean): Boolean { - alarmMap[alarm.alarmId] = FakeData(reschedule, getAlarmDateTime(alarm)) - return true + override fun schedule(alarm: Alarm, timeInMillis: Long) { + val instant = Instant.fromEpochMilliseconds(timeInMillis) + val dateTime = instant.toLocalDateTime(TimeZone.currentSystemDefault()) + alarmMap[alarm.alarmId] = FakeData(timeInMillis, dateTime) } override fun cancel(alarm: Alarm) { alarmMap.remove(alarm.alarmId) } + override fun update(alarm: Alarm) { + // For testing, update just marks the alarm as updated + val existing = alarmMap[alarm.alarmId] + if (existing != null) { + alarmMap[alarm.alarmId] = existing.copy(updated = true) + } + } + fun isAlarmScheduled(alarm: Alarm): Boolean = alarmMap.contains(alarm.alarmId) fun clear() = alarmMap.clear() - fun getAlarmTime(alarmId: Long): LocalDateTime? = - alarmMap[alarmId]?.time - - @OptIn(ExperimentalTime::class) - private fun getAlarmDateTime(alarm: Alarm): LocalDateTime { - val nowInstant = Clock.System.now() - val tz = TimeZone.currentSystemDefault() - val today = nowInstant.toLocalDateTime(tz).date - return LocalDateTime( - date = today, - time = kotlinx.datetime.LocalTime(alarm.hour, alarm.minute, 0) - ) - } + fun getAlarmTimeMillis(alarmId: Long): Long? = alarmMap[alarmId]?.timeInMillis + + fun getScheduledAlarms(): Map = alarmMap.toMap() } -data class FakeData(val reschedule: Boolean, val time: LocalDateTime) +data class FakeData( + val timeInMillis: Long, + val dateTime: LocalDateTime, + val updated: Boolean = false +) diff --git a/core/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/fake/AlarmTimeCalculatorFake.kt b/core/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/fake/AlarmTimeCalculatorFake.kt new file mode 100644 index 0000000..da73594 --- /dev/null +++ b/core/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/fake/AlarmTimeCalculatorFake.kt @@ -0,0 +1,35 @@ +package com.timilehinaregbesola.mathalarm.fake + +import com.timilehinaregbesola.mathalarm.domain.model.Alarm +import com.timilehinaregbesola.mathalarm.provider.AlarmTimeCalculator +import kotlin.time.Clock +import kotlin.time.ExperimentalTime + +/** + * Fake implementation of AlarmTimeCalculator for testing. + */ +@OptIn(ExperimentalTime::class) +class AlarmTimeCalculatorFake : AlarmTimeCalculator { + + private var currentTimeMillis: Long = Clock.System.now().toEpochMilliseconds() + + /** + * Set a custom current time for testing. + */ + fun setCurrentTime(timeMillis: Long) { + currentTimeMillis = timeMillis + } + + override fun calculateAlarmTimes(alarm: Alarm): List { + // Return a simple future time for testing (1 hour from "now") + return listOf(currentTimeMillis + 3600_000L) + } + + override fun calculateNextAlarmTime(alarm: Alarm): Long? { + return currentTimeMillis + 3600_000L + } + + override fun isInFuture(timeInMillis: Long): Boolean { + return timeInMillis > currentTimeMillis + } +} diff --git a/core/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/fake/DateTimeProviderFake.kt b/core/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/fake/DateTimeProviderFake.kt new file mode 100644 index 0000000..8c72e3a --- /dev/null +++ b/core/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/fake/DateTimeProviderFake.kt @@ -0,0 +1,45 @@ +package com.timilehinaregbesola.mathalarm.fake + +import com.timilehinaregbesola.mathalarm.provider.DateTimeProvider +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import kotlin.time.Clock +import kotlin.time.ExperimentalTime + +class DateTimeProviderFake : DateTimeProvider { + private var fixedDateTime: LocalDateTime? = null + + @OptIn(ExperimentalTime::class) + override fun getCurrentDateTime(): LocalDateTime { + return fixedDateTime ?: Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()) + } + + /** + * Set a fixed date/time for deterministic testing. + */ + fun setFixedDateTime(dateTime: LocalDateTime) { + fixedDateTime = dateTime + } + + /** + * Set a fixed date/time using individual components. + */ + fun setFixedDateTime( + year: Int, + month: Int, + dayOfMonth: Int, + hour: Int, + minute: Int, + second: Int = 0 + ) { + fixedDateTime = LocalDateTime(year, month, dayOfMonth, hour, minute, second) + } + + /** + * Clear the fixed time and return to using the real clock. + */ + fun clearFixedDateTime() { + fixedDateTime = null + } +} diff --git a/core/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/provider/AlarmTimeCalculatorImplTest.kt b/core/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/provider/AlarmTimeCalculatorImplTest.kt new file mode 100644 index 0000000..f5e5b86 --- /dev/null +++ b/core/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/provider/AlarmTimeCalculatorImplTest.kt @@ -0,0 +1,287 @@ +package com.timilehinaregbesola.mathalarm.provider + +import com.timilehinaregbesola.mathalarm.domain.model.Alarm +import com.timilehinaregbesola.mathalarm.fake.DateTimeProviderFake +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.Instant +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toInstant +import kotlinx.datetime.toLocalDateTime +import kotlin.test.BeforeTest +import kotlin.test.Test + +class AlarmTimeCalculatorImplTest { + + private lateinit var dateTimeProvider: DateTimeProviderFake + private lateinit var calculator: AlarmTimeCalculatorImpl + + @BeforeTest + fun setup() { + dateTimeProvider = DateTimeProviderFake() + calculator = AlarmTimeCalculatorImpl(dateTimeProvider) + } + + @Test + fun `non-repeating alarm in the future today should be scheduled for today`() { + // Wednesday, January 8, 2025 at 9:00 AM + dateTimeProvider.setFixedDateTime(2025, 1, 8, 9, 0) + + // Alarm set for 10:00 AM (1 hour in future) + val alarm = Alarm(hour = 10, minute = 0, repeatDays = "FFFFFFF") + + val times = calculator.calculateAlarmTimes(alarm) + + times shouldHaveSize 1 + val alarmDateTime = instantToLocalDateTime(times.first()) + alarmDateTime.dayOfMonth shouldBe 8 + alarmDateTime.hour shouldBe 10 + alarmDateTime.minute shouldBe 0 + } + + @Test + fun `non-repeating alarm in the past today should be scheduled for tomorrow`() { + // Wednesday, January 8, 2025 at 11:00 AM + dateTimeProvider.setFixedDateTime(2025, 1, 8, 11, 0) + + // Alarm set for 10:00 AM (already passed) + val alarm = Alarm(hour = 10, minute = 0, repeatDays = "FFFFFFF") + + val times = calculator.calculateAlarmTimes(alarm) + + times shouldHaveSize 1 + val alarmDateTime = instantToLocalDateTime(times.first()) + alarmDateTime.dayOfMonth shouldBe 9 // Tomorrow + alarmDateTime.hour shouldBe 10 + alarmDateTime.minute shouldBe 0 + } + + @Test + fun `non-repeating alarm at midnight should handle day rollover`() { + // Saturday at 11:30 PM + dateTimeProvider.setFixedDateTime(2025, 1, 11, 23, 30) + + // Alarm set for midnight (00:00) - should be tomorrow + val alarm = Alarm(hour = 0, minute = 0, repeatDays = "FFFFFFF") + + val times = calculator.calculateAlarmTimes(alarm) + + times shouldHaveSize 1 + val alarmDateTime = instantToLocalDateTime(times.first()) + alarmDateTime.dayOfMonth shouldBe 12 // Sunday + alarmDateTime.hour shouldBe 0 + alarmDateTime.minute shouldBe 0 + } + + @Test + fun `repeating alarm for weekdays only should return 5 times`() { + // Sunday, January 5, 2025 at 8:00 AM + dateTimeProvider.setFixedDateTime(2025, 1, 5, 8, 0) + + // Alarm for weekdays: Mon-Fri (indices 1-5) + // repeatDays format: "FTTTTFF" (Sun=F, Mon=T, Tue=T, Wed=T, Thu=T, Fri=F, Sat=F) + // Wait, let me check the format - indices are SUN=0, MON=1, etc. + // So weekdays Mon-Fri = indices 1,2,3,4,5 = "FTTTTTF" + val alarm = Alarm(hour = 7, minute = 0, repeat = true, repeatDays = "FTTTTTF") + + val times = calculator.calculateAlarmTimes(alarm) + + times shouldHaveSize 5 + } + + @Test + fun `repeating alarm for weekends only should return 2 times`() { + // Monday, January 6, 2025 at 8:00 AM + dateTimeProvider.setFixedDateTime(2025, 1, 6, 8, 0) + + // Alarm for weekends: Sat and Sun (indices 0 and 6) + // repeatDays = "TFFFFFT" + val alarm = Alarm(hour = 9, minute = 0, repeat = true, repeatDays = "TFFFFFT") + + val times = calculator.calculateAlarmTimes(alarm) + + times shouldHaveSize 2 + } + + @Test + fun `repeating alarm for all days should return 7 times`() { + // Monday, January 6, 2025 at 8:00 AM + dateTimeProvider.setFixedDateTime(2025, 1, 6, 8, 0) + + val alarm = Alarm(hour = 6, minute = 30, repeat = true, repeatDays = "TTTTTTT") + + val times = calculator.calculateAlarmTimes(alarm) + + times shouldHaveSize 7 + } + + @Test + fun `repeating alarm for single day in future this week`() { + // Monday, January 6, 2025 at 8:00 AM + dateTimeProvider.setFixedDateTime(2025, 1, 6, 8, 0) + + // Alarm only on Wednesday (index 3) + val alarm = Alarm(hour = 7, minute = 0, repeat = true, repeatDays = "FFFTFFF") + + val times = calculator.calculateAlarmTimes(alarm) + + times shouldHaveSize 1 + val alarmDateTime = instantToLocalDateTime(times.first()) + alarmDateTime.dayOfWeek shouldBe DayOfWeek.WEDNESDAY + alarmDateTime.dayOfMonth shouldBe 8 // Wednesday Jan 8 + } + + @Test + fun `repeating alarm for single day already passed this week should schedule next week`() { + // Wednesday, January 8, 2025 at 8:00 AM + dateTimeProvider.setFixedDateTime(2025, 1, 8, 8, 0) + + // Alarm only on Monday (index 1) - already passed this week + val alarm = Alarm(hour = 7, minute = 0, repeat = true, repeatDays = "FTFFFFF") + + val times = calculator.calculateAlarmTimes(alarm) + + times shouldHaveSize 1 + val alarmDateTime = instantToLocalDateTime(times.first()) + alarmDateTime.dayOfWeek shouldBe DayOfWeek.MONDAY + alarmDateTime.dayOfMonth shouldBe 13 // Next Monday + } + + @Test + fun `repeating alarm for today but time already passed should schedule next week`() { + // Wednesday, January 8, 2025 at 10:00 AM + dateTimeProvider.setFixedDateTime(2025, 1, 8, 10, 0) + + // Alarm only on Wednesday (index 3) at 7:00 AM - already passed + val alarm = Alarm(hour = 7, minute = 0, repeat = true, repeatDays = "FFFTFFF") + + val times = calculator.calculateAlarmTimes(alarm) + + times shouldHaveSize 1 + val alarmDateTime = instantToLocalDateTime(times.first()) + alarmDateTime.dayOfWeek shouldBe DayOfWeek.WEDNESDAY + alarmDateTime.dayOfMonth shouldBe 15 // Next Wednesday + } + + @Test + fun `repeating alarm for today in the future should schedule today`() { + // Wednesday, January 8, 2025 at 6:00 AM + dateTimeProvider.setFixedDateTime(2025, 1, 8, 6, 0) + + // Alarm only on Wednesday (index 3) at 7:00 AM - in the future + val alarm = Alarm(hour = 7, minute = 0, repeat = true, repeatDays = "FFFTFFF") + + val times = calculator.calculateAlarmTimes(alarm) + + times shouldHaveSize 1 + val alarmDateTime = instantToLocalDateTime(times.first()) + alarmDateTime.dayOfWeek shouldBe DayOfWeek.WEDNESDAY + alarmDateTime.day shouldBe 8 // Today + } + + @Test + fun `saturday to sunday rollover for non-repeating alarm`() { + // Saturday at 11:00 PM + dateTimeProvider.setFixedDateTime(2025, 1, 11, 23, 0) + + // Alarm set for 8:00 AM (already passed today, so tomorrow) + val alarm = Alarm(hour = 8, minute = 0, repeatDays = "FFFFFFF") + + val times = calculator.calculateAlarmTimes(alarm) + + times shouldHaveSize 1 + val alarmDateTime = instantToLocalDateTime(times.first()) + alarmDateTime.dayOfWeek shouldBe DayOfWeek.SUNDAY + alarmDateTime.day shouldBe 12 + } + + @Test + fun `calculateNextAlarmTime returns soonest time`() { + // Monday, January 6, 2025 at 8:00 AM + dateTimeProvider.setFixedDateTime(2025, 1, 6, 8, 0) + + // Alarm for Tuesday and Thursday + val alarm = Alarm(hour = 7, minute = 0, repeat = true, repeatDays = "FFTFTFF") + + val nextTime = calculator.calculateNextAlarmTime(alarm) + + // Should return Tuesday (soonest) + val nextDateTime = instantToLocalDateTime(nextTime!!) + nextDateTime.dayOfWeek shouldBe DayOfWeek.TUESDAY + } + + @Test + fun `isInFuture returns true for future time`() { + dateTimeProvider.setFixedDateTime(2025, 1, 8, 10, 0) + + val futureTime = LocalDateTime(2025, 1, 8, 11, 0) + .toInstant(TimeZone.currentSystemDefault()) + .toEpochMilliseconds() + + calculator.isInFuture(futureTime) shouldBe true + } + + @Test + fun `isInFuture returns false for past time`() { + dateTimeProvider.setFixedDateTime(2025, 1, 8, 10, 0) + + val pastTime = LocalDateTime(2025, 1, 8, 9, 0) + .toInstant(TimeZone.currentSystemDefault()) + .toEpochMilliseconds() + + calculator.isInFuture(pastTime) shouldBe false + } + + @Test + fun `alarm at same minute should be treated as passed`() { + // Wednesday at 7:00:30 AM + dateTimeProvider.setFixedDateTime(2025, 1, 8, 7, 0, 30) + + // Alarm at 7:00 AM - same minute but seconds past + val alarm = Alarm(hour = 7, minute = 0, repeatDays = "FFFFFFF") + + val times = calculator.calculateAlarmTimes(alarm) + + // Should schedule for tomorrow since the time has effectively passed + val alarmDateTime = instantToLocalDateTime(times.first()) + alarmDateTime.day shouldBe 9 + } + + @Test + fun `alarm crossing month boundary`() { + // January 31st at 10:00 PM + dateTimeProvider.setFixedDateTime(2025, 1, 31, 22, 0) + + // Alarm at 8:00 AM (already passed, should be February 1st) + val alarm = Alarm(hour = 8, minute = 0, repeatDays = "FFFFFFF") + + val times = calculator.calculateAlarmTimes(alarm) + + val alarmDateTime = instantToLocalDateTime(times.first()) + alarmDateTime.monthNumber shouldBe 2 + alarmDateTime.dayOfMonth shouldBe 1 + } + + @Test + fun `alarm crossing year boundary`() { + // December 31st at 10:00 PM + dateTimeProvider.setFixedDateTime(2024, 12, 31, 22, 0) + + // Alarm at 8:00 AM (already passed, should be January 1st next year) + val alarm = Alarm(hour = 8, minute = 0, repeatDays = "FFFFFFF") + + val times = calculator.calculateAlarmTimes(alarm) + + val alarmDateTime = instantToLocalDateTime(times.first()) + alarmDateTime.year shouldBe 2025 + alarmDateTime.monthNumber shouldBe 1 + alarmDateTime.dayOfMonth shouldBe 1 + } + + private fun instantToLocalDateTime(epochMillis: Long): LocalDateTime { + return Instant.fromEpochMilliseconds(epochMillis) + .toLocalDateTime(TimeZone.currentSystemDefault()) + } +} diff --git a/core/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/usecases/BootPersistenceTest.kt b/core/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/usecases/BootPersistenceTest.kt new file mode 100644 index 0000000..d192609 --- /dev/null +++ b/core/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/usecases/BootPersistenceTest.kt @@ -0,0 +1,292 @@ +package com.timilehinaregbesola.mathalarm.usecases + +import com.timilehinaregbesola.mathalarm.data.AlarmRepository +import com.timilehinaregbesola.mathalarm.domain.model.Alarm +import com.timilehinaregbesola.mathalarm.fake.AlarmInteractorFake +import com.timilehinaregbesola.mathalarm.fake.AlarmRepositoryFake +import com.timilehinaregbesola.mathalarm.fake.DateTimeProviderFake +import com.timilehinaregbesola.mathalarm.provider.AlarmTimeCalculatorImpl +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.time.Instant + +/** + * Integration-style tests that verify alarms are correctly rescheduled after + * boot/reboot scenarios. Uses real AlarmTimeCalculatorImpl with a fake time provider + * to simulate realistic scenarios. + */ +@ExperimentalCoroutinesApi +class BootPersistenceTest { + private lateinit var dataSource: AlarmRepositoryFake + private lateinit var alarmRepository: AlarmRepository + private lateinit var alarmInteractor: AlarmInteractorFake + private lateinit var dateTimeProvider: DateTimeProviderFake + private lateinit var alarmTimeCalculator: AlarmTimeCalculatorImpl + private lateinit var scheduleNextAlarm: ScheduleNextAlarm + private lateinit var rescheduleFutureAlarms: RescheduleFutureAlarms + private lateinit var addAlarm: AddAlarm + + @BeforeTest + fun setup() = runTest { + dataSource = AlarmRepositoryFake() + alarmRepository = AlarmRepository(dataSource) + alarmInteractor = AlarmInteractorFake() + dateTimeProvider = DateTimeProviderFake() + alarmTimeCalculator = AlarmTimeCalculatorImpl(dateTimeProvider) + scheduleNextAlarm = ScheduleNextAlarm(alarmInteractor, alarmTimeCalculator) + rescheduleFutureAlarms = RescheduleFutureAlarms( + alarmRepository, + alarmInteractor, + alarmTimeCalculator, + scheduleNextAlarm + ) + addAlarm = AddAlarm(alarmRepository) + + alarmRepository.clear() + alarmInteractor.clear() + } + + @Test + fun `after reboot - future one-time alarms are rescheduled`() = runTest { + // Scenario: User has a one-time alarm set for 7:00 AM tomorrow + // Device reboots at 10:00 PM today + // After reboot, the alarm should be rescheduled + + // Set current time to Wednesday 10:00 PM + dateTimeProvider.setFixedDateTime(2025, 1, 8, 22, 0) + + // Alarm for 7:00 AM (tomorrow since time is 10 PM) + val alarm = Alarm( + alarmId = 1, + hour = 7, + minute = 0, + repeatDays = "FFFFFFF", // Non-repeating + isOn = true, + isSaved = true, + title = "Wake up" + ) + addAlarm(alarm) + + // Simulate boot - reschedule alarms + rescheduleFutureAlarms() + + // Verify alarm is scheduled + alarmInteractor.isAlarmScheduled(alarm) shouldBe true + + // Verify it's scheduled for tomorrow (Thursday) + val scheduledTime = alarmInteractor.getAlarmTimeMillis(alarm.alarmId) + val scheduledDateTime = instantToLocalDateTime(scheduledTime!!) + scheduledDateTime.day shouldBe 9 // Thursday + scheduledDateTime.hour shouldBe 7 + scheduledDateTime.minute shouldBe 0 + } + + @Test + fun `after reboot - repeating weekday alarms are rescheduled`() = runTest { + // Set current time to Sunday 8:00 AM + dateTimeProvider.setFixedDateTime(2025, 1, 5, 8, 0) + + // Weekday alarm at 6:30 AM (Mon-Fri) + val alarm = Alarm( + alarmId = 2, + hour = 6, + minute = 30, + repeat = true, + repeatDays = "FTTTTTF", // Mon-Fri (indices 1-5) + isOn = true, + isSaved = true, + title = "Workday alarm" + ) + addAlarm(alarm) + + // Simulate boot + rescheduleFutureAlarms() + + // Alarm should be scheduled (the actual scheduling creates entries for all enabled days, + // but the fake only stores the last scheduled time. The important thing is that + // the alarm IS scheduled) + alarmInteractor.isAlarmScheduled(alarm) shouldBe true + + // Verify scheduled time is at the correct hour/minute + val scheduledTime = alarmInteractor.getAlarmTimeMillis(alarm.alarmId) + val scheduledDateTime = instantToLocalDateTime(scheduledTime!!) + scheduledDateTime.hour shouldBe 6 + scheduledDateTime.minute shouldBe 30 + } + + @Test + fun `after reboot - disabled alarms are NOT rescheduled`() = runTest { + dateTimeProvider.setFixedDateTime(2025, 1, 8, 8, 0) + + val enabledAlarm = Alarm( + alarmId = 1, + hour = 7, + minute = 0, + isOn = true, + isSaved = true + ) + val disabledAlarm = Alarm( + alarmId = 2, + hour = 8, + minute = 0, + isOn = false, // Disabled + isSaved = true + ) + addAlarm(enabledAlarm) + addAlarm(disabledAlarm) + + rescheduleFutureAlarms() + + alarmInteractor.isAlarmScheduled(enabledAlarm) shouldBe true + alarmInteractor.isAlarmScheduled(disabledAlarm) shouldBe false + } + + @Test + fun `after reboot - daily repeating alarm is rescheduled`() = runTest { + // Scenario: User had a daily 7:00 AM alarm + // Device reboots at 9:00 AM (after the alarm time passed) + // The alarm should be rescheduled for future days + + dateTimeProvider.setFixedDateTime(2025, 1, 8, 9, 0) // Wednesday 9 AM + + val repeatingAlarm = Alarm( + alarmId = 3, + hour = 7, + minute = 0, + repeat = true, + repeatDays = "TTTTTTT", // Daily + isOn = true, + isSaved = true + ) + addAlarm(repeatingAlarm) + + rescheduleFutureAlarms() + + // Should be scheduled + alarmInteractor.isAlarmScheduled(repeatingAlarm) shouldBe true + + // Verify time is correct (7:00 AM) + val scheduledTime = alarmInteractor.getAlarmTimeMillis(repeatingAlarm.alarmId) + val scheduledDateTime = instantToLocalDateTime(scheduledTime!!) + scheduledDateTime.hour shouldBe 7 + scheduledDateTime.minute shouldBe 0 + } + + @Test + fun `after reboot with multiple alarms - all active alarms rescheduled correctly`() = runTest { + dateTimeProvider.setFixedDateTime(2025, 1, 8, 12, 0) // Wednesday noon + + val morningAlarm = Alarm(alarmId = 1, hour = 6, minute = 0, isOn = true, isSaved = true) + val eveningAlarm = Alarm(alarmId = 2, hour = 18, minute = 0, isOn = true, isSaved = true) + val disabledAlarm = Alarm(alarmId = 3, hour = 8, minute = 0, isOn = false, isSaved = true) + val weekendAlarm = Alarm( + alarmId = 4, + hour = 10, + minute = 0, + repeat = true, + repeatDays = "TFFFFFT", // Sat & Sun + isOn = true, + isSaved = true + ) + + addAlarm(morningAlarm) + addAlarm(eveningAlarm) + addAlarm(disabledAlarm) + addAlarm(weekendAlarm) + + rescheduleFutureAlarms() + + // Morning alarm: 6 AM already passed today, should be tomorrow + alarmInteractor.isAlarmScheduled(morningAlarm) shouldBe true + val morningTime = alarmInteractor.getAlarmTimeMillis(morningAlarm.alarmId) + instantToLocalDateTime(morningTime!!).dayOfMonth shouldBe 9 + + // Evening alarm: 6 PM is in the future, should be today + alarmInteractor.isAlarmScheduled(eveningAlarm) shouldBe true + val eveningTime = alarmInteractor.getAlarmTimeMillis(eveningAlarm.alarmId) + instantToLocalDateTime(eveningTime!!).dayOfMonth shouldBe 8 + instantToLocalDateTime(eveningTime).hour shouldBe 18 + + // Disabled alarm should not be scheduled + alarmInteractor.isAlarmScheduled(disabledAlarm) shouldBe false + + // Weekend alarm should be scheduled for Saturday (Jan 11) + alarmInteractor.isAlarmScheduled(weekendAlarm) shouldBe true + val weekendTime = alarmInteractor.getAlarmTimeMillis(weekendAlarm.alarmId) + instantToLocalDateTime(weekendTime!!).dayOfWeek shouldBe DayOfWeek.SATURDAY + } + + @Test + fun `package replaced event - alarms are rescheduled same as boot`() = runTest { + // This tests the same flow as ACTION_MY_PACKAGE_REPLACED + dateTimeProvider.setFixedDateTime(2025, 1, 8, 10, 0) + + val alarm = Alarm( + alarmId = 1, + hour = 15, + minute = 30, + isOn = true, + isSaved = true + ) + addAlarm(alarm) + + // Simulate package replacement (app updated) + rescheduleFutureAlarms() + + alarmInteractor.isAlarmScheduled(alarm) shouldBe true + val scheduledTime = alarmInteractor.getAlarmTimeMillis(alarm.alarmId) + instantToLocalDateTime(scheduledTime!!).hour shouldBe 15 + instantToLocalDateTime(scheduledTime).minute shouldBe 30 + } + + + @Test + fun `reboot at midnight - correctly handles day transition`() = runTest { + // Reboot exactly at midnight + dateTimeProvider.setFixedDateTime(2025, 1, 9, 0, 0) + + // Alarm at 00:30 (30 minutes after midnight) + val alarm = Alarm( + alarmId = 1, + hour = 0, + minute = 30, + isOn = true, + isSaved = true + ) + addAlarm(alarm) + + rescheduleFutureAlarms() + + alarmInteractor.isAlarmScheduled(alarm) shouldBe true + val scheduledTime = alarmInteractor.getAlarmTimeMillis(alarm.alarmId) + val scheduledDateTime = instantToLocalDateTime(scheduledTime!!) + scheduledDateTime.day shouldBe 9 // Same day (today) + scheduledDateTime.hour shouldBe 0 + scheduledDateTime.minute shouldBe 30 + } + + @Test + fun `no saved alarms - reschedule completes without error`() = runTest { + dateTimeProvider.setFixedDateTime(2025, 1, 8, 10, 0) + + // No alarms added + + rescheduleFutureAlarms() + + // Should complete without error, no alarms scheduled + alarmInteractor.getScheduledAlarms().size shouldBe 0 + } + + + private fun instantToLocalDateTime(epochMillis: Long): LocalDateTime { + return Instant.fromEpochMilliseconds(epochMillis) + .toLocalDateTime(TimeZone.currentSystemDefault()) + } +} diff --git a/core/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/usecases/RescheduleFutureAlarmsTest.kt b/core/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/usecases/RescheduleFutureAlarmsTest.kt index c32b5c2..8951808 100644 --- a/core/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/usecases/RescheduleFutureAlarmsTest.kt +++ b/core/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/usecases/RescheduleFutureAlarmsTest.kt @@ -4,6 +4,7 @@ import com.timilehinaregbesola.mathalarm.data.AlarmRepository import com.timilehinaregbesola.mathalarm.domain.model.Alarm import com.timilehinaregbesola.mathalarm.fake.AlarmInteractorFake import com.timilehinaregbesola.mathalarm.fake.AlarmRepositoryFake +import com.timilehinaregbesola.mathalarm.fake.AlarmTimeCalculatorFake import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import kotlin.test.BeforeTest @@ -19,10 +20,19 @@ class RescheduleFutureAlarmsTest { private val alarmRepository = AlarmRepository(dataSource) private val alarmInteractor = AlarmInteractorFake() + + private val alarmTimeCalculator = AlarmTimeCalculatorFake() private val addAlarmUseCase = AddAlarm(alarmRepository) + + private val scheduleNextAlarmUseCase = ScheduleNextAlarm(alarmInteractor, alarmTimeCalculator) - private val rescheduleFutureAlarmUseCase = RescheduleFutureAlarms(alarmRepository, alarmInteractor) + private val rescheduleFutureAlarmUseCase = RescheduleFutureAlarms( + alarmRepository, + alarmInteractor, + alarmTimeCalculator, + scheduleNextAlarmUseCase + ) @BeforeTest fun setup() = runTest { diff --git a/core/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/usecases/ScheduleAlarmTest.kt b/core/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/usecases/ScheduleAlarmTest.kt index ecf5305..705d33d 100644 --- a/core/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/usecases/ScheduleAlarmTest.kt +++ b/core/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/usecases/ScheduleAlarmTest.kt @@ -4,6 +4,7 @@ import com.timilehinaregbesola.mathalarm.data.AlarmRepository import com.timilehinaregbesola.mathalarm.domain.model.Alarm import com.timilehinaregbesola.mathalarm.fake.AlarmInteractorFake import com.timilehinaregbesola.mathalarm.fake.AlarmRepositoryFake +import com.timilehinaregbesola.mathalarm.fake.AlarmTimeCalculatorFake import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import kotlin.test.BeforeTest @@ -17,12 +18,14 @@ class ScheduleAlarmTest { private val alarmRepository = AlarmRepository(dataSource) private val alarmInteractor = AlarmInteractorFake() + + private val alarmTimeCalculator = AlarmTimeCalculatorFake() private val addAlarmUseCase = AddAlarm(alarmRepository) private val findAlarmUseCase = FindAlarm(alarmRepository) - private val scheduleAlarmUseCase = ScheduleAlarm(alarmRepository, alarmInteractor) + private val scheduleAlarmUseCase = ScheduleAlarm(alarmRepository, alarmInteractor, alarmTimeCalculator) @BeforeTest fun setup() = runTest { diff --git a/core/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/usecases/ScheduleNextAlarmTest.kt b/core/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/usecases/ScheduleNextAlarmTest.kt index 6216cc6..2b76acf 100644 --- a/core/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/usecases/ScheduleNextAlarmTest.kt +++ b/core/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/usecases/ScheduleNextAlarmTest.kt @@ -4,6 +4,7 @@ import com.timilehinaregbesola.mathalarm.data.AlarmRepository import com.timilehinaregbesola.mathalarm.domain.model.Alarm import com.timilehinaregbesola.mathalarm.fake.AlarmInteractorFake import com.timilehinaregbesola.mathalarm.fake.AlarmRepositoryFake +import com.timilehinaregbesola.mathalarm.fake.AlarmTimeCalculatorFake import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import kotlin.test.BeforeTest @@ -19,10 +20,12 @@ class ScheduleNextAlarmTest { private val alarmRepository = AlarmRepository(dataSource) private val alarmInteractor = AlarmInteractorFake() + + private val alarmTimeCalculator = AlarmTimeCalculatorFake() private val addAlarmUseCase = AddAlarm(alarmRepository) - private val scheduleNextAlarmUseCase = ScheduleNextAlarm(alarmInteractor) + private val scheduleNextAlarmUseCase = ScheduleNextAlarm(alarmInteractor, alarmTimeCalculator) private val baseAlarm = Alarm(alarmId = 2, title = "new alarm", isOn = true) diff --git a/core/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/usecases/ShowAlarmTest.kt b/core/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/usecases/ShowAlarmTest.kt index ff310be..aea7ffb 100644 --- a/core/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/usecases/ShowAlarmTest.kt +++ b/core/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/usecases/ShowAlarmTest.kt @@ -4,6 +4,7 @@ import com.timilehinaregbesola.mathalarm.data.AlarmRepository import com.timilehinaregbesola.mathalarm.domain.model.Alarm import com.timilehinaregbesola.mathalarm.fake.AlarmInteractorFake import com.timilehinaregbesola.mathalarm.fake.AlarmRepositoryFake +import com.timilehinaregbesola.mathalarm.fake.AlarmTimeCalculatorFake import com.timilehinaregbesola.mathalarm.fake.NotificationInteractorFake import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest @@ -19,12 +20,14 @@ class ShowAlarmTest { private val alarmRepository = AlarmRepository(dataSource) private val alarmInteractor = AlarmInteractorFake() + + private val alarmTimeCalculator = AlarmTimeCalculatorFake() private val notificationInteractor = NotificationInteractorFake() private val addAlarmUseCase = AddAlarm(alarmRepository) - private val scheduleNextAlarmUseCase = ScheduleNextAlarm(alarmInteractor) + private val scheduleNextAlarmUseCase = ScheduleNextAlarm(alarmInteractor, alarmTimeCalculator) private val showAlarmUseCase = ShowAlarm(alarmRepository, notificationInteractor, scheduleNextAlarmUseCase) diff --git a/core/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/usecases/SnoozeAlarmTest.kt b/core/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/usecases/SnoozeAlarmTest.kt index fa52e52..6796fc6 100644 --- a/core/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/usecases/SnoozeAlarmTest.kt +++ b/core/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/usecases/SnoozeAlarmTest.kt @@ -4,56 +4,115 @@ import com.timilehinaregbesola.mathalarm.data.AlarmRepository import com.timilehinaregbesola.mathalarm.domain.model.Alarm import com.timilehinaregbesola.mathalarm.fake.AlarmInteractorFake import com.timilehinaregbesola.mathalarm.fake.AlarmRepositoryFake +import com.timilehinaregbesola.mathalarm.fake.DateTimeProviderFake import com.timilehinaregbesola.mathalarm.fake.NotificationInteractorFake -import com.timilehinaregbesola.mathalarm.provider.DateTimeProviderImpl +import io.kotest.matchers.shouldBe import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.time.Instant @ExperimentalCoroutinesApi class SnoozeAlarmTest { - private val dataSource = AlarmRepositoryFake() - - private val alarmRepository = AlarmRepository(dataSource) - - private val alarmInteractor = AlarmInteractorFake() - - private val notificationInteractor = NotificationInteractorFake() - - private val calendarProvider = DateTimeProviderImpl() - - private val addAlarmUseCase = AddAlarm(alarmRepository) - - private val snoozeAlarmUseCase = SnoozeAlarm( - calendarProvider, notificationInteractor, alarmInteractor, alarmRepository - ) + private lateinit var dataSource: AlarmRepositoryFake + private lateinit var alarmRepository: AlarmRepository + private lateinit var alarmInteractor: AlarmInteractorFake + private lateinit var notificationInteractor: NotificationInteractorFake + private lateinit var dateTimeProvider: DateTimeProviderFake + private lateinit var addAlarmUseCase: AddAlarm + private lateinit var snoozeAlarmUseCase: SnoozeAlarm private val baseAlarm = Alarm(alarmId = 216L, title = "snooze me", isOn = true) + @BeforeTest fun setup() = runTest { + dataSource = AlarmRepositoryFake() + alarmRepository = AlarmRepository(dataSource) + alarmInteractor = AlarmInteractorFake() + notificationInteractor = NotificationInteractorFake() + dateTimeProvider = DateTimeProviderFake() + addAlarmUseCase = AddAlarm(alarmRepository) + snoozeAlarmUseCase = SnoozeAlarm( + dateTimeProvider, notificationInteractor, alarmInteractor, alarmRepository + ) + alarmRepository.clear() alarmInteractor.clear() notificationInteractor.clear() } - // Test fails sometimes - /* @Test - fun `test if alarm is snoozed`() = runTest { + @Test + fun `test if alarm is snoozed for correct time`() = runTest { + // Set fixed time: 7:00 AM + dateTimeProvider.setFixedDateTime(2025, 1, 8, 7, 0) addAlarmUseCase(baseAlarm) val snoozeMinutes = 5 snoozeAlarmUseCase(baseAlarm.alarmId, snoozeMinutes) - val calendarAssert = Calendar.getInstance().apply { - time = calendarProvider.getCurrentCalendar().time - add(Calendar.MINUTE, snoozeMinutes) - } + // Should be scheduled for 7:05 AM + val scheduledTime = alarmInteractor.getAlarmTimeMillis(baseAlarm.alarmId) + val scheduledDateTime = instantToLocalDateTime(scheduledTime!!) + + scheduledDateTime.hour shouldBe 7 + scheduledDateTime.minute shouldBe 5 + } + + @Test + fun `test if snooze correctly handles hour rollover`() = runTest { + // Set fixed time: 7:58 AM + dateTimeProvider.setFixedDateTime(2025, 1, 8, 7, 58) + addAlarmUseCase(baseAlarm) + val snoozeMinutes = 5 + + snoozeAlarmUseCase(baseAlarm.alarmId, snoozeMinutes) + + // Should be scheduled for 8:03 AM + val scheduledTime = alarmInteractor.getAlarmTimeMillis(baseAlarm.alarmId) + val scheduledDateTime = instantToLocalDateTime(scheduledTime!!) + + scheduledDateTime.hour shouldBe 8 + scheduledDateTime.minute shouldBe 3 + } + + @Test + fun `test if snooze correctly handles day rollover`() = runTest { + // Set fixed time: 11:58 PM + dateTimeProvider.setFixedDateTime(2025, 1, 8, 23, 58) + addAlarmUseCase(baseAlarm) + val snoozeMinutes = 5 + + snoozeAlarmUseCase(baseAlarm.alarmId, snoozeMinutes) + + // Should be scheduled for 12:03 AM next day + val scheduledTime = alarmInteractor.getAlarmTimeMillis(baseAlarm.alarmId) + val scheduledDateTime = instantToLocalDateTime(scheduledTime!!) + + scheduledDateTime.day shouldBe 9 + scheduledDateTime.hour shouldBe 0 + scheduledDateTime.minute shouldBe 3 + } + + @Test + fun `test if snooze with custom duration works`() = runTest { + dateTimeProvider.setFixedDateTime(2025, 1, 8, 9, 0) + addAlarmUseCase(baseAlarm) + val snoozeMinutes = 15 + + snoozeAlarmUseCase(baseAlarm.alarmId, snoozeMinutes) - val result = alarmInteractor.getAlarmTime(baseAlarm.alarmId) - Assert.assertEquals(calendarAssert.time.time, result?.time?.time) - }*/ + val scheduledTime = alarmInteractor.getAlarmTimeMillis(baseAlarm.alarmId) + val scheduledDateTime = instantToLocalDateTime(scheduledTime!!) + + scheduledDateTime.hour shouldBe 9 + scheduledDateTime.minute shouldBe 15 + } @Test fun `test if error is shown when snoozing with negative number`() = runTest { @@ -63,11 +122,36 @@ class SnoozeAlarmTest { } @Test - fun `test if notification is dismissed`() = runTest { + fun `test if error is shown when snoozing with zero minutes`() = runTest { + assertFailsWith { + snoozeAlarmUseCase(baseAlarm.alarmId, 0) + } + } + + @Test + fun `test if notification is dismissed after snooze`() = runTest { + dateTimeProvider.setFixedDateTime(2025, 1, 8, 7, 0) + addAlarmUseCase(baseAlarm) notificationInteractor.show(baseAlarm) snoozeAlarmUseCase(baseAlarm.alarmId, 5) - notificationInteractor.isNotificationShown(baseAlarm.alarmId) + assertFalse(notificationInteractor.isNotificationShown(baseAlarm.alarmId)) + } + + @Test + fun `test snooze with non-existent alarm does nothing`() = runTest { + dateTimeProvider.setFixedDateTime(2025, 1, 8, 7, 0) + // Don't add alarm to repository + + snoozeAlarmUseCase(999L, 5) + + // Should not throw, but alarm should not be scheduled + alarmInteractor.getAlarmTimeMillis(999L) shouldBe null + } + + private fun instantToLocalDateTime(epochMillis: Long): LocalDateTime { + return Instant.fromEpochMilliseconds(epochMillis) + .toLocalDateTime(TimeZone.currentSystemDefault()) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 146e580..af4e586 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -37,6 +37,7 @@ test_espresso_core = "3.7.0" test_junit = "4.13.2" test_junit_ext = "1.3.0" test_mockk = "1.14.7" +test_robolectric = "4.16" test_turbine = "1.2.1" test_kotest = "6.0.7" timber = "4.7.1" @@ -89,6 +90,7 @@ lyricist = { module = "cafe.adriel.lyricist:lyricist", version.ref = "lyricist" lyricist-processor = { module = "cafe.adriel.lyricist:lyricist-processor", version.ref = "lyricist" } material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "material3" } mockk = { module = "io.mockk:mockk", version.ref = "test_mockk" } +robolectric = { module = "org.robolectric:robolectric", version.ref = "test_robolectric" } turbine = { module = "app.cash.turbine:turbine", version.ref = "test_turbine" } kotest-assertions = { module = "io.kotest:kotest-assertions-core", version.ref = "test_kotest" } multiplatform-settings-no-arg = { module = "com.russhwolf:multiplatform-settings-no-arg", version.ref = "multiplatformSettingsNoArg" } diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index 8865cee..3448f42 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ 058557D9273AAEEB004C7B11 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 058557D8273AAEEB004C7B11 /* ContentView.swift */; }; 2152FB042600AC8F00CF470E /* iOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2152FB032600AC8F00CF470E /* iOSApp.swift */; }; ALARMKIT01273AAA24004C7B11 /* AlarmKitWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = ALARMKIT02273AAA24004C7B11 /* AlarmKitWrapper.swift */; }; + ALARMAUDIO01273AAA24004C7B /* AlarmAudioController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ALARMAUDIO02273AAA24004C7B /* AlarmAudioController.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -20,6 +21,7 @@ 7555FF7B242A565900829871 /* MathAlarm.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MathAlarm.app; sourceTree = BUILT_PRODUCTS_DIR; }; 7555FF8C242A565B00829871 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; ALARMKIT02273AAA24004C7B11 /* AlarmKitWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmKitWrapper.swift; sourceTree = ""; }; + ALARMAUDIO02273AAA24004C7B /* AlarmAudioController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmAudioController.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -36,6 +38,7 @@ 058557D7273AAEEB004C7B11 /* iosApp */ = { isa = PBXGroup; children = ( + ALARMAUDIO02273AAA24004C7B /* AlarmAudioController.swift */, ALARMKIT02273AAA24004C7B11 /* AlarmKitWrapper.swift */, 058557BA273AAA24004C7B11 /* Assets.xcassets */, 058557D8273AAEEB004C7B11 /* ContentView.swift */, @@ -160,6 +163,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + ALARMAUDIO01273AAA24004C7B /* AlarmAudioController.swift in Sources */, ALARMKIT01273AAA24004C7B11 /* AlarmKitWrapper.swift in Sources */, 2152FB042600AC8F00CF470E /* iOSApp.swift in Sources */, 058557D9273AAEEB004C7B11 /* ContentView.swift in Sources */, diff --git a/iosApp/iosApp.xcodeproj/project.xcworkspace/xcuserdata/timilehin.xcuserdatad/UserInterfaceState.xcuserstate b/iosApp/iosApp.xcodeproj/project.xcworkspace/xcuserdata/timilehin.xcuserdatad/UserInterfaceState.xcuserstate index 1d33eae..7e5c8b0 100644 Binary files a/iosApp/iosApp.xcodeproj/project.xcworkspace/xcuserdata/timilehin.xcuserdatad/UserInterfaceState.xcuserstate and b/iosApp/iosApp.xcodeproj/project.xcworkspace/xcuserdata/timilehin.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/iosApp/iosApp.xcodeproj/xcuserdata/timilehin.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/iosApp/iosApp.xcodeproj/xcuserdata/timilehin.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist index b813454..8a11e74 100644 --- a/iosApp/iosApp.xcodeproj/xcuserdata/timilehin.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist +++ b/iosApp/iosApp.xcodeproj/xcuserdata/timilehin.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist @@ -8,7 +8,7 @@ BreakpointExtensionID = "Xcode.Breakpoint.FileBreakpoint"> + + + + + + + + + + + + diff --git a/iosApp/iosApp/AlarmAudioController.swift b/iosApp/iosApp/AlarmAudioController.swift new file mode 100644 index 0000000..2d9a637 --- /dev/null +++ b/iosApp/iosApp/AlarmAudioController.swift @@ -0,0 +1,205 @@ +import Foundation +import AVFoundation +import AudioToolbox + +/// Controller for playing alarm sounds and haptic feedback +/// Used when notifications arrive (both foreground and when user taps notification) +class AlarmAudioController: NSObject, AVAudioPlayerDelegate { + + static let shared = AlarmAudioController() + + private var audioPlayer: AVAudioPlayer? + private var vibrationTimer: Timer? + private var soundTimer: Timer? + private var isPlaying = false + + /// Default alarm sound name (bundled with app) + static let defaultSoundName = "alarm_default" + + // System sound IDs that are known to work + // See: https://github.com/TUNER88/iOSSystemSoundsLibrary + private let alarmSoundID: SystemSoundID = 1005 // SMS Alert - loud and noticeable + private let alternateSoundID: SystemSoundID = 1007 // SMS Tri-tone + + private override init() { + super.init() + print("AlarmAudioController: Initialized") + } + + // MARK: - Audio Session Configuration + + private func configureAudioSession() { + do { + let session = AVAudioSession.sharedInstance() + // Use playback category to play even when device is silent/locked + // Don't mix with others - alarm should be prominent + try session.setCategory(.playback, mode: .default, options: []) + try session.setActive(true, options: .notifyOthersOnDeactivation) + print("AlarmAudioController: Audio session configured for playback") + } catch { + print("AlarmAudioController: Failed to configure audio session: \(error)") + } + } + + // MARK: - Public Interface + + /// Start playing alarm sound + /// - Parameters: + /// - soundName: Name of the sound file (without extension), empty for default + /// - vibrate: Whether to enable vibration + func startAlarm(soundName: String, vibrate: Bool) { + // Allow restarting if called again + if isPlaying { + print("AlarmAudioController: Already playing, restarting...") + stopAlarm() + } + + print("AlarmAudioController: 🔔 Starting alarm - sound='\(soundName)', vibrate=\(vibrate)") + isPlaying = true + + // Configure audio session for playback + configureAudioSession() + + // Try to play custom/bundled sound first + if !playBundledSound(named: soundName) { + // Fall back to system sounds + print("AlarmAudioController: No bundled sound, using system alert sounds") + startSystemSoundLoop() + } + + // Start vibration if enabled + if vibrate { + startVibration() + } + } + + /// Stop the alarm sound and vibration + func stopAlarm() { + print("AlarmAudioController: 🔕 Stopping alarm") + isPlaying = false + + audioPlayer?.stop() + audioPlayer = nil + + soundTimer?.invalidate() + soundTimer = nil + + stopVibration() + + // Deactivate audio session + do { + try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) + } catch { + print("AlarmAudioController: Failed to deactivate audio session: \(error)") + } + } + + /// Snooze the alarm - stops current sound, will be rescheduled + func snoozeAlarm(minutes: Int32) { + print("AlarmAudioController: Snoozing alarm for \(minutes) minutes") + stopAlarm() + } + + // MARK: - Bundled Sound Playback + + private func playBundledSound(named soundName: String) -> Bool { + let effectiveName = soundName.isEmpty ? AlarmAudioController.defaultSoundName : soundName + + // Try to find the sound file in bundle with various extensions + let extensions = ["caf", "aiff", "wav", "mp3", "m4a", "aac"] + var soundURL: URL? = nil + + for ext in extensions { + if let url = Bundle.main.url(forResource: effectiveName, withExtension: ext) { + soundURL = url + print("AlarmAudioController: Found bundled sound: \(effectiveName).\(ext)") + break + } + } + + // Also try without extension (file might have extension in name) + if soundURL == nil, let url = Bundle.main.url(forResource: effectiveName, withExtension: nil) { + soundURL = url + print("AlarmAudioController: Found bundled sound (no ext): \(effectiveName)") + } + + guard let url = soundURL else { + print("AlarmAudioController: No bundled sound found for '\(effectiveName)'") + return false + } + + do { + audioPlayer = try AVAudioPlayer(contentsOf: url) + audioPlayer?.delegate = self + audioPlayer?.numberOfLoops = -1 // Loop indefinitely + audioPlayer?.volume = 1.0 + audioPlayer?.prepareToPlay() + + if audioPlayer?.play() == true { + print("AlarmAudioController: ▶️ Playing bundled sound: \(url.lastPathComponent)") + return true + } else { + print("AlarmAudioController: Failed to start playback") + return false + } + } catch { + print("AlarmAudioController: Failed to play bundled sound: \(error)") + return false + } + } + + // MARK: - System Sound Playback (Fallback) + + private func startSystemSoundLoop() { + print("AlarmAudioController: Starting system sound loop") + playSystemSound() + + // Repeat every 1.5 seconds for alarm effect + soundTimer = Timer.scheduledTimer(withTimeInterval: 1.5, repeats: true) { [weak self] _ in + guard let self = self, self.isPlaying else { return } + self.playSystemSound() + } + } + + private func playSystemSound() { + guard isPlaying else { return } + + // Play alert sound (respects ringer volume, plays through speaker) + AudioServicesPlayAlertSound(alarmSoundID) + print("AlarmAudioController: 🔊 Playing system sound \(alarmSoundID)") + } + + // MARK: - Vibration + + private func startVibration() { + print("AlarmAudioController: Starting vibration") + + // Vibrate immediately + AudioServicesPlaySystemSound(kSystemSoundID_Vibrate) + + // Set up repeating vibration + vibrationTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in + guard self?.isPlaying == true else { return } + AudioServicesPlaySystemSound(kSystemSoundID_Vibrate) + } + } + + private func stopVibration() { + vibrationTimer?.invalidate() + vibrationTimer = nil + } + + // MARK: - AVAudioPlayerDelegate + + func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { + print("AlarmAudioController: Audio player finished (success: \(flag))") + } + + func audioPlayerDecodeErrorDidOccur(_ player: AVAudioPlayer, error: Error?) { + print("AlarmAudioController: Audio decode error: \(error?.localizedDescription ?? "unknown")") + // Fall back to system sounds + if isPlaying { + startSystemSoundLoop() + } + } +} diff --git a/iosApp/iosApp/AlarmKitWrapper.swift b/iosApp/iosApp/AlarmKitWrapper.swift index 2b762ef..1e1ec71 100644 --- a/iosApp/iosApp/AlarmKitWrapper.swift +++ b/iosApp/iosApp/AlarmKitWrapper.swift @@ -557,16 +557,63 @@ class AlarmKitWrapperImpl: NSObject { let stopIntent = StopAlarmIntent(alarmUUID: alarmUUID) let snoozeIntent: SnoozeAlarmIntent? = snoozeMinutes > 0 ? SnoozeAlarmIntent(alarmUUID: alarmUUID) : nil - // Create alarm configuration with intents - // Note: .default may not play sounds in some cases, .named("") plays default sound - let configuration = MathAlarmConfiguration( - countdownDuration: countdownDuration, - schedule: schedule, - attributes: attributes, - stopIntent: stopIntent, - secondaryIntent: snoozeIntent, - sound: .named("") - ) + // Create alarm configuration + // Note on AlarmKit sounds (from https://levelup.gitconnected.com/swiftui-alarm-app-copycat-with-alarmkit-wwdc-2025-part-2-5c3cb2194c54): + // - .default does NOT play any sound (AlarmKit bug/quirk) + // - .named("") plays the default system alarm sound + // - .named("customSound") plays a custom sound from bundle + let hasCustomSound = !soundName.isEmpty && Bundle.main.url(forResource: soundName, withExtension: "caf") != nil + + let configuration: MathAlarmConfiguration + if countdownDuration == nil { + // Traditional alarm without countdown - use .alarm() factory + print("AlarmKitWrapper: Creating traditional alarm configuration") + if hasCustomSound { + print("AlarmKitWrapper: Using custom sound: \(soundName)") + configuration = MathAlarmConfiguration.alarm( + schedule: schedule, + attributes: attributes, + stopIntent: stopIntent, + secondaryIntent: snoozeIntent, + sound: .named(soundName) + ) + } else { + // Using .named("") to get default system alarm sound + print("AlarmKitWrapper: Using default system alarm sound via .named(\"\")") + configuration = MathAlarmConfiguration.alarm( + schedule: schedule, + attributes: attributes, + stopIntent: stopIntent, + secondaryIntent: snoozeIntent, + sound: .named("") + ) + } + } else { + // Alarm with countdown/snooze - use generic initializer + print("AlarmKitWrapper: Creating alarm with countdown configuration") + if hasCustomSound { + print("AlarmKitWrapper: Using custom sound: \(soundName)") + configuration = MathAlarmConfiguration( + countdownDuration: countdownDuration, + schedule: schedule, + attributes: attributes, + stopIntent: stopIntent, + secondaryIntent: snoozeIntent, + sound: .named(soundName) + ) + } else { + // Using .named("") to get default system alarm sound + print("AlarmKitWrapper: Using default system alarm sound via .named(\"\")") + configuration = MathAlarmConfiguration( + countdownDuration: countdownDuration, + schedule: schedule, + attributes: attributes, + stopIntent: stopIntent, + secondaryIntent: snoozeIntent, + sound: .named("") + ) + } + } print("AlarmKitWrapper: Created configuration with intents, about to schedule...") // Schedule the alarm with id and configuration diff --git a/iosApp/iosApp/iOSApp.swift b/iosApp/iosApp/iOSApp.swift index db5c5ec..c3c0eef 100644 --- a/iosApp/iosApp/iOSApp.swift +++ b/iosApp/iosApp/iOSApp.swift @@ -9,9 +9,23 @@ struct iOSApp: App { @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @Environment(\.scenePhase) private var scenePhase + init() { + print("iOSApp.init: Initializing Koin early (before UI)") + MainViewControllerKt.doInitKoin() + + // Prewarm database in background so it's ready when UI needs it + MainViewControllerKt.prewarmDatabaseInBackground() + } + var body: some Scene { WindowGroup { - ContentView().ignoresSafeArea() + ContentView() + .ignoresSafeArea() + .onAppear { + // Request notification permissions after UI is shown + // This is deferred to not block startup + MainViewControllerKt.requestNotificationPermissionsDeferred() + } } .onChange(of: scenePhase) { newPhase in if newPhase == .active { @@ -186,6 +200,9 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele // Handle alarm notification - start audio and set deeplink private func handleAlarmNotification(userInfo: [AnyHashable: Any]) { + print("iOSApp: 🔔 handleAlarmNotification called") + print("iOSApp: userInfo keys = \(userInfo.keys)") + // Extract alarm data let alarmId = (userInfo["alarmId"] as? NSNumber)?.int64Value ?? 0 let hour = (userInfo["hour"] as? NSNumber)?.intValue ?? 0 @@ -196,9 +213,14 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele let title = (userInfo["title"] as? String) ?? "" let alarmTone = (userInfo["alarmTone"] as? String) ?? "" + print("iOSApp: Extracted - alarmId=\(alarmId), tone='\(alarmTone)', vibrate=\(vibrate)") + // Start alarm audio immediately when notification is tapped - print("iOSApp: Starting alarm audio - tone=\(alarmTone), vibrate=\(vibrate)") - AlarmAudioController.shared.startAlarm(soundName: alarmTone, vibrate: vibrate) + // Run on main thread to ensure audio session works correctly + print("iOSApp: 🔊 Starting AlarmAudioController...") + DispatchQueue.main.async { + AlarmAudioController.shared.startAlarm(soundName: alarmTone, vibrate: vibrate) + } // Create JSON string for the alarm - matching AlarmEntity format let vibrateStr = vibrate ? "true" : "false" @@ -216,18 +238,26 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { let userInfo = notification.request.content.userInfo - print("iOSApp: Notification arrived in foreground") + print("iOSApp: 🔔 Notification arrived in foreground!") + print("iOSApp: userInfo = \(userInfo)") // Start alarm audio immediately when notification arrives in foreground let alarmTone = (userInfo["alarmTone"] as? String) ?? "" let vibrate = (userInfo["vibrate"] as? NSNumber)?.boolValue ?? false - AlarmAudioController.shared.startAlarm(soundName: alarmTone, vibrate: vibrate) + print("iOSApp: Starting AlarmAudioController - tone='\(alarmTone)', vibrate=\(vibrate)") + + // Run on main thread to ensure audio session works + DispatchQueue.main.async { + AlarmAudioController.shared.startAlarm(soundName: alarmTone, vibrate: vibrate) + } // Also set the deeplink so the UI navigates to MathScreen handleAlarmNotification(userInfo: userInfo) - // Show banner and badge (sound is handled by our audio player) - completionHandler([.banner, .badge]) + // Show banner, badge, AND sound (sound as backup in case AlarmAudioController fails) + // Our AlarmAudioController provides the full looping alarm, but notification sound + // gives us at least something if that fails + completionHandler([.banner, .badge, .sound]) } }