diff --git a/.github/workflows/cherry-pick-rc-to-develop.yml b/.github/workflows/cherry-pick-rc-to-develop.yml index 4f24fbf8eeb..1537a2425fb 100644 --- a/.github/workflows/cherry-pick-rc-to-develop.yml +++ b/.github/workflows/cherry-pick-rc-to-develop.yml @@ -92,7 +92,8 @@ jobs: cd .. git add ${{ env.SUBMODULE_NAME }} git commit -m "Update submodule ${{ env.SUBMODULE_NAME }} to latest from ${{ env.TARGET_BRANCH }}" - echo "lastCommitMessage=$LAST_COMMIT_MESSAGE" >> $GITHUB_ENV + # Base64 encode the commit message to avoid issues with newlines and special characters + echo "lastCommitMessageBase64=$(echo "$LAST_COMMIT_MESSAGE" | base64 -w 0 )" >> $GITHUB_ENV - name: Get Cherry-pick commit id: get-cherry @@ -101,13 +102,23 @@ jobs: if [[ -n "${{ env.SUBMODULE_NAME }}" ]]; then # If SUBMODULE_NAME is set git reset --soft HEAD~2 - git commit -m "${{ env.lastCommitMessage }}" + # Decode the base64-encoded string + LAST_COMMIT_MESSAGE=$(echo "${{ env.lastCommitMessageBase64 }}" | base64 --decode) + git commit -m "$LAST_COMMIT_MESSAGE" fi # Get the SHA of the current commit (either squashed or not based on the condition above) CHERRY_PICK_COMMIT=$(git rev-parse HEAD) echo "cherryPickCommit=$CHERRY_PICK_COMMIT" >> $GITHUB_ENV + - name: Get Original Author + id: get-author + if: env.shouldCherryPick == 'true' + run: | + ORIGINAL_AUTHOR=$(git log -1 --pretty=format:'%an <%ae>' ${{ github.event.pull_request.merge_commit_sha }}) + echo "Original author: $ORIGINAL_AUTHOR" + echo "originalAuthor=$ORIGINAL_AUTHOR" >> $GITHUB_ENV + - name: Cherry-pick commits id: commit-cherry-pick if: env.shouldCherryPick == 'true' @@ -122,7 +133,10 @@ jobs: echo "Captured conflicted files: $CONFLICTED_FILES" if [[ "$OUTPUT" == *"CONFLICT"* ]]; then # Commit the remaining conflicts - git commit -am "Commit with unresolved merge conflicts outside of ${{ env.SUBMODULE_NAME }}" + git add . + git commit --author "${{ env.originalAuthor }}" -am "Commit with unresolved merge conflicts outside of ${{ env.SUBMODULE_NAME }}" + else + git commit --author "${{ env.originalAuthor }}" --amend --no-edit fi # Push branch and remove temp diff --git a/.github/workflows/gradle-run-unit-tests.yml b/.github/workflows/gradle-run-unit-tests.yml index 8a954be3b1c..23262c6524a 100644 --- a/.github/workflows/gradle-run-unit-tests.yml +++ b/.github/workflows/gradle-run-unit-tests.yml @@ -64,19 +64,19 @@ jobs: uses: actions/upload-artifact@v4 with: name: report - path: app/build/reports/jacoco + path: app/build/reports/kover - name: Download Test Reports Folder uses: actions/download-artifact@v4 with: name: report - path: app/build/reports/jacoco + path: app/build/reports/kover merge-multiple: true - name: Upload Test Report uses: codecov/codecov-action@v3 with: - files: "app/build/reports/jacoco/jacocoReport/jacocoReport.xml" + files: "app/build/reports/kover/report.xml" - name: Cleanup Gradle Cache # Remove some files from the Gradle cache, so they aren't cached by GitHub Actions. diff --git a/.gitignore b/.gitignore index 6168c4c16e5..f2abb861c48 100644 --- a/.gitignore +++ b/.gitignore @@ -95,4 +95,9 @@ lint/tmp/ # Autogenerated file with git hash information. app/src/main/assets/version.txt +app/src/main/assets/dependencies_version.json /intellij.gdsl + +# Editor temporary files +*~ +\#*# diff --git a/AR-builder.groovy b/AR-builder.groovy index 9de771b8ed6..24e74ff0b9f 100644 --- a/AR-builder.groovy +++ b/AR-builder.groovy @@ -65,7 +65,7 @@ pipeline { string(name: 'SOURCE_BRANCH', description: 'Branch or PR name to') string(name: 'CHANGE_BRANCH', description: 'Change branch name to build only used to checkout the correct branch if you need the branch name use SOURCE_BRANCH') choice(name: 'BUILD_TYPE', choices: ['Compatrelease', 'Debug', 'Release', 'Compat'], description: 'Build Type for the Client') - choice(name: 'FLAVOR', choices: ['Prod', 'Dev', 'Staging', 'Internal', 'Beta'], description: 'Product Flavor to build') + choice(name: 'FLAVOR', choices: ['Prod', 'Fdroid', 'Dev', 'Staging', 'Internal', 'Beta'], description: 'Product Flavor to build') booleanParam(name: 'UPLOAD_TO_S3', defaultValue: false, description: 'Boolean Flag to define if the build should be uploaded to S3') booleanParam(name: 'UPLOAD_TO_PLAYSTORE_ENABLED', defaultValue: false, description: 'Boolean Flag to define if the build should be uploaded to Playstore') booleanParam(name: 'RUN_UNIT_TEST', defaultValue: true, description: 'Boolean Flag to define if the unit tests should be run') @@ -540,7 +540,6 @@ pipeline { } } - sh './gradlew jacocoReport' wireSend(secret: env.WIRE_BOT_SECRET, message: "**[#${BUILD_NUMBER} Link](${BUILD_URL})** [${SOURCE_BRANCH}] - ✅ SUCCESS 🎉" + "\nLast 5 commits:\n```text\n$lastCommits\n```") } diff --git a/Jenkinsfile b/Jenkinsfile index fe00ad9c02c..4203efa6da0 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -12,7 +12,7 @@ List defineFlavor() { } else if (branchName == "develop") { return ['Staging', 'Dev'] } else if (branchName == "prod") { - return ['Prod'] + return ['Prod', 'Fdroid'] } else if (branchName == "internal") { return ['Internal'] } diff --git a/app/build.gradle.kts b/app/build.gradle.kts index cd0d0540d80..067b4b33943 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,3 +1,5 @@ +import scripts.Variants_gradle + /* * Wire * Copyright (C) 2024 Wire Swiss GmbH @@ -42,6 +44,11 @@ repositories { google() } +fun isFossSourceSet(): Boolean { + return (Variants_gradle.Default.explicitBuildFlavor() ?: gradle.startParameter.taskRequests.toString()) + .lowercase() + .contains("fdroid") +} android { // Most of the configuration is done in the build-logic // through the Wire Application convention plugin @@ -56,8 +63,30 @@ android { jniLibs.pickFirsts.add("**/libsodium.so") } android.buildFeatures.buildConfig = true + + val fdroidBuild = isFossSourceSet() + + sourceSets { + // Add the "foss" sourceSets for the fdroid flavor + if (fdroidBuild) { + getByName("fdroid") { + java.srcDirs("src/foss/kotlin", "src/prod/kotlin") + res.srcDirs("src/prod/res") + println("Building with FOSS sourceSets") + } + // For all other flavors use the "nonfree" sourceSets + } else { + getByName("main") { + java.srcDirs("src/nonfree/kotlin") + println("Building with non-free sourceSets") + } + } + } } + + + dependencies { implementation("com.wire.kalium:kalium-logic") implementation("com.wire.kalium:kalium-util") @@ -70,6 +99,7 @@ dependencies { implementation(libs.androidx.splashscreen) implementation(libs.androidx.exifInterface) implementation(libs.androidx.biometric) + implementation(libs.androidx.startup) implementation(libs.ktx.dateTime) implementation(libs.material) @@ -140,10 +170,16 @@ dependencies { implementation(libs.bundlizer.core) // firebase - implementation(platform(libs.firebase.bom)) - implementation(libs.firebase.fcm) + var fdroidBuild = isFossSourceSet() + + if (!fdroidBuild) { + implementation(platform(libs.firebase.bom)) + implementation(libs.firebase.fcm) + implementation(libs.googleGms.location) + } else { + println("Excluding FireBase for FDroid build") + } implementation(libs.androidx.work) - implementation(libs.googleGms.location) // commonMark implementation(libs.commonmark.core) diff --git a/app/src/androidTest/java/com/wire/android/SelfDeletionTimerTest.kt b/app/src/androidTest/java/com/wire/android/SelfDeletionTimerTest.kt deleted file mode 100644 index a9d5dbba7c9..00000000000 --- a/app/src/androidTest/java/com/wire/android/SelfDeletionTimerTest.kt +++ /dev/null @@ -1,433 +0,0 @@ -/* - * Wire - * Copyright (C) 2024 Wire Swiss GmbH - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program 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 General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see http://www.gnu.org/licenses/. - */ -package com.wire.android - -import androidx.test.platform.app.InstrumentationRegistry -import com.wire.android.ui.home.conversations.SelfDeletionTimerHelper -import com.wire.android.ui.home.conversations.model.ExpirationStatus -import com.wire.kalium.logic.data.message.Message -import org.junit.Test -import kotlin.time.Duration.Companion.days -import kotlin.time.Duration.Companion.hours -import kotlin.time.Duration.Companion.minutes -import kotlin.time.Duration.Companion.seconds - -class SelfDeletionTimerTest { - - private val selfDeletionTimer = SelfDeletionTimerHelper( - context = InstrumentationRegistry.getInstrumentation().targetContext - ) - - @Test - fun givenTimeLeftIsAboveOneHour_whenGettingTheUpdateInterval_ThenIsEqualToMinutesLeftTillWholeHour() { - val selfDeletionTimer = selfDeletionTimer.fromExpirationStatus( - ExpirationStatus.Expirable( - expireAfter = 23.hours + 30.minutes, - selfDeletionStatus = Message.ExpirationData.SelfDeletionStatus.NotStarted - ) - ) - assert(selfDeletionTimer is SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable) - val interval = (selfDeletionTimer as SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable).updateInterval() - assert(interval == 30.minutes) - } - - @Test - fun givenTimeLeftIsEqualToWholeHour_whenGettingTheUpdateInterval_ThenIsEqualToOneMinute() { - val selfDeletionTimer = selfDeletionTimer.fromExpirationStatus( - ExpirationStatus.Expirable( - expireAfter = 23.hours, - selfDeletionStatus = Message.ExpirationData.SelfDeletionStatus.NotStarted - ) - ) - assert(selfDeletionTimer is SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable) - val interval = (selfDeletionTimer as SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable).updateInterval() - assert(interval == 1.hours) - } - - @Test - fun givenTimeLeftIsEqualToOneHour_whenGettingTheUpdateInterval_ThenIsEqualToOneMinute() { - val selfDeletionTimer = selfDeletionTimer.fromExpirationStatus( - ExpirationStatus.Expirable( - expireAfter = 1.hours, - selfDeletionStatus = Message.ExpirationData.SelfDeletionStatus.NotStarted - ) - ) - assert(selfDeletionTimer is SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable) - val interval = (selfDeletionTimer as SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable).updateInterval() - assert(interval == 1.minutes) - } - - @Test - fun givenTimeLeftIsEqualToOneMinute_whenGettingTheUpdateInterval_ThenIsEqualToOneSeconds() { - val selfDeletionTimer = selfDeletionTimer.fromExpirationStatus( - ExpirationStatus.Expirable( - expireAfter = 1.minutes, - selfDeletionStatus = Message.ExpirationData.SelfDeletionStatus.NotStarted - ) - ) - assert(selfDeletionTimer is SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable) - val interval = (selfDeletionTimer as SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable).updateInterval() - assert(interval == 1.seconds) - } - - @Test - fun givenTimeLeftIsEqualToThirtySeconds_whenGettingTheUpdateInterval_ThenIsEqualToOneSeconds() { - val selfDeletionTimer = selfDeletionTimer.fromExpirationStatus( - ExpirationStatus.Expirable( - expireAfter = 30.seconds, - selfDeletionStatus = Message.ExpirationData.SelfDeletionStatus.NotStarted - ) - ) - assert(selfDeletionTimer is SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable) - val interval = (selfDeletionTimer as SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable).updateInterval() - assert(interval == 1.seconds) - } - - @Test - fun givenTimeLeftIsEqualToFiftyDays_whenGettingThTimeLeftFormatted_ThenIsEqualToFourWeeksLeft() { - val selfDeletionTimer = selfDeletionTimer.fromExpirationStatus( - ExpirationStatus.Expirable( - expireAfter = 50.days, - selfDeletionStatus = Message.ExpirationData.SelfDeletionStatus.NotStarted - ) - ) - assert(selfDeletionTimer is SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable) - val timeLeftLabel = (selfDeletionTimer as SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable).timeLeftFormatted() - assert(timeLeftLabel == "4 weeks left") - } - - @Test - fun givenTimeLeftIsEqualToTwentySevenDays_whenGettingThTimeLeftFormatted_ThenIsEqualToFourWeeksLeft() { - val selfDeletionTimer = selfDeletionTimer.fromExpirationStatus( - ExpirationStatus.Expirable( - expireAfter = 27.days, - selfDeletionStatus = Message.ExpirationData.SelfDeletionStatus.NotStarted - ) - ) - assert(selfDeletionTimer is SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable) - val timeLeftLabel = (selfDeletionTimer as SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable).timeLeftFormatted() - assert(timeLeftLabel == "4 weeks left") - } - - @Test - fun givenTimeLeftIsEqualToTwentySevenDaysAndTwelveHours_whenGettingThTimeLeftFormatted_ThenIsEqualToFourWeeksLeft() { - val selfDeletionTimer = selfDeletionTimer.fromExpirationStatus( - ExpirationStatus.Expirable( - expireAfter = 27.days + 12.hours, - selfDeletionStatus = Message.ExpirationData.SelfDeletionStatus.NotStarted - ) - ) - assert(selfDeletionTimer is SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable) - val timeLeftLabel = (selfDeletionTimer as SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable).timeLeftFormatted() - assert(timeLeftLabel == "4 weeks left") - } - - @Test - fun givenTimeLeftIsEqualToTwentySevenDaysAndOneSecond_whenGettingThTimeLeftFormatted_ThenIsEqualToFourWeeksLeft() { - val selfDeletionTimer = selfDeletionTimer.fromExpirationStatus( - ExpirationStatus.Expirable( - expireAfter = 27.days + 1.seconds, - selfDeletionStatus = Message.ExpirationData.SelfDeletionStatus.NotStarted - ) - ) - assert(selfDeletionTimer is SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable) - val timeLeftLabel = (selfDeletionTimer as SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable).timeLeftFormatted() - assert(timeLeftLabel == "4 weeks left") - } - - @Test - fun givenTimeLeftIsEqualToTwentyEightDays_whenGettingThTimeLeftFormatted_ThenIsEqualToFourWeeksLeft() { - val selfDeletionTimer = selfDeletionTimer.fromExpirationStatus( - ExpirationStatus.Expirable( - expireAfter = 28.days, - selfDeletionStatus = Message.ExpirationData.SelfDeletionStatus.NotStarted - ) - ) - assert(selfDeletionTimer is SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable) - val timeLeftLabel = (selfDeletionTimer as SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable).timeLeftFormatted() - assert(timeLeftLabel == "4 weeks left") - } - - @Test - fun givenTimeLeftIsEqualToTwentyOneDays_whenGettingThTimeLeftFormatted_ThenIsEqualToTwentyOneLeft() { - val selfDeletionTimer = selfDeletionTimer.fromExpirationStatus( - ExpirationStatus.Expirable( - expireAfter = 21.days, - selfDeletionStatus = Message.ExpirationData.SelfDeletionStatus.NotStarted - ) - ) - assert(selfDeletionTimer is SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable) - val timeLeftLabel = (selfDeletionTimer as SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable).timeLeftFormatted() - assert(timeLeftLabel == "21 days left") - } - - @Test - fun givenTimeLeftIsEqualToFourTeenDays_whenGettingThTimeLeftFormatted_ThenIsEqualToFourTeenDaysLeft() { - val selfDeletionTimer = selfDeletionTimer.fromExpirationStatus( - ExpirationStatus.Expirable( - expireAfter = 14.days, - selfDeletionStatus = Message.ExpirationData.SelfDeletionStatus.NotStarted - ) - ) - assert(selfDeletionTimer is SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable) - val timeLeftLabel = (selfDeletionTimer as SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable).timeLeftFormatted() - assert(timeLeftLabel == "14 days left") - } - - @Test - fun givenTimeLeftIsEqualToTwentyDays_whenGettingThTimeLeftFormatted_ThenIsEqualToTwentyDaysLeft() { - val selfDeletionTimer = selfDeletionTimer.fromExpirationStatus( - ExpirationStatus.Expirable( - expireAfter = 20.days, - selfDeletionStatus = Message.ExpirationData.SelfDeletionStatus.NotStarted - ) - ) - assert(selfDeletionTimer is SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable) - val timeLeftLabel = (selfDeletionTimer as SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable).timeLeftFormatted() - assert(timeLeftLabel == "20 days left") - } - - @Test - fun givenTimeLeftIsEqualToSevenDays_whenGettingThTimeLeftFormatted_ThenIsEqualToOneWeekLeft() { - val selfDeletionTimer = selfDeletionTimer.fromExpirationStatus( - ExpirationStatus.Expirable( - expireAfter = 7.days, - selfDeletionStatus = Message.ExpirationData.SelfDeletionStatus.NotStarted - ) - ) - assert(selfDeletionTimer is SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable) - val timeLeftLabel = (selfDeletionTimer as SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable).timeLeftFormatted() - assert(timeLeftLabel == "1 week left") - } - - @Test - fun givenTimeLeftIsEqualToSixDays_whenGettingThTimeLeftFormatted_ThenIsEqualToOneWeekLeft() { - val selfDeletionTimer = selfDeletionTimer.fromExpirationStatus( - ExpirationStatus.Expirable( - expireAfter = 6.days, - selfDeletionStatus = Message.ExpirationData.SelfDeletionStatus.NotStarted - ) - ) - assert(selfDeletionTimer is SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable) - val timeLeftLabel = (selfDeletionTimer as SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable).timeLeftFormatted() - assert(timeLeftLabel == "1 week left") - } - - @Test - fun givenTimeLeftIsEqualToSixDaysAnd12Hours_whenGettingThTimeLeftFormatted_ThenIsEqualToOneWeekLeft() { - val selfDeletionTimer = selfDeletionTimer.fromExpirationStatus( - ExpirationStatus.Expirable( - expireAfter = 6.days + 12.hours, - selfDeletionStatus = Message.ExpirationData.SelfDeletionStatus.NotStarted - ) - ) - assert(selfDeletionTimer is SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable) - val timeLeftLabel = (selfDeletionTimer as SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable).timeLeftFormatted() - assert(timeLeftLabel == "1 week left") - } - - @Test - fun givenTimeLeftIsEqualToSixDaysAndOneSecond_whenGettingThTimeLeftFormatted_ThenIsEqualToOneWeekLeft() { - val selfDeletionTimer = selfDeletionTimer.fromExpirationStatus( - ExpirationStatus.Expirable( - expireAfter = 6.days + 1.seconds, - selfDeletionStatus = Message.ExpirationData.SelfDeletionStatus.NotStarted - ) - ) - assert(selfDeletionTimer is SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable) - val timeLeftLabel = (selfDeletionTimer as SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable).timeLeftFormatted() - assert(timeLeftLabel == "1 week left") - } - - @Test - fun givenTimeLeftIsEqualToThirteenDays_whenGettingThTimeLeftFormatted_ThenIsEqualToThirteenDays() { - val selfDeletionTimer = selfDeletionTimer.fromExpirationStatus( - ExpirationStatus.Expirable( - expireAfter = 13.days, - selfDeletionStatus = Message.ExpirationData.SelfDeletionStatus.NotStarted - ) - ) - assert(selfDeletionTimer is SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable) - val timeLeftLabel = (selfDeletionTimer as SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable).timeLeftFormatted() - assert(timeLeftLabel == "13 days left") - } - - @Test - fun givenTimeLeftIsEqualToOneDay_whenGettingThTimeLeftFormatted_ThenIsEqualToOneDayLeft() { - val selfDeletionTimer = selfDeletionTimer.fromExpirationStatus( - ExpirationStatus.Expirable( - expireAfter = 1.days, - selfDeletionStatus = Message.ExpirationData.SelfDeletionStatus.NotStarted - ) - ) - assert(selfDeletionTimer is SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable) - val timeLeftLabel = (selfDeletionTimer as SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable).timeLeftFormatted() - assert(timeLeftLabel == "1 day left") - } - - @Test - fun givenTimeLeftIsEqualToTwentyFourHours_whenGettingThTimeLeftFormatted_ThenIsEqualToOneDayLeft() { - val selfDeletionTimer = selfDeletionTimer.fromExpirationStatus( - ExpirationStatus.Expirable( - expireAfter = 24.hours, - selfDeletionStatus = Message.ExpirationData.SelfDeletionStatus.NotStarted - ) - ) - assert(selfDeletionTimer is SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable) - val timeLeftLabel = (selfDeletionTimer as SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable).timeLeftFormatted() - assert(timeLeftLabel == "1 day left") - } - - @Test - fun givenTimeLeftIsEqualToTwentyThreeHours_whenGettingThTimeLeftFormatted_ThenIsEqualToTwentyThreeHourLeft() { - val selfDeletionTimer = selfDeletionTimer.fromExpirationStatus( - ExpirationStatus.Expirable( - expireAfter = 23.hours, - selfDeletionStatus = Message.ExpirationData.SelfDeletionStatus.NotStarted - ) - ) - assert(selfDeletionTimer is SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable) - val timeLeftLabel = (selfDeletionTimer as SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable).timeLeftFormatted() - assert(timeLeftLabel == "23 hours left") - } - - @Test - fun givenTimeLeftIsEqualToSixtyMinutes_whenGettingThTimeLeftFormatted_ThenIsEqualToOneHourLeft() { - val selfDeletionTimer = selfDeletionTimer.fromExpirationStatus( - ExpirationStatus.Expirable( - expireAfter = 60.minutes, - selfDeletionStatus = Message.ExpirationData.SelfDeletionStatus.NotStarted - ) - ) - assert(selfDeletionTimer is SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable) - val timeLeftLabel = (selfDeletionTimer as SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable).timeLeftFormatted() - assert(timeLeftLabel == "1 hour left") - } - - @Test - fun givenTimeLeftIsEqualToOneMinute_whenGettingThTimeLeftFormatted_ThenIsEqualToOneMinuteLeft() { - val selfDeletionTimer = selfDeletionTimer.fromExpirationStatus( - ExpirationStatus.Expirable( - expireAfter = 1.minutes, - selfDeletionStatus = Message.ExpirationData.SelfDeletionStatus.NotStarted - ) - ) - assert(selfDeletionTimer is SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable) - val timeLeftLabel = (selfDeletionTimer as SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable).timeLeftFormatted() - assert(timeLeftLabel == "1 minute left") - } - - @Test - fun givenTimeLeftIsEqualToOFiftyNineMinutes_whenGettingThTimeLeftFormatted_ThenIsEqualToFiftyNineMinutes() { - val selfDeletionTimer = selfDeletionTimer.fromExpirationStatus( - ExpirationStatus.Expirable( - expireAfter = 59.minutes, - selfDeletionStatus = Message.ExpirationData.SelfDeletionStatus.NotStarted - ) - ) - assert(selfDeletionTimer is SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable) - val timeLeftLabel = (selfDeletionTimer as SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable).timeLeftFormatted() - assert(timeLeftLabel == "59 minutes left") - } - - @Test - fun givenTimeLeftIsEqualToSixtySeconds_whenGettingThTimeLeftFormatted_ThenIsEqualToOneMinute() { - val selfDeletionTimer = selfDeletionTimer.fromExpirationStatus( - ExpirationStatus.Expirable( - expireAfter = 60.seconds, - selfDeletionStatus = Message.ExpirationData.SelfDeletionStatus.NotStarted - ) - ) - assert(selfDeletionTimer is SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable) - val timeLeftLabel = (selfDeletionTimer as SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable).timeLeftFormatted() - assert(timeLeftLabel == "1 minute left") - } - - @Test - fun givenTimeLeftIsEqualToOneDayAndTwelveHours_whenDecreasingTimeWithInterval_thenTimeLeftIsEqualToExpecetedTimeLeft() { - val selfDeletionTimer = selfDeletionTimer.fromExpirationStatus( - ExpirationStatus.Expirable( - expireAfter = 1.days + 12.hours, - selfDeletionStatus = Message.ExpirationData.SelfDeletionStatus.NotStarted - ) - ) - assert(selfDeletionTimer is SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable) - (selfDeletionTimer as SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable).decreaseTimeLeft( - selfDeletionTimer.updateInterval() - ) - assert(selfDeletionTimer.timeLeftFormatted() == "1 day left") - - selfDeletionTimer.decreaseTimeLeft(selfDeletionTimer.updateInterval()) - assert(selfDeletionTimer.timeLeftFormatted() == "23 hours left") - } - - @Test - fun givenTimeLeftIsEqualToTwentyThreeHoursAndTwentyThreeMinutes_whenDecreasingTimeWithInterval_thenTimeLeftIsEqualToExpeceted() { - val selfDeletionTimer = selfDeletionTimer.fromExpirationStatus( - ExpirationStatus.Expirable( - expireAfter = 23.hours + 23.minutes, - selfDeletionStatus = Message.ExpirationData.SelfDeletionStatus.NotStarted - ) - ) - assert(selfDeletionTimer is SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable) - (selfDeletionTimer as SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable).decreaseTimeLeft( - selfDeletionTimer.updateInterval() - ) - - val timeLeftLabel = selfDeletionTimer.timeLeftFormatted() - assert(timeLeftLabel == "23 hours left") - } - - @Test - fun givenTimeLeftIsEqualToOneHourAndTwelveMinutes_whenDecreasingTimeWithInterval_thenTimeLeftIsEqualToExpecetedTimeLeft() { - val selfDeletionTimer = selfDeletionTimer.fromExpirationStatus( - ExpirationStatus.Expirable( - expireAfter = 1.hours + 12.minutes, - selfDeletionStatus = Message.ExpirationData.SelfDeletionStatus.NotStarted - ) - ) - assert(selfDeletionTimer is SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable) - (selfDeletionTimer as SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable).decreaseTimeLeft( - selfDeletionTimer.updateInterval() - ) - assert(selfDeletionTimer.timeLeftFormatted() == "1 hour left") - selfDeletionTimer.decreaseTimeLeft( - selfDeletionTimer.updateInterval() - ) - assert(selfDeletionTimer.timeLeftFormatted() == "59 minutes left") - } - - @Test - fun givenTimeLeftIsEqualToOneHourAndTwentyThreeSeconds_whenDecreasingTimeWithInterval_thenTimeLeftIsEqualToExpecetedTimeLeft() { - val selfDeletionTimer = selfDeletionTimer.fromExpirationStatus( - ExpirationStatus.Expirable( - expireAfter = 1.minutes + 23.seconds, - selfDeletionStatus = Message.ExpirationData.SelfDeletionStatus.NotStarted - ) - ) - assert(selfDeletionTimer is SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable) - (selfDeletionTimer as SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable).decreaseTimeLeft( - selfDeletionTimer.updateInterval() - ) - assert(selfDeletionTimer.timeLeftFormatted() == "1 minute left") - selfDeletionTimer.decreaseTimeLeft( - selfDeletionTimer.updateInterval() - ) - assert(selfDeletionTimer.timeLeftFormatted() == "59 seconds left") - } -} diff --git a/app/src/beta/kotlin/com/wire/android/ExternalLoggerManager.kt b/app/src/beta/kotlin/com/wire/android/ExternalLoggerManager.kt index d29ebd8205c..78cc0b1658e 100644 --- a/app/src/beta/kotlin/com/wire/android/ExternalLoggerManager.kt +++ b/app/src/beta/kotlin/com/wire/android/ExternalLoggerManager.kt @@ -30,6 +30,7 @@ import com.datadog.android.rum.tracking.ComponentPredicate import com.wire.android.datastore.GlobalDataStore import com.wire.android.ui.WireActivity import com.wire.android.util.getDeviceIdString +import com.wire.android.util.getGitBuildId import com.wire.android.util.sha256 import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking @@ -73,7 +74,8 @@ object ExternalLoggerManager { val credentials = Credentials(clientToken, environmentName, appVariantName, applicationId) val extraInfo = mapOf( - "encrypted_proteus_storage_enabled" to runBlocking { globalDataStore.isEncryptedProteusStorageEnabled().first() } + "encrypted_proteus_storage_enabled" to runBlocking { globalDataStore.isEncryptedProteusStorageEnabled().first() }, + "git_commit_hash" to context.getGitBuildId() ) Datadog.initialize(context, credentials, configuration, TrackingConsent.GRANTED) diff --git a/app/src/beta/kotlin/com/wire/android/util/DataDogLogger.kt b/app/src/beta/kotlin/com/wire/android/util/DataDogLogger.kt index e0c5c22169d..547c9b0fd63 100644 --- a/app/src/beta/kotlin/com/wire/android/util/DataDogLogger.kt +++ b/app/src/beta/kotlin/com/wire/android/util/DataDogLogger.kt @@ -34,12 +34,22 @@ object DataDogLogger : LogWriter() { .build() override fun log(severity: Severity, message: String, tag: String, throwable: Throwable?) { - val attributes = KaliumLogger.UserClientData.getFromTag(tag)?.let { userClientData -> - mapOf( - "userId" to userClientData.userId, - "clientId" to userClientData.clientId, - ) - } ?: emptyMap() - logger.log(severity.ordinal, message, throwable, attributes) + val logInfo = KaliumLogger.LogAttributes.getInfoFromTagString(tag) + val userAccountData = mapOf( + "userId" to logInfo.userClientData?.userId, + "clientId" to logInfo.userClientData?.clientId, + ) + val attributes = mapOf( + "wireAccount" to userAccountData, + "tag" to logInfo.textTag + ) + when (severity) { + Severity.Debug -> logger.d(message, throwable, attributes) + Severity.Info -> logger.i(message, throwable, attributes) + Severity.Warn -> logger.w(message, throwable, attributes) + Severity.Error -> logger.e(message, throwable, attributes) + Severity.Assert, + Severity.Verbose -> logger.v(message, throwable, attributes) + } } } diff --git a/app/src/dev/kotlin/com/wire/android/util/DataDogLogger.kt b/app/src/dev/kotlin/com/wire/android/util/DataDogLogger.kt index 8eaf3265325..055d776ff8f 100644 --- a/app/src/dev/kotlin/com/wire/android/util/DataDogLogger.kt +++ b/app/src/dev/kotlin/com/wire/android/util/DataDogLogger.kt @@ -29,19 +29,29 @@ object DataDogLogger : LogWriter() { private val logger = Logger.Builder() .setNetworkInfoEnabled(true) - .setLogcatLogsEnabled(true) .setLogcatLogsEnabled(false) // we already use platformLogWriter() along with DataDogLogger, don't need duplicates in LogCat + .setDatadogLogsEnabled(true) .setBundleWithTraceEnabled(true) .setLoggerName("DATADOG") .build() override fun log(severity: Severity, message: String, tag: String, throwable: Throwable?) { - val attributes = KaliumLogger.UserClientData.getFromTag(tag)?.let { userClientData -> - mapOf( - "userId" to userClientData.userId, - "clientId" to userClientData.clientId, - ) - } ?: emptyMap() - logger.log(severity.ordinal, message, throwable, attributes) + val logInfo = KaliumLogger.LogAttributes.getInfoFromTagString(tag) + val userAccountData = mapOf( + "userId" to logInfo.userClientData?.userId, + "clientId" to logInfo.userClientData?.clientId, + ) + val attributes = mapOf( + "wireAccount" to userAccountData, + "tag" to logInfo.textTag + ) + when (severity) { + Severity.Debug -> logger.d(message, throwable, attributes) + Severity.Info -> logger.i(message, throwable, attributes) + Severity.Warn -> logger.w(message, throwable, attributes) + Severity.Error -> logger.e(message, throwable, attributes) + Severity.Assert, + Severity.Verbose -> logger.v(message, throwable, attributes) + } } } diff --git a/app/src/fdroid/AndroidManifest.xml b/app/src/fdroid/AndroidManifest.xml new file mode 100644 index 00000000000..2be4aeab9f9 --- /dev/null +++ b/app/src/fdroid/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + diff --git a/app/src/foss/kotlin/com/wire/android/ui/home/messagecomposer/location/LocationPickerHelperFlavor.kt b/app/src/foss/kotlin/com/wire/android/ui/home/messagecomposer/location/LocationPickerHelperFlavor.kt new file mode 100644 index 00000000000..f50538d7437 --- /dev/null +++ b/app/src/foss/kotlin/com/wire/android/ui/home/messagecomposer/location/LocationPickerHelperFlavor.kt @@ -0,0 +1,32 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.home.messagecomposer.location + +import android.content.Context +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class LocationPickerHelperFlavor @Inject constructor(context: Context) : LocationPickerHelper(context) { + suspend fun getLocation(onSuccess: (GeoLocatedAddress) -> Unit, onError: () -> Unit) { + getLocationWithoutGms( + onSuccess = onSuccess, + onError = onError + ) + } +} diff --git a/app/src/foss/kotlin/com/wire/android/util/extension/GoogleServices.kt b/app/src/foss/kotlin/com/wire/android/util/extension/GoogleServices.kt new file mode 100644 index 00000000000..fe4dbe7d9a0 --- /dev/null +++ b/app/src/foss/kotlin/com/wire/android/util/extension/GoogleServices.kt @@ -0,0 +1,30 @@ +/* + * Wire + * Copyright (C) 2023 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + * + */ + +package com.wire.android.util.extension + +import android.content.Context + +fun Context.isGoogleServicesAvailable(): Boolean { + val returnValue: Boolean = false + return returnValue +} + +fun Context.initGoogleFirebase() { /* Stub for compatibility */ } diff --git a/app/src/internal/kotlin/com/wire/android/util/DataDogLogger.kt b/app/src/internal/kotlin/com/wire/android/util/DataDogLogger.kt index 8eaf3265325..055d776ff8f 100644 --- a/app/src/internal/kotlin/com/wire/android/util/DataDogLogger.kt +++ b/app/src/internal/kotlin/com/wire/android/util/DataDogLogger.kt @@ -29,19 +29,29 @@ object DataDogLogger : LogWriter() { private val logger = Logger.Builder() .setNetworkInfoEnabled(true) - .setLogcatLogsEnabled(true) .setLogcatLogsEnabled(false) // we already use platformLogWriter() along with DataDogLogger, don't need duplicates in LogCat + .setDatadogLogsEnabled(true) .setBundleWithTraceEnabled(true) .setLoggerName("DATADOG") .build() override fun log(severity: Severity, message: String, tag: String, throwable: Throwable?) { - val attributes = KaliumLogger.UserClientData.getFromTag(tag)?.let { userClientData -> - mapOf( - "userId" to userClientData.userId, - "clientId" to userClientData.clientId, - ) - } ?: emptyMap() - logger.log(severity.ordinal, message, throwable, attributes) + val logInfo = KaliumLogger.LogAttributes.getInfoFromTagString(tag) + val userAccountData = mapOf( + "userId" to logInfo.userClientData?.userId, + "clientId" to logInfo.userClientData?.clientId, + ) + val attributes = mapOf( + "wireAccount" to userAccountData, + "tag" to logInfo.textTag + ) + when (severity) { + Severity.Debug -> logger.d(message, throwable, attributes) + Severity.Info -> logger.i(message, throwable, attributes) + Severity.Warn -> logger.w(message, throwable, attributes) + Severity.Error -> logger.e(message, throwable, attributes) + Severity.Assert, + Severity.Verbose -> logger.v(message, throwable, attributes) + } } } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f60f99354dc..d31f77cb8d5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -19,7 +19,9 @@ + android:sharedUserId="${sharedUserId}" + android:installLocation="internalOnly" + > @@ -39,6 +41,7 @@ + @@ -61,7 +64,11 @@ android:name="android.hardware.camera" android:required="false" /> + + + + + + + - - - - - - - - - - + android:foregroundServiceType="phoneCall|microphone" /> diff --git a/app/src/main/kotlin/com/wire/android/AppLogger.kt b/app/src/main/kotlin/com/wire/android/AppLogger.kt index 3fe35faa35d..81311175d12 100644 --- a/app/src/main/kotlin/com/wire/android/AppLogger.kt +++ b/app/src/main/kotlin/com/wire/android/AppLogger.kt @@ -19,16 +19,53 @@ package com.wire.android import com.wire.kalium.logger.KaliumLogLevel import com.wire.kalium.logger.KaliumLogger +import com.wire.kalium.util.serialization.toJsonElement private var appLoggerConfig = KaliumLogger.Config.disabled() + // App wide global logger, carefully initialized when our application is "onCreate" internal var appLogger = KaliumLogger.disabled() + object AppLogger { fun init(config: KaliumLogger.Config) { appLoggerConfig = config appLogger = KaliumLogger(config = config, tag = "WireAppLogger") } + fun setLogLevel(level: KaliumLogLevel) { appLoggerConfig.setLogLevel(level) } } + +object AppJsonStyledLogger { + /** + * Log a structured JSON message, in the following format: + * + * Example: + * ``` + * leadingMessage: {map of key-value pairs represented as JSON} + * ``` + * @param level the severity of the log message + * @param error optional - the throwable error to be logged + * @param leadingMessage the leading message useful for later grok parsing + * @param jsonStringKeyValues the map of key-value pairs to be logged in a valid JSON format + */ + fun log( + level: KaliumLogLevel, + error: Throwable? = null, + leadingMessage: String, + jsonStringKeyValues: Map + ) = with(appLogger) { + val logJson = jsonStringKeyValues.toJsonElement() + val sanitizedLeadingMessage = if (leadingMessage.endsWith(":")) leadingMessage else "$leadingMessage:" + val logMessage = "$sanitizedLeadingMessage $logJson" + when (level) { + KaliumLogLevel.DEBUG -> d(logMessage) + KaliumLogLevel.INFO -> i(logMessage) + KaliumLogLevel.WARN -> w(logMessage) + KaliumLogLevel.ERROR -> e(logMessage, throwable = error) + KaliumLogLevel.VERBOSE -> v(logMessage) + KaliumLogLevel.DISABLED -> Unit + } + } +} diff --git a/app/src/main/kotlin/com/wire/android/GlobalObserversManager.kt b/app/src/main/kotlin/com/wire/android/GlobalObserversManager.kt index 954fe2a4a71..71744a199f0 100644 --- a/app/src/main/kotlin/com/wire/android/GlobalObserversManager.kt +++ b/app/src/main/kotlin/com/wire/android/GlobalObserversManager.kt @@ -22,11 +22,13 @@ import com.wire.android.datastore.UserDataStoreProvider import com.wire.android.di.KaliumCoreLogic import com.wire.android.notification.NotificationChannelsManager import com.wire.android.notification.WireNotificationManager +import com.wire.android.util.CurrentScreenManager import com.wire.android.util.dispatchers.DispatcherProvider import com.wire.kalium.logic.CoreLogic import com.wire.kalium.logic.data.logout.LogoutReason import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.feature.auth.LogoutCallback +import com.wire.kalium.logic.feature.session.CurrentSessionResult import com.wire.kalium.logic.feature.user.webSocketStatus.ObservePersistentWebSocketConnectionStatusUseCase import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob @@ -35,8 +37,12 @@ import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import javax.inject.Inject import javax.inject.Singleton @@ -52,6 +58,7 @@ class GlobalObserversManager @Inject constructor( private val notificationManager: WireNotificationManager, private val notificationChannelsManager: NotificationChannelsManager, private val userDataStoreProvider: UserDataStoreProvider, + private val currentScreenManager: CurrentScreenManager, ) { private val scope = CoroutineScope(SupervisorJob() + dispatcherProvider.io()) @@ -65,6 +72,7 @@ class GlobalObserversManager @Inject constructor( } } scope.handleLogouts() + scope.handleDeleteEphemeralMessageEndDate() } private suspend fun setUpNotifications() { @@ -83,14 +91,26 @@ class GlobalObserversManager @Inject constructor( } coreLogic.getGlobalScope().observeValidAccounts() + .combine(persistentStatusesFlow) { list, persistentStatuses -> + val persistentStatusesMap = persistentStatuses.associate { it.userId to it.isPersistentWebSocketEnabled } + /* + Intersect both lists as they can be slightly out of sync because both lists can be updated at slightly different times. + When user is logged out, at this time one of them can still contain this invalid user - make sure that it's ignored. + When user is logged in, at this time one of them can still not contain this new user - ignore for now, + the user will be handled correctly in the next iteration when the second list becomes updated as well. + */ + list.map { (selfUser, _) -> selfUser } + .filter { persistentStatusesMap.containsKey(it.id) } + .map { it to persistentStatusesMap.getValue(it.id) } + } .distinctUntilChanged() - .combine(persistentStatusesFlow, ::Pair) - .collect { (list, persistentStatuses) -> - notificationChannelsManager.createUserNotificationChannels(list.map { it.first }) + .collectLatest { + // create notification channels for all valid users + notificationChannelsManager.createUserNotificationChannels(it.map { it.first }) - list.map { it.first.id } - // do not observe notifications for users with PersistentWebSocketEnabled, it will be done in PersistentWebSocketService - .filter { userId -> persistentStatuses.none { it.userId == userId && it.isPersistentWebSocketEnabled } } + // do not observe notifications for users with PersistentWebSocketEnabled, it will be done in PersistentWebSocketService + it.filter { (_, isPersistentWebSocketEnabled) -> !isPersistentWebSocketEnabled } + .map { (selfUser, _) -> selfUser.id } .run { notificationManager.observeNotificationsAndCallsWhileRunning(this, scope) } @@ -104,7 +124,6 @@ class GlobalObserversManager @Inject constructor( val callback: LogoutCallback = object : LogoutCallback { override suspend fun invoke(userId: UserId, reason: LogoutReason) { notificationManager.stopObservingOnLogout(userId) - notificationChannelsManager.deleteChannelGroup(userId) if (reason != LogoutReason.SELF_SOFT_LOGOUT) { userDataStoreProvider.getOrCreate(userId).clear() } @@ -114,4 +133,21 @@ class GlobalObserversManager @Inject constructor( awaitClose { coreLogic.getGlobalScope().logoutCallbackManager.unregister(callback) } }.launchIn(this) } + + private fun CoroutineScope.handleDeleteEphemeralMessageEndDate() { + launch { + currentScreenManager.isAppVisibleFlow() + .flatMapLatest { isAppVisible -> + if (isAppVisible) { + coreLogic.getGlobalScope().session.currentSessionFlow() + .distinctUntilChanged() + .filter { it is CurrentSessionResult.Success && it.accountInfo.isValid() } + .map { (it as CurrentSessionResult.Success).accountInfo.userId } + } else { + emptyFlow() + } + } + .collect { userId -> coreLogic.getSessionScope(userId).messages.deleteEphemeralMessageEndDate() } + } + } } diff --git a/app/src/main/kotlin/com/wire/android/WireApplication.kt b/app/src/main/kotlin/com/wire/android/WireApplication.kt index 7924cbf3153..71d41d122d6 100644 --- a/app/src/main/kotlin/com/wire/android/WireApplication.kt +++ b/app/src/main/kotlin/com/wire/android/WireApplication.kt @@ -24,14 +24,11 @@ import android.os.Build import android.os.StrictMode import androidx.work.Configuration import co.touchlab.kermit.platformLogWriter -import com.google.firebase.FirebaseApp -import com.google.firebase.FirebaseOptions import com.wire.android.datastore.GlobalDataStore import com.wire.android.di.ApplicationScope import com.wire.android.di.KaliumCoreLogic import com.wire.android.util.DataDogLogger import com.wire.android.util.LogFileWriter -import com.wire.android.util.extension.isGoogleServicesAvailable import com.wire.android.util.getGitBuildId import com.wire.android.util.lifecycle.ConnectionPolicyManager import com.wire.android.workmanager.WireWorkerFactory @@ -39,6 +36,7 @@ import com.wire.kalium.logger.KaliumLogLevel import com.wire.kalium.logger.KaliumLogger import com.wire.kalium.logic.CoreLogger import com.wire.kalium.logic.CoreLogic +import dagger.Lazy import dagger.hilt.android.HiltAndroidApp import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.first @@ -50,29 +48,30 @@ class WireApplication : Application(), Configuration.Provider { @Inject @KaliumCoreLogic - lateinit var coreLogic: CoreLogic + lateinit var coreLogic: Lazy @Inject - lateinit var logFileWriter: LogFileWriter + lateinit var logFileWriter: Lazy @Inject - lateinit var connectionPolicyManager: ConnectionPolicyManager + lateinit var connectionPolicyManager: Lazy @Inject - lateinit var wireWorkerFactory: WireWorkerFactory + lateinit var wireWorkerFactory: Lazy @Inject - lateinit var globalObserversManager: GlobalObserversManager + lateinit var globalObserversManager: Lazy @Inject - lateinit var globalDataStore: GlobalDataStore + lateinit var globalDataStore: Lazy @Inject @ApplicationScope lateinit var globalAppScope: CoroutineScope override fun getWorkManagerConfiguration(): Configuration { return Configuration.Builder() - .setWorkerFactory(wireWorkerFactory) + .setWorkerFactory(wireWorkerFactory.get()) + .setMinimumLoggingLevel(android.util.Log.DEBUG) .build() } @@ -81,23 +80,19 @@ class WireApplication : Application(), Configuration.Provider { enableStrictMode() - if (this.isGoogleServicesAvailable()) { - val firebaseOptions = FirebaseOptions.Builder() - .setApplicationId(BuildConfig.FIREBASE_APP_ID) - .setGcmSenderId(BuildConfig.FIREBASE_PUSH_SENDER_ID) - .setApiKey(BuildConfig.GOOGLE_API_KEY) - .setProjectId(BuildConfig.FCM_PROJECT_ID) - .build() - FirebaseApp.initializeApp(this, firebaseOptions) - } + globalAppScope.launch { + initializeApplicationLoggingFrameworks() - initializeApplicationLoggingFrameworks() - connectionPolicyManager.startObservingAppLifecycle() + appLogger.i("$TAG app lifecycle") + connectionPolicyManager.get().startObservingAppLifecycle() - // TODO: Can be handled in one of Sync steps - coreLogic.updateApiVersionsScheduler.schedulePeriodicApiVersionUpdate() + appLogger.i("$TAG api version update") + // TODO: Can be handled in one of Sync steps + coreLogic.get().updateApiVersionsScheduler.schedulePeriodicApiVersionUpdate() - globalObserversManager.observe() + appLogger.i("$TAG global observers") + globalObserversManager.get().observe() + } } private fun enableStrictMode() { @@ -121,29 +116,27 @@ class WireApplication : Application(), Configuration.Provider { } } - private fun initializeApplicationLoggingFrameworks() { - globalAppScope.launch { - // 1. Datadog should be initialized first - ExternalLoggerManager.initDatadogLogger(applicationContext, globalDataStore) - // 2. Initialize our internal logging framework - val isLoggingEnabled = globalDataStore.isLoggingEnabled().first() - val config = if (isLoggingEnabled) { - KaliumLogger.Config.DEFAULT.apply { - setLogLevel(KaliumLogLevel.VERBOSE) - setLogWriterList(listOf(DataDogLogger, platformLogWriter())) - } - } else { - KaliumLogger.Config.disabled() + private suspend fun initializeApplicationLoggingFrameworks() { + // 1. Datadog should be initialized first + ExternalLoggerManager.initDatadogLogger(applicationContext, globalDataStore.get()) + // 2. Initialize our internal logging framework + val isLoggingEnabled = globalDataStore.get().isLoggingEnabled().first() + val config = if (isLoggingEnabled) { + KaliumLogger.Config.DEFAULT.apply { + setLogLevel(KaliumLogLevel.VERBOSE) + setLogWriterList(listOf(DataDogLogger, platformLogWriter())) } - // 2. Initialize our internal logging framework - AppLogger.init(config) - CoreLogger.init(config) - // 3. Initialize our internal FILE logging framework - logFileWriter.start() - // 4. Everything ready, now we can log device info - appLogger.i("Logger enabled") - logDeviceInformation() + } else { + KaliumLogger.Config.disabled() } + // 2. Initialize our internal logging framework + AppLogger.init(config) + CoreLogger.init(config) + // 3. Initialize our internal FILE logging framework + logFileWriter.get().start() + // 4. Everything ready, now we can log device info + appLogger.i("Logger enabled") + logDeviceInformation() } private fun logDeviceInformation() { @@ -169,7 +162,7 @@ class WireApplication : Application(), Configuration.Provider { override fun onLowMemory() { super.onLowMemory() appLogger.w("onLowMemory called - Stopping logging, buckling the seatbelt and hoping for the best!") - logFileWriter.stop() + logFileWriter.get().stop() } private companion object { @@ -190,5 +183,6 @@ class WireApplication : Application(), Configuration.Provider { values().firstOrNull { it.level == value } ?: TRIM_MEMORY_UNKNOWN } } + private const val TAG = "WireApplication" } } diff --git a/app/src/main/kotlin/com/wire/android/di/CoreLogicModule.kt b/app/src/main/kotlin/com/wire/android/di/CoreLogicModule.kt index 1e881239543..bea47ad95d2 100644 --- a/app/src/main/kotlin/com/wire/android/di/CoreLogicModule.kt +++ b/app/src/main/kotlin/com/wire/android/di/CoreLogicModule.kt @@ -35,6 +35,7 @@ import com.wire.kalium.logic.feature.connection.BlockUserUseCase import com.wire.kalium.logic.feature.connection.UnblockUserUseCase import com.wire.kalium.logic.feature.conversation.ObserveOtherUserSecurityClassificationLabelUseCase import com.wire.kalium.logic.feature.conversation.ObserveSecurityClassificationLabelUseCase +import com.wire.kalium.logic.feature.e2ei.usecase.FetchConversationMLSVerificationStatusUseCase import com.wire.kalium.logic.feature.featureConfig.ObserveIsAppLockEditableUseCase import com.wire.kalium.logic.feature.selfDeletingMessages.ObserveSelfDeletionTimerSettingsForConversationUseCase import com.wire.kalium.logic.feature.selfDeletingMessages.ObserveTeamSettingsSelfDeletingStatusUseCase @@ -206,16 +207,6 @@ class UseCaseModule { fun provideGetServerConfigUserCase(@KaliumCoreLogic coreLogic: CoreLogic) = coreLogic.getGlobalScope().fetchServerConfigFromDeepLink - @ViewModelScoped - @Provides - fun provideFetchApiVersionUserCase(@KaliumCoreLogic coreLogic: CoreLogic) = - coreLogic.getGlobalScope().fetchApiVersion - - @ViewModelScoped - @Provides - fun provideObserveServerConfigUseCase(@KaliumCoreLogic coreLogic: CoreLogic) = - coreLogic.getGlobalScope().observeServerConfig - @ViewModelScoped @Provides fun provideUpdateApiVersionsUseCase(@KaliumCoreLogic coreLogic: CoreLogic) = @@ -256,11 +247,26 @@ class UseCaseModule { @CurrentAccount currentAccount: UserId ) = coreLogic.getSessionScope(currentAccount).getPersistentWebSocketStatus + @ViewModelScoped + @Provides + fun provideCheckCrlRevocationListUseCase(@KaliumCoreLogic coreLogic: CoreLogic, @CurrentAccount currentAccount: UserId) = + coreLogic.getSessionScope(currentAccount).checkCrlRevocationList + @ViewModelScoped @Provides fun provideIsMLSEnabledUseCase(@KaliumCoreLogic coreLogic: CoreLogic, @CurrentAccount currentAccount: UserId) = coreLogic.getSessionScope(currentAccount).isMLSEnabled + @ViewModelScoped + @Provides + fun provideGetDefaultProtocol(@KaliumCoreLogic coreLogic: CoreLogic, @CurrentAccount currentAccount: UserId) = + coreLogic.getSessionScope(currentAccount).getDefaultProtocol + + @ViewModelScoped + @Provides + fun provideIsE2EIEnabledUseCase(@KaliumCoreLogic coreLogic: CoreLogic, @CurrentAccount currentAccount: UserId) = + coreLogic.getSessionScope(currentAccount).isE2EIEnabled + @ViewModelScoped @Provides fun provideIsFileSharingEnabledUseCase(@KaliumCoreLogic coreLogic: CoreLogic, @CurrentAccount currentAccount: UserId) = @@ -440,4 +446,11 @@ class UseCaseModule { fun provideObserveIsAppLockEditableUseCase( @KaliumCoreLogic coreLogic: CoreLogic ): ObserveIsAppLockEditableUseCase = coreLogic.getGlobalScope().observeIsAppLockEditableUseCase + + @ViewModelScoped + @Provides + fun provideFetchConversationMLSVerificationStatusUseCase( + @KaliumCoreLogic coreLogic: CoreLogic, + @CurrentAccount currentAccount: UserId + ): FetchConversationMLSVerificationStatusUseCase = coreLogic.getSessionScope(currentAccount).fetchConversationMLSVerificationStatus } diff --git a/app/src/main/kotlin/com/wire/android/di/KaliumConfigsModule.kt b/app/src/main/kotlin/com/wire/android/di/KaliumConfigsModule.kt index 8d8d0634ece..b6b2dc73003 100644 --- a/app/src/main/kotlin/com/wire/android/di/KaliumConfigsModule.kt +++ b/app/src/main/kotlin/com/wire/android/di/KaliumConfigsModule.kt @@ -51,7 +51,7 @@ class KaliumConfigsModule { forceConstantBitrateCalls = BuildConfig.FORCE_CONSTANT_BITRATE_CALLS, // we use upsert, available from SQL3.24, which is supported from Android API30, so for older APIs we have to use SQLCipher shouldEncryptData = !BuildConfig.DEBUG || Build.VERSION.SDK_INT < Build.VERSION_CODES.R, - lowerKeyPackageLimits = BuildConfig.PRIVATE_BUILD, + lowerKeyPackageLimits = BuildConfig.LOWER_KEYPACKAGE_LIMIT, lowerKeyingMaterialsUpdateThreshold = BuildConfig.PRIVATE_BUILD, isMLSSupportEnabled = BuildConfig.MLS_SUPPORT_ENABLED, developmentApiEnabled = BuildConfig.DEVELOPMENT_API_ENABLED, @@ -64,6 +64,8 @@ class KaliumConfigsModule { wipeOnRootedDevice = BuildConfig.WIPE_ON_ROOTED_DEVICE, isWebSocketEnabledByDefault = isWebsocketEnabledByDefault(context), certPinningConfig = BuildConfig.CERTIFICATE_PINNING_CONFIG, + maxRemoteSearchResultCount = BuildConfig.MAX_REMOTE_SEARCH_RESULT_COUNT, + limitTeamMembersFetchDuringSlowSync = BuildConfig.LIMIT_TEAM_MEMBERS_FETCH_DURING_SLOW_SYNC ) } } diff --git a/app/src/main/kotlin/com/wire/android/di/ObserveIfE2EIRequiredDuringLoginUseCaseProvider.kt b/app/src/main/kotlin/com/wire/android/di/ObserveIfE2EIRequiredDuringLoginUseCaseProvider.kt new file mode 100644 index 00000000000..f5e90784f39 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/di/ObserveIfE2EIRequiredDuringLoginUseCaseProvider.kt @@ -0,0 +1,37 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.di + +import com.wire.kalium.logic.CoreLogic +import com.wire.kalium.logic.data.user.UserId +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +class ObserveIfE2EIRequiredDuringLoginUseCaseProvider @AssistedInject constructor( + @KaliumCoreLogic private val coreLogic: CoreLogic, + @Assisted + private val userId: UserId +) { + suspend fun observeIfE2EIIsRequiredDuringLogin() = coreLogic.getSessionScope(userId).observeIfE2EIRequiredDuringLogin() + + @AssistedFactory + interface Factory { + fun create(userId: UserId): ObserveIfE2EIRequiredDuringLoginUseCaseProvider + } +} diff --git a/app/src/main/kotlin/com/wire/android/di/accountScoped/CallsModule.kt b/app/src/main/kotlin/com/wire/android/di/accountScoped/CallsModule.kt index 098f73210f6..c0f98555a70 100644 --- a/app/src/main/kotlin/com/wire/android/di/accountScoped/CallsModule.kt +++ b/app/src/main/kotlin/com/wire/android/di/accountScoped/CallsModule.kt @@ -37,7 +37,8 @@ import com.wire.kalium.logic.feature.call.usecase.StartCallUseCase import com.wire.kalium.logic.feature.call.usecase.TurnLoudSpeakerOffUseCase import com.wire.kalium.logic.feature.call.usecase.TurnLoudSpeakerOnUseCase import com.wire.kalium.logic.feature.call.usecase.UnMuteCallUseCase -import com.wire.kalium.logic.feature.call.usecase.UpdateVideoStateUseCase +import com.wire.kalium.logic.feature.call.usecase.video.SetVideoSendStateUseCase +import com.wire.kalium.logic.feature.call.usecase.video.UpdateVideoStateUseCase import dagger.hilt.InstallIn import dagger.hilt.android.components.ViewModelComponent import dagger.hilt.android.scopes.ViewModelScoped @@ -167,6 +168,13 @@ class CallsModule { ): UpdateVideoStateUseCase = callsScope.updateVideoState + @ViewModelScoped + @Provides + fun provideSetVideoSendStateUseCase( + callsScope: CallsScope + ): SetVideoSendStateUseCase = + callsScope.setVideoSendState + @ViewModelScoped @Provides fun provideIsCallRunningUseCase(callsScope: CallsScope) = diff --git a/app/src/main/kotlin/com/wire/android/di/accountScoped/ConversationModule.kt b/app/src/main/kotlin/com/wire/android/di/accountScoped/ConversationModule.kt index 42dba9c29a2..060a17f4141 100644 --- a/app/src/main/kotlin/com/wire/android/di/accountScoped/ConversationModule.kt +++ b/app/src/main/kotlin/com/wire/android/di/accountScoped/ConversationModule.kt @@ -30,6 +30,7 @@ import com.wire.kalium.logic.feature.conversation.CreateGroupConversationUseCase import com.wire.kalium.logic.feature.conversation.GetConversationUnreadEventsCountUseCase import com.wire.kalium.logic.feature.conversation.GetOneToOneConversationUseCase import com.wire.kalium.logic.feature.conversation.GetOrCreateOneToOneConversationUseCase +import com.wire.kalium.logic.feature.conversation.IsOneToOneConversationCreatedUseCase import com.wire.kalium.logic.feature.conversation.JoinConversationViaCodeUseCase import com.wire.kalium.logic.feature.conversation.LeaveConversationUseCase import com.wire.kalium.logic.feature.conversation.NotifyConversationIsOpenUseCase @@ -38,9 +39,9 @@ import com.wire.kalium.logic.feature.conversation.ObserveConversationDetailsUseC import com.wire.kalium.logic.feature.conversation.ObserveConversationInteractionAvailabilityUseCase import com.wire.kalium.logic.feature.conversation.ObserveConversationListDetailsUseCase import com.wire.kalium.logic.feature.conversation.ObserveConversationMembersUseCase +import com.wire.kalium.logic.feature.conversation.ObserveConversationUnderLegalHoldNotifiedUseCase import com.wire.kalium.logic.feature.conversation.ObserveDegradedConversationNotifiedUseCase import com.wire.kalium.logic.feature.conversation.ObserveIsSelfUserMemberUseCase -import com.wire.kalium.logic.feature.conversation.ObserveConversationUnderLegalHoldNotifiedUseCase import com.wire.kalium.logic.feature.conversation.ObserveUserListByIdUseCase import com.wire.kalium.logic.feature.conversation.ObserveUsersTypingUseCase import com.wire.kalium.logic.feature.conversation.RefreshConversationsWithoutMetadataUseCase @@ -288,4 +289,9 @@ class ConversationModule { fun provideObserveLegalHoldWithChangeNotifiedForConversationUseCase( conversationScope: ConversationScope, ): ObserveConversationUnderLegalHoldNotifiedUseCase = conversationScope.observeConversationUnderLegalHoldNotified + + @ViewModelScoped + @Provides + fun provideIsOneToOneConversationCreatedUseCase(conversationScope: ConversationScope): IsOneToOneConversationCreatedUseCase = + conversationScope.isOneToOneConversationCreatedUseCase } diff --git a/app/src/main/kotlin/com/wire/android/di/accountScoped/SearchModule.kt b/app/src/main/kotlin/com/wire/android/di/accountScoped/SearchModule.kt index f649bc17bac..02f80574ae6 100644 --- a/app/src/main/kotlin/com/wire/android/di/accountScoped/SearchModule.kt +++ b/app/src/main/kotlin/com/wire/android/di/accountScoped/SearchModule.kt @@ -22,6 +22,7 @@ import com.wire.android.di.KaliumCoreLogic import com.wire.kalium.logic.CoreLogic import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.feature.search.FederatedSearchParser +import com.wire.kalium.logic.feature.search.SearchByHandleUseCase import com.wire.kalium.logic.feature.search.SearchScope import com.wire.kalium.logic.feature.search.SearchUsersUseCase import dagger.Module @@ -43,7 +44,11 @@ class SearchModule { @ViewModelScoped @Provides - fun provideSearchUsersUseCase(searchScope: SearchScope): SearchUsersUseCase = searchScope.searchUsersUseCase + fun provideSearchUsersUseCase(searchScope: SearchScope): SearchUsersUseCase = searchScope.searchUsers + + @ViewModelScoped + @Provides + fun provideSearchByHandleUseCase(searchScope: SearchScope): SearchByHandleUseCase = searchScope.searchByHandle @ViewModelScoped @Provides diff --git a/app/src/main/kotlin/com/wire/android/di/accountScoped/UserModule.kt b/app/src/main/kotlin/com/wire/android/di/accountScoped/UserModule.kt index d70a66afd68..524bbfc748f 100644 --- a/app/src/main/kotlin/com/wire/android/di/accountScoped/UserModule.kt +++ b/app/src/main/kotlin/com/wire/android/di/accountScoped/UserModule.kt @@ -24,12 +24,15 @@ import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.feature.asset.DeleteAssetUseCase import com.wire.kalium.logic.feature.asset.GetAssetSizeLimitUseCase import com.wire.kalium.logic.feature.asset.GetAvatarAssetUseCase +import com.wire.kalium.logic.feature.client.FinalizeMLSClientAfterE2EIEnrollment import com.wire.kalium.logic.feature.conversation.GetAllContactsNotInConversationUseCase -import com.wire.kalium.logic.feature.e2ei.usecase.EnrollE2EIUseCase +import com.wire.kalium.logic.feature.e2ei.CertificateRevocationListCheckWorker import com.wire.kalium.logic.feature.e2ei.usecase.GetE2eiCertificateUseCase import com.wire.kalium.logic.feature.e2ei.usecase.GetMembersE2EICertificateStatusesUseCase import com.wire.kalium.logic.feature.e2ei.usecase.GetUserE2eiCertificateStatusUseCase import com.wire.kalium.logic.feature.e2ei.usecase.GetUserE2eiCertificatesUseCase +import com.wire.kalium.logic.feature.e2ei.usecase.ObserveCertificateRevocationForSelfClientUseCase +import com.wire.kalium.logic.feature.featureConfig.FeatureFlagsSyncWorker import com.wire.kalium.logic.feature.publicuser.GetAllContactsUseCase import com.wire.kalium.logic.feature.publicuser.GetKnownUserUseCase import com.wire.kalium.logic.feature.publicuser.RefreshUsersWithoutMetadataUseCase @@ -114,8 +117,8 @@ class UserModule { @ViewModelScoped @Provides - fun provideEnrollE2EIUseCase(userScope: UserScope): EnrollE2EIUseCase = - userScope.enrollE2EI + fun provideFinalizeMLSClientAfterE2EIEnrollmentUseCase(userScope: UserScope): FinalizeMLSClientAfterE2EIEnrollment = + userScope.finalizeMLSClientAfterE2EIEnrollment @ViewModelScoped @Provides @@ -226,4 +229,19 @@ class UserModule { @Provides fun provideGetUserE2eiCertificates(userScope: UserScope): GetUserE2eiCertificatesUseCase = userScope.getUserE2eiCertificates + + @ViewModelScoped + @Provides + fun provideCertificateRevocationListCheckWorker(userScope: UserScope): CertificateRevocationListCheckWorker = + userScope.certificateRevocationListCheckWorker + + @ViewModelScoped + @Provides + fun provideFeatureFlagsSyncWorker(userScope: UserScope): FeatureFlagsSyncWorker = + userScope.featureFlagsSyncWorker + + @ViewModelScoped + @Provides + fun provideObserveCertificateRevocationForSelfClientUseCase(userScope: UserScope): ObserveCertificateRevocationForSelfClientUseCase = + userScope.observeCertificateRevocationForSelfClient } diff --git a/app/src/main/kotlin/com/wire/android/feature/AccountSwitchUseCase.kt b/app/src/main/kotlin/com/wire/android/feature/AccountSwitchUseCase.kt index 307cf2be77f..17ce4b5dad8 100644 --- a/app/src/main/kotlin/com/wire/android/feature/AccountSwitchUseCase.kt +++ b/app/src/main/kotlin/com/wire/android/feature/AccountSwitchUseCase.kt @@ -68,12 +68,39 @@ class AccountSwitchUseCase @Inject constructor( val current = currentAccount.await() appLogger.i("$TAG Switching account invoked: ${params.toLogString()}, current account: ${current?.userId?.toLogString() ?: "-"}") return when (params) { - is SwitchAccountParam.SwitchToAccount -> switch(params.userId, current) + is SwitchAccountParam.SwitchToAccount -> checkAccountAndSwitchIfPossible(params.userId, current) SwitchAccountParam.TryToSwitchToNextAccount -> getNextAccountIfPossibleAndSwitch(current) SwitchAccountParam.Clear -> switch(null, current) } } + private suspend fun checkAccountAndSwitchIfPossible(userId: UserId, current: AccountInfo?): SwitchAccountResult = + getSessions().let { + when (it) { + is GetAllSessionsResult.Success -> { + val isAccountLoggedInAndValid = it.sessions.any { + accountInfo -> (accountInfo is AccountInfo.Valid) && (accountInfo.userId == userId) + } + if (isAccountLoggedInAndValid) { + switch(userId, current) + } else { + appLogger.i("$TAG Given account is not logged in or invalid: ${userId.toLogString()}") + return SwitchAccountResult.GivenAccountIsInvalid + } + } + + is GetAllSessionsResult.Failure.Generic -> { + appLogger.i("$TAG Failure when switching account to: ${userId.toLogString()}") + SwitchAccountResult.Failure + } + + GetAllSessionsResult.Failure.NoSessionFound -> { + appLogger.i("$TAG Given account is not found: ${userId.toLogString()}") + SwitchAccountResult.GivenAccountIsInvalid + } + } + } + private suspend fun getNextAccountIfPossibleAndSwitch(current: AccountInfo?): SwitchAccountResult { val nextSessionId: UserId? = getSessions().let { when (it) { @@ -103,7 +130,10 @@ class AccountSwitchUseCase @Inject constructor( } successResult } - is UpdateCurrentSessionUseCase.Result.Failure -> SwitchAccountResult.Failure + is UpdateCurrentSessionUseCase.Result.Failure -> { + appLogger.i("$TAG Failure when switching account to: ${userId?.toLogString() ?: "-"}") + SwitchAccountResult.Failure + } } } @@ -161,9 +191,10 @@ sealed class SwitchAccountParam { } sealed class SwitchAccountResult { - object Failure : SwitchAccountResult() - object SwitchedToAnotherAccount : SwitchAccountResult() - object NoOtherAccountToSwitch : SwitchAccountResult() + data object Failure : SwitchAccountResult() + data object SwitchedToAnotherAccount : SwitchAccountResult() + data object NoOtherAccountToSwitch : SwitchAccountResult() + data object GivenAccountIsInvalid : SwitchAccountResult() fun callAction(actions: SwitchAccountActions) = when (this) { NoOtherAccountToSwitch -> actions.noOtherAccountToSwitch() diff --git a/app/src/main/kotlin/com/wire/android/feature/ObserveAppLockConfigUseCase.kt b/app/src/main/kotlin/com/wire/android/feature/ObserveAppLockConfigUseCase.kt index d946074d9b1..19896940ebb 100644 --- a/app/src/main/kotlin/com/wire/android/feature/ObserveAppLockConfigUseCase.kt +++ b/app/src/main/kotlin/com/wire/android/feature/ObserveAppLockConfigUseCase.kt @@ -37,12 +37,8 @@ class ObserveAppLockConfigUseCase @Inject constructor( ) { operator fun invoke(): Flow = channelFlow { coreLogic.getGlobalScope().session.currentSessionFlow().collectLatest { sessionResult -> - when (sessionResult) { - is CurrentSessionResult.Failure -> { - send(AppLockConfig.Disabled(DEFAULT_APP_LOCK_TIMEOUT)) - } - - is CurrentSessionResult.Success -> { + when { + sessionResult is CurrentSessionResult.Success && sessionResult.accountInfo.isValid() -> { val userId = sessionResult.accountInfo.userId val appLockTeamFeatureConfigFlow = coreLogic.getSessionScope(userId).appLockTeamFeatureConfigObserver @@ -67,6 +63,10 @@ class ObserveAppLockConfigUseCase @Inject constructor( send(it) } } + + else -> { + send(AppLockConfig.Disabled(DEFAULT_APP_LOCK_TIMEOUT)) + } } } } diff --git a/app/src/main/kotlin/com/wire/android/feature/ShouldStartPersistentWebSocketServiceUseCase.kt b/app/src/main/kotlin/com/wire/android/feature/ShouldStartPersistentWebSocketServiceUseCase.kt new file mode 100644 index 00000000000..586ac8bc9ec --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/feature/ShouldStartPersistentWebSocketServiceUseCase.kt @@ -0,0 +1,57 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.feature + +import com.wire.android.di.KaliumCoreLogic +import com.wire.kalium.logic.CoreLogic +import com.wire.kalium.logic.feature.user.webSocketStatus.ObservePersistentWebSocketConnectionStatusUseCase +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.withTimeoutOrNull +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ShouldStartPersistentWebSocketServiceUseCase @Inject constructor( + @KaliumCoreLogic private val coreLogic: CoreLogic +) { + suspend operator fun invoke(): Result { + return coreLogic.getGlobalScope().observePersistentWebSocketConnectionStatus().let { result -> + when (result) { + is ObservePersistentWebSocketConnectionStatusUseCase.Result.Failure -> Result.Failure + + is ObservePersistentWebSocketConnectionStatusUseCase.Result.Success -> { + val statusList = withTimeoutOrNull(TIMEOUT) { + val res = result.persistentWebSocketStatusListFlow.firstOrNull() + res + } + if (statusList != null && statusList.map { it.isPersistentWebSocketEnabled }.contains(true)) Result.Success(true) + else Result.Success(false) + } + } + } + } + + sealed class Result { + data object Failure : Result() + data class Success(val shouldStartPersistentWebSocketService: Boolean) : Result() + } + + companion object { + const val TIMEOUT = 10_000L + } +} diff --git a/app/src/main/kotlin/com/wire/android/feature/StartPersistentWebsocketIfNecessaryUseCase.kt b/app/src/main/kotlin/com/wire/android/feature/StartPersistentWebsocketIfNecessaryUseCase.kt new file mode 100644 index 00000000000..b2ae0514e25 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/feature/StartPersistentWebsocketIfNecessaryUseCase.kt @@ -0,0 +1,76 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +@file:Suppress("StringTemplate") + +package com.wire.android.feature + +import android.content.Context +import android.content.Intent +import android.os.Build +import com.wire.android.appLogger +import com.wire.android.services.PersistentWebSocketService +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class StartPersistentWebsocketIfNecessaryUseCase @Inject constructor( + @ApplicationContext private val appContext: Context, + private val shouldStartPersistentWebSocketService: ShouldStartPersistentWebSocketServiceUseCase +) { + suspend operator fun invoke() { + val persistentWebSocketServiceIntent = PersistentWebSocketService.newIntent(appContext) + shouldStartPersistentWebSocketService().let { + when (it) { + is ShouldStartPersistentWebSocketServiceUseCase.Result.Failure -> { + appLogger.e("${TAG}: Failure while fetching persistent web socket status flow") + } + + is ShouldStartPersistentWebSocketServiceUseCase.Result.Success -> { + if (it.shouldStartPersistentWebSocketService) { + startForegroundService(persistentWebSocketServiceIntent) + } else { + appLogger.i("${TAG}: Stopping PersistentWebsocketService, no user with persistent web socket enabled found") + appContext.stopService(persistentWebSocketServiceIntent) + } + } + } + } + } + + private fun startForegroundService(persistentWebSocketServiceIntent: Intent) { + when { + PersistentWebSocketService.isServiceStarted -> { + appLogger.i("${TAG}: PersistentWebsocketService already started, not starting again") + } + + else -> { + appLogger.i("${TAG}: Starting PersistentWebsocketService") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + appContext.startForegroundService(persistentWebSocketServiceIntent) + } else { + appContext.startService(persistentWebSocketServiceIntent) + } + } + } + } + + companion object { + const val TAG = "StartPersistentWebsocketIfNecessaryUseCase" + } +} diff --git a/app/src/main/kotlin/com/wire/android/feature/e2ei/GetE2EICertificateUseCase.kt b/app/src/main/kotlin/com/wire/android/feature/e2ei/GetE2EICertificateUseCase.kt deleted file mode 100644 index b62fa991096..00000000000 --- a/app/src/main/kotlin/com/wire/android/feature/e2ei/GetE2EICertificateUseCase.kt +++ /dev/null @@ -1,79 +0,0 @@ -/* - * Wire - * Copyright (C) 2024 Wire Swiss GmbH - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program 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 General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see http://www.gnu.org/licenses/. - */ -package com.wire.android.feature.e2ei - -import android.content.Context -import com.wire.android.util.dispatchers.DispatcherProvider -import com.wire.android.util.extension.getActivity -import com.wire.kalium.logic.CoreFailure -import com.wire.kalium.logic.E2EIFailure -import com.wire.kalium.logic.feature.e2ei.usecase.E2EIEnrollmentResult -import com.wire.kalium.logic.feature.e2ei.usecase.EnrollE2EIUseCase -import com.wire.kalium.logic.functional.Either -import com.wire.kalium.logic.functional.fold -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.launch -import javax.inject.Inject - -class GetE2EICertificateUseCase @Inject constructor( - private val enrollE2EI: EnrollE2EIUseCase, - val dispatcherProvider: DispatcherProvider -) { - - private val scope = CoroutineScope(SupervisorJob() + dispatcherProvider.default()) - private lateinit var initialEnrollmentResult: E2EIEnrollmentResult.Initialized - lateinit var enrollmentResultHandler: (Either) -> Unit - - operator fun invoke(context: Context, enrollmentResultHandler: (Either) -> Unit) { - this.enrollmentResultHandler = enrollmentResultHandler - scope.launch { - enrollE2EI.initialEnrollment().fold({ - enrollmentResultHandler(Either.Left(it)) - }, { - if (it is E2EIEnrollmentResult.Initialized) { - initialEnrollmentResult = it - OAuthUseCase(context, it.target, it.oAuthState).launch( - context.getActivity()!!.activityResultRegistry, - ::oAuthResultHandler - ) - } else enrollmentResultHandler(Either.Right(it)) - }) - } - } - - private fun oAuthResultHandler(oAuthResult: OAuthUseCase.OAuthResult) { - scope.launch { - when (oAuthResult) { - is OAuthUseCase.OAuthResult.Success -> { - enrollmentResultHandler( - enrollE2EI.finalizeEnrollment( - oAuthResult.idToken, - oAuthResult.authState, - initialEnrollmentResult - ) - ) - } - - is OAuthUseCase.OAuthResult.Failed -> { - enrollmentResultHandler(Either.Left(E2EIFailure.FailedOAuth(oAuthResult.reason))) - } - } - } - } -} diff --git a/app/src/main/kotlin/com/wire/android/feature/e2ei/OAuthUseCase.kt b/app/src/main/kotlin/com/wire/android/feature/e2ei/OAuthUseCase.kt index 6e0476116e9..b3b96844731 100644 --- a/app/src/main/kotlin/com/wire/android/feature/e2ei/OAuthUseCase.kt +++ b/app/src/main/kotlin/com/wire/android/feature/e2ei/OAuthUseCase.kt @@ -22,12 +22,14 @@ import android.content.Context import android.content.Intent import android.net.Uri import android.util.Base64 -import android.util.Log import androidx.activity.result.ActivityResult import androidx.activity.result.ActivityResultRegistry import androidx.activity.result.contract.ActivityResultContracts import com.wire.android.appLogger import com.wire.android.util.deeplink.DeepLinkProcessor +import com.wire.android.util.findParameterValue +import com.wire.android.util.removeQueryParams +import kotlinx.serialization.json.JsonObject import net.openid.appauth.AppAuthConfiguration import net.openid.appauth.AuthState import net.openid.appauth.AuthorizationException @@ -36,23 +38,18 @@ import net.openid.appauth.AuthorizationResponse import net.openid.appauth.AuthorizationService import net.openid.appauth.AuthorizationServiceConfiguration import net.openid.appauth.ClientAuthentication -import net.openid.appauth.ClientSecretBasic import net.openid.appauth.ResponseTypeValues -import net.openid.appauth.browser.BrowserAllowList -import net.openid.appauth.browser.VersionedBrowserMatcher -import net.openid.appauth.connectivity.ConnectionBuilder -import java.net.HttpURLConnection -import java.net.URL +import org.json.JSONObject +import java.net.URI import java.security.MessageDigest import java.security.SecureRandom -import java.security.cert.X509Certificate -import javax.net.ssl.HostnameVerifier -import javax.net.ssl.HttpsURLConnection -import javax.net.ssl.SSLContext -import javax.net.ssl.TrustManager -import javax.net.ssl.X509TrustManager - -class OAuthUseCase(context: Context, private val authUrl: String, oAuthState: String?) { + +class OAuthUseCase( + context: Context, + private val authUrl: String, + private val claims: JsonObject, + oAuthState: String? +) { private var authState: AuthState = oAuthState?.let { AuthState.jsonDeserialize(it) } ?: AuthState() @@ -60,73 +57,64 @@ class OAuthUseCase(context: Context, private val authUrl: String, oAuthState: St private var authorizationService: AuthorizationService private lateinit var authServiceConfig: AuthorizationServiceConfiguration - // todo: this is a temporary code to ignore ssl issues on the test environment, will be removed after the preparation of the environment - // region Ignore SSL for OAuth - val naiveTrustManager = object : X509TrustManager { - override fun getAcceptedIssuers(): Array = arrayOf() - override fun checkClientTrusted(certs: Array, authType: String) = Unit - override fun checkServerTrusted(certs: Array, authType: String) = Unit - } - val insecureSocketFactory = SSLContext.getInstance("SSL").apply { - val trustAllCerts = arrayOf(naiveTrustManager) - init(null, trustAllCerts, SecureRandom()) - }.socketFactory - - private var insecureConnection = ConnectionBuilder() { uri -> - val url = URL(uri.toString()) - val connection = url.openConnection() as HttpURLConnection - if (connection is HttpsURLConnection) { - connection.hostnameVerifier = HostnameVerifier { _, _ -> true } - connection.sslSocketFactory = insecureSocketFactory - } - connection - } - // endregion - private var appAuthConfiguration: AppAuthConfiguration = AppAuthConfiguration.Builder() - .setBrowserMatcher( - BrowserAllowList( - VersionedBrowserMatcher.CHROME_CUSTOM_TAB, VersionedBrowserMatcher.SAMSUNG_CUSTOM_TAB - ) - ) - .setConnectionBuilder(insecureConnection) - .setSkipIssuerHttpsCheck(true) .build() init { authorizationService = AuthorizationService(context, appAuthConfiguration) } - private fun getAuthorizationRequestIntent(): Intent = authorizationService.getAuthorizationRequestIntent(getAuthorizationRequest()) + private fun getAuthorizationRequestIntent(clientId: String): Intent = + authorizationService.getAuthorizationRequestIntent(getAuthorizationRequest(clientId)) - fun launch(activityResultRegistry: ActivityResultRegistry, resultHandler: (OAuthResult) -> Unit) { - authState.performActionWithFreshTokens(authorizationService) { _, idToken, exception -> - if (exception != null) { - Log.e("OAuthTokenRefreshManager", "Error refreshing tokens, continue with login!", exception) - launchLoginFlow(activityResultRegistry, resultHandler) - } else { - resultHandler(OAuthResult.Success(idToken.toString(), authState.jsonSerializeString())) + fun launch( + activityResultRegistry: ActivityResultRegistry, + forceLoginFlow: Boolean, + resultHandler: (OAuthResult) -> Unit, + ) { + if (forceLoginFlow) { + launchLoginFlow(activityResultRegistry, resultHandler) + } else { + authState.performActionWithFreshTokens(authorizationService) { _, idToken, exception -> + if (exception != null) { + appLogger.e( + message = "OAuthTokenRefreshManager: Error refreshing tokens, continue with login!", throwable = exception + ) + launchLoginFlow(activityResultRegistry, resultHandler) + } else { + resultHandler( + OAuthResult.Success( + idToken.toString(), authState.jsonSerializeString() + ) + ) + } } } } - private fun launchLoginFlow(activityResultRegistry: ActivityResultRegistry, resultHandler: (OAuthResult) -> Unit) { + private fun launchLoginFlow( + activityResultRegistry: ActivityResultRegistry, + resultHandler: (OAuthResult) -> Unit + ) { val resultLauncher = activityResultRegistry.register( OAUTH_ACTIVITY_RESULT_KEY, ActivityResultContracts.StartActivityForResult() ) { result -> handleActivityResult(result, resultHandler) } + val clientId = URI(authUrl).findParameterValue(CLIENT_ID_QUERY_PARAM) + AuthorizationServiceConfiguration.fetchFromUrl( - Uri.parse(authUrl.plus(IDP_CONFIGURATION_PATH)), - { configuration, ex -> - if (ex == null) { - authServiceConfig = configuration!! - resultLauncher.launch(getAuthorizationRequestIntent()) - } else { - resultHandler(OAuthResult.Failed.InvalidActivityResult("Fetching the configurations failed! $ex")) + Uri.parse(URI(authUrl).removeQueryParams().toString().plus(IDP_CONFIGURATION_PATH)) + ) { configuration, ex -> + if (ex == null) { + authServiceConfig = configuration!! + clientId?.let { + resultLauncher.launch(getAuthorizationRequestIntent(it)) } - }, insecureConnection - ) + } else { + resultHandler(OAuthResult.Failed.InvalidActivityResult("Fetching the configurations failed! $ex")) + } + } } private fun handleActivityResult(result: ActivityResult, resultHandler: (OAuthResult) -> Unit) { @@ -139,7 +127,7 @@ class OAuthUseCase(context: Context, private val authUrl: String, oAuthState: St private fun handleAuthorizationResponse(intent: Intent, resultHandler: (OAuthResult) -> Unit) { val authorizationResponse: AuthorizationResponse? = AuthorizationResponse.fromIntent(intent) - val clientAuth: ClientAuthentication = ClientSecretBasic(CLIENT_SECRET) + val clientAuth: ClientAuthentication = AuthState().clientAuthentication val error = AuthorizationException.fromIntent(intent) @@ -170,14 +158,16 @@ class OAuthUseCase(context: Context, private val authUrl: String, oAuthState: St } ?: resultHandler(OAuthResult.Failed.Unknown) } - private fun getAuthorizationRequest() = AuthorizationRequest.Builder( - authServiceConfig, CLIENT_ID, ResponseTypeValues.CODE, URL_AUTH_REDIRECT + private fun getAuthorizationRequest(clientId: String) = AuthorizationRequest.Builder( + authServiceConfig, clientId, ResponseTypeValues.CODE, URL_AUTH_REDIRECT ).setCodeVerifier().setScopes( AuthorizationRequest.Scope.OPENID, AuthorizationRequest.Scope.EMAIL, AuthorizationRequest.Scope.PROFILE, AuthorizationRequest.Scope.OFFLINE_ACCESS - ).build() + ).setClaims(JSONObject(claims.toString())) + .setPrompt(AuthorizationRequest.Prompt.LOGIN) + .build() private fun AuthorizationRequest.Builder.setCodeVerifier(): AuthorizationRequest.Builder { val codeVerifier = getCodeVerifier() @@ -211,10 +201,7 @@ class OAuthUseCase(context: Context, private val authUrl: String, oAuthState: St companion object { const val OAUTH_ACTIVITY_RESULT_KEY = "OAuthActivityResult" - - // todo: clientId and the clientSecret will be replaced with the values from the BE once the BE provides them - const val CLIENT_ID = "wireapp" - const val CLIENT_SECRET = "dUpVSGx2dVdFdGQ0dmsxWGhDalQ0SldU" + const val CLIENT_ID_QUERY_PARAM = "client_id" const val CODE_VERIFIER_CHALLENGE_METHOD = "S256" const val MESSAGE_DIGEST_ALGORITHM = "SHA-256" val MESSAGE_DIGEST = MessageDigest.getInstance(MESSAGE_DIGEST_ALGORITHM) diff --git a/app/src/main/kotlin/com/wire/android/initializer/InitializerEntryPoint.kt b/app/src/main/kotlin/com/wire/android/initializer/InitializerEntryPoint.kt new file mode 100644 index 00000000000..365ce3eac6b --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/initializer/InitializerEntryPoint.kt @@ -0,0 +1,37 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.initializer + +import android.content.Context +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent + +@EntryPoint +@InstallIn(SingletonComponent::class) +interface InitializerEntryPoint { + + companion object { + // a helper method to resolve the InitializerEntryPoint from the context + fun resolve(context: Context): InitializerEntryPoint { + val appContext = context.applicationContext ?: throw IllegalStateException() + return EntryPointAccessors.fromApplication(appContext, InitializerEntryPoint::class.java) + } + } +} diff --git a/app/src/main/kotlin/com/wire/android/mapper/ContactMapper.kt b/app/src/main/kotlin/com/wire/android/mapper/ContactMapper.kt index 4162e24aa68..91f41a6fcc2 100644 --- a/app/src/main/kotlin/com/wire/android/mapper/ContactMapper.kt +++ b/app/src/main/kotlin/com/wire/android/mapper/ContactMapper.kt @@ -29,6 +29,7 @@ import com.wire.kalium.logic.data.publicuser.model.UserSearchDetails import com.wire.kalium.logic.data.service.ServiceDetails import com.wire.kalium.logic.data.user.ConnectionState import com.wire.kalium.logic.data.user.OtherUser +import com.wire.kalium.logic.data.user.type.UserType import javax.inject.Inject class ContactMapper @@ -76,7 +77,7 @@ class ContactMapper id = id.value, domain = id.domain, name = name ?: String.EMPTY, - label = handle ?: String.EMPTY, + label = mapUserHandle(user), avatarData = UserAvatarData( asset = previewAssetId?.let { ImageAsset.UserAvatarAsset(wireSessionImageLoader, it) } ), @@ -85,4 +86,14 @@ class ContactMapper ) } } + + /** + * Adds the fully qualified handle to the contact label in case of federated users. + */ + private fun mapUserHandle(user: UserSearchDetails): String { + return when (user.type) { + UserType.FEDERATED -> "${user.handle}@${user.id.domain}" + else -> user.handle ?: String.EMPTY + } + } } diff --git a/app/src/main/kotlin/com/wire/android/mapper/MessageMapper.kt b/app/src/main/kotlin/com/wire/android/mapper/MessageMapper.kt index 28436f28bd4..eb79310bca2 100644 --- a/app/src/main/kotlin/com/wire/android/mapper/MessageMapper.kt +++ b/app/src/main/kotlin/com/wire/android/mapper/MessageMapper.kt @@ -55,25 +55,25 @@ class MessageMapper @Inject constructor( ) { fun memberIdList(messages: List): List = messages.flatMap { message -> - listOf(message.senderUserId).plus( - when (message) { - is Message.Regular -> { - when (val failureType = message.deliveryStatus) { - is DeliveryStatus.CompleteDelivery -> listOf() - is DeliveryStatus.PartialDelivery -> - failureType.recipientsFailedDelivery + failureType.recipientsFailedWithNoClients - } + when (message) { + is Message.Regular -> { + when (val failureType = message.deliveryStatus) { + is DeliveryStatus.CompleteDelivery -> listOf() + is DeliveryStatus.PartialDelivery -> + failureType.recipientsFailedDelivery + failureType.recipientsFailedWithNoClients } - is Message.System -> { - when (val content = message.content) { - is MessageContent.MemberChange -> content.members - is MessageContent.LegalHold.ForMembers -> content.members - else -> listOf() - } + } + + is Message.System -> { + when (val content = message.content) { + is MessageContent.MemberChange -> content.members + is MessageContent.LegalHold.ForMembers -> content.members + else -> listOf() } - is Message.Signaling -> listOf() } - ) + + is Message.Signaling -> listOf() + } }.distinct() @Suppress("LongMethod") @@ -170,7 +170,7 @@ class MessageMapper @Inject constructor( when (val status = message.status) { Message.Status.Pending -> MessageFlowStatus.Sending Message.Status.Sent -> MessageFlowStatus.Sent - is Message.Status.Read -> MessageFlowStatus.Read(status.readCount) + is Message.Status.Read -> MessageFlowStatus.Read(status.readCount) Message.Status.Failed -> MessageFlowStatus.Failure.Send.Locally(isMessageEdited) Message.Status.FailedRemotely -> MessageFlowStatus.Failure.Send.Remotely(isMessageEdited, message.conversationId.domain) Message.Status.Delivered -> MessageFlowStatus.Delivered diff --git a/app/src/main/kotlin/com/wire/android/migration/feature/MigrateActiveAccountsUseCase.kt b/app/src/main/kotlin/com/wire/android/migration/feature/MigrateActiveAccountsUseCase.kt index 29b8a5d7ffb..055000e9a0d 100644 --- a/app/src/main/kotlin/com/wire/android/migration/feature/MigrateActiveAccountsUseCase.kt +++ b/app/src/main/kotlin/com/wire/android/migration/feature/MigrateActiveAccountsUseCase.kt @@ -115,7 +115,11 @@ class MigrateActiveAccountsUseCase @Inject constructor( private suspend fun handleMissingData( serverConfig: ServerConfig, refreshToken: String, - ): Either = coreLogic.authenticationScope(serverConfig) { + ): Either = coreLogic.authenticationScope( + serverConfig, + // scala did not support proxy mode so we can pass null + proxyCredentials = null + ) { ssoLoginScope.getLoginSession(refreshToken) }.let { when (it) { diff --git a/app/src/main/kotlin/com/wire/android/migration/feature/MigrateClientsDataUseCase.kt b/app/src/main/kotlin/com/wire/android/migration/feature/MigrateClientsDataUseCase.kt index b4f2378bf82..eb2a9cd3cbc 100644 --- a/app/src/main/kotlin/com/wire/android/migration/feature/MigrateClientsDataUseCase.kt +++ b/app/src/main/kotlin/com/wire/android/migration/feature/MigrateClientsDataUseCase.kt @@ -47,7 +47,7 @@ class MigrateClientsDataUseCase @Inject constructor( private val scalaUserDBProvider: ScalaUserDatabaseProvider, private val userDataStoreProvider: UserDataStoreProvider ) { - @Suppress("ReturnCount") + @Suppress("ReturnCount", "ComplexMethod") suspend operator fun invoke(userId: UserId, isFederated: Boolean): Either = scalaUserDBProvider.clientDAO(userId.value).flatMap { clientDAO -> val clientId = clientDAO.clientInfo()?.clientId?.let { ClientId(it) } @@ -103,6 +103,19 @@ class MigrateClientsDataUseCase @Inject constructor( userDataStoreProvider.getOrCreate(userId).setInitialSyncCompleted() } } + + is RegisterClientResult.E2EICertificateRequired -> + withTimeoutOrNull(SYNC_START_TIMEOUT) { + syncManager.waitUntilStartedOrFailure() + }.let { + it ?: Either.Left(NetworkFailure.NoNetworkConnection(null)) + }.flatMap { + syncManager.waitUntilLiveOrFailure() + .onSuccess { + userDataStoreProvider.getOrCreate(userId).setInitialSyncCompleted() + TODO() // TODO: ask question about this! + } + } } } } diff --git a/app/src/main/kotlin/com/wire/android/migration/feature/MigrateServerConfigUseCase.kt b/app/src/main/kotlin/com/wire/android/migration/feature/MigrateServerConfigUseCase.kt index f2eb55a6690..1c68b612519 100644 --- a/app/src/main/kotlin/com/wire/android/migration/feature/MigrateServerConfigUseCase.kt +++ b/app/src/main/kotlin/com/wire/android/migration/feature/MigrateServerConfigUseCase.kt @@ -27,7 +27,7 @@ import com.wire.kalium.logic.StorageFailure import com.wire.kalium.logic.configuration.server.CommonApiVersionType import com.wire.kalium.logic.configuration.server.ServerConfig import com.wire.kalium.logic.failure.ServerConfigFailure -import com.wire.kalium.logic.feature.server.FetchApiVersionResult +import com.wire.kalium.logic.feature.auth.autoVersioningAuth.AutoVersionAuthScopeUseCase import com.wire.kalium.logic.feature.server.GetServerConfigResult import com.wire.kalium.logic.feature.server.StoreServerConfigResult import com.wire.kalium.logic.functional.Either @@ -66,13 +66,13 @@ class MigrateServerConfigUseCase @Inject constructor( } private suspend fun ServerConfig.Links.fetchApiVersionAndStore(): Either = - coreLogic.getGlobalScope().fetchApiVersion(this).let { // it also already stores the fetched config + // scala did not support proxy mode so we can pass null here + coreLogic.versionedAuthenticationScope(this)(null).let { // it also already stores the fetched config when (it) { - is FetchApiVersionResult.Success -> Either.Right(it.serverConfig) - FetchApiVersionResult.Failure.TooNewVersion -> Either.Left(ServerConfigFailure.NewServerVersion) - FetchApiVersionResult.Failure.UnknownServerVersion -> Either.Left(ServerConfigFailure.UnknownServerVersion) - is FetchApiVersionResult.Failure.Generic -> Either.Left(it.genericFailure) + is AutoVersionAuthScopeUseCase.Result.Failure.Generic -> Either.Left(it.genericFailure) + AutoVersionAuthScopeUseCase.Result.Failure.TooNewVersion -> Either.Left(ServerConfigFailure.NewServerVersion) + AutoVersionAuthScopeUseCase.Result.Failure.UnknownServerVersion -> Either.Left(ServerConfigFailure.UnknownServerVersion) + is AutoVersionAuthScopeUseCase.Result.Success -> Either.Right(it.authenticationScope.currentServerConfig()) } } - } diff --git a/app/src/main/kotlin/com/wire/android/model/ImageAsset.kt b/app/src/main/kotlin/com/wire/android/model/ImageAsset.kt index 02658abf206..b5c78f40e74 100644 --- a/app/src/main/kotlin/com/wire/android/model/ImageAsset.kt +++ b/app/src/main/kotlin/com/wire/android/model/ImageAsset.kt @@ -18,13 +18,13 @@ package com.wire.android.model -import android.net.Uri import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import com.wire.android.util.ui.WireSessionImageLoader import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.data.id.QualifiedIdMapper import com.wire.kalium.logic.data.user.UserAssetId +import okio.Path @Stable sealed class ImageAsset(private val imageLoader: WireSessionImageLoader) { @@ -50,7 +50,7 @@ sealed class ImageAsset(private val imageLoader: WireSessionImageLoader) { @Stable data class LocalImageAsset( private val imageLoader: WireSessionImageLoader, - val dataUri: Uri, + val dataPath: Path, val idKey: String ) : ImageAsset(imageLoader) { diff --git a/app/src/main/kotlin/com/wire/android/notification/NotificationChannelsManager.kt b/app/src/main/kotlin/com/wire/android/notification/NotificationChannelsManager.kt index 12b84a02203..751cc4581bd 100644 --- a/app/src/main/kotlin/com/wire/android/notification/NotificationChannelsManager.kt +++ b/app/src/main/kotlin/com/wire/android/notification/NotificationChannelsManager.kt @@ -18,7 +18,6 @@ package com.wire.android.notification -import android.app.NotificationChannelGroup import android.content.ContentResolver import android.content.Context import android.media.AudioAttributes @@ -26,9 +25,9 @@ import android.net.Uri import android.os.Build import androidx.annotation.RequiresApi import androidx.core.app.NotificationChannelCompat +import androidx.core.app.NotificationChannelGroupCompat import androidx.core.app.NotificationManagerCompat import com.wire.android.appLogger -import com.wire.kalium.logger.obfuscateId import com.wire.kalium.logic.data.user.SelfUser import com.wire.kalium.logic.data.user.UserId import javax.inject.Inject @@ -53,7 +52,10 @@ class NotificationChannelsManager @Inject constructor( ) } - // Creating user-specific NotificationChannels for each user, they will be grouped by User in App Settings. + /** + * Creating user-specific NotificationChannels for each user, they will be grouped by User in App Settings. + * And removing the ChannelGroups (with all the channels in it) that are not belongs to any user in a list (user logged out e.x.) + */ fun createUserNotificationChannels(allUsers: List) { appLogger.i("$TAG: creating all the notification channels for ${allUsers.size} users") if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return @@ -68,15 +70,23 @@ class NotificationChannelsManager @Inject constructor( // OngoingCall is not user specific channel, but common for all users. createOngoingNotificationChannel() + + deleteRedundantChannelGroups(allUsers) } /** - * Deletes NotificationChanelGroup (and all NotificationChannels that belongs to it) for a specific User. - * Use it on logout. + * Deletes NotificationChanelGroup (and all NotificationChannels that belongs to it) + * for the users that are not in [activeUsers] list. */ - fun deleteChannelGroup(userId: UserId) { - appLogger.i("$TAG: deleting notification channels for ${userId.toString().obfuscateId()} user") - notificationManagerCompat.deleteNotificationChannelGroup(NotificationConstants.getChanelGroupIdForUser(userId)) + private fun deleteRedundantChannelGroups(activeUsers: List) { + val groupsToKeep = activeUsers.map { NotificationConstants.getChanelGroupIdForUser(it.id) } + + notificationManagerCompat.notificationChannelGroups + .filter { group -> groupsToKeep.none { it == group.id } } + .forEach { group -> + appLogger.i("$TAG: deleting notification channels for ${group.name} group") + notificationManagerCompat.deleteNotificationChannelGroup(group.id) + } } /** @@ -85,10 +95,9 @@ class NotificationChannelsManager @Inject constructor( @RequiresApi(Build.VERSION_CODES.O) private fun createNotificationChannelGroup(userId: UserId, userName: String): String { val chanelGroupId = NotificationConstants.getChanelGroupIdForUser(userId) - val channelGroup = NotificationChannelGroup( - chanelGroupId, - getChanelGroupNameForUser(userName) - ) + val channelGroup = NotificationChannelGroupCompat.Builder(chanelGroupId) + .setName(getChanelGroupNameForUser(userName)) + .build() notificationManagerCompat.createNotificationChannelGroup(channelGroup) return chanelGroupId } @@ -121,6 +130,7 @@ class NotificationChannelsManager @Inject constructor( .setVibrationEnabled(false) .setImportance(NotificationManagerCompat.IMPORTANCE_DEFAULT) .setSound(null, null) + .setShowBadge(false) .build() notificationManagerCompat.createNotificationChannel(notificationChannel) @@ -156,6 +166,7 @@ class NotificationChannelsManager @Inject constructor( val notificationChannel = NotificationChannelCompat .Builder(channelId, NotificationManagerCompat.IMPORTANCE_HIGH) .setName(channelName) + .setShowBadge(false) .build() notificationManagerCompat.createNotificationChannel(notificationChannel) diff --git a/app/src/main/kotlin/com/wire/android/notification/WireNotificationManager.kt b/app/src/main/kotlin/com/wire/android/notification/WireNotificationManager.kt index aaabb885f29..7bf369a96ff 100644 --- a/app/src/main/kotlin/com/wire/android/notification/WireNotificationManager.kt +++ b/app/src/main/kotlin/com/wire/android/notification/WireNotificationManager.kt @@ -18,6 +18,8 @@ package com.wire.android.notification +import android.os.Build +import androidx.annotation.VisibleForTesting import com.wire.android.R import com.wire.android.appLogger import com.wire.android.di.KaliumCoreLogic @@ -36,6 +38,7 @@ import com.wire.kalium.logic.data.notification.LocalNotificationMessage import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.feature.message.MarkMessagesAsNotifiedUseCase import com.wire.kalium.logic.feature.session.CurrentSessionResult +import com.wire.kalium.logic.feature.session.DoesValidSessionExistResult import com.wire.kalium.logic.feature.session.GetAllSessionsResult import com.wire.kalium.logic.feature.user.E2EIRequiredResult import kotlinx.coroutines.CoroutineScope @@ -244,9 +247,8 @@ class WireNotificationManager @Inject constructor( return } - // start observing notifications only for new users - userIds - .filter { observingJobs.userJobs[it]?.isAllActive() != true } + // start observing notifications only for new users with valid session and without active jobs + newUsersWithValidSessionAndWithoutActiveJobs(userIds) { observingJobs.userJobs[it]?.isAllActive() == true } .forEach { userId -> val jobs = UserObservingJobs( currentScreenJob = scope.launch(dispatcherProvider.default()) { @@ -271,6 +273,20 @@ class WireNotificationManager @Inject constructor( } } + @VisibleForTesting + internal suspend fun newUsersWithValidSessionAndWithoutActiveJobs( + userIds: List, + hasActiveJobs: (UserId) -> Boolean + ): List = userIds + .filter { !hasActiveJobs(it) } + .filter { + // double check if the valid session for the given user still exists + when (val result = coreLogic.getGlobalScope().doesValidSessionExist(it)) { + is DoesValidSessionExistResult.Success -> result.doesValidSessionExist + else -> false + } + } + private fun stopObservingForUser(userId: UserId, observingJobs: ObservingJobs) { messagesNotificationManager.hideAllNotificationsForUser(userId) observingJobs.userJobs[userId]?.cancelAll() @@ -384,7 +400,7 @@ class WireNotificationManager @Inject constructor( private suspend fun observeOngoingCalls(currentScreenState: StateFlow) { currentScreenState .flatMapLatest { currentScreen -> - if (currentScreen !is CurrentScreen.InBackground) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE && currentScreen !is CurrentScreen.InBackground) { flowOf(null) } else { coreLogic.getGlobalScope().session.currentSessionFlow() diff --git a/app/src/main/kotlin/com/wire/android/services/OngoingCallService.kt b/app/src/main/kotlin/com/wire/android/services/OngoingCallService.kt index 1baf0f220a1..d0810c4bb44 100644 --- a/app/src/main/kotlin/com/wire/android/services/OngoingCallService.kt +++ b/app/src/main/kotlin/com/wire/android/services/OngoingCallService.kt @@ -22,6 +22,7 @@ import android.app.Notification import android.app.Service import android.content.Context import android.content.Intent +import android.content.pm.ServiceInfo import android.os.IBinder import com.wire.android.appLogger import com.wire.android.di.KaliumCoreLogic @@ -47,6 +48,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import java.util.concurrent.atomic.AtomicReference import javax.inject.Inject +import androidx.core.app.ServiceCompat @AndroidEntryPoint class OngoingCallService : Service() { @@ -131,17 +133,38 @@ class OngoingCallService : Service() { scope.cancel() } - private fun generateForegroundNotification(callName: String, conversationId: String, userId: UserId) { + private fun generateForegroundNotification( + callName: String, + conversationId: String, + userId: UserId + ) { appLogger.i("$TAG: generating foregroundNotification...") - val notification: Notification = callNotificationManager.builder.getOngoingCallNotification(callName, conversationId, userId) - startForeground(CALL_ONGOING_NOTIFICATION_ID, notification) + val notification: Notification = callNotificationManager.builder.getOngoingCallNotification( + callName, + conversationId, + userId + ) + ServiceCompat.startForeground( + this, + CALL_ONGOING_NOTIFICATION_ID, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE + ) + appLogger.i("$TAG: started foreground with proper notification") } private fun generatePlaceholderForegroundNotification() { appLogger.i("$TAG: generating foregroundNotification placeholder...") - val notification: Notification = callNotificationManager.builder.getOngoingCallPlaceholderNotification() - startForeground(CALL_ONGOING_NOTIFICATION_ID, notification) + val notification: Notification = + callNotificationManager.builder.getOngoingCallPlaceholderNotification() + ServiceCompat.startForeground( + this, + CALL_ONGOING_NOTIFICATION_ID, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE + ) + appLogger.i("$TAG: started foreground with placeholder notification") } diff --git a/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt b/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt index 5e7381dae30..c90046edd39 100644 --- a/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt +++ b/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt @@ -55,6 +55,7 @@ import com.wire.android.BuildConfig import com.wire.android.appLogger import com.wire.android.config.CustomUiConfigurationProvider import com.wire.android.config.LocalCustomUiConfigurationProvider +import com.wire.android.datastore.UserDataStore import com.wire.android.feature.NavigationSwitchAccountActions import com.wire.android.navigation.BackStackMode import com.wire.android.navigation.NavigationCommand @@ -65,7 +66,9 @@ import com.wire.android.ui.calling.ProximitySensorManager import com.wire.android.ui.common.snackbar.LocalSnackbarHostState import com.wire.android.ui.common.topappbar.CommonTopAppBar import com.wire.android.ui.common.topappbar.CommonTopAppBarViewModel +import com.wire.android.ui.common.visbility.rememberVisibilityState import com.wire.android.ui.destinations.ConversationScreenDestination +import com.wire.android.ui.destinations.E2EIEnrollmentScreenDestination import com.wire.android.ui.destinations.E2eiCertificateDetailsScreenDestination import com.wire.android.ui.destinations.HomeScreenDestination import com.wire.android.ui.destinations.ImportMediaScreenDestination @@ -77,6 +80,8 @@ import com.wire.android.ui.destinations.OtherUserProfileScreenDestination import com.wire.android.ui.destinations.SelfDevicesScreenDestination import com.wire.android.ui.destinations.SelfUserProfileScreenDestination import com.wire.android.ui.destinations.WelcomeScreenDestination +import com.wire.android.ui.e2eiEnrollment.GetE2EICertificateUI +import com.wire.android.ui.home.E2EICertificateRevokedDialog import com.wire.android.ui.home.E2EIRequiredDialog import com.wire.android.ui.home.E2EIResultDialog import com.wire.android.ui.home.E2EISnoozeDialog @@ -90,6 +95,8 @@ import com.wire.android.ui.legalhold.dialog.requested.LegalHoldRequestedState import com.wire.android.ui.legalhold.dialog.requested.LegalHoldRequestedViewModel import com.wire.android.ui.theme.ThemeOption import com.wire.android.ui.theme.WireTheme +import com.wire.android.ui.userprofile.self.dialog.LogoutOptionsDialog +import com.wire.android.ui.userprofile.self.dialog.LogoutOptionsDialogState import com.wire.android.util.CurrentScreenManager import com.wire.android.util.LocalSyncStateObserver import com.wire.android.util.SyncStateObserver @@ -97,6 +104,7 @@ import com.wire.android.util.debug.FeatureVisibilityFlags import com.wire.android.util.debug.LocalFeatureVisibilityFlags import com.wire.android.util.deeplink.DeepLinkResult import com.wire.android.util.ui.updateScreenSettings +import dagger.Lazy import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.collectLatest @@ -117,7 +125,7 @@ class WireActivity : AppCompatActivity() { lateinit var proximitySensorManager: ProximitySensorManager @Inject - lateinit var lockCodeTimeManager: LockCodeTimeManager + lateinit var lockCodeTimeManager: Lazy private val viewModel: WireActivityViewModel by viewModels() @@ -133,26 +141,39 @@ class WireActivity : AppCompatActivity() { private var shouldKeepSplashOpen = true override fun onCreate(savedInstanceState: Bundle?) { + + appLogger.i("$TAG splash install") // We need to keep the splash screen open until the first screen is drawn. // Otherwise a white screen is displayed. // It's an API limitation, at some point we may need to remove it - installSplashScreen().setKeepOnScreenCondition { - shouldKeepSplashOpen - } + val splashScreen = installSplashScreen() super.onCreate(savedInstanceState) - proximitySensorManager.initialize() + splashScreen.setKeepOnScreenCondition { shouldKeepSplashOpen } + lifecycle.addObserver(currentScreenManager) WindowCompat.setDecorFitsSystemWindows(window, false) - viewModel.observePersistentConnectionStatus() - val startDestination = when (viewModel.initialAppState) { - InitialAppState.NOT_MIGRATED -> MigrationScreenDestination - InitialAppState.NOT_LOGGED_IN -> WelcomeScreenDestination - InitialAppState.LOGGED_IN -> HomeScreenDestination - } - setComposableContent(startDestination) { - shouldKeepSplashOpen = false - handleDeepLink(intent, savedInstanceState) + appLogger.i("$TAG proximity sensor") + proximitySensorManager.initialize() + + lifecycleScope.launch { + + appLogger.i("$TAG persistent connection status") + viewModel.observePersistentConnectionStatus() + + appLogger.i("$TAG start destination") + val startDestination = when (viewModel.initialAppState) { + InitialAppState.NOT_MIGRATED -> MigrationScreenDestination + InitialAppState.NOT_LOGGED_IN -> WelcomeScreenDestination + InitialAppState.ENROLL_E2EI -> E2EIEnrollmentScreenDestination + InitialAppState.LOGGED_IN -> HomeScreenDestination + } + appLogger.i("$TAG composable content") + setComposableContent(startDestination) { + appLogger.i("$TAG splash hide") + shouldKeepSplashOpen = false + handleDeepLink(intent, savedInstanceState) + } } } @@ -325,12 +346,32 @@ class WireActivity : AppCompatActivity() { hideDialogStatus = featureFlagNotificationViewModel::dismissSelfDeletingMessagesDialog ) } + val logoutOptionsDialogState = rememberVisibilityState() + + LogoutOptionsDialog( + dialogState = logoutOptionsDialogState, + checkboxEnabled = false, + logout = { + viewModel.doHardLogout( + { UserDataStore(context, it) }, + NavigationSwitchAccountActions(navigate) + ) + logoutOptionsDialogState.dismiss() + } + ) + + if (shouldShowE2eiCertificateRevokedDialog) { + E2EICertificateRevokedDialog( + onLogout = { logoutOptionsDialogState.show(LogoutOptionsDialogState(shouldWipeData = true)) }, + onContinue = featureFlagNotificationViewModel::dismissE2EICertificateRevokedDialog, + ) + } e2EIRequired?.let { E2EIRequiredDialog( e2EIRequired = e2EIRequired, isE2EILoading = isE2EILoading, - getCertificate = { featureFlagNotificationViewModel.getE2EICertificate(it, context) }, + getCertificate = featureFlagNotificationViewModel::enrollE2EICertificate, snoozeDialog = featureFlagNotificationViewModel::snoozeE2EIdRequiredDialog ) } @@ -345,9 +386,9 @@ class WireActivity : AppCompatActivity() { e2EIResult?.let { E2EIResultDialog( result = e2EIResult, - updateCertificate = { featureFlagNotificationViewModel.getE2EICertificate(it, context) }, + updateCertificate = featureFlagNotificationViewModel::enrollE2EICertificate, snoozeDialog = featureFlagNotificationViewModel::snoozeE2EIdRequiredDialog, - openCertificateDetails = { navigate(NavigationCommand(E2eiCertificateDetailsScreenDestination(it))) }, + openCertificateDetails = { navigate(NavigationCommand(E2eiCertificateDetailsScreenDestination(it, true))) }, dismissSuccessDialog = featureFlagNotificationViewModel::dismissSuccessE2EIdDialog, isE2EILoading = isE2EILoading ) @@ -399,6 +440,13 @@ class WireActivity : AppCompatActivity() { featureFlagNotificationViewModel::dismissCallEndedBecauseOfConversationDegraded ) } + + if (startGettingE2EICertificate) { + GetE2EICertificateUI( + enrollmentResultHandler = { featureFlagNotificationViewModel.handleE2EIEnrollmentResult(it) }, + isNewClient = false + ) + } } } @@ -412,7 +460,7 @@ class WireActivity : AppCompatActivity() { super.onResume() lifecycleScope.launch { - lockCodeTimeManager.observeAppLock() + lockCodeTimeManager.get().observeAppLock() // Listen to one flow in a lifecycle-aware manner using flowWithLifecycle .flowWithLifecycle(lifecycle, Lifecycle.State.STARTED) .first().let { @@ -506,6 +554,7 @@ class WireActivity : AppCompatActivity() { companion object { private const val HANDLED_DEEPLINK_FLAG = "deeplink_handled_flag_key" + private const val TAG = "WireActivity" } } diff --git a/app/src/main/kotlin/com/wire/android/ui/WireActivityDialogs.kt b/app/src/main/kotlin/com/wire/android/ui/WireActivityDialogs.kt index 180c054480e..5c85033f8b5 100644 --- a/app/src/main/kotlin/com/wire/android/ui/WireActivityDialogs.kt +++ b/app/src/main/kotlin/com/wire/android/ui/WireActivityDialogs.kt @@ -43,7 +43,7 @@ import com.wire.android.ui.joinConversation.JoinConversationViaCodeState import com.wire.android.ui.joinConversation.JoinConversationViaDeepLinkDialog import com.wire.android.ui.joinConversation.JoinConversationViaInviteLinkError import com.wire.android.ui.theme.WireTheme -import com.wire.android.util.formatMediumDateTime +import com.wire.android.util.deviceDateTimeFormat import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.android.util.ui.UIText import com.wire.kalium.logic.configuration.server.ServerConfig @@ -219,8 +219,7 @@ fun CustomBackendDialog( ) { if (globalAppState.customBackendDialog != null) { CustomServerDialog( - serverLinksTitle = globalAppState.customBackendDialog.serverLinks.title, - serverLinksApi = globalAppState.customBackendDialog.serverLinks.api, + serverLinks = globalAppState.customBackendDialog.serverLinks, onDismiss = onDismiss, onConfirm = onConfirm ) @@ -320,7 +319,7 @@ fun NewClientDialog( val devicesList = data.clientsInfo.map { stringResource( R.string.new_device_dialog_message_defice_info, - it.date.formatMediumDateTime() ?: "", + it.date.deviceDateTimeFormat() ?: "", it.deviceInfo.asString() ) }.joinToString("") diff --git a/app/src/main/kotlin/com/wire/android/ui/WireActivityState.kt b/app/src/main/kotlin/com/wire/android/ui/WireActivityState.kt index db8e2921fa5..4916122814c 100644 --- a/app/src/main/kotlin/com/wire/android/ui/WireActivityState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/WireActivityState.kt @@ -18,10 +18,11 @@ package com.wire.android.ui -sealed class WireActivityState { +sealed class +WireActivityState { - data class NavigationGraph(val startNavigationRoute: String, val navigationArguments: List): WireActivityState() - data class ClientUpdateRequired(val clientUpdateUrl: String): WireActivityState() - object ServerVersionNotSupported: WireActivityState() - object Loading: WireActivityState() + data class NavigationGraph(val startNavigationRoute: String, val navigationArguments: List) : WireActivityState() + data class ClientUpdateRequired(val clientUpdateUrl: String) : WireActivityState() + object ServerVersionNotSupported : WireActivityState() + object Loading : WireActivityState() } diff --git a/app/src/main/kotlin/com/wire/android/ui/WireActivityViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/WireActivityViewModel.kt index 5d934eb6b93..12ba8586963 100644 --- a/app/src/main/kotlin/com/wire/android/ui/WireActivityViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/WireActivityViewModel.kt @@ -24,16 +24,19 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import androidx.work.WorkManager import com.wire.android.BuildConfig import com.wire.android.appLogger import com.wire.android.datastore.GlobalDataStore import com.wire.android.di.AuthServerConfigProvider import com.wire.android.di.KaliumCoreLogic +import com.wire.android.di.ObserveIfE2EIRequiredDuringLoginUseCaseProvider import com.wire.android.di.ObserveScreenshotCensoringConfigUseCaseProvider import com.wire.android.di.ObserveSyncStateUseCaseProvider import com.wire.android.feature.AccountSwitchUseCase import com.wire.android.feature.SwitchAccountActions import com.wire.android.feature.SwitchAccountParam +import com.wire.android.feature.SwitchAccountResult import com.wire.android.migration.MigrationManager import com.wire.android.services.ServicesManager import com.wire.android.ui.authentication.devices.model.displayName @@ -46,6 +49,8 @@ import com.wire.android.util.deeplink.DeepLinkProcessor import com.wire.android.util.deeplink.DeepLinkResult import com.wire.android.util.dispatchers.DispatcherProvider import com.wire.android.util.ui.UIText +import com.wire.android.workmanager.worker.cancelPeriodicPersistentWebsocketCheckWorker +import com.wire.android.workmanager.worker.enqueuePeriodicPersistentWebsocketCheckWorker import com.wire.kalium.logic.CoreLogic import com.wire.kalium.logic.configuration.server.ServerConfig import com.wire.kalium.logic.data.auth.AccountInfo @@ -110,6 +115,8 @@ class WireActivityViewModel @Inject constructor( private val currentScreenManager: CurrentScreenManager, private val observeScreenshotCensoringConfigUseCaseProviderFactory: ObserveScreenshotCensoringConfigUseCaseProvider.Factory, private val globalDataStore: GlobalDataStore, + private val observeIfE2EIRequiredDuringLoginUseCaseProviderFactory: ObserveIfE2EIRequiredDuringLoginUseCaseProvider.Factory, + private val workManager: WorkManager, ) : ViewModel() { var globalAppState: GlobalAppState by mutableStateOf(GlobalAppState()) @@ -142,6 +149,13 @@ class WireActivityViewModel @Inject constructor( private val _observeSyncFlowState: MutableStateFlow = MutableStateFlow(null) val observeSyncFlowState: StateFlow = _observeSyncFlowState + private val observeE2EIState = observeUserId + .flatMapLatest { + it?.let { observeIfE2EIRequiredDuringLoginUseCaseProviderFactory.create(it).observeIfE2EIIsRequiredDuringLogin() } + ?: flowOf(null) + } + .distinctUntilChanged() + init { observeSyncState() observeUpdateAppState() @@ -172,7 +186,7 @@ class WireActivityViewModel @Inject constructor( } private fun observeUpdateAppState() { - viewModelScope.launch(dispatchers.io()) { + viewModelScope.launch { observeIfAppUpdateRequired(BuildConfig.VERSION_CODE) .distinctUntilChanged() .collect { @@ -233,6 +247,7 @@ class WireActivityViewModel @Inject constructor( get() = when { shouldMigrate() -> InitialAppState.NOT_MIGRATED shouldLogIn() -> InitialAppState.NOT_LOGGED_IN + blockedByE2EI() -> InitialAppState.ENROLL_E2EI else -> InitialAppState.LOGGED_IN } @@ -263,8 +278,10 @@ class WireActivityViewModel @Inject constructor( // to handle the deepLinks above user needs to be Logged in // do nothing, already handled by initialAppState } + result is DeepLinkResult.JoinConversation -> onConversationInviteDeepLink(result.code, result.key, result.domain, onOpenConversation) + result != null -> onResult(result) result is DeepLinkResult.Unknown -> appLogger.e("unknown deeplink result $result") } @@ -289,6 +306,27 @@ class WireActivityViewModel @Inject constructor( } } + // TODO: needs to be covered with test once hard logout is validated to be used + fun doHardLogout( + clearUserData: (userId: UserId) -> Unit, + switchAccountActions: SwitchAccountActions + ) { + viewModelScope.launch { + coreLogic.getGlobalScope().session.currentSession().takeIf { + it is CurrentSessionResult.Success + }?.let { + val currentUserId = (it as CurrentSessionResult.Success).accountInfo.userId + coreLogic.getSessionScope(currentUserId).logout(LogoutReason.SELF_HARD_LOGOUT) + clearUserData(currentUserId) + } + accountSwitch(SwitchAccountParam.TryToSwitchToNextAccount).also { + if (it == SwitchAccountResult.NoOtherAccountToSwitch) { + globalDataStore.clearAppLockPasscode() + } + }.callAction(switchAccountActions) + } + } + fun dismissNewClientsDialog(userId: UserId) { globalAppState = globalAppState.copy(newClientDialog = null) viewModelScope.launch { @@ -391,6 +429,10 @@ class WireActivityViewModel @Inject constructor( fun shouldLogIn(): Boolean = !hasValidCurrentSession() + private fun blockedByE2EI(): Boolean = runBlocking { + observeE2EIState.first() ?: false + } + private fun hasValidCurrentSession(): Boolean = runBlocking { // TODO: the usage of currentSessionFlow is a temporary solution, it should be replaced with a proper solution currentSessionFlow().first().let { @@ -424,9 +466,11 @@ class WireActivityViewModel @Inject constructor( if (statuses.any { it.isPersistentWebSocketEnabled }) { if (!servicesManager.isPersistentWebSocketServiceRunning()) { servicesManager.startPersistentWebSocketService() + workManager.enqueuePeriodicPersistentWebsocketCheckWorker() } } else { servicesManager.stopPersistentWebSocketService() + workManager.cancelPeriodicPersistentWebsocketCheckWorker() } } } @@ -510,5 +554,5 @@ data class GlobalAppState( ) enum class InitialAppState { - NOT_MIGRATED, NOT_LOGGED_IN, LOGGED_IN + NOT_MIGRATED, NOT_LOGGED_IN, LOGGED_IN, ENROLL_E2EI } diff --git a/app/src/main/kotlin/com/wire/android/ui/authentication/ServerTitle.kt b/app/src/main/kotlin/com/wire/android/ui/authentication/ServerTitle.kt index da422d39476..d8524960afe 100644 --- a/app/src/main/kotlin/com/wire/android/ui/authentication/ServerTitle.kt +++ b/app/src/main/kotlin/com/wire/android/ui/authentication/ServerTitle.kt @@ -44,9 +44,11 @@ import com.wire.android.ui.common.WireDialogButtonType import com.wire.android.ui.common.clickable import com.wire.android.ui.common.colorsScheme import com.wire.android.ui.common.dimensions +import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireDimensions import com.wire.android.ui.theme.wireTypography +import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.android.util.ui.stringWithStyledArgs import com.wire.kalium.logic.configuration.server.ServerConfig import java.net.URL @@ -93,24 +95,62 @@ fun ServerTitle( ) if (serverFullDetailsDialogState) { - WireDialog( - title = stringResource(id = R.string.server_details_dialog_title), - text = LocalContext.current.resources.stringWithStyledArgs( - R.string.server_details_dialog_body, - MaterialTheme.wireTypography.body02, - MaterialTheme.wireTypography.body02, - normalColor = colorsScheme().secondaryText, - argsColor = colorsScheme().onBackground, - serverLinks.title, - serverLinks.api - ), - onDismiss = { serverFullDetailsDialogState = false }, - optionButton1Properties = WireDialogButtonProperties( - stringResource(id = R.string.label_ok), - onClick = { serverFullDetailsDialogState = false }, - type = WireDialogButtonType.Primary - ) + ServerEnrollmentDialogContent( + serverLinks = serverLinks, + onClick = { serverFullDetailsDialogState = false }, + onDismiss = { serverFullDetailsDialogState = false } ) } } } + +@Composable +private fun ServerEnrollmentDialogContent( + serverLinks: ServerConfig.Links, + onDismiss: () -> Unit, + onClick: () -> Unit, +) { + val text = if (serverLinks.apiProxy == null) { + LocalContext.current.resources.stringWithStyledArgs( + R.string.server_details_dialog_body, + MaterialTheme.wireTypography.body02, + MaterialTheme.wireTypography.body02, + normalColor = colorsScheme().secondaryText, + argsColor = colorsScheme().onBackground, + serverLinks.title, + serverLinks.api + ) + } else { + LocalContext.current.resources.stringWithStyledArgs( + R.string.server_details_dialog_body_with_proxy, + MaterialTheme.wireTypography.body02, + MaterialTheme.wireTypography.body02, + normalColor = colorsScheme().secondaryText, + argsColor = colorsScheme().onBackground, + serverLinks.title, + serverLinks.api, + serverLinks.apiProxy!!.host, + serverLinks.apiProxy!!.needsAuthentication.toString() + ) + } + WireDialog( + title = stringResource(id = R.string.server_details_dialog_title), + text = text, + onDismiss = onDismiss, + optionButton1Properties = WireDialogButtonProperties( + stringResource(id = R.string.label_ok), + onClick = onClick, + type = WireDialogButtonType.Primary + ) + ) +} + +@PreviewMultipleThemes +@Composable +fun PreviewServerEnrollmentDialog() = WireTheme { + ServerEnrollmentDialogContent( + serverLinks = ServerConfig.DEFAULT, + onClick = { }, + onDismiss = { } + ) +} diff --git a/app/src/main/kotlin/com/wire/android/ui/authentication/create/code/CreateAccountCodeViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/authentication/create/code/CreateAccountCodeViewModel.kt index d8b8fda010c..7aed2837927 100644 --- a/app/src/main/kotlin/com/wire/android/ui/authentication/create/code/CreateAccountCodeViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/authentication/create/code/CreateAccountCodeViewModel.kt @@ -70,7 +70,8 @@ class CreateAccountCodeViewModel @Inject constructor( fun resendCode() { codeState = codeState.copy(loading = true) viewModelScope.launch { - val authScope = coreLogic.versionedAuthenticationScope(serverConfig)().let { + // create account does not support proxy yet + val authScope = coreLogic.versionedAuthenticationScope(serverConfig)(null).let { when (it) { is AutoVersionAuthScopeUseCase.Result.Success -> it.authenticationScope @@ -129,7 +130,8 @@ class CreateAccountCodeViewModel @Inject constructor( private fun onCodeContinue(onSuccess: () -> Unit) { codeState = codeState.copy(loading = true) viewModelScope.launch { - val authScope = coreLogic.versionedAuthenticationScope(serverConfig)().let { + // create account does not support proxy yet + val authScope = coreLogic.versionedAuthenticationScope(serverConfig)(null).let { when (it) { is AutoVersionAuthScopeUseCase.Result.Success -> it.authenticationScope @@ -187,6 +189,11 @@ class CreateAccountCodeViewModel @Inject constructor( is RegisterClientResult.Success -> { onSuccess() } + + is RegisterClientResult.E2EICertificateRequired -> { + // TODO + onSuccess() + } } } } diff --git a/app/src/main/kotlin/com/wire/android/ui/authentication/create/email/CreateAccountEmailViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/authentication/create/email/CreateAccountEmailViewModel.kt index 75fb10548db..5656091bdc5 100644 --- a/app/src/main/kotlin/com/wire/android/ui/authentication/create/email/CreateAccountEmailViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/authentication/create/email/CreateAccountEmailViewModel.kt @@ -33,8 +33,6 @@ import com.wire.kalium.logic.configuration.server.ServerConfig import com.wire.kalium.logic.feature.auth.ValidateEmailUseCase import com.wire.kalium.logic.feature.auth.autoVersioningAuth.AutoVersionAuthScopeUseCase import com.wire.kalium.logic.feature.register.RequestActivationCodeResult -import com.wire.kalium.logic.feature.server.FetchApiVersionResult -import com.wire.kalium.logic.feature.server.FetchApiVersionUseCase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch import javax.inject.Inject @@ -44,7 +42,6 @@ import javax.inject.Inject class CreateAccountEmailViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val authServerConfigProvider: AuthServerConfigProvider, - private val fetchApiVersion: FetchApiVersionUseCase, private val validateEmail: ValidateEmailUseCase, @KaliumCoreLogic private val coreLogic: CoreLogic, ) : ViewModel() { @@ -69,25 +66,6 @@ class CreateAccountEmailViewModel @Inject constructor( fun onEmailContinue(onSuccess: () -> Unit) { emailState = emailState.copy(loading = true, continueEnabled = false) viewModelScope.launch { - fetchApiVersion(authServerConfigProvider.authServer.value).let { - when (it) { - is FetchApiVersionResult.Success -> {} - is FetchApiVersionResult.Failure.UnknownServerVersion -> { - emailState = emailState.copy(showServerVersionNotSupportedDialog = true) - return@launch - } - - is FetchApiVersionResult.Failure.TooNewVersion -> { - emailState = emailState.copy(showClientUpdateDialog = true) - return@launch - } - - is FetchApiVersionResult.Failure.Generic -> { - return@launch - } - } - } - val emailError = if (validateEmail(emailState.email.text.trim().lowercase())) CreateAccountEmailViewState.EmailError.None else CreateAccountEmailViewState.EmailError.TextFieldError.InvalidEmailError @@ -106,7 +84,7 @@ class CreateAccountEmailViewModel @Inject constructor( fun onTermsAccept(onSuccess: () -> Unit) { emailState = emailState.copy(loading = true, continueEnabled = false, termsDialogVisible = false, termsAccepted = true) viewModelScope.launch { - val authScope = coreLogic.versionedAuthenticationScope(serverConfig)().let { + val authScope = coreLogic.versionedAuthenticationScope(serverConfig)(null).let { when (it) { is AutoVersionAuthScopeUseCase.Result.Success -> it.authenticationScope diff --git a/app/src/main/kotlin/com/wire/android/ui/authentication/devices/DeviceItem.kt b/app/src/main/kotlin/com/wire/android/ui/authentication/devices/DeviceItem.kt index d0e598ef275..2b19ebb5c43 100644 --- a/app/src/main/kotlin/com/wire/android/ui/authentication/devices/DeviceItem.kt +++ b/app/src/main/kotlin/com/wire/android/ui/authentication/devices/DeviceItem.kt @@ -20,17 +20,19 @@ package com.wire.android.ui.authentication.devices import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.calculateEndPadding import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -40,22 +42,20 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.wire.android.BuildConfig import com.wire.android.R import com.wire.android.ui.authentication.devices.model.Device import com.wire.android.ui.authentication.devices.model.lastActiveDescription +import com.wire.android.ui.common.Icon import com.wire.android.ui.common.MLSVerificationIcon import com.wire.android.ui.common.ProteusVerifiedIcon import com.wire.android.ui.common.button.WireSecondaryButton -import com.wire.android.ui.common.button.getMinTouchMargins import com.wire.android.ui.common.button.wireSecondaryButtonColors import com.wire.android.ui.common.shimmerPlaceholder import com.wire.android.ui.theme.WireTheme @@ -64,7 +64,7 @@ import com.wire.android.ui.theme.wireDimensions import com.wire.android.ui.theme.wireTypography import com.wire.android.util.extension.formatAsFingerPrint import com.wire.android.util.extension.formatAsString -import com.wire.android.util.formatMediumDateTime +import com.wire.android.util.deviceDateTimeFormat import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.android.util.ui.UIText @@ -73,21 +73,23 @@ fun DeviceItem( device: Device, placeholder: Boolean, shouldShowVerifyLabel: Boolean, + isCurrentClient: Boolean = false, + shouldShowE2EIInfo: Boolean = false, background: Color? = null, - leadingIcon: @Composable (() -> Unit), - leadingIconBorder: Dp = 1.dp, + icon: @Composable (() -> Unit), isWholeItemClickable: Boolean = false, - onRemoveDeviceClick: ((Device) -> Unit)? = null + onClickAction: ((Device) -> Unit)? = null ) { DeviceItemContent( device = device, placeholder = placeholder, background = background, - leadingIcon = leadingIcon, - leadingIconBorder = leadingIconBorder, - onRemoveDeviceClick = onRemoveDeviceClick, + icon = icon, + onClickAction = onClickAction, isWholeItemClickable = isWholeItemClickable, - shouldShowVerifyLabel = shouldShowVerifyLabel + shouldShowVerifyLabel = shouldShowVerifyLabel, + isCurrentClient = isCurrentClient, + shouldShowE2EIInfo = shouldShowE2EIInfo ) } @@ -96,18 +98,19 @@ private fun DeviceItemContent( device: Device, placeholder: Boolean, background: Color? = null, - leadingIcon: @Composable (() -> Unit), - leadingIconBorder: Dp, - onRemoveDeviceClick: ((Device) -> Unit)?, + icon: @Composable (() -> Unit), + onClickAction: ((Device) -> Unit)?, isWholeItemClickable: Boolean, - shouldShowVerifyLabel: Boolean + shouldShowVerifyLabel: Boolean, + isCurrentClient: Boolean, + shouldShowE2EIInfo: Boolean ) { Row( verticalAlignment = Alignment.Top, modifier = (if (background != null) Modifier.background(color = background) else Modifier) .clickable(enabled = isWholeItemClickable) { if (isWholeItemClickable) { - onRemoveDeviceClick?.invoke(device) + onClickAction?.invoke(device) } } ) { @@ -126,32 +129,28 @@ private fun DeviceItemContent( modifier = Modifier .padding(start = MaterialTheme.wireDimensions.removeDeviceItemPadding) .weight(1f) - ) { DeviceItemTexts(device, placeholder, shouldShowVerifyLabel) } + ) { DeviceItemTexts(device, placeholder, shouldShowVerifyLabel, isCurrentClient, shouldShowE2EIInfo) } } - val (buttonTopPadding, buttonEndPadding) = getMinTouchMargins(minSize = MaterialTheme.wireDimensions.buttonSmallMinSize) - .let { - // default button touch area [48x48] is higher than button size [40x32] so it will have margins, we have to subtract - // these margins from the default item padding so that all elements are the same distance from the edge - Pair( - MaterialTheme.wireDimensions.removeDeviceItemPadding - it.calculateTopPadding(), - MaterialTheme.wireDimensions.removeDeviceItemPadding - it.calculateEndPadding(LocalLayoutDirection.current) + if (!placeholder) { + if (onClickAction != null && !isWholeItemClickable) { + WireSecondaryButton( + modifier = Modifier.testTag("remove device button"), + onClick = { onClickAction(device) }, + leadingIcon = icon, + fillMaxWidth = false, + minSize = MaterialTheme.wireDimensions.buttonSmallMinSize, + minClickableSize = MaterialTheme.wireDimensions.buttonMinClickableSize, + shape = RoundedCornerShape(size = MaterialTheme.wireDimensions.buttonSmallCornerSize), + contentPadding = PaddingValues(0.dp), + colors = wireSecondaryButtonColors().copy( + enabled = background ?: MaterialTheme.wireColorScheme.secondaryButtonEnabled + ) ) + } else { + Box(modifier = Modifier.padding(MaterialTheme.wireDimensions.removeDeviceItemPadding)) { + icon() + } } - if (!placeholder && onRemoveDeviceClick != null) { - WireSecondaryButton( - modifier = Modifier.testTag("remove device button"), - onClick = { onRemoveDeviceClick(device) }, - leadingIcon = leadingIcon, - fillMaxWidth = false, - minSize = MaterialTheme.wireDimensions.buttonSmallMinSize, - minClickableSize = MaterialTheme.wireDimensions.buttonMinClickableSize, - shape = RoundedCornerShape(size = MaterialTheme.wireDimensions.buttonSmallCornerSize), - contentPadding = PaddingValues(0.dp), - borderWidth = leadingIconBorder, - colors = wireSecondaryButtonColors().copy( - enabled = background ?: MaterialTheme.wireColorScheme.secondaryButtonEnabled - ) - ) } } } @@ -161,6 +160,8 @@ private fun DeviceItemTexts( device: Device, placeholder: Boolean, shouldShowVerifyLabel: Boolean, + isCurrentClient: Boolean = false, + shouldShowE2EIInfo: Boolean = false, isDebug: Boolean = BuildConfig.DEBUG ) { val displayZombieIndicator = remember { @@ -180,10 +181,15 @@ private fun DeviceItemTexts( .wrapContentWidth() .shimmerPlaceholder(visible = placeholder) ) - MLSVerificationIcon(device.e2eiCertificateStatus) if (shouldShowVerifyLabel) { + if (shouldShowE2EIInfo) { + MLSVerificationIcon(device.e2eiCertificateStatus) + } Spacer(modifier = Modifier.width(MaterialTheme.wireDimensions.spacing8x)) - if (device.isVerifiedProteus) ProteusVerifiedIcon(Modifier.wrapContentWidth().align(Alignment.CenterVertically)) + if (device.isVerifiedProteus && !isCurrentClient) ProteusVerifiedIcon( + Modifier + .wrapContentWidth() + .align(Alignment.CenterVertically)) } } @@ -221,14 +227,14 @@ private fun DeviceItemTexts( stringResource( R.string.remove_device_id_and_time_label_active_label, device.clientId.formatAsString(), - device.registrationTime.formatMediumDateTime() ?: "", + device.registrationTime.deviceDateTimeFormat() ?: "", device.lastActiveDescription() ?: "" ) } else { stringResource( R.string.remove_device_id_and_time_label, device.clientId.formatAsString(), - device.registrationTime.formatMediumDateTime() ?: "" + device.registrationTime.deviceDateTimeFormat() ?: "" ) } } else { @@ -249,14 +255,31 @@ private fun DeviceItemTexts( @PreviewMultipleThemes @Composable -fun PreviewDeviceItem() { +fun PreviewDeviceItemWithActionIcon() { WireTheme { DeviceItem( device = Device(name = UIText.DynamicString("name"), isVerifiedProteus = true), placeholder = false, shouldShowVerifyLabel = true, + isCurrentClient = true, + shouldShowE2EIInfo = true, background = null, { Icon(painter = painterResource(id = R.drawable.ic_remove), contentDescription = "") } ) {} } } + +@PreviewMultipleThemes +@Composable +fun PreviewDeviceItem() { + WireTheme { + DeviceItem( + device = Device(name = UIText.DynamicString("name"), isVerifiedProteus = true), + placeholder = false, + shouldShowVerifyLabel = true, + background = null, + isWholeItemClickable = true, + icon = Icons.Filled.ChevronRight.Icon() + ) {} + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/authentication/devices/model/Device.kt b/app/src/main/kotlin/com/wire/android/ui/authentication/devices/model/Device.kt index 1e8e7c3122e..4f5e3911eb0 100644 --- a/app/src/main/kotlin/com/wire/android/ui/authentication/devices/model/Device.kt +++ b/app/src/main/kotlin/com/wire/android/ui/authentication/devices/model/Device.kt @@ -51,6 +51,20 @@ data class Device( mlsPublicKeys = client.mlsPublicKeys, e2eiCertificateStatus = e2eiCertificateStatus ) + + fun updateFromClient(client: Client): Device = copy( + name = client.displayName(), + clientId = client.id, + registrationTime = client.registrationTime?.toIsoDateTimeString(), + lastActiveInWholeWeeks = client.lastActiveInWholeWeeks(), + isValid = client.isValid, + isVerifiedProteus = client.isVerified, + mlsPublicKeys = client.mlsPublicKeys, + ) + + fun updateE2EICertificateStatus(e2eiCertificateStatus: CertificateStatus): Device = copy( + e2eiCertificateStatus = e2eiCertificateStatus + ) } /** diff --git a/app/src/main/kotlin/com/wire/android/ui/authentication/devices/register/RegisterDeviceScreen.kt b/app/src/main/kotlin/com/wire/android/ui/authentication/devices/register/RegisterDeviceScreen.kt index 72c89dbeaf3..ba16c4bb82c 100644 --- a/app/src/main/kotlin/com/wire/android/ui/authentication/devices/register/RegisterDeviceScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/authentication/devices/register/RegisterDeviceScreen.kt @@ -61,6 +61,7 @@ import com.wire.android.ui.common.textfield.clearAutofillTree import com.wire.android.ui.common.topappbar.NavigationIconType import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar import com.wire.android.ui.common.visbility.rememberVisibilityState +import com.wire.android.ui.destinations.E2EIEnrollmentScreenDestination import com.wire.android.ui.destinations.HomeScreenDestination import com.wire.android.ui.destinations.InitialSyncScreenDestination import com.wire.android.ui.destinations.RemoveDeviceScreenDestination @@ -81,11 +82,14 @@ fun RegisterDeviceScreen(navigator: Navigator) { is RegisterDeviceFlowState.Success -> { navigator.navigate( NavigationCommand( - destination = if (flowState.initialSyncCompleted) HomeScreenDestination else InitialSyncScreenDestination, + destination = if (flowState.isE2EIRequired) E2EIEnrollmentScreenDestination + else if (flowState.initialSyncCompleted) HomeScreenDestination + else InitialSyncScreenDestination, backStackMode = BackStackMode.CLEAR_WHOLE ) ) } + is RegisterDeviceFlowState.TooManyDevices -> navigator.navigate(NavigationCommand(RemoveDeviceScreenDestination)) else -> RegisterDeviceContent( @@ -189,6 +193,7 @@ private fun PasswordTextField(state: RegisterDeviceState, onPasswordChange: (Tex state = when (state.flowState) { is RegisterDeviceFlowState.Error.InvalidCredentialsError -> WireTextFieldState.Error(stringResource(id = R.string.remove_device_invalid_password)) + else -> WireTextFieldState.Default }, imeAction = ImeAction.Done, diff --git a/app/src/main/kotlin/com/wire/android/ui/authentication/devices/register/RegisterDeviceState.kt b/app/src/main/kotlin/com/wire/android/ui/authentication/devices/register/RegisterDeviceState.kt index c0169ccdc8c..766959596da 100644 --- a/app/src/main/kotlin/com/wire/android/ui/authentication/devices/register/RegisterDeviceState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/authentication/devices/register/RegisterDeviceState.kt @@ -20,17 +20,26 @@ package com.wire.android.ui.authentication.devices.register import androidx.compose.ui.text.input.TextFieldValue import com.wire.kalium.logic.CoreFailure +import com.wire.kalium.logic.data.conversation.ClientId +import com.wire.kalium.logic.data.user.UserId data class RegisterDeviceState( val password: TextFieldValue = TextFieldValue(""), val continueEnabled: Boolean = false, val flowState: RegisterDeviceFlowState = RegisterDeviceFlowState.Default ) + sealed class RegisterDeviceFlowState { object Default : RegisterDeviceFlowState() object Loading : RegisterDeviceFlowState() object TooManyDevices : RegisterDeviceFlowState() - data class Success(val initialSyncCompleted: Boolean) : RegisterDeviceFlowState() + data class Success( + val initialSyncCompleted: Boolean, + val isE2EIRequired: Boolean, + val clientId: ClientId, + val userId: UserId? = null + ) : RegisterDeviceFlowState() + sealed class Error : RegisterDeviceFlowState() { object InvalidCredentialsError : Error() data class GenericError(val coreFailure: CoreFailure) : Error() diff --git a/app/src/main/kotlin/com/wire/android/ui/authentication/devices/register/RegisterDeviceViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/authentication/devices/register/RegisterDeviceViewModel.kt index bc9ac87c013..b7d0354d7f3 100644 --- a/app/src/main/kotlin/com/wire/android/ui/authentication/devices/register/RegisterDeviceViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/authentication/devices/register/RegisterDeviceViewModel.kt @@ -83,8 +83,25 @@ class RegisterDeviceViewModel @Inject constructor( )) { is RegisterClientResult.Failure.TooManyClients -> updateFlowState(RegisterDeviceFlowState.TooManyDevices) + is RegisterClientResult.Success -> - updateFlowState(RegisterDeviceFlowState.Success(userDataStore.initialSyncCompleted.first())) + updateFlowState( + RegisterDeviceFlowState.Success( + userDataStore.initialSyncCompleted.first(), + false, + registerDeviceResult.client.id + ) + ) + + is RegisterClientResult.E2EICertificateRequired -> + updateFlowState( + RegisterDeviceFlowState.Success( + userDataStore.initialSyncCompleted.first(), + true, + registerDeviceResult.client.id, + registerDeviceResult.userId + ) + ) is RegisterClientResult.Failure.Generic -> state = state.copy( continueEnabled = true, diff --git a/app/src/main/kotlin/com/wire/android/ui/authentication/devices/remove/RemoveDeviceDialog.kt b/app/src/main/kotlin/com/wire/android/ui/authentication/devices/remove/RemoveDeviceDialog.kt index 817ec16122b..bcd7afae6c4 100644 --- a/app/src/main/kotlin/com/wire/android/ui/authentication/devices/remove/RemoveDeviceDialog.kt +++ b/app/src/main/kotlin/com/wire/android/ui/authentication/devices/remove/RemoveDeviceDialog.kt @@ -41,7 +41,7 @@ import com.wire.android.ui.common.button.WireButtonState import com.wire.android.ui.common.textfield.WirePasswordTextField import com.wire.android.ui.common.textfield.WireTextFieldState import com.wire.android.ui.theme.wireDimensions -import com.wire.android.util.formatMediumDateTime +import com.wire.android.util.deviceDateTimeFormat @OptIn(ExperimentalComposeUiApi::class) @Composable @@ -63,7 +63,7 @@ fun RemoveDeviceDialog( stringResource( R.string.remove_device_id_and_time_label, state.device.clientId.value, - state.device.registrationTime?.formatMediumDateTime() ?: "" + state.device.registrationTime?.deviceDateTimeFormat() ?: "" ), onDismiss = onDialogDismissHideKeyboard, dismissButtonProperties = WireDialogButtonProperties( diff --git a/app/src/main/kotlin/com/wire/android/ui/authentication/devices/remove/RemoveDeviceScreen.kt b/app/src/main/kotlin/com/wire/android/ui/authentication/devices/remove/RemoveDeviceScreen.kt index 1e8f40bdbfe..ae337f60e78 100644 --- a/app/src/main/kotlin/com/wire/android/ui/authentication/devices/remove/RemoveDeviceScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/authentication/devices/remove/RemoveDeviceScreen.kt @@ -58,6 +58,7 @@ import com.wire.android.ui.common.divider.WireDivider import com.wire.android.ui.common.rememberTopBarElevationState import com.wire.android.ui.common.textfield.clearAutofillTree import com.wire.android.ui.common.visbility.rememberVisibilityState +import com.wire.android.ui.destinations.E2EIEnrollmentScreenDestination import com.wire.android.ui.destinations.HomeScreenDestination import com.wire.android.ui.destinations.InitialSyncScreenDestination import com.wire.android.util.dialogErrorStrings @@ -73,9 +74,11 @@ fun RemoveDeviceScreen(navigator: Navigator) { val state: RemoveDeviceState = viewModel.state val clearSessionState: ClearSessionState = clearSessionViewModel.state - fun navigateAfterSuccess(initialSyncCompleted: Boolean) = navigator.navigate( + fun navigateAfterSuccess(initialSyncCompleted: Boolean, isE2EIRequired: Boolean) = navigator.navigate( NavigationCommand( - destination = if (initialSyncCompleted) HomeScreenDestination else InitialSyncScreenDestination, + destination = if (isE2EIRequired) E2EIEnrollmentScreenDestination + else if (initialSyncCompleted) HomeScreenDestination + else InitialSyncScreenDestination, backStackMode = BackStackMode.CLEAR_WHOLE ) ) @@ -84,9 +87,9 @@ fun RemoveDeviceScreen(navigator: Navigator) { RemoveDeviceContent( state = state, clearSessionState = clearSessionState, - onItemClicked = { viewModel.onItemClicked(it) { navigateAfterSuccess(it) } }, + onItemClicked = { viewModel.onItemClicked(it, ::navigateAfterSuccess) }, onPasswordChange = viewModel::onPasswordChange, - onRemoveConfirm = { viewModel.onRemoveConfirmed { navigateAfterSuccess(it) } }, + onRemoveConfirm = { viewModel.onRemoveConfirmed(::navigateAfterSuccess) }, onDialogDismiss = viewModel::onDialogDismissed, onErrorDialogDismiss = viewModel::clearDeleteClientError, onBackButtonClicked = clearSessionViewModel::onBackButtonClicked, @@ -185,9 +188,9 @@ private fun RemoveDeviceItemsList( DeviceItem( device = device, placeholder = placeholders, - onRemoveDeviceClick = onItemClicked, + onClickAction = onItemClicked, shouldShowVerifyLabel = false, - leadingIcon = { + icon = { Icon( painterResource(id = R.drawable.ic_remove), stringResource(R.string.content_description_remove_devices_screen_remove_icon) diff --git a/app/src/main/kotlin/com/wire/android/ui/authentication/devices/remove/RemoveDeviceViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/authentication/devices/remove/RemoveDeviceViewModel.kt index 44422ce4613..ceb0f3b653f 100644 --- a/app/src/main/kotlin/com/wire/android/ui/authentication/devices/remove/RemoveDeviceViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/authentication/devices/remove/RemoveDeviceViewModel.kt @@ -92,7 +92,7 @@ class RemoveDeviceViewModel @Inject constructor( updateStateIfDialogVisible { state.copy(error = RemoveDeviceError.None) } } - fun onItemClicked(device: Device, onCompleted: (initialSyncCompleted: Boolean) -> Unit) { + fun onItemClicked(device: Device, onCompleted: (initialSyncCompleted: Boolean, isE2EIRequired: Boolean) -> Unit) { viewModelScope.launch { val isPasswordRequired: Boolean = when (val passwordRequiredResult = isPasswordRequired()) { is IsPasswordRequiredUseCase.Result.Failure -> { @@ -113,7 +113,7 @@ class RemoveDeviceViewModel @Inject constructor( } } - private suspend fun registerClient(password: String?, onCompleted: (initialSyncCompleted: Boolean) -> Unit) { + private suspend fun registerClient(password: String?, onCompleted: (initialSyncCompleted: Boolean, isE2EIRequired: Boolean) -> Unit) { registerClientUseCase( RegisterClientUseCase.RegisterClientParam(password, null) ).also { result -> @@ -125,12 +125,17 @@ class RemoveDeviceViewModel @Inject constructor( is RegisterClientResult.Failure.Generic -> state = state.copy(error = RemoveDeviceError.GenericError(result.genericFailure)) is RegisterClientResult.Failure.InvalidCredentials -> state = state.copy(error = RemoveDeviceError.InvalidCredentialsError) is RegisterClientResult.Failure.TooManyClients -> loadClientsList() - is RegisterClientResult.Success -> onCompleted(userDataStore.initialSyncCompleted.first()) + is RegisterClientResult.Success -> onCompleted(userDataStore.initialSyncCompleted.first(), false) + is RegisterClientResult.E2EICertificateRequired -> onCompleted(userDataStore.initialSyncCompleted.first(), true) } } } - private suspend fun deleteClient(password: String?, device: Device, onCompleted: (initialSyncCompleted: Boolean) -> Unit) { + private suspend fun deleteClient( + password: String?, + device: Device, + onCompleted: (initialSyncCompleted: Boolean, isE2EIRequired: Boolean) -> Unit + ) { when (val deleteResult = deleteClientUseCase(DeleteClientParam(password, device.clientId))) { is DeleteClientResult.Failure.Generic -> { state = state.copy(error = RemoveDeviceError.GenericError(deleteResult.genericFailure)) @@ -147,7 +152,7 @@ class RemoveDeviceViewModel @Inject constructor( } } - fun onRemoveConfirmed(onCompleted: (initialSyncCompleted: Boolean) -> Unit) { + fun onRemoveConfirmed(onCompleted: (initialSyncCompleted: Boolean, isE2EIRequired: Boolean) -> Unit) { (state.removeDeviceDialogState as? RemoveDeviceDialogState.Visible)?.let { dialogStateVisible -> updateStateIfDialogVisible { state.copy(removeDeviceDialogState = it.copy(loading = true, removeEnabled = false)) } viewModelScope.launch { diff --git a/app/src/main/kotlin/com/wire/android/ui/authentication/login/LoginScreen.kt b/app/src/main/kotlin/com/wire/android/ui/authentication/login/LoginScreen.kt index 55a656be33c..ecb66047e12 100644 --- a/app/src/main/kotlin/com/wire/android/ui/authentication/login/LoginScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/authentication/login/LoginScreen.kt @@ -48,6 +48,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.window.DialogProperties import androidx.hilt.navigation.compose.hiltViewModel @@ -74,6 +75,7 @@ import com.wire.android.ui.common.dialogs.FeatureDisabledWithProxyDialogState import com.wire.android.ui.common.rememberTopBarElevationState import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar import com.wire.android.ui.common.visbility.rememberVisibilityState +import com.wire.android.ui.destinations.E2EIEnrollmentScreenDestination import com.wire.android.ui.destinations.HomeScreenDestination import com.wire.android.ui.destinations.InitialSyncScreenDestination import com.wire.android.ui.destinations.RemoveDeviceScreenDestination @@ -98,13 +100,12 @@ fun LoginScreen( LoginContent( navigator::navigateBack, - { initialSyncCompleted -> - navigator.navigate( - NavigationCommand( - if (initialSyncCompleted) HomeScreenDestination else InitialSyncScreenDestination, - BackStackMode.CLEAR_WHOLE - ) - ) + { initialSyncCompleted, isE2EIRequired -> + val destination = if (isE2EIRequired) E2EIEnrollmentScreenDestination + else if (initialSyncCompleted) HomeScreenDestination + else InitialSyncScreenDestination + + navigator.navigate(NavigationCommand(destination, BackStackMode.CLEAR_WHOLE)) }, { navigator.navigate(NavigationCommand(RemoveDeviceScreenDestination, BackStackMode.CLEAR_WHOLE)) }, loginViewModel, @@ -117,7 +118,7 @@ fun LoginScreen( @Composable private fun LoginContent( onBackPressed: () -> Unit, - onSuccess: (initialSyncCompleted: Boolean) -> Unit, + onSuccess: (initialSyncCompleted: Boolean, isE2EIRequired: Boolean) -> Unit, onRemoveDeviceNeeded: () -> Unit, viewModel: LoginViewModel, loginEmailViewModel: LoginEmailViewModel, @@ -146,7 +147,7 @@ private fun LoginContent( @Composable private fun MainLoginContent( onBackPressed: () -> Unit, - onSuccess: (initialSyncCompleted: Boolean) -> Unit, + onSuccess: (initialSyncCompleted: Boolean, isE2EIRequired: Boolean) -> Unit, onRemoveDeviceNeeded: () -> Unit, viewModel: LoginViewModel, loginEmailViewModel: LoginEmailViewModel, @@ -242,22 +243,22 @@ fun LoginErrorDialog( ) { val dialogErrorData: LoginDialogErrorData = when (error) { is LoginError.DialogError.InvalidCredentialsError -> LoginDialogErrorData( - stringResource(R.string.login_error_invalid_credentials_title), - stringResource(R.string.login_error_invalid_credentials_message), - onDialogDismiss + title = stringResource(R.string.login_error_invalid_credentials_title), + body = AnnotatedString(stringResource(R.string.login_error_invalid_credentials_message)), + onDismiss = onDialogDismiss ) is LoginError.DialogError.UserAlreadyExists -> LoginDialogErrorData( - stringResource(R.string.login_error_user_already_logged_in_title), - stringResource(R.string.login_error_user_already_logged_in_message), - onDialogDismiss + title = stringResource(R.string.login_error_user_already_logged_in_title), + body = AnnotatedString(stringResource(R.string.login_error_user_already_logged_in_message)), + onDismiss = onDialogDismiss ) is LoginError.DialogError.ProxyError -> { LoginDialogErrorData( - stringResource(R.string.error_socket_title), - stringResource(R.string.error_socket_message), - onDialogDismiss + title = stringResource(R.string.error_socket_title), + body = AnnotatedString(stringResource(R.string.error_socket_message)), + onDismiss = onDialogDismiss ) } @@ -265,36 +266,36 @@ fun LoginErrorDialog( val strings = error.coreFailure.dialogErrorStrings(LocalContext.current.resources) LoginDialogErrorData( strings.title, - strings.message, + strings.annotatedMessage, onDialogDismiss ) } is LoginError.DialogError.InvalidSSOCodeError -> LoginDialogErrorData( - stringResource(R.string.login_error_invalid_credentials_title), - stringResource(R.string.login_error_invalid_sso_code), - onDialogDismiss + title = stringResource(R.string.login_error_invalid_credentials_title), + body = AnnotatedString(stringResource(R.string.login_error_invalid_sso_code)), + onDismiss = onDialogDismiss ) is LoginError.DialogError.InvalidSSOCookie -> LoginDialogErrorData( - stringResource(R.string.login_sso_error_invalid_cookie_title), - stringResource(R.string.login_sso_error_invalid_cookie_message), - onDialogDismiss + title = stringResource(R.string.login_sso_error_invalid_cookie_title), + body = AnnotatedString(stringResource(R.string.login_sso_error_invalid_cookie_message)), + onDismiss = onDialogDismiss ) is LoginError.DialogError.SSOResultError -> { with(ssoLoginResult as DeepLinkResult.SSOLogin.Failure) { LoginDialogErrorData( - stringResource(R.string.sso_error_dialog_title), - stringResource(R.string.sso_error_dialog_message, this.ssoError.errorCode), - onDialogDismiss + title = stringResource(R.string.sso_error_dialog_title), + body = AnnotatedString(stringResource(R.string.sso_error_dialog_message, this.ssoError.errorCode)), + onDismiss = onDialogDismiss ) } } is LoginError.DialogError.ServerVersionNotSupported -> LoginDialogErrorData( title = stringResource(R.string.api_versioning_server_version_not_supported_title), - body = stringResource(R.string.api_versioning_server_version_not_supported_message), + body = AnnotatedString(stringResource(R.string.api_versioning_server_version_not_supported_message)), onDismiss = onDialogDismiss, actionTextId = R.string.label_close, dismissOnClickOutside = false @@ -302,7 +303,7 @@ fun LoginErrorDialog( is LoginError.DialogError.ClientUpdateRequired -> LoginDialogErrorData( title = stringResource(R.string.api_versioning_client_update_required_title), - body = stringResource(R.string.api_versioning_client_update_required_message), + body = AnnotatedString(stringResource(R.string.api_versioning_client_update_required_message)), onDismiss = onDialogDismiss, actionTextId = R.string.label_update, onAction = updateTheApp, @@ -312,9 +313,9 @@ fun LoginErrorDialog( LoginError.DialogError.PasswordNeededToRegisterClient -> TODO() else -> LoginDialogErrorData( - stringResource(R.string.error_unknown_title), - stringResource(R.string.error_unknown_message), - onDialogDismiss + title = stringResource(R.string.error_unknown_title), + body = AnnotatedString(stringResource(R.string.error_unknown_message)), + onDismiss = onDialogDismiss ) } @@ -337,7 +338,7 @@ fun LoginErrorDialog( data class LoginDialogErrorData( val title: String, - val body: String, + val body: AnnotatedString, val onDismiss: () -> Unit, @StringRes val actionTextId: Int = R.string.label_ok, val onAction: () -> Unit = onDismiss, @@ -353,6 +354,6 @@ enum class LoginTabItem(@StringRes override val titleResId: Int) : TabItem { @Composable private fun PreviewLoginScreen() { WireTheme { - MainLoginContent({}, {}, {}, hiltViewModel(), hiltViewModel(), ssoLoginResult = null) + MainLoginContent({}, { _, _ -> }, {}, hiltViewModel(), hiltViewModel(), ssoLoginResult = null) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/authentication/login/LoginState.kt b/app/src/main/kotlin/com/wire/android/ui/authentication/login/LoginState.kt index 578caa2d0c9..84eb7d9f42f 100644 --- a/app/src/main/kotlin/com/wire/android/ui/authentication/login/LoginState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/authentication/login/LoginState.kt @@ -20,6 +20,7 @@ package com.wire.android.ui.authentication.login import androidx.compose.ui.text.input.TextFieldValue import com.wire.android.ui.common.dialogs.CustomServerDialogState +import com.wire.kalium.logic.data.auth.login.ProxyCredentials data class LoginState( val userIdentifier: TextFieldValue = TextFieldValue(""), @@ -36,7 +37,14 @@ data class LoginState( val loginError: LoginError = LoginError.None, val isProxyEnabled: Boolean = false, val customServerDialogState: CustomServerDialogState? = null, -) +) { + fun getProxyCredentials(): ProxyCredentials? = + if (proxyIdentifier.text.isNotBlank() && proxyPassword.text.isNotBlank()) { + ProxyCredentials(proxyIdentifier.text, proxyPassword.text) + } else { + null + } +} fun LoginState.updateEmailLoginEnabled() = copy( diff --git a/app/src/main/kotlin/com/wire/android/ui/authentication/login/LoginViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/authentication/login/LoginViewModel.kt index 5d48582fcca..6f59359106a 100644 --- a/app/src/main/kotlin/com/wire/android/ui/authentication/login/LoginViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/authentication/login/LoginViewModel.kt @@ -39,7 +39,6 @@ import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.feature.auth.AddAuthenticatedUserUseCase import com.wire.kalium.logic.feature.auth.AuthenticationResult import com.wire.kalium.logic.feature.auth.DomainLookupUseCase -import com.wire.kalium.logic.feature.auth.autoVersioningAuth.AutoVersionAuthScopeUseCase import com.wire.kalium.logic.feature.client.RegisterClientResult import com.wire.kalium.logic.feature.client.RegisterClientUseCase import dagger.hilt.android.lifecycle.HiltViewModel @@ -67,8 +66,6 @@ open class LoginViewModel @Inject constructor( } } - protected suspend fun authScope(): AutoVersionAuthScopeUseCase.Result = coreLogic.versionedAuthenticationScope(serverConfig)() - private val loginNavArgs: LoginNavArgs = savedStateHandle.navArgs() private val preFilledUserIdentifier: PreFilledUserIdentifierType = loginNavArgs.userHandle.let { if (it.isNullOrEmpty()) PreFilledUserIdentifierType.None else PreFilledUserIdentifierType.PreFilled(it) @@ -84,8 +81,8 @@ open class LoginViewModel @Inject constructor( userIdentifierEnabled = preFilledUserIdentifier is PreFilledUserIdentifierType.None, password = TextFieldValue(String.EMPTY), isProxyAuthRequired = - if (serverConfig.apiProxy?.needsAuthentication != null) serverConfig.apiProxy?.needsAuthentication!! - else false, + if (serverConfig.apiProxy?.needsAuthentication != null) serverConfig.apiProxy?.needsAuthentication!! + else false, isProxyEnabled = serverConfig.apiProxy != null ) ) diff --git a/app/src/main/kotlin/com/wire/android/ui/authentication/login/email/LoginEmailScreen.kt b/app/src/main/kotlin/com/wire/android/ui/authentication/login/email/LoginEmailScreen.kt index a3a326cd82d..f03624ae095 100644 --- a/app/src/main/kotlin/com/wire/android/ui/authentication/login/email/LoginEmailScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/authentication/login/email/LoginEmailScreen.kt @@ -77,7 +77,7 @@ import kotlinx.coroutines.launch @Composable fun LoginEmailScreen( - onSuccess: (initialSyncCompleted: Boolean) -> Unit, + onSuccess: (initialSyncCompleted: Boolean, isE2EIRequired: Boolean) -> Unit, onRemoveDeviceNeeded: () -> Unit, loginEmailViewModel: LoginEmailViewModel, scrollState: ScrollState = rememberScrollState() diff --git a/app/src/main/kotlin/com/wire/android/ui/authentication/login/email/LoginEmailVerificationCodeScreen.kt b/app/src/main/kotlin/com/wire/android/ui/authentication/login/email/LoginEmailVerificationCodeScreen.kt index f2dcd0f71de..96f61b3edfe 100644 --- a/app/src/main/kotlin/com/wire/android/ui/authentication/login/email/LoginEmailVerificationCodeScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/authentication/login/email/LoginEmailVerificationCodeScreen.kt @@ -38,15 +38,17 @@ import com.wire.android.R import com.wire.android.ui.authentication.verificationcode.VerificationCode import com.wire.android.ui.authentication.verificationcode.VerificationCodeState import com.wire.android.ui.common.Logo +import com.wire.android.ui.common.colorsScheme import com.wire.android.ui.common.dimensions import com.wire.android.ui.common.spacers.VerticalSpace import com.wire.android.ui.common.textfield.CodeFieldValue import com.wire.android.ui.common.typography +import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.android.util.ui.UIText @Composable fun LoginEmailVerificationCodeScreen( - onSuccess: (initialSyncCompleted: Boolean) -> Unit, + onSuccess: (initialSyncCompleted: Boolean, isE2EIRequired: Boolean) -> Unit, viewModel: LoginEmailViewModel = hiltViewModel() ) = LoginEmailVerificationCodeContent( viewModel.secondFactorVerificationCodeState, @@ -111,6 +113,7 @@ private fun MainContent( ) { Text( text = UIText.StringResource(R.string.second_factor_authentication_title).asString(), + color = colorsScheme().onBackground, style = typography().title01, textAlign = TextAlign.Start ) @@ -120,6 +123,7 @@ private fun MainContent( R.string.second_factor_authentication_instructions_label, codeState.emailUsed ).asString(), + color = colorsScheme().onBackground, style = typography().body01, textAlign = TextAlign.Start ) @@ -135,6 +139,7 @@ private fun MainContent( } @Preview(showBackground = true) +@PreviewMultipleThemes @Composable internal fun LoginEmailVerificationCodeScreenPreview() = LoginEmailVerificationCodeContent( VerificationCodeState( diff --git a/app/src/main/kotlin/com/wire/android/ui/authentication/login/email/LoginEmailViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/authentication/login/email/LoginEmailViewModel.kt index 52f2e6a9e3e..4fd15a6c2e3 100644 --- a/app/src/main/kotlin/com/wire/android/ui/authentication/login/email/LoginEmailViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/authentication/login/email/LoginEmailViewModel.kt @@ -36,7 +36,6 @@ import com.wire.android.ui.authentication.verificationcode.VerificationCodeState import com.wire.android.ui.common.textfield.CodeFieldValue import com.wire.android.util.dispatchers.DispatcherProvider import com.wire.kalium.logic.CoreLogic -import com.wire.kalium.logic.data.auth.login.ProxyCredentials import com.wire.kalium.logic.data.auth.verification.VerifiableAction import com.wire.kalium.logic.feature.auth.AddAuthenticatedUserUseCase import com.wire.kalium.logic.feature.auth.AuthenticationResult @@ -72,7 +71,7 @@ class LoginEmailViewModel @Inject constructor( ) @Suppress("LongMethod") - fun login(onSuccess: (initialSyncCompleted: Boolean) -> Unit) { + fun login(onSuccess: (initialSyncCompleted: Boolean, isE2EIRequired: Boolean) -> Unit) { loginState = loginState.copy(emailLoginLoading = true, loginError = LoginError.None).updateEmailLoginEnabled() viewModelScope.launch { val authScope = withContext(dispatchers.io()) { resolveCurrentAuthScope() } ?: return@launch @@ -123,7 +122,12 @@ class LoginEmailViewModel @Inject constructor( } is RegisterClientResult.Success -> { - onSuccess(isInitialSyncCompleted(storedUserId)) + onSuccess(isInitialSyncCompleted(storedUserId), false) + } + + is RegisterClientResult.E2EICertificateRequired -> { + onSuccess(isInitialSyncCompleted(storedUserId), true) + return@launch } } } @@ -132,29 +136,27 @@ class LoginEmailViewModel @Inject constructor( private suspend fun resolveCurrentAuthScope(): AuthenticationScope? = coreLogic.versionedAuthenticationScope(serverConfig).invoke( - AutoVersionAuthScopeUseCase.ProxyAuthentication.UsernameAndPassword( - ProxyCredentials(loginState.proxyIdentifier.text, loginState.proxyPassword.text) - ) - ).let { - when (it) { - is AutoVersionAuthScopeUseCase.Result.Success -> it.authenticationScope - - is AutoVersionAuthScopeUseCase.Result.Failure.UnknownServerVersion -> { - updateEmailLoginError(LoginError.DialogError.ServerVersionNotSupported) - return null - } + loginState.getProxyCredentials() + ).let { + when (it) { + is AutoVersionAuthScopeUseCase.Result.Success -> it.authenticationScope + + is AutoVersionAuthScopeUseCase.Result.Failure.UnknownServerVersion -> { + updateEmailLoginError(LoginError.DialogError.ServerVersionNotSupported) + return null + } - is AutoVersionAuthScopeUseCase.Result.Failure.TooNewVersion -> { - updateEmailLoginError(LoginError.DialogError.ClientUpdateRequired) - return null - } + is AutoVersionAuthScopeUseCase.Result.Failure.TooNewVersion -> { + updateEmailLoginError(LoginError.DialogError.ClientUpdateRequired) + return null + } - is AutoVersionAuthScopeUseCase.Result.Failure.Generic -> { - updateEmailLoginError(LoginError.DialogError.GenericError(it.genericFailure)) - return null + is AutoVersionAuthScopeUseCase.Result.Failure.Generic -> { + updateEmailLoginError(LoginError.DialogError.GenericError(it.genericFailure)) + return null + } } } - } private suspend fun handleAuthenticationFailure(it: AuthenticationResult.Failure, authScope: AuthenticationScope) { when (it) { @@ -215,7 +217,7 @@ class LoginEmailViewModel @Inject constructor( loginState = loginState.copy(proxyPassword = newText).updateEmailLoginEnabled() } - fun onCodeChange(newValue: CodeFieldValue, onSuccess: (initialSyncCompleted: Boolean) -> Unit) { + fun onCodeChange(newValue: CodeFieldValue, onSuccess: (initialSyncCompleted: Boolean, isE2EIRequired: Boolean) -> Unit) { secondFactorVerificationCodeState = secondFactorVerificationCodeState.copy(codeInput = newValue, isCurrentCodeInvalid = false) if (newValue.isFullyFilled) { login(onSuccess) diff --git a/app/src/main/kotlin/com/wire/android/ui/authentication/login/sso/LoginSSOScreen.kt b/app/src/main/kotlin/com/wire/android/ui/authentication/login/sso/LoginSSOScreen.kt index d60f10de2d0..a7ceb4f44f8 100644 --- a/app/src/main/kotlin/com/wire/android/ui/authentication/login/sso/LoginSSOScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/authentication/login/sso/LoginSSOScreen.kt @@ -61,7 +61,7 @@ import kotlinx.coroutines.flow.onEach @Composable fun LoginSSOScreen( - onSuccess: (initialSyncCompleted: Boolean) -> Unit, + onSuccess: (initialSyncCompleted: Boolean, isE2EIRequired: Boolean) -> Unit, onRemoveDeviceNeeded: () -> Unit, ssoLoginResult: DeepLinkResult.SSOLogin?, scrollState: ScrollState = rememberScrollState() @@ -142,8 +142,7 @@ private fun LoginSSOContent( if (loginState.customServerDialogState != null) { CustomServerDialog( - serverLinksTitle = loginState.customServerDialogState.serverLinks.title, - serverLinksApi = loginState.customServerDialogState.serverLinks.api, + serverLinks = loginState.customServerDialogState.serverLinks, onDismiss = onCustomServerDialogDismiss, onConfirm = onCustomServerDialogConfirm ) diff --git a/app/src/main/kotlin/com/wire/android/ui/authentication/login/sso/LoginSSOViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/authentication/login/sso/LoginSSOViewModel.kt index 263f1f25b9a..0b8bc779420 100644 --- a/app/src/main/kotlin/com/wire/android/ui/authentication/login/sso/LoginSSOViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/authentication/login/sso/LoginSSOViewModel.kt @@ -90,7 +90,9 @@ class LoginSSOViewModel @Inject constructor( if (loginState.customServerDialogState != null) { authServerConfigProvider.updateAuthServer(loginState.customServerDialogState!!.serverLinks) - val authScope = coreLogic.versionedAuthenticationScope(loginState.customServerDialogState!!.serverLinks)().let { + // sso does not support proxy + // TODO: add proxy support + val authScope = coreLogic.versionedAuthenticationScope(loginState.customServerDialogState!!.serverLinks)(null).let { when (it) { is AutoVersionAuthScopeUseCase.Result.Failure.Generic, AutoVersionAuthScopeUseCase.Result.Failure.TooNewVersion, @@ -134,7 +136,9 @@ class LoginSSOViewModel @Inject constructor( val defaultAuthScope: AuthenticationScope = coreLogic.versionedAuthenticationScope( authServerConfigProvider.defaultServerLinks() - )().let { + // domain lockup does not support proxy + // TODO: add proxy support + )(null).let { when (it) { is AutoVersionAuthScopeUseCase.Result.Failure.Generic, AutoVersionAuthScopeUseCase.Result.Failure.TooNewVersion, @@ -168,7 +172,8 @@ class LoginSSOViewModel @Inject constructor( private fun ssoLoginWithCodeFlow() { viewModelScope.launch { val authScope = - authScope().let { + // sso does not support proxy + coreLogic.versionedAuthenticationScope(serverConfig)(null).let { when (it) { is AutoVersionAuthScopeUseCase.Result.Success -> it.authenticationScope @@ -197,13 +202,17 @@ class LoginSSOViewModel @Inject constructor( } } - @Suppress("ComplexMethod") + @Suppress("ComplexMethod", "LongMethod") @VisibleForTesting - fun establishSSOSession(cookie: String, serverConfigId: String, onSuccess: (initialSyncCompleted: Boolean) -> Unit) { + fun establishSSOSession( + cookie: String, + serverConfigId: String, + onSuccess: (initialSyncCompleted: Boolean, isE2EIRequired: Boolean) -> Unit + ) { loginState = loginState.copy(ssoLoginLoading = true, loginError = LoginError.None).updateSSOLoginEnabled() viewModelScope.launch { val authScope = - authScope().let { + coreLogic.versionedAuthenticationScope(serverConfig)(null).let { when (it) { is AutoVersionAuthScopeUseCase.Result.Success -> it.authenticationScope @@ -251,13 +260,17 @@ class LoginSSOViewModel @Inject constructor( registerClient(storedUserId, null).let { when (it) { is RegisterClientResult.Success -> { - onSuccess(isInitialSyncCompleted(storedUserId)) + onSuccess(isInitialSyncCompleted(storedUserId), false) } is RegisterClientResult.Failure -> { updateSSOLoginError(it.toLoginError()) return@launch } + + is RegisterClientResult.E2EICertificateRequired -> { + onSuccess(isInitialSyncCompleted(storedUserId), true) + } } } } @@ -272,15 +285,18 @@ class LoginSSOViewModel @Inject constructor( savedStateHandle.set(SSO_CODE_SAVED_STATE_KEY, newText.text) } - fun handleSSOResult(ssoLoginResult: DeepLinkResult.SSOLogin?, onSuccess: (initialSyncCompleted: Boolean) -> Unit) = + fun handleSSOResult( + ssoLoginResult: DeepLinkResult.SSOLogin?, + onSuccess: (initialSyncCompleted: Boolean, isE2EIRequired: Boolean) -> Unit + ) = when (ssoLoginResult) { - is DeepLinkResult.SSOLogin.Success -> { - establishSSOSession(ssoLoginResult.cookie, ssoLoginResult.serverConfigId, onSuccess) - } + is DeepLinkResult.SSOLogin.Success -> { + establishSSOSession(ssoLoginResult.cookie, ssoLoginResult.serverConfigId, onSuccess) + } - is DeepLinkResult.SSOLogin.Failure -> updateSSOLoginError(LoginError.DialogError.SSOResultError(ssoLoginResult.ssoError)) - null -> {} - } + is DeepLinkResult.SSOLogin.Failure -> updateSSOLoginError(LoginError.DialogError.SSOResultError(ssoLoginResult.ssoError)) + null -> {} + } private fun openWebUrl(url: String) { viewModelScope.launch { diff --git a/app/src/main/kotlin/com/wire/android/ui/authentication/welcome/WelcomeScreen.kt b/app/src/main/kotlin/com/wire/android/ui/authentication/welcome/WelcomeScreen.kt index 5245fe4ffed..0759a0a9aa7 100644 --- a/app/src/main/kotlin/com/wire/android/ui/authentication/welcome/WelcomeScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/authentication/welcome/WelcomeScreen.kt @@ -165,13 +165,7 @@ private fun WelcomeContent( ServerTitle(serverLinks = state, modifier = Modifier.padding(top = dimensions().spacing16x)) } - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - modifier = Modifier.weight(1f, true) - ) { - WelcomeCarousel() - } + WelcomeCarousel(modifier = Modifier.weight(1f, true)) Column( modifier = Modifier @@ -230,7 +224,7 @@ private fun WelcomeContent( @OptIn(ExperimentalFoundationApi::class) @Composable -private fun WelcomeCarousel() { +private fun WelcomeCarousel(modifier: Modifier = Modifier) { val delay = integerResource(id = R.integer.welcome_carousel_item_time_ms) val icons: List = typedArrayResource(id = R.array.welcome_carousel_icons).drawableResIdList() val texts: List = stringArrayResource(id = R.array.welcome_carousel_texts).toList() @@ -249,7 +243,7 @@ private fun WelcomeCarousel() { CompositionLocalProvider(LocalOverscrollConfiguration provides null) { HorizontalPager( state = pagerState, - modifier = Modifier.fillMaxWidth() + modifier = modifier.fillMaxWidth() ) { page -> val (pageIconResId, pageText) = circularItemsList[page] WelcomeCarouselItem(pageIconResId = pageIconResId, pageText = pageText) @@ -300,6 +294,7 @@ private fun WelcomeCarouselItem(pageIconResId: Int, pageText: String) { Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth() ) { Image( painter = painterResource(id = pageIconResId), diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/ProximitySensorManager.kt b/app/src/main/kotlin/com/wire/android/ui/calling/ProximitySensorManager.kt index cd688f23720..b61eaccabf7 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/ProximitySensorManager.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/ProximitySensorManager.kt @@ -31,6 +31,7 @@ import com.wire.android.di.KaliumCoreLogic import com.wire.kalium.logic.CoreLogic import com.wire.kalium.logic.feature.session.CurrentSessionResult import com.wire.kalium.logic.feature.session.CurrentSessionUseCase +import dagger.Lazy import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import javax.inject.Inject @@ -39,8 +40,8 @@ import javax.inject.Singleton @Singleton class ProximitySensorManager @Inject constructor( private val context: Context, - private val currentSession: CurrentSessionUseCase, - @KaliumCoreLogic private val coreLogic: CoreLogic, + private val currentSession: Lazy, + @KaliumCoreLogic private val coreLogic: Lazy, @ApplicationScope private val appCoroutineScope: CoroutineScope ) { @@ -69,11 +70,12 @@ class ProximitySensorManager @Inject constructor( override fun onSensorChanged(event: SensorEvent) { appCoroutineScope.launch { - coreLogic.globalScope { - when (val currentSession = currentSession()) { - is CurrentSessionResult.Success -> { + coreLogic.get().globalScope { + val currentSession = currentSession.get().invoke() + when { + currentSession is CurrentSessionResult.Success && currentSession.accountInfo.isValid() -> { val userId = currentSession.accountInfo.userId - val isCallRunning = coreLogic.getSessionScope(userId).calls.isCallRunning() + val isCallRunning = coreLogic.get().getSessionScope(userId).calls.isCallRunning() val distance = event.values.first() val shouldTurnOffScreen = distance == NEAR_DISTANCE && isCallRunning appLogger.i( @@ -91,8 +93,10 @@ class ProximitySensorManager @Inject constructor( } } - else -> { - // NO SESSION - Nothing to do + else -> { // NO SESSION - just release in case it's still held + if (wakeLock.isHeld) { + wakeLock.release() + } } } } diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/SharedCallingViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/calling/SharedCallingViewModel.kt index 4eccb51976d..26d101a46e1 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/SharedCallingViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/SharedCallingViewModel.kt @@ -52,7 +52,7 @@ import com.wire.kalium.logic.feature.call.usecase.SetVideoPreviewUseCase import com.wire.kalium.logic.feature.call.usecase.TurnLoudSpeakerOffUseCase import com.wire.kalium.logic.feature.call.usecase.TurnLoudSpeakerOnUseCase import com.wire.kalium.logic.feature.call.usecase.UnMuteCallUseCase -import com.wire.kalium.logic.feature.call.usecase.UpdateVideoStateUseCase +import com.wire.kalium.logic.feature.call.usecase.video.UpdateVideoStateUseCase import com.wire.kalium.logic.feature.conversation.ObserveConversationDetailsUseCase import com.wire.kalium.logic.util.PlatformView import dagger.hilt.android.lifecycle.HiltViewModel @@ -127,8 +127,9 @@ class SharedCallingViewModel @Inject constructor( private suspend fun observeScreenState() { currentScreenManager.observeCurrentScreen(viewModelScope).collect { - if (it == CurrentScreen.InBackground) { - stopVideo() + // clear video preview when the screen is in background to avoid memory leaks + if (it == CurrentScreen.InBackground && callState.isCameraOn) { + clearVideoPreview() } } } @@ -279,6 +280,11 @@ class SharedCallingViewModel @Inject constructor( callState = callState.copy( isCameraOn = !callState.isCameraOn ) + if (callState.isCameraOn) { + updateVideoState(conversationId, VideoState.STARTED) + } else { + updateVideoState(conversationId, VideoState.STOPPED) + } } } @@ -286,7 +292,6 @@ class SharedCallingViewModel @Inject constructor( viewModelScope.launch { appLogger.i("SharedCallingViewModel: clearing video preview..") setVideoPreview(conversationId, PlatformView(null)) - updateVideoState(conversationId, VideoState.STOPPED) } } @@ -295,18 +300,6 @@ class SharedCallingViewModel @Inject constructor( appLogger.i("SharedCallingViewModel: setting video preview..") setVideoPreview(conversationId, PlatformView(null)) setVideoPreview(conversationId, PlatformView(view)) - updateVideoState(conversationId, VideoState.STARTED) - } - } - - fun stopVideo() { - viewModelScope.launch { - if (callState.isCameraOn) { - appLogger.i("SharedCallingViewModel: stopping video..") - callState = callState.copy(isCameraOn = false, isSpeakerOn = false) - clearVideoPreview() - turnLoudSpeakerOff() - } } } } diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/model/UICallParticipant.kt b/app/src/main/kotlin/com/wire/android/ui/calling/model/UICallParticipant.kt index 8bbeccfd793..91df9d2c8a3 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/model/UICallParticipant.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/model/UICallParticipant.kt @@ -25,7 +25,7 @@ import com.wire.kalium.logic.data.id.QualifiedID data class UICallParticipant( val id: QualifiedID, val clientId: String, - val name: String = "", + val name: String? = null, val isMuted: Boolean, val isSpeaking: Boolean = false, val isCameraOn: Boolean, diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallScreen.kt b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallScreen.kt index 1696dd17dcf..baddd56f441 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallScreen.kt @@ -122,8 +122,14 @@ fun OngoingCallScreen( hangUpCall = { sharedCallingViewModel.hangUpCall(navigator::navigateBack) }, toggleVideo = sharedCallingViewModel::toggleVideo, flipCamera = sharedCallingViewModel::flipCamera, - setVideoPreview = sharedCallingViewModel::setVideoPreview, - clearVideoPreview = sharedCallingViewModel::clearVideoPreview, + setVideoPreview = { + sharedCallingViewModel.setVideoPreview(it) + ongoingCallViewModel.startSendingVideoFeed() + }, + clearVideoPreview = { + sharedCallingViewModel.clearVideoPreview() + ongoingCallViewModel.stopSendingVideoFeed() + }, navigateBack = navigator::navigateBack, requestVideoStreams = ongoingCallViewModel::requestVideoStreams, hideDoubleTapToast = ongoingCallViewModel::hideDoubleTapToast diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallViewModel.kt index 10ee083f22c..9b3abb360b7 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallViewModel.kt @@ -33,11 +33,14 @@ import com.wire.android.ui.calling.model.UICallParticipant import com.wire.android.ui.navArgs import com.wire.android.util.CurrentScreen import com.wire.android.util.CurrentScreenManager +import com.wire.kalium.logic.data.call.Call import com.wire.kalium.logic.data.call.CallClient +import com.wire.kalium.logic.data.call.VideoState import com.wire.kalium.logic.data.id.QualifiedID import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.feature.call.usecase.ObserveEstablishedCallsUseCase import com.wire.kalium.logic.feature.call.usecase.RequestVideoStreamsUseCase +import com.wire.kalium.logic.feature.call.usecase.video.SetVideoSendStateUseCase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged @@ -54,7 +57,8 @@ class OngoingCallViewModel @Inject constructor( private val globalDataStore: GlobalDataStore, private val establishedCalls: ObserveEstablishedCallsUseCase, private val requestVideoStreams: RequestVideoStreamsUseCase, - private val currentScreenManager: CurrentScreenManager, + private val setVideoSendState: SetVideoSendStateUseCase, + private val currentScreenManager: CurrentScreenManager ) : ViewModel() { private val ongoingCallNavArgs: CallingNavArgs = savedStateHandle.navArgs() @@ -70,6 +74,7 @@ class OngoingCallViewModel @Inject constructor( init { viewModelScope.launch { establishedCalls().first { it.isNotEmpty() }.run { + initCameraState(this) // We start observing once we have an ongoing call observeCurrentCall() } @@ -77,6 +82,28 @@ class OngoingCallViewModel @Inject constructor( showDoubleTapToast() } + private fun initCameraState(calls: List) { + val currentCall = calls.find { call -> call.conversationId == conversationId } + currentCall?.let { + if (it.isCameraOn) { + startSendingVideoFeed() + } else { + stopSendingVideoFeed() + } + } + } + + fun startSendingVideoFeed() { + viewModelScope.launch { + setVideoSendState(conversationId, VideoState.STARTED) + } + } + fun stopSendingVideoFeed() { + viewModelScope.launch { + setVideoSendState(conversationId, VideoState.STOPPED) + } + } + private suspend fun observeCurrentCall() { establishedCalls() .distinctUntilChanged() diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/participantsview/ParticipantTile.kt b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/participantsview/ParticipantTile.kt index 14659df2b49..e444aa47942 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/participantsview/ParticipantTile.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/participantsview/ParticipantTile.kt @@ -94,6 +94,7 @@ fun ParticipantTile( onSelfUserVideoPreviewCreated: (view: View) -> Unit, onClearSelfUserVideoPreview: () -> Unit ) { + val defaultUserName = stringResource(id = R.string.calling_participant_tile_default_user_name) val alpha = if (participantTitleState.hasEstablishedAudio) ContentAlpha.high else ContentAlpha.medium Surface( @@ -154,7 +155,7 @@ fun ParticipantTile( end.linkTo((parent.end)) } .widthIn(max = onGoingCallTileUsernameMaxWidth), - name = participantTitleState.name, + name = participantTitleState.name ?: defaultUserName, isSpeaking = participantTitleState.isSpeaking, hasEstablishedAudio = participantTitleState.hasEstablishedAudio ) diff --git a/app/src/main/kotlin/com/wire/android/ui/common/TextWithLinkSuffix.kt b/app/src/main/kotlin/com/wire/android/ui/common/TextWithLinkSuffix.kt new file mode 100644 index 00000000000..67f3b163aa0 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/common/TextWithLinkSuffix.kt @@ -0,0 +1,167 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.common + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.foundation.text.appendInlineContent +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.Dp +import com.wire.android.ui.theme.WireTheme +import com.wire.android.ui.theme.wireColorScheme +import com.wire.android.ui.theme.wireTypography +import com.wire.android.util.ui.PreviewMultipleThemes + +@Composable +fun TextWithLinkSuffix( + text: AnnotatedString, + linkText: String? = null, + onLinkClick: () -> Unit = {}, + linkTag: String = "link", + textStyle: TextStyle = MaterialTheme.wireTypography.body01, + textColor: Color = MaterialTheme.wireColorScheme.onBackground, + linkStyle: TextStyle = MaterialTheme.wireTypography.body02, + linkColor: Color = MaterialTheme.wireColorScheme.primary, + linkDecoration: TextDecoration = TextDecoration.Underline, + onTextLayout: (TextLayoutResult) -> Unit = {}, + modifier: Modifier = Modifier, +) { + val textMeasurer = rememberTextMeasurer() + val linkId = "link" + val inlineText = if (linkText != null) { + text.plus( + buildAnnotatedString { + append(" ") + appendInlineContent(linkId, "[link]") + } + ) + } else text + val inlineContent = buildMap { + if (linkText != null) { + val textLayoutResult: TextLayoutResult = textMeasurer.measure( + text = linkText, + style = linkStyle.copy(textDecoration = linkDecoration), + ) + val textSize = textLayoutResult.size + val density = LocalDensity.current + val (linkWidthSp, linkHeightSp) = with(density) { textSize.width.toSp() to textSize.height.toSp() } + + put(linkId, InlineTextContent( + placeholder = Placeholder( + width = linkWidthSp, + height = linkHeightSp, + placeholderVerticalAlign = PlaceholderVerticalAlign.Bottom + ), + children = { + Text( + text = linkText, + style = linkStyle, + color = linkColor, + textDecoration = linkDecoration, + modifier = Modifier + .testTag(linkTag) + .clickable(onClick = onLinkClick) + ) + } + ) + ) + } + } + + Text( + text = inlineText, + style = textStyle, + color = textColor, + inlineContent = inlineContent, + onTextLayout = onTextLayout, + modifier = modifier, + ) +} + +@Composable +private fun PreviewTextWithLinkSuffixBuilder( + textLines: List = listOf("This is a text with a link"), + linkText: String = "link", + calculateWidth: (lastTextLineWidthDp: Dp, linkWidthDp: Dp) -> Dp +) { + val textStyle = MaterialTheme.wireTypography.body01 + val linkStyle = MaterialTheme.wireTypography.body02.copy(textDecoration = TextDecoration.Underline) + val textMeasurer = rememberTextMeasurer() + val lastTextLineLayoutResult = textMeasurer.measure(text = "${textLines.last()} ", style = textStyle) + val linkLayoutResult = textMeasurer.measure(text = linkText, style = linkStyle) + val density = LocalDensity.current + val lastTextLineWidthDp = with(density) { lastTextLineLayoutResult.size.width.toDp() } + val linkWidthDp = with(density) { linkLayoutResult.size.width.toDp() } + TextWithLinkSuffix( + text = AnnotatedString(textLines.joinToString(separator = "\n")), + linkText = linkText, + onLinkClick = {}, + modifier = Modifier.width(calculateWidth(lastTextLineWidthDp, linkWidthDp)) + ) +} + +@PreviewMultipleThemes +@Composable +fun PreviewTextWithLinkSuffixWithoutALink() = WireTheme { + TextWithLinkSuffix(text = AnnotatedString("This is a text without a link")) +} + +@PreviewMultipleThemes +@Composable +fun PreviewTextWithLinkSuffixFittingInSameLine() = WireTheme { + PreviewTextWithLinkSuffixBuilder { lastTextLineWidthDp, linkWidthDp -> lastTextLineWidthDp + linkWidthDp } +} + +@PreviewMultipleThemes +@Composable +fun PreviewTextWithLinkSuffixNotFittingInSameLine() = WireTheme { + PreviewTextWithLinkSuffixBuilder { lastTextLineWidthDp, linkWidthDp -> lastTextLineWidthDp + (linkWidthDp / 2) } +} + +@PreviewMultipleThemes +@Composable +fun PreviewTextWithLinkSuffixMultilineFittingInLastLine() = WireTheme { + PreviewTextWithLinkSuffixBuilder( + textLines = listOf("This is a text with a link", "This is a text with a"), + linkText = "link", + ) { lastTextLineWidthDp, linkWidthDp -> lastTextLineWidthDp + linkWidthDp } +} + +@PreviewMultipleThemes +@Composable +fun PreviewTextWithLinkSuffixMultilineNotFittingInLastLine() = WireTheme { + PreviewTextWithLinkSuffixBuilder( + textLines = listOf("This is a text with a", "This is a text with a"), + linkText = "link" + ) { lastTextLineWidthDp, linkWidthDp -> lastTextLineWidthDp + (linkWidthDp / 2) } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/common/WireDialog.kt b/app/src/main/kotlin/com/wire/android/ui/common/WireDialog.kt index b3375ee30e1..2bfbc916103 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/WireDialog.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/WireDialog.kt @@ -41,7 +41,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Shape import androidx.compose.ui.platform.LocalUriHandler @@ -51,7 +50,6 @@ import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.withStyle -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import com.wire.android.ui.common.button.WireButtonState @@ -60,11 +58,11 @@ import com.wire.android.ui.common.button.WireSecondaryButton import com.wire.android.ui.common.button.WireTertiaryButton import com.wire.android.ui.common.progress.WireCircularProgressIndicator import com.wire.android.ui.common.textfield.WirePasswordTextField -import com.wire.android.ui.markdown.MarkdownConstants import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireDimensions import com.wire.android.ui.theme.wireTypography +import com.wire.android.util.ui.PreviewMultipleThemes @Stable fun wireDialogPropertiesBuilder( @@ -81,6 +79,7 @@ fun wireDialogPropertiesBuilder( fun WireDialog( title: String, text: String, + textSuffixLink: DialogTextSuffixLink? = null, onDismiss: () -> Unit, optionButton1Properties: WireDialogButtonProperties? = null, optionButton2Properties: WireDialogButtonProperties? = null, @@ -116,6 +115,7 @@ fun WireDialog( ) withStyle(style) { append(text) } }, + textSuffixLink = textSuffixLink, centerContent = centerContent, content = content ) @@ -125,6 +125,7 @@ fun WireDialog( fun WireDialog( title: String, text: AnnotatedString? = null, + textSuffixLink: DialogTextSuffixLink? = null, onDismiss: () -> Unit, optionButton1Properties: WireDialogButtonProperties? = null, optionButton2Properties: WireDialogButtonProperties? = null, @@ -153,6 +154,7 @@ fun WireDialog( title = title, titleLoading = titleLoading, text = text, + textSuffixLink = textSuffixLink, centerContent = centerContent, content = content ) @@ -164,6 +166,7 @@ private fun WireDialogContent( title: String, titleLoading: Boolean = false, text: AnnotatedString? = null, + textSuffixLink: DialogTextSuffixLink? = null, optionButton1Properties: WireDialogButtonProperties? = null, optionButton2Properties: WireDialogButtonProperties? = null, dismissButtonProperties: WireDialogButtonProperties? = null, @@ -188,75 +191,89 @@ private fun WireDialogContent( .padding(contentPadding), horizontalAlignment = if (centerContent) Alignment.CenterHorizontally else Alignment.Start ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = title, - style = MaterialTheme.wireTypography.title02, - ) - if (titleLoading) { - WireCircularProgressIndicator(progressColor = MaterialTheme.wireColorScheme.onBackground) - } - } - text?.let { - LazyColumn( - modifier = Modifier - .weight(1f, fill = false) - .fillMaxWidth() - ) { + // Title + TitleDialogSection(title, titleLoading) + + // Dynamic sized body content + LazyColumn( + modifier = Modifier + .weight(1f, fill = false) + .padding( + top = MaterialTheme.wireDimensions.dialogTextsSpacing, + bottom = MaterialTheme.wireDimensions.dialogTextsSpacing + ) + .fillMaxWidth() + ) { + text?.let { item { - ClickableText( + TextWithLinkSuffix( text = text, - style = MaterialTheme.wireTypography.body01, - modifier = Modifier.padding( - top = MaterialTheme.wireDimensions.dialogTextsSpacing, - bottom = MaterialTheme.wireDimensions.dialogTextsSpacing, - ), - onClick = { offset -> - text.getStringAnnotations( - tag = MarkdownConstants.TAG_URL, - start = offset, - end = offset, - ).firstOrNull()?.let { result -> uriHandler.openUri(result.item) } - } + linkText = textSuffixLink?.linkText, + onLinkClick = { textSuffixLink?.linkUrl?.let { uriHandler.openUri(it) } }, + modifier = Modifier.padding(bottom = MaterialTheme.wireDimensions.dialogTextsSpacing) ) } } - } - content?.let { - Box { - it.invoke() - } - } - val containsAnyButton = dismissButtonProperties != null || optionButton1Properties != null || optionButton2Properties != null - val dialogButtonsSpacing = if (containsAnyButton) dimensions().dialogButtonsSpacing else dimensions().spacing0x - if (buttonsHorizontalAlignment) { - Row(Modifier.padding(top = dialogButtonsSpacing)) { - dismissButtonProperties.getButton(Modifier.weight(1f)) - if (dismissButtonProperties != null) { - Spacer(Modifier.width(dialogButtonsSpacing)) - } - optionButton1Properties.getButton(Modifier.weight(1f)) - if (optionButton2Properties != null) { - Spacer(Modifier.width(dialogButtonsSpacing)) + content?.let { + item { + Box { + it.invoke() + } } - optionButton2Properties.getButton(Modifier.weight(1f)) } - } else { - Column(Modifier.padding(top = dialogButtonsSpacing)) { - optionButton1Properties.getButton() + } - if (optionButton2Properties != null) { - Spacer(Modifier.height(dialogButtonsSpacing)) - } - optionButton2Properties.getButton() + // Buttons actions + DialogButtonsSection(dismissButtonProperties, optionButton1Properties, optionButton2Properties, buttonsHorizontalAlignment) + } + } +} - if (dismissButtonProperties != null) { - Spacer(Modifier.height(dialogButtonsSpacing)) - } - dismissButtonProperties.getButton() - } +@Composable +private fun TitleDialogSection(title: String, titleLoading: Boolean) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(text = title, style = MaterialTheme.wireTypography.title02) + if (titleLoading) { + WireCircularProgressIndicator(progressColor = MaterialTheme.wireColorScheme.onBackground) + } + } +} + +@Composable +private fun DialogButtonsSection( + dismissButtonProperties: WireDialogButtonProperties?, + optionButton1Properties: WireDialogButtonProperties?, + optionButton2Properties: WireDialogButtonProperties?, + buttonsHorizontalAlignment: Boolean +) { + val containsAnyButton = dismissButtonProperties != null || optionButton1Properties != null || optionButton2Properties != null + val dialogButtonsSpacing = if (containsAnyButton) dimensions().dialogButtonsSpacing else dimensions().spacing0x + if (buttonsHorizontalAlignment) { + Row(Modifier.padding(top = dialogButtonsSpacing)) { + dismissButtonProperties.getButton(Modifier.weight(1f)) + if (dismissButtonProperties != null) { + Spacer(Modifier.width(dialogButtonsSpacing)) } + optionButton1Properties.getButton(Modifier.weight(1f)) + if (optionButton2Properties != null) { + Spacer(Modifier.width(dialogButtonsSpacing)) + } + optionButton2Properties.getButton(Modifier.weight(1f)) + } + } else { + Column(Modifier.padding(top = dialogButtonsSpacing)) { + optionButton1Properties.getButton() + + if (optionButton2Properties != null) { + Spacer(Modifier.height(dialogButtonsSpacing)) + } + optionButton2Properties.getButton() + + if (dismissButtonProperties != null) { + Spacer(Modifier.height(dialogButtonsSpacing)) + } + dismissButtonProperties.getButton() } } } @@ -279,8 +296,7 @@ private fun WireDialogButtonProperties?.getButton(modifier: Modifier = Modifier) } } -@OptIn(ExperimentalComposeUiApi::class) -@Preview(showBackground = true) +@PreviewMultipleThemes @Composable fun PreviewWireDialog() { var password by remember { mutableStateOf(TextFieldValue("")) } @@ -322,8 +338,28 @@ fun PreviewWireDialog() { } } -@OptIn(ExperimentalComposeUiApi::class) -@Preview(showBackground = true) +@PreviewMultipleThemes +@Composable +fun PreviewWireDialogWithSuffixLink() { + WireTheme { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxWidth() + ) { + WireDialogContent( + dismissButtonProperties = WireDialogButtonProperties( + text = "OK", + onClick = { } + ), + title = "title", + text = AnnotatedString("This is a long text with a link on a second line.\nThis is a second line."), + textSuffixLink = DialogTextSuffixLink("link", "https://www.wire.com"), + ) + } + } +} + +@PreviewMultipleThemes @Composable fun PreviewWireDialogWith2OptionButtons() { var password by remember { mutableStateOf(TextFieldValue("")) } @@ -372,8 +408,7 @@ fun PreviewWireDialogWith2OptionButtons() { } } -@OptIn(ExperimentalComposeUiApi::class) -@Preview(showBackground = true) +@PreviewMultipleThemes @Composable fun PreviewWireDialogCentered() { var password by remember { mutableStateOf(TextFieldValue("")) } @@ -425,3 +460,5 @@ data class WireDialogButtonProperties( val type: WireDialogButtonType = WireDialogButtonType.Secondary, val loading: Boolean = false ) + +data class DialogTextSuffixLink(val linkText: String, val linkUrl: String) diff --git a/app/src/main/kotlin/com/wire/android/ui/common/WireLabelledCheckbox.kt b/app/src/main/kotlin/com/wire/android/ui/common/WireLabelledCheckbox.kt index f5f740cfc80..390d779fcde 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/WireLabelledCheckbox.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/WireLabelledCheckbox.kt @@ -46,6 +46,7 @@ fun WireLabelledCheckbox( overflow: TextOverflow = TextOverflow.Visible, horizontalArrangement: Arrangement.Horizontal = Arrangement.Start, contentPadding: PaddingValues = PaddingValues(dimensions().spacing0x), + checkboxEnabled: Boolean = true, modifier: Modifier = Modifier ) { Row( @@ -55,12 +56,17 @@ fun WireLabelledCheckbox( .toggleable( value = checked, role = Role.Checkbox, - onValueChange = { onCheckClicked(!checked) } + onValueChange = { + if (checkboxEnabled) { + onCheckClicked(!checked) + } + } ) .padding(contentPadding) ) { Checkbox( checked = checked, + enabled = checkboxEnabled, onCheckedChange = null // null since we are handling the click on parent ) diff --git a/app/src/main/kotlin/com/wire/android/ui/common/dialogs/CustomServerDialog.kt b/app/src/main/kotlin/com/wire/android/ui/common/dialogs/CustomServerDialog.kt index 3e2c80feaf6..aa130cbc332 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/dialogs/CustomServerDialog.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/dialogs/CustomServerDialog.kt @@ -18,40 +18,46 @@ package com.wire.android.ui.common.dialogs +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.platform.LocalContext +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextDecoration import com.wire.android.R import com.wire.android.ui.common.WireDialog import com.wire.android.ui.common.WireDialogButtonProperties import com.wire.android.ui.common.WireDialogButtonType import com.wire.android.ui.common.button.WireButtonState import com.wire.android.ui.common.colorsScheme +import com.wire.android.ui.common.dimensions +import com.wire.android.ui.common.spacers.VerticalSpace import com.wire.android.ui.common.wireDialogPropertiesBuilder +import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireTypography -import com.wire.android.util.ui.stringWithStyledArgs +import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.kalium.logic.configuration.server.ServerConfig @Composable internal fun CustomServerDialog( - serverLinksTitle: String, - serverLinksApi: String, + serverLinks: ServerConfig.Links, onDismiss: () -> Unit, onConfirm: () -> Unit ) { + var showDetails by remember { mutableStateOf(false) } WireDialog( title = stringResource(R.string.custom_backend_dialog_title), - text = LocalContext.current.resources.stringWithStyledArgs( - R.string.custom_backend_dialog_body, - MaterialTheme.wireTypography.body01, - MaterialTheme.wireTypography.body02, - colorsScheme().onBackground, - colorsScheme().onBackground, - serverLinksTitle, - serverLinksApi - ), - + text = stringResource(R.string.custom_backend_dialog_body), buttonsHorizontalAlignment = true, properties = wireDialogPropertiesBuilder( dismissOnBackPress = false, @@ -69,8 +75,102 @@ internal fun CustomServerDialog( type = WireDialogButtonType.Primary, state = WireButtonState.Default - ) + ), + content = { + Column { + CustomServerPropertyInfo( + title = stringResource(id = R.string.custom_backend_dialog_body_backend_name), + value = serverLinks.title + ) + CustomServerPropertyInfo( + title = stringResource(id = R.string.custom_backend_dialog_body_backend_api), + value = serverLinks.api + ) + if (serverLinks.apiProxy != null) { + CustomServerPropertyInfo( + title = stringResource(id = R.string.custom_backend_dialog_body_backend_proxy_url), + value = serverLinks.apiProxy!!.host + ) + + CustomServerPropertyInfo( + title = stringResource(id = R.string.custom_backend_dialog_body_backend_proxy_authentication), + value = serverLinks.apiProxy!!.needsAuthentication.toString() + ) + } + if (showDetails) { + CustomServerPropertyInfo( + title = stringResource(id = R.string.custom_backend_dialog_body_backend_websocket), + value = serverLinks.webSocket + ) + CustomServerPropertyInfo( + title = stringResource(id = R.string.custom_backend_dialog_body_backend_blacklist), + value = serverLinks.blackList + ) + CustomServerPropertyInfo( + title = stringResource(id = R.string.custom_backend_dialog_body_backend_teams), + value = serverLinks.teams + ) + CustomServerPropertyInfo( + title = stringResource(id = R.string.custom_backend_dialog_body_backend_accounts), + value = serverLinks.accounts + ) + CustomServerPropertyInfo( + title = stringResource(id = R.string.custom_backend_dialog_body_backend_website), + value = serverLinks.website + ) + } + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = dimensions().spacing8x) + ) { + Text( + text = stringResource(id = if (showDetails) R.string.label_hide_details else R.string.label_show_details), + style = MaterialTheme.wireTypography.body02.copy( + textDecoration = TextDecoration.Underline, + color = MaterialTheme.colorScheme.primary + ), + modifier = Modifier + .align(Alignment.Start) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = { showDetails = !showDetails } + ) + ) + } + } + } + ) +} + +@Composable +private fun CustomServerPropertyInfo( + title: String, + value: String +) { + Text( + text = title, + style = MaterialTheme.wireTypography.body01, + color = colorsScheme().onBackground, ) + VerticalSpace.x4() + Text( + text = value, + style = MaterialTheme.wireTypography.body02, + color = colorsScheme().onBackground, + ) + VerticalSpace.x16() } data class CustomServerDialogState(val serverLinks: ServerConfig.Links) + +@PreviewMultipleThemes +@Composable +fun PreviewCustomServerDialog() = WireTheme { + CustomServerDialog( + serverLinks = ServerConfig.DEFAULT, + onConfirm = { }, + onDismiss = { } + ) +} diff --git a/app/src/main/kotlin/com/wire/android/ui/common/groupname/GroupConversationNameComponent.kt b/app/src/main/kotlin/com/wire/android/ui/common/groupname/GroupConversationNameComponent.kt index 177774939e3..34f86264735 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/groupname/GroupConversationNameComponent.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/groupname/GroupConversationNameComponent.kt @@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardActions @@ -31,7 +32,6 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material3.MaterialTheme -import com.wire.android.ui.common.scaffold.WireScaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -46,19 +46,18 @@ import androidx.compose.ui.tooling.preview.Preview import com.wire.android.R import com.wire.android.ui.common.Icon import com.wire.android.ui.common.ShakeAnimation -import com.wire.android.ui.common.WireDropDown import com.wire.android.ui.common.button.WireButtonState import com.wire.android.ui.common.button.WirePrimaryButton import com.wire.android.ui.common.groupname.GroupNameMode.CREATION import com.wire.android.ui.common.rememberBottomBarElevationState import com.wire.android.ui.common.rememberTopBarElevationState +import com.wire.android.ui.common.scaffold.WireScaffold import com.wire.android.ui.common.textfield.WireTextField import com.wire.android.ui.common.textfield.WireTextFieldState import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireDimensions import com.wire.android.ui.theme.wireTypography -import com.wire.kalium.logic.data.conversation.ConversationOptions @OptIn(ExperimentalComposeUiApi::class) @Composable @@ -120,18 +119,23 @@ fun GroupNameScreen( ) } } - if (mode == CREATION && mlsEnabled) { - WireDropDown( - items = - ConversationOptions.Protocol.values().map { it.name }, - defaultItemIndex = ConversationOptions.Protocol.PROTEUS.ordinal, - selectedItemIndex = groupProtocol.ordinal, - label = stringResource(R.string.protocol), + if (mode == CREATION) { + Spacer(modifier = Modifier.height(MaterialTheme.wireDimensions.spacing16x)) + Text( + text = stringResource(R.string.protocol), + style = MaterialTheme.wireTypography.label01, modifier = Modifier - .padding(MaterialTheme.wireDimensions.spacing16x) - ) { selectedIndex -> - groupProtocol = ConversationOptions.Protocol.values()[selectedIndex] - } + .fillMaxWidth() + .padding(horizontal = MaterialTheme.wireDimensions.spacing16x) + .padding(bottom = MaterialTheme.wireDimensions.spacing4x) + ) + Text( + text = groupProtocol.name, + style = MaterialTheme.wireTypography.body02, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = MaterialTheme.wireDimensions.spacing16x) + ) } Spacer(modifier = Modifier.weight(1f)) diff --git a/app/src/main/kotlin/com/wire/android/ui/common/groupname/GroupMetadataState.kt b/app/src/main/kotlin/com/wire/android/ui/common/groupname/GroupMetadataState.kt index 0a3030db861..0275832caea 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/groupname/GroupMetadataState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/groupname/GroupMetadataState.kt @@ -28,10 +28,9 @@ data class GroupMetadataState( val originalGroupName: String = "", val selectedUsers: ImmutableSet = persistentSetOf(), val groupName: TextFieldValue = TextFieldValue(""), - var groupProtocol: ConversationOptions.Protocol = ConversationOptions.Protocol.PROTEUS, + val groupProtocol: ConversationOptions.Protocol = ConversationOptions.Protocol.PROTEUS, val animatedGroupNameError: Boolean = false, val continueEnabled: Boolean = false, - val mlsEnabled: Boolean = true, val isLoading: Boolean = false, val error: NewGroupError = NewGroupError.None, val mode: GroupNameMode = GroupNameMode.CREATION, diff --git a/app/src/main/kotlin/com/wire/android/ui/connection/ConnectionActionButton.kt b/app/src/main/kotlin/com/wire/android/ui/connection/ConnectionActionButton.kt index 690d0ef4183..748808d8d3b 100644 --- a/app/src/main/kotlin/com/wire/android/ui/connection/ConnectionActionButton.kt +++ b/app/src/main/kotlin/com/wire/android/ui/connection/ConnectionActionButton.kt @@ -23,24 +23,34 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import com.wire.android.R import com.wire.android.di.hiltViewModelScoped import com.wire.android.model.ClickBlockParams +import com.wire.android.ui.common.VisibilityState +import com.wire.android.ui.common.WireDialog +import com.wire.android.ui.common.WireDialogButtonProperties +import com.wire.android.ui.common.WireDialogButtonType import com.wire.android.ui.common.button.WireButtonState import com.wire.android.ui.common.button.WirePrimaryButton import com.wire.android.ui.common.button.WireSecondaryButton +import com.wire.android.ui.common.colorsScheme import com.wire.android.ui.common.dialogs.UnblockUserDialogContent import com.wire.android.ui.common.dialogs.UnblockUserDialogState import com.wire.android.ui.common.dimensions import com.wire.android.ui.common.snackbar.LocalSnackbarHostState import com.wire.android.ui.common.snackbar.collectAndShowSnackbar +import com.wire.android.ui.common.visbility.VisibilityState import com.wire.android.ui.common.visbility.rememberVisibilityState import com.wire.android.ui.theme.WireTheme +import com.wire.android.ui.theme.wireTypography import com.wire.android.util.ui.PreviewMultipleThemes +import com.wire.android.util.ui.stringWithStyledArgs import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.data.user.ConnectionState import com.wire.kalium.logic.data.user.UserId @@ -49,7 +59,9 @@ import com.wire.kalium.logic.data.user.UserId fun ConnectionActionButton( userId: UserId, userName: String, + fullName: String, connectionStatus: ConnectionState, + isConversationStarted: Boolean, onConnectionRequestIgnored: (String) -> Unit = {}, onOpenConversation: (ConversationId) -> Unit = {}, viewModel: ConnectionActionButtonViewModel = @@ -59,6 +71,7 @@ fun ConnectionActionButton( ) { LocalSnackbarHostState.current.collectAndShowSnackbar(snackbarFlow = viewModel.infoMessage) val unblockUserDialogState = rememberVisibilityState() + val unableStartConversationDialogState = rememberVisibilityState() UnblockUserDialogContent( dialogState = unblockUserDialogState, @@ -66,6 +79,8 @@ fun ConnectionActionButton( isLoading = viewModel.actionableState().isPerformingAction, ) + UnableStartConversationDialogContent(dialogState = unableStartConversationDialogState) + if (!viewModel.actionableState().isPerformingAction) { unblockUserDialogState.dismiss() } @@ -79,9 +94,13 @@ fun ConnectionActionButton( ) ConnectionState.ACCEPTED -> WirePrimaryButton( - text = stringResource(R.string.label_open_conversation), + text = stringResource(if (isConversationStarted) R.string.label_open_conversation else R.string.label_start_conversation), loading = viewModel.actionableState().isPerformingAction, - onClick = { viewModel.onOpenConversation(onOpenConversation) }, + onClick = { + viewModel.onOpenConversation(onOpenConversation) { + unableStartConversationDialogState.show(UnableStartConversationDialogState(fullName)) + } + }, ) ConnectionState.IGNORED -> WirePrimaryButton( @@ -167,6 +186,31 @@ fun ConnectionActionButton( } } +@Composable +fun UnableStartConversationDialogContent(dialogState: VisibilityState) { + VisibilityState(dialogState) { state -> + WireDialog( + title = stringResource(id = R.string.missing_keypackage_dialog_title), + text = LocalContext.current.resources.stringWithStyledArgs( + R.string.missing_keypackage_dialog_body, + MaterialTheme.wireTypography.body01, + MaterialTheme.wireTypography.body02, + colorsScheme().onBackground, + colorsScheme().onBackground, + state.userName + ), + onDismiss = dialogState::dismiss, + optionButton1Properties = WireDialogButtonProperties( + onClick = dialogState::dismiss, + text = stringResource(id = R.string.label_ok), + type = WireDialogButtonType.Primary, + ), + ) + } +} + +data class UnableStartConversationDialogState(val userName: String) + @Composable @PreviewMultipleThemes fun PreviewOtherUserConnectionActionButtonPending() { @@ -174,7 +218,9 @@ fun PreviewOtherUserConnectionActionButtonPending() { ConnectionActionButton( userId = UserId("value", "domain"), userName = "Username", + fullName = "some user", connectionStatus = ConnectionState.PENDING, + isConversationStarted = false ) } } @@ -186,7 +232,9 @@ fun PreviewOtherUserConnectionActionButtonNotConnected() { ConnectionActionButton( userId = UserId("value", "domain"), userName = "Username", + fullName = "some user", connectionStatus = ConnectionState.NOT_CONNECTED, + isConversationStarted = false ) } } @@ -198,7 +246,9 @@ fun PreviewOtherUserConnectionActionButtonBlocked() { ConnectionActionButton( userId = UserId("value", "domain"), userName = "Username", + fullName = "some user", connectionStatus = ConnectionState.BLOCKED, + isConversationStarted = false ) } } @@ -210,7 +260,9 @@ fun PreviewOtherUserConnectionActionButtonCanceled() { ConnectionActionButton( userId = UserId("value", "domain"), userName = "Username", + fullName = "some user", connectionStatus = ConnectionState.CANCELLED, + isConversationStarted = false ) } } @@ -222,7 +274,9 @@ fun PreviewOtherUserConnectionActionButtonAccepted() { ConnectionActionButton( userId = UserId("value", "domain"), userName = "Username", + fullName = "some user", connectionStatus = ConnectionState.ACCEPTED, + isConversationStarted = false ) } } @@ -234,7 +288,9 @@ fun PreviewOtherUserConnectionActionButtonSent() { ConnectionActionButton( userId = UserId("value", "domain"), userName = "Username", + fullName = "some user", connectionStatus = ConnectionState.SENT, + isConversationStarted = false ) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/connection/ConnectionActionButtonViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/connection/ConnectionActionButtonViewModel.kt index 101e05b9a8f..dedd2ee27b8 100644 --- a/app/src/main/kotlin/com/wire/android/ui/connection/ConnectionActionButtonViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/connection/ConnectionActionButtonViewModel.kt @@ -26,13 +26,14 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.wire.android.R import com.wire.android.appLogger +import com.wire.android.di.ViewModelScopedPreview import com.wire.android.di.scopedArgs import com.wire.android.model.ActionableState import com.wire.android.model.finishAction import com.wire.android.model.performAction -import com.wire.android.di.ViewModelScopedPreview import com.wire.android.util.dispatchers.DispatcherProvider import com.wire.android.util.ui.UIText +import com.wire.kalium.logic.CoreFailure import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.data.id.QualifiedID import com.wire.kalium.logic.feature.connection.AcceptConnectionRequestUseCase @@ -59,13 +60,14 @@ import javax.inject.Inject interface ConnectionActionButtonViewModel { val infoMessage: SharedFlow get() = MutableSharedFlow() + fun actionableState(): ActionableState = ActionableState() fun onSendConnectionRequest() {} fun onCancelConnectionRequest() {} fun onAcceptConnectionRequest() {} fun onIgnoreConnectionRequest(onSuccess: (userName: String) -> Unit) {} fun onUnblockUser() {} - fun onOpenConversation(onSuccess: (conversationId: ConversationId) -> Unit) {} + fun onOpenConversation(onSuccess: (conversationId: ConversationId) -> Unit, onMissingKeyPackages: () -> Unit) {} } @Suppress("LongParameterList", "TooManyFunctions") @@ -191,17 +193,19 @@ class ConnectionActionButtonViewModelImpl @Inject constructor( } } - override fun onOpenConversation(onSuccess: (conversationId: ConversationId) -> Unit) { + override fun onOpenConversation(onSuccess: (conversationId: ConversationId) -> Unit, onMissingKeyPackages: () -> Unit) { viewModelScope.launch { state = state.performAction() when (val result = withContext(dispatchers.io()) { getOrCreateOneToOneConversation(userId) }) { is CreateConversationResult.Failure -> { appLogger.d(("Couldn't retrieve or create the conversation")) state = state.finishAction() + if (result.coreFailure is CoreFailure.MissingKeyPackages) onMissingKeyPackages() } is CreateConversationResult.Success -> onSuccess(result.conversation.id) } + state.finishAction() } } } diff --git a/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptions.kt b/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptions.kt index e492e64a7d7..7cc855f5187 100644 --- a/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptions.kt +++ b/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptions.kt @@ -17,7 +17,6 @@ */ package com.wire.android.ui.debug -import android.content.Context import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -25,21 +24,13 @@ import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue +import androidx.compose.runtime.Stable +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import com.wire.android.BuildConfig import com.wire.android.R -import com.wire.android.datastore.GlobalDataStore -import com.wire.android.di.CurrentAccount -import com.wire.android.feature.e2ei.GetE2EICertificateUseCase -import com.wire.android.migration.failure.UserMigrationStatus import com.wire.android.model.Clickable import com.wire.android.ui.common.RowItemTemplate import com.wire.android.ui.common.WireDialog @@ -48,172 +39,19 @@ import com.wire.android.ui.common.WireDialogButtonType import com.wire.android.ui.common.WireSwitch import com.wire.android.ui.common.button.WirePrimaryButton import com.wire.android.ui.common.dimensions +import com.wire.android.ui.e2eiEnrollment.GetE2EICertificateUI import com.wire.android.ui.home.conversationslist.common.FolderHeader import com.wire.android.ui.home.settings.SettingsItem import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireDimensions import com.wire.android.ui.theme.wireTypography -import com.wire.android.util.getDeviceIdString -import com.wire.android.util.getGitBuildId import com.wire.android.util.ui.PreviewMultipleThemes -import com.wire.kalium.logic.E2EIFailure +import com.wire.kalium.logic.CoreFailure import com.wire.kalium.logic.data.user.UserId -import com.wire.kalium.logic.feature.debug.DisableEventProcessingUseCase import com.wire.kalium.logic.feature.e2ei.usecase.E2EIEnrollmentResult -import com.wire.kalium.logic.feature.keypackage.MLSKeyPackageCountResult -import com.wire.kalium.logic.feature.keypackage.MLSKeyPackageCountUseCase -import com.wire.kalium.logic.functional.fold -import com.wire.kalium.logic.sync.periodic.UpdateApiVersionsScheduler -import com.wire.kalium.logic.sync.slow.RestartSlowSyncProcessForRecoveryUseCase -import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch -import javax.inject.Inject - -//region DebugDataOptionsViewModel -data class DebugDataOptionsState( - val isEncryptedProteusStorageEnabled: Boolean = false, - val isEventProcessingDisabled: Boolean = false, - val keyPackagesCount: Int = 0, - val mslClientId: String = "null", - val mlsErrorMessage: String = "null", - val isManualMigrationAllowed: Boolean = false, - val debugId: String = "null", - val commitish: String = "null", - val certificate: String = "null", - val showCertificate: Boolean = false -) - -@Suppress("LongParameterList") -@HiltViewModel -class DebugDataOptionsViewModel -@Inject constructor( - @ApplicationContext private val context: Context, - @CurrentAccount val currentAccount: UserId, - private val globalDataStore: GlobalDataStore, - private val updateApiVersions: UpdateApiVersionsScheduler, - private val mlsKeyPackageCountUseCase: MLSKeyPackageCountUseCase, - private val restartSlowSyncProcessForRecovery: RestartSlowSyncProcessForRecoveryUseCase, - private val disableEventProcessingUseCase: DisableEventProcessingUseCase, - private val e2eiCertificateUseCase: GetE2EICertificateUseCase -) : ViewModel() { - - var state by mutableStateOf( - DebugDataOptionsState() - ) - - init { - observeEncryptedProteusStorageState() - observeMlsMetadata() - checkIfCanTriggerManualMigration() - state = state.copy( - debugId = context.getDeviceIdString() ?: "null", - commitish = context.getGitBuildId() - ) - } - - fun enableEncryptedProteusStorage(enabled: Boolean) { - if (enabled) { - viewModelScope.launch { - globalDataStore.setEncryptedProteusStorageEnabled(true) - } - } - } - - fun restartSlowSyncForRecovery() { - viewModelScope.launch { - restartSlowSyncProcessForRecovery() - } - } - - fun enrollE2EICertificate(context: Context) { - e2eiCertificateUseCase(context) { result -> - result.fold({ - state = state.copy( - certificate = (it as E2EIFailure.FailedOAuth).reason, showCertificate = true - ) - }, { - if (it is E2EIEnrollmentResult.Finalized) { - state = state.copy( - certificate = it.certificate, showCertificate = true - ) - } - }) - } - } - - fun dismissCertificateDialog() { - state = state.copy( - showCertificate = false, - ) - } - - fun forceUpdateApiVersions() { - updateApiVersions.scheduleImmediateApiVersionUpdate() - } - - fun disableEventProcessing(disabled: Boolean) { - viewModelScope.launch { - disableEventProcessingUseCase(disabled) - state = state.copy(isEventProcessingDisabled = disabled) - } - } - - //region Private - private fun observeEncryptedProteusStorageState() { - viewModelScope.launch { - globalDataStore.isEncryptedProteusStorageEnabled().collect { - state = state.copy(isEncryptedProteusStorageEnabled = it) - } - } - } - - // If status is NoNeed, it means that the user has already been migrated in and older app version, - // or it is a new install - // this is why we check the existence of the database file - private fun checkIfCanTriggerManualMigration() { - viewModelScope.launch { - globalDataStore.getUserMigrationStatus(currentAccount.value).first() - .let { migrationStatus -> - if (migrationStatus != UserMigrationStatus.NoNeed) { - context.getDatabasePath(currentAccount.value).let { - state = state.copy( - isManualMigrationAllowed = (it.exists() && it.isFile) - ) - } - } - } - } - } - - private fun observeMlsMetadata() { - viewModelScope.launch { - mlsKeyPackageCountUseCase().let { - when (it) { - is MLSKeyPackageCountResult.Success -> { - state = state.copy( - keyPackagesCount = it.count, - mslClientId = it.clientId.value - ) - } - - is MLSKeyPackageCountResult.Failure.NetworkCallFailure -> { - state = state.copy(mlsErrorMessage = "Network Error!") - } - - is MLSKeyPackageCountResult.Failure.FetchClientIdFailure -> { - state = state.copy(mlsErrorMessage = "ClientId Fetch Error!") - } - - is MLSKeyPackageCountResult.Failure.Generic -> {} - } - } - } - } - //endregion -} -//endregion +import com.wire.kalium.logic.functional.Either +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.persistentMapOf @Composable fun DebugDataOptions( @@ -234,7 +72,10 @@ fun DebugDataOptions( onManualMigrationPressed = { onManualMigrationPressed(viewModel.currentAccount) }, onDisableEventProcessingChange = viewModel::disableEventProcessing, enrollE2EICertificate = viewModel::enrollE2EICertificate, - dismissCertificateDialog = viewModel::dismissCertificateDialog + handleE2EIEnrollmentResult = viewModel::handleE2EIEnrollmentResult, + dismissCertificateDialog = viewModel::dismissCertificateDialog, + checkCrlRevocationList = viewModel::checkCrlRevocationList, + dependenciesMap = viewModel.state.dependencies ) } @@ -250,8 +91,11 @@ fun DebugDataOptionsContent( onRestartSlowSyncForRecovery: () -> Unit, onForceUpdateApiVersions: () -> Unit, onManualMigrationPressed: () -> Unit, - enrollE2EICertificate: (Context) -> Unit, - dismissCertificateDialog: () -> Unit + enrollE2EICertificate: () -> Unit, + handleE2EIEnrollmentResult: (Either) -> Unit, + dismissCertificateDialog: () -> Unit, + checkCrlRevocationList: () -> Unit, + dependenciesMap: ImmutableMap ) { Column { @@ -286,6 +130,7 @@ fun DebugDataOptionsContent( onClick = { onCopyText(state.commitish) } ) ) + DependenciesItem(dependenciesMap) if (BuildConfig.PRIVATE_BUILD) { SettingsItem( @@ -337,7 +182,8 @@ fun DebugDataOptionsContent( isEventProcessingEnabled = state.isEventProcessingDisabled, onDisableEventProcessingChange = onDisableEventProcessingChange, onRestartSlowSyncForRecovery = onRestartSlowSyncForRecovery, - onForceUpdateApiVersions = onForceUpdateApiVersions + onForceUpdateApiVersions = onForceUpdateApiVersions, + checkCrlRevocationList = checkCrlRevocationList ) } @@ -347,14 +193,20 @@ fun DebugDataOptionsContent( onManualMigrationClicked = onManualMigrationPressed ) } + + if (state.startGettingE2EICertificate) { + GetE2EICertificateUI( + enrollmentResultHandler = { handleE2EIEnrollmentResult(it) }, + isNewClient = false + ) + } } } @Composable private fun GetE2EICertificateSwitch( - enrollE2EI: (context: Context) -> Unit + enrollE2EI: () -> Unit ) { - val context = LocalContext.current Column { FolderHeader(stringResource(R.string.debug_settings_e2ei_enrollment_title)) RowItemTemplate(modifier = Modifier.wrapContentWidth(), @@ -369,7 +221,7 @@ private fun GetE2EICertificateSwitch( actions = { WirePrimaryButton( onClick = { - enrollE2EI(context) + enrollE2EI() }, text = stringResource(R.string.label_get_e2ei_cetificate), fillMaxWidth = false @@ -499,7 +351,8 @@ private fun DebugToolsOptions( isEventProcessingEnabled: Boolean, onDisableEventProcessingChange: (Boolean) -> Unit, onRestartSlowSyncForRecovery: () -> Unit, - onForceUpdateApiVersions: () -> Unit + onForceUpdateApiVersions: () -> Unit, + checkCrlRevocationList: () -> Unit ) { FolderHeader(stringResource(R.string.label_debug_tools_title)) Column { @@ -527,6 +380,29 @@ private fun DebugToolsOptions( ) } ) + + // checkCrlRevocationList + RowItemTemplate( + modifier = Modifier.wrapContentWidth(), + title = { + Text( + style = MaterialTheme.wireTypography.body01, + color = MaterialTheme.wireColorScheme.onBackground, + text = "CRL revocation check", + modifier = Modifier.padding(start = dimensions().spacing8x) + ) + }, + actions = { + WirePrimaryButton( + minSize = MaterialTheme.wireDimensions.buttonMediumMinSize, + minClickableSize = MaterialTheme.wireDimensions.buttonMinClickableSize, + onClick = checkCrlRevocationList, + text = stringResource(R.string.debug_settings_force_api_versioning_update_button_text), + fillMaxWidth = false + ) + } + ) + RowItemTemplate( modifier = Modifier.wrapContentWidth(), title = { @@ -550,6 +426,28 @@ private fun DebugToolsOptions( } } +/** + * + */ +@Composable +fun DependenciesItem(dependencies: ImmutableMap) { + val title = stringResource(id = R.string.item_dependencies_title) + val text = remember { + prettyPrintMap(dependencies, title) + } + RowItemTemplate( + modifier = Modifier.wrapContentWidth(), + title = { + Text( + style = MaterialTheme.wireTypography.body01, + color = MaterialTheme.wireColorScheme.onBackground, + text = text, + modifier = Modifier.padding(start = dimensions().spacing8x) + ) + } + ) +} + @Composable private fun DisableEventProcessingSwitch( isEnabled: Boolean = false, @@ -578,6 +476,15 @@ private fun DisableEventProcessingSwitch( } ) } + +@Stable +private fun prettyPrintMap(map: Map, title: String): String = StringBuilder().apply { + append("$title\n") + map.forEach { (key, value) -> + append("$key: $value\n") + } +}.toString() + //endregion @PreviewMultipleThemes @@ -602,6 +509,9 @@ fun PreviewOtherDebugOptions() { onRestartSlowSyncForRecovery = {}, onManualMigrationPressed = {}, enrollE2EICertificate = {}, + handleE2EIEnrollmentResult = {}, dismissCertificateDialog = {}, + checkCrlRevocationList = {}, + dependenciesMap = persistentMapOf() ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptionsState.kt b/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptionsState.kt new file mode 100644 index 00000000000..c0f648184dc --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptionsState.kt @@ -0,0 +1,36 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.debug + +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.persistentMapOf + +data class DebugDataOptionsState( + val isEncryptedProteusStorageEnabled: Boolean = false, + val isEventProcessingDisabled: Boolean = false, + val keyPackagesCount: Int = 0, + val mslClientId: String = "null", + val mlsErrorMessage: String = "null", + val isManualMigrationAllowed: Boolean = false, + val debugId: String = "null", + val commitish: String = "null", + val certificate: String = "null", + val showCertificate: Boolean = false, + val startGettingE2EICertificate: Boolean = false, + val dependencies: ImmutableMap = persistentMapOf() +) diff --git a/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptionsViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptionsViewModel.kt new file mode 100644 index 00000000000..944c27e0336 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptionsViewModel.kt @@ -0,0 +1,215 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.debug + +import android.content.Context +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.wire.android.datastore.GlobalDataStore +import com.wire.android.di.CurrentAccount +import com.wire.android.migration.failure.UserMigrationStatus +import com.wire.android.util.getDependenciesVersion +import com.wire.android.util.getDeviceIdString +import com.wire.android.util.getGitBuildId +import com.wire.kalium.logic.CoreFailure +import com.wire.kalium.logic.E2EIFailure +import com.wire.kalium.logic.data.user.UserId +import com.wire.kalium.logic.feature.e2ei.CheckCrlRevocationListUseCase +import com.wire.kalium.logic.feature.e2ei.usecase.E2EIEnrollmentResult +import com.wire.kalium.logic.feature.keypackage.MLSKeyPackageCountResult +import com.wire.kalium.logic.feature.keypackage.MLSKeyPackageCountUseCase +import com.wire.kalium.logic.functional.Either +import com.wire.kalium.logic.functional.fold +import com.wire.kalium.logic.sync.periodic.UpdateApiVersionsScheduler +import com.wire.kalium.logic.sync.slow.RestartSlowSyncProcessForRecoveryUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.collections.immutable.toImmutableMap +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import javax.inject.Inject + +@Suppress("LongParameterList") +@HiltViewModel +class DebugDataOptionsViewModel +@Inject constructor( + @ApplicationContext private val context: Context, + @CurrentAccount val currentAccount: UserId, + private val globalDataStore: GlobalDataStore, + private val updateApiVersions: UpdateApiVersionsScheduler, + private val mlsKeyPackageCount: MLSKeyPackageCountUseCase, + private val restartSlowSyncProcessForRecovery: RestartSlowSyncProcessForRecoveryUseCase, + private val checkCrlRevocationList: CheckCrlRevocationListUseCase +) : ViewModel() { + + var state by mutableStateOf( + DebugDataOptionsState() + ) + + init { + observeEncryptedProteusStorageState() + observeMlsMetadata() + checkIfCanTriggerManualMigration() + setGitHashAndDeviceId() + checkDependenciesVersion() + } + + private fun checkDependenciesVersion() { + viewModelScope.launch { + val dependencies = context.getDependenciesVersion().toImmutableMap() + state = state.copy( + dependencies = dependencies + ) + } + } + + private fun setGitHashAndDeviceId() { + viewModelScope.launch { + val deviceId = context.getDeviceIdString() ?: "null" + val gitBuildId = context.getGitBuildId() + state = state.copy( + debugId = deviceId, + commitish = gitBuildId + ) + } + } + + fun checkCrlRevocationList() { + viewModelScope.launch { + checkCrlRevocationList( + forceUpdate = true + ) + } + } + + fun enableEncryptedProteusStorage(enabled: Boolean) { + if (enabled) { + viewModelScope.launch { + globalDataStore.setEncryptedProteusStorageEnabled(true) + } + } + } + + fun restartSlowSyncForRecovery() { + viewModelScope.launch { + restartSlowSyncProcessForRecovery() + } + } + + fun enrollE2EICertificate() { + state = state.copy(startGettingE2EICertificate = true) + } + + fun handleE2EIEnrollmentResult(result: Either) { + result.fold({ + state = state.copy( + certificate = (it as E2EIFailure.OAuth).reason, + showCertificate = true, + startGettingE2EICertificate = false + ) + }, { + if (it is E2EIEnrollmentResult.Finalized) { + state = state.copy( + certificate = it.certificate, + showCertificate = true, + startGettingE2EICertificate = false + ) + } else { + state.copy( + certificate = it.toString(), + showCertificate = true, + startGettingE2EICertificate = false + ) + } + }) + } + + fun dismissCertificateDialog() { + state = state.copy( + showCertificate = false, + ) + } + + fun forceUpdateApiVersions() { + updateApiVersions.scheduleImmediateApiVersionUpdate() + } + + fun disableEventProcessing(disabled: Boolean) { + viewModelScope.launch { + disableEventProcessing(disabled) + state = state.copy(isEventProcessingDisabled = disabled) + } + } + + //region Private + private fun observeEncryptedProteusStorageState() { + viewModelScope.launch { + globalDataStore.isEncryptedProteusStorageEnabled().collect { + state = state.copy(isEncryptedProteusStorageEnabled = it) + } + } + } + + // If status is NoNeed, it means that the user has already been migrated in and older app version, + // or it is a new install + // this is why we check the existence of the database file + private fun checkIfCanTriggerManualMigration() { + viewModelScope.launch { + globalDataStore.getUserMigrationStatus(currentAccount.value).first() + .let { migrationStatus -> + if (migrationStatus != UserMigrationStatus.NoNeed) { + context.getDatabasePath(currentAccount.value).let { + state = state.copy( + isManualMigrationAllowed = (it.exists() && it.isFile) + ) + } + } + } + } + } + + private fun observeMlsMetadata() { + viewModelScope.launch { + mlsKeyPackageCount().let { + when (it) { + is MLSKeyPackageCountResult.Success -> { + state = state.copy( + keyPackagesCount = it.count, + mslClientId = it.clientId.value + ) + } + + is MLSKeyPackageCountResult.Failure.NetworkCallFailure -> { + state = state.copy(mlsErrorMessage = "Network Error!") + } + + is MLSKeyPackageCountResult.Failure.FetchClientIdFailure -> { + state = state.copy(mlsErrorMessage = "ClientId Fetch Error!") + } + + is MLSKeyPackageCountResult.Failure.Generic -> {} + } + } + } + } + //endregion +} +//endregion diff --git a/app/src/main/kotlin/com/wire/android/ui/debug/DebugScreen.kt b/app/src/main/kotlin/com/wire/android/ui/debug/DebugScreen.kt index cc6d9225d8b..fa38be1fb90 100644 --- a/app/src/main/kotlin/com/wire/android/ui/debug/DebugScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/debug/DebugScreen.kt @@ -70,9 +70,9 @@ fun DebugScreen(navigator: Navigator) { private fun UserDebugContent( onNavigationPressed: () -> Unit, onManualMigrationPressed: (currentAccount: UserId) -> Unit, -) { + userDebugViewModel: UserDebugViewModel = hiltViewModel(), - val userDebugViewModel: UserDebugViewModel = hiltViewModel() +) { val debugContentState: DebugContentState = rememberDebugContentState(userDebugViewModel.logPath) WireScaffold( diff --git a/app/src/main/kotlin/com/wire/android/ui/debug/StartServiceReceiver.kt b/app/src/main/kotlin/com/wire/android/ui/debug/StartServiceReceiver.kt index 98f788c39f2..93dd70ce2ce 100644 --- a/app/src/main/kotlin/com/wire/android/ui/debug/StartServiceReceiver.kt +++ b/app/src/main/kotlin/com/wire/android/ui/debug/StartServiceReceiver.kt @@ -21,13 +21,9 @@ package com.wire.android.ui.debug import android.content.BroadcastReceiver import android.content.Context import android.content.Intent -import android.os.Build import com.wire.android.appLogger -import com.wire.android.di.KaliumCoreLogic -import com.wire.android.services.PersistentWebSocketService +import com.wire.android.feature.StartPersistentWebsocketIfNecessaryUseCase import com.wire.android.util.dispatchers.DispatcherProvider -import com.wire.kalium.logic.CoreLogic -import com.wire.kalium.logic.feature.user.webSocketStatus.ObservePersistentWebSocketConnectionStatusUseCase import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob @@ -43,41 +39,18 @@ class StartServiceReceiver : BroadcastReceiver() { lateinit var dispatcherProvider: DispatcherProvider @Inject - @KaliumCoreLogic - lateinit var coreLogic: CoreLogic + lateinit var startPersistentWebSocketService: StartPersistentWebsocketIfNecessaryUseCase private val scope by lazy { CoroutineScope(SupervisorJob() + dispatcherProvider.io()) } override fun onReceive(context: Context?, intent: Intent?) { - val persistentWebSocketServiceIntent = PersistentWebSocketService.newIntent(context) - appLogger.e("persistent web socket receiver") - scope.launch { - coreLogic.getGlobalScope().observePersistentWebSocketConnectionStatus().let { result -> - when (result) { - is ObservePersistentWebSocketConnectionStatusUseCase.Result.Failure -> { - appLogger.e("Failure while fetching persistent web socket status flow from StartServiceReceiver") - } + appLogger.i("$TAG: onReceive called with action ${intent?.action}") + scope.launch { startPersistentWebSocketService() } + } - is ObservePersistentWebSocketConnectionStatusUseCase.Result.Success -> { - result.persistentWebSocketStatusListFlow.collect { status -> - if (status.map { it.isPersistentWebSocketEnabled }.contains(true)) { - appLogger.e("Starting PersistentWebsocket Service from StartServiceReceiver") - if (!PersistentWebSocketService.isServiceStarted) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - context?.startForegroundService(persistentWebSocketServiceIntent) - } else { - context?.startService(persistentWebSocketServiceIntent) - } - } - } else { - context?.stopService(persistentWebSocketServiceIntent) - } - } - } - } - } - } + companion object { + const val TAG = "StartServiceReceiver" } } diff --git a/app/src/main/kotlin/com/wire/android/ui/e2eiEnrollment/E2EIEnrollmentScreen.kt b/app/src/main/kotlin/com/wire/android/ui/e2eiEnrollment/E2EIEnrollmentScreen.kt new file mode 100644 index 00000000000..b2557824f93 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/e2eiEnrollment/E2EIEnrollmentScreen.kt @@ -0,0 +1,241 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.e2eiEnrollment + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.annotation.RootNavGraph +import com.wire.android.R +import com.wire.android.feature.NavigationSwitchAccountActions +import com.wire.android.navigation.BackStackMode +import com.wire.android.navigation.NavigationCommand +import com.wire.android.navigation.Navigator +import com.wire.android.navigation.style.PopUpNavigationAnimation +import com.wire.android.ui.common.ClickableText +import com.wire.android.ui.common.button.WireButtonState +import com.wire.android.ui.common.button.WirePrimaryButton +import com.wire.android.ui.common.colorsScheme +import com.wire.android.ui.common.dialogs.CancelLoginDialogContent +import com.wire.android.ui.common.dialogs.CancelLoginDialogState +import com.wire.android.ui.common.dimensions +import com.wire.android.ui.common.scaffold.WireScaffold +import com.wire.android.ui.common.topappbar.NavigationIconType +import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar +import com.wire.android.ui.common.visbility.rememberVisibilityState +import com.wire.android.ui.destinations.E2eiCertificateDetailsScreenDestination +import com.wire.android.ui.destinations.InitialSyncScreenDestination +import com.wire.android.ui.home.E2EIEnrollmentErrorWithDismissDialog +import com.wire.android.ui.home.E2EISuccessDialog +import com.wire.android.ui.markdown.MarkdownConstants +import com.wire.android.ui.theme.WireTheme +import com.wire.android.ui.theme.wireDimensions +import com.wire.android.ui.theme.wireTypography +import com.wire.android.util.ui.PreviewMultipleThemes +import com.wire.kalium.logic.CoreFailure +import com.wire.kalium.logic.feature.e2ei.usecase.E2EIEnrollmentResult +import com.wire.kalium.logic.functional.Either + +@RootNavGraph +@Destination( + style = PopUpNavigationAnimation::class +) +@Composable +fun E2EIEnrollmentScreen( + navigator: Navigator, + viewModel: E2EIEnrollmentViewModel = hiltViewModel(), +) { + val state = viewModel.state + + E2EIEnrollmentScreenContent( + state = state, + dismissSuccess = { + navigator.navigate(NavigationCommand(InitialSyncScreenDestination, BackStackMode.CLEAR_WHOLE)) + viewModel.finalizeMLSClient() + }, + dismissErrorDialog = viewModel::dismissErrorDialog, + enrollE2EICertificate = viewModel::enrollE2EICertificate, + handleE2EIEnrollmentResult = viewModel::handleE2EIEnrollmentResult, + openCertificateDetails = { + navigator.navigate(NavigationCommand(E2eiCertificateDetailsScreenDestination(state.certificate, true))) + }, + onBackButtonClicked = viewModel::onBackButtonClicked, + onCancelEnrollmentClicked = { viewModel.onCancelEnrollmentClicked(NavigationSwitchAccountActions(navigator::navigate)) }, + onProceedEnrollmentClicked = viewModel::onProceedEnrollmentClicked + ) +} + +@Composable +private fun E2EIEnrollmentScreenContent( + state: E2EIEnrollmentState, + dismissSuccess: () -> Unit, + dismissErrorDialog: () -> Unit, + enrollE2EICertificate: () -> Unit, + handleE2EIEnrollmentResult: (Either) -> Unit, + openCertificateDetails: () -> Unit, + onBackButtonClicked: () -> Unit, + onCancelEnrollmentClicked: () -> Unit, + onProceedEnrollmentClicked: () -> Unit +) { + val uriHandler = LocalUriHandler.current + BackHandler { + onBackButtonClicked() + } + val cancelLoginDialogState = rememberVisibilityState() + CancelLoginDialogContent( + dialogState = cancelLoginDialogState, + onActionButtonClicked = { + onCancelEnrollmentClicked() + }, + onProceedButtonClicked = { + onProceedEnrollmentClicked() + } + ) + if (state.showCancelLoginDialog) { + cancelLoginDialogState.show( + cancelLoginDialogState.savedState ?: CancelLoginDialogState + ) + } else { + cancelLoginDialogState.dismiss() + } + WireScaffold( + topBar = { + WireCenterAlignedTopAppBar( + elevation = 0.dp, + title = stringResource(id = R.string.end_to_end_identity_required_dialog_title), + navigationIconType = NavigationIconType.Close, + onNavigationPressed = onBackButtonClicked + ) + }, + bottomBar = { + Column( + Modifier + .wrapContentWidth(Alignment.CenterHorizontally) + ) { + WirePrimaryButton( + onClick = enrollE2EICertificate, + text = stringResource(id = R.string.end_to_end_identity_required_dialog_positive_button), + state = WireButtonState.Default, + loading = state.isLoading, + modifier = Modifier.padding( + top = dimensions().spacing16x, + start = dimensions().spacing16x, + end = dimensions().spacing16x, + bottom = dimensions().spacing16x + ) + ) + } + } + ) { internalPadding -> + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Top, + modifier = Modifier.padding(PaddingValues(MaterialTheme.wireDimensions.dialogContentPadding)) + ) { + Spacer(modifier = Modifier.height(internalPadding.calculateTopPadding())) + val text = buildAnnotatedString { + val style = SpanStyle( + color = colorsScheme().onBackground, + fontWeight = MaterialTheme.wireTypography.body01.fontWeight, + fontSize = MaterialTheme.wireTypography.body01.fontSize, + fontFamily = MaterialTheme.wireTypography.body01.fontFamily, + fontStyle = MaterialTheme.wireTypography.body01.fontStyle + ) + withStyle(style) { append(stringResource(id = R.string.end_to_end_identity_required_dialog_text_no_snooze)) } + } + ClickableText( + text = text, + style = MaterialTheme.wireTypography.body01, + modifier = Modifier.padding( + top = MaterialTheme.wireDimensions.dialogTextsSpacing, + bottom = MaterialTheme.wireDimensions.dialogTextsSpacing, + ), + onClick = { offset -> + text.getStringAnnotations( + tag = MarkdownConstants.TAG_URL, + start = offset, + end = offset, + ).firstOrNull()?.let { result -> uriHandler.openUri(result.item) } + } + ) + } + + if (state.isCertificateEnrollError) { + E2EIEnrollmentErrorWithDismissDialog( + isE2EILoading = state.isLoading, + onClick = enrollE2EICertificate, + onDismiss = dismissErrorDialog + ) + } + + if (state.isCertificateEnrollSuccess) { + E2EISuccessDialog( + openCertificateDetails = openCertificateDetails, + dismissDialog = dismissSuccess + ) + } + + if (state.startGettingE2EICertificate) { + GetE2EICertificateUI( + enrollmentResultHandler = { handleE2EIEnrollmentResult(it) }, + isNewClient = true + ) + } + } +} + +@PreviewMultipleThemes +@Composable +fun previewE2EIEnrollmentScreenContent() { + WireTheme { + E2EIEnrollmentScreenContent(E2EIEnrollmentState(), {}, {}, {}, {}, {}, {}, {}) { } + } +} + +@PreviewMultipleThemes +@Composable +fun previewE2EIEnrollmentScreenContentWithSuccess() { + WireTheme { + E2EIEnrollmentScreenContent(E2EIEnrollmentState(isCertificateEnrollSuccess = true), {}, {}, {}, {}, {}, {}, {}) { } + } +} + +@PreviewMultipleThemes +@Composable +fun previewE2EIEnrollmentScreenContentWithError() { + WireTheme { + E2EIEnrollmentScreenContent(E2EIEnrollmentState(isCertificateEnrollError = true), {}, {}, {}, {}, {}, {}, {}) { } + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/e2eiEnrollment/E2EIEnrollmentViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/e2eiEnrollment/E2EIEnrollmentViewModel.kt new file mode 100644 index 00000000000..63d99974623 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/e2eiEnrollment/E2EIEnrollmentViewModel.kt @@ -0,0 +1,135 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.e2eiEnrollment + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.wire.android.appLogger +import com.wire.android.feature.AccountSwitchUseCase +import com.wire.android.feature.SwitchAccountActions +import com.wire.android.feature.SwitchAccountParam +import com.wire.kalium.logic.CoreFailure +import com.wire.kalium.logic.feature.client.FinalizeMLSClientAfterE2EIEnrollment +import com.wire.kalium.logic.feature.e2ei.usecase.E2EIEnrollmentResult +import com.wire.kalium.logic.feature.session.CurrentSessionResult +import com.wire.kalium.logic.feature.session.CurrentSessionUseCase +import com.wire.kalium.logic.feature.session.DeleteSessionUseCase +import com.wire.kalium.logic.functional.Either +import com.wire.kalium.logic.functional.fold +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class E2EIEnrollmentState( + val certificate: String = "null", + val showCertificate: Boolean = false, + val isLoading: Boolean = false, + val isCertificateEnrollError: Boolean = false, + val isCertificateEnrollSuccess: Boolean = false, + val showCancelLoginDialog: Boolean = false, + val startGettingE2EICertificate: Boolean = false +) + +@HiltViewModel +class E2EIEnrollmentViewModel @Inject constructor( + private val finalizeMLSClientAfterE2EIEnrollment: FinalizeMLSClientAfterE2EIEnrollment, + private val currentSession: CurrentSessionUseCase, + private val deleteSession: DeleteSessionUseCase, + private val switchAccount: AccountSwitchUseCase +) : ViewModel() { + var state by mutableStateOf(E2EIEnrollmentState()) + + fun finalizeMLSClient() { + viewModelScope.launch { + finalizeMLSClientAfterE2EIEnrollment.invoke() + } + } + + fun onBackButtonClicked() { + state = state.copy(showCancelLoginDialog = true) + } + + fun onProceedEnrollmentClicked() { + state = state.copy(showCancelLoginDialog = false) + } + + fun onCancelEnrollmentClicked(switchAccountActions: SwitchAccountActions) { + state = state.copy(showCancelLoginDialog = false) + viewModelScope.launch { + currentSession().let { + when (it) { + is CurrentSessionResult.Success -> { + deleteSession(it.accountInfo.userId) + } + + is CurrentSessionResult.Failure.Generic -> { + appLogger.e("failed to delete session") + } + + CurrentSessionResult.Failure.SessionNotFound -> { + appLogger.e("session not found") + } + } + } + }.invokeOnCompletion { + viewModelScope.launch { + switchAccount(SwitchAccountParam.TryToSwitchToNextAccount) + .callAction(switchAccountActions) + } + } + } + + fun enrollE2EICertificate() { + state = state.copy(isLoading = true, startGettingE2EICertificate = true) + } + + fun handleE2EIEnrollmentResult(result: Either) { + result.fold({ + state = state.copy( + isLoading = false, + isCertificateEnrollError = true, + startGettingE2EICertificate = false + ) + }, { + if (it is E2EIEnrollmentResult.Finalized) { + state = state.copy( + certificate = it.certificate, + isCertificateEnrollSuccess = true, + isCertificateEnrollError = false, + isLoading = false, + startGettingE2EICertificate = false + ) + } else { + state = state.copy( + isLoading = false, + isCertificateEnrollError = true, + startGettingE2EICertificate = false + ) + } + }) + } + + fun dismissErrorDialog() { + state = state.copy( + isCertificateEnrollError = false, + ) + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/e2eiEnrollment/GetE2EICertificateUI.kt b/app/src/main/kotlin/com/wire/android/ui/e2eiEnrollment/GetE2EICertificateUI.kt new file mode 100644 index 00000000000..9a844bea755 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/e2eiEnrollment/GetE2EICertificateUI.kt @@ -0,0 +1,56 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.e2eiEnrollment + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.platform.LocalContext +import androidx.hilt.navigation.compose.hiltViewModel +import com.wire.android.feature.e2ei.OAuthUseCase +import com.wire.android.util.extension.getActivity +import com.wire.kalium.logic.CoreFailure +import com.wire.kalium.logic.feature.e2ei.usecase.E2EIEnrollmentResult +import com.wire.kalium.logic.functional.Either +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +@Composable +fun GetE2EICertificateUI( + enrollmentResultHandler: (Either) -> Unit, + isNewClient: Boolean, + viewModel: GetE2EICertificateViewModel = hiltViewModel() +) { + val coroutineScope = rememberCoroutineScope() + val context = LocalContext.current + + LaunchedEffect(Unit) { + viewModel.requestOAuthFlow.onEach { + OAuthUseCase(context, it.target, it.oAuthClaims, it.oAuthState).launch( + context.getActivity()!!.activityResultRegistry, forceLoginFlow = true + ) { result -> viewModel.handleOAuthResult(result, it) } + }.launchIn(coroutineScope) + } + + LaunchedEffect(Unit) { + viewModel.enrollmentResultFlow.onEach { enrollmentResultHandler(it) }.launchIn(coroutineScope) + } + LaunchedEffect(Unit) { + viewModel.getCertificate(isNewClient) + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/e2eiEnrollment/GetE2EICertificateViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/e2eiEnrollment/GetE2EICertificateViewModel.kt new file mode 100644 index 00000000000..118c0eb2de1 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/e2eiEnrollment/GetE2EICertificateViewModel.kt @@ -0,0 +1,95 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.e2eiEnrollment + +import androidx.lifecycle.ViewModel +import com.wire.android.di.KaliumCoreLogic +import com.wire.android.feature.e2ei.OAuthUseCase +import com.wire.android.util.dispatchers.DispatcherProvider +import com.wire.kalium.logic.CoreFailure +import com.wire.kalium.logic.CoreLogic +import com.wire.kalium.logic.E2EIFailure +import com.wire.kalium.logic.feature.e2ei.usecase.E2EIEnrollmentResult +import com.wire.kalium.logic.feature.session.CurrentSessionResult +import com.wire.kalium.logic.feature.session.CurrentSessionUseCase +import com.wire.kalium.logic.functional.Either +import com.wire.kalium.logic.functional.fold +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class GetE2EICertificateViewModel @Inject constructor( + @KaliumCoreLogic private val coreLogic: CoreLogic, + private val currentSession: CurrentSessionUseCase, + val dispatcherProvider: DispatcherProvider +) : ViewModel() { + + private val scope = CoroutineScope(SupervisorJob() + dispatcherProvider.default()) + + val requestOAuthFlow = MutableSharedFlow() + val enrollmentResultFlow = MutableSharedFlow>() + + fun handleOAuthResult(oAuthResult: OAuthUseCase.OAuthResult, initialEnrollmentResult: E2EIEnrollmentResult.Initialized) { + scope.launch { + when (oAuthResult) { + is OAuthUseCase.OAuthResult.Success -> finalizeEnrollment(oAuthResult, initialEnrollmentResult) + + is OAuthUseCase.OAuthResult.Failed -> enrollmentResultFlow.emit(Either.Left(E2EIFailure.OAuth(oAuthResult.reason))) + } + } + } + + fun getCertificate(isNewClient: Boolean) { + scope.launch { + val currentSessionResult = currentSession() + if (currentSessionResult is CurrentSessionResult.Success && currentSessionResult.accountInfo.isValid()) { + coreLogic.getSessionScope(currentSessionResult.accountInfo.userId) + .users + .enrollE2EI + .initialEnrollment(isNewClientRegistration = isNewClient) + .fold({ + enrollmentResultFlow.emit(Either.Left(it)) + }, { + requestOAuthFlow.emit(it) + }) + } + } + } + + private suspend fun finalizeEnrollment( + oAuthResult: OAuthUseCase.OAuthResult.Success, + initialEnrollmentResult: E2EIEnrollmentResult.Initialized + ) { + val currentSessionResult = currentSession() + + if (currentSessionResult is CurrentSessionResult.Success && currentSessionResult.accountInfo.isValid()) { + val enrollmentResult = coreLogic.getSessionScope(currentSessionResult.accountInfo.userId) + .users + .enrollE2EI.finalizeEnrollment( + oAuthResult.idToken, + oAuthResult.authState, + initialEnrollmentResult + ) + enrollmentResultFlow.emit(enrollmentResult) + } + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/AppSyncViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/AppSyncViewModel.kt new file mode 100644 index 00000000000..79b2a2cbfb9 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/AppSyncViewModel.kt @@ -0,0 +1,49 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.home + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import com.wire.android.navigation.SavedStateViewModel +import com.wire.kalium.logic.feature.e2ei.CertificateRevocationListCheckWorker +import com.wire.kalium.logic.feature.e2ei.usecase.ObserveCertificateRevocationForSelfClientUseCase +import com.wire.kalium.logic.feature.featureConfig.FeatureFlagsSyncWorker +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class AppSyncViewModel @Inject constructor( + override val savedStateHandle: SavedStateHandle, + private val certificateRevocationListCheckWorker: CertificateRevocationListCheckWorker, + private val observeCertificateRevocationForSelfClient: ObserveCertificateRevocationForSelfClientUseCase, + private val featureFlagsSyncWorker: FeatureFlagsSyncWorker +) : SavedStateViewModel(savedStateHandle) { + + fun startSyncingAppConfig() { + viewModelScope.launch { + certificateRevocationListCheckWorker.execute() + } + viewModelScope.launch { + observeCertificateRevocationForSelfClient.invoke() + } + viewModelScope.launch { + featureFlagsSyncWorker.execute() + } + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/E2EIDialogs.kt b/app/src/main/kotlin/com/wire/android/ui/home/E2EIDialogs.kt index 993805ad25b..1d5206ceed3 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/E2EIDialogs.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/E2EIDialogs.kt @@ -52,29 +52,29 @@ import kotlin.time.Duration.Companion.seconds fun E2EIRequiredDialog( e2EIRequired: FeatureFlagState.E2EIRequired, isE2EILoading: Boolean, - getCertificate: (FeatureFlagState.E2EIRequired) -> Unit, + getCertificate: () -> Unit, snoozeDialog: (FeatureFlagState.E2EIRequired.WithGracePeriod) -> Unit, ) { when (e2EIRequired) { FeatureFlagState.E2EIRequired.NoGracePeriod.Create -> E2EIRequiredNoSnoozeDialog( isLoading = isE2EILoading, - getCertificate = { getCertificate(e2EIRequired) } + getCertificate = getCertificate ) FeatureFlagState.E2EIRequired.NoGracePeriod.Renew -> E2EIRenewNoSnoozeDialog( isLoading = isE2EILoading, - updateCertificate = { getCertificate(e2EIRequired) } + updateCertificate = getCertificate ) is FeatureFlagState.E2EIRequired.WithGracePeriod.Create -> E2EIRequiredWithSnoozeDialog( isLoading = isE2EILoading, - getCertificate = { getCertificate(e2EIRequired) }, + getCertificate = getCertificate, snoozeDialog = { snoozeDialog(e2EIRequired) } ) is FeatureFlagState.E2EIRequired.WithGracePeriod.Renew -> E2EIRenewWithSnoozeDialog( isLoading = isE2EILoading, - updateCertificate = { getCertificate(e2EIRequired) }, + updateCertificate = getCertificate, snoozeDialog = { snoozeDialog(e2EIRequired) } ) } @@ -84,7 +84,7 @@ fun E2EIRequiredDialog( fun E2EIResultDialog( result: FeatureFlagState.E2EIResult, isE2EILoading: Boolean, - updateCertificate: (FeatureFlagState.E2EIRequired) -> Unit, + updateCertificate: () -> Unit, snoozeDialog: (FeatureFlagState.E2EIRequired.WithGracePeriod) -> Unit, openCertificateDetails: (String) -> Unit, dismissSuccessDialog: () -> Unit @@ -93,7 +93,7 @@ fun E2EIResultDialog( is FeatureFlagState.E2EIResult.Failure -> E2EIRenewErrorDialog( e2EIRequired = result.e2EIRequired, isE2EILoading = isE2EILoading, - updateCertificate = { updateCertificate(result.e2EIRequired) }, + updateCertificate = updateCertificate, snoozeDialog = snoozeDialog ) @@ -195,14 +195,46 @@ fun E2EISuccessDialog( } @Composable -fun E2EIErrorWithDismissDialog( +fun E2EIUpdateErrorWithDismissDialog( isE2EILoading: Boolean, updateCertificate: () -> Unit, onDismiss: () -> Unit ) { - WireDialog( + E2EIErrorWithDismissDialog( title = stringResource(id = R.string.end_to_end_identity_renew_error_dialog_title), text = stringResource(id = R.string.end_to_end_identity_renew_error_dialog_text), + isE2EILoading = isE2EILoading, + updateCertificate = updateCertificate, + onDismiss = onDismiss + ) +} + +@Composable +fun E2EIEnrollmentErrorWithDismissDialog( + isE2EILoading: Boolean, + onClick: () -> Unit, + onDismiss: () -> Unit +) { + E2EIErrorWithDismissDialog( + title = stringResource(id = R.string.end_to_end_identity_enrollment_error_dialog_title), + text = stringResource(id = R.string.end_to_end_identity_enrollment_error_dialog_text), + isE2EILoading = isE2EILoading, + updateCertificate = onClick, + onDismiss = onDismiss + ) +} + +@Composable +private fun E2EIErrorWithDismissDialog( + title: String, + text: String, + isE2EILoading: Boolean, + updateCertificate: () -> Unit, + onDismiss: () -> Unit +) { + WireDialog( + title = title, + text = text, onDismiss = onDismiss, optionButton1Properties = WireDialogButtonProperties( onClick = updateCertificate, @@ -363,9 +395,43 @@ private fun E2EIRenewNoSnoozeDialog(isLoading: Boolean, updateCertificate: () -> ) } +@Composable +fun E2EICertificateRevokedDialog( + onLogout: () -> Unit, + onContinue: () -> Unit +) { + WireDialog( + title = stringResource(id = R.string.end_to_end_identity_certificate_revoked_dialog_title), + text = stringResource(id = R.string.end_to_end_identity_certificate_revoked_dialog_description), + onDismiss = onContinue, + optionButton1Properties = WireDialogButtonProperties( + onClick = onLogout, + text = stringResource(id = R.string.end_to_end_identity_certificate_revoked_dialog_button_logout), + type = WireDialogButtonType.Primary + ), + optionButton2Properties = WireDialogButtonProperties( + onClick = onContinue, + text = stringResource(id = R.string.end_to_end_identity_certificate_revoked_dialog_button_continue), + type = WireDialogButtonType.Secondary, + ), + buttonsHorizontalAlignment = false, + properties = DialogProperties( + usePlatformDefaultWidth = false, + dismissOnBackPress = false, + dismissOnClickOutside = false + ) + ) +} + +@PreviewMultipleThemes +@Composable +fun PreviewE2EICertificateRevokedDialog() { + E2EICertificateRevokedDialog({}, {}) +} + @PreviewMultipleThemes @Composable -fun previewE2EIdRequiredWithSnoozeDialog() { +fun PreviewE2EIdRequiredWithSnoozeDialog() { WireTheme { E2EIRequiredWithSnoozeDialog(false, {}) {} } @@ -373,7 +439,7 @@ fun previewE2EIdRequiredWithSnoozeDialog() { @PreviewMultipleThemes @Composable -fun previewE2EIdRequiredNoSnoozeDialog() { +fun PreviewE2EIdRequiredNoSnoozeDialog() { WireTheme { E2EIRequiredNoSnoozeDialog(false) {} } @@ -381,7 +447,7 @@ fun previewE2EIdRequiredNoSnoozeDialog() { @PreviewMultipleThemes @Composable -fun previewE2EIdRenewRequiredWithSnoozeDialog() { +fun PreviewE2EIdRenewRequiredWithSnoozeDialog() { WireTheme { E2EIRenewWithSnoozeDialog(false, {}) {} } @@ -389,7 +455,7 @@ fun previewE2EIdRenewRequiredWithSnoozeDialog() { @PreviewMultipleThemes @Composable -fun previewE2EIdRenewRequiredNoSnoozeDialog() { +fun PreviewE2EIdRenewRequiredNoSnoozeDialog() { WireTheme { E2EIRenewNoSnoozeDialog(false) {} } @@ -397,7 +463,7 @@ fun previewE2EIdRenewRequiredNoSnoozeDialog() { @PreviewMultipleThemes @Composable -fun previewE2EIdSnoozeDialog() { +fun PreviewE2EIdSnoozeDialog() { WireTheme { E2EISnoozeDialog(2.seconds) {} } @@ -405,7 +471,7 @@ fun previewE2EIdSnoozeDialog() { @PreviewMultipleThemes @Composable -fun previewE2EIRenewErrorDialogNoGracePeriod() { +fun PreviewE2EIRenewErrorDialogNoGracePeriod() { WireTheme { E2EIRenewErrorDialog(FeatureFlagState.E2EIRequired.NoGracePeriod.Renew, false, { }) {} } @@ -413,7 +479,7 @@ fun previewE2EIRenewErrorDialogNoGracePeriod() { @PreviewMultipleThemes @Composable -fun previewE2EIRenewErrorDialogWithGracePeriod() { +fun PreviewE2EIRenewErrorDialogWithGracePeriod() { WireTheme { E2EIRenewErrorDialog(FeatureFlagState.E2EIRequired.WithGracePeriod.Renew(2.days), false, { }) {} } @@ -421,7 +487,7 @@ fun previewE2EIRenewErrorDialogWithGracePeriod() { @PreviewMultipleThemes @Composable -fun previewE2EISuccessDialog() { +fun PreviewE2EISuccessDialog() { WireTheme { E2EISuccessDialog({ }) {} } @@ -429,7 +495,7 @@ fun previewE2EISuccessDialog() { @PreviewMultipleThemes @Composable -fun previewE2EIRenewErrorNoSnoozeDialog() { +fun PreviewE2EIRenewErrorNoSnoozeDialog() { WireTheme { E2EIErrorNoSnoozeDialog(false) { } } @@ -437,7 +503,7 @@ fun previewE2EIRenewErrorNoSnoozeDialog() { @PreviewMultipleThemes @Composable -fun previewE2EIRenewErrorWithSnoozeDialog() { +fun PreviewE2EIRenewErrorWithSnoozeDialog() { WireTheme { E2EIErrorWithSnoozeDialog(isE2EILoading = false, updateCertificate = {}) { } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/FeatureFlagState.kt b/app/src/main/kotlin/com/wire/android/ui/home/FeatureFlagState.kt index 6282528519c..ed540612adf 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/FeatureFlagState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/FeatureFlagState.kt @@ -26,6 +26,7 @@ data class FeatureFlagState( val isFileSharingEnabledState: Boolean = true, val fileSharingRestrictedState: SharingRestrictedState? = null, val shouldShowGuestRoomLinkDialog: Boolean = false, + val shouldShowE2eiCertificateRevokedDialog: Boolean = false, val shouldShowTeamAppLockDialog: Boolean = false, val isTeamAppLockEnabled: Boolean = false, val isGuestRoomLinkEnabled: Boolean = true, @@ -36,7 +37,8 @@ data class FeatureFlagState( val e2EISnoozeInfo: E2EISnooze? = null, val e2EIResult: E2EIResult? = null, val isE2EILoading: Boolean = false, - val showCallEndedBecauseOfConversationDegraded: Boolean = false + val showCallEndedBecauseOfConversationDegraded: Boolean = false, + val startGettingE2EICertificate: Boolean = false ) { enum class SharingRestrictedState { NONE, NO_USER, RESTRICTED_IN_TEAM diff --git a/app/src/main/kotlin/com/wire/android/ui/home/HomeScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/HomeScreen.kt index 9330e43cab0..b64521cc649 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/HomeScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/HomeScreen.kt @@ -38,6 +38,7 @@ import androidx.compose.material3.ModalDrawerSheet import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Modifier @@ -52,6 +53,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.min import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver import com.google.accompanist.navigation.material.ExperimentalMaterialNavigationApi import com.ramcosta.composedestinations.DestinationsNavHost import com.ramcosta.composedestinations.animations.defaults.RootNavGraphDefaultAnimations @@ -96,6 +98,7 @@ import kotlinx.coroutines.launch fun HomeScreen( navigator: Navigator, homeViewModel: HomeViewModel = hiltViewModel(), + appSyncViewModel: AppSyncViewModel = hiltViewModel(), homeDrawerViewModel: HomeDrawerViewModel = hiltViewModel(), conversationListViewModel: ConversationListViewModel = hiltViewModel(), // TODO: move required elements from this one to HomeViewModel?, groupDetailsScreenResultRecipient: ResultRecipient, @@ -106,6 +109,21 @@ fun HomeScreen( val showNotificationsFlow = rememberRequestPushNotificationsPermissionFlow( onPermissionDenied = { /** TODO: Show a dialog rationale explaining why the permission is needed **/ }) + val lifecycleOwner = LocalLifecycleOwner.current + + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { source, event -> + if (event == Lifecycle.Event.ON_RESUME) { + appSyncViewModel.startSyncingAppConfig() + } + } + lifecycleOwner.lifecycle.addObserver(observer) + + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + LaunchedEffect(homeViewModel.savedStateHandle) { showNotificationsFlow.launch() } @@ -300,7 +318,10 @@ fun HomeContent( contentScale = ContentScale.FillBounds, colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onPrimary), modifier = Modifier - .padding(start = dimensions().spacing4x, top = dimensions().spacing2x) + .padding( + start = dimensions().spacing4x, + top = dimensions().spacing2x + ) .size(dimensions().fabIconSize) ) }, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/HomeViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/HomeViewModel.kt index ffcaa4ed585..f810fec27eb 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/HomeViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/HomeViewModel.kt @@ -29,6 +29,7 @@ import com.wire.android.model.ImageAsset.UserAvatarAsset import com.wire.android.navigation.SavedStateViewModel import com.wire.android.util.ui.WireSessionImageLoader import com.wire.kalium.logic.feature.client.NeedsToRegisterClientUseCase +import com.wire.kalium.logic.feature.e2ei.CertificateRevocationListCheckWorker import com.wire.kalium.logic.feature.user.GetSelfUserUseCase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.first @@ -43,7 +44,8 @@ class HomeViewModel @Inject constructor( private val getSelf: GetSelfUserUseCase, private val needsToRegisterClient: NeedsToRegisterClientUseCase, private val wireSessionImageLoader: WireSessionImageLoader, - private val shouldTriggerMigrationForUser: ShouldTriggerMigrationForUserUserCase + private val shouldTriggerMigrationForUser: ShouldTriggerMigrationForUserUserCase, + private val certificateRevocationListCheckWorker: CertificateRevocationListCheckWorker ) : SavedStateViewModel(savedStateHandle) { var homeState by mutableStateOf(HomeState()) @@ -51,6 +53,9 @@ class HomeViewModel @Inject constructor( init { loadUserAvatar() + viewModelScope.launch { + certificateRevocationListCheckWorker.execute() + } } fun checkRequirements(onRequirement: (HomeRequirement) -> Unit) { diff --git a/app/src/main/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockScreenViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockScreenViewModel.kt index 3e3262e0f51..60d2e6e500d 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockScreenViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockScreenViewModel.kt @@ -201,7 +201,6 @@ class ForgotLockScreenViewModel @Inject constructor( // TODO: we should have a dedicated manager to perform these required actions in AR after every LogoutUseCase call private suspend fun hardLogoutAccount(userId: UserId) { notificationManager.stopObservingOnLogout(userId) - notificationChannelsManager.deleteChannelGroup(userId) coreLogic.getSessionScope(userId).logout(reason = LogoutReason.SELF_HARD_LOGOUT, waitUntilCompletes = true) userDataStoreProvider.getOrCreate(userId).clear() } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt index bedb879b5e8..d7447d68f78 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt @@ -56,9 +56,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusManager import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview @@ -144,6 +142,7 @@ import com.wire.kalium.logic.data.message.SelfDeletionTimer import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.feature.call.usecase.ConferenceCallingResult import com.wire.kalium.logic.feature.conversation.InteractionAvailability +import kotlinx.collections.immutable.PersistentMap import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow @@ -187,7 +186,6 @@ fun ConversationScreen( val coroutineScope = rememberCoroutineScope() val uriHandler = LocalUriHandler.current val showDialog = remember { mutableStateOf(ConversationScreenDialogType.NONE) } - val focusManager = LocalFocusManager.current val conversationScreenState = rememberConversationScreenState() val messageComposerViewState = messageComposerViewModel.messageComposerViewState val messageComposerStateHolder = rememberMessageComposerStateHolder( @@ -280,6 +278,7 @@ fun ConversationScreen( ConversationScreenDialogType.VERIFICATION_DEGRADED -> { SureAboutCallingInDegradedConversationDialog( callAnyway = { + conversationCallViewModel.onApplyConversationDegradation() startCallIfPossible( conversationCallViewModel, showDialog, @@ -292,7 +291,6 @@ fun ConversationScreen( }, onDialogDismiss = { showDialog.value = ConversationScreenDialogType.NONE } ) - conversationCallViewModel.onConversationDegradedDialogShown() } ConversationScreenDialogType.NONE -> {} @@ -373,7 +371,7 @@ fun ConversationScreen( } } }, - onBackButtonClick = { conversationScreenOnBackButtonClick(messageComposerViewModel, focusManager, navigator) }, + onBackButtonClick = { conversationScreenOnBackButtonClick(messageComposerViewModel, messageComposerStateHolder, navigator) }, composerMessages = messageComposerViewModel.infoMessage, conversationMessages = conversationMessagesViewModel.infoMessage, conversationMessagesViewModel = conversationMessagesViewModel, @@ -402,7 +400,7 @@ fun ConversationScreen( }, onTypingEvent = messageComposerViewModel::sendTypingEvent ) - BackHandler { conversationScreenOnBackButtonClick(messageComposerViewModel, focusManager, navigator) } + BackHandler { conversationScreenOnBackButtonClick(messageComposerViewModel, messageComposerStateHolder, navigator) } DeleteMessageDialog( state = messageComposerViewModel.deleteMessageDialogsState, actions = messageComposerViewModel.deleteMessageHelper @@ -434,12 +432,12 @@ fun ConversationScreen( ) (messageComposerViewModel.sureAboutMessagingDialogState as? SureAboutMessagingDialogState.Visible.ConversationUnderLegalHold)?.let { - LegalHoldSubjectMessageDialog( - conversationName = conversationInfoViewModel.conversationInfoViewState.conversationName.asString(), - dialogDismissed = messageComposerViewModel::dismissSureAboutSendingMessage, - sendAnywayClicked = messageComposerViewModel::acceptSureAboutSendingMessage, - ) - } + LegalHoldSubjectMessageDialog( + conversationName = conversationInfoViewModel.conversationInfoViewState.conversationName.asString(), + dialogDismissed = messageComposerViewModel::dismissSureAboutSendingMessage, + sendAnywayClicked = messageComposerViewModel::acceptSureAboutSendingMessage, + ) + } groupDetailsScreenResultRecipient.onNavResult { result -> when (result) { @@ -498,11 +496,11 @@ fun ConversationScreen( private fun conversationScreenOnBackButtonClick( messageComposerViewModel: MessageComposerViewModel, - focusManager: FocusManager, + messageComposerStateHolder: MessageComposerStateHolder, navigator: Navigator ) { messageComposerViewModel.sendTypingEvent(TypingIndicatorMode.STOPPED) - focusManager.clearFocus(true) + messageComposerStateHolder.messageCompositionInputStateHolder.collapseComposer(null) navigator.navigateBack() } @@ -713,7 +711,7 @@ private fun ConversationScreenContent( conversationId: ConversationId, lastUnreadMessageInstant: Instant?, unreadEventCount: Int, - audioMessagesState: Map, + audioMessagesState: PersistentMap, selectedMessageId: String?, messageComposerStateHolder: MessageComposerStateHolder, messages: Flow>, @@ -814,13 +812,14 @@ fun SnackBarMessage( } } +@Suppress("ComplexMethod", "ComplexCondition") @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") @Composable fun MessageList( lazyPagingMessages: LazyPagingItems, lazyListState: LazyListState, lastUnreadMessageInstant: Instant?, - audioMessagesState: Map, + audioMessagesState: PersistentMap, onUpdateConversationReadDate: (String) -> Unit, onAssetItemClicked: (String) -> Unit, onImageFullScreenMode: (UIMessage.Regular, Boolean) -> Unit, @@ -837,26 +836,39 @@ fun MessageList( onLinkClick: (String) -> Unit, selectedMessageId: String? ) { - val mostRecentMessage = lazyPagingMessages.itemCount.takeIf { it > 0 }?.let { lazyPagingMessages[0] } + val prevItemCount = remember { mutableStateOf(lazyPagingMessages.itemCount) } + val readLastMessageAtStartTriggered = remember { mutableStateOf(false) } - LaunchedEffect(mostRecentMessage) { - // Most recent message changed, if the user didn't scroll up, we automatically scroll down to reveal the new message - if (lazyListState.firstVisibleItemIndex < MAXIMUM_SCROLLED_MESSAGES_UNTIL_AUTOSCROLL_STOPS) { - lazyListState.animateScrollToItem(0) + LaunchedEffect(lazyPagingMessages.itemCount) { + if (lazyPagingMessages.itemCount > prevItemCount.value && selectedMessageId == null) { + if (prevItemCount.value > 0 + && lazyListState.firstVisibleItemIndex > 0 + && lazyListState.firstVisibleItemIndex <= MAXIMUM_SCROLLED_MESSAGES_UNTIL_AUTOSCROLL_STOPS + ) { + lazyListState.animateScrollToItem(0) + } + prevItemCount.value = lazyPagingMessages.itemCount } } + // update last read message when scroll ends LaunchedEffect(lazyListState.isScrollInProgress) { if (!lazyListState.isScrollInProgress && lazyPagingMessages.itemCount > 0) { val lastVisibleMessage = lazyPagingMessages[lazyListState.firstVisibleItemIndex] ?: return@LaunchedEffect + updateLastReadMessage(lastVisibleMessage, lastUnreadMessageInstant, onUpdateConversationReadDate) + } + } - val lastVisibleMessageInstant = Instant.parse(lastVisibleMessage.header.messageTime.utcISO) - - // TODO: This IF condition should be in the UseCase - // If there are no unread messages, then use distant future and don't update read date - if (lastVisibleMessageInstant > (lastUnreadMessageInstant ?: Instant.DISTANT_FUTURE)) { - onUpdateConversationReadDate(lastVisibleMessage.header.messageTime.utcISO) + // update last read message on start or when list is not scrollable + LaunchedEffect(lazyPagingMessages.itemCount) { + if ((!readLastMessageAtStartTriggered.value || (!lazyListState.canScrollBackward && !lazyListState.canScrollForward)) + && lazyPagingMessages.itemSnapshotList.items.isNotEmpty() + ) { + val lastVisibleMessage = lazyPagingMessages[lazyListState.firstVisibleItemIndex] ?: return@LaunchedEffect + if (!readLastMessageAtStartTriggered.value) { + readLastMessageAtStartTriggered.value = true } + updateLastReadMessage(lastVisibleMessage, lastUnreadMessageInstant, onUpdateConversationReadDate) } } @@ -924,6 +936,20 @@ fun MessageList( }) } +private fun updateLastReadMessage( + lastVisibleMessage: UIMessage, + lastUnreadMessageInstant: Instant?, + onUpdateConversationReadDate: (String) -> Unit +) { + val lastVisibleMessageInstant = Instant.parse(lastVisibleMessage.header.messageTime.utcISO) + + // TODO: This IF condition should be in the UseCase + // If there are no unread messages, then use distant future and don't update read date + if (lastVisibleMessageInstant > (lastUnreadMessageInstant ?: Instant.DISTANT_FUTURE)) { + onUpdateConversationReadDate(lastVisibleMessage.header.messageTime.utcISO) + } +} + @Composable fun JumpToLastMessageButton( coroutineScope: CoroutineScope = rememberCoroutineScope(), diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/MessageComposerViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/MessageComposerViewModel.kt index fbe5d4155cf..ff1c167bf58 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/MessageComposerViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/MessageComposerViewModel.kt @@ -76,13 +76,19 @@ import com.wire.kalium.logic.feature.message.SendTextMessageUseCase import com.wire.kalium.logic.feature.message.ephemeral.EnqueueMessageSelfDeletionUseCase import com.wire.kalium.logic.feature.selfDeletingMessages.ObserveSelfDeletionTimerSettingsForConversationUseCase import com.wire.kalium.logic.feature.selfDeletingMessages.PersistNewSelfDeletionTimerUseCase +import com.wire.kalium.logic.feature.session.CurrentSessionFlowUseCase +import com.wire.kalium.logic.feature.session.CurrentSessionResult import com.wire.kalium.logic.feature.user.IsFileSharingEnabledUseCase import com.wire.kalium.logic.functional.Either import com.wire.kalium.logic.functional.onFailure import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.datetime.Instant @@ -120,6 +126,7 @@ class MessageComposerViewModel @Inject constructor( private val setNotifiedAboutConversationUnderLegalHold: SetNotifiedAboutConversationUnderLegalHoldUseCase, private val observeConversationUnderLegalHoldNotified: ObserveConversationUnderLegalHoldNotifiedUseCase, private val sendLocation: SendLocationUseCase, + private val currentSessionFlowUseCase: CurrentSessionFlowUseCase, ) : SavedStateViewModel(savedStateHandle) { var messageComposerViewState = mutableStateOf(MessageComposerViewState()) @@ -188,14 +195,24 @@ class MessageComposerViewModel @Inject constructor( } private fun observeIsTypingAvailable() = viewModelScope.launch { - observeConversationInteractionAvailability(conversationId).collect { result -> - messageComposerViewState.value = messageComposerViewState.value.copy( - interactionAvailability = when (result) { - is IsInteractionAvailableResult.Failure -> InteractionAvailability.DISABLED - is IsInteractionAvailableResult.Success -> result.interactionAvailability + currentSessionFlowUseCase() + .flatMapLatest { + when (it) { + is CurrentSessionResult.Success -> { + observeConversationInteractionAvailability(conversationId) + .mapLatest { result -> + when (result) { + is IsInteractionAvailableResult.Failure -> InteractionAvailability.DISABLED + is IsInteractionAvailableResult.Success -> result.interactionAvailability + } + } + } + else -> flowOf(InteractionAvailability.DISABLED) } - ) - } + } + .collectLatest { + messageComposerViewState.value = messageComposerViewState.value.copy(interactionAvailability = it) + } } private fun observeSelfDeletingMessagesStatus() = viewModelScope.launch { @@ -525,11 +542,7 @@ class MessageComposerViewModel @Inject constructor( } fun dismissSureAboutSendingMessage() { - (sureAboutMessagingDialogState as? SureAboutMessagingDialogState.Visible)?.let { - viewModelScope.launch { - it.markAsNotified() - } - } + sureAboutMessagingDialogState = SureAboutMessagingDialogState.Hidden } private suspend fun SureAboutMessagingDialogState.markAsNotified() { diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/MessageExpiration.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/MessageExpiration.kt index 5eadf4f0cf8..cb3b04720bb 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/MessageExpiration.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/MessageExpiration.kt @@ -17,15 +17,18 @@ */ package com.wire.android.ui.home.conversations -import android.content.Context -import android.content.res.Resources +import androidx.annotation.VisibleForTesting import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.repeatOnLifecycle import com.wire.android.R import com.wire.android.ui.home.conversations.model.ExpirationStatus import com.wire.android.ui.home.conversations.model.UIMessage @@ -33,33 +36,56 @@ import com.wire.android.ui.home.conversations.model.UIMessageContent import com.wire.kalium.logic.data.message.Message import kotlinx.coroutines.delay import kotlinx.datetime.Clock +import kotlinx.datetime.Instant import kotlin.time.Duration import kotlin.time.Duration.Companion.ZERO import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds +import kotlin.time.DurationUnit +import kotlin.time.toDuration @Composable fun rememberSelfDeletionTimer(expirationStatus: ExpirationStatus): SelfDeletionTimerHelper.SelfDeletionTimerState { - val context = LocalContext.current + val stringResourceProvider: StringResourceProvider = stringResourceProvider() + val currentTimeProvider: CurrentTimeProvider = { Clock.System.now() } - return remember( - (expirationStatus as? ExpirationStatus.Expirable)?.selfDeletionStatus ?: true - ) { SelfDeletionTimerHelper(context).fromExpirationStatus(expirationStatus) } + return remember((expirationStatus as? ExpirationStatus.Expirable)?.selfDeletionStatus ?: true) { + SelfDeletionTimerHelper(stringResourceProvider, currentTimeProvider) + .fromExpirationStatus(expirationStatus) + } } -class SelfDeletionTimerHelper(private val context: Context) { +@Composable +private fun stringResourceProvider(): StringResourceProvider { + with(LocalContext.current.resources) { + return object : StringResourceProvider { + override fun quantityString(type: StringResourceType, quantity: Int): String = + getQuantityString( + when (type) { + StringResourceType.WEEKS -> R.plurals.weeks_left + StringResourceType.DAYS -> R.plurals.days_left + StringResourceType.HOURS -> R.plurals.hours_left + StringResourceType.MINUTES -> R.plurals.minutes_left + StringResourceType.SECONDS -> R.plurals.seconds_left + }, quantity, quantity + ) + } + } +} + +class SelfDeletionTimerHelper(private val stringResourceProvider: StringResourceProvider, private val currentTime: CurrentTimeProvider) { fun fromExpirationStatus(expirationStatus: ExpirationStatus): SelfDeletionTimerState { return if (expirationStatus is ExpirationStatus.Expirable) { with(expirationStatus) { - val timeLeft = calculateTimeLeft(selfDeletionStatus, expireAfter) + val expireAt = calculateExpireAt(selfDeletionStatus, expireAfter) SelfDeletionTimerState.Expirable( - context.resources, - timeLeft, + stringResourceProvider, expireAfter, - selfDeletionStatus is Message.ExpirationData.SelfDeletionStatus.Started + expireAt, + currentTime ) } } else { @@ -67,35 +93,23 @@ class SelfDeletionTimerHelper(private val context: Context) { } } - private fun calculateTimeLeft( + private fun calculateExpireAt( selfDeletionStatus: Message.ExpirationData.SelfDeletionStatus?, - expireAfter: Duration - ): Duration { - return if (selfDeletionStatus is Message.ExpirationData.SelfDeletionStatus.Started) { - val timeElapsedSinceSelfDeletionStartDate = Clock.System.now() - selfDeletionStatus.selfDeletionStartDate - val timeLeft = expireAfter - timeElapsedSinceSelfDeletionStartDate - - /** - * time left for deletion, can be a negative value if the time difference between the self deletion start date and - * Clock.System.now() is greater then [expireAfter], we normalize it to 0 seconds - */ - if (timeLeft.isNegative()) { - ZERO - } else { - timeLeft - } - } else { - expireAfter + expireAfter: Duration, + ) = + if (selfDeletionStatus is Message.ExpirationData.SelfDeletionStatus.Started) selfDeletionStatus.selfDeletionStartDate + expireAfter + else { + val currentTime = currentTime() + currentTime + expireAfter } - } sealed class SelfDeletionTimerState { class Expirable( - private val resources: Resources, - timeLeft: Duration, + private val stringResourceProvider: StringResourceProvider, private val expireAfter: Duration, - val timerStarted: Boolean + private val expireAt: Instant, + private val currentTime: CurrentTimeProvider, ) : SelfDeletionTimerState() { companion object { /** @@ -115,67 +129,37 @@ class SelfDeletionTimerHelper(private val context: Context) { private const val OPAQUE_BACKGROUND_COLOR_ALPHA_VALUE = 1F } - var timeLeft by mutableStateOf(timeLeft) - + var timeLeft by mutableStateOf(calculateTimeLeft()) + private set @Suppress("MagicNumber", "ComplexMethod") - fun timeLeftFormatted(): String = when { - timeLeft > 28.days -> - resources.getQuantityString( - R.plurals.weeks_left, - 4, - 4 - ) - // 4 weeks - timeLeft >= 27.days && timeLeft <= 28.days -> - resources.getQuantityString( - R.plurals.weeks_left, - 4, - 4 - ) - // days below 4 weeks - timeLeft <= 27.days && timeLeft > 7.days -> - resources.getQuantityString( - R.plurals.days_left, - timeLeft.inWholeDays.toInt(), - timeLeft.inWholeDays.toInt() - ) - // one week - timeLeft >= 6.days && timeLeft <= 7.days -> - resources.getQuantityString( - R.plurals.weeks_left, - 1, - 1 - ) - // days below 1 week - timeLeft < 7.days && timeLeft >= 1.days -> - resources.getQuantityString( - R.plurals.days_left, - timeLeft.inWholeDays.toInt(), - timeLeft.inWholeDays.toInt() - ) - // hours below one day - timeLeft >= 1.hours && timeLeft < 24.hours -> - resources.getQuantityString( - R.plurals.hours_left, - timeLeft.inWholeHours.toInt(), - timeLeft.inWholeHours.toInt() - ) - // minutes below hour - timeLeft >= 1.minutes && timeLeft < 60.minutes -> - resources.getQuantityString( - R.plurals.minutes_left, - timeLeft.inWholeMinutes.toInt(), - timeLeft.inWholeMinutes.toInt() - ) - // seconds below minute - timeLeft < 60.seconds -> - resources.getQuantityString( - R.plurals.seconds_left, - timeLeft.inWholeSeconds.toInt(), - timeLeft.inWholeSeconds.toInt() - ) + val timeLeftFormatted: String by derivedStateOf { + when { + timeLeft > 28.days -> + stringResourceProvider.quantityString(StringResourceType.WEEKS, 4) + // 4 weeks + timeLeft >= 27.days && timeLeft <= 28.days -> + stringResourceProvider.quantityString(StringResourceType.WEEKS, 4) + // days below 4 weeks + timeLeft <= 27.days && timeLeft > 7.days -> + stringResourceProvider.quantityString(StringResourceType.DAYS, timeLeft.inWholeDays.toInt()) + // one week + timeLeft >= 6.days && timeLeft <= 7.days -> + stringResourceProvider.quantityString(StringResourceType.WEEKS, 1) + // days below 1 week + timeLeft < 7.days && timeLeft >= 1.days -> + stringResourceProvider.quantityString(StringResourceType.DAYS, timeLeft.inWholeDays.toInt()) + // hours below one day + timeLeft >= 1.hours && timeLeft < 24.hours -> + stringResourceProvider.quantityString(StringResourceType.HOURS, timeLeft.inWholeHours.toInt()) + // minutes below hour + timeLeft >= 1.minutes && timeLeft < 60.minutes -> + stringResourceProvider.quantityString(StringResourceType.MINUTES, timeLeft.inWholeMinutes.toInt()) + // seconds below minute + timeLeft < 60.seconds -> + stringResourceProvider.quantityString(StringResourceType.SECONDS, timeLeft.inWholeSeconds.toInt()) - else -> throw IllegalStateException("Not possible state for a time left label") + else -> throw IllegalStateException("Not possible state for a time left label") + } } /** @@ -186,48 +170,41 @@ class SelfDeletionTimerHelper(private val context: Context) { * updated every second. * @return how long until the next timer update. */ - fun updateInterval(): Duration { - val timeLeftUpdateInterval = when { - timeLeft > 24.hours -> { - val timeLeftTillWholeDay = (timeLeft.inWholeMinutes % 1.days.inWholeMinutes).minutes - if (timeLeftTillWholeDay == ZERO) { - 1.days - } else { - timeLeftTillWholeDay - } - } - - timeLeft <= 24.hours && timeLeft > 1.hours -> { - val timeLeftTillWholeHour = (timeLeft.inWholeSeconds % 1.hours.inWholeSeconds).seconds - if (timeLeftTillWholeHour == ZERO) { - 1.hours - } else { - timeLeftTillWholeHour - } - } - - timeLeft <= 1.hours && timeLeft > 1.minutes -> { - val timeLeftTillWholeMinute = (timeLeft.inWholeSeconds % 1.minutes.inWholeSeconds).seconds - if (timeLeftTillWholeMinute == ZERO) { - 1.minutes - } else { - timeLeftTillWholeMinute - } - } - - timeLeft <= 1.minutes -> { - 1.seconds - } + @VisibleForTesting + internal fun updateInterval(): Duration { + fun remainingTimeToDurationUnit(durationUnit: DurationUnit): Duration { + /* + * Function toLong returns the whole part for the given duration unit and then this whole value is converted back to + * Duration and subtracted from the original duration, which gives the remaining time to the next full duration unit. + * + * For example, if the time left is "1 day and 1 hour" and durationUnit is DAYS, then toLong will return 1L + * which means "1 full day" (just like .inWholeDays) and then it will be converted back to Duration type. + * Then this "1 day" will be subtracted from the original duration, returning "1 hour" left ("1d 1h" - "1d" = "1h"). + * So in this case it's the same as `timeLeft - timeLeft.inWholeHours.hours` + * because `timeLeft.inWholeDays` is basically `timeLeft.toLong(DurationUnit.DAYS)` + * and `1L.days` is the same as `1L.toDuration(DurationUnit.DAYS)`. + */ + val timeLeftForDurationUnit = timeLeft - timeLeft.toLong(durationUnit).toDuration(durationUnit) + return if (timeLeftForDurationUnit == ZERO) 1.toDuration(durationUnit) + else timeLeftForDurationUnit + } + val timeLeftUpdateInterval = when { + timeLeft > 24.hours -> remainingTimeToDurationUnit(DurationUnit.DAYS) + timeLeft <= 24.hours && timeLeft > 1.hours -> remainingTimeToDurationUnit(DurationUnit.HOURS) + timeLeft <= 1.hours && timeLeft > 1.minutes -> remainingTimeToDurationUnit(DurationUnit.MINUTES) + timeLeft <= 1.minutes -> remainingTimeToDurationUnit(DurationUnit.SECONDS) else -> throw IllegalStateException("Not possible state for the interval") } return timeLeftUpdateInterval } - fun decreaseTimeLeft(interval: Duration) { - if (timeLeft.inWholeSeconds != 0L) timeLeft -= interval - } + // non-negative value, returns ZERO if message is already expired + private fun calculateTimeLeft(): Duration = (expireAt - currentTime()).let { if (it.isNegative()) ZERO else it } + + @VisibleForTesting + internal fun recalculateTimeLeft() { timeLeft = calculateTimeLeft() } /** * if the time elapsed ratio is between 0.50 and 0.75 @@ -266,72 +243,82 @@ class SelfDeletionTimerHelper(private val context: Context) { OPAQUE_BACKGROUND_COLOR_ALPHA_VALUE } } - } - object NotExpirable : SelfDeletionTimerState() - } -} + @Composable + fun startDeletionTimer(message: UIMessage, onStartMessageSelfDeletion: (UIMessage) -> Unit) { + when (val messageContent = message.messageContent) { + is UIMessageContent.AssetMessage -> startAssetDeletion( + onSelfDeletingMessageRead = { onStartMessageSelfDeletion(message) }, + downloadStatus = messageContent.downloadStatus + ) -@Composable -fun startDeletionTimer( - message: UIMessage, - expirableTimer: SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable, - onStartMessageSelfDeletion: (UIMessage) -> Unit -) { - when (val messageContent = message.messageContent) { - is UIMessageContent.AssetMessage -> startAssetDeletion( - expirableTimer = expirableTimer, - onSelfDeletingMessageRead = { onStartMessageSelfDeletion(message) }, - downloadStatus = messageContent.downloadStatus - ) + is UIMessageContent.AudioAssetMessage -> startAssetDeletion( + onSelfDeletingMessageRead = { onStartMessageSelfDeletion(message) }, + downloadStatus = messageContent.downloadStatus + ) - is UIMessageContent.AudioAssetMessage -> startAssetDeletion( - expirableTimer = expirableTimer, - onSelfDeletingMessageRead = { onStartMessageSelfDeletion(message) }, - downloadStatus = messageContent.downloadStatus - ) + is UIMessageContent.ImageMessage -> startAssetDeletion( + onSelfDeletingMessageRead = { onStartMessageSelfDeletion(message) }, + downloadStatus = messageContent.downloadStatus + ) - is UIMessageContent.ImageMessage -> startAssetDeletion( - expirableTimer = expirableTimer, - onSelfDeletingMessageRead = { onStartMessageSelfDeletion(message) }, - downloadStatus = messageContent.downloadStatus - ) + else -> startRegularDeletion(message = message, onStartMessageSelfDeletion = onStartMessageSelfDeletion) + } + } - else -> { - LaunchedEffect(Unit) { - onStartMessageSelfDeletion(message) + @Composable + private fun startAssetDeletion(onSelfDeletingMessageRead: () -> Unit, downloadStatus: Message.DownloadStatus) { + LaunchedEffect(downloadStatus) { + if (downloadStatus == Message.DownloadStatus.SAVED_EXTERNALLY + || downloadStatus == Message.DownloadStatus.SAVED_INTERNALLY + ) { + onSelfDeletingMessageRead() + } + } + LaunchedEffect(key1 = timeLeft, key2 = downloadStatus) { + if (downloadStatus == Message.DownloadStatus.SAVED_EXTERNALLY + || downloadStatus == Message.DownloadStatus.SAVED_INTERNALLY + ) { + if (timeLeft != ZERO) { + delay(updateInterval()) + recalculateTimeLeft() + } + } + } + val lifecycleOwner = LocalLifecycleOwner.current + LaunchedEffect(lifecycleOwner) { + lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + recalculateTimeLeft() + } + } } - LaunchedEffect(expirableTimer.timeLeft) { - with(expirableTimer) { + + @Composable + private fun startRegularDeletion(message: UIMessage, onStartMessageSelfDeletion: (UIMessage) -> Unit) { + LaunchedEffect(Unit) { + onStartMessageSelfDeletion(message) + } + LaunchedEffect(timeLeft) { if (timeLeft != ZERO) { delay(updateInterval()) - decreaseTimeLeft(updateInterval()) + recalculateTimeLeft() + } + } + val lifecycleOwner = LocalLifecycleOwner.current + LaunchedEffect(lifecycleOwner) { + lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + recalculateTimeLeft() } } } } + + object NotExpirable : SelfDeletionTimerState() } } -@Composable -private fun startAssetDeletion( - expirableTimer: SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable, - onSelfDeletingMessageRead: () -> Unit, - downloadStatus: Message.DownloadStatus -) { - LaunchedEffect(downloadStatus) { - if (downloadStatus == Message.DownloadStatus.SAVED_EXTERNALLY || downloadStatus == Message.DownloadStatus.SAVED_INTERNALLY) { - onSelfDeletingMessageRead() - } - } - LaunchedEffect(expirableTimer.timeLeft, downloadStatus) { - if (downloadStatus == Message.DownloadStatus.SAVED_EXTERNALLY || downloadStatus == Message.DownloadStatus.SAVED_INTERNALLY) { - with(expirableTimer) { - if (timeLeft != ZERO) { - delay(updateInterval()) - decreaseTimeLeft(updateInterval()) - } - } - } - } +typealias CurrentTimeProvider = () -> Instant +enum class StringResourceType { WEEKS, DAYS, HOURS, MINUTES, SECONDS; } +interface StringResourceProvider { + fun quantityString(type: StringResourceType, quantity: Int): String } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/MessageItem.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/MessageItem.kt index 0154d6b0e2d..d0cf2f57272 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/MessageItem.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/MessageItem.kt @@ -87,6 +87,7 @@ import com.wire.android.ui.theme.wireTypography import com.wire.android.util.launchGeoIntent import com.wire.kalium.logic.data.message.Message import com.wire.kalium.logic.data.user.UserId +import kotlinx.collections.immutable.PersistentMap // TODO: a definite candidate for a refactor and cleanup @Suppress("ComplexMethod") @@ -98,7 +99,7 @@ fun MessageItem( searchQuery: String = "", showAuthor: Boolean = true, useSmallBottomPadding: Boolean = false, - audioMessagesState: Map, + audioMessagesState: PersistentMap, onLongClicked: (UIMessage.Regular) -> Unit, onAssetMessageClicked: (String) -> Unit, onAudioClick: (String) -> Unit, @@ -125,9 +126,8 @@ fun MessageItem( !message.isPending && !message.sendingFailed ) { - startDeletionTimer( + selfDeletionTimerState.startDeletionTimer( message = message, - expirableTimer = selfDeletionTimerState, onStartMessageSelfDeletion = onSelfDeletingMessageRead ) } @@ -147,7 +147,7 @@ fun MessageItem( } val colorAnimation = remember { Animatable(Color.Transparent) } - val highlightColor = colorsScheme().selectedMessageHighlightColor + val highlightColor = colorsScheme().primaryVariant val transparentColor = colorsScheme().primary.copy(alpha = 0F) LaunchedEffect(isSelectedMessage) { if (isSelectedMessage) { @@ -172,7 +172,7 @@ fun MessageItem( }, onLongClick = remember(message) { { - if (!isContentClickable) { + if (!isContentClickable && !message.isDeleted) { onLongClicked(message) } } @@ -226,7 +226,7 @@ fun MessageItem( MessageAuthorRow(messageHeader = message.header) } if (selfDeletionTimerState is SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable) { - MessageExpireLabel(messageContent, selfDeletionTimerState.timeLeftFormatted()) + MessageExpireLabel(messageContent, selfDeletionTimerState.timeLeftFormatted) // if the message is marked as deleted and is [SelfDeletionTimer.SelfDeletionTimerState.Expirable] // the deletion responsibility belongs to the receiver, therefore we need to wait for the receiver diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/SystemMessageItem.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/SystemMessageItem.kt index 69dbac57898..90e6e81cadd 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/SystemMessageItem.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/SystemMessageItem.kt @@ -38,7 +38,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.ClickableText import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -55,11 +54,9 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.style.TextDecoration import com.wire.android.R +import com.wire.android.ui.common.TextWithLinkSuffix import com.wire.android.ui.common.button.WireSecondaryButton import com.wire.android.ui.common.colorsScheme import com.wire.android.ui.common.dimensions @@ -97,9 +94,8 @@ fun SystemMessageItem( !message.isPending && !message.sendingFailed ) { - startDeletionTimer( + selfDeletionTimerState.startDeletionTimer( message = message, - expirableTimer = selfDeletionTimerState, onStartMessageSelfDeletion = onSelfDeletingMessageRead ) } @@ -163,37 +159,18 @@ fun SystemMessageItem( errorColor = MaterialTheme.wireColorScheme.error, isErrorString = message.addingFailed, ) - val learnMoreAnnotatedString = message.messageContent.learnMoreResId?.let { - val learnMoreLink = stringResource(id = message.messageContent.learnMoreResId) - val learnMoreText = stringResource(id = R.string.label_learn_more) - buildAnnotatedString { - append(learnMoreText) - addStyle( - style = SpanStyle( - color = MaterialTheme.colorScheme.primary, - textDecoration = TextDecoration.Underline - ), - start = 0, - end = learnMoreText.length - ) - addStringAnnotation(tag = TAG_LEARN_MORE, annotation = learnMoreLink, start = 0, end = learnMoreText.length) - } - } - val fullAnnotatedString = - if (learnMoreAnnotatedString != null) annotatedString + AnnotatedString(" ") + learnMoreAnnotatedString - else annotatedString - - ClickableText( - modifier = Modifier.defaultMinSize(minHeight = dimensions().spacing20x), - text = fullAnnotatedString, - onClick = { offset -> - fullAnnotatedString.getStringAnnotations(TAG_LEARN_MORE, offset, offset) - .firstOrNull()?.let { result -> CustomTabsHelper.launchUrl(context, result.item) } - }, - style = MaterialTheme.wireTypography.body02, + val learnMoreLink = message.messageContent.learnMoreResId?.let { stringResource(id = it) } + + TextWithLinkSuffix( + text = annotatedString, + linkText = learnMoreLink?.let { stringResource(id = R.string.label_learn_more) }, + textColor = MaterialTheme.wireColorScheme.secondaryText, + linkColor = MaterialTheme.wireColorScheme.onBackground, + onLinkClick = { learnMoreLink?.let { CustomTabsHelper.launchUrl(context, it) } }, onTextLayout = { centerOfFirstLine = if (it.lineCount == 0) 0f else ((it.getLineTop(0) + it.getLineBottom(0)) / 2) - } + }, + modifier = Modifier.defaultMinSize(minHeight = dimensions().spacing20x), ) if ((message.addingFailed && expanded) || message.singleUserAddFailed) { @@ -795,4 +772,3 @@ private fun SystemMessage.MemberFailedToAdd.toFailedToAddMarkdownText( private const val EXPANDABLE_THRESHOLD = 4 private const val SINGLE_EXPANDABLE_THRESHOLD = 1 -private const val TAG_LEARN_MORE = "tag_learn_more" diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/call/ConversationCallViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/call/ConversationCallViewModel.kt index 7850cdd67f2..c95e4d6a1b0 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/call/ConversationCallViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/call/ConversationCallViewModel.kt @@ -186,7 +186,7 @@ class ConversationCallViewModel @Inject constructor( suspend fun isConferenceCallingEnabled(conversationType: Conversation.Type): ConferenceCallingResult = isConferenceCallingEnabled.invoke(conversationId, conversationType) - fun onConversationDegradedDialogShown() { + fun onApplyConversationDegradation() { viewModelScope.launch { setUserInformedAboutVerification.invoke(conversationId) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/edit/EditMessageMenuItems.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/edit/EditMessageMenuItems.kt index a01c7c4a819..098313239ba 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/edit/EditMessageMenuItems.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/edit/EditMessageMenuItems.kt @@ -137,7 +137,7 @@ fun EditMessageMenuItems( onDeleteClick = onDeleteItemClick, onDetailsClick = onDetailsItemClick, onReactionClick = onReactionItemClick, - onEditClick = if (message.isMyMessage) onEditItemClick else null, + onEditClick = if (message.isMyMessage && !message.isDeleted) onEditItemClick else null, onCopyClick = onCopyItemClick, onReplyClick = onReplyItemClick ) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/info/ConversationInfoViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/info/ConversationInfoViewModel.kt index f7ba4647941..a0d6c7cc135 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/info/ConversationInfoViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/info/ConversationInfoViewModel.kt @@ -39,6 +39,7 @@ import com.wire.kalium.logic.data.id.QualifiedIdMapper import com.wire.kalium.logic.data.user.ConnectionState import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.feature.conversation.ObserveConversationDetailsUseCase +import com.wire.kalium.logic.feature.e2ei.usecase.FetchConversationMLSVerificationStatusUseCase import com.wire.kalium.logic.feature.user.GetSelfUserUseCase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.first @@ -52,6 +53,7 @@ class ConversationInfoViewModel @Inject constructor( override val savedStateHandle: SavedStateHandle, private val observeConversationDetails: ObserveConversationDetailsUseCase, private val observerSelfUser: GetSelfUserUseCase, + private val fetchConversationMLSVerificationStatus: FetchConversationMLSVerificationStatusUseCase, private val wireSessionImageLoader: WireSessionImageLoader, ) : SavedStateViewModel(savedStateHandle) { @@ -64,6 +66,13 @@ class ConversationInfoViewModel @Inject constructor( init { getSelfUserId() + fetchMLSVerificationStatus() + } + + private fun fetchMLSVerificationStatus() { + viewModelScope.launch { + fetchConversationMLSVerificationStatus(conversationId) + } } private fun getSelfUserId() { diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/ConversationMediaScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/ConversationMediaScreen.kt index 1734890f00f..8e63357c09c 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/ConversationMediaScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/ConversationMediaScreen.kt @@ -65,6 +65,8 @@ import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireDimensions import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.kalium.logic.data.id.ConversationId +import kotlinx.collections.immutable.PersistentMap +import kotlinx.collections.immutable.persistentMapOf import kotlinx.coroutines.launch @RootNavGraph @@ -120,7 +122,7 @@ private fun Content( state: ConversationAssetMessagesViewState, onNavigationPressed: () -> Unit = {}, onImageFullScreenMode: (conversationId: ConversationId, messageId: String, isSelfAsset: Boolean) -> Unit, - audioMessagesState: Map = emptyMap(), + audioMessagesState: PersistentMap = persistentMapOf(), onAudioItemClicked: (String) -> Unit, onAssetItemClicked: (String) -> Unit ) { diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/FileAssetsContent.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/FileAssetsContent.kt index eaf580175f1..d25aa9cd6f0 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/FileAssetsContent.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/FileAssetsContent.kt @@ -44,12 +44,14 @@ import com.wire.android.ui.home.conversations.model.UIMessage import com.wire.android.ui.home.conversations.usecase.UIPagingItem import com.wire.android.ui.home.conversationslist.common.FolderHeader import com.wire.android.ui.theme.wireColorScheme +import kotlinx.collections.immutable.PersistentMap +import kotlinx.collections.immutable.persistentMapOf import kotlinx.coroutines.flow.Flow @Composable fun FileAssetsContent( groupedAssetMessageList: Flow>, - audioMessagesState: Map = emptyMap(), + audioMessagesState: PersistentMap = persistentMapOf(), onAudioItemClicked: (String) -> Unit, onAssetItemClicked: (String) -> Unit ) { @@ -72,7 +74,7 @@ fun FileAssetsContent( @Composable private fun AssetMessagesListContent( groupedAssetMessageList: LazyPagingItems, - audioMessagesState: Map, + audioMessagesState: PersistentMap, onAudioItemClicked: (String) -> Unit, onAssetItemClicked: (String) -> Unit, ) { @@ -114,19 +116,19 @@ private fun AssetMessagesListContent( message = message, conversationDetailsData = ConversationDetailsData.None, audioMessagesState = audioMessagesState, - onAudioClick = onAudioItemClicked, - onChangeAudioPosition = { _, _ -> }, onLongClicked = { }, onAssetMessageClicked = onAssetItemClicked, + onAudioClick = onAudioItemClicked, + onChangeAudioPosition = { _, _ -> }, onImageMessageClicked = { _, _ -> }, onOpenProfile = { _ -> }, onReactionClicked = { _, _ -> }, onResetSessionClicked = { _, _ -> }, onSelfDeletingMessageRead = { }, + onLinkClick = { }, defaultBackgroundColor = colorsScheme().backgroundVariant, shouldDisplayMessageStatus = false, - shouldDisplayFooter = false, - onLinkClick = { } + shouldDisplayFooter = false ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModel.kt index f4be3695369..f0c6b9ad6b7 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModel.kt @@ -58,6 +58,7 @@ import com.wire.kalium.logic.feature.message.ToggleReactionUseCase import com.wire.kalium.logic.feature.sessionreset.ResetSessionResult import com.wire.kalium.logic.feature.sessionreset.ResetSessionUseCase import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.collections.immutable.toPersistentMap import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow @@ -119,7 +120,7 @@ class ConversationMessagesViewModel @Inject constructor( viewModelScope.launch { conversationAudioMessagePlayer.observableAudioMessagesState.collect { conversationViewState = conversationViewState.copy( - audioMessagesState = it + audioMessagesState = it.toPersistentMap() ) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewState.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewState.kt index 068590be362..b98d75c76dd 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewState.kt @@ -22,6 +22,8 @@ import androidx.paging.PagingData import com.wire.android.media.audiomessage.AudioState import com.wire.android.ui.home.conversations.model.AssetBundle import com.wire.android.ui.home.conversations.model.UIMessage +import kotlinx.collections.immutable.PersistentMap +import kotlinx.collections.immutable.persistentMapOf import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow import kotlinx.datetime.Instant @@ -31,7 +33,7 @@ data class ConversationMessagesViewState( val firstUnreadInstant: Instant? = null, val firstuUnreadEventIndex: Int = 0, val downloadedAssetDialogState: DownloadedAssetDialogVisibilityState = DownloadedAssetDialogVisibilityState.Hidden, - val audioMessagesState: Map = emptyMap(), + val audioMessagesState: PersistentMap = persistentMapOf(), val searchedMessageId: String? = null ) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/MessageTypesPreview.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/MessageTypesPreview.kt index ccbe8ecd548..99a78c777a0 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/MessageTypesPreview.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/MessageTypesPreview.kt @@ -41,6 +41,7 @@ import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.android.util.ui.UIText import com.wire.kalium.logic.data.message.Message import com.wire.kalium.logic.data.user.UserId +import kotlinx.collections.immutable.persistentMapOf private val previewUserId = UserId("value", "domain") @@ -57,7 +58,8 @@ fun PreviewMessage() { ) ) ), - audioMessagesState = emptyMap(), + conversationDetailsData = ConversationDetailsData.None, + audioMessagesState = persistentMapOf(), onLongClicked = {}, onAssetMessageClicked = {}, onAudioClick = {}, @@ -67,7 +69,6 @@ fun PreviewMessage() { onReactionClicked = { _, _ -> }, onResetSessionClicked = { _, _ -> }, onSelfDeletingMessageRead = {}, - conversationDetailsData = ConversationDetailsData.None ) } } @@ -86,7 +87,8 @@ fun PreviewMessageWithReactions() { ), messageFooter = mockFooter ), - audioMessagesState = emptyMap(), + conversationDetailsData = ConversationDetailsData.None, + audioMessagesState = persistentMapOf(), onLongClicked = {}, onAssetMessageClicked = {}, onAudioClick = {}, @@ -95,8 +97,7 @@ fun PreviewMessageWithReactions() { onOpenProfile = { _ -> }, onReactionClicked = { _, _ -> }, onResetSessionClicked = { _, _ -> }, - onSelfDeletingMessageRead = {}, - conversationDetailsData = ConversationDetailsData.None + onSelfDeletingMessageRead = {} ) } } @@ -126,7 +127,8 @@ fun PreviewMessageWithReply() { ) ) ), - audioMessagesState = emptyMap(), + conversationDetailsData = ConversationDetailsData.None, + audioMessagesState = persistentMapOf(), onLongClicked = {}, onAssetMessageClicked = {}, onAudioClick = {}, @@ -135,8 +137,7 @@ fun PreviewMessageWithReply() { onOpenProfile = { _ -> }, onReactionClicked = { _, _ -> }, onResetSessionClicked = { _, _ -> }, - onSelfDeletingMessageRead = {}, - conversationDetailsData = ConversationDetailsData.None + onSelfDeletingMessageRead = {} ) } } @@ -156,7 +157,8 @@ fun PreviewDeletedMessage() { ) ) }, - audioMessagesState = emptyMap(), + conversationDetailsData = ConversationDetailsData.None, + audioMessagesState = persistentMapOf(), onLongClicked = {}, onAssetMessageClicked = {}, onAudioClick = {}, @@ -165,8 +167,7 @@ fun PreviewDeletedMessage() { onOpenProfile = { _ -> }, onReactionClicked = { _, _ -> }, onResetSessionClicked = { _, _ -> }, - onSelfDeletingMessageRead = { }, - conversationDetailsData = ConversationDetailsData.None + onSelfDeletingMessageRead = { } ) } } @@ -187,7 +188,8 @@ fun PreviewFailedSendMessage() { messageFooter = mockFooter.copy(reactions = emptyMap(), ownReactions = emptySet()) ) }, - audioMessagesState = emptyMap(), + conversationDetailsData = ConversationDetailsData.None, + audioMessagesState = persistentMapOf(), onLongClicked = {}, onAssetMessageClicked = {}, onAudioClick = {}, @@ -196,8 +198,7 @@ fun PreviewFailedSendMessage() { onOpenProfile = { _ -> }, onReactionClicked = { _, _ -> }, onResetSessionClicked = { _, _ -> }, - onSelfDeletingMessageRead = { }, - conversationDetailsData = ConversationDetailsData.None + onSelfDeletingMessageRead = { } ) } } @@ -218,7 +219,8 @@ fun PreviewFailedDecryptionMessage() { messageFooter = mockFooter.copy(reactions = emptyMap(), ownReactions = emptySet()) ) }, - audioMessagesState = emptyMap(), + conversationDetailsData = ConversationDetailsData.None, + audioMessagesState = persistentMapOf(), onLongClicked = {}, onAssetMessageClicked = {}, onAudioClick = {}, @@ -227,8 +229,7 @@ fun PreviewFailedDecryptionMessage() { onOpenProfile = { _ -> }, onReactionClicked = { _, _ -> }, onResetSessionClicked = { _, _ -> }, - onSelfDeletingMessageRead = { }, - conversationDetailsData = ConversationDetailsData.None + onSelfDeletingMessageRead = { } ) } } @@ -239,7 +240,8 @@ fun PreviewAssetMessageWithReactions() { WireTheme { MessageItem( message = mockAssetMessage().copy(messageFooter = mockFooter), - audioMessagesState = emptyMap(), + conversationDetailsData = ConversationDetailsData.None, + audioMessagesState = persistentMapOf(), onLongClicked = {}, onAssetMessageClicked = {}, onAudioClick = {}, @@ -248,8 +250,7 @@ fun PreviewAssetMessageWithReactions() { onOpenProfile = { _ -> }, onReactionClicked = { _, _ -> }, onResetSessionClicked = { _, _ -> }, - onSelfDeletingMessageRead = { }, - conversationDetailsData = ConversationDetailsData.None + onSelfDeletingMessageRead = { } ) } } @@ -328,7 +329,8 @@ fun PreviewImageMessageUploaded() { WireTheme { MessageItem( message = mockedImageUIMessage(Message.UploadStatus.UPLOADED), - audioMessagesState = emptyMap(), + conversationDetailsData = ConversationDetailsData.None, + audioMessagesState = persistentMapOf(), onLongClicked = {}, onAssetMessageClicked = {}, onAudioClick = {}, @@ -337,8 +339,7 @@ fun PreviewImageMessageUploaded() { onOpenProfile = { _ -> }, onReactionClicked = { _, _ -> }, onResetSessionClicked = { _, _ -> }, - onSelfDeletingMessageRead = { }, - conversationDetailsData = ConversationDetailsData.None + onSelfDeletingMessageRead = { } ) } } @@ -349,7 +350,8 @@ fun PreviewImageMessageUploading() { WireTheme { MessageItem( message = mockedImageUIMessage(Message.UploadStatus.UPLOAD_IN_PROGRESS), - audioMessagesState = emptyMap(), + conversationDetailsData = ConversationDetailsData.None, + audioMessagesState = persistentMapOf(), onLongClicked = {}, onAssetMessageClicked = {}, onAudioClick = {}, @@ -358,8 +360,7 @@ fun PreviewImageMessageUploading() { onOpenProfile = { _ -> }, onReactionClicked = { _, _ -> }, onResetSessionClicked = { _, _ -> }, - onSelfDeletingMessageRead = { }, - conversationDetailsData = ConversationDetailsData.None + onSelfDeletingMessageRead = { } ) } } @@ -376,7 +377,8 @@ fun PreviewImageMessageFailedUpload() { expirationStatus = ExpirationStatus.NotExpirable ) ), - audioMessagesState = emptyMap(), + conversationDetailsData = ConversationDetailsData.None, + audioMessagesState = persistentMapOf(), onLongClicked = {}, onAssetMessageClicked = {}, onAudioClick = {}, @@ -385,8 +387,7 @@ fun PreviewImageMessageFailedUpload() { onOpenProfile = { _ -> }, onReactionClicked = { _, _ -> }, onResetSessionClicked = { _, _ -> }, - onSelfDeletingMessageRead = { }, - conversationDetailsData = ConversationDetailsData.None + onSelfDeletingMessageRead = { } ) } } @@ -398,7 +399,8 @@ fun PreviewMessageWithSystemMessage() { Column { MessageItem( message = mockMessageWithText, - audioMessagesState = emptyMap(), + conversationDetailsData = ConversationDetailsData.None, + audioMessagesState = persistentMapOf(), onLongClicked = {}, onAssetMessageClicked = {}, onAudioClick = {}, @@ -407,8 +409,7 @@ fun PreviewMessageWithSystemMessage() { onOpenProfile = { _ -> }, onReactionClicked = { _, _ -> }, onResetSessionClicked = { _, _ -> }, - onSelfDeletingMessageRead = { }, - conversationDetailsData = ConversationDetailsData.None + onSelfDeletingMessageRead = { } ) SystemMessageItem( mockMessageWithKnock.copy( @@ -442,7 +443,8 @@ fun PreviewMessagesWithUnavailableQuotedMessage() { ) ) ), - audioMessagesState = emptyMap(), + conversationDetailsData = ConversationDetailsData.None, + audioMessagesState = persistentMapOf(), onLongClicked = {}, onAssetMessageClicked = {}, onAudioClick = {}, @@ -451,8 +453,7 @@ fun PreviewMessagesWithUnavailableQuotedMessage() { onOpenProfile = { _ -> }, onReactionClicked = { _, _ -> }, onResetSessionClicked = { _, _ -> }, - onSelfDeletingMessageRead = {}, - conversationDetailsData = ConversationDetailsData.None + onSelfDeletingMessageRead = {} ) } } @@ -464,7 +465,8 @@ fun PreviewAggregatedMessagesWithErrorMessage() { Column { MessageItem( message = mockMessageWithText, - audioMessagesState = emptyMap(), + conversationDetailsData = ConversationDetailsData.None, + audioMessagesState = persistentMapOf(), onLongClicked = {}, onAssetMessageClicked = {}, onAudioClick = {}, @@ -473,8 +475,7 @@ fun PreviewAggregatedMessagesWithErrorMessage() { onOpenProfile = { _ -> }, onReactionClicked = { _, _ -> }, onResetSessionClicked = { _, _ -> }, - onSelfDeletingMessageRead = {}, - conversationDetailsData = ConversationDetailsData.None + onSelfDeletingMessageRead = {} ) MessageItem( message = mockMessageWithText.copy( @@ -485,8 +486,9 @@ fun PreviewAggregatedMessagesWithErrorMessage() { ) ) ), + conversationDetailsData = ConversationDetailsData.None, showAuthor = false, - audioMessagesState = emptyMap(), + audioMessagesState = persistentMapOf(), onLongClicked = {}, onAssetMessageClicked = {}, onAudioClick = {}, @@ -495,8 +497,7 @@ fun PreviewAggregatedMessagesWithErrorMessage() { onOpenProfile = { _ -> }, onReactionClicked = { _, _ -> }, onResetSessionClicked = { _, _ -> }, - onSelfDeletingMessageRead = {}, - conversationDetailsData = ConversationDetailsData.None + onSelfDeletingMessageRead = {} ) MessageItem( message = mockMessageWithText.copy( @@ -507,8 +508,9 @@ fun PreviewAggregatedMessagesWithErrorMessage() { ) ) ), + conversationDetailsData = ConversationDetailsData.None, showAuthor = false, - audioMessagesState = emptyMap(), + audioMessagesState = persistentMapOf(), onLongClicked = {}, onAssetMessageClicked = {}, onAudioClick = {}, @@ -518,7 +520,6 @@ fun PreviewAggregatedMessagesWithErrorMessage() { onReactionClicked = { _, _ -> }, onResetSessionClicked = { _, _ -> }, onSelfDeletingMessageRead = {}, - conversationDetailsData = ConversationDetailsData.None ) } } @@ -530,7 +531,8 @@ fun PreviewMessageWithMarkdownTextAndLinks() { WireTheme { MessageItem( message = mockMessageWithMarkdownTextAndLinks, - audioMessagesState = emptyMap(), + conversationDetailsData = ConversationDetailsData.None, + audioMessagesState = persistentMapOf(), onLongClicked = {}, onAssetMessageClicked = {}, onAudioClick = {}, @@ -539,8 +541,7 @@ fun PreviewMessageWithMarkdownTextAndLinks() { onOpenProfile = { _ -> }, onReactionClicked = { _, _ -> }, onResetSessionClicked = { _, _ -> }, - onSelfDeletingMessageRead = {}, - conversationDetailsData = ConversationDetailsData.None + onSelfDeletingMessageRead = {} ) } } @@ -551,7 +552,8 @@ fun PreviewMessageWithMarkdownListAndImages() { WireTheme { MessageItem( message = mockMessageWithMarkdownListAndImages, - audioMessagesState = emptyMap(), + conversationDetailsData = ConversationDetailsData.None, + audioMessagesState = persistentMapOf(), onLongClicked = {}, onAssetMessageClicked = {}, onAudioClick = {}, @@ -560,8 +562,7 @@ fun PreviewMessageWithMarkdownListAndImages() { onOpenProfile = { _ -> }, onReactionClicked = { _, _ -> }, onResetSessionClicked = { _, _ -> }, - onSelfDeletingMessageRead = {}, - conversationDetailsData = ConversationDetailsData.None + onSelfDeletingMessageRead = {} ) } } @@ -572,7 +573,8 @@ fun PreviewMessageWithMarkdownTablesAndBlocks() { WireTheme { MessageItem( message = mockMessageWithMarkdownTablesAndBlocks, - audioMessagesState = emptyMap(), + conversationDetailsData = ConversationDetailsData.None, + audioMessagesState = persistentMapOf(), onLongClicked = {}, onAssetMessageClicked = {}, onAudioClick = {}, @@ -581,8 +583,7 @@ fun PreviewMessageWithMarkdownTablesAndBlocks() { onOpenProfile = { _ -> }, onReactionClicked = { _, _ -> }, onResetSessionClicked = { _, _ -> }, - onSelfDeletingMessageRead = {}, - conversationDetailsData = ConversationDetailsData.None + onSelfDeletingMessageRead = {} ) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/HighLightName.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/HighLightName.kt index 3602912f963..48dc5706076 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/HighLightName.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/HighLightName.kt @@ -65,7 +65,10 @@ fun HighlightName( .forEach { highLightIndex -> if (highLightIndex.endIndex <= this.length) { addStyle( - style = SpanStyle(background = MaterialTheme.wireColorScheme.highLight.copy(alpha = 0.5f)), + style = SpanStyle( + background = MaterialTheme.wireColorScheme.highlight, + color = MaterialTheme.wireColorScheme.onHighlight, + ), start = highLightIndex.startIndex, end = highLightIndex.endIndex ) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/HighLightSubtTitle.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/HighLightSubtTitle.kt index 35cb6c0e54d..21edf700ffd 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/HighLightSubtTitle.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/HighLightSubtTitle.kt @@ -67,7 +67,8 @@ fun HighlightSubtitle( if (highLightIndex.endIndex <= this.length) { addStyle( style = SpanStyle( - background = MaterialTheme.wireColorScheme.highLight.copy(alpha = 0.5f), + background = MaterialTheme.wireColorScheme.highlight, + color = MaterialTheme.wireColorScheme.onHighlight, ), start = highLightIndex.startIndex + suffix.length, end = highLightIndex.endIndex + suffix.length diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/QueryExtension.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/QueryExtension.kt index 1f62a2dda3c..51140974697 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/QueryExtension.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/QueryExtension.kt @@ -17,10 +17,4 @@ */ package com.wire.android.ui.home.conversations.search -fun String.removeQueryPrefix(): String { - return if (startsWith("@")) { - removePrefix("@") - } else { - this - } -} +fun String.removeQueryPrefix(): String = removePrefix("@") diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/SearchPeopleRouter.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/SearchPeopleRouter.kt index 2b34fe08542..42110004980 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/SearchPeopleRouter.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/SearchPeopleRouter.kt @@ -178,14 +178,6 @@ fun SearchUsersAndServicesScreen( } } } - - LaunchedEffect(pagerState.isScrollInProgress, focusedTabIndex, pagerState.currentPage) { - if (!pagerState.isScrollInProgress && focusedTabIndex != pagerState.currentPage) { - keyboardController?.hide() - focusManager.clearFocus() - focusedTabIndex = pagerState.currentPage - } - } } } else { SearchAllPeopleOrContactsScreen( diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/SearchUserViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/SearchUserViewModel.kt index d7169308c8c..d55b187d847 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/SearchUserViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/SearchUserViewModel.kt @@ -26,7 +26,10 @@ import androidx.lifecycle.viewModelScope import com.wire.android.mapper.ContactMapper import com.wire.android.ui.home.newconversation.model.Contact import com.wire.android.ui.navArgs +import com.wire.kalium.logic.feature.auth.ValidateUserHandleResult +import com.wire.kalium.logic.feature.auth.ValidateUserHandleUseCase import com.wire.kalium.logic.feature.search.FederatedSearchParser +import com.wire.kalium.logic.feature.search.SearchByHandleUseCase import com.wire.kalium.logic.feature.search.SearchUsersUseCase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.collections.immutable.ImmutableList @@ -39,8 +42,10 @@ import javax.inject.Inject @HiltViewModel class SearchUserViewModel @Inject constructor( private val searchUserUseCase: SearchUsersUseCase, + private val searchByHandleUseCase: SearchByHandleUseCase, private val contactMapper: ContactMapper, private val federatedSearchParser: FederatedSearchParser, + private val validateUserHandle: ValidateUserHandleUseCase, savedStateHandle: SavedStateHandle ) : ViewModel() { @@ -61,7 +66,29 @@ class SearchUserViewModel @Inject constructor( @VisibleForTesting suspend fun safeSearch(query: String) { val (searchTerm, domain) = federatedSearchParser(query) + val isHandleSearch = validateUserHandle(searchTerm.removeQueryPrefix()) is ValidateUserHandleResult.Valid + if (isHandleSearch) { + searchByHandle(searchTerm, domain) + } else { + searchByName(searchTerm, domain) + } + } + + private suspend fun searchByHandle(searchTerm: String, domain: String?) { + searchByHandleUseCase( + searchTerm, + excludingConversation = addMembersSearchNavArgs?.conversationId, + customDomain = domain + ).also { userSearchEntities -> + state = state.copy( + contactsResult = userSearchEntities.connected.map(contactMapper::fromSearchUserResult).toImmutableList(), + publicResult = userSearchEntities.notConnected.map(contactMapper::fromSearchUserResult).toImmutableList() + ) + } + } + + private suspend fun searchByName(searchTerm: String, domain: String?) { searchUserUseCase( searchTerm, excludingMembersOfConversation = addMembersSearchNavArgs?.conversationId, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesResultsScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesResultsScreen.kt index a1f0e498005..18285fac00c 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesResultsScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesResultsScreen.kt @@ -31,6 +31,7 @@ import com.wire.android.ui.home.conversations.mock.mockMessageWithText import com.wire.android.ui.home.conversations.model.UIMessage import com.wire.android.ui.theme.WireTheme import com.wire.android.util.ui.PreviewMultipleThemes +import kotlinx.collections.immutable.persistentMapOf import kotlinx.coroutines.flow.flowOf @Composable @@ -54,7 +55,7 @@ fun SearchConversationMessagesResultsScreen( message = message, conversationDetailsData = ConversationDetailsData.None, searchQuery = searchQuery, - audioMessagesState = mapOf(), + audioMessagesState = persistentMapOf(), onLongClicked = { }, onAssetMessageClicked = { }, onAudioClick = { }, @@ -64,11 +65,11 @@ fun SearchConversationMessagesResultsScreen( onReactionClicked = { _, _ -> }, onResetSessionClicked = { _, _ -> }, onSelfDeletingMessageRead = { }, + isContentClickable = true, + onMessageClick = onMessageClick, defaultBackgroundColor = colorsScheme().backgroundVariant, shouldDisplayMessageStatus = false, - shouldDisplayFooter = false, - isContentClickable = true, - onMessageClick = onMessageClick + shouldDisplayFooter = false ) } is UIMessage.System -> { } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesViewModel.kt index 2203663392a..ff851cb99fc 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesViewModel.kt @@ -96,7 +96,7 @@ class SearchConversationMessagesViewModel @Inject constructor( ) if (textQueryChanged && searchQuery.text.isNotBlank()) { viewModelScope.launch { - mutableSearchQueryFlow.emit(searchQuery.text.trim()) + mutableSearchQueryFlow.emit(searchQuery.text) } } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/typing/TypingIndicatorViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/typing/TypingIndicatorViewModel.kt index b69e832f13f..512f065bf8f 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/typing/TypingIndicatorViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/typing/TypingIndicatorViewModel.kt @@ -23,7 +23,6 @@ import androidx.compose.runtime.setValue import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.wire.android.appLogger import com.wire.android.ui.home.conversations.ConversationNavArgs import com.wire.android.ui.home.conversations.usecase.ObserveUsersTypingInConversationUseCase import com.wire.android.ui.navArgs @@ -51,7 +50,6 @@ class TypingIndicatorViewModel @Inject constructor( private fun observeUsersTypingState() { viewModelScope.launch { observeUsersTypingInConversation(conversationId).collect { - appLogger.d("Users typing: $it") usersTypingViewState = usersTypingViewState.copy(usersTyping = it) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/GetAssetMessagesFromConversationUseCase.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/GetAssetMessagesFromConversationUseCase.kt index 6858ac48e83..9563a17deed 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/GetAssetMessagesFromConversationUseCase.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/GetAssetMessagesFromConversationUseCase.kt @@ -27,12 +27,9 @@ import com.wire.android.ui.home.conversations.model.UIMessage import com.wire.android.util.dispatchers.DispatcherProvider import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.feature.asset.GetPaginatedFlowOfAssetMessageByConversationIdUseCase -import com.wire.kalium.logic.feature.conversation.ObserveUserListByIdUseCase import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.mapLatest import kotlinx.datetime.Instant import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime @@ -41,7 +38,7 @@ import kotlin.math.max class GetAssetMessagesFromConversationUseCase @Inject constructor( private val getAssetMessages: GetPaginatedFlowOfAssetMessageByConversationIdUseCase, - private val observeMemberDetailsByIds: ObserveUserListByIdUseCase, + private val getUsersForMessage: GetUsersForMessageUseCase, private val messageMapper: MessageMapper, private val dispatchers: DispatcherProvider ) { @@ -69,12 +66,10 @@ class GetAssetMessagesFromConversationUseCase @Inject constructor( ).map { pagingData -> val currentTime = TimeZone.currentSystemDefault() val uiMessagePagingData: PagingData = pagingData.flatMap { messageItem -> - observeMemberDetailsByIds(messageMapper.memberIdList(listOf(messageItem))) - .mapLatest { usersList -> - messageMapper.toUIMessage(usersList, messageItem) - ?.let { listOf(UIPagingItem.Message(it, Instant.parse(messageItem.date))) } - ?: emptyList() - }.first() + val usersForMessage = getUsersForMessage(messageItem) + messageMapper.toUIMessage(usersForMessage, messageItem) + ?.let { listOf(UIPagingItem.Message(it, Instant.parse(messageItem.date))) } + ?: emptyList() }.insertSeparators { before: UIPagingItem.Message?, after: UIPagingItem.Message? -> if (before == null && after != null) { val localDateTime = after.date.toLocalDateTime(currentTime) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationMessagesFromSearchUseCase.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationMessagesFromSearchUseCase.kt index 258eaa78a6a..cc40c6b6114 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationMessagesFromSearchUseCase.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationMessagesFromSearchUseCase.kt @@ -24,20 +24,17 @@ import com.wire.android.mapper.MessageMapper import com.wire.android.ui.home.conversations.model.UIMessage import com.wire.android.util.dispatchers.DispatcherProvider import com.wire.kalium.logic.data.id.ConversationId -import com.wire.kalium.logic.feature.conversation.ObserveUserListByIdUseCase import com.wire.kalium.logic.feature.message.GetPaginatedFlowOfMessagesBySearchQueryAndConversationIdUseCase import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.mapLatest import javax.inject.Inject import kotlin.math.max class GetConversationMessagesFromSearchUseCase @Inject constructor( private val getMessagesSearch: GetPaginatedFlowOfMessagesBySearchQueryAndConversationIdUseCase, - private val observeMemberDetailsByIds: ObserveUserListByIdUseCase, + private val getUsersForMessage: GetUsersForMessageUseCase, private val messageMapper: MessageMapper, private val dispatchers: DispatcherProvider ) { @@ -67,11 +64,8 @@ class GetConversationMessagesFromSearchUseCase @Inject constructor( startingOffset = max(0, lastReadIndex - PREFETCH_DISTANCE).toLong() ).map { pagingData -> pagingData.flatMap { messageItem -> - observeMemberDetailsByIds(messageMapper.memberIdList(listOf(messageItem))) - .mapLatest { usersList -> - messageMapper.toUIMessage(usersList, messageItem)?.let { listOf(it) } - ?: emptyList() - }.first() + val usersForMessage = getUsersForMessage(messageItem) + messageMapper.toUIMessage(usersForMessage, messageItem)?.let { listOf(it) } ?: emptyList() } }.flowOn(dispatchers.io()) } else { diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/GetMessagesForConversationUseCase.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/GetMessagesForConversationUseCase.kt index a0976399255..b93a61afbca 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/GetMessagesForConversationUseCase.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/GetMessagesForConversationUseCase.kt @@ -25,25 +25,20 @@ import com.wire.android.mapper.MessageMapper import com.wire.android.ui.home.conversations.model.UIMessage import com.wire.android.util.dispatchers.DispatcherProvider import com.wire.kalium.logic.data.id.ConversationId -import com.wire.kalium.logic.feature.conversation.ObserveUserListByIdUseCase import com.wire.kalium.logic.feature.message.GetPaginatedFlowOfMessagesByConversationUseCase -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.mapLatest import java.lang.Integer.max import javax.inject.Inject class GetMessagesForConversationUseCase @Inject constructor( private val getMessages: GetPaginatedFlowOfMessagesByConversationUseCase, - private val observeMemberDetailsByIds: ObserveUserListByIdUseCase, + private val getUsersForMessage: GetUsersForMessageUseCase, private val messageMapper: MessageMapper, private val dispatchers: DispatcherProvider, ) { - @OptIn(ExperimentalCoroutinesApi::class) suspend operator fun invoke(conversationId: ConversationId, lastReadIndex: Int): Flow> { val pagingConfig = PagingConfig( pageSize = PAGE_SIZE, @@ -56,10 +51,8 @@ class GetMessagesForConversationUseCase @Inject constructor( startingOffset = max(0, lastReadIndex - PREFETCH_DISTANCE).toLong() ).map { pagingData -> pagingData.flatMap { messageItem -> - observeMemberDetailsByIds(messageMapper.memberIdList(listOf(messageItem))) - .mapLatest { usersList -> - messageMapper.toUIMessage(usersList, messageItem)?.let { listOf(it) } ?: emptyList() - }.first() + val usersForMessage = getUsersForMessage(messageItem) + messageMapper.toUIMessage(usersForMessage, messageItem)?.let { listOf(it) } ?: emptyList() } }.flowOn(dispatchers.io()) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/GetUsersForMessageUseCase.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/GetUsersForMessageUseCase.kt new file mode 100644 index 00000000000..5d7e24b047d --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/GetUsersForMessageUseCase.kt @@ -0,0 +1,49 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.ui.home.conversations.usecase + +import com.wire.android.mapper.MessageMapper +import com.wire.kalium.logic.data.message.Message +import com.wire.kalium.logic.data.user.User +import com.wire.kalium.logic.feature.conversation.ObserveUserListByIdUseCase +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.mapLatest +import javax.inject.Inject + +class GetUsersForMessageUseCase @Inject constructor( + private val observeMemberDetailsByIds: ObserveUserListByIdUseCase, + private val messageMapper: MessageMapper +) { + + @OptIn(ExperimentalCoroutinesApi::class) + suspend operator fun invoke(message: Message): List { + val listWithSender: List = message.sender?.let { listOf(it) } ?: listOf() + val otherUserIdList = messageMapper.memberIdList(listOf(message)) + + return if (otherUserIdList.isNotEmpty()) { + observeMemberDetailsByIds(otherUserIdList) + .mapLatest { usersList -> + listWithSender.plus(usersList) + }.first() + } else { + listWithSender + } + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModel.kt index 9010ada16bf..61e40af9308 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModel.kt @@ -604,7 +604,8 @@ private fun ConversationDetails.toConversationItem( ), conversationInfo = ConversationInfo( name = otherUser?.name.orEmpty(), - membership = userTypeMapper.toMembership(userType) + membership = userTypeMapper.toMembership(userType), + isSenderUnavailable = otherUser?.isUnavailableUser ?: true ), lastMessageContent = UILastMessageContent.Connection( connection.status, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationItemFactory.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationItemFactory.kt index 6f5636852ac..6a4fe4bb7fa 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationItemFactory.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationItemFactory.kt @@ -372,6 +372,25 @@ fun PreviewConnectionConversationItemWithSentConnectRequestBadge() { ) } +@Preview +@Composable +fun PreviewConnectionConversationItemWithSentConnectRequestBadgeWithUnknownSender() { + ConversationItemFactory( + conversation = ConversationItem.ConnectionConversation( + userAvatarData = UserAvatarData(), + conversationId = QualifiedID("value", "domain"), + mutedStatus = MutedConversationStatus.OnlyMentionsAndRepliesAllowed, + lastMessageContent = null, + badgeEventType = BadgeEventType.SentConnectRequest, + conversationInfo = ConversationInfo("", isSenderUnavailable = true) + ), + searchQuery = "", + isSelectableItem = false, + isChecked = false, + {}, {}, {}, {}, {}, {} + ) +} + @Preview @Composable fun PreviewPrivateConversationItemWithBlockedBadge() { diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/model/ConversationItem.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/model/ConversationItem.kt index dbae33d1b5c..179d6ae12d3 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/model/ConversationItem.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/model/ConversationItem.kt @@ -126,5 +126,6 @@ fun ConversationItem.ConnectionConversation.toUserInfoLabel() = UserInfoLabel( labelName = conversationInfo.name, isLegalHold = isLegalHold, - membership = conversationInfo.membership + membership = conversationInfo.membership, + unavailable = conversationInfo.isSenderUnavailable ) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/EnabledMessageComposer.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/EnabledMessageComposer.kt index 8cdec9c3d7a..3261fcb3439 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/EnabledMessageComposer.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/EnabledMessageComposer.kt @@ -111,7 +111,7 @@ fun EnabledMessageComposer( messageCompositionInputStateHolder.clearFocus() } else if (additionalOptionStateHolder.selectedOption == AdditionalOptionSelectItem.SelfDeleting) { messageCompositionInputStateHolder.requestFocus() - additionalOptionStateHolder.hideAdditionalOptionsMenu() + additionalOptionStateHolder.unselectAdditionalOptionsMenu() } } @@ -178,6 +178,7 @@ fun EnabledMessageComposer( inputFocused = messageCompositionInputStateHolder.inputFocused, onInputFocusedChanged = ::onInputFocusedChanged, onToggleInputSize = messageCompositionInputStateHolder::toggleInputSize, + onTextCollapse = messageCompositionInputStateHolder::collapseText, onCancelReply = messageCompositionHolder::clearReply, onCancelEdit = ::cancelEdit, onMessageTextChanged = { @@ -247,7 +248,7 @@ fun EnabledMessageComposer( onAdditionalOptionsMenuClicked = { if (!isKeyboardMoving) { if (additionalOptionStateHolder.selectedOption == AdditionalOptionSelectItem.AttachFile) { - additionalOptionStateHolder.hideAdditionalOptionsMenu() + additionalOptionStateHolder.unselectAdditionalOptionsMenu() messageCompositionInputStateHolder.toComposing() } else { showAdditionalOptionsMenu() @@ -293,10 +294,7 @@ fun EnabledMessageComposer( cancelEdit() } BackHandler(isImeVisible || inputStateHolder.optionsVisible) { - inputStateHolder.handleBackPressed( - isImeVisible, - additionalOptionStateHolder.additionalOptionsSubMenuState - ) + inputStateHolder.collapseComposer(additionalOptionStateHolder.additionalOptionsSubMenuState) } } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposerInput.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposerInput.kt index e05f159ea58..41ed3ab5586 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposerInput.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposerInput.kt @@ -47,6 +47,9 @@ import androidx.compose.ui.draw.rotate import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.nativeKeyCode +import androidx.compose.ui.input.key.onPreInterceptKeyBeforeSoftKeyboard import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -79,6 +82,7 @@ fun ActiveMessageComposerInput( onEditButtonClicked: () -> Unit, onChangeSelfDeletionClicked: () -> Unit, onToggleInputSize: () -> Unit, + onTextCollapse: () -> Unit, onCancelReply: () -> Unit, onCancelEdit: () -> Unit, onInputFocusedChanged: (Boolean) -> Unit, @@ -138,6 +142,7 @@ fun ActiveMessageComposerInput( onLineBottomYCoordinateChanged = onLineBottomYCoordinateChanged, showOptions = showOptions, onPlusClick = onPlusClick, + onTextCollapse = onTextCollapse, modifier = stretchToMaxParentConstraintHeightOrWithInBoundary, ) } @@ -162,6 +167,7 @@ fun ActiveMessageComposerInput( onLineBottomYCoordinateChanged = onLineBottomYCoordinateChanged, showOptions = showOptions, onPlusClick = onPlusClick, + onTextCollapse = onTextCollapse, modifier = stretchToMaxParentConstraintHeightOrWithInBoundary ) } @@ -196,6 +202,7 @@ private fun InputContent( onLineBottomYCoordinateChanged: (Float) -> Unit, showOptions: Boolean, onPlusClick: () -> Unit, + onTextCollapse: () -> Unit, modifier: Modifier, ) { if (!showOptions && inputType is MessageCompositionType.Composing) { @@ -209,6 +216,7 @@ private fun InputContent( } MessageComposerTextInput( + isTextExpanded = isTextExpanded, inputFocused = inputFocused, colors = inputType.inputTextColor(), messageText = messageComposition.messageTextFieldValue, @@ -218,6 +226,7 @@ private fun InputContent( onFocusChanged = onInputFocusedChanged, onSelectedLineIndexChanged = onSelectedLineIndexChanged, onLineBottomYCoordinateChanged = onLineBottomYCoordinateChanged, + onTextCollapse = onTextCollapse, modifier = modifier ) @@ -254,6 +263,7 @@ private fun InputContent( @OptIn(ExperimentalComposeUiApi::class) @Composable private fun MessageComposerTextInput( + isTextExpanded: Boolean, inputFocused: Boolean, colors: WireTextFieldColors, singleLine: Boolean, @@ -263,6 +273,7 @@ private fun MessageComposerTextInput( onFocusChanged: (Boolean) -> Unit = {}, onSelectedLineIndexChanged: (Int) -> Unit = { }, onLineBottomYCoordinateChanged: (Float) -> Unit = { }, + onTextCollapse: () -> Unit, modifier: Modifier = Modifier ) { val keyboardController = LocalSoftwareKeyboardController.current @@ -304,6 +315,18 @@ private fun MessageComposerTextInput( if (focusState.isFocused) { onFocusChanged(focusState.isFocused) } + } + .onPreInterceptKeyBeforeSoftKeyboard { event -> + if (event.key.nativeKeyCode == android.view.KeyEvent.KEYCODE_BACK) { + if (isTextExpanded) { + onTextCollapse() + true + } else { + false + } + } else { + false + } }, interactionSource = interactionSource, onSelectedLineIndexChanged = onSelectedLineIndexChanged, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/location/LocationPickerComponent.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/location/LocationPickerComponent.kt index 46c7ae976d9..75f6fc587dc 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/location/LocationPickerComponent.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/location/LocationPickerComponent.kt @@ -17,6 +17,7 @@ */ package com.wire.android.ui.home.messagecomposer.location +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope @@ -30,16 +31,20 @@ import androidx.compose.material.icons.filled.Send import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SheetValue +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.zIndex import androidx.hilt.navigation.compose.hiltViewModel import com.wire.android.R import com.wire.android.ui.common.Icon @@ -54,10 +59,12 @@ import com.wire.android.ui.common.dimensions import com.wire.android.ui.common.progress.WireCircularProgressIndicator import com.wire.android.ui.common.spacers.HorizontalSpace import com.wire.android.ui.common.spacers.VerticalSpace +import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireTypography import com.wire.android.util.orDefault import com.wire.android.util.permission.PermissionsDeniedRequestDialog import com.wire.android.util.permission.rememberCurrentLocationFlow +import kotlinx.coroutines.launch /** * Component to pick the current location to send. @@ -70,12 +77,11 @@ fun LocationPickerComponent( onLocationClosed: () -> Unit ) { val viewModel = hiltViewModel() - val context = LocalContext.current val coroutineScope = rememberCoroutineScope() val sheetState = rememberDismissibleWireModalSheetState(initialValue = SheetValue.Expanded, onLocationClosed) val locationFlow = LocationFlow( - onCurrentLocationPicked = { viewModel.getCurrentLocation(context) }, + onCurrentLocationPicked = viewModel::getCurrentLocation, onLocationDenied = viewModel::onPermissionsDenied ) LaunchedEffect(Unit) { @@ -104,42 +110,91 @@ fun LocationPickerComponent( } } add { - Column( + Box( modifier = Modifier - .align(alignment = Alignment.Start) - .padding(horizontal = dimensions().spacing16x) .wrapContentHeight() .fillMaxWidth() ) { - WirePrimaryButton( - onClick = { - onLocationPicked(geoLocatedAddress!!) - onLocationClosed() - }, - leadingIcon = Icons.Filled.Send.Icon(Modifier.padding(end = dimensions().spacing8x)), - text = stringResource(id = R.string.content_description_send_button), - state = if (isLocationLoading || geoLocatedAddress == null) { - WireButtonState.Disabled - } else { - WireButtonState.Default + if (showLocationSharingError) { + LocationErrorMessage { + coroutineScope.launch { + sheetState.hide() + viewModel.onLocationSharingErrorDialogDiscarded() + onLocationClosed() + } } + } + SendLocationButton( + isLocationLoading = isLocationLoading, + geoLocatedAddress = geoLocatedAddress, + onLocationPicked = onLocationPicked, + onLocationClosed = onLocationClosed ) - VerticalSpace.x16() } } } ) + + if (showPermissionDeniedDialog) { + PermissionsDeniedRequestDialog( + body = R.string.location_app_permission_dialog_body, + onDismiss = { + viewModel.onPermissionsDialogDiscarded() + onLocationClosed() + } + ) + } } } +} - if (viewModel.state.showPermissionDeniedDialog) { - PermissionsDeniedRequestDialog( - body = R.string.location_app_permission_dialog_body, - onDismiss = { - viewModel.onPermissionsDialogDiscarded() +@Composable +private fun SendLocationButton( + isLocationLoading: Boolean, + geoLocatedAddress: GeoLocatedAddress?, + onLocationPicked: (GeoLocatedAddress) -> Unit, + onLocationClosed: () -> Unit +) { + Column( + modifier = Modifier + .padding(horizontal = dimensions().spacing16x) + .wrapContentHeight() + .fillMaxWidth() + ) { + WirePrimaryButton( + onClick = { + onLocationPicked(geoLocatedAddress!!) onLocationClosed() + }, + leadingIcon = Icons.Filled.Send.Icon(Modifier.padding(end = dimensions().spacing8x)), + text = stringResource(id = R.string.content_description_send_button), + state = if (isLocationLoading || geoLocatedAddress == null) { + WireButtonState.Disabled + } else { + WireButtonState.Default } ) + VerticalSpace.x16() + } +} + +@Composable +private fun LocationErrorMessage( + message: String = stringResource(id = R.string.location_could_not_be_shared), + onLocationClosed: () -> Unit +) { + Box(Modifier.zIndex(Float.MAX_VALUE), contentAlignment = Alignment.BottomCenter) { + val snackbarHostState = remember { SnackbarHostState() } + LaunchedEffect(snackbarHostState) { + val result = snackbarHostState.showSnackbar(message = message, duration = SnackbarDuration.Short) + when (result) { + SnackbarResult.Dismissed -> onLocationClosed() + SnackbarResult.ActionPerformed -> { + /* do nothing */ + } + } + } + SnackbarHost(hostState = snackbarHostState) } } @@ -162,7 +217,7 @@ private fun LocationInformation(geoLocatedAddress: GeoLocatedAddress?) { @Composable private fun RowScope.LoadingLocation() { WireCircularProgressIndicator( - progressColor = Color.Black, + progressColor = MaterialTheme.wireColorScheme.primary, modifier = Modifier.align(alignment = Alignment.CenterVertically) ) HorizontalSpace.x8() diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/location/LocationPickerHelper.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/location/LocationPickerHelper.kt new file mode 100644 index 00000000000..f66fa5aec8a --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/location/LocationPickerHelper.kt @@ -0,0 +1,68 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.home.messagecomposer.location + +import android.annotation.SuppressLint +import android.content.Context +import android.location.Geocoder +import android.location.Location +import android.location.LocationListener +import android.location.LocationManager +import androidx.core.location.LocationManagerCompat +import com.wire.android.AppJsonStyledLogger +import com.wire.kalium.logger.KaliumLogLevel +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject + +open class LocationPickerHelper @Inject constructor(@ApplicationContext val context: Context) { + + @SuppressLint("MissingPermission") + protected fun getLocationWithoutGms(onSuccess: (GeoLocatedAddress) -> Unit, onError: () -> Unit) { + if (isLocationServicesEnabled()) { + AppJsonStyledLogger.log( + level = KaliumLogLevel.INFO, + leadingMessage = "GetLocation", + jsonStringKeyValues = mapOf("isUsingGms" to false) + ) + val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager + val networkLocationListener: LocationListener = object : LocationListener { + override fun onLocationChanged(location: Location) { + val address = Geocoder(context).getFromLocation(location.latitude, location.longitude, 1).orEmpty() + onSuccess(GeoLocatedAddress(address.firstOrNull(), location)) + locationManager.removeUpdates(this) // important step, otherwise it will keep listening for location changes + } + } + locationManager.requestLocationUpdates(LocationManager.GPS_PROVIDER, 0, 0f, networkLocationListener) + } else { + AppJsonStyledLogger.log( + level = KaliumLogLevel.WARN, + leadingMessage = "GetLocation", + jsonStringKeyValues = mapOf( + "isUsingGms" to false, + "error" to "Location services are not enabled" + ) + ) + onError() + } + } + + protected fun isLocationServicesEnabled(): Boolean { + val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager + return LocationManagerCompat.isLocationEnabled(locationManager) + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/location/LocationPickerState.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/location/LocationPickerState.kt index e8d10c30848..d0f2d55d703 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/location/LocationPickerState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/location/LocationPickerState.kt @@ -27,5 +27,6 @@ data class LocationPickerState( val isLocationLoading: Boolean = false, val isPermissionDiscarded: Boolean = false, val showPermissionDeniedDialog: Boolean = false, + val showLocationSharingError: Boolean = false, val wireModalSheetState: WireModalSheetState = WireModalSheetState(SheetValue.Hidden) ) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/location/LocationPickerViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/location/LocationPickerViewModel.kt index fbde6c867d7..86c8a87d1d5 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/location/LocationPickerViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/location/LocationPickerViewModel.kt @@ -17,29 +17,18 @@ */ package com.wire.android.ui.home.messagecomposer.location -import android.annotation.SuppressLint -import android.content.Context -import android.location.Geocoder -import android.location.Location -import android.location.LocationListener -import android.location.LocationManager import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.google.android.gms.location.LocationServices -import com.google.android.gms.location.Priority.PRIORITY_HIGH_ACCURACY -import com.google.android.gms.tasks.CancellationTokenSource -import com.wire.android.appLogger -import com.wire.android.util.extension.isGoogleServicesAvailable import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch -import kotlinx.coroutines.tasks.await import javax.inject.Inject @HiltViewModel -class LocationPickerViewModel @Inject constructor() : ViewModel() { +class LocationPickerViewModel @Inject constructor(private val locationPickerHelper: LocationPickerHelperFlavor) : ViewModel() { + var state: LocationPickerState by mutableStateOf(LocationPickerState()) private set @@ -47,50 +36,45 @@ class LocationPickerViewModel @Inject constructor() : ViewModel() { state = state.copy(showPermissionDeniedDialog = false) } - fun onPermissionsDenied() { - state = state.copy(showPermissionDeniedDialog = true) + fun onLocationSharingErrorDialogDiscarded() { + state = state.copy(showLocationSharingError = false) } - private fun toStartLoadingLocationState() { - state = state.copy(isLocationLoading = true, geoLocatedAddress = null) + fun onPermissionsDenied() { + state = state.copy(showPermissionDeniedDialog = true) } - private fun toLocationLoadedState(geoLocatedAddress: GeoLocatedAddress) { - state = state.copy(isLocationLoading = false, geoLocatedAddress = geoLocatedAddress) + fun getCurrentLocation() { + viewModelScope.launch { + toStartLoadingLocationState() + locationPickerHelper.getLocation( + onSuccess = { toLocationLoadedState(it) }, + onError = ::toLocationError + ) + } } - fun getCurrentLocation(context: Context) { - toStartLoadingLocationState() - when (context.isGoogleServicesAvailable()) { - true -> getLocationWithGms(context) - false -> getLocationWithoutGms(context) - } + private fun toStartLoadingLocationState() { + state = state.copy( + showLocationSharingError = false, + isLocationLoading = true, + geoLocatedAddress = null + ) } - /** - * Choosing the best location estimate by docs. - * https://developer.android.com/develop/sensors-and-location/location/retrieve-current#BestEstimate - */ - @SuppressLint("MissingPermission") - private fun getLocationWithGms(context: Context) = viewModelScope.launch { - appLogger.d("Getting location with GMS") - val locationProvider = LocationServices.getFusedLocationProviderClient(context) - val currentLocation = locationProvider.getCurrentLocation(PRIORITY_HIGH_ACCURACY, CancellationTokenSource().token).await() - val address = Geocoder(context).getFromLocation(currentLocation.latitude, currentLocation.longitude, 1).orEmpty() - toLocationLoadedState(GeoLocatedAddress(address.firstOrNull(), currentLocation)) + private fun toLocationLoadedState(geoLocatedAddress: GeoLocatedAddress) { + state = state.copy( + showLocationSharingError = false, + isLocationLoading = false, + geoLocatedAddress = geoLocatedAddress + ) } - @SuppressLint("MissingPermission") - private fun getLocationWithoutGms(context: Context) = viewModelScope.launch { - appLogger.d("Getting location without GMS") - val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager - val networkLocationListener: LocationListener = object : LocationListener { - override fun onLocationChanged(location: Location) { - val address = Geocoder(context).getFromLocation(location.latitude, location.longitude, 1).orEmpty() - toLocationLoadedState(GeoLocatedAddress(address.firstOrNull(), location)) - locationManager.removeUpdates(this) // important step, otherwise it will keep listening for location changes - } - } - locationManager.requestLocationUpdates(LocationManager.GPS_PROVIDER, 0, 0f, networkLocationListener) + private fun toLocationError() { + state = state.copy( + showLocationSharingError = true, + isLocationLoading = false, + geoLocatedAddress = null, + ) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/AudioMediaRecorder.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/AudioMediaRecorder.kt index 494d18e5ecc..43d202a48ea 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/AudioMediaRecorder.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/AudioMediaRecorder.kt @@ -21,10 +21,9 @@ import android.content.Context import android.media.MediaRecorder import android.os.Build import com.wire.android.appLogger -import com.wire.android.util.audioFileDateTime +import com.wire.android.util.fileDateTime import com.wire.android.util.dispatchers.DispatcherProvider import com.wire.kalium.logic.data.asset.KaliumFileSystem -import com.wire.kalium.logic.feature.asset.GetAssetSizeLimitUseCase import com.wire.kalium.util.DateTimeUtil import dagger.hilt.android.scopes.ViewModelScoped import kotlinx.coroutines.CoroutineScope @@ -36,14 +35,12 @@ import kotlinx.coroutines.launch import java.io.File import java.io.IOException import javax.inject.Inject -import kotlin.properties.Delegates @ViewModelScoped class AudioMediaRecorder @Inject constructor( private val context: Context, private val kaliumFileSystem: KaliumFileSystem, - private val dispatcherProvider: DispatcherProvider, - private val getAssetSizeLimit: GetAssetSizeLimitUseCase + private val dispatcherProvider: DispatcherProvider ) { private val scope by lazy { @@ -52,21 +49,13 @@ class AudioMediaRecorder @Inject constructor( private var mediaRecorder: MediaRecorder? = null - private var assetLimitInMegabyte by Delegates.notNull() - var outputFile: File? = null private val _maxFileSizeReached = MutableSharedFlow() fun getMaxFileSizeReached(): Flow = _maxFileSizeReached.asSharedFlow() - init { - scope.launch { - assetLimitInMegabyte = getAssetSizeLimit(isImage = false) - } - } - - fun setUp() { + fun setUp(assetLimitInMegabyte: Long) { if (mediaRecorder == null) { mediaRecorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { MediaRecorder(context) @@ -87,22 +76,23 @@ class AudioMediaRecorder @Inject constructor( mediaRecorder?.setMaxFileSize(assetLimitInMegabyte) mediaRecorder?.setOutputFile(outputFile) - observeAudioFileSize() + observeAudioFileSize(assetLimitInMegabyte) } } - fun startRecording() { - try { + fun startRecording(): Boolean = try { mediaRecorder?.prepare() mediaRecorder?.start() + true } catch (e: IllegalStateException) { e.printStackTrace() appLogger.e("[RecordAudio] startRecording: IllegalStateException - ${e.message}") + false } catch (e: IOException) { e.printStackTrace() appLogger.e("[RecordAudio] startRecording: IOException - ${e.message}") + false } - } fun stop() { mediaRecorder?.stop() @@ -112,7 +102,7 @@ class AudioMediaRecorder @Inject constructor( mediaRecorder?.release() } - private fun observeAudioFileSize() { + private fun observeAudioFileSize(assetLimitInMegabyte: Long) { mediaRecorder?.setOnInfoListener { _, what, _ -> if (what == MediaRecorder.MEDIA_RECORDER_INFO_MAX_FILESIZE_REACHED) { scope.launch { @@ -128,7 +118,7 @@ class AudioMediaRecorder @Inject constructor( private companion object { fun getRecordingAudioFileName(): String = - "wire-audio-${DateTimeUtil.currentInstant().audioFileDateTime()}.m4a" + "wire-audio-${DateTimeUtil.currentInstant().fileDateTime()}.m4a" const val SIZE_OF_1MB = 1024 * 1024 const val AUDIO_CHANNELS = 1 const val SAMPLING_RATE = 44100 diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/RecordAudioInfoMessageType.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/RecordAudioInfoMessageType.kt index 3c19e698ddd..7c25f07b4a3 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/RecordAudioInfoMessageType.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/RecordAudioInfoMessageType.kt @@ -24,9 +24,16 @@ import com.wire.android.util.ui.UIText sealed class RecordAudioInfoMessageType(override val uiText: UIText) : SnackBarMessage { // Unable to Record Audio due to being in a call - object UnableToRecordAudioCall : RecordAudioInfoMessageType( + data object UnableToRecordAudioCall : RecordAudioInfoMessageType( UIText.StringResource( R.string.record_audio_unable_due_to_ongoing_call ) ) + + // Unable to Record Audio due to error + data object UnableToRecordAudioError : RecordAudioInfoMessageType( + UIText.StringResource( + R.string.record_audio_unable_due_to_error + ) + ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/RecordAudioViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/RecordAudioViewModel.kt index a07aaab4d34..bf31b06a9a3 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/RecordAudioViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/RecordAudioViewModel.kt @@ -31,6 +31,7 @@ import com.wire.android.util.CurrentScreen import com.wire.android.util.CurrentScreenManager import com.wire.android.util.getAudioLengthInMs import com.wire.android.util.ui.UIText +import com.wire.kalium.logic.feature.asset.GetAssetSizeLimitUseCase import com.wire.kalium.logic.feature.call.usecase.ObserveEstablishedCallsUseCase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableSharedFlow @@ -47,6 +48,7 @@ import kotlin.io.path.deleteIfExists class RecordAudioViewModel @Inject constructor( private val recordAudioMessagePlayer: RecordAudioMessagePlayer, private val observeEstablishedCalls: ObserveEstablishedCallsUseCase, + private val getAssetSizeLimit: GetAssetSizeLimitUseCase, private val currentScreenManager: CurrentScreenManager, private val audioMediaRecorder: AudioMediaRecorder ) : ViewModel() { @@ -130,17 +132,18 @@ class RecordAudioViewModel @Inject constructor( infoMessage.emit(RecordAudioInfoMessageType.UnableToRecordAudioCall.uiText) } } else { - audioMediaRecorder.setUp() - - state = state.copy( - outputFile = audioMediaRecorder.outputFile - ) - - audioMediaRecorder.startRecording() - - state = state.copy( - buttonState = RecordAudioButtonState.RECORDING - ) + viewModelScope.launch { + val assetSizeLimit = getAssetSizeLimit(false) + audioMediaRecorder.setUp(assetSizeLimit) + if (audioMediaRecorder.startRecording()) { + state = state.copy( + outputFile = audioMediaRecorder.outputFile, + buttonState = RecordAudioButtonState.RECORDING + ) + } else { + infoMessage.emit(RecordAudioInfoMessageType.UnableToRecordAudioError.uiText) + } + } } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/AdditionalOptionMenuState.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/AdditionalOptionMenuState.kt index 02c0a1dfb32..93a8365e703 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/AdditionalOptionMenuState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/AdditionalOptionMenuState.kt @@ -64,7 +64,7 @@ class AdditionalOptionStateHolder { additionalOptionsSubMenuState = AdditionalOptionSubMenuState.AttachFile } - fun hideAdditionalOptionsMenu() { + fun unselectAdditionalOptionsMenu() { selectedOption = AdditionalOptionSelectItem.None } @@ -88,6 +88,7 @@ class AdditionalOptionStateHolder { fun toAttachmentAndAdditionalOptionsMenu() { additionalOptionState = AdditionalOptionMenuState.AttachmentAndAdditionalOptionsMenu + unselectAdditionalOptionsMenu() } fun toSelfDeletingOptionsMenu() { diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageComposerStateHolder.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageComposerStateHolder.kt index a4913c6a586..981e20bbc76 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageComposerStateHolder.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageComposerStateHolder.kt @@ -112,7 +112,7 @@ class MessageComposerStateHolder( fun onInputFocusedChanged(onFocused: Boolean) { if (onFocused) { - additionalOptionStateHolder.hideAdditionalOptionsMenu() + additionalOptionStateHolder.unselectAdditionalOptionsMenu() messageCompositionInputStateHolder.requestFocus() } else { messageCompositionInputStateHolder.clearFocus() diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionInputStateHolder.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionInputStateHolder.kt index 2657742d451..12ebdb41068 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionInputStateHolder.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionInputStateHolder.kt @@ -17,6 +17,7 @@ */ package com.wire.android.ui.home.messagecomposer.state +import androidx.annotation.VisibleForTesting import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.Stable @@ -32,7 +33,6 @@ import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.max -import com.google.android.gms.common.util.VisibleForTesting import com.wire.android.R import com.wire.android.ui.common.colorsScheme import com.wire.android.ui.common.textfield.WireTextFieldColors @@ -149,6 +149,10 @@ class MessageCompositionInputStateHolder( isTextExpanded = !isTextExpanded } + fun collapseText() { + isTextExpanded = false + } + fun clearFocus() { inputFocused = false } @@ -168,8 +172,8 @@ class MessageCompositionInputStateHolder( clearFocus() } - fun handleBackPressed(isImeVisible: Boolean, additionalOptionsSubMenuState: AdditionalOptionSubMenuState) { - if ((isImeVisible || optionsVisible) && additionalOptionsSubMenuState != AdditionalOptionSubMenuState.RecordAudio) { + fun collapseComposer(additionalOptionsSubMenuState: AdditionalOptionSubMenuState? = null) { + if (additionalOptionsSubMenuState != AdditionalOptionSubMenuState.RecordAudio) { optionsVisible = false subOptionsVisible = false isTextExpanded = false diff --git a/app/src/main/kotlin/com/wire/android/ui/home/newconversation/NewConversationViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/newconversation/NewConversationViewModel.kt index 8a127d0c7e0..e520c76a374 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/newconversation/NewConversationViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/newconversation/NewConversationViewModel.kt @@ -36,7 +36,7 @@ import com.wire.kalium.logic.data.conversation.ConversationOptions import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.feature.conversation.CreateGroupConversationUseCase -import com.wire.kalium.logic.feature.user.IsMLSEnabledUseCase +import com.wire.kalium.logic.feature.user.GetDefaultProtocolUseCase import com.wire.kalium.logic.feature.user.IsSelfATeamMemberUseCase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.collections.immutable.toImmutableSet @@ -48,13 +48,16 @@ import javax.inject.Inject class NewConversationViewModel @Inject constructor( private val createGroupConversation: CreateGroupConversationUseCase, private val isSelfATeamMember: IsSelfATeamMemberUseCase, - isMLSEnabled: IsMLSEnabledUseCase + getDefaultProtocol: GetDefaultProtocolUseCase ) : ViewModel() { var newGroupState: GroupMetadataState by mutableStateOf( - GroupMetadataState( - mlsEnabled = isMLSEnabled(), - ) + GroupMetadataState().let { + val defaultProtocol = ConversationOptions + .Protocol + .fromSupportedProtocolToConversationOptionsProtocol(getDefaultProtocol()) + it.copy(groupProtocol = defaultProtocol) + } ) var groupOptionsState: GroupOptionState by mutableStateOf(GroupOptionState()) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/newconversation/common/CreateGroupErrorDialog.kt b/app/src/main/kotlin/com/wire/android/ui/home/newconversation/common/CreateGroupErrorDialog.kt index 6d9e206f16e..1e7caa2b4e9 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/newconversation/common/CreateGroupErrorDialog.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/newconversation/common/CreateGroupErrorDialog.kt @@ -19,18 +19,13 @@ package com.wire.android.ui.home.newconversation.common import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.style.TextDecoration -import androidx.compose.ui.text.withStyle import com.wire.android.R +import com.wire.android.ui.common.DialogTextSuffixLink import com.wire.android.ui.common.WireDialog import com.wire.android.ui.common.WireDialogButtonProperties import com.wire.android.ui.common.WireDialogButtonType -import com.wire.android.ui.common.colorsScheme -import com.wire.android.ui.markdown.MarkdownConstants import com.wire.android.ui.theme.WireTheme -import com.wire.android.util.DialogAnnotatedErrorStrings +import com.wire.android.util.DialogErrorStrings import com.wire.android.util.ui.PreviewMultipleThemes @Composable @@ -40,51 +35,34 @@ fun CreateGroupErrorDialog( onAccept: () -> Unit, onCancel: () -> Unit ) { - val dialogStrings = when (error) { - is CreateGroupState.Error.LackingConnection -> DialogAnnotatedErrorStrings( - stringResource(R.string.error_no_network_title), - buildAnnotatedString { append(stringResource(R.string.error_no_network_message)) } - ) + val (dialogStrings, dialogSuffixLink) = when (error) { + is CreateGroupState.Error.LackingConnection -> DialogErrorStrings( + title = stringResource(R.string.error_no_network_title), + message = stringResource(R.string.error_no_network_message), + ) to null - is CreateGroupState.Error.Unknown -> DialogAnnotatedErrorStrings( - stringResource(R.string.error_unknown_title), - buildAnnotatedString { append(stringResource(R.string.error_unknown_message)) } - ) + is CreateGroupState.Error.Unknown -> DialogErrorStrings( + title = stringResource(R.string.error_unknown_title), + message = stringResource(R.string.error_unknown_message), + ) to null - is CreateGroupState.Error.ConflictedBackends -> DialogAnnotatedErrorStrings( + is CreateGroupState.Error.ConflictedBackends -> DialogErrorStrings( title = stringResource(id = R.string.group_can_not_be_created_title), - annotatedMessage = buildAnnotatedString { - val description = stringResource( + message = stringResource( id = R.string.group_can_not_be_created_federation_conflict_description, error.domains.dropLast(1).joinToString(", "), error.domains.last() - ) - val learnMore = stringResource(id = R.string.label_learn_more) - - append(description) - append(' ') - - withStyle( - style = SpanStyle( - color = colorsScheme().primary, - textDecoration = TextDecoration.Underline - ) - ) { - append(learnMore) - } - addStringAnnotation( - tag = MarkdownConstants.TAG_URL, - annotation = stringResource(id = R.string.url_message_details_offline_backends_learn_more), - start = description.length + 1, - end = description.length + 1 + learnMore.length - ) - } + ), + ) to DialogTextSuffixLink( + linkText = stringResource(id = R.string.label_learn_more), + linkUrl = stringResource(id = R.string.url_message_details_offline_backends_learn_more) ) } WireDialog( - dialogStrings.title, - dialogStrings.annotatedMessage, + title = dialogStrings.title, + text = dialogStrings.annotatedMessage, + textSuffixLink = dialogSuffixLink, onDismiss = onDismiss, buttonsHorizontalAlignment = false, optionButton1Properties = WireDialogButtonProperties( diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/SettingsItem.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/SettingsItem.kt index 95f34f6e8ad..9203dd155b6 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/settings/SettingsItem.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/SettingsItem.kt @@ -60,7 +60,7 @@ fun SettingsItem( @DrawableRes trailingIcon: Int? = null, switchState: SwitchState = SwitchState.None, onRowPressed: Clickable = Clickable(false), - onIconPressed: Clickable = Clickable(false) + onIconPressed: Clickable? = null ) { RowItemTemplate( title = { diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/account/MyAccountScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/account/MyAccountScreen.kt index 0d908b6f901..08d78bc53ff 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/settings/account/MyAccountScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/account/MyAccountScreen.kt @@ -71,6 +71,9 @@ import com.wire.android.ui.theme.wireTypography import com.wire.android.util.CustomTabsHelper import com.wire.android.util.extension.folderWithElements import com.wire.android.util.toTitleCase +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -143,11 +146,11 @@ private fun mapToUISections( navigateToChangeDisplayName: () -> Unit, navigateToChangeHandle: () -> Unit, navigateToChangeEmail: () -> Unit -): List { +): ImmutableList { return with(state) { listOfNotNull( if (fullName.isNotBlank()) { - DisplayName(fullName, clickableActionIfPossible(state.isReadOnlyAccount, navigateToChangeDisplayName)) + DisplayName(fullName, clickableActionIfPossible(!state.isEditNameAllowed, navigateToChangeDisplayName)) } else { null }, @@ -162,7 +165,7 @@ private fun mapToUISections( ) else null, if (!teamName.isNullOrBlank()) Team(teamName) else null, if (domain.isNotBlank()) Domain(domain) else null - ) + ).toImmutableList() } } @@ -171,7 +174,7 @@ private fun clickableActionIfPossible(shouldDisableAction: Boolean, action: () - @Composable fun MyAccountContent( - accountDetailItems: List = emptyList(), + accountDetailItems: ImmutableList, forgotPasswordUrl: String?, canDeleteAccount: Boolean, onDeleteAccountClicked: () -> Unit, @@ -262,7 +265,7 @@ fun MyAccountContent( @Composable fun PreviewMyAccountScreen() { MyAccountContent( - accountDetailItems = listOf( + accountDetailItems = persistentListOf( DisplayName("Bob", Clickable(enabled = true) {}), Username("@bob_wire", Clickable(enabled = true) {}), Email("bob@wire.com", Clickable(enabled = true) {}), diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/account/MyAccountState.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/account/MyAccountState.kt index dea7467fe1e..1b319148563 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/settings/account/MyAccountState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/account/MyAccountState.kt @@ -25,7 +25,7 @@ data class MyAccountState( val teamName: String? = null, val domain: String = "", val changePasswordUrl: String? = null, - val isReadOnlyAccount: Boolean = true, + val isEditNameAllowed: Boolean = false, val isEditEmailAllowed: Boolean = false, val isEditHandleAllowed: Boolean = false ) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/account/MyAccountViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/account/MyAccountViewModel.kt index 578cf23a85d..dde795ca0aa 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/settings/account/MyAccountViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/account/MyAccountViewModel.kt @@ -18,18 +18,19 @@ package com.wire.android.ui.home.settings.account +import androidx.annotation.VisibleForTesting import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope -import com.google.android.gms.common.util.VisibleForTesting import com.wire.android.BuildConfig import com.wire.android.appLogger import com.wire.android.navigation.SavedStateViewModel import com.wire.android.util.dispatchers.DispatcherProvider import com.wire.kalium.logic.feature.team.GetUpdatedSelfTeamUseCase import com.wire.kalium.logic.feature.user.GetSelfUserUseCase +import com.wire.kalium.logic.feature.user.IsE2EIEnabledUseCase import com.wire.kalium.logic.feature.user.IsPasswordRequiredUseCase import com.wire.kalium.logic.feature.user.IsReadOnlyAccountUseCase import com.wire.kalium.logic.feature.user.SelfServerConfigUseCase @@ -53,7 +54,8 @@ class MyAccountViewModel @Inject constructor( private val serverConfig: SelfServerConfigUseCase, private val isPasswordRequired: IsPasswordRequiredUseCase, private val isReadOnlyAccount: IsReadOnlyAccountUseCase, - private val dispatchers: DispatcherProvider + private val dispatchers: DispatcherProvider, + private val isE2EIEnabledUseCase: IsE2EIEnabledUseCase ) : SavedStateViewModel(savedStateHandle) { var myAccountState by mutableStateOf(MyAccountState()) @@ -65,6 +67,9 @@ class MyAccountViewModel @Inject constructor( @VisibleForTesting var managedByWire by Delegates.notNull() + @VisibleForTesting + var isE2EIEnabled by Delegates.notNull() + init { runBlocking { hasSAMLCred = when (val result = isPasswordRequired()) { @@ -76,11 +81,12 @@ class MyAccountViewModel @Inject constructor( // is the account is read only it means it is not maneged by wire managedByWire = !isReadOnlyAccount() + isE2EIEnabled = isE2EIEnabledUseCase() } myAccountState = myAccountState.copy( - isReadOnlyAccount = !managedByWire, isEditEmailAllowed = isChangeEmailEnabledByBuild() && !hasSAMLCred && managedByWire, - isEditHandleAllowed = managedByWire + isEditNameAllowed = managedByWire && !isE2EIEnabled, + isEditHandleAllowed = managedByWire && !isE2EIEnabled ) viewModelScope.launch { fetchSelfUser() diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/account/email/updateEmail/ChangeEmailViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/account/email/updateEmail/ChangeEmailViewModel.kt index 8ae8561c6f7..f97da02b068 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/settings/account/email/updateEmail/ChangeEmailViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/account/email/updateEmail/ChangeEmailViewModel.kt @@ -17,6 +17,7 @@ */ package com.wire.android.ui.home.settings.account.email.updateEmail +import androidx.annotation.VisibleForTesting import android.util.Patterns import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -24,7 +25,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.google.android.gms.common.util.VisibleForTesting import com.wire.kalium.logic.feature.user.GetSelfUserUseCase import com.wire.kalium.logic.feature.user.UpdateEmailUseCase import dagger.hilt.android.lifecycle.HiltViewModel diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/account/handle/ChangeHandleViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/account/handle/ChangeHandleViewModel.kt index b2284b79299..4ba506ba847 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/settings/account/handle/ChangeHandleViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/account/handle/ChangeHandleViewModel.kt @@ -17,13 +17,13 @@ */ package com.wire.android.ui.home.settings.account.handle +import androidx.annotation.VisibleForTesting import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.google.android.gms.common.util.VisibleForTesting import com.wire.android.ui.authentication.create.common.handle.HandleUpdateErrorState import com.wire.kalium.logic.feature.auth.ValidateUserHandleResult import com.wire.kalium.logic.feature.auth.ValidateUserHandleUseCase diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/appsettings/networkSettings/NetworkSettingsScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/appsettings/networkSettings/NetworkSettingsScreen.kt index 312e5b3f722..a25fb847f99 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/settings/appsettings/networkSettings/NetworkSettingsScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/appsettings/networkSettings/NetworkSettingsScreen.kt @@ -21,7 +21,6 @@ package com.wire.android.ui.home.settings.appsettings.networkSettings import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding -import com.wire.android.ui.common.scaffold.WireScaffold import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -33,6 +32,7 @@ import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.RootNavGraph import com.wire.android.R import com.wire.android.navigation.Navigator +import com.wire.android.ui.common.scaffold.WireScaffold import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar import com.wire.android.ui.home.conversations.details.options.ArrowType import com.wire.android.ui.home.conversations.details.options.GroupConversationOptionsItem @@ -74,20 +74,27 @@ fun NetworkSettingsScreenContent( .fillMaxSize() .padding(internalPadding) ) { - if (!isWebsocketEnabledByDefault(LocalContext.current)) { - GroupConversationOptionsItem( - title = stringResource(R.string.settings_keep_connection_to_websocket), - subtitle = stringResource( - R.string.settings_keep_connection_to_websocket_description, - backendName - ), - switchState = SwitchState.Enabled( - value = isWebSocketEnabled, - onCheckedChange = setWebSocketState - ), - arrowType = ArrowType.NONE + val appContext = LocalContext.current + val isWebSocketEnforcedByDefault = isWebsocketEnabledByDefault(appContext) + + val switchState = if (isWebSocketEnforcedByDefault) { + SwitchState.TextOnly(true) + } else { + SwitchState.Enabled( + value = isWebSocketEnabled, + onCheckedChange = setWebSocketState ) } + + GroupConversationOptionsItem( + title = stringResource(R.string.settings_keep_connection_to_websocket), + subtitle = stringResource( + R.string.settings_keep_connection_to_websocket_description, + backendName + ), + switchState = switchState, + arrowType = ArrowType.NONE + ) } } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/sync/FeatureFlagNotificationViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/sync/FeatureFlagNotificationViewModel.kt index 39f0e77dc0a..916e1a12017 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/sync/FeatureFlagNotificationViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/sync/FeatureFlagNotificationViewModel.kt @@ -18,7 +18,6 @@ package com.wire.android.ui.home.sync -import android.content.Context import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -29,11 +28,10 @@ import com.wire.android.datastore.GlobalDataStore import com.wire.android.di.KaliumCoreLogic import com.wire.android.feature.AppLockSource import com.wire.android.feature.DisableAppLockUseCase -import com.wire.android.feature.e2ei.GetE2EICertificateUseCase import com.wire.android.ui.home.FeatureFlagState import com.wire.android.ui.home.conversations.selfdeletion.SelfDeletionMapper.toSelfDeletionDuration import com.wire.android.ui.home.messagecomposer.SelfDeletionDuration -import com.wire.android.util.dispatchers.DispatcherProvider +import com.wire.kalium.logic.CoreFailure import com.wire.kalium.logic.CoreLogic import com.wire.kalium.logic.configuration.FileSharingStatus import com.wire.kalium.logic.data.message.TeamSelfDeleteTimer @@ -43,6 +41,7 @@ import com.wire.kalium.logic.feature.e2ei.usecase.E2EIEnrollmentResult import com.wire.kalium.logic.feature.session.CurrentSessionFlowUseCase import com.wire.kalium.logic.feature.session.CurrentSessionResult import com.wire.kalium.logic.feature.user.E2EIRequiredResult +import com.wire.kalium.logic.functional.Either import com.wire.kalium.logic.functional.fold import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.coroutineScope @@ -59,7 +58,6 @@ class FeatureFlagNotificationViewModel @Inject constructor( private val currentSessionFlow: CurrentSessionFlowUseCase, private val globalDataStore: GlobalDataStore, private val disableAppLockUseCase: DisableAppLockUseCase, - private val dispatcherProvider: DispatcherProvider ) : ViewModel() { var featureFlagState by mutableStateOf(FeatureFlagState()) @@ -92,12 +90,14 @@ class FeatureFlagNotificationViewModel @Inject constructor( fileSharingRestrictedState = FeatureFlagState.SharingRestrictedState.NO_USER ) } + currentSessionResult is CurrentSessionResult.Success && !currentSessionResult.accountInfo.isValid() -> { appLogger.i("$TAG: Invalid current session") featureFlagState = FeatureFlagState( // invalid session, clear feature flag state to default and set NO_USER fileSharingRestrictedState = FeatureFlagState.SharingRestrictedState.NO_USER ) } + currentSessionResult is CurrentSessionResult.Success && currentSessionResult.accountInfo.isValid() -> { featureFlagState = FeatureFlagState() // new session, clear feature flag state to default and wait until synced currentSessionResult.accountInfo.userId.let { userId -> @@ -120,6 +120,13 @@ class FeatureFlagNotificationViewModel @Inject constructor( launch { setE2EIRequiredState(userId) } launch { setTeamAppLockFeatureFlag(userId) } launch { observeCallEndedBecauseOfConversationDegraded(userId) } + launch { observeShouldNotifyForRevokedCertificate(userId) } + } + } + + private suspend fun observeShouldNotifyForRevokedCertificate(userId: UserId) { + coreLogic.getSessionScope(userId).observeShouldNotifyForRevokedCertificate().collect { + featureFlagState = featureFlagState.copy(shouldShowE2eiCertificateRevokedDialog = it) } } @@ -145,35 +152,35 @@ class FeatureFlagNotificationViewModel @Inject constructor( } private suspend fun setGuestRoomLinkFeatureFlag(userId: UserId) { - coreLogic.getSessionScope(userId).observeGuestRoomLinkFeatureFlag() - .collect { guestRoomLinkStatus -> - guestRoomLinkStatus.isGuestRoomLinkEnabled?.let { - featureFlagState = featureFlagState.copy(isGuestRoomLinkEnabled = it) - } - guestRoomLinkStatus.isStatusChanged?.let { - featureFlagState = featureFlagState.copy(shouldShowGuestRoomLinkDialog = it) - } + coreLogic.getSessionScope(userId).observeGuestRoomLinkFeatureFlag() + .collect { guestRoomLinkStatus -> + guestRoomLinkStatus.isGuestRoomLinkEnabled?.let { + featureFlagState = featureFlagState.copy(isGuestRoomLinkEnabled = it) } - } + guestRoomLinkStatus.isStatusChanged?.let { + featureFlagState = featureFlagState.copy(shouldShowGuestRoomLinkDialog = it) + } + } + } private suspend fun setTeamAppLockFeatureFlag(userId: UserId) { - coreLogic.getSessionScope(userId).appLockTeamFeatureConfigObserver() - .distinctUntilChanged() - .collectLatest { appLockConfig -> - appLockConfig?.isStatusChanged?.let { isStatusChanged -> - val shouldBlockApp = if (isStatusChanged) { - true - } else { - (!isUserAppLockSet() && appLockConfig.isEnforced) - } - - featureFlagState = featureFlagState.copy( - isTeamAppLockEnabled = appLockConfig.isEnforced, - shouldShowTeamAppLockDialog = shouldBlockApp - ) + coreLogic.getSessionScope(userId).appLockTeamFeatureConfigObserver() + .distinctUntilChanged() + .collectLatest { appLockConfig -> + appLockConfig?.isStatusChanged?.let { isStatusChanged -> + val shouldBlockApp = if (isStatusChanged) { + true + } else { + (!isUserAppLockSet() && appLockConfig.isEnforced) } + + featureFlagState = featureFlagState.copy( + isTeamAppLockEnabled = appLockConfig.isEnforced, + shouldShowTeamAppLockDialog = shouldBlockApp + ) } - } + } + } private suspend fun observeTeamSettingsSelfDeletionStatus(userId: UserId) { coreLogic.getSessionScope(userId).observeTeamSettingsSelfDeletionStatus() @@ -232,6 +239,15 @@ class FeatureFlagNotificationViewModel @Inject constructor( } } + fun dismissE2EICertificateRevokedDialog() { + featureFlagState = featureFlagState.copy(shouldShowE2eiCertificateRevokedDialog = false) + currentUserId?.let { + viewModelScope.launch { + coreLogic.getSessionScope(it).markNotifyForRevokedCertificateAsNotified() + } + } + } + fun dismissFileSharingDialog() { featureFlagState = featureFlagState.copy(showFileSharingDialog = false) viewModelScope.launch { @@ -272,33 +288,36 @@ class FeatureFlagNotificationViewModel @Inject constructor( fun isUserAppLockSet() = globalDataStore.isAppLockPasscodeSet() - fun getE2EICertificate(e2eiRequired: FeatureFlagState.E2EIRequired, context: Context) { - featureFlagState = featureFlagState.copy(isE2EILoading = true) - currentUserId?.let { userId -> - GetE2EICertificateUseCase(coreLogic.getSessionScope(userId).enrollE2EI, dispatcherProvider).invoke(context) { result -> - result.fold({ - featureFlagState = featureFlagState.copy( - isE2EILoading = false, - e2EIRequired = null, - e2EIResult = FeatureFlagState.E2EIResult.Failure(e2eiRequired) - ) - }, { - if (it is E2EIEnrollmentResult.Finalized) { - featureFlagState = featureFlagState.copy( - isE2EILoading = false, - e2EIRequired = null, - e2EIResult = FeatureFlagState.E2EIResult.Success(it.certificate) - ) - } else if (it is E2EIEnrollmentResult.Failed) { - featureFlagState = featureFlagState.copy( - isE2EILoading = false, - e2EIRequired = null, - e2EIResult = FeatureFlagState.E2EIResult.Failure(e2eiRequired) - ) - } - }) + fun enrollE2EICertificate() { + featureFlagState = featureFlagState.copy(isE2EILoading = true, startGettingE2EICertificate = true) + } + + fun handleE2EIEnrollmentResult(result: Either) { + val e2eiRequired = featureFlagState.e2EIRequired + result.fold({ + featureFlagState = featureFlagState.copy( + isE2EILoading = false, + startGettingE2EICertificate = false, + e2EIRequired = null, + e2EIResult = e2eiRequired?.let { FeatureFlagState.E2EIResult.Failure(e2eiRequired) } + ) + }, { + featureFlagState = if (it is E2EIEnrollmentResult.Finalized) { + featureFlagState.copy( + isE2EILoading = false, + e2EIRequired = null, + startGettingE2EICertificate = false, + e2EIResult = FeatureFlagState.E2EIResult.Success(it.certificate) + ) + } else { + featureFlagState.copy( + isE2EILoading = false, + e2EIRequired = null, + startGettingE2EICertificate = false, + e2EIResult = e2eiRequired?.let { FeatureFlagState.E2EIResult.Failure(e2eiRequired) } + ) } - } + }) } fun snoozeE2EIdRequiredDialog(result: FeatureFlagState.E2EIRequired.WithGracePeriod) { diff --git a/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownBlockQuote.kt b/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownBlockQuote.kt index 911032faa82..2dc3348affe 100644 --- a/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownBlockQuote.kt +++ b/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownBlockQuote.kt @@ -32,6 +32,8 @@ import com.wire.android.ui.common.dimensions import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireTypography import org.commonmark.node.BlockQuote +import org.commonmark.node.BulletList +import org.commonmark.node.OrderedList @Composable fun MarkdownBlockQuote(blockQuote: BlockQuote, nodeData: NodeData) { @@ -52,6 +54,8 @@ fun MarkdownBlockQuote(blockQuote: BlockQuote, nodeData: NodeData) { while (child != null) { when (child) { is BlockQuote -> MarkdownBlockQuote(child, nodeData) + is BulletList -> MarkdownBulletList(child, nodeData) + is OrderedList -> MarkdownOrderedList(child, nodeData) else -> { val text = buildAnnotatedString { pushStyle( diff --git a/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownComposer.kt b/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownComposer.kt index ee5dd8aa6b8..c025fd247e5 100644 --- a/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownComposer.kt +++ b/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownComposer.kt @@ -306,7 +306,8 @@ fun appendLinksAndMentions( if (highLightIndex.endIndex <= length) { addStyle( style = SpanStyle( - background = nodeData.colorScheme.highLight.copy(alpha = 0.5f), + background = nodeData.colorScheme.highlight, + color = nodeData.colorScheme.onHighlight, fontFamily = nodeData.typography.body02.fontFamily, fontWeight = FontWeight.Bold ), diff --git a/app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceDetailsScreen.kt b/app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceDetailsScreen.kt index 7078e6ad607..081bccdce72 100644 --- a/app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceDetailsScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceDetailsScreen.kt @@ -17,7 +17,6 @@ */ package com.wire.android.ui.settings.devices -import android.content.Context import androidx.annotation.StringRes import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box @@ -75,20 +74,24 @@ import com.wire.android.ui.common.scaffold.WireScaffold import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar import com.wire.android.ui.common.topappbar.WireTopAppBarTitle import com.wire.android.ui.destinations.E2eiCertificateDetailsScreenDestination -import com.wire.android.ui.home.E2EIErrorWithDismissDialog +import com.wire.android.ui.e2eiEnrollment.GetE2EICertificateUI import com.wire.android.ui.home.E2EISuccessDialog +import com.wire.android.ui.home.E2EIUpdateErrorWithDismissDialog import com.wire.android.ui.home.conversationslist.common.FolderHeader import com.wire.android.ui.settings.devices.model.DeviceDetailsState import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireDimensions import com.wire.android.ui.theme.wireTypography import com.wire.android.util.CustomTabsHelper +import com.wire.android.util.deviceDateTimeFormat import com.wire.android.util.dialogErrorStrings import com.wire.android.util.extension.formatAsFingerPrint import com.wire.android.util.extension.formatAsString -import com.wire.android.util.formatMediumDateTime import com.wire.android.util.ui.UIText +import com.wire.kalium.logic.CoreFailure import com.wire.kalium.logic.data.conversation.ClientId +import com.wire.kalium.logic.feature.e2ei.usecase.E2EIEnrollmentResult +import com.wire.kalium.logic.functional.Either @RootNavGraph @Destination( @@ -110,10 +113,13 @@ fun DeviceDetailsScreen( onErrorDialogDismiss = viewModel::clearDeleteClientError, onNavigateBack = navigator::navigateBack, onUpdateClientVerification = viewModel::onUpdateVerificationStatus, - enrollE2eiCertificate = viewModel::enrollE2eiCertificate, + enrollE2eiCertificate = viewModel::enrollE2EICertificate, + handleE2EIEnrollmentResult = viewModel::handleE2EIEnrollmentResult, onNavigateToE2eiCertificateDetailsScreen = { navigator.navigate( - NavigationCommand(E2eiCertificateDetailsScreenDestination(it)) + NavigationCommand( + E2eiCertificateDetailsScreenDestination(it, viewModel.state.isSelfClient, viewModel.state.userName) + ) ) }, onEnrollE2EIErrorDismiss = viewModel::hideEnrollE2EICertificateError, @@ -132,14 +138,15 @@ fun DeviceDetailsContent( onRemoveConfirm: () -> Unit = {}, onDialogDismiss: () -> Unit = {}, onErrorDialogDismiss: () -> Unit = {}, - enrollE2eiCertificate: (Context) -> Unit = {}, + enrollE2eiCertificate: () -> Unit = {}, + handleE2EIEnrollmentResult: (Either) -> Unit, onUpdateClientVerification: (Boolean) -> Unit = {}, onEnrollE2EIErrorDismiss: () -> Unit = {}, onEnrollE2EISuccessDismiss: () -> Unit = {} ) { val screenState = rememberConversationScreenState() WireScaffold( - topBar = { DeviceDetailsTopBar(onNavigateBack, state.device, state.isCurrentDevice) }, + topBar = { DeviceDetailsTopBar(onNavigateBack, state.device, state.isCurrentDevice, state.isE2EIEnabled) }, bottomBar = { Column( Modifier @@ -173,7 +180,6 @@ fun DeviceDetailsContent( } } ) { internalPadding -> - val context = LocalContext.current LazyColumn( modifier = Modifier .fillMaxSize() @@ -187,17 +193,19 @@ fun DeviceDetailsContent( Divider(color = MaterialTheme.wireColorScheme.background) } } - item { - EndToEndIdentityCertificateItem( - isE2eiCertificateActivated = state.isE2eiCertificateActivated, - certificate = state.e2eiCertificate, - isCurrentDevice = state.isCurrentDevice, - isLoadingCertificate = state.isLoadingCertificate, - enrollE2eiCertificate = { enrollE2eiCertificate(context) }, - updateE2eiCertificate = {}, - showCertificate = onNavigateToE2eiCertificateDetailsScreen - ) - Divider(color = colorsScheme().background) + + if (state.isE2EIEnabled) { + item { + EndToEndIdentityCertificateItem( + isE2eiCertificateActivated = state.isE2eiCertificateActivated, + certificate = state.e2eiCertificate, + isCurrentDevice = state.isCurrentDevice, + isLoadingCertificate = state.isLoadingCertificate, + enrollE2eiCertificate = { enrollE2eiCertificate() }, + showCertificate = onNavigateToE2eiCertificateDetailsScreen + ) + Divider(color = colorsScheme().background) + } } item { FolderHeader( @@ -210,7 +218,7 @@ fun DeviceDetailsContent( Divider(color = MaterialTheme.wireColorScheme.background) } - state.device.registrationTime?.formatMediumDateTime()?.let { + state.device.registrationTime?.deviceDateTimeFormat()?.let { item { DeviceDetailSectionContent( stringResource(id = R.string.label_client_added_time), @@ -273,9 +281,9 @@ fun DeviceDetailsContent( } if (state.isE2EICertificateEnrollError) { - E2EIErrorWithDismissDialog( + E2EIUpdateErrorWithDismissDialog( isE2EILoading = state.isLoadingCertificate, - updateCertificate = { enrollE2eiCertificate(context) }, + updateCertificate = { enrollE2eiCertificate() }, onDismiss = onEnrollE2EIErrorDismiss ) } @@ -286,6 +294,13 @@ fun DeviceDetailsContent( dismissDialog = onEnrollE2EISuccessDismiss ) } + + if (state.startGettingE2EICertificate) { + GetE2EICertificateUI( + enrollmentResultHandler = { handleE2EIEnrollmentResult(it) }, + isNewClient = false + ) + } } } @@ -293,7 +308,8 @@ fun DeviceDetailsContent( private fun DeviceDetailsTopBar( onNavigateBack: () -> Unit, device: Device, - isCurrentDevice: Boolean + isCurrentDevice: Boolean, + shouldShowE2EIInfo: Boolean ) { WireCenterAlignedTopAppBar( onNavigationPressed = onNavigateBack, @@ -306,7 +322,9 @@ private fun DeviceDetailsTopBar( maxLines = 2 ) - MLSVerificationIcon(device.e2eiCertificateStatus) + if (shouldShowE2EIInfo) { + MLSVerificationIcon(device.e2eiCertificateStatus) + } if (!isCurrentDevice && device.isVerifiedProteus) { ProteusVerifiedIcon(Modifier.align(Alignment.CenterVertically)) @@ -566,6 +584,7 @@ fun PreviewDeviceDetailsScreen() { ), onPasswordChange = { }, enrollE2eiCertificate = { }, + handleE2EIEnrollmentResult = {}, onRemoveConfirm = { }, onDialogDismiss = { }, onErrorDialogDismiss = { } diff --git a/app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceDetailsViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceDetailsViewModel.kt index c9366037c48..af5297be976 100644 --- a/app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceDetailsViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceDetailsViewModel.kt @@ -17,7 +17,6 @@ */ package com.wire.android.ui.settings.devices -import android.content.Context import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -26,13 +25,13 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.wire.android.appLogger import com.wire.android.di.CurrentAccount -import com.wire.android.feature.e2ei.GetE2EICertificateUseCase import com.wire.android.navigation.SavedStateViewModel import com.wire.android.ui.authentication.devices.model.Device import com.wire.android.ui.authentication.devices.remove.RemoveDeviceDialogState import com.wire.android.ui.authentication.devices.remove.RemoveDeviceError import com.wire.android.ui.navArgs import com.wire.android.ui.settings.devices.model.DeviceDetailsState +import com.wire.kalium.logic.CoreFailure import com.wire.kalium.logic.data.client.ClientType import com.wire.kalium.logic.data.client.DeleteClientParam import com.wire.kalium.logic.data.conversation.ClientId @@ -48,8 +47,10 @@ import com.wire.kalium.logic.feature.e2ei.usecase.E2EIEnrollmentResult import com.wire.kalium.logic.feature.e2ei.usecase.GetE2EICertificateUseCaseResult import com.wire.kalium.logic.feature.e2ei.usecase.GetE2eiCertificateUseCase import com.wire.kalium.logic.feature.user.GetUserInfoResult +import com.wire.kalium.logic.feature.user.IsE2EIEnabledUseCase import com.wire.kalium.logic.feature.user.IsPasswordRequiredUseCase import com.wire.kalium.logic.feature.user.ObserveUserInfoUseCase +import com.wire.kalium.logic.functional.Either import com.wire.kalium.logic.functional.fold import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch @@ -68,14 +69,19 @@ class DeviceDetailsViewModel @Inject constructor( private val updateClientVerificationStatus: UpdateClientVerificationStatusUseCase, private val observeUserInfo: ObserveUserInfoUseCase, private val e2eiCertificate: GetE2eiCertificateUseCase, - private val enrolE2EICertificateUseCase: GetE2EICertificateUseCase + isE2EIEnabledUseCase: IsE2EIEnabledUseCase ) : SavedStateViewModel(savedStateHandle) { private val deviceDetailsNavArgs: DeviceDetailsNavArgs = savedStateHandle.navArgs() private val deviceId: ClientId = deviceDetailsNavArgs.clientId private val userId: UserId = deviceDetailsNavArgs.userId - var state: DeviceDetailsState by mutableStateOf(DeviceDetailsState(isSelfClient = isSelfClient)) + var state: DeviceDetailsState by mutableStateOf( + DeviceDetailsState( + isSelfClient = isSelfClient, + isE2EIEnabled = isE2EIEnabledUseCase() + ) + ) private set init { @@ -112,7 +118,8 @@ class DeviceDetailsViewModel @Inject constructor( state.copy( isE2eiCertificateActivated = true, e2eiCertificate = certificate.certificate, - isLoadingCertificate = false + isLoadingCertificate = false, + device = state.device.updateE2EICertificateStatus(certificate.certificate.status) ) } else { state.copy(isE2eiCertificateActivated = false, isLoadingCertificate = false) @@ -120,26 +127,29 @@ class DeviceDetailsViewModel @Inject constructor( } } - fun enrollE2eiCertificate(context: Context) { - state = state.copy(isLoadingCertificate = true) - enrolE2EICertificateUseCase(context) { result -> - result.fold({ + fun enrollE2EICertificate() { + state = state.copy(isLoadingCertificate = true, startGettingE2EICertificate = true) + } + + fun handleE2EIEnrollmentResult(result: Either) { + result.fold({ + state = state.copy( + isLoadingCertificate = false, + startGettingE2EICertificate = false, + isE2EICertificateEnrollError = true, + ) + }, { + if (it is E2EIEnrollmentResult.Finalized) { + getE2eiCertificate() + state = state.copy(isE2EICertificateEnrollSuccess = true, startGettingE2EICertificate = false) + } else { state = state.copy( isLoadingCertificate = false, - isE2EICertificateEnrollError = true + isE2EICertificateEnrollError = true, + startGettingE2EICertificate = false, ) - }, { - if (it is E2EIEnrollmentResult.Finalized) { - getE2eiCertificate() - state = state.copy(isE2EICertificateEnrollSuccess = true) - } else if (it is E2EIEnrollmentResult.Failed) { - state = state.copy( - isLoadingCertificate = false, - isE2EICertificateEnrollError = true - ) - } - }) - } + } + }) } private fun getClientFingerPrint() { @@ -162,7 +172,7 @@ class DeviceDetailsViewModel @Inject constructor( is GetClientDetailsResult.Success -> { state.copy( - device = Device(result.client), + device = state.device.updateFromClient(result.client), isCurrentDevice = result.isCurrentClient, removeDeviceDialogState = RemoveDeviceDialogState.Hidden, canBeRemoved = !result.isCurrentClient && isSelfClient && result.client.type == ClientType.Permanent, diff --git a/app/src/main/kotlin/com/wire/android/ui/settings/devices/EndToEndIdentityCertificateItem.kt b/app/src/main/kotlin/com/wire/android/ui/settings/devices/EndToEndIdentityCertificateItem.kt index 1e134d0aef4..9bdafc232ee 100644 --- a/app/src/main/kotlin/com/wire/android/ui/settings/devices/EndToEndIdentityCertificateItem.kt +++ b/app/src/main/kotlin/com/wire/android/ui/settings/devices/EndToEndIdentityCertificateItem.kt @@ -42,6 +42,7 @@ import com.wire.android.ui.theme.wireTypography import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.kalium.logic.feature.e2ei.CertificateStatus import com.wire.kalium.logic.feature.e2ei.E2eiCertificate +import kotlinx.datetime.Instant @Composable fun EndToEndIdentityCertificateItem( @@ -50,7 +51,6 @@ fun EndToEndIdentityCertificateItem( isCurrentDevice: Boolean, isLoadingCertificate: Boolean, enrollE2eiCertificate: () -> Unit, - updateE2eiCertificate: () -> Unit, showCertificate: (String) -> Unit ) { Column( @@ -199,14 +199,13 @@ fun PreviewEndToEndIdentityCertificateItem() { isE2eiCertificateActivated = true, isCurrentDevice = false, certificate = E2eiCertificate( - issuer = "Wire", status = CertificateStatus.VALID, serialNumber = "e5:d5:e6:75:7e:04:86:07:14:3c:a0:ed:9a:8d:e4:fd", - certificateDetail = "" + certificateDetail = "", + endAt = Instant.DISTANT_FUTURE ), isLoadingCertificate = false, enrollE2eiCertificate = {}, - updateE2eiCertificate = {}, showCertificate = {} ) } @@ -218,14 +217,13 @@ fun PreviewEndToEndIdentityCertificateSelfItem() { isE2eiCertificateActivated = true, isCurrentDevice = true, certificate = E2eiCertificate( - issuer = "Wire", status = CertificateStatus.VALID, serialNumber = "e5:d5:e6:75:7e:04:86:07:14:3c:a0:ed:9a:8d:e4:fd", - certificateDetail = "" + certificateDetail = "", + endAt = Instant.DISTANT_FUTURE ), isLoadingCertificate = false, enrollE2eiCertificate = {}, - updateE2eiCertificate = {}, showCertificate = {} ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/settings/devices/SelfDevicesScreen.kt b/app/src/main/kotlin/com/wire/android/ui/settings/devices/SelfDevicesScreen.kt index 4909e6c2ea8..9b10b2d1310 100644 --- a/app/src/main/kotlin/com/wire/android/ui/settings/devices/SelfDevicesScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/settings/devices/SelfDevicesScreen.kt @@ -90,17 +90,22 @@ fun SelfDevicesScreenContent( false -> { state.currentDevice?.let { currentDevice -> folderDeviceItems( - context.getString(R.string.current_device_label), - listOf(currentDevice), - false, - onDeviceClick + header = context.getString(R.string.current_device_label), + items = listOf(currentDevice), + shouldShowVerifyLabel = true, + isCurrentClient = true, + isE2EIEnabled = state.isE2EIEnabled, + onDeviceClick = onDeviceClick, + ) } folderDeviceItems( - context.getString(R.string.other_devices_label), - state.deviceList, - true, - onDeviceClick + header = context.getString(R.string.other_devices_label), + items = state.deviceList, + shouldShowVerifyLabel = true, + isCurrentClient = false, + isE2EIEnabled = state.isE2EIEnabled, + onDeviceClick = onDeviceClick ) } } @@ -108,11 +113,13 @@ fun SelfDevicesScreenContent( } ) } - +@Suppress("LongParameterList") private fun LazyListScope.folderDeviceItems( header: String, items: List, shouldShowVerifyLabel: Boolean, + isCurrentClient: Boolean, + isE2EIEnabled: Boolean, onDeviceClick: (Device) -> Unit = {} ) { folderWithElements( @@ -129,11 +136,12 @@ private fun LazyListScope.folderDeviceItems( item, background = MaterialTheme.wireColorScheme.surface, placeholder = false, - onRemoveDeviceClick = onDeviceClick, - leadingIcon = Icons.Filled.ChevronRight.Icon(), - leadingIconBorder = 0.dp, + onClickAction = onDeviceClick, + icon = Icons.Filled.ChevronRight.Icon(), isWholeItemClickable = true, - shouldShowVerifyLabel = shouldShowVerifyLabel + shouldShowVerifyLabel = shouldShowVerifyLabel, + isCurrentClient = isCurrentClient, + shouldShowE2EIInfo = isE2EIEnabled ) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/settings/devices/SelfDevicesViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/settings/devices/SelfDevicesViewModel.kt index 18a0acf72c8..6a57ced5b92 100644 --- a/app/src/main/kotlin/com/wire/android/ui/settings/devices/SelfDevicesViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/settings/devices/SelfDevicesViewModel.kt @@ -31,6 +31,7 @@ import com.wire.kalium.logic.feature.client.FetchSelfClientsFromRemoteUseCase import com.wire.kalium.logic.feature.client.ObserveClientsByUserIdUseCase import com.wire.kalium.logic.feature.client.ObserveCurrentClientIdUseCase import com.wire.kalium.logic.feature.e2ei.usecase.GetUserE2eiCertificatesUseCase +import com.wire.kalium.logic.feature.user.IsE2EIEnabledUseCase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.launch @@ -43,10 +44,11 @@ class SelfDevicesViewModel @Inject constructor( private val observeClientList: ObserveClientsByUserIdUseCase, private val currentClientIdUseCase: ObserveCurrentClientIdUseCase, private val getUserE2eiCertificates: GetUserE2eiCertificatesUseCase, + isE2EIEnabledUseCase: IsE2EIEnabledUseCase ) : ViewModel() { var state: SelfDevicesState by mutableStateOf( - SelfDevicesState(deviceList = listOf(), isLoadingClientsList = true, currentDevice = null) + SelfDevicesState(deviceList = listOf(), isLoadingClientsList = true, currentDevice = null, isE2EIEnabled = isE2EIEnabledUseCase()) ) private set @@ -67,7 +69,8 @@ class SelfDevicesViewModel @Inject constructor( state.copy( isLoadingClientsList = false, currentDevice = result.clients - .firstOrNull { it.id == currentClientId }?.let { Device(it, e2eiCertificates[it.id.value]?.status) }, + .firstOrNull { it.id == currentClientId } + ?.let { Device(it, e2eiCertificates[it.id.value]?.status) }, deviceList = result.clients .filter { it.id != currentClientId } .map { Device(it, e2eiCertificates[it.id.value]?.status) } diff --git a/app/src/main/kotlin/com/wire/android/ui/settings/devices/e2ei/E2eiCertificateDetailsScreen.kt b/app/src/main/kotlin/com/wire/android/ui/settings/devices/e2ei/E2eiCertificateDetailsScreen.kt index 70f3093c9fa..f8496fb52cb 100644 --- a/app/src/main/kotlin/com/wire/android/ui/settings/devices/e2ei/E2eiCertificateDetailsScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/settings/devices/e2ei/E2eiCertificateDetailsScreen.kt @@ -28,7 +28,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.TextAlign @@ -49,11 +48,9 @@ import com.wire.android.ui.common.topappbar.NavigationIconType import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar import com.wire.android.util.copyLinkToClipboard import com.wire.android.util.createPemFile -import com.wire.android.util.saveFileToDownloadsFolder import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import okio.Path.Companion.toOkioPath @RootNavGraph @Destination( @@ -67,7 +64,7 @@ fun E2eiCertificateDetailsScreen( ) { val snackbarHostState = LocalSnackbarHostState.current val scope = rememberCoroutineScope() - val context = LocalContext.current + val downloadedString = stringResource(id = R.string.media_gallery_on_image_downloaded) WireScaffold( topBar = { @@ -92,7 +89,6 @@ fun E2eiCertificateDetailsScreen( with(e2eiCertificateDetailsViewModel) { val copiedToClipboardString = stringResource(id = R.string.e2ei_certificate_details_certificate_copied_to_clipboard) - val downloadedString = stringResource(id = R.string.media_gallery_on_image_downloaded) E2eiCertificateDetailsContent( padding = it, @@ -110,14 +106,10 @@ fun E2eiCertificateDetailsScreen( onDownload = { scope.launch { withContext(Dispatchers.IO) { - createPemFile(CERTIFICATE_FILE_NAME, getCertificate()).also { - saveFileToDownloadsFolder( - context = context, - assetName = CERTIFICATE_FILE_NAME, - assetDataPath = it.toPath().toOkioPath(), - assetDataSize = it.length() - ) - } + createPemFile( + pathname = getCertificateName(), + content = getCertificate() + ) } state.wireModalSheetState.hide() snackbarHostState.showSnackbar(downloadedString) @@ -153,5 +145,3 @@ fun E2eiCertificateDetailsContent( style = textStyle ) } - -const val CERTIFICATE_FILE_NAME = "certificate.pem" diff --git a/app/src/main/kotlin/com/wire/android/ui/settings/devices/e2ei/E2eiCertificateDetailsScreenNavArgs.kt b/app/src/main/kotlin/com/wire/android/ui/settings/devices/e2ei/E2eiCertificateDetailsScreenNavArgs.kt index 340caeb21de..bc764d5e581 100644 --- a/app/src/main/kotlin/com/wire/android/ui/settings/devices/e2ei/E2eiCertificateDetailsScreenNavArgs.kt +++ b/app/src/main/kotlin/com/wire/android/ui/settings/devices/e2ei/E2eiCertificateDetailsScreenNavArgs.kt @@ -18,5 +18,7 @@ package com.wire.android.ui.settings.devices.e2ei data class E2eiCertificateDetailsScreenNavArgs( - val certificateString: String + val certificateString: String, + val isSelfUser: Boolean, + val otherUserName: String? = null ) diff --git a/app/src/main/kotlin/com/wire/android/ui/settings/devices/e2ei/E2eiCertificateDetailsViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/settings/devices/e2ei/E2eiCertificateDetailsViewModel.kt index dbe4c82028a..c81bb2a9b33 100644 --- a/app/src/main/kotlin/com/wire/android/ui/settings/devices/e2ei/E2eiCertificateDetailsViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/settings/devices/e2ei/E2eiCertificateDetailsViewModel.kt @@ -22,14 +22,21 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.wire.android.ui.common.bottomsheet.WireModalSheetState import com.wire.android.ui.navArgs +import com.wire.android.util.fileDateTime +import com.wire.kalium.logic.feature.user.GetSelfUserUseCase +import com.wire.kalium.util.DateTimeUtil import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class E2eiCertificateDetailsViewModel @Inject constructor( savedStateHandle: SavedStateHandle, + private val observerSelfUser: GetSelfUserUseCase, ) : ViewModel() { var state: E2eiCertificateDetailsState by mutableStateOf(E2eiCertificateDetailsState()) @@ -38,7 +45,30 @@ class E2eiCertificateDetailsViewModel @Inject constructor( private val e2eiCertificateDetailsScreenNavArgs: E2eiCertificateDetailsScreenNavArgs = savedStateHandle.navArgs() + private var selfUserName: String? = null + + init { + getSelfUserId() + } + + private fun getSelfUserId() { + viewModelScope.launch { + selfUserName = observerSelfUser().first().name + } + } + fun getCertificate() = e2eiCertificateDetailsScreenNavArgs.certificateString + + fun getCertificateName(): String { + val date = DateTimeUtil.currentInstant().fileDateTime() + val userName = + if (e2eiCertificateDetailsScreenNavArgs.isSelfUser) { + selfUserName + } else { + e2eiCertificateDetailsScreenNavArgs.otherUserName + } + return "wire-certificate-$userName-$date.txt" + } } data class E2eiCertificateDetailsState( diff --git a/app/src/main/kotlin/com/wire/android/ui/settings/devices/model/DeviceDetailsState.kt b/app/src/main/kotlin/com/wire/android/ui/settings/devices/model/DeviceDetailsState.kt index 1ed4b74b9a1..784f0d38fb1 100644 --- a/app/src/main/kotlin/com/wire/android/ui/settings/devices/model/DeviceDetailsState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/settings/devices/model/DeviceDetailsState.kt @@ -20,7 +20,9 @@ package com.wire.android.ui.settings.devices.model import com.wire.android.ui.authentication.devices.model.Device import com.wire.android.ui.authentication.devices.remove.RemoveDeviceDialogState import com.wire.android.ui.authentication.devices.remove.RemoveDeviceError +import com.wire.kalium.logic.feature.e2ei.CertificateStatus import com.wire.kalium.logic.feature.e2ei.E2eiCertificate +import kotlinx.datetime.Instant data class DeviceDetailsState( val device: Device = Device(), @@ -31,9 +33,16 @@ data class DeviceDetailsState( val isSelfClient: Boolean = false, val userName: String? = null, val isE2eiCertificateActivated: Boolean = false, - val e2eiCertificate: E2eiCertificate = E2eiCertificate(), + val e2eiCertificate: E2eiCertificate = E2eiCertificate( + status = CertificateStatus.EXPIRED, + serialNumber = "", + certificateDetail = "", + endAt = Instant.DISTANT_FUTURE + ), val canBeRemoved: Boolean = false, val isLoadingCertificate: Boolean = false, val isE2EICertificateEnrollSuccess: Boolean = false, val isE2EICertificateEnrollError: Boolean = false, + val isE2EIEnabled: Boolean = false, + val startGettingE2EICertificate: Boolean = false ) diff --git a/app/src/main/kotlin/com/wire/android/ui/settings/devices/model/SelfDevicesState.kt b/app/src/main/kotlin/com/wire/android/ui/settings/devices/model/SelfDevicesState.kt index 2b88e2f5a8c..26d12dd815c 100644 --- a/app/src/main/kotlin/com/wire/android/ui/settings/devices/model/SelfDevicesState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/settings/devices/model/SelfDevicesState.kt @@ -23,5 +23,6 @@ import com.wire.android.ui.authentication.devices.model.Device data class SelfDevicesState ( val currentDevice: Device?, val deviceList: List, - val isLoadingClientsList: Boolean + val isLoadingClientsList: Boolean, + val isE2EIEnabled: Boolean = false ) diff --git a/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaAuthenticatedViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaAuthenticatedViewModel.kt index 3d6b03389fc..0df4866b6be 100644 --- a/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaAuthenticatedViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaAuthenticatedViewModel.kt @@ -445,7 +445,6 @@ class ImportMediaAuthenticatedViewModel @Inject constructor( size = fileMetadata.sizeInBytes, mimeType = mimeType, dataPath = tempAssetPath, - dataUri = uri, key = assetKey, width = imgWidth, height = imgHeight, @@ -460,7 +459,6 @@ class ImportMediaAuthenticatedViewModel @Inject constructor( size = fileMetadata.sizeInBytes, mimeType = mimeType, dataPath = tempAssetPath, - dataUri = uri, key = assetKey ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/sharing/ImportedMediaTypes.kt b/app/src/main/kotlin/com/wire/android/ui/sharing/ImportedMediaTypes.kt index 5d1fd1880dd..1bf55d4e388 100644 --- a/app/src/main/kotlin/com/wire/android/ui/sharing/ImportedMediaTypes.kt +++ b/app/src/main/kotlin/com/wire/android/ui/sharing/ImportedMediaTypes.kt @@ -17,7 +17,6 @@ */ package com.wire.android.ui.sharing -import android.net.Uri import androidx.compose.runtime.Composable import com.wire.android.model.Clickable import com.wire.android.model.ImageAsset @@ -70,7 +69,6 @@ sealed class ImportedMediaAsset( open val size: Long, open val mimeType: String, open val dataPath: Path, - open val dataUri: Uri, open val key: String ) { class GenericAsset( @@ -78,9 +76,8 @@ sealed class ImportedMediaAsset( override val size: Long, override val mimeType: String, override val dataPath: Path, - override val dataUri: Uri, override val key: String - ) : ImportedMediaAsset(name, size, mimeType, dataPath, dataUri, key) + ) : ImportedMediaAsset(name, size, mimeType, dataPath, key) class Image( val width: Int, @@ -89,10 +86,9 @@ sealed class ImportedMediaAsset( override val size: Long, override val mimeType: String, override val dataPath: Path, - override val dataUri: Uri, override val key: String, val wireSessionImageLoader: WireSessionImageLoader - ) : ImportedMediaAsset(name, size, mimeType, dataPath, dataUri, key) { - val localImageAsset = ImageAsset.LocalImageAsset(wireSessionImageLoader, dataUri, key) + ) : ImportedMediaAsset(name, size, mimeType, dataPath, key) { + val localImageAsset = ImageAsset.LocalImageAsset(wireSessionImageLoader, dataPath, key) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/theme/WireColorPalette.kt b/app/src/main/kotlin/com/wire/android/ui/theme/WireColorPalette.kt index e34148bb19c..4fb5a1af31a 100644 --- a/app/src/main/kotlin/com/wire/android/ui/theme/WireColorPalette.kt +++ b/app/src/main/kotlin/com/wire/android/ui/theme/WireColorPalette.kt @@ -134,25 +134,25 @@ object WireColorPalette { val LightRed900 = Color(0xFF3A0006) @Stable - val LightYellow50 = Color(0xFFF3F0ED) + val LightAmber50 = Color(0xFFF3F0ED) @Stable - val LightYellow100 = Color(0xFFE5E0DA) + val LightAmber100 = Color(0xFFE5E0DA) @Stable - val LightYellow200 = Color(0xFFCCC1B5) + val LightAmber200 = Color(0xFFCCC1B5) @Stable - val LightYellow300 = Color(0xFFB2A38F) + val LightAmber300 = Color(0xFFB2A38F) @Stable - val LightYellow400 = Color(0xFF99846A) + val LightAmber400 = Color(0xFF99846A) @Stable - val LightYellow500 = Color(0xFF7F6545) + val LightAmber500 = Color(0xFF7F6545) @Stable - val LightYellow600 = Color(0xFF665137) + val LightAmber600 = Color(0xFF665137) @Stable - val LightYellow700 = Color(0xFF4C3D29) + val LightAmber700 = Color(0xFF4C3D29) @Stable - val LightYellow800 = Color(0xFF4C3D29) + val LightAmber800 = Color(0xFF4C3D29) @Stable - val LightYellow900 = Color(0xFF261E15) + val LightAmber900 = Color(0xFF261E15) @Stable val DarkBlue50 = Color(0xFFEEF7FF) @@ -261,25 +261,25 @@ object WireColorPalette { val DarkRed900 = Color(0xFF4D2422) @Stable - val DarkYellow50 = Color(0xFFFFFBEA) + val DarkAmber50 = Color(0xFFFFFBEA) @Stable - val DarkYellow100 = Color(0xFFFFF6D4) + val DarkAmber100 = Color(0xFFFFF6D4) @Stable - val DarkYellow200 = Color(0xFFFFEEA8) + val DarkAmber200 = Color(0xFFFFEEA8) @Stable - val DarkYellow300 = Color(0xFFFFE57D) + val DarkAmber300 = Color(0xFFFFE57D) @Stable - val DarkYellow400 = Color(0xFFFFDD51) + val DarkAmber400 = Color(0xFFFFDD51) @Stable - val DarkYellow500 = Color(0xFFFFD426) + val DarkAmber500 = Color(0xFFFFD426) @Stable - val DarkYellow600 = Color(0xFFCCAA1E) + val DarkAmber600 = Color(0xFFCCAA1E) @Stable - val DarkYellow700 = Color(0xFF997F17) + val DarkAmber700 = Color(0xFF997F17) @Stable - val DarkYellow800 = Color(0xFF66550F) + val DarkAmber800 = Color(0xFF66550F) @Stable - val DarkYellow900 = Color(0xFF4D400B) + val DarkAmber900 = Color(0xFF4D400B) @Stable val Gray10 = Color(0xFFFAFAFA) diff --git a/app/src/main/kotlin/com/wire/android/ui/theme/WireColorScheme.kt b/app/src/main/kotlin/com/wire/android/ui/theme/WireColorScheme.kt index 10702cfb0ba..dc48a7bb7f5 100644 --- a/app/src/main/kotlin/com/wire/android/ui/theme/WireColorScheme.kt +++ b/app/src/main/kotlin/com/wire/android/ui/theme/WireColorScheme.kt @@ -66,7 +66,7 @@ data class WireColorScheme( val scrim: Color, val labelText: Color, val badge: Color, val onBadge: Color, - val highLight: Color, + val highlight: Color, val onHighlight: Color, val uncheckedColor: Color, val disabledCheckedColor: Color, val disabledIndeterminateColor: Color, @@ -102,7 +102,6 @@ data class WireColorScheme( val onScrollToBottomButtonColor: Color, val validE2eiStatusColor: Color, val mlsVerificationTextColor: Color, - val selectedMessageHighlightColor: Color ) { fun toColorScheme(): ColorScheme = ColorScheme( primary = primary, @@ -139,7 +138,7 @@ private val LightWireColorScheme = WireColorScheme( primaryVariant = WireColorPalette.LightBlue50, onPrimaryVariant = WireColorPalette.LightBlue500, error = WireColorPalette.LightRed500, onError = Color.White, errorOutline = WireColorPalette.LightRed200, - warning = WireColorPalette.LightYellow500, onWarning = Color.White, + warning = WireColorPalette.LightAmber500, onWarning = Color.White, positive = WireColorPalette.LightGreen500, onPositive = Color.White, background = WireColorPalette.Gray20, onBackground = Color.Black, backgroundVariant = WireColorPalette.Gray10, onBackgroundVariant = Color.Black, @@ -172,7 +171,7 @@ private val LightWireColorScheme = WireColorScheme( scrim = WireColorPalette.BlackAlpha55, labelText = WireColorPalette.Gray80, badge = WireColorPalette.Gray90, onBadge = Color.White, - highLight = WireColorPalette.DarkYellow300, + highlight = WireColorPalette.DarkAmber200, onHighlight = Color.Black, uncheckedColor = WireColorPalette.Gray80, disabledCheckedColor = WireColorPalette.Gray80, disabledIndeterminateColor = WireColorPalette.Gray80, @@ -197,9 +196,9 @@ private val LightWireColorScheme = WireColorScheme( WireColorPalette.LightPurple500, WireColorPalette.LightPurple700, // Yellow - Amber - WireColorPalette.LightYellow300, - WireColorPalette.LightYellow500, - WireColorPalette.LightYellow700, + WireColorPalette.LightAmber300, + WireColorPalette.LightAmber500, + WireColorPalette.LightAmber700, // Petrol WireColorPalette.LightPetrol300, WireColorPalette.LightPetrol500, @@ -237,7 +236,6 @@ private val LightWireColorScheme = WireColorScheme( onScrollToBottomButtonColor = Color.White, validE2eiStatusColor = WireColorPalette.LightGreen550, mlsVerificationTextColor = WireColorPalette.DarkGreen700, - selectedMessageHighlightColor = WireColorPalette.DarkBlue50 ) // Dark WireColorScheme @@ -248,7 +246,7 @@ private val DarkWireColorScheme = WireColorScheme( primaryVariant = WireColorPalette.DarkBlue800, onPrimaryVariant = WireColorPalette.DarkBlue300, error = WireColorPalette.DarkRed500, onError = Color.Black, errorOutline = WireColorPalette.DarkRed800, - warning = WireColorPalette.DarkYellow500, onWarning = Color.Black, + warning = WireColorPalette.DarkAmber500, onWarning = Color.Black, positive = WireColorPalette.DarkGreen500, onPositive = Color.Black, background = WireColorPalette.Gray100, onBackground = Color.White, backgroundVariant = WireColorPalette.Gray95, onBackgroundVariant = Color.White, @@ -281,7 +279,7 @@ private val DarkWireColorScheme = WireColorScheme( scrim = WireColorPalette.BlackAlpha55, labelText = WireColorPalette.Gray30, badge = WireColorPalette.Gray10, onBadge = Color.Black, - highLight = WireColorPalette.DarkYellow300, + highlight = WireColorPalette.DarkAmber300, onHighlight = Color.Black, uncheckedColor = WireColorPalette.Gray60, disabledCheckedColor = WireColorPalette.Gray80, disabledIndeterminateColor = WireColorPalette.Gray80, @@ -306,9 +304,9 @@ private val DarkWireColorScheme = WireColorScheme( WireColorPalette.DarkPurple500, WireColorPalette.DarkPurple700, // Yellow - Amber - WireColorPalette.DarkYellow300, - WireColorPalette.DarkYellow500, - WireColorPalette.DarkYellow700, + WireColorPalette.DarkAmber300, + WireColorPalette.DarkAmber500, + WireColorPalette.DarkAmber700, // Petrol WireColorPalette.DarkPetrol300, WireColorPalette.DarkPetrol500, @@ -340,13 +338,12 @@ private val DarkWireColorScheme = WireColorScheme( classifiedBannerForegroundColor = WireColorPalette.DarkGreen500, unclassifiedBannerBackgroundColor = WireColorPalette.DarkRed500, unclassifiedBannerForegroundColor = Color.Black, - recordAudioStartColor = WireColorPalette.LightBlue500, - recordAudioStopColor = WireColorPalette.LightRed500, + recordAudioStartColor = WireColorPalette.DarkBlue500, + recordAudioStopColor = WireColorPalette.DarkRed500, scrollToBottomButtonColor = WireColorPalette.Gray60, onScrollToBottomButtonColor = Color.Black, validE2eiStatusColor = WireColorPalette.DarkGreen500, mlsVerificationTextColor = WireColorPalette.DarkGreen700, - selectedMessageHighlightColor = WireColorPalette.DarkBlue50 ) @PackagePrivate diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/avatarpicker/AvatarPickerViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/avatarpicker/AvatarPickerViewModel.kt index 16eab63472e..8bdc8879867 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/avatarpicker/AvatarPickerViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/avatarpicker/AvatarPickerViewModel.kt @@ -67,6 +67,8 @@ class AvatarPickerViewModel @Inject constructor( var pictureState by mutableStateOf(PictureState.Empty) private set + private var initialPictureLoadingState by mutableStateOf(InitialPictureLoadingState.None) + private val _infoMessage = MutableSharedFlow() val infoMessage = _infoMessage.asSharedFlow() @@ -75,24 +77,27 @@ class AvatarPickerViewModel @Inject constructor( val temporaryAvatarUri: Uri = avatarImageManager.getShareableTempAvatarUri(defaultAvatarPath) - private lateinit var currentAvatarUri: Uri - init { loadInitialAvatarState() } + @Suppress("TooGenericExceptionCaught") fun loadInitialAvatarState() { viewModelScope.launch { + initialPictureLoadingState = InitialPictureLoadingState.Loading try { dataStore.avatarAssetId.first()?.apply { val qualifiedAsset = qualifiedIdMapper.fromStringToQualifiedID(this) val avatarRawPath = (getAvatarAsset(assetKey = qualifiedAsset) as PublicAssetResult.Success).assetPath - currentAvatarUri = avatarImageManager.getWritableAvatarUri(avatarRawPath) - - pictureState = PictureState.Initial(currentAvatarUri) + val currentAvatarUri = avatarImageManager.getWritableAvatarUri(avatarRawPath) + initialPictureLoadingState = InitialPictureLoadingState.Loaded(currentAvatarUri) + if (pictureState is PictureState.Empty) { + pictureState = PictureState.Initial(currentAvatarUri) + } } - } catch (e: ClassCastException) { + } catch (e: Exception) { appLogger.e("There was an error loading the user avatar", e) + initialPictureLoadingState = InitialPictureLoadingState.None } } } @@ -109,7 +114,6 @@ class AvatarPickerViewModel @Inject constructor( val avatarPath = defaultAvatarPath val imageDataSize = imgUri.toByteArray(appContext, dispatchers).size.toLong() - when (val result = uploadUserAvatar(avatarPath, imageDataSize)) { is UploadAvatarResult.Success -> { dataStore.updateUserAvatarAssetId(result.userAssetId.toString()) @@ -120,7 +124,12 @@ class AvatarPickerViewModel @Inject constructor( is NetworkFailure.NoNetworkConnection -> showInfoMessage(InfoMessageType.NoNetworkError) else -> showInfoMessage(InfoMessageType.UploadAvatarError) } - pictureState = PictureState.Initial(currentAvatarUri) + with(initialPictureLoadingState) { + pictureState = when (this) { + is InitialPictureLoadingState.Loaded -> PictureState.Initial(avatarUri) + else -> PictureState.Empty + } + } } } } @@ -130,16 +139,23 @@ class AvatarPickerViewModel @Inject constructor( _infoMessage.emit(type.uiText) } + @Stable + private sealed class InitialPictureLoadingState { + data object None : InitialPictureLoadingState() + data object Loading : InitialPictureLoadingState() + data class Loaded(val avatarUri: Uri) : InitialPictureLoadingState() + } + @Stable sealed class PictureState(open val avatarUri: Uri) { data class Uploading(override val avatarUri: Uri) : PictureState(avatarUri) data class Initial(override val avatarUri: Uri) : PictureState(avatarUri) data class Picked(override val avatarUri: Uri) : PictureState(avatarUri) - object Empty : PictureState("".toUri()) + data object Empty : PictureState("".toUri()) } sealed class InfoMessageType(override val uiText: UIText) : SnackBarMessage { - object UploadAvatarError : InfoMessageType(UIText.StringResource(R.string.error_uploading_user_avatar)) - object NoNetworkError : InfoMessageType(UIText.StringResource(R.string.error_no_network_message)) + data object UploadAvatarError : InfoMessageType(UIText.StringResource(R.string.error_uploading_user_avatar)) + data object NoNetworkError : InfoMessageType(UIText.StringResource(R.string.error_no_network_message)) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserDevicesScreen.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserDevicesScreen.kt index 36f7800537b..ffe69c37d60 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserDevicesScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserDevicesScreen.kt @@ -37,7 +37,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp import com.wire.android.BuildConfig import com.wire.android.R import com.wire.android.ui.authentication.devices.DeviceItem @@ -51,6 +50,7 @@ import com.wire.android.ui.theme.wireTypography import com.wire.android.util.CustomTabsHelper import com.wire.android.util.ui.LinkText import com.wire.android.util.ui.LinkTextData +import com.wire.kalium.logic.feature.e2ei.CertificateStatus @Composable fun OtherUserDevicesScreen( @@ -121,10 +121,10 @@ private fun OtherUserDevicesContent( placeholder = false, background = MaterialTheme.wireColorScheme.surface, isWholeItemClickable = true, - onRemoveDeviceClick = onDeviceClick, - leadingIcon = Icons.Filled.ChevronRight.Icon(), - leadingIconBorder = 0.dp, - shouldShowVerifyLabel = true + onClickAction = onDeviceClick, + icon = Icons.Filled.ChevronRight.Icon(), + shouldShowVerifyLabel = true, + shouldShowE2EIInfo = item.e2eiCertificateStatus == CertificateStatus.VALID ) if (index < otherUserDevices.lastIndex) WireDivider() } diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileScreen.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileScreen.kt index e7dd1482b01..17a47102684 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileScreen.kt @@ -531,7 +531,9 @@ private fun ContentFooter( ConnectionActionButton( state.userId, state.userName, + state.fullName, state.connectionState, + state.isConversationStarted, onIgnoreConnectionRequest, onOpenConversation ) diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileScreenViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileScreenViewModel.kt index 654aa596a3b..5a990572c02 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileScreenViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileScreenViewModel.kt @@ -63,6 +63,7 @@ import com.wire.kalium.logic.feature.conversation.ArchiveStatusUpdateResult import com.wire.kalium.logic.feature.conversation.ClearConversationContentUseCase import com.wire.kalium.logic.feature.conversation.ConversationUpdateStatusResult import com.wire.kalium.logic.feature.conversation.GetOneToOneConversationUseCase +import com.wire.kalium.logic.feature.conversation.IsOneToOneConversationCreatedUseCase import com.wire.kalium.logic.feature.conversation.RemoveMemberFromConversationUseCase import com.wire.kalium.logic.feature.conversation.UpdateConversationArchivedStatusUseCase import com.wire.kalium.logic.feature.conversation.UpdateConversationMemberRoleResult @@ -107,6 +108,7 @@ class OtherUserProfileScreenViewModel @Inject constructor( private val updateConversationArchivedStatus: UpdateConversationArchivedStatusUseCase, private val getUserE2eiCertificateStatus: GetUserE2eiCertificateStatusUseCase, private val getUserE2eiCertificates: GetUserE2eiCertificatesUseCase, + private val isOneToOneConversationCreated: IsOneToOneConversationCreatedUseCase, savedStateHandle: SavedStateHandle ) : ViewModel(), OtherUserProfileEventsHandler, OtherUserProfileBottomSheetEventsHandler { @@ -134,6 +136,13 @@ class OtherUserProfileScreenViewModel @Inject constructor( observeUserInfoAndUpdateViewState() persistClients() getMLSVerificationStatus() + getIfConversationExist() + } + + private fun getIfConversationExist() { + viewModelScope.launch { + state = state.copy(isConversationStarted = isOneToOneConversationCreated(userId)) + } } private fun getMLSVerificationStatus() { diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileState.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileState.kt index a8b751828d3..5c0f958d69e 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileState.kt @@ -49,7 +49,8 @@ data class OtherUserProfileState( val otherUserDevices: List = listOf(), val blockingState: BlockingState = BlockingState.CAN_NOT_BE_BLOCKED, val isProteusVerified: Boolean = false, - val isMLSVerified: Boolean = false + val isMLSVerified: Boolean = false, + val isConversationStarted: Boolean = false ) { fun updateMuteStatus(status: MutedConversationStatus): OtherUserProfileState { return conversationSheetContent?.let { diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/self/SelfUserProfileScreen.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/self/SelfUserProfileScreen.kt index a7b3c290e0b..ac3309f019d 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/self/SelfUserProfileScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/self/SelfUserProfileScreen.kt @@ -205,8 +205,7 @@ private fun SelfUserProfileContent( userName = userName, teamName = teamName, onUserProfileClick = onChangeUserProfilePicture, - editableState = if (state.isReadOnlyAccount) EditableState.NotEditable - else EditableState.IsEditable(onEditClick) + editableState = EditableState.IsEditable(onEditClick) ) } if (!state.teamName.isNullOrBlank()) { diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/self/SelfUserProfileViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/self/SelfUserProfileViewModel.kt index 049607eaa6f..d8320635526 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/self/SelfUserProfileViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/self/SelfUserProfileViewModel.kt @@ -218,7 +218,6 @@ class SelfUserProfileViewModel @Inject constructor( } notificationManager.stopObservingOnLogout(selfUserId) - notificationChannelsManager.deleteChannelGroup(selfUserId) accountSwitch(SwitchAccountParam.TryToSwitchToNextAccount).also { if (it == SwitchAccountResult.NoOtherAccountToSwitch) { globalDataStore.clearAppLockPasscode() diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/self/dialog/LogoutOptionsDialog.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/self/dialog/LogoutOptionsDialog.kt index f1f9611887a..221b012bcb6 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/self/dialog/LogoutOptionsDialog.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/self/dialog/LogoutOptionsDialog.kt @@ -41,7 +41,8 @@ import com.wire.android.ui.common.visbility.VisibilityState @Composable fun LogoutOptionsDialog( dialogState: VisibilityState, - logout: (Boolean) -> Unit + logout: (Boolean) -> Unit, + checkboxEnabled: Boolean = true ) { VisibilityState(dialogState) { state -> WireDialog( @@ -61,15 +62,16 @@ fun LogoutOptionsDialog( ) ) { WireLabelledCheckbox( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = dimensions().spacing16x) + .clip(RoundedCornerShape(size = dimensions().spacing4x)), label = stringResource(R.string.dialog_logout_wipe_data_checkbox), checked = state.shouldWipeData, onCheckClicked = remember { { dialogState.show(state.copy(shouldWipeData = it)) } }, horizontalArrangement = Arrangement.Center, contentPadding = PaddingValues(vertical = dimensions().spacing4x), - modifier = Modifier - .fillMaxWidth() - .padding(bottom = dimensions().spacing16x) - .clip(RoundedCornerShape(size = dimensions().spacing4x)) + checkboxEnabled = checkboxEnabled ) } } diff --git a/app/src/main/kotlin/com/wire/android/util/CoreFailureUtil.kt b/app/src/main/kotlin/com/wire/android/util/CoreFailureUtil.kt index 85717698ea0..ac3f60ae358 100644 --- a/app/src/main/kotlin/com/wire/android/util/CoreFailureUtil.kt +++ b/app/src/main/kotlin/com/wire/android/util/CoreFailureUtil.kt @@ -48,5 +48,6 @@ fun CoreFailure.uiText(): UIText = when (this) { else -> UIText.StringResource(R.string.error_unknown_message) } -data class DialogErrorStrings(val title: String, val message: String) -data class DialogAnnotatedErrorStrings(val title: String, val annotatedMessage: AnnotatedString) +data class DialogErrorStrings(val title: String, val annotatedMessage: AnnotatedString) { + constructor(title: String, message: String) : this(title, AnnotatedString(message)) +} diff --git a/app/src/main/kotlin/com/wire/android/util/CurrentScreenManager.kt b/app/src/main/kotlin/com/wire/android/util/CurrentScreenManager.kt index 542e38e0fcd..3161efdb414 100644 --- a/app/src/main/kotlin/com/wire/android/util/CurrentScreenManager.kt +++ b/app/src/main/kotlin/com/wire/android/util/CurrentScreenManager.kt @@ -34,6 +34,8 @@ import com.wire.android.ui.destinations.CreateAccountSummaryScreenDestination import com.wire.android.ui.destinations.CreatePersonalAccountOverviewScreenDestination import com.wire.android.ui.destinations.CreateTeamAccountOverviewScreenDestination import com.wire.android.ui.destinations.Destination +import com.wire.android.ui.destinations.E2EIEnrollmentScreenDestination +import com.wire.android.ui.destinations.E2eiCertificateDetailsScreenDestination import com.wire.android.ui.destinations.HomeScreenDestination import com.wire.android.ui.destinations.ImportMediaScreenDestination import com.wire.android.ui.destinations.IncomingCallScreenDestination @@ -43,6 +45,7 @@ import com.wire.android.ui.destinations.LoginScreenDestination import com.wire.android.ui.destinations.MigrationScreenDestination import com.wire.android.ui.destinations.OngoingCallScreenDestination import com.wire.android.ui.destinations.OtherUserProfileScreenDestination +import com.wire.android.ui.destinations.RegisterDeviceScreenDestination import com.wire.android.ui.destinations.RemoveDeviceScreenDestination import com.wire.android.ui.destinations.SelfDevicesScreenDestination import com.wire.android.ui.destinations.WelcomeScreenDestination @@ -213,6 +216,9 @@ sealed class CurrentScreen { is CreateAccountSummaryScreenDestination, is MigrationScreenDestination, is InitialSyncScreenDestination, + is E2EIEnrollmentScreenDestination, + is E2eiCertificateDetailsScreenDestination, + is RegisterDeviceScreenDestination, is RemoveDeviceScreenDestination -> AuthRelated else -> SomeOther diff --git a/app/src/main/kotlin/com/wire/android/util/DateTimeUtil.kt b/app/src/main/kotlin/com/wire/android/util/DateTimeUtil.kt index a642e6635b7..343346d88da 100644 --- a/app/src/main/kotlin/com/wire/android/util/DateTimeUtil.kt +++ b/app/src/main/kotlin/com/wire/android/util/DateTimeUtil.kt @@ -34,6 +34,8 @@ private val serverDateTimeFormat = SimpleDateFormat( ).apply { timeZone = TimeZone.getTimeZone("UTC") } private val mediumDateTimeFormat = DateFormat .getDateTimeInstance(DateFormat.MEDIUM, DateFormat.MEDIUM) +private val longDateShortTimeFormat = DateFormat + .getDateTimeInstance(DateFormat.LONG, DateFormat.SHORT) private val mediumOnlyDateTimeFormat = DateFormat .getDateInstance(DateFormat.MEDIUM) private val messageTimeFormatter = DateFormat @@ -48,7 +50,7 @@ private val readReceiptDateTimeFormat = SimpleDateFormat( Locale.getDefault() ).apply { timeZone = TimeZone.getDefault() } -private val audioFileDateTimeFormat = SimpleDateFormat( +private val fileDateTimeFormat = SimpleDateFormat( "yyyy-MM-dd-hh-mm-ss", Locale.getDefault() ).apply { timeZone = TimeZone.getDefault() } @@ -61,6 +63,13 @@ fun String.formatMediumDateTime(): String? = null } +fun String.deviceDateTimeFormat(): String? = + try { + this.serverDate()?.let { longDateShortTimeFormat.format(it) } + } catch (e: ParseException) { + null + } + fun String.formatFullDateShortTime(): String? = try { this.serverDate()?.let { fullDateShortTimeFormatter.format(it) } @@ -87,7 +96,7 @@ fun Date.toMediumOnlyDateTime(): String = mediumOnlyDateTimeFormat.format(this) fun Instant.uiReadReceiptDateTime(): String = readReceiptDateTimeFormat.format(Date(this.toEpochMilliseconds())) -fun Instant.audioFileDateTime(): String = audioFileDateTimeFormat +fun Instant.fileDateTime(): String = fileDateTimeFormat .format(Date(this.toEpochMilliseconds())) fun getCurrentParsedDateTime(): String = mediumDateTimeFormat.format(System.currentTimeMillis()) diff --git a/app/src/main/kotlin/com/wire/android/util/FileUtil.kt b/app/src/main/kotlin/com/wire/android/util/FileUtil.kt index cc24748afff..4a66f7c3b2e 100644 --- a/app/src/main/kotlin/com/wire/android/util/FileUtil.kt +++ b/app/src/main/kotlin/com/wire/android/util/FileUtil.kt @@ -58,6 +58,7 @@ import com.wire.kalium.logic.util.buildFileName import com.wire.kalium.logic.util.splitFileExtensionAndCopyCounter import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json import okio.Path import java.io.File import java.io.FileNotFoundException @@ -421,6 +422,14 @@ fun Context.getGitBuildId(): String = runCatching { } }.getOrDefault("") +suspend fun Context.getDependenciesVersion(): Map = withContext(Dispatchers.IO) { + assets.open("dependencies_version.json").use { inputStream -> + inputStream.bufferedReader().use { it.readText() } + }.let { + Json.decodeFromString(it) + } +} + fun Context.getProviderAuthority() = "$packageName.provider" @VisibleForTesting diff --git a/app/src/main/kotlin/com/wire/android/util/UriUtil.kt b/app/src/main/kotlin/com/wire/android/util/UriUtil.kt index ca24a259712..4443a6de455 100644 --- a/app/src/main/kotlin/com/wire/android/util/UriUtil.kt +++ b/app/src/main/kotlin/com/wire/android/util/UriUtil.kt @@ -61,3 +61,23 @@ fun sanitizeUrl(url: String): String { return url // Return the original URL if any errors occur } } + +fun URI.removeQueryParams(): URI { + val regex = Regex("[?&][^=]+=[^&]*") + return URI(this.toString().replace(regex, "")) +} + +@Suppress("TooGenericExceptionCaught") +fun URI.findParameterValue(parameterName: String): String? { + return try { + rawQuery.split('&').map { + val parts = it.split('=') + val name = parts.firstOrNull() ?: "" + val value = parts.drop(1).firstOrNull() ?: "" + Pair(name, value) + }.firstOrNull { it.first == parameterName }?.second + } catch (e: NullPointerException) { + appLogger.w("Error finding parameter value: $parameterName", e) + null + } +} diff --git a/app/src/main/kotlin/com/wire/android/util/extension/Context.kt b/app/src/main/kotlin/com/wire/android/util/extension/Context.kt index 3d643d89098..67149ae0f4e 100644 --- a/app/src/main/kotlin/com/wire/android/util/extension/Context.kt +++ b/app/src/main/kotlin/com/wire/android/util/extension/Context.kt @@ -23,19 +23,12 @@ import android.content.ContextWrapper import android.content.pm.PackageManager import androidx.appcompat.app.AppCompatActivity import androidx.core.content.ContextCompat -import com.google.android.gms.common.ConnectionResult -import com.google.android.gms.common.GoogleApiAvailability fun Context.checkPermission(permission: String): Boolean { return ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED } -fun Context.isGoogleServicesAvailable(): Boolean { - val status = GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(this) - return status == ConnectionResult.SUCCESS -} - fun Context.getActivity(): AppCompatActivity? = when (this) { is AppCompatActivity -> this is ContextWrapper -> baseContext.getActivity() diff --git a/app/src/main/kotlin/com/wire/android/util/ui/AssetImageFetcher.kt b/app/src/main/kotlin/com/wire/android/util/ui/AssetImageFetcher.kt index faf1f250e67..505f614073b 100644 --- a/app/src/main/kotlin/com/wire/android/util/ui/AssetImageFetcher.kt +++ b/app/src/main/kotlin/com/wire/android/util/ui/AssetImageFetcher.kt @@ -18,15 +18,11 @@ package com.wire.android.util.ui -import android.content.Context import coil.ImageLoader -import coil.decode.DataSource -import coil.fetch.DrawableResult import coil.fetch.FetchResult import coil.fetch.Fetcher import coil.request.Options import com.wire.android.model.ImageAsset -import com.wire.android.util.toDrawable import com.wire.kalium.logic.CoreFailure import com.wire.kalium.logic.NetworkFailure import com.wire.kalium.logic.feature.asset.DeleteAssetUseCase @@ -36,7 +32,6 @@ import com.wire.kalium.logic.feature.asset.MessageAssetResult import com.wire.kalium.logic.feature.asset.PublicAssetResult internal class AssetImageFetcher( - private val context: Context, private val assetFetcherParameters: AssetFetcherParameters, private val getPublicAsset: GetAvatarAssetUseCase, private val getPrivateAsset: GetMessageAssetUseCase, @@ -79,15 +74,7 @@ internal class AssetImageFetcher( } } - is ImageAsset.LocalImageAsset -> { - data.dataUri.toDrawable(context)?.let { - DrawableResult( - drawable = it, - isSampled = true, - dataSource = DataSource.DISK - ) - } - } + is ImageAsset.LocalImageAsset -> drawableResultWrapper.toFetchResult(data.dataPath) } } } @@ -103,7 +90,6 @@ internal class AssetImageFetcher( private val getPrivateAssetUseCase: GetMessageAssetUseCase, private val deleteAssetUseCase: DeleteAssetUseCase, private val drawableResultWrapper: DrawableResultWrapper, - private val context: Context ) : Fetcher.Factory { override fun create( data: ImageAsset, @@ -115,7 +101,6 @@ internal class AssetImageFetcher( getPrivateAsset = getPrivateAssetUseCase, deleteAsset = deleteAssetUseCase, drawableResultWrapper = drawableResultWrapper, - context = context ) } } diff --git a/app/src/main/kotlin/com/wire/android/util/ui/WireSessionImageLoader.kt b/app/src/main/kotlin/com/wire/android/util/ui/WireSessionImageLoader.kt index 2c66526d8ea..a78af9835f3 100644 --- a/app/src/main/kotlin/com/wire/android/util/ui/WireSessionImageLoader.kt +++ b/app/src/main/kotlin/com/wire/android/util/ui/WireSessionImageLoader.kt @@ -136,7 +136,6 @@ class WireSessionImageLoader( getPrivateAssetUseCase = getPrivateAsset, deleteAssetUseCase = deleteAsset, drawableResultWrapper = DrawableResultWrapper(resources), - context = context ) ) if (SDK_INT >= 28) { diff --git a/app/src/main/kotlin/com/wire/android/workmanager/WireWorkerFactory.kt b/app/src/main/kotlin/com/wire/android/workmanager/WireWorkerFactory.kt index e7b64e6f328..e763092fcf0 100644 --- a/app/src/main/kotlin/com/wire/android/workmanager/WireWorkerFactory.kt +++ b/app/src/main/kotlin/com/wire/android/workmanager/WireWorkerFactory.kt @@ -23,11 +23,13 @@ import androidx.work.ListenableWorker import androidx.work.WorkerFactory import androidx.work.WorkerParameters import com.wire.android.di.KaliumCoreLogic +import com.wire.android.feature.StartPersistentWebsocketIfNecessaryUseCase import com.wire.android.migration.MigrationManager import com.wire.android.notification.NotificationChannelsManager import com.wire.android.notification.WireNotificationManager import com.wire.android.workmanager.worker.MigrationWorker import com.wire.android.workmanager.worker.NotificationFetchWorker +import com.wire.android.workmanager.worker.PersistentWebsocketCheckWorker import com.wire.android.workmanager.worker.SingleUserMigrationWorker import com.wire.kalium.logic.CoreLogic import com.wire.kalium.logic.sync.WrapperWorker @@ -38,6 +40,7 @@ class WireWorkerFactory @Inject constructor( private val wireNotificationManager: WireNotificationManager, private val notificationChannelsManager: NotificationChannelsManager, private val migrationManager: MigrationManager, + private val startPersistentWebsocketIfNecessary: StartPersistentWebsocketIfNecessaryUseCase, @KaliumCoreLogic private val coreLogic: CoreLogic ) : WorkerFactory() { @@ -47,12 +50,19 @@ class WireWorkerFactory @Inject constructor( WrapperWorker::class.java.canonicalName -> WrapperWorkerFactory(coreLogic, WireForegroundNotificationDetailsProvider) .createWorker(appContext, workerClassName, workerParameters) + NotificationFetchWorker::class.java.canonicalName -> NotificationFetchWorker(appContext, workerParameters, wireNotificationManager, notificationChannelsManager) + MigrationWorker::class.java.canonicalName -> MigrationWorker(appContext, workerParameters, migrationManager, notificationChannelsManager) + SingleUserMigrationWorker::class.java.canonicalName -> SingleUserMigrationWorker(appContext, workerParameters, migrationManager, notificationChannelsManager) + + PersistentWebsocketCheckWorker::class.java.canonicalName -> + PersistentWebsocketCheckWorker(appContext, workerParameters, startPersistentWebsocketIfNecessary) + else -> null } } diff --git a/app/src/main/kotlin/com/wire/android/workmanager/worker/PersistentWebsocketCheckWorker.kt b/app/src/main/kotlin/com/wire/android/workmanager/worker/PersistentWebsocketCheckWorker.kt new file mode 100644 index 00000000000..b3d3be9a2ef --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/workmanager/worker/PersistentWebsocketCheckWorker.kt @@ -0,0 +1,74 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +@file:Suppress("StringTemplate") + +package com.wire.android.workmanager.worker + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import com.wire.android.appLogger +import com.wire.android.feature.StartPersistentWebsocketIfNecessaryUseCase +import com.wire.android.workmanager.worker.PersistentWebsocketCheckWorker.Companion.NAME +import com.wire.android.workmanager.worker.PersistentWebsocketCheckWorker.Companion.TAG +import com.wire.android.workmanager.worker.PersistentWebsocketCheckWorker.Companion.WORK_INTERVAL +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import kotlinx.coroutines.coroutineScope +import kotlin.time.Duration.Companion.hours +import kotlin.time.toJavaDuration + +@HiltWorker +class PersistentWebsocketCheckWorker +@AssistedInject constructor( + @Assisted private val appContext: Context, + @Assisted private val workerParams: WorkerParameters, + private val startPersistentWebsocketIfNecessary: StartPersistentWebsocketIfNecessaryUseCase +) : CoroutineWorker(appContext, workerParams) { + + override suspend fun doWork(): Result = coroutineScope { + appLogger.i("${TAG}: Starting periodic work check for persistent websocket connection") + startPersistentWebsocketIfNecessary() + Result.success() + } + + companion object { + const val NAME = "wss_check_worker" + const val TAG = "PersistentWebsocketCheckWorker" + val WORK_INTERVAL = 24.hours.toJavaDuration() + } +} + +fun WorkManager.enqueuePeriodicPersistentWebsocketCheckWorker() { + appLogger.i("${TAG}: Enqueueing periodic work for $TAG") + enqueueUniquePeriodicWork( + NAME, ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE, + PeriodicWorkRequestBuilder(WORK_INTERVAL) + .addTag(TAG) // adds the tag so we can cancel later all related work. + .build() + ) +} + +fun WorkManager.cancelPeriodicPersistentWebsocketCheckWorker() { + appLogger.i("${TAG}: Cancelling all periodic scheduled work for the tag $TAG") + cancelAllWorkByTag(TAG) +} diff --git a/app/src/main/res/drawable/ic_launcher_wire_logo.xml b/app/src/main/res/drawable/ic_launcher_wire_logo.xml new file mode 100644 index 00000000000..7b6912bb1b5 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_wire_logo.xml @@ -0,0 +1,39 @@ + + + + + + + + + diff --git a/app/src/main/res/values-af/strings.xml b/app/src/main/res/values-af/strings.xml index 007cff28cb8..1d068fdcfd7 100644 --- a/app/src/main/res/values-af/strings.xml +++ b/app/src/main/res/values-af/strings.xml @@ -403,7 +403,7 @@ Saved File not available File upload failed - Do you want to open the file or save it to your + Do you want to open the file, or save it to your device\'s download folder? Open @@ -609,8 +609,8 @@ %1$s **participants** could not be added to the group. %1$s could not be added to the group. %1$s could not be added to the group. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. All devices are verified (end-to-end identity) All fingerprints are verified (Proteus) Communication in Wire is always end-to-end encrypted. Everything you send and receive in this conversation is only accessible to you and other group participants.\n**Please still be careful with who you share sensitive information.** @@ -710,8 +710,8 @@ Media Pictures Files - No pictures have been shared in this conversation yet 🥲 - No files have been shared in this conversation yet 🙀 + Nobody shared pictures in this conversation yet 🥲 + Nobody shared files in this conversation yet 🙀 CONTACTS New Group @@ -989,7 +989,14 @@ Open Profile You can\'t switch accounts while in a call Redirect to an on-premises backend? - If you proceed, your client will be redirected to the following on-premises backend:\n\nBackend name:\n%1$s\n\nBackend URL:\n%2$s + If you proceed, your client will be redirected to the following on-premises backend: + Backend name: + Backend URL: + Blacklist URL: + Teams URL: + Accounts URL: + Website URL: + Backend WSURL: Receiving new messages Text copied to clipboard Logs @@ -1334,4 +1341,5 @@ Share Location Allow Wire to access your device location to send your location. Please wait... + Location could not be shared diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 999f34415fa..25b40377762 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -407,7 +407,7 @@ Saved File not available File upload failed - Do you want to open the file or save it to your + Do you want to open the file, or save it to your device\'s download folder? Open @@ -621,8 +621,8 @@ %1$s **participants** could not be added to the group. %1$s could not be added to the group. %1$s could not be added to the group. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. All devices are verified (end-to-end identity) All fingerprints are verified (Proteus) Communication in Wire is always end-to-end encrypted. Everything you send and receive in this conversation is only accessible to you and other group participants.\n**Please still be careful with who you share sensitive information.** @@ -774,8 +774,8 @@ Media Pictures Files - No pictures have been shared in this conversation yet 🥲 - No files have been shared in this conversation yet 🙀 + Nobody shared pictures in this conversation yet 🥲 + Nobody shared files in this conversation yet 🙀 CONTACTS New Group @@ -1061,7 +1061,14 @@ Open Profile You can\'t switch accounts while in a call Redirect to an on-premises backend? - If you proceed, your client will be redirected to the following on-premises backend:\n\nBackend name:\n%1$s\n\nBackend URL:\n%2$s + If you proceed, your client will be redirected to the following on-premises backend: + Backend name: + Backend URL: + Blacklist URL: + Teams URL: + Accounts URL: + Website URL: + Backend WSURL: Receiving new messages Text copied to clipboard Logs @@ -1446,4 +1453,5 @@ Share Location Allow Wire to access your device location to send your location. Please wait... + Location could not be shared diff --git a/app/src/main/res/values-bn/strings.xml b/app/src/main/res/values-bn/strings.xml index 5f0812e9f09..55c1f71fffc 100644 --- a/app/src/main/res/values-bn/strings.xml +++ b/app/src/main/res/values-bn/strings.xml @@ -403,7 +403,7 @@ Saved File not available File upload failed - Do you want to open the file or save it to your + Do you want to open the file, or save it to your device\'s download folder? Open @@ -609,8 +609,8 @@ %1$s **participants** could not be added to the group. %1$s could not be added to the group. %1$s could not be added to the group. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. All devices are verified (end-to-end identity) All fingerprints are verified (Proteus) Communication in Wire is always end-to-end encrypted. Everything you send and receive in this conversation is only accessible to you and other group participants.\n**Please still be careful with who you share sensitive information.** @@ -710,8 +710,8 @@ Media Pictures Files - No pictures have been shared in this conversation yet 🥲 - No files have been shared in this conversation yet 🙀 + Nobody shared pictures in this conversation yet 🥲 + Nobody shared files in this conversation yet 🙀 CONTACTS New Group @@ -989,7 +989,14 @@ Open Profile You can\'t switch accounts while in a call Redirect to an on-premises backend? - If you proceed, your client will be redirected to the following on-premises backend:\n\nBackend name:\n%1$s\n\nBackend URL:\n%2$s + If you proceed, your client will be redirected to the following on-premises backend: + Backend name: + Backend URL: + Blacklist URL: + Teams URL: + Accounts URL: + Website URL: + Backend WSURL: Receiving new messages Text copied to clipboard Logs @@ -1334,4 +1341,5 @@ Share Location Allow Wire to access your device location to send your location. Please wait... + Location could not be shared diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 007cff28cb8..1d068fdcfd7 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -403,7 +403,7 @@ Saved File not available File upload failed - Do you want to open the file or save it to your + Do you want to open the file, or save it to your device\'s download folder? Open @@ -609,8 +609,8 @@ %1$s **participants** could not be added to the group. %1$s could not be added to the group. %1$s could not be added to the group. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. All devices are verified (end-to-end identity) All fingerprints are verified (Proteus) Communication in Wire is always end-to-end encrypted. Everything you send and receive in this conversation is only accessible to you and other group participants.\n**Please still be careful with who you share sensitive information.** @@ -710,8 +710,8 @@ Media Pictures Files - No pictures have been shared in this conversation yet 🥲 - No files have been shared in this conversation yet 🙀 + Nobody shared pictures in this conversation yet 🥲 + Nobody shared files in this conversation yet 🙀 CONTACTS New Group @@ -989,7 +989,14 @@ Open Profile You can\'t switch accounts while in a call Redirect to an on-premises backend? - If you proceed, your client will be redirected to the following on-premises backend:\n\nBackend name:\n%1$s\n\nBackend URL:\n%2$s + If you proceed, your client will be redirected to the following on-premises backend: + Backend name: + Backend URL: + Blacklist URL: + Teams URL: + Accounts URL: + Website URL: + Backend WSURL: Receiving new messages Text copied to clipboard Logs @@ -1334,4 +1341,5 @@ Share Location Allow Wire to access your device location to send your location. Please wait... + Location could not be shared diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 8837a226354..6ebdc3e238b 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -405,7 +405,7 @@ Saved File not available File upload failed - Do you want to open the file or save it to your + Do you want to open the file, or save it to your device\'s download folder? Open @@ -615,8 +615,8 @@ %1$s **participants** could not be added to the group. %1$s could not be added to the group. %1$s could not be added to the group. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. All devices are verified (end-to-end identity) All fingerprints are verified (Proteus) Communication in Wire is always end-to-end encrypted. Everything you send and receive in this conversation is only accessible to you and other group participants.\n**Please still be careful with who you share sensitive information.** @@ -742,8 +742,8 @@ Media Pictures Files - No pictures have been shared in this conversation yet 🥲 - No files have been shared in this conversation yet 🙀 + Nobody shared pictures in this conversation yet 🥲 + Nobody shared files in this conversation yet 🙀 CONTACTS New Group @@ -1025,7 +1025,14 @@ Open Profile You can\'t switch accounts while in a call Redirect to an on-premises backend? - If you proceed, your client will be redirected to the following on-premises backend:\n\nBackend name:\n%1$s\n\nBackend URL:\n%2$s + If you proceed, your client will be redirected to the following on-premises backend: + Backend name: + Backend URL: + Blacklist URL: + Teams URL: + Accounts URL: + Website URL: + Backend WSURL: Receiving new messages Text copied to clipboard Logs @@ -1390,4 +1397,5 @@ Share Location Allow Wire to access your device location to send your location. Please wait... + Location could not be shared diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 007cff28cb8..1d068fdcfd7 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -403,7 +403,7 @@ Saved File not available File upload failed - Do you want to open the file or save it to your + Do you want to open the file, or save it to your device\'s download folder? Open @@ -609,8 +609,8 @@ %1$s **participants** could not be added to the group. %1$s could not be added to the group. %1$s could not be added to the group. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. All devices are verified (end-to-end identity) All fingerprints are verified (Proteus) Communication in Wire is always end-to-end encrypted. Everything you send and receive in this conversation is only accessible to you and other group participants.\n**Please still be careful with who you share sensitive information.** @@ -710,8 +710,8 @@ Media Pictures Files - No pictures have been shared in this conversation yet 🥲 - No files have been shared in this conversation yet 🙀 + Nobody shared pictures in this conversation yet 🥲 + Nobody shared files in this conversation yet 🙀 CONTACTS New Group @@ -989,7 +989,14 @@ Open Profile You can\'t switch accounts while in a call Redirect to an on-premises backend? - If you proceed, your client will be redirected to the following on-premises backend:\n\nBackend name:\n%1$s\n\nBackend URL:\n%2$s + If you proceed, your client will be redirected to the following on-premises backend: + Backend name: + Backend URL: + Blacklist URL: + Teams URL: + Accounts URL: + Website URL: + Backend WSURL: Receiving new messages Text copied to clipboard Logs @@ -1334,4 +1341,5 @@ Share Location Allow Wire to access your device location to send your location. Please wait... + Location could not be shared diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index e438e7f8959..b989aa21564 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -210,6 +210,7 @@ Wire wird unabhängig geprüft und ist ISO-, CCPA-, DSGVO- und SOX-konform Team erstellen Backend-Name:\n%1$s\n\nBackend-URL:\n%2$s + Backend name:\n%1$s\n\nBackend URL:\n%2$s\n\nProxy-URL:\n%3$s\n\nProxy-Authentifizierung:\n%4$s Lokales Backend Willkommen in unserer neuen App 👋 Wir haben die App überarbeitet, um sie für alle benutzerfreundlicher zu machen.\n\nErfahren Sie mehr über die neu gestaltete App – zusätzliche Optionen und verbesserte Barrierefreiheit bei gleichbleibend hoher Sicherheit. @@ -610,8 +611,8 @@ %1$s **Teilnehmer** konnten der Gruppe nicht hinzugefügt werden. %1$s konnten der Gruppe nicht hinzugefügt werden. %1$s konnte der Gruppe nicht hinzugefügt werden. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. + Diese Unterhaltung ist nicht mehr überprüft, da mindestens ein Teilnehmer ein neues Gerät verwendet oder ein ungültiges Zertifikat hat. + Diese Unterhaltung ist nicht mehr überprüft, da mindestens ein Teilnehmer ein neues Gerät verwendet oder ein ungültiges Zertifikat hat. Alle Geräte sind überprüft (Ende-zu-Ende-Identität) Alle Fingerabdrücke sind überprüft (Proteus) Kommunikation in Wire ist immer Ende-zu-Ende verschlüsselt. Alles, was Sie in dieser Unterhaltung senden und empfangen, ist nur für Sie und andere Gruppenteilnehmer zugänglich.\n**Bitte seien Sie dennoch vorsichtig, mit wem Sie vertrauliche Informationen teilen.** @@ -711,8 +712,8 @@ Medien Bilder Dateien - Es wurden noch keine Bilder in dieser Unterhaltung geteilt 🙀 - Es wurden noch keine Dateien in dieser Unterhaltung geteilt 🙀 + Bislang hat niemand Bilder in dieser Unterhaltung geteilt 🥲 + Bislang hat niemand Dateien in dieser Unterhaltung geteilt 🙀 KONTAKTE Neue Gruppe @@ -990,7 +991,16 @@ Profil öffnen Sie können während eines Anrufs nicht das Konto wechseln Umleitung auf ein lokales Backend? - Wenn Sie fortfahren, wird Ihr Client auf das folgende Backend weitergeleitet:\n\nBackend-Name:\n%1$s\n\nBackend-URL:\n%2$s + Wenn Sie fortfahren, wird Ihr Client an das folgende lokale Backend weitergeleitet: + Backend-Name: + Backend-URL: + Proxy-URL: + Proxy-Authentifizierung: + Blacklist-URL: + Teams-URL: + Accounts-URL: + Website-URL: + Backend-WSURL: Empfang von neuen Nachrichten Text in Zwischenablage kopiert Protokolle @@ -1336,4 +1346,5 @@ registriert. Bitte versuchen Sie es mit einer anderen. Standort teilen Erlauben Sie Wire den Zugriff auf Ihren Gerätestandort, um Ihren Standort zu senden. Bitte warten… + Standort konnte nicht geteilt werden diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 007cff28cb8..1d068fdcfd7 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -403,7 +403,7 @@ Saved File not available File upload failed - Do you want to open the file or save it to your + Do you want to open the file, or save it to your device\'s download folder? Open @@ -609,8 +609,8 @@ %1$s **participants** could not be added to the group. %1$s could not be added to the group. %1$s could not be added to the group. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. All devices are verified (end-to-end identity) All fingerprints are verified (Proteus) Communication in Wire is always end-to-end encrypted. Everything you send and receive in this conversation is only accessible to you and other group participants.\n**Please still be careful with who you share sensitive information.** @@ -710,8 +710,8 @@ Media Pictures Files - No pictures have been shared in this conversation yet 🥲 - No files have been shared in this conversation yet 🙀 + Nobody shared pictures in this conversation yet 🥲 + Nobody shared files in this conversation yet 🙀 CONTACTS New Group @@ -989,7 +989,14 @@ Open Profile You can\'t switch accounts while in a call Redirect to an on-premises backend? - If you proceed, your client will be redirected to the following on-premises backend:\n\nBackend name:\n%1$s\n\nBackend URL:\n%2$s + If you proceed, your client will be redirected to the following on-premises backend: + Backend name: + Backend URL: + Blacklist URL: + Teams URL: + Accounts URL: + Website URL: + Backend WSURL: Receiving new messages Text copied to clipboard Logs @@ -1334,4 +1341,5 @@ Share Location Allow Wire to access your device location to send your location. Please wait... + Location could not be shared diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 34bbd9453a6..d67e0bf4f0d 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -401,7 +401,9 @@ Un mensaje eliminado no puede ser restaurado. Guardado Archivo no disponible Error al cargar archivo - ¿Desea abrir el archivo o guardarlo en la carpeta de descargas de su dispositivo? + Do you want to open the file, or save it to your + device\'s download folder? + Abrir Guardar Cuenta eliminada @@ -607,8 +609,8 @@ Hasta 500 personas pueden unirse a una conversación en grupo. %1$s **participants** could not be added to the group. %1$s no pudieron ser añadidos a la conversación. %1$s no pudo ser añadido a la conversación. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. All devices are verified (end-to-end identity) All fingerprints are verified (Proteus) Communication in Wire is always end-to-end encrypted. Everything you send and receive in this conversation is only accessible to you and other group participants.\n**Please still be careful with who you share sensitive information.** @@ -708,8 +710,8 @@ Hasta 500 personas pueden unirse a una conversación en grupo. Media Pictures Files - No pictures have been shared in this conversation yet 🥲 - No files have been shared in this conversation yet 🙀 + Nobody shared pictures in this conversation yet 🥲 + Nobody shared files in this conversation yet 🙀 CONTACTOS Nuevo Grupo @@ -984,13 +986,14 @@ Hasta 500 personas pueden unirse a una conversación en grupo. Abrir perfil No puedes cambiar de cuenta mientras estás en una llamada ¿Redirigir a un backend local? - Si continúas, tu cliente será redirigido al siguiente backend local: - -Nombre del backend: -%1$s - -URL del backend: -%2$s + Si continúa, su cliente será redirigido al siguiente servidor local: + Backend name: + Backend URL: + Blacklist URL: + Teams URL: + Accounts URL: + Website URL: + Backend WSURL: Recibiendo nuevos mensajes Texto copiado al portapapeles Registros @@ -1337,4 +1340,5 @@ URL del backend: Compartir ubicación Permite a Wire acceder a la ubicación del dispositivo para enviar tu ubicación. Un momento... + No se pudo compartir la ubicación diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 05ba6c92fde..1328a3fd24f 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -403,7 +403,7 @@ Saved File not available File upload failed - Do you want to open the file or save it to your + Do you want to open the file, or save it to your device\'s download folder? Open @@ -609,8 +609,8 @@ %1$s **participants** could not be added to the group. %1$s could not be added to the group. %1$s could not be added to the group. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. All devices are verified (end-to-end identity) All fingerprints are verified (Proteus) Communication in Wire is always end-to-end encrypted. Everything you send and receive in this conversation is only accessible to you and other group participants.\n**Please still be careful with who you share sensitive information.** @@ -710,8 +710,8 @@ Media Pictures Files - No pictures have been shared in this conversation yet 🥲 - No files have been shared in this conversation yet 🙀 + Nobody shared pictures in this conversation yet 🥲 + Nobody shared files in this conversation yet 🙀 CONTACTS New Group @@ -989,7 +989,14 @@ Open Profile You can\'t switch accounts while in a call Redirect to an on-premises backend? - If you proceed, your client will be redirected to the following on-premises backend:\n\nBackend name:\n%1$s\n\nBackend URL:\n%2$s + If you proceed, your client will be redirected to the following on-premises backend: + Backend name: + Backend URL: + Blacklist URL: + Teams URL: + Accounts URL: + Website URL: + Backend WSURL: Receiving new messages Text copied to clipboard Logs @@ -1334,4 +1341,5 @@ Share Location Allow Wire to access your device location to send your location. Please wait... + Location could not be shared diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 5f0812e9f09..55c1f71fffc 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -403,7 +403,7 @@ Saved File not available File upload failed - Do you want to open the file or save it to your + Do you want to open the file, or save it to your device\'s download folder? Open @@ -609,8 +609,8 @@ %1$s **participants** could not be added to the group. %1$s could not be added to the group. %1$s could not be added to the group. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. All devices are verified (end-to-end identity) All fingerprints are verified (Proteus) Communication in Wire is always end-to-end encrypted. Everything you send and receive in this conversation is only accessible to you and other group participants.\n**Please still be careful with who you share sensitive information.** @@ -710,8 +710,8 @@ Media Pictures Files - No pictures have been shared in this conversation yet 🥲 - No files have been shared in this conversation yet 🙀 + Nobody shared pictures in this conversation yet 🥲 + Nobody shared files in this conversation yet 🙀 CONTACTS New Group @@ -989,7 +989,14 @@ Open Profile You can\'t switch accounts while in a call Redirect to an on-premises backend? - If you proceed, your client will be redirected to the following on-premises backend:\n\nBackend name:\n%1$s\n\nBackend URL:\n%2$s + If you proceed, your client will be redirected to the following on-premises backend: + Backend name: + Backend URL: + Blacklist URL: + Teams URL: + Accounts URL: + Website URL: + Backend WSURL: Receiving new messages Text copied to clipboard Logs @@ -1334,4 +1341,5 @@ Share Location Allow Wire to access your device location to send your location. Please wait... + Location could not be shared diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index 007cff28cb8..1d068fdcfd7 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -403,7 +403,7 @@ Saved File not available File upload failed - Do you want to open the file or save it to your + Do you want to open the file, or save it to your device\'s download folder? Open @@ -609,8 +609,8 @@ %1$s **participants** could not be added to the group. %1$s could not be added to the group. %1$s could not be added to the group. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. All devices are verified (end-to-end identity) All fingerprints are verified (Proteus) Communication in Wire is always end-to-end encrypted. Everything you send and receive in this conversation is only accessible to you and other group participants.\n**Please still be careful with who you share sensitive information.** @@ -710,8 +710,8 @@ Media Pictures Files - No pictures have been shared in this conversation yet 🥲 - No files have been shared in this conversation yet 🙀 + Nobody shared pictures in this conversation yet 🥲 + Nobody shared files in this conversation yet 🙀 CONTACTS New Group @@ -989,7 +989,14 @@ Open Profile You can\'t switch accounts while in a call Redirect to an on-premises backend? - If you proceed, your client will be redirected to the following on-premises backend:\n\nBackend name:\n%1$s\n\nBackend URL:\n%2$s + If you proceed, your client will be redirected to the following on-premises backend: + Backend name: + Backend URL: + Blacklist URL: + Teams URL: + Accounts URL: + Website URL: + Backend WSURL: Receiving new messages Text copied to clipboard Logs @@ -1334,4 +1341,5 @@ Share Location Allow Wire to access your device location to send your location. Please wait... + Location could not be shared diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index ed415f1bcb0..639dd951c50 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -392,7 +392,7 @@ Saved File not available File upload failed - Do you want to open the file or save it to your + Do you want to open the file, or save it to your device\'s download folder? Open @@ -598,8 +598,8 @@ %1$s **participants** could not be added to the group. %1$s could not be added to the group. %1$s could not be added to the group. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. All devices are verified (end-to-end identity) All fingerprints are verified (Proteus) Communication in Wire is always end-to-end encrypted. Everything you send and receive in this conversation is only accessible to you and other group participants.\n**Please still be careful with who you share sensitive information.** @@ -699,8 +699,8 @@ Media Pictures Files - No pictures have been shared in this conversation yet 🥲 - No files have been shared in this conversation yet 🙀 + Nobody shared pictures in this conversation yet 🥲 + Nobody shared files in this conversation yet 🙀 CONTACTS New Group @@ -978,7 +978,14 @@ Open Profile You can\'t switch accounts while in a call Redirect to an on-premises backend? - If you proceed, your client will be redirected to the following on-premises backend:\n\nBackend name:\n%1$s\n\nBackend URL:\n%2$s + If you proceed, your client will be redirected to the following on-premises backend: + Backend name: + Backend URL: + Blacklist URL: + Teams URL: + Accounts URL: + Website URL: + Backend WSURL: Receiving new messages Text copied to clipboard Logs @@ -1323,4 +1330,5 @@ Share Location Allow Wire to access your device location to send your location. Please wait... + Location could not be shared diff --git a/app/src/main/res/values-he/strings.xml b/app/src/main/res/values-he/strings.xml index d5cf344936c..d11a263a31a 100644 --- a/app/src/main/res/values-he/strings.xml +++ b/app/src/main/res/values-he/strings.xml @@ -405,7 +405,7 @@ Saved File not available File upload failed - Do you want to open the file or save it to your + Do you want to open the file, or save it to your device\'s download folder? Open @@ -615,8 +615,8 @@ %1$s **participants** could not be added to the group. %1$s could not be added to the group. %1$s could not be added to the group. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. All devices are verified (end-to-end identity) All fingerprints are verified (Proteus) Communication in Wire is always end-to-end encrypted. Everything you send and receive in this conversation is only accessible to you and other group participants.\n**Please still be careful with who you share sensitive information.** @@ -742,8 +742,8 @@ Media Pictures Files - No pictures have been shared in this conversation yet 🥲 - No files have been shared in this conversation yet 🙀 + Nobody shared pictures in this conversation yet 🥲 + Nobody shared files in this conversation yet 🙀 CONTACTS New Group @@ -1025,7 +1025,14 @@ Open Profile You can\'t switch accounts while in a call Redirect to an on-premises backend? - If you proceed, your client will be redirected to the following on-premises backend:\n\nBackend name:\n%1$s\n\nBackend URL:\n%2$s + If you proceed, your client will be redirected to the following on-premises backend: + Backend name: + Backend URL: + Blacklist URL: + Teams URL: + Accounts URL: + Website URL: + Backend WSURL: Receiving new messages Text copied to clipboard Logs @@ -1390,4 +1397,5 @@ Share Location Allow Wire to access your device location to send your location. Please wait... + Location could not be shared diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 5f0812e9f09..55c1f71fffc 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -403,7 +403,7 @@ Saved File not available File upload failed - Do you want to open the file or save it to your + Do you want to open the file, or save it to your device\'s download folder? Open @@ -609,8 +609,8 @@ %1$s **participants** could not be added to the group. %1$s could not be added to the group. %1$s could not be added to the group. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. All devices are verified (end-to-end identity) All fingerprints are verified (Proteus) Communication in Wire is always end-to-end encrypted. Everything you send and receive in this conversation is only accessible to you and other group participants.\n**Please still be careful with who you share sensitive information.** @@ -710,8 +710,8 @@ Media Pictures Files - No pictures have been shared in this conversation yet 🥲 - No files have been shared in this conversation yet 🙀 + Nobody shared pictures in this conversation yet 🥲 + Nobody shared files in this conversation yet 🙀 CONTACTS New Group @@ -989,7 +989,14 @@ Open Profile You can\'t switch accounts while in a call Redirect to an on-premises backend? - If you proceed, your client will be redirected to the following on-premises backend:\n\nBackend name:\n%1$s\n\nBackend URL:\n%2$s + If you proceed, your client will be redirected to the following on-premises backend: + Backend name: + Backend URL: + Blacklist URL: + Teams URL: + Accounts URL: + Website URL: + Backend WSURL: Receiving new messages Text copied to clipboard Logs @@ -1334,4 +1341,5 @@ Share Location Allow Wire to access your device location to send your location. Please wait... + Location could not be shared diff --git a/app/src/main/res/values-hr/strings.xml b/app/src/main/res/values-hr/strings.xml index 290291c7304..a7df1d6587f 100644 --- a/app/src/main/res/values-hr/strings.xml +++ b/app/src/main/res/values-hr/strings.xml @@ -398,8 +398,9 @@ Spremljeno Datoteka nije dostupna Prijenos datoteke nije uspio - Želite li otvoriti datoteku ili je spremiti na svoju - mapu za preuzimanje na uređaju? + Do you want to open the file, or save it to your + device\'s download folder? + Otvori Spremi Obrisan kontakt @@ -605,8 +606,8 @@ %1$s **participants** could not be added to the group. %1$s could not be added to the group. %1$s could not be added to the group. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. All devices are verified (end-to-end identity) All fingerprints are verified (Proteus) Communication in Wire is always end-to-end encrypted. Everything you send and receive in this conversation is only accessible to you and other group participants.\n**Please still be careful with who you share sensitive information.** @@ -719,8 +720,8 @@ Media Pictures Files - No pictures have been shared in this conversation yet 🥲 - No files have been shared in this conversation yet 🙀 + Nobody shared pictures in this conversation yet 🥲 + Nobody shared files in this conversation yet 🙀 KONTAKTI Nova Grupa @@ -1000,7 +1001,14 @@ Open Profile You can\'t switch accounts while in a call Redirect to an on-premises backend? - If you proceed, your client will be redirected to the following on-premises backend:\n\nBackend name:\n%1$s\n\nBackend URL:\n%2$s + If you proceed, your client will be redirected to the following on-premises backend: + Backend name: + Backend URL: + Blacklist URL: + Teams URL: + Accounts URL: + Website URL: + Backend WSURL: Receiving new messages Text copied to clipboard Logs @@ -1355,4 +1363,5 @@ Share Location Allow Wire to access your device location to send your location. Please wait... + Location could not be shared diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index ea2076fae84..8a1f437c504 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -403,8 +403,8 @@ Mentve A fájl nem elérhető A fájl feltöltése nem sikerült - Megnyitja a fájlt, vagy menti - eszköze letöltési mappájába? + Do you want to open the file, or save it to your + device\'s download folder? Megnyitás Mentés @@ -609,8 +609,8 @@ %1$s **résztvevőket** nem sikerült hozzáadni a csoporthoz. %1$s nem sikerült hozzáadni a csoporthoz. %1$s nem sikerült hozzáadni a csoporthoz. - Ez a beszélgetés többé nem ellenőrzött, mivel valaki legalább egy eszközt érvényes végpontok közötti azonosító tanúsítvány nélkül használ. - Ez a beszélgetés többé nem ellenőrzött, mivel valaki legalább egy eszközt érvényes végpontok közötti azonosító tanúsítvány nélkül használ. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. Minden eszköz ellenőrzött (végpontok közötti azonosítás) Minden ujjlenyomat ellenőrizve (Proteus) A Wire-ben a kommunikáció mindig titkos a végpontok között. Minden, amit küld és fogad ebben a beszélgetésben, csak az Ön és a csoport résztvevői számára hozzáférhető.\n**Továbbra is legyen körültekintő, kivel oszt meg érzékeny információkat.** @@ -710,8 +710,8 @@ Media Pictures Files - No pictures have been shared in this conversation yet 🥲 - No files have been shared in this conversation yet 🙀 + Nobody shared pictures in this conversation yet 🥲 + Nobody shared files in this conversation yet 🙀 NÉVJEGYEK Új csoport @@ -989,7 +989,14 @@ Profil megnyitása Nem válthat fiókot hívás közben Átirányítja egy helyi kiszolgálóra? - Ha továbblép, a kliense átirányításra kerül a következő helyi kiszolgálóra:\n\nKiszolgáló neve:\n%1$s\n\nKiszolgáló URL-je:\n%2$s + If you proceed, your client will be redirected to the following on-premises backend: + Backend name: + Backend URL: + Blacklist URL: + Teams URL: + Accounts URL: + Website URL: + Backend WSURL: Új üzenetek lekérdezése Szöveg a vágólapra másolva Naplók @@ -1335,4 +1342,5 @@ Kérjük, próbálja meg újra. Share Location Allow Wire to access your device location to send your location. Please wait... + Location could not be shared diff --git a/app/src/main/res/values-id/strings.xml b/app/src/main/res/values-id/strings.xml index c4628b3f439..6c7340fea2e 100644 --- a/app/src/main/res/values-id/strings.xml +++ b/app/src/main/res/values-id/strings.xml @@ -402,7 +402,7 @@ Saved File not available File upload failed - Do you want to open the file or save it to your + Do you want to open the file, or save it to your device\'s download folder? Open @@ -606,8 +606,8 @@ %1$s **participants** could not be added to the group. %1$s could not be added to the group. %1$s could not be added to the group. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. All devices are verified (end-to-end identity) All fingerprints are verified (Proteus) Communication in Wire is always end-to-end encrypted. Everything you send and receive in this conversation is only accessible to you and other group participants.\n**Please still be careful with who you share sensitive information.** @@ -694,8 +694,8 @@ Media Pictures Files - No pictures have been shared in this conversation yet 🥲 - No files have been shared in this conversation yet 🙀 + Nobody shared pictures in this conversation yet 🥲 + Nobody shared files in this conversation yet 🙀 CONTACTS New Group @@ -971,7 +971,14 @@ Open Profile You can\'t switch accounts while in a call Redirect to an on-premises backend? - If you proceed, your client will be redirected to the following on-premises backend:\n\nBackend name:\n%1$s\n\nBackend URL:\n%2$s + If you proceed, your client will be redirected to the following on-premises backend: + Backend name: + Backend URL: + Blacklist URL: + Teams URL: + Accounts URL: + Website URL: + Backend WSURL: Receiving new messages Text copied to clipboard Logs @@ -1306,4 +1313,5 @@ Share Location Allow Wire to access your device location to send your location. Please wait... + Location could not be shared diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 9ddd78a7713..cea78b18b54 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -401,7 +401,9 @@ Un messaggio eliminato non può essere ripristinato. Salvato File non disponibile Caricamento del file non riuscito - Vuoi aprire il file o salvarlo nella cartella dei download del tuo dispositivo? + Do you want to open the file, or save it to your + device\'s download folder? + Apri Salva Account eliminato @@ -607,8 +609,8 @@ Fino a 500 persone possono unirsi a una conversazione di gruppo. Impossibile aggiungere %1$s **partecipanti** al gruppo. Impossibile aggiungere %1$s al gruppo. Impossibile aggiungere %1$s al gruppo. - Questa conversazione non è più verificata, poiché alcuni utenti utilizzano almeno un dispositivo privo del certificato di identità end-to-end. - Questa conversazione non è più verificata, poiché alcuni utenti utilizzano almeno un dispositivo privo del certificato di identità end-to-end. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. Tutti i dispositivi sono verificati (identità end-to-end) Tutte le impronte digitali sono verificate (Proteus) La comunicazione su Wire è sempre crittografata end-to-end. Tutto ciò che invii e ricevi in questa conversazione è accessibile soltanto a te e agli altri partecipanti del gruppo.\n**Ti preghiamo di prestare comunque attenzione a con chi condividi le informazioni sensibili.** @@ -708,8 +710,8 @@ Fino a 500 persone possono unirsi a una conversazione di gruppo. Multimedia Immagini File - Nessuna immagine è stata ancora condivisa in questa conversazione 🥲 - Ancora nessun file è stato condiviso in questa conversazione 🙀 + Nobody shared pictures in this conversation yet 🥲 + Nobody shared files in this conversation yet 🙀 CONTATTI Nuovo Gruppo @@ -984,13 +986,14 @@ Rispondendo qui, verrà riagganciata l\'altra chiamata. Apri il profilo Non puoi cambiare account durante una chiamata Redirect verso un backend on-premises? - Se procedi, il tuo client verrà reindirizzato al seguente backend on-premises: - -Nome del backend: - %1$s - -URL del backend: - %2$s + If you proceed, your client will be redirected to the following on-premises backend: + Backend name: + Backend URL: + Blacklist URL: + Teams URL: + Accounts URL: + Website URL: + Backend WSURL: Ricezione nuovi messaggi in corso Testo copiato negli appunti Registri @@ -1336,4 +1339,5 @@ registrato. Sei pregato di riprovare. Share Location Allow Wire to access your device location to send your location. Please wait... + Location could not be shared diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index e62210675a6..a2b8b772cb3 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -402,7 +402,7 @@ Saved File not available File upload failed - Do you want to open the file or save it to your + Do you want to open the file, or save it to your device\'s download folder? Open @@ -606,8 +606,8 @@ %1$s **participants** could not be added to the group. %1$s could not be added to the group. %1$s could not be added to the group. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. All devices are verified (end-to-end identity) All fingerprints are verified (Proteus) Communication in Wire is always end-to-end encrypted. Everything you send and receive in this conversation is only accessible to you and other group participants.\n**Please still be careful with who you share sensitive information.** @@ -694,8 +694,8 @@ Media Pictures Files - No pictures have been shared in this conversation yet 🥲 - No files have been shared in this conversation yet 🙀 + Nobody shared pictures in this conversation yet 🥲 + Nobody shared files in this conversation yet 🙀 CONTACTS New Group @@ -971,7 +971,14 @@ Open Profile You can\'t switch accounts while in a call Redirect to an on-premises backend? - If you proceed, your client will be redirected to the following on-premises backend:\n\nBackend name:\n%1$s\n\nBackend URL:\n%2$s + If you proceed, your client will be redirected to the following on-premises backend: + Backend name: + Backend URL: + Blacklist URL: + Teams URL: + Accounts URL: + Website URL: + Backend WSURL: Receiving new messages Text copied to clipboard Logs @@ -1306,4 +1313,5 @@ Share Location Allow Wire to access your device location to send your location. Please wait... + Location could not be shared diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 3ede90b32a2..a2340e3f407 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -402,7 +402,7 @@ Saved File not available File upload failed - Do you want to open the file or save it to your + Do you want to open the file, or save it to your device\'s download folder? Open @@ -606,8 +606,8 @@ %1$s **participants** could not be added to the group. %1$s could not be added to the group. %1$s could not be added to the group. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. All devices are verified (end-to-end identity) All fingerprints are verified (Proteus) Communication in Wire is always end-to-end encrypted. Everything you send and receive in this conversation is only accessible to you and other group participants.\n**Please still be careful with who you share sensitive information.** @@ -694,8 +694,8 @@ Media Pictures Files - No pictures have been shared in this conversation yet 🥲 - No files have been shared in this conversation yet 🙀 + Nobody shared pictures in this conversation yet 🥲 + Nobody shared files in this conversation yet 🙀 CONTACTS New Group @@ -971,7 +971,14 @@ Open Profile You can\'t switch accounts while in a call Redirect to an on-premises backend? - If you proceed, your client will be redirected to the following on-premises backend:\n\nBackend name:\n%1$s\n\nBackend URL:\n%2$s + If you proceed, your client will be redirected to the following on-premises backend: + Backend name: + Backend URL: + Blacklist URL: + Teams URL: + Accounts URL: + Website URL: + Backend WSURL: Receiving new messages Text copied to clipboard Logs @@ -1306,4 +1313,5 @@ Share Location Allow Wire to access your device location to send your location. Please wait... + Location could not be shared diff --git a/app/src/main/res/values-lt/strings.xml b/app/src/main/res/values-lt/strings.xml index b0bcd6cc8f9..c90a1bc7897 100644 --- a/app/src/main/res/values-lt/strings.xml +++ b/app/src/main/res/values-lt/strings.xml @@ -405,7 +405,7 @@ Saved File not available File upload failed - Do you want to open the file or save it to your + Do you want to open the file, or save it to your device\'s download folder? Open @@ -615,8 +615,8 @@ %1$s **participants** could not be added to the group. %1$s could not be added to the group. %1$s could not be added to the group. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. All devices are verified (end-to-end identity) All fingerprints are verified (Proteus) Communication in Wire is always end-to-end encrypted. Everything you send and receive in this conversation is only accessible to you and other group participants.\n**Please still be careful with who you share sensitive information.** @@ -742,8 +742,8 @@ Media Pictures Files - No pictures have been shared in this conversation yet 🥲 - No files have been shared in this conversation yet 🙀 + Nobody shared pictures in this conversation yet 🥲 + Nobody shared files in this conversation yet 🙀 CONTACTS New Group @@ -1025,7 +1025,14 @@ Open Profile You can\'t switch accounts while in a call Redirect to an on-premises backend? - If you proceed, your client will be redirected to the following on-premises backend:\n\nBackend name:\n%1$s\n\nBackend URL:\n%2$s + If you proceed, your client will be redirected to the following on-premises backend: + Backend name: + Backend URL: + Blacklist URL: + Teams URL: + Accounts URL: + Website URL: + Backend WSURL: Receiving new messages Text copied to clipboard Logs @@ -1390,4 +1397,5 @@ Share Location Allow Wire to access your device location to send your location. Please wait... + Location could not be shared diff --git a/app/src/main/res/values-mk/strings.xml b/app/src/main/res/values-mk/strings.xml index 5f0812e9f09..55c1f71fffc 100644 --- a/app/src/main/res/values-mk/strings.xml +++ b/app/src/main/res/values-mk/strings.xml @@ -403,7 +403,7 @@ Saved File not available File upload failed - Do you want to open the file or save it to your + Do you want to open the file, or save it to your device\'s download folder? Open @@ -609,8 +609,8 @@ %1$s **participants** could not be added to the group. %1$s could not be added to the group. %1$s could not be added to the group. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. All devices are verified (end-to-end identity) All fingerprints are verified (Proteus) Communication in Wire is always end-to-end encrypted. Everything you send and receive in this conversation is only accessible to you and other group participants.\n**Please still be careful with who you share sensitive information.** @@ -710,8 +710,8 @@ Media Pictures Files - No pictures have been shared in this conversation yet 🥲 - No files have been shared in this conversation yet 🙀 + Nobody shared pictures in this conversation yet 🥲 + Nobody shared files in this conversation yet 🙀 CONTACTS New Group @@ -989,7 +989,14 @@ Open Profile You can\'t switch accounts while in a call Redirect to an on-premises backend? - If you proceed, your client will be redirected to the following on-premises backend:\n\nBackend name:\n%1$s\n\nBackend URL:\n%2$s + If you proceed, your client will be redirected to the following on-premises backend: + Backend name: + Backend URL: + Blacklist URL: + Teams URL: + Accounts URL: + Website URL: + Backend WSURL: Receiving new messages Text copied to clipboard Logs @@ -1334,4 +1341,5 @@ Share Location Allow Wire to access your device location to send your location. Please wait... + Location could not be shared diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 007cff28cb8..1d068fdcfd7 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -403,7 +403,7 @@ Saved File not available File upload failed - Do you want to open the file or save it to your + Do you want to open the file, or save it to your device\'s download folder? Open @@ -609,8 +609,8 @@ %1$s **participants** could not be added to the group. %1$s could not be added to the group. %1$s could not be added to the group. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. All devices are verified (end-to-end identity) All fingerprints are verified (Proteus) Communication in Wire is always end-to-end encrypted. Everything you send and receive in this conversation is only accessible to you and other group participants.\n**Please still be careful with who you share sensitive information.** @@ -710,8 +710,8 @@ Media Pictures Files - No pictures have been shared in this conversation yet 🥲 - No files have been shared in this conversation yet 🙀 + Nobody shared pictures in this conversation yet 🥲 + Nobody shared files in this conversation yet 🙀 CONTACTS New Group @@ -989,7 +989,14 @@ Open Profile You can\'t switch accounts while in a call Redirect to an on-premises backend? - If you proceed, your client will be redirected to the following on-premises backend:\n\nBackend name:\n%1$s\n\nBackend URL:\n%2$s + If you proceed, your client will be redirected to the following on-premises backend: + Backend name: + Backend URL: + Blacklist URL: + Teams URL: + Accounts URL: + Website URL: + Backend WSURL: Receiving new messages Text copied to clipboard Logs @@ -1334,4 +1341,5 @@ Share Location Allow Wire to access your device location to send your location. Please wait... + Location could not be shared diff --git a/app/src/main/res/values-no/strings.xml b/app/src/main/res/values-no/strings.xml index 007cff28cb8..1d068fdcfd7 100644 --- a/app/src/main/res/values-no/strings.xml +++ b/app/src/main/res/values-no/strings.xml @@ -403,7 +403,7 @@ Saved File not available File upload failed - Do you want to open the file or save it to your + Do you want to open the file, or save it to your device\'s download folder? Open @@ -609,8 +609,8 @@ %1$s **participants** could not be added to the group. %1$s could not be added to the group. %1$s could not be added to the group. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. All devices are verified (end-to-end identity) All fingerprints are verified (Proteus) Communication in Wire is always end-to-end encrypted. Everything you send and receive in this conversation is only accessible to you and other group participants.\n**Please still be careful with who you share sensitive information.** @@ -710,8 +710,8 @@ Media Pictures Files - No pictures have been shared in this conversation yet 🥲 - No files have been shared in this conversation yet 🙀 + Nobody shared pictures in this conversation yet 🥲 + Nobody shared files in this conversation yet 🙀 CONTACTS New Group @@ -989,7 +989,14 @@ Open Profile You can\'t switch accounts while in a call Redirect to an on-premises backend? - If you proceed, your client will be redirected to the following on-premises backend:\n\nBackend name:\n%1$s\n\nBackend URL:\n%2$s + If you proceed, your client will be redirected to the following on-premises backend: + Backend name: + Backend URL: + Blacklist URL: + Teams URL: + Accounts URL: + Website URL: + Backend WSURL: Receiving new messages Text copied to clipboard Logs @@ -1334,4 +1341,5 @@ Share Location Allow Wire to access your device location to send your location. Please wait... + Location could not be shared diff --git a/app/src/main/res/values-pa/strings.xml b/app/src/main/res/values-pa/strings.xml index 5f0812e9f09..55c1f71fffc 100644 --- a/app/src/main/res/values-pa/strings.xml +++ b/app/src/main/res/values-pa/strings.xml @@ -403,7 +403,7 @@ Saved File not available File upload failed - Do you want to open the file or save it to your + Do you want to open the file, or save it to your device\'s download folder? Open @@ -609,8 +609,8 @@ %1$s **participants** could not be added to the group. %1$s could not be added to the group. %1$s could not be added to the group. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. All devices are verified (end-to-end identity) All fingerprints are verified (Proteus) Communication in Wire is always end-to-end encrypted. Everything you send and receive in this conversation is only accessible to you and other group participants.\n**Please still be careful with who you share sensitive information.** @@ -710,8 +710,8 @@ Media Pictures Files - No pictures have been shared in this conversation yet 🥲 - No files have been shared in this conversation yet 🙀 + Nobody shared pictures in this conversation yet 🥲 + Nobody shared files in this conversation yet 🙀 CONTACTS New Group @@ -989,7 +989,14 @@ Open Profile You can\'t switch accounts while in a call Redirect to an on-premises backend? - If you proceed, your client will be redirected to the following on-premises backend:\n\nBackend name:\n%1$s\n\nBackend URL:\n%2$s + If you proceed, your client will be redirected to the following on-premises backend: + Backend name: + Backend URL: + Blacklist URL: + Teams URL: + Accounts URL: + Website URL: + Backend WSURL: Receiving new messages Text copied to clipboard Logs @@ -1334,4 +1341,5 @@ Share Location Allow Wire to access your device location to send your location. Please wait... + Location could not be shared diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index b5d9e457376..eaed3a43ce9 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -405,7 +405,9 @@ Usunięta wiadomość nie może zostać przywrócona.Zapisane Plik jest niedostępny Ładowanie pliku nie powiodło się - Czy chcesz otworzyć plik czy zapisać go w folderze pobierania na Twoim urządzeniu? + Do you want to open the file, or save it to your + device\'s download folder? + Otwórz Zapisz Konto usunięte @@ -615,8 +617,8 @@ Do grupy może dołączyć maksymalnie 500 osób. %1$s **participants** could not be added to the group. %1$s could not be added to the group. %1$s could not be added to the group. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. All devices are verified (end-to-end identity) All fingerprints are verified (Proteus) Communication in Wire is always end-to-end encrypted. Everything you send and receive in this conversation is only accessible to you and other group participants.\n**Please still be careful with who you share sensitive information.** @@ -742,8 +744,8 @@ Do grupy może dołączyć maksymalnie 500 osób. Media Pictures Files - No pictures have been shared in this conversation yet 🥲 - No files have been shared in this conversation yet 🙀 + Nobody shared pictures in this conversation yet 🥲 + Nobody shared files in this conversation yet 🙀 KONTAKTY Nowa grupa @@ -1024,13 +1026,14 @@ Dołączenie do tego połączenia spowoduje zakończenie tam Otwórz profil Nie możesz przełączać kont podczas rozmowy Przekierować do lokalnego serwera? - Jeśli kontynuujesz, twojemu klientowi zostanie przekierowany do następującego lokalnego serwera: - -Nazwa serwera: -%1$s - -URL serwera: -%2$s + If you proceed, your client will be redirected to the following on-premises backend: + Backend name: + Backend URL: + Blacklist URL: + Teams URL: + Accounts URL: + Website URL: + Backend WSURL: Pobieranie nowych wiadomości Tekst skopiowany do schowka Dzienniki @@ -1396,4 +1399,5 @@ Prosimy użyć zarządzania zespołami (%1$s) na tym środow Share Location Allow Wire to access your device location to send your location. Please wait... + Location could not be shared diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 0a81771612f..ae03f0480f1 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -403,7 +403,9 @@ Uma mensagem excluída não pode ser restaurada. Salvo Arquivo não disponível Falha ao enviar o arquivo - Você quer abrir o arquivo ou salvá-lo na pasta de download do dispositivo? + Do you want to open the file, or save it to your + device\'s download folder? + Abrir Salvar Conta deletada @@ -608,8 +610,8 @@ Até 500 pessoas podem participar de uma conversa em grupo. %1$s **participants** could not be added to the group. %1$s could not be added to the group. %1$s could not be added to the group. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. All devices are verified (end-to-end identity) All fingerprints are verified (Proteus) Communication in Wire is always end-to-end encrypted. Everything you send and receive in this conversation is only accessible to you and other group participants.\n**Please still be careful with who you share sensitive information.** @@ -709,8 +711,8 @@ Até 500 pessoas podem participar de uma conversa em grupo. Media Pictures Files - No pictures have been shared in this conversation yet 🥲 - No files have been shared in this conversation yet 🙀 + Nobody shared pictures in this conversation yet 🥲 + Nobody shared files in this conversation yet 🙀 CONTATOS Novo Grupo @@ -984,13 +986,14 @@ Até 500 pessoas podem participar de uma conversa em grupo. Abrir Perfil Você não pode mudar de conta enquanto estiver em uma chamada Redirecionar para um backend local? - Se você prosseguir, o seu cliente será redirecionado para o seguinte backend local: - -Nome do backend: -%1$s - -URL do backend: -%2$s + If you proceed, your client will be redirected to the following on-premises backend: + Backend name: + Backend URL: + Blacklist URL: + Teams URL: + Accounts URL: + Website URL: + Backend WSURL: Recebendo novas mensagens Texto copiado para a área de transferência Registros @@ -1338,4 +1341,5 @@ Por favor, use o gerenciamento de equipe (%1$s) neste backend. Share Location Allow Wire to access your device location to send your location. Please wait... + Location could not be shared diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index f88480ae315..188c89b24bb 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -404,7 +404,7 @@ Saved File not available File upload failed - Do you want to open the file or save it to your + Do you want to open the file, or save it to your device\'s download folder? Open @@ -612,8 +612,8 @@ %1$s **participants** could not be added to the group. %1$s could not be added to the group. %1$s could not be added to the group. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. All devices are verified (end-to-end identity) All fingerprints are verified (Proteus) Communication in Wire is always end-to-end encrypted. Everything you send and receive in this conversation is only accessible to you and other group participants.\n**Please still be careful with who you share sensitive information.** @@ -726,8 +726,8 @@ Media Pictures Files - No pictures have been shared in this conversation yet 🥲 - No files have been shared in this conversation yet 🙀 + Nobody shared pictures in this conversation yet 🥲 + Nobody shared files in this conversation yet 🙀 CONTACTS New Group @@ -1007,7 +1007,14 @@ Open Profile You can\'t switch accounts while in a call Redirect to an on-premises backend? - If you proceed, your client will be redirected to the following on-premises backend:\n\nBackend name:\n%1$s\n\nBackend URL:\n%2$s + If you proceed, your client will be redirected to the following on-premises backend: + Backend name: + Backend URL: + Blacklist URL: + Teams URL: + Accounts URL: + Website URL: + Backend WSURL: Receiving new messages Text copied to clipboard Logs @@ -1362,4 +1369,5 @@ Share Location Allow Wire to access your device location to send your location. Please wait... + Location could not be shared diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 443d277a6d6..20c055d04aa 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -614,8 +614,8 @@ Не удалось добавить в группу %1$s **участников**. Не удалось добавить %1$s в группу. Не удалось добавить %1$s в группу. - Эта беседа больше не является верифицированной, поскольку кто-то из пользователей использует по крайней мере одно устройство без действительного сертификата сквозной идентификации. - Эта беседа больше не является верифицированной, поскольку кто-то из пользователей использует по крайней мере одно устройство без действительного сертификата сквозной идентификации. + Эта беседа больше является верифицированной, поскольку по крайней мере один из участников начал использовать новое устройство или имеет недействительный сертификат. + Эта беседа больше является верифицированной, поскольку по крайней мере один из участников начал использовать новое устройство или имеет недействительный сертификат. Все устройства верифицированы (сквозная идентификация) Все отпечатки верифицированы (Proteus) Общение в Wire всегда ведется с использованием сквозного шифрования. Все, что вы отправляете и получаете в этой беседе, доступно только вам и другим участникам группы.\n**Пожалуйста, будьте осторожны с теми, кому вы сообщаете конфиденциальную информацию.** @@ -741,8 +741,8 @@ Медиа Изображения Файлы - В этой беседе пока нет изображений 🥲 - В этой беседе пока нет файлов 🥲 + Никто не делился фотографиями в этой беседе 🥲 + Никто не делился файлами в этой беседе 🙀 КОНТАКТЫ Новая группа @@ -1024,7 +1024,14 @@ Открыть профиль Невозможно переключать аккаунты во время звонка Перенаправить на локальный бэкэнд? - В случае продолжения клиент будет перенаправлен на следующий локальный бэкэнд:\n\nНазвание бэкэнда:\n%1$s\n\nURL бэкэнда:\n%2$s + При продолжении вы будете перенаправлены на следующий локальный бэкэнд: + Название бэкэнда: + URL бэкэнда: + Blacklist URL: + Teams URL: + Accounts URL: + Website URL: + Backend WSURL: Получение новых сообщений Текст скопирован в буфер обмена Журналы @@ -1390,4 +1397,5 @@ Поделиться местоположением Разрешите Wire получить доступ к местоположению вашего устройства, чтобы иметь возможность им поделиться. Пожалуйста, подождите... + Не удалось поделиться местоположением diff --git a/app/src/main/res/values-si/strings.xml b/app/src/main/res/values-si/strings.xml index 908c75d236f..0c64c14578b 100644 --- a/app/src/main/res/values-si/strings.xml +++ b/app/src/main/res/values-si/strings.xml @@ -395,7 +395,9 @@ සුරැකිණි ගොනුව නොතිබේ ගොනුව උඩුගත නොවිණි - ඔබට ගොනුව ඇරීමට හෝ එය ඔබගේ උපාංගයේ බාගැනීම් බහාලුමට සුරැකීමට වුවමනා ද? + Do you want to open the file, or save it to your + device\'s download folder? + අරින්න සුරකින්න මකාදැමූ ගිණුමකි @@ -597,8 +599,8 @@ **සහභාගීන්** %1$s ක් සමූහයට එක් කිරීමට නොහැකි විය. %1$s දෙනෙක් සමූහයට එක් කිරීමට නොහැකි විය. %1$s දෙනෙක් සමූහයට එක් කිරීමට නොහැකි විය. - ඇතැම් පරිශ්‍රීලකයින් වලංගු අන්ත අනන්‍යතා සහතිකයක් නැතිව අවම වශයෙන් එක් උපාංගයක් භාවිතා කරන බැවින් මෙම සංවාදය තවදුරටත් සත්‍යාපිත නොවේ. - ඇතැම් පරිශ්‍රීලකයින් වලංගු අන්ත අනන්‍යතා සහතිකයක් නැතිව අවම වශයෙන් එක් උපාංගයක් භාවිතා කරන බැවින් මෙම සංවාදය තවදුරටත් සත්‍යාපිත නොවේ. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. සියලු උපාංග සත්‍යාපිතයි (අන්ත අනන්‍යතාව) සියලු ඇඟිලි සටහන් සත්‍යාපිතයි (ප්‍රෝටියස්) වයර් සන්නිවේදනය සෑම විටම අන්ත සංකේතිතයි. මෙම සංවාදයේ ඔබ යවන සහ ලැබෙන සෑම දෙයක්ම ඔබට සහ වෙනත් සමූහ සහභාගීන්ට පමණක් ප්‍රවේශ වීමට හැකිය.\n**ඔබ සංවේදී තොරතුරු බෙදාගන්නේ කවුරුන් සමඟ දැයි තවදුරටත් සැලකිලිමත් වන්න.** @@ -698,8 +700,8 @@ මාධ්‍ය ඡායාරූප ගොනු - මෙම සංවාදයේ ඡායාරූප කිසිවක් බෙදාගෙන නැත 🥲 - මෙම සංවාදයේ ගොනු කිසිවක් බෙදාගෙන නැත 🥲 + Nobody shared pictures in this conversation yet 🥲 + Nobody shared files in this conversation yet 🙀 සබඳතා නව සමූහයක් @@ -975,7 +977,14 @@ පැතිකඩ අරින්න ඇමතුමක් ඇත්නම් ගිණුම් අතර මාරු වීමට නොහැකිය පරිශ්‍රයක සේවාදායකයකට හරවා යවන්නද? - ඔබ ඉදිරියට ගියහොත්, ඔබගේ අනුග්‍රාහකය පහත පරිශ්‍රයේ සේවාදායකයට හරවා යවනු ලැබේ:\n\nසේවාදායකයේ නම:\n%1$s\n\nඒ.ස.නි.:\n%2$s + If you proceed, your client will be redirected to the following on-premises backend: + Backend name: + Backend URL: + Blacklist URL: + Teams URL: + Accounts URL: + Website URL: + Backend WSURL: නව පණිවිඩ ලැබෙමින් පෙළ පසුරු පුවරුවට පිටපත් විය සටහන් @@ -1320,4 +1329,5 @@ Share Location Allow Wire to access your device location to send your location. Please wait... + Location could not be shared diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index db649f6dd7f..fe993069714 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -405,7 +405,7 @@ Saved File not available File upload failed - Do you want to open the file or save it to your + Do you want to open the file, or save it to your device\'s download folder? Open @@ -615,8 +615,8 @@ %1$s **participants** could not be added to the group. %1$s could not be added to the group. %1$s could not be added to the group. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. All devices are verified (end-to-end identity) All fingerprints are verified (Proteus) Communication in Wire is always end-to-end encrypted. Everything you send and receive in this conversation is only accessible to you and other group participants.\n**Please still be careful with who you share sensitive information.** @@ -742,8 +742,8 @@ Media Pictures Files - No pictures have been shared in this conversation yet 🥲 - No files have been shared in this conversation yet 🙀 + Nobody shared pictures in this conversation yet 🥲 + Nobody shared files in this conversation yet 🙀 CONTACTS New Group @@ -1025,7 +1025,14 @@ Open Profile You can\'t switch accounts while in a call Redirect to an on-premises backend? - If you proceed, your client will be redirected to the following on-premises backend:\n\nBackend name:\n%1$s\n\nBackend URL:\n%2$s + If you proceed, your client will be redirected to the following on-premises backend: + Backend name: + Backend URL: + Blacklist URL: + Teams URL: + Accounts URL: + Website URL: + Backend WSURL: Receiving new messages Text copied to clipboard Logs @@ -1390,4 +1397,5 @@ Share Location Allow Wire to access your device location to send your location. Please wait... + Location could not be shared diff --git a/app/src/main/res/values-sl/strings.xml b/app/src/main/res/values-sl/strings.xml index 900a6041f27..d8ce8a38247 100644 --- a/app/src/main/res/values-sl/strings.xml +++ b/app/src/main/res/values-sl/strings.xml @@ -405,7 +405,7 @@ Saved File not available File upload failed - Do you want to open the file or save it to your + Do you want to open the file, or save it to your device\'s download folder? Open @@ -615,8 +615,8 @@ %1$s **participants** could not be added to the group. %1$s could not be added to the group. %1$s could not be added to the group. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. All devices are verified (end-to-end identity) All fingerprints are verified (Proteus) Communication in Wire is always end-to-end encrypted. Everything you send and receive in this conversation is only accessible to you and other group participants.\n**Please still be careful with who you share sensitive information.** @@ -742,8 +742,8 @@ Media Pictures Files - No pictures have been shared in this conversation yet 🥲 - No files have been shared in this conversation yet 🙀 + Nobody shared pictures in this conversation yet 🥲 + Nobody shared files in this conversation yet 🙀 CONTACTS New Group @@ -1025,7 +1025,14 @@ Open Profile You can\'t switch accounts while in a call Redirect to an on-premises backend? - If you proceed, your client will be redirected to the following on-premises backend:\n\nBackend name:\n%1$s\n\nBackend URL:\n%2$s + If you proceed, your client will be redirected to the following on-premises backend: + Backend name: + Backend URL: + Blacklist URL: + Teams URL: + Accounts URL: + Website URL: + Backend WSURL: Receiving new messages Text copied to clipboard Logs @@ -1390,4 +1397,5 @@ Share Location Allow Wire to access your device location to send your location. Please wait... + Location could not be shared diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml index f88480ae315..188c89b24bb 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -404,7 +404,7 @@ Saved File not available File upload failed - Do you want to open the file or save it to your + Do you want to open the file, or save it to your device\'s download folder? Open @@ -612,8 +612,8 @@ %1$s **participants** could not be added to the group. %1$s could not be added to the group. %1$s could not be added to the group. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. All devices are verified (end-to-end identity) All fingerprints are verified (Proteus) Communication in Wire is always end-to-end encrypted. Everything you send and receive in this conversation is only accessible to you and other group participants.\n**Please still be careful with who you share sensitive information.** @@ -726,8 +726,8 @@ Media Pictures Files - No pictures have been shared in this conversation yet 🥲 - No files have been shared in this conversation yet 🙀 + Nobody shared pictures in this conversation yet 🥲 + Nobody shared files in this conversation yet 🙀 CONTACTS New Group @@ -1007,7 +1007,14 @@ Open Profile You can\'t switch accounts while in a call Redirect to an on-premises backend? - If you proceed, your client will be redirected to the following on-premises backend:\n\nBackend name:\n%1$s\n\nBackend URL:\n%2$s + If you proceed, your client will be redirected to the following on-premises backend: + Backend name: + Backend URL: + Blacklist URL: + Teams URL: + Accounts URL: + Website URL: + Backend WSURL: Receiving new messages Text copied to clipboard Logs @@ -1362,4 +1369,5 @@ Share Location Allow Wire to access your device location to send your location. Please wait... + Location could not be shared diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index d29c49d5014..54cc4d5c16d 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -403,7 +403,7 @@ Saved File not available File upload failed - Do you want to open the file or save it to your + Do you want to open the file, or save it to your device\'s download folder? Open @@ -609,8 +609,8 @@ %1$s **participants** could not be added to the group. %1$s could not be added to the group. %1$s could not be added to the group. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. All devices are verified (end-to-end identity) All fingerprints are verified (Proteus) Communication in Wire is always end-to-end encrypted. Everything you send and receive in this conversation is only accessible to you and other group participants.\n**Please still be careful with who you share sensitive information.** @@ -710,8 +710,8 @@ Media Bilder Filer - No pictures have been shared in this conversation yet 🥲 - No files have been shared in this conversation yet 🙀 + Nobody shared pictures in this conversation yet 🥲 + Nobody shared files in this conversation yet 🙀 KONTAKTER Ny grupp @@ -989,7 +989,14 @@ Open Profile You can\'t switch accounts while in a call Redirect to an on-premises backend? - If you proceed, your client will be redirected to the following on-premises backend:\n\nBackend name:\n%1$s\n\nBackend URL:\n%2$s + If you proceed, your client will be redirected to the following on-premises backend: + Backend name: + Backend URL: + Blacklist URL: + Teams URL: + Accounts URL: + Website URL: + Backend WSURL: Receiving new messages Text copied to clipboard Loggar @@ -1328,10 +1335,11 @@ Call Anyway App permissions - Settings - Not Now + Inställningar + Inte nu Share Location Allow Wire to access your device location to send your location. Please wait... + Location could not be shared diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index f20a2211db5..a127da00fc1 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -403,7 +403,7 @@ Saved File not available File upload failed - Do you want to open the file or save it to your + Do you want to open the file, or save it to your device\'s download folder? Open @@ -609,8 +609,8 @@ %1$s **participants** could not be added to the group. %1$s could not be added to the group. %1$s could not be added to the group. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. All devices are verified (end-to-end identity) All fingerprints are verified (Proteus) Communication in Wire is always end-to-end encrypted. Everything you send and receive in this conversation is only accessible to you and other group participants.\n**Please still be careful with who you share sensitive information.** @@ -710,8 +710,8 @@ Media Pictures Files - No pictures have been shared in this conversation yet 🥲 - No files have been shared in this conversation yet 🙀 + Nobody shared pictures in this conversation yet 🥲 + Nobody shared files in this conversation yet 🙀 CONTACTS New Group @@ -989,7 +989,14 @@ Open Profile You can\'t switch accounts while in a call Redirect to an on-premises backend? - If you proceed, your client will be redirected to the following on-premises backend:\n\nBackend name:\n%1$s\n\nBackend URL:\n%2$s + If you proceed, your client will be redirected to the following on-premises backend: + Backend name: + Backend URL: + Blacklist URL: + Teams URL: + Accounts URL: + Website URL: + Backend WSURL: Receiving new messages Text copied to clipboard Logs @@ -1334,4 +1341,5 @@ Share Location Allow Wire to access your device location to send your location. Please wait... + Location could not be shared diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 0c223940d19..0fb22659d3f 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -405,7 +405,7 @@ Saved File not available File upload failed - Do you want to open the file or save it to your + Do you want to open the file, or save it to your device\'s download folder? Open @@ -615,8 +615,8 @@ %1$s **participants** could not be added to the group. %1$s could not be added to the group. %1$s could not be added to the group. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. All devices are verified (end-to-end identity) All fingerprints are verified (Proteus) Communication in Wire is always end-to-end encrypted. Everything you send and receive in this conversation is only accessible to you and other group participants.\n**Please still be careful with who you share sensitive information.** @@ -742,8 +742,8 @@ Media Pictures Files - No pictures have been shared in this conversation yet 🥲 - No files have been shared in this conversation yet 🙀 + Nobody shared pictures in this conversation yet 🥲 + Nobody shared files in this conversation yet 🙀 CONTACTS New Group @@ -1025,7 +1025,14 @@ Open Profile You can\'t switch accounts while in a call Redirect to an on-premises backend? - If you proceed, your client will be redirected to the following on-premises backend:\n\nBackend name:\n%1$s\n\nBackend URL:\n%2$s + If you proceed, your client will be redirected to the following on-premises backend: + Backend name: + Backend URL: + Blacklist URL: + Teams URL: + Accounts URL: + Website URL: + Backend WSURL: Receiving new messages Text copied to clipboard Logs @@ -1390,4 +1397,5 @@ Share Location Allow Wire to access your device location to send your location. Please wait... + Location could not be shared diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 3ede90b32a2..a2340e3f407 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -402,7 +402,7 @@ Saved File not available File upload failed - Do you want to open the file or save it to your + Do you want to open the file, or save it to your device\'s download folder? Open @@ -606,8 +606,8 @@ %1$s **participants** could not be added to the group. %1$s could not be added to the group. %1$s could not be added to the group. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. All devices are verified (end-to-end identity) All fingerprints are verified (Proteus) Communication in Wire is always end-to-end encrypted. Everything you send and receive in this conversation is only accessible to you and other group participants.\n**Please still be careful with who you share sensitive information.** @@ -694,8 +694,8 @@ Media Pictures Files - No pictures have been shared in this conversation yet 🥲 - No files have been shared in this conversation yet 🙀 + Nobody shared pictures in this conversation yet 🥲 + Nobody shared files in this conversation yet 🙀 CONTACTS New Group @@ -971,7 +971,14 @@ Open Profile You can\'t switch accounts while in a call Redirect to an on-premises backend? - If you proceed, your client will be redirected to the following on-premises backend:\n\nBackend name:\n%1$s\n\nBackend URL:\n%2$s + If you proceed, your client will be redirected to the following on-premises backend: + Backend name: + Backend URL: + Blacklist URL: + Teams URL: + Accounts URL: + Website URL: + Backend WSURL: Receiving new messages Text copied to clipboard Logs @@ -1306,4 +1313,5 @@ Share Location Allow Wire to access your device location to send your location. Please wait... + Location could not be shared diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index c4628b3f439..6c7340fea2e 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -402,7 +402,7 @@ Saved File not available File upload failed - Do you want to open the file or save it to your + Do you want to open the file, or save it to your device\'s download folder? Open @@ -606,8 +606,8 @@ %1$s **participants** could not be added to the group. %1$s could not be added to the group. %1$s could not be added to the group. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. All devices are verified (end-to-end identity) All fingerprints are verified (Proteus) Communication in Wire is always end-to-end encrypted. Everything you send and receive in this conversation is only accessible to you and other group participants.\n**Please still be careful with who you share sensitive information.** @@ -694,8 +694,8 @@ Media Pictures Files - No pictures have been shared in this conversation yet 🥲 - No files have been shared in this conversation yet 🙀 + Nobody shared pictures in this conversation yet 🥲 + Nobody shared files in this conversation yet 🙀 CONTACTS New Group @@ -971,7 +971,14 @@ Open Profile You can\'t switch accounts while in a call Redirect to an on-premises backend? - If you proceed, your client will be redirected to the following on-premises backend:\n\nBackend name:\n%1$s\n\nBackend URL:\n%2$s + If you proceed, your client will be redirected to the following on-premises backend: + Backend name: + Backend URL: + Blacklist URL: + Teams URL: + Accounts URL: + Website URL: + Backend WSURL: Receiving new messages Text copied to clipboard Logs @@ -1306,4 +1313,5 @@ Share Location Allow Wire to access your device location to send your location. Please wait... + Location could not be shared diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2b256620c5f..d50b8a80171 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -199,6 +199,7 @@ API VERSIONING E2EI Manual Enrollment Force API versioning update + Dependencies: Update Support Back up & Restore Conversations @@ -226,6 +227,7 @@ Wire is independently audited and ISO, CCPA, GDPR, SOX-compliant Create a Team Backend name:\n%1$s\n\nBackend URL:\n%2$s + Backend name:\n%1$s\n\nBackend URL:\n%2$s\n\nProxy URL:\n%3$s\n\nProxy authentication:\n%4$s On-premises Backend Welcome To Our New Android App 👋 We rebuilt the app to make it more usable for everyone.\n\nFind out more about Wire’s redesigned app—new options and improved accessibility, with the same strong security. @@ -624,8 +626,8 @@ %1$s **participants** could not be added to the group. %1$s could not be added to the group. %1$s could not be added to the group. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. + This conversation is no longer verified, as at least one participant started using a new device or has an invalid certificate. All devices are verified (end-to-end identity) All fingerprints are verified (Proteus) Communication in Wire is always end-to-end encrypted. Everything you send and receive in this conversation is only accessible to you and other group participants.\n**Please still be careful with who you share sensitive information.** @@ -727,8 +729,8 @@ Media Pictures Files - No pictures have been shared in this conversation yet 🥲 - No files have been shared in this conversation yet 🙀 + Nobody shared pictures in this conversation yet 🥲 + Nobody shared files in this conversation yet 🙀 CONTACTS New Group @@ -787,6 +789,7 @@ HorizontalBouncingWritingPen transition Collapse button rotation degree transition Open Conversation + Start Conversation Email Phone @@ -842,6 +845,7 @@ Start a call Are you sure you want to call %1$s people? Call + Name not available Return to call Decrypting messages @@ -868,6 +872,9 @@ Ignore Get certainty about the identity of %s\'s before connecting. Please verify the person\'s identity before accepting the connection request. + + Unable to start conversation + You can’t start the conversation with %1$s right now. %1$s needs to open Wire or log in again first. Please try again later. Media Gallery Saved to Downloads folder @@ -1009,7 +1016,16 @@ Open Profile You can\'t switch accounts while in a call Redirect to an on-premises backend? - If you proceed, your client will be redirected to the following on-premises backend:\n\nBackend name:\n%1$s\n\nBackend URL:\n%2$s + If you proceed, your client will be redirected to the following on-premises backend: + Backend name: + Backend URL: + Proxy URL: + Proxy authentication: + Blacklist URL: + Teams URL: + Accounts URL: + Website URL: + Backend WSURL: Receiving new messages Text copied to clipboard Logs @@ -1266,7 +1282,13 @@ Certificate updated The certificate is updated and your device is verified. Certificate Details + Certificate couldn’t be issued + Please try again, or reach out to your team admin. Certificate Details + End-to-end certificate revoked + Log out to reduce security risks. Then log in again, get a new certificate, and reset your password.\n\nIf you keep using this device, your conversations are no longer verified. + Log out + Continue Using This Device Start Recording Recording Audio… @@ -1280,6 +1302,7 @@ Recording Stopped File size for audio messages is limited to %1$d MB. You can’t record an audio message during a call. + Something went wrong while trying to record audio message. Please try again. App permission To make a call, allow Wire to access your microphone in your device settings. Not Now @@ -1360,4 +1383,5 @@ Share Location Allow Wire to access your device location to send your location. Please wait... + Location could not be shared diff --git a/app/src/nonfree/kotlin/com/wire/android/initializer/FirebaseInitializer.kt b/app/src/nonfree/kotlin/com/wire/android/initializer/FirebaseInitializer.kt new file mode 100644 index 00000000000..2a5f62a33ad --- /dev/null +++ b/app/src/nonfree/kotlin/com/wire/android/initializer/FirebaseInitializer.kt @@ -0,0 +1,40 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.initializer + +import android.content.Context +import androidx.startup.Initializer +import com.google.firebase.FirebaseApp +import com.google.firebase.FirebaseOptions +import com.wire.android.BuildConfig +import com.wire.android.util.extension.isGoogleServicesAvailable + +class FirebaseInitializer : Initializer { + override fun create(context: Context) { + if (context.isGoogleServicesAvailable()) { + val firebaseOptions = FirebaseOptions.Builder() + .setApplicationId(BuildConfig.FIREBASE_APP_ID) + .setGcmSenderId(BuildConfig.FIREBASE_PUSH_SENDER_ID) + .setApiKey(BuildConfig.GOOGLE_API_KEY) + .setProjectId(BuildConfig.FCM_PROJECT_ID) + .build() + FirebaseApp.initializeApp(context, firebaseOptions) + } + } + override fun dependencies(): List>> = emptyList() // no dependencies on other libraries +} diff --git a/app/src/main/kotlin/com/wire/android/services/WireFirebaseMessagingService.kt b/app/src/nonfree/kotlin/com/wire/android/services/WireFirebaseMessagingService.kt similarity index 100% rename from app/src/main/kotlin/com/wire/android/services/WireFirebaseMessagingService.kt rename to app/src/nonfree/kotlin/com/wire/android/services/WireFirebaseMessagingService.kt diff --git a/app/src/nonfree/kotlin/com/wire/android/ui/home/messagecomposer/location/LocationPickerHelperFlavor.kt b/app/src/nonfree/kotlin/com/wire/android/ui/home/messagecomposer/location/LocationPickerHelperFlavor.kt new file mode 100644 index 00000000000..829aa040835 --- /dev/null +++ b/app/src/nonfree/kotlin/com/wire/android/ui/home/messagecomposer/location/LocationPickerHelperFlavor.kt @@ -0,0 +1,64 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.home.messagecomposer.location + +import android.annotation.SuppressLint +import android.content.Context +import android.location.Geocoder +import com.google.android.gms.location.LocationServices +import com.google.android.gms.location.Priority +import com.google.android.gms.tasks.CancellationTokenSource +import com.wire.android.util.extension.isGoogleServicesAvailable +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.tasks.await + +@Singleton +class LocationPickerHelperFlavor @Inject constructor(context: Context) : LocationPickerHelper(context) { + + suspend fun getLocation(onSuccess: (GeoLocatedAddress) -> Unit, onError: () -> Unit) { + if (context.isGoogleServicesAvailable()) { + getLocationWithGms( + onSuccess = onSuccess, + onError = onError + ) + } else { + getLocationWithoutGms( + onSuccess = onSuccess, + onError = onError + ) + } + } + + /** + * Choosing the best location estimate by docs. + * https://developer.android.com/develop/sensors-and-location/location/retrieve-current#BestEstimate + */ + @SuppressLint("MissingPermission") + private suspend fun getLocationWithGms(onSuccess: (GeoLocatedAddress) -> Unit, onError: () -> Unit) { + if (isLocationServicesEnabled()) { + val locationProvider = LocationServices.getFusedLocationProviderClient(context) + val currentLocation = + locationProvider.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, CancellationTokenSource().token).await() + val address = Geocoder(context).getFromLocation(currentLocation.latitude, currentLocation.longitude, 1).orEmpty() + onSuccess(GeoLocatedAddress(address.firstOrNull(), currentLocation)) + } else { + onError() + } + } +} diff --git a/app/src/nonfree/kotlin/com/wire/android/util/extension/GoogleServices.kt b/app/src/nonfree/kotlin/com/wire/android/util/extension/GoogleServices.kt new file mode 100644 index 00000000000..151da8c0ddf --- /dev/null +++ b/app/src/nonfree/kotlin/com/wire/android/util/extension/GoogleServices.kt @@ -0,0 +1,31 @@ +/* + * Wire + * Copyright (C) 2023 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + * + */ + +package com.wire.android.util.extension + +import android.content.Context + +import com.google.android.gms.common.ConnectionResult +import com.google.android.gms.common.GoogleApiAvailability + +fun Context.isGoogleServicesAvailable(): Boolean { + val status = GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(this) + return status == ConnectionResult.SUCCESS +} diff --git a/app/src/staging/kotlin/com/wire/android/util/DataDogLogger.kt b/app/src/staging/kotlin/com/wire/android/util/DataDogLogger.kt index 8eaf3265325..055d776ff8f 100644 --- a/app/src/staging/kotlin/com/wire/android/util/DataDogLogger.kt +++ b/app/src/staging/kotlin/com/wire/android/util/DataDogLogger.kt @@ -29,19 +29,29 @@ object DataDogLogger : LogWriter() { private val logger = Logger.Builder() .setNetworkInfoEnabled(true) - .setLogcatLogsEnabled(true) .setLogcatLogsEnabled(false) // we already use platformLogWriter() along with DataDogLogger, don't need duplicates in LogCat + .setDatadogLogsEnabled(true) .setBundleWithTraceEnabled(true) .setLoggerName("DATADOG") .build() override fun log(severity: Severity, message: String, tag: String, throwable: Throwable?) { - val attributes = KaliumLogger.UserClientData.getFromTag(tag)?.let { userClientData -> - mapOf( - "userId" to userClientData.userId, - "clientId" to userClientData.clientId, - ) - } ?: emptyMap() - logger.log(severity.ordinal, message, throwable, attributes) + val logInfo = KaliumLogger.LogAttributes.getInfoFromTagString(tag) + val userAccountData = mapOf( + "userId" to logInfo.userClientData?.userId, + "clientId" to logInfo.userClientData?.clientId, + ) + val attributes = mapOf( + "wireAccount" to userAccountData, + "tag" to logInfo.textTag + ) + when (severity) { + Severity.Debug -> logger.d(message, throwable, attributes) + Severity.Info -> logger.i(message, throwable, attributes) + Severity.Warn -> logger.w(message, throwable, attributes) + Severity.Error -> logger.e(message, throwable, attributes) + Severity.Assert, + Severity.Verbose -> logger.v(message, throwable, attributes) + } } } diff --git a/app/src/test/kotlin/com/wire/android/GlobalObserversManagerTest.kt b/app/src/test/kotlin/com/wire/android/GlobalObserversManagerTest.kt index 3e75aac6c02..e9579c3534b 100644 --- a/app/src/test/kotlin/com/wire/android/GlobalObserversManagerTest.kt +++ b/app/src/test/kotlin/com/wire/android/GlobalObserversManagerTest.kt @@ -24,10 +24,18 @@ import com.wire.android.datastore.UserDataStoreProvider import com.wire.android.framework.TestUser import com.wire.android.notification.NotificationChannelsManager import com.wire.android.notification.WireNotificationManager +import com.wire.android.util.CurrentScreenManager +import com.wire.kalium.logic.CoreFailure import com.wire.kalium.logic.CoreLogic +import com.wire.kalium.logic.data.auth.AccountInfo import com.wire.kalium.logic.data.auth.PersistentWebSocketStatus +import com.wire.kalium.logic.data.logout.LogoutReason import com.wire.kalium.logic.data.team.Team import com.wire.kalium.logic.data.user.SelfUser +import com.wire.kalium.logic.feature.UserSessionScope +import com.wire.kalium.logic.feature.auth.LogoutCallbackManager +import com.wire.kalium.logic.feature.message.MessageScope +import com.wire.kalium.logic.feature.session.CurrentSessionResult import com.wire.kalium.logic.feature.user.webSocketStatus.ObservePersistentWebSocketConnectionStatusUseCase import io.mockk.MockKAnnotations import io.mockk.coEvery @@ -35,6 +43,7 @@ import io.mockk.coVerify import io.mockk.every import io.mockk.impl.annotations.MockK import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @@ -87,6 +96,73 @@ class GlobalObserversManagerTest { } } + @Test + fun `given app visible and valid session, when handling ephemeral messages, then call deleteEphemeralMessageEndDate`() { + val (arrangement, manager) = Arrangement() + .withCurrentSessionFlow(CurrentSessionResult.Success(AccountInfo.Valid(TestUser.SELF_USER.id))) + .withAppVisibleFlow(true) + .arrange() + manager.observe() + coVerify(exactly = 1) { arrangement.messageScope.deleteEphemeralMessageEndDate() } + } + + @Test + fun `given app not visible and valid session, when handling ephemeral messages, then do not call deleteEphemeralMessageEndDate`() { + val (arrangement, manager) = Arrangement() + .withCurrentSessionFlow(CurrentSessionResult.Success(AccountInfo.Valid(TestUser.SELF_USER.id))) + .withAppVisibleFlow(false) + .arrange() + manager.observe() + coVerify(exactly = 0) { arrangement.messageScope.deleteEphemeralMessageEndDate() } + } + + @Test + fun `given app visible and invalid session, when handling ephemeral messages, then do not call deleteEphemeralMessageEndDate`() { + val (arrangement, manager) = Arrangement() + .withCurrentSessionFlow(CurrentSessionResult.Success(AccountInfo.Invalid(TestUser.SELF_USER.id, LogoutReason.DELETED_ACCOUNT))) + .withAppVisibleFlow(true) + .arrange() + manager.observe() + coVerify(exactly = 0) { arrangement.messageScope.deleteEphemeralMessageEndDate() } + } + + @Test + fun `given app visible and no session, when handling ephemeral messages, then do not call deleteEphemeralMessageEndDate`() { + val (arrangement, manager) = Arrangement() + .withCurrentSessionFlow(CurrentSessionResult.Failure.SessionNotFound) + .withAppVisibleFlow(true) + .arrange() + manager.observe() + coVerify(exactly = 0) { arrangement.messageScope.deleteEphemeralMessageEndDate() } + } + + @Test + fun `given app visible and session failure, when handling ephemeral messages, then do not call deleteEphemeralMessageEndDate`() { + val (arrangement, manager) = Arrangement() + .withCurrentSessionFlow(CurrentSessionResult.Failure.Generic(CoreFailure.Unknown(RuntimeException("error")))) + .withAppVisibleFlow(true) + .arrange() + manager.observe() + coVerify(exactly = 0) { arrangement.messageScope.deleteEphemeralMessageEndDate() } + } + + @Test + fun `given validAccounts and persistentStatuses are out of sync, when setting up notifications, then ignore invalid users`() { + val validAccountsList = listOf(TestUser.SELF_USER) + val persistentStatusesList = listOf( + PersistentWebSocketStatus(TestUser.SELF_USER.id, false), + PersistentWebSocketStatus(TestUser.USER_ID.copy(value = "something else"), true) + ) + val (arrangement, manager) = Arrangement() + .withValidAccounts(validAccountsList.map { it to null }) + .withPersistentWebSocketConnectionStatuses(persistentStatusesList) + .arrange() + manager.observe() + coVerify(exactly = 1) { + arrangement.notificationChannelsManager.createUserNotificationChannels(listOf(TestUser.SELF_USER)) + } + } + private class Arrangement { @MockK @@ -101,6 +177,18 @@ class GlobalObserversManagerTest { @MockK lateinit var userDataStoreProvider: UserDataStoreProvider + @MockK + lateinit var currentScreenManager: CurrentScreenManager + + @MockK + lateinit var logoutCallbackManager: LogoutCallbackManager + + @MockK + lateinit var userSessionScope: UserSessionScope + + @MockK + lateinit var messageScope: MessageScope + private val manager by lazy { GlobalObserversManager( dispatcherProvider = TestDispatcherProvider(), @@ -108,6 +196,7 @@ class GlobalObserversManagerTest { notificationChannelsManager = notificationChannelsManager, notificationManager = notificationManager, userDataStoreProvider = userDataStoreProvider, + currentScreenManager = currentScreenManager, ) } @@ -118,6 +207,14 @@ class GlobalObserversManagerTest { // Default empty values mockUri() every { notificationChannelsManager.createUserNotificationChannels(any()) } returns Unit + every { coreLogic.getGlobalScope().logoutCallbackManager } returns logoutCallbackManager + every { coreLogic.getSessionScope(any()) } returns userSessionScope + every { userSessionScope.messages } returns messageScope + coEvery { messageScope.deleteEphemeralMessageEndDate() } returns Unit + withPersistentWebSocketConnectionStatuses(emptyList()) + withValidAccounts(emptyList()) + withCurrentSessionFlow(CurrentSessionResult.Failure.SessionNotFound) + withAppVisibleFlow(true) } fun withValidAccounts(list: List>): Arrangement = apply { @@ -129,6 +226,14 @@ class GlobalObserversManagerTest { ObservePersistentWebSocketConnectionStatusUseCase.Result.Success(flowOf(list)) } + fun withCurrentSessionFlow(result: CurrentSessionResult): Arrangement = apply { + coEvery { coreLogic.getGlobalScope().session.currentSessionFlow() } returns flowOf(result) + } + + fun withAppVisibleFlow(isVisible: Boolean) = apply { + coEvery { currentScreenManager.isAppVisibleFlow() } returns MutableStateFlow(isVisible) + } + fun arrange() = this to manager } } diff --git a/app/src/test/kotlin/com/wire/android/SelfDeletionTimerTest.kt b/app/src/test/kotlin/com/wire/android/SelfDeletionTimerTest.kt new file mode 100644 index 00000000000..e626114820a --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/SelfDeletionTimerTest.kt @@ -0,0 +1,496 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android + +import com.wire.android.ui.home.conversations.CurrentTimeProvider +import com.wire.android.ui.home.conversations.SelfDeletionTimerHelper +import com.wire.android.ui.home.conversations.StringResourceProvider +import com.wire.android.ui.home.conversations.StringResourceType +import com.wire.android.ui.home.conversations.model.ExpirationStatus +import com.wire.kalium.logic.data.message.Message +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.Instant +import org.junit.jupiter.api.Test +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +class SelfDeletionTimerTest { + + private val dispatcher = StandardTestDispatcher() + + @Test + fun givenTimeLeftIsAboveOneHour_whenGettingTheUpdateInterval_ThenIsEqualToMinutesLeftTillWholeHour() = runTest(dispatcher) { + val (_, selfDeletionTimerHelper) = Arrangement(dispatcher).arrange() + val selfDeletionTimer = selfDeletionTimerHelper.fromExpirationStatus( + ExpirationStatus.Expirable( + expireAfter = 23.hours + 30.minutes, + selfDeletionStatus = Message.ExpirationData.SelfDeletionStatus.NotStarted + ) + ) + assert(selfDeletionTimer is SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable) + val interval = (selfDeletionTimer as SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable).updateInterval() + assert(interval == 30.minutes) + } + + @Test + fun givenTimeLeftIsEqualToWholeHour_whenGettingTheUpdateInterval_ThenIsEqualToOneMinute() = runTest(dispatcher) { + val (_, selfDeletionTimerHelper) = Arrangement(dispatcher).arrange() + val selfDeletionTimer = selfDeletionTimerHelper.fromExpirationStatus( + ExpirationStatus.Expirable( + expireAfter = 23.hours, + selfDeletionStatus = Message.ExpirationData.SelfDeletionStatus.NotStarted + ) + ) + assert(selfDeletionTimer is SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable) + val interval = (selfDeletionTimer as SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable).updateInterval() + assert(interval == 1.hours) + } + + @Test + fun givenTimeLeftIsEqualToOneHour_whenGettingTheUpdateInterval_ThenIsEqualToOneMinute() = runTest(dispatcher) { + val (_, selfDeletionTimerHelper) = Arrangement(dispatcher).arrange() + val selfDeletionTimer = selfDeletionTimerHelper.fromExpirationStatus( + ExpirationStatus.Expirable( + expireAfter = 1.hours, + selfDeletionStatus = Message.ExpirationData.SelfDeletionStatus.NotStarted + ) + ) + assert(selfDeletionTimer is SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable) + val interval = (selfDeletionTimer as SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable).updateInterval() + assert(interval == 1.minutes) + } + + @Test + fun givenTimeLeftIsEqualToOneMinute_whenGettingTheUpdateInterval_ThenIsEqualToOneSeconds() = runTest(dispatcher) { + val (_, selfDeletionTimerHelper) = Arrangement(dispatcher).arrange() + val selfDeletionTimer = selfDeletionTimerHelper.fromExpirationStatus( + ExpirationStatus.Expirable( + expireAfter = 1.minutes, + selfDeletionStatus = Message.ExpirationData.SelfDeletionStatus.NotStarted + ) + ) + assert(selfDeletionTimer is SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable) + val interval = (selfDeletionTimer as SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable).updateInterval() + assert(interval == 1.seconds) + } + + @Test + fun givenTimeLeftIsEqualTo1Min10SecAnd900Millis_whenGettingTheUpdateInterval_ThenIsEqualTo10SecAnd900Millis() = runTest(dispatcher) { + val (_, selfDeletionTimerHelper) = Arrangement(dispatcher).arrange() + val selfDeletionTimer = selfDeletionTimerHelper.fromExpirationStatus( + ExpirationStatus.Expirable( + expireAfter = 1.minutes + 10.seconds + 900.milliseconds, + selfDeletionStatus = Message.ExpirationData.SelfDeletionStatus.NotStarted + ) + ) + assert(selfDeletionTimer is SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable) + val interval = (selfDeletionTimer as SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable).updateInterval() + assert(interval == 10.seconds + 900.milliseconds) + } + + @Test + fun givenTimeLeftIsEqualToThirtySeconds_whenGettingTheUpdateInterval_ThenIsEqualToOneSeconds() = runTest(dispatcher) { + val (_, selfDeletionTimerHelper) = Arrangement(dispatcher).arrange() + val selfDeletionTimer = selfDeletionTimerHelper.fromExpirationStatus( + ExpirationStatus.Expirable( + expireAfter = 30.seconds, + selfDeletionStatus = Message.ExpirationData.SelfDeletionStatus.NotStarted + ) + ) + assert(selfDeletionTimer is SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable) + val interval = (selfDeletionTimer as SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable).updateInterval() + assert(interval == 1.seconds) + } + + @Test + fun givenTimeLeftIsEqualToFiftyDays_whenGettingThTimeLeftFormatted_ThenIsEqualToFourWeeksLeft() = runTest(dispatcher) { + val (arrangement, selfDeletionTimerHelper) = Arrangement(dispatcher).arrange() + val selfDeletionTimer = selfDeletionTimerHelper.fromExpirationStatus( + ExpirationStatus.Expirable( + expireAfter = 50.days, + selfDeletionStatus = Message.ExpirationData.SelfDeletionStatus.NotStarted + ) + ) + assert(selfDeletionTimer is SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable) + val timeLeftLabel = (selfDeletionTimer as SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable).timeLeftFormatted + assert(timeLeftLabel == arrangement.stringsProvider.quantityString(StringResourceType.WEEKS, 4)) + } + + @Test + fun givenTimeLeftIsEqualToTwentySevenDays_whenGettingThTimeLeftFormatted_ThenIsEqualToFourWeeksLeft() = runTest(dispatcher) { + val (arrangement, selfDeletionTimerHelper) = Arrangement(dispatcher).arrange() + val selfDeletionTimer = selfDeletionTimerHelper.fromExpirationStatus( + ExpirationStatus.Expirable( + expireAfter = 27.days, + selfDeletionStatus = Message.ExpirationData.SelfDeletionStatus.NotStarted + ) + ) + assert(selfDeletionTimer is SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable) + val timeLeftLabel = (selfDeletionTimer as SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable).timeLeftFormatted + assert(timeLeftLabel == arrangement.stringsProvider.quantityString(StringResourceType.WEEKS, 4)) + } + + @Test + fun givenTimeLeftIsEqualTo27DaysAnd12Hours_whenGettingThTimeLeftFormatted_ThenIsEqualToFourWeeksLeft() = runTest(dispatcher) { + val (arrangement, selfDeletionTimerHelper) = Arrangement(dispatcher).arrange() + val selfDeletionTimer = selfDeletionTimerHelper.fromExpirationStatus( + ExpirationStatus.Expirable( + expireAfter = 27.days + 12.hours, + selfDeletionStatus = Message.ExpirationData.SelfDeletionStatus.NotStarted + ) + ) + assert(selfDeletionTimer is SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable) + val timeLeftLabel = (selfDeletionTimer as SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable).timeLeftFormatted + assert(timeLeftLabel == arrangement.stringsProvider.quantityString(StringResourceType.WEEKS, 4)) + } + + @Test + fun givenTimeLeftIsEqualTo27DaysAnd1Second_whenGettingThTimeLeftFormatted_ThenIsEqualToFourWeeksLeft() = runTest(dispatcher) { + val (arrangement, selfDeletionTimerHelper) = Arrangement(dispatcher).arrange() + val selfDeletionTimer = selfDeletionTimerHelper.fromExpirationStatus( + ExpirationStatus.Expirable( + expireAfter = 27.days + 1.seconds, + selfDeletionStatus = Message.ExpirationData.SelfDeletionStatus.NotStarted + ) + ) + assert(selfDeletionTimer is SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable) + val timeLeftLabel = (selfDeletionTimer as SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable).timeLeftFormatted + assert(timeLeftLabel == arrangement.stringsProvider.quantityString(StringResourceType.WEEKS, 4)) + } + + @Test + fun givenTimeLeftIsEqualTo28Days_whenGettingThTimeLeftFormatted_ThenIsEqualToFourWeeksLeft() = runTest(dispatcher) { + val (arrangement, selfDeletionTimerHelper) = Arrangement(dispatcher).arrange() + val selfDeletionTimer = selfDeletionTimerHelper.fromExpirationStatus( + ExpirationStatus.Expirable( + expireAfter = 28.days, + selfDeletionStatus = Message.ExpirationData.SelfDeletionStatus.NotStarted + ) + ) + assert(selfDeletionTimer is SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable) + val timeLeftLabel = (selfDeletionTimer as SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable).timeLeftFormatted + assert(timeLeftLabel == arrangement.stringsProvider.quantityString(StringResourceType.WEEKS, 4)) + } + + @Test + fun givenTimeLeftIsEqualTo21Days_whenGettingThTimeLeftFormatted_ThenIsEqualToTwentyOneLeft() = runTest(dispatcher) { + val (arrangement, selfDeletionTimerHelper) = Arrangement(dispatcher).arrange() + val selfDeletionTimer = selfDeletionTimerHelper.fromExpirationStatus( + ExpirationStatus.Expirable( + expireAfter = 21.days, + selfDeletionStatus = Message.ExpirationData.SelfDeletionStatus.NotStarted + ) + ) + assert(selfDeletionTimer is SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable) + val timeLeftLabel = (selfDeletionTimer as SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable).timeLeftFormatted + assert(timeLeftLabel == arrangement.stringsProvider.quantityString(StringResourceType.DAYS, 21)) + } + + @Test + fun givenTimeLeftIsEqualTo14Days_whenGettingThTimeLeftFormatted_ThenIsEqualToFourTeenDaysLeft() = runTest(dispatcher) { + val (arrangement, selfDeletionTimerHelper) = Arrangement(dispatcher).arrange() + val selfDeletionTimer = selfDeletionTimerHelper.fromExpirationStatus( + ExpirationStatus.Expirable( + expireAfter = 14.days, + selfDeletionStatus = Message.ExpirationData.SelfDeletionStatus.NotStarted + ) + ) + assert(selfDeletionTimer is SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable) + val timeLeftLabel = (selfDeletionTimer as SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable).timeLeftFormatted + assert(timeLeftLabel == arrangement.stringsProvider.quantityString(StringResourceType.DAYS, 14)) + } + + @Test + fun givenTimeLeftIsEqualTo20Days_whenGettingThTimeLeftFormatted_ThenIsEqualToTwentyDaysLeft() = runTest(dispatcher) { + val (arrangement, selfDeletionTimerHelper) = Arrangement(dispatcher).arrange() + val selfDeletionTimer = selfDeletionTimerHelper.fromExpirationStatus( + ExpirationStatus.Expirable( + expireAfter = 20.days, + selfDeletionStatus = Message.ExpirationData.SelfDeletionStatus.NotStarted + ) + ) + assert(selfDeletionTimer is SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable) + val timeLeftLabel = (selfDeletionTimer as SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable).timeLeftFormatted + assert(timeLeftLabel == arrangement.stringsProvider.quantityString(StringResourceType.DAYS, 20)) + } + + @Test + fun givenTimeLeftIsEqualToSevenDays_whenGettingThTimeLeftFormatted_ThenIsEqualToOneWeekLeft() = runTest(dispatcher) { + val (arrangement, selfDeletionTimerHelper) = Arrangement(dispatcher).arrange() + val selfDeletionTimer = selfDeletionTimerHelper.fromExpirationStatus( + ExpirationStatus.Expirable( + expireAfter = 7.days, + selfDeletionStatus = Message.ExpirationData.SelfDeletionStatus.NotStarted + ) + ) + assert(selfDeletionTimer is SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable) + val timeLeftLabel = (selfDeletionTimer as SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable).timeLeftFormatted + assert(timeLeftLabel == arrangement.stringsProvider.quantityString(StringResourceType.WEEKS, 1)) + } + + @Test + fun givenTimeLeftIsEqualToSixDays_whenGettingThTimeLeftFormatted_ThenIsEqualToOneWeekLeft() = runTest(dispatcher) { + val (arrangement, selfDeletionTimerHelper) = Arrangement(dispatcher).arrange() + val selfDeletionTimer = selfDeletionTimerHelper.fromExpirationStatus( + ExpirationStatus.Expirable( + expireAfter = 6.days, + selfDeletionStatus = Message.ExpirationData.SelfDeletionStatus.NotStarted + ) + ) + assert(selfDeletionTimer is SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable) + val timeLeftLabel = (selfDeletionTimer as SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable).timeLeftFormatted + assert(timeLeftLabel == arrangement.stringsProvider.quantityString(StringResourceType.WEEKS, 1)) + } + + @Test + fun givenTimeLeftIsEqualToSixDaysAnd12Hours_whenGettingThTimeLeftFormatted_ThenIsEqualToOneWeekLeft() = runTest(dispatcher) { + val (arrangement, selfDeletionTimerHelper) = Arrangement(dispatcher).arrange() + val selfDeletionTimer = selfDeletionTimerHelper.fromExpirationStatus( + ExpirationStatus.Expirable( + expireAfter = 6.days + 12.hours, + selfDeletionStatus = Message.ExpirationData.SelfDeletionStatus.NotStarted + ) + ) + assert(selfDeletionTimer is SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable) + val timeLeftLabel = (selfDeletionTimer as SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable).timeLeftFormatted + assert(timeLeftLabel == arrangement.stringsProvider.quantityString(StringResourceType.WEEKS, 1)) + } + + @Test + fun givenTimeLeftIsEqualToSixDaysAndOneSecond_whenGettingThTimeLeftFormatted_ThenIsEqualToOneWeekLeft() = runTest(dispatcher) { + val (arrangement, selfDeletionTimerHelper) = Arrangement(dispatcher).arrange() + val selfDeletionTimer = selfDeletionTimerHelper.fromExpirationStatus( + ExpirationStatus.Expirable( + expireAfter = 6.days + 1.seconds, + selfDeletionStatus = Message.ExpirationData.SelfDeletionStatus.NotStarted + ) + ) + assert(selfDeletionTimer is SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable) + val timeLeftLabel = (selfDeletionTimer as SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable).timeLeftFormatted + assert(timeLeftLabel == arrangement.stringsProvider.quantityString(StringResourceType.WEEKS, 1)) + } + + @Test + fun givenTimeLeftIsEqualToThirteenDays_whenGettingThTimeLeftFormatted_ThenIsEqualToThirteenDays() = runTest(dispatcher) { + val (arrangement, selfDeletionTimerHelper) = Arrangement(dispatcher).arrange() + val selfDeletionTimer = selfDeletionTimerHelper.fromExpirationStatus( + ExpirationStatus.Expirable( + expireAfter = 13.days, + selfDeletionStatus = Message.ExpirationData.SelfDeletionStatus.NotStarted + ) + ) + assert(selfDeletionTimer is SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable) + val timeLeftLabel = (selfDeletionTimer as SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable).timeLeftFormatted + assert(timeLeftLabel == arrangement.stringsProvider.quantityString(StringResourceType.DAYS, 13)) + } + + @Test + fun givenTimeLeftIsEqualToOneDay_whenGettingThTimeLeftFormatted_ThenIsEqualToOneDayLeft() = runTest(dispatcher) { + val (arrangement, selfDeletionTimerHelper) = Arrangement(dispatcher).arrange() + val selfDeletionTimer = selfDeletionTimerHelper.fromExpirationStatus( + ExpirationStatus.Expirable( + expireAfter = 1.days, + selfDeletionStatus = Message.ExpirationData.SelfDeletionStatus.NotStarted + ) + ) + assert(selfDeletionTimer is SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable) + val timeLeftLabel = (selfDeletionTimer as SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable).timeLeftFormatted + assert(timeLeftLabel == arrangement.stringsProvider.quantityString(StringResourceType.DAYS, 1)) + } + + @Test + fun givenTimeLeftIsEqualToTwentyFourHours_whenGettingThTimeLeftFormatted_ThenIsEqualToOneDayLeft() = runTest(dispatcher) { + val (arrangement, selfDeletionTimerHelper) = Arrangement(dispatcher).arrange() + val selfDeletionTimer = selfDeletionTimerHelper.fromExpirationStatus( + ExpirationStatus.Expirable( + expireAfter = 24.hours, + selfDeletionStatus = Message.ExpirationData.SelfDeletionStatus.NotStarted + ) + ) + assert(selfDeletionTimer is SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable) + val timeLeftLabel = (selfDeletionTimer as SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable).timeLeftFormatted + assert(timeLeftLabel == arrangement.stringsProvider.quantityString(StringResourceType.DAYS, 1)) + } + + @Test + fun givenTimeLeftIsEqualToTwentyThreeHours_whenGettingThTimeLeftFormatted_ThenIsEqualToTwentyThreeHourLeft() = runTest(dispatcher) { + val (arrangement, selfDeletionTimerHelper) = Arrangement(dispatcher).arrange() + val selfDeletionTimer = selfDeletionTimerHelper.fromExpirationStatus( + ExpirationStatus.Expirable( + expireAfter = 23.hours, + selfDeletionStatus = Message.ExpirationData.SelfDeletionStatus.NotStarted + ) + ) + assert(selfDeletionTimer is SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable) + val timeLeftLabel = (selfDeletionTimer as SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable).timeLeftFormatted + assert(timeLeftLabel == arrangement.stringsProvider.quantityString(StringResourceType.HOURS, 23)) + } + + @Test + fun givenTimeLeftIsEqualToSixtyMinutes_whenGettingThTimeLeftFormatted_ThenIsEqualToOneHourLeft() = runTest(dispatcher) { + val (arrangement, selfDeletionTimerHelper) = Arrangement(dispatcher).arrange() + val selfDeletionTimer = selfDeletionTimerHelper.fromExpirationStatus( + ExpirationStatus.Expirable( + expireAfter = 60.minutes, + selfDeletionStatus = Message.ExpirationData.SelfDeletionStatus.NotStarted + ) + ) + assert(selfDeletionTimer is SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable) + val timeLeftLabel = (selfDeletionTimer as SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable).timeLeftFormatted + assert(timeLeftLabel == arrangement.stringsProvider.quantityString(StringResourceType.HOURS, 1)) + } + + @Test + fun givenTimeLeftIsEqualToOneMinute_whenGettingThTimeLeftFormatted_ThenIsEqualToOneMinuteLeft() = runTest(dispatcher) { + val (arrangement, selfDeletionTimerHelper) = Arrangement(dispatcher).arrange() + val selfDeletionTimer = selfDeletionTimerHelper.fromExpirationStatus( + ExpirationStatus.Expirable( + expireAfter = 1.minutes, + selfDeletionStatus = Message.ExpirationData.SelfDeletionStatus.NotStarted + ) + ) + assert(selfDeletionTimer is SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable) + val timeLeftLabel = (selfDeletionTimer as SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable).timeLeftFormatted + assert(timeLeftLabel == arrangement.stringsProvider.quantityString(StringResourceType.MINUTES, 1)) + } + + @Test + fun givenTimeLeftIsEqualToOFiftyNineMinutes_whenGettingThTimeLeftFormatted_ThenIsEqualToFiftyNineMinutes() = runTest(dispatcher) { + val (arrangement, selfDeletionTimerHelper) = Arrangement(dispatcher).arrange() + val selfDeletionTimer = selfDeletionTimerHelper.fromExpirationStatus( + ExpirationStatus.Expirable( + expireAfter = 59.minutes, + selfDeletionStatus = Message.ExpirationData.SelfDeletionStatus.NotStarted + ) + ) + assert(selfDeletionTimer is SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable) + val timeLeftLabel = (selfDeletionTimer as SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable).timeLeftFormatted + assert(timeLeftLabel == arrangement.stringsProvider.quantityString(StringResourceType.MINUTES, 59)) + } + + @Test + fun givenTimeLeftIsEqualToSixtySeconds_whenGettingThTimeLeftFormatted_ThenIsEqualToOneMinute() = runTest(dispatcher) { + val (arrangement, selfDeletionTimerHelper) = Arrangement(dispatcher).arrange() + val selfDeletionTimer = selfDeletionTimerHelper.fromExpirationStatus( + ExpirationStatus.Expirable( + expireAfter = 60.seconds, + selfDeletionStatus = Message.ExpirationData.SelfDeletionStatus.NotStarted + ) + ) + assert(selfDeletionTimer is SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable) + val timeLeftLabel = (selfDeletionTimer as SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable).timeLeftFormatted + assert(timeLeftLabel == arrangement.stringsProvider.quantityString(StringResourceType.MINUTES, 1)) + } + + @Test + fun givenTimeLeftIs1DayAnd12Hours_whenRecalculatingTimeAfterIntervals_thenTimeLeftIsEqualToExpected() = runTest(dispatcher) { + val (arrangement, selfDeletionTimerHelper) = Arrangement(dispatcher).arrange() + val selfDeletionTimer = selfDeletionTimerHelper.fromExpirationStatus( + ExpirationStatus.Expirable( + expireAfter = 1.days + 12.hours, + selfDeletionStatus = Message.ExpirationData.SelfDeletionStatus.NotStarted + ) + ) + assert(selfDeletionTimer is SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable) + with(selfDeletionTimer as SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable) { + advanceTimeBy(updateInterval()) + recalculateTimeLeft() + assert(selfDeletionTimer.timeLeftFormatted == arrangement.stringsProvider.quantityString(StringResourceType.DAYS, 1)) + + advanceTimeBy(updateInterval()) + recalculateTimeLeft() + assert(selfDeletionTimer.timeLeftFormatted == arrangement.stringsProvider.quantityString(StringResourceType.HOURS, 23)) + } + } + + @Test + fun givenTimeLeftIs23HoursAnd23Minutes_whenRecalculatingTimeAfterIntervals_thenTimeLeftIsEqualToExpected() = runTest(dispatcher) { + val (arrangement, selfDeletionTimerHelper) = Arrangement(dispatcher).arrange() + val selfDeletionTimer = selfDeletionTimerHelper.fromExpirationStatus( + ExpirationStatus.Expirable( + expireAfter = 23.hours + 23.minutes, + selfDeletionStatus = Message.ExpirationData.SelfDeletionStatus.NotStarted + ) + ) + assert(selfDeletionTimer is SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable) + with(selfDeletionTimer as SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable) { + advanceTimeBy(updateInterval()) + recalculateTimeLeft() + assert(selfDeletionTimer.timeLeftFormatted == arrangement.stringsProvider.quantityString(StringResourceType.HOURS, 23)) + } + } + + @Test + fun givenTimeLeftIs1HourAnd12Minutes_whenRecalculatingTimeAfterIntervals_thenTimeLeftIsEqualToExpected() = runTest(dispatcher) { + val (arrangement, selfDeletionTimerHelper) = Arrangement(dispatcher).arrange() + val selfDeletionTimer = selfDeletionTimerHelper.fromExpirationStatus( + ExpirationStatus.Expirable( + expireAfter = 1.hours + 12.minutes, + selfDeletionStatus = Message.ExpirationData.SelfDeletionStatus.NotStarted + ) + ) + assert(selfDeletionTimer is SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable) + with(selfDeletionTimer as SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable) { + advanceTimeBy(updateInterval()) + recalculateTimeLeft() + assert(selfDeletionTimer.timeLeftFormatted == arrangement.stringsProvider.quantityString(StringResourceType.HOURS, 1)) + + advanceTimeBy(updateInterval()) + recalculateTimeLeft() + assert(selfDeletionTimer.timeLeftFormatted == arrangement.stringsProvider.quantityString(StringResourceType.MINUTES, 59)) + } + } + + @Test + fun givenTimeLeftIs1HourAnd23Seconds_whenRecalculatingTimeAfterIntervals_thenTimeLeftIsEqualToExpected() = runTest(dispatcher) { + val (arrangement, selfDeletionTimerHelper) = Arrangement(dispatcher).arrange() + val selfDeletionTimer = selfDeletionTimerHelper.fromExpirationStatus( + ExpirationStatus.Expirable( + expireAfter = 1.minutes + 23.seconds, + selfDeletionStatus = Message.ExpirationData.SelfDeletionStatus.NotStarted + ) + ) + assert(selfDeletionTimer is SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable) + with(selfDeletionTimer as SelfDeletionTimerHelper.SelfDeletionTimerState.Expirable) { + advanceTimeBy(updateInterval()) + recalculateTimeLeft() + assert(selfDeletionTimer.timeLeftFormatted == arrangement.stringsProvider.quantityString(StringResourceType.MINUTES, 1)) + + advanceTimeBy(updateInterval()) + recalculateTimeLeft() + assert(selfDeletionTimer.timeLeftFormatted == arrangement.stringsProvider.quantityString(StringResourceType.SECONDS, 59)) + } + } + + internal class Arrangement(val dispatcher: TestDispatcher) { + + val stringsProvider: StringResourceProvider = object : StringResourceProvider { + override fun quantityString(type: StringResourceType, quantity: Int): String = "${type.name}: $quantity" + } + private val currentTime: CurrentTimeProvider = { Instant.fromEpochMilliseconds(dispatcher.scheduler.currentTime) } + + private val selfDeletionTimerHelper by lazy { SelfDeletionTimerHelper(stringsProvider, currentTime) } + fun arrange() = this to selfDeletionTimerHelper + } +} diff --git a/app/src/test/kotlin/com/wire/android/feature/AccountSwitchUseCaseTest.kt b/app/src/test/kotlin/com/wire/android/feature/AccountSwitchUseCaseTest.kt index 0e8364e5bbb..b89654c3853 100644 --- a/app/src/test/kotlin/com/wire/android/feature/AccountSwitchUseCaseTest.kt +++ b/app/src/test/kotlin/com/wire/android/feature/AccountSwitchUseCaseTest.kt @@ -55,6 +55,7 @@ class AccountSwitchUseCaseTest { val (arrangement, switchAccount) = Arrangement(testScope) .withGetCurrentSession(CurrentSessionResult.Success(ACCOUNT_VALID_1)) + .withGetAllSessions(GetAllSessionsResult.Success(listOf(ACCOUNT_VALID_1, ACCOUNT_VALID_2))) .withUpdateCurrentSession(UpdateCurrentSessionUseCase.Result.Success) .arrange() @@ -78,7 +79,7 @@ class AccountSwitchUseCaseTest { Arrangement(testScope) .withGetCurrentSession(CurrentSessionResult.Success(ACCOUNT_VALID_1)) .withUpdateCurrentSession(UpdateCurrentSessionUseCase.Result.Success) - .withGetAllSessions(GetAllSessionsResult.Success(emptyList())) + .withGetAllSessions(GetAllSessionsResult.Success(listOf(ACCOUNT_VALID_1))) .withServerConfigForAccount(ServerConfigForAccountUseCase.Result.Success(serverConfig)) .arrange() @@ -95,7 +96,7 @@ class AccountSwitchUseCaseTest { @Test fun givenCurrentSessionIsInvalid_whenSwitchingToAccount_thenUpdateCurrentSessionAndDeleteTheOldOne() = testScope.runTest { val currentAccount = ACCOUNT_INVALID_3 - val switchTO = ACCOUNT_VALID_2 + val switchTo = ACCOUNT_VALID_2 val expectedResult = SwitchAccountResult.SwitchedToAnotherAccount @@ -103,21 +104,55 @@ class AccountSwitchUseCaseTest { Arrangement(testScope) .withGetCurrentSession(CurrentSessionResult.Success(currentAccount)) .withUpdateCurrentSession(UpdateCurrentSessionUseCase.Result.Success) - .withGetAllSessions(GetAllSessionsResult.Success(emptyList())) + .withGetAllSessions(GetAllSessionsResult.Success(listOf(currentAccount, switchTo))) .withDeleteSession(currentAccount.userId, DeleteSessionUseCase.Result.Success) .arrange() - val result = switchAccount(SwitchAccountParam.SwitchToAccount(switchTO.userId)) + val result = switchAccount(SwitchAccountParam.SwitchToAccount(switchTo.userId)) testScope.advanceUntilIdle() assertEquals(expectedResult, result) coVerify(exactly = 1) { arrangement.currentSession() - arrangement.updateCurrentSession(switchTO.userId) + arrangement.updateCurrentSession(switchTo.userId) arrangement.deleteSession(currentAccount.userId) } } + @Test + fun givenProvidedAccountIsNotFound_whenSwitchingToAccount_thenReturnGivenAccountIsInvalid() = testScope.runTest { + val (arrangement, switchAccount) = + Arrangement(testScope) + .withGetCurrentSession(CurrentSessionResult.Success(ACCOUNT_VALID_1)) + .withGetAllSessions(GetAllSessionsResult.Success(listOf(ACCOUNT_VALID_1))) + .arrange() + + val result = switchAccount(SwitchAccountParam.SwitchToAccount(ACCOUNT_VALID_2.userId)) + + assertEquals(SwitchAccountResult.GivenAccountIsInvalid, result) + coVerify(exactly = 1) { + arrangement.currentSession() + arrangement.getSessions() + } + } + + @Test + fun givenProvidedAccountIsNotValid_whenSwitchingToAccount_thenReturnGivenAccountIsInvalid() = testScope.runTest { + val (arrangement, switchAccount) = + Arrangement(testScope) + .withGetCurrentSession(CurrentSessionResult.Success(ACCOUNT_VALID_1)) + .withGetAllSessions(GetAllSessionsResult.Success(listOf(ACCOUNT_VALID_1, ACCOUNT_INVALID_3))) + .arrange() + + val result = switchAccount(SwitchAccountParam.SwitchToAccount(ACCOUNT_INVALID_3.userId)) + + assertEquals(SwitchAccountResult.GivenAccountIsInvalid, result) + coVerify(exactly = 1) { + arrangement.currentSession() + arrangement.getSessions() + } + } + private companion object { val ACCOUNT_VALID_1 = AccountInfo.Valid(UserId("userId_valid_1", "domain_valid_1")) val ACCOUNT_VALID_2 = AccountInfo.Valid(UserId("userId_valid_2", "domain_valid_2")) diff --git a/app/src/test/kotlin/com/wire/android/feature/ObserveAppLockConfigUseCaseTest.kt b/app/src/test/kotlin/com/wire/android/feature/ObserveAppLockConfigUseCaseTest.kt index e0caa52dfdf..c566f67c9f7 100644 --- a/app/src/test/kotlin/com/wire/android/feature/ObserveAppLockConfigUseCaseTest.kt +++ b/app/src/test/kotlin/com/wire/android/feature/ObserveAppLockConfigUseCaseTest.kt @@ -22,6 +22,7 @@ import com.wire.android.datastore.GlobalDataStore import com.wire.kalium.logic.CoreLogic import com.wire.kalium.logic.configuration.AppLockTeamConfig import com.wire.kalium.logic.data.auth.AccountInfo +import com.wire.kalium.logic.data.logout.LogoutReason import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.feature.UserSessionScope import com.wire.kalium.logic.feature.applock.AppLockTeamFeatureConfigObserver @@ -54,6 +55,22 @@ class ObserveAppLockConfigUseCaseTest { } } + @Test + fun givenInvalidSession_whenObservingAppLock_thenSendDisabledStatus() = runTest { + val (_, useCase) = Arrangement() + .withInvalidSession() + .arrange() + + val result = useCase.invoke() + + result.test { + val appLockStatus = awaitItem() + + assertEquals(AppLockConfig.Disabled(timeout), appLockStatus) + awaitComplete() + } + } + @Test fun givenValidSessionAndAppLockedByTeam_whenObservingAppLock_thenSendEnforcedByTeamStatus() = runTest { @@ -142,6 +159,11 @@ class ObserveAppLockConfigUseCaseTest { flowOf(CurrentSessionResult.Failure.SessionNotFound) } + fun withInvalidSession() = apply { + coEvery { coreLogic.getGlobalScope().session.currentSessionFlow() } returns + flowOf(CurrentSessionResult.Success(accountInfoInvalid)) + } + fun withValidSession() = apply { coEvery { coreLogic.getGlobalScope().session.currentSessionFlow() } returns flowOf(CurrentSessionResult.Success(accountInfo)) @@ -177,7 +199,9 @@ class ObserveAppLockConfigUseCaseTest { } companion object { - private val accountInfo = AccountInfo.Valid(UserId("userId", "domain")) + private val userId = UserId("userId", "domain") + private val accountInfo = AccountInfo.Valid(userId) + private val accountInfoInvalid = AccountInfo.Invalid(userId, LogoutReason.DELETED_ACCOUNT) private val timeout = 60.seconds } } diff --git a/app/src/test/kotlin/com/wire/android/feature/ShouldStartPersistentWebSocketServiceUseCaseTest.kt b/app/src/test/kotlin/com/wire/android/feature/ShouldStartPersistentWebSocketServiceUseCaseTest.kt new file mode 100644 index 00000000000..e4ac6157a8d --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/feature/ShouldStartPersistentWebSocketServiceUseCaseTest.kt @@ -0,0 +1,157 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.feature + +import com.wire.kalium.logic.CoreLogic +import com.wire.kalium.logic.data.auth.PersistentWebSocketStatus +import com.wire.kalium.logic.data.user.UserId +import com.wire.kalium.logic.feature.user.webSocketStatus.ObservePersistentWebSocketConnectionStatusUseCase +import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import org.amshove.kluent.internal.assertEquals +import org.junit.jupiter.api.Assertions.assertInstanceOf +import org.junit.jupiter.api.Test + +class ShouldStartPersistentWebSocketServiceUseCaseTest { + + @Test + fun givenObservePersistentWebSocketStatusReturnsSuccessAndThereAreUsersWithPersistentFlagOn_whenInvoking_shouldReturnSuccessTrue() = + runTest { + // given + val (_, useCase) = Arrangement() + .withObservePersistentWebSocketConnectionStatusSuccess(flowOf(listOf(PersistentWebSocketStatus(userId, true)))) + .arrange() + // when + val result = useCase.invoke() + // then + assertInstanceOf(ShouldStartPersistentWebSocketServiceUseCase.Result.Success::class.java, result).also { + assertEquals(true, it.shouldStartPersistentWebSocketService) + } + } + + @Test + fun givenObservePersistentWebSocketStatusReturnsSuccessAndThereAreNoUsersWithPersistentFlagOn_whenInvoking_shouldReturnSuccessFalse() = + runTest { + // given + val (_, useCase) = Arrangement() + .withObservePersistentWebSocketConnectionStatusSuccess(flowOf(listOf(PersistentWebSocketStatus(userId, false)))) + .arrange() + // when + val result = useCase.invoke() + // then + assertInstanceOf(ShouldStartPersistentWebSocketServiceUseCase.Result.Success::class.java, result).also { + assertEquals(false, it.shouldStartPersistentWebSocketService) + } + } + + @Test + fun givenObservePersistentWebSocketStatusReturnsSuccessAndThereAreNoUsers_whenInvoking_shouldReturnSuccessFalse() = + runTest { + // given + val (_, useCase) = Arrangement() + .withObservePersistentWebSocketConnectionStatusSuccess(flowOf(emptyList())) + .arrange() + // when + val result = useCase.invoke() + // then + assertInstanceOf(ShouldStartPersistentWebSocketServiceUseCase.Result.Success::class.java, result).also { + assertEquals(false, it.shouldStartPersistentWebSocketService) + } + } + + @Test + fun givenObservePersistentWebSocketStatusReturnsSuccessAndTheFlowIsEmpty_whenInvoking_shouldReturnSuccessFalse() = + runTest { + // given + val (_, useCase) = Arrangement() + .withObservePersistentWebSocketConnectionStatusSuccess(emptyFlow()) + .arrange() + // when + val result = useCase.invoke() + // then + assertInstanceOf(ShouldStartPersistentWebSocketServiceUseCase.Result.Success::class.java, result).also { + assertEquals(false, it.shouldStartPersistentWebSocketService) + } + } + + @Test + fun givenObservePersistentWebSocketStatusReturnsSuccessAndFlowTimesOut_whenInvoking_shouldReturnSuccessFalse() = + runTest { + // given + val sharedFlow = MutableSharedFlow>() // shared flow doesn't close so we can test the timeout + val (_, useCase) = Arrangement() + .withObservePersistentWebSocketConnectionStatusSuccess(sharedFlow) + .arrange() + // when + val result = useCase.invoke() + advanceTimeBy(ShouldStartPersistentWebSocketServiceUseCase.TIMEOUT + 1000L) + // then + assertInstanceOf(ShouldStartPersistentWebSocketServiceUseCase.Result.Success::class.java, result).also { + assertEquals(false, it.shouldStartPersistentWebSocketService) + } + } + + @Test + fun givenObservePersistentWebSocketStatusReturnsFailure_whenInvoking_shouldReturnFailure() = + runTest { + // given + val (_, useCase) = Arrangement() + .withObservePersistentWebSocketConnectionStatusFailure() + .arrange() + // when + val result = useCase.invoke() + // then + assertInstanceOf(ShouldStartPersistentWebSocketServiceUseCase.Result.Failure::class.java, result) + } + + inner class Arrangement { + + @MockK + private lateinit var coreLogic: CoreLogic + + val useCase by lazy { + ShouldStartPersistentWebSocketServiceUseCase(coreLogic) + } + + init { + MockKAnnotations.init(this, relaxUnitFun = true) + } + + fun arrange() = this to useCase + + fun withObservePersistentWebSocketConnectionStatusSuccess(flow: Flow>) = apply { + coEvery { coreLogic.getGlobalScope().observePersistentWebSocketConnectionStatus() } returns + ObservePersistentWebSocketConnectionStatusUseCase.Result.Success(flow) + } + fun withObservePersistentWebSocketConnectionStatusFailure() = apply { + coEvery { coreLogic.getGlobalScope().observePersistentWebSocketConnectionStatus() } returns + ObservePersistentWebSocketConnectionStatusUseCase.Result.Failure.StorageFailure + } + } + + companion object { + private val userId = UserId("userId", "domain") + } +} diff --git a/app/src/test/kotlin/com/wire/android/feature/StartPersistentWebsocketIfNecessaryUseCaseTest.kt b/app/src/test/kotlin/com/wire/android/feature/StartPersistentWebsocketIfNecessaryUseCaseTest.kt new file mode 100644 index 00000000000..0288e4bae7e --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/feature/StartPersistentWebsocketIfNecessaryUseCaseTest.kt @@ -0,0 +1,86 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.feature + +import android.content.ComponentName +import android.content.Context +import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test + +class StartPersistentWebsocketIfNecessaryUseCaseTest { + + @Test + fun givenShouldStartPersistentWebsocketTrue_whenInvoking_thenStartService() = + runTest { + // given + val (arrangement, sut) = Arrangement() + .withShouldStartPersistentWebsocketServiceResult(true) + .arrange() + + // when + sut.invoke() + + // then + verify(exactly = 1) { arrangement.applicationContext.startService(any()) } + } + + @Test + fun givenShouldStartPersistentWebsocketFalse_whenInvoking_thenDONTStartService() = + runTest { + // given + val (arrangement, sut) = Arrangement() + .withShouldStartPersistentWebsocketServiceResult(false) + .arrange() + + // when + sut.invoke() + + // then + verify(exactly = 0) { arrangement.applicationContext.startService(any()) } + } + + inner class Arrangement { + + @MockK + lateinit var shouldStartPersistentWebSocketServiceUseCase: ShouldStartPersistentWebSocketServiceUseCase + + @MockK + lateinit var applicationContext: Context + + init { + MockKAnnotations.init(this, relaxUnitFun = true) + every { applicationContext.startService(any()) } returns ComponentName.createRelative("dummy", "class") + every { applicationContext.stopService(any()) } returns true + } + + fun arrange() = this to StartPersistentWebsocketIfNecessaryUseCase( + applicationContext, + shouldStartPersistentWebSocketServiceUseCase + ) + + fun withShouldStartPersistentWebsocketServiceResult(shouldStart: Boolean) = apply { + coEvery { shouldStartPersistentWebSocketServiceUseCase.invoke() } returns + ShouldStartPersistentWebSocketServiceUseCase.Result.Success(shouldStart) + } + } +} diff --git a/app/src/test/kotlin/com/wire/android/mapper/MessageMapperTest.kt b/app/src/test/kotlin/com/wire/android/mapper/MessageMapperTest.kt index d7a2b2e91ba..abfa517dea1 100644 --- a/app/src/test/kotlin/com/wire/android/mapper/MessageMapperTest.kt +++ b/app/src/test/kotlin/com/wire/android/mapper/MessageMapperTest.kt @@ -60,16 +60,14 @@ class MessageMapperTest { fun givenMessagesList_whenGettingMemberIdList_thenReturnCorrectList() = runTest { // Given val (_, mapper) = Arrangement().arrange() - val clientMessageAuthor = UserId("client-id", "client-domain") - val serverMessageAuthor = UserId("server-id", "server-domain") + val removedUserId = UserId("server-id", "server-domain") val messages = listOf( - TestMessage.TEXT_MESSAGE.copy(senderUserId = clientMessageAuthor), + TestMessage.TEXT_MESSAGE, TestMessage.MEMBER_REMOVED_MESSAGE.copy( - senderUserId = serverMessageAuthor, - content = MessageContent.MemberChange.Removed(listOf(serverMessageAuthor)) + content = MessageContent.MemberChange.Removed(listOf(removedUserId)) ) ) - val expected = listOf(clientMessageAuthor, serverMessageAuthor) + val expected = listOf(removedUserId) // When val list = mapper.memberIdList(messages) // Then diff --git a/app/src/test/kotlin/com/wire/android/migration/MigrateServerConfigUseCaseTest.kt b/app/src/test/kotlin/com/wire/android/migration/MigrateServerConfigUseCaseTest.kt index 7509e8c1e8c..5b12c4da989 100644 --- a/app/src/test/kotlin/com/wire/android/migration/MigrateServerConfigUseCaseTest.kt +++ b/app/src/test/kotlin/com/wire/android/migration/MigrateServerConfigUseCaseTest.kt @@ -27,7 +27,8 @@ import com.wire.kalium.logic.CoreLogic import com.wire.kalium.logic.GlobalKaliumScope import com.wire.kalium.logic.StorageFailure import com.wire.kalium.logic.configuration.server.ServerConfig -import com.wire.kalium.logic.feature.server.FetchApiVersionResult +import com.wire.kalium.logic.feature.auth.AuthenticationScope +import com.wire.kalium.logic.feature.auth.autoVersioningAuth.AutoVersionAuthScopeUseCase import com.wire.kalium.logic.feature.server.GetServerConfigResult import com.wire.kalium.logic.feature.server.StoreServerConfigResult import com.wire.kalium.logic.functional.Either @@ -59,7 +60,6 @@ class MigrateServerConfigUseCaseTest { .arrange() val result = useCase() coVerify(exactly = 1) { arrangement.globalKaliumScope.storeServerConfig(expected.links, versionInfo) } - coVerify { arrangement.globalKaliumScope.fetchApiVersion(any()) wasNot Called } assert(result.isRight()) assertEquals(expected, (result as Either.Right).value) } @@ -69,10 +69,10 @@ class MigrateServerConfigUseCaseTest { val expected = Arrangement.serverConfig val (arrangement, useCase) = Arrangement() .withScalaServerConfig(ScalaServerConfig.Links(expected.links)) - .withFetchApiVersionResult(FetchApiVersionResult.Success(expected)) + .withCurrentServerConfig(expected) .arrange() + val result = useCase() - coVerify(exactly = 1) { arrangement.globalKaliumScope.fetchApiVersion(expected.links) } assert(result.isRight()) assertEquals(expected, (result as Either.Right).value) } @@ -84,11 +84,11 @@ class MigrateServerConfigUseCaseTest { val (arrangement, useCase) = Arrangement() .withScalaServerConfig(ScalaServerConfig.ConfigUrl(customConfigUrl)) .withFetchServerConfigFromDeepLinkResult(GetServerConfigResult.Success(expected.links)) - .withFetchApiVersionResult(FetchApiVersionResult.Success(expected)) + .withCurrentServerConfig(expected) .arrange() + val result = useCase() coVerify(exactly = 1) { arrangement.globalKaliumScope.fetchServerConfigFromDeepLink(customConfigUrl) } - coVerify(exactly = 1) { arrangement.globalKaliumScope.fetchApiVersion(expected.links) } assert(result.isRight()) assertEquals(expected, (result as Either.Right).value) } @@ -107,8 +107,10 @@ class MigrateServerConfigUseCaseTest { private class Arrangement { @MockK lateinit var coreLogic: CoreLogic + @MockK lateinit var scalaServerConfigDAO: ScalaServerConfigDAO + @MockK lateinit var globalKaliumScope: GlobalKaliumScope @@ -116,27 +118,37 @@ class MigrateServerConfigUseCaseTest { MigrateServerConfigUseCase(coreLogic, scalaServerConfigDAO) } + @MockK + lateinit var autoVersionAuthScopeUseCase: AutoVersionAuthScopeUseCase + + @MockK + lateinit var authScope: AuthenticationScope + init { MockKAnnotations.init(this, relaxUnitFun = true) every { coreLogic.getGlobalScope() } returns globalKaliumScope + every { coreLogic.versionedAuthenticationScope(any()) } returns autoVersionAuthScopeUseCase + coEvery { autoVersionAuthScopeUseCase(any()) } returns AutoVersionAuthScopeUseCase.Result.Success(authScope) + } + + fun withCurrentServerConfig(serverConfig: ServerConfig) = apply { + every { authScope.currentServerConfig() } returns serverConfig } fun withScalaServerConfig(scalaServerConfig: ScalaServerConfig): Arrangement { every { scalaServerConfigDAO.scalaServerConfig } returns scalaServerConfig return this } - fun withStoreServerConfigResult(result : StoreServerConfigResult): Arrangement { + + fun withStoreServerConfigResult(result: StoreServerConfigResult): Arrangement { coEvery { globalKaliumScope.storeServerConfig(any(), any()) } returns result return this } - fun withFetchServerConfigFromDeepLinkResult(result : GetServerConfigResult): Arrangement { + + fun withFetchServerConfigFromDeepLinkResult(result: GetServerConfigResult): Arrangement { coEvery { globalKaliumScope.fetchServerConfigFromDeepLink(any()) } returns result return this } - fun withFetchApiVersionResult(result : FetchApiVersionResult): Arrangement { - coEvery { globalKaliumScope.fetchApiVersion(any()) } returns result - return this - } fun arrange() = this to useCase diff --git a/app/src/test/kotlin/com/wire/android/model/ImageAssetTest.kt b/app/src/test/kotlin/com/wire/android/model/ImageAssetTest.kt index 76dd4baef92..a7cf6427b9f 100644 --- a/app/src/test/kotlin/com/wire/android/model/ImageAssetTest.kt +++ b/app/src/test/kotlin/com/wire/android/model/ImageAssetTest.kt @@ -19,7 +19,6 @@ package com.wire.android.model import android.net.Uri -import androidx.core.net.toUri import com.wire.android.util.ui.WireSessionImageLoader import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.data.user.UserAssetId @@ -28,6 +27,8 @@ import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.mockk import io.mockk.mockkStatic +import okio.Path +import okio.Path.Companion.toPath import org.amshove.kluent.shouldBeEqualTo import org.amshove.kluent.shouldNotBeEqualTo import org.junit.jupiter.api.BeforeEach @@ -46,21 +47,21 @@ class ImageAssetTest { every { Uri.parse(any()) } returns mockUri } - fun createUserAvatarAsset(userAssetId: UserAssetId) = ImageAsset.UserAvatarAsset( + private fun createUserAvatarAsset(userAssetId: UserAssetId) = ImageAsset.UserAvatarAsset( imageLoader, userAssetId ) - fun createPrivateAsset( + private fun createPrivateAsset( conversationId: ConversationId, messageId: String, isSelfAsset: Boolean ) = ImageAsset.PrivateAsset(imageLoader, conversationId, messageId, isSelfAsset) - fun createLocalAsset( - dataUri: Uri, + private fun createLocalAsset( + dataPath: Path, imageKey: String ): ImageAsset.LocalImageAsset { - return ImageAsset.LocalImageAsset(imageLoader, dataUri, imageKey) + return ImageAsset.LocalImageAsset(imageLoader, dataPath, imageKey) } @Test @@ -138,7 +139,7 @@ class ImageAssetTest { @Test fun givenEqualUriAndKeyLocalAssets_whenGettingUniqueKey_thenResultsShouldBeEqual() { val assetKey = "assetKey" - val localAssetUri = "local-uri".toUri() + val localAssetUri = "local-uri".toPath() val subject1 = createLocalAsset( localAssetUri, @@ -154,7 +155,7 @@ class ImageAssetTest { @Test fun givenSameUriButDifferentKeyLocalAssets_whenGettingUniqueKey_thenResultsShouldBeDifferent() { - val assetUri = "assetUri".toUri() + val assetUri = "assetUri".toPath() val assetKey = "assetKey" val baseSubject = createLocalAsset( @@ -171,8 +172,8 @@ class ImageAssetTest { @Test fun givenSameKeyButDifferentUriLocalAssets_whenGettingUniqueKey_thenResultsShouldBeTheSame() { - val assetUri1 = "assetUri1".toUri() - val assetUri2 = "assetUri2".toUri() + val assetUri1 = "assetUri1".toPath() + val assetUri2 = "assetUri2".toPath() val assetKey = "assetKey" val baseSubject = createLocalAsset( diff --git a/app/src/test/kotlin/com/wire/android/notification/WireNotificationManagerTest.kt b/app/src/test/kotlin/com/wire/android/notification/WireNotificationManagerTest.kt index 8c837d07f5f..5d8c0f2d868 100644 --- a/app/src/test/kotlin/com/wire/android/notification/WireNotificationManagerTest.kt +++ b/app/src/test/kotlin/com/wire/android/notification/WireNotificationManagerTest.kt @@ -55,6 +55,7 @@ import com.wire.kalium.logic.feature.message.MessageScope import com.wire.kalium.logic.feature.message.Result import com.wire.kalium.logic.feature.session.CurrentSessionFlowUseCase import com.wire.kalium.logic.feature.session.CurrentSessionResult +import com.wire.kalium.logic.feature.session.DoesValidSessionExistResult import com.wire.kalium.logic.feature.session.GetAllSessionsResult import com.wire.kalium.logic.feature.session.GetSessionsUseCase import com.wire.kalium.logic.feature.user.E2EIRequiredResult @@ -83,6 +84,7 @@ import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlinx.datetime.Instant +import org.amshove.kluent.internal.assertEquals import org.junit.jupiter.api.Test import kotlin.time.Duration.Companion.minutes @@ -696,6 +698,51 @@ class WireNotificationManagerTest { } } + @Test + fun givenSessionExistsForTheUserAndNoActiveJobs_whenGettingUsersToObserve_thenReturnThatUser() = + runTest(dispatcherProvider.main()) { + // given + val userId = provideUserId() + val (_, manager) = Arrangement() + .withDoesValidSessionExistResult(userId, DoesValidSessionExistResult.Success(true)) + .arrange() + val hasActiveJobs: (UserId) -> Boolean = { false } + // when + val result = manager.newUsersWithValidSessionAndWithoutActiveJobs(listOf(userId), hasActiveJobs) + // then + assertEquals(listOf(userId), result) + } + + @Test + fun givenSessionExistsForTheUserButWithActiveJobs_whenGettingUsersToObserve_thenDoNotReturnThatUser() = + runTest(dispatcherProvider.main()) { + // given + val userId = provideUserId() + val (_, manager) = Arrangement() + .withDoesValidSessionExistResult(userId, DoesValidSessionExistResult.Success(true)) + .arrange() + val hasActiveJobs: (UserId) -> Boolean = { true } + // when + val result = manager.newUsersWithValidSessionAndWithoutActiveJobs(listOf(userId), hasActiveJobs) + // then + assertEquals(listOf(), result) + } + + @Test + fun givenSessionDoesNotExistForTheUserAndNoActiveJobs_whenGettingUsersToObserve_thenDoNotReturnThatUser() = + runTest(dispatcherProvider.main()) { + // given + val userId = provideUserId() + val (_, manager) = Arrangement() + .withDoesValidSessionExistResult(userId, DoesValidSessionExistResult.Success(false)) + .arrange() + val hasActiveJobs: (UserId) -> Boolean = { false } + // when + val result = manager.newUsersWithValidSessionAndWithoutActiveJobs(listOf(userId), hasActiveJobs) + // then + assertEquals(listOf(), result) + } + private inner class Arrangement { @MockK lateinit var coreLogic: CoreLogic @@ -813,6 +860,7 @@ class WireNotificationManagerTest { every { servicesManager.startOngoingCallService() } returns Unit every { servicesManager.stopOngoingCallService() } returns Unit every { pingRinger.ping(any(), any()) } returns Unit + coEvery { globalKaliumScope.doesValidSessionExist.invoke(any()) } returns DoesValidSessionExistResult.Success(true) } private fun mockSpecificUserSession( @@ -890,6 +938,10 @@ class WireNotificationManagerTest { coEvery { observeE2EIRequired.invoke() } returns flowOf(result) } + fun withDoesValidSessionExistResult(userId: UserId, result: DoesValidSessionExistResult) = apply { + coEvery { globalKaliumScope.doesValidSessionExist.invoke(userId) } returns result + } + fun arrange() = this to wireNotificationManager } diff --git a/app/src/test/kotlin/com/wire/android/ui/WireActivityViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/WireActivityViewModelTest.kt index 7c163531275..4e3905c7ba6 100644 --- a/app/src/test/kotlin/com/wire/android/ui/WireActivityViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/WireActivityViewModelTest.kt @@ -21,11 +21,14 @@ package com.wire.android.ui import android.content.Intent +import androidx.work.WorkManager +import androidx.work.impl.OperationImpl import com.wire.android.config.CoroutineTestExtension import com.wire.android.config.TestDispatcherProvider import com.wire.android.config.mockUri import com.wire.android.datastore.GlobalDataStore import com.wire.android.di.AuthServerConfigProvider +import com.wire.android.di.ObserveIfE2EIRequiredDuringLoginUseCaseProvider import com.wire.android.di.ObserveScreenshotCensoringConfigUseCaseProvider import com.wire.android.di.ObserveSyncStateUseCaseProvider import com.wire.android.feature.AccountSwitchUseCase @@ -588,6 +591,7 @@ class WireActivityViewModelTest { } private class Arrangement { + init { // Tests setup MockKAnnotations.init(this, relaxUnitFun = true) @@ -608,6 +612,10 @@ class WireActivityViewModelTest { coEvery { observeScreenshotCensoringConfigUseCase() } returns flowOf(ObserveScreenshotCensoringConfigResult.Disabled) coEvery { currentScreenManager.observeCurrentScreen(any()) } returns MutableStateFlow(CurrentScreen.SomeOther) coEvery { globalDataStore.selectedThemeOptionFlow() } returns flowOf(ThemeOption.LIGHT) + coEvery { observeIfE2EIRequiredDuringLoginUseCaseProviderFactory.create(any()).observeIfE2EIIsRequiredDuringLogin() } returns + flowOf(false) + every { workManager.cancelAllWorkByTag(any()) } returns OperationImpl() + every { workManager.enqueueUniquePeriodicWork(any(), any(), any()) } returns OperationImpl() } @MockK @@ -663,9 +671,15 @@ class WireActivityViewModelTest { @MockK private lateinit var observeScreenshotCensoringConfigUseCaseProviderFactory: ObserveScreenshotCensoringConfigUseCaseProvider.Factory + @MockK + private lateinit var observeIfE2EIRequiredDuringLoginUseCaseProviderFactory: ObserveIfE2EIRequiredDuringLoginUseCaseProvider.Factory + @MockK lateinit var globalDataStore: GlobalDataStore + @MockK + lateinit var workManager: WorkManager + @MockK(relaxed = true) lateinit var onDeepLinkResult: (DeepLinkResult) -> Unit @@ -691,7 +705,9 @@ class WireActivityViewModelTest { clearNewClientsForUser = clearNewClientsForUser, currentScreenManager = currentScreenManager, observeScreenshotCensoringConfigUseCaseProviderFactory = observeScreenshotCensoringConfigUseCaseProviderFactory, - globalDataStore = globalDataStore + globalDataStore = globalDataStore, + observeIfE2EIRequiredDuringLoginUseCaseProviderFactory = observeIfE2EIRequiredDuringLoginUseCaseProviderFactory, + workManager = workManager ) } @@ -755,6 +771,7 @@ class WireActivityViewModelTest { fun withCurrentScreen(currentScreenFlow: StateFlow) = apply { coEvery { currentScreenManager.observeCurrentScreen(any()) } returns currentScreenFlow + coEvery { coreLogic.getSessionScope(TEST_ACCOUNT_INFO.userId).observeIfE2EIRequiredDuringLogin() } returns flowOf(false) } suspend fun withScreenshotCensoringConfig(result: ObserveScreenshotCensoringConfigResult) = apply { diff --git a/app/src/test/kotlin/com/wire/android/ui/authentication/login/email/LoginEmailViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/authentication/login/email/LoginEmailViewModelTest.kt index 71d5c8d9dbf..c4c079e269f 100644 --- a/app/src/test/kotlin/com/wire/android/ui/authentication/login/email/LoginEmailViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/authentication/login/email/LoginEmailViewModelTest.kt @@ -116,7 +116,7 @@ class LoginEmailViewModelTest { private lateinit var authenticationScope: AuthenticationScope @MockK(relaxed = true) - private lateinit var onSuccess: (Boolean) -> Unit + private lateinit var onSuccess: (Boolean, Boolean) -> Unit private lateinit var loginViewModel: LoginEmailViewModel @@ -210,7 +210,7 @@ class LoginEmailViewModelTest { advanceUntilIdle() coVerify(exactly = 1) { loginUseCase(any(), any(), any(), any(), any()) } coVerify(exactly = 1) { getOrRegisterClientUseCase(any()) } - coVerify(exactly = 1) { onSuccess(true) } + coVerify(exactly = 1) { onSuccess(true, false) } } @Test @@ -233,7 +233,7 @@ class LoginEmailViewModelTest { advanceUntilIdle() coVerify(exactly = 1) { loginUseCase(any(), any(), any(), any(), any()) } coVerify(exactly = 1) { getOrRegisterClientUseCase(any()) } - coVerify(exactly = 1) { onSuccess(false) } + coVerify(exactly = 1) { onSuccess(false, false) } } @Test @@ -440,7 +440,7 @@ class LoginEmailViewModelTest { advanceUntilIdle() coVerify(exactly = 1) { loginUseCase(email, any(), any(), any(), code) } coVerify(exactly = 1) { getOrRegisterClientUseCase(any()) } - coVerify(exactly = 1) { onSuccess(any()) } + coVerify(exactly = 1) { onSuccess(any(), any()) } } @Test diff --git a/app/src/test/kotlin/com/wire/android/ui/authentication/login/sso/LoginSSOViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/authentication/login/sso/LoginSSOViewModelTest.kt index f99282e6a33..3d4ca18db66 100644 --- a/app/src/test/kotlin/com/wire/android/ui/authentication/login/sso/LoginSSOViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/authentication/login/sso/LoginSSOViewModelTest.kt @@ -121,7 +121,7 @@ class LoginSSOViewModelTest { private lateinit var fetchSSOSettings: FetchSSOSettingsUseCase @MockK(relaxed = true) - private lateinit var onSuccess: (Boolean) -> Unit + private lateinit var onSuccess: (Boolean, Boolean) -> Unit private lateinit var loginViewModel: LoginSSOViewModel @@ -139,7 +139,7 @@ class LoginSSOViewModelTest { authServerConfigProvider.updateAuthServer(newServerConfig(1).links) coEvery { - autoVersionAuthScopeUseCase() + autoVersionAuthScopeUseCase(null) } returns AutoVersionAuthScopeUseCase.Result.Success( authenticationScope ) @@ -262,7 +262,7 @@ class LoginSSOViewModelTest { coVerify(exactly = 1) { getSSOLoginSessionUseCase(any()) } coVerify(exactly = 1) { getOrRegisterClientUseCase(any()) } coVerify(exactly = 1) { addAuthenticatedUserUseCase(any(), any(), any(), any()) } - coVerify(exactly = 1) { onSuccess(false) } + coVerify(exactly = 1) { onSuccess(false, false) } } @Test @@ -288,7 +288,7 @@ class LoginSSOViewModelTest { coVerify(exactly = 1) { getSSOLoginSessionUseCase(any()) } coVerify(exactly = 1) { getOrRegisterClientUseCase(any()) } coVerify(exactly = 1) { addAuthenticatedUserUseCase(any(), any(), any(), any()) } - coVerify(exactly = 1) { onSuccess(true) } + coVerify(exactly = 1) { onSuccess(true, false) } } @Test @@ -312,7 +312,7 @@ class LoginSSOViewModelTest { coVerify(exactly = 1) { getSSOLoginSessionUseCase(any()) } coVerify(exactly = 0) { loginViewModel.registerClient(any(), null) } coVerify(exactly = 0) { addAuthenticatedUserUseCase(any(), any(), any(), any()) } - verify(exactly = 0) { onSuccess(any()) } + verify(exactly = 0) { onSuccess(any(), any()) } } @Test @@ -353,7 +353,7 @@ class LoginSSOViewModelTest { loginViewModel.handleSSOResult(DeepLinkResult.SSOLogin.Success("", ""), onSuccess) advanceUntilIdle() - verify(exactly = 1) { onSuccess(any()) } + verify(exactly = 1) { onSuccess(any(), any()) } } @Test @@ -376,7 +376,7 @@ class LoginSSOViewModelTest { coVerify(exactly = 1) { getSSOLoginSessionUseCase(any()) } coVerify(exactly = 0) { loginViewModel.registerClient(any(), null) } coVerify(exactly = 1) { addAuthenticatedUserUseCase(any(), any(), any(), any()) } - verify(exactly = 0) { onSuccess(any()) } + verify(exactly = 0) { onSuccess(any(), any()) } } @Test @@ -405,7 +405,7 @@ class LoginSSOViewModelTest { coVerify(exactly = 1) { getOrRegisterClientUseCase(any()) } coVerify(exactly = 1) { getSSOLoginSessionUseCase(any()) } coVerify(exactly = 1) { addAuthenticatedUserUseCase(any(), any(), any(), any()) } - verify(exactly = 0) { onSuccess(any()) } + verify(exactly = 0) { onSuccess(any(), any()) } } @Test diff --git a/app/src/test/kotlin/com/wire/android/ui/calling/OngoingCallViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/calling/OngoingCallViewModelTest.kt index 37fa4e544ae..ddb7a36e4ff 100644 --- a/app/src/test/kotlin/com/wire/android/ui/calling/OngoingCallViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/calling/OngoingCallViewModelTest.kt @@ -31,12 +31,14 @@ import com.wire.android.util.CurrentScreenManager import com.wire.kalium.logic.data.call.Call import com.wire.kalium.logic.data.call.CallClient import com.wire.kalium.logic.data.call.CallStatus +import com.wire.kalium.logic.data.call.VideoState import com.wire.kalium.logic.data.conversation.Conversation import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.data.id.QualifiedID import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.feature.call.usecase.ObserveEstablishedCallsUseCase import com.wire.kalium.logic.feature.call.usecase.RequestVideoStreamsUseCase +import com.wire.kalium.logic.feature.call.usecase.video.SetVideoSendStateUseCase import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.coVerify @@ -68,6 +70,9 @@ class OngoingCallViewModelTest { @MockK private lateinit var currentScreenManager: CurrentScreenManager + @MockK + private lateinit var setVideoSendState: SetVideoSendStateUseCase + @MockK private lateinit var globalDataStore: GlobalDataStore @@ -80,6 +85,7 @@ class OngoingCallViewModelTest { coEvery { establishedCall.invoke() } returns flowOf(listOf(provideCall())) coEvery { currentScreenManager.observeCurrentScreen(any()) } returns MutableStateFlow(CurrentScreen.SomeOther) coEvery { globalDataStore.getShouldShowDoubleTapToast(any()) } returns false + coEvery { setVideoSendState.invoke(any(), any()) } returns Unit ongoingCallViewModel = OngoingCallViewModel( savedStateHandle = savedStateHandle, @@ -87,10 +93,25 @@ class OngoingCallViewModelTest { requestVideoStreams = requestVideoStreams, currentScreenManager = currentScreenManager, currentUserId = currentUserId, + setVideoSendState = setVideoSendState, globalDataStore = globalDataStore, ) } + @Test + fun givenAnOngoingCall_WhenTurningOnCamera_ThenSetVideoSendStateToStarted() = runTest { + ongoingCallViewModel.startSendingVideoFeed() + + coVerify(exactly = 1) { setVideoSendState.invoke(any(), VideoState.STARTED) } + } + + @Test + fun givenAnOngoingCall_WhenTurningOffCamera_ThenSetVideoSendStateToStopped() = runTest { + ongoingCallViewModel.stopSendingVideoFeed() + + coVerify { setVideoSendState.invoke(any(), VideoState.STOPPED) } + } + @Test fun givenParticipantsList_WhenRequestingVideoStream_ThenRequestItForOnlyParticipantsWithVideoEnabled() = runTest { val expectedClients = listOf( diff --git a/app/src/test/kotlin/com/wire/android/ui/calling/SharedCallingViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/calling/SharedCallingViewModelTest.kt index 4fae4db752c..82e7ffe8208 100644 --- a/app/src/test/kotlin/com/wire/android/ui/calling/SharedCallingViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/calling/SharedCallingViewModelTest.kt @@ -42,7 +42,7 @@ import com.wire.kalium.logic.feature.call.usecase.SetVideoPreviewUseCase import com.wire.kalium.logic.feature.call.usecase.TurnLoudSpeakerOffUseCase import com.wire.kalium.logic.feature.call.usecase.TurnLoudSpeakerOnUseCase import com.wire.kalium.logic.feature.call.usecase.UnMuteCallUseCase -import com.wire.kalium.logic.feature.call.usecase.UpdateVideoStateUseCase +import com.wire.kalium.logic.feature.call.usecase.video.UpdateVideoStateUseCase import com.wire.kalium.logic.feature.conversation.ObserveConversationDetailsUseCase import io.mockk.MockKAnnotations import io.mockk.coEvery @@ -261,6 +261,7 @@ class SharedCallingViewModelTest { advanceUntilIdle() sharedCallingViewModel.callState.isCameraOn shouldBeEqualTo false + coVerify(exactly = 1) { updateVideoState(any(), VideoState.STOPPED) } } @Test @@ -272,6 +273,7 @@ class SharedCallingViewModelTest { advanceUntilIdle() sharedCallingViewModel.callState.isCameraOn shouldBeEqualTo true + coVerify(exactly = 1) { updateVideoState(any(), VideoState.STARTED) } } @Test @@ -315,57 +317,24 @@ class SharedCallingViewModelTest { } @Test - fun `given an active call, when setVideoPreview is called, then set the video preview and update video state to STARTED`() = + fun `given a call, when setVideoPreview is called, then set the video preview`() = runTest { coEvery { setVideoPreview(any(), any()) } returns Unit - coEvery { updateVideoState(any(), any()) } returns Unit sharedCallingViewModel.setVideoPreview(view) advanceUntilIdle() coVerify(exactly = 2) { setVideoPreview(any(), any()) } - coVerify(exactly = 1) { updateVideoState(any(), VideoState.STARTED) } } @Test - fun `given an active call, when clearVideoPreview is called, then update video state to STOPPED`() = - runTest { - coEvery { setVideoPreview(any(), any()) } returns Unit - coEvery { updateVideoState(any(), any()) } returns Unit - - sharedCallingViewModel.clearVideoPreview() - advanceUntilIdle() - - coVerify(exactly = 1) { updateVideoState(any(), VideoState.STOPPED) } - } - - @Test - fun `given a video call, when stopping video, then clear Video Preview and turn off speaker`() = - runTest { - sharedCallingViewModel.callState = - sharedCallingViewModel.callState.copy(isCameraOn = true) - coEvery { setVideoPreview(any(), any()) } returns Unit - coEvery { updateVideoState(any(), any()) } returns Unit - coEvery { turnLoudSpeakerOff() } returns Unit - - sharedCallingViewModel.stopVideo() - advanceUntilIdle() - - coVerify(exactly = 1) { setVideoPreview(any(), any()) } - coVerify(exactly = 1) { turnLoudSpeakerOff() } - } - - @Test - fun `given an audio call, when stopVideo is invoked, then do not do anything`() = runTest { - sharedCallingViewModel.callState = sharedCallingViewModel.callState.copy(isCameraOn = false) + fun `given a call, when clearVideoPreview is called, then clear view`() = runTest { coEvery { setVideoPreview(any(), any()) } returns Unit - coEvery { turnLoudSpeakerOff() } returns Unit - sharedCallingViewModel.stopVideo() + sharedCallingViewModel.clearVideoPreview() advanceUntilIdle() - coVerify(inverse = true) { setVideoPreview(any(), any()) } - coVerify(inverse = true) { turnLoudSpeakerOff() } + coVerify(exactly = 1) { setVideoPreview(any(), any()) } } companion object { diff --git a/app/src/test/kotlin/com/wire/android/ui/connection/ConnectionActionButtonViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/connection/ConnectionActionButtonViewModelTest.kt index c9c350c6352..ad8f66719c9 100644 --- a/app/src/test/kotlin/com/wire/android/ui/connection/ConnectionActionButtonViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/connection/ConnectionActionButtonViewModelTest.kt @@ -248,13 +248,14 @@ class ConnectionActionButtonViewModelTest { .arrange() // when - viewModel.onOpenConversation(arrangement.onOpenConversation) + viewModel.onOpenConversation(arrangement.onOpenConversation, arrangement.onMissingKeyPackages) // then coVerify { arrangement.getOrCreateOneToOneConversation(TestUser.USER_ID) } verify { arrangement.onOpenConversation(any()) } + verify { arrangement.onMissingKeyPackages wasNot Called } } @Test @@ -266,13 +267,33 @@ class ConnectionActionButtonViewModelTest { .arrange() // when - viewModel.onOpenConversation(arrangement.onOpenConversation) + viewModel.onOpenConversation(arrangement.onOpenConversation, arrangement.onMissingKeyPackages) // then coVerify { arrangement.getOrCreateOneToOneConversation(TestUser.USER_ID) } verify { arrangement.onOpenConversation wasNot Called } + verify { arrangement.onMissingKeyPackages wasNot Called } + } + + @Test + fun `given a conversationId, when trying to open the conversation and fails with MissingKeyPackages, then call MissingKeyPackage()`() = + runTest { + // given + val (arrangement, viewModel) = ConnectionActionButtonHiltArrangement() + .withGetOneToOneConversation(CreateConversationResult.Failure(CoreFailure.MissingKeyPackages(setOf()))) + .arrange() + + // when + viewModel.onOpenConversation(arrangement.onOpenConversation, arrangement.onMissingKeyPackages) + + // then + coVerify { + arrangement.getOrCreateOneToOneConversation(TestUser.USER_ID) + } + verify { arrangement.onOpenConversation wasNot Called } + verify { arrangement.onMissingKeyPackages() } } companion object { @@ -315,6 +336,9 @@ internal class ConnectionActionButtonHiltArrangement { @MockK(relaxed = true) lateinit var onOpenConversation: (conversationId: ConversationId) -> Unit + @MockK(relaxed = true) + lateinit var onMissingKeyPackages: () -> Unit + private val viewModel by lazy { ConnectionActionButtonViewModelImpl( TestDispatcherProvider(), diff --git a/app/src/test/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockScreenViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockScreenViewModelTest.kt index 3171b629f9f..582005956f1 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockScreenViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockScreenViewModelTest.kt @@ -159,7 +159,6 @@ class ForgotLockScreenViewModelTest { val logoutActionsCalledExactly = if (userLogoutActionsCalled) 1 else 0 coVerify(exactly = logoutActionsCalledExactly) { logoutUseCase(any(), any()) } coVerify(exactly = logoutActionsCalledExactly) { notificationManager.stopObservingOnLogout(any()) } - coVerify(exactly = logoutActionsCalledExactly) { notificationChannelsManager.deleteChannelGroup(any()) } coVerify(exactly = logoutActionsCalledExactly) { userDataStore.clear() } } private fun testLoggingOut( diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/MessageComposerViewModelArrangement.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/MessageComposerViewModelArrangement.kt index 27e0e2cbde5..4d7fe9ab41b 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/MessageComposerViewModelArrangement.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/MessageComposerViewModelArrangement.kt @@ -24,6 +24,7 @@ import com.wire.android.config.TestDispatcherProvider import com.wire.android.config.mockUri import com.wire.android.framework.FakeKaliumFileSystem import com.wire.android.framework.TestConversation +import com.wire.android.framework.TestUser import com.wire.android.mapper.ContactMapper import com.wire.android.media.PingRinger import com.wire.android.model.UserAvatarData @@ -41,6 +42,7 @@ import com.wire.android.util.ImageUtil import com.wire.android.util.ui.UIText import com.wire.kalium.logic.CoreFailure import com.wire.kalium.logic.configuration.FileSharingStatus +import com.wire.kalium.logic.data.auth.AccountInfo import com.wire.kalium.logic.data.conversation.Conversation import com.wire.kalium.logic.data.conversation.ConversationDetails import com.wire.kalium.logic.data.id.ConversationId @@ -77,6 +79,8 @@ import com.wire.kalium.logic.feature.message.SendTextMessageUseCase import com.wire.kalium.logic.feature.message.ephemeral.EnqueueMessageSelfDeletionUseCase import com.wire.kalium.logic.feature.selfDeletingMessages.ObserveSelfDeletionTimerSettingsForConversationUseCase import com.wire.kalium.logic.feature.selfDeletingMessages.PersistNewSelfDeletionTimerUseCase +import com.wire.kalium.logic.feature.session.CurrentSessionFlowUseCase +import com.wire.kalium.logic.feature.session.CurrentSessionResult import com.wire.kalium.logic.feature.user.IsFileSharingEnabledUseCase import com.wire.kalium.logic.functional.Either import com.wire.kalium.logic.sync.ObserveSyncStateUseCase @@ -85,6 +89,7 @@ import io.mockk.coEvery import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.mockk +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flowOf import okio.Path @@ -113,6 +118,7 @@ internal class MessageComposerViewModelArrangement { coEvery { observeDegradedConversationNotifiedUseCase(any()) } returns flowOf(true) coEvery { setNotifiedAboutConversationUnderLegalHold(any()) } returns Unit coEvery { observeConversationUnderLegalHoldNotified(any()) } returns flowOf(true) + coEvery { currentSessionFlowUseCase() } returns flowOf(CurrentSessionResult.Success(AccountInfo.Valid(TestUser.USER_ID))) } @MockK @@ -202,6 +208,9 @@ internal class MessageComposerViewModelArrangement { @MockK lateinit var sendLocation: SendLocationUseCase + @MockK + lateinit var currentSessionFlowUseCase: CurrentSessionFlowUseCase + private val fakeKaliumFileSystem = FakeKaliumFileSystem() private val viewModel by lazy { @@ -232,7 +241,8 @@ internal class MessageComposerViewModelArrangement { observeDegradedConversationNotified = observeDegradedConversationNotifiedUseCase, setNotifiedAboutConversationUnderLegalHold = setNotifiedAboutConversationUnderLegalHold, observeConversationUnderLegalHoldNotified = observeConversationUnderLegalHoldNotified, - sendLocation = sendLocation + sendLocation = sendLocation, + currentSessionFlowUseCase = currentSessionFlowUseCase, ) } @@ -363,6 +373,10 @@ internal class MessageComposerViewModelArrangement { coEvery { retryFailedMessageUseCase(any(), any()) } returns Either.Right(Unit) } + fun withCurrentSessionFlowResult(resultFlow: Flow) = apply { + coEvery { currentSessionFlowUseCase() } returns resultFlow + } + fun arrange() = this to viewModel } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/MessageComposerViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/MessageComposerViewModelTest.kt index 2077a39c3df..c93b3293189 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/MessageComposerViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/MessageComposerViewModelTest.kt @@ -34,9 +34,12 @@ import com.wire.kalium.logic.data.conversation.Conversation import com.wire.kalium.logic.data.message.SelfDeletionTimer import com.wire.kalium.logic.failure.LegalHoldEnabledForConversationFailure import com.wire.kalium.logic.feature.asset.GetAssetSizeLimitUseCaseImpl.Companion.ASSET_SIZE_DEFAULT_LIMIT_BYTES +import com.wire.kalium.logic.feature.conversation.InteractionAvailability +import com.wire.kalium.logic.feature.session.CurrentSessionResult import io.mockk.coVerify import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import okio.Path.Companion.toPath @@ -818,4 +821,16 @@ class MessageComposerViewModelTest { coVerify(exactly = 1) { arrangement.sendLocation.invoke(any(), any(), any(), any(), any()) } assertEquals(SureAboutMessagingDialogState.Hidden, viewModel.sureAboutMessagingDialogState) } + + @Test + fun `given no current session, then disable interaction`() = runTest { + // given + val (_, viewModel) = MessageComposerViewModelArrangement() + .withSuccessfulViewModelInit() + .withCurrentSessionFlowResult(flowOf(CurrentSessionResult.Failure.SessionNotFound)) + .arrange() + advanceUntilIdle() + // then + assertEquals(InteractionAvailability.DISABLED, viewModel.messageComposerViewState.value.interactionAvailability) + } } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/info/ConversationInfoViewModelArrangement.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/info/ConversationInfoViewModelArrangement.kt index 5a17b46ecb8..397c3e3107b 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/info/ConversationInfoViewModelArrangement.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/info/ConversationInfoViewModelArrangement.kt @@ -31,6 +31,7 @@ import com.wire.kalium.logic.data.id.QualifiedID import com.wire.kalium.logic.data.id.QualifiedIdMapper import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.feature.conversation.ObserveConversationDetailsUseCase +import com.wire.kalium.logic.feature.e2ei.usecase.FetchConversationMLSVerificationStatusUseCase import com.wire.kalium.logic.feature.user.GetSelfUserUseCase import io.mockk.MockKAnnotations import io.mockk.coEvery @@ -59,6 +60,9 @@ class ConversationInfoViewModelArrangement { @MockK lateinit var observerSelfUser: GetSelfUserUseCase + @MockK + lateinit var fetchConversationMLSVerificationStatus: FetchConversationMLSVerificationStatusUseCase + @MockK private lateinit var wireSessionImageLoader: WireSessionImageLoader @@ -71,6 +75,7 @@ class ConversationInfoViewModelArrangement { savedStateHandle, observeConversationDetails, observerSelfUser, + fetchConversationMLSVerificationStatus, wireSessionImageLoader ) } @@ -86,6 +91,7 @@ class ConversationInfoViewModelArrangement { coEvery { observeConversationDetails(any()) } returns conversationDetailsChannel.consumeAsFlow().map { ObserveConversationDetailsUseCase.Result.Success(it) } + coEvery { fetchConversationMLSVerificationStatus.invoke(any()) } returns Unit } suspend fun withConversationDetailUpdate(conversationDetails: ConversationDetails) = apply { diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/SearchConversationMessagesViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/SearchConversationMessagesViewModelTest.kt index b9d4d8db67a..386898793c0 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/SearchConversationMessagesViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/SearchConversationMessagesViewModelTest.kt @@ -126,6 +126,57 @@ class SearchConversationMessagesViewModelTest { } } + @Test + fun `given search term with space, when searching for messages, then search results are as expected`() = runTest { + // given + val searchTerm = "no " + val message1 = mockMessageWithText.copy( + messageContent = UIMessageContent.TextMessage( + messageBody = MessageBody( + UIText.DynamicString("not a normal text") + ) + ) + ) + val message2 = mockMessageWithText.copy( + messageContent = UIMessageContent.TextMessage( + messageBody = MessageBody( + UIText.DynamicString("this message contains a no message") + ) + ) + ) + + val messages = listOf( + message1, + message2 + ) + + val (arrangement, viewModel) = SearchConversationMessagesViewModelArrangement() + .withSuccessSearch(PagingData.from(messages)) + .arrange() + + // when + viewModel.searchQueryChanged(TextFieldValue(searchTerm)) + advanceUntilIdle() + + // then + assertEquals( + TextFieldValue(searchTerm), + viewModel.searchConversationMessagesState.searchQuery + ) + coVerify(exactly = 0) { + arrangement.getSearchMessagesForConversation( + searchTerm, + arrangement.conversationId, + any() + ) + } + viewModel.searchConversationMessagesState.searchResult.test { + awaitItem().map { + it shouldBeEqualTo message2 + } + } + } + @Test fun `given search term with empty space at start and at end, when searching for messages, then specific messages are returned`() = runTest { diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/search/SearchUserViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/search/SearchUserViewModelTest.kt index 9029b72f584..ce86bcea5e3 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/search/SearchUserViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/search/SearchUserViewModelTest.kt @@ -31,14 +31,17 @@ import com.wire.kalium.logic.data.publicuser.model.UserSearchDetails import com.wire.kalium.logic.data.user.ConnectionState import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.data.user.type.UserType +import com.wire.kalium.logic.feature.auth.ValidateUserHandleResult +import com.wire.kalium.logic.feature.auth.ValidateUserHandleUseCase import com.wire.kalium.logic.feature.search.FederatedSearchParser +import com.wire.kalium.logic.feature.search.SearchByHandleUseCase +import com.wire.kalium.logic.feature.search.SearchUserResult import com.wire.kalium.logic.feature.search.SearchUsersUseCase import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.impl.annotations.MockK -import io.mockk.verify import kotlinx.coroutines.test.runTest import org.amshove.kluent.internal.assertEquals import org.junit.jupiter.api.Test @@ -55,7 +58,7 @@ class SearchUserViewModelTest { val (arrangement, viewModel) = Arrangement() .withAddMembersSearchNavArgsThatThrowsException() .withSearchResult( - SearchUsersUseCase.Result( + SearchUserResult( connected = listOf(), notConnected = listOf() ) @@ -66,6 +69,7 @@ class SearchUserViewModelTest { domain = "domain" ) ) + .withIsValidHandleResult(ValidateUserHandleResult.Invalid.TooLong("")) .arrange() viewModel.safeSearch(query) @@ -77,7 +81,7 @@ class SearchUserViewModelTest { ) } - verify(exactly = 1) { + coVerify(exactly = 1) { arrangement.federatedSearchParser(any()) } } @@ -91,7 +95,7 @@ class SearchUserViewModelTest { val (arrangement, viewModel) = Arrangement() .withAddMembersSearchNavArgs(AddMembersSearchNavArgs(conversationId, true)) .withSearchResult( - SearchUsersUseCase.Result( + SearchUserResult( connected = listOf(), notConnected = listOf() ) @@ -102,6 +106,7 @@ class SearchUserViewModelTest { domain = "domain" ) ) + .withIsValidHandleResult(ValidateUserHandleResult.Invalid.TooLong("")) .arrange() viewModel.safeSearch(query) @@ -114,7 +119,7 @@ class SearchUserViewModelTest { ) } - verify(exactly = 1) { + coVerify(exactly = 1) { arrangement.federatedSearchParser(any()) } } @@ -123,7 +128,7 @@ class SearchUserViewModelTest { fun `given searchUserUseCase returns a list of connected users, when calling the searchUseCase, then contactsResult is set`() = runTest { - val result = SearchUsersUseCase.Result( + val result = SearchUserResult( connected = listOf( UserSearchDetails( id = UserId("connected", "domain"), @@ -158,6 +163,7 @@ class SearchUserViewModelTest { domain = "domain" ) ) + .withIsValidHandleResult(ValidateUserHandleResult.Invalid.TooLong("")) .arrange() viewModel.safeSearch(query) @@ -170,7 +176,7 @@ class SearchUserViewModelTest { ) } - verify(exactly = 1) { + coVerify(exactly = 1) { arrangement.federatedSearchParser(any()) } @@ -178,6 +184,40 @@ class SearchUserViewModelTest { assertEquals(result.notConnected.map(arrangement::fromSearchUserResult), viewModel.state.publicResult) } + @Test + fun `given search term is a valid handle, when searching, then search by handle`() = runTest { + val query = "query" + val (arrangement, viewModel) = Arrangement() + .withAddMembersSearchNavArgsThatThrowsException() + .withSearchByHandleResult( + SearchUserResult( + connected = listOf(), + notConnected = listOf() + ) + ) + .withFederatedSearchParserResult( + FederatedSearchParser.Result( + searchTerm = query, + domain = "domain" + ) + ) + .withIsValidHandleResult(ValidateUserHandleResult.Valid("")) + .arrange() + + viewModel.safeSearch(query) + coVerify(exactly = 1) { + arrangement.searchByHandleUseCase.invoke( + query, + excludingConversation = null, + customDomain = "domain" + ) + } + + coVerify(exactly = 1) { + arrangement.federatedSearchParser(any()) + } + } + private class Arrangement { @MockK @@ -192,6 +232,11 @@ class SearchUserViewModelTest { @MockK lateinit var federatedSearchParser: FederatedSearchParser + @MockK + lateinit var validateUserHandle: ValidateUserHandleUseCase + + @MockK + lateinit var searchByHandleUseCase: SearchByHandleUseCase init { MockKAnnotations.init(this, relaxUnitFun = true) every { contactMapper.fromSearchUserResult(any()) } answers { @@ -233,7 +278,7 @@ class SearchUserViewModelTest { } } - fun withSearchResult(result: SearchUsersUseCase.Result) = apply { + fun withSearchResult(result: SearchUserResult) = apply { coEvery { searchUsersUseCase(any(), any(), any()) } returns result } @@ -241,13 +286,23 @@ class SearchUserViewModelTest { coEvery { federatedSearchParser(any()) } returns result } + fun withIsValidHandleResult(result: ValidateUserHandleResult) = apply { + coEvery { validateUserHandle(any()) } returns result + } + + fun withSearchByHandleResult(result: SearchUserResult) = apply { + coEvery { searchByHandleUseCase(any(), any(), any()) } returns result + } + private lateinit var searchUserViewModel: SearchUserViewModel fun arrange() = apply { searchUserViewModel = SearchUserViewModel( searchUsersUseCase, + searchByHandleUseCase, contactMapper, federatedSearchParser, + validateUserHandle, savedStateHandle ) }.run { diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationMessagesFromSearchUseCaseTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationMessagesFromSearchUseCaseTest.kt index e3c1ea2f0f2..db742c3a64a 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationMessagesFromSearchUseCaseTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationMessagesFromSearchUseCaseTest.kt @@ -36,7 +36,6 @@ import com.wire.kalium.logic.data.message.MessageContent import com.wire.kalium.logic.data.user.User import com.wire.kalium.logic.data.user.UserAvailabilityStatus import com.wire.kalium.logic.data.user.UserId -import com.wire.kalium.logic.feature.conversation.ObserveUserListByIdUseCase import com.wire.kalium.logic.feature.message.GetPaginatedFlowOfMessagesBySearchQueryAndConversationIdUseCase import io.mockk.MockKAnnotations import io.mockk.coEvery @@ -109,7 +108,7 @@ class GetConversationMessagesFromSearchUseCaseTest { lateinit var getMessagesSearch: GetPaginatedFlowOfMessagesBySearchQueryAndConversationIdUseCase @MockK - lateinit var observeMemberDetailsByIds: ObserveUserListByIdUseCase + lateinit var getUsersForMessages: GetUsersForMessageUseCase @MockK lateinit var messageMapper: MessageMapper @@ -117,7 +116,7 @@ class GetConversationMessagesFromSearchUseCaseTest { private val useCase: GetConversationMessagesFromSearchUseCase by lazy { GetConversationMessagesFromSearchUseCase( getMessagesSearch, - observeMemberDetailsByIds, + getUsersForMessages, messageMapper, dispatchers = TestDispatcherProvider(), ) @@ -152,9 +151,7 @@ class GetConversationMessagesFromSearchUseCaseTest { } suspend fun withMemberDetails() = apply { - coEvery { observeMemberDetailsByIds(any()) } returns flowOf( - listOf(user1, user2) - ) + coEvery { getUsersForMessages(any()) } returns listOf(user1, user2) } fun withMappedMessage(user: User, message: Message.Standalone) = apply { diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/usecase/GetUsersForMessageUseCaseTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/usecase/GetUsersForMessageUseCaseTest.kt new file mode 100644 index 00000000000..ec93f9da5ed --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/usecase/GetUsersForMessageUseCaseTest.kt @@ -0,0 +1,105 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.home.conversations.usecase + +import com.wire.android.config.CoroutineTestExtension +import com.wire.android.framework.TestMessage +import com.wire.android.framework.TestUser +import com.wire.android.mapper.MessageMapper +import com.wire.kalium.logic.data.user.User +import com.wire.kalium.logic.data.user.UserId +import com.wire.kalium.logic.feature.conversation.ObserveUserListByIdUseCase +import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith + +@OptIn(ExperimentalCoroutinesApi::class) +@ExtendWith(CoroutineTestExtension::class) +class GetUsersForMessageUseCaseTest { + + @Test + fun givenMessageWithoutAdditionalUserIds_whenInvoke_thenObserveMemberDetailsByIdsIsNotTriggered() = runTest { + val sender = TestUser.OTHER_USER + val userWithoutOtherUsers = TestMessage.TEXT_MESSAGE.copy(sender = sender, senderUserId = sender.id) + + val (arrangement, useCase) = Arrangement() + .withMemberDetails(listOf()) + .withMemberList(listOf()) + .arrange() + + val result = useCase(userWithoutOtherUsers) + + assertTrue(result.first() == sender) + coVerify(exactly = 0) { arrangement.observeMemberDetailsByIds(any()) } + } + + @Test + fun givenMessageWithAdditionalUserIds_whenInvoke_thenObserveMemberDetailsByIdsIsTriggered() = runTest { + val otherUser = TestUser.OTHER_USER + val userWithoutOtherUsers = TestMessage.MEMBER_REMOVED_MESSAGE + val user1 = TestUser.OTHER_USER.copy( + id = UserId("user-id1", "domain") + ) + val user2 = TestUser.OTHER_USER.copy( + id = UserId("user-id2", "domain") + ) + + val (arrangement, useCase) = Arrangement() + .withMemberDetails(listOf(user1, user2)) + .withMemberList(listOf(otherUser.id)) + .arrange() + + val result = useCase(userWithoutOtherUsers) + + assertTrue(result.first().equals(user1)) + coVerify(exactly = 1) { arrangement.observeMemberDetailsByIds(any()) } + } + + private class Arrangement { + + @MockK + lateinit var observeMemberDetailsByIds: ObserveUserListByIdUseCase + + @MockK + lateinit var messageMapper: MessageMapper + + init { + MockKAnnotations.init(this, relaxUnitFun = true) + } + + suspend fun withMemberDetails(userList: List) = apply { + coEvery { observeMemberDetailsByIds(any()) } returns flowOf(userList) + } + + fun withMemberList(userIdList: List) = apply { + every { messageMapper.memberIdList(any()) } returns userIdList + } + + fun arrange() = this to GetUsersForMessageUseCase( + observeMemberDetailsByIds, messageMapper + ) + } +} diff --git a/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/location/LocationPickerViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/location/LocationPickerViewModelTest.kt new file mode 100644 index 00000000000..b573830b6a0 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/location/LocationPickerViewModelTest.kt @@ -0,0 +1,101 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.home.messagecomposer.location + +import android.location.Location +import com.wire.android.config.CoroutineTestExtension +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.slot +import kotlinx.coroutines.test.runTest +import org.amshove.kluent.internal.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith + +@ExtendWith(CoroutineTestExtension::class) +class LocationPickerViewModelTest { + + @Test + fun `given user has device location disabled, when sharing location, then an error message will be shown`() = runTest { + // given + val (_, viewModel) = Arrangement() + .withGetGeoLocationError() + .arrange() + + // when + viewModel.getCurrentLocation() + + // then + assertEquals(true, viewModel.state.showLocationSharingError) + assertEquals(true, viewModel.state.geoLocatedAddress == null) + } + + @Test + fun `given user has device location enabled, when sharing location, then should load the location`() = runTest { + // given + val (arrangement, viewModel) = Arrangement() + .withGetGeoLocationSuccess() + .arrange() + + // when + viewModel.getCurrentLocation() + + // then + assertEquals(false, viewModel.state.showLocationSharingError) + assertEquals(true, viewModel.state.geoLocatedAddress != null) + coVerify(exactly = 1) { arrangement.locationPickerHelper.getLocation(any(), any()) } + } + + private class Arrangement { + + val locationPickerHelper = mockk() + + fun withGetGeoLocationSuccess() = apply { + coEvery { + locationPickerHelper.getLocation( + capture(onEngineStartSuccess), + capture(onEngineStartFailure) + ) + } coAnswers { + firstArg().invoke(successResponse) + } + } + + fun withGetGeoLocationError() = apply { + coEvery { + locationPickerHelper.getLocation( + capture(onEngineStartSuccess), + capture(onEngineStartFailure) + ) + } coAnswers { + secondArg<() -> Unit>().invoke() + } + } + + fun arrange() = this to LocationPickerViewModel(locationPickerHelper) + } + + private companion object { + val onEngineStartSuccess = slot() + val onEngineStartFailure = slot<() -> Unit>() + val successResponse = GeoLocatedAddress(null, Location("dummy-location")) + } +} + +private typealias PickedGeoLocation = (GeoLocatedAddress) -> Unit diff --git a/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/RecordAudioViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/RecordAudioViewModelTest.kt index 8ade9e40156..32c41cde474 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/RecordAudioViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/RecordAudioViewModelTest.kt @@ -22,18 +22,22 @@ import com.wire.android.config.CoroutineTestExtension import com.wire.android.framework.FakeKaliumFileSystem import com.wire.android.media.audiomessage.AudioState import com.wire.android.media.audiomessage.RecordAudioMessagePlayer +import com.wire.android.ui.home.messagecomposer.recordaudio.RecordAudioViewModelTest.Arrangement.Companion.ASSET_SIZE_LIMIT import com.wire.android.util.CurrentScreen import com.wire.android.util.CurrentScreenManager import com.wire.kalium.logic.data.call.Call import com.wire.kalium.logic.data.call.CallStatus import com.wire.kalium.logic.data.conversation.Conversation import com.wire.kalium.logic.data.id.ConversationId +import com.wire.kalium.logic.feature.asset.GetAssetSizeLimitUseCase import com.wire.kalium.logic.feature.asset.GetAssetSizeLimitUseCaseImpl import com.wire.kalium.logic.feature.call.usecase.ObserveEstablishedCallsUseCase import io.mockk.MockKAnnotations import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.mockk +import io.mockk.verify import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest @@ -69,7 +73,7 @@ class RecordAudioViewModelTest { fun `given user is not in a call, when start recording audio, then recording screen is shown`() = runTest { // given - val (_, viewModel) = Arrangement() + val (arrangement, viewModel) = Arrangement() .arrange() // when @@ -80,6 +84,10 @@ class RecordAudioViewModelTest { RecordAudioButtonState.RECORDING, viewModel.getButtonState() ) + coVerify(exactly = 1) { arrangement.getAssetSizeLimit(false) } + verify(exactly = 1) { arrangement.audioMediaRecorder.setUp(ASSET_SIZE_LIMIT) } + verify(exactly = 1) { arrangement.audioMediaRecorder.setUp(ASSET_SIZE_LIMIT) } + verify(exactly = 1) { arrangement.audioMediaRecorder.startRecording() } } @Test @@ -216,19 +224,55 @@ class RecordAudioViewModelTest { } } + @Test + fun `given start recording succeeded, when recording audio, then recording screen is shown`() = + runTest { + // given + val (_, viewModel) = Arrangement() + .withStartRecordingSuccessful() + .arrange() + + viewModel.getInfoMessage().test { + // when + viewModel.startRecording() + // then + assertEquals(RecordAudioButtonState.RECORDING, viewModel.getButtonState()) + expectNoEvents() + } + } + + @Test + fun `given start recording failed, when recording audio, then info message is shown`() = + runTest { + // given + val (_, viewModel) = Arrangement() + .withStartRecordingFailed() + .arrange() + + viewModel.getInfoMessage().test { + // when + viewModel.startRecording() + // then + assertEquals(RecordAudioButtonState.ENABLED, viewModel.getButtonState()) + assertEquals(RecordAudioInfoMessageType.UnableToRecordAudioError.uiText, awaitItem()) + } + } + private class Arrangement { val recordAudioMessagePlayer = mockk() val audioMediaRecorder = mockk() val observeEstablishedCalls = mockk() val currentScreenManager = mockk() + val getAssetSizeLimit = mockk() val viewModel by lazy { RecordAudioViewModel( recordAudioMessagePlayer = recordAudioMessagePlayer, observeEstablishedCalls = observeEstablishedCalls, currentScreenManager = currentScreenManager, - audioMediaRecorder = audioMediaRecorder + audioMediaRecorder = audioMediaRecorder, + getAssetSizeLimit = getAssetSizeLimit, ) } @@ -237,8 +281,9 @@ class RecordAudioViewModelTest { val fakeKaliumFileSystem = FakeKaliumFileSystem() - every { audioMediaRecorder.setUp() } returns Unit - every { audioMediaRecorder.startRecording() } returns Unit + coEvery { getAssetSizeLimit.invoke(false) } returns ASSET_SIZE_LIMIT + every { audioMediaRecorder.setUp(ASSET_SIZE_LIMIT) } returns Unit + every { audioMediaRecorder.startRecording() } returns true every { audioMediaRecorder.stop() } returns Unit every { audioMediaRecorder.release() } returns Unit every { audioMediaRecorder.outputFile } returns fakeKaliumFileSystem @@ -273,9 +318,13 @@ class RecordAudioViewModelTest { ) } + fun withStartRecordingSuccessful() = apply { every { audioMediaRecorder.startRecording() } returns true } + fun withStartRecordingFailed() = apply { every { audioMediaRecorder.startRecording() } returns false } + fun arrange() = this to viewModel companion object { + const val ASSET_SIZE_LIMIT = 5L val DUMMY_CALL = Call( conversationId = ConversationId( value = "conversationId", diff --git a/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionInputStateHolderTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionInputStateHolderTest.kt index 5febb3ae68c..cd9e57a4fa4 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionInputStateHolderTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionInputStateHolderTest.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.unit.dp import com.wire.android.config.CoroutineTestExtension import com.wire.kalium.logic.data.message.SelfDeletionTimer import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest import org.amshove.kluent.shouldBeEqualTo import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -46,7 +47,7 @@ class MessageCompositionInputStateHolderTest { } @Test - fun `when IME is visible, showOptions should be set to true`() { + fun `when IME is visible, showOptions should be set to true`() = runTest { // Given val isImeVisible = true @@ -58,7 +59,7 @@ class MessageCompositionInputStateHolderTest { } @Test - fun `when IME is hidden and showSubOptions is true, showOptions remains unchanged`() { + fun `when IME is hidden and showSubOptions is true, showOptions remains unchanged`() = runTest { // Given val isImeVisible = false state.updateValuesForTesting(showSubOptions = true, showOptions = false) @@ -71,7 +72,7 @@ class MessageCompositionInputStateHolderTest { } @Test - fun `when IME is hidden and showSubOptions is false, showOptions should be set to false`() { + fun `when IME is hidden and showSubOptions is false, showOptions should be set to false`() = runTest { // Given val isImeVisible = false state.updateValuesForTesting(showSubOptions = false) @@ -84,7 +85,7 @@ class MessageCompositionInputStateHolderTest { } @Test - fun `when offset increases and is bigger than previous and options height, options height is updated`() { + fun `when offset increases and is bigger than previous and options height, options height is updated`() = runTest { // When state.handleOffsetChange( 50.dp, @@ -99,7 +100,7 @@ class MessageCompositionInputStateHolderTest { } @Test - fun `when offset decreases and showSubOptions is false, options height is updated`() { + fun `when offset decreases and showSubOptions is false, options height is updated`() = runTest { // Given state.updateValuesForTesting(previousOffset = 50.dp) @@ -116,7 +117,7 @@ class MessageCompositionInputStateHolderTest { } @Test - fun `when offset decreases to zero, showOptions and isTextExpanded are set to false`() { + fun `when offset decreases to zero, showOptions and isTextExpanded are set to false`() = runTest { // Given state.updateValuesForTesting(previousOffset = 50.dp) @@ -134,7 +135,7 @@ class MessageCompositionInputStateHolderTest { } @Test - fun `when offset equals keyboard height, showSubOptions is set to false`() { + fun `when offset equals keyboard height, showSubOptions is set to false`() = runTest { // Given state.updateValuesForTesting(keyboardHeight = 30.dp) @@ -151,7 +152,7 @@ class MessageCompositionInputStateHolderTest { } @Test - fun `when offset is greater than keyboard height, keyboardHeight is updated`() { + fun `when offset is greater than keyboard height, keyboardHeight is updated`() = runTest { // Given state.updateValuesForTesting(keyboardHeight = 20.dp) @@ -168,7 +169,7 @@ class MessageCompositionInputStateHolderTest { } @Test - fun `when offset increases and is greater than keyboardHeight but is less than previousOffset, keyboardHeight is updated`() { + fun `when offset increases and is greater than keyboardHeight but is less than previousOffset, keyboardHeight is updated`() = runTest { // Given state.updateValuesForTesting(previousOffset = 50.dp, keyboardHeight = 20.dp) @@ -186,51 +187,53 @@ class MessageCompositionInputStateHolderTest { } @Test - fun `when offset decreases, showSubOptions is true, and actualOffset is greater than optionsHeight, values remain unchanged`() { - // Given - state.updateValuesForTesting( - previousOffset = 50.dp, - keyboardHeight = 20.dp, - showSubOptions = true, - optionsHeight = 10.dp - ) - - // When - state.handleOffsetChange( - 30.dp, - NAVIGATION_BAR_HEIGHT, - SOURCE, - TARGET - ) - - // Then - state.optionsHeight shouldBeEqualTo 10.dp - } + fun `when offset decreases, showSubOptions is true, and actualOffset is greater than optionsHeight, values remain unchanged`() = + runTest { + // Given + state.updateValuesForTesting( + previousOffset = 50.dp, + keyboardHeight = 20.dp, + showSubOptions = true, + optionsHeight = 10.dp + ) + + // When + state.handleOffsetChange( + 30.dp, + NAVIGATION_BAR_HEIGHT, + SOURCE, + TARGET + ) + + // Then + state.optionsHeight shouldBeEqualTo 10.dp + } @Test - fun `when offset decreases, showSubOptions is false, and actualOffset is greater than optionsHeight, optionsHeight is updated`() { - // Given - state.updateValuesForTesting( - previousOffset = 50.dp, - keyboardHeight = 20.dp, - showSubOptions = false, - optionsHeight = 10.dp - ) - - // When - state.handleOffsetChange( - 30.dp, - NAVIGATION_BAR_HEIGHT, - SOURCE, - TARGET - ) - - // Then - state.optionsHeight shouldBeEqualTo 30.dp - } + fun `when offset decreases, showSubOptions is false, and actualOffset is greater than optionsHeight, optionsHeight is updated`() = + runTest { + // Given + state.updateValuesForTesting( + previousOffset = 50.dp, + keyboardHeight = 20.dp, + showSubOptions = false, + optionsHeight = 10.dp + ) + + // When + state.handleOffsetChange( + 30.dp, + NAVIGATION_BAR_HEIGHT, + SOURCE, + TARGET + ) + + // Then + state.optionsHeight shouldBeEqualTo 30.dp + } @Test - fun `when offset is the same as previousOffset and greater than current keyboardHeight, keyboardHeight is updated`() { + fun `when offset is the same as previousOffset and greater than current keyboardHeight, keyboardHeight is updated`() = runTest { // Given state.updateValuesForTesting(previousOffset = 40.dp, keyboardHeight = 20.dp) @@ -248,7 +251,7 @@ class MessageCompositionInputStateHolderTest { } @Test - fun `given first keyboard appear when source equals target, then initialKeyboardHeight is set`() { + fun `given first keyboard appear when source equals target, then initialKeyboardHeight is set`() = runTest { // Given val imeValue = 50.dp state.updateValuesForTesting(initialKeyboardHeight = 0.dp) @@ -261,22 +264,23 @@ class MessageCompositionInputStateHolderTest { } @Test - fun `given extended keyboard height when attachment button is clicked, then keyboardHeight is set to initialKeyboardHeight`() { - // Given - val initialKeyboardHeight = 10.dp - state.updateValuesForTesting(previousOffset = 40.dp, keyboardHeight = 20.dp, initialKeyboardHeight = initialKeyboardHeight) + fun `given extended keyboard height when attachment button is clicked, then keyboardHeight is set to initialKeyboardHeight`() = + runTest { + // Given + val initialKeyboardHeight = 10.dp + state.updateValuesForTesting(previousOffset = 40.dp, keyboardHeight = 20.dp, initialKeyboardHeight = initialKeyboardHeight) - // When - state.showOptions() - state.handleOffsetChange(0.dp, NAVIGATION_BAR_HEIGHT, source = TARGET, target = SOURCE) + // When + state.showOptions() + state.handleOffsetChange(0.dp, NAVIGATION_BAR_HEIGHT, source = TARGET, target = SOURCE) - // Then - state.keyboardHeight shouldBeEqualTo 20.dp - state.optionsHeight shouldBeEqualTo initialKeyboardHeight - } + // Then + state.keyboardHeight shouldBeEqualTo 20.dp + state.optionsHeight shouldBeEqualTo initialKeyboardHeight + } @Test - fun `when offset decreases but is not zero, only optionsHeight is updated`() { + fun `when offset decreases but is not zero, only optionsHeight is updated`() = runTest { // Given state.updateValuesForTesting(previousOffset = 50.dp) diff --git a/app/src/test/kotlin/com/wire/android/ui/home/newconversation/NewConversationViewModelArrangement.kt b/app/src/test/kotlin/com/wire/android/ui/home/newconversation/NewConversationViewModelArrangement.kt index 705efda75c5..1c30b5ec750 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/newconversation/NewConversationViewModelArrangement.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/newconversation/NewConversationViewModelArrangement.kt @@ -21,6 +21,7 @@ package com.wire.android.ui.home.newconversation import com.wire.android.config.mockUri import com.wire.android.framework.TestUser import com.wire.android.ui.home.newconversation.common.CreateGroupState +import com.wire.android.ui.home.newconversation.groupOptions.GroupOptionState import com.wire.kalium.logic.CoreFailure import com.wire.kalium.logic.data.conversation.Conversation import com.wire.kalium.logic.data.conversation.MutedConversationStatus @@ -33,10 +34,12 @@ import com.wire.kalium.logic.data.user.UserAssetId import com.wire.kalium.logic.data.user.UserAvailabilityStatus import com.wire.kalium.logic.data.user.type.UserType import com.wire.kalium.logic.feature.conversation.CreateGroupConversationUseCase +import com.wire.kalium.logic.feature.user.GetDefaultProtocolUseCase import com.wire.kalium.logic.feature.user.IsMLSEnabledUseCase import com.wire.kalium.logic.feature.user.IsSelfATeamMemberUseCaseImpl import io.mockk.MockKAnnotations import io.mockk.coEvery +import io.mockk.every import io.mockk.impl.annotations.MockK internal class NewConversationViewModelArrangement { @@ -47,6 +50,7 @@ internal class NewConversationViewModelArrangement { // Default empty values coEvery { isMLSEnabledUseCase() } returns true coEvery { createGroupConversation(any(), any(), any()) } returns CreateGroupConversationUseCase.Result.Success(CONVERSATION) + every { getDefaultProtocol() } returns SupportedProtocol.PROTEUS } @MockK @@ -61,6 +65,13 @@ internal class NewConversationViewModelArrangement { @MockK(relaxed = true) lateinit var onGroupCreated: (ConversationId) -> Unit + @MockK + lateinit var getDefaultProtocol: GetDefaultProtocolUseCase + + private var groupOptionsState: GroupOptionState = GroupOptionState() + + private var createGroupState: CreateGroupState = CreateGroupState() + private companion object { val CONVERSATION_ID = ConversationId(value = "userId", domain = "domainId") val CONVERSATION = Conversation( @@ -128,14 +139,6 @@ internal class NewConversationViewModelArrangement { ) } - private val viewModel by lazy { - NewConversationViewModel( - createGroupConversation = createGroupConversation, - isMLSEnabled = isMLSEnabledUseCase, - isSelfATeamMember = isSelfTeamMember, - ) - } - fun withSyncFailureOnCreatingGroup() = apply { coEvery { createGroupConversation(any(), any(), any()) } returns CreateGroupConversationUseCase.Result.SyncFailure } @@ -147,7 +150,7 @@ internal class NewConversationViewModelArrangement { } fun withConflictingBackendsFailure() = apply { - viewModel.createGroupState = viewModel.createGroupState.copy( + createGroupState = createGroupState.copy( error = CreateGroupState.Error.ConflictedBackends(listOf("bella.wire.link", "foma.wire.link")) ) } @@ -157,14 +160,23 @@ internal class NewConversationViewModelArrangement { } fun withGuestEnabled(isGuestModeEnabled: Boolean) = apply { - viewModel.groupOptionsState = viewModel - .groupOptionsState - .copy(isAllowGuestEnabled = isGuestModeEnabled) + groupOptionsState = groupOptionsState.copy(isAllowGuestEnabled = isGuestModeEnabled) } fun withServicesEnabled(areServicesEnabled: Boolean) = apply { - viewModel.groupOptionsState = viewModel.groupOptionsState.copy(isAllowServicesEnabled = areServicesEnabled) + groupOptionsState = groupOptionsState.copy(isAllowServicesEnabled = areServicesEnabled) } - fun arrange() = this to viewModel + fun withDefaultProtocol(supportedProtocol: SupportedProtocol) = apply { + every { getDefaultProtocol() } returns supportedProtocol + } + + fun arrange() = this to NewConversationViewModel( + createGroupConversation = createGroupConversation, + isSelfATeamMember = isSelfTeamMember, + getDefaultProtocol = getDefaultProtocol + ).also { + it.groupOptionsState = groupOptionsState + it.createGroupState = createGroupState + } } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/newconversation/NewConversationViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/newconversation/NewConversationViewModelTest.kt index 77df786162a..73199955b5b 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/newconversation/NewConversationViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/newconversation/NewConversationViewModelTest.kt @@ -24,11 +24,13 @@ import com.wire.android.config.CoroutineTestExtension import com.wire.android.ui.home.newconversation.common.CreateGroupState import com.wire.kalium.logic.data.conversation.Conversation import com.wire.kalium.logic.data.conversation.ConversationOptions +import com.wire.kalium.logic.data.user.SupportedProtocol import com.wire.kalium.logic.data.user.UserId import io.mockk.coVerify import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest +import org.amshove.kluent.internal.assertEquals import org.amshove.kluent.shouldBeEqualTo import org.amshove.kluent.shouldBeNull import org.junit.jupiter.api.Test @@ -143,4 +145,24 @@ class NewConversationViewModelTest { ) } } + + @Test + fun `given team settings is MLS default protocol, when getting default protocol, then result is MLS`() = runTest { + // given + val (_, viewModel) = NewConversationViewModelArrangement() + .withDefaultProtocol(SupportedProtocol.MLS) + .withIsSelfTeamMember(true) + .withServicesEnabled(false) + .withGuestEnabled(true) + .arrange() + + // when + val result = viewModel.newGroupState.groupProtocol + + // then + assertEquals( + ConversationOptions.Protocol.MLS, + result + ) + } } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/settings/account/MyAccountViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/settings/account/MyAccountViewModelTest.kt index 4911685f2b0..226b26d100c 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/settings/account/MyAccountViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/settings/account/MyAccountViewModelTest.kt @@ -28,6 +28,7 @@ import com.wire.kalium.logic.StorageFailure import com.wire.kalium.logic.data.id.TeamId import com.wire.kalium.logic.feature.team.GetUpdatedSelfTeamUseCase import com.wire.kalium.logic.feature.user.GetSelfUserUseCase +import com.wire.kalium.logic.feature.user.IsE2EIEnabledUseCase import com.wire.kalium.logic.feature.user.IsPasswordRequiredUseCase import com.wire.kalium.logic.feature.user.IsPasswordRequiredUseCase.Result.Success import com.wire.kalium.logic.feature.user.IsReadOnlyAccountUseCase @@ -55,9 +56,10 @@ class MyAccountViewModelTest { @Test fun `when trying to compute if the user requires password fails, then hasSAMLCred is false`() = runTest { - val (arrangement, viewModel) = Arrangement() + val (_, viewModel) = Arrangement() .withUserRequiresPasswordResult(IsPasswordRequiredUseCase.Result.Failure(StorageFailure.DataNotFound)) .withIsReadOnlyAccountResult(true) + .withE2EIEnabledResult(false) .arrange() assertFalse(viewModel.hasSAMLCred) @@ -65,9 +67,10 @@ class MyAccountViewModelTest { @Test fun `when trying to compute if the user requires password return true, then hasSAMLCred is false`() = runTest { - val (arrangement, viewModel) = Arrangement() + val (_, viewModel) = Arrangement() .withUserRequiresPasswordResult(Success(true)) .withIsReadOnlyAccountResult(true) + .withE2EIEnabledResult(false) .arrange() assertFalse(viewModel.hasSAMLCred) @@ -75,9 +78,10 @@ class MyAccountViewModelTest { @Test fun `when trying to compute if the user requires password return false, then hasSAMLCred is true`() = runTest { - val (arrangement, viewModel) = Arrangement() + val (_, viewModel) = Arrangement() .withUserRequiresPasswordResult(Success(false)) .withIsReadOnlyAccountResult(true) + .withE2EIEnabledResult(false) .arrange() assertTrue(viewModel.hasSAMLCred) @@ -85,9 +89,10 @@ class MyAccountViewModelTest { @Test fun `when isAccountReadOnly return true, then managedByWire is false`() = runTest { - val (arrangement, viewModel) = Arrangement() + val (_, viewModel) = Arrangement() .withUserRequiresPasswordResult(Success(false)) .withIsReadOnlyAccountResult(true) + .withE2EIEnabledResult(false) .arrange() assertFalse(viewModel.managedByWire) @@ -95,9 +100,10 @@ class MyAccountViewModelTest { @Test fun `when isAccountReadOnly return false, then managedByWire is true`() = runTest { - val (arrangement, viewModel) = Arrangement() + val (_, viewModel) = Arrangement() .withUserRequiresPasswordResult(Success(false)) .withIsReadOnlyAccountResult(false) + .withE2EIEnabledResult(false) .arrange() assertTrue(viewModel.managedByWire) @@ -108,6 +114,7 @@ class MyAccountViewModelTest { val (arrangement, _) = Arrangement() .withUserRequiresPasswordResult(Success(false)) .withIsReadOnlyAccountResult(true) + .withE2EIEnabledResult(false) .arrange() verify { @@ -120,6 +127,7 @@ class MyAccountViewModelTest { val (arrangement, _) = Arrangement() .withUserRequiresPasswordResult(Success(true)) .withIsReadOnlyAccountResult(true) + .withE2EIEnabledResult(false) .arrange() coVerify(exactly = 1) { arrangement.selfServerConfigUseCase() } @@ -130,6 +138,7 @@ class MyAccountViewModelTest { val (_, viewModel) = Arrangement() .withUserRequiresPasswordResult(Success(true)) .withIsReadOnlyAccountResult(false) + .withE2EIEnabledResult(false) .arrange() assertTrue(viewModel.myAccountState.isEditHandleAllowed) @@ -140,6 +149,7 @@ class MyAccountViewModelTest { val (_, viewModel) = Arrangement() .withUserRequiresPasswordResult(Success(false)) .withIsReadOnlyAccountResult(true) + .withE2EIEnabledResult(false) .arrange() assertFalse(viewModel.myAccountState.isEditHandleAllowed) @@ -151,6 +161,7 @@ class MyAccountViewModelTest { .withUserRequiresPasswordResult(Success(true)) .withIsReadOnlyAccountResult(false) .withEmailStatusByBuild(emailEditEnabled = false) + .withE2EIEnabledResult(false) .arrange() assertFalse(viewModel.myAccountState.isEditEmailAllowed) @@ -162,11 +173,34 @@ class MyAccountViewModelTest { .withUserRequiresPasswordResult(Success(true)) .withIsReadOnlyAccountResult(false) .withEmailStatusByBuild(emailEditEnabled = true) + .withE2EIEnabledResult(false) .arrange() assertTrue(viewModel.myAccountState.isEditEmailAllowed) } + @Test + fun `given e2ei is enabled, then edit name is NOT allowed`() = runTest { + val (_, viewModel) = Arrangement() + .withUserRequiresPasswordResult(Success(true)) + .withIsReadOnlyAccountResult(false) + .withE2EIEnabledResult(true) + .arrange() + + assertFalse(viewModel.myAccountState.isEditNameAllowed) + } + + @Test + fun `given e2ei is NOT enabled, then edit name IS allowed`() = runTest { + val (_, viewModel) = Arrangement() + .withUserRequiresPasswordResult(Success(true)) + .withIsReadOnlyAccountResult(false) + .withE2EIEnabledResult(false) + .arrange() + + assertTrue(viewModel.myAccountState.isEditNameAllowed) + } + private class Arrangement { @MockK @@ -187,6 +221,9 @@ class MyAccountViewModelTest { @MockK private lateinit var savedStateHandle: SavedStateHandle + @MockK + lateinit var isE2EIEnabledUseCase: IsE2EIEnabledUseCase + private val viewModel by lazy { MyAccountViewModel( savedStateHandle, @@ -195,7 +232,8 @@ class MyAccountViewModelTest { selfServerConfigUseCase, isPasswordRequiredUseCase, isReadOnlyAccountUseCase, - TestDispatcherProvider() + TestDispatcherProvider(), + isE2EIEnabledUseCase ) } @@ -219,6 +257,10 @@ class MyAccountViewModelTest { coEvery { isReadOnlyAccountUseCase() } returns result } + fun withE2EIEnabledResult(result: Boolean) = apply { + coEvery { isE2EIEnabledUseCase() } returns result + } + fun arrange() = this to viewModel } } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/sync/FeatureFlagNotificationViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/sync/FeatureFlagNotificationViewModelTest.kt index fa8363628a2..8e71c6d0401 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/sync/FeatureFlagNotificationViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/sync/FeatureFlagNotificationViewModelTest.kt @@ -18,7 +18,6 @@ package com.wire.android.ui.home.sync import com.wire.android.config.CoroutineTestExtension -import com.wire.android.config.TestDispatcherProvider import com.wire.android.datastore.GlobalDataStore import com.wire.android.feature.AppLockSource import com.wire.android.feature.DisableAppLockUseCase @@ -37,6 +36,7 @@ import com.wire.kalium.logic.feature.session.CurrentSessionResult import com.wire.kalium.logic.feature.user.E2EIRequiredResult import com.wire.kalium.logic.feature.user.MarkEnablingE2EIAsNotifiedUseCase import com.wire.kalium.logic.feature.user.MarkSelfDeletionStatusAsNotifiedUseCase +import com.wire.kalium.logic.feature.user.e2ei.MarkNotifyForRevokedCertificateAsNotifiedUseCase import com.wire.kalium.logic.feature.user.guestroomlink.MarkGuestLinkFeatureFlagAsNotChangedUseCase import io.mockk.MockKAnnotations import io.mockk.coEvery @@ -146,7 +146,7 @@ class FeatureFlagNotificationViewModelTest { @Test fun givenE2EIRequired_thenShowDialog() = runTest { - val (arrangement, viewModel) = Arrangement() + val (_, viewModel) = Arrangement() .withE2EIRequiredSettings(E2EIRequiredResult.NoGracePeriod.Create) .arrange() advanceUntilIdle() @@ -173,7 +173,7 @@ class FeatureFlagNotificationViewModelTest { @Test fun givenSnoozeE2EIRequiredDialogShown_whenDismissCalled_thenItSnoozedAndDialogHidden() = runTest { val gracePeriod = 1.days - val (arrangement, viewModel) = Arrangement() + val (_, viewModel) = Arrangement() .withE2EIRequiredSettings(E2EIRequiredResult.WithGracePeriod.Create(gracePeriod)) .arrange() viewModel.snoozeE2EIdRequiredDialog(FeatureFlagState.E2EIRequired.WithGracePeriod.Create(gracePeriod)) @@ -186,7 +186,7 @@ class FeatureFlagNotificationViewModelTest { @Test fun givenE2EIRenewRequired_thenShowDialog() = runTest { - val (arrangement, viewModel) = Arrangement() + val (_, viewModel) = Arrangement() .withE2EIRequiredSettings(E2EIRequiredResult.NoGracePeriod.Renew) .arrange() advanceUntilIdle() @@ -213,7 +213,7 @@ class FeatureFlagNotificationViewModelTest { @Test fun givenSnoozeE2EIRenewDialogShown_whenDismissCalled_thenItSnoozedAndDialogHidden() = runTest { val gracePeriod = 1.days - val (arrangement, viewModel) = Arrangement() + val (_, viewModel) = Arrangement() .withE2EIRequiredSettings(E2EIRequiredResult.WithGracePeriod.Renew(gracePeriod)) .arrange() viewModel.snoozeE2EIdRequiredDialog(FeatureFlagState.E2EIRequired.WithGracePeriod.Renew(gracePeriod)) @@ -266,7 +266,7 @@ class FeatureFlagNotificationViewModelTest { @Test fun givenE2EIRequired_whenUserLoggedOut_thenHideDialog() = runTest { val currentSessionsFlow = MutableSharedFlow(1) - val (arrangement, viewModel) = Arrangement() + val (_, viewModel) = Arrangement() .withE2EIRequiredSettings(E2EIRequiredResult.NoGracePeriod.Create) .withCurrentSessionsFlow(currentSessionsFlow) .arrange() @@ -284,6 +284,19 @@ class FeatureFlagNotificationViewModelTest { assertEquals(null, viewModel.featureFlagState.e2EIRequired) } + @Test + fun givenADisplayedDialog_whenDismissingIt_thenInvokeMarkFileSharingStatusAsNotifiedUseCaseOnce() = runTest { + val (arrangement, viewModel) = Arrangement() + .withCurrentSessionsFlow(flowOf(CurrentSessionResult.Success(AccountInfo.Valid(UserId("value", "domain"))))) + .arrange() + coEvery { arrangement.markNotifyForRevokedCertificateAsNotified() } returns Unit + + viewModel.dismissE2EICertificateRevokedDialog() + + assertEquals(false, viewModel.featureFlagState.shouldShowE2eiCertificateRevokedDialog) + coVerify(exactly = 1) { arrangement.markNotifyForRevokedCertificateAsNotified() } + } + private inner class Arrangement { @MockK @@ -310,13 +323,15 @@ class FeatureFlagNotificationViewModelTest { @MockK lateinit var globalDataStore: GlobalDataStore + @MockK + lateinit var markNotifyForRevokedCertificateAsNotified: MarkNotifyForRevokedCertificateAsNotifiedUseCase + val viewModel: FeatureFlagNotificationViewModel by lazy { FeatureFlagNotificationViewModel( coreLogic = coreLogic, currentSessionFlow = currentSessionFlow, globalDataStore = globalDataStore, - disableAppLockUseCase = disableAppLockUseCase, - dispatcherProvider = TestDispatcherProvider() + disableAppLockUseCase = disableAppLockUseCase ) } init { @@ -332,6 +347,9 @@ class FeatureFlagNotificationViewModelTest { coEvery { coreLogic.getSessionScope(any()).observeGuestRoomLinkFeatureFlag.invoke() } returns flowOf() coEvery { coreLogic.getSessionScope(any()).observeE2EIRequired.invoke() } returns flowOf() coEvery { coreLogic.getSessionScope(any()).calls.observeEndCallDialog() } returns flowOf() + coEvery { coreLogic.getSessionScope(any()).observeShouldNotifyForRevokedCertificate() } returns flowOf() + every { coreLogic.getSessionScope(any()).markNotifyForRevokedCertificateAsNotified } returns + markNotifyForRevokedCertificateAsNotified coEvery { ppLockTeamFeatureConfigObserver() } returns flowOf(null) } diff --git a/app/src/test/kotlin/com/wire/android/ui/settings/devices/DeviceDetailsViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/settings/devices/DeviceDetailsViewModelTest.kt index 428f868d683..38b7d5ceaf6 100644 --- a/app/src/test/kotlin/com/wire/android/ui/settings/devices/DeviceDetailsViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/settings/devices/DeviceDetailsViewModelTest.kt @@ -17,11 +17,9 @@ */ package com.wire.android.ui.settings.devices -import android.content.Context import androidx.lifecycle.SavedStateHandle import com.wire.android.config.CoroutineTestExtension import com.wire.android.config.NavigationTestExtension -import com.wire.android.feature.e2ei.GetE2EICertificateUseCase import com.wire.android.framework.TestClient import com.wire.android.framework.TestUser import com.wire.android.ui.authentication.devices.remove.RemoveDeviceDialogState @@ -42,6 +40,7 @@ import com.wire.kalium.logic.feature.client.UpdateClientVerificationStatusUseCas import com.wire.kalium.logic.feature.e2ei.usecase.GetE2EICertificateUseCaseResult import com.wire.kalium.logic.feature.e2ei.usecase.GetE2eiCertificateUseCase import com.wire.kalium.logic.feature.user.GetUserInfoResult +import com.wire.kalium.logic.feature.user.IsE2EIEnabledUseCase import com.wire.kalium.logic.feature.user.IsPasswordRequiredUseCase import com.wire.kalium.logic.feature.user.ObserveUserInfoUseCase import io.mockk.Called @@ -276,19 +275,14 @@ class DeviceDetailsViewModelTest { .withClientDetailsResult(GetClientDetailsResult.Success(TestClient.CLIENT, true)) .arrange() - viewModel.enrollE2eiCertificate(arrangement.context) + viewModel.enrollE2EICertificate() - coVerify { - arrangement.enrolE2EICertificateUseCase(any(), any()) - } assertTrue(viewModel.state.isLoadingCertificate) + assertTrue(viewModel.state.startGettingE2EICertificate) } private class Arrangement { - @MockK - lateinit var context: Context - @MockK lateinit var savedStateHandle: SavedStateHandle @@ -313,12 +307,12 @@ class DeviceDetailsViewModelTest { @MockK lateinit var getE2eiCertificate: GetE2eiCertificateUseCase - @MockK - lateinit var enrolE2EICertificateUseCase: GetE2EICertificateUseCase - @MockK(relaxed = true) lateinit var onSuccess: () -> Unit + @MockK + lateinit var isE2EIEnabledUseCase: IsE2EIEnabledUseCase + val currentUserId = UserId("currentUserId", "currentUserDomain") val viewModel by lazy { @@ -332,7 +326,7 @@ class DeviceDetailsViewModelTest { currentUserId = currentUserId, observeUserInfo = observeUserInfo, e2eiCertificate = getE2eiCertificate, - enrolE2EICertificateUseCase = enrolE2EICertificateUseCase + isE2EIEnabledUseCase = isE2EIEnabledUseCase ) } @@ -340,7 +334,8 @@ class DeviceDetailsViewModelTest { MockKAnnotations.init(this, relaxUnitFun = true) withFingerprintSuccess() coEvery { observeUserInfo(any()) } returns flowOf(GetUserInfoResult.Success(TestUser.OTHER_USER, null)) - coEvery { getE2eiCertificate(any()) } returns GetE2EICertificateUseCaseResult.Failure.NotActivated + coEvery { getE2eiCertificate(any()) } returns GetE2EICertificateUseCaseResult.NotActivated + coEvery { isE2EIEnabledUseCase() } returns true } fun withUserRequiresPasswordResult(result: IsPasswordRequiredUseCase.Result = IsPasswordRequiredUseCase.Result.Success(true)) = diff --git a/app/src/test/kotlin/com/wire/android/ui/settings/devices/SelfDevicesViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/settings/devices/SelfDevicesViewModelTest.kt index b47e39a3f20..bb88aabdc6a 100644 --- a/app/src/test/kotlin/com/wire/android/ui/settings/devices/SelfDevicesViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/settings/devices/SelfDevicesViewModelTest.kt @@ -29,6 +29,7 @@ import com.wire.kalium.logic.feature.client.ObserveClientsByUserIdUseCase import com.wire.kalium.logic.feature.client.ObserveCurrentClientIdUseCase import com.wire.kalium.logic.feature.client.SelfClientsResult import com.wire.kalium.logic.feature.e2ei.usecase.GetUserE2eiCertificatesUseCase +import com.wire.kalium.logic.feature.user.IsE2EIEnabledUseCase import io.mockk.coEvery import io.mockk.MockKAnnotations import io.mockk.impl.annotations.MockK @@ -70,6 +71,9 @@ class SelfDevicesViewModelTest { @MockK lateinit var getUserE2eiCertificates: GetUserE2eiCertificatesUseCase + @MockK + lateinit var isE2EIEnabledUseCase: IsE2EIEnabledUseCase + val selfId = UserId("selfId", "domain") private val viewModel by lazy { @@ -78,7 +82,8 @@ class SelfDevicesViewModelTest { currentAccountId = selfId, currentClientIdUseCase = currentClientId, fetchSelfClientsFromRemote = fetchSelfClientsFromRemote, - getUserE2eiCertificates = getUserE2eiCertificates + getUserE2eiCertificates = getUserE2eiCertificates, + isE2EIEnabledUseCase = isE2EIEnabledUseCase ) } @@ -95,6 +100,7 @@ class SelfDevicesViewModelTest { ) ) coEvery { getUserE2eiCertificates.invoke(any()) } returns mapOf() + coEvery { isE2EIEnabledUseCase() } returns true } fun arrange() = this to viewModel diff --git a/app/src/test/kotlin/com/wire/android/ui/userprofile/image/AvatarPickerViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/userprofile/image/AvatarPickerViewModelTest.kt index 63b68cd51ce..94af0c5006c 100644 --- a/app/src/test/kotlin/com/wire/android/ui/userprofile/image/AvatarPickerViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/userprofile/image/AvatarPickerViewModelTest.kt @@ -52,6 +52,7 @@ import kotlinx.coroutines.flow.flow import kotlinx.coroutines.test.runTest import okio.buffer import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertInstanceOf import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @@ -112,6 +113,32 @@ class AvatarPickerViewModelTest { } } + @Test + fun `given current avatar download failed, when uploading the asset fails, then set state as Empty`() = runTest { + // Given + val (arrangement, avatarPickerViewModel) = Arrangement() + .withFailedInitialAvatarLoad() + .withErrorUploadResponse() + .arrange() + // When + avatarPickerViewModel.uploadNewPickedAvatar(arrangement.onSuccess) + // Then + assertInstanceOf(AvatarPickerViewModel.PictureState.Empty::class.java, avatarPickerViewModel.pictureState) + } + + @Test + fun `given current avatar download succeeded, when uploading the asset fails, then set state as Initial`() = runTest { + // Given + val (arrangement, avatarPickerViewModel) = Arrangement() + .withSuccessfulInitialAvatarLoad() + .withErrorUploadResponse() + .arrange() + // When + avatarPickerViewModel.uploadNewPickedAvatar(arrangement.onSuccess) + // Then + assertInstanceOf(AvatarPickerViewModel.PictureState.Initial::class.java, avatarPickerViewModel.pictureState) + } + private class Arrangement { val userDataStore = mockk() @@ -146,9 +173,12 @@ class AvatarPickerViewModelTest { private val mockUri = mockk() + init { + MockKAnnotations.init(this, relaxUnitFun = true) + } + fun withSuccessfulInitialAvatarLoad(): Arrangement { val avatarAssetId = "avatar-value@avatar-domain" - MockKAnnotations.init(this, relaxUnitFun = true) mockkStatic(Uri::class) mockkStatic(Uri::resampleImageAndCopyToTempPath) mockkStatic(Uri::toByteArray) @@ -169,6 +199,16 @@ class AvatarPickerViewModelTest { return this } + fun withFailedInitialAvatarLoad(): Arrangement { + val avatarAssetId = "avatar-value@avatar-domain" + coEvery { getAvatarAsset(any()) } returns PublicAssetResult.Failure(Unknown(RuntimeException("some error")), false) + coEvery { avatarImageManager.getShareableTempAvatarUri(any()) } returns mockUri + every { userDataStore.avatarAssetId } returns flow { emit(avatarAssetId) } + every { qualifiedIdMapper.fromStringToQualifiedID(any()) } returns QualifiedID("avatar-value", "avatar-domain") + + return this + } + fun withSuccessfulAvatarUpload(expectedUserAssetId: UserAssetId): Arrangement { coEvery { userDataStore.updateUserAvatarAssetId(any()) } returns Unit coEvery { uploadUserAvatarUseCase(any(), any()) } returns UploadAvatarResult.Success(expectedUserAssetId) diff --git a/app/src/test/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileViewModelArrangement.kt b/app/src/test/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileViewModelArrangement.kt index 1729d4aa114..3dd80464f49 100644 --- a/app/src/test/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileViewModelArrangement.kt +++ b/app/src/test/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileViewModelArrangement.kt @@ -38,6 +38,7 @@ import com.wire.kalium.logic.feature.connection.UnblockUserUseCase import com.wire.kalium.logic.feature.conversation.ArchiveStatusUpdateResult import com.wire.kalium.logic.feature.conversation.ClearConversationContentUseCase import com.wire.kalium.logic.feature.conversation.GetOneToOneConversationUseCase +import com.wire.kalium.logic.feature.conversation.IsOneToOneConversationCreatedUseCase import com.wire.kalium.logic.feature.conversation.RemoveMemberFromConversationUseCase import com.wire.kalium.logic.feature.conversation.UpdateConversationArchivedStatusUseCase import com.wire.kalium.logic.feature.conversation.UpdateConversationMemberRoleResult @@ -112,6 +113,9 @@ internal class OtherUserProfileViewModelArrangement { @MockK lateinit var getUserE2eiCertificates: GetUserE2eiCertificatesUseCase + @MockK + lateinit var isOneToOneConversationCreated: IsOneToOneConversationCreatedUseCase + private val viewModel by lazy { OtherUserProfileScreenViewModel( TestDispatcherProvider(), @@ -131,6 +135,7 @@ internal class OtherUserProfileViewModelArrangement { updateConversationArchivedStatus, getUserE2eiCertificateStatus, getUserE2eiCertificates, + isOneToOneConversationCreated, savedStateHandle, ) } @@ -161,6 +166,7 @@ internal class OtherUserProfileViewModelArrangement { ) coEvery { getUserE2eiCertificateStatus.invoke(any()) } returns GetUserE2eiCertificateStatusResult.Success(CertificateStatus.VALID) coEvery { getUserE2eiCertificates.invoke(any()) } returns mapOf() + coEvery { isOneToOneConversationCreated.invoke(any()) } returns true } suspend fun withBlockUserResult(result: BlockUserResult) = apply { diff --git a/app/src/test/kotlin/com/wire/android/util/DateTimeUtilKtTest.kt b/app/src/test/kotlin/com/wire/android/util/DateTimeUtilKtTest.kt index 79e7ad4c0e1..17c9bfd0108 100644 --- a/app/src/test/kotlin/com/wire/android/util/DateTimeUtilKtTest.kt +++ b/app/src/test/kotlin/com/wire/android/util/DateTimeUtilKtTest.kt @@ -25,10 +25,16 @@ class DateTimeUtilKtTest { @Test fun `given a invalid date, when performing a transformation, then return null`() { - val result = "NOT_VALID".formatMediumDateTime() + val result = "NOT_VALID".deviceDateTimeFormat() assertEquals(null, result) } + @Test + fun `given a valid date, when performing a transformation for device, then return with medium format`() { + val result = "2022-03-24T18:02:30.360Z".deviceDateTimeFormat() + assertEquals("March 24, 2022, 6:02 PM", result) + } + @Test fun `given a valid date, when performing a transformation, then return with medium format`() { val result = "2022-03-24T18:02:30.360Z".formatMediumDateTime() diff --git a/app/src/test/kotlin/com/wire/android/util/UriUtilTest.kt b/app/src/test/kotlin/com/wire/android/util/UriUtilTest.kt index f78d4f77540..e42b1878e40 100644 --- a/app/src/test/kotlin/com/wire/android/util/UriUtilTest.kt +++ b/app/src/test/kotlin/com/wire/android/util/UriUtilTest.kt @@ -20,6 +20,7 @@ package com.wire.android.util import com.wire.android.string import org.amshove.kluent.internal.assertEquals import org.junit.jupiter.api.Test +import java.net.URI import kotlin.random.Random class UriUtilTest { @@ -86,4 +87,26 @@ class UriUtilTest { val actual = normalizeLink(input) assertEquals(input, actual) } + + @Test + fun givenLinkWithQueryParams_whenCallingFindParameterValue_thenReturnsParamValue() { + val parameterName = "wire_client" + val parameterValue = "value1" + val url = "https://example.com?play=value&$parameterName=$parameterValue" + val actual = URI(url).findParameterValue(parameterName) + assertEquals(parameterValue, actual) + } + + @Test + fun givenLinkWithoutRequestedParam_whenCallingFindParameterValue_thenReturnsParamValue() { + val url = "https://example.com?play=value1" + val actual = URI(url).findParameterValue("wire_client") + assertEquals(null, actual) + } + @Test + fun givenLinkWithoutParams_whenCallingFindParameterValue_thenReturnsParamValue() { + val url = "https://example.com" + val actual = URI(url).findParameterValue("wire_client") + assertEquals(null, actual) + } } diff --git a/app/src/test/kotlin/com/wire/android/util/ui/AssetImageFetcherTest.kt b/app/src/test/kotlin/com/wire/android/util/ui/AssetImageFetcherTest.kt index b16ba1b5c15..b5d1e57edf7 100644 --- a/app/src/test/kotlin/com/wire/android/util/ui/AssetImageFetcherTest.kt +++ b/app/src/test/kotlin/com/wire/android/util/ui/AssetImageFetcherTest.kt @@ -372,8 +372,7 @@ internal class AssetImageFetcherTest { getPublicAsset = getPublicAsset, getPrivateAsset = getPrivateAsset, deleteAsset = deleteAsset, - drawableResultWrapper = drawableResultWrapper, - context = mockContext + drawableResultWrapper = drawableResultWrapper ) } diff --git a/build-logic/plugins/src/main/kotlin/AndroidCoordinates.kt b/build-logic/plugins/src/main/kotlin/AndroidCoordinates.kt index 8786a24986d..0e131bb1f95 100644 --- a/build-logic/plugins/src/main/kotlin/AndroidCoordinates.kt +++ b/build-logic/plugins/src/main/kotlin/AndroidCoordinates.kt @@ -25,6 +25,6 @@ object AndroidSdk { object AndroidApp { const val id = "com.wire.android" - const val versionName = "4.6.0" + const val versionName = "4.6.3" val versionCode = Versionizer().versionCode } diff --git a/build.gradle.kts b/build.gradle.kts index cdf069f0bd5..3cd06b21ce7 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -25,7 +25,18 @@ buildscript { } dependencies { classpath(libs.hilt.gradlePlugin) - classpath(libs.googleGms.gradlePlugin) + val fdroidBuild = (System.getenv("flavor") + ?: System.getenv("FLAVOR") + ?: gradle.startParameter.taskRequests.toString()) + .lowercase() + .contains("fdroid") + + if (fdroidBuild) { + println("Not including gms") + } else { + println("Including gms") + classpath(libs.googleGms.gradlePlugin) + } classpath(libs.aboutLibraries.gradlePlugin) } } @@ -44,3 +55,4 @@ plugins { id(ScriptPlugins.infrastructure) alias(libs.plugins.ksp) apply false // https://github.com/google/dagger/issues/3965 } + diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index c5f8b454b4b..b6d47f377b0 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -19,6 +19,7 @@ private object Dependencies { const val kotlinGradlePlugin = "org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.0" const val detektGradlePlugin = "io.gitlab.arturbosch.detekt:detekt-gradle-plugin:1.23.0" + const val koverGradlePlugin = "org.jetbrains.kotlinx:kover-gradle-plugin:0.7.5" const val junit = "junit:junit:4.13.2" const val kluent = "org.amshove.kluent:kluent:1.73" const val spotless = "com.diffplug.spotless:spotless-plugin-gradle:6.1.2" @@ -44,6 +45,7 @@ dependencies { implementation("com.android.tools.build:gradle:${klibs.versions.agp.get()}") implementation(Dependencies.kotlinGradlePlugin) implementation(Dependencies.detektGradlePlugin) + implementation(Dependencies.koverGradlePlugin) implementation(Dependencies.spotless) implementation(Dependencies.junit5) diff --git a/buildSrc/src/main/kotlin/MapTojson.kt b/buildSrc/src/main/kotlin/MapTojson.kt new file mode 100644 index 00000000000..6d4cdb2fe20 --- /dev/null +++ b/buildSrc/src/main/kotlin/MapTojson.kt @@ -0,0 +1,31 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +fun Map.toJsonString (): String { + if (this.isEmpty()) return "{}" + + return StringBuilder().apply { + append("{") + this@toJsonString.forEach { (key, value) -> + append("\"$key\":\"$value\",") + } + deleteCharAt(length - 1) + append("}") + + }.toString() +} diff --git a/buildSrc/src/main/kotlin/WriteKeyValuesToFileTask.kt b/buildSrc/src/main/kotlin/WriteKeyValuesToFileTask.kt new file mode 100644 index 00000000000..3955a7a11d9 --- /dev/null +++ b/buildSrc/src/main/kotlin/WriteKeyValuesToFileTask.kt @@ -0,0 +1,70 @@ +import org.gradle.api.DefaultTask +import org.gradle.api.provider.MapProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.TaskAction +import java.io.File +import java.io.FileOutputStream + +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +abstract class WriteKeyValuesToFileTask : DefaultTask() { + + /** + * The JSON file where the [keyValues] will be written + */ + @get:OutputFile + abstract val outputJsonFile: Property + + /** + * Map of key-value pairs that will be written to the [outputJsonFile]. + */ + @get:Input + abstract val keyValues: MapProperty + + init { + group = "build" + description = "Write a set of key-value pairs to a desired file" + } + + @TaskAction + fun processGitBuildIdentifier() { + val outFile = outputJsonFile.get() + require(!outFile.isDirectory) { + "The specified output must be a regular file, not a directory: ${outFile.absolutePath}" + } + runCatching { + logger.debug("\uD83D\uDD27 Writing key-values to ${outFile.absolutePath}.") + keyValues.get().toJsonString().also { writeToFile(it) } + }.onFailure { + logger.error("\uD83D\uDD27 Failed to write key-values to file: ${it.stackTraceToString()}") + writeToFile("{}") + } + } + + /** + * Write the given [text] to the [outputJsonFile]. + */ + private fun writeToFile(text: String) { + FileOutputStream(outputJsonFile.get()).use { + it.write(text.toByteArray()) + } + logger.debug("\u2705 Successfully wrote '$text' to $outputJsonFile.") + } +} diff --git a/buildSrc/src/main/kotlin/customization/FeatureConfigs.kt b/buildSrc/src/main/kotlin/customization/FeatureConfigs.kt index e61fe3d0402..3d6bbf921a6 100644 --- a/buildSrc/src/main/kotlin/customization/FeatureConfigs.kt +++ b/buildSrc/src/main/kotlin/customization/FeatureConfigs.kt @@ -54,6 +54,7 @@ enum class FeatureConfigs(val value: String, val configType: ConfigType) { * Security/Cryptography stuff */ MLS_SUPPORT_ENABLED("mls_support_enabled", ConfigType.BOOLEAN), + LOWER_KEYPACKAGE_LIMIT("lower_keypackage_limit", ConfigType.BOOLEAN), ENCRYPT_PROTEUS_STORAGE("encrypt_proteus_storage", ConfigType.BOOLEAN), WIPE_ON_COOKIE_INVALID("wipe_on_cookie_invalid", ConfigType.BOOLEAN), WIPE_ON_ROOTED_DEVICE("wipe_on_rooted_device", ConfigType.BOOLEAN), @@ -96,5 +97,8 @@ enum class FeatureConfigs(val value: String, val configType: ConfigType) { CERTIFICATE_PINNING_CONFIG("cert_pinning_config", ConfigType.MapOfStringToListOfStrings), // TODO: Add support for default proxy configs - IS_PASSWORD_PROTECTED_GUEST_LINK_ENABLED("is_password_protected_guest_link_enabled", ConfigType.BOOLEAN) + IS_PASSWORD_PROTECTED_GUEST_LINK_ENABLED("is_password_protected_guest_link_enabled", ConfigType.BOOLEAN), + + MAX_REMOTE_SEARCH_RESULT_COUNT("max_remote_search_result_count", ConfigType.INT), + LIMIT_TEAM_MEMBERS_FETCH_DURING_SLOW_SYNC("limit_team_members_fetch_during_slow_sync", ConfigType.INT), } diff --git a/buildSrc/src/main/kotlin/flavor/ProductFlavors.kt b/buildSrc/src/main/kotlin/flavor/ProductFlavors.kt index 48affa5818b..e13d4bc25bd 100644 --- a/buildSrc/src/main/kotlin/flavor/ProductFlavors.kt +++ b/buildSrc/src/main/kotlin/flavor/ProductFlavors.kt @@ -35,6 +35,7 @@ sealed class ProductFlavors( object Beta : ProductFlavors("beta", "Wire Beta") object Internal : ProductFlavors("internal", "Wire Internal") object Production : ProductFlavors("prod", "Wire", shareduserId = "com.waz.userid") + object Fdroid : ProductFlavors("fdroid", "Wire", shareduserId = "com.waz.userid") companion object { val all: Collection = setOf( @@ -43,6 +44,7 @@ sealed class ProductFlavors( Beta, Internal, Production, + Fdroid, ) } } diff --git a/buildSrc/src/main/kotlin/scripts/compilation.gradle.kts b/buildSrc/src/main/kotlin/scripts/compilation.gradle.kts index cd04c23c298..421db62bed7 100644 --- a/buildSrc/src/main/kotlin/scripts/compilation.gradle.kts +++ b/buildSrc/src/main/kotlin/scripts/compilation.gradle.kts @@ -18,6 +18,7 @@ package scripts +import WriteKeyValuesToFileTask import IncludeGitBuildTask plugins { @@ -25,12 +26,29 @@ plugins { } // TODO: Extract to a convention plugin -project.tasks.register("includeGitBuildIdentifier", IncludeGitBuildTask::class) { +val gitIdTask = project.tasks.register("includeGitBuildIdentifier", IncludeGitBuildTask::class) { println("> Registering Task :includeGitBuildIdentifier") } +val dependenciesVersionTask = project.tasks.register("dependenciesVersionTask", WriteKeyValuesToFileTask::class) { + outputJsonFile.set(project.file("src/main/assets/dependencies_version.json")) + val catalogs = project.extensions.getByType(VersionCatalogsExtension::class.java) + val catalog = catalogs.named("klibs") + val pairs = mapOf( + "avs" to catalog.findVersion("avs").get().requiredVersion, + "core-crypto" to catalog.findVersion("core-crypto").get().requiredVersion + ) + keyValues.set(pairs) +} + project.afterEvaluate { - project.tasks.matching { it.name.startsWith("bundle") || it.name.startsWith("assemble") }.configureEach { - dependsOn("includeGitBuildIdentifier") + project.tasks.matching { + it.name.startsWith("merge") && + it.name.endsWith("Assets") || + it.name.startsWith("lintVitalAnalyze") } + .configureEach { + dependsOn(gitIdTask) + dependsOn(dependenciesVersionTask) + } } diff --git a/buildSrc/src/main/kotlin/scripts/infrastructure.gradle.kts b/buildSrc/src/main/kotlin/scripts/infrastructure.gradle.kts index 6703c3919c4..d5ed870a37a 100644 --- a/buildSrc/src/main/kotlin/scripts/infrastructure.gradle.kts +++ b/buildSrc/src/main/kotlin/scripts/infrastructure.gradle.kts @@ -38,7 +38,7 @@ tasks.register("runUnitTests") { tasks.register("runAcceptanceTests") { description = "Runs all Acceptance Tests in the connected device." - dependsOn(":app:connected${Default.BUILD_FLAVOR.capitalize()}DebugAndroidTest") + dependsOn(":app:connected${Default.resolvedBuildFlavor().capitalize()}DebugAndroidTest") } tasks.register("assembleApp") { @@ -71,7 +71,7 @@ tasks.register("runApp", Exec::class) { val sdkDir = properties["sdk.dir"] val adb = "${sdkDir}/platform-tools/adb" - val applicationPackage = "com.wire.android.${Default.BUILD_FLAVOR}" + val applicationPackage = "com.wire.android.${Default.resolvedBuildFlavor()}" val launchActivity = "com.wire.android.feature.launch.ui.LauncherActivity" commandLine(adb, "shell", "am", "start", "-n", "${applicationPackage}/${launchActivity}") diff --git a/buildSrc/src/main/kotlin/scripts/quality.gradle.kts b/buildSrc/src/main/kotlin/scripts/quality.gradle.kts index 1c6415c4486..8de42f5679b 100644 --- a/buildSrc/src/main/kotlin/scripts/quality.gradle.kts +++ b/buildSrc/src/main/kotlin/scripts/quality.gradle.kts @@ -23,8 +23,8 @@ import io.gitlab.arturbosch.detekt.DetektCreateBaselineTask plugins { id("com.android.application") apply false - id("jacoco") id("io.gitlab.arturbosch.detekt") + id("org.jetbrains.kotlinx.kover") } dependencies { @@ -83,71 +83,49 @@ tasks.register("staticCodeAnalysis") { dependsOn(detektAll) } -// Jacoco Configuration -val jacocoReport by tasks.registering(JacocoReport::class) { - group = "Quality" - description = "Reports code coverage on tests within the Wire Android codebase" - val buildVariant = "devDebug" // It's not necessary to run unit tests on every variant so we default to "devDebug" - dependsOn("test${buildVariant.capitalize()}UnitTest") - - val outputDir = "$buildDir/jacoco/html" - val classPathBuildVariant = buildVariant - - reports { - xml.required.set(true) - html.required.set(true) - html.outputLocation.set(file(outputDir)) - } - - classDirectories.setFrom( - fileTree(project.buildDir) { - include( - "**/classes/**/main/**", // This probably can be removed - "**/tmp/kotlin-classes/$classPathBuildVariant/**" - ) - exclude( - "**/R.class", - "**/R\$*.class", - "**/BuildConfig.*", - "**/Manifest*.*", - "**/Manifest$*.class", - "**/*Test*.*", - "**/Injector.*", - "android/**/*.*", - "**/*\$Lambda$*.*", - "**/*\$inlined$*.*", - "**/di/*.*", - "**/*Database.*", - "**/*Response.*", - "**/*Application.*", - "**/*Entity.*", - "**/mock/**", - "**/*Screen*", // These are composable classes - "**/*Kt*", // These are "usually" kotlin generated classes - "**/theme/**/*.*", // Ignores jetpack compose theme related code - "**/common/**/*.*", // Ignores jetpack compose common components related code - "**/navigation/**/*.*" // Ignores jetpack navigation related code - ) - } - ) - - sourceDirectories.setFrom( - fileTree(project.projectDir) { - include("src/main/java/**", "src/main/kotlin/**") - } - ) - - executionData.setFrom( - fileTree(project.buildDir) { - include("**/*.exec", "**/*.ec") - } - ) - - doLast { println("Report file: $outputDir/index.html") } -} - tasks.register("testCoverage") { group = "Quality" description = "Reports code coverage on tests within the Wire Android codebase." - dependsOn(jacocoReport) + dependsOn("koverXmlReport") +} + +koverReport { + defaults { + mergeWith("devDebug") + + filters { + excludes { + classes( + "*Fragment", + "*Fragment\$*", + "*Activity", + "*Activity\$*", + "*.databinding.*", + "*.BuildConfig", + "**/R.class", + "**/R\$*.class", + "**/Manifest*.*", + "**/Manifest$*.class", + "**/*Test*.*", + "*NavArgs*", + "*ComposableSingletons*", + "*_HiltModules*", + "*Hilt_*", + ) + packages( + "hilt_aggregated_deps", + "com.wire.android.di", + "dagger.hilt.internal.aggregatedroot.codegen", + "com.wire.android.ui.home.conversations.mock", + ) + annotatedBy( + "*Generated*", + "*HomeNavGraph*", + "*Destination*", + "*Composable*", + "*Preview*", + ) + } + } + } } diff --git a/buildSrc/src/main/kotlin/scripts/variants.gradle.kts b/buildSrc/src/main/kotlin/scripts/variants.gradle.kts index e61bd559d92..c9fa0382723 100644 --- a/buildSrc/src/main/kotlin/scripts/variants.gradle.kts +++ b/buildSrc/src/main/kotlin/scripts/variants.gradle.kts @@ -40,10 +40,17 @@ object BuildTypes { } object Default { - val BUILD_FLAVOR: String = System.getenv("flavor") ?: System.getenv("FLAVOR") ?: ProductFlavors.Dev.buildName - val BUILD_TYPE = System.getenv("buildType") ?: System.getenv("BUILD_TYPE") ?: BuildTypes.DEBUG + fun explicitBuildFlavor(): String? = System.getenv("flavor") + ?: System.getenv("FLAVOR") - val BUILD_VARIANT = "${BUILD_FLAVOR.capitalize()}${BUILD_TYPE.capitalize()}" + fun resolvedBuildFlavor(): String = explicitBuildFlavor() ?: ProductFlavors.Dev.buildName + + fun explicitBuildType(): String? = System.getenv("buildType") + ?: System.getenv("BUILD_TYPE") + + fun resolvedBuildType(): String = explicitBuildType() ?: BuildTypes.DEBUG + + val BUILD_VARIANT = "${resolvedBuildFlavor().capitalize()}${resolvedBuildType().capitalize()}" } fun NamedDomainObjectContainer.createAppFlavour( diff --git a/default.json b/default.json index 0243e34c39b..47e1a9c53ab 100644 --- a/default.json +++ b/default.json @@ -27,7 +27,8 @@ "default_backend_url_blacklist": "https://clientblacklist.wire.com/staging", "default_backend_url_website": "https://wire.com", "default_backend_title": "wire-staging", - "is_password_protected_guest_link_enabled": true + "is_password_protected_guest_link_enabled": true, + "encrypt_proteus_storage": true }, "staging": { "application_id": "com.waz.zclient.dev", @@ -46,7 +47,8 @@ "default_backend_url_teams": "https://wire-teams-staging.zinfra.io", "default_backend_url_blacklist": "https://clientblacklist.wire.com/staging", "default_backend_url_website": "https://wire.com", - "default_backend_title": "wire-staging" + "default_backend_title": "wire-staging", + "encrypt_proteus_storage": true }, "beta": { "application_id": "com.wire.android.internal", @@ -54,7 +56,8 @@ "logging_enabled": true, "application_is_private_build": true, "development_api_enabled": false, - "mls_support_enabled": false + "mls_support_enabled": false, + "encrypt_proteus_storage": true }, "internal": { "application_id": "com.wire.internal", @@ -62,7 +65,15 @@ "logging_enabled": true, "application_is_private_build": true, "development_api_enabled": false, - "mls_support_enabled": true + "encrypt_proteus_storage": true + }, + "fdroid": { + "application_id": "com.wire", + "developer_features_enabled": false, + "logging_enabled": false, + "application_is_private_build": false, + "development_api_enabled": false, + "mls_support_enabled": false } }, "application_name": "Wire", @@ -78,6 +89,7 @@ "force_constant_bitrate_calls": false, "ignore_ssl_certificates": false, "mls_support_enabled": true, + "lower_keypackage_limit": false, "encrypt_proteus_storage": false, "self_deleting_messages": true, "wipe_on_cookie_invalid": false, @@ -109,5 +121,7 @@ "is_password_protected_guest_link_enabled": false, "url_rss_release_notes": "https://medium.com/feed/wire-news/tagged/android", "team_app_lock": false, - "team_app_lock_timeout": 60 + "team_app_lock_timeout": 60, + "max_remote_search_result_count": 30, + "limit_team_members_fetch_during_slow_sync": 2000 } diff --git a/docker-agent/builder.sh b/docker-agent/builder.sh new file mode 100755 index 00000000000..6010b02dcc0 --- /dev/null +++ b/docker-agent/builder.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash + +if [ "$RUN_STATIC_CODE_ANALYSIS" = true ]; then + echo "Running Static Code Analysis" + ./gradlew detektAll + ./gradlew staticCodeAnalysis +fi + +if [ "$RUN_APP_UNIT_TESTS" = true ] ; then + echo "Running App Unit Tests" + ./gradlew runUnitTests + ./gradlew runUnitTestsFdroid +fi + +if [ "$RUN_APP_ACCEPTANCE_TESTS" = true ] ; then + echo "Running Acceptance Tests" + ./gradlew runAcceptanceTests +fi + +buildOption='' +if [ "$BUILD_WITH_STACKTRACE" = true ] ; then + buildOption="--stacktrace " + echo "Stacktrace option enabled" +fi + +if [ "$CLEAN_PROJECT_BEFORE_BUILD" = true ] ; then + echo "Cleaning the Project" + ./gradlew clean +else + echo "Cleaning the project will be skipped" +fi + +if [ "$BUILD_CLIENT" = true ] ; then + echo "Compiling the client with Flavor:${CUSTOM_FLAVOR} and BuildType:${BUILD_TYPE}" + #./gradlew ${buildOption}assemble${FLAVOR_TYPE}${BUILD_TYPE} + ./gradlew ${buildOption}assemble${CUSTOM_FLAVOR} +else + echo "Building the client will be skipped" +fi + +if [ "$SIGN_APK" = true ] ; then + echo "Signing APK with given details" + clientVersion=$(sed -ne "s/.*ANDROID_CLIENT_MAJOR_VERSION = \"\([^']*\)\"/\1/p" buildSrc/src/main/kotlin/Dependencies.kt) + /home/android-agent/android-sdk/build-tools/30.0.2/apksigner sign --ks ${HOME}/wire-android/${KEYSTORE_PATH} --ks-key-alias ${KEYSTORE_KEY_NAME} --ks-pass pass:${KSTOREPWD} --key-pass pass:${KEYPWD} "${HOME}/wire-android/app/build/outputs/apk/wire-${CUSTOM_FLAVOR,,}-${BUILD_TYPE,,}-${clientVersion}${PATCH_VERSION}.apk" +else + echo "Apk will not be signed by the builder script" +fi diff --git a/docker-agent/configure-project.sh b/docker-agent/configure-project.sh new file mode 100755 index 00000000000..84a785d95d8 --- /dev/null +++ b/docker-agent/configure-project.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +FILE=local.properties +if test -f "$FILE"; then + echo "${FILE} exists already, replacing existing sdk.dir and ndk.dir" + sed -i -r "s!sdk.dir=(.*)!sdk.dir=${ANDROID_HOME}!g" $FILE + sed -i -r "s!android.ndkPath=(.*)!android.ndkPath=${ANDROID_NDK_HOME}!g" $FILE +else + echo "sdk.dir="$ANDROID_HOME >> local.properties + echo "android.ndkPath="$ANDROID_NDK_HOME >> local.properties +fi + echo "$ANDROID_HOME has been added as sdk.dir to ${FILE}" + echo "$ANDROID_NDK_HOME has been added as ndk.dir to ${FILE}" + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000000..1f2e03fa488 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,45 @@ +version: '3.9' +services: + wire-android-build-server: + build: + context: . + dockerfile: docker-agent/AndroidAgent + image: builder-agent:latest + container_name: wire-android-build-server + environment: + - CUSTOM_FLAVOR=Fdroid + - BUILD_TYPE=Release + - PATCH_VERSION=0 + - CLEAN_PROJECT_BEFORE_BUILD=true + - RUN_APP_UNIT_TESTS=true + - RUN_STATIC_CODE_ANALYSIS=true + - RUN_STORAGE_UNIT_TESTS=true + - RUN_STORAGE_ACCEPTANCE_TESTS=true + - BUILD_CLIENT=true + #### Signing Vars (KEYSTORE_PATH's home directory is the wire-android folder inside the docker container, start from there e.g. app/keystorefile.keystore) + #- SIGN_APK=true + #- KEYSTORE_PATH=your-path-to-your-keystore-file + #- KSTOREPWD=your-keystore-password + #- KEYPWD=your-key-password + #- KEYSTORE_KEY_NAME=your-key-name + ###### needed for custom client compilation + #- CUSTOM_REPOSITORY=https://github.com/wireapp/wire-android-custom-example + #- CUSTOM_FOLDER=example-co + #- CLIENT_FOLDER=client2 + #- GRGIT_USER="your-github-api-token-or-user-name" + #- GRGIT_PASSWORD="your-github-password-only-when-using-username" #only outcomment this if you wanna use username and password instead of a github api token + #### Debug Optins + - BUILD_WITH_STACKTRACE=true + # For permissions isues with GHA + user: "${UID}:${GID}" + volumes: + - ".:/home/android-agent/wire-android" + command: bash -c "cd /home/android-agent/wire-android && /home/android-agent/wire-android/docker-agent/configure-project.sh && /home/android-agent/wire-android/docker-agent/builder.sh" + # enable this service if you wanna check out the progress of the wire-androd-build-server on a webconsole over http://localhost:9999 + #dozzle: + # container_name: dozzle + # image: amir20/dozzle:latest + # volumes: + # - /var/run/docker.sock:/var/run/docker.sock + # ports: + # - 9999:8080 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 90f4c21fb1b..85a672045d9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -36,6 +36,7 @@ androidx-splashscreen = "1.0.1" androidx-workManager = "2.8.1" androidx-browser = "1.5.0" androidx-biometric = "1.1.0" +androidx-startup = "1.1.1" # Compose composeBom = "2023.10.01" # TODO check if in new version [anchoredDraggable] is available @@ -86,7 +87,7 @@ androidx-text-archCore = "2.1.0" junit4 = "4.13.2" junit5 = "5.10.0" kluent = "1.73" -mockk = "1.13.5" +mockk = "1.13.9" okio = "3.6.0" turbine = "1.0.0" @@ -158,6 +159,7 @@ androidx-exifInterface = { module = "androidx.exifinterface:exifinterface", vers androidx-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "androidx-splashscreen" } androidx-profile-installer = { group = "androidx.profileinstaller", name = "profileinstaller", version.ref = "profileinstaller" } androidx-biometric = { group = "androidx.biometric", name = "biometric", version.ref = "androidx-biometric" } +androidx-startup = { group = "androidx.startup", name = "startup-runtime", version.ref = "androidx-startup" } # Dependency Injection hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } diff --git a/kalium b/kalium index aa8d9077dcf..91be23a81d9 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit aa8d9077dcf4be11b174de6f78f3ed5869765edf +Subproject commit 91be23a81d973fdc30df33e42c88c531b965eb6a diff --git a/settings.gradle.kts b/settings.gradle.kts index 62426aa4225..83bcfff9fc0 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -34,6 +34,14 @@ rootDir include(":${it.name}") } +dependencyResolutionManagement { + versionCatalogs { + create("klibs") { + from(files("kalium/gradle/libs.versions.toml")) + } + } +} + // A work-around where we define the included builds in a different file // so Reloaded's Dependabot doesn't try to look into Kalium's build.gradle.kts, which is inaccessible as it is a git submodule. // See: https://github.com/dependabot/dependabot-core/issues/7201#issuecomment-1571319655