diff --git a/.github/actions/decode-secrets/action.yml b/.github/actions/decode-secrets/action.yml new file mode 100644 index 0000000..469c825 --- /dev/null +++ b/.github/actions/decode-secrets/action.yml @@ -0,0 +1,21 @@ +name: "Decode Secrets" +description: "Decode keystores and google-services.json" +inputs: + DEBUG_KEY_BASE_64: + required: true + DEBUG_KEY_PROPERTIES: + required: true + GOOGLE_SERVICES_JSON: + required: true + RELEASE_KEY_BASE_64: + required: true +runs: + using: "composite" + steps: + - run: | + echo "${{ inputs.RELEASE_KEY_BASE_64 }}" | base64 -d > release.jks + mkdir -p app/keystore/debug + echo "${{ inputs.DEBUG_KEY_BASE_64 }}" | base64 -d > app/keystore/debug/debug.jks + echo "${{ inputs.DEBUG_KEY_PROPERTIES }}" | base64 -d > app/keystore/debug/debug.properties + echo "${{ inputs.GOOGLE_SERVICES_JSON }}" | base64 -d > app/google-services.json + shell: bash diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a51942e..7aa339c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,94 +1,178 @@ -name: Build, run static analysis, run tests, generate release APK, AAB +name: Open SMS Forwarder CI on: push: - branches: - - main - - develop + branches: [ main, develop ] pull_request: - branches: - - main - - develop + branches: [ main, develop ] env: RELEASE_KEYSTORE_PASSWORD: ${{ secrets.RELEASE_KEYSTORE_PASSWORD }} RELEASE_KEYSTORE_ALIAS: ${{ secrets.RELEASE_KEYSTORE_ALIAS }} RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }} - CLIENT_ID: ${{ secrets.CLIENT_ID }} CLIENT_SECRET: ${{ secrets.CLIENT_SECRET }} REDIRECT_URI: ${{ secrets.REDIRECT_URI }} FEEDBACK_DB_PATH: ${{ secrets.FEEDBACK_DB_PATH }} +defaults: + run: + shell: bash + jobs: - build: + prepare: + name: Setup & Cache runs-on: ubuntu-latest - steps: - - name: Checking out branch + - name: Checkout uses: actions/checkout@v4 - - name: Setup Java (JDK) + - name: Setup JDK uses: actions/setup-java@v4 with: distribution: 'oracle' java-version: '17' - - name: Grant execute permission for gradlew - run: chmod +x gradlew + - name: Cache Gradle + uses: actions/cache@v3 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + .gradle + key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: gradle-${{ runner.os }}- + + static-analysis: + name: Static Analysis + runs-on: ubuntu-latest + needs: prepare + steps: + - uses: actions/checkout@v4 + - run: ./gradlew detekt --build-cache --parallel - - name: Run static code analysis - run: ./gradlew detekt + unit-tests: + name: Unit Tests + runs-on: ubuntu-latest + needs: prepare + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/decode-secrets + with: + DEBUG_KEY_BASE_64: ${{ secrets.DEBUG_KEY_BASE_64 }} + DEBUG_KEY_PROPERTIES: ${{ secrets.DEBUG_KEY_PROPERTIES }} + GOOGLE_SERVICES_JSON: ${{ secrets.GOOGLE_SERVICES_JSON }} + - run: ./gradlew testDebugUnitTest --build-cache --parallel - - name: Decode Release Keystore - env: - ENCODED_RELEASE_STRING: ${{ secrets.RELEASE_KEY_BASE_64 }} + instrumentation-tests: + name: Instrumentation Tests + runs-on: ubuntu-latest + env: + ANDROID_AVD_HOME: ${{ github.workspace }}/.android/avd + if: github.ref == 'refs/heads/main' || github.event.pull_request.base.ref == 'main' + needs: prepare + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/decode-secrets + with: + DEBUG_KEY_BASE_64: ${{ secrets.DEBUG_KEY_BASE_64 }} + DEBUG_KEY_PROPERTIES: ${{ secrets.DEBUG_KEY_PROPERTIES }} + GOOGLE_SERVICES_JSON: ${{ secrets.GOOGLE_SERVICES_JSON }} + + - name: Setup Android SDK + uses: android-actions/setup-android@v3 + + - name: Install system image + run: sdkmanager "system-images;android-29;google_apis;x86" + + - name: Create AVD run: | - echo $ENCODED_RELEASE_STRING > release-keystore-b64.txt - base64 -di release-keystore-b64.txt > release.jks + mkdir -p $ANDROID_AVD_HOME + echo "no" | avdmanager create avd -n test -k "system-images;android-29;google_apis;x86" --device "pixel" + avdmanager list avd + ls -l $ANDROID_AVD_HOME - - name: Decode Debug Keystore - env: - ENCODED_DEBUG_STRING: ${{ secrets.DEBUG_KEY_BASE_64 }} - ENCODED_DEBUG_PROPERTIES: ${{ secrets.DEBUG_KEY_PROPERTIES }} + - name: Start Emulator run: | - echo $ENCODED_DEBUG_STRING > debug-keystore-b64.txt - base64 -di debug-keystore-b64.txt > app/keystore/debug/debug.jks - echo $ENCODED_DEBUG_PROPERTIES > debug-key-properties-b64.txt - base64 -di debug-key-properties-b64.txt > app/keystore/debug/debug.properties + $ANDROID_HOME/emulator/emulator -avd test -accel off -no-snapshot-save -no-window -no-audio -gpu swiftshader_indirect -no-boot-anim & + echo $! > emulator.pid - - name: Load Google Service file - env: - DATA: ${{ secrets.GOOGLE_SERVICES_JSON }} - run: echo $DATA | base64 -di > app/google-services.json + - name: Wait for Emulator to Boot + run: | + echo "Waiting for emulator to start..." + adb wait-for-device + + boot_completed="" + until [ "$boot_completed" = "1" ]; do + sleep 5 + boot_completed=$(adb shell getprop sys.boot_completed | tr -d '\r') + echo "Waiting... boot_completed=$boot_completed" + done + + until adb shell pm list packages > /dev/null 2>&1; do + echo "Waiting for package manager to become available..." + sleep 5 + done + + api_level="" + until [ -n "$api_level" ] && [ "$api_level" != "unknown" ]; do + sleep 5 + api_level=$(adb shell getprop ro.build.version.sdk 2>/dev/null | tr -d '\r') + echo "Waiting for emulator to report API level... api_level=$api_level" + done + + echo "Emulator fully ready with API level $api_level" + + - name: Run Instrumentation Tests + run: ./gradlew connectedCheck --build-cache --parallel + + - name: Kill Emulator + if: always() + run: | + if [ -f emulator.pid ]; then + kill $(cat emulator.pid) || true + fi - - name: Build - run: ./gradlew build + build-release: + name: Build APK & AAB + runs-on: ubuntu-latest + needs: [ static-analysis, unit-tests ] + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: ./.github/actions/decode-secrets + with: + RELEASE_KEY_BASE_64: ${{ secrets.RELEASE_KEY_BASE_64 }} + GOOGLE_SERVICES_JSON: ${{ secrets.GOOGLE_SERVICES_JSON }} - - name: Run unit tests - run: ./gradlew test + - name: Make schema check executable + run: chmod +x scripts/check_room_schema_drift.sh - - name: Build Release apk - run: ./gradlew assembleRelease --stacktrace + - name: Run Room schema drift check + run: ./scripts/check_room_schema_drift.sh + env: + GITHUB_EVENT_NAME: ${{ github.event_name }} + GITHUB_BASE_REF: ${{ github.base_ref }} - - name: Build Release bundle - run: ./gradlew bundleRelease --stacktrace + - name: Build Release APK and AAB + run: ./gradlew assembleRelease bundleRelease --build-cache --parallel - - name: Get release apk file path + - name: Get release apk path id: releaseApk run: echo "apkfile=$(find app/build/outputs/apk/release/*.apk)" >> $GITHUB_OUTPUT - - name: Get release aab file path + - name: Get release aab path id: releaseAab run: echo "aabfile=$(find app/build/outputs/bundle/release/*.aab)" >> $GITHUB_OUTPUT - - name: Upload Release APK to Artifacts + - name: Upload Release APK uses: actions/upload-artifact@v4 with: name: release-apk-artifacts path: ${{ steps.releaseApk.outputs.apkfile }} - - name: Upload Release AAB to Artifacts + - name: Upload Release AAB uses: actions/upload-artifact@v4 with: name: release-aab-artifacts diff --git a/.github/workflows/versioning.yml b/.github/workflows/versioning.yml new file mode 100644 index 0000000..3744014 --- /dev/null +++ b/.github/workflows/versioning.yml @@ -0,0 +1,47 @@ +name: Bump Version and Tag + +on: + workflow_dispatch: + +jobs: + bump-version: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Set up Git (GitHub Actions bot) + run: | + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config --global user.name "github-actions[bot]" + + - name: Read and bump version + id: bump + run: | + VERSION=$(grep VERSION_NAME version.properties | cut -d= -f2) + MAJOR=$(echo $VERSION | cut -d. -f1) + MINOR=$(echo $VERSION | cut -d. -f2) + PATCH=$(echo $VERSION | cut -d. -f3) + + PATCH=$((PATCH + 1)) + NEW_VERSION="$MAJOR.$MINOR.$PATCH" + NEW_CODE=$((MAJOR * 10000 + MINOR * 100 + PATCH)) + + echo "New version: $NEW_VERSION ($NEW_CODE)" + echo "new_version=$NEW_VERSION" >> "$GITHUB_OUTPUT" + echo "new_code=$NEW_CODE" >> "$GITHUB_OUTPUT" + + echo "VERSION_NAME=$NEW_VERSION" > version.properties + echo "VERSION_CODE=$NEW_CODE" >> version.properties + + - name: Commit version bump + run: | + git add version.properties + git commit -m "ci: bump version to ${{ steps.bump.outputs.new_version }}" + git push + + - name: Create Git Tag + run: | + git tag v${{ steps.bump.outputs.new_version }} + git push origin v${{ steps.bump.outputs.new_version }} diff --git a/README.md b/README.md index 3358bd5..1cac7eb 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@ [View on Google Play](https://play.google.com/store/apps/details?id=org.open.smsforwarder) -The application for forwarding incoming SMS messages by email or via SMS to your colleagues and friends. -You can create a set of forwarding rules that will act as a filter for all incoming messages. All SMS that will meet the forwarding rules will be redirected to a chosen reciever's phone or email. +The application for forwarding incoming SMS messages by email or other forwarding types to your colleagues and friends. +You can create a set of forwarding rules that will act as a filter for all incoming messages. All SMS that will meet the forwarding rules will be redirected to a chosen receiver's phone or email. # The idea diff --git a/app/build.gradle b/app/build.gradle index a83cd74..d8638c8 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -15,20 +15,24 @@ plugins { id 'io.gitlab.arturbosch.detekt' version("1.23.6") } +def versionProps = new Properties() +versionProps.load(new File(rootProject.projectDir, "version.properties").newDataInputStream()) + android { namespace 'org.open.smsforwarder' - compileSdk 34 + compileSdk 35 defaultConfig { applicationId "org.open.smsforwarder" minSdk 26 - targetSdk 34 - versionCode 4 - versionName "1.4" + targetSdk 35 + versionName versionProps['VERSION_NAME'] + versionCode versionProps['VERSION_CODE'].toInteger() android.buildFeatures.buildConfig true buildConfigField "String", "OAUTH_BASE_URL", "\"https://oauth2.googleapis.com/\"" buildConfigField "String", "API_BASE_URL", "\"https://www.googleapis.com/\"" + buildConfigField "String", "TELEGRAM_API_BASE_URL", "\"https://api.telegram.org/\"" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } @@ -105,6 +109,10 @@ android { ksp { arg("room.schemaLocation", "$projectDir/schemas") } + + sourceSets { + androidTest.assets.srcDirs += files("$projectDir/schemas".toString()) + } } detekt { @@ -113,14 +121,18 @@ detekt { buildUponDefaultConfig = true } +tasks.withType(Test).configureEach { + useJUnitPlatform() +} + dependencies { // Common - implementation 'androidx.core:core-ktx:1.13.1' + implementation 'androidx.core:core-ktx:1.16.0' // https://issuetracker.google.com/issues/280481594 - implementation 'androidx.fragment:fragment-ktx:1.8.1' - implementation 'androidx.appcompat:appcompat:1.7.0' + implementation 'androidx.fragment:fragment-ktx:1.8.8' + implementation 'androidx.appcompat:appcompat:1.7.1' implementation 'com.google.android.material:material:1.12.0' - implementation 'androidx.constraintlayout:constraintlayout:2.1.4' + implementation 'androidx.constraintlayout:constraintlayout:2.2.1' // Splash implementation("androidx.core:core-splashscreen:1.0.1") @@ -133,32 +145,34 @@ dependencies { // Retrofit implementation("com.squareup.retrofit2:retrofit:2.9.0") - implementation("com.squareup.okhttp3:logging-interceptor:4.10.0") + implementation("com.squareup.okhttp3:logging-interceptor:4.12.0") // Serialization implementation("com.squareup.retrofit2:converter-moshi:2.9.0") implementation("com.squareup.moshi:moshi-kotlin:1.14.0") // Room - implementation 'androidx.room:room-runtime:2.6.1' - implementation 'androidx.room:room-ktx:2.6.1' - ksp "androidx.room:room-compiler:2.6.1" + implementation 'androidx.room:room-runtime:2.7.2' + implementation 'androidx.room:room-ktx:2.7.2' + implementation 'com.google.firebase:firebase-appcheck-ktx:18.0.0' + ksp "androidx.room:room-compiler:2.7.2" // Hilt - implementation 'com.google.dagger:hilt-android:2.49' - kapt 'com.google.dagger:hilt-android-compiler:2.49' + implementation 'com.google.dagger:hilt-android:2.55' + kapt 'com.google.dagger:hilt-android-compiler:2.55' // Hilt (for Workers) implementation 'androidx.hilt:hilt-work:1.2.0' kapt 'androidx.hilt:hilt-compiler:1.2.0' // WorkManager - implementation 'androidx.work:work-runtime:2.9.0' - implementation "androidx.work:work-runtime-ktx:2.9.0" + implementation 'androidx.work:work-runtime:2.10.2' + implementation "androidx.work:work-runtime-ktx:2.10.2" // Coroutines implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3' - implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3' + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0' + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.9.0' // Gmail API implementation 'com.google.apis:google-api-services-gmail:v1-rev20231218-2.0.0' @@ -168,25 +182,39 @@ dependencies { implementation 'com.sun.mail:android-activation:1.6.2' // KSP - implementation 'com.google.devtools.ksp:symbol-processing-api:1.9.22-1.0.17' - - // Google OAuth (NEW) - implementation 'androidx.credentials:credentials:1.3.0' - implementation 'androidx.credentials:credentials-play-services-auth:1.3.0' - implementation 'com.google.android.libraries.identity.googleid:googleid:1.1.1' - + implementation 'com.google.devtools.ksp:symbol-processing-api:2.1.10-1.0.31' - implementation 'com.google.android.gms:play-services-auth:21.2.0' + // Google OAuth + implementation "androidx.credentials:credentials:1.5.0" + implementation 'com.google.android.gms:play-services-auth:21.4.0' + implementation "com.google.android.libraries.identity.googleid:googleid:1.1.1" // Firebase - implementation(platform('com.google.firebase:firebase-bom:33.1.1')) + implementation(platform('com.google.firebase:firebase-bom:33.0.0')) implementation 'com.google.firebase:firebase-auth-ktx' implementation 'com.google.firebase:firebase-database-ktx' implementation "com.google.firebase:firebase-crashlytics" implementation "com.google.firebase:firebase-analytics" + implementation "com.google.firebase:firebase-appcheck" + implementation "com.google.firebase:firebase-appcheck-debug" + implementation "com.google.firebase:firebase-appcheck-playintegrity" - // Test + // Unit tests testImplementation 'junit:junit:4.13.2' + testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:5.10.2" + testImplementation "org.junit.jupiter:junit-jupiter-api:5.10.2" + testImplementation "androidx.arch.core:core-testing:2.2.0" + testImplementation "org.mockito.kotlin:mockito-kotlin:5.4.0" + testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3" + testImplementation "org.mockito:mockito-junit-jupiter:5.12.0" + testImplementation 'org.mockito:mockito-core:5.12.0' + + // Instrumentation tests + androidTestImplementation "androidx.arch.core:core-testing:2.2.0" androidTestImplementation 'androidx.test.ext:junit:1.2.1' androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' + androidTestImplementation "androidx.room:room-testing:2.7.2" + androidTestImplementation "androidx.test:core:1.6.1" + androidTestImplementation "androidx.test.ext:junit:1.2.1" + androidTestImplementation "androidx.test:runner:1.6.2" } diff --git a/app/config/detekt/detekt.yml b/app/config/detekt/detekt.yml index f8409a9..d19bf4b 100644 --- a/app/config/detekt/detekt.yml +++ b/app/config/detekt/detekt.yml @@ -168,7 +168,7 @@ complexity: active: true excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] thresholdInFiles: 11 - thresholdInClasses: 16 + thresholdInClasses: 17 thresholdInInterfaces: 11 thresholdInObjects: 11 thresholdInEnums: 11 @@ -694,7 +694,7 @@ style: ignoredCharacters: [] ThrowsCount: active: true - max: 2 + max: 3 excludeGuardClauses: false TrailingWhitespace: active: false diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index f97acc2..96d6e95 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -39,11 +39,30 @@ # kept. Suspend functions are wrapped in continuations where the type argument # is used. -keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation --if class androidx.credentials.CredentialManager --keep class androidx.credentials.playservices.** { - *; -} - -keep class org.open.smsforwarder.data.remote.dto.** { *; } -keep class * extends androidx.room.RoomDatabase --keep @androidx.room.Entity class * \ No newline at end of file +-keep @androidx.room.Entity class * + +# Credential Manager - Keep Credential Classes & Annotations +-keep class androidx.credentials.** { *; } +-keep @interface androidx.credentials.** { *; } + +# Google Identity Services - Prevent Stripping of Public API +-keep class com.google.android.gms.auth.api.identity.** { *; } + +# Keep annotations used for reflection +-keep @interface com.google.android.gms.common.annotation.KeepName + +# Required for Sign-In Intent + Credential retrieval +-keepclassmembers class * { + @com.google.android.gms.common.annotation.KeepName *; +} + +# Keep Play Services internal annotations & base +-keep class com.google.android.gms.common.** { *; } + +-keep class com.google.android.gms.** { *; } +-dontwarn com.google.android.gms.** + + + diff --git a/app/schemas/org.open.smsforwarder.data.local.database.AppDatabase/2.json b/app/schemas/org.open.smsforwarder.data.local.database.AppDatabase/2.json new file mode 100644 index 0000000..64949f2 --- /dev/null +++ b/app/schemas/org.open.smsforwarder.data.local.database.AppDatabase/2.json @@ -0,0 +1,226 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "c560fca29014d5927b5aa05a5af0fe54", + "entities": [ + { + "tableName": "forwarding_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `forwarding_type` TEXT, `sender_email` TEXT, `recipient_email` TEXT NOT NULL, `telegram_api_token` TEXT NOT NULL, `telegram_chat_id` TEXT NOT NULL, `error_text` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "forwardingTitle", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "forwardingType", + "columnName": "forwarding_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "senderEmail", + "columnName": "sender_email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "recipientEmail", + "columnName": "recipient_email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "telegramApiToken", + "columnName": "telegram_api_token", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "telegramChatId", + "columnName": "telegram_chat_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "errorText", + "columnName": "error_text", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "auth_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `forwarding_id` INTEGER NOT NULL, `access_token` TEXT, `refresh_token` TEXT, FOREIGN KEY(`forwarding_id`) REFERENCES `forwarding_table`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "forwardingId", + "columnName": "forwarding_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "access_token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "refreshToken", + "columnName": "refresh_token", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "forwarding_table", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "forwarding_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "forwarding_rules_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `forwarding_id` INTEGER NOT NULL, `rule` TEXT NOT NULL, FOREIGN KEY(`forwarding_id`) REFERENCES `forwarding_table`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "forwardingId", + "columnName": "forwarding_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rule", + "columnName": "rule", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "forwarding_table", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "forwarding_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "forwarding_history_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `forwarding_id` INTEGER NOT NULL, `date` INTEGER, `message` TEXT NOT NULL, `is_successful` INTEGER NOT NULL, FOREIGN KEY(`forwarding_id`) REFERENCES `forwarding_table`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "forwardingId", + "columnName": "forwarding_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isForwardingSuccessful", + "columnName": "is_successful", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "forwarding_table", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "forwarding_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'c560fca29014d5927b5aa05a5af0fe54')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/github/smsforwarder/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/github/smsforwarder/ExampleInstrumentedTest.kt deleted file mode 100644 index 7ab3a6f..0000000 --- a/app/src/androidTest/java/com/github/smsforwarder/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.github.smsforwarder - -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.ext.junit.runners.AndroidJUnit4 - -import org.junit.Test -import org.junit.runner.RunWith - -import org.junit.Assert.* - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.example.innoforwarder", appContext.packageName) - } -} diff --git a/app/src/androidTest/java/org/open/smsforwarder/RoomMigrationTest.kt b/app/src/androidTest/java/org/open/smsforwarder/RoomMigrationTest.kt new file mode 100644 index 0000000..6ebfb95 --- /dev/null +++ b/app/src/androidTest/java/org/open/smsforwarder/RoomMigrationTest.kt @@ -0,0 +1,94 @@ +package org.open.smsforwarder + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.room.testing.MigrationTestHelper +import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.open.smsforwarder.data.local.database.AppDatabase +import org.open.smsforwarder.data.local.database.migration.MIGRATION_1_2 + +@RunWith(AndroidJUnit4::class) +class RoomMigrationTest { + + private lateinit var helper: MigrationTestHelper + + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + @Before + fun setUp() { + helper = MigrationTestHelper( + InstrumentationRegistry.getInstrumentation(), + AppDatabase::class.java, + emptyList(), + FrameworkSQLiteOpenHelperFactory() + ) + } + + @Test + fun migrate1To2_removesRecipientPhone_addsTelegramFields() { + val db = helper.createDatabase("test_db", 1) + + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS `forwarding_table` ( + `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + `title` TEXT NOT NULL, + `forwarding_type` TEXT, + `sender_email` TEXT, + `recipient_phone` TEXT NOT NULL, + `recipient_email` TEXT NOT NULL, + `error_text` TEXT NOT NULL + ) + """.trimIndent() + ) + + db.execSQL( + """ + INSERT INTO `forwarding_table` + (`id`, `title`, `forwarding_type`, `sender_email`, `recipient_phone`, `recipient_email`, `error_text`) + VALUES (1, 'Test Title', 'EMAIL', 'sender@example.com', '+123456789', 'recipient@example.com', 'none') + """.trimIndent() + ) + + db.close() + + // Step 2: Run migration to version 2 + val migratedDb = helper.runMigrationsAndValidate( + "test_db", + 2, + true, + MIGRATION_1_2 + ) + + // Step 3: Validate migrated data + val cursor = migratedDb.query("SELECT * FROM forwarding_table") + assertTrue(cursor.moveToFirst()) + + assertEquals(1, cursor.getLong(cursor.getColumnIndexOrThrow("id"))) + assertEquals("Test Title", cursor.getString(cursor.getColumnIndexOrThrow("title"))) + assertEquals("EMAIL", cursor.getString(cursor.getColumnIndexOrThrow("forwarding_type"))) + assertEquals("sender@example.com", cursor.getString(cursor.getColumnIndexOrThrow("sender_email"))) + assertEquals("recipient@example.com", cursor.getString(cursor.getColumnIndexOrThrow("recipient_email"))) + assertEquals("none", cursor.getString(cursor.getColumnIndexOrThrow("error_text"))) + + // Verify new fields were added with default values + assertEquals("", cursor.getString(cursor.getColumnIndexOrThrow("telegram_api_token"))) + assertEquals("", cursor.getString(cursor.getColumnIndexOrThrow("telegram_chat_id"))) + + // Verify removed field is truly gone + assertThrows(IllegalArgumentException::class.java) { + cursor.getColumnIndexOrThrow("recipient_phone") + } + + cursor.close() + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 542fdb6..5ef6467 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -8,8 +8,6 @@ - - diff --git a/app/src/main/kotlin/org/open/smsforwarder/App.kt b/app/src/main/kotlin/org/open/smsforwarder/App.kt index 5d08e3c..a315f3c 100644 --- a/app/src/main/kotlin/org/open/smsforwarder/App.kt +++ b/app/src/main/kotlin/org/open/smsforwarder/App.kt @@ -3,6 +3,10 @@ package org.open.smsforwarder import android.app.Application import androidx.hilt.work.HiltWorkerFactory import androidx.work.Configuration +import com.google.firebase.FirebaseApp +import com.google.firebase.appcheck.FirebaseAppCheck +import com.google.firebase.appcheck.debug.DebugAppCheckProviderFactory +import com.google.firebase.appcheck.playintegrity.PlayIntegrityAppCheckProviderFactory import dagger.hilt.EntryPoint import dagger.hilt.EntryPoints import dagger.hilt.InstallIn @@ -31,6 +35,15 @@ class App : Application(), Configuration.Provider { override fun onCreate() { super.onCreate() + + FirebaseApp.initializeApp(this) + val appCheck = FirebaseAppCheck.getInstance() + + if (BuildConfig.DEBUG) { + appCheck.installAppCheckProviderFactory(DebugAppCheckProviderFactory.getInstance()) + } else { + appCheck.installAppCheckProviderFactory(PlayIntegrityAppCheckProviderFactory.getInstance()) + } workManagerConfigurator.scheduleDailyForwardedMessageCountWork() } } diff --git a/app/src/main/kotlin/org/open/smsforwarder/MainActivity.kt b/app/src/main/kotlin/org/open/smsforwarder/MainActivity.kt index 1462a3e..6ca37bd 100644 --- a/app/src/main/kotlin/org/open/smsforwarder/MainActivity.kt +++ b/app/src/main/kotlin/org/open/smsforwarder/MainActivity.kt @@ -4,9 +4,12 @@ import android.os.Bundle import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import androidx.core.view.isVisible +import androidx.lifecycle.Lifecycle import com.github.terrakok.cicerone.NavigatorHolder import dagger.hilt.android.AndroidEntryPoint import org.open.smsforwarder.databinding.ActivityMainBinding +import org.open.smsforwarder.extension.observeWithLifecycle import org.open.smsforwarder.extension.playCustomSplashAnimation import org.open.smsforwarder.navigation.AnimatedAppNavigator import javax.inject.Inject @@ -28,6 +31,10 @@ class MainActivity : AppCompatActivity() { super.onCreate(savedInstanceState) _binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) + + viewModel.networkStatus.observeWithLifecycle(this, Lifecycle.State.CREATED) { isOnline -> + binding.networkOfflineTv.isVisible = !isOnline + } } private fun setUpSplashScreen() { diff --git a/app/src/main/kotlin/org/open/smsforwarder/MainViewModel.kt b/app/src/main/kotlin/org/open/smsforwarder/MainViewModel.kt index 5947eb6..a2b3dd2 100644 --- a/app/src/main/kotlin/org/open/smsforwarder/MainViewModel.kt +++ b/app/src/main/kotlin/org/open/smsforwarder/MainViewModel.kt @@ -1,22 +1,39 @@ package org.open.smsforwarder import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.github.terrakok.cicerone.Router import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import org.open.smsforwarder.analytics.AnalyticsEvents.HOME_SCREEN_START import org.open.smsforwarder.analytics.AnalyticsEvents.ONBOARDING_SCREEN_START import org.open.smsforwarder.analytics.AnalyticsTracker import org.open.smsforwarder.data.repository.LocalSettingsRepository +import org.open.smsforwarder.domain.NetworkStateObserver import org.open.smsforwarder.navigation.Screens import javax.inject.Inject +private const val STOP_TIMEOUT_MILLIS = 5000L + @HiltViewModel class MainViewModel @Inject constructor( private val localSettingsRepository: LocalSettingsRepository, private val router: Router, - private val analyticsTracker: AnalyticsTracker + private val analyticsTracker: AnalyticsTracker, + networkStateObserver: NetworkStateObserver, ) : ViewModel() { + val networkStatus: StateFlow = networkStateObserver.networkStatus + .map { it.isOnline() } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(STOP_TIMEOUT_MILLIS), + initialValue = networkStateObserver.networkStatus.value.isOnline() + ) + fun onInit(containerChildCount: Int) { if (containerChildCount == 0) { when (localSettingsRepository.isOnboardingCompleted()) { diff --git a/app/src/main/kotlin/org/open/smsforwarder/data/NetworkStateObserverImpl.kt b/app/src/main/kotlin/org/open/smsforwarder/data/NetworkStateObserverImpl.kt new file mode 100644 index 0000000..7b42f4b --- /dev/null +++ b/app/src/main/kotlin/org/open/smsforwarder/data/NetworkStateObserverImpl.kt @@ -0,0 +1,82 @@ +package org.open.smsforwarder.data + +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.stateIn +import org.open.smsforwarder.domain.NetworkStateObserver +import org.open.smsforwarder.domain.model.NetworkStatus +import javax.inject.Inject + +private const val STOP_TIMEOUT_MILLIS = 5000L + +class NetworkStateObserverImpl @Inject constructor( + private val connectivityManager: ConnectivityManager, + coroutineScope: CoroutineScope +) : NetworkStateObserver { + + private var currentNetwork: Network? = null + + override val networkStatus: StateFlow = callbackFlow { + + val callback = object : ConnectivityManager.NetworkCallback() { + + override fun onAvailable(network: Network) { + val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return + if (capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)) { + currentNetwork = network + trySend(NetworkStatus.Available) + } + } + + override fun onUnavailable() { + trySend(NetworkStatus.Unavailable) + } + + override fun onLosing(network: Network, maxMsToLive: Int) { + if (network == currentNetwork) { + trySend(NetworkStatus.Losing) + } + } + + override fun onLost(network: Network) { + if (network == currentNetwork) { + currentNetwork = null + trySend(NetworkStatus.Lost) + } + } + } + + val request: NetworkRequest = NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + .build() + + connectivityManager.registerNetworkCallback(request, callback) + + awaitClose { + connectivityManager.unregisterNetworkCallback(callback) + } + + }.stateIn( + scope = coroutineScope, + started = SharingStarted.WhileSubscribed(STOP_TIMEOUT_MILLIS), + initialValue = getCurrentStatus() + ) + + private fun getCurrentStatus(): NetworkStatus { + val activeNetwork = connectivityManager.activeNetwork + val capabilities = connectivityManager.getNetworkCapabilities(activeNetwork) + return if (capabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true) { + NetworkStatus.Available + } else { + NetworkStatus.Unavailable + } + } +} diff --git a/app/src/main/kotlin/org/open/smsforwarder/data/local/database/AppDatabase.kt b/app/src/main/kotlin/org/open/smsforwarder/data/local/database/AppDatabase.kt index 9f2f7c9..c7142dc 100644 --- a/app/src/main/kotlin/org/open/smsforwarder/data/local/database/AppDatabase.kt +++ b/app/src/main/kotlin/org/open/smsforwarder/data/local/database/AppDatabase.kt @@ -18,11 +18,16 @@ import org.open.smsforwarder.data.local.database.entity.RuleEntity RuleEntity::class, HistoryEntity::class ], - version = 1 + version = AppDatabase.DATABASE_VERSION ) abstract class AppDatabase : RoomDatabase() { abstract fun forwardingDao(): ForwardingDao abstract fun authDao(): AuthTokenDao abstract fun rulesDao(): RulesDao abstract fun historyDao(): HistoryDao + + companion object { + const val DATABASE_NAME = "sms_forwarder" + const val DATABASE_VERSION = 2 + } } diff --git a/app/src/main/kotlin/org/open/smsforwarder/data/local/database/dao/HistoryDao.kt b/app/src/main/kotlin/org/open/smsforwarder/data/local/database/dao/HistoryDao.kt index 2612267..098b0b8 100644 --- a/app/src/main/kotlin/org/open/smsforwarder/data/local/database/dao/HistoryDao.kt +++ b/app/src/main/kotlin/org/open/smsforwarder/data/local/database/dao/HistoryDao.kt @@ -3,6 +3,7 @@ package org.open.smsforwarder.data.local.database.dao import androidx.room.Dao import androidx.room.Query import androidx.room.Upsert +import kotlinx.coroutines.flow.Flow import org.open.smsforwarder.data.local.database.entity.HistoryEntity import org.open.smsforwarder.data.local.database.entity.HistoryEntity.Companion.DATE import org.open.smsforwarder.data.local.database.entity.HistoryEntity.Companion.FORWARDING_HISTORY_TABLE @@ -20,4 +21,7 @@ interface HistoryDao { """ ) suspend fun getForwardedMessagesCountLast24Hours(): Int + + @Query("SELECT * FROM $FORWARDING_HISTORY_TABLE ORDER BY $DATE DESC") + fun getForwardingHistoryFlow(): Flow> } diff --git a/app/src/main/kotlin/org/open/smsforwarder/data/local/database/entity/ForwardingEntity.kt b/app/src/main/kotlin/org/open/smsforwarder/data/local/database/entity/ForwardingEntity.kt index 4578650..8baef96 100644 --- a/app/src/main/kotlin/org/open/smsforwarder/data/local/database/entity/ForwardingEntity.kt +++ b/app/src/main/kotlin/org/open/smsforwarder/data/local/database/entity/ForwardingEntity.kt @@ -22,12 +22,15 @@ data class ForwardingEntity( @ColumnInfo(name = SENDER_EMAIL) val senderEmail: String? = null, - @ColumnInfo(name = RECIPIENT_PHONE) - val recipientPhone: String = "", - @ColumnInfo(name = RECIPIENT_EMAIL) val recipientEmail: String = "", + @ColumnInfo(name = TELEGRAM_API_TOKEN) + val telegramApiToken: String = "", + + @ColumnInfo(name = TELEGRAM_CHAT_ID) + val telegramChatId: String = "", + @ColumnInfo(name = ERROR_TEXT) val errorText: String = "" ) { @@ -37,9 +40,10 @@ data class ForwardingEntity( const val ID = "id" const val TITLE = "title" const val FORWARDING_TYPE = "forwarding_type" - const val RECIPIENT_PHONE = "recipient_phone" const val SENDER_EMAIL = "sender_email" const val RECIPIENT_EMAIL = "recipient_email" const val ERROR_TEXT = "error_text" + const val TELEGRAM_API_TOKEN = "telegram_api_token" + const val TELEGRAM_CHAT_ID = "telegram_chat_id" } } diff --git a/app/src/main/kotlin/org/open/smsforwarder/data/local/database/migration/RoomMigrationChecker.kt b/app/src/main/kotlin/org/open/smsforwarder/data/local/database/migration/RoomMigrationChecker.kt new file mode 100644 index 0000000..8c3a464 --- /dev/null +++ b/app/src/main/kotlin/org/open/smsforwarder/data/local/database/migration/RoomMigrationChecker.kt @@ -0,0 +1,57 @@ +package org.open.smsforwarder.data.local.database.migration + +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import androidx.room.migration.Migration +import org.open.smsforwarder.data.local.database.AppDatabase + +object RoomMigrationChecker { + + fun validateMigrations( + context: Context, + registeredMigrations: List + ) { + val targetVersion = AppDatabase.DATABASE_VERSION + val dbVersion = getVersion(context) + if (dbVersion == targetVersion || dbVersion == -1) { + return // No migration needed: DB is already up to date or doesn't exists yet + } + + val expectedMigrations = mutableSetOf() + for (version in dbVersion until targetVersion) { + expectedMigrations += version + } + + val actualMigrations = registeredMigrations.map { it.startVersion }.toSet() + val missingMigrations = expectedMigrations - actualMigrations + + if (missingMigrations.isNotEmpty()) { + error( + "❌ Room migration(s) missing from version(s): $missingMigrations\n" + + "Detected DB version: $dbVersion, Target DB version: $targetVersion.\n" + + "You must addMigration(s)." + ) + } else { + println("✅ All required Room migrations are registered.") + } + } + + private fun getVersion(context: Context): Int { + val dbPath = context.getDatabasePath(AppDatabase.DATABASE_NAME) + if (!dbPath.exists()) return -1 + + val db = SQLiteDatabase.openDatabase( + dbPath.absolutePath, + null, + SQLiteDatabase.OPEN_READONLY + ) + + val cursor = db.rawQuery("PRAGMA user_version", null) + val version = if (cursor.moveToFirst()) cursor.getInt(0) else -1 + + cursor.close() + db.close() + + return version + } +} diff --git a/app/src/main/kotlin/org/open/smsforwarder/data/local/database/migration/RoomMigrations.kt b/app/src/main/kotlin/org/open/smsforwarder/data/local/database/migration/RoomMigrations.kt new file mode 100644 index 0000000..c14958f --- /dev/null +++ b/app/src/main/kotlin/org/open/smsforwarder/data/local/database/migration/RoomMigrations.kt @@ -0,0 +1,41 @@ +package org.open.smsforwarder.data.local.database.migration + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val MIGRATION_1_2 = object : Migration(1, 2) { + + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS `forwarding_table_new` ( + `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + `title` TEXT NOT NULL, + `forwarding_type` TEXT, + `sender_email` TEXT, + `recipient_email` TEXT NOT NULL, + `telegram_api_token` TEXT NOT NULL, + `telegram_chat_id` TEXT NOT NULL, + `error_text` TEXT NOT NULL + ) + """.trimIndent() + ) + + db.execSQL( + """ + INSERT INTO `forwarding_table_new` ( + `id`, `title`, `forwarding_type`, `sender_email`, `recipient_email`, `error_text`, + `telegram_api_token`, `telegram_chat_id` + ) + SELECT + `id`, `title`, `forwarding_type`, `sender_email`, `recipient_email`, `error_text`, + '', '' + FROM `forwarding_table` + """.trimIndent() + ) + + db.execSQL("DROP TABLE `forwarding_table`") + + db.execSQL("ALTER TABLE `forwarding_table_new` RENAME TO `forwarding_table`") + } +} diff --git a/app/src/main/kotlin/org/open/smsforwarder/data/local/prefs/Prefs.kt b/app/src/main/kotlin/org/open/smsforwarder/data/local/prefs/Prefs.kt index f48d502..2857e06 100644 --- a/app/src/main/kotlin/org/open/smsforwarder/data/local/prefs/Prefs.kt +++ b/app/src/main/kotlin/org/open/smsforwarder/data/local/prefs/Prefs.kt @@ -1,6 +1,7 @@ package org.open.smsforwarder.data.local.prefs import android.content.SharedPreferences +import androidx.core.content.edit import javax.inject.Inject class Prefs @Inject constructor( @@ -11,9 +12,9 @@ class Prefs @Inject constructor( get() = sharedPreferences.getBoolean(ONBOARDING_COMPLETED, false) set(value) = sharedPreferences - .edit() - .putBoolean(ONBOARDING_COMPLETED, value) - .apply() + .edit { + putBoolean(ONBOARDING_COMPLETED, value) + } private companion object { const val ONBOARDING_COMPLETED = "ONBOARDING_COMPLETED" diff --git a/app/src/main/kotlin/org/open/smsforwarder/data/mapper/DataMapper.kt b/app/src/main/kotlin/org/open/smsforwarder/data/mapper/DataMapper.kt index 1d0f748..6895cf0 100644 --- a/app/src/main/kotlin/org/open/smsforwarder/data/mapper/DataMapper.kt +++ b/app/src/main/kotlin/org/open/smsforwarder/data/mapper/DataMapper.kt @@ -1,8 +1,10 @@ package org.open.smsforwarder.data.mapper import org.open.smsforwarder.data.local.database.entity.ForwardingEntity +import org.open.smsforwarder.data.local.database.entity.HistoryEntity import org.open.smsforwarder.data.local.database.entity.RuleEntity import org.open.smsforwarder.domain.model.Forwarding +import org.open.smsforwarder.domain.model.History import org.open.smsforwarder.domain.model.Rule fun ForwardingEntity.toDomain(): Forwarding = @@ -10,9 +12,10 @@ fun ForwardingEntity.toDomain(): Forwarding = id = id, title = forwardingTitle, forwardingType = forwardingType, - recipientPhone = recipientPhone, senderEmail = senderEmail, recipientEmail = recipientEmail, + telegramApiToken = telegramApiToken, + telegramChatId = telegramChatId, error = errorText ) @@ -23,14 +26,24 @@ fun RuleEntity.toDomain() = textRule = rule ) +fun HistoryEntity.toDomain() = + History( + id = id, + date = date, + forwardingId = forwardingId, + message = message, + isForwardingSuccessful = isForwardingSuccessful + ) + fun Forwarding.toData(): ForwardingEntity = ForwardingEntity( id = id, forwardingTitle = title, forwardingType = forwardingType, - recipientPhone = recipientPhone, senderEmail = senderEmail, recipientEmail = recipientEmail, + telegramApiToken = telegramApiToken, + telegramChatId = telegramChatId, errorText = error ) @@ -40,3 +53,12 @@ fun Rule.toData(): RuleEntity = forwardingId = forwardingId, rule = textRule ) + +fun History.toData(): HistoryEntity = + HistoryEntity( + id = id, + date = date, + forwardingId = forwardingId, + message = message, + isForwardingSuccessful = isForwardingSuccessful + ) diff --git a/app/src/main/kotlin/org/open/smsforwarder/data/mapper/Mapper.kt b/app/src/main/kotlin/org/open/smsforwarder/data/mapper/Mapper.kt deleted file mode 100644 index 15b0804..0000000 --- a/app/src/main/kotlin/org/open/smsforwarder/data/mapper/Mapper.kt +++ /dev/null @@ -1,20 +0,0 @@ -package org.open.smsforwarder.data.mapper - -import org.open.smsforwarder.data.local.database.entity.HistoryEntity -import javax.inject.Inject - -class Mapper @Inject constructor() { - - fun toHistoryEntity( - forwardingId: Long, - time: Long, - message: String, - isForwardingSuccessful: Boolean, - ): HistoryEntity = - HistoryEntity( - forwardingId = forwardingId, - date = time, - message = message, - isForwardingSuccessful = isForwardingSuccessful - ) -} diff --git a/app/src/main/kotlin/org/open/smsforwarder/data/remote/dto/AuthCodeExchangeResponse.kt b/app/src/main/kotlin/org/open/smsforwarder/data/remote/dto/AuthCodeExchangeResponse.kt index 06b80a1..7841259 100644 --- a/app/src/main/kotlin/org/open/smsforwarder/data/remote/dto/AuthCodeExchangeResponse.kt +++ b/app/src/main/kotlin/org/open/smsforwarder/data/remote/dto/AuthCodeExchangeResponse.kt @@ -7,4 +7,8 @@ data class AuthCodeExchangeResponse( val accessToken: String, @Json(name = "refresh_token") val refreshToken: String, + @Json(name = "scope") + val scope: String, + @Json(name = "id_token") + val idToken: String ) diff --git a/app/src/main/kotlin/org/open/smsforwarder/data/remote/dto/AuthorizationResult.kt b/app/src/main/kotlin/org/open/smsforwarder/data/remote/dto/AuthorizationResult.kt new file mode 100644 index 0000000..7b39fc4 --- /dev/null +++ b/app/src/main/kotlin/org/open/smsforwarder/data/remote/dto/AuthorizationResult.kt @@ -0,0 +1,5 @@ +package org.open.smsforwarder.data.remote.dto + +data class AuthorizationResult( + val email: String? +) diff --git a/app/src/main/kotlin/org/open/smsforwarder/data/remote/dto/SignInResult.kt b/app/src/main/kotlin/org/open/smsforwarder/data/remote/dto/SignInResult.kt new file mode 100644 index 0000000..1495bcb --- /dev/null +++ b/app/src/main/kotlin/org/open/smsforwarder/data/remote/dto/SignInResult.kt @@ -0,0 +1,7 @@ +package org.open.smsforwarder.data.remote.dto + +import android.content.IntentSender + +data class SignInResult( + val intentSender: IntentSender +) diff --git a/app/src/main/kotlin/org/open/smsforwarder/data/remote/service/AuthService.kt b/app/src/main/kotlin/org/open/smsforwarder/data/remote/service/AuthService.kt index 8f53995..a665643 100644 --- a/app/src/main/kotlin/org/open/smsforwarder/data/remote/service/AuthService.kt +++ b/app/src/main/kotlin/org/open/smsforwarder/data/remote/service/AuthService.kt @@ -4,6 +4,7 @@ import org.open.smsforwarder.BuildConfig import org.open.smsforwarder.data.remote.dto.AuthCodeExchangeResponse import org.open.smsforwarder.data.remote.dto.GrantType import org.open.smsforwarder.data.remote.dto.RefreshTokenResponse +import retrofit2.Response import retrofit2.http.Field import retrofit2.http.FormUrlEncoded import retrofit2.http.POST @@ -29,4 +30,10 @@ interface AuthService { @Field("client_id") clientId: String = BuildConfig.CLIENT_ID, @Field("client_secret") clientSecret: String = BuildConfig.CLIENT_SECRET, ): RefreshTokenResponse + + @FormUrlEncoded + @POST("revoke") + suspend fun revokeToken( + @Field("token") token: String? + ): Response } diff --git a/app/src/main/kotlin/org/open/smsforwarder/data/remote/service/TelegramService.kt b/app/src/main/kotlin/org/open/smsforwarder/data/remote/service/TelegramService.kt new file mode 100644 index 0000000..b120523 --- /dev/null +++ b/app/src/main/kotlin/org/open/smsforwarder/data/remote/service/TelegramService.kt @@ -0,0 +1,15 @@ +package org.open.smsforwarder.data.remote.service + +import okhttp3.ResponseBody +import retrofit2.http.GET +import retrofit2.http.Path +import retrofit2.http.Query + +interface TelegramService { + @GET("/bot{apiToken}/sendMessage") + suspend fun sendMessage( + @Path("apiToken", encoded = true) apiToken: String, + @Query("chat_id") chatId: String, + @Query("text") text: String + ): ResponseBody +} diff --git a/app/src/main/kotlin/org/open/smsforwarder/data/repository/AuthRepository.kt b/app/src/main/kotlin/org/open/smsforwarder/data/repository/AuthRepository.kt index 24bd01f..1367108 100644 --- a/app/src/main/kotlin/org/open/smsforwarder/data/repository/AuthRepository.kt +++ b/app/src/main/kotlin/org/open/smsforwarder/data/repository/AuthRepository.kt @@ -1,48 +1,63 @@ package org.open.smsforwarder.data.repository -import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.withContext import org.open.smsforwarder.data.local.database.dao.AuthTokenDao import org.open.smsforwarder.data.local.database.entity.AuthTokenEntity -import org.open.smsforwarder.data.remote.dto.AuthCodeExchangeResponse +import org.open.smsforwarder.data.remote.dto.AuthorizationResult import org.open.smsforwarder.data.remote.service.AuthService -import org.open.smsforwarder.domain.model.Forwarding +import org.open.smsforwarder.domain.IdTokenParser +import org.open.smsforwarder.utils.runSuspendCatching import javax.inject.Inject class AuthRepository @Inject constructor( private val authService: AuthService, private val authTokenDao: AuthTokenDao, - private val forwardingRepository: ForwardingRepository, + private val idTokenParser: IdTokenParser, + private val ioDispatcher: CoroutineDispatcher, ) { - suspend fun exchangeAuthCodeForTokensAnd(authCode: String?): Result = - runCatching { - authService.exchangeAuthCodeForTokens(authCode) + suspend fun exchangeAuthCodeForTokens( + authCode: String, + forwardingId: Long + ): AuthorizationResult = + withContext(ioDispatcher) { + val authResponse = authService.exchangeAuthCodeForTokens(authCode) + val senderEmail = idTokenParser.extractEmail(authResponse.idToken) + saveTokens(forwardingId, authResponse.accessToken, authResponse.refreshToken) + AuthorizationResult(email = senderEmail) } - suspend fun saveTokens( + suspend fun signOut(forwardingId: Long): Result = + withContext(ioDispatcher) { + runSuspendCatching { + val authTokenEntity = + authTokenDao.getAuthToken(forwardingId) ?: return@runSuspendCatching + authService.revokeToken(authTokenEntity.accessToken) + authTokenDao.upsertAuthToken( + authTokenEntity.copy( + accessToken = null, + refreshToken = null + ) + ) + } + } + + private suspend fun saveTokens( forwardingId: Long, accessToken: String, refreshToken: String, ) { - val authEntity = authTokenDao.getAuthToken(forwardingId)?.copy( - accessToken = accessToken, - refreshToken = refreshToken - ) ?: AuthTokenEntity( - forwardingId = forwardingId, - accessToken = accessToken, - refreshToken = refreshToken - ) - authTokenDao.upsertAuthToken(authEntity) - } - - suspend fun signOut(forwarding: Forwarding): Result = - runCatching { - withContext(Dispatchers.IO) { - authTokenDao.getAuthToken(forwarding.id)?.let { - authTokenDao.upsertAuthToken(it.copy(accessToken = null, refreshToken = null)) - } - forwardingRepository.insertOrUpdateForwarding(forwarding.copy(senderEmail = null)) - } + withContext(ioDispatcher) { + val authEntity = authTokenDao.getAuthToken(forwardingId)?.copy( + accessToken = accessToken, + refreshToken = refreshToken + ) ?: AuthTokenEntity( + forwardingId = forwardingId, + accessToken = accessToken, + refreshToken = refreshToken + ) + authTokenDao.upsertAuthToken(authEntity) } + } } diff --git a/app/src/main/kotlin/org/open/smsforwarder/data/repository/FeedbackRepository.kt b/app/src/main/kotlin/org/open/smsforwarder/data/repository/FeedbackRepository.kt index b5dc0b4..9b8cd87 100644 --- a/app/src/main/kotlin/org/open/smsforwarder/data/repository/FeedbackRepository.kt +++ b/app/src/main/kotlin/org/open/smsforwarder/data/repository/FeedbackRepository.kt @@ -2,33 +2,32 @@ package org.open.smsforwarder.data.repository import com.google.firebase.auth.FirebaseAuth import com.google.firebase.database.FirebaseDatabase -import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.tasks.await import org.open.smsforwarder.BuildConfig import javax.inject.Inject -import kotlin.coroutines.resume -class FeedbackRepository @Inject constructor( - private val firebaseAuth: FirebaseAuth, - private val firebaseDatabase: FirebaseDatabase -) { +class FeedbackRepository @Inject constructor() { suspend fun sendFeedback(email: String, body: String): Boolean { - return suspendCancellableCoroutine { continuation -> - firebaseAuth.signInAnonymously().addOnCompleteListener { authTask -> - if (authTask.isSuccessful) { - val user = firebaseAuth.currentUser - val myRef = firebaseDatabase.getReference(BuildConfig.FEEDBACK_DB_PATH) - .child(user?.uid ?: "") - - val feedbackData = mapOf(EMAIL_KEY to email, BODY_KEY to body) - myRef.setValue(feedbackData).addOnCompleteListener { task -> - continuation.resume(task.isSuccessful) - } - } else { - continuation.resume(false) - } - } + val auth = FirebaseAuth.getInstance() + + if (auth.currentUser == null) { + auth.signInAnonymously().await() } + + val uid = auth.currentUser?.uid ?: return false + + val feedbackRef = FirebaseDatabase.getInstance() + .getReference(BuildConfig.FEEDBACK_DB_PATH) + .child(uid) + + val feedbackData = mapOf( + EMAIL_KEY to email, + BODY_KEY to body + ) + + feedbackRef.push().setValue(feedbackData).await() + return true } private companion object { diff --git a/app/src/main/kotlin/org/open/smsforwarder/data/repository/HistoryRepository.kt b/app/src/main/kotlin/org/open/smsforwarder/data/repository/HistoryRepository.kt index a83cf9a..71024d3 100644 --- a/app/src/main/kotlin/org/open/smsforwarder/data/repository/HistoryRepository.kt +++ b/app/src/main/kotlin/org/open/smsforwarder/data/repository/HistoryRepository.kt @@ -1,29 +1,40 @@ package org.open.smsforwarder.data.repository import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext import org.open.smsforwarder.data.local.database.dao.HistoryDao -import org.open.smsforwarder.data.mapper.Mapper +import org.open.smsforwarder.data.local.database.entity.HistoryEntity +import org.open.smsforwarder.data.mapper.toDomain +import org.open.smsforwarder.domain.model.History import javax.inject.Inject class HistoryRepository @Inject constructor( private val historyDao: HistoryDao, - private val mapper: Mapper ) { + suspend fun getForwardedMessagesForLast24Hours(): Int = withContext(Dispatchers.IO) { historyDao.getForwardedMessagesCountLast24Hours() } - suspend fun insertOrUpdateForwardedSms( + fun getForwardingHistoryFlow(): Flow> = + historyDao + .getForwardingHistoryFlow() + .distinctUntilChanged() + .map { historyEntity -> historyEntity.map(HistoryEntity::toDomain) } + + suspend fun insertForwardedSms( forwardingId: Long, message: String, isForwardingSuccessful: Boolean ) { withContext(Dispatchers.IO) { historyDao.upsertForwardedSms( - mapper.toHistoryEntity( + HistoryEntity( + date = System.currentTimeMillis(), forwardingId = forwardingId, - time = System.currentTimeMillis(), message = message, isForwardingSuccessful = isForwardingSuccessful ) diff --git a/app/src/main/kotlin/org/open/smsforwarder/data/repository/IdTokenParserImpl.kt b/app/src/main/kotlin/org/open/smsforwarder/data/repository/IdTokenParserImpl.kt new file mode 100644 index 0000000..cb12884 --- /dev/null +++ b/app/src/main/kotlin/org/open/smsforwarder/data/repository/IdTokenParserImpl.kt @@ -0,0 +1,43 @@ +package org.open.smsforwarder.data.repository + +import android.util.Base64 +import android.util.Log +import org.json.JSONException +import org.json.JSONObject +import org.open.smsforwarder.domain.IdTokenParser +import javax.inject.Inject + +class IdTokenParserImpl @Inject constructor() : IdTokenParser { + + override fun extractEmail(idToken: String): String? { + val parts = idToken.split(".") + if (parts.size != ID_TOKEN_PARTS_SIZE) return null + + return try { + val payload = parts[1] + val paddedPayload = + payload.padEnd( + (payload.length + PADDING_OFFSET) + / BLOCK_SIZE_DIVIDER * BLOCK_SIZE_MULTIPLIER, PAD_CHAR + ) + val decodedBytes = Base64.decode(paddedPayload, Base64.URL_SAFE or Base64.NO_WRAP) + val json = JSONObject(String(decodedBytes)) + json.optString(EMAIL) + } catch (e: JSONException) { + Log.w("TokenParser", "Failed to parse JSON in ID Token", e) + null + } catch (e: IllegalArgumentException) { + Log.w("TokenParser", "Failed to decode Base64 payload in ID Token", e) + null + } + } + + private companion object { + const val EMAIL = "email" + const val PAD_CHAR = '=' + const val ID_TOKEN_PARTS_SIZE = 3 + const val PADDING_OFFSET = 3 + const val BLOCK_SIZE_DIVIDER = 4 + const val BLOCK_SIZE_MULTIPLIER = 4 + } +} diff --git a/app/src/main/kotlin/org/open/smsforwarder/di/AuthorizationModule.kt b/app/src/main/kotlin/org/open/smsforwarder/di/AuthorizationModule.kt new file mode 100644 index 0000000..5c1ada9 --- /dev/null +++ b/app/src/main/kotlin/org/open/smsforwarder/di/AuthorizationModule.kt @@ -0,0 +1,66 @@ +package org.open.smsforwarder.di + +import androidx.credentials.GetCredentialRequest +import com.google.android.gms.auth.api.identity.AuthorizationRequest +import com.google.android.gms.common.Scopes +import com.google.android.gms.common.api.Scope +import com.google.android.libraries.identity.googleid.GetSignInWithGoogleOption +import com.google.api.services.gmail.GmailScopes +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import org.open.smsforwarder.BuildConfig +import org.open.smsforwarder.data.repository.IdTokenParserImpl +import org.open.smsforwarder.domain.IdTokenParser +import org.open.smsforwarder.platform.CredentialClientWrapper +import org.open.smsforwarder.platform.CredentialClientWrapperImpl +import org.open.smsforwarder.platform.IdentityClientWrapper +import org.open.smsforwarder.platform.IdentityClientWrapperImpl + +@Module +@InstallIn(SingletonComponent::class) +abstract class AuthorizationModule { + + @Binds + abstract fun bindIdTokenParser(idTokenParserImpl: IdTokenParserImpl): IdTokenParser + + @Binds + abstract fun bindCredentialClientWrapper( + credentialManagerWrapperImpl: CredentialClientWrapperImpl + ): CredentialClientWrapper + + @Binds + abstract fun bindIdentityClientWrapper( + identityClientWrapperImpl: IdentityClientWrapperImpl + ): IdentityClientWrapper + + + companion object { + + @Provides + fun provideAuthorizationRequest(): AuthorizationRequest = + AuthorizationRequest + .Builder() + .setRequestedScopes( + listOf( + Scope(GmailScopes.GMAIL_SEND), + Scope(Scopes.EMAIL), + ) + ) + .requestOfflineAccess(BuildConfig.CLIENT_ID, true) + .build() + + @Provides + fun provideGetCredentialRequest(): GetCredentialRequest { + val googleOption = GetSignInWithGoogleOption + .Builder(BuildConfig.CLIENT_ID) + .build() + + return GetCredentialRequest.Builder() + .addCredentialOption(googleOption) + .build() + } + } +} diff --git a/app/src/main/kotlin/org/open/smsforwarder/di/CoroutineDispatcherModule.kt b/app/src/main/kotlin/org/open/smsforwarder/di/CoroutineDispatcherModule.kt new file mode 100644 index 0000000..26bfd2b --- /dev/null +++ b/app/src/main/kotlin/org/open/smsforwarder/di/CoroutineDispatcherModule.kt @@ -0,0 +1,18 @@ +package org.open.smsforwarder.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object CoroutineDispatcherModule { + + @Provides + @Singleton + fun provideIODispatcher(): CoroutineDispatcher = Dispatchers.IO +} diff --git a/app/src/main/kotlin/org/open/smsforwarder/di/DatabaseModule.kt b/app/src/main/kotlin/org/open/smsforwarder/di/DatabaseModule.kt index e25a043..88d35b8 100644 --- a/app/src/main/kotlin/org/open/smsforwarder/di/DatabaseModule.kt +++ b/app/src/main/kotlin/org/open/smsforwarder/di/DatabaseModule.kt @@ -7,25 +7,42 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import org.open.smsforwarder.BuildConfig import org.open.smsforwarder.data.local.database.AppDatabase import org.open.smsforwarder.data.local.database.dao.AuthTokenDao import org.open.smsforwarder.data.local.database.dao.ForwardingDao import org.open.smsforwarder.data.local.database.dao.HistoryDao import org.open.smsforwarder.data.local.database.dao.RulesDao +import org.open.smsforwarder.data.local.database.migration.MIGRATION_1_2 +import org.open.smsforwarder.data.local.database.migration.RoomMigrationChecker import javax.inject.Singleton @InstallIn(SingletonComponent::class) @Module class DatabaseModule { + private val migrations = listOf( + MIGRATION_1_2 + ) + @Provides @Singleton - fun provideDatabase(@ApplicationContext appContext: Context): AppDatabase = - Room.databaseBuilder( - appContext, - AppDatabase::class.java, - DATABASE_NAME - ).build() + @Suppress("SpreadOperator") + fun provideDatabase(@ApplicationContext appContext: Context): AppDatabase { + if (BuildConfig.DEBUG) { + RoomMigrationChecker.validateMigrations(appContext, migrations) + } + + val database = Room + .databaseBuilder( + appContext, + AppDatabase::class.java, + AppDatabase.DATABASE_NAME + ) + .addMigrations(*migrations.toTypedArray()) + .build() + return database + } @Provides @Singleton @@ -43,8 +60,4 @@ class DatabaseModule { @Singleton fun provideForwardingHistoryDao(database: AppDatabase): HistoryDao = database.historyDao() - - private companion object { - const val DATABASE_NAME = "sms_forwarder" - } } diff --git a/app/src/main/kotlin/org/open/smsforwarder/di/FirebaseModule.kt b/app/src/main/kotlin/org/open/smsforwarder/di/FirebaseModule.kt index 4c43336..96449ee 100644 --- a/app/src/main/kotlin/org/open/smsforwarder/di/FirebaseModule.kt +++ b/app/src/main/kotlin/org/open/smsforwarder/di/FirebaseModule.kt @@ -6,21 +6,14 @@ import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) object FirebaseModule { @Provides - @Singleton - fun provideFirebaseAuth(): FirebaseAuth { - return FirebaseAuth.getInstance() - } + fun provideFirebaseAuth(): FirebaseAuth = FirebaseAuth.getInstance() @Provides - @Singleton - fun provideFirebaseDatabase(): FirebaseDatabase { - return FirebaseDatabase.getInstance() - } + fun provideFirebaseDatabase(): FirebaseDatabase = FirebaseDatabase.getInstance() } diff --git a/app/src/main/kotlin/org/open/smsforwarder/di/Forwarders.kt b/app/src/main/kotlin/org/open/smsforwarder/di/Forwarders.kt index f081d42..2499adc 100644 --- a/app/src/main/kotlin/org/open/smsforwarder/di/Forwarders.kt +++ b/app/src/main/kotlin/org/open/smsforwarder/di/Forwarders.kt @@ -9,7 +9,7 @@ import dagger.multibindings.IntoMap import org.open.smsforwarder.domain.model.ForwardingType import org.open.smsforwarder.processing.forwarder.EmailForwarder import org.open.smsforwarder.processing.forwarder.Forwarder -import org.open.smsforwarder.processing.forwarder.SmsForwarder +import org.open.smsforwarder.processing.forwarder.TelegramForwarder @Module @InstallIn(SingletonComponent::class) @@ -22,8 +22,8 @@ abstract class Forwarders { @Binds @IntoMap - @ForwardingTypeKey(ForwardingType.SMS) - abstract fun provideSmsForwarder(smsForwarder: SmsForwarder): Forwarder + @ForwardingTypeKey(ForwardingType.TELEGRAM) + abstract fun provideTelegramForwarder(telegramForwarder: TelegramForwarder): Forwarder } @MapKey diff --git a/app/src/main/kotlin/org/open/smsforwarder/di/GoogleSignInModule.kt b/app/src/main/kotlin/org/open/smsforwarder/di/GoogleSignInModule.kt deleted file mode 100644 index 56335d9..0000000 --- a/app/src/main/kotlin/org/open/smsforwarder/di/GoogleSignInModule.kt +++ /dev/null @@ -1,39 +0,0 @@ -package org.open.smsforwarder.di - -import android.content.Context -import com.google.android.gms.auth.api.signin.GoogleSignIn -import com.google.android.gms.auth.api.signin.GoogleSignInClient -import com.google.android.gms.auth.api.signin.GoogleSignInOptions -import com.google.android.gms.common.api.Scope -import com.google.api.services.gmail.GmailScopes -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.android.components.ViewModelComponent -import dagger.hilt.android.qualifiers.ApplicationContext -import org.open.smsforwarder.BuildConfig -import org.open.smsforwarder.helper.GoogleSignInHelper - -@Module -@InstallIn(ViewModelComponent::class) -class GoogleSignInModule { - - @Provides - fun provideGoogleSignInOptions(): GoogleSignInOptions = - GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN) - .requestServerAuthCode(BuildConfig.CLIENT_ID, true) - .requestScopes(Scope(GmailScopes.GMAIL_SEND)) - .requestEmail() - .build() - - @Provides - fun provideGoogleSignInClient( - @ApplicationContext context: Context, - googleSignInOptions: GoogleSignInOptions, - ): GoogleSignInClient = GoogleSignIn.getClient(context, googleSignInOptions) - - @Provides - fun provideSignInHelper( - googleSignInClient: GoogleSignInClient, - ): GoogleSignInHelper = GoogleSignInHelper(googleSignInClient) -} diff --git a/app/src/main/kotlin/org/open/smsforwarder/di/NetworkModule.kt b/app/src/main/kotlin/org/open/smsforwarder/di/NetworkModule.kt index a543587..bda678d 100644 --- a/app/src/main/kotlin/org/open/smsforwarder/di/NetworkModule.kt +++ b/app/src/main/kotlin/org/open/smsforwarder/di/NetworkModule.kt @@ -1,6 +1,5 @@ package org.open.smsforwarder.di -import org.open.smsforwarder.BuildConfig import com.squareup.moshi.Moshi import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory import dagger.Module @@ -9,11 +8,13 @@ import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor +import org.open.smsforwarder.BuildConfig import org.open.smsforwarder.data.local.database.dao.AuthTokenDao import org.open.smsforwarder.data.remote.interceptor.AuthInterceptor import org.open.smsforwarder.data.remote.interceptor.TokenAuthenticator import org.open.smsforwarder.data.remote.service.AuthService import org.open.smsforwarder.data.remote.service.EmailService +import org.open.smsforwarder.data.remote.service.TelegramService import retrofit2.Retrofit import retrofit2.converter.moshi.MoshiConverterFactory import javax.inject.Qualifier @@ -103,6 +104,20 @@ class NetworkModule { .baseUrl(BuildConfig.API_BASE_URL) .build() .create(EmailService::class.java) + + @Provides + @Singleton + fun provideTelegramService( + okHttpClient: OkHttpClient, + moshiFactory: MoshiConverterFactory, + ): TelegramService = + Retrofit + .Builder() + .client(okHttpClient) + .addConverterFactory(moshiFactory) + .baseUrl(BuildConfig.TELEGRAM_API_BASE_URL) + .build() + .create(TelegramService::class.java) } @Qualifier diff --git a/app/src/main/kotlin/org/open/smsforwarder/di/NetworkStateModule.kt b/app/src/main/kotlin/org/open/smsforwarder/di/NetworkStateModule.kt new file mode 100644 index 0000000..7829495 --- /dev/null +++ b/app/src/main/kotlin/org/open/smsforwarder/di/NetworkStateModule.kt @@ -0,0 +1,39 @@ +package org.open.smsforwarder.di + +import android.content.Context +import android.net.ConnectivityManager +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import org.open.smsforwarder.data.NetworkStateObserverImpl +import org.open.smsforwarder.domain.NetworkStateObserver +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class NetworkStateModule { + + @Binds + @Singleton + abstract fun bindNetworkStateObserver(newStateObserverImpl: NetworkStateObserverImpl): NetworkStateObserver + + companion object { + @Provides + @Singleton + fun provideConnectivityManager( + @ApplicationContext context: Context + ): ConnectivityManager = + context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + + @Provides + @Singleton + fun provideAppCoroutineScope(): CoroutineScope = + CoroutineScope(SupervisorJob() + Dispatchers.Default) + } +} diff --git a/app/src/main/kotlin/org/open/smsforwarder/di/ValidationModule.kt b/app/src/main/kotlin/org/open/smsforwarder/di/ValidationModule.kt index 93f0e17..5bfda0b 100644 --- a/app/src/main/kotlin/org/open/smsforwarder/di/ValidationModule.kt +++ b/app/src/main/kotlin/org/open/smsforwarder/di/ValidationModule.kt @@ -5,9 +5,7 @@ import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import org.open.smsforwarder.domain.EmailValidator -import org.open.smsforwarder.domain.PhoneValidator import org.open.smsforwarder.processing.validator.EmailValidatorImpl -import org.open.smsforwarder.processing.validator.PhoneValidatorImpl @InstallIn(SingletonComponent::class) @Module @@ -15,7 +13,4 @@ interface ValidationModule { @Binds fun bindEmailPatternValidator(emailPatternVerifierImpl: EmailValidatorImpl): EmailValidator - - @Binds - fun bindPhonePatternValidator(phoneValidatorImpl: PhoneValidatorImpl): PhoneValidator } diff --git a/app/src/main/kotlin/org/open/smsforwarder/domain/IdTokenParser.kt b/app/src/main/kotlin/org/open/smsforwarder/domain/IdTokenParser.kt new file mode 100644 index 0000000..765111c --- /dev/null +++ b/app/src/main/kotlin/org/open/smsforwarder/domain/IdTokenParser.kt @@ -0,0 +1,5 @@ +package org.open.smsforwarder.domain + +interface IdTokenParser { + fun extractEmail(idToken: String): String? +} diff --git a/app/src/main/kotlin/org/open/smsforwarder/domain/NetworkStateObserver.kt b/app/src/main/kotlin/org/open/smsforwarder/domain/NetworkStateObserver.kt new file mode 100644 index 0000000..31a39ce --- /dev/null +++ b/app/src/main/kotlin/org/open/smsforwarder/domain/NetworkStateObserver.kt @@ -0,0 +1,8 @@ +package org.open.smsforwarder.domain + +import kotlinx.coroutines.flow.StateFlow +import org.open.smsforwarder.domain.model.NetworkStatus + +interface NetworkStateObserver { + val networkStatus: StateFlow +} diff --git a/app/src/main/kotlin/org/open/smsforwarder/domain/PhoneValidator.kt b/app/src/main/kotlin/org/open/smsforwarder/domain/PhoneValidator.kt deleted file mode 100644 index 2cc8489..0000000 --- a/app/src/main/kotlin/org/open/smsforwarder/domain/PhoneValidator.kt +++ /dev/null @@ -1,5 +0,0 @@ -package org.open.smsforwarder.domain - -interface PhoneValidator { - fun isValid(phoneNumber: String): Boolean -} diff --git a/app/src/main/kotlin/org/open/smsforwarder/domain/ValidationError.kt b/app/src/main/kotlin/org/open/smsforwarder/domain/ValidationError.kt new file mode 100644 index 0000000..3c0bcf9 --- /dev/null +++ b/app/src/main/kotlin/org/open/smsforwarder/domain/ValidationError.kt @@ -0,0 +1,8 @@ +package org.open.smsforwarder.domain + +enum class ValidationError { + BLANK_FIELD, + BLANK_EMAIL, + INVALID_EMAIL, + INVALID_PHONE +} diff --git a/app/src/main/kotlin/org/open/smsforwarder/domain/ValidationResult.kt b/app/src/main/kotlin/org/open/smsforwarder/domain/ValidationResult.kt index c70b512..31b3fec 100644 --- a/app/src/main/kotlin/org/open/smsforwarder/domain/ValidationResult.kt +++ b/app/src/main/kotlin/org/open/smsforwarder/domain/ValidationResult.kt @@ -1,8 +1,6 @@ package org.open.smsforwarder.domain -import org.open.smsforwarder.utils.Resources - data class ValidationResult( val successful: Boolean, - val errorMessage: Resources.StringProvider? = null + val errorType: ValidationError? = null ) diff --git a/app/src/main/kotlin/org/open/smsforwarder/domain/model/Forwarding.kt b/app/src/main/kotlin/org/open/smsforwarder/domain/model/Forwarding.kt index fe7276d..346c00d 100644 --- a/app/src/main/kotlin/org/open/smsforwarder/domain/model/Forwarding.kt +++ b/app/src/main/kotlin/org/open/smsforwarder/domain/model/Forwarding.kt @@ -5,12 +5,13 @@ data class Forwarding( val title: String = "", val forwardingType: ForwardingType? = null, val senderEmail: String? = null, - val recipientPhone: String = "", val recipientEmail: String = "", + val telegramApiToken: String = "", + val telegramChatId: String = "", val error: String = "", ) { - val isSmsForwardingType: Boolean get() = forwardingType != null && forwardingType == ForwardingType.SMS - val isEmailForwardingType: Boolean get() = forwardingType != null && forwardingType == ForwardingType.EMAIL + + val isTelegramForwardingType: Boolean get() = forwardingType != null && forwardingType == ForwardingType.TELEGRAM } diff --git a/app/src/main/kotlin/org/open/smsforwarder/domain/model/ForwardingType.kt b/app/src/main/kotlin/org/open/smsforwarder/domain/model/ForwardingType.kt index da93ae1..b9514cd 100644 --- a/app/src/main/kotlin/org/open/smsforwarder/domain/model/ForwardingType.kt +++ b/app/src/main/kotlin/org/open/smsforwarder/domain/model/ForwardingType.kt @@ -1,6 +1,6 @@ package org.open.smsforwarder.domain.model enum class ForwardingType(val value: String) { - SMS("Sms"), - EMAIL("E-mail"); + EMAIL("E-mail"), + TELEGRAM("Telegram"); } diff --git a/app/src/main/kotlin/org/open/smsforwarder/domain/model/History.kt b/app/src/main/kotlin/org/open/smsforwarder/domain/model/History.kt new file mode 100644 index 0000000..f278f1a --- /dev/null +++ b/app/src/main/kotlin/org/open/smsforwarder/domain/model/History.kt @@ -0,0 +1,9 @@ +package org.open.smsforwarder.domain.model + +data class History( + val id: Long = 0, + val date: Long? = 0, + val forwardingId: Long = 0, + val message: String = "", + val isForwardingSuccessful: Boolean = false +) diff --git a/app/src/main/kotlin/org/open/smsforwarder/domain/model/NetworkStatus.kt b/app/src/main/kotlin/org/open/smsforwarder/domain/model/NetworkStatus.kt new file mode 100644 index 0000000..7f24222 --- /dev/null +++ b/app/src/main/kotlin/org/open/smsforwarder/domain/model/NetworkStatus.kt @@ -0,0 +1,13 @@ +package org.open.smsforwarder.domain.model + +sealed class NetworkStatus { + data object Available : NetworkStatus() + data object Unavailable : NetworkStatus() + data object Losing : NetworkStatus() + data object Lost : NetworkStatus() + + fun isOnline(): Boolean = when (this) { + Available, Losing -> true + Lost, Unavailable -> false + } +} diff --git a/app/src/main/kotlin/org/open/smsforwarder/domain/usecase/ValidateBlankFieldUseCase.kt b/app/src/main/kotlin/org/open/smsforwarder/domain/usecase/ValidateBlankFieldUseCase.kt index 1403b90..f550112 100644 --- a/app/src/main/kotlin/org/open/smsforwarder/domain/usecase/ValidateBlankFieldUseCase.kt +++ b/app/src/main/kotlin/org/open/smsforwarder/domain/usecase/ValidateBlankFieldUseCase.kt @@ -1,8 +1,7 @@ package org.open.smsforwarder.domain.usecase -import org.open.smsforwarder.R +import org.open.smsforwarder.domain.ValidationError import org.open.smsforwarder.domain.ValidationResult -import org.open.smsforwarder.utils.Resources import javax.inject.Inject class ValidateBlankFieldUseCase @Inject constructor() { @@ -10,7 +9,7 @@ class ValidateBlankFieldUseCase @Inject constructor() { fun execute(field: String?): ValidationResult = if (field.isNullOrBlank()) { ValidationResult( successful = false, - errorMessage = Resources.StringResource(R.string.error_generic_is_blank) + errorType = ValidationError.BLANK_FIELD ) } else { ValidationResult(successful = true) diff --git a/app/src/main/kotlin/org/open/smsforwarder/domain/usecase/ValidateEmailUseCase.kt b/app/src/main/kotlin/org/open/smsforwarder/domain/usecase/ValidateEmailUseCase.kt index 81771f1..fb1335b 100644 --- a/app/src/main/kotlin/org/open/smsforwarder/domain/usecase/ValidateEmailUseCase.kt +++ b/app/src/main/kotlin/org/open/smsforwarder/domain/usecase/ValidateEmailUseCase.kt @@ -1,9 +1,8 @@ package org.open.smsforwarder.domain.usecase -import org.open.smsforwarder.R import org.open.smsforwarder.domain.EmailValidator +import org.open.smsforwarder.domain.ValidationError import org.open.smsforwarder.domain.ValidationResult -import org.open.smsforwarder.utils.Resources import javax.inject.Inject class ValidateEmailUseCase @Inject constructor( @@ -14,13 +13,13 @@ class ValidateEmailUseCase @Inject constructor( if (email.isBlank()) { return ValidationResult( successful = false, - errorMessage = Resources.StringResource(R.string.error_email_is_blank) + errorType = ValidationError.BLANK_EMAIL ) } return if (!emailValidator.isValid(email)) { ValidationResult( successful = false, - errorMessage = Resources.StringResource(R.string.error_email_is_not_valid) + errorType = ValidationError.INVALID_EMAIL ) } else { ValidationResult(successful = true) diff --git a/app/src/main/kotlin/org/open/smsforwarder/domain/usecase/ValidatePhoneUseCase.kt b/app/src/main/kotlin/org/open/smsforwarder/domain/usecase/ValidatePhoneUseCase.kt deleted file mode 100644 index 2d44e3b..0000000 --- a/app/src/main/kotlin/org/open/smsforwarder/domain/usecase/ValidatePhoneUseCase.kt +++ /dev/null @@ -1,24 +0,0 @@ -package org.open.smsforwarder.domain.usecase - -import org.open.smsforwarder.R -import org.open.smsforwarder.domain.PhoneValidator -import org.open.smsforwarder.domain.ValidationResult -import org.open.smsforwarder.utils.Resources -import javax.inject.Inject - -class ValidatePhoneUseCase @Inject constructor( - private val phoneValidator: PhoneValidator -) { - - fun execute(phone: String): ValidationResult = - if (!phoneValidator.isValid(phone)) { - ValidationResult( - successful = false, - errorMessage = Resources.StringResource(R.string.error_phone_number_is_not_valid) - ) - } else { - ValidationResult( - successful = true - ) - } -} diff --git a/app/src/main/kotlin/org/open/smsforwarder/extension/ContextExt.kt b/app/src/main/kotlin/org/open/smsforwarder/extension/ContextExt.kt index f430f09..7cc4ff3 100644 --- a/app/src/main/kotlin/org/open/smsforwarder/extension/ContextExt.kt +++ b/app/src/main/kotlin/org/open/smsforwarder/extension/ContextExt.kt @@ -12,12 +12,6 @@ fun Context.smsReceivePermissionGranted(): Boolean = Manifest.permission.RECEIVE_SMS ) == PackageManager.PERMISSION_GRANTED -fun Context.smsSendPermissionGranted(): Boolean = - ContextCompat.checkSelfPermission( - this, - Manifest.permission.SEND_SMS - ) == PackageManager.PERMISSION_GRANTED - fun Context.notificationsPermissionGranted(): Boolean = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { ContextCompat.checkSelfPermission( diff --git a/app/src/main/kotlin/org/open/smsforwarder/extension/DateTimeExt.kt b/app/src/main/kotlin/org/open/smsforwarder/extension/DateTimeExt.kt new file mode 100644 index 0000000..fc5511b --- /dev/null +++ b/app/src/main/kotlin/org/open/smsforwarder/extension/DateTimeExt.kt @@ -0,0 +1,16 @@ +package org.open.smsforwarder.extension + +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +private val DATE_FORMAT = ThreadLocal.withInitial { + SimpleDateFormat("HH:mm, dd.MM.yyyy", Locale.getDefault()) +} + +fun Long?.toDateTime(): String? = + runCatching { + this?.let { timestamp -> + DATE_FORMAT.get()?.format(Date(timestamp)) + } + }.getOrDefault(null) diff --git a/app/src/main/kotlin/org/open/smsforwarder/extension/StringExt.kt b/app/src/main/kotlin/org/open/smsforwarder/extension/StringExt.kt new file mode 100644 index 0000000..614c677 --- /dev/null +++ b/app/src/main/kotlin/org/open/smsforwarder/extension/StringExt.kt @@ -0,0 +1,3 @@ +package org.open.smsforwarder.extension + +fun String.normalizeSpaces(): String = this.replace('\u00A0', ' ').trim() diff --git a/app/src/main/kotlin/org/open/smsforwarder/extension/ValidationErrorMessageExt.kt b/app/src/main/kotlin/org/open/smsforwarder/extension/ValidationErrorMessageExt.kt new file mode 100644 index 0000000..2a420d5 --- /dev/null +++ b/app/src/main/kotlin/org/open/smsforwarder/extension/ValidationErrorMessageExt.kt @@ -0,0 +1,14 @@ +package org.open.smsforwarder.extension + +import org.open.smsforwarder.R +import org.open.smsforwarder.domain.ValidationError +import org.open.smsforwarder.utils.Resources + +fun ValidationError.getStringProvider(): Resources.StringProvider { + return when (this) { + ValidationError.BLANK_FIELD -> Resources.StringResource(R.string.error_generic_is_blank) + ValidationError.BLANK_EMAIL -> Resources.StringResource(R.string.error_email_is_blank) + ValidationError.INVALID_EMAIL -> Resources.StringResource(R.string.error_email_is_not_valid) + ValidationError.INVALID_PHONE -> Resources.StringResource(R.string.error_phone_number_is_not_valid) + } +} diff --git a/app/src/main/kotlin/org/open/smsforwarder/extension/ViewExt.kt b/app/src/main/kotlin/org/open/smsforwarder/extension/ViewExt.kt index f590bb2..8fb6a1b 100644 --- a/app/src/main/kotlin/org/open/smsforwarder/extension/ViewExt.kt +++ b/app/src/main/kotlin/org/open/smsforwarder/extension/ViewExt.kt @@ -1,9 +1,18 @@ package org.open.smsforwarder.extension +import android.annotation.SuppressLint +import android.graphics.Color +import android.graphics.drawable.GradientDrawable +import android.view.Gravity import android.view.View +import android.view.ViewGroup +import android.view.accessibility.AccessibilityEvent import android.widget.EditText +import android.widget.PopupWindow import android.widget.RadioButton import android.widget.TextView +import androidx.annotation.StringRes +import androidx.core.graphics.toColorInt import androidx.core.view.isVisible fun TextView.setTextIfChanged(newValue: String?) { @@ -33,3 +42,76 @@ fun RadioButton.setValueIfChanged(newValue: Boolean) { isChecked = newValue } } + +fun View.setAccessibilityFocus() { + this.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_FOCUSED) + this.requestFocus() +} + +@SuppressLint("ClickableViewAccessibility") +fun View.showTooltip( + @StringRes messageResId: Int, + textColor: Int = Color.WHITE, + backgroundColor: Int = "#7E49FF".toColorInt(), + cornerRadiusValue: Float = 24f, + leftPadding: Int = 20, + topPadding: Int = 10, + rightPadding: Int = 20, + bottomPadding: Int = 10 +) { + val context = this.context + + val background = GradientDrawable().apply { + setColor(backgroundColor) + cornerRadius = cornerRadiusValue + } + + val tooltipText = TextView(context).apply { + text = context.getString(messageResId) + setTextColor(textColor) + setBackground(background) + setPadding( + leftPadding, + topPadding, + rightPadding, + bottomPadding + ) + } + + var popupWindow: PopupWindow? = PopupWindow( + tooltipText, + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + false + ).apply { + isOutsideTouchable = true + isClippingEnabled = true + } + + val location = IntArray(2) + this.getLocationOnScreen(location) + + popupWindow?.showAtLocation( + this, + Gravity.NO_GRAVITY, + location[0], + location[1] + this.height + ) + + this.addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener { + override fun onViewDetachedFromWindow(v: View) { + popupWindow?.takeIf { it.isShowing }?.dismiss() + } + + override fun onViewAttachedToWindow(v: View) = Unit + }) + + popupWindow?.contentView?.setOnTouchListener { _, _ -> + popupWindow?.takeIf { it.isShowing }?.dismiss() + true + } + + popupWindow?.setOnDismissListener { + popupWindow = null + } +} diff --git a/app/src/main/kotlin/org/open/smsforwarder/helper/GoogleSignInHelper.kt b/app/src/main/kotlin/org/open/smsforwarder/helper/GoogleSignInHelper.kt deleted file mode 100644 index cff70a4..0000000 --- a/app/src/main/kotlin/org/open/smsforwarder/helper/GoogleSignInHelper.kt +++ /dev/null @@ -1,30 +0,0 @@ -package org.open.smsforwarder.helper - -import android.content.Intent -import com.google.android.gms.auth.api.signin.GoogleSignIn -import com.google.android.gms.auth.api.signin.GoogleSignInAccount -import com.google.android.gms.auth.api.signin.GoogleSignInClient -import com.google.android.gms.tasks.Tasks -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import javax.inject.Inject - -class GoogleSignInHelper @Inject constructor( - private val googleSignInClient: GoogleSignInClient, -) { - - val signInIntent: Intent - get() { - // Call googleSignInClient.signOut() before getting signInIntent every time auth flow starts. - // Otherwise last logged in account will be used as default. "Choose an account" dialog will not be shown - googleSignInClient.signOut() - return googleSignInClient.signInIntent - } - - suspend fun getGoogleSignInAccount(data: Intent?): Result = - runCatching { - withContext(Dispatchers.IO) { - Tasks.await(GoogleSignIn.getSignedInAccountFromIntent(data)) - } - } -} diff --git a/app/src/main/kotlin/org/open/smsforwarder/navigation/Screens.kt b/app/src/main/kotlin/org/open/smsforwarder/navigation/Screens.kt index ec4806a..13cd625 100644 --- a/app/src/main/kotlin/org/open/smsforwarder/navigation/Screens.kt +++ b/app/src/main/kotlin/org/open/smsforwarder/navigation/Screens.kt @@ -1,11 +1,12 @@ package org.open.smsforwarder.navigation import com.github.terrakok.cicerone.androidx.FragmentScreen +import org.open.smsforwarder.ui.feedback.FeedbackFragment +import org.open.smsforwarder.ui.history.HistoryFragment import org.open.smsforwarder.ui.home.HomeFragment import org.open.smsforwarder.ui.onboarding.OnboardingFragment -import org.open.smsforwarder.ui.feedback.FeedbackFragment import org.open.smsforwarder.ui.steps.addrecipientdetails.addemaildetails.AddEmailDetailsFragment -import org.open.smsforwarder.ui.steps.addrecipientdetails.addphonedetails.AddPhoneDetailsFragment +import org.open.smsforwarder.ui.steps.addrecipientdetails.addtelegramdetails.AddTelegramDetailsFragment import org.open.smsforwarder.ui.steps.addrule.AddForwardingRuleFragment import org.open.smsforwarder.ui.steps.choosemethod.ChooseForwardingMethodFragment @@ -15,16 +16,18 @@ object Screens { fun chooseForwardingMethodFragment(id: Long) = FragmentScreen { ChooseForwardingMethodFragment.newInstance(id) } - fun addPhoneDetailsFragment(id: Long) = - FragmentScreen { AddPhoneDetailsFragment.newInstance(id) } - fun addEmailDetailsFragment(id: Long) = FragmentScreen { AddEmailDetailsFragment.newInstance(id) } + fun addTelegramDetailsFragment(id: Long) = + FragmentScreen { AddTelegramDetailsFragment.newInstance(id) } + fun addForwardingRuleFragment(id: Long) = FragmentScreen { AddForwardingRuleFragment.newInstance(id) } fun onboardingFragment() = FragmentScreen { OnboardingFragment() } + fun historyFragment() = FragmentScreen { HistoryFragment() } + fun feedbackFragment() = FragmentScreen { FeedbackFragment() } } diff --git a/app/src/main/kotlin/org/open/smsforwarder/platform/CredentialClientWrapper.kt b/app/src/main/kotlin/org/open/smsforwarder/platform/CredentialClientWrapper.kt new file mode 100644 index 0000000..dc2fc4e --- /dev/null +++ b/app/src/main/kotlin/org/open/smsforwarder/platform/CredentialClientWrapper.kt @@ -0,0 +1,7 @@ +package org.open.smsforwarder.platform + +import android.app.Activity + +interface CredentialClientWrapper { + suspend fun getCredential(activity: Activity) +} diff --git a/app/src/main/kotlin/org/open/smsforwarder/platform/CredentialClientWrapperImpl.kt b/app/src/main/kotlin/org/open/smsforwarder/platform/CredentialClientWrapperImpl.kt new file mode 100644 index 0000000..b0ad04e --- /dev/null +++ b/app/src/main/kotlin/org/open/smsforwarder/platform/CredentialClientWrapperImpl.kt @@ -0,0 +1,15 @@ +package org.open.smsforwarder.platform + +import android.app.Activity +import androidx.credentials.CredentialManager +import androidx.credentials.GetCredentialRequest +import javax.inject.Inject + +class CredentialClientWrapperImpl @Inject constructor( + private val getCredentialRequest: GetCredentialRequest +) : CredentialClientWrapper { + + override suspend fun getCredential(activity: Activity) { + CredentialManager.create(activity).getCredential(activity, getCredentialRequest) + } +} diff --git a/app/src/main/kotlin/org/open/smsforwarder/platform/GoogleAuthClient.kt b/app/src/main/kotlin/org/open/smsforwarder/platform/GoogleAuthClient.kt new file mode 100644 index 0000000..14c3165 --- /dev/null +++ b/app/src/main/kotlin/org/open/smsforwarder/platform/GoogleAuthClient.kt @@ -0,0 +1,65 @@ +package org.open.smsforwarder.platform + +import android.app.Activity +import android.content.Intent +import androidx.credentials.exceptions.GetCredentialCancellationException +import androidx.credentials.exceptions.GetCredentialException +import com.google.android.gms.auth.api.identity.AuthorizationResult +import com.google.android.gms.common.api.ApiException +import org.open.smsforwarder.data.remote.dto.SignInResult +import org.open.smsforwarder.domain.NetworkStateObserver +import javax.inject.Inject + +class GoogleAuthClient @Inject constructor( + private val credentialClientWrapper: CredentialClientWrapper, + private val identityClientWrapper: IdentityClientWrapper, + private val networkStateObserver: NetworkStateObserver +) { + + suspend fun getSignInIntent(activity: Activity): SignInResult { + fetchCredentials(activity) + val authorizationResult = authorize(activity) + val intentSender = authorizationResult?.pendingIntent?.intentSender + ?: throw GoogleSignInFailure.MissingPendingIntent + + return SignInResult(intentSender) + } + + + fun extractAuthorizationCode(data: Intent?): String { + data ?: throw GoogleSignInFailure.AuthResultIntentIsNull + val authorizationResult = identityClientWrapper.getAuthorizationResultFromIntent(data) + return authorizationResult.serverAuthCode ?: throw GoogleSignInFailure.MissingAuthCode + } + + private suspend fun fetchCredentials(activity: Activity) { + try { + credentialClientWrapper.getCredential(activity) + } catch (e: GetCredentialCancellationException) { + if (!networkStateObserver.networkStatus.value.isOnline()) { + throw GoogleSignInFailure.NoInternetConnection(e) + } + throw GoogleSignInFailure.CredentialCancellation(e) + } catch (e: GetCredentialException) { + throw GoogleSignInFailure.CredentialsNotFound(e) + } + } + + private suspend fun authorize(activity: Activity): AuthorizationResult? { + return try { + identityClientWrapper.authorize(activity) + } catch (e: ApiException) { + throw GoogleSignInFailure.AuthorizationFailed(e) + } + } +} + +sealed class GoogleSignInFailure(cause: Throwable? = null) : RuntimeException(cause) { + class CredentialsNotFound(cause: Throwable? = null) : GoogleSignInFailure(cause) + class CredentialCancellation(cause: Throwable? = null) : GoogleSignInFailure(cause) + class NoInternetConnection(cause: Throwable? = null) : GoogleSignInFailure(cause) + class AuthorizationFailed(cause: Throwable? = null) : GoogleSignInFailure(cause) + data object AuthResultIntentIsNull : GoogleSignInFailure() + data object MissingPendingIntent : GoogleSignInFailure() + data object MissingAuthCode : GoogleSignInFailure() +} diff --git a/app/src/main/kotlin/org/open/smsforwarder/platform/IdentityClientWrapper.kt b/app/src/main/kotlin/org/open/smsforwarder/platform/IdentityClientWrapper.kt new file mode 100644 index 0000000..9ee7389 --- /dev/null +++ b/app/src/main/kotlin/org/open/smsforwarder/platform/IdentityClientWrapper.kt @@ -0,0 +1,10 @@ +package org.open.smsforwarder.platform + +import android.app.Activity +import android.content.Intent +import com.google.android.gms.auth.api.identity.AuthorizationResult + +interface IdentityClientWrapper { + suspend fun authorize(activity: Activity): AuthorizationResult? + fun getAuthorizationResultFromIntent(data: Intent): AuthorizationResult +} diff --git a/app/src/main/kotlin/org/open/smsforwarder/platform/IdentityClientWrapperImpl.kt b/app/src/main/kotlin/org/open/smsforwarder/platform/IdentityClientWrapperImpl.kt new file mode 100644 index 0000000..66e261e --- /dev/null +++ b/app/src/main/kotlin/org/open/smsforwarder/platform/IdentityClientWrapperImpl.kt @@ -0,0 +1,30 @@ +package org.open.smsforwarder.platform + +import android.app.Activity +import android.content.Context +import android.content.Intent +import com.google.android.gms.auth.api.identity.AuthorizationRequest +import com.google.android.gms.auth.api.identity.AuthorizationResult +import com.google.android.gms.auth.api.identity.Identity +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.tasks.await +import javax.inject.Inject + +class IdentityClientWrapperImpl @Inject constructor( + @ApplicationContext private val applicationContext: Context, + private val authorizationRequest: AuthorizationRequest, +) : IdentityClientWrapper { + + override suspend fun authorize(activity: Activity): AuthorizationResult = + Identity.getAuthorizationClient(activity) + .authorize(authorizationRequest) + .await() + + override fun getAuthorizationResultFromIntent( + data: Intent + ): AuthorizationResult = + Identity + .getAuthorizationClient(applicationContext) + .getAuthorizationResultFromIntent(data) + +} diff --git a/app/src/main/kotlin/org/open/smsforwarder/processing/forwarder/EmailForwarder.kt b/app/src/main/kotlin/org/open/smsforwarder/processing/forwarder/EmailForwarder.kt index 0f52990..f36ed38 100644 --- a/app/src/main/kotlin/org/open/smsforwarder/processing/forwarder/EmailForwarder.kt +++ b/app/src/main/kotlin/org/open/smsforwarder/processing/forwarder/EmailForwarder.kt @@ -3,6 +3,7 @@ package org.open.smsforwarder.processing.forwarder import org.open.smsforwarder.data.remote.service.EmailService import org.open.smsforwarder.domain.model.Forwarding import org.open.smsforwarder.processing.composer.EmailComposer +import org.open.smsforwarder.utils.runSuspendCatching import javax.inject.Inject class EmailForwarder @Inject constructor( @@ -11,7 +12,7 @@ class EmailForwarder @Inject constructor( ) : Forwarder { override suspend fun execute(forwarding: Forwarding, message: String): Result = - runCatching { + runSuspendCatching { val emailMessage = emailComposer.composeMessage( toEmailAddress = forwarding.recipientEmail, subject = DEFAULT_SUBJECT, diff --git a/app/src/main/kotlin/org/open/smsforwarder/processing/forwarder/SmsForwarder.kt b/app/src/main/kotlin/org/open/smsforwarder/processing/forwarder/SmsForwarder.kt deleted file mode 100644 index 7433abe..0000000 --- a/app/src/main/kotlin/org/open/smsforwarder/processing/forwarder/SmsForwarder.kt +++ /dev/null @@ -1,22 +0,0 @@ -package org.open.smsforwarder.processing.forwarder - -import android.content.Context -import android.telephony.SmsManager -import androidx.core.content.ContextCompat -import dagger.hilt.android.qualifiers.ApplicationContext -import org.open.smsforwarder.domain.model.Forwarding -import javax.inject.Inject - -class SmsForwarder @Inject constructor( - @ApplicationContext private val context: Context, -) : Forwarder { - - override suspend fun execute(forwarding: Forwarding, message: String): Result = - runCatching { - val smsManager: SmsManager? = ContextCompat.getSystemService( - context, - SmsManager::class.java - ) - smsManager?.sendTextMessage(forwarding.recipientPhone, null, message, null, null) - } -} diff --git a/app/src/main/kotlin/org/open/smsforwarder/processing/forwarder/TelegramForwarder.kt b/app/src/main/kotlin/org/open/smsforwarder/processing/forwarder/TelegramForwarder.kt new file mode 100644 index 0000000..9a35a3f --- /dev/null +++ b/app/src/main/kotlin/org/open/smsforwarder/processing/forwarder/TelegramForwarder.kt @@ -0,0 +1,20 @@ +package org.open.smsforwarder.processing.forwarder + +import org.open.smsforwarder.data.remote.service.TelegramService +import org.open.smsforwarder.domain.model.Forwarding +import org.open.smsforwarder.utils.runSuspendCatching +import javax.inject.Inject + +class TelegramForwarder @Inject constructor( + private val telegramService: TelegramService +) : Forwarder { + + override suspend fun execute(forwarding: Forwarding, message: String): Result = + runSuspendCatching { + telegramService.sendMessage( + apiToken = forwarding.telegramApiToken, + chatId = forwarding.telegramChatId, + text = message + ) + } +} diff --git a/app/src/main/kotlin/org/open/smsforwarder/processing/processor/ForwardingProcessor.kt b/app/src/main/kotlin/org/open/smsforwarder/processing/processor/ForwardingProcessor.kt index 61e9d94..920f691 100644 --- a/app/src/main/kotlin/org/open/smsforwarder/processing/processor/ForwardingProcessor.kt +++ b/app/src/main/kotlin/org/open/smsforwarder/processing/processor/ForwardingProcessor.kt @@ -8,6 +8,7 @@ import org.open.smsforwarder.data.repository.HistoryRepository import org.open.smsforwarder.data.repository.RulesRepository import org.open.smsforwarder.domain.model.Forwarding import org.open.smsforwarder.domain.model.ForwardingType +import org.open.smsforwarder.extension.normalizeSpaces import org.open.smsforwarder.processing.forwarder.Forwarder import javax.inject.Inject @@ -26,8 +27,9 @@ class ForwardingProcessor @Inject constructor( val messagesToForward = mutableListOf>() messages.forEach { message -> + val normalizedMessage = message.normalizeSpaces() rules.forEach { rule -> - if (message.contains(rule.textRule)) { + if (normalizedMessage.contains(rule.textRule.normalizeSpaces())) { messagesToForward.add(rule.forwardingId to message) } } @@ -38,30 +40,39 @@ class ForwardingProcessor @Inject constructor( forwarders[recipient.forwardingType] ?.execute(recipient, message) ?.onSuccess { - updateForwardingStatus(recipient, message, "") + postProcessForwarding(recipient, message, "") } ?.onFailure { error -> - updateForwardingStatus(recipient, message, error.message.orEmpty()) - if (error is TokenRevokedException || error is RefreshTokenException) { - authRepository.signOut(recipient) - } + postProcessForwarding( + recipient, + message, + error.message.orEmpty() + ) + handleTokenErrors(error, recipient) } } } } - private suspend fun updateForwardingStatus( + private suspend fun postProcessForwarding( forwarding: Forwarding, message: String, - errorText: String + errorText: String, ) { forwardingRepository.insertOrUpdateForwarding( forwarding.copy(error = errorText) ) - historyRepository.insertOrUpdateForwardedSms( + historyRepository.insertForwardedSms( forwardingId = forwarding.id, message = message, - isForwardingSuccessful = errorText.isEmpty() + isForwardingSuccessful = errorText.isEmpty(), ) } + + private suspend fun handleTokenErrors(error: Throwable, recipient: Forwarding) { + if (error is TokenRevokedException || error is RefreshTokenException) { + authRepository.signOut(recipient.id) + forwardingRepository.insertOrUpdateForwarding(recipient.copy(senderEmail = null)) + } + } } diff --git a/app/src/main/kotlin/org/open/smsforwarder/processing/validator/PhoneValidatorImpl.kt b/app/src/main/kotlin/org/open/smsforwarder/processing/validator/PhoneValidatorImpl.kt deleted file mode 100644 index 850f10f..0000000 --- a/app/src/main/kotlin/org/open/smsforwarder/processing/validator/PhoneValidatorImpl.kt +++ /dev/null @@ -1,16 +0,0 @@ -package org.open.smsforwarder.processing.validator - -import org.open.smsforwarder.domain.PhoneValidator -import java.util.regex.Pattern -import javax.inject.Inject - -class PhoneValidatorImpl @Inject constructor() : PhoneValidator { - - private val pattern: Pattern = Pattern.compile(PHONE_PATTERN) - - override fun isValid(phoneNumber: String): Boolean = pattern.matcher(phoneNumber).matches() - - private companion object { - const val PHONE_PATTERN = "^[+][0-9]{10,13}$" - } -} diff --git a/app/src/main/kotlin/org/open/smsforwarder/ui/dialog/permission/PermissionsDialog.kt b/app/src/main/kotlin/org/open/smsforwarder/ui/dialog/permission/PermissionsDialog.kt index 3ecb0eb..7c35fae 100644 --- a/app/src/main/kotlin/org/open/smsforwarder/ui/dialog/permission/PermissionsDialog.kt +++ b/app/src/main/kotlin/org/open/smsforwarder/ui/dialog/permission/PermissionsDialog.kt @@ -1,12 +1,12 @@ package org.open.smsforwarder.ui.dialog.permission import android.app.Dialog -import androidx.appcompat.app.AlertDialog import android.content.Intent import android.net.Uri import android.os.Bundle import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.StringRes +import androidx.appcompat.app.AlertDialog import androidx.core.os.bundleOf import androidx.fragment.app.DialogFragment import org.open.smsforwarder.R @@ -14,7 +14,6 @@ import org.open.smsforwarder.analytics.AnalyticsEvents.PERMISSIONS_DIALOG_GO_TO_ import org.open.smsforwarder.analytics.AnalyticsTracker import org.open.smsforwarder.extension.notificationsPermissionGranted import org.open.smsforwarder.extension.smsReceivePermissionGranted -import org.open.smsforwarder.extension.smsSendPermissionGranted import javax.inject.Inject class PermissionsDialog : DialogFragment() { @@ -25,7 +24,7 @@ class PermissionsDialog : DialogFragment() { private val systemSettingsStartForResultLauncher = registerForActivityResult( ActivityResultContracts.StartActivityForResult() ) { _ -> - if (requireActivity().smsReceivePermissionGranted() && requireActivity().smsSendPermissionGranted() + if (requireActivity().smsReceivePermissionGranted() && requireActivity().notificationsPermissionGranted() ) { dismiss() diff --git a/app/src/main/kotlin/org/open/smsforwarder/ui/feedback/FeedbackFragment.kt b/app/src/main/kotlin/org/open/smsforwarder/ui/feedback/FeedbackFragment.kt index 5598dc1..5a6be0e 100644 --- a/app/src/main/kotlin/org/open/smsforwarder/ui/feedback/FeedbackFragment.kt +++ b/app/src/main/kotlin/org/open/smsforwarder/ui/feedback/FeedbackFragment.kt @@ -47,8 +47,8 @@ class FeedbackFragment : Fragment(R.layout.fragment_feedback) { private fun renderState(state: FeedbackState) { with(binding) { submitBtn.isEnabled = state.submitButtonEnabled - emailEtLayout.error = state.emailInputError?.asString(requireContext()) - bodyEtLayout.error = state.bodyInputError?.asString(requireContext()) + emailEtLayout.error = state.emailInputErrorProvider?.asString(requireContext()) + bodyEtLayout.error = state.bodyInputErrorProvider?.asString(requireContext()) } } diff --git a/app/src/main/kotlin/org/open/smsforwarder/ui/feedback/FeedbackState.kt b/app/src/main/kotlin/org/open/smsforwarder/ui/feedback/FeedbackState.kt index 3b74e92..09538e0 100644 --- a/app/src/main/kotlin/org/open/smsforwarder/ui/feedback/FeedbackState.kt +++ b/app/src/main/kotlin/org/open/smsforwarder/ui/feedback/FeedbackState.kt @@ -3,12 +3,12 @@ package org.open.smsforwarder.ui.feedback import org.open.smsforwarder.utils.Resources data class FeedbackState( - val emailInputError: Resources.StringProvider? = null, - val bodyInputError: Resources.StringProvider? = null, + val emailInputErrorProvider: Resources.StringProvider? = null, + val bodyInputErrorProvider: Resources.StringProvider? = null, val emailInput: String? = null, val bodyInput: String? = null ) { - private val hasNoInputErrors = (emailInputError == null) && (bodyInputError == null) + private val hasNoInputErrors = (emailInputErrorProvider == null) && (bodyInputErrorProvider == null) private val hasValues = !emailInput.isNullOrBlank() && !bodyInput.isNullOrBlank() val submitButtonEnabled = hasNoInputErrors && hasValues } diff --git a/app/src/main/kotlin/org/open/smsforwarder/ui/feedback/FeedbackViewModel.kt b/app/src/main/kotlin/org/open/smsforwarder/ui/feedback/FeedbackViewModel.kt index 81a93a2..42408cb 100644 --- a/app/src/main/kotlin/org/open/smsforwarder/ui/feedback/FeedbackViewModel.kt +++ b/app/src/main/kotlin/org/open/smsforwarder/ui/feedback/FeedbackViewModel.kt @@ -15,6 +15,8 @@ import org.open.smsforwarder.R import org.open.smsforwarder.data.repository.FeedbackRepository import org.open.smsforwarder.domain.usecase.ValidateBlankFieldUseCase import org.open.smsforwarder.domain.usecase.ValidateEmailUseCase +import org.open.smsforwarder.extension.getStringProvider +import org.open.smsforwarder.utils.runSuspendCatching import javax.inject.Inject @HiltViewModel @@ -40,13 +42,16 @@ class FeedbackViewModel @Inject constructor( body: String, ) { viewModelScope.launch { - val result = feedbackRepository.sendFeedback(email, body) - val messageRes = when (result) { - true -> R.string.feedback_success - false -> R.string.feedback_failure + runSuspendCatching { + feedbackRepository.sendFeedback(email, body) } - _viewEffect.trySend(SubmitResultEffect(messageRes)) - router.exit() + .onSuccess { + _viewEffect.trySend(SubmitResultEffect(R.string.feedback_success)) + router.exit() + } + .onFailure { + _viewEffect.trySend(SubmitResultEffect(R.string.feedback_failure)) + } } } @@ -55,7 +60,7 @@ class FeedbackViewModel @Inject constructor( _viewState.update { it.copy( emailInput = email, - emailInputError = emailValidationResult.errorMessage + emailInputErrorProvider = emailValidationResult.errorType?.getStringProvider() ) } } @@ -65,7 +70,7 @@ class FeedbackViewModel @Inject constructor( _viewState.update { state -> state.copy( bodyInput = body, - bodyInputError = bodyValidationResult.errorMessage + bodyInputErrorProvider = bodyValidationResult.errorType?.getStringProvider() ) } } diff --git a/app/src/main/kotlin/org/open/smsforwarder/ui/history/HistoryFragment.kt b/app/src/main/kotlin/org/open/smsforwarder/ui/history/HistoryFragment.kt new file mode 100644 index 0000000..eb2070e --- /dev/null +++ b/app/src/main/kotlin/org/open/smsforwarder/ui/history/HistoryFragment.kt @@ -0,0 +1,54 @@ +package org.open.smsforwarder.ui.history + +import android.os.Bundle +import android.view.View +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import by.kirich1409.viewbindingdelegate.viewBinding +import dagger.hilt.android.AndroidEntryPoint +import org.open.smsforwarder.R +import org.open.smsforwarder.databinding.FragmentForwardingHistoryBinding +import org.open.smsforwarder.extension.bindClicksTo +import org.open.smsforwarder.extension.observeWithLifecycle +import org.open.smsforwarder.extension.setAccessibilityFocus +import org.open.smsforwarder.extension.unsafeLazy +import org.open.smsforwarder.ui.history.adapter.HistoryAdapter + +@AndroidEntryPoint +class HistoryFragment : Fragment(R.layout.fragment_forwarding_history) { + + private val binding by viewBinding(FragmentForwardingHistoryBinding::bind) + private val viewModel: HistoryViewModel by viewModels() + private val adapter by unsafeLazy { HistoryAdapter() } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + setUpAdapter() + setUpListeners() + setObservers() + } + + override fun onStart() { + super.onStart() + binding.titleLabel.setAccessibilityFocus() + } + + private fun setUpListeners() { + binding.arrowBackIv bindClicksTo viewModel::onBackClicked + } + + private fun setObservers() { + viewModel.viewState.observeWithLifecycle(viewLifecycleOwner, action = ::renderState) + } + + private fun renderState(state: HistoryState) { + adapter.submitList(state.historyItems) + binding.emptyStateText.isVisible = state.isEmptyStateTextVisible + binding.historyItems.isVisible = state.isHistoryItemsVisible + } + + private fun setUpAdapter() { + binding.historyItems.adapter = adapter + } +} diff --git a/app/src/main/kotlin/org/open/smsforwarder/ui/history/HistoryState.kt b/app/src/main/kotlin/org/open/smsforwarder/ui/history/HistoryState.kt new file mode 100644 index 0000000..5014139 --- /dev/null +++ b/app/src/main/kotlin/org/open/smsforwarder/ui/history/HistoryState.kt @@ -0,0 +1,14 @@ +package org.open.smsforwarder.ui.history + +import org.open.smsforwarder.ui.model.HistoryUI + +data class HistoryState( + val historyItems: List = emptyList(), +) { + + val isHistoryItemsVisible: Boolean + get() = historyItems.isNotEmpty() + + val isEmptyStateTextVisible: Boolean + get() = historyItems.isEmpty() +} diff --git a/app/src/main/kotlin/org/open/smsforwarder/ui/history/HistoryViewModel.kt b/app/src/main/kotlin/org/open/smsforwarder/ui/history/HistoryViewModel.kt new file mode 100644 index 0000000..db29a23 --- /dev/null +++ b/app/src/main/kotlin/org/open/smsforwarder/ui/history/HistoryViewModel.kt @@ -0,0 +1,40 @@ +package org.open.smsforwarder.ui.history + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.terrakok.cicerone.Router +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import org.open.smsforwarder.data.repository.HistoryRepository +import org.open.smsforwarder.extension.asStateFlowWithInitialAction +import org.open.smsforwarder.extension.launchAndCancelPrevious +import org.open.smsforwarder.ui.mapper.toHistoryUi +import javax.inject.Inject + +@HiltViewModel +class HistoryViewModel @Inject constructor( + private val historyRepository: HistoryRepository, + private val router: Router, +) : ViewModel() { + + private var _viewState = MutableStateFlow(HistoryState()) + val viewState = _viewState.asStateFlowWithInitialAction(viewModelScope) { loadData() } + + + fun onBackClicked() { + router.exit() + } + + private fun loadData() { + launchAndCancelPrevious { + historyRepository + .getForwardingHistoryFlow() + .collect { result -> + _viewState.update { + it.copy(historyItems = result.map { resultItem -> resultItem.toHistoryUi() }) + } + } + } + } +} diff --git a/app/src/main/kotlin/org/open/smsforwarder/ui/history/adapter/HistoryAdapter.kt b/app/src/main/kotlin/org/open/smsforwarder/ui/history/adapter/HistoryAdapter.kt new file mode 100644 index 0000000..4dba5a7 --- /dev/null +++ b/app/src/main/kotlin/org/open/smsforwarder/ui/history/adapter/HistoryAdapter.kt @@ -0,0 +1,19 @@ +package org.open.smsforwarder.ui.history.adapter + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.ListAdapter +import org.open.smsforwarder.databinding.ItemHistoryBinding +import org.open.smsforwarder.ui.model.HistoryUI + +class HistoryAdapter : ListAdapter(SmsHistoryDiffCallback()) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HistoryViewHolder { + val binding = ItemHistoryBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return HistoryViewHolder(binding) + } + + override fun onBindViewHolder(holder: HistoryViewHolder, position: Int) { + holder.bind(getItem(position)) + } +} diff --git a/app/src/main/kotlin/org/open/smsforwarder/ui/history/adapter/HistoryViewHolder.kt b/app/src/main/kotlin/org/open/smsforwarder/ui/history/adapter/HistoryViewHolder.kt new file mode 100644 index 0000000..0458fe8 --- /dev/null +++ b/app/src/main/kotlin/org/open/smsforwarder/ui/history/adapter/HistoryViewHolder.kt @@ -0,0 +1,30 @@ +package org.open.smsforwarder.ui.history.adapter + +import androidx.recyclerview.widget.RecyclerView.ViewHolder +import org.open.smsforwarder.R +import org.open.smsforwarder.databinding.ItemHistoryBinding +import org.open.smsforwarder.extension.toDateTime +import org.open.smsforwarder.ui.model.HistoryUI + +class HistoryViewHolder( + private val binding: ItemHistoryBinding, +) : ViewHolder(binding.root) { + + fun bind(item: HistoryUI) = with(binding) { + val context = itemView.context + dateLabel.contentDescription = context.getString( + R.string.forwarding_history_item_date_content_description, + item.id.toString() + ) + date.text = item.date.toDateTime() + message.text = item.message + + if (item.isForwardingSuccessful) { + status.text = context.getString(R.string.forwarding_history_item_status_success) + status.setTextColor(context.getColor(R.color.green)) + } else { + status.text = context.getString(R.string.forwarding_history_item_status_fail) + status.setTextColor(context.getColor(R.color.red)) + } + } +} diff --git a/app/src/main/kotlin/org/open/smsforwarder/ui/history/adapter/SmsHistoryDiffCallback.kt b/app/src/main/kotlin/org/open/smsforwarder/ui/history/adapter/SmsHistoryDiffCallback.kt new file mode 100644 index 0000000..1aa2578 --- /dev/null +++ b/app/src/main/kotlin/org/open/smsforwarder/ui/history/adapter/SmsHistoryDiffCallback.kt @@ -0,0 +1,15 @@ +package org.open.smsforwarder.ui.history.adapter + +import androidx.recyclerview.widget.DiffUtil +import org.open.smsforwarder.ui.model.HistoryUI + +class SmsHistoryDiffCallback : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(oldItem: HistoryUI, newItem: HistoryUI) = + oldItem.id == newItem.id + + override fun areContentsTheSame(oldItem: HistoryUI, newItem: HistoryUI) = + oldItem.isForwardingSuccessful == newItem.isForwardingSuccessful && + oldItem.date == newItem.date && + oldItem.message == newItem.message +} diff --git a/app/src/main/kotlin/org/open/smsforwarder/ui/home/HomeFragment.kt b/app/src/main/kotlin/org/open/smsforwarder/ui/home/HomeFragment.kt index 02f89f5..8f5a770 100644 --- a/app/src/main/kotlin/org/open/smsforwarder/ui/home/HomeFragment.kt +++ b/app/src/main/kotlin/org/open/smsforwarder/ui/home/HomeFragment.kt @@ -28,6 +28,7 @@ import org.open.smsforwarder.analytics.AnalyticsTracker import org.open.smsforwarder.databinding.FragmentHomeBinding import org.open.smsforwarder.extension.bindClicksTo import org.open.smsforwarder.extension.observeWithLifecycle +import org.open.smsforwarder.extension.setAccessibilityFocus import org.open.smsforwarder.extension.showOkDialog import org.open.smsforwarder.extension.unsafeLazy import org.open.smsforwarder.ui.dialog.delete.DeleteDialog @@ -52,7 +53,6 @@ class HomeFragment : Fragment(R.layout.fragment_home), DeleteDialogListener { private val permissions = mutableListOf().apply { add(Manifest.permission.RECEIVE_SMS) - add(Manifest.permission.SEND_SMS) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { add(Manifest.permission.POST_NOTIFICATIONS) } @@ -77,6 +77,7 @@ class HomeFragment : Fragment(R.layout.fragment_home), DeleteDialogListener { override fun onStart() { super.onStart() + binding.titleLabel.setAccessibilityFocus() requestPermissions() } @@ -96,6 +97,7 @@ class HomeFragment : Fragment(R.layout.fragment_home), DeleteDialogListener { powerManagementWarningIv bindClicksTo viewModel::onBatteryOptimizationWarningClicked feedbackIv bindClicksTo viewModel::onFeedbackClicked startNewForwardingBtn bindClicksTo viewModel::onNewForwardingClicked + binding.forwardingHistory bindClicksTo viewModel::onSmsHistoryClicked } } diff --git a/app/src/main/kotlin/org/open/smsforwarder/ui/home/HomeViewModel.kt b/app/src/main/kotlin/org/open/smsforwarder/ui/home/HomeViewModel.kt index 5740ef8..6bb536c 100644 --- a/app/src/main/kotlin/org/open/smsforwarder/ui/home/HomeViewModel.kt +++ b/app/src/main/kotlin/org/open/smsforwarder/ui/home/HomeViewModel.kt @@ -60,6 +60,10 @@ class HomeViewModel @Inject constructor( } } + fun onSmsHistoryClicked() { + router.navigateTo(Screens.historyFragment()) + } + fun onItemEditClicked(id: Long) { router.navigateTo(Screens.chooseForwardingMethodFragment(id)) } diff --git a/app/src/main/kotlin/org/open/smsforwarder/ui/home/adapter/HomeViewHolder.kt b/app/src/main/kotlin/org/open/smsforwarder/ui/home/adapter/HomeViewHolder.kt index 1e8a73c..3ee2171 100644 --- a/app/src/main/kotlin/org/open/smsforwarder/ui/home/adapter/HomeViewHolder.kt +++ b/app/src/main/kotlin/org/open/smsforwarder/ui/home/adapter/HomeViewHolder.kt @@ -12,23 +12,27 @@ class HomeViewHolder( private val onItemRemove: (Long) -> Unit, ) : RecyclerView.ViewHolder(binding.root) { - fun bind(state: ForwardingUI) = with(binding) { + fun bind(item: ForwardingUI) = with(binding) { val context = itemView.context - title.isVisible = state.title.isNotBlank() - title.text = state.title - forwardingType.text = state.forwardingType?.value - phoneGroup.isVisible = state.isSmsBlockCompleted() - recipientPhoneEt.text = state.recipientPhone - emailGroup.isVisible = state.isEmailBlockCompleted() - email.text = state.recipientEmail - error.isVisible = !state.allStepsCompleted || state.error.isNotEmpty() + title.isVisible = item.title.isNotBlank() + title.text = item.title + title.contentDescription = + context.getString(R.string.forwarding_named, item.title) + forwardingType.text = item.forwardingType?.value + emailGroup.isVisible = item.isEmailBlockCompleted() + email.text = item.recipientEmail + error.isVisible = !item.allStepsCompleted || item.error.isNotEmpty() error.text = when { - !state.allStepsCompleted -> context.getString(R.string.steps_are_not_completed_error) - state.error.isNotEmpty() -> state.error + !item.allStepsCompleted -> context.getString(R.string.steps_are_not_completed_error) + item.error.isNotEmpty() -> item.error else -> null } - buttonEditItem.setOnClickListener { onItemEdit(state.id) } - buttonRemoveItem.setOnClickListener { onItemRemove(state.id) } + editItemBtn.setOnClickListener { onItemEdit(item.id) } + editItemBtn.contentDescription = + context.getString(R.string.edit_forwarding_named, item.title) + removeItemBtn.setOnClickListener { onItemRemove(item.id) } + removeItemBtn.contentDescription = + context.getString(R.string.delete_forwarding_named, item.title) } } diff --git a/app/src/main/kotlin/org/open/smsforwarder/ui/mapper/PresentationMapper.kt b/app/src/main/kotlin/org/open/smsforwarder/ui/mapper/PresentationMapper.kt index 6874c97..6ca3d2e 100644 --- a/app/src/main/kotlin/org/open/smsforwarder/ui/mapper/PresentationMapper.kt +++ b/app/src/main/kotlin/org/open/smsforwarder/ui/mapper/PresentationMapper.kt @@ -1,36 +1,43 @@ package org.open.smsforwarder.ui.mapper +import kotlinx.coroutines.CancellationException +import org.open.smsforwarder.R import org.open.smsforwarder.domain.model.Forwarding +import org.open.smsforwarder.domain.model.History import org.open.smsforwarder.domain.model.Rule +import org.open.smsforwarder.platform.GoogleSignInFailure import org.open.smsforwarder.ui.home.HomeState import org.open.smsforwarder.ui.model.ForwardingUI +import org.open.smsforwarder.ui.model.HistoryUI import org.open.smsforwarder.ui.steps.addrecipientdetails.addemaildetails.AddEmailDetailsState -import org.open.smsforwarder.ui.steps.addrecipientdetails.addphonedetails.AddPhoneDetailsState +import org.open.smsforwarder.ui.steps.addrecipientdetails.addtelegramdetails.AddTelegramDetailsState +import java.net.UnknownHostException -fun Forwarding.toEmailDetailsPresentation(): AddEmailDetailsState { - val signInTvVisible = isEmailForwardingType && senderEmail.isNullOrEmpty() - val senderEmailVisible = !senderEmail.isNullOrEmpty() && isEmailForwardingType - val sigInBtnVisible = senderEmail.isNullOrEmpty() && isEmailForwardingType - val signOutBtnVisible = !senderEmail.isNullOrEmpty() && isEmailForwardingType +fun Forwarding.toEmailDetailsUi(): AddEmailDetailsState { return AddEmailDetailsState( id = id, title = title, forwardingType = forwardingType, - signInTvVisible = signInTvVisible, - senderEmailVisible = senderEmailVisible, senderEmail = senderEmail, recipientEmail = recipientEmail, - sigInBtnVisible = sigInBtnVisible, - signOutBtnVisible = signOutBtnVisible ) } -fun Forwarding.toPhoneDetailsPresentation(): AddPhoneDetailsState = - AddPhoneDetailsState( +fun Forwarding.toTelegramDetailsUi(): AddTelegramDetailsState = + AddTelegramDetailsState( id = id, title = title, forwardingType = forwardingType, - recipientPhone = recipientPhone + telegramApiToken = telegramApiToken, + telegramChatId = telegramChatId, + ) + +fun History.toHistoryUi() = + HistoryUI( + id = id, + date = date, + message = message, + isForwardingSuccessful = isForwardingSuccessful ) fun List.mergeWithRules(rules: List): HomeState { @@ -42,7 +49,8 @@ fun List.mergeWithRules(rules: List): HomeState { title = forwarding.title, forwardingType = forwarding.forwardingType, senderEmail = forwarding.senderEmail, - recipientPhone = forwarding.recipientPhone, + telegramApiToken = forwarding.telegramApiToken, + telegramChatId = forwarding.telegramChatId, recipientEmail = forwarding.recipientEmail, error = forwarding.error, atLeastOneRuleAdded = rules.any { forwarding.id == it.forwardingId } @@ -52,12 +60,13 @@ fun List.mergeWithRules(rules: List): HomeState { return HomeState(forwardingUI) } -fun AddPhoneDetailsState.toDomain() = +fun AddTelegramDetailsState.toDomain() = Forwarding( id = id, title = title, forwardingType = forwardingType, - recipientPhone = recipientPhone + telegramApiToken = telegramApiToken, + telegramChatId = telegramChatId, ) fun AddEmailDetailsState.toDomain() = @@ -68,3 +77,15 @@ fun AddEmailDetailsState.toDomain() = senderEmail = senderEmail, recipientEmail = recipientEmail ) + +fun Throwable.toUserMessageResId(): Int = when (this) { + is CancellationException -> throw this + is GoogleSignInFailure.CredentialCancellation -> R.string.google_sign_in_canceled + is GoogleSignInFailure.NoInternetConnection, is UnknownHostException -> R.string.network_failure + is GoogleSignInFailure.CredentialsNotFound -> R.string.credentials_not_found + is GoogleSignInFailure.AuthorizationFailed -> R.string.authorization_failed + is GoogleSignInFailure.AuthResultIntentIsNull -> R.string.authorization_result_is_null + is GoogleSignInFailure.MissingPendingIntent -> R.string.pending_intent_missing + is GoogleSignInFailure.MissingAuthCode -> R.string.auth_code_not_received + else -> R.string.sign_in_general_error +} diff --git a/app/src/main/kotlin/org/open/smsforwarder/ui/model/ForwardingUI.kt b/app/src/main/kotlin/org/open/smsforwarder/ui/model/ForwardingUI.kt index 87f5162..c795449 100644 --- a/app/src/main/kotlin/org/open/smsforwarder/ui/model/ForwardingUI.kt +++ b/app/src/main/kotlin/org/open/smsforwarder/ui/model/ForwardingUI.kt @@ -7,19 +7,22 @@ data class ForwardingUI( val title: String = "", val forwardingType: ForwardingType? = null, val senderEmail: String? = null, - val recipientPhone: String = "", val recipientEmail: String = "", + val telegramApiToken: String = "", + val telegramChatId: String = "", val error: String = "", val atLeastOneRuleAdded: Boolean = true ) { fun isEmailBlockCompleted() = - forwardingType == ForwardingType.EMAIL && recipientEmail.isNotEmpty() - - fun isSmsBlockCompleted() = - forwardingType == ForwardingType.SMS && recipientPhone.isNotEmpty() + forwardingType == ForwardingType.EMAIL && recipientEmail.isNotEmpty() && !senderEmail.isNullOrEmpty() val allStepsCompleted: Boolean - get() = ((isEmailBlockCompleted() && !senderEmail.isNullOrEmpty()) - || isSmsBlockCompleted()) && atLeastOneRuleAdded + get() = (isEmailBlockCompleted() + || isTelegramBlockCompleted()) + && atLeastOneRuleAdded + + private fun isTelegramBlockCompleted(): Boolean = + forwardingType == ForwardingType.TELEGRAM + && telegramApiToken.isNotBlank() && telegramChatId.isNotBlank() } diff --git a/app/src/main/kotlin/org/open/smsforwarder/ui/model/HistoryUI.kt b/app/src/main/kotlin/org/open/smsforwarder/ui/model/HistoryUI.kt new file mode 100644 index 0000000..06abe68 --- /dev/null +++ b/app/src/main/kotlin/org/open/smsforwarder/ui/model/HistoryUI.kt @@ -0,0 +1,8 @@ +package org.open.smsforwarder.ui.model + +data class HistoryUI ( + val id: Long = 0, + val date: Long? = null, + val message: String? = "", + val isForwardingSuccessful: Boolean = true +) diff --git a/app/src/main/kotlin/org/open/smsforwarder/ui/onboarding/OnboardingFragment.kt b/app/src/main/kotlin/org/open/smsforwarder/ui/onboarding/OnboardingFragment.kt index b464466..6bb0059 100644 --- a/app/src/main/kotlin/org/open/smsforwarder/ui/onboarding/OnboardingFragment.kt +++ b/app/src/main/kotlin/org/open/smsforwarder/ui/onboarding/OnboardingFragment.kt @@ -13,9 +13,11 @@ import org.open.smsforwarder.databinding.FragmentOnboardingBinding import org.open.smsforwarder.extension.bindClicksTo import org.open.smsforwarder.extension.bindPageChangesTo import org.open.smsforwarder.extension.observeWithLifecycle +import org.open.smsforwarder.extension.setAccessibilityFocus import org.open.smsforwarder.extension.showOkDialog import org.open.smsforwarder.extension.unsafeLazy import org.open.smsforwarder.ui.onboarding.OnboardingState.Companion.slides +import org.open.smsforwarder.ui.onboarding.adapter.OnboardingPagerTransformer import org.open.smsforwarder.ui.onboarding.adapter.OnboardingSliderAdapter import org.open.smsforwarder.utils.ButtonFillAnimator @@ -35,11 +37,20 @@ class OnboardingFragment : Fragment(R.layout.fragment_onboarding) { setupObservers() } + override fun onStart() { + super.onStart() + binding.stepLabel.setAccessibilityFocus() + } + private fun setupView() { with(binding) { adapter.setData(slides) + onboardingVp.setPageTransformer(OnboardingPagerTransformer()) onboardingVp.adapter = adapter - TabLayoutMediator(dotsIndicator, onboardingVp) { _, _ -> }.attach() + dotsIndicator.tabIconTint = null + TabLayoutMediator(dotsIndicator, onboardingVp) { tab, _ -> + tab.setIcon(R.drawable.dots_selector) + }.attach() buttonFillAnimator = ButtonFillAnimator( button = nextBtn, lifecycle = viewLifecycleOwner.lifecycle, @@ -72,6 +83,8 @@ class OnboardingFragment : Fragment(R.layout.fragment_onboarding) { with(binding) { backBtn.isVisible = onboardingState.currentStep >= 2 stepLabel.text = getString(R.string.onboarding_step_label, onboardingState.currentStep) + stepLabel.contentDescription = + getString(R.string.onboarding_title_content_description, stepLabel.text) skipAllBtn.isVisible = !onboardingState.isLastSlide nextBtn.text = getString(onboardingState.nextButtonRes) acknowledgeChBx.isVisible = onboardingState.isLastSlide @@ -89,9 +102,20 @@ class OnboardingFragment : Fragment(R.layout.fragment_onboarding) { ) } - NextPageEffect -> binding.onboardingVp.currentItem++ - PreviousPageEffect -> binding.onboardingVp.currentItem-- - SkipAllEffect -> binding.onboardingVp.currentItem = adapter.itemCount - 1 + NextPageEffect -> { + binding.onboardingVp.currentItem++ + binding.stepLabel.setAccessibilityFocus() + } + + PreviousPageEffect -> { + binding.onboardingVp.currentItem-- + binding.stepLabel.setAccessibilityFocus() + } + + SkipAllEffect -> { + binding.onboardingVp.currentItem = adapter.itemCount - 1 + binding.stepLabel.setAccessibilityFocus() + } } } } diff --git a/app/src/main/kotlin/org/open/smsforwarder/ui/onboarding/OnboardingState.kt b/app/src/main/kotlin/org/open/smsforwarder/ui/onboarding/OnboardingState.kt index 714e573..49a70d2 100644 --- a/app/src/main/kotlin/org/open/smsforwarder/ui/onboarding/OnboardingState.kt +++ b/app/src/main/kotlin/org/open/smsforwarder/ui/onboarding/OnboardingState.kt @@ -17,19 +17,24 @@ data class OnboardingState( val slides: List by unsafeLazy { listOf( OnboardingPagerSlide( - titleId = R.string.onboarding_first_step_heading, - subtitleId = R.string.onboarding_first_step_description, - imageId = R.drawable.step_1, + titleId = R.string.onboarding_step_1_heading, + subtitleId = R.string.onboarding_step_1_description, + imageId = R.drawable.logo, ), OnboardingPagerSlide( - titleId = R.string.onboarding_second_step_heading, - subtitleId = R.string.onboarding_second_step_description, - imageId = R.drawable.step_2, + titleId = R.string.onboarding_step_2_heading, + subtitleId = R.string.onboarding_step_2_description, + imageId = R.drawable.onboarding_step_2, ), OnboardingPagerSlide( - titleId = R.string.onboarding_third_step_heading, - subtitleId = R.string.onboarding_third_step_description, - imageId = R.drawable.step_3, + titleId = R.string.onboarding_step_3_heading, + subtitleId = R.string.onboarding_step_3_description, + imageId = R.drawable.onboarding_step_3, + ), + OnboardingPagerSlide( + titleId = R.string.onboarding_step_4_heading, + subtitleId = R.string.onboarding_step_4_description, + imageId = R.drawable.onboarding_step_4, ), OnboardingPagerSlide( titleId = R.string.privacy_info_heading, diff --git a/app/src/main/kotlin/org/open/smsforwarder/ui/onboarding/adapter/OnboardingPagerSlide.kt b/app/src/main/kotlin/org/open/smsforwarder/ui/onboarding/adapter/OnboardingPagerSlide.kt index 444cf4f..9bfb9d8 100644 --- a/app/src/main/kotlin/org/open/smsforwarder/ui/onboarding/adapter/OnboardingPagerSlide.kt +++ b/app/src/main/kotlin/org/open/smsforwarder/ui/onboarding/adapter/OnboardingPagerSlide.kt @@ -6,5 +6,5 @@ import androidx.annotation.StringRes data class OnboardingPagerSlide( @StringRes val titleId: Int, @StringRes val subtitleId: Int, - @DrawableRes val imageId: Int, + @DrawableRes val imageId: Int ) diff --git a/app/src/main/kotlin/org/open/smsforwarder/ui/onboarding/adapter/OnboardingPagerTransformer.kt b/app/src/main/kotlin/org/open/smsforwarder/ui/onboarding/adapter/OnboardingPagerTransformer.kt new file mode 100644 index 0000000..0740c2f --- /dev/null +++ b/app/src/main/kotlin/org/open/smsforwarder/ui/onboarding/adapter/OnboardingPagerTransformer.kt @@ -0,0 +1,21 @@ +package org.open.smsforwarder.ui.onboarding.adapter + +import android.view.View +import androidx.viewpager2.widget.ViewPager2 +import com.google.android.material.textview.MaterialTextView +import org.open.smsforwarder.R + +private const val TITLE_PARALLAX_FACTOR = 0.45f +private const val SUBTITLE_PARALLAX_FACTOR = 2.5f + +class OnboardingPagerTransformer : ViewPager2.PageTransformer { + + override fun transformPage(page: View, position: Float) { + if (position >= -1 && position <= 1) { + page.findViewById(R.id.text_title).translationX = + position * page.width / TITLE_PARALLAX_FACTOR + page.findViewById(R.id.text_subtitle).translationX = + position * page.width / SUBTITLE_PARALLAX_FACTOR + } + } +} diff --git a/app/src/main/kotlin/org/open/smsforwarder/ui/onboarding/adapter/OnboardingSliderAdapter.kt b/app/src/main/kotlin/org/open/smsforwarder/ui/onboarding/adapter/OnboardingSliderAdapter.kt index 00cfb6e..9e32b51 100644 --- a/app/src/main/kotlin/org/open/smsforwarder/ui/onboarding/adapter/OnboardingSliderAdapter.kt +++ b/app/src/main/kotlin/org/open/smsforwarder/ui/onboarding/adapter/OnboardingSliderAdapter.kt @@ -2,6 +2,7 @@ package org.open.smsforwarder.ui.onboarding.adapter import android.view.LayoutInflater import android.view.ViewGroup +import androidx.core.view.isVisible import androidx.recyclerview.widget.RecyclerView import org.open.smsforwarder.databinding.ItemOnboardingSlideBinding diff --git a/app/src/main/kotlin/org/open/smsforwarder/ui/steps/addrecipientdetails/addemaildetails/AddEmailDetailsEffect.kt b/app/src/main/kotlin/org/open/smsforwarder/ui/steps/addrecipientdetails/addemaildetails/AddEmailDetailsEffect.kt index 6d9e6e3..922a8cf 100644 --- a/app/src/main/kotlin/org/open/smsforwarder/ui/steps/addrecipientdetails/addemaildetails/AddEmailDetailsEffect.kt +++ b/app/src/main/kotlin/org/open/smsforwarder/ui/steps/addrecipientdetails/addemaildetails/AddEmailDetailsEffect.kt @@ -1,14 +1,11 @@ package org.open.smsforwarder.ui.steps.addrecipientdetails.addemaildetails -import android.content.Intent +import android.content.IntentSender import androidx.annotation.StringRes sealed interface AddEmailDetailsViewEffect -data class GoogleSignInViewEffect( - val signInIntent: Intent? = null -) : AddEmailDetailsViewEffect +data class GoogleSignInIntentSenderEffect(val intentSender: IntentSender) : AddEmailDetailsViewEffect -data class GoogleSignInErrorViewEffect( - @StringRes val errorMessage: Int? = null -) : AddEmailDetailsViewEffect +data class GoogleSignInErrorEffect(@StringRes val errorMessageRes: Int? = null) : + AddEmailDetailsViewEffect diff --git a/app/src/main/kotlin/org/open/smsforwarder/ui/steps/addrecipientdetails/addemaildetails/AddEmailDetailsFragment.kt b/app/src/main/kotlin/org/open/smsforwarder/ui/steps/addrecipientdetails/addemaildetails/AddEmailDetailsFragment.kt index 9d97aef..ebb8a45 100644 --- a/app/src/main/kotlin/org/open/smsforwarder/ui/steps/addrecipientdetails/addemaildetails/AddEmailDetailsFragment.kt +++ b/app/src/main/kotlin/org/open/smsforwarder/ui/steps/addrecipientdetails/addemaildetails/AddEmailDetailsFragment.kt @@ -2,6 +2,7 @@ package org.open.smsforwarder.ui.steps.addrecipientdetails.addemaildetails import android.os.Bundle import android.view.View +import androidx.activity.result.IntentSenderRequest import androidx.activity.result.contract.ActivityResultContracts import androidx.core.os.bundleOf import androidx.fragment.app.Fragment @@ -13,6 +14,7 @@ import org.open.smsforwarder.extension.assistedViewModels import org.open.smsforwarder.extension.bindClicksTo import org.open.smsforwarder.extension.bindTextChangesTo import org.open.smsforwarder.extension.observeWithLifecycle +import org.open.smsforwarder.extension.setAccessibilityFocus import org.open.smsforwarder.extension.setTextIfChanged import org.open.smsforwarder.extension.setTextIfChangedKeepState import org.open.smsforwarder.extension.setVisibilityIfChanged @@ -22,14 +24,14 @@ import org.open.smsforwarder.extension.showToast class AddEmailDetailsFragment : Fragment(R.layout.fragment_add_email_details) { private val binding by viewBinding(FragmentAddEmailDetailsBinding::bind) - private val viewModel by assistedViewModels { factory -> + private val viewModel by + assistedViewModels { factory -> factory.create(requireArguments().getLong(ID_KEY)) } - private val googleSigInLauncher = - registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> - viewModel.onSignInResultReceived(result) - } + private val signInLauncher = registerForActivityResult( + ActivityResultContracts.StartIntentSenderForResult() + ) { result -> viewModel.onSignInResult(result) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) @@ -37,11 +39,16 @@ class AddEmailDetailsFragment : Fragment(R.layout.fragment_add_email_details) { setObservers() } + override fun onStart() { + super.onStart() + binding.step2.setAccessibilityFocus() + } + private fun setListeners() { with(binding) { arrowBackIv bindClicksTo viewModel::onBackClicked recipientEmailEt bindTextChangesTo viewModel::onEmailChanged - signInBtn bindClicksTo viewModel::onSignInClicked + signInBtn bindClicksTo { viewModel.onSignInWithGoogleClicked(requireActivity()) } signOutBtn bindClicksTo viewModel::onSignOutClicked nextBtn bindClicksTo viewModel::onNextClicked } @@ -55,21 +62,24 @@ class AddEmailDetailsFragment : Fragment(R.layout.fragment_add_email_details) { private fun renderState(state: AddEmailDetailsState) { with(binding) { - signInTv.setVisibilityIfChanged(state.signInTvVisible) - senderEmailTv.setVisibilityIfChanged(state.senderEmailVisible) + val isSignedIn = state.senderEmail != null + signInTv.setVisibilityIfChanged(!isSignedIn) + senderEmailTv.setVisibilityIfChanged(isSignedIn) senderEmailTv.setTextIfChanged(getString(R.string.signed_in_as, state.senderEmail)) - signInBtn.setVisibilityIfChanged(state.sigInBtnVisible) - signOutBtn.setVisibilityIfChanged(state.signOutBtnVisible) + signInBtn.setVisibilityIfChanged(!isSignedIn) + signOutBtn.setVisibilityIfChanged(isSignedIn) recipientEmailEt.setTextIfChangedKeepState(state.recipientEmail) nextBtn.isEnabled = state.nextButtonEnabled - recipientEmailLayout.error = state.inputError?.asString(requireContext()) + recipientEmailLayout.error = state.inputErrorProvider?.asString(requireContext()) } } private fun handleEffect(effect: AddEmailDetailsViewEffect) { when (effect) { - is GoogleSignInErrorViewEffect -> effect.errorMessage?.let(::showToast) - is GoogleSignInViewEffect -> effect.signInIntent?.let(googleSigInLauncher::launch) + is GoogleSignInErrorEffect -> effect.errorMessageRes?.let(::showToast) + is GoogleSignInIntentSenderEffect -> signInLauncher.launch( + IntentSenderRequest.Builder(effect.intentSender).build() + ) } } diff --git a/app/src/main/kotlin/org/open/smsforwarder/ui/steps/addrecipientdetails/addemaildetails/AddEmailDetailsState.kt b/app/src/main/kotlin/org/open/smsforwarder/ui/steps/addrecipientdetails/addemaildetails/AddEmailDetailsState.kt index 977e796..6652227 100644 --- a/app/src/main/kotlin/org/open/smsforwarder/ui/steps/addrecipientdetails/addemaildetails/AddEmailDetailsState.kt +++ b/app/src/main/kotlin/org/open/smsforwarder/ui/steps/addrecipientdetails/addemaildetails/AddEmailDetailsState.kt @@ -7,14 +7,10 @@ data class AddEmailDetailsState( val id: Long = 0, val title: String = "", val forwardingType: ForwardingType? = null, - val signInTvVisible: Boolean = false, - val senderEmailVisible: Boolean = false, val senderEmail: String? = null, - val sigInBtnVisible: Boolean = false, - val signOutBtnVisible: Boolean = false, val recipientEmail: String = "", - val inputError: Resources.StringProvider? = null + val inputErrorProvider: Resources.StringProvider? = null ) { val nextButtonEnabled = - inputError == null && !senderEmail.isNullOrEmpty() && recipientEmail.isNotBlank() + (inputErrorProvider == null) && !senderEmail.isNullOrEmpty() && recipientEmail.isNotBlank() } diff --git a/app/src/main/kotlin/org/open/smsforwarder/ui/steps/addrecipientdetails/addemaildetails/AddEmailDetailsViewModel.kt b/app/src/main/kotlin/org/open/smsforwarder/ui/steps/addrecipientdetails/addemaildetails/AddEmailDetailsViewModel.kt index 8d268fa..c8bca2c 100644 --- a/app/src/main/kotlin/org/open/smsforwarder/ui/steps/addrecipientdetails/addemaildetails/AddEmailDetailsViewModel.kt +++ b/app/src/main/kotlin/org/open/smsforwarder/ui/steps/addrecipientdetails/addemaildetails/AddEmailDetailsViewModel.kt @@ -24,20 +24,24 @@ import org.open.smsforwarder.data.repository.AuthRepository import org.open.smsforwarder.data.repository.ForwardingRepository import org.open.smsforwarder.domain.usecase.ValidateEmailUseCase import org.open.smsforwarder.extension.asStateFlowWithInitialAction +import org.open.smsforwarder.extension.getStringProvider import org.open.smsforwarder.extension.launchAndCancelPrevious -import org.open.smsforwarder.helper.GoogleSignInHelper import org.open.smsforwarder.navigation.Screens +import org.open.smsforwarder.platform.GoogleAuthClient import org.open.smsforwarder.ui.mapper.toDomain -import org.open.smsforwarder.ui.mapper.toEmailDetailsPresentation +import org.open.smsforwarder.ui.mapper.toEmailDetailsUi +import org.open.smsforwarder.ui.mapper.toUserMessageResId +import org.open.smsforwarder.utils.mapSuspendCatching +import org.open.smsforwarder.utils.runSuspendCatching @HiltViewModel(assistedFactory = AddEmailDetailsViewModel.Factory::class) class AddEmailDetailsViewModel @AssistedInject constructor( @Assisted private val id: Long, private val forwardingRepository: ForwardingRepository, private val authRepository: AuthRepository, - private val googleSignInHelper: GoogleSignInHelper, private val validateEmailUseCase: ValidateEmailUseCase, private val analyticsTracker: AnalyticsTracker, + private val googleAuthClient: GoogleAuthClient, private val router: Router, ) : ViewModel(), DefaultLifecycleObserver { @@ -60,23 +64,86 @@ class AddEmailDetailsViewModel @AssistedInject constructor( .getForwardingByIdFlow(id) .collect { forwarding -> _viewState.update { - forwarding.toEmailDetailsPresentation() + forwarding.toEmailDetailsUi() } } } } - fun onSignInClicked() { - _viewEffect.trySend(GoogleSignInViewEffect(signInIntent = googleSignInHelper.signInIntent)) + fun onSignInWithGoogleClicked(activity: Activity) { + viewModelScope.launch { + runSuspendCatching { + googleAuthClient.getSignInIntent(activity) + } + .onSuccess { signInResult -> + _viewEffect.trySend( + GoogleSignInIntentSenderEffect(signInResult.intentSender) + ) + } + .onFailure { error -> + _viewEffect.trySend( + GoogleSignInErrorEffect(error.toUserMessageResId()) + ) + } + } + } + + fun onSignInResult(result: ActivityResult) { + when (result.resultCode) { + Activity.RESULT_OK -> { + viewModelScope.launch { + runSuspendCatching { + googleAuthClient.extractAuthorizationCode(result.data) + } + .mapSuspendCatching { authCode -> + authRepository.exchangeAuthCodeForTokens( + authCode, + viewState.value.id + ) + } + .onSuccess { authResult -> + _viewState.update { + it.copy(senderEmail = authResult.email) + } + } + .onFailure { error -> + _viewEffect.trySend( + GoogleSignInErrorEffect(error.toUserMessageResId()) + ) + } + } + } + + Activity.RESULT_CANCELED -> { + _viewEffect.trySend( + GoogleSignInErrorEffect( + errorMessageRes = R.string.google_sign_in_canceled + ) + ) + } + + else -> { + _viewEffect.trySend( + GoogleSignInErrorEffect( + errorMessageRes = R.string.error_google_sign_in + ) + ) + } + } } fun onSignOutClicked() { viewModelScope.launch { authRepository - .signOut(viewState.value.toDomain()) + .signOut(viewState.value.id) + .onSuccess { + _viewState.update { + it.copy(senderEmail = null) + } + } .onFailure { _viewEffect.trySend( - GoogleSignInErrorViewEffect(R.string.error_google_sign_out) + GoogleSignInErrorEffect(R.string.error_google_sign_out) ) } } @@ -87,45 +154,11 @@ class AddEmailDetailsViewModel @AssistedInject constructor( _viewState.update { it.copy( recipientEmail = email, - inputError = emailValidationResult.errorMessage + inputErrorProvider = emailValidationResult.errorType?.getStringProvider() ) } } - fun onSignInResultReceived(result: ActivityResult) { - if (result.resultCode == Activity.RESULT_OK) { - viewModelScope.launch { - googleSignInHelper - .getGoogleSignInAccount(data = result.data) - .onSuccess { googleAccount -> - authRepository - .exchangeAuthCodeForTokensAnd(googleAccount.serverAuthCode) - .onSuccess { response -> - val recipient = viewState.value.copy( - senderEmail = googleAccount.email - ) - forwardingRepository.insertOrUpdateForwarding(recipient.toDomain()) - authRepository.saveTokens( - viewState.value.id, - response.accessToken, - response.refreshToken - ) - } - .onFailure { - _viewEffect.trySend( - GoogleSignInErrorViewEffect(R.string.error_google_sign_in) - ) - } - } - .onFailure { - _viewEffect.trySend( - GoogleSignInErrorViewEffect(R.string.error_google_sign_in) - ) - } - } - } - } - fun onNextClicked() { analyticsTracker.trackEvent(RECIPIENT_CREATION_STEP2_NEXT_CLICKED) router.navigateTo(Screens.addForwardingRuleFragment(id)) diff --git a/app/src/main/kotlin/org/open/smsforwarder/ui/steps/addrecipientdetails/addphonedetails/AddPhoneDetailsState.kt b/app/src/main/kotlin/org/open/smsforwarder/ui/steps/addrecipientdetails/addphonedetails/AddPhoneDetailsState.kt deleted file mode 100644 index b09bb09..0000000 --- a/app/src/main/kotlin/org/open/smsforwarder/ui/steps/addrecipientdetails/addphonedetails/AddPhoneDetailsState.kt +++ /dev/null @@ -1,17 +0,0 @@ -package org.open.smsforwarder.ui.steps.addrecipientdetails.addphonedetails - -import org.open.smsforwarder.domain.model.ForwardingType -import org.open.smsforwarder.domain.model.Rule -import org.open.smsforwarder.utils.Resources - -data class AddPhoneDetailsState( - val id: Long = 0, - val title: String = "", - val forwardingType: ForwardingType? = null, - val recipientPhone: String = "", - val inputError: Resources.StringProvider? = null, - val rules: List = emptyList() -) { - - val nextButtonEnabled = inputError == null && recipientPhone.isNotBlank() -} diff --git a/app/src/main/kotlin/org/open/smsforwarder/ui/steps/addrecipientdetails/addphonedetails/AddPhoneDetailsFragment.kt b/app/src/main/kotlin/org/open/smsforwarder/ui/steps/addrecipientdetails/addtelegramdetails/AddTelegramDetailsFragment.kt similarity index 50% rename from app/src/main/kotlin/org/open/smsforwarder/ui/steps/addrecipientdetails/addphonedetails/AddPhoneDetailsFragment.kt rename to app/src/main/kotlin/org/open/smsforwarder/ui/steps/addrecipientdetails/addtelegramdetails/AddTelegramDetailsFragment.kt index 38f6039..8a2ff7e 100644 --- a/app/src/main/kotlin/org/open/smsforwarder/ui/steps/addrecipientdetails/addphonedetails/AddPhoneDetailsFragment.kt +++ b/app/src/main/kotlin/org/open/smsforwarder/ui/steps/addrecipientdetails/addtelegramdetails/AddTelegramDetailsFragment.kt @@ -1,4 +1,4 @@ -package org.open.smsforwarder.ui.steps.addrecipientdetails.addphonedetails +package org.open.smsforwarder.ui.steps.addrecipientdetails.addtelegramdetails import android.os.Bundle import android.view.View @@ -7,18 +7,20 @@ import androidx.fragment.app.Fragment import by.kirich1409.viewbindingdelegate.viewBinding import dagger.hilt.android.AndroidEntryPoint import org.open.smsforwarder.R -import org.open.smsforwarder.databinding.FragmentAddPhoneDetailsBinding +import org.open.smsforwarder.databinding.FragmentAddTelegramDetailsBinding import org.open.smsforwarder.extension.assistedViewModels import org.open.smsforwarder.extension.bindClicksTo import org.open.smsforwarder.extension.bindTextChangesTo import org.open.smsforwarder.extension.observeWithLifecycle import org.open.smsforwarder.extension.setTextIfChangedKeepState +import org.open.smsforwarder.extension.showTooltip @AndroidEntryPoint -class AddPhoneDetailsFragment : Fragment(R.layout.fragment_add_phone_details) { +class AddTelegramDetailsFragment : Fragment(R.layout.fragment_add_telegram_details) { - private val binding by viewBinding(FragmentAddPhoneDetailsBinding::bind) - private val viewModel by assistedViewModels { factory -> + private val binding by viewBinding(FragmentAddTelegramDetailsBinding::bind) + private val viewModel by + assistedViewModels { factory -> factory.create(requireArguments().getLong(ID_KEY)) } @@ -31,7 +33,10 @@ class AddPhoneDetailsFragment : Fragment(R.layout.fragment_add_phone_details) { private fun setListeners() { with(binding) { arrowBackIv bindClicksTo viewModel::onBackClicked - recipientPhoneEt bindTextChangesTo viewModel::onPhoneChanged + telegramApiTokenEt bindTextChangesTo viewModel::onTelegramApiTokenChanged + telegramApiTokenIv bindClicksTo { telegramApiTokenIv.showTooltip(R.string.telegram_api_token_tooltip) } + telegramChatIdEt bindTextChangesTo viewModel::onTelegramChatIdChanged + telegramChatIdIv bindClicksTo { telegramChatIdIv.showTooltip(R.string.telegram_chat_id_tooltip) } nextBtn bindClicksTo viewModel::onNextClicked } } @@ -41,17 +46,19 @@ class AddPhoneDetailsFragment : Fragment(R.layout.fragment_add_phone_details) { viewLifecycleOwner.lifecycle.addObserver(viewModel) } - private fun renderState(state: AddPhoneDetailsState) { + private fun renderState(state: AddTelegramDetailsState) { with(binding) { - recipientPhoneEt.setTextIfChangedKeepState(state.recipientPhone) - recipientPhoneLayout.error = state.inputError?.asString(requireContext()) + telegramApiTokenEt.setTextIfChangedKeepState(state.telegramApiToken) + telegramChatIdEt.setTextIfChangedKeepState(state.telegramChatId) + telegramApiTokenLayout.error = state.inputErrorApiToken?.asString(requireContext()) + telegramChatIdLayout.error = state.inputErrorChatId?.asString(requireContext()) nextBtn.isEnabled = state.nextButtonEnabled } } companion object { - fun newInstance(id: Long): AddPhoneDetailsFragment = - AddPhoneDetailsFragment().apply { + fun newInstance(id: Long): AddTelegramDetailsFragment = + AddTelegramDetailsFragment().apply { arguments = bundleOf(ID_KEY to id) } diff --git a/app/src/main/kotlin/org/open/smsforwarder/ui/steps/addrecipientdetails/addtelegramdetails/AddTelegramDetailsState.kt b/app/src/main/kotlin/org/open/smsforwarder/ui/steps/addrecipientdetails/addtelegramdetails/AddTelegramDetailsState.kt new file mode 100644 index 0000000..f1482e9 --- /dev/null +++ b/app/src/main/kotlin/org/open/smsforwarder/ui/steps/addrecipientdetails/addtelegramdetails/AddTelegramDetailsState.kt @@ -0,0 +1,20 @@ +package org.open.smsforwarder.ui.steps.addrecipientdetails.addtelegramdetails + +import org.open.smsforwarder.domain.model.ForwardingType +import org.open.smsforwarder.utils.Resources + +data class AddTelegramDetailsState( + val id: Long = 0, + val title: String = "", + val forwardingType: ForwardingType? = null, + val telegramApiToken: String = "", + val telegramChatId: String = "", + val inputErrorApiToken: Resources.StringProvider? = null, + val inputErrorChatId: Resources.StringProvider? = null, +) { + + val nextButtonEnabled = inputErrorApiToken == null + && inputErrorChatId == null + && telegramApiToken.isNotBlank() + && telegramChatId.isNotBlank() +} diff --git a/app/src/main/kotlin/org/open/smsforwarder/ui/steps/addrecipientdetails/addphonedetails/AddPhoneDetailsViewModel.kt b/app/src/main/kotlin/org/open/smsforwarder/ui/steps/addrecipientdetails/addtelegramdetails/AddTelegramDetailsViewModel.kt similarity index 58% rename from app/src/main/kotlin/org/open/smsforwarder/ui/steps/addrecipientdetails/addphonedetails/AddPhoneDetailsViewModel.kt rename to app/src/main/kotlin/org/open/smsforwarder/ui/steps/addrecipientdetails/addtelegramdetails/AddTelegramDetailsViewModel.kt index d6dda2b..cd1db09 100644 --- a/app/src/main/kotlin/org/open/smsforwarder/ui/steps/addrecipientdetails/addphonedetails/AddPhoneDetailsViewModel.kt +++ b/app/src/main/kotlin/org/open/smsforwarder/ui/steps/addrecipientdetails/addtelegramdetails/AddTelegramDetailsViewModel.kt @@ -1,4 +1,4 @@ -package org.open.smsforwarder.ui.steps.addrecipientdetails.addphonedetails +package org.open.smsforwarder.ui.steps.addrecipientdetails.addtelegramdetails import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner @@ -12,26 +12,24 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import org.open.smsforwarder.analytics.AnalyticsEvents.RECIPIENT_CREATION_STEP2_NEXT_CLICKED -import org.open.smsforwarder.analytics.AnalyticsTracker import org.open.smsforwarder.data.repository.ForwardingRepository -import org.open.smsforwarder.domain.usecase.ValidatePhoneUseCase +import org.open.smsforwarder.domain.usecase.ValidateBlankFieldUseCase import org.open.smsforwarder.extension.asStateFlowWithInitialAction +import org.open.smsforwarder.extension.getStringProvider import org.open.smsforwarder.extension.launchAndCancelPrevious import org.open.smsforwarder.navigation.Screens import org.open.smsforwarder.ui.mapper.toDomain -import org.open.smsforwarder.ui.mapper.toPhoneDetailsPresentation +import org.open.smsforwarder.ui.mapper.toTelegramDetailsUi -@HiltViewModel(assistedFactory = AddPhoneDetailsViewModel.Factory::class) -class AddPhoneDetailsViewModel @AssistedInject constructor( +@HiltViewModel(assistedFactory = AddTelegramDetailsViewModel.Factory::class) +class AddTelegramDetailsViewModel @AssistedInject constructor( @Assisted private val id: Long, private val forwardingRepository: ForwardingRepository, - private val validatePhoneUseCase: ValidatePhoneUseCase, + private val validateBlankFieldUseCase: ValidateBlankFieldUseCase, private val router: Router, - private val analyticsTracker: AnalyticsTracker, ) : ViewModel(), DefaultLifecycleObserver { - private var _viewState = MutableStateFlow(AddPhoneDetailsState()) + private var _viewState = MutableStateFlow(AddTelegramDetailsState()) val viewState = _viewState.asStateFlowWithInitialAction(viewModelScope) { loadData() } override fun onPause(owner: LifecycleOwner) { @@ -47,24 +45,33 @@ class AddPhoneDetailsViewModel @AssistedInject constructor( .getForwardingByIdFlow(id) .collect { forwarding -> _viewState.update { - forwarding.toPhoneDetailsPresentation() + forwarding.toTelegramDetailsUi() } } } } - fun onPhoneChanged(phoneNumber: String) { - val phoneValidationResult = validatePhoneUseCase.execute(phoneNumber) + fun onTelegramApiTokenChanged(apiToken: String) { + val apiTokenValidationResult = validateBlankFieldUseCase.execute(apiToken) _viewState.update { it.copy( - recipientPhone = phoneNumber, - inputError = phoneValidationResult.errorMessage + telegramApiToken = apiToken, + inputErrorApiToken = apiTokenValidationResult.errorType?.getStringProvider() + ) + } + } + + fun onTelegramChatIdChanged(chatId: String) { + val chatIdValidationResult = validateBlankFieldUseCase.execute(chatId) + _viewState.update { + it.copy( + telegramChatId = chatId, + inputErrorChatId = chatIdValidationResult.errorType?.getStringProvider() ) } } fun onNextClicked() { - analyticsTracker.trackEvent(RECIPIENT_CREATION_STEP2_NEXT_CLICKED) router.navigateTo(Screens.addForwardingRuleFragment(id)) } @@ -74,6 +81,6 @@ class AddPhoneDetailsViewModel @AssistedInject constructor( @AssistedFactory interface Factory { - fun create(id: Long): AddPhoneDetailsViewModel + fun create(id: Long): AddTelegramDetailsViewModel } } diff --git a/app/src/main/kotlin/org/open/smsforwarder/ui/steps/addrule/AddForwardingRuleFragment.kt b/app/src/main/kotlin/org/open/smsforwarder/ui/steps/addrule/AddForwardingRuleFragment.kt index f42a9d3..d1f391e 100644 --- a/app/src/main/kotlin/org/open/smsforwarder/ui/steps/addrule/AddForwardingRuleFragment.kt +++ b/app/src/main/kotlin/org/open/smsforwarder/ui/steps/addrule/AddForwardingRuleFragment.kt @@ -2,6 +2,7 @@ package org.open.smsforwarder.ui.steps.addrule import android.os.Bundle import android.view.View +import android.view.accessibility.AccessibilityEvent import androidx.core.os.bundleOf import androidx.core.view.isVisible import androidx.fragment.app.Fragment @@ -14,6 +15,7 @@ import org.open.smsforwarder.extension.assistedViewModels import org.open.smsforwarder.extension.bindClicksTo import org.open.smsforwarder.extension.bindTextChangesTo import org.open.smsforwarder.extension.observeWithLifecycle +import org.open.smsforwarder.extension.setAccessibilityFocus import org.open.smsforwarder.extension.unsafeLazy import org.open.smsforwarder.ui.dialog.delete.DeleteDialog import org.open.smsforwarder.ui.dialog.delete.DeleteDialogListener @@ -44,6 +46,11 @@ class AddForwardingRuleFragment : Fragment(R.layout.fragment_add_forwarding_rule setObservers() } + override fun onStart() { + super.onStart() + binding.step3.setAccessibilityFocus() + } + private fun setAdapter() { with(binding) { rulesRv.adapter = adapter @@ -74,6 +81,7 @@ class AddForwardingRuleFragment : Fragment(R.layout.fragment_add_forwarding_rule adapter.submitList(state.rules) emptyTv.isVisible = state.rules.isEmpty() finishBtn.isEnabled = state.rules.isNotEmpty() + rulesRv.isVisible = state.rules.isNotEmpty() messagePatternLayout.error = state.errorMessage?.let { getString(it) } buttonAddRuleBtn.isEnabled = state.isAddRuleButtonEnabled } diff --git a/app/src/main/kotlin/org/open/smsforwarder/ui/steps/addrule/adapter/RuleViewHolder.kt b/app/src/main/kotlin/org/open/smsforwarder/ui/steps/addrule/adapter/RuleViewHolder.kt index 3fe575b..b2616cd 100644 --- a/app/src/main/kotlin/org/open/smsforwarder/ui/steps/addrule/adapter/RuleViewHolder.kt +++ b/app/src/main/kotlin/org/open/smsforwarder/ui/steps/addrule/adapter/RuleViewHolder.kt @@ -1,6 +1,7 @@ package org.open.smsforwarder.ui.steps.addrule.adapter import androidx.recyclerview.widget.RecyclerView +import org.open.smsforwarder.R import org.open.smsforwarder.databinding.ItemRuleBinding import org.open.smsforwarder.domain.model.Rule @@ -10,9 +11,15 @@ class RuleViewHolder( private val onItemRemove: (Rule) -> Unit ) : RecyclerView.ViewHolder(binding.root) { - fun bind(rule: Rule) = with(binding) { - buttonEditItem.setOnClickListener { onItemEdit(rule) } - buttonRemoveItem.setOnClickListener { onItemRemove(rule) } - ruleTv.text = rule.textRule + fun bind(item: Rule) = with(binding) { + val context = itemView.context + buttonEditItem.setOnClickListener { onItemEdit(item) } + buttonEditItem.contentDescription = + context.getString(R.string.edit_rule_named, item.textRule) + buttonRemoveItem.setOnClickListener { onItemRemove(item) } + buttonRemoveItem.contentDescription = + context.getString(R.string.delete_rule_named, item.textRule) + ruleTv.text = item.textRule + ruleTv.contentDescription = context.getString(R.string.rule_named, item.textRule) } } diff --git a/app/src/main/kotlin/org/open/smsforwarder/ui/steps/choosemethod/ChooseForwardingMethodFragment.kt b/app/src/main/kotlin/org/open/smsforwarder/ui/steps/choosemethod/ChooseForwardingMethodFragment.kt index e72e546..a7f7246 100644 --- a/app/src/main/kotlin/org/open/smsforwarder/ui/steps/choosemethod/ChooseForwardingMethodFragment.kt +++ b/app/src/main/kotlin/org/open/smsforwarder/ui/steps/choosemethod/ChooseForwardingMethodFragment.kt @@ -15,9 +15,9 @@ import org.open.smsforwarder.extension.bindCheckChangesTo import org.open.smsforwarder.extension.bindClicksTo import org.open.smsforwarder.extension.bindTextChangesTo import org.open.smsforwarder.extension.observeWithLifecycle +import org.open.smsforwarder.extension.setAccessibilityFocus import org.open.smsforwarder.extension.setTextIfChangedKeepState import org.open.smsforwarder.extension.setValueIfChanged -import org.open.smsforwarder.extension.setVisibilityIfChanged @AndroidEntryPoint class ChooseForwardingMethodFragment : Fragment(R.layout.fragment_choose_forwarding_method) { @@ -34,11 +34,16 @@ class ChooseForwardingMethodFragment : Fragment(R.layout.fragment_choose_forward setObservers() } + override fun onStart() { + super.onStart() + binding.step1.setAccessibilityFocus() + } + private fun setListeners() { with(binding) { titleEt bindTextChangesTo viewModel::onTitleChanged emailRb bindCheckChangesTo { viewModel.onForwardingMethodChanged(ForwardingType.EMAIL) } - smsRb bindCheckChangesTo { viewModel.onForwardingMethodChanged(ForwardingType.SMS) } + telegramRb bindCheckChangesTo { viewModel.onForwardingMethodChanged(ForwardingType.TELEGRAM) } arrowBackIv bindClicksTo viewModel::onBackClicked nextBtn bindClicksTo viewModel::onNextClicked } @@ -51,11 +56,10 @@ class ChooseForwardingMethodFragment : Fragment(R.layout.fragment_choose_forward private fun renderState(state: Forwarding) { with(binding) { - nextBtn.isEnabled = state.forwardingType != null titleEt.setTextIfChangedKeepState(state.title) emailRb.setValueIfChanged(state.isEmailForwardingType) - smsRb.setValueIfChanged(state.isSmsForwardingType) - smsInfoTv.setVisibilityIfChanged(state.isSmsForwardingType) + telegramRb.setValueIfChanged(state.isTelegramForwardingType) + nextBtn.isEnabled = state.forwardingType != null } } diff --git a/app/src/main/kotlin/org/open/smsforwarder/ui/steps/choosemethod/ChooseForwardingMethodViewModel.kt b/app/src/main/kotlin/org/open/smsforwarder/ui/steps/choosemethod/ChooseForwardingMethodViewModel.kt index e7f5a2a..4352839 100644 --- a/app/src/main/kotlin/org/open/smsforwarder/ui/steps/choosemethod/ChooseForwardingMethodViewModel.kt +++ b/app/src/main/kotlin/org/open/smsforwarder/ui/steps/choosemethod/ChooseForwardingMethodViewModel.kt @@ -60,8 +60,8 @@ class ChooseForwardingMethodViewModel @AssistedInject constructor( fun onNextClicked() { viewState.value.forwardingType?.let { forwardingType -> val screenToNavigate = when (forwardingType) { - ForwardingType.SMS -> Screens.addPhoneDetailsFragment(id) ForwardingType.EMAIL -> Screens.addEmailDetailsFragment(id) + ForwardingType.TELEGRAM -> Screens.addTelegramDetailsFragment(id) } analyticsTracker.trackEvent(RECIPIENT_CREATION_STEP1_NEXT_CLICKED) router.navigateTo(screenToNavigate) diff --git a/app/src/main/kotlin/org/open/smsforwarder/utils/SafeResult.kt b/app/src/main/kotlin/org/open/smsforwarder/utils/SafeResult.kt new file mode 100644 index 0000000..784efd8 --- /dev/null +++ b/app/src/main/kotlin/org/open/smsforwarder/utils/SafeResult.kt @@ -0,0 +1,36 @@ +package org.open.smsforwarder.utils + +import kotlin.coroutines.cancellation.CancellationException + +/** + * Calls the specified function block with this value as its receiver and returns + * its encapsulated result if invocation was successful, + * catching [CancellationException] and rethrow it + * catch any Throwable exception, excluding [CancellationException], that was thrown from the + * block function execution and encapsulating it as a failure. + */ +@Suppress("TooGenericExceptionCaught") +suspend fun runSuspendCatching(block: suspend () -> R): Result { + return try { + Result.success(block()) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Result.failure(e) + } +} + +@Suppress("TooGenericExceptionCaught") +inline fun Result.mapSuspendCatching(transform: (value: T) -> R): Result { + return if (isSuccess) { + try { + Result.success(transform(getOrThrow())) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Result.failure(e) + } + } else { + Result.failure(exceptionOrNull() ?: IllegalStateException("Unknown failure")) + } +} diff --git a/app/src/main/res/drawable-night/onboarding_step_2.png b/app/src/main/res/drawable-night/onboarding_step_2.png new file mode 100644 index 0000000..9d40a58 Binary files /dev/null and b/app/src/main/res/drawable-night/onboarding_step_2.png differ diff --git a/app/src/main/res/drawable-night/onboarding_step_3.png b/app/src/main/res/drawable-night/onboarding_step_3.png new file mode 100644 index 0000000..b0bc3aa Binary files /dev/null and b/app/src/main/res/drawable-night/onboarding_step_3.png differ diff --git a/app/src/main/res/drawable-night/onboarding_step_4.png b/app/src/main/res/drawable-night/onboarding_step_4.png new file mode 100644 index 0000000..ba87b6b Binary files /dev/null and b/app/src/main/res/drawable-night/onboarding_step_4.png differ diff --git a/app/src/main/res/drawable-night/radio_button_text_colors.xml b/app/src/main/res/drawable-night/radio_button_text_colors.xml new file mode 100644 index 0000000..ef76e51 --- /dev/null +++ b/app/src/main/res/drawable-night/radio_button_text_colors.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-night/step_1.png b/app/src/main/res/drawable-night/step_1.png deleted file mode 100644 index 79dd07f..0000000 Binary files a/app/src/main/res/drawable-night/step_1.png and /dev/null differ diff --git a/app/src/main/res/drawable-night/step_2.png b/app/src/main/res/drawable-night/step_2.png deleted file mode 100644 index 7f3b25d..0000000 Binary files a/app/src/main/res/drawable-night/step_2.png and /dev/null differ diff --git a/app/src/main/res/drawable-night/step_3.png b/app/src/main/res/drawable-night/step_3.png deleted file mode 100644 index c8b0671..0000000 Binary files a/app/src/main/res/drawable-night/step_3.png and /dev/null differ diff --git a/app/src/main/res/drawable/button_add_rule_colors.xml b/app/src/main/res/drawable/button_add_rule_colors.xml index 5557eb4..17131e9 100644 --- a/app/src/main/res/drawable/button_add_rule_colors.xml +++ b/app/src/main/res/drawable/button_add_rule_colors.xml @@ -1,5 +1,5 @@ - + \ No newline at end of file diff --git a/app/src/main/res/drawable/default_dot.xml b/app/src/main/res/drawable/default_dot.xml index 0f05be0..c5b76e9 100644 --- a/app/src/main/res/drawable/default_dot.xml +++ b/app/src/main/res/drawable/default_dot.xml @@ -4,7 +4,7 @@ android:width="8dp" android:height="8dp"> - + \ No newline at end of file diff --git a/app/src/main/res/drawable/edit_text_layout_stroke_colors.xml b/app/src/main/res/drawable/edit_text_layout_stroke_colors.xml index 836dbe9..80eae7b 100644 --- a/app/src/main/res/drawable/edit_text_layout_stroke_colors.xml +++ b/app/src/main/res/drawable/edit_text_layout_stroke_colors.xml @@ -1,5 +1,5 @@ - - + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_add_circle_24.xml b/app/src/main/res/drawable/ic_add_circle_24.xml index 52322e8..213fafa 100644 --- a/app/src/main/res/drawable/ic_add_circle_24.xml +++ b/app/src/main/res/drawable/ic_add_circle_24.xml @@ -1,7 +1,7 @@ + + diff --git a/app/src/main/res/drawable/ic_refresh.xml b/app/src/main/res/drawable/ic_refresh.xml new file mode 100644 index 0000000..2d9b89b --- /dev/null +++ b/app/src/main/res/drawable/ic_refresh.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/info_ic.xml b/app/src/main/res/drawable/info_ic.xml new file mode 100644 index 0000000..25d6c00 --- /dev/null +++ b/app/src/main/res/drawable/info_ic.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/logo.xml b/app/src/main/res/drawable/logo.xml new file mode 100644 index 0000000..ce7583b --- /dev/null +++ b/app/src/main/res/drawable/logo.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/onboarding_step_2.png b/app/src/main/res/drawable/onboarding_step_2.png new file mode 100644 index 0000000..5e31061 Binary files /dev/null and b/app/src/main/res/drawable/onboarding_step_2.png differ diff --git a/app/src/main/res/drawable/onboarding_step_3.png b/app/src/main/res/drawable/onboarding_step_3.png new file mode 100644 index 0000000..6c482f1 Binary files /dev/null and b/app/src/main/res/drawable/onboarding_step_3.png differ diff --git a/app/src/main/res/drawable/onboarding_step_4.png b/app/src/main/res/drawable/onboarding_step_4.png new file mode 100644 index 0000000..bf7abca Binary files /dev/null and b/app/src/main/res/drawable/onboarding_step_4.png differ diff --git a/app/src/main/res/drawable/step_1.png b/app/src/main/res/drawable/step_1.png deleted file mode 100644 index 4886118..0000000 Binary files a/app/src/main/res/drawable/step_1.png and /dev/null differ diff --git a/app/src/main/res/drawable/step_2.png b/app/src/main/res/drawable/step_2.png deleted file mode 100644 index b050f3c..0000000 Binary files a/app/src/main/res/drawable/step_2.png and /dev/null differ diff --git a/app/src/main/res/drawable/step_3.png b/app/src/main/res/drawable/step_3.png deleted file mode 100644 index 4d17638..0000000 Binary files a/app/src/main/res/drawable/step_3.png and /dev/null differ diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 48832c8..ce3c35a 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -1,5 +1,23 @@ - + android:layout_height="wrap_content" + android:orientation="vertical"> + + + + + diff --git a/app/src/main/res/layout/dialog_delete.xml b/app/src/main/res/layout/dialog_delete.xml index 15d3a01..c89a1ca 100644 --- a/app/src/main/res/layout/dialog_delete.xml +++ b/app/src/main/res/layout/dialog_delete.xml @@ -25,7 +25,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="16dp" - android:textAppearance="@style/SmsTextAppearance.Regular.14.OnSurfaceVariant" + android:textAppearance="@style/SmsTextAppearance.Regular.14.Subsidiary" tools:text="The rule test will be deleted." /> - - + + @@ -64,7 +70,7 @@ android:layout_marginTop="32dp" android:text="@string/signed_in_as" android:textAppearance="@style/SmsTextAppearance.Regular.14" - android:textColor="@color/green" + android:textColor="?attr/colorSuccess" android:visibility="gone" app:layout_constraintEnd_toEndOf="@id/end_margin" app:layout_constraintStart_toStartOf="@id/start_margin" @@ -131,9 +137,9 @@ android:layout_height="wrap_content" android:layout_marginTop="16dp" app:errorEnabled="true" - app:errorIconTint="@color/red" - app:errorTextColor="@color/red" - app:helperTextTextColor="@color/red" + app:errorIconTint="?attr/colorError" + app:errorTextColor="?attr/colorError" + app:helperTextTextColor="?attr/colorError" app:layout_constraintEnd_toEndOf="@id/end_margin" app:layout_constraintStart_toStartOf="@id/start_margin" app:layout_constraintTop_toBottomOf="@id/enter_email_tv" @@ -144,6 +150,7 @@ android:layout_width="match_parent" android:layout_height="56dp" android:hint="@string/enter_email_hint" + android:textColorHint="?attr/colorSubsidiary" android:imeOptions="actionDone" android:inputType="textEmailAddress" /> diff --git a/app/src/main/res/layout/fragment_add_forwarding_rule.xml b/app/src/main/res/layout/fragment_add_forwarding_rule.xml index 4f6aff7..3d4b157 100644 --- a/app/src/main/res/layout/fragment_add_forwarding_rule.xml +++ b/app/src/main/res/layout/fragment_add_forwarding_rule.xml @@ -21,27 +21,32 @@ android:orientation="vertical" app:layout_constraintGuide_end="16dp" /> - - + app:layout_constraintTop_toTopOf="@id/arrow_back_iv" /> + + @@ -81,6 +86,7 @@ android:layout_width="match_parent" android:layout_height="56dp" android:hint="@string/enter_text_rule_pattern" + android:textColorHint="?attr/colorSubsidiary" android:imeOptions="actionDone" android:inputType="text" android:textAppearance="@style/SmsTextAppearance.Regular.16" /> diff --git a/app/src/main/res/layout/fragment_add_phone_details.xml b/app/src/main/res/layout/fragment_add_phone_details.xml index 41c2c55..a2d44ea 100644 --- a/app/src/main/res/layout/fragment_add_phone_details.xml +++ b/app/src/main/res/layout/fragment_add_phone_details.xml @@ -24,25 +24,30 @@ android:orientation="vertical" app:layout_constraintGuide_end="16dp" /> - - + + diff --git a/app/src/main/res/layout/fragment_add_telegram_details.xml b/app/src/main/res/layout/fragment_add_telegram_details.xml new file mode 100644 index 0000000..951b81b --- /dev/null +++ b/app/src/main/res/layout/fragment_add_telegram_details.xml @@ -0,0 +1,154 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_choose_forwarding_method.xml b/app/src/main/res/layout/fragment_choose_forwarding_method.xml index 709f3ee..06f4eb8 100644 --- a/app/src/main/res/layout/fragment_choose_forwarding_method.xml +++ b/app/src/main/res/layout/fragment_choose_forwarding_method.xml @@ -1,7 +1,6 @@ - - + + + android:textAppearance="@style/SmsTextAppearance.Regular.16" + android:textColorHint="?attr/colorSubsidiary" /> - - + android:textAppearance="@style/SmsTextAppearance.Regular.14.Subsidiary" /> diff --git a/app/src/main/res/layout/fragment_forwarding_history.xml b/app/src/main/res/layout/fragment_forwarding_history.xml new file mode 100644 index 0000000..1c3506a --- /dev/null +++ b/app/src/main/res/layout/fragment_forwarding_history.xml @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml index 295a551..22cc352 100644 --- a/app/src/main/res/layout/fragment_home.xml +++ b/app/src/main/res/layout/fragment_home.xml @@ -26,7 +26,7 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginTop="16dp" - android:text="@string/sms_recipients" + android:text="@string/your_forwardings" android:textAppearance="@style/SmsTextAppearance.Bold.16" app:layout_constraintEnd_toStartOf="@id/power_management_warning_iv" app:layout_constraintStart_toStartOf="@id/start_margin" @@ -36,23 +36,41 @@ android:id="@+id/power_management_warning_iv" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:padding="6dp" + android:contentDescription="@string/battery_save_warning_button" + android:minWidth="48dp" + android:minHeight="48dp" + android:padding="14dp" android:src="@drawable/ic_warning" android:visibility="visible" - app:layout_constraintBottom_toBottomOf="@id/title_label" app:layout_constraintEnd_toStartOf="@id/feedback_iv" - tools:ignore="ContentDescription" /> + app:layout_constraintTop_toTopOf="parent" /> + + + app:layout_constraintTop_toTopOf="parent" /> @@ -42,9 +46,12 @@ android:id="@+id/skip_all_btn" android:layout_width="wrap_content" android:layout_height="wrap_content" + android:minWidth="48dp" + android:minHeight="48dp" + android:gravity="center" android:paddingVertical="8dp" android:text="@string/onboarding_skip_all" - android:textAppearance="@style/SmsTextAppearance.Regular.14.OnSurfaceVariant" + android:textAppearance="@style/SmsTextAppearance.Regular.14.Subsidiary" app:layout_constraintBottom_toBottomOf="@id/step_label" app:layout_constraintEnd_toEndOf="@id/right_guideline" app:layout_constraintTop_toTopOf="@id/step_label" /> @@ -76,13 +83,14 @@ diff --git a/app/src/main/res/layout/item_history.xml b/app/src/main/res/layout/item_history.xml new file mode 100644 index 0000000..eab5d53 --- /dev/null +++ b/app/src/main/res/layout/item_history.xml @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_recipient.xml b/app/src/main/res/layout/item_recipient.xml index e79ef5f..b235417 100644 --- a/app/src/main/res/layout/item_recipient.xml +++ b/app/src/main/res/layout/item_recipient.xml @@ -18,10 +18,11 @@ android:layout_marginEnd="16dp" android:textAppearance="@style/SmsTextAppearance.SemiBold.18" android:visibility="gone" - app:layout_constraintEnd_toStartOf="@id/button_edit_item" + app:layout_constraintEnd_toStartOf="@id/edit_item_btn" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" - tools:text="John Doe" /> + tools:text="John Doe" + tools:visibility="visible" /> @@ -40,40 +41,18 @@ android:layout_marginTop="4dp" android:layout_marginEnd="16dp" android:textAppearance="@style/SmsTextAppearance.Medium.12" - app:layout_constraintEnd_toStartOf="@id/button_edit_item" + app:layout_constraintEnd_toStartOf="@id/edit_item_btn" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/forwarding_type_label" tools:text="email" /> - - - - @@ -84,7 +63,7 @@ android:layout_marginTop="4dp" android:layout_marginEnd="16dp" android:textAppearance="@style/SmsTextAppearance.Medium.12" - app:layout_constraintEnd_toStartOf="@id/button_edit_item" + app:layout_constraintEnd_toStartOf="@id/edit_item_btn" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/email_label" tools:text="john.doe@company.com" /> @@ -94,24 +73,27 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" app:barrierDirection="bottom" - app:constraint_referenced_ids="recipient_phone_et,email" /> + app:constraint_referenced_ids="email" /> - - @color/stroke_dark @color/gray @color/black + @color/gray_light + @color/red_light + @color/green_light + + @@ -44,12 +49,12 @@ 14sp - - - \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 3894acd..401aa48 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -14,6 +14,9 @@ @color/text_disabled_light @color/platinum @color/light_blue + @color/gray + @color/red + @color/green