diff --git a/.editorconfig b/.editorconfig index 6d83688d..eab196de 100644 --- a/.editorconfig +++ b/.editorconfig @@ -11,3 +11,13 @@ insert_final_newline = true [.idea/codeStyles/*.xml] indent_size = 2 insert_final_newline = false + +[*.{kt,kts}] +max_line_length = 120 +ktlint_code_style = android_studio +ktlint_standard_import-ordering = disabled +ktlint_class_signature_rule_force_multiline_when_parameter_count_greater_or_equal_than = unset +ktlint_function_signature_rule_force_multiline_when_parameter_count_greater_or_equal_than = unset +ktlint_standard_trailing-comma-on-call-site = disabled +ktlint_standard_trailing-comma-on-declaration-site = disabled +ktlint_standard_function-signature = disabled diff --git a/.github/actions/get-avd-info/action.yml b/.github/actions/get-avd-info/action.yml index a65c0e6e..293841eb 100644 --- a/.github/actions/get-avd-info/action.yml +++ b/.github/actions/get-avd-info/action.yml @@ -5,18 +5,22 @@ inputs: description: The API level for which to retrieve AVD info required: true outputs: - arch: - description: CPU architecture of the system image - value: ${{ steps.get-avd-arch.outputs.arch }} target: description: Target of the system image value: ${{ steps.get-avd-target.outputs.target }} + arch: + description: CPU architecture of the system image + value: ${{ steps.get-avd-arch.outputs.arch }} runs: using: "composite" steps: - - id: get-avd-arch - run: echo "::set-output name=arch::$(if [ ${{ inputs.api-level }} -ge 30 ]; then echo x86_64; else echo x86; fi)" - shell: bash + # Prefer ATD system images available in API 30+ as they are optimized for headless tests. + # Google Play services is required and is available in the google_atd and google_apis images. + # Note, API 27 does not provide a google_apis system image. - id: get-avd-target - run: echo "::set-output name=target::$(if [ ${{ inputs.api-level }} -ge 32 ]; then echo google_apis; else echo default; fi)" + run: echo "target=$(if [ ${{ inputs.api-level }} -eq 27 ]; then echo default; elif [ ${{ inputs.api-level }} -ge 30 ]; then echo google_atd; else echo google_apis; fi)" >> $GITHUB_OUTPUT + shell: bash + # Prefer x86_64 architecture + - id: get-avd-arch + run: echo "arch=x86_64" >> $GITHUB_OUTPUT shell: bash diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index d3942ece..44276cb9 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -17,11 +17,11 @@ jobs: - name: Checkout the code uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 - - name: Set up JDK 11 + - name: Set up JDK 17 uses: actions/setup-java@99b8673ff64fbf99d8d325f52d9a5bdedb8483e9 with: - java-version: '11' - distribution: 'temurin' + java-version: '17' + distribution: 'corretto' cache: gradle - name: Clear gradle cache @@ -63,7 +63,7 @@ jobs: - name: Upload reports uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 with: - name: Test-Reports + name: analyze-reports path: app/build/reports if: always() @@ -76,11 +76,11 @@ jobs: - name: Checkout the code uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 - - name: Set up JDK 11 + - name: Set up JDK 17 uses: actions/setup-java@99b8673ff64fbf99d8d325f52d9a5bdedb8483e9 with: - java-version: '11' - distribution: 'temurin' + java-version: '17' + distribution: 'corretto' cache: gradle - name: Clear gradle cache @@ -106,28 +106,28 @@ jobs: - name: Upload reports uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 with: - name: Test-Reports + name: unit-tests-reports path: app/build/reports if: always() instrumentation-tests: - name: Instrumentation tests on ${{ matrix.target }} API ${{ matrix.api-level }} - runs-on: macos-latest - timeout-minutes: 30 + name: Instrumentation tests on API ${{ matrix.api-level }} + runs-on: ubuntu-latest + timeout-minutes: 15 needs: unit-tests strategy: - fail-fast: true + fail-fast: false matrix: - api-level: [ 21, 32 ] + api-level: [ 26, 27, 28, 29, 30, 31, 32, 33, 34 ] steps: - name: Checkout the code uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 - - name: Set up JDK 11 + - name: Set up JDK 17 uses: actions/setup-java@99b8673ff64fbf99d8d325f52d9a5bdedb8483e9 with: - java-version: '11' - distribution: 'temurin' + java-version: '17' + distribution: 'corretto' cache: gradle - name: Clear gradle cache @@ -145,7 +145,15 @@ jobs: - name: Check Gradle wrapper uses: gradle/wrapper-validation-action@216d1ad2b3710bf005dc39237337b9673fd8fcd5 - # API 30+ emulators only have x86_64 system images + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Create directory for AVD + run: mkdir -p ~/.android/avd + - name: Get AVD info uses: ./.github/actions/get-avd-info id: avd-info @@ -168,29 +176,29 @@ jobs: uses: reactivecircus/android-emulator-runner@6b0df4b0efb23bb0ec63d881db79aefbc976e4b2 with: api-level: ${{ matrix.api-level }} - arch: ${{ steps.avd-info.outputs.arch }} target: ${{ steps.avd-info.outputs.target }} - disable-animations: false + arch: ${{ steps.avd-info.outputs.arch }} + disable-animations: true force-avd-creation: false ram-size: 4096M emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none script: echo "Generated AVD snapshot" - name: Run instrumentation tests - uses: reactivecircus/android-emulator-runner@6b0df4b0efb23bb0ec63d881db79aefbc976e4b2 + uses: reactivecircus/android-emulator-runner@62dbb605bba737720e10b196cb4220d374026a6d with: api-level: ${{ matrix.api-level }} - arch: ${{ steps.avd-info.outputs.arch }} target: ${{ steps.avd-info.outputs.target }} + arch: ${{ steps.avd-info.outputs.arch }} disable-animations: true force-avd-creation: false ram-size: 4096M - emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -no-snapshot-save - script: mv .github/debug.keystore ~/.android; ./gradlew connectedDebugAndroidTest + emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + script: ./gradlew connectedCheck && killall -INT crashpad_handler || true - name: Upload reports uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 with: - name: Test-Reports + name: instrumentation-tests-reports-api${{ matrix.api-level }} path: app/build/reports if: always() diff --git a/.github/workflows/master.yaml b/.github/workflows/master.yaml index b62bed1f..eb7771ec 100644 --- a/.github/workflows/master.yaml +++ b/.github/workflows/master.yaml @@ -34,11 +34,11 @@ jobs: GOOGLE_SERVICES_JSON: ${{ secrets.GOOGLE_SERVICES_JSON }} run: echo $GOOGLE_SERVICES_JSON > app/google-services.json - - name: Set up JDK 11 + - name: Set up JDK 17 uses: actions/setup-java@99b8673ff64fbf99d8d325f52d9a5bdedb8483e9 with: - java-version: '11' - distribution: 'temurin' + java-version: '17' + distribution: 'corretto' cache: gradle - name: Clear gradle cache @@ -98,6 +98,13 @@ jobs: name: bisq-release.aab path: app/build/outputs/bundle/release + - name: Determine latest build-tools version + shell: bash + run: | + LATEST_BUILD_TOOLS_VERSION=$(ls /usr/local/lib/android/sdk/build-tools/ | tail -n 1) + echo "LATEST_BUILD_TOOLS_VERSION=$LATEST_BUILD_TOOLS_VERSION" >> $GITHUB_ENV + echo Latest build tools version is: $LATEST_BUILD_TOOLS_VERSION + - name: Sign APK uses: r0adkll/sign-android-release@dbeba6b98a60b0fd540c02443c7f428cdedf0e7f id: sign_apk @@ -107,6 +114,8 @@ jobs: alias: ${{ secrets.KEY_ALIAS }} keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }} keyPassword: ${{ secrets.KEY_PASSWORD }} + env: + BUILD_TOOLS_VERSION: ${{ env.LATEST_BUILD_TOOLS_VERSION }} - name: Sign AAB uses: r0adkll/sign-android-release@dbeba6b98a60b0fd540c02443c7f428cdedf0e7f @@ -117,6 +126,8 @@ jobs: alias: ${{ secrets.KEY_ALIAS }} keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }} keyPassword: ${{ secrets.KEY_PASSWORD }} + env: + BUILD_TOOLS_VERSION: ${{ env.LATEST_BUILD_TOOLS_VERSION }} - name: Upload signed APK uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 824b3aa4..c87441f2 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -19,7 +19,7 @@ jobs: - name: Read VERSION file id: get_version - run: echo "::set-output name=version::$(cat VERSION/VERSION)" + run: echo "version=$(cat VERSION/VERSION)" >> $GITHUB_OUTPUT - name: Create release id: create_release diff --git a/README.md b/README.md index 56b33b1a..82a856ec 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,46 @@ # Bisq Notification Android App + Since Bisq is a desktop-based application, this Android app enables you to pair it with your desktop application and receive important notifications such as trade updates and offer alerts when you are not near your computer. -## How to Run -In order to pair the app and receive notifications, you will need to create your own +## Prerequisites + +In order to pair the app and receive notifications, you will need to obtain an appropriate `google-services.json` file and place it under the app/ directory. Refer to [firebase documentation](https://firebase.google.com/docs/android/setup#add-config-file) for more information. +> Note, the `google-services.json` file needs to correspond to the `fcmServiceAccountKey.json` +> used by the [bisq-relay](https://github.com/bisq-network/bisq-relay) service. + +## Updating Dependencies + +Whenever dependencies are updated/changed, it is necessary to update the following: + +- [gradle/verification-metadata.xml](gradle/verification-metadata.xml) - can be updated using the + following command: + +```shell +./gradlew --write-verification-metadata sha256 build :app:processDebugResources :app:connectedDebugAndroidTest +``` + +- [gradle.lockfile](gradle.lockfile) - can be updated using the following command: + +```shell +./gradlew dependencies --write-locks +``` + ## Architectural Design + For information on the architectural design, refer to the [Bisq Remote Specification](https://github.com/bisq-network/bisqremote/wiki/Specification). ## Screenshots -![Welcome](images/welcome.png) -![Scan Pairing Code](images/scan_pairing_code.png) -![Pairing Success](images/pairing_success.png) -![Notification List](images/notification_list.png) -![Offer Taken Details](images/offer_taken_details.png) -![Settings](images/settings.png) + +Welcome +Scan Pairing Code +Pairing Success +Notification List +Offer Taken Details +Settings diff --git a/app/build.gradle b/app/build.gradle index 4d72ee48..1a867113 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,24 +1,15 @@ apply plugin: 'com.android.application' +apply plugin: 'com.google.devtools.ksp' apply plugin: 'com.google.gms.google-services' +apply plugin: 'io.gitlab.arturbosch.detekt' apply plugin: 'kotlin-android' -apply plugin: 'kotlin-kapt' apply plugin: 'org.jlleitschuh.gradle.ktlint' -apply plugin: 'io.gitlab.arturbosch.detekt' repositories { google() mavenCentral() } -// This is primarily to be able to run unit and instrumented tests in GitHub workflows, -// since google-services.json is unavailable and will cause the GoogleServices task to fail -if (!project.file('google-services.json').exists()) { - android.applicationVariants.all { variant -> - def googleTask = tasks.findByName("process${variant.name.capitalize()}GoogleServices") - googleTask.enabled = false - } -} - Properties props = new Properties() props.load(new FileInputStream("$project.rootDir/version.properties")) props.each { prop -> @@ -43,13 +34,14 @@ def getVersionName = { -> android { namespace 'bisq.android' - compileSdkVersion 32 + compileSdk 34 + defaultConfig { applicationId "com.joachimneumann.bisq" versionCode getVersionCode() versionName getVersionName() - minSdkVersion 21 - targetSdkVersion 32 + minSdkVersion 26 + targetSdkVersion 34 multiDexEnabled true setProperty("archivesBaseName", "bisq") @@ -60,10 +52,18 @@ android { // that the app's state is completely cleared between tests. testInstrumentationRunnerArguments clearPackageData: 'true' } + testOptions { animationsDisabled = true execution 'ANDROIDX_TEST_ORCHESTRATOR' + + packagingOptions { + jniLibs { + useLegacyPackaging true + } + } } + buildTypes { debug { minifyEnabled false @@ -72,81 +72,199 @@ android { minifyEnabled true proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' } - applicationVariants.all { - variant -> - variant.outputs.each { - output -> - def name = "bisq-${variant.name}.apk" - output.outputFileName = name - } - } } - sourceSets { - androidTest { - java.srcDirs += "$projectDir/src/testCommon/java" + + applicationVariants.configureEach { variant -> + variant.outputs.configureEach { output -> + output.outputFileName = "bisq-${variant.name}.apk" } - test { - java.srcDirs += "$projectDir/src/testCommon/java" + + // Must disable the GoogleServices task when running unit and instrumented tests in GitHub + // workflows, since google-services.json is unavailable and will cause it to fail. + if (!project.file('google-services.json').exists()) { + tasks.configureEach { Task task -> + if (task.name.matches("process${variant.name.capitalize()}GoogleServices")) { + task.enabled = false + } + } } } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } + + packagingOptions { + resources.excludes.add("META-INF/*") + } } dependencies { - implementation 'androidx.test.uiautomator:uiautomator:2.2.0' - detektPlugins("io.gitlab.arturbosch.detekt:detekt-formatting:1.20.0") + detektPlugins('io.gitlab.arturbosch.detekt:detekt-formatting:1.23.5') - implementation fileTree(include: ['*.jar'], dir: 'libs') + implementation 'androidx.core:core-ktx:1.12.0' - implementation 'androidx.appcompat:appcompat:1.4.2' - implementation 'androidx.constraintlayout:constraintlayout:2.1.4' + implementation 'androidx.preference:preference-ktx:1.2.1' + + implementation 'androidx.appcompat:appcompat:1.7.0' + implementation 'androidx.constraintlayout:constraintlayout:2.2.0' implementation 'androidx.legacy:legacy-support-v4:1.0.0' - implementation 'androidx.media:media:1.6.0' - implementation 'androidx.recyclerview:recyclerview:1.2.1' + implementation 'androidx.media:media:1.7.0' + implementation 'androidx.recyclerview:recyclerview:1.3.2' - implementation 'com.google.android.material:material:1.6.1' + implementation 'com.google.android.material:material:1.12.0' - implementation 'com.google.zxing:core:3.4.1' + implementation 'com.google.zxing:core:3.5.3' - implementation platform('com.google.firebase:firebase-bom:30.1.0') - implementation 'com.google.firebase:firebase-analytics-ktx:21.0.0' - implementation 'com.google.firebase:firebase-messaging:23.0.5' + implementation platform('com.google.firebase:firebase-bom:33.7.0') + implementation 'com.google.firebase:firebase-analytics-ktx:22.1.2' + implementation 'com.google.firebase:firebase-messaging:24.1.0' - implementation 'com.google.code.gson:gson:2.9.0' + implementation 'com.google.code.gson:gson:2.10.1' - implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.0' + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3' - implementation 'androidx.room:room-ktx:2.4.2' - implementation 'androidx.room:room-runtime:2.4.2' - annotationProcessor 'androidx.room:room-compiler:2.4.2' - kapt 'androidx.room:room-compiler:2.4.2' + implementation 'androidx.room:room-ktx:2.6.1' + implementation 'androidx.room:room-runtime:2.6.1' + annotationProcessor 'androidx.room:room-compiler:2.6.1' + ksp 'androidx.room:room-compiler:2.6.1' implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0' - implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.4.1' - implementation "androidx.lifecycle:lifecycle-common-java8:2.4.1" - implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.1" + implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.8.7' + implementation 'androidx.lifecycle:lifecycle-common-java8:2.8.7' + implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.7' implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" - implementation "androidx.multidex:multidex:2.0.1" + implementation 'androidx.multidex:multidex:2.0.1' testImplementation 'junit:junit:4.13.2' + testImplementation 'org.assertj:assertj-core:3.25.3' testImplementation 'org.hamcrest:hamcrest:2.2' - testImplementation "io.mockk:mockk:1.12.3" - testImplementation 'org.powermock:powermock:1.6.5' - testImplementation 'org.powermock:powermock-module-junit4:2.0.2' - testImplementation 'org.powermock:powermock-api-mockito2:2.0.2' - - androidTestImplementation "junit:junit:4.13.2" - androidTestImplementation "androidx.test:core-ktx:1.4.0" - androidTestImplementation 'androidx.test.ext:junit:1.1.3' - androidTestImplementation "androidx.test.ext:junit-ktx:1.1.3" - androidTestImplementation "androidx.test.espresso:espresso-core:3.4.0" - androidTestImplementation "androidx.test.espresso:espresso-intents:3.4.0" - androidTestImplementation "androidx.test.espresso:espresso-contrib:3.4.0" - androidTestImplementation "androidx.test.uiautomator:uiautomator:2.2.0" - androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.0" - androidTestImplementation "io.mockk:mockk-android:1.12.3" + testImplementation 'io.mockk:mockk:1.13.9' + testImplementation 'org.powermock:powermock:1.6.6' + testImplementation 'org.powermock:powermock-module-junit4:2.0.9' + testImplementation 'org.powermock:powermock-api-mockito2:2.0.9' + testImplementation(project(':testCommon')) + + androidTestImplementation 'junit:junit:4.13.2' + androidTestImplementation 'org.assertj:assertj-core:3.25.3' + androidTestImplementation 'androidx.test:core-ktx:1.6.1' + androidTestImplementation 'androidx.test.ext:junit:1.2.1' + androidTestImplementation 'androidx.test.ext:junit-ktx:1.2.1' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' + androidTestImplementation 'androidx.test.espresso:espresso-intents:3.6.1' + androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.6.1' + androidTestImplementation 'androidx.test:rules:1.6.1' + androidTestImplementation 'androidx.test.uiautomator:uiautomator:2.3.0' + androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3' androidTestImplementation 'org.awaitility:awaitility-kotlin:4.2.0' + androidTestImplementation 'io.mockk:mockk-android:1.13.9' + androidTestImplementation(project(':testCommon')) { + // Prevent conflict with mockk-android + exclude group: 'io.mockk', module: 'mockk' + } + androidTestImplementation 'org.objenesis:objenesis:3.3' + + androidTestUtil 'androidx.test:orchestrator:1.5.1' +} + +def androidTestsReportsDirectory() { + return "${layout.buildDirectory.get()}/reports/androidTests/connected/debug" +} - androidTestUtil 'androidx.test:orchestrator:1.4.1' +tasks.register('embedScreenshotsIntoReport') { + group = "reporting" + doFirst { + def failureScreenshotsDirectory = + new File(androidTestsReportsDirectory(), "screenshots/failures") + + if (!failureScreenshotsDirectory.exists()) { + return + } + + println "Embedding failure screenshots into report from ${failureScreenshotsDirectory}" + + failureScreenshotsDirectory.eachFile { failedTestClassDirectory -> + def failedTestClassName = failedTestClassDirectory.name + + failedTestClassDirectory.eachFile { failedTestFile -> + def failedTestName = failedTestFile.name + def failedTestNameWithoutExtension = + failedTestName.take(failedTestName.lastIndexOf('.')) + def failedTestClassJunitReportFile = + new File(androidTestsReportsDirectory(), "${failedTestClassName}.html") + + if (!failedTestClassJunitReportFile.exists()) { + println "Could not find JUnit report file for test class " + + "'${failedTestClassJunitReportFile}'" + return + } + + def failedTestJunitReportContent = failedTestClassJunitReportFile.text + + def patternToFind = "

" + + "${failedTestNameWithoutExtension}

" + def patternToReplace = "${patternToFind}
" + + failedTestJunitReportContent = failedTestJunitReportContent.replaceAll( + patternToFind, patternToReplace) + + failedTestClassJunitReportFile.write(failedTestJunitReportContent) + } + } + } +} + +static def deviceScreenshotsDir() { + return "/storage/emulated/0/Pictures/bisq" +} + +tasks.register('clearScreenshotsFromDevice', Exec) { + group = "reporting" + ignoreExitValue = true + + def adbExecutableProvider = project.provider { android.getAdbExecutable().absolutePath } + def deviceScreenshotsDirProvider = project.provider { deviceScreenshotsDir() } + + doFirst { + commandLine = [ + adbExecutableProvider.get(), + 'shell', 'rm', '-rf', + deviceScreenshotsDirProvider.get() + ] + } +} + +tasks.register('fetchScreenshotsFromDevice', Exec) { + group = "reporting" + ignoreExitValue = true + + def adbExecutableProvider = project.provider { android.getAdbExecutable().absolutePath } + def deviceScreenshotsDirProvider = project.provider { "${deviceScreenshotsDir()}/." } + def androidTestsReportsDirectoryProvider = project.provider { androidTestsReportsDirectory() } + + doFirst { + commandLine = [ + adbExecutableProvider.get(), + 'pull', + deviceScreenshotsDirProvider.get(), + androidTestsReportsDirectoryProvider.get() + ] + } +} + +tasks.configureEach { task -> + if (task.name == 'connectedDebugAndroidTest') { + task.doFirst { 'clearScreenshotsFromDevice' } + task.finalizedBy { 'fetchScreenshotsFromDevice' } + task.finalizedBy { 'clearScreenshotsFromDevice' } + task.finalizedBy { 'embedScreenshotsIntoReport' } + } } diff --git a/app/src/androidTest/java/bisq/android/rules/FirebasePushNotificationTestRule.kt b/app/src/androidTest/java/bisq/android/rules/FirebasePushNotificationTestRule.kt new file mode 100644 index 00000000..e119c6d4 --- /dev/null +++ b/app/src/androidTest/java/bisq/android/rules/FirebasePushNotificationTestRule.kt @@ -0,0 +1,63 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.android.rules + +import android.app.Service +import android.content.Context +import android.content.ContextWrapper +import androidx.test.platform.app.InstrumentationRegistry +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +/** + * This rule interacts with the lifecycle of FirebaseMessagingService, + * effectively simulating the receipt of push notifications. + */ +class FirebasePushNotificationTestRule(private val pushService: FirebaseMessagingService) : TestWatcher() { + + companion object { + private const val FIREBASE_PUSH_TOKEN = "mocked_token_value" + } + + override fun starting(description: Description) { + super.starting(description) + pushService.attachBaseContext() + pushService.onCreate() + pushService.onNewToken(FIREBASE_PUSH_TOKEN) + } + + override fun finished(description: Description) { + pushService.onDestroy() + super.finished(description) + } + + fun onNewToken(token: String) = pushService.onNewToken(token) + + fun sendPush(remoteMessage: RemoteMessage) = pushService.onMessageReceived(remoteMessage) +} + +internal fun Service.attachBaseContext() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + + val attachBaseContextMethod = ContextWrapper::class.java.getDeclaredMethod("attachBaseContext", Context::class.java) + attachBaseContextMethod.isAccessible = true + + attachBaseContextMethod.invoke(this, context) +} diff --git a/app/src/androidTest/java/bisq/android/rules/LazyActivityScenarioRule.kt b/app/src/androidTest/java/bisq/android/rules/LazyActivityScenarioRule.kt new file mode 100644 index 00000000..6ee8b094 --- /dev/null +++ b/app/src/androidTest/java/bisq/android/rules/LazyActivityScenarioRule.kt @@ -0,0 +1,98 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.android.rules + +import android.app.Activity +import android.content.Intent +import androidx.test.core.app.ActivityScenario +import androidx.test.ext.junit.rules.ActivityScenarioRule +import org.junit.rules.ExternalResource + +/** + * Provides a drop-in replacement for [ActivityScenarioRule] that allows for + * optional launching of the Activity on start. + * + * @get:Rule + * val rule = lazyActivityScenarioRule(launchActivity = false) + * + * @Test + * fun myTest() { + * // do some setup + * rule.launch() + * // do some stuff with the Activity + * } + */ +class LazyActivityScenarioRule : ExternalResource { + private var launchActivity: Boolean + + private var scenarioSupplier: () -> ActivityScenario + + private var scenario: ActivityScenario? = null + + private var scenarioLaunched: Boolean = false + + constructor(launchActivity: Boolean, startActivityIntentSupplier: () -> Intent) { + this.launchActivity = launchActivity + scenarioSupplier = { ActivityScenario.launch(startActivityIntentSupplier()) } + } + + constructor(launchActivity: Boolean, startActivityIntent: Intent) { + this.launchActivity = launchActivity + scenarioSupplier = { ActivityScenario.launch(startActivityIntent) } + } + + constructor(launchActivity: Boolean, startActivityClass: Class) { + this.launchActivity = launchActivity + scenarioSupplier = { ActivityScenario.launch(startActivityClass) } + } + + override fun before() { + if (launchActivity) { + launch() + } + } + + override fun after() { + scenario?.close() + } + + fun launch(newIntent: Intent? = null) { + if (!scenarioLaunched) { + newIntent?.let { scenarioSupplier = { ActivityScenario.launch(it) } } + scenario = scenarioSupplier() + scenarioLaunched = true + } + } + + fun getScenario(): ActivityScenario = checkNotNull(scenario) +} + +inline fun lazyActivityScenarioRule( + launchActivity: Boolean = true, + noinline intentSupplier: () -> Intent +): LazyActivityScenarioRule = + LazyActivityScenarioRule(launchActivity, intentSupplier) + +inline fun lazyActivityScenarioRule( + launchActivity: Boolean = true, + intent: Intent? = null +): LazyActivityScenarioRule = if (intent == null) { + LazyActivityScenarioRule(launchActivity, A::class.java) +} else { + LazyActivityScenarioRule(launchActivity, intent) +} diff --git a/app/src/androidTest/java/bisq/android/rules/ScreenshotRule.kt b/app/src/androidTest/java/bisq/android/rules/ScreenshotRule.kt new file mode 100644 index 00000000..fb2f8011 --- /dev/null +++ b/app/src/androidTest/java/bisq/android/rules/ScreenshotRule.kt @@ -0,0 +1,79 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.android.rules + +import android.content.ContentValues +import android.graphics.Bitmap +import android.os.Build +import android.os.Environment.DIRECTORY_PICTURES +import android.os.Environment.getExternalStoragePublicDirectory +import android.provider.MediaStore +import androidx.test.core.app.takeScreenshot +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.rules.TestWatcher +import org.junit.runner.Description +import java.io.File +import kotlin.io.path.Path +import kotlin.io.path.createDirectories +import kotlin.io.path.pathString + +/** + * Takes screenshots during test execution. + */ +class ScreenshotRule : TestWatcher() { + companion object { + private val screenshotsPath = Path("bisq", "screenshots") + } + + override fun failed(e: Throwable, description: Description) { + super.failed(e, description) + val parentFolderPath = Path("failures", description.className) + saveScreenshot(takeScreenshot(), parentFolderPath.pathString, description.methodName) + } + + private fun saveScreenshot(bitmap: Bitmap, parentFolderPath: String = "", screenshotName: String) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val contentValues = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, "$screenshotName.png") + put(MediaStore.MediaColumns.MIME_TYPE, "image/png") + put( + MediaStore.MediaColumns.RELATIVE_PATH, + Path(DIRECTORY_PICTURES, screenshotsPath.pathString, parentFolderPath).pathString + ) + } + val contentResolver = InstrumentationRegistry.getInstrumentation().targetContext.contentResolver + val contentUri = MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL) + val uri = contentResolver.insert(contentUri, contentValues) + + contentResolver.openOutputStream(uri ?: return)?.use { + bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) + } + } else { + val imagePath = Path( + getExternalStoragePublicDirectory(DIRECTORY_PICTURES).path, + screenshotsPath.pathString, + parentFolderPath + ) + imagePath.createDirectories() + val image = File(imagePath.pathString, "$screenshotName.png") + image.outputStream().use { + bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) + } + } + } +} diff --git a/app/src/androidTest/java/bisq/android/screens/NotificationDetailScreen.kt b/app/src/androidTest/java/bisq/android/screens/NotificationDetailScreen.kt index 86996eb9..47570010 100644 --- a/app/src/androidTest/java/bisq/android/screens/NotificationDetailScreen.kt +++ b/app/src/androidTest/java/bisq/android/screens/NotificationDetailScreen.kt @@ -18,13 +18,14 @@ package bisq.android.screens import bisq.android.R +import bisq.android.screens.elements.ButtonElement import bisq.android.screens.elements.TextElement class NotificationDetailScreen : Screen() { - - val title = TextElement(R.id.detail_title) - val message = TextElement(R.id.detail_message) - val action = TextElement(R.id.detail_action) - val eventTime = TextElement(R.id.detail_event_time) - val receivedTime = TextElement(R.id.detail_received_time) + val title = TextElement(R.id.notification_detail_title) + val message = TextElement(R.id.notification_detail_message) + val action = TextElement(R.id.notification_detail_action) + val eventTime = TextElement(R.id.notification_detail_event_time) + val receivedTime = TextElement(R.id.notification_detail_received_time) + val deleteButton = ButtonElement(R.id.notification_delete_button) } diff --git a/app/src/androidTest/java/bisq/android/screens/NotificationTableScreen.kt b/app/src/androidTest/java/bisq/android/screens/NotificationTableScreen.kt index 9e063830..ba448d13 100644 --- a/app/src/androidTest/java/bisq/android/screens/NotificationTableScreen.kt +++ b/app/src/androidTest/java/bisq/android/screens/NotificationTableScreen.kt @@ -18,11 +18,21 @@ package bisq.android.screens import bisq.android.R -import bisq.android.screens.elements.ButtonElement +import bisq.android.screens.dialogs.ChoicePromptDialog +import bisq.android.screens.elements.MenuItemElement import bisq.android.screens.elements.RecyclerViewElement class NotificationTableScreen : Screen() { - - val settingsButton = ButtonElement(R.id.action_settings) - val notificationRecylerView = RecyclerViewElement(R.id.notification_recycler_view) + val addExampleNotificationsMenuItem = + MenuItemElement(applicationContext.resources.getString(R.string.button_add_example_notifications)) + val markAllAsReadMenuItem = + MenuItemElement(applicationContext.resources.getString(R.string.button_mark_as_read)) + val deleteAllMenuItem = + MenuItemElement(applicationContext.resources.getString(R.string.button_delete_notifications)) + val settingsMenuItem = + MenuItemElement(applicationContext.resources.getString(R.string.settings)) + val notificationRecylerView = RecyclerViewElement(R.id.notification_table_recycler_view) + val alertDialogDeleteAll = ChoicePromptDialog( + applicationContext.resources.getString(R.string.delete_all_notifications_confirmation) + ) } diff --git a/app/src/androidTest/java/bisq/android/screens/PairingScanScreen.kt b/app/src/androidTest/java/bisq/android/screens/PairingScanScreen.kt index d3f05183..8b7def21 100644 --- a/app/src/androidTest/java/bisq/android/screens/PairingScanScreen.kt +++ b/app/src/androidTest/java/bisq/android/screens/PairingScanScreen.kt @@ -21,6 +21,5 @@ import bisq.android.R import bisq.android.screens.elements.ButtonElement class PairingScanScreen : Screen() { - - val noWebcamButton = ButtonElement(R.id.noWebcamButton) + val noWebcamButton = ButtonElement(R.id.pairing_scan_no_webcam_button) } diff --git a/app/src/androidTest/java/bisq/android/screens/PairingSendScreen.kt b/app/src/androidTest/java/bisq/android/screens/PairingSendScreen.kt index e54dbcf5..4349b5cd 100644 --- a/app/src/androidTest/java/bisq/android/screens/PairingSendScreen.kt +++ b/app/src/androidTest/java/bisq/android/screens/PairingSendScreen.kt @@ -21,6 +21,5 @@ import bisq.android.R import bisq.android.screens.elements.ButtonElement class PairingSendScreen : Screen() { - - val sendPairingTokenButton = ButtonElement(R.id.sendPairingTokenButton) + val sendPairingTokenButton = ButtonElement(R.id.pairing_send_pairing_token_button) } diff --git a/app/src/androidTest/java/bisq/android/screens/PairingSuccessScreen.kt b/app/src/androidTest/java/bisq/android/screens/PairingSuccessScreen.kt index f0b4ba3b..289b44ef 100644 --- a/app/src/androidTest/java/bisq/android/screens/PairingSuccessScreen.kt +++ b/app/src/androidTest/java/bisq/android/screens/PairingSuccessScreen.kt @@ -18,9 +18,10 @@ package bisq.android.screens import bisq.android.R +import bisq.android.screens.dialogs.PermissionPrompt import bisq.android.screens.elements.ButtonElement class PairingSuccessScreen : Screen() { - - val pairingCompleteButton = ButtonElement(R.id.pairing_complete_button) + val pairingCompleteButton = ButtonElement(R.id.pairing_scan_pairing_complete_button) + val permissionPrompt = PermissionPrompt() } diff --git a/app/src/androidTest/java/bisq/android/screens/RequestNotificationPermissionScreen.kt b/app/src/androidTest/java/bisq/android/screens/RequestNotificationPermissionScreen.kt new file mode 100644 index 00000000..b9b72ec7 --- /dev/null +++ b/app/src/androidTest/java/bisq/android/screens/RequestNotificationPermissionScreen.kt @@ -0,0 +1,28 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.android.screens + +import bisq.android.R +import bisq.android.screens.dialogs.PermissionPrompt +import bisq.android.screens.elements.ButtonElement + +class RequestNotificationPermissionScreen : Screen() { + val enableNotificationsButton = ButtonElement(R.id.request_notification_permission_button) + val skipPermissionButton = ButtonElement(R.id.skip_request_notification_permission_button) + val permissionPrompt = PermissionPrompt() +} diff --git a/app/src/androidTest/java/bisq/android/screens/Screen.kt b/app/src/androidTest/java/bisq/android/screens/Screen.kt index f9d484df..ace90504 100644 --- a/app/src/androidTest/java/bisq/android/screens/Screen.kt +++ b/app/src/androidTest/java/bisq/android/screens/Screen.kt @@ -20,7 +20,6 @@ package bisq.android.screens import android.content.Context import androidx.test.core.app.ApplicationProvider -open class Screen { - +abstract class Screen { protected val applicationContext: Context = ApplicationProvider.getApplicationContext() } diff --git a/app/src/androidTest/java/bisq/android/screens/SettingsScreen.kt b/app/src/androidTest/java/bisq/android/screens/SettingsScreen.kt index 9283a8fb..2085dca0 100644 --- a/app/src/androidTest/java/bisq/android/screens/SettingsScreen.kt +++ b/app/src/androidTest/java/bisq/android/screens/SettingsScreen.kt @@ -21,20 +21,23 @@ import bisq.android.BISQ_MOBILE_URL import bisq.android.BISQ_NETWORK_URL import bisq.android.R import bisq.android.screens.dialogs.ChoicePromptDialog -import bisq.android.screens.elements.ButtonElement +import bisq.android.screens.dialogs.ThemePromptDialog +import bisq.android.screens.elements.PreferenceElement class SettingsScreen : Screen() { - - val aboutBisqButton = ButtonElement(R.id.settingsAboutBisqButton) - val aboutAppButton = ButtonElement(R.id.settingsAboutAppButton) + val themePreference = PreferenceElement(applicationContext.getString(R.string.theme)) + val themePromptDialog = ThemePromptDialog() + val resetPairingPreference = PreferenceElement(applicationContext.getString(R.string.reset_pairing)) + val alertDialogResetPairing = ChoicePromptDialog( + applicationContext.resources.getString(R.string.register_again_confirmation) + ) + val scanPairingTokenPreference = PreferenceElement(applicationContext.getString(R.string.scan_pairing_token)) + val aboutBisqPreference = PreferenceElement(applicationContext.getString(R.string.about_bisq)) + val aboutAppPreference = PreferenceElement(applicationContext.getString(R.string.about_this_app)) val alertDialogLoadBisqNetworkUrl = ChoicePromptDialog( - applicationContext.resources.getString(R.string.load_web_page_text, BISQ_NETWORK_URL) + applicationContext.resources.getString(R.string.load_web_page_confirmation, BISQ_NETWORK_URL) ) val alertDialogLoadBisqMobileUrl = ChoicePromptDialog( - applicationContext.resources.getString(R.string.load_web_page_text, BISQ_MOBILE_URL) + applicationContext.resources.getString(R.string.load_web_page_confirmation, BISQ_MOBILE_URL) ) - val resetButton = ButtonElement(R.id.settingsRegisterAgainButton) - val deleteNotificationsButton = ButtonElement(R.id.settingsDeleteAllNotificationsButton) - val markAsReadButton = ButtonElement(R.id.settingsMarkAsReadButton) - val addExampleNotificationsButton = ButtonElement(R.id.settingsAddExampleButton) } diff --git a/app/src/androidTest/java/bisq/android/screens/WelcomeScreen.kt b/app/src/androidTest/java/bisq/android/screens/WelcomeScreen.kt index 641298c3..993f66fe 100644 --- a/app/src/androidTest/java/bisq/android/screens/WelcomeScreen.kt +++ b/app/src/androidTest/java/bisq/android/screens/WelcomeScreen.kt @@ -24,9 +24,8 @@ import bisq.android.screens.dialogs.PromptDialog import bisq.android.screens.elements.ButtonElement class WelcomeScreen : Screen() { - - val pairButton = ButtonElement(R.id.pairButton) - val learnMoreButton = ButtonElement(R.id.learnMoreButton) + val pairButton = ButtonElement(R.id.welcome_pair_button) + val learnMoreButton = ButtonElement(R.id.welcome_learn_more_button) val alertDialogGooglePlayServicesUnavailable = PromptDialog( applicationContext.resources.getString(R.string.google_play_services_unavailable) ) @@ -34,6 +33,6 @@ class WelcomeScreen : Screen() { applicationContext.resources.getString(R.string.cannot_retrieve_fcm_token) ) val alertDialogLoadBisqMobileUrl = ChoicePromptDialog( - applicationContext.resources.getString(R.string.load_web_page_text, BISQ_MOBILE_URL) + applicationContext.resources.getString(R.string.load_web_page_confirmation, BISQ_MOBILE_URL) ) } diff --git a/app/src/androidTest/java/bisq/android/screens/dialogs/Dialog.kt b/app/src/androidTest/java/bisq/android/screens/dialogs/Dialog.kt index 9b0793df..592b955d 100644 --- a/app/src/androidTest/java/bisq/android/screens/dialogs/Dialog.kt +++ b/app/src/androidTest/java/bisq/android/screens/dialogs/Dialog.kt @@ -17,6 +17,8 @@ package bisq.android.screens.dialogs +import android.content.Context +import androidx.test.core.app.ApplicationProvider import androidx.test.espresso.Espresso.onView import androidx.test.espresso.NoMatchingViewException import androidx.test.espresso.assertion.ViewAssertions.matches @@ -24,7 +26,8 @@ import androidx.test.espresso.matcher.ViewMatchers.Visibility import androidx.test.espresso.matcher.ViewMatchers.withEffectiveVisibility import androidx.test.espresso.matcher.ViewMatchers.withText -open class Dialog(private val message: String) { +abstract class Dialog(private val message: String) { + protected val applicationContext: Context = ApplicationProvider.getApplicationContext() fun isDisplayed(): Boolean { return try { diff --git a/app/src/androidTest/java/bisq/android/screens/dialogs/PermissionPrompt.kt b/app/src/androidTest/java/bisq/android/screens/dialogs/PermissionPrompt.kt new file mode 100644 index 00000000..7534ac59 --- /dev/null +++ b/app/src/androidTest/java/bisq/android/screens/dialogs/PermissionPrompt.kt @@ -0,0 +1,58 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.android.screens.dialogs + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.UiSelector + +class PermissionPrompt { + private val device + get() = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + private val textElement + get() = device.findObject(UiSelector().index(1)) + private val grantPermissionButton + get() = device.findObject(UiSelector().textMatches("(?:Allow|ALLOW)")) + private val denyPermissionButton + get() = device.findObject(UiSelector().textMatches("(?:Don’t allow|DON’T ALLOW)")) + + fun isDisplayed(): Boolean { + return grantPermissionButton.exists() && denyPermissionButton.exists() + } + + fun text(): String { + if (!textElement.exists()) { + throw IllegalStateException("Text element does not exist") + } + return textElement.text + } + + fun grantPermission() { + if (!grantPermissionButton.exists()) { + throw IllegalStateException("Grant permissions button does not exist") + } + grantPermissionButton.click() + } + + fun denyPermission() { + if (!grantPermissionButton.exists()) { + throw IllegalStateException("Deny permissions button does not exist") + } + denyPermissionButton.click() + } +} diff --git a/app/src/androidTest/java/bisq/android/screens/dialogs/PromptDialog.kt b/app/src/androidTest/java/bisq/android/screens/dialogs/PromptDialog.kt index ca91e8a5..482a7b65 100644 --- a/app/src/androidTest/java/bisq/android/screens/dialogs/PromptDialog.kt +++ b/app/src/androidTest/java/bisq/android/screens/dialogs/PromptDialog.kt @@ -20,6 +20,6 @@ package bisq.android.screens.dialogs import android.R import bisq.android.screens.elements.ButtonElement -class PromptDialog(message: String) : Dialog(message) { - val button = ButtonElement(R.id.button1) +open class PromptDialog(message: String) : Dialog(message) { + val dismissButton = ButtonElement(R.id.button1) } diff --git a/app/src/androidTest/java/bisq/android/screens/dialogs/ThemePromptDialog.kt b/app/src/androidTest/java/bisq/android/screens/dialogs/ThemePromptDialog.kt new file mode 100644 index 00000000..0d99ff04 --- /dev/null +++ b/app/src/androidTest/java/bisq/android/screens/dialogs/ThemePromptDialog.kt @@ -0,0 +1,36 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.android.screens.dialogs + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import bisq.android.R +import bisq.android.screens.elements.ButtonElement +import bisq.android.screens.elements.SelectionElement + +class ThemePromptDialog : PromptDialog(message) { + val darkThemeSelection = SelectionElement(applicationContext.getString(R.string.dark_theme)) + val lightThemeSelection = SelectionElement(applicationContext.getString(R.string.light_theme)) + val systemThemeSelection = SelectionElement(applicationContext.getString(R.string.system_theme)) + val cancelButton = ButtonElement(android.R.id.button2) + + companion object { + private val applicationContext: Context = ApplicationProvider.getApplicationContext() + val message: String = applicationContext.getString(R.string.theme) + } +} diff --git a/app/src/androidTest/java/bisq/android/screens/elements/ButtonElement.kt b/app/src/androidTest/java/bisq/android/screens/elements/ButtonElement.kt index 3d9b6d3f..e13b2da3 100644 --- a/app/src/androidTest/java/bisq/android/screens/elements/ButtonElement.kt +++ b/app/src/androidTest/java/bisq/android/screens/elements/ButtonElement.kt @@ -22,9 +22,8 @@ import androidx.test.espresso.action.ViewActions import androidx.test.espresso.assertion.ViewAssertions import androidx.test.espresso.matcher.ViewMatchers -class ButtonElement(private val id: Int) : Element(id) { - - fun click() { +class ButtonElement(private val id: Int) : ElementById(id), ClickableElement { + override fun click() { Espresso.onView(ViewMatchers.withId(id)) .check(ViewAssertions.matches(ViewMatchers.isDisplayed())) .perform(ViewActions.click()) diff --git a/app/src/main/java/bisq/android/util/TextUtil.kt b/app/src/androidTest/java/bisq/android/screens/elements/ClickableElement.kt similarity index 72% rename from app/src/main/java/bisq/android/util/TextUtil.kt rename to app/src/androidTest/java/bisq/android/screens/elements/ClickableElement.kt index 20dd4dd4..075e95c4 100644 --- a/app/src/main/java/bisq/android/util/TextUtil.kt +++ b/app/src/androidTest/java/bisq/android/screens/elements/ClickableElement.kt @@ -15,15 +15,8 @@ * along with Bisq. If not, see . */ -package bisq.android.util +package bisq.android.screens.elements -object TextUtil { - private const val TRUNCATE_AFTER = 10 - - fun truncateSensitiveText(text: String?): String { - if (text == null) { - return "" - } - return text.substring(0, TRUNCATE_AFTER) + "..." - } +interface ClickableElement { + fun click() } diff --git a/app/src/androidTest/java/bisq/android/screens/elements/Element.kt b/app/src/androidTest/java/bisq/android/screens/elements/Element.kt index d6ed21d0..cec1a603 100644 --- a/app/src/androidTest/java/bisq/android/screens/elements/Element.kt +++ b/app/src/androidTest/java/bisq/android/screens/elements/Element.kt @@ -17,15 +17,18 @@ package bisq.android.screens.elements +import android.view.View import androidx.test.espresso.Espresso import androidx.test.espresso.assertion.ViewAssertions import androidx.test.espresso.matcher.ViewMatchers +import org.hamcrest.Matcher -open class Element(private val id: Int) { +abstract class Element { + abstract fun getViewMatcher(): Matcher? fun isDisplayed(): Boolean { try { - Espresso.onView(ViewMatchers.withId(id)) + Espresso.onView(getViewMatcher()) .check(ViewAssertions.matches(ViewMatchers.isDisplayed())) } catch (e: AssertionError) { return false @@ -35,7 +38,7 @@ open class Element(private val id: Int) { fun isEnabled(): Boolean { try { - Espresso.onView(ViewMatchers.withId(id)) + Espresso.onView(getViewMatcher()) .check(ViewAssertions.matches(ViewMatchers.isDisplayed())) .check(ViewAssertions.matches(ViewMatchers.isEnabled())) } catch (e: AssertionError) { diff --git a/app/src/androidTest/java/bisq/android/screens/elements/ElementById.kt b/app/src/androidTest/java/bisq/android/screens/elements/ElementById.kt new file mode 100644 index 00000000..73d10875 --- /dev/null +++ b/app/src/androidTest/java/bisq/android/screens/elements/ElementById.kt @@ -0,0 +1,28 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.android.screens.elements + +import android.view.View +import androidx.test.espresso.matcher.ViewMatchers +import org.hamcrest.Matcher + +abstract class ElementById(private val id: Int) : Element() { + override fun getViewMatcher(): Matcher? { + return ViewMatchers.withId(id) + } +} diff --git a/app/src/androidTest/java/bisq/android/screens/elements/ElementByText.kt b/app/src/androidTest/java/bisq/android/screens/elements/ElementByText.kt new file mode 100644 index 00000000..57eebc7f --- /dev/null +++ b/app/src/androidTest/java/bisq/android/screens/elements/ElementByText.kt @@ -0,0 +1,28 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.android.screens.elements + +import android.view.View +import androidx.test.espresso.matcher.ViewMatchers +import org.hamcrest.Matcher + +abstract class ElementByText(private val text: String) : Element() { + override fun getViewMatcher(): Matcher? { + return ViewMatchers.withText(text) + } +} diff --git a/app/src/androidTest/java/bisq/android/screens/elements/MenuItemElement.kt b/app/src/androidTest/java/bisq/android/screens/elements/MenuItemElement.kt new file mode 100644 index 00000000..b5282e68 --- /dev/null +++ b/app/src/androidTest/java/bisq/android/screens/elements/MenuItemElement.kt @@ -0,0 +1,35 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.android.screens.elements + +import androidx.test.espresso.Espresso +import androidx.test.espresso.action.ViewActions +import androidx.test.espresso.assertion.ViewAssertions +import androidx.test.espresso.matcher.RootMatchers.isPlatformPopup +import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation + +class MenuItemElement(private val text: String) : ElementByText(text), ClickableElement { + override fun click() { + Espresso.openActionBarOverflowOrOptionsMenu(getInstrumentation().targetContext) + Espresso.onView(ViewMatchers.withText(text)) + .inRoot(isPlatformPopup()) + .check(ViewAssertions.matches(ViewMatchers.isDisplayed())) + .perform(ViewActions.click()) + } +} diff --git a/app/src/androidTest/java/bisq/android/screens/elements/PreferenceElement.kt b/app/src/androidTest/java/bisq/android/screens/elements/PreferenceElement.kt new file mode 100644 index 00000000..cb8765d7 --- /dev/null +++ b/app/src/androidTest/java/bisq/android/screens/elements/PreferenceElement.kt @@ -0,0 +1,31 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.android.screens.elements + +import androidx.test.espresso.Espresso +import androidx.test.espresso.action.ViewActions +import androidx.test.espresso.assertion.ViewAssertions +import androidx.test.espresso.matcher.ViewMatchers + +class PreferenceElement(private val text: String) : ElementByText(text), ClickableElement { + override fun click() { + Espresso.onView(ViewMatchers.withText(text)) + .check(ViewAssertions.matches(ViewMatchers.isDisplayed())) + .perform(ViewActions.click()) + } +} diff --git a/app/src/androidTest/java/bisq/android/screens/elements/RecyclerViewElement.kt b/app/src/androidTest/java/bisq/android/screens/elements/RecyclerViewElement.kt index 791ab660..1ab36356 100644 --- a/app/src/androidTest/java/bisq/android/screens/elements/RecyclerViewElement.kt +++ b/app/src/androidTest/java/bisq/android/screens/elements/RecyclerViewElement.kt @@ -20,8 +20,9 @@ package bisq.android.screens.elements import android.view.View import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.ViewHolder import androidx.test.espresso.Espresso.onView -import androidx.test.espresso.action.GeneralLocation +import androidx.test.espresso.action.CoordinatesProvider import androidx.test.espresso.action.GeneralSwipeAction import androidx.test.espresso.action.Press import androidx.test.espresso.action.Swipe @@ -31,44 +32,49 @@ import androidx.test.espresso.contrib.RecyclerViewActions.actionOnItemAtPosition import androidx.test.espresso.contrib.RecyclerViewActions.scrollToPosition import androidx.test.espresso.matcher.ViewMatchers import androidx.test.espresso.matcher.ViewMatchers.withId -import bisq.android.ui.notification.NotificationAdapter import org.hamcrest.CoreMatchers.allOf import org.hamcrest.Description import org.hamcrest.Matcher import org.hamcrest.TypeSafeMatcher -class RecyclerViewElement(private val id: Int) : Element(id) { - +class RecyclerViewElement(private val id: Int) : ElementById(id) { fun scrollToPosition(position: Int) { onView(withId(id)).perform( - scrollToPosition(position) + scrollToPosition(position) ) } fun clickAtPosition(position: Int) { onView(withId(id)).perform( - scrollToPosition(position), - actionOnItemAtPosition(position, click()) + scrollToPosition(position), + actionOnItemAtPosition(position, click()) ) } - fun swipeToDeleteAtPosition(position: Int) { + fun swipeRightToLeftAtPosition(position: Int) { + val from = CoordinatesProvider { it.getPointCoordinatesOfView(0.75f, 0.5f) } + val to = CoordinatesProvider { it.getPointCoordinatesOfView(0f, 0.5f) } + val swipeAction = GeneralSwipeAction(Swipe.FAST, from, to, Press.FINGER) onView(withId(id)).perform( - scrollToPosition(position), - actionOnItemAtPosition( - position, - GeneralSwipeAction( - Swipe.FAST, GeneralLocation.BOTTOM_RIGHT, GeneralLocation.BOTTOM_LEFT, - Press.FINGER - ) - ) + scrollToPosition(position), + actionOnItemAtPosition(position, swipeAction) ) } + private fun View.getPointCoordinatesOfView(xPercent: Float, yPercent: Float): FloatArray { + val xy = IntArray(2).apply { getLocationOnScreen(this) } + val x = xy[0] + (width - 1) * xPercent + val y = xy[1] + (height - 1) * yPercent + return floatArrayOf(x, y) + } + fun getItemCount(): Int { val count = intArrayOf(0) val matcher: Matcher = object : TypeSafeMatcher() { - override fun describeTo(description: Description?) {} + override fun describeTo(description: Description?) { + // Intentionally left empty + } + override fun matchesSafely(item: View?): Boolean { count[0] = (item as RecyclerView).adapter!!.itemCount return true @@ -81,7 +87,10 @@ class RecyclerViewElement(private val id: Int) : Element(id) { fun getScrollPosition(): Int { val position = intArrayOf(0) val matcher: Matcher = object : TypeSafeMatcher() { - override fun describeTo(description: Description?) {} + override fun describeTo(description: Description?) { + // Intentionally left empty + } + override fun matchesSafely(item: View?): Boolean { position[0] = ((item as RecyclerView).layoutManager!! as LinearLayoutManager) .findFirstVisibleItemPosition() @@ -92,34 +101,20 @@ class RecyclerViewElement(private val id: Int) : Element(id) { return position[0] } - fun getContentAtPosition(position: Int): String { - var content = String() + fun getContentAtPosition(position: Int): ViewHolder { + var viewHolder: ViewHolder? = null val matcher: Matcher = object : TypeSafeMatcher() { - override fun describeTo(description: Description?) {} - override fun matchesSafely(item: View?): Boolean { - val viewHolder = (item as RecyclerView).findViewHolderForAdapterPosition(position) - ?: return false // has no item on such position - content = - (viewHolder as NotificationAdapter.NotificationViewHolder).title.text.toString() - return true + override fun describeTo(description: Description?) { + // Intentionally left empty } - } - onView(allOf(withId(id), ViewMatchers.isDisplayed())).check(matches(matcher)) - return content - } - fun getReadStateAtPosition(position: Int): Boolean { - var readState = false - val matcher: Matcher = object : TypeSafeMatcher() { - override fun describeTo(description: Description?) {} override fun matchesSafely(item: View?): Boolean { - val viewHolder = (item as RecyclerView).findViewHolderForAdapterPosition(position) + viewHolder = (item as RecyclerView).findViewHolderForAdapterPosition(position) ?: return false // has no item on such position - readState = (viewHolder as NotificationAdapter.NotificationViewHolder).read return true } } onView(allOf(withId(id), ViewMatchers.isDisplayed())).check(matches(matcher)) - return readState + return viewHolder!! } } diff --git a/app/src/androidTest/java/bisq/android/screens/elements/SelectionElement.kt b/app/src/androidTest/java/bisq/android/screens/elements/SelectionElement.kt new file mode 100644 index 00000000..e4714440 --- /dev/null +++ b/app/src/androidTest/java/bisq/android/screens/elements/SelectionElement.kt @@ -0,0 +1,31 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.android.screens.elements + +import androidx.test.espresso.Espresso +import androidx.test.espresso.action.ViewActions +import androidx.test.espresso.assertion.ViewAssertions +import androidx.test.espresso.matcher.ViewMatchers + +class SelectionElement(private val text: String) : ElementByText(text), ClickableElement { + override fun click() { + Espresso.onView(ViewMatchers.withText(text)) + .check(ViewAssertions.matches(ViewMatchers.isDisplayed())) + .perform(ViewActions.click()) + } +} diff --git a/app/src/androidTest/java/bisq/android/screens/elements/TextElement.kt b/app/src/androidTest/java/bisq/android/screens/elements/TextElement.kt index 8fa30021..2f2a706b 100644 --- a/app/src/androidTest/java/bisq/android/screens/elements/TextElement.kt +++ b/app/src/androidTest/java/bisq/android/screens/elements/TextElement.kt @@ -26,8 +26,7 @@ import androidx.test.espresso.matcher.ViewMatchers import androidx.test.espresso.matcher.ViewMatchers.isAssignableFrom import org.hamcrest.Matcher -class TextElement(private val id: Int) : Element(id) { - +class TextElement(private val id: Int) : ElementById(id) { fun getText(): String { var text = String() Espresso.onView(ViewMatchers.withId(id)).perform(object : ViewAction { diff --git a/app/src/androidTest/java/bisq/android/tests/BaseTest.kt b/app/src/androidTest/java/bisq/android/tests/BaseTest.kt index 3cb861c0..878cec53 100644 --- a/app/src/androidTest/java/bisq/android/tests/BaseTest.kt +++ b/app/src/androidTest/java/bisq/android/tests/BaseTest.kt @@ -18,25 +18,40 @@ package bisq.android.tests import android.content.Context +import android.os.Build import android.util.Log import androidx.test.core.app.ApplicationProvider import androidx.test.espresso.Espresso import androidx.test.espresso.base.DefaultFailureHandler import androidx.test.espresso.intent.Intents import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation +import androidx.test.rule.GrantPermissionRule import androidx.test.uiautomator.UiDevice import androidx.test.uiautomator.UiSelector import bisq.android.model.Device import bisq.android.model.DeviceStatus +import bisq.android.rules.ScreenshotRule +import bisq.android.rules.lazyActivityScenarioRule import bisq.android.screens.NotificationDetailScreen import bisq.android.screens.NotificationTableScreen import bisq.android.screens.PairingScanScreen import bisq.android.screens.PairingSendScreen import bisq.android.screens.PairingSuccessScreen +import bisq.android.screens.RequestNotificationPermissionScreen import bisq.android.screens.SettingsScreen import bisq.android.screens.WelcomeScreen +import bisq.android.ui.notification.NotificationTableActivity +import bisq.android.ui.pairing.PairingScanActivity +import bisq.android.ui.pairing.PairingSendActivity +import bisq.android.ui.pairing.PairingSuccessActivity +import bisq.android.ui.pairing.RequestNotificationPermissionActivity +import bisq.android.ui.settings.SettingsActivity +import bisq.android.ui.welcome.WelcomeActivity import org.junit.After +import org.junit.Assume.assumeTrue import org.junit.Before +import org.junit.Rule +import org.junit.rules.RuleChain import java.util.Locale abstract class BaseTest { @@ -52,6 +67,7 @@ abstract class BaseTest { protected val pairingScanScreen = PairingScanScreen() protected val pairingSendScreen = PairingSendScreen() protected val pairingSuccessScreen = PairingSuccessScreen() + protected val requestNotificationPermissionScreen = RequestNotificationPermissionScreen() protected val notificationDetailScreen = NotificationDetailScreen() protected val notificationTableScreen = NotificationTableScreen() @@ -68,6 +84,40 @@ abstract class BaseTest { "Root:" ) + protected val welcomeActivityRule = lazyActivityScenarioRule(launchActivity = false) + protected val notificationTableActivityRule = + lazyActivityScenarioRule(launchActivity = false) + protected val pairingScanActivityRule = + lazyActivityScenarioRule(launchActivity = false) + protected val pairingSendActivityRule = + lazyActivityScenarioRule(launchActivity = false) + protected val pairingSuccessActivityRule = + lazyActivityScenarioRule(launchActivity = false) + protected val requestNotificationPermissionActivityRule = + lazyActivityScenarioRule(launchActivity = false) + protected val settingsActivityRule = + lazyActivityScenarioRule(launchActivity = false) + + private val permissionRule: GrantPermissionRule + get() = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + GrantPermissionRule.grant(android.Manifest.permission.WRITE_EXTERNAL_STORAGE) + } else { + GrantPermissionRule.grant() + } + + // Define a RuleChain to ensure screenshots are taken BEFORE the teardown of activity rules + @get:Rule + val ruleChain: RuleChain = RuleChain + .outerRule(permissionRule) + .around(welcomeActivityRule) + .around(notificationTableActivityRule) + .around(pairingScanActivityRule) + .around(pairingSendActivityRule) + .around(pairingSuccessActivityRule) + .around(requestNotificationPermissionActivityRule) + .around(settingsActivityRule) + .around(ScreenshotRule()) + @Before open fun setup() { Espresso.setFailureHandler { error, viewMatcher -> @@ -103,7 +153,7 @@ abstract class BaseTest { Intents.release() } - fun pairDevice() { + protected fun pairDevice() { val token = "fnWtGaJGSByKiPwT71O3Lo:APA91bGU05lvoKxvz3Y0fnFHytSveA_juVjq2QMY3_H9URqDsEpLHGbLSFBN" + "3wY7YdHDD3w52GECwRWuKGBJm1O1f5fJhVvcr1rJxo94aDjoWwsnkVp-ecWwh5YY_MQ6LRqbWzumCeX_" @@ -111,4 +161,18 @@ abstract class BaseTest { Device.instance.status = DeviceStatus.PAIRED Device.instance.saveToPreferences(applicationContext) } + + protected fun assumeMaxApiLevel(apiLevel: Int) { + assumeTrue( + "API level $apiLevel or older is required", + Build.VERSION.SDK_INT < apiLevel + ) + } + + protected fun assumeMinApiLevel(apiLevel: Int) { + assumeTrue( + "API level $apiLevel or newer is required", + Build.VERSION.SDK_INT >= apiLevel + ) + } } diff --git a/app/src/androidTest/java/bisq/android/tests/NotificationDetailTest.kt b/app/src/androidTest/java/bisq/android/tests/NotificationDetailTest.kt index b049cb7e..8dc15d40 100644 --- a/app/src/androidTest/java/bisq/android/tests/NotificationDetailTest.kt +++ b/app/src/androidTest/java/bisq/android/tests/NotificationDetailTest.kt @@ -17,22 +17,17 @@ package bisq.android.tests -import androidx.test.core.app.ActivityScenario -import androidx.test.espresso.intent.Intents -import androidx.test.espresso.intent.matcher.IntentMatchers +import androidx.test.espresso.intent.Intents.intended +import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent import androidx.test.ext.junit.runners.AndroidJUnit4 import bisq.android.ui.notification.NotificationDetailActivity -import bisq.android.ui.notification.NotificationTableActivity -import org.hamcrest.MatcherAssert.assertThat -import org.hamcrest.Matchers.matchesPattern -import org.junit.Assert.assertEquals +import org.assertj.core.api.Assertions.assertThat import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class NotificationDetailTest : BaseTest() { - @Before override fun setup() { super.setup() @@ -41,25 +36,42 @@ class NotificationDetailTest : BaseTest() { @Test fun notificationDetailsArePopulatedCorrectly() { - ActivityScenario.launch(NotificationTableActivity::class.java).use { - notificationTableScreen.settingsButton.click() - settingsScreen.addExampleNotificationsButton.click() - notificationTableScreen.notificationRecylerView.clickAtPosition(2) - Intents.intended(IntentMatchers.hasComponent(NotificationDetailActivity::class.java.name)) - assertEquals("(example) Dispute message", notificationDetailScreen.title.getText()) - assertEquals( - "You received a dispute message for trade with ID 34059340", - notificationDetailScreen.message.getText() - ) - assertEquals("Please contact the arbitrator", notificationDetailScreen.action.getText()) - assertThat( - notificationDetailScreen.eventTime.getText(), - matchesPattern("Event occurred: 20\\d\\d-\\d\\d-\\d\\d \\d\\d:\\d\\d:\\d\\d") - ) - assertThat( - notificationDetailScreen.receivedTime.getText(), - matchesPattern("Event received: 20\\d\\d-\\d\\d-\\d\\d \\d\\d:\\d\\d:\\d\\d") - ) - } + notificationTableActivityRule.launch() + + notificationTableScreen.addExampleNotificationsMenuItem.click() + notificationTableScreen.notificationRecylerView.clickAtPosition(2) + intended(hasComponent(NotificationDetailActivity::class.java.name)) + assertThat(notificationDetailScreen.title.getText()) + .describedAs("Notification title") + .isEqualTo("Dispute message") + assertThat(notificationDetailScreen.message.getText()) + .describedAs("Notification message") + .isEqualTo("You received a dispute message for trade with ID 34059340") + assertThat(notificationDetailScreen.action.getText()) + .describedAs("Notification action") + .isEqualTo("Please contact the arbitrator") + assertThat(notificationDetailScreen.eventTime.getText()) + .describedAs("Notification event time") + .matches("Event occurred: 20\\d\\d-\\d\\d-\\d\\d \\d\\d:\\d\\d:\\d\\d") + assertThat(notificationDetailScreen.receivedTime.getText()) + .describedAs("Notification received time") + .matches("Event received: 20\\d\\d-\\d\\d-\\d\\d \\d\\d:\\d\\d:\\d\\d") + } + + @Test + fun clickDeleteButtonDeletesNotification() { + notificationTableActivityRule.launch() + + notificationTableScreen.addExampleNotificationsMenuItem.click() + val countBeforeSwipe = notificationTableScreen.notificationRecylerView.getItemCount() + + notificationTableScreen.notificationRecylerView.clickAtPosition(0) + intended(hasComponent(NotificationDetailActivity::class.java.name)) + + notificationDetailScreen.deleteButton.click() + val countAfterDelete = notificationTableScreen.notificationRecylerView.getItemCount() + assertThat(countAfterDelete) + .describedAs("Notification count after delete") + .isEqualTo(countBeforeSwipe - 1) } } diff --git a/app/src/androidTest/java/bisq/android/tests/NotificationTableTest.kt b/app/src/androidTest/java/bisq/android/tests/NotificationTableTest.kt index d7c8146d..b5e64d56 100644 --- a/app/src/androidTest/java/bisq/android/tests/NotificationTableTest.kt +++ b/app/src/androidTest/java/bisq/android/tests/NotificationTableTest.kt @@ -17,21 +17,20 @@ package bisq.android.tests -import androidx.test.core.app.ActivityScenario import androidx.test.espresso.Espresso.pressBack -import androidx.test.espresso.intent.Intents -import androidx.test.espresso.intent.matcher.IntentMatchers +import androidx.test.espresso.intent.Intents.intended +import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent import androidx.test.ext.junit.runners.AndroidJUnit4 +import bisq.android.ui.notification.NotificationAdapter import bisq.android.ui.notification.NotificationDetailActivity -import bisq.android.ui.notification.NotificationTableActivity -import org.junit.Assert.assertEquals +import bisq.android.ui.settings.SettingsActivity +import org.assertj.core.api.Assertions.assertThat import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class NotificationTableTest : BaseTest() { - @Before override fun setup() { super.setup() @@ -40,63 +39,146 @@ class NotificationTableTest : BaseTest() { @Test fun clickNotificationLoadsNotificationDetailActivity() { - ActivityScenario.launch(NotificationTableActivity::class.java).use { - notificationTableScreen.settingsButton.click() - settingsScreen.addExampleNotificationsButton.click() - notificationTableScreen.notificationRecylerView.clickAtPosition(0) - Intents.intended(IntentMatchers.hasComponent(NotificationDetailActivity::class.java.name)) - } + notificationTableActivityRule.launch() + + notificationTableScreen.addExampleNotificationsMenuItem.click() + notificationTableScreen.notificationRecylerView.clickAtPosition(0) + intended(hasComponent(NotificationDetailActivity::class.java.name)) } @Test fun viewedNotificationIsMarkedAsRead() { - ActivityScenario.launch(NotificationTableActivity::class.java).use { - notificationTableScreen.settingsButton.click() - settingsScreen.addExampleNotificationsButton.click() - var readState = - notificationTableScreen.notificationRecylerView.getReadStateAtPosition(0) - assertEquals(false, readState) - notificationTableScreen.notificationRecylerView.clickAtPosition(0) - Intents.intended(IntentMatchers.hasComponent(NotificationDetailActivity::class.java.name)) - pressBack() - readState = notificationTableScreen.notificationRecylerView.getReadStateAtPosition(0) - assertEquals(true, readState) - } + notificationTableActivityRule.launch() + + notificationTableScreen.addExampleNotificationsMenuItem.click() + var readState = getContentAtPosition(0).read + assertThat(readState) + .describedAs("Read state") + .isFalse() + + notificationTableScreen.notificationRecylerView.clickAtPosition(0) + intended(hasComponent(NotificationDetailActivity::class.java.name)) + pressBack() + readState = getContentAtPosition(0).read + assertThat(readState) + .describedAs("Read state") + .isTrue() } @Test fun swipeToDeleteNotificationDeletesNotification() { - ActivityScenario.launch(NotificationTableActivity::class.java).use { - notificationTableScreen.settingsButton.click() - settingsScreen.addExampleNotificationsButton.click() - val countBeforeSwipe = notificationTableScreen.notificationRecylerView.getItemCount() - notificationTableScreen.notificationRecylerView.swipeToDeleteAtPosition(0) - val countAfterSwipe = notificationTableScreen.notificationRecylerView.getItemCount() - assertEquals(countBeforeSwipe - 1, countAfterSwipe) - } + notificationTableActivityRule.launch() + + notificationTableScreen.addExampleNotificationsMenuItem.click() + val countBeforeSwipe = notificationTableScreen.notificationRecylerView.getItemCount() + notificationTableScreen.notificationRecylerView.swipeRightToLeftAtPosition(0) + val countAfterSwipe = notificationTableScreen.notificationRecylerView.getItemCount() + assertThat(countAfterSwipe) + .describedAs("Notification count after swipe to delete") + .isEqualTo(countBeforeSwipe - 1) } @Test fun scrollPositionIsRetainedWhenNavigatingBack() { - ActivityScenario.launch(NotificationTableActivity::class.java).use { - for (counter in 1..5) { - notificationTableScreen.settingsButton.click() - settingsScreen.addExampleNotificationsButton.click() - } - notificationTableScreen.notificationRecylerView.scrollToPosition( - notificationTableScreen.notificationRecylerView.getItemCount() - 1 - ) - val positionBeforeClick = notificationTableScreen.notificationRecylerView - .getScrollPosition() - notificationTableScreen.notificationRecylerView.clickAtPosition( - notificationTableScreen.notificationRecylerView.getItemCount() - 1 - ) - Intents.intended(IntentMatchers.hasComponent(NotificationDetailActivity::class.java.name)) - pressBack() - assertEquals( - positionBeforeClick, - notificationTableScreen.notificationRecylerView.getScrollPosition() - ) + notificationTableActivityRule.launch() + + for (counter in 1..5) { + notificationTableScreen.addExampleNotificationsMenuItem.click() } + notificationTableScreen.notificationRecylerView.scrollToPosition( + notificationTableScreen.notificationRecylerView.getItemCount() - 1 + ) + val positionBeforeClick = notificationTableScreen.notificationRecylerView + .getScrollPosition() + notificationTableScreen.notificationRecylerView.clickAtPosition( + notificationTableScreen.notificationRecylerView.getItemCount() - 1 + ) + intended(hasComponent(NotificationDetailActivity::class.java.name)) + pressBack() + assertThat(notificationTableScreen.notificationRecylerView.getScrollPosition()) + .describedAs("Scroll position when navigating back") + .isEqualTo(positionBeforeClick) + } + + @Test + fun clickDeleteAllNotificationsMenuItemAndNotAcceptingConfirmationDoesNotDeleteNotifications() { + notificationTableActivityRule.launch() + + notificationTableScreen.addExampleNotificationsMenuItem.click() + + val itemCount = notificationTableScreen.notificationRecylerView.getItemCount() + + assertThat(itemCount).isGreaterThan(0) + + notificationTableScreen.deleteAllMenuItem.click() + assertThat(notificationTableScreen.alertDialogDeleteAll.isDisplayed()) + .describedAs("Delete all alert dialog is displayed") + .isTrue() + + notificationTableScreen.alertDialogDeleteAll.negativeButton.click() + + assertThat(notificationTableScreen.notificationRecylerView.getItemCount()) + .describedAs("Item count") + .isEqualTo(itemCount) + } + + @Test + fun clickDeleteAllNotificationsMenuItemAndAcceptingConfirmationDeletesAllNotifications() { + notificationTableActivityRule.launch() + + notificationTableScreen.addExampleNotificationsMenuItem.click() + + assertThat(notificationTableScreen.notificationRecylerView.getItemCount()) + .describedAs("Item count") + .isGreaterThan(0) + + notificationTableScreen.deleteAllMenuItem.click() + assertThat(notificationTableScreen.alertDialogDeleteAll.isDisplayed()) + .describedAs("Delete all alert dialog is displayed") + .isTrue() + + notificationTableScreen.alertDialogDeleteAll.positiveButton.click() + + assertThat(notificationTableScreen.notificationRecylerView.getItemCount()) + .describedAs("Item count") + .isZero() + } + + @Test + fun clickMarkAsReadMenuItemMarksAllNotificationsAsRead() { + notificationTableActivityRule.launch() + + notificationTableScreen.addExampleNotificationsMenuItem.click() + + val count = notificationTableScreen.notificationRecylerView.getItemCount() + for (position in 0 until count - 1) { + val readState = getContentAtPosition(position).read + assertThat(readState) + .describedAs("Read state") + .isFalse() + } + + notificationTableScreen.markAllAsReadMenuItem.click() + + for (position in 0 until count - 1) { + val readState = getContentAtPosition(position).read + assertThat(readState) + .describedAs("Read state") + .isTrue() + } + } + + @Test + fun clickSettingsMenuItemLoadsSettingsActivity() { + notificationTableActivityRule.launch() + + notificationTableScreen.settingsMenuItem.click() + intended(hasComponent(SettingsActivity::class.java.name)) + } + + private fun getContentAtPosition(position: Int): NotificationAdapter.NotificationViewHolder { + val viewHolder = + notificationTableScreen.notificationRecylerView.getContentAtPosition(position) + return viewHolder as NotificationAdapter.NotificationViewHolder } } diff --git a/app/src/androidTest/java/bisq/android/tests/NotificationTest.kt b/app/src/androidTest/java/bisq/android/tests/NotificationTest.kt new file mode 100644 index 00000000..4dd381ad --- /dev/null +++ b/app/src/androidTest/java/bisq/android/tests/NotificationTest.kt @@ -0,0 +1,204 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.android.tests + +import android.app.Notification +import android.app.NotificationManager +import android.content.Context +import android.os.Build +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.GrantPermissionRule +import androidx.test.uiautomator.By +import androidx.test.uiautomator.BySelector +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.UiObject2 +import androidx.test.uiautomator.Until +import bisq.android.database.BisqNotification +import bisq.android.model.Device +import bisq.android.model.DeviceStatus +import bisq.android.model.NotificationMessage.Companion.BISQ_MESSAGE_ANDROID_MAGIC +import bisq.android.model.NotificationType +import bisq.android.rules.FirebasePushNotificationTestRule +import bisq.android.rules.ScreenshotRule +import bisq.android.screens.NotificationTableScreen +import bisq.android.services.BisqFirebaseMessagingService +import bisq.android.util.CryptoUtil +import bisq.android.util.DateUtil +import com.google.firebase.messaging.RemoteMessage +import com.google.gson.GsonBuilder +import org.assertj.core.api.Assertions.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Ignore +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import org.junit.runner.RunWith +import java.util.Date + +@RunWith(AndroidJUnit4::class) +class NotificationTest { + companion object { + private const val WAIT_CONDITION_TIMEOUT_MS: Long = 1000 + } + + private val fcmTestRule = FirebasePushNotificationTestRule(BisqFirebaseMessagingService()) + + private val permissionRule: GrantPermissionRule + get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + GrantPermissionRule.grant(android.Manifest.permission.POST_NOTIFICATIONS) + } else { + GrantPermissionRule.grant() + } + + // Define a RuleChain to ensure screenshots are taken BEFORE the teardown of activity rules + @get:Rule + val ruleChain: RuleChain = RuleChain + .outerRule(permissionRule) + .around(fcmTestRule) + .around(ScreenshotRule()) + + private val applicationContext: Context = ApplicationProvider.getApplicationContext() + + private val deviceToken = "fnWtGaJGSByKiPwT71O3Lo:APA91bGU05lvoKxvz3Y0fnFHytSveA_juVjq2QMY3_H9URqDsEpLHGbLSFBN" + + "3wY7YdHDD3w52GECwRWuKGBJm1O1f5fJhVvcr1rJxo94aDjoWwsnkVp-ecWwh5YY_MQ6LRqbWzumCeX_" + + private val bisqNotification = buildBisqNotification() + + @Before + fun setupDevice() { + pairDevice() + } + + @After + fun closeNotificationPanel() { + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + device.pressBack() + } + + @Test + fun whenReceivingEncryptedNotification_thenDecryptedContentShownInNotificationPanel() { + val remoteMessage = buildRemoteMessage(bisqNotification) + + fcmTestRule.sendPush(remoteMessage) + + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + device.openNotification() + device.wait(Until.hasObject(By.text("Bisq")), WAIT_CONDITION_TIMEOUT_MS) + val manager = applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + assertThat( + manager.activeNotifications.find { + it.notification.extras.getString(Notification.EXTRA_TITLE).equals(bisqNotification.title) + } + ).isNotNull + assertThat( + manager.activeNotifications.find { + it.notification.extras.getString(Notification.EXTRA_TEXT).equals(bisqNotification.message) + } + ).isNotNull + } + + @Test + fun whenReceivingMultipleNotifications_thenAllShownInNotificationPanel() { + val bisqNotifications = mutableListOf() + for (counter in 1..5) { + val bisqNotification = buildBisqNotification() + bisqNotifications.add(bisqNotification) + fcmTestRule.sendPush(buildRemoteMessage(bisqNotification)) + } + + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + device.openNotification() + device.wait(Until.hasObject(By.text("Bisq")), WAIT_CONDITION_TIMEOUT_MS) + val manager = applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + bisqNotifications.forEach { bisqNotification -> + assertThat( + manager.activeNotifications.find { + it.notification.extras.getString(Notification.EXTRA_TEXT).equals(bisqNotification.message) + } + ).isNotNull + } + } + + @Test + @Ignore("Failing in CI") + fun whenClickingReceivedNotification_thenAppOpenedToNotificationView() { + val remoteMessage = buildRemoteMessage(bisqNotification) + + fcmTestRule.sendPush(remoteMessage) + + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + device.openNotification() + device.wait(Until.hasObject(By.text("Bisq")), WAIT_CONDITION_TIMEOUT_MS) + val title: UiObject2 = device.getObject(By.text(bisqNotification.title!!)) + + title.click() + + val notificationTableScreen = NotificationTableScreen() + assertThat(notificationTableScreen.notificationRecylerView.isDisplayed()).isTrue() + val notificationCount = notificationTableScreen.notificationRecylerView.getItemCount() + assertThat(notificationCount) + .describedAs("Notification count") + .isEqualTo(1) + } + + private fun pairDevice() { + Device.instance.newToken(deviceToken) + Device.instance.status = DeviceStatus.PAIRED + Device.instance.saveToPreferences(applicationContext) + } + + private fun buildBisqNotification(): BisqNotification { + val now = Date() + val tradeId = (100000..999999).random() + val bisqNotification = BisqNotification() + bisqNotification.type = NotificationType.TRADE.name + bisqNotification.title = "Trade confirmed" + bisqNotification.message = "The trade with ID $tradeId is confirmed." + bisqNotification.sentDate = now.time - 1000 * 60 + bisqNotification.receivedDate = now.time + return bisqNotification + } + + private fun serializeNotificationPayload(bisqNotification: BisqNotification): String { + val gsonBuilder = GsonBuilder() + gsonBuilder.registerTypeAdapter(Date::class.java, DateUtil()) + val gson = gsonBuilder.create() + return gson.toJson(bisqNotification) + } + + private fun buildRemoteMessage(bisqNotification: BisqNotification): RemoteMessage { + val initializationVector = (1000000000000000..9999999999999999).random().toString() + val encryptedContent = CryptoUtil(Device.instance.key!!).encrypt( + serializeNotificationPayload(bisqNotification), + initializationVector + ) + + return RemoteMessage.Builder(deviceToken).addData( + "encrypted", + "${BISQ_MESSAGE_ANDROID_MAGIC}|$initializationVector|$encryptedContent" + ).build() + } + + private fun UiDevice.getObject(selector: BySelector): UiObject2 { + wait(Until.hasObject(selector), WAIT_CONDITION_TIMEOUT_MS) + return findObject(selector) ?: error("Object not found for: $selector") + } +} diff --git a/app/src/androidTest/java/bisq/android/tests/PairingScanTest.kt b/app/src/androidTest/java/bisq/android/tests/PairingScanTest.kt index 2fe56b79..450e66f2 100644 --- a/app/src/androidTest/java/bisq/android/tests/PairingScanTest.kt +++ b/app/src/androidTest/java/bisq/android/tests/PairingScanTest.kt @@ -17,23 +17,20 @@ package bisq.android.tests -import androidx.test.core.app.ActivityScenario -import androidx.test.espresso.intent.Intents -import androidx.test.espresso.intent.matcher.IntentMatchers +import androidx.test.espresso.intent.Intents.intended +import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent import androidx.test.ext.junit.runners.AndroidJUnit4 -import bisq.android.ui.pairing.PairingScanActivity import bisq.android.ui.pairing.PairingSendActivity import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class PairingScanTest : BaseTest() { - @Test fun clickNoWebcamButtonLoadsPairingSendActivity() { - ActivityScenario.launch(PairingScanActivity::class.java).use { - pairingScanScreen.noWebcamButton.click() - Intents.intended(IntentMatchers.hasComponent(PairingSendActivity::class.java.name)) - } + pairingScanActivityRule.launch() + + pairingScanScreen.noWebcamButton.click() + intended(hasComponent(PairingSendActivity::class.java.name)) } } diff --git a/app/src/androidTest/java/bisq/android/tests/PairingSendTest.kt b/app/src/androidTest/java/bisq/android/tests/PairingSendTest.kt index a052809e..959978f6 100644 --- a/app/src/androidTest/java/bisq/android/tests/PairingSendTest.kt +++ b/app/src/androidTest/java/bisq/android/tests/PairingSendTest.kt @@ -18,37 +18,35 @@ package bisq.android.tests import android.content.Intent -import androidx.test.core.app.ActivityScenario -import androidx.test.espresso.intent.Intents -import androidx.test.espresso.intent.matcher.BundleMatchers +import androidx.test.espresso.intent.Intents.intended +import androidx.test.espresso.intent.matcher.BundleMatchers.hasValue import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction import androidx.test.espresso.intent.matcher.IntentMatchers.hasExtras import androidx.test.ext.junit.runners.AndroidJUnit4 import bisq.android.model.Device -import bisq.android.ui.pairing.PairingSendActivity import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class PairingSendTest : BaseTest() { - @Test fun clickSendPairingTokenButton() { Device.instance.newToken( - "fnWtGaJGSByKiPwT71O3Lo:APA91bGU05lvoKxvz3Y0fnFHytSveA_juVjq2QMY3_H9URqDsEpLHGbLSFBN3wY7YdHDD3w52GECwRWuKGBJm1O1f5fJhVvcr1rJxo94aDjoWwsnkVp-ecWwh5YY_MQ6LRqbWzumCeX_" + "fnWtGaJGSByKiPwT71O3Lo:APA91bGU05lvoKxvz3Y0fnFHytSveA_juVjq2QMY3_H9URqDsEp" + + "LHGbLSFBN3wY7YdHDD3w52GECwRWuKGBJm1O1f5fJhVvcr1rJxo94aDjoWwsnkVp-ecWwh5YY_MQ6LRqbWzumCeX_" ) - ActivityScenario.launch(PairingSendActivity::class.java).use { - pairingSendScreen.sendPairingTokenButton.click() - Intents.intended(hasAction(Intent.ACTION_CHOOSER)) - Intents.intended( - hasExtras( - BundleMatchers.hasValue( - hasExtras( - BundleMatchers.hasValue(Device.instance.pairingToken()) - ) + pairingSendActivityRule.launch() + + pairingSendScreen.sendPairingTokenButton.click() + intended(hasAction(Intent.ACTION_CHOOSER)) + intended( + hasExtras( + hasValue( + hasExtras( + hasValue(Device.instance.pairingToken()) ) ) ) - } + ) } } diff --git a/app/src/androidTest/java/bisq/android/tests/PairingSuccessTest.kt b/app/src/androidTest/java/bisq/android/tests/PairingSuccessTest.kt index c4f3c6f4..d6c51ad3 100644 --- a/app/src/androidTest/java/bisq/android/tests/PairingSuccessTest.kt +++ b/app/src/androidTest/java/bisq/android/tests/PairingSuccessTest.kt @@ -17,30 +17,167 @@ package bisq.android.tests -import androidx.test.core.app.ActivityScenario -import androidx.test.espresso.intent.Intents +import android.Manifest +import android.content.pm.PackageManager +import android.os.Build +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import androidx.test.espresso.intent.Intents.intended import androidx.test.espresso.intent.matcher.IntentMatchers +import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent import androidx.test.ext.junit.runners.AndroidJUnit4 import bisq.android.ui.notification.NotificationTableActivity -import bisq.android.ui.pairing.PairingSuccessActivity +import bisq.android.ui.pairing.RequestNotificationPermissionActivity +import io.mockk.every +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import org.assertj.core.api.Assertions.assertThat +import org.hamcrest.Matchers +import org.junit.After import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class PairingSuccessTest : BaseTest() { - @Before override fun setup() { super.setup() pairDevice() } + @After + fun removeMocks() { + unmockkStatic("androidx.core.app.ActivityCompat") + unmockkStatic("androidx.core.content.ContextCompat") + } + + @Test + fun clickPairingSuccessButtonLoadsNotificationTableScreenWithApi32AndOlder() { + assumeMaxApiLevel(Build.VERSION_CODES.S_V2) + + pairingSuccessActivityRule.launch() + + pairingSuccessScreen.pairingCompleteButton.click() + intended(hasComponent(NotificationTableActivity::class.java.name)) + + assertThat(pairingSuccessScreen.permissionPrompt.isDisplayed()) + .describedAs("Request notification permission prompt is not displayed") + .isFalse() + } + + @Test + fun clickPairingSuccessButtonLoadsNotificationPermissionRequestPromptWithApi33AndNewer() { + assumeMinApiLevel(Build.VERSION_CODES.TIRAMISU) + + pairingSuccessActivityRule.launch() + + pairingSuccessScreen.pairingCompleteButton.click() + val expectedIntent = Matchers.allOf( + IntentMatchers.hasAction("android.content.pm.action.REQUEST_PERMISSIONS"), + IntentMatchers.hasExtra( + "android.content.pm.extra.REQUEST_PERMISSIONS_NAMES", + Matchers.hasItemInArray(Manifest.permission.POST_NOTIFICATIONS) + ), + ) + intended(expectedIntent) + assertThat(pairingSuccessScreen.permissionPrompt.isDisplayed()) + .describedAs("Request notification permission prompt is displayed") + .isTrue() + } + @Test - fun clickPairingSuccessButtonLoadsNotificationTableScreen() { - ActivityScenario.launch(PairingSuccessActivity::class.java).use { - pairingSuccessScreen.pairingCompleteButton.click() - Intents.intended(IntentMatchers.hasComponent(NotificationTableActivity::class.java.name)) - } + fun acceptingNotificationPermissionRequestLoadsNotificationTableScreen() { + assumeMinApiLevel(Build.VERSION_CODES.TIRAMISU) + + pairingSuccessActivityRule.launch() + + pairingSuccessScreen.pairingCompleteButton.click() + assertThat(pairingSuccessScreen.permissionPrompt.isDisplayed()) + .describedAs("Request notification permission prompt is displayed") + .isTrue() + + pairingSuccessScreen.permissionPrompt.grantPermission() + intended(hasComponent(NotificationTableActivity::class.java.name)) + + assertThat( + ContextCompat.checkSelfPermission( + applicationContext, + Manifest.permission.POST_NOTIFICATIONS + ) + ) + .describedAs("Post notifications permission") + .isEqualTo(PackageManager.PERMISSION_GRANTED) + } + + @Test + fun denyingNotificationPermissionRequestLoadsNotificationTableScreen() { + assumeMinApiLevel(Build.VERSION_CODES.TIRAMISU) + + pairingSuccessActivityRule.launch() + + pairingSuccessScreen.pairingCompleteButton.click() + assertThat(pairingSuccessScreen.permissionPrompt.isDisplayed()) + .describedAs("Request notification permission prompt is displayed") + .isTrue() + + pairingSuccessScreen.permissionPrompt.denyPermission() + intended(hasComponent(NotificationTableActivity::class.java.name)) + + assertThat( + ContextCompat.checkSelfPermission( + applicationContext, + Manifest.permission.POST_NOTIFICATIONS + ) + ) + .describedAs("Post notifications permission") + .isEqualTo(PackageManager.PERMISSION_DENIED) + } + + @Test + fun clickPairingSuccessButtonWithPermissionsAlreadyGrantedLoadsNotificationTableScreen() { + assumeMinApiLevel(Build.VERSION_CODES.TIRAMISU) + + // Attempting to grant the permission does not work, + // i.e. GrantPermissionRule.grant(Manifest.permission.POST_NOTIFICATIONS). + // Therefore, just mock the permission check. Not ideal, but works for now. + mockkStatic("androidx.core.content.ContextCompat") + every { + ContextCompat.checkSelfPermission( + any(), Manifest.permission.POST_NOTIFICATIONS + ) + } returns PackageManager.PERMISSION_GRANTED + + pairingSuccessActivityRule.launch() + + pairingSuccessScreen.pairingCompleteButton.click() + intended(hasComponent(NotificationTableActivity::class.java.name)) + + assertThat(pairingSuccessScreen.permissionPrompt.isDisplayed()) + .describedAs("Request notification permission prompt is not displayed") + .isFalse() + } + + @Test + fun clickPairingSuccessButtonWithPermissionsPreviouslyDeniedLoadsRequestPermissionsActivity() { + assumeMinApiLevel(Build.VERSION_CODES.TIRAMISU) + + // I have no idea how to ensure shouldShowRequestPermissionRationale returns true. + // Therefore, just mock it. Not ideal, but works for now. + mockkStatic("androidx.core.app.ActivityCompat") + every { + ActivityCompat.shouldShowRequestPermissionRationale( + any(), Manifest.permission.POST_NOTIFICATIONS + ) + } returns true + + pairingSuccessActivityRule.launch() + + pairingSuccessScreen.pairingCompleteButton.click() + intended(hasComponent(RequestNotificationPermissionActivity::class.java.name)) + + assertThat(pairingSuccessScreen.permissionPrompt.isDisplayed()) + .describedAs("Request notification permission prompt is not displayed") + .isFalse() } } diff --git a/app/src/androidTest/java/bisq/android/tests/RequestNotificationPermissionTest.kt b/app/src/androidTest/java/bisq/android/tests/RequestNotificationPermissionTest.kt new file mode 100644 index 00000000..0b5dbd12 --- /dev/null +++ b/app/src/androidTest/java/bisq/android/tests/RequestNotificationPermissionTest.kt @@ -0,0 +1,106 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.android.tests + +import android.Manifest +import android.content.pm.PackageManager +import android.os.Build +import androidx.core.content.ContextCompat +import androidx.test.espresso.intent.Intents.intended +import androidx.test.espresso.intent.matcher.IntentMatchers +import androidx.test.ext.junit.runners.AndroidJUnit4 +import bisq.android.ui.notification.NotificationTableActivity +import org.assertj.core.api.Assertions.assertThat +import org.hamcrest.Matchers +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class RequestNotificationPermissionTest : BaseTest() { + @Before + override fun setup() { + super.setup() + pairDevice() + } + + @Test + fun clickEnableNotificationsButtonLoadsNotificationPermissionRequestPrompt() { + assumeMinApiLevel(Build.VERSION_CODES.TIRAMISU) + + requestNotificationPermissionActivityRule.launch() + + requestNotificationPermissionScreen.enableNotificationsButton.click() + val expectedIntent = Matchers.allOf( + IntentMatchers.hasAction("android.content.pm.action.REQUEST_PERMISSIONS"), + IntentMatchers.hasExtra( + "android.content.pm.extra.REQUEST_PERMISSIONS_NAMES", + Matchers.hasItemInArray(Manifest.permission.POST_NOTIFICATIONS) + ) + ) + intended(expectedIntent) + assertThat(requestNotificationPermissionScreen.permissionPrompt.isDisplayed()) + .describedAs("Request notification permission prompt is displayed") + .isTrue() + } + + @Test + fun acceptingNotificationPermissionRequestLoadsNotificationTableScreen() { + assumeMinApiLevel(Build.VERSION_CODES.TIRAMISU) + + requestNotificationPermissionActivityRule.launch() + + requestNotificationPermissionScreen.enableNotificationsButton.click() + assertThat(requestNotificationPermissionScreen.permissionPrompt.isDisplayed()) + .describedAs("Request notification permission prompt is displayed") + .isTrue() + requestNotificationPermissionScreen.permissionPrompt.grantPermission() + intended(IntentMatchers.hasComponent(NotificationTableActivity::class.java.name)) + assertThat( + ContextCompat.checkSelfPermission( + applicationContext, + Manifest.permission.POST_NOTIFICATIONS + ) + ) + .describedAs("Post notifications permission") + .isEqualTo(PackageManager.PERMISSION_GRANTED) + } + + @Test + fun clickSkipButtonLoadsNotificationTableScreen() { + assumeMinApiLevel(Build.VERSION_CODES.TIRAMISU) + + requestNotificationPermissionActivityRule.launch() + + requestNotificationPermissionScreen.skipPermissionButton.click() + intended(IntentMatchers.hasComponent(NotificationTableActivity::class.java.name)) + + assertThat(requestNotificationPermissionScreen.permissionPrompt.isDisplayed()) + .describedAs("Request notification permission prompt is not displayed") + .isFalse() + + assertThat( + ContextCompat.checkSelfPermission( + applicationContext, + Manifest.permission.POST_NOTIFICATIONS + ) + ) + .describedAs("Post notifications permission") + .isEqualTo(PackageManager.PERMISSION_DENIED) + } +} diff --git a/app/src/androidTest/java/bisq/android/tests/SettingsTest.kt b/app/src/androidTest/java/bisq/android/tests/SettingsTest.kt index 2756cddb..7639e4b2 100644 --- a/app/src/androidTest/java/bisq/android/tests/SettingsTest.kt +++ b/app/src/androidTest/java/bisq/android/tests/SettingsTest.kt @@ -19,39 +19,37 @@ package bisq.android.tests import android.app.Activity import android.app.Instrumentation +import android.app.UiModeManager import android.content.Intent -import android.os.Build -import androidx.test.core.app.ActivityScenario +import androidx.appcompat.app.AppCompatDelegate import androidx.test.espresso.intent.Intents.intended import androidx.test.espresso.intent.Intents.intending import androidx.test.espresso.intent.matcher.IntentMatchers import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.filters.SdkSuppress import bisq.android.BISQ_MOBILE_URL import bisq.android.BISQ_NETWORK_URL -import bisq.android.mocks.FirebaseMock import bisq.android.model.Device import bisq.android.model.DeviceStatus import bisq.android.services.BisqFirebaseMessagingService.Companion.isFirebaseMessagingInitialized -import bisq.android.ui.notification.NotificationTableActivity +import bisq.android.testCommon.mocks.FirebaseMock +import bisq.android.ui.pairing.PairingScanActivity import bisq.android.ui.settings.SettingsActivity import bisq.android.ui.welcome.WelcomeActivity +import junit.framework.AssertionFailedError +import org.assertj.core.api.Assertions.assertThat import org.awaitility.Durations.TEN_SECONDS import org.awaitility.kotlin.atMost import org.awaitility.kotlin.await import org.awaitility.kotlin.untilNotNull +import org.hamcrest.core.AllOf import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotEquals -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertTrue +import org.junit.Assert import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class SettingsTest : BaseTest() { - @Before override fun setup() { super.setup() @@ -65,86 +63,235 @@ class SettingsTest : BaseTest() { } @Test - @SdkSuppress(minSdkVersion = Build.VERSION_CODES.P) - fun clickResetButtonWipesPairingAndLoadsWelcomeScreen() { + fun clickThemePromptsToChangeTheme() { + settingsActivityRule.launch() + + settingsScreen.themePreference.click() + assertThat(settingsScreen.themePromptDialog.isDisplayed()) + .describedAs("Theme prompt dialog is displayed") + .isTrue() + + settingsScreen.themePromptDialog.cancelButton.click() + intended(IntentMatchers.hasComponent(SettingsActivity::class.java.name)) + } + + @Test + fun clickDarkThemeChangesToDarkTheme() { + settingsActivityRule.launch() + + settingsScreen.themePreference.click() + assertThat(settingsScreen.themePromptDialog.isDisplayed()) + .describedAs("Theme prompt dialog is displayed") + .isTrue() + + settingsScreen.themePromptDialog.darkThemeSelection.click() + + assertThat(AppCompatDelegate.getDefaultNightMode()) + .describedAs("Night mode") + .isEqualTo(UiModeManager.MODE_NIGHT_YES) + } + + @Test + fun clickLightThemeChangesToLightTheme() { + settingsActivityRule.launch() + + settingsScreen.themePreference.click() + assertThat(settingsScreen.themePromptDialog.isDisplayed()) + .describedAs("Theme prompt dialog is displayed") + .isTrue() + + settingsScreen.themePromptDialog.lightThemeSelection.click() + + assertThat(AppCompatDelegate.getDefaultNightMode()) + .describedAs("Night mode") + .isEqualTo(UiModeManager.MODE_NIGHT_NO) + } + + @Test + fun clickSystemThemeChangesToSystemTheme() { + settingsActivityRule.launch() + + settingsScreen.themePreference.click() + assertThat(settingsScreen.themePromptDialog.isDisplayed()) + .describedAs("Theme prompt dialog is displayed") + .isTrue() + + settingsScreen.themePromptDialog.systemThemeSelection.click() + + assertThat(AppCompatDelegate.getDefaultNightMode()) + .describedAs("Night mode") + .isEqualTo(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) + } + + @Test + fun clickResetPairingAndNotAcceptingConfirmationDoesNotWipePairing() { if (!isFirebaseMessagingInitialized()) { FirebaseMock.mockFirebaseTokenSuccessful() } val key = Device.instance.key val token = Device.instance.token - ActivityScenario.launch(SettingsActivity::class.java).use { - settingsScreen.resetButton.click() - intended(IntentMatchers.hasComponent(WelcomeActivity::class.java.name)) - await atMost TEN_SECONDS untilNotNull { Device.instance.key } - assertNotNull(Device.instance.key) - assertNotEquals(key, Device.instance.key) - assertNotNull(Device.instance.token) - assertNotEquals(token, Device.instance.token) - assertEquals(DeviceStatus.UNPAIRED, Device.instance.status) - } + settingsActivityRule.launch() + + settingsScreen.resetPairingPreference.click() + assertThat(settingsScreen.alertDialogResetPairing.isDisplayed()) + .describedAs("Reset pairing alert dialog is displayed") + .isTrue() + + settingsScreen.alertDialogResetPairing.negativeButton.click() + + intended(IntentMatchers.hasComponent(SettingsActivity::class.java.name)) + assertThat(Device.instance.key) + .describedAs("Device key") + .isEqualTo(key) + assertThat(Device.instance.token) + .describedAs("Device token") + .isEqualTo(token) + assertThat(Device.instance.status) + .describedAs("Device status") + .isEqualTo(DeviceStatus.PAIRED) } @Test - fun clickDeleteAllNotificationsButtonDeletesAllNotifications() { - ActivityScenario.launch(NotificationTableActivity::class.java).use { - notificationTableScreen.settingsButton.click() - settingsScreen.addExampleNotificationsButton.click() - assertTrue(notificationTableScreen.notificationRecylerView.getItemCount() > 0) - notificationTableScreen.settingsButton.click() - settingsScreen.deleteNotificationsButton.click() - assertTrue(notificationTableScreen.notificationRecylerView.getItemCount() == 0) + fun clickResetPairingAndAcceptingConfirmationWipesPairingAndLoadsWelcomeScreen() { + if (!isFirebaseMessagingInitialized()) { + FirebaseMock.mockFirebaseTokenSuccessful() } + val key = Device.instance.key + val token = Device.instance.token + settingsActivityRule.launch() + + settingsScreen.resetPairingPreference.click() + assertThat(settingsScreen.alertDialogResetPairing.isDisplayed()) + .describedAs("Reset pairing alert dialog is displayed") + .isTrue() + + settingsScreen.alertDialogResetPairing.positiveButton.click() + + intended(IntentMatchers.hasComponent(WelcomeActivity::class.java.name)) + await atMost TEN_SECONDS untilNotNull { Device.instance.key } + assertThat(Device.instance.key) + .describedAs("Device key") + .isNotNull() + assertThat(Device.instance.key) + .describedAs("Device key") + .isNotEqualTo(key) + assertThat(Device.instance.token) + .describedAs("Device token") + .isNotNull() + assertThat(Device.instance.token) + .describedAs("Device token") + .isNotEqualTo(token) + assertThat(Device.instance.status) + .describedAs("Device status") + .isEqualTo(DeviceStatus.UNPAIRED) } @Test - fun clickMarkAsReadButtonMarksAllNotificationsAsRead() { - ActivityScenario.launch(NotificationTableActivity::class.java).use { - notificationTableScreen.settingsButton.click() - settingsScreen.addExampleNotificationsButton.click() - - val count = notificationTableScreen.notificationRecylerView.getItemCount() - for (position in 0 until count - 1) { - val readState = - notificationTableScreen.notificationRecylerView.getReadStateAtPosition(position) - assertEquals(false, readState) - } - - notificationTableScreen.settingsButton.click() - settingsScreen.markAsReadButton.click() - - for (position in 0 until count - 1) { - val readState = - notificationTableScreen.notificationRecylerView.getReadStateAtPosition(position) - assertEquals(true, readState) - } + fun clickScanPairingTokenLoadsPairingScanActivity() { + if (!isFirebaseMessagingInitialized()) { + FirebaseMock.mockFirebaseTokenSuccessful() } + settingsActivityRule.launch() + + settingsScreen.scanPairingTokenPreference.click() + intended(IntentMatchers.hasComponent(PairingScanActivity::class.java.name)) } @Test - fun clickAboutBisqButtonLoadsBisqNetworkWebpage() { - ActivityScenario.launch(SettingsActivity::class.java).use { - intending(IntentMatchers.hasAction(Intent.ACTION_VIEW)).respondWith( - Instrumentation.ActivityResult(Activity.RESULT_OK, Intent()) + fun clickAboutBisqAndNotAcceptingConfirmationDoesNotLoadBisqNetworkWebpage() { + settingsActivityRule.launch() + + settingsScreen.aboutBisqPreference.click() + assertThat(settingsScreen.alertDialogLoadBisqNetworkUrl.isDisplayed()) + .describedAs("Load Bisq Network URL alert dialog is displayed") + .isTrue() + + settingsScreen.alertDialogLoadBisqNetworkUrl.negativeButton.click() + + try { + val expectedIntent = AllOf.allOf( + IntentMatchers.hasAction(Intent.ACTION_VIEW), + IntentMatchers.hasData(BISQ_NETWORK_URL) ) - settingsScreen.aboutBisqButton.click() - assertTrue(settingsScreen.alertDialogLoadBisqNetworkUrl.isDisplayed()) - settingsScreen.alertDialogLoadBisqNetworkUrl.positiveButton.click() - intended(IntentMatchers.hasAction(Intent.ACTION_VIEW)) - intended(IntentMatchers.hasData(BISQ_NETWORK_URL)) + intending(expectedIntent).respondWith(Instrumentation.ActivityResult(0, null)) + intended(expectedIntent) + } catch (e: AssertionFailedError) { + // We want the assertion to fail, since trying to negate the intended + // doesn't seem to work + return } + Assert.fail("Loaded web page after clicking cancel") } @Test - fun clickAboutAppButtonLoadsBisqMobileWebpage() { - ActivityScenario.launch(SettingsActivity::class.java).use { - intending(IntentMatchers.hasAction(Intent.ACTION_VIEW)).respondWith( - Instrumentation.ActivityResult(Activity.RESULT_OK, Intent()) + fun clickAboutBisqAndAcceptingConfirmationLoadsBisqNetworkWebpage() { + settingsActivityRule.launch() + + intending(IntentMatchers.hasAction(Intent.ACTION_VIEW)).respondWith( + Instrumentation.ActivityResult(Activity.RESULT_OK, Intent()) + ) + + settingsScreen.aboutBisqPreference.click() + assertThat(settingsScreen.alertDialogLoadBisqNetworkUrl.isDisplayed()) + .describedAs("Load Bisq Network URL alert dialog is displayed") + .isTrue() + + settingsScreen.alertDialogLoadBisqNetworkUrl.positiveButton.click() + + val expectedIntent = AllOf.allOf( + IntentMatchers.hasAction(Intent.ACTION_VIEW), + IntentMatchers.hasData(BISQ_NETWORK_URL) + ) + intending(expectedIntent).respondWith(Instrumentation.ActivityResult(0, null)) + intended(expectedIntent) + } + + @Test + fun clickAboutAppAndNotAcceptingConfirmationDoesNotLoadBisqMobileWebpage() { + settingsActivityRule.launch() + + settingsScreen.aboutAppPreference.click() + assertThat(settingsScreen.alertDialogLoadBisqMobileUrl.isDisplayed()) + .describedAs("Load Bisq mobile URL alert dialog is displayed") + .isTrue() + + settingsScreen.alertDialogLoadBisqMobileUrl.negativeButton.click() + + try { + val expectedIntent = AllOf.allOf( + IntentMatchers.hasAction(Intent.ACTION_VIEW), + IntentMatchers.hasData(BISQ_MOBILE_URL) ) - settingsScreen.aboutAppButton.click() - assertTrue(settingsScreen.alertDialogLoadBisqMobileUrl.isDisplayed()) - settingsScreen.alertDialogLoadBisqMobileUrl.positiveButton.click() - intended(IntentMatchers.hasAction(Intent.ACTION_VIEW)) - intended(IntentMatchers.hasData(BISQ_MOBILE_URL)) + intending(expectedIntent).respondWith(Instrumentation.ActivityResult(0, null)) + intended(expectedIntent) + } catch (e: AssertionFailedError) { + // We want the assertion to fail, since trying to negate the intended + // doesn't seem to work + return } + Assert.fail("Loaded web page after clicking cancel") + } + + @Test + fun clickAboutAppAndAcceptingConfirmationLoadsBisqMobileWebpage() { + settingsActivityRule.launch() + + intending(IntentMatchers.hasAction(Intent.ACTION_VIEW)).respondWith( + Instrumentation.ActivityResult(Activity.RESULT_OK, Intent()) + ) + + settingsScreen.aboutAppPreference.click() + assertThat(settingsScreen.alertDialogLoadBisqMobileUrl.isDisplayed()) + .describedAs("Load Bisq mobile URL alert dialog is displayed") + .isTrue() + + settingsScreen.alertDialogLoadBisqMobileUrl.positiveButton.click() + + val expectedIntent = AllOf.allOf( + IntentMatchers.hasAction(Intent.ACTION_VIEW), + IntentMatchers.hasData(BISQ_MOBILE_URL) + ) + intending(expectedIntent).respondWith(Instrumentation.ActivityResult(0, null)) + intended(expectedIntent) } } diff --git a/app/src/androidTest/java/bisq/android/tests/WelcomeTest.kt b/app/src/androidTest/java/bisq/android/tests/WelcomeTest.kt index ec625bde..196c5aee 100644 --- a/app/src/androidTest/java/bisq/android/tests/WelcomeTest.kt +++ b/app/src/androidTest/java/bisq/android/tests/WelcomeTest.kt @@ -20,31 +20,27 @@ package bisq.android.tests import android.app.Activity import android.app.Instrumentation import android.content.Intent -import android.os.Build -import androidx.test.core.app.ActivityScenario import androidx.test.espresso.intent.Intents.intended import androidx.test.espresso.intent.Intents.intending import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent import androidx.test.espresso.intent.matcher.IntentMatchers.hasData import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.filters.SdkSuppress import bisq.android.BISQ_MOBILE_URL -import bisq.android.mocks.FirebaseMock import bisq.android.model.Device +import bisq.android.testCommon.mocks.FirebaseMock import bisq.android.ui.pairing.PairingScanActivity -import bisq.android.ui.welcome.WelcomeActivity +import junit.framework.AssertionFailedError +import org.assertj.core.api.Assertions.assertThat +import org.hamcrest.core.AllOf.allOf import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue +import org.junit.Assert.fail import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class WelcomeTest : BaseTest() { - @Before override fun setup() { super.setup() @@ -59,91 +55,141 @@ class WelcomeTest : BaseTest() { } @Test - @SdkSuppress(minSdkVersion = Build.VERSION_CODES.P) fun testClickPairButtonWhenGooglePlayServicesUnavailableShowsPrompt() { FirebaseMock.mockGooglePlayServicesNotAvailable() - ActivityScenario.launch(WelcomeActivity::class.java).use { - welcomeScreen.pairButton.click() - assertTrue(welcomeScreen.alertDialogGooglePlayServicesUnavailable.isDisplayed()) - - welcomeScreen.alertDialogGooglePlayServicesUnavailable.button.click() - assertFalse(welcomeScreen.alertDialogGooglePlayServicesUnavailable.isDisplayed()) - - welcomeScreen.pairButton.click() - assertTrue(welcomeScreen.alertDialogGooglePlayServicesUnavailable.isDisplayed()) - } + welcomeActivityRule.launch() + + welcomeScreen.pairButton.click() + assertThat(welcomeScreen.alertDialogGooglePlayServicesUnavailable.isDisplayed()) + .describedAs("Google Play Services unavailable alert dialog is displayed") + .isTrue() + + welcomeScreen.alertDialogGooglePlayServicesUnavailable.dismissButton.click() + assertThat(welcomeScreen.alertDialogGooglePlayServicesUnavailable.isDisplayed()) + .describedAs("Google Play Services unavailable alert dialog is not displayed") + .isFalse() + + welcomeScreen.pairButton.click() + assertThat(welcomeScreen.alertDialogGooglePlayServicesUnavailable.isDisplayed()) + .describedAs("Google Play Services unavailable alert dialog is displayed") + .isTrue() } @Test - @SdkSuppress(minSdkVersion = Build.VERSION_CODES.P) fun clickPairButtonAfterReceivingFcmTokenLoadsPairingScanActivity() { FirebaseMock.mockFirebaseTokenSuccessful() - ActivityScenario.launch(WelcomeActivity::class.java).use { - welcomeScreen.pairButton.click() - intended(hasComponent(PairingScanActivity::class.java.name)) - } + welcomeActivityRule.launch() + + welcomeScreen.pairButton.click() + intended(hasComponent(PairingScanActivity::class.java.name)) } @Test - @SdkSuppress(minSdkVersion = Build.VERSION_CODES.P) fun clickPairButtonAfterFailingToReceiveFcmTokenShowsPromptToRetryFetchingToken() { FirebaseMock.mockFirebaseTokenUnsuccessful() - ActivityScenario.launch(WelcomeActivity::class.java).use { - welcomeScreen.pairButton.click() - assertTrue(welcomeScreen.alertDialogCannotRetrieveDeviceToken.isDisplayed()) - } + welcomeActivityRule.launch() + + welcomeScreen.pairButton.click() + assertThat(welcomeScreen.alertDialogCannotRetrieveDeviceToken.isDisplayed()) + .describedAs("Cannot retrieve device token alert dialog is displayed") + .isTrue() } @Test - @SdkSuppress(minSdkVersion = Build.VERSION_CODES.P) fun clickCancelOnTokenFailurePromptAllowsClickingPairButtonAgain() { FirebaseMock.mockFirebaseTokenUnsuccessful() - ActivityScenario.launch(WelcomeActivity::class.java).use { - welcomeScreen.pairButton.click() - assertTrue(welcomeScreen.alertDialogCannotRetrieveDeviceToken.isDisplayed()) - - welcomeScreen.alertDialogCannotRetrieveDeviceToken.negativeButton.click() - assertFalse(welcomeScreen.alertDialogCannotRetrieveDeviceToken.isDisplayed()) - - welcomeScreen.pairButton.click() - assertTrue(welcomeScreen.alertDialogCannotRetrieveDeviceToken.isDisplayed()) - } + welcomeActivityRule.launch() + + welcomeScreen.pairButton.click() + assertThat(welcomeScreen.alertDialogCannotRetrieveDeviceToken.isDisplayed()) + .describedAs("Cannot retrieve device token alert dialog is displayed") + .isTrue() + + welcomeScreen.alertDialogCannotRetrieveDeviceToken.negativeButton.click() + assertThat(welcomeScreen.alertDialogCannotRetrieveDeviceToken.isDisplayed()) + .describedAs("Cannot retrieve device token alert dialog is not displayed") + .isFalse() + + welcomeScreen.pairButton.click() + assertThat(welcomeScreen.alertDialogCannotRetrieveDeviceToken.isDisplayed()) + .describedAs("Cannot retrieve device token alert dialog is displayed") + .isTrue() } @Test - @SdkSuppress(minSdkVersion = Build.VERSION_CODES.P) fun clickTryAgainOnTokenFailurePromptRetriesFetchingToken() { FirebaseMock.mockFirebaseTokenUnsuccessful() - ActivityScenario.launch(WelcomeActivity::class.java).use { - welcomeScreen.pairButton.click() - assertTrue(welcomeScreen.alertDialogCannotRetrieveDeviceToken.isDisplayed()) - - FirebaseMock.mockFirebaseTokenSuccessful() - welcomeScreen.alertDialogCannotRetrieveDeviceToken.positiveButton.click() - intended(hasComponent(PairingScanActivity::class.java.name)) - assertEquals( + welcomeActivityRule.launch() + + welcomeScreen.pairButton.click() + assertThat(welcomeScreen.alertDialogCannotRetrieveDeviceToken.isDisplayed()) + .describedAs("Cannot retrieve device token alert dialog is displayed") + .isTrue() + + FirebaseMock.mockFirebaseTokenSuccessful() + welcomeScreen.alertDialogCannotRetrieveDeviceToken.positiveButton.click() + intended(hasComponent(PairingScanActivity::class.java.name)) + assertThat(Device.instance.token) + .describedAs("Device token") + .isEqualTo( "cutUn7ZaTra9q3ayZG5vCQ:APA91bGrc9pTJdqzBgKYWQfP4I1g21rukjFpyKsjGCvFqn" + "Ql8owMqD_7_HB7viqHYXW5XE5O8B82Vyu9kZbAZ7u-S1sP_qVU9HS-MjZlfFJXc-LU_ycjwdHY" + - "E7XPFUQDD7UlnVB-giAI", - Device.instance.token + "E7XPFUQDD7UlnVB-giAI" ) - } } @Test - @SdkSuppress(minSdkVersion = Build.VERSION_CODES.P) - fun clickLearnMoreButtonLoadsBisqMobileWebpage() { - FirebaseMock.mockFirebaseTokenSuccessful() + fun clickLearnMoreButtonAndNotAcceptingConfirmationDoesNotLoadBisqMobileWebpage() { intending(hasAction(Intent.ACTION_VIEW)).respondWith( Instrumentation.ActivityResult(Activity.RESULT_OK, Intent()) ) - ActivityScenario.launch(WelcomeActivity::class.java).use { - welcomeScreen.learnMoreButton.click() - assertTrue(welcomeScreen.alertDialogLoadBisqMobileUrl.isDisplayed()) + welcomeActivityRule.launch() + + welcomeScreen.learnMoreButton.click() + assertThat(welcomeScreen.alertDialogLoadBisqMobileUrl.isDisplayed()) + .describedAs("Load Bisq mobile URL alert dialog is displayed") + .isTrue() - welcomeScreen.alertDialogLoadBisqMobileUrl.positiveButton.click() - intended(hasAction(Intent.ACTION_VIEW)) - intended(hasData(BISQ_MOBILE_URL)) + welcomeScreen.alertDialogLoadBisqMobileUrl.negativeButton.click() + + try { + val expectedIntent = allOf( + hasAction(Intent.ACTION_VIEW), + hasData(BISQ_MOBILE_URL) + ) + intending(expectedIntent).respondWith(Instrumentation.ActivityResult(0, null)) + intended(expectedIntent) + } catch (e: AssertionFailedError) { + // We want the assertion to fail, since trying to negate the intended + // doesn't seem to work + return } + fail("Loaded web page after clicking cancel") + } + + @Test + fun clickLearnMoreButtonAndAcceptingConfirmationLoadsBisqMobileWebpage() { + intending(hasAction(Intent.ACTION_VIEW)).respondWith( + Instrumentation.ActivityResult(Activity.RESULT_OK, Intent()) + ) + welcomeActivityRule.launch() + + intending(hasAction(Intent.ACTION_VIEW)).respondWith( + Instrumentation.ActivityResult(Activity.RESULT_OK, Intent()) + ) + + welcomeScreen.learnMoreButton.click() + assertThat(welcomeScreen.alertDialogLoadBisqMobileUrl.isDisplayed()) + .describedAs("Load Bisq mobile URL alert dialog is displayed") + .isTrue() + + welcomeScreen.alertDialogLoadBisqMobileUrl.positiveButton.click() + + val expectedIntent = allOf( + hasAction(Intent.ACTION_VIEW), + hasData(BISQ_MOBILE_URL) + ) + intending(expectedIntent).respondWith(Instrumentation.ActivityResult(0, null)) + intended(expectedIntent) } } diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml new file mode 100644 index 00000000..63b4aeca --- /dev/null +++ b/app/src/debug/AndroidManifest.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 47b28cbc..a140432d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,57 +2,33 @@ + + + android:theme="@style/BisqMaterialTheme" + tools:targetApi="34"> + android:exported="true"> - - - - - - + + + + + + + - diff --git a/app/src/main/java/bisq/android/Application.kt b/app/src/main/java/bisq/android/Application.kt index 102854e7..90bc2cfd 100644 --- a/app/src/main/java/bisq/android/Application.kt +++ b/app/src/main/java/bisq/android/Application.kt @@ -17,9 +17,14 @@ package bisq.android +import android.app.ActivityManager +import android.app.NotificationChannel +import android.app.NotificationManager import android.content.Context import android.content.pm.PackageManager +import androidx.appcompat.app.AppCompatDelegate import androidx.multidex.MultiDexApplication +import bisq.android.ui.ThemeProvider class Application : MultiDexApplication() { init { @@ -44,5 +49,32 @@ class Application : MultiDexApplication() { } return version.toString() } + + fun isAppInBackground(): Boolean { + val myProcess = ActivityManager.RunningAppProcessInfo() + ActivityManager.getMyMemoryState(myProcess) + return myProcess.importance != ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND + } + } + + override fun onCreate() { + super.onCreate() + val theme = ThemeProvider(this).getThemeFromPreferences() + AppCompatDelegate.setDefaultNightMode(theme) + + createNotificationChannel() + } + + private fun createNotificationChannel() { + val id = getString(R.string.default_notification_channel_id) + val name = getString(R.string.notification_channel_name) + val descriptionText = getString(R.string.notification_channel_description) + val importance = NotificationManager.IMPORTANCE_DEFAULT + val channel = NotificationChannel(id, name, importance) + .apply { description = descriptionText } + + val notificationManager: NotificationManager = + getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.createNotificationChannel(channel) } } diff --git a/app/src/main/java/bisq/android/database/BisqNotification.kt b/app/src/main/java/bisq/android/database/BisqNotification.kt index 67f1c2d0..23dd70d0 100644 --- a/app/src/main/java/bisq/android/database/BisqNotification.kt +++ b/app/src/main/java/bisq/android/database/BisqNotification.kt @@ -17,7 +17,6 @@ package bisq.android.database -import androidx.annotation.NonNull import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey @@ -27,7 +26,6 @@ import com.google.gson.annotations.SerializedName @Entity data class BisqNotification( @PrimaryKey(autoGenerate = true) - @NonNull @SerializedName("uid") var uid: Int = 0, @@ -68,21 +66,20 @@ data class BisqNotification( var read: Boolean = false ) { override fun equals(other: Any?): Boolean { - return when (other) { - is BisqNotification -> { - this.type == other.type && - this.title == other.title && - this.message == other.message && - this.actionRequired == other.actionRequired && - this.txId == other.txId && - this.sentDate == other.sentDate - } - else -> false - } + if (this === other) return true + if (other !is BisqNotification) return false + return version == other.version && + type == other.type && + title == other.title && + message == other.message && + actionRequired == other.actionRequired && + txId == other.txId && + sentDate == other.sentDate } override fun hashCode(): Int { - return uid + return listOf(version, type, title, message, actionRequired, txId, sentDate) + .hashCode() } override fun toString(): String { diff --git a/app/src/main/java/bisq/android/database/BisqNotificationDao.kt b/app/src/main/java/bisq/android/database/BisqNotificationDao.kt index 29507e6c..fd2af104 100644 --- a/app/src/main/java/bisq/android/database/BisqNotificationDao.kt +++ b/app/src/main/java/bisq/android/database/BisqNotificationDao.kt @@ -37,6 +37,18 @@ interface BisqNotificationDao { @Insert suspend fun insert(bisqNotification: BisqNotification): Long + @Query( + """ + DELETE FROM BisqNotification + WHERE rowid NOT IN ( + SELECT MIN(rowid) + FROM BisqNotification + GROUP BY version, type, title, message, actionRequired, txId, sentDate + ) + """ + ) + suspend fun removeDuplicates() + @Delete suspend fun delete(bisqNotification: BisqNotification) diff --git a/app/src/main/java/bisq/android/database/NotificationRepository.kt b/app/src/main/java/bisq/android/database/NotificationRepository.kt index ff440430..bede2b15 100644 --- a/app/src/main/java/bisq/android/database/NotificationRepository.kt +++ b/app/src/main/java/bisq/android/database/NotificationRepository.kt @@ -37,9 +37,10 @@ class NotificationRepository(context: Context) { suspend fun insert(bisqNotification: BisqNotification) = coroutineScope { launch { - if (bisqNotification !in bisqNotificationDao.getAll()) { - bisqNotificationDao.insert(bisqNotification) - } + bisqNotificationDao.insert(bisqNotification) + // This is a hack to prevent duplicate entries. + // All other attempts at enforcing uniqueness were unsuccessful. + bisqNotificationDao.removeDuplicates() } } diff --git a/app/src/main/java/bisq/android/ext/BroadcastReceiverExt.kt b/app/src/main/java/bisq/android/ext/BroadcastReceiverExt.kt index 5ead4d08..41c861dd 100644 --- a/app/src/main/java/bisq/android/ext/BroadcastReceiverExt.kt +++ b/app/src/main/java/bisq/android/ext/BroadcastReceiverExt.kt @@ -19,9 +19,11 @@ package bisq.android.ext import android.content.BroadcastReceiver import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch +@OptIn(DelicateCoroutinesApi::class) fun BroadcastReceiver.goAsync( coroutineScope: CoroutineScope = GlobalScope, block: suspend () -> Unit @@ -32,7 +34,7 @@ fun BroadcastReceiver.goAsync( block() } finally { // Always call finish(), even if the coroutineScope was cancelled - result.finish() + result?.finish() } } } diff --git a/app/src/main/java/bisq/android/model/Device.kt b/app/src/main/java/bisq/android/model/Device.kt index 068d01e8..9dbfe27a 100644 --- a/app/src/main/java/bisq/android/model/Device.kt +++ b/app/src/main/java/bisq/android/model/Device.kt @@ -50,14 +50,15 @@ class Device private constructor() { var status: DeviceStatus = DeviceStatus.UNPAIRED private object Holder { - var INSTANCE = Device() + val INSTANCE = Device() } fun pairingToken(): String? { - return if (key != null) { - DEVICE_MAGIC_ANDROID + DEVICE_SEPARATOR + descriptor + DEVICE_SEPARATOR + key + - DEVICE_SEPARATOR + token - } else null + return if (token != null) { + DEVICE_MAGIC_ANDROID + DEVICE_SEPARATOR + descriptor + DEVICE_SEPARATOR + key + DEVICE_SEPARATOR + token + } else { + null + } } fun newToken(token: String) { @@ -67,8 +68,7 @@ class Device private constructor() { } fun reset() { - key = null - token = null + key = generateKey() status = DeviceStatus.UNPAIRED } @@ -95,8 +95,7 @@ class Device private constructor() { @Suppress("ThrowsCount") fun fromString(s: String): Boolean { - val a = s.split(DEVICE_SEPARATOR_ESCAPED.toRegex()) - .dropLastWhile { it.isEmpty() } + val a = s.split(DEVICE_SEPARATOR_ESCAPED.toRegex()).dropLastWhile { it.isEmpty() } .toTypedArray() try { if (a.size != PAIRING_TOKEN_SEGMENTS) { @@ -116,8 +115,7 @@ class Device private constructor() { } if (a[PAIRING_TOKEN_MAGIC_INDEX] != DEVICE_MAGIC_ANDROID) { throw IOException( - "Invalid $BISQ_SHARED_PREFERENCE_PAIRING_TOKEN format;" + - " incorrect device magic value" + "Invalid $BISQ_SHARED_PREFERENCE_PAIRING_TOKEN format;" + " incorrect device magic value" ) } key = a[PAIRING_TOKEN_KEY_INDEX] @@ -136,22 +134,33 @@ class Device private constructor() { val model = Build.MODEL return if (model.startsWith(manufacturer)) { model.capitalizeEachWord() - } else manufacturer.capitalizeEachWord() + " " + model + } else { + manufacturer.capitalizeEachWord() + " " + model + } } fun isEmulator(): Boolean { val emulatorHardware = listOf( - "goldfish", "ranchu" + "goldfish", + "ranchu" ) val emulatorModels = listOf( - "google_sdk", "Emulator", "Android SDK built for x86" + "google_sdk", + "Emulator", + "Android SDK built for x86" ) val emulatorManufacturers = listOf( "Genymotion" ) val emulatorProducts = listOf( - "sdk_google", "google_sdk", "sdk", "sdk_x86", "sdk_gphone64_arm64", - "vbox86p", "emulator", "simulator" + "sdk_google", + "google_sdk", + "sdk", + "sdk_x86", + "sdk_gphone64_arm64", + "vbox86p", + "emulator", + "simulator" ) return ( Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic") || diff --git a/app/src/main/java/bisq/android/services/BisqFirebaseMessagingService.kt b/app/src/main/java/bisq/android/services/BisqFirebaseMessagingService.kt index 2d6919d8..b7ff35d3 100644 --- a/app/src/main/java/bisq/android/services/BisqFirebaseMessagingService.kt +++ b/app/src/main/java/bisq/android/services/BisqFirebaseMessagingService.kt @@ -20,12 +20,16 @@ package bisq.android.services import android.content.Context import android.content.Intent import android.util.Log +import bisq.android.Application +import bisq.android.Application.Companion.isAppInBackground import bisq.android.R +import bisq.android.database.BisqNotification import bisq.android.model.Device import bisq.android.model.DeviceStatus +import bisq.android.ui.notification.NotificationSender import bisq.android.ui.welcome.WelcomeActivity import com.google.android.gms.common.ConnectionResult -import com.google.android.gms.common.GoogleApiAvailability +import com.google.android.gms.common.GoogleApiAvailabilityLight import com.google.android.gms.tasks.OnCompleteListener import com.google.firebase.messaging.FirebaseMessaging import com.google.firebase.messaging.FirebaseMessagingService @@ -38,7 +42,7 @@ class BisqFirebaseMessagingService : FirebaseMessagingService() { private var tokenBeingFetched: Boolean = false fun isGooglePlayServicesAvailable(context: Context): Boolean { - val googleApiAvailability = GoogleApiAvailability.getInstance() + val googleApiAvailability = GoogleApiAvailabilityLight.getInstance() val resultCode = googleApiAvailability.isGooglePlayServicesAvailable(context) return resultCode == ConnectionResult.SUCCESS } @@ -128,18 +132,68 @@ class BisqFirebaseMessagingService : FirebaseMessagingService() { } } + /* + * Firebase notifications behave differently depending on the foreground/background state of the receiving app + * and whether the message contains notification data or not. + * For more details, see https://firebase.google.com/docs/cloud-messaging/android/receive. + */ override fun onMessageReceived(remoteMessage: RemoteMessage) { Log.i(TAG, "Message received") super.onMessageReceived(remoteMessage) - val notificationMessage = remoteMessage.data["encrypted"] - if (notificationMessage != null) { - Log.i(TAG, "Broadcasting " + getString(R.string.notification_receiver_action)) + + val encryptedData = remoteMessage.data["encrypted"] + if (encryptedData == null) { + Log.w(TAG, "Message does not contain encrypted data; ${remoteMessage.data}") + return + } + + if (remoteMessage.notification != null) { + // If the message contains notification data, then this method is only called while the app is in + // the foreground. Since the app is running and the NotificationReceiver should be registered, only + // need to broadcast the notification so the NotificationReceiver can process it. + Log.i( + TAG, + "Notification message received, broadcasting " + getString(R.string.notification_receiver_action) + ) Intent().also { broadcastIntent -> broadcastIntent.action = getString(R.string.notification_receiver_action) broadcastIntent.flags = Intent.FLAG_INCLUDE_STOPPED_PACKAGES - broadcastIntent.putExtra("encrypted", notificationMessage) + broadcastIntent.putExtra("encrypted", encryptedData) sendBroadcast(broadcastIntent) } + } else { + // Otherwise, if the message does not contain notification data, then this method is called when the app + // is in the foreground or background. The NotificationReceiver may not be registered if the app is in the + // background, so cannot simply broadcast the notification. Instead, send it directly to the + // NotificationReceiver. + Log.i(TAG, "Data message received") + + Intent().also { notificationIntent -> + notificationIntent.putExtra( + "encrypted", + encryptedData + ) + NotificationReceiver().onReceive(Application.applicationContext(), notificationIntent) + } + + // Since this is a data-only message, will need to show a notification if the app is not in the foreground + if (isAppInBackground()) { + processNotification(encryptedData)?.let { bisqNotification -> + NotificationSender.sendNotification( + bisqNotification.title ?: getString(R.string.you_have_received_notification), + bisqNotification.message + ) + } ?: NotificationSender.sendNotification(getString(R.string.you_have_received_notification), null) + } + } + } + + private fun processNotification(encryptedData: String): BisqNotification? { + return try { + NotificationProcessor.processNotification(encryptedData) + } catch (e: ProcessingException) { + e.message?.let { Log.e(TAG, it) } + null } } @@ -163,7 +217,9 @@ class BisqFirebaseMessagingService : FirebaseMessagingService() { Device.instance.clearPreferences(this) Device.instance.status = DeviceStatus.NEEDS_REPAIR Device.instance.newToken(newToken) - startActivity(Intent(Intent(this, WelcomeActivity::class.java))) + val intent = Intent(this, WelcomeActivity::class.java) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + startActivity(intent) } } } diff --git a/app/src/main/java/bisq/android/services/NotificationHandler.kt b/app/src/main/java/bisq/android/services/NotificationHandler.kt index 8d55e741..368cfa7a 100644 --- a/app/src/main/java/bisq/android/services/NotificationHandler.kt +++ b/app/src/main/java/bisq/android/services/NotificationHandler.kt @@ -38,6 +38,7 @@ object NotificationHandler { when (bisqNotification.type) { NotificationType.SETUP_CONFIRMATION.name -> { + Log.i(TAG, "Setup confirmation") if (Device.instance.token == null) { Log.e(TAG, "Device token is null") return @@ -52,15 +53,14 @@ object NotificationHandler { } Device.instance.status = DeviceStatus.PAIRED Device.instance.saveToPreferences(context) - Log.i(TAG, "Setup confirmed") } NotificationType.ERASE.name -> { + Log.i(TAG, "Erase pairing") Device.instance.reset() Device.instance.clearPreferences(context) notificationRepository.deleteAll() Device.instance.status = DeviceStatus.REMOTE_ERASED refreshFcmToken() - Log.i(TAG, "Pairing erased") } else -> { Log.i(TAG, "Inserting ${bisqNotification.type} notification to repository") diff --git a/app/src/main/java/bisq/android/services/NotificationProcessor.kt b/app/src/main/java/bisq/android/services/NotificationProcessor.kt index 21694579..f7869955 100644 --- a/app/src/main/java/bisq/android/services/NotificationProcessor.kt +++ b/app/src/main/java/bisq/android/services/NotificationProcessor.kt @@ -40,7 +40,8 @@ object NotificationProcessor { try { val notificationMessage = parseNotificationContent(notificationContent) val decryptedNotificationPayload = decryptNotificationPayload( - notificationMessage.encryptedPayload, notificationMessage.initializationVector + notificationMessage.encryptedPayload, + notificationMessage.initializationVector ) val bisqNotification = deserializeNotificationPayload(decryptedNotificationPayload) bisqNotification.receivedDate = Date().time @@ -69,7 +70,7 @@ object NotificationProcessor { val initializationVector = array[1] val encryptedPayload = array[2] if (magicValue != BISQ_MESSAGE_ANDROID_MAGIC) { - throw ParseException("Invalid magic value", 0) + throw ParseException("Invalid magic value [$magicValue]", 0) } if (initializationVector.length != CryptoUtil.IV_LENGTH) { throw ParseException( @@ -85,11 +86,10 @@ object NotificationProcessor { fun decryptNotificationPayload(encryptedPayload: String, initializationVector: String): String { @Suppress("TooGenericExceptionCaught") try { - if (Device.instance.key == null) { - throw IllegalStateException("Device key is null") - } + checkNotNull(Device.instance.key) { "Device key is null" } return CryptoUtil(Device.instance.key!!).decrypt( - encryptedPayload, initializationVector + encryptedPayload, + initializationVector ) } catch (e: Throwable) { when (e) { diff --git a/app/src/main/java/bisq/android/services/NotificationReceiver.kt b/app/src/main/java/bisq/android/services/NotificationReceiver.kt index 9ff3c4eb..6657646e 100644 --- a/app/src/main/java/bisq/android/services/NotificationReceiver.kt +++ b/app/src/main/java/bisq/android/services/NotificationReceiver.kt @@ -43,7 +43,7 @@ class NotificationReceiver : BroadcastReceiver() { val bisqNotification: BisqNotification try { bisqNotification = NotificationProcessor.processNotification( - intent.extras?.get("encrypted").toString() + intent.extras?.getString("encrypted").toString() ) } catch (e: ProcessingException) { e.message?.let { Log.e(TAG, it) } diff --git a/app/src/main/java/bisq/android/ui/BaseActivity.kt b/app/src/main/java/bisq/android/ui/BaseActivity.kt index 394bce1d..d94ee455 100644 --- a/app/src/main/java/bisq/android/ui/BaseActivity.kt +++ b/app/src/main/java/bisq/android/ui/BaseActivity.kt @@ -18,14 +18,13 @@ package bisq.android.ui import android.app.Activity -import android.content.ActivityNotFoundException -import android.content.Intent import android.content.IntentFilter +import android.media.MediaPlayer import android.media.RingtoneManager -import android.net.Uri +import android.os.Build +import android.os.Bundle import android.util.Log import android.view.View -import android.widget.Toast import androidx.annotation.IdRes import androidx.appcompat.app.AppCompatActivity import bisq.android.R @@ -41,37 +40,54 @@ open class BaseActivity : AppCompatActivity() { private var intentReceiver: IntentReceiver? = null fun Activity.bind(@IdRes res: Int): T { - @Suppress("UNCHECKED_CAST") return findViewById(res) } - override fun onStart() { - super.onStart() - registerIntentReceiver() + override fun onCreate(savedInstanceState: Bundle?) { + Log.d(TAG, "onCreate ${this::class.simpleName}") + super.onCreate(savedInstanceState) + registerNotificationReceiver() + } + + override fun onDestroy() { + Log.d(TAG, "onDestroy ${this::class.simpleName}") + super.onDestroy() + unregisterNotificationReceiver() } - override fun onStop() { - super.onStop() + override fun onPause() { + Log.d(TAG, "onPause ${this::class.simpleName}") + super.onPause() unregisterIntentReceiver() } - protected fun registerNotificationReceiver() { - Log.i(TAG, "Registering notification receiver") + override fun onResume() { + Log.d(TAG, "onResume ${this::class.simpleName}") + super.onResume() + registerIntentReceiver() + } + + private fun registerNotificationReceiver() { + Log.d(TAG, "Registering notification receiver for ${this::class.simpleName}") if (notificationReceiver != null) { - Log.i(TAG, "Notification receiver already registered") + Log.d(TAG, "Notification receiver already registered") return } notificationReceiver = NotificationReceiver() val filter = IntentFilter() filter.addAction(getString(R.string.notification_receiver_action)) - registerReceiver(notificationReceiver, filter) - Log.i(TAG, "Notification receiver registered") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + registerReceiver(notificationReceiver, filter, RECEIVER_EXPORTED) + } else { + registerReceiver(notificationReceiver, filter) + } + Log.d(TAG, "Notification receiver registered for ${this::class.simpleName}") } - protected fun unregisterNotificationReceiver() { - Log.i(TAG, "Unregistering notification receiver") + private fun unregisterNotificationReceiver() { + Log.d(TAG, "Unregistering notification receiver for ${this::class.simpleName}") if (notificationReceiver == null) { - Log.i(TAG, "Notification receiver already unregistered") + Log.d(TAG, "Notification receiver already unregistered") return } try { @@ -80,26 +96,30 @@ open class BaseActivity : AppCompatActivity() { // Receiver not registered, do nothing } notificationReceiver = null - Log.i(TAG, "Notification receiver unregistered") + Log.d(TAG, "Notification receiver unregistered for ${this::class.simpleName}") } - protected fun registerIntentReceiver() { - Log.i(TAG, "Registering intent receiver") + private fun registerIntentReceiver() { + Log.d(TAG, "Registering intent receiver for ${this::class.simpleName}") if (intentReceiver != null) { - Log.i(TAG, "Intent receiver already registered") + Log.d(TAG, "Intent receiver already registered") return } intentReceiver = IntentReceiver(this) val filter = IntentFilter() filter.addAction(getString(R.string.intent_receiver_action)) - registerReceiver(intentReceiver, filter) - Log.i(TAG, "Intent receiver registered") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + registerReceiver(intentReceiver, filter, RECEIVER_EXPORTED) + } else { + registerReceiver(intentReceiver, filter) + } + Log.d(TAG, "Intent receiver registered for ${this::class.simpleName}") } - protected fun unregisterIntentReceiver() { - Log.i(TAG, "Unregistering intent receiver") + private fun unregisterIntentReceiver() { + Log.d(TAG, "Unregistering intent receiver for ${this::class.simpleName}") if (intentReceiver == null) { - Log.i(TAG, "Intent receiver already unregistered") + Log.d(TAG, "Intent receiver already unregistered") return } try { @@ -108,7 +128,7 @@ open class BaseActivity : AppCompatActivity() { // Receiver not registered, do nothing } intentReceiver = null - Log.i(TAG, "Intent receiver unregistered") + Log.d(TAG, "Intent receiver unregistered for ${this::class.simpleName}") } protected fun playTone() { @@ -116,25 +136,9 @@ open class BaseActivity : AppCompatActivity() { try { val notificationTone = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION) - RingtoneManager.getRingtone(applicationContext, notificationTone).play() + MediaPlayer.create(applicationContext, notificationTone).start() } catch (e: Exception) { Log.e(TAG, "Unable to play notification tone", e) } } - - protected fun loadWebPage(uri: String) { - DialogBuilder.choicePrompt( - this, getString(R.string.warning), getString(R.string.load_web_page_text, uri), - getString(R.string.yes), getString(R.string.no), - { _, _ -> - try { - startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(uri))) - } catch (ignored: ActivityNotFoundException) { - Toast.makeText( - this, getString(R.string.cannot_launch_browser), Toast.LENGTH_LONG - ).show() - } - } - ).show() - } } diff --git a/app/src/main/java/bisq/android/ui/DialogBuilder.kt b/app/src/main/java/bisq/android/ui/DialogBuilder.kt index a04d0e11..37205847 100644 --- a/app/src/main/java/bisq/android/ui/DialogBuilder.kt +++ b/app/src/main/java/bisq/android/ui/DialogBuilder.kt @@ -39,11 +39,12 @@ object DialogBuilder { val builder = AlertDialog.Builder(context) builder.setTitle(title) builder.setMessage(message) - builder.setCancelable(false) + builder.setCancelable(true) builder.setPositiveButton(positiveButtonText, positiveActionListener) if (negativeActionListener != null) { builder.setNegativeButton( - negativeButtonText, negativeActionListener + negativeButtonText, + negativeActionListener ) } else { builder.setNegativeButton( @@ -75,10 +76,11 @@ object DialogBuilder { val builder = AlertDialog.Builder(context) builder.setTitle(title) builder.setMessage(message) - builder.setCancelable(false) + builder.setCancelable(true) if (actionListener != null) { builder.setPositiveButton( - buttonText, actionListener + buttonText, + actionListener ) } else { builder.setPositiveButton( diff --git a/app/src/main/java/bisq/android/ui/PairedBaseActivity.kt b/app/src/main/java/bisq/android/ui/PairedBaseActivity.kt index e887c834..9dd8060b 100644 --- a/app/src/main/java/bisq/android/ui/PairedBaseActivity.kt +++ b/app/src/main/java/bisq/android/ui/PairedBaseActivity.kt @@ -28,7 +28,9 @@ open class PairedBaseActivity : BaseActivity() { override fun onStart() { super.onStart() if (Device.instance.status != DeviceStatus.PAIRED) { - startActivity(Intent(this, WelcomeActivity::class.java)) + val intent = Intent(this, WelcomeActivity::class.java) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + startActivity(intent) } } @@ -36,9 +38,13 @@ open class PairedBaseActivity : BaseActivity() { this.runOnUiThread { playTone() Toast.makeText( - this, toastMessage, Toast.LENGTH_LONG + this, + toastMessage, + Toast.LENGTH_LONG ).show() - startActivity(Intent(Intent(this, WelcomeActivity::class.java))) + val intent = Intent(this, WelcomeActivity::class.java) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + startActivity(intent) } } } diff --git a/app/src/main/java/bisq/android/ui/ThemeProvider.kt b/app/src/main/java/bisq/android/ui/ThemeProvider.kt new file mode 100644 index 00000000..3ed8f026 --- /dev/null +++ b/app/src/main/java/bisq/android/ui/ThemeProvider.kt @@ -0,0 +1,57 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.android.ui + +import android.app.UiModeManager +import android.content.Context +import androidx.appcompat.app.AppCompatDelegate +import androidx.preference.PreferenceManager +import bisq.android.R +import java.security.InvalidParameterException + +class ThemeProvider(private val context: Context) { + fun getThemeFromPreferences(): Int { + val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context) + val selectedTheme = sharedPreferences.getString( + context.getString(R.string.theme_preferences_key), + context.getString(R.string.system_theme_preference_value) + ) + + return selectedTheme?.let { + getTheme(it) + } ?: AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM + } + + fun getThemeDescriptionForPreference(preferenceValue: String?): String = + when (preferenceValue) { + context.getString(R.string.dark_theme_preference_value) -> + context.getString(R.string.dark_theme_description) + + context.getString(R.string.light_theme_preference_value) -> + context.getString(R.string.light_theme_description) + + else -> context.getString(R.string.system_theme_description) + } + + fun getTheme(selectedTheme: String): Int = when (selectedTheme) { + context.getString(R.string.dark_theme_preference_value) -> UiModeManager.MODE_NIGHT_YES + context.getString(R.string.light_theme_preference_value) -> UiModeManager.MODE_NIGHT_NO + context.getString(R.string.system_theme_preference_value) -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM + else -> throw InvalidParameterException("Theme not defined for $selectedTheme") + } +} diff --git a/app/src/main/java/bisq/android/ui/UiUtil.kt b/app/src/main/java/bisq/android/ui/UiUtil.kt new file mode 100644 index 00000000..0f3d910c --- /dev/null +++ b/app/src/main/java/bisq/android/ui/UiUtil.kt @@ -0,0 +1,48 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.android.ui + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.widget.Toast +import bisq.android.R + +object UiUtil { + fun loadWebPage(context: Context, uri: String) { + DialogBuilder.choicePrompt( + context, + context.getString(R.string.confirm), + context.getString(R.string.load_web_page_confirmation, uri), + context.getString(R.string.yes), + context.getString(R.string.no), + { _, _ -> + try { + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(uri))) + } catch (ignored: ActivityNotFoundException) { + Toast.makeText( + context, + context.getString(R.string.cannot_launch_browser), + Toast.LENGTH_LONG + ).show() + } + } + ).show() + } +} diff --git a/app/src/main/java/bisq/android/ui/UnpairedBaseActivity.kt b/app/src/main/java/bisq/android/ui/UnpairedBaseActivity.kt index dfd871b5..7807a828 100644 --- a/app/src/main/java/bisq/android/ui/UnpairedBaseActivity.kt +++ b/app/src/main/java/bisq/android/ui/UnpairedBaseActivity.kt @@ -25,7 +25,9 @@ open class UnpairedBaseActivity : BaseActivity() { fun pairingConfirmed() { this.runOnUiThread { playTone() - startActivity(Intent(Intent(this, PairingSuccessActivity::class.java))) } + val intent = Intent(this, PairingSuccessActivity::class.java) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + startActivity(intent) } } diff --git a/app/src/main/java/bisq/android/ui/notification/NotificationAdapter.kt b/app/src/main/java/bisq/android/ui/notification/NotificationAdapter.kt index 900596be..2581a735 100644 --- a/app/src/main/java/bisq/android/ui/notification/NotificationAdapter.kt +++ b/app/src/main/java/bisq/android/ui/notification/NotificationAdapter.kt @@ -36,7 +36,7 @@ class NotificationAdapter( companion object { var iconTypeface: Typeface? = null - var iconTextSize: Float = 33F + var iconTextSize: Float = 28F } override fun onBindViewHolder(holder: NotificationViewHolder, position: Int) { @@ -52,7 +52,7 @@ class NotificationAdapter( } holder.title.text = notification.title - holder.time.text = DateUtil.format(notification.sentDate) + holder.time.text = if (notification.sentDate > 0) DateUtil.format(notification.sentDate) else "" holder.read = notification.read if (notification.read) { holder.icon.setTextColor( @@ -63,16 +63,33 @@ class NotificationAdapter( ) holder.title.setTextColor( ContextCompat.getColor( - holder.icon.context, - R.color.read_title + holder.title.context, + R.color.read_notification + ) + ) + holder.time.setTextColor( + ContextCompat.getColor( + holder.time.context, + R.color.read_notification ) ) } else { - holder.icon.setTextColor(ContextCompat.getColor(holder.icon.context, R.color.primary)) - holder.title.setTextColor( + holder.icon.setTextColor( ContextCompat.getColor( holder.icon.context, - R.color.unread_title + R.color.primary + ) + ) + holder.title.setTextColor( + ContextCompat.getColor( + holder.title.context, + R.color.unread_notification + ) + ) + holder.time.setTextColor( + ContextCompat.getColor( + holder.time.context, + R.color.unread_notification ) ) } diff --git a/app/src/main/java/bisq/android/ui/notification/NotificationDetailActivity.kt b/app/src/main/java/bisq/android/ui/notification/NotificationDetailActivity.kt index e8ae5b43..4c211d32 100644 --- a/app/src/main/java/bisq/android/ui/notification/NotificationDetailActivity.kt +++ b/app/src/main/java/bisq/android/ui/notification/NotificationDetailActivity.kt @@ -19,6 +19,7 @@ package bisq.android.ui.notification import android.os.Bundle import android.view.View +import android.widget.Button import android.widget.TextView import androidx.lifecycle.ViewModelProvider import bisq.android.R @@ -34,6 +35,7 @@ class NotificationDetailActivity : PairedBaseActivity() { private lateinit var action: TextView private lateinit var eventTime: TextView private lateinit var receivedTime: TextView + private lateinit var deleteButton: Button override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -56,11 +58,16 @@ class NotificationDetailActivity : PairedBaseActivity() { private fun initView() { setContentView(R.layout.activity_notification_detail) - title = bind(R.id.detail_title) - message = bind(R.id.detail_message) - action = bind(R.id.detail_action) - eventTime = bind(R.id.detail_event_time) - receivedTime = bind(R.id.detail_received_time) + title = bind(R.id.notification_detail_title) + message = bind(R.id.notification_detail_message) + action = bind(R.id.notification_detail_action) + eventTime = bind(R.id.notification_detail_event_time) + receivedTime = bind(R.id.notification_detail_received_time) + deleteButton = bind(R.id.notification_delete_button) + deleteButton.setOnClickListener { + getNotification()?.let { notification -> viewModel.delete(notification) } + finish() + } } private fun updateView(notification: BisqNotification) { @@ -78,9 +85,15 @@ class NotificationDetailActivity : PairedBaseActivity() { action.visibility = View.GONE } - eventTime.text = + eventTime.text = if (notification.sentDate > 0) { getString(R.string.event_occurred_at, DateUtil.format(notification.sentDate)) - receivedTime.text = + } else { + "" + } + receivedTime.text = if (notification.receivedDate > 0) { getString(R.string.event_received_at, DateUtil.format(notification.receivedDate)) + } else { + "" + } } } diff --git a/app/src/main/java/bisq/android/ui/notification/NotificationSender.kt b/app/src/main/java/bisq/android/ui/notification/NotificationSender.kt new file mode 100644 index 00000000..d7d7ed9e --- /dev/null +++ b/app/src/main/java/bisq/android/ui/notification/NotificationSender.kt @@ -0,0 +1,116 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.android.ui.notification + +import android.Manifest +import android.app.PendingIntent +import android.content.Intent +import android.content.pm.PackageManager +import android.media.RingtoneManager +import android.util.Log +import androidx.core.app.ActivityCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import bisq.android.Application +import bisq.android.R +import java.util.Date + +object NotificationSender { + private const val TAG = "NotificationSender" + private const val SUMMARY_NOTIFICATION_TAG = "summary" + + fun sendNotification(contentTitle: String, contentText: String?) { + val context = Application.applicationContext() + + if (ActivityCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS + ) != PackageManager.PERMISSION_GRANTED + ) { + Log.d(TAG, "*** Unable to send notification; POST_NOTIFICATIONS permission not granted") + return + } + + val intent = Intent(context, NotificationTableActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_CLEAR_TOP + } + val requestCode = 0 + val pendingIntent: PendingIntent = PendingIntent.getActivity( + context, + requestCode, + intent, + PendingIntent.FLAG_IMMUTABLE + ) + + val defaultSoundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION) + val groupKey = context.getString(R.string.default_notification_group_key) + val channelId = context.getString(R.string.default_notification_channel_id) + val notificationManager = NotificationManagerCompat.from(context) + + val notification = NotificationCompat.Builder(context, channelId) + .setSmallIcon(R.drawable.bisq_mark) + .setContentTitle(contentTitle) + .setContentText(contentText) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setContentIntent(pendingIntent) + .setAutoCancel(true) + .setSound(defaultSoundUri) + .setVisibility(NotificationCompat.VISIBILITY_PRIVATE) + .setGroup(groupKey) + .build() + + val summaryNotification = NotificationCompat.Builder(context, channelId) + .setSmallIcon(R.drawable.bisq_mark) + .setGroup(groupKey) + .setGroupSummary(true) + .build() + + // Get the current active notifications, other than summary notification + val activeNotifications = notificationManager.activeNotifications.filter { + it.groupKey.endsWith(groupKey) && it.tag != SUMMARY_NOTIFICATION_TAG + } + + // Post the summary notification only if there are already previous notifications. + // In other words, only if there are multiple notifications shown will they be grouped. + if (activeNotifications.isNotEmpty()) { + // The summary notification tag/id pair must stay the same so that it's only posted once + notificationManager.notify(SUMMARY_NOTIFICATION_TAG, 0, summaryNotification) + + // Previous notifications must be updated to prevent an issue where the first notification + // received is not included in the summary group, while subsequent notifications are included. + // So as to prevent unnecessary updates, this only needs to be done if there are 2 or less + // active notifications. + if (activeNotifications.size <= 2) { + activeNotifications.forEach { activeNotification -> + notificationManager.cancel(activeNotification.tag, activeNotification.id) + notificationManager.notify( + activeNotification.tag, + activeNotification.id, + activeNotification.notification + ) + } + } + } + + // Post the new notification. + // Each notification must have a unique id or combination of tag/id pair. + // Using the current timestamp down to the millisecond should ensure uniqueness. + // Since the id only accepts an int, which does not have enough resolution, use the timestamp as the tag. + notificationManager.notify(Date().time.toString(), activeNotifications.size + 1, notification) + } +} diff --git a/app/src/main/java/bisq/android/ui/notification/NotificationTableActivity.kt b/app/src/main/java/bisq/android/ui/notification/NotificationTableActivity.kt index 53840b9f..7626e31f 100644 --- a/app/src/main/java/bisq/android/ui/notification/NotificationTableActivity.kt +++ b/app/src/main/java/bisq/android/ui/notification/NotificationTableActivity.kt @@ -29,15 +29,22 @@ import android.view.Menu import android.view.MenuItem import android.view.View import androidx.appcompat.widget.Toolbar +import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat +import androidx.core.view.MenuCompat import androidx.lifecycle.ViewModelProvider import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView +import bisq.android.Application import bisq.android.R import bisq.android.database.BisqNotification +import bisq.android.model.Device +import bisq.android.model.NotificationType +import bisq.android.ui.DialogBuilder import bisq.android.ui.PairedBaseActivity import bisq.android.ui.settings.SettingsActivity +import java.util.Date @Suppress("TooManyFunctions") class NotificationTableActivity : PairedBaseActivity() { @@ -64,15 +71,30 @@ class NotificationTableActivity : PairedBaseActivity() { bisqNotifications!! ) } + clearDataNotifications() + } + + private fun clearDataNotifications() { + val context = Application.applicationContext() + val notificationManager = NotificationManagerCompat.from(context) + val groupKey = context.getString(R.string.default_notification_group_key) + + notificationManager.apply { + activeNotifications.filter { + it.groupKey.endsWith(groupKey) + }.forEach { notification -> + cancel(notification.tag, notification.id) + } + } } private fun initView() { setContentView(R.layout.activity_notification_table) - toolbar = bind(R.id.bisq_toolbar) + toolbar = bind(R.id.notification_table_toolbar) setSupportActionBar(toolbar) - recyclerView = bind(R.id.notification_recycler_view) + recyclerView = bind(R.id.notification_table_recycler_view) recyclerView.layoutManager = LinearLayoutManager(this) val swipeHandler = object : SwipeToDeleteCallback(this) { @@ -105,22 +127,50 @@ class NotificationTableActivity : PairedBaseActivity() { } } - override fun onBackPressed() { - val a = Intent(Intent.ACTION_MAIN) - a.addCategory(Intent.CATEGORY_HOME) - a.flags = Intent.FLAG_ACTIVITY_NEW_TASK - startActivity(a) - } - override fun onCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.menu, menu) + MenuCompat.setGroupDividerEnabled(menu, true) + if (Device.instance.isEmulator()) { + menu.setGroupVisible(R.id.debug, true) + } else { + menu.setGroupVisible(R.id.debug, false) + } + viewModel.bisqNotifications.observe(this) { bisqNotifications -> + if (bisqNotifications.isEmpty()) { + menu.setGroupEnabled(R.id.notifications, false) + } else { + menu.setGroupEnabled(R.id.notifications, true) + } + } return true } override fun onOptionsItemSelected(item: MenuItem): Boolean { - val id = item.itemId - if (id == R.id.action_settings) { - startActivity(Intent(this, SettingsActivity::class.java)) + when (item.itemId) { + R.id.action_add_example_notifications -> { + addExampleNotifications() + } + + R.id.action_mark_all_read -> { + viewModel.markAllAsRead() + } + + R.id.action_delete_all -> { + DialogBuilder.choicePrompt( + this, + getString(R.string.confirm), + getString(R.string.delete_all_notifications_confirmation), + getString(R.string.yes), + getString(R.string.no), + { _, _ -> + viewModel.nukeTable() + } + ).show() + } + + R.id.action_settings -> { + startActivity(Intent(this, SettingsActivity::class.java)) + } } return true } @@ -139,6 +189,54 @@ class NotificationTableActivity : PairedBaseActivity() { scrollFirstVisibleItemPosition = llm.findFirstVisibleItemPosition() scrollLastVisibleItemPosition = llm.findLastVisibleItemPosition() } + + @Suppress("MagicNumber") + private fun addExampleNotifications() { + for (counter in 1..5) { + val now = Date() + val bisqNotification = BisqNotification() + bisqNotification.receivedDate = now.time + counter * 1000 + bisqNotification.sentDate = bisqNotification.receivedDate - 1000 * 30 + when (counter) { + 1 -> { + bisqNotification.type = NotificationType.TRADE.name + bisqNotification.title = "Trade confirmed" + bisqNotification.message = "The trade with ID 38765384 is confirmed." + } + + 2 -> { + bisqNotification.type = NotificationType.OFFER.name + bisqNotification.title = "Offer taken" + bisqNotification.message = "Your offer with ID 39847534 was taken" + } + + 3 -> { + bisqNotification.type = NotificationType.DISPUTE.name + bisqNotification.title = "Dispute message" + bisqNotification.actionRequired = "Please contact the arbitrator" + bisqNotification.message = + "You received a dispute message for trade with ID 34059340" + bisqNotification.txId = "34059340" + } + + 4 -> { + bisqNotification.type = NotificationType.PRICE.name + bisqNotification.title = "Price alert for United States Dollar" + bisqNotification.message = "Your price alert got triggered. The current" + + " United States Dollar price is 35351.08 BTC/USD" + } + + 5 -> { + bisqNotification.type = NotificationType.MARKET.name + bisqNotification.title = "New offer" + bisqNotification.message = "A new offer with price 36000 USD" + + " (1% above market price) and payment method Zelle was published to" + + " the Bisq offerbook.\nThe offer ID is 34534" + } + } + viewModel.insert(bisqNotification) + } + } } abstract class SwipeToDeleteCallback(context: NotificationTableActivity) : diff --git a/app/src/main/java/bisq/android/ui/pairing/PairingScanActivity.kt b/app/src/main/java/bisq/android/ui/pairing/PairingScanActivity.kt index 578972b3..2e33aeba 100644 --- a/app/src/main/java/bisq/android/ui/pairing/PairingScanActivity.kt +++ b/app/src/main/java/bisq/android/ui/pairing/PairingScanActivity.kt @@ -21,7 +21,6 @@ import android.content.Intent import android.os.Bundle import android.os.Handler import android.os.Looper -import android.util.Log import android.view.View import android.widget.Button import android.widget.ImageView @@ -34,42 +33,36 @@ import bisq.android.ui.UnpairedBaseActivity import bisq.android.util.QrUtil class PairingScanActivity : UnpairedBaseActivity() { - private lateinit var qrImage: ImageView - private lateinit var qrText: TextView + private lateinit var qrPlaceholderText: TextView private lateinit var noWebcamButton: Button private lateinit var simulatePairingButton: Button private val mainHandler = Handler(Looper.getMainLooper()) - companion object { - private const val TAG = "PairingScanActivity" - } - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) initView() - Log.i(TAG, "Pairing token: ${Device.instance.pairingToken()}") } private fun initView() { setContentView(R.layout.activity_pairing_scan) - qrImage = this.bind(R.id.qrImageView) + qrImage = this.bind(R.id.pairing_scan_qr_image) - qrText = this.bind(R.id.qrTextView) + qrPlaceholderText = this.bind(R.id.pairing_scan_qr_placeholder) - noWebcamButton = bind(R.id.noWebcamButton) + noWebcamButton = bind(R.id.pairing_scan_no_webcam_button) noWebcamButton.setOnClickListener { - onNoWebcamButtonClick() + onNoWebcam() } - simulatePairingButton = bind(R.id.simulatePairingButton) - if (Device.instance.isEmulator()) { + simulatePairingButton = bind(R.id.pairing_scan_simulate_pairing_button) + if (Device.instance.isEmulator() && Device.instance.status != DeviceStatus.PAIRED) { simulatePairingButton.visibility = View.VISIBLE } simulatePairingButton.setOnClickListener { - onSimulatePairingButtonClick() + onSimulatePairing() } mainHandler.post { @@ -80,21 +73,22 @@ class PairingScanActivity : UnpairedBaseActivity() { try { val bmp = QrUtil.createQrImage(Device.instance.pairingToken()!!) qrImage.setImageBitmap(bmp) - qrText.visibility = View.INVISIBLE + qrPlaceholderText.visibility = View.INVISIBLE } catch (ignored: Exception) { Toast.makeText( - this, getString(R.string.cannot_generate_qr_code), + this, + getString(R.string.cannot_generate_qr_code), Toast.LENGTH_LONG ).show() } } } - private fun onNoWebcamButtonClick() { + private fun onNoWebcam() { startActivity(Intent(Intent(this, PairingSendActivity::class.java))) } - private fun onSimulatePairingButtonClick() { + private fun onSimulatePairing() { Device.instance.status = DeviceStatus.PAIRED Device.instance.saveToPreferences(this) pairingConfirmed() diff --git a/app/src/main/java/bisq/android/ui/pairing/PairingSendActivity.kt b/app/src/main/java/bisq/android/ui/pairing/PairingSendActivity.kt index a09dcb09..8123328b 100644 --- a/app/src/main/java/bisq/android/ui/pairing/PairingSendActivity.kt +++ b/app/src/main/java/bisq/android/ui/pairing/PairingSendActivity.kt @@ -38,19 +38,21 @@ class PairingSendActivity : UnpairedBaseActivity() { private fun initView() { setContentView(R.layout.activity_pairing_send) - sendPairingTokenInstructions = bind(R.id.sendPairingTokenInstructions) + sendPairingTokenInstructions = bind(R.id.pairing_send_pairing_token_instructions) - sendPairingTokenButton = bind(R.id.sendPairingTokenButton) + sendPairingTokenButton = bind(R.id.pairing_send_pairing_token_button) sendPairingTokenButton.setOnClickListener { - onSendPairingTokenButtonClick() + onSendPairingToken() } } - private fun onSendPairingTokenButtonClick() { - val intent = Intent(Intent.ACTION_SEND) - intent.type = "text/html" - intent.putExtra(Intent.EXTRA_SUBJECT, getString(R.string.send_pairing_subject)) - intent.putExtra(Intent.EXTRA_TEXT, Device.instance.pairingToken()) - startActivity(Intent.createChooser(intent, getString(R.string.send_pairing_token))) + private fun onSendPairingToken() { + val sendIntent: Intent = Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra(Intent.EXTRA_SUBJECT, getString(R.string.send_pairing_subject)) + putExtra(Intent.EXTRA_TEXT, Device.instance.pairingToken()) + } + startActivity(Intent.createChooser(sendIntent, getString(R.string.send_pairing_token))) } } diff --git a/app/src/main/java/bisq/android/ui/pairing/PairingSuccessActivity.kt b/app/src/main/java/bisq/android/ui/pairing/PairingSuccessActivity.kt index 45ae8cac..b298511d 100644 --- a/app/src/main/java/bisq/android/ui/pairing/PairingSuccessActivity.kt +++ b/app/src/main/java/bisq/android/ui/pairing/PairingSuccessActivity.kt @@ -17,14 +17,22 @@ package bisq.android.ui.pairing +import android.Manifest import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build import android.os.Bundle import android.widget.Button +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat import bisq.android.R import bisq.android.ui.PairedBaseActivity import bisq.android.ui.notification.NotificationTableActivity class PairingSuccessActivity : PairedBaseActivity() { + companion object { + private const val NOTIFICATION_PERMISSION_REQUEST_CODE = 1 + } private lateinit var pairingCompleteButton: Button @@ -36,13 +44,53 @@ class PairingSuccessActivity : PairedBaseActivity() { private fun initView() { setContentView(R.layout.activity_pairing_success) - pairingCompleteButton = bind(R.id.pairing_complete_button) + pairingCompleteButton = bind(R.id.pairing_scan_pairing_complete_button) pairingCompleteButton.setOnClickListener { - onPairingCompleteButtonClick() + onPairingComplete() } } - private fun onPairingCompleteButtonClick() { - startActivity(Intent(Intent(this, NotificationTableActivity::class.java))) + private fun onPairingComplete() { + when { + ContextCompat.checkSelfPermission( + this, + Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED -> { + val intent = Intent(this, NotificationTableActivity::class.java) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + startActivity(intent) + } + + ActivityCompat.shouldShowRequestPermissionRationale( + this, + Manifest.permission.POST_NOTIFICATIONS + ) -> { + startActivity(Intent(Intent(this, RequestNotificationPermissionActivity::class.java))) + } + + else -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + requestPermissions( + arrayOf(Manifest.permission.POST_NOTIFICATIONS), + NOTIFICATION_PERMISSION_REQUEST_CODE + ) + } + } + } + } + + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + when (requestCode) { + NOTIFICATION_PERMISSION_REQUEST_CODE -> { + // If request is cancelled, the result arrays are empty + if (grantResults.isNotEmpty()) { + val intent = Intent(this, NotificationTableActivity::class.java) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + startActivity(intent) + } + return + } + } } } diff --git a/app/src/main/java/bisq/android/ui/pairing/RequestNotificationPermissionActivity.kt b/app/src/main/java/bisq/android/ui/pairing/RequestNotificationPermissionActivity.kt new file mode 100644 index 00000000..abacfbc4 --- /dev/null +++ b/app/src/main/java/bisq/android/ui/pairing/RequestNotificationPermissionActivity.kt @@ -0,0 +1,87 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.android.ui.pairing + +import android.Manifest +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.widget.Button +import androidx.core.app.ActivityCompat +import bisq.android.R +import bisq.android.ui.PairedBaseActivity +import bisq.android.ui.notification.NotificationTableActivity + +class RequestNotificationPermissionActivity : PairedBaseActivity() { + companion object { + private const val NOTIFICATION_PERMISSION_REQUEST_CODE = 1 + } + + private lateinit var requestNotificationPermissionButton: Button + private lateinit var skipPermissionButton: Button + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + initView() + } + + private fun initView() { + setContentView(R.layout.activity_request_notification_permission) + + requestNotificationPermissionButton = bind(R.id.request_notification_permission_button) + requestNotificationPermissionButton.setOnClickListener { + onRequestNotificationPermission() + } + + skipPermissionButton = bind(R.id.skip_request_notification_permission_button) + skipPermissionButton.setOnClickListener { + val intent = Intent(this, NotificationTableActivity::class.java) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + startActivity(intent) + } + } + + private fun onRequestNotificationPermission() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + ActivityCompat.requestPermissions( + this, + arrayOf(Manifest.permission.POST_NOTIFICATIONS), + NOTIFICATION_PERMISSION_REQUEST_CODE + ) + } else { + val intent = Intent(this, NotificationTableActivity::class.java) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + startActivity(intent) + } + } + + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + when (requestCode) { + NOTIFICATION_PERMISSION_REQUEST_CODE -> { + // If request is cancelled, the result arrays are empty + if (grantResults.isNotEmpty()) { + val intent = Intent(this, NotificationTableActivity::class.java) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + startActivity(intent) + } + return + } + } + } +} diff --git a/app/src/main/java/bisq/android/ui/settings/SettingsActivity.kt b/app/src/main/java/bisq/android/ui/settings/SettingsActivity.kt index a7710c72..4992b60c 100644 --- a/app/src/main/java/bisq/android/ui/settings/SettingsActivity.kt +++ b/app/src/main/java/bisq/android/ui/settings/SettingsActivity.kt @@ -17,171 +17,18 @@ package bisq.android.ui.settings -import android.content.Intent import android.os.Bundle -import android.view.View -import android.widget.Button -import android.widget.TextView -import androidx.lifecycle.ViewModelProvider -import bisq.android.Application -import bisq.android.BISQ_MOBILE_URL -import bisq.android.BISQ_NETWORK_URL import bisq.android.R -import bisq.android.database.BisqNotification -import bisq.android.model.Device -import bisq.android.model.DeviceStatus -import bisq.android.model.NotificationType -import bisq.android.services.BisqFirebaseMessagingService.Companion.refreshFcmToken import bisq.android.ui.PairedBaseActivity -import bisq.android.ui.notification.NotificationViewModel -import bisq.android.ui.welcome.WelcomeActivity -import bisq.android.util.TextUtil.truncateSensitiveText -import java.util.Date class SettingsActivity : PairedBaseActivity() { - private lateinit var viewModel: NotificationViewModel - private lateinit var registerAgainButton: Button - private lateinit var deleteAllNotificationsButton: Button - private lateinit var markAllAsReadButton: Button - private lateinit var addExampleNotificationsButton: Button - private lateinit var aboutBisqButton: Button - private lateinit var aboutAppButton: Button - private lateinit var versionTextView: TextView - private lateinit var keyTextView: TextView - private lateinit var tokenTextView: TextView - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - viewModel = ViewModelProvider(this)[NotificationViewModel::class.java] initView() } private fun initView() { setContentView(R.layout.activity_settings) - - registerAgainButton = bind(R.id.settingsRegisterAgainButton) - registerAgainButton.setOnClickListener { - onRegisterAgainButtonClick() - } - - deleteAllNotificationsButton = bind(R.id.settingsDeleteAllNotificationsButton) - deleteAllNotificationsButton.setOnClickListener { - onDeleteAllNotificationsButtonClick() - } - - markAllAsReadButton = bind(R.id.settingsMarkAsReadButton) - markAllAsReadButton.setOnClickListener { - onMarkAllAsReadButtonClick() - } - - addExampleNotificationsButton = bind(R.id.settingsAddExampleButton) - if (Device.instance.isEmulator()) { - addExampleNotificationsButton.visibility = View.VISIBLE - } - addExampleNotificationsButton.setOnClickListener { - onAddExampleNotificationsButtonClick() - } - - aboutBisqButton = bind(R.id.settingsAboutBisqButton) - aboutBisqButton.setOnClickListener { - onAboutBisqButtonClick() - } - - aboutAppButton = bind(R.id.settingsAboutAppButton) - aboutAppButton.setOnClickListener { - onAboutAppButtonClick() - } - - versionTextView = bind(R.id.settingsVersionTextView) - versionTextView.text = getString(R.string.version, Application.getAppVersion()) - - keyTextView = bind(R.id.settingsKeyTextView) - keyTextView.text = getString( - R.string.key, - truncateSensitiveText(Device.instance.key) - ) - - tokenTextView = bind(R.id.settingsTokenTextView) - tokenTextView.text = getString( - R.string.token, - truncateSensitiveText(Device.instance.token) - ) - } - - private fun onAboutAppButtonClick() { - loadWebPage(BISQ_MOBILE_URL) - } - - private fun onAboutBisqButtonClick() { - loadWebPage(BISQ_NETWORK_URL) - } - - private fun onAddExampleNotificationsButtonClick() { - addExampleNotifications() - finish() - } - - private fun onMarkAllAsReadButtonClick() { - viewModel.markAllAsRead() - finish() - } - - private fun onDeleteAllNotificationsButtonClick() { - viewModel.nukeTable() - finish() - } - - private fun onRegisterAgainButtonClick() { - Device.instance.reset() - Device.instance.clearPreferences(this) - viewModel.nukeTable() - Device.instance.status = DeviceStatus.ERASED - refreshFcmToken() - startActivity(Intent(this, WelcomeActivity::class.java)) - } - - @Suppress("MagicNumber") - private fun addExampleNotifications() { - for (counter in 1..5) { - val now = Date() - val bisqNotification = BisqNotification() - bisqNotification.receivedDate = now.time + counter * 1000 - bisqNotification.sentDate = bisqNotification.receivedDate - 1000 * 30 - when (counter) { - 1 -> { - bisqNotification.type = NotificationType.TRADE.name - bisqNotification.title = "(example) Trade confirmed" - bisqNotification.message = "The trade with ID 38765384 is confirmed." - } - 2 -> { - bisqNotification.type = NotificationType.OFFER.name - bisqNotification.title = "(example) Offer taken" - bisqNotification.message = "Your offer with ID 39847534 was taken" - } - 3 -> { - bisqNotification.type = NotificationType.DISPUTE.name - bisqNotification.title = "(example) Dispute message" - bisqNotification.actionRequired = "Please contact the arbitrator" - bisqNotification.message = - "You received a dispute message for trade with ID 34059340" - bisqNotification.txId = "34059340" - } - 4 -> { - bisqNotification.type = NotificationType.PRICE.name - bisqNotification.title = "(example) Price alert for United States Dollar" - bisqNotification.message = "Your price alert got triggered. The current" + - " United States Dollar price is 35351.08 BTC/USD" - } - 5 -> { - bisqNotification.type = NotificationType.MARKET.name - bisqNotification.title = "(example) New offer" - bisqNotification.message = "A new offer offer with price 36000 USD" + - " (1% above market price) and payment method Zelle was published to" + - " the Bisq offerbook.\nThe offer ID is 34534" - } - } - viewModel.insert(bisqNotification) - } } } diff --git a/app/src/main/java/bisq/android/ui/settings/SettingsFragment.kt b/app/src/main/java/bisq/android/ui/settings/SettingsFragment.kt new file mode 100644 index 00000000..70d2130d --- /dev/null +++ b/app/src/main/java/bisq/android/ui/settings/SettingsFragment.kt @@ -0,0 +1,151 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.android.ui.settings + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.appcompat.app.AppCompatDelegate +import androidx.lifecycle.ViewModelProvider +import androidx.preference.ListPreference +import androidx.preference.Preference +import androidx.preference.PreferenceFragmentCompat +import bisq.android.Application +import bisq.android.BISQ_MOBILE_URL +import bisq.android.BISQ_NETWORK_URL +import bisq.android.R +import bisq.android.model.Device +import bisq.android.model.DeviceStatus +import bisq.android.services.BisqFirebaseMessagingService +import bisq.android.ui.DialogBuilder +import bisq.android.ui.ThemeProvider +import bisq.android.ui.UiUtil.loadWebPage +import bisq.android.ui.notification.NotificationViewModel +import bisq.android.ui.pairing.PairingScanActivity +import bisq.android.ui.welcome.WelcomeActivity + +@Suppress("TooManyFunctions") +class SettingsFragment : PreferenceFragmentCompat() { + private val themeProvider by lazy { ThemeProvider(requireContext()) } + private val themePreference by lazy { + findPreference(getString(R.string.theme_preferences_key)) + } + private val resetPairingPreference by lazy { + findPreference(getString(R.string.reset_pairing_preferences_key)) + } + private val scanPairingTokenPreference by lazy { + findPreference(getString(R.string.scan_pairing_token_preferences_key)) + } + private val aboutBisqPreference by lazy { + findPreference(getString(R.string.about_bisq_preferences_key)) + } + private val aboutAppPreference by lazy { + findPreference(getString(R.string.about_app_preferences_key)) + } + private val versionPreference by lazy { + findPreference(getString(R.string.version_preferences_key)) + } + + private lateinit var viewModel: NotificationViewModel + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + viewModel = ViewModelProvider(this)[NotificationViewModel::class.java] + } + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + setPreferencesFromResource(R.xml.settings, rootKey) + setThemePreference() + setResetPairingPreference() + setScanPairingTokenPreference() + setAboutBisqPreference() + setAboutAppPreference() + setVersionPreference() + } + + private fun setThemePreference() { + themePreference?.onPreferenceChangeListener = + Preference.OnPreferenceChangeListener { _, newValue -> + if (newValue is String) { + val theme = themeProvider.getTheme(newValue) + AppCompatDelegate.setDefaultNightMode(theme) + } + true + } + themePreference?.summaryProvider = getThemeSummaryProvider() + } + + private fun getThemeSummaryProvider() = + Preference.SummaryProvider { preference -> + themeProvider.getThemeDescriptionForPreference(preference.value) + } + + private fun setResetPairingPreference() { + resetPairingPreference?.setOnPreferenceClickListener { + this.context?.let { context -> + onResetPairing(context) + } + true + } + } + + private fun onResetPairing(context: Context) { + DialogBuilder.choicePrompt( + context, + getString(R.string.confirm), + getString(R.string.register_again_confirmation), + getString(R.string.yes), + getString(R.string.no), + { _, _ -> + Device.instance.reset() + Device.instance.clearPreferences(context) + viewModel.nukeTable() + Device.instance.status = DeviceStatus.ERASED + BisqFirebaseMessagingService.refreshFcmToken() + val intent = Intent(context, WelcomeActivity::class.java) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + startActivity(intent) + } + ).show() + } + + private fun setScanPairingTokenPreference() { + scanPairingTokenPreference?.setOnPreferenceClickListener { + startActivity(Intent(Intent(context, PairingScanActivity::class.java))) + true + } + } + + private fun setAboutBisqPreference() { + aboutBisqPreference?.setOnPreferenceClickListener { + this.context?.let { context -> loadWebPage(context, BISQ_NETWORK_URL) } + true + } + } + + private fun setAboutAppPreference() { + aboutAppPreference?.setOnPreferenceClickListener { + this.context?.let { context -> loadWebPage(context, BISQ_MOBILE_URL) } + true + } + } + + private fun setVersionPreference() { + versionPreference?.title = getString(R.string.version, Application.getAppVersion()) + } +} diff --git a/app/src/main/java/bisq/android/ui/welcome/WelcomeActivity.kt b/app/src/main/java/bisq/android/ui/welcome/WelcomeActivity.kt index 92664de4..00076b1b 100644 --- a/app/src/main/java/bisq/android/ui/welcome/WelcomeActivity.kt +++ b/app/src/main/java/bisq/android/ui/welcome/WelcomeActivity.kt @@ -36,6 +36,7 @@ import bisq.android.services.BisqFirebaseMessagingService import bisq.android.services.BisqFirebaseMessagingService.Companion.isGooglePlayServicesAvailable import bisq.android.services.BisqFirebaseMessagingService.Companion.isTokenBeingFetched import bisq.android.ui.DialogBuilder +import bisq.android.ui.UiUtil.loadWebPage import bisq.android.ui.UnpairedBaseActivity import bisq.android.ui.notification.NotificationTableActivity import bisq.android.ui.pairing.PairingScanActivity @@ -57,8 +58,6 @@ class WelcomeActivity : UnpairedBaseActivity() { initView() - registerNotificationReceiver() - if (Device.instance.readFromPreferences(this)) { return } @@ -77,6 +76,7 @@ class WelcomeActivity : UnpairedBaseActivity() { DeviceStatus.PAIRED -> { startActivity(Intent(this, NotificationTableActivity::class.java)) } + DeviceStatus.NEEDS_REPAIR -> { Toast.makeText( this, @@ -85,6 +85,7 @@ class WelcomeActivity : UnpairedBaseActivity() { ).show() Device.instance.status = DeviceStatus.UNPAIRED } + DeviceStatus.REMOTE_ERASED -> { Toast.makeText( this, @@ -93,21 +94,17 @@ class WelcomeActivity : UnpairedBaseActivity() { ).show() Device.instance.status = DeviceStatus.UNPAIRED } + else -> { // Do nothing } } } - override fun onDestroy() { - super.onDestroy() - unregisterNotificationReceiver() - } - private fun initView() { setContentView(R.layout.activity_welcome) - pairButton = bind(R.id.pairButton) + pairButton = bind(R.id.welcome_pair_button) if (isGooglePlayServicesAvailable(this)) { pairButton.setOnClickListener { maybeProceedToPairingScanActivity() @@ -118,12 +115,12 @@ class WelcomeActivity : UnpairedBaseActivity() { } } - learnMoreButton = bind(R.id.learnMoreButton) + learnMoreButton = bind(R.id.welcome_learn_more_button) learnMoreButton.setOnClickListener { - loadWebPage(BISQ_MOBILE_URL) + loadWebPage(this, BISQ_MOBILE_URL) } - progressBar = bind(R.id.circularProgressbar) + progressBar = bind(R.id.welcome_circular_progressbar) progressBar.progressDrawable = ContextCompat.getDrawable(this, R.drawable.circular_progressbar) Thread { @@ -198,13 +195,13 @@ class WelcomeActivity : UnpairedBaseActivity() { val extras = intent.extras if (extras != null) { Log.i(TAG, "Processing opened notification") - val notificationMessage = extras.get("encrypted") + val notificationMessage = extras.getString("encrypted") if (notificationMessage != null) { Log.i(TAG, "Broadcasting " + getString(R.string.notification_receiver_action)) Intent().also { broadcastIntent -> broadcastIntent.action = getString(R.string.notification_receiver_action) broadcastIntent.flags = Intent.FLAG_INCLUDE_STOPPED_PACKAGES - broadcastIntent.putExtra("encrypted", notificationMessage as String) + broadcastIntent.putExtra("encrypted", notificationMessage) sendBroadcast(broadcastIntent) } } diff --git a/app/src/main/java/bisq/android/util/CryptoUtil.kt b/app/src/main/java/bisq/android/util/CryptoUtil.kt index 5e3212f8..2466c4ab 100644 --- a/app/src/main/java/bisq/android/util/CryptoUtil.kt +++ b/app/src/main/java/bisq/android/util/CryptoUtil.kt @@ -37,9 +37,7 @@ class CryptoUtil(private val key: String) { private var cipher: Cipher? = null init { - if (key.length != KEY_LENGTH) { - throw IllegalArgumentException("Key is not $KEY_LENGTH characters") - } + require(key.length == KEY_LENGTH) { "Key is not $KEY_LENGTH characters" } cipher = Cipher.getInstance("AES/CBC/NOPadding") } @@ -61,9 +59,7 @@ class CryptoUtil(private val key: String) { @Throws(IllegalArgumentException::class, CryptoException::class) fun encrypt(valueToEncrypt: String, iv: String): String { - if (iv.length != IV_LENGTH) { - throw IllegalArgumentException("Initialization vector is not $IV_LENGTH characters") - } + require(iv.length == IV_LENGTH) { "Initialization vector is not $IV_LENGTH characters" } var paddedValueToEncrypt = valueToEncrypt while (paddedValueToEncrypt.length % IV_LENGTH != 0) { paddedValueToEncrypt = "$paddedValueToEncrypt " @@ -76,9 +72,7 @@ class CryptoUtil(private val key: String) { @Throws(IllegalArgumentException::class, CryptoException::class) fun decrypt(valueToDecrypt: String, iv: String): String { - if (iv.length != IV_LENGTH) { - throw IllegalArgumentException("Initialization vector is not $IV_LENGTH characters") - } + require(iv.length == IV_LENGTH) { "Initialization vector is not $IV_LENGTH characters" } ivSpec = IvParameterSpec(iv.toByteArray()) val decryptedBytes = decryptInternal(valueToDecrypt, ivSpec!!) return String(decryptedBytes!!) @@ -86,9 +80,7 @@ class CryptoUtil(private val key: String) { @Throws(IllegalArgumentException::class, CryptoException::class) private fun encryptInternal(text: String?, ivSpec: IvParameterSpec): ByteArray? { - if (text == null || text.isEmpty()) { - throw IllegalArgumentException("Empty string") - } + require(!text.isNullOrEmpty()) { "Empty string" } val encrypted: ByteArray? @Suppress("SwallowedException", "TooGenericExceptionCaught") try { @@ -102,9 +94,7 @@ class CryptoUtil(private val key: String) { @Throws(IllegalArgumentException::class, CryptoException::class) private fun decryptInternal(codeBase64: String?, ivSpec: IvParameterSpec): ByteArray? { - if (codeBase64 == null || codeBase64.isEmpty()) { - throw IllegalArgumentException("Empty string") - } + require(!codeBase64.isNullOrEmpty()) { "Empty string" } val decrypted: ByteArray? @Suppress("SwallowedException", "TooGenericExceptionCaught") try { diff --git a/app/src/main/java/bisq/android/util/QrUtil.kt b/app/src/main/java/bisq/android/util/QrUtil.kt index cca222d5..d9df4df0 100644 --- a/app/src/main/java/bisq/android/util/QrUtil.kt +++ b/app/src/main/java/bisq/android/util/QrUtil.kt @@ -51,7 +51,7 @@ object QrUtil { for (y in 0 until bitMatrix.height) { val offset = y * bitMatrix.width for (x in 0 until bitMatrix.width) { - pixels[offset + x] = if (bitMatrix.get(x, y)) Color.BLACK else Color.WHITE + pixels[offset + x] = if (bitMatrix[x, y]) Color.BLACK else Color.WHITE } } return pixels diff --git a/app/src/main/res/drawable/bisq_logo_green.xml b/app/src/main/res/drawable/bisq_logo_green.xml new file mode 100644 index 00000000..2e270fbd --- /dev/null +++ b/app/src/main/res/drawable/bisq_logo_green.xml @@ -0,0 +1,36 @@ + + + + + + + diff --git a/app/src/main/res/drawable/circular_progressbar.xml b/app/src/main/res/drawable/circular_progressbar.xml index 1c7f798d..beeeffc3 100644 --- a/app/src/main/res/drawable/circular_progressbar.xml +++ b/app/src/main/res/drawable/circular_progressbar.xml @@ -1,7 +1,5 @@ android:layout_width="wrap_content" android:layout_height="wrap_content"> diff --git a/app/src/main/res/drawable/ic_bisq_mark.xml b/app/src/main/res/drawable/ic_bisq_mark.xml new file mode 100644 index 00000000..a1257a38 --- /dev/null +++ b/app/src/main/res/drawable/ic_bisq_mark.xml @@ -0,0 +1,12 @@ + + + diff --git a/app/src/main/res/drawable/ic_info_white_24.xml b/app/src/main/res/drawable/ic_info_white_24.xml new file mode 100644 index 00000000..0988477f --- /dev/null +++ b/app/src/main/res/drawable/ic_info_white_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_link_white_24.xml b/app/src/main/res/drawable/ic_link_white_24.xml new file mode 100644 index 00000000..3c695385 --- /dev/null +++ b/app/src/main/res/drawable/ic_link_white_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_qr_code_scanner_white_24.xml b/app/src/main/res/drawable/ic_qr_code_scanner_white_24.xml new file mode 100644 index 00000000..a2c3c832 --- /dev/null +++ b/app/src/main/res/drawable/ic_qr_code_scanner_white_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_restart_white_24.xml b/app/src/main/res/drawable/ic_restart_white_24.xml new file mode 100644 index 00000000..e17d695b --- /dev/null +++ b/app/src/main/res/drawable/ic_restart_white_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_stat_notifications.xml b/app/src/main/res/drawable/ic_stat_notifications.xml new file mode 100644 index 00000000..b081271a --- /dev/null +++ b/app/src/main/res/drawable/ic_stat_notifications.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_theme_white_24.xml b/app/src/main/res/drawable/ic_theme_white_24.xml new file mode 100644 index 00000000..2e4e12a7 --- /dev/null +++ b/app/src/main/res/drawable/ic_theme_white_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/rounded_corner_gray.xml b/app/src/main/res/drawable/rounded_corner_gray.xml deleted file mode 100644 index 38966cc2..00000000 --- a/app/src/main/res/drawable/rounded_corner_gray.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/rounded_corner_pink.xml b/app/src/main/res/drawable/rounded_corner_pink.xml deleted file mode 100644 index c4ceda03..00000000 --- a/app/src/main/res/drawable/rounded_corner_pink.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/activity_notification_detail.xml b/app/src/main/res/layout/activity_notification_detail.xml index f0e695be..98f9042b 100644 --- a/app/src/main/res/layout/activity_notification_detail.xml +++ b/app/src/main/res/layout/activity_notification_detail.xml @@ -3,10 +3,11 @@ xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" - android:layout_height="match_parent"> + android:layout_height="match_parent" + android:background="@color/background"> + tools:ignore="HardcodedText" /> + app:layout_constraintTop_toBottomOf="@+id/notification_detail_action" + tools:ignore="HardcodedText" /> + app:layout_constraintTop_toBottomOf="@+id/notification_detail_event_time" + tools:ignore="HardcodedText" /> + +