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
diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml
index a7121d0..8cf3a29 100644
--- a/app/src/main/res/values/attrs.xml
+++ b/app/src/main/res/values/attrs.xml
@@ -1,4 +1,6 @@
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index 072857b..edaf6cd 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -2,8 +2,9 @@
#FF000000
#7E49FF
- #A1A1A1
- #E2E8F0
+ #B6B6B6
+ #737373
+ #EBEBEB
#282828
#494949
#CBD5E1
@@ -11,9 +12,11 @@
#F1F5F9
#64748B
#FFFFFFFF
+ #FF3232
#D72828
#03379F
- #2D9F03
+ #42FF00
+ #208016
#3C4043
#DADCE0
#636366
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 6a8d949..e959023 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -7,16 +7,16 @@
You need to add at least one rule
Enter text rule
Why do you need to grant permissions?
- SMS and notification permissions are needed for forwarding SMS.
+ SMS and notification permissions are needed to read SMS and to show daily notification with forwarded messages count.
Some of permissions are permanently denied. To use full app functionality Go to Settings and enable permissions manually.
OK
Cancel
Save
Submit
+ Back
Pattern is already in the list
Pattern can\'t be blank
- Can\'t send sms to some recipients, please recheck steps
Go to Settings
Title:
Forwarding type:
@@ -25,22 +25,29 @@
Forwarding is disabled.\nPlease check if all setup steps were completed
Forwarding is disabled.\nPlease relogin
Forwarding is disabled.\nPlease check recipient phone number
- Recipients
- Forwarding step 1/3
- Forwarding step 2/3
- Forwarding step 3/3
+ Your forwardings
+ Forwarding step 1 of 3
+ Forwarding step 2 of 3
+ Forwarding step 3 of 3
+ Forwarding
+ Forwarding %s
Forwarding Title
Add recipient
- Add forwarding rule(s)
+ Add forwarding rules
SMS
+ Telegram
+ Telegram Api Token
+ Telegram Chat Id
+ Enter telegram Api Token
+ Enter Telegram Chat Id
Warning! The mobile operator will charge a fee for sending each SMS.
Email
Next
Error
- Forwarding Title
+ Enter forwarding title
You are about to remove item
Do you want to continue?
- +375xx-xxx-xx-xx
+ Enter phone number
Enter recipient email
Finish
Required *
@@ -48,7 +55,8 @@
Email is not valid
Email can not be blank
Field can not be blank
- Unable to sign in to account
+ There was an error while trying to sign in
+ Sign in was canceled
Unable to sign out from account
Sign in with Google
Sign in to your gmail account to be able to forward messages on your behalf*
@@ -57,6 +65,10 @@
Forwarding method
Please, select forwarding method
Choose forwarding method*
+ Rule
+ Rule %s
+ Edit rule %s
+ Delete rule %s
Delete rule
The rule \"%s\" will be deleted.
Change rule
@@ -64,28 +76,36 @@
Forwarding SMS…
You are about to remove item
Do you want to continue?
- Enter the text that the message should contain
+ Rule is the text that the message should contain
Add Rule
Remove
You are about to remove item
The rule \"%s\" will be deleted.
Delete
+ Delete forwarding
+ Delete forwarding %s
+ Edit forwarding
+ Edit forwarding %s
The rule \"%s\" will be changed to
Change rule
Recipient email*
Recipient phone Number*
+ Warning: app can be stopped by your device\'s power settings.
Make app an exemptions from Power Management
In order for app to work when device is not active for a long time,\nplease, make it an exemption from Power Management features
+ How to use the app, %s
Finish
Next
Skip all
Step %1$d
- Choose forwarding method
- Select what type of forwarding you prefer to use. You can forward your incoming SMS messages through email or SMS.
- Provide recipient details
- Provide the recipient\'s email or phone number.
- Add forwarding rules
- Define the keywords that the SMS must contain to be eligible for forwarding.
+ Welcome to\nOpen SMS Forwarder!
+ Thank you for choosing Open SMS Forwarder.\nWe’ll guide you through the app in a few simple steps.
+ Choose forwarding method
+ Select what type of forwarding you prefer to use.
+ Provide recipient details
+ Specify the recipient details.\nFor example, if you choose \'Email\' as forwarding type, you need to:\n1. Login to a Google account to forward messages on your behalf.\n2. Fill recipient email.\n\nFor other types just follow the hints on the screen.
+ Add forwarding rules
+ 1. Enter the keyword that an SMS must contain to be eligible for forwarding.\n2. Submit it with \'Add Rule\' button.\n3. Click \'Finish\'.\n\nForwarding setup is complete!
Privacy info
Be cautious — scammers may trick you into sharing details to intercept your SMS. This app is not distributed by banks or public organizations and is for personal use only. Download it exclusively from Google Play.
I acknowledge the potential risks and agree to proceed
@@ -101,6 +121,15 @@
Forwarding is disabled.\nGeneric Failure Error
Forwarded messages
%1$s messages were forwarded in the last 24 hours
+ Forwarded message number %s\nDate:
+ Date:
+ Status
+ Message:
+ Success
+ Fail
+ Forwarding history
+ Retrying…
+ There is no forwarded messages yet
Feedback
Fill out the form and we will be in touch
Contact e-mail
@@ -108,4 +137,24 @@
Feedback from %s
Feedback successfully sent!
Something went wrong while sending feedback.
+ Telegram api token info
+ Telegram chat id info
+ A Telegram API token is a secret key that gives you access to control a Telegram Bot through the Telegram Bot API. If you don\'t have one yet then pass these steps to get it.\n\n
+ Step 1: Open Telegram App. Use either the mobile or desktop Telegram app.\n\n
+ Step2: Search for @BotFather.\n\n
+ Step 3: Create a New Bot by sending this command - /newbot. Fill the Bot name and Bot username.\n\n
+ Step 4: Find the token in bot message in such format:\n123456789:ABCdefGhIJKLmnoPQRstuVWxyz
+
+ To get Telegram Chat Id open your browser and request:\n
+ https://api.telegram.org/bot[YOUR_BOT_TOKEN]/getUpdates\n
+ In the response you\'ll see chat_id, copy and paste it.
+
+ No credentials found on this device. Please check your account settings.
+ Authorization failed. Please try again later.
+ Sign-in failed: Invalid authorization response received.
+ Something went wrong while preparing sign-in. Please try again.
+ Unable to retrieve authorization code. Please try again.
+ Unexpected error. Please check your connection or try again.
+ Offline
+ Network failure. Please check your connection and try again.
diff --git a/app/src/main/res/values/text_appearance.xml b/app/src/main/res/values/text_appearance.xml
index b75f17e..88c0bf6 100644
--- a/app/src/main/res/values/text_appearance.xml
+++ b/app/src/main/res/values/text_appearance.xml
@@ -36,6 +36,11 @@
- 18sp
+
+
@@ -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
@@ -35,10 +38,10 @@
- 8dp
- 8dp
- clear_text
- - @color/red
- - @color/red
- - @color/red
- - @color/red
+ - ?attr/colorError
+ - ?attr/colorError
+ - ?attr/colorError
+ - ?attr/colorError
- ?attr/colorOnPrimary
- false
- ?attr/colorOnPrimary
diff --git a/app/src/test/java/com/github/smsforwarder/ExampleUnitTest.kt b/app/src/test/java/com/github/smsforwarder/ExampleUnitTest.kt
deleted file mode 100644
index 0313c50..0000000
--- a/app/src/test/java/com/github/smsforwarder/ExampleUnitTest.kt
+++ /dev/null
@@ -1,17 +0,0 @@
-package com.github.smsforwarder
-
-import org.junit.Test
-
-import org.junit.Assert.assertEquals
-
-/**
- * Example local unit test, which will execute on the development machine (host).
- *
- * See [testing documentation](http://d.android.com/tools/testing).
- */
-class ExampleUnitTest {
- @Test
- fun addition_isCorrect() {
- assertEquals(4, 2 + 2)
- }
-}
diff --git a/app/src/test/java/org/open/smsforwarder/GoogleAuthClientTest.kt b/app/src/test/java/org/open/smsforwarder/GoogleAuthClientTest.kt
new file mode 100644
index 0000000..1eacac7
--- /dev/null
+++ b/app/src/test/java/org/open/smsforwarder/GoogleAuthClientTest.kt
@@ -0,0 +1,142 @@
+package org.open.smsforwarder
+
+import android.app.Activity
+import android.app.PendingIntent
+import android.content.Intent
+import android.content.IntentSender
+import com.google.android.gms.auth.api.identity.AuthorizationResult
+import kotlinx.coroutines.test.runTest
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.assertThrows
+import org.junit.jupiter.api.extension.ExtendWith
+import org.mockito.Mock
+import org.mockito.Mockito.mock
+import org.mockito.junit.jupiter.MockitoExtension
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.whenever
+import org.open.smsforwarder.domain.NetworkStateObserver
+import org.open.smsforwarder.platform.CredentialClientWrapper
+import org.open.smsforwarder.platform.GoogleAuthClient
+import org.open.smsforwarder.platform.GoogleSignInFailure
+import org.open.smsforwarder.platform.IdentityClientWrapper
+
+@ExtendWith(MockitoExtension::class)
+class GoogleAuthClientTest {
+
+ @Mock
+ lateinit var activity: Activity
+
+ @Mock
+ lateinit var credentialClientWrapper: CredentialClientWrapper
+
+ @Mock
+ lateinit var identityClientWrapper: IdentityClientWrapper
+
+ @Mock
+ lateinit var authorizationResult: AuthorizationResult
+
+ @Mock
+ lateinit var pendingIntent: PendingIntent
+
+ @Mock
+ lateinit var intentSender: IntentSender
+
+ @Mock
+ lateinit var networkStateObserver: NetworkStateObserver
+
+ private lateinit var client: GoogleAuthClient
+
+ @BeforeEach
+ fun setUp() {
+ client = GoogleAuthClient(credentialClientWrapper, identityClientWrapper, networkStateObserver)
+ }
+
+ @Test
+ fun `getSignInIntent should succeed`() = runTest {
+ whenever(identityClientWrapper.authorize(activity)).thenReturn(authorizationResult)
+ whenever(authorizationResult.pendingIntent).thenReturn(pendingIntent)
+ whenever(pendingIntent.intentSender).thenReturn(intentSender)
+
+ val result = client.getSignInIntent(activity)
+
+ assertEquals(intentSender, result.intentSender)
+ verify(credentialClientWrapper).getCredential(activity)
+ verify(identityClientWrapper).authorize(activity)
+ }
+
+ @Test
+ fun `getSignInIntent should throw MissingPendingIntent when pendingIntent is null`() = runTest {
+ whenever(identityClientWrapper.authorize(activity)).thenReturn(authorizationResult)
+ whenever(authorizationResult.pendingIntent).thenReturn(null)
+
+ assertThrows {
+ client.getSignInIntent(activity)
+ }
+ verify(credentialClientWrapper).getCredential(activity)
+ }
+
+ @Test
+ fun `getSignInIntent should throw CredentialCancellation when cancelled`() = runTest {
+ whenever(credentialClientWrapper.getCredential(activity))
+ .thenThrow(GoogleSignInFailure.CredentialCancellation())
+
+ assertThrows {
+ client.getSignInIntent(activity)
+ }
+ }
+
+ @Test
+ fun `getSignInIntent should throw CredentialsNotFound when credential error occurs`() =
+ runTest {
+ whenever(credentialClientWrapper.getCredential(activity))
+ .thenThrow(GoogleSignInFailure.CredentialsNotFound())
+
+ assertThrows {
+ client.getSignInIntent(activity)
+ }
+ }
+
+ @Test
+ fun `getSignInIntent should throw AuthorizationFailed when authorization fails`() = runTest {
+ whenever(credentialClientWrapper.getCredential(activity)).then { /* success */ }
+ whenever(identityClientWrapper.authorize(activity))
+ .thenThrow(GoogleSignInFailure.AuthorizationFailed())
+
+ assertThrows {
+ client.getSignInIntent(activity)
+ }
+ }
+
+ @Test
+ fun `extractAuthorizationCode should throw AuthResultIntentIsNull when data is null`() {
+ assertThrows {
+ client.extractAuthorizationCode(null)
+ }
+ }
+
+ @Test
+ fun `extractAuthorizationCode should throw MissingAuthCode when serverAuthCode is null`() {
+ val intent = mock(Intent::class.java)
+ whenever(identityClientWrapper.getAuthorizationResultFromIntent(intent))
+ .thenReturn(authorizationResult)
+ whenever(authorizationResult.serverAuthCode).thenReturn(null)
+
+ assertThrows {
+ client.extractAuthorizationCode(intent)
+ }
+ }
+
+ @Test
+ fun `extractAuthorizationCode should return serverAuthCode when present`() {
+ val intent = mock(Intent::class.java)
+ whenever(identityClientWrapper.getAuthorizationResultFromIntent(intent))
+ .thenReturn(authorizationResult)
+ whenever(authorizationResult.serverAuthCode).thenReturn("auth-code")
+
+ val code = client.extractAuthorizationCode(intent)
+
+ assertEquals("auth-code", code)
+ }
+}
diff --git a/app/src/test/java/org/open/smsforwarder/data/mapper/DataMapperTest.kt b/app/src/test/java/org/open/smsforwarder/data/mapper/DataMapperTest.kt
new file mode 100644
index 0000000..a1d441d
--- /dev/null
+++ b/app/src/test/java/org/open/smsforwarder/data/mapper/DataMapperTest.kt
@@ -0,0 +1,135 @@
+package org.open.smsforwarder.data.mapper
+
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Test
+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.ForwardingType
+import org.open.smsforwarder.domain.model.History
+import org.open.smsforwarder.domain.model.Rule
+import java.util.Date
+
+class DataMapperTest {
+
+ @Test
+ fun `ForwardingEntity maps to Forwarding`() {
+ val entity = ForwardingEntity(
+ id = 1,
+ forwardingTitle = "Test Title",
+ forwardingType = ForwardingType.EMAIL,
+ senderEmail = "sender@example.com",
+ recipientEmail = "recipient@example.com",
+ telegramApiToken = "api-token",
+ telegramChatId = "chat-id",
+ errorText = "Some error"
+ )
+
+ val domain = entity.toDomain()
+
+ assertEquals(entity.id, domain.id)
+ assertEquals(entity.forwardingTitle, domain.title)
+ assertEquals(entity.forwardingType, domain.forwardingType)
+ assertEquals(entity.senderEmail, domain.senderEmail)
+ assertEquals(entity.recipientEmail, domain.recipientEmail)
+ assertEquals(entity.telegramApiToken, domain.telegramApiToken)
+ assertEquals(entity.telegramChatId, domain.telegramChatId)
+ assertEquals(entity.errorText, domain.error)
+ }
+
+ @Test
+ fun `Forwarding maps to ForwardingEntity`() {
+ val model = Forwarding(
+ id = 2,
+ title = "My Forwarding",
+ forwardingType = ForwardingType.TELEGRAM,
+ senderEmail = null,
+ recipientEmail = "",
+ telegramApiToken = "12345",
+ telegramChatId = "54321",
+ error = ""
+ )
+
+ val entity = model.toData()
+
+ assertEquals(model.id, entity.id)
+ assertEquals(model.title, entity.forwardingTitle)
+ assertEquals(model.forwardingType, entity.forwardingType)
+ assertEquals(model.senderEmail, entity.senderEmail)
+ assertEquals(model.recipientEmail, entity.recipientEmail)
+ assertEquals(model.telegramApiToken, entity.telegramApiToken)
+ assertEquals(model.telegramChatId, entity.telegramChatId)
+ assertEquals(model.error, entity.errorText)
+ }
+
+ @Test
+ fun `RuleEntity maps to Rule`() {
+ val entity = RuleEntity(
+ id = 5,
+ forwardingId = 10,
+ rule = "contains:abc"
+ )
+
+ val model = entity.toDomain()
+
+ assertEquals(entity.id, model.id)
+ assertEquals(entity.forwardingId, model.forwardingId)
+ assertEquals(entity.rule, model.textRule)
+ }
+
+ @Test
+ fun `Rule maps to RuleEntity`() {
+ val rule = Rule(
+ id = 6,
+ forwardingId = 11,
+ textRule = "startsWith:SMS"
+ )
+
+ val entity = rule.toData()
+
+ assertEquals(rule.id, entity.id)
+ assertEquals(rule.forwardingId, entity.forwardingId)
+ assertEquals(rule.textRule, entity.rule)
+ }
+
+ @Test
+ fun `HistoryEntity maps to History`() {
+ val date = Date().time
+ val entity = HistoryEntity(
+ id = 99,
+ date = date,
+ forwardingId = 123,
+ message = "Hello",
+ isForwardingSuccessful = true
+ )
+
+ val model = entity.toDomain()
+
+ assertEquals(entity.id, model.id)
+ assertEquals(entity.date, model.date)
+ assertEquals(entity.forwardingId, model.forwardingId)
+ assertEquals(entity.message, model.message)
+ assertEquals(entity.isForwardingSuccessful, model.isForwardingSuccessful)
+ }
+
+ @Test
+ fun `History maps to HistoryEntity`() {
+ val now = Date()
+ val history = History(
+ id = 100,
+ date = now.time,
+ forwardingId = 321,
+ message = "Test message",
+ isForwardingSuccessful = false
+ )
+
+ val entity = history.toData()
+
+ assertEquals(history.id, entity.id)
+ assertEquals(history.date, entity.date)
+ assertEquals(history.forwardingId, entity.forwardingId)
+ assertEquals(history.message, entity.message)
+ assertEquals(history.isForwardingSuccessful, entity.isForwardingSuccessful)
+ }
+}
diff --git a/app/src/test/java/org/open/smsforwarder/ui/mapper/PresentationMapperTest.kt b/app/src/test/java/org/open/smsforwarder/ui/mapper/PresentationMapperTest.kt
new file mode 100644
index 0000000..4232820
--- /dev/null
+++ b/app/src/test/java/org/open/smsforwarder/ui/mapper/PresentationMapperTest.kt
@@ -0,0 +1,181 @@
+package org.open.smsforwarder.ui.mapper
+
+import kotlinx.coroutines.CancellationException
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertFalse
+import org.junit.jupiter.api.Assertions.assertThrows
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+import org.open.smsforwarder.R
+import org.open.smsforwarder.domain.model.Forwarding
+import org.open.smsforwarder.domain.model.ForwardingType
+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.steps.addrecipientdetails.addemaildetails.AddEmailDetailsState
+import org.open.smsforwarder.ui.steps.addrecipientdetails.addtelegramdetails.AddTelegramDetailsState
+import java.net.UnknownHostException
+import java.util.Date
+
+class PresentationMapperTest {
+
+ @Test
+ fun `Forwarding maps to AddEmailDetailsState`() {
+ val forwarding = Forwarding(
+ id = 1,
+ title = "Email Rule",
+ forwardingType = ForwardingType.EMAIL,
+ senderEmail = "sender@example.com",
+ recipientEmail = "receiver@example.com"
+ )
+
+ val state = forwarding.toEmailDetailsUi()
+
+ assertEquals(forwarding.id, state.id)
+ assertEquals(forwarding.title, state.title)
+ assertEquals(forwarding.forwardingType, state.forwardingType)
+ assertEquals(forwarding.senderEmail, state.senderEmail)
+ assertEquals(forwarding.recipientEmail, state.recipientEmail)
+ }
+
+ @Test
+ fun `Forwarding maps to AddTelegramDetailsState`() {
+ val forwarding = Forwarding(
+ id = 2,
+ title = "Telegram Rule",
+ forwardingType = ForwardingType.TELEGRAM,
+ telegramApiToken = "token123",
+ telegramChatId = "chat456"
+ )
+
+ val state = forwarding.toTelegramDetailsUi()
+
+ assertEquals(forwarding.id, state.id)
+ assertEquals(forwarding.title, state.title)
+ assertEquals(forwarding.forwardingType, state.forwardingType)
+ assertEquals(forwarding.telegramApiToken, state.telegramApiToken)
+ assertEquals(forwarding.telegramChatId, state.telegramChatId)
+ }
+
+ @Test
+ fun `History maps to HistoryUI`() {
+ val date = Date()
+ val history = History(
+ id = 10,
+ date = date.time,
+ forwardingId = 99,
+ message = "Test message",
+ isForwardingSuccessful = true
+ )
+
+ val ui = history.toHistoryUi()
+
+ assertEquals(history.id, ui.id)
+ assertEquals(history.date, ui.date)
+ assertEquals(history.message, ui.message)
+ assertEquals(history.isForwardingSuccessful, ui.isForwardingSuccessful)
+ }
+
+ @Test
+ fun `mergeWithRules maps correctly to HomeState with rules present`() {
+ val forwardingList = listOf(
+ Forwarding(id = 1, title = "F1", forwardingType = ForwardingType.EMAIL),
+ Forwarding(id = 2, title = "F2", forwardingType = ForwardingType.TELEGRAM)
+ )
+
+ val rules = listOf(
+ Rule(id = 1, forwardingId = 2, textRule = "rule")
+ )
+
+ val homeState = forwardingList.mergeWithRules(rules)
+
+ assertEquals(2, homeState.forwardings.size)
+ assertFalse(homeState.forwardings[0].atLeastOneRuleAdded)
+ assertTrue(homeState.forwardings[1].atLeastOneRuleAdded)
+ }
+
+ @Test
+ fun `AddEmailDetailsState maps to Forwarding`() {
+ val state = AddEmailDetailsState(
+ id = 5,
+ title = "Email Forward",
+ forwardingType = ForwardingType.EMAIL,
+ senderEmail = "a@b.com",
+ recipientEmail = "b@c.com"
+ )
+
+ val model = state.toDomain()
+
+ assertEquals(state.id, model.id)
+ assertEquals(state.title, model.title)
+ assertEquals(state.forwardingType, model.forwardingType)
+ assertEquals(state.senderEmail, model.senderEmail)
+ assertEquals(state.recipientEmail, model.recipientEmail)
+ }
+
+ @Test
+ fun `AddTelegramDetailsState maps to Forwarding`() {
+ val state = AddTelegramDetailsState(
+ id = 6,
+ title = "Telegram Forward",
+ forwardingType = ForwardingType.TELEGRAM,
+ telegramApiToken = "token",
+ telegramChatId = "chatId"
+ )
+
+ val model = state.toDomain()
+
+ assertEquals(state.id, model.id)
+ assertEquals(state.title, model.title)
+ assertEquals(state.forwardingType, model.forwardingType)
+ assertEquals(state.telegramApiToken, model.telegramApiToken)
+ assertEquals(state.telegramChatId, model.telegramChatId)
+ }
+
+ @Test
+ fun `Throwable toUserMessageResId maps correctly`() {
+ assertEquals(
+ R.string.google_sign_in_canceled,
+ GoogleSignInFailure.CredentialCancellation().toUserMessageResId()
+ )
+ assertEquals(
+ R.string.network_failure,
+ GoogleSignInFailure.NoInternetConnection().toUserMessageResId()
+ )
+ assertEquals(
+ R.string.network_failure,
+ UnknownHostException().toUserMessageResId()
+ )
+ assertEquals(
+ R.string.credentials_not_found,
+ GoogleSignInFailure.CredentialsNotFound().toUserMessageResId()
+ )
+ assertEquals(
+ R.string.authorization_failed,
+ GoogleSignInFailure.AuthorizationFailed().toUserMessageResId()
+ )
+ assertEquals(
+ R.string.authorization_result_is_null,
+ GoogleSignInFailure.AuthResultIntentIsNull.toUserMessageResId()
+ )
+ assertEquals(
+ R.string.pending_intent_missing,
+ GoogleSignInFailure.MissingPendingIntent.toUserMessageResId()
+ )
+ assertEquals(
+ R.string.auth_code_not_received,
+ GoogleSignInFailure.MissingAuthCode.toUserMessageResId()
+ )
+ assertEquals(
+ R.string.sign_in_general_error,
+ RuntimeException("unknown error").toUserMessageResId()
+ )
+ }
+
+ @Test
+ fun `CancellationException thrown in toUserMessageResId`() {
+ assertThrows(CancellationException::class.java) {
+ CancellationException("Cancelled").toUserMessageResId()
+ }
+ }
+}
diff --git a/app/src/test/java/org/open/smsforwarder/ui/onboarding/OnboardingViewModelTest.kt b/app/src/test/java/org/open/smsforwarder/ui/onboarding/OnboardingViewModelTest.kt
new file mode 100644
index 0000000..e181464
--- /dev/null
+++ b/app/src/test/java/org/open/smsforwarder/ui/onboarding/OnboardingViewModelTest.kt
@@ -0,0 +1,118 @@
+import com.github.terrakok.cicerone.Router
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.extension.ExtendWith
+import org.mockito.Mock
+import org.mockito.Mockito.verify
+import org.mockito.junit.jupiter.MockitoExtension
+import org.mockito.kotlin.argThat
+import org.open.smsforwarder.R
+import org.open.smsforwarder.analytics.AnalyticsEvents
+import org.open.smsforwarder.analytics.AnalyticsTracker
+import org.open.smsforwarder.data.repository.LocalSettingsRepository
+import org.open.smsforwarder.navigation.Screens
+import org.open.smsforwarder.ui.onboarding.NextPageEffect
+import org.open.smsforwarder.ui.onboarding.OnboardingViewModel
+import org.open.smsforwarder.ui.onboarding.PreviousPageEffect
+import org.open.smsforwarder.ui.onboarding.SkipAllEffect
+import org.open.smsforwarder.ui.onboarding.WarningEffect
+
+@ExperimentalCoroutinesApi
+@ExtendWith(MockitoExtension::class)
+class OnboardingViewModelTest {
+
+ private val testDispatcher = UnconfinedTestDispatcher()
+
+ @Mock
+ lateinit var localSettingsRepository: LocalSettingsRepository
+
+ @Mock
+ lateinit var analyticsTracker: AnalyticsTracker
+
+ @Mock
+ lateinit var router: Router
+
+ private lateinit var viewModel: OnboardingViewModel
+
+ @BeforeEach
+ fun setup() {
+ Dispatchers.setMain(testDispatcher)
+ viewModel = OnboardingViewModel(localSettingsRepository, analyticsTracker, router)
+ }
+
+ @AfterEach
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ @Test
+ fun `onSlidePage - updates state correctly when not last page`() = runTest {
+ viewModel.onSlidePage(0)
+ val state = viewModel.viewState.first()
+ assertEquals(1, state.currentStep)
+ assertEquals(R.string.onboarding_fragment_next, state.nextButtonRes)
+ assertEquals(false, state.isLastSlide)
+ }
+
+ @Test
+ fun `onSlidePage - updates state correctly when last page`() = runTest {
+ viewModel.onSlidePage(4)
+ val state = viewModel.viewState.first()
+ assertEquals(5, state.currentStep)
+ assertEquals(R.string.onboarding_fragment_finish, state.nextButtonRes)
+ assertEquals(true, state.isLastSlide)
+ }
+
+ @Test
+ fun `onNextButtonClicked - last page with acknowledgment - completes onboarding`() = runTest {
+ viewModel.onSlidePage(4)
+ viewModel.onNextButtonClicked(4, true)
+
+ verify(localSettingsRepository).setOnboardingCompleted()
+ verify(analyticsTracker).trackEvent(AnalyticsEvents.ONBOARDING_COMPLETE)
+ verify(router).replaceScreen(argThat { screen ->
+ screen.screenKey == Screens.homeFragment().screenKey
+ })
+ }
+
+ @Test
+ fun `onNextButtonClicked - last page without acknowledgment - sends warning`() = runTest {
+ viewModel.onSlidePage(3)
+ viewModel.onNextButtonClicked(4, false)
+
+ val effect = viewModel.viewEffect.first()
+ verify(analyticsTracker).trackEvent(AnalyticsEvents.ONBOARDING_WARNING_SHOW)
+ assertEquals(WarningEffect, effect)
+ }
+
+ @Test
+ fun `onNextButtonClicked - not last page - sends NextPageEffect`() = runTest {
+ viewModel.onNextButtonClicked(0, true)
+ val effect = viewModel.viewEffect.first()
+ assertEquals(NextPageEffect, effect)
+ }
+
+ @Test
+ fun `onPreviousButtonClicked - sends PreviousPageEffect`() = runTest {
+ viewModel.onPreviousButtonClicked()
+ val effect = viewModel.viewEffect.first()
+ assertEquals(PreviousPageEffect, effect)
+ }
+
+ @Test
+ fun `onSkipAllButtonClicked - sends SkipAllEffect`() = runTest {
+ viewModel.onSkipAllButtonClicked()
+ val effect = viewModel.viewEffect.first()
+ assertEquals(SkipAllEffect, effect)
+ }
+}
diff --git a/app/src/test/java/org/open/smsforwarder/ui/steps/addrecipientdetails/addtelegramdetails/AddTelegramDetailsViewModelTest.kt b/app/src/test/java/org/open/smsforwarder/ui/steps/addrecipientdetails/addtelegramdetails/AddTelegramDetailsViewModelTest.kt
new file mode 100644
index 0000000..a13af9f
--- /dev/null
+++ b/app/src/test/java/org/open/smsforwarder/ui/steps/addrecipientdetails/addtelegramdetails/AddTelegramDetailsViewModelTest.kt
@@ -0,0 +1,133 @@
+package org.open.smsforwarder.ui.steps.addrecipientdetails.addtelegramdetails
+
+import androidx.lifecycle.LifecycleOwner
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertNotNull
+import org.junit.jupiter.api.Assertions.assertNull
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.extension.ExtendWith
+import org.mockito.Mock
+import org.mockito.Mockito.mock
+import org.mockito.Mockito.verify
+import org.mockito.junit.jupiter.MockitoExtension
+import org.mockito.junit.jupiter.MockitoSettings
+import org.mockito.kotlin.argThat
+import org.mockito.kotlin.whenever
+import org.mockito.quality.Strictness
+import org.open.smsforwarder.data.repository.ForwardingRepository
+import org.open.smsforwarder.domain.ValidationError
+import org.open.smsforwarder.domain.ValidationResult
+import org.open.smsforwarder.domain.model.Forwarding
+import org.open.smsforwarder.domain.model.ForwardingType
+import org.open.smsforwarder.domain.usecase.ValidateBlankFieldUseCase
+import org.open.smsforwarder.navigation.Screens
+import org.open.smsforwarder.ui.mapper.toDomain
+import org.open.smsforwarder.utils.awaitInitialAction
+
+@ExperimentalCoroutinesApi
+@ExtendWith(MockitoExtension::class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+class AddTelegramDetailsViewModelTest {
+
+ private val testDispatcher = UnconfinedTestDispatcher()
+
+ @Mock
+ lateinit var repository: ForwardingRepository
+
+ @Mock
+ lateinit var router: com.github.terrakok.cicerone.Router
+
+ @Mock
+ lateinit var validateBlankFieldUseCase: ValidateBlankFieldUseCase
+
+ private lateinit var viewModel: AddTelegramDetailsViewModel
+ private val testId = 42L
+
+ @BeforeEach
+ fun setup() {
+ Dispatchers.setMain(testDispatcher)
+ whenever(repository.getForwardingByIdFlow(testId))
+ .thenReturn(
+ MutableStateFlow(
+ Forwarding(
+ id = testId,
+ forwardingType = ForwardingType.TELEGRAM
+ )
+ )
+ )
+
+ viewModel = AddTelegramDetailsViewModel(
+ id = testId,
+ forwardingRepository = repository,
+ validateBlankFieldUseCase = validateBlankFieldUseCase,
+ router = router
+ )
+ }
+
+ @AfterEach
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ @Test
+ fun `onTelegramApiTokenChanged - updates state with validation error`() = runTest {
+ whenever(validateBlankFieldUseCase.execute("")).thenReturn(
+ ValidationResult(
+ successful = false,
+ errorType = ValidationError.BLANK_FIELD
+ )
+ )
+ awaitInitialAction(viewModel.viewState) {
+ viewModel.onTelegramApiTokenChanged("")
+
+ val state = viewModel.viewState.value
+ assertEquals("", state.telegramApiToken)
+ assertNotNull(state.inputErrorApiToken)
+ }
+ }
+
+ @Test
+ fun `onTelegramChatIdChanged - updates state with validation error`() = runTest {
+ whenever(validateBlankFieldUseCase.execute("chatId")).thenReturn(ValidationResult(successful = true))
+ awaitInitialAction(viewModel.viewState) {
+ viewModel.onTelegramChatIdChanged("chatId")
+
+ val state = viewModel.viewState.value
+ assertEquals("chatId", state.telegramChatId)
+ assertNull(state.inputErrorChatId)
+ }
+ }
+
+ @Test
+ fun `onNextClicked - navigates to addForwardingRuleFragment`() = runTest {
+ viewModel.onNextClicked()
+ verify(router).navigateTo(argThat { screenKey == Screens.addForwardingRuleFragment(testId).screenKey })
+ }
+
+ @Test
+ fun `onBackClicked - exits router`() = runTest {
+ viewModel.onBackClicked()
+ verify(router).exit()
+ }
+
+ @Test
+ fun `onPause - saves current state to repository`() = runTest {
+ val owner = mock()
+ val expected = viewModel.viewState.value.toDomain()
+
+ viewModel.onPause(owner)
+
+ verify(repository).insertOrUpdateForwarding(expected)
+ }
+}
diff --git a/app/src/test/java/org/open/smsforwarder/ui/steps/addrule/AddForwardingRuleViewModelTest.kt b/app/src/test/java/org/open/smsforwarder/ui/steps/addrule/AddForwardingRuleViewModelTest.kt
new file mode 100644
index 0000000..ae88163
--- /dev/null
+++ b/app/src/test/java/org/open/smsforwarder/ui/steps/addrule/AddForwardingRuleViewModelTest.kt
@@ -0,0 +1,167 @@
+package org.open.smsforwarder.ui.steps.addrule
+
+import com.github.terrakok.cicerone.Router
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.take
+import kotlinx.coroutines.flow.toList
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.extension.ExtendWith
+import org.mockito.Mock
+import org.mockito.junit.jupiter.MockitoExtension
+import org.mockito.junit.jupiter.MockitoSettings
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.whenever
+import org.mockito.quality.Strictness
+import org.open.smsforwarder.R
+import org.open.smsforwarder.analytics.AnalyticsEvents
+import org.open.smsforwarder.analytics.AnalyticsTracker
+import org.open.smsforwarder.data.repository.RulesRepository
+import org.open.smsforwarder.domain.model.Rule
+import org.open.smsforwarder.utils.awaitInitialAction
+
+@ExperimentalCoroutinesApi
+@ExtendWith(MockitoExtension::class)
+class AddForwardingRuleViewModelTest {
+
+ private val testDispatcher = UnconfinedTestDispatcher()
+
+ @Mock
+ lateinit var rulesRepository: RulesRepository
+
+ @Mock
+ lateinit var router: Router
+
+ @Mock
+ lateinit var analyticsTracker: AnalyticsTracker
+
+ private lateinit var viewModel: AddForwardingRuleViewModel
+ private val testId = 123L
+
+ @BeforeEach
+ fun setup() {
+ Dispatchers.setMain(testDispatcher)
+
+ viewModel = AddForwardingRuleViewModel(
+ id = testId,
+ rulesRepository = rulesRepository,
+ router = router,
+ analyticsTracker = analyticsTracker
+ )
+ }
+
+ @AfterEach
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ @Test
+ fun `onNewRuleEntered - enables button when input is valid and not duplicate`() = runTest {
+ whenever(rulesRepository.getRulesByForwardingIdFlow(testId))
+ .thenReturn(MutableStateFlow(emptyList()))
+ awaitInitialAction(viewModel.viewState) {
+ viewModel.onNewRuleEntered("valid-rule")
+ val state = viewModel.viewState.value
+ assertEquals(true, state.isAddRuleButtonEnabled)
+ assertEquals(null, state.errorMessage)
+ }
+ }
+
+ @Test
+ fun `onNewRuleEntered - disables button when input is blank`() = runTest {
+ viewModel.onNewRuleEntered(" ")
+ val state = viewModel.viewState.value
+ assertEquals(false, state.isAddRuleButtonEnabled)
+ }
+
+ @Test
+ fun `onNewRuleEntered - disables button and sets error when rule already exists`() = runTest {
+ val existingRule = Rule(id = 1L, textRule = "duplicate", forwardingId = testId)
+ whenever(rulesRepository.getRulesByForwardingIdFlow(testId))
+ .thenReturn(MutableStateFlow(listOf(existingRule)))
+
+ awaitInitialAction(viewModel.viewState) {
+ viewModel.onNewRuleEntered("duplicate")
+ val state = viewModel.viewState.value
+ assertEquals(false, state.isAddRuleButtonEnabled)
+ assertEquals(R.string.add_rule_error, state.errorMessage)
+ }
+ }
+
+ @Test
+ fun `onFinishClicked - tracks event and navigates backTo null`() = runTest {
+ viewModel.onFinishClicked()
+ verify(analyticsTracker).trackEvent(AnalyticsEvents.RECIPIENT_CREATION_FINISHED)
+ verify(router).backTo(null)
+ }
+
+ @Test
+ fun `onBackClicked - exits router`() = runTest {
+ viewModel.onBackClicked()
+ verify(router).exit()
+ }
+
+ @Test
+ fun `onAddRuleClicked - inserts rule and tracks analytics`() = runTest {
+ viewModel.onAddRuleClicked("new-rule")
+
+ val expected = Rule(forwardingId = testId, textRule = "new-rule")
+ verify(rulesRepository).insertRule(expected)
+ verify(analyticsTracker).trackEvent(AnalyticsEvents.RULE_ADD_CLICKED)
+ }
+
+ @Test
+ fun `onItemEditClicked - sends ForwardingEditRuleEffect`() = runTest {
+ val rule = Rule(id = 42L, textRule = "to-delete", forwardingId = testId)
+
+ viewModel.onItemRemoveClicked(rule)
+
+ val effect = viewModel.viewEffect.take(1).toList().first()
+
+ assertTrue(effect is ForwardingDeleteRuleEffect)
+ assertEquals(rule, (effect as ForwardingDeleteRuleEffect).rule)
+ }
+
+ @Test
+ fun `onItemEdited - updates existing rule`() = runTest {
+ val existing = Rule(id = 1L, textRule = "old", forwardingId = testId)
+ whenever(rulesRepository.getRulesByForwardingIdFlow(testId))
+ .thenReturn(MutableStateFlow(listOf(existing)))
+
+ awaitInitialAction(viewModel.viewState) {
+ viewModel.onItemEdited(1L, "updated")
+
+ val updated = existing.copy(textRule = "updated")
+ verify(rulesRepository).insertRule(updated)
+ }
+ }
+
+ @Test
+ fun `onItemRemoved - deletes rule by id`() = runTest {
+ viewModel.onItemRemoved(999L)
+ verify(rulesRepository).deleteRule(999L)
+ }
+
+ @Test
+ fun `onItemRemoveClicked - sends ForwardingDeleteRuleEffect`() = runTest {
+ val rule = Rule(id = 42L, textRule = "to-delete", forwardingId = testId)
+
+ viewModel.onItemRemoveClicked(rule)
+
+ val effect = viewModel.viewEffect.take(1).toList().first()
+
+ assertTrue(effect is ForwardingDeleteRuleEffect)
+ assertEquals(rule, (effect as ForwardingDeleteRuleEffect).rule)
+ }
+}
diff --git a/app/src/test/java/org/open/smsforwarder/ui/steps/choosemethod/ChooseForwardingMethodViewModelTest.kt b/app/src/test/java/org/open/smsforwarder/ui/steps/choosemethod/ChooseForwardingMethodViewModelTest.kt
new file mode 100644
index 0000000..5111b14
--- /dev/null
+++ b/app/src/test/java/org/open/smsforwarder/ui/steps/choosemethod/ChooseForwardingMethodViewModelTest.kt
@@ -0,0 +1,137 @@
+package org.open.smsforwarder.ui.steps.choosemethod
+
+import com.github.terrakok.cicerone.Router
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.extension.ExtendWith
+import org.mockito.Mock
+import org.mockito.Mockito.verify
+import org.mockito.Mockito.`when`
+import org.mockito.junit.jupiter.MockitoExtension
+import org.mockito.junit.jupiter.MockitoSettings
+import org.mockito.kotlin.argThat
+import org.mockito.quality.Strictness
+import org.open.smsforwarder.analytics.AnalyticsEvents
+import org.open.smsforwarder.analytics.AnalyticsTracker
+import org.open.smsforwarder.data.repository.ForwardingRepository
+import org.open.smsforwarder.domain.model.Forwarding
+import org.open.smsforwarder.domain.model.ForwardingType
+import org.open.smsforwarder.navigation.Screens
+import org.open.smsforwarder.utils.awaitInitialAction
+
+@ExperimentalCoroutinesApi
+@ExtendWith(MockitoExtension::class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+class ChooseForwardingMethodViewModelTest {
+
+ private val testDispatcher = UnconfinedTestDispatcher()
+
+ @Mock
+ lateinit var forwardingRepository: ForwardingRepository
+
+ @Mock
+ lateinit var analyticsTracker: AnalyticsTracker
+
+ @Mock
+ lateinit var router: Router
+
+ private lateinit var viewModel: ChooseForwardingMethodViewModel
+
+ private val testId = 123L
+
+ @BeforeEach
+ fun setup() {
+ Dispatchers.setMain(testDispatcher)
+
+ `when`(forwardingRepository.getForwardingByIdFlow(testId))
+ .thenReturn(flowOf(Forwarding(id = testId)))
+
+ viewModel = ChooseForwardingMethodViewModel(
+ id = testId,
+ forwardingRepository = forwardingRepository,
+ analyticsTracker = analyticsTracker,
+ router = router
+ )
+ }
+
+ @AfterEach
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ @Test
+ fun `onTitleChanged - updates view state with new title`() = runTest {
+ awaitInitialAction(viewModel.viewState) {
+ viewModel.onTitleChanged("My Forwarding")
+
+ val state = viewModel.viewState.value
+ assertEquals("My Forwarding", state.title)
+ }
+ }
+
+ @Test
+ fun `onForwardingMethodChanged - updates view state with new method`() = runTest {
+ awaitInitialAction(viewModel.viewState) {
+ viewModel.onForwardingMethodChanged(ForwardingType.EMAIL)
+ val state = viewModel.viewState.value
+ assertEquals(ForwardingType.EMAIL, state.forwardingType)
+ }
+ }
+
+ @Test
+ fun `onNextClicked - with EMAIL method navigates to email screen`() = runTest {
+ awaitInitialAction(viewModel.viewState) {
+ viewModel.onForwardingMethodChanged(ForwardingType.EMAIL)
+ viewModel.onNextClicked()
+
+ verify(analyticsTracker).trackEvent(AnalyticsEvents.RECIPIENT_CREATION_STEP1_NEXT_CLICKED)
+ verify(router).navigateTo(argThat { screenKey == Screens.addEmailDetailsFragment(testId).screenKey })
+ }
+ }
+
+ @Test
+ fun `onNextClicked - with TELEGRAM method navigates to telegram screen`() = runTest {
+ awaitInitialAction(viewModel.viewState) {
+ viewModel.onForwardingMethodChanged(ForwardingType.TELEGRAM)
+ viewModel.onNextClicked()
+
+ verify(analyticsTracker).trackEvent(AnalyticsEvents.RECIPIENT_CREATION_STEP1_NEXT_CLICKED)
+ verify(router).navigateTo(argThat {
+ screenKey == Screens.addTelegramDetailsFragment(
+ testId
+ ).screenKey
+ })
+ }
+ }
+
+ @Test
+ fun `onNextClicked - with null method does not navigate`() = runTest {
+ awaitInitialAction(viewModel.viewState) {
+ viewModel.onForwardingMethodChanged(null)
+ viewModel.onNextClicked()
+
+ verify(
+ analyticsTracker,
+ org.mockito.Mockito.never()
+ ).trackEvent(AnalyticsEvents.RECIPIENT_CREATION_STEP1_NEXT_CLICKED)
+ verify(router, org.mockito.Mockito.never()).navigateTo(org.mockito.kotlin.any())
+ }
+ }
+
+ @Test
+ fun `onBackClicked - calls router exit`() = runTest {
+ viewModel.onBackClicked()
+ verify(router).exit()
+ }
+}
diff --git a/app/src/test/java/org/open/smsforwarder/utils/TestUtils.kt b/app/src/test/java/org/open/smsforwarder/utils/TestUtils.kt
new file mode 100644
index 0000000..ed0ec53
--- /dev/null
+++ b/app/src/test/java/org/open/smsforwarder/utils/TestUtils.kt
@@ -0,0 +1,20 @@
+package org.open.smsforwarder.utils
+
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.advanceUntilIdle
+
+@OptIn(ExperimentalCoroutinesApi::class)
+inline fun TestScope.awaitInitialAction(
+ viewState: StateFlow,
+ block: () -> Unit
+) {
+ val collectionJob = launch {
+ viewState.collect { }
+ }
+ advanceUntilIdle()
+ block()
+ collectionJob.cancel()
+}
diff --git a/build.gradle b/build.gradle
index 3443244..4aace10 100644
--- a/build.gradle
+++ b/build.gradle
@@ -1,9 +1,9 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
- id 'com.android.application' version '8.2.2' apply false
- id 'org.jetbrains.kotlin.android' version '1.9.22' apply false
- id 'com.google.dagger.hilt.android' version '2.49' apply false
- id 'com.google.devtools.ksp' version '1.9.22-1.0.17' apply false
+ id 'com.android.application' version '8.7.2' apply false
+ id 'org.jetbrains.kotlin.android' version '2.1.10' apply false
+ id 'com.google.dagger.hilt.android' version '2.55' apply false
+ id 'com.google.devtools.ksp' version '2.1.10-1.0.31' apply false
id 'com.google.gms.google-services' version '4.4.2' apply false
id 'com.google.firebase.crashlytics' version '3.0.2' apply false
}
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index faf01a5..3ffc321 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
#Mon Jan 08 13:55:21 MSK 2024
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.10-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
diff --git a/scripts/check_room_schema_drift.sh b/scripts/check_room_schema_drift.sh
new file mode 100644
index 0000000..002fafa
--- /dev/null
+++ b/scripts/check_room_schema_drift.sh
@@ -0,0 +1,44 @@
+#!/bin/bash
+
+set -e
+
+echo "🔍 Checking Room schema drift..."
+
+# Determine base branch/commit for diff
+if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then
+ echo "📦 Pull request detected"
+ BASE_BRANCH="$GITHUB_BASE_REF"
+ BASE_REF="origin/${GITHUB_BASE_REF}"
+ git fetch origin "$BASE_BRANCH"
+else
+ echo "📦 Push detected"
+ BASE_REF=$(git merge-base HEAD HEAD^)
+fi
+
+echo "🔁 Comparing with: $BASE_REF"
+
+# Get status of changed schema files (A=Added, M=Modified, D=Deleted, etc.)
+SCHEMA_DIFFS=$(git diff --name-status "$BASE_REF" HEAD -- app/schemas/)
+
+MODIFIED_OR_DELETED=()
+
+while read -r status file; do
+ if [[ "$status" == "M" || "$status" == "D" ]]; then
+ MODIFIED_OR_DELETED+=("$file")
+ fi
+done <<< "$SCHEMA_DIFFS"
+
+if [[ ${#MODIFIED_OR_DELETED[@]} -eq 0 ]]; then
+ echo "✅ No Room schema changes detected between commits."
+else
+ echo "❌ Room schema modifications detected:"
+ for file in "${MODIFIED_OR_DELETED[@]}"; do
+ echo " - $file"
+ done
+ echo "💡 If this change was intentional, check if you have done the following steps:"
+ echo " - Bump the Room DB version"
+ echo " - Add proper migration"
+ echo " - Regenerate schema with: ./gradlew assembleDebug"
+ echo " - Write instrumentation test to validate migration"
+ exit 1
+fi
diff --git a/version.properties b/version.properties
new file mode 100644
index 0000000..20fb5b8
--- /dev/null
+++ b/version.properties
@@ -0,0 +1,2 @@
+VERSION_NAME=1.5.1
+VERSION_CODE=10501
\ No newline at end of file