Compare commits

..

1 Commits

Author SHA1 Message Date
Marcel Hibbe
84e3615fc5
bump version to 21.1.0 RC2
Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
2025-05-23 11:10:32 +02:00
426 changed files with 3428 additions and 10822 deletions

View File

@ -1,4 +1,4 @@
FROM ubuntu:noble@sha256:c4570d2f4665d5d118ae29fb494dee4f8db8fcfaee0e37a2e19b827f399070d3
FROM ubuntu:noble@sha256:6015f66923d7afbc53558d7ccffd325d43b4e249f41a6e93eef074c9505d2233
ARG DEBIAN_FRONTEND=noninteractive
ENV ANDROID_HOME=/usr/lib/android-sdk

View File

@ -8,7 +8,7 @@ name: generic
steps:
- name: generic
image: ghcr.io/nextcloud/continuous-integration-android8:4
image: ghcr.io/nextcloud/continuous-integration-android8:3
commands:
- ./gradlew --console=plain assembleGeneric
@ -27,7 +27,7 @@ name: gplay
steps:
- name: gplay
image: ghcr.io/nextcloud/continuous-integration-android8:4
image: ghcr.io/nextcloud/continuous-integration-android8:3
commands:
- ./gradlew --console=plain assembleGplay
@ -46,7 +46,7 @@ name: tests
steps:
- name: all
image: ghcr.io/nextcloud/continuous-integration-android8:4
image: ghcr.io/nextcloud/continuous-integration-android8:3
privileged: true
commands:
- emulator -avd android -no-snapshot -gpu swiftshader_indirect -no-window -no-audio -skin 500x833 &
@ -81,6 +81,4 @@ trigger:
- pull_request
---
kind: signature
hmac: cf0c19e54fa45d1ee226f5f05202a32329b90aaf46711ea073c566a4c4a8a6c5
...
hmac: cdce3f7eea46ef85c0223f62f66d1fe53d7dad007ef095c9f70fa063450d8c75

View File

@ -29,7 +29,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Disabled on forks
if: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository }}
if: ${{ github.event.pull_request.head.repo.full_name != github.repository }}
run: |
echo 'Can not analyze PRs from forks'
exit 1

View File

@ -34,7 +34,7 @@ jobs:
java-version: 17
- name: Gradle validate
uses: gradle/actions/wrapper-validation@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1
uses: gradle/actions/wrapper-validation@06832c7b30a0129d7fb559bcc6e43d26f6374244 # v4.3.1
- name: Build ${{ matrix.flavor }}
run: |

View File

@ -43,7 +43,7 @@ jobs:
with:
swap-size-gb: 10
- name: Initialize CodeQL
uses: github/codeql-action/init@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2
uses: github/codeql-action/init@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18
with:
languages: ${{ matrix.language }}
- name: Set up JDK 17
@ -57,4 +57,4 @@ jobs:
echo "org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError" > "$HOME/.gradle/gradle.properties"
./gradlew assembleDebug
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2
uses: github/codeql-action/analyze@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18

View File

@ -36,7 +36,7 @@ jobs:
blocklist=$(curl https://raw.githubusercontent.com/nextcloud/.github/master/non-community-usernames.txt | paste -s -d, -)
echo "blocklist=$blocklist" >> "$GITHUB_OUTPUT"
- uses: nextcloud/pr-feedback-action@e397f3c7e655092b746e3610d121545530c6a90e # main
- uses: nextcloud/pr-feedback-action@1883b38a033fb16f576875e0cf45f98b857655c4 # main
with:
feedback-message: |
Hello there,

View File

@ -29,6 +29,8 @@ jobs:
permissions:
# for hmarr/auto-approve-action to approve PRs
pull-requests: write
# for alexwilson/enable-github-automerge-action to approve PRs
contents: write
steps:
- name: Disabled on forks
@ -44,18 +46,13 @@ jobs:
# GitHub actions bot approve
- uses: hmarr/auto-approve-action@f0939ea97e9205ef24d872e76833fa908a770363 # v4.0.0
if: github.actor == 'renovate[bot]'
if: startsWith(steps.branchname.outputs.branch, 'renovate/')
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.head_ref }}
# Enable GitHub auto merge
- name: Enable Pull Request Automerge
if: github.actor == 'renovate[bot]'
run: gh pr merge --merge --auto
env:
GH_TOKEN: ${{ secrets.AUTOMERGE }}
- name: Auto merge
uses: alexwilson/enable-github-automerge-action@56e3117d1ae1540309dc8f7a9f2825bc3c5f06ff # v2.0.0
if: startsWith(steps.branchname.outputs.branch, 'renovate/')
with:
github-token: ${{ secrets.GITHUB_TOKEN }}

View File

@ -12,11 +12,11 @@ name: REUSE Compliance Check
on: [pull_request]
permissions:
contents: read
contents: read
jobs:
reuse-compliance-check:
runs-on: ubuntu-latest-low
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

View File

@ -34,7 +34,7 @@ jobs:
persist-credentials: false
- name: "Run analysis"
uses: ossf/scorecard-action@05b42c624433fc40578a4040d5cf5e36ddca8cde # v2.4.2
uses: ossf/scorecard-action@f49aabe0b5af0936a0987cfb85d86b75731b0186 # v2.4.1
with:
results_file: results.sarif
results_format: sarif
@ -42,6 +42,6 @@ jobs:
# Upload the results to GitHub's code scanning dashboard.
- name: "Upload to code-scanning"
uses: github/codeql-action/upload-sarif@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2
uses: github/codeql-action/upload-sarif@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18
with:
sarif_file: results.sarif

View File

@ -33,7 +33,7 @@ jobs:
java-version: 17
- name: Setup Gradle
uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1
uses: gradle/actions/setup-gradle@06832c7b30a0129d7fb559bcc6e43d26f6374244 # v4.3.1
- name: Run unit tests with coverage
run: ./gradlew testGplayDebugUnit

View File

@ -9,36 +9,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
Types of changes can be: Added/Changed/Deprecated/Removed/Fixed/Security
## [21.1.0] - 2025-06-05
### Added
- Allow adding participants to one-to-one chats creating a new conversation
- Handling of event conversations
- Show info about participant (organization, role, timezone, ...) in 1:1 conversation info screen
- Added gallery option in chat attachment menu (access photos and videos with one click without giving permissions)
- Add self-test button for push notifications in diagnosis screen
- Edit checkbox in chat messages
- Team mentions in chat
- Add option to mark a conversation as sensitive
- Allow bluetooth headset to be discovered and used during a call (@gavine99)
### Changed
- Design of participants grid in call
- Improve subline in conversations screen when last message is attachment
- Improve message search
- In search window, show messages at last
- Switch video capture for calls between 4:3 and 16:9 ratio depending on portrait/ landscape mode
### Fixed
- Crashes
- Videos in videocall lost after app comes back to foreground
- Open conversations not being shown in search
- Minor bugs (@MmAaXx500)
Minimum: NC 17 Server, Android 8.0 Oreo
For a full list, please see https://github.com/nextcloud/talk-android/milestone/94?closed=1
## [21.0.1] - 2025-04-15
### Fixed

View File

@ -13,9 +13,9 @@ import com.github.spotbugs.snom.Effort
import com.github.spotbugs.snom.SpotBugsTask
plugins {
id "org.jetbrains.kotlin.plugin.compose" version "2.2.0"
id "org.jetbrains.kotlin.plugin.compose" version "2.1.21"
id "org.jetbrains.kotlin.kapt"
id 'com.google.devtools.ksp' version '2.2.0-2.0.2'
id 'com.google.devtools.ksp' version '2.1.21-2.0.1'
}
apply plugin: 'com.android.application'
@ -28,19 +28,19 @@ apply plugin: "org.jlleitschuh.gradle.ktlint"
apply plugin: 'kotlinx-serialization'
android {
compileSdk 35
compileSdk 34
namespace 'com.nextcloud.talk'
defaultConfig {
minSdkVersion 26
targetSdkVersion 35
targetSdkVersion 34
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
// mayor.minor.hotfix.increment (for increment: 01-50=Alpha / 51-89=RC / 90-99=stable)
// xx .xxx .xx .xx
versionCode 220000010
versionName "22.0.0 Alpha 10"
versionCode 210010052
versionName "21.1.0 RC2"
flavorDimensions "default"
renderscriptTargetApi 19
@ -155,18 +155,18 @@ ext {
daggerVersion = "2.56.2"
emojiVersion = "1.5.0"
fidoVersion = "4.1.0-patch2"
lifecycleVersion = '2.9.1'
lifecycleVersion = '2.8.7'
okhttpVersion = "4.12.0"
markwonVersion = "4.6.2"
materialDialogsVersion = "3.3.0"
parcelerVersion = "1.1.13"
prismVersion = "2.0.0"
retrofit2Version = "3.0.0"
roomVersion = "2.7.2"
workVersion = "2.10.2"
retrofit2Version = "2.11.0"
roomVersion = "2.7.1"
workVersion = "2.9.1"
espressoVersion = "3.6.1"
androidxTestVersion = "1.5.0"
media3_version = "1.7.1"
media3_version = "1.4.1"
coroutines_version = "1.10.2"
mockitoKotlinVersion = "5.4.0"
}
@ -180,20 +180,20 @@ configurations.configureEach {
dependencies {
spotbugsPlugins 'com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0'
spotbugsPlugins 'com.mebigfatguy.fb-contrib:fb-contrib:7.6.11'
spotbugsPlugins 'com.mebigfatguy.fb-contrib:fb-contrib:7.6.9'
detektPlugins("io.gitlab.arturbosch.detekt:detekt-formatting:1.23.8")
implementation("androidx.compose.runtime:runtime:1.8.3")
implementation("androidx.compose.runtime:runtime:1.7.8")
implementation 'androidx.preference:preference-ktx:1.2.1'
implementation 'androidx.datastore:datastore-core:1.1.7'
implementation 'androidx.datastore:datastore-preferences:1.1.7'
implementation 'androidx.datastore:datastore-core:1.1.6'
implementation 'androidx.datastore:datastore-preferences:1.1.6'
implementation 'androidx.test.ext:junit-ktx:1.2.1'
implementation fileTree(include: ['*'], dir: 'libs')
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0"
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1"
implementation 'androidx.appcompat:appcompat:1.7.1'
implementation 'androidx.appcompat:appcompat:1.7.0'
implementation 'com.google.android.material:material:1.12.0'
implementation 'androidx.constraintlayout:constraintlayout:2.2.1'
implementation "com.vanniktech:emoji-google:0.21.0"
@ -210,7 +210,6 @@ dependencies {
exclude group: 'org.ogce', module: 'xpp3' // Android comes with its own XmlPullParser
})
implementation 'org.conscrypt:conscrypt-android:2.5.3'
implementation "com.github.nextcloud-deps:qrcodescanner:0.1.2.4" // "com.github.blikoon:QRCodeScanner:0.1.2"
implementation "androidx.camera:camera-core:${androidxCameraVersion}"
implementation "androidx.camera:camera-camera2:${androidxCameraVersion}"
@ -237,7 +236,7 @@ dependencies {
implementation "com.squareup.okhttp3:logging-interceptor:${okhttpVersion}"
implementation 'com.bluelinelabs:logansquare:1.3.7'
implementation 'com.fasterxml.jackson.core:jackson-core:2.19.1'
implementation 'com.fasterxml.jackson.core:jackson-core:2.14.3'
kapt 'com.bluelinelabs:logansquare-compiler:1.3.7'
implementation "com.squareup.retrofit2:retrofit:${retrofit2Version}"
@ -252,7 +251,7 @@ dependencies {
compileOnly 'javax.annotation:javax.annotation-api:1.3.2'
implementation 'org.greenrobot:eventbus:3.3.1'
implementation 'net.zetetic:sqlcipher-android:4.9.0'
implementation 'net.zetetic:android-database-sqlcipher:4.5.4'
implementation "androidx.room:room-runtime:${roomVersion}"
implementation "androidx.room:room-rxjava2:${roomVersion}"
@ -302,35 +301,35 @@ dependencies {
exclude group: 'org.apache.httpcomponents', module: 'httpclient'
})
implementation 'androidx.core:core-ktx:1.16.0'
implementation 'androidx.activity:activity-ktx:1.10.1'
implementation 'com.github.nextcloud.android-common:ui:0.27.0'
implementation 'androidx.core:core-ktx:1.13.1'
implementation 'androidx.activity:activity-ktx:1.9.3'
implementation 'com.github.nextcloud.android-common:ui:0.23.2'
implementation 'com.github.nextcloud-deps:android-talk-webrtc:132.6834.0'
gplayImplementation 'com.google.android.gms:play-services-base:18.6.0'
gplayImplementation "com.google.firebase:firebase-messaging:24.1.2"
gplayImplementation "com.google.firebase:firebase-messaging:24.1.1"
//compose
implementation(platform("androidx.compose:compose-bom:2025.06.01"))
implementation(platform("androidx.compose:compose-bom:2025.04.00"))
implementation("androidx.compose.ui:ui")
implementation 'androidx.compose.material3:material3:1.3.2'
implementation("androidx.compose.ui:ui-tooling-preview")
implementation 'androidx.activity:activity-compose:1.10.1'
implementation 'androidx.activity:activity-compose:1.9.3'
debugImplementation("androidx.compose.ui:ui-tooling")
//tests
androidTestImplementation("androidx.compose.ui:ui-test-junit4:1.8.3")
androidTestImplementation("androidx.compose.ui:ui-test-junit4:1.7.8")
debugImplementation("androidx.compose.ui:ui-test-manifest")
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.mockito:mockito-core:5.18.0'
testImplementation 'org.mockito:mockito-core:5.17.0'
testImplementation 'androidx.arch.core:core-testing:2.2.0'
androidTestImplementation "androidx.test:core:1.6.1"
androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2"
androidTestImplementation 'androidx.test:core-ktx:1.6.1'
androidTestImplementation 'org.mockito:mockito-android:5.18.0'
androidTestImplementation 'org.mockito:mockito-android:5.17.0'
androidTestImplementation "androidx.work:work-testing:${workVersion}"
// Espresso core
androidTestImplementation ("androidx.test.espresso:espresso-core:$espressoVersion", {
@ -344,11 +343,11 @@ dependencies {
androidTestImplementation('com.android.support.test.espresso:espresso-intents:3.0.2')
androidTestImplementation(platform("androidx.compose:compose-bom:2025.06.01"))
androidTestImplementation(platform("androidx.compose:compose-bom:2025.04.00"))
testImplementation "org.mockito.kotlin:mockito-kotlin:$mockitoKotlinVersion"
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version"
testImplementation 'org.junit.vintage:junit-vintage-engine:5.13.3'
testImplementation 'org.junit.vintage:junit-vintage-engine:5.12.2'
}
tasks.register('installGitHooks', Copy) {

View File

@ -1,731 +0,0 @@
{
"formatVersion": 1,
"database": {
"version": 16,
"identityHash": "bbf526d5c78a99eb951635cc46f4c59f",
"entities": [
{
"tableName": "User",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` TEXT, `username` TEXT, `baseUrl` TEXT, `token` TEXT, `displayName` TEXT, `pushConfigurationState` TEXT, `capabilities` TEXT, `serverVersion` TEXT DEFAULT '', `clientCertificate` TEXT, `externalSignalingServer` TEXT, `current` INTEGER NOT NULL, `scheduledForDeletion` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "TEXT"
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT"
},
{
"fieldPath": "baseUrl",
"columnName": "baseUrl",
"affinity": "TEXT"
},
{
"fieldPath": "token",
"columnName": "token",
"affinity": "TEXT"
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT"
},
{
"fieldPath": "pushConfigurationState",
"columnName": "pushConfigurationState",
"affinity": "TEXT"
},
{
"fieldPath": "capabilities",
"columnName": "capabilities",
"affinity": "TEXT"
},
{
"fieldPath": "serverVersion",
"columnName": "serverVersion",
"affinity": "TEXT",
"defaultValue": "''"
},
{
"fieldPath": "clientCertificate",
"columnName": "clientCertificate",
"affinity": "TEXT"
},
{
"fieldPath": "externalSignalingServer",
"columnName": "externalSignalingServer",
"affinity": "TEXT"
},
{
"fieldPath": "current",
"columnName": "current",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "scheduledForDeletion",
"columnName": "scheduledForDeletion",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
},
{
"tableName": "ArbitraryStorage",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountIdentifier` INTEGER NOT NULL, `key` TEXT NOT NULL, `object` TEXT, `value` TEXT, PRIMARY KEY(`accountIdentifier`, `key`))",
"fields": [
{
"fieldPath": "accountIdentifier",
"columnName": "accountIdentifier",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "key",
"columnName": "key",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "storageObject",
"columnName": "object",
"affinity": "TEXT"
},
{
"fieldPath": "value",
"columnName": "value",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"accountIdentifier",
"key"
]
}
},
{
"tableName": "Conversations",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`internalId` TEXT NOT NULL, `accountId` INTEGER NOT NULL, `token` TEXT NOT NULL, `displayName` TEXT NOT NULL, `actorId` TEXT NOT NULL, `actorType` TEXT NOT NULL, `avatarVersion` TEXT NOT NULL, `callFlag` INTEGER NOT NULL, `callRecording` INTEGER NOT NULL, `callStartTime` INTEGER NOT NULL, `canDeleteConversation` INTEGER NOT NULL, `canLeaveConversation` INTEGER NOT NULL, `canStartCall` INTEGER NOT NULL, `description` TEXT NOT NULL, `hasCall` INTEGER NOT NULL, `hasPassword` INTEGER NOT NULL, `isCustomAvatar` INTEGER NOT NULL, `isFavorite` INTEGER NOT NULL, `lastActivity` INTEGER NOT NULL, `lastCommonReadMessage` INTEGER NOT NULL, `lastMessage` TEXT, `lastPing` INTEGER NOT NULL, `lastReadMessage` INTEGER NOT NULL, `lobbyState` TEXT NOT NULL, `lobbyTimer` INTEGER NOT NULL, `messageExpiration` INTEGER NOT NULL, `name` TEXT NOT NULL, `notificationCalls` INTEGER NOT NULL, `notificationLevel` TEXT NOT NULL, `objectType` TEXT NOT NULL, `objectId` TEXT NOT NULL, `participantType` TEXT NOT NULL, `permissions` INTEGER NOT NULL, `readOnly` TEXT NOT NULL, `recordingConsent` INTEGER NOT NULL, `remoteServer` TEXT, `remoteToken` TEXT, `sessionId` TEXT NOT NULL, `status` TEXT, `statusClearAt` INTEGER, `statusIcon` TEXT, `statusMessage` TEXT, `type` TEXT NOT NULL, `unreadMention` INTEGER NOT NULL, `unreadMentionDirect` INTEGER NOT NULL, `unreadMessages` INTEGER NOT NULL, `hasArchived` INTEGER NOT NULL, `hasSensitive` INTEGER NOT NULL, `hasImportant` INTEGER NOT NULL, PRIMARY KEY(`internalId`), FOREIGN KEY(`accountId`) REFERENCES `User`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "internalId",
"columnName": "internalId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "token",
"columnName": "token",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "actorId",
"columnName": "actorId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "actorType",
"columnName": "actorType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "avatarVersion",
"columnName": "avatarVersion",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "callFlag",
"columnName": "callFlag",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "callRecording",
"columnName": "callRecording",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "callStartTime",
"columnName": "callStartTime",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "canDeleteConversation",
"columnName": "canDeleteConversation",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "canLeaveConversation",
"columnName": "canLeaveConversation",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "canStartCall",
"columnName": "canStartCall",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "hasCall",
"columnName": "hasCall",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "hasPassword",
"columnName": "hasPassword",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "hasCustomAvatar",
"columnName": "isCustomAvatar",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "favorite",
"columnName": "isFavorite",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastActivity",
"columnName": "lastActivity",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastCommonReadMessage",
"columnName": "lastCommonReadMessage",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastMessage",
"columnName": "lastMessage",
"affinity": "TEXT"
},
{
"fieldPath": "lastPing",
"columnName": "lastPing",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastReadMessage",
"columnName": "lastReadMessage",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lobbyState",
"columnName": "lobbyState",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lobbyTimer",
"columnName": "lobbyTimer",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "messageExpiration",
"columnName": "messageExpiration",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "notificationCalls",
"columnName": "notificationCalls",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationLevel",
"columnName": "notificationLevel",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "objectType",
"columnName": "objectType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "objectId",
"columnName": "objectId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "participantType",
"columnName": "participantType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "permissions",
"columnName": "permissions",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "conversationReadOnlyState",
"columnName": "readOnly",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "recordingConsentRequired",
"columnName": "recordingConsent",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "remoteServer",
"columnName": "remoteServer",
"affinity": "TEXT"
},
{
"fieldPath": "remoteToken",
"columnName": "remoteToken",
"affinity": "TEXT"
},
{
"fieldPath": "sessionId",
"columnName": "sessionId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "status",
"columnName": "status",
"affinity": "TEXT"
},
{
"fieldPath": "statusClearAt",
"columnName": "statusClearAt",
"affinity": "INTEGER"
},
{
"fieldPath": "statusIcon",
"columnName": "statusIcon",
"affinity": "TEXT"
},
{
"fieldPath": "statusMessage",
"columnName": "statusMessage",
"affinity": "TEXT"
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "unreadMention",
"columnName": "unreadMention",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "unreadMentionDirect",
"columnName": "unreadMentionDirect",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "unreadMessages",
"columnName": "unreadMessages",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "hasArchived",
"columnName": "hasArchived",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "hasSensitive",
"columnName": "hasSensitive",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "hasImportant",
"columnName": "hasImportant",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"internalId"
]
},
"indices": [
{
"name": "index_Conversations_accountId",
"unique": false,
"columnNames": [
"accountId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_Conversations_accountId` ON `${TABLE_NAME}` (`accountId`)"
}
],
"foreignKeys": [
{
"table": "User",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"accountId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "ChatMessages",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`internalId` TEXT NOT NULL, `accountId` INTEGER NOT NULL, `token` TEXT NOT NULL, `id` INTEGER NOT NULL, `internalConversationId` TEXT NOT NULL, `actorDisplayName` TEXT NOT NULL, `message` TEXT NOT NULL, `actorId` TEXT NOT NULL, `actorType` TEXT NOT NULL, `deleted` INTEGER NOT NULL, `expirationTimestamp` INTEGER NOT NULL, `isReplyable` INTEGER NOT NULL, `isTemporary` INTEGER NOT NULL, `lastEditActorDisplayName` TEXT, `lastEditActorId` TEXT, `lastEditActorType` TEXT, `lastEditTimestamp` INTEGER, `markdown` INTEGER, `messageParameters` TEXT, `messageType` TEXT NOT NULL, `parent` INTEGER, `reactions` TEXT, `reactionsSelf` TEXT, `referenceId` TEXT, `sendingFailed` INTEGER NOT NULL, `silent` INTEGER NOT NULL, `systemMessage` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`internalId`), FOREIGN KEY(`internalConversationId`) REFERENCES `Conversations`(`internalId`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "internalId",
"columnName": "internalId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "token",
"columnName": "token",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "internalConversationId",
"columnName": "internalConversationId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "actorDisplayName",
"columnName": "actorDisplayName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "message",
"columnName": "message",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "actorId",
"columnName": "actorId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "actorType",
"columnName": "actorType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "deleted",
"columnName": "deleted",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "expirationTimestamp",
"columnName": "expirationTimestamp",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "replyable",
"columnName": "isReplyable",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isTemporary",
"columnName": "isTemporary",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastEditActorDisplayName",
"columnName": "lastEditActorDisplayName",
"affinity": "TEXT"
},
{
"fieldPath": "lastEditActorId",
"columnName": "lastEditActorId",
"affinity": "TEXT"
},
{
"fieldPath": "lastEditActorType",
"columnName": "lastEditActorType",
"affinity": "TEXT"
},
{
"fieldPath": "lastEditTimestamp",
"columnName": "lastEditTimestamp",
"affinity": "INTEGER"
},
{
"fieldPath": "renderMarkdown",
"columnName": "markdown",
"affinity": "INTEGER"
},
{
"fieldPath": "messageParameters",
"columnName": "messageParameters",
"affinity": "TEXT"
},
{
"fieldPath": "messageType",
"columnName": "messageType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "parentMessageId",
"columnName": "parent",
"affinity": "INTEGER"
},
{
"fieldPath": "reactions",
"columnName": "reactions",
"affinity": "TEXT"
},
{
"fieldPath": "reactionsSelf",
"columnName": "reactionsSelf",
"affinity": "TEXT"
},
{
"fieldPath": "referenceId",
"columnName": "referenceId",
"affinity": "TEXT"
},
{
"fieldPath": "sendingFailed",
"columnName": "sendingFailed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "silent",
"columnName": "silent",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "systemMessageType",
"columnName": "systemMessage",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "timestamp",
"columnName": "timestamp",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"internalId"
]
},
"indices": [
{
"name": "index_ChatMessages_internalId",
"unique": true,
"columnNames": [
"internalId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ChatMessages_internalId` ON `${TABLE_NAME}` (`internalId`)"
},
{
"name": "index_ChatMessages_internalConversationId",
"unique": false,
"columnNames": [
"internalConversationId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_ChatMessages_internalConversationId` ON `${TABLE_NAME}` (`internalConversationId`)"
}
],
"foreignKeys": [
{
"table": "Conversations",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"internalConversationId"
],
"referencedColumns": [
"internalId"
]
}
]
},
{
"tableName": "ChatBlocks",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `internalConversationId` TEXT NOT NULL, `accountId` INTEGER, `token` TEXT, `oldestMessageId` INTEGER NOT NULL, `newestMessageId` INTEGER NOT NULL, `hasHistory` INTEGER NOT NULL, FOREIGN KEY(`internalConversationId`) REFERENCES `Conversations`(`internalId`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "internalConversationId",
"columnName": "internalConversationId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "INTEGER"
},
{
"fieldPath": "token",
"columnName": "token",
"affinity": "TEXT"
},
{
"fieldPath": "oldestMessageId",
"columnName": "oldestMessageId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "newestMessageId",
"columnName": "newestMessageId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "hasHistory",
"columnName": "hasHistory",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_ChatBlocks_internalConversationId",
"unique": false,
"columnNames": [
"internalConversationId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_ChatBlocks_internalConversationId` ON `${TABLE_NAME}` (`internalConversationId`)"
}
],
"foreignKeys": [
{
"table": "Conversations",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"internalConversationId"
],
"referencedColumns": [
"internalId"
]
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'bbf526d5c78a99eb951635cc46f4c59f')"
]
}
}

View File

@ -1,730 +0,0 @@
{
"formatVersion": 1,
"database": {
"version": 17,
"identityHash": "5bc4247e179307faa995552da5d34324",
"entities": [
{
"tableName": "User",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` TEXT, `username` TEXT, `baseUrl` TEXT, `token` TEXT, `displayName` TEXT, `pushConfigurationState` TEXT, `capabilities` TEXT, `serverVersion` TEXT DEFAULT '', `clientCertificate` TEXT, `externalSignalingServer` TEXT, `current` INTEGER NOT NULL, `scheduledForDeletion` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "TEXT"
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT"
},
{
"fieldPath": "baseUrl",
"columnName": "baseUrl",
"affinity": "TEXT"
},
{
"fieldPath": "token",
"columnName": "token",
"affinity": "TEXT"
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT"
},
{
"fieldPath": "pushConfigurationState",
"columnName": "pushConfigurationState",
"affinity": "TEXT"
},
{
"fieldPath": "capabilities",
"columnName": "capabilities",
"affinity": "TEXT"
},
{
"fieldPath": "serverVersion",
"columnName": "serverVersion",
"affinity": "TEXT",
"defaultValue": "''"
},
{
"fieldPath": "clientCertificate",
"columnName": "clientCertificate",
"affinity": "TEXT"
},
{
"fieldPath": "externalSignalingServer",
"columnName": "externalSignalingServer",
"affinity": "TEXT"
},
{
"fieldPath": "current",
"columnName": "current",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "scheduledForDeletion",
"columnName": "scheduledForDeletion",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
},
{
"tableName": "ArbitraryStorage",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountIdentifier` INTEGER NOT NULL, `key` TEXT NOT NULL, `object` TEXT, `value` TEXT, PRIMARY KEY(`accountIdentifier`, `key`))",
"fields": [
{
"fieldPath": "accountIdentifier",
"columnName": "accountIdentifier",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "key",
"columnName": "key",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "storageObject",
"columnName": "object",
"affinity": "TEXT"
},
{
"fieldPath": "value",
"columnName": "value",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"accountIdentifier",
"key"
]
}
},
{
"tableName": "Conversations",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`internalId` TEXT NOT NULL, `accountId` INTEGER NOT NULL, `token` TEXT NOT NULL, `displayName` TEXT NOT NULL, `actorId` TEXT NOT NULL, `actorType` TEXT NOT NULL, `avatarVersion` TEXT NOT NULL, `callFlag` INTEGER NOT NULL, `callRecording` INTEGER NOT NULL, `callStartTime` INTEGER NOT NULL, `canDeleteConversation` INTEGER NOT NULL, `canLeaveConversation` INTEGER NOT NULL, `canStartCall` INTEGER NOT NULL, `description` TEXT NOT NULL, `hasCall` INTEGER NOT NULL, `hasPassword` INTEGER NOT NULL, `isCustomAvatar` INTEGER NOT NULL, `isFavorite` INTEGER NOT NULL, `lastActivity` INTEGER NOT NULL, `lastCommonReadMessage` INTEGER NOT NULL, `lastMessage` TEXT, `lastPing` INTEGER NOT NULL, `lastReadMessage` INTEGER NOT NULL, `lobbyState` TEXT NOT NULL, `lobbyTimer` INTEGER NOT NULL, `messageExpiration` INTEGER NOT NULL, `name` TEXT NOT NULL, `notificationCalls` INTEGER NOT NULL, `notificationLevel` TEXT NOT NULL, `objectType` TEXT NOT NULL, `objectId` TEXT NOT NULL, `participantType` TEXT NOT NULL, `permissions` INTEGER NOT NULL, `readOnly` TEXT NOT NULL, `recordingConsent` INTEGER NOT NULL, `remoteServer` TEXT, `remoteToken` TEXT, `sessionId` TEXT NOT NULL, `status` TEXT, `statusClearAt` INTEGER, `statusIcon` TEXT, `statusMessage` TEXT, `type` TEXT NOT NULL, `unreadMention` INTEGER NOT NULL, `unreadMentionDirect` INTEGER NOT NULL, `unreadMessages` INTEGER NOT NULL, `hasArchived` INTEGER NOT NULL, `hasSensitive` INTEGER NOT NULL, `hasImportant` INTEGER NOT NULL, PRIMARY KEY(`internalId`), FOREIGN KEY(`accountId`) REFERENCES `User`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "internalId",
"columnName": "internalId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "token",
"columnName": "token",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "actorId",
"columnName": "actorId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "actorType",
"columnName": "actorType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "avatarVersion",
"columnName": "avatarVersion",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "callFlag",
"columnName": "callFlag",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "callRecording",
"columnName": "callRecording",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "callStartTime",
"columnName": "callStartTime",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "canDeleteConversation",
"columnName": "canDeleteConversation",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "canLeaveConversation",
"columnName": "canLeaveConversation",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "canStartCall",
"columnName": "canStartCall",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "hasCall",
"columnName": "hasCall",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "hasPassword",
"columnName": "hasPassword",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "hasCustomAvatar",
"columnName": "isCustomAvatar",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "favorite",
"columnName": "isFavorite",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastActivity",
"columnName": "lastActivity",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastCommonReadMessage",
"columnName": "lastCommonReadMessage",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastMessage",
"columnName": "lastMessage",
"affinity": "TEXT"
},
{
"fieldPath": "lastPing",
"columnName": "lastPing",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastReadMessage",
"columnName": "lastReadMessage",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lobbyState",
"columnName": "lobbyState",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lobbyTimer",
"columnName": "lobbyTimer",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "messageExpiration",
"columnName": "messageExpiration",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "notificationCalls",
"columnName": "notificationCalls",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationLevel",
"columnName": "notificationLevel",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "objectType",
"columnName": "objectType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "objectId",
"columnName": "objectId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "participantType",
"columnName": "participantType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "permissions",
"columnName": "permissions",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "conversationReadOnlyState",
"columnName": "readOnly",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "recordingConsentRequired",
"columnName": "recordingConsent",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "remoteServer",
"columnName": "remoteServer",
"affinity": "TEXT"
},
{
"fieldPath": "remoteToken",
"columnName": "remoteToken",
"affinity": "TEXT"
},
{
"fieldPath": "sessionId",
"columnName": "sessionId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "status",
"columnName": "status",
"affinity": "TEXT"
},
{
"fieldPath": "statusClearAt",
"columnName": "statusClearAt",
"affinity": "INTEGER"
},
{
"fieldPath": "statusIcon",
"columnName": "statusIcon",
"affinity": "TEXT"
},
{
"fieldPath": "statusMessage",
"columnName": "statusMessage",
"affinity": "TEXT"
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "unreadMention",
"columnName": "unreadMention",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "unreadMentionDirect",
"columnName": "unreadMentionDirect",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "unreadMessages",
"columnName": "unreadMessages",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "hasArchived",
"columnName": "hasArchived",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "hasSensitive",
"columnName": "hasSensitive",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "hasImportant",
"columnName": "hasImportant",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"internalId"
]
},
"indices": [
{
"name": "index_Conversations_accountId",
"unique": false,
"columnNames": [
"accountId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_Conversations_accountId` ON `${TABLE_NAME}` (`accountId`)"
}
],
"foreignKeys": [
{
"table": "User",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"accountId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "ChatMessages",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`internalId` TEXT NOT NULL, `accountId` INTEGER NOT NULL, `token` TEXT NOT NULL, `id` INTEGER NOT NULL, `internalConversationId` TEXT NOT NULL, `actorDisplayName` TEXT NOT NULL, `message` TEXT NOT NULL, `actorId` TEXT NOT NULL, `actorType` TEXT NOT NULL, `deleted` INTEGER NOT NULL, `expirationTimestamp` INTEGER NOT NULL, `isReplyable` INTEGER NOT NULL, `isTemporary` INTEGER NOT NULL, `lastEditActorDisplayName` TEXT, `lastEditActorId` TEXT, `lastEditActorType` TEXT, `lastEditTimestamp` INTEGER, `markdown` INTEGER, `messageParameters` TEXT, `messageType` TEXT NOT NULL, `parent` INTEGER, `reactions` TEXT, `reactionsSelf` TEXT, `referenceId` TEXT, `sendStatus` TEXT, `silent` INTEGER NOT NULL, `systemMessage` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`internalId`), FOREIGN KEY(`internalConversationId`) REFERENCES `Conversations`(`internalId`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "internalId",
"columnName": "internalId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "token",
"columnName": "token",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "internalConversationId",
"columnName": "internalConversationId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "actorDisplayName",
"columnName": "actorDisplayName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "message",
"columnName": "message",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "actorId",
"columnName": "actorId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "actorType",
"columnName": "actorType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "deleted",
"columnName": "deleted",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "expirationTimestamp",
"columnName": "expirationTimestamp",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "replyable",
"columnName": "isReplyable",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isTemporary",
"columnName": "isTemporary",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastEditActorDisplayName",
"columnName": "lastEditActorDisplayName",
"affinity": "TEXT"
},
{
"fieldPath": "lastEditActorId",
"columnName": "lastEditActorId",
"affinity": "TEXT"
},
{
"fieldPath": "lastEditActorType",
"columnName": "lastEditActorType",
"affinity": "TEXT"
},
{
"fieldPath": "lastEditTimestamp",
"columnName": "lastEditTimestamp",
"affinity": "INTEGER"
},
{
"fieldPath": "renderMarkdown",
"columnName": "markdown",
"affinity": "INTEGER"
},
{
"fieldPath": "messageParameters",
"columnName": "messageParameters",
"affinity": "TEXT"
},
{
"fieldPath": "messageType",
"columnName": "messageType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "parentMessageId",
"columnName": "parent",
"affinity": "INTEGER"
},
{
"fieldPath": "reactions",
"columnName": "reactions",
"affinity": "TEXT"
},
{
"fieldPath": "reactionsSelf",
"columnName": "reactionsSelf",
"affinity": "TEXT"
},
{
"fieldPath": "referenceId",
"columnName": "referenceId",
"affinity": "TEXT"
},
{
"fieldPath": "sendStatus",
"columnName": "sendStatus",
"affinity": "TEXT"
},
{
"fieldPath": "silent",
"columnName": "silent",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "systemMessageType",
"columnName": "systemMessage",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "timestamp",
"columnName": "timestamp",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"internalId"
]
},
"indices": [
{
"name": "index_ChatMessages_internalId",
"unique": true,
"columnNames": [
"internalId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ChatMessages_internalId` ON `${TABLE_NAME}` (`internalId`)"
},
{
"name": "index_ChatMessages_internalConversationId",
"unique": false,
"columnNames": [
"internalConversationId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_ChatMessages_internalConversationId` ON `${TABLE_NAME}` (`internalConversationId`)"
}
],
"foreignKeys": [
{
"table": "Conversations",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"internalConversationId"
],
"referencedColumns": [
"internalId"
]
}
]
},
{
"tableName": "ChatBlocks",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `internalConversationId` TEXT NOT NULL, `accountId` INTEGER, `token` TEXT, `oldestMessageId` INTEGER NOT NULL, `newestMessageId` INTEGER NOT NULL, `hasHistory` INTEGER NOT NULL, FOREIGN KEY(`internalConversationId`) REFERENCES `Conversations`(`internalId`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "internalConversationId",
"columnName": "internalConversationId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "INTEGER"
},
{
"fieldPath": "token",
"columnName": "token",
"affinity": "TEXT"
},
{
"fieldPath": "oldestMessageId",
"columnName": "oldestMessageId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "newestMessageId",
"columnName": "newestMessageId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "hasHistory",
"columnName": "hasHistory",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_ChatBlocks_internalConversationId",
"unique": false,
"columnNames": [
"internalConversationId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_ChatBlocks_internalConversationId` ON `${TABLE_NAME}` (`internalConversationId`)"
}
],
"foreignKeys": [
{
"table": "Conversations",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"internalConversationId"
],
"referencedColumns": [
"internalId"
]
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5bc4247e179307faa995552da5d34324')"
]
}
}

View File

@ -180,8 +180,8 @@ class ChatBlocksDaoTest {
scheduledForDeletion = java.lang.Boolean.FALSE
)
private fun createConversationEntity(accountId: Long, token: String, roomName: String) =
ConversationEntity(
private fun createConversationEntity(accountId: Long, token: String, roomName: String): ConversationEntity {
return ConversationEntity(
internalId = "$accountId@$token",
accountId = accountId,
token = token,
@ -229,4 +229,5 @@ class ChatBlocksDaoTest {
participantType = Participant.ParticipantType.DUMMY,
recordingConsentRequired = 1
)
}
}

View File

@ -23,8 +23,8 @@ class ShareUtilsIT {
assertEquals(TEST_DATE_IN_MILLIS, HttpUtils.parseDate("Mon, 09 Apr 2008 23:55:38 GMT")?.time)
}
private fun parseDate2(dateStr: String): Date =
DateUtils.parseDate(
private fun parseDate2(dateStr: String): Date {
return DateUtils.parseDate(
dateStr, Locale.US,
HttpUtils.httpDateFormatStr,
// RFC 822, updated by RFC 1123 with any TZ
@ -48,6 +48,7 @@ class ShareUtilsIT {
// RI bug 6641315 claims a cookie of this format was once served by www.yahoo.com
"EEE MMM d yyyy HH:mm:ss z"
)
}
companion object {
private const val TEST_DATE_IN_MILLIS = 1207778138000

View File

@ -24,9 +24,7 @@ import com.nextcloud.talk.jobs.GetFirebasePushTokenWorker
import java.util.concurrent.TimeUnit
@AutoInjector(NextcloudTalkApplication::class)
class ClosedInterfaceImpl :
ClosedInterface,
ProviderInstaller.ProviderInstallListener {
class ClosedInterfaceImpl : ClosedInterface, ProviderInstaller.ProviderInstallListener {
override val isGooglePlayServicesAvailable: Boolean = isGPlayServicesAvailable()

View File

@ -123,7 +123,7 @@
android:theme="@style/AppTheme" />
<activity
android:name=".account.BrowserLoginActivity"
android:name=".account.WebViewLoginActivity"
android:theme="@style/AppTheme" />
<activity android:name=".contacts.ContactsActivity"

View File

@ -1,12 +0,0 @@
/*
* Nextcloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2025 Sowjanya Kota <sowjanya.kch@gmail.com>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.nextcloud.talk
object PhoneUtils {
fun isPhoneNumber(input: String?): Boolean = input?.matches(Regex("^\\+?\\d+$")) == true
}

View File

@ -91,7 +91,7 @@ class AccountVerificationActivity : BaseActivity() {
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
setContentView(binding.root)
actionBar?.hide()
initSystemBars()
setupSystemColors()
handleIntent()
}
@ -158,7 +158,7 @@ class AccountVerificationActivity : BaseActivity() {
bundle.putString(KEY_USERNAME, username)
bundle.putString(KEY_PASSWORD, "")
val intent = Intent(context, BrowserLoginActivity::class.java)
val intent = Intent(context, WebViewLoginActivity::class.java)
intent.putExtras(bundle)
startActivity(intent)
} else {
@ -490,9 +490,9 @@ class AccountVerificationActivity : BaseActivity() {
WorkManager.getInstance(applicationContext).enqueue(accountRemovalWork)
WorkManager.getInstance(context).getWorkInfoByIdLiveData(accountRemovalWork.id)
.observeForever { workInfo: WorkInfo? ->
.observeForever { workInfo: WorkInfo ->
when (workInfo?.state) {
when (workInfo.state) {
WorkInfo.State.SUCCEEDED -> {
val intent = Intent(this, ServerSelectionActivity::class.java)
startActivity(intent)

View File

@ -1,417 +0,0 @@
/*
* Nextcloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2025 Julius Linus <juliuslinus1@gmail.com>
* SPDX-FileCopyrightText: 2023 Marcel Hibbe <dev@mhibbe.de>
* SPDX-FileCopyrightText: 2022 Andy Scherzinger <info@andy-scherzinger.de>
* SPDX-FileCopyrightText: 2017 Mario Danic <mario@lovelyhq.com>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.nextcloud.talk.account
import android.annotation.SuppressLint
import android.content.Intent
import android.content.pm.ActivityInfo
import android.os.Bundle
import android.text.TextUtils
import android.util.Log
import androidx.activity.OnBackPressedCallback
import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.work.OneTimeWorkRequest
import androidx.work.WorkInfo
import androidx.work.WorkManager
import autodagger.AutoInjector
import com.google.android.material.snackbar.Snackbar
import com.google.gson.JsonParser
import com.nextcloud.talk.R
import com.nextcloud.talk.activities.BaseActivity
import com.nextcloud.talk.activities.MainActivity
import com.nextcloud.talk.application.NextcloudTalkApplication
import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
import com.nextcloud.talk.databinding.ActivityWebViewLoginBinding
import com.nextcloud.talk.jobs.AccountRemovalWorker
import com.nextcloud.talk.models.LoginData
import com.nextcloud.talk.users.UserManager
import com.nextcloud.talk.utils.bundle.BundleKeys
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_BASE_URL
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ORIGINAL_PROTOCOL
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_TOKEN
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_USERNAME
import com.nextcloud.talk.utils.ssl.SSLSocketFactoryCompat
import com.nextcloud.talk.utils.ssl.TrustManager
import io.reactivex.disposables.Disposable
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.ConnectionSpec
import okhttp3.CookieJar
import okhttp3.FormBody
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody
import org.json.JSONException
import org.json.JSONObject
import java.io.IOException
import java.net.URLDecoder
import java.util.concurrent.Executors
import java.util.concurrent.ScheduledExecutorService
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.net.ssl.SSLHandshakeException
import javax.net.ssl.SSLSession
@AutoInjector(NextcloudTalkApplication::class)
class BrowserLoginActivity : BaseActivity() {
private lateinit var binding: ActivityWebViewLoginBinding
@Inject
lateinit var userManager: UserManager
@Inject
lateinit var trustManager: TrustManager
@Inject
lateinit var socketFactory: SSLSocketFactoryCompat
private var userQueryDisposable: Disposable? = null
private var baseUrl: String? = null
private var reauthorizeAccount = false
private var username: String? = null
private var password: String? = null
private val loginFlowExecutorService: ScheduledExecutorService? = Executors.newSingleThreadScheduledExecutor()
private var isLoginProcessCompleted = false
private var token: String = ""
private lateinit var okHttpClient: OkHttpClient
private val onBackPressedCallback = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
val intent = Intent(context, MainActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
startActivity(intent)
}
}
@SuppressLint("SourceLockedOrientationActivity")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
sharedApplication!!.componentApplication.inject(this)
binding = ActivityWebViewLoginBinding.inflate(layoutInflater)
okHttpClient = OkHttpClient.Builder()
.cookieJar(CookieJar.NO_COOKIES)
.connectionSpecs(listOf(ConnectionSpec.COMPATIBLE_TLS))
.sslSocketFactory(socketFactory, trustManager)
.hostnameVerifier { _: String?, _: SSLSession? -> true }
.build()
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
setContentView(binding.root)
actionBar?.hide()
initSystemBars()
initViews()
onBackPressedDispatcher.addCallback(this, onBackPressedCallback)
handleIntent()
lifecycle.addObserver(lifecycleEventObserver)
}
private fun handleIntent() {
val extras = intent.extras!!
baseUrl = extras.getString(KEY_BASE_URL)
username = extras.getString(KEY_USERNAME)
if (extras.containsKey(BundleKeys.KEY_REAUTHORIZE_ACCOUNT)) {
reauthorizeAccount = extras.getBoolean(BundleKeys.KEY_REAUTHORIZE_ACCOUNT)
}
if (extras.containsKey(BundleKeys.KEY_PASSWORD)) {
password = extras.getString(BundleKeys.KEY_PASSWORD)
}
if (extras.containsKey(BundleKeys.KEY_FROM_QR)) {
val resultData = extras.getString(BundleKeys.KEY_FROM_QR)
try {
parseLoginDataUrl(resultData!!)
} catch (e: IllegalArgumentException) {
Log.e(TAG, "Error in scanning QR Code: $e")
}
} else {
anonymouslyPostLoginRequest()
}
}
private fun initViews() {
viewThemeUtils.material.colorMaterialButtonFilledOnPrimary(binding.cancelLoginBtn)
viewThemeUtils.material.colorProgressBar(binding.progressBar)
binding.cancelLoginBtn.setOnClickListener {
lifecycle.removeObserver(lifecycleEventObserver)
onBackPressedDispatcher.onBackPressed()
}
}
private fun anonymouslyPostLoginRequest() {
CoroutineScope(Dispatchers.IO).launch {
val url = "$baseUrl/index.php/login/v2"
try {
val response = getResponseOfAnonymouslyPostLoginRequest(url)
val jsonObject: com.google.gson.JsonObject = JsonParser.parseString(response).asJsonObject
val loginUrl: String = getLoginUrl(jsonObject)
withContext(Dispatchers.Main) {
launchDefaultWebBrowser(loginUrl)
}
token = jsonObject.getAsJsonObject("poll").get("token").asString
} catch (e: SSLHandshakeException) {
Log.e(TAG, "Error caught at anonymouslyPostLoginRequest: $e")
}
}
}
private fun getResponseOfAnonymouslyPostLoginRequest(url: String): String? {
val request = Request.Builder()
.url(url)
.post(FormBody.Builder().build())
.addHeader("Clear-Site-Data", "cookies")
.build()
okHttpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
throw IOException("Unexpected code $response")
}
return response.body?.string()
}
}
private fun getLoginUrl(response: com.google.gson.JsonObject): String {
var result: String? = response.get("login").asString
if (result == null) {
result = ""
}
return result
}
private fun launchDefaultWebBrowser(url: String) {
val intent = Intent(Intent.ACTION_VIEW, url.toUri())
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
startActivity(intent)
}
private val lifecycleEventObserver = LifecycleEventObserver { lifecycleOwner, event ->
if (event === Lifecycle.Event.ON_START && token != "") {
Log.d(TAG, "Start poolLogin")
poolLogin()
}
}
private fun poolLogin() {
loginFlowExecutorService?.scheduleWithFixedDelay({
if (!isLoginProcessCompleted) {
performLoginFlowV2()
}
}, 0, INTERVAL, TimeUnit.SECONDS)
}
private fun performLoginFlowV2() {
val postRequestUrl = "$baseUrl/login/v2/poll"
val requestBody: RequestBody = FormBody.Builder()
.add("token", token)
.build()
val request = Request.Builder()
.url(postRequestUrl)
.post(requestBody)
.build()
try {
okHttpClient.newCall(request).execute()
.use { response ->
if (!response.isSuccessful) {
throw IOException("Unexpected code $response")
}
val status: Int = response.code
val response = response.body?.string()
Log.d(TAG, "performLoginFlowV2 status: $status")
Log.d(TAG, "performLoginFlowV2 response: $response")
if (response?.isNotEmpty() == true) {
runOnUiThread { completeLoginFlow(response, status) }
}
}
} catch (e: IllegalStateException) {
Log.e(TAG, "Error caught at performLoginFlowV2: $e")
}
}
/**
* QR returns a URI of format `nc://login/server:xxx&user:xxx&password:xxx`
* with the variables not always been in the provided order
*
* @throws IllegalArgumentException
*/
fun parseLoginDataUrl(dataString: String) {
if (!dataString.startsWith(PREFIX)) {
throw IllegalArgumentException("Invalid login URL detected")
}
val data = dataString.removePrefix(PREFIX)
val values = data.split('&')
if (values.size !in 1..MAX_ARGS) {
throw IllegalArgumentException("Illegal number of login URL elements detected: ${values.size}")
}
val loginData = LoginData()
values.forEach { value ->
when {
value.startsWith(USER_KEY) -> {
loginData.username = URLDecoder.decode(value.removePrefix(USER_KEY), "UTF-8")
}
value.startsWith(PASS_KEY) -> {
loginData.token = URLDecoder.decode(value.removePrefix(PASS_KEY), "UTF-8")
}
value.startsWith(SERVER_KEY) -> {
loginData.serverUrl = URLDecoder.decode(value.removePrefix(SERVER_KEY), "UTF-8")
baseUrl = loginData.serverUrl
}
}
}
parseAndLogin(loginData)
}
private fun completeLoginFlow(response: String, status: Int) {
try {
val jsonObject = JSONObject(response)
val server: String = jsonObject.getString("server")
val loginName: String = jsonObject.getString("loginName")
val appPassword: String = jsonObject.getString("appPassword")
val loginData = LoginData()
loginData.serverUrl = server
loginData.username = loginName
loginData.token = appPassword
isLoginProcessCompleted =
(status == HTTP_OK && !server.isEmpty() && !loginName.isEmpty() && !appPassword.isEmpty())
parseAndLogin(loginData)
} catch (e: JSONException) {
Log.e(TAG, "Error caught at completeLoginFlow: $e")
}
loginFlowExecutorService?.shutdown()
lifecycle.removeObserver(lifecycleEventObserver)
}
private fun dispose() {
if (userQueryDisposable != null && !userQueryDisposable!!.isDisposed) {
userQueryDisposable!!.dispose()
}
userQueryDisposable = null
}
private fun parseAndLogin(loginData: LoginData) {
dispose()
if (userManager.checkIfUserIsScheduledForDeletion(loginData.username!!, baseUrl!!).blockingGet()) {
Log.e(TAG, "Tried to add already existing user who is scheduled for deletion.")
Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show()
// however the user is not yet deleted, just start AccountRemovalWorker again to make sure to delete it.
startAccountRemovalWorkerAndRestartApp()
} else if (userManager.checkIfUserExists(loginData.username!!, baseUrl!!).blockingGet()) {
if (reauthorizeAccount) {
updateUserAndRestartApp(loginData)
} else {
Log.w(TAG, "It was tried to add an account that account already exists. Skipped user creation.")
restartApp()
}
} else {
startAccountVerification(loginData)
}
}
private fun startAccountVerification(loginData: LoginData) {
val bundle = Bundle()
bundle.putString(KEY_USERNAME, loginData.username)
bundle.putString(KEY_TOKEN, loginData.token)
bundle.putString(KEY_BASE_URL, loginData.serverUrl)
var protocol = ""
if (baseUrl!!.startsWith("http://")) {
protocol = "http://"
} else if (baseUrl!!.startsWith("https://")) {
protocol = "https://"
}
if (!TextUtils.isEmpty(protocol)) {
bundle.putString(KEY_ORIGINAL_PROTOCOL, protocol)
}
val intent = Intent(context, AccountVerificationActivity::class.java)
intent.putExtras(bundle)
startActivity(intent)
}
private fun restartApp() {
val intent = Intent(context, MainActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
startActivity(intent)
}
private fun updateUserAndRestartApp(loginData: LoginData) {
val currentUser = currentUserProvider.currentUser.blockingGet()
if (currentUser != null) {
currentUser.clientCertificate = appPreferences.temporaryClientCertAlias
currentUser.token = loginData.token
val rowsUpdated = userManager.updateOrCreateUser(currentUser).blockingGet()
Log.d(TAG, "User rows updated: $rowsUpdated")
restartApp()
}
}
private fun startAccountRemovalWorkerAndRestartApp() {
val accountRemovalWork = OneTimeWorkRequest.Builder(AccountRemovalWorker::class.java).build()
WorkManager.getInstance(applicationContext).enqueue(accountRemovalWork)
WorkManager.getInstance(context).getWorkInfoByIdLiveData(accountRemovalWork.id)
.observeForever { workInfo: WorkInfo? ->
when (workInfo?.state) {
WorkInfo.State.SUCCEEDED, WorkInfo.State.FAILED, WorkInfo.State.CANCELLED -> {
restartApp()
}
else -> {}
}
}
}
public override fun onDestroy() {
super.onDestroy()
dispose()
}
init {
sharedApplication!!.componentApplication.inject(this)
}
override val appBarLayoutType: AppBarLayoutType
get() = AppBarLayoutType.EMPTY
companion object {
private val TAG = BrowserLoginActivity::class.java.simpleName
private const val INTERVAL = 30L
private const val HTTP_OK = 200
private const val USER_KEY = "user:"
private const val SERVER_KEY = "server:"
private const val PASS_KEY = "password:"
private const val PREFIX = "nc://login/"
private const val MAX_ARGS = 3
}
}

View File

@ -8,7 +8,6 @@
*/
package com.nextcloud.talk.account
import android.Manifest
import android.accounts.Account
import android.annotation.SuppressLint
import android.content.Intent
@ -22,13 +21,8 @@ import android.view.View
import android.view.inputmethod.EditorInfo
import android.widget.TextView
import androidx.activity.OnBackPressedCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.net.toUri
import androidx.core.os.bundleOf
import autodagger.AutoInjector
import com.blikoon.qrcodescanner.QrCodeActivity
import com.github.dhaval2404.imagepicker.util.PermissionUtil
import com.google.android.material.snackbar.Snackbar
import com.nextcloud.talk.R
import com.nextcloud.talk.activities.BaseActivity
import com.nextcloud.talk.api.NcApi
@ -84,7 +78,7 @@ class ServerSelectionActivity : BaseActivity() {
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
setContentView(binding.root)
actionBar?.hide()
initSystemBars()
setupSystemColors()
onBackPressedDispatcher.addCallback(this, onBackPressedCallback)
}
@ -126,8 +120,6 @@ class ServerSelectionActivity : BaseActivity() {
}
binding.certTextView.setOnClickListener { onCertClick() }
binding.scanQr.setOnClickListener { onScan() }
if (ApplicationWideMessageHolder.getInstance().messageType != null) {
if (ApplicationWideMessageHolder.getInstance().messageType
== ApplicationWideMessageHolder.MessageType.SERVER_WITHOUT_TALK
@ -162,9 +154,10 @@ class ServerSelectionActivity : BaseActivity() {
)
}
private fun isAbleToShowProviderLink(): Boolean =
!resources!!.getBoolean(R.bool.hide_provider) &&
private fun isAbleToShowProviderLink(): Boolean {
return !resources!!.getBoolean(R.bool.hide_provider) &&
!TextUtils.isEmpty(resources!!.getString(R.string.nc_providers_url))
}
private fun showImportAccountsInfo(availableAccounts: List<Account>) {
if (!TextUtils.isEmpty(
@ -211,8 +204,9 @@ class ServerSelectionActivity : BaseActivity() {
}
}
private fun isImportAccountNameSet(): Boolean =
!TextUtils.isEmpty(resources!!.getString(R.string.nc_import_account_type))
private fun isImportAccountNameSet(): Boolean {
return !TextUtils.isEmpty(resources!!.getString(R.string.nc_import_account_type))
}
@SuppressLint("LongLogTag")
@Suppress("Detekt.TooGenericExceptionCaught")
@ -339,7 +333,7 @@ class ServerSelectionActivity : BaseActivity() {
val bundle = Bundle()
bundle.putString(BundleKeys.KEY_BASE_URL, queryUrl.replace("/status.php", ""))
val intent = Intent(context, BrowserLoginActivity::class.java)
val intent = Intent(context, WebViewLoginActivity::class.java)
intent.putExtras(bundle)
startActivity(intent)
}
@ -368,8 +362,9 @@ class ServerSelectionActivity : BaseActivity() {
})
}
private fun isServerStatusQueryable(status: Status): Boolean =
status.installed && !status.maintenance && !status.needsUpgrade
private fun isServerStatusQueryable(status: Status): Boolean {
return status.installed && !status.maintenance && !status.needsUpgrade
}
private fun setErrorText(text: String?) {
binding.errorWrapper.visibility = View.VISIBLE
@ -398,52 +393,6 @@ class ServerSelectionActivity : BaseActivity() {
}
}
private val requestCameraPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean ->
if (isGranted) {
// Permission was granted
startQRScanner()
}
}
fun onScan() {
if (PermissionUtil.isPermissionGranted(this, Manifest.permission.CAMERA)) {
startQRScanner()
} else {
requestCameraPermissionLauncher.launch(Manifest.permission.CAMERA)
}
}
private fun startQRScanner() {
val intent = Intent(this, QrCodeActivity::class.java)
qrScanResultLauncher.launch(intent)
}
private val qrScanResultLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
val data = result.data
if (data == null) {
return@registerForActivityResult
}
val resultData = data.getStringExtra(QR_URI)
if (resultData == null || !resultData.startsWith("nc")) {
Snackbar.make(binding.root, getString(R.string.qr_code_error), Snackbar.LENGTH_SHORT).show()
return@registerForActivityResult
}
val intent = Intent(this, BrowserLoginActivity::class.java)
val bundle = bundleOf().apply {
putString(BundleKeys.KEY_FROM_QR, resultData)
}
intent.putExtras(bundle)
startActivity(intent)
}
}
public override fun onDestroy() {
super.onDestroy()
dispose()
@ -462,6 +411,5 @@ class ServerSelectionActivity : BaseActivity() {
companion object {
private val TAG = ServerSelectionActivity::class.java.simpleName
const val MIN_SERVER_MAJOR_VERSION = 13
private const val QR_URI = "com.blikoon.qrcodescanner.got_qr_scan_relult"
}
}

View File

@ -86,7 +86,7 @@ class SwitchAccountActivity : BaseActivity() {
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
setContentView(binding.root)
setupActionBar()
initSystemBars()
setupSystemColors()
Configuration.getInstance().load(context, PreferenceManager.getDefaultSharedPreferences(context))

View File

@ -0,0 +1,459 @@
/*
* Nextcloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2023 Marcel Hibbe <dev@mhibbe.de>
* SPDX-FileCopyrightText: 2022 Andy Scherzinger <info@andy-scherzinger.de>
* SPDX-FileCopyrightText: 2017 Mario Danic <mario@lovelyhq.com>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.nextcloud.talk.account
import android.annotation.SuppressLint
import android.content.Intent
import android.content.pm.ActivityInfo
import android.graphics.Bitmap
import android.net.http.SslError
import android.os.Build
import android.os.Bundle
import android.security.KeyChain
import android.security.KeyChainException
import android.text.TextUtils
import android.util.Log
import android.view.View
import android.webkit.ClientCertRequest
import android.webkit.CookieSyncManager
import android.webkit.SslErrorHandler
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.activity.OnBackPressedCallback
import androidx.work.OneTimeWorkRequest
import androidx.work.WorkInfo
import androidx.work.WorkManager
import autodagger.AutoInjector
import com.google.android.material.snackbar.Snackbar
import com.nextcloud.talk.R
import com.nextcloud.talk.activities.BaseActivity
import com.nextcloud.talk.activities.MainActivity
import com.nextcloud.talk.application.NextcloudTalkApplication
import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
import com.nextcloud.talk.databinding.ActivityWebViewLoginBinding
import com.nextcloud.talk.events.CertificateEvent
import com.nextcloud.talk.jobs.AccountRemovalWorker
import com.nextcloud.talk.models.LoginData
import com.nextcloud.talk.users.UserManager
import com.nextcloud.talk.utils.bundle.BundleKeys
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_BASE_URL
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ORIGINAL_PROTOCOL
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_TOKEN
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_USERNAME
import com.nextcloud.talk.utils.ssl.TrustManager
import de.cotech.hw.fido.WebViewFidoBridge
import de.cotech.hw.fido2.WebViewWebauthnBridge
import de.cotech.hw.fido2.ui.WebauthnDialogOptions
import io.reactivex.disposables.Disposable
import java.lang.reflect.Field
import java.net.CookieManager
import java.net.URLDecoder
import java.security.PrivateKey
import java.security.cert.CertificateException
import java.security.cert.X509Certificate
import java.util.Locale
import javax.inject.Inject
@AutoInjector(NextcloudTalkApplication::class)
class WebViewLoginActivity : BaseActivity() {
private lateinit var binding: ActivityWebViewLoginBinding
@Inject
lateinit var userManager: UserManager
@Inject
lateinit var trustManager: TrustManager
@Inject
lateinit var cookieManager: CookieManager
private var assembledPrefix: String? = null
private var userQueryDisposable: Disposable? = null
private var baseUrl: String? = null
private var reauthorizeAccount = false
private var username: String? = null
private var password: String? = null
private var loginStep = 0
private var automatedLoginAttempted = false
private var webViewFidoBridge: WebViewFidoBridge? = null
private var webViewWebauthnBridge: WebViewWebauthnBridge? = null
private val onBackPressedCallback = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
val intent = Intent(context, MainActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
startActivity(intent)
}
}
private val webLoginUserAgent: String
get() = (
Build.MANUFACTURER.substring(0, 1).uppercase(Locale.getDefault()) +
Build.MANUFACTURER.substring(1).uppercase(Locale.getDefault()) +
" " +
Build.MODEL +
" (" +
resources!!.getString(R.string.nc_app_product_name) +
")"
)
@SuppressLint("SourceLockedOrientationActivity")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
sharedApplication!!.componentApplication.inject(this)
binding = ActivityWebViewLoginBinding.inflate(layoutInflater)
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
setContentView(binding.root)
actionBar?.hide()
setupSystemColors()
onBackPressedDispatcher.addCallback(this, onBackPressedCallback)
handleIntent()
setupWebView()
}
private fun handleIntent() {
val extras = intent.extras!!
baseUrl = extras.getString(KEY_BASE_URL)
username = extras.getString(KEY_USERNAME)
if (extras.containsKey(BundleKeys.KEY_REAUTHORIZE_ACCOUNT)) {
reauthorizeAccount = extras.getBoolean(BundleKeys.KEY_REAUTHORIZE_ACCOUNT)
}
if (extras.containsKey(BundleKeys.KEY_PASSWORD)) {
password = extras.getString(BundleKeys.KEY_PASSWORD)
}
}
@SuppressLint("SetJavaScriptEnabled")
private fun setupWebView() {
assembledPrefix = resources!!.getString(R.string.nc_talk_login_scheme) + PROTOCOL_SUFFIX + "login/"
binding.webview.settings.allowFileAccess = false
binding.webview.settings.allowFileAccessFromFileURLs = false
binding.webview.settings.javaScriptEnabled = true
binding.webview.settings.javaScriptCanOpenWindowsAutomatically = false
binding.webview.settings.domStorageEnabled = true
binding.webview.settings.userAgentString = webLoginUserAgent
binding.webview.settings.saveFormData = false
binding.webview.settings.savePassword = false
binding.webview.settings.setRenderPriority(WebSettings.RenderPriority.HIGH)
binding.webview.clearCache(true)
binding.webview.clearFormData()
binding.webview.clearHistory()
WebView.clearClientCertPreferences(null)
webViewFidoBridge = WebViewFidoBridge.createInstanceForWebView(this, binding.webview)
val webauthnOptionsBuilder = WebauthnDialogOptions.builder().setShowSdkLogo(true).setAllowSkipPin(true)
webViewWebauthnBridge = WebViewWebauthnBridge.createInstanceForWebView(
this, binding.webview, webauthnOptionsBuilder
)
CookieSyncManager.createInstance(this)
android.webkit.CookieManager.getInstance().removeAllCookies(null)
val headers: MutableMap<String, String> = HashMap()
headers["OCS-APIRequest"] = "true"
binding.webview.webViewClient = object : WebViewClient() {
private var basePageLoaded = false
override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest): WebResourceResponse? {
webViewFidoBridge?.delegateShouldInterceptRequest(view, request)
webViewWebauthnBridge?.delegateShouldInterceptRequest(view, request)
return super.shouldInterceptRequest(view, request)
}
override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) {
super.onPageStarted(view, url, favicon)
webViewFidoBridge?.delegateOnPageStarted(view, url, favicon)
webViewWebauthnBridge?.delegateOnPageStarted(view, url, favicon)
}
@Deprecated("Use shouldOverrideUrlLoading(WebView view, WebResourceRequest request)")
override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean {
if (url.startsWith(assembledPrefix!!)) {
parseAndLoginFromWebView(url)
return true
}
return false
}
@Suppress("Detekt.TooGenericExceptionCaught")
override fun onPageFinished(view: WebView, url: String) {
loginStep++
if (!basePageLoaded) {
binding.progressBar.visibility = View.GONE
binding.webview.visibility = View.VISIBLE
basePageLoaded = true
}
if (!TextUtils.isEmpty(username)) {
if (loginStep == 1) {
binding.webview.loadUrl(
"javascript: {document.getElementsByClassName('login')[0].click(); };"
)
} else if (!automatedLoginAttempted) {
automatedLoginAttempted = true
if (TextUtils.isEmpty(password)) {
binding.webview.loadUrl(
"javascript:var justStore = document.getElementById('user').value = '$username';"
)
} else {
binding.webview.loadUrl(
"javascript: {" +
"document.getElementById('user').value = '" + username + "';" +
"document.getElementById('password').value = '" + password + "';" +
"document.getElementById('submit').click(); };"
)
}
}
}
super.onPageFinished(view, url)
}
override fun onReceivedClientCertRequest(view: WebView, request: ClientCertRequest) {
var alias: String? = null
if (!reauthorizeAccount) {
alias = appPreferences.temporaryClientCertAlias
}
val user = currentUserProvider.currentUser.blockingGet()
if (TextUtils.isEmpty(alias) && user != null) {
alias = user.clientCertificate
}
if (!TextUtils.isEmpty(alias)) {
val finalAlias = alias
Thread {
try {
val privateKey = KeyChain.getPrivateKey(applicationContext, finalAlias!!)
val certificates = KeyChain.getCertificateChain(
applicationContext,
finalAlias
)
if (privateKey != null && certificates != null) {
request.proceed(privateKey, certificates)
} else {
request.cancel()
}
} catch (e: KeyChainException) {
request.cancel()
} catch (e: InterruptedException) {
request.cancel()
}
}.start()
} else {
KeyChain.choosePrivateKeyAlias(
this@WebViewLoginActivity,
{ chosenAlias: String? ->
if (chosenAlias != null) {
appPreferences!!.temporaryClientCertAlias = chosenAlias
Thread {
var privateKey: PrivateKey? = null
try {
privateKey = KeyChain.getPrivateKey(applicationContext, chosenAlias)
val certificates = KeyChain.getCertificateChain(
applicationContext,
chosenAlias
)
if (privateKey != null && certificates != null) {
request.proceed(privateKey, certificates)
} else {
request.cancel()
}
} catch (e: KeyChainException) {
request.cancel()
} catch (e: InterruptedException) {
request.cancel()
}
}.start()
} else {
request.cancel()
}
},
arrayOf("RSA", "EC"),
null,
request.host,
request.port,
null
)
}
}
@Suppress("Detekt.TooGenericExceptionCaught")
override fun onReceivedSslError(view: WebView, handler: SslErrorHandler, error: SslError) {
try {
val sslCertificate = error.certificate
val f: Field = sslCertificate.javaClass.getDeclaredField("mX509Certificate")
f.isAccessible = true
val cert = f[sslCertificate] as X509Certificate
if (cert == null) {
handler.cancel()
} else {
try {
trustManager.checkServerTrusted(arrayOf(cert), "generic")
handler.proceed()
} catch (exception: CertificateException) {
eventBus.post(CertificateEvent(cert, trustManager, handler))
}
}
} catch (exception: Exception) {
handler.cancel()
}
}
@Deprecated("Deprecated in super implementation")
override fun onReceivedError(view: WebView, errorCode: Int, description: String, failingUrl: String) {
super.onReceivedError(view, errorCode, description, failingUrl)
}
}
binding.webview.loadUrl("$baseUrl/index.php/login/flow", headers)
}
private fun dispose() {
if (userQueryDisposable != null && !userQueryDisposable!!.isDisposed) {
userQueryDisposable!!.dispose()
}
userQueryDisposable = null
}
private fun parseAndLoginFromWebView(dataString: String) {
val loginData = parseLoginData(assembledPrefix, dataString)
if (loginData != null) {
dispose()
cookieManager.cookieStore.removeAll()
if (userManager.checkIfUserIsScheduledForDeletion(loginData.username!!, baseUrl!!).blockingGet()) {
Log.e(TAG, "Tried to add already existing user who is scheduled for deletion.")
Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show()
// however the user is not yet deleted, just start AccountRemovalWorker again to make sure to delete it.
startAccountRemovalWorkerAndRestartApp()
} else if (userManager.checkIfUserExists(loginData.username!!, baseUrl!!).blockingGet()) {
if (reauthorizeAccount) {
updateUserAndRestartApp(loginData)
} else {
Log.w(TAG, "It was tried to add an account that account already exists. Skipped user creation.")
restartApp()
}
} else {
startAccountVerification(loginData)
}
}
}
private fun startAccountVerification(loginData: LoginData) {
val bundle = Bundle()
bundle.putString(KEY_USERNAME, loginData.username)
bundle.putString(KEY_TOKEN, loginData.token)
bundle.putString(KEY_BASE_URL, loginData.serverUrl)
var protocol = ""
if (baseUrl!!.startsWith("http://")) {
protocol = "http://"
} else if (baseUrl!!.startsWith("https://")) {
protocol = "https://"
}
if (!TextUtils.isEmpty(protocol)) {
bundle.putString(KEY_ORIGINAL_PROTOCOL, protocol)
}
val intent = Intent(context, AccountVerificationActivity::class.java)
intent.putExtras(bundle)
startActivity(intent)
}
private fun restartApp() {
val intent = Intent(context, MainActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
startActivity(intent)
}
private fun updateUserAndRestartApp(loginData: LoginData) {
val currentUser = currentUserProvider.currentUser.blockingGet()
if (currentUser != null) {
currentUser.clientCertificate = appPreferences.temporaryClientCertAlias
currentUser.token = loginData.token
val rowsUpdated = userManager.updateOrCreateUser(currentUser).blockingGet()
Log.d(TAG, "User rows updated: $rowsUpdated")
restartApp()
}
}
private fun startAccountRemovalWorkerAndRestartApp() {
val accountRemovalWork = OneTimeWorkRequest.Builder(AccountRemovalWorker::class.java).build()
WorkManager.getInstance(applicationContext).enqueue(accountRemovalWork)
WorkManager.getInstance(context).getWorkInfoByIdLiveData(accountRemovalWork.id)
.observeForever { workInfo: WorkInfo ->
when (workInfo.state) {
WorkInfo.State.SUCCEEDED, WorkInfo.State.FAILED, WorkInfo.State.CANCELLED -> {
restartApp()
}
else -> {}
}
}
}
private fun parseLoginData(prefix: String?, dataString: String): LoginData? {
if (dataString.length < prefix!!.length) {
return null
}
val loginData = LoginData()
// format is xxx://login/server:xxx&user:xxx&password:xxx
val data: String = dataString.substring(prefix.length)
val values: Array<String> = data.split("&").toTypedArray()
if (values.size != PARAMETER_COUNT) {
return null
}
for (value in values) {
if (value.startsWith("user" + LOGIN_URL_DATA_KEY_VALUE_SEPARATOR)) {
loginData.username = URLDecoder.decode(
value.substring(("user" + LOGIN_URL_DATA_KEY_VALUE_SEPARATOR).length)
)
} else if (value.startsWith("password" + LOGIN_URL_DATA_KEY_VALUE_SEPARATOR)) {
loginData.token = URLDecoder.decode(
value.substring(("password" + LOGIN_URL_DATA_KEY_VALUE_SEPARATOR).length)
)
} else if (value.startsWith("server" + LOGIN_URL_DATA_KEY_VALUE_SEPARATOR)) {
loginData.serverUrl = URLDecoder.decode(
value.substring(("server" + LOGIN_URL_DATA_KEY_VALUE_SEPARATOR).length)
)
} else {
return null
}
}
return if (!TextUtils.isEmpty(loginData.serverUrl) && !TextUtils.isEmpty(loginData.username) &&
!TextUtils.isEmpty(loginData.token)
) {
loginData
} else {
null
}
}
public override fun onDestroy() {
super.onDestroy()
dispose()
}
init {
sharedApplication!!.componentApplication.inject(this)
}
override val appBarLayoutType: AppBarLayoutType
get() = AppBarLayoutType.EMPTY
companion object {
private val TAG = WebViewLoginActivity::class.java.simpleName
private const val PROTOCOL_SUFFIX = "://"
private const val LOGIN_URL_DATA_KEY_VALUE_SEPARATOR = ":"
private const val PARAMETER_COUNT = 3
}
}

View File

@ -11,13 +11,11 @@ package com.nextcloud.talk.activities
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.text.TextUtils
import android.util.Log
import android.view.View
import android.view.ViewGroup
import android.view.WindowInsets
import android.view.WindowManager
import android.view.inputmethod.EditorInfo
import android.webkit.SslErrorHandler
@ -29,9 +27,9 @@ import autodagger.AutoInjector
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.nextcloud.talk.R
import com.nextcloud.talk.account.AccountVerificationActivity
import com.nextcloud.talk.account.BrowserLoginActivity
import com.nextcloud.talk.account.ServerSelectionActivity
import com.nextcloud.talk.account.SwitchAccountActivity
import com.nextcloud.talk.account.WebViewLoginActivity
import com.nextcloud.talk.application.NextcloudTalkApplication
import com.nextcloud.talk.chat.ChatActivity
import com.nextcloud.talk.events.CertificateEvent
@ -39,7 +37,6 @@ import com.nextcloud.talk.ui.theme.ViewThemeUtils
import com.nextcloud.talk.utils.DisplayUtils
import com.nextcloud.talk.utils.FileViewerUtils
import com.nextcloud.talk.utils.UriUtils
import com.nextcloud.talk.utils.adjustUIForAPILevel35
import com.nextcloud.talk.utils.bundle.BundleKeys
import com.nextcloud.talk.utils.database.user.CurrentUserProviderNew
import com.nextcloud.talk.utils.preferences.AppPreferences
@ -84,7 +81,6 @@ open class BaseActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this)
adjustUIForAPILevel35()
super.onCreate(savedInstanceState)
cleanTempCertPreference()
@ -115,22 +111,9 @@ open class BaseActivity : AppCompatActivity() {
eventBus.unregister(this)
}
/*
* May be aligned with android-common lib in the future: .../ui/util/extensions/AppCompatActivityExtensions.kt
*/
fun initSystemBars() {
window.decorView.setOnApplyWindowInsetsListener { view, insets ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
val statusBarHeight = insets.getInsets(WindowInsets.Type.statusBars()).top
view.setPadding(0, statusBarHeight, 0, 0)
val color = ResourcesCompat.getColor(resources, R.color.bg_default, context.theme)
view.setBackgroundColor(color)
} else {
colorizeStatusBar()
colorizeNavigationBar()
}
insets
}
fun setupSystemColors() {
colorizeStatusBar()
colorizeNavigationBar()
}
open fun colorizeStatusBar() {
@ -235,7 +218,7 @@ open class BaseActivity : AppCompatActivity() {
val temporaryClassNames: MutableList<String> = ArrayList()
temporaryClassNames.add(ServerSelectionActivity::class.java.name)
temporaryClassNames.add(AccountVerificationActivity::class.java.name)
temporaryClassNames.add(BrowserLoginActivity::class.java.name)
temporaryClassNames.add(WebViewLoginActivity::class.java.name)
temporaryClassNames.add(SwitchAccountActivity::class.java.name)
if (!temporaryClassNames.contains(javaClass.name)) {
appPreferences.removeTemporaryClientCertAlias()

View File

@ -376,8 +376,6 @@ class CallActivity : CallBaseActivity() {
Log.d(TAG, "onCreate")
super.onCreate(savedInstanceState)
sharedApplication!!.componentApplication.inject(this)
rootEglBase = EglBase.create()
binding = CallActivityBinding.inflate(layoutInflater)
setContentView(binding!!.root)
hideNavigationIfNoPipAvailable()
@ -767,6 +765,7 @@ class CallActivity : CallBaseActivity() {
}
private fun basicInitialization() {
rootEglBase = EglBase.create()
createCameraEnumerator()
// Create a new PeerConnectionFactory instance.
@ -948,7 +947,8 @@ class CallActivity : CallBaseActivity() {
ParticipantGrid(
participantUiStates = participantUiStates,
eglBase = rootEglBase!!,
isVoiceOnlyCall = isVoiceOnlyCall
isVoiceOnlyCall = isVoiceOnlyCall,
isInPipMode = isInPipMode
) {
animateCallControls(true, 0)
}
@ -2233,8 +2233,7 @@ class CallActivity : CallBaseActivity() {
}
if (!isSelfInCall &&
currentCallStatus !== CallStatus.LEAVING &&
ApplicationWideCurrentRoomHolder.getInstance().isInCall
currentCallStatus !== CallStatus.LEAVING && ApplicationWideCurrentRoomHolder.getInstance().isInCall
) {
Log.d(TAG, "Most probably a moderator ended the call for all.")
hangup(shutDownView = true, endCallForAll = false)

View File

@ -10,6 +10,7 @@
package com.nextcloud.talk.activities
import android.app.KeyguardManager
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.provider.ContactsContract
@ -23,8 +24,8 @@ import androidx.lifecycle.ProcessLifecycleOwner
import autodagger.AutoInjector
import com.google.android.material.snackbar.Snackbar
import com.nextcloud.talk.R
import com.nextcloud.talk.account.BrowserLoginActivity
import com.nextcloud.talk.account.ServerSelectionActivity
import com.nextcloud.talk.account.WebViewLoginActivity
import com.nextcloud.talk.api.NcApi
import com.nextcloud.talk.application.NextcloudTalkApplication
import com.nextcloud.talk.chat.ChatActivity
@ -48,10 +49,7 @@ import io.reactivex.schedulers.Schedulers
import javax.inject.Inject
@AutoInjector(NextcloudTalkApplication::class)
class MainActivity :
BaseActivity(),
ActionBarProvider {
class MainActivity : BaseActivity(), ActionBarProvider {
lateinit var binding: ActivityMainBinding
@Inject
@ -92,7 +90,7 @@ class MainActivity :
}
fun lockScreenIfConditionsApply() {
val keyguardManager = getSystemService(KEYGUARD_SERVICE) as KeyguardManager
val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
if (keyguardManager.isKeyguardSecure && appPreferences.isScreenLocked) {
if (!SecurityUtils.checkIfWeAreAuthenticated(appPreferences.screenLockTimeout)) {
val lockIntent = Intent(context, LockedActivity::class.java)
@ -103,7 +101,7 @@ class MainActivity :
private fun launchServerSelection() {
if (isBrandingUrlSet()) {
val intent = Intent(context, BrowserLoginActivity::class.java)
val intent = Intent(context, WebViewLoginActivity::class.java)
val bundle = Bundle()
bundle.putString(BundleKeys.KEY_BASE_URL, resources.getString(R.string.weblogin_url))
intent.putExtras(bundle)
@ -253,6 +251,10 @@ class MainActivity :
startActivity(chatIntent)
}
} else {
if (!appPreferences.isDbRoomMigrated) {
appPreferences.isDbRoomMigrated = true
}
userManager.users.subscribe(object : SingleObserver<List<User>> {
override fun onSubscribe(d: Disposable) {
// unused atm

View File

@ -49,9 +49,13 @@ class GeocodingAdapter(private val context: Context, private var dataSource: Lis
}
}
override fun getItemCount(): Int = dataSource.size
override fun getItemCount(): Int {
return dataSource.size
}
fun getItem(position: Int): Any = dataSource[position]
fun getItem(position: Int): Any {
return dataSource[position]
}
inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val nameView: TextView = itemView.findViewById(R.id.name)

View File

@ -153,8 +153,8 @@ class ParticipantDisplayItem(
participantDisplayItemNotifier.notifyChange()
}
private fun buildUiState(): ParticipantUiState =
ParticipantUiState(
private fun buildUiState(): ParticipantUiState {
return ParticipantUiState(
sessionKey = sessionKey,
nick = nick ?: "Guest",
isConnected = isConnected,
@ -164,6 +164,7 @@ class ParticipantDisplayItem(
avatarUrl = urlForAvatar,
mediaStream = mediaStream
)
}
private fun updateUrlForAvatar() {
if (actorType == ActorType.FEDERATED) {
@ -191,8 +192,8 @@ class ParticipantDisplayItem(
participantDisplayItemNotifier.removeObserver(observer)
}
override fun toString(): String =
"ParticipantSession{" +
override fun toString(): String {
return "ParticipantSession{" +
"userId='" + userId + '\'' +
", actorType='" + actorType + '\'' +
", actorId='" + actorId + '\'' +
@ -205,6 +206,7 @@ class ParticipantDisplayItem(
", rootEglBase=" + rootEglBase +
", raisedHand=" + raisedHand +
'}'
}
companion object {
/**

View File

@ -30,5 +30,7 @@ class PredefinedStatusListAdapter(
holder.bind(list[position], clickListener, context, isBackupStatusAvailable)
}
override fun getItemCount(): Int = list.size
override fun getItemCount(): Int {
return list.size
}
}

View File

@ -8,4 +8,7 @@ package com.nextcloud.talk.adapters
import com.nextcloud.talk.models.json.reactions.ReactionVoter
data class ReactionItem(val reactionVoter: ReactionVoter, val reaction: String?)
data class ReactionItem(
val reactionVoter: ReactionVoter,
val reaction: String?
)

View File

@ -12,8 +12,10 @@ import androidx.recyclerview.widget.RecyclerView
import com.nextcloud.talk.data.user.model.User
import com.nextcloud.talk.databinding.ReactionItemBinding
class ReactionsAdapter(private val clickListener: ReactionItemClickListener, private val user: User?) :
RecyclerView.Adapter<ReactionsViewHolder>() {
class ReactionsAdapter(
private val clickListener: ReactionItemClickListener,
private val user: User?
) : RecyclerView.Adapter<ReactionsViewHolder>() {
internal var list: MutableList<ReactionItem> = ArrayList<ReactionItem>()
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ReactionsViewHolder {
@ -25,5 +27,7 @@ class ReactionsAdapter(private val clickListener: ReactionItemClickListener, pri
holder.bind(list[position], clickListener)
}
override fun getItemCount(): Int = list.size
override fun getItemCount(): Int {
return list.size
}
}

View File

@ -16,8 +16,10 @@ import com.nextcloud.talk.extensions.loadGuestAvatar
import com.nextcloud.talk.extensions.loadUserAvatar
import com.nextcloud.talk.models.json.reactions.ReactionVoter
class ReactionsViewHolder(private val binding: ReactionItemBinding, private val user: User?) :
RecyclerView.ViewHolder(binding.root) {
class ReactionsViewHolder(
private val binding: ReactionItemBinding,
private val user: User?
) : RecyclerView.ViewHolder(binding.root) {
fun bind(reactionItem: ReactionItem, clickListener: ReactionItemClickListener) {
binding.root.setOnClickListener { clickListener.onClick(reactionItem) }

View File

@ -35,24 +35,29 @@ class AdvancedUserItem(
val account: Account?,
private val viewThemeUtils: ViewThemeUtils,
private val actionRequiredCount: Int
) : AbstractFlexibleItem<UserItemViewHolder>(),
IFilterable<String?> {
override fun equals(other: Any?): Boolean =
if (other is AdvancedUserItem) {
) : AbstractFlexibleItem<UserItemViewHolder>(), IFilterable<String?> {
override fun equals(other: Any?): Boolean {
return if (other is AdvancedUserItem) {
model == other.model
} else {
false
}
}
override fun hashCode(): Int = model.hashCode()
override fun hashCode(): Int {
return model.hashCode()
}
override fun getLayoutRes(): Int = R.layout.account_item
override fun getLayoutRes(): Int {
return R.layout.account_item
}
override fun createViewHolder(
view: View?,
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>?
): UserItemViewHolder = UserItemViewHolder(view, adapter)
): UserItemViewHolder {
return UserItemViewHolder(view, adapter)
}
override fun bindViewHolder(
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
@ -89,12 +94,13 @@ class AdvancedUserItem(
}
}
override fun filter(constraint: String?): Boolean =
model.displayName != null &&
override fun filter(constraint: String?): Boolean {
return model.displayName != null &&
Pattern
.compile(constraint, Pattern.CASE_INSENSITIVE or Pattern.LITERAL)
.matcher(model.displayName!!.trim())
.find()
}
class UserItemViewHolder(view: View?, adapter: FlexibleAdapter<*>?) : FlexibleViewHolder(view, adapter) {
var binding: AccountItemBinding

View File

@ -49,10 +49,12 @@ class ContactItem(
}
return false
}
override fun hashCode(): Int = model.hashCode()
override fun hashCode(): Int {
return model.hashCode()
}
override fun filter(constraint: String?): Boolean =
model.displayName != null &&
override fun filter(constraint: String?): Boolean {
return model.displayName != null &&
(
Pattern.compile(constraint!!, Pattern.CASE_INSENSITIVE or Pattern.LITERAL)
.matcher(model.displayName!!.trim())
@ -61,13 +63,18 @@ class ContactItem(
.matcher(model.calculatedActorId!!.trim())
.find()
)
}
override fun getLayoutRes(): Int = R.layout.rv_item_contact
override fun getLayoutRes(): Int {
return R.layout.rv_item_contact
}
override fun createViewHolder(
view: View?,
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>?
): ContactItemViewHolder = ContactItemViewHolder(view, adapter)
): ContactItemViewHolder {
return ContactItemViewHolder(view, adapter)
}
override fun bindViewHolder(
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>?,
@ -136,8 +143,7 @@ class ContactItem(
} else if (model.calculatedActorType == Participant.ActorType.EMAILS) {
setGenericAvatar(holder!!, R.drawable.ic_avatar_mail)
} else if (model.calculatedActorType == Participant.ActorType.GUESTS ||
model.type == Participant.ParticipantType.GUEST ||
model.type == Participant.ParticipantType.GUEST_MODERATOR
model.type == Participant.ParticipantType.GUEST || model.type == Participant.ParticipantType.GUEST_MODERATOR
) {
var displayName: String?
@ -174,7 +180,9 @@ class ContactItem(
holder.binding.avatarView.loadUserAvatar(avatar)
}
override fun getHeader(): GenericTextHeaderItem? = header
override fun getHeader(): GenericTextHeaderItem? {
return header
}
override fun setHeader(p0: GenericTextHeaderItem?) {
this.header = header
@ -186,6 +194,7 @@ class ContactItem(
}
companion object {
const val VIEW_TYPE = FlexibleItemViewType.CONTACT_ITEM
private const val FULLY_OPAQUE: Float = 1.0f
private const val SEMI_TRANSPARENT: Float = 0.38f
}

View File

@ -19,7 +19,6 @@ import android.text.TextUtils
import android.text.format.DateUtils
import android.text.style.ImageSpan
import android.view.View
import android.widget.RelativeLayout
import androidx.core.content.ContextCompat
import androidx.core.content.res.ResourcesCompat
import com.nextcloud.talk.R
@ -59,7 +58,6 @@ class ConversationItem(
IFilterable<String?> {
private var header: GenericTextHeaderItem? = null
private val chatMessage = model.lastMessage?.asModel()
var mHolder: ConversationItemViewHolder? = null
constructor(
conversation: ConversationModel,
@ -84,12 +82,17 @@ class ConversationItem(
return result
}
override fun getLayoutRes(): Int = R.layout.rv_item_conversation_with_last_message
override fun getLayoutRes(): Int {
return R.layout.rv_item_conversation_with_last_message
}
override fun getItemViewType(): Int = VIEW_TYPE
override fun getItemViewType(): Int {
return VIEW_TYPE
}
override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<*>?>?): ConversationItemViewHolder =
ConversationItemViewHolder(view, adapter)
override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<*>?>?): ConversationItemViewHolder {
return ConversationItemViewHolder(view, adapter)
}
@SuppressLint("SetTextI18n")
override fun bindViewHolder(
@ -98,7 +101,6 @@ class ConversationItem(
position: Int,
payloads: List<Any>
) {
mHolder = holder
val appContext = sharedApplication!!.applicationContext
holder.binding.dialogName.setTextColor(
ResourcesCompat.getColor(
@ -153,30 +155,6 @@ class ConversationItem(
} else {
holder.binding.userStatusImage.visibility = View.GONE
}
val dialogNameParams = holder.binding.dialogName.layoutParams as RelativeLayout.LayoutParams
val unreadBubbleParams = holder.binding.dialogUnreadBubble.layoutParams as RelativeLayout.LayoutParams
val relativeLayoutParams = holder.binding.relativeLayout.layoutParams as RelativeLayout.LayoutParams
if (model.hasSensitive == true) {
dialogNameParams.addRule(RelativeLayout.CENTER_VERTICAL)
relativeLayoutParams.addRule(RelativeLayout.ALIGN_TOP, R.id.dialogAvatarFrameLayout)
dialogNameParams.marginEnd =
context.resources.getDimensionPixelSize(R.dimen.standard_double_padding)
unreadBubbleParams.topMargin =
context.resources.getDimensionPixelSize(R.dimen.double_margin_between_elements)
unreadBubbleParams.addRule(RelativeLayout.CENTER_VERTICAL)
} else {
dialogNameParams.removeRule(RelativeLayout.CENTER_VERTICAL)
relativeLayoutParams.removeRule(RelativeLayout.ALIGN_TOP)
dialogNameParams.marginEnd = 0
unreadBubbleParams.topMargin = 0
unreadBubbleParams.removeRule(RelativeLayout.CENTER_VERTICAL)
}
holder.binding.relativeLayout.layoutParams = relativeLayoutParams
holder.binding.dialogUnreadBubble.layoutParams = unreadBubbleParams
holder.binding.dialogName.layoutParams = dialogNameParams
setLastMessage(holder, appContext)
showAvatar(holder)
}
@ -216,8 +194,8 @@ class ConversationItem(
}
}
private fun shouldLoadAvatar(holder: ConversationItemViewHolder): Boolean =
when (model.objectType) {
private fun shouldLoadAvatar(holder: ConversationItemViewHolder): Boolean {
return when (model.objectType) {
ConversationEnums.ObjectType.SHARE_PASSWORD -> {
holder.binding.dialogAvatar.setImageDrawable(
ContextCompat.getDrawable(
@ -240,6 +218,7 @@ class ConversationItem(
else -> true
}
}
private fun setLastMessage(holder: ConversationItemViewHolder, appContext: Context) {
if (chatMessage != null) {
@ -273,8 +252,8 @@ class ConversationItem(
}
}
private fun calculateRegularLastMessageText(appContext: Context): CharSequence =
if (chatMessage?.actorId == user.userId) {
private fun calculateRegularLastMessageText(appContext: Context): CharSequence {
return if (chatMessage?.actorId == user.userId) {
String.format(
appContext.getString(R.string.nc_formatted_message_you),
lastMessageDisplayText
@ -297,6 +276,7 @@ class ConversationItem(
lastMessageDisplayText
)
}
}
private fun showUnreadMessages(holder: ConversationItemViewHolder) {
holder.binding.dialogName.setTypeface(holder.binding.dialogName.typeface, Typeface.BOLD)
@ -338,14 +318,17 @@ class ConversationItem(
}
}
override fun filter(constraint: String?): Boolean =
model.displayName != null &&
override fun filter(constraint: String?): Boolean {
return model.displayName != null &&
Pattern
.compile(constraint!!, Pattern.CASE_INSENSITIVE or Pattern.LITERAL)
.matcher(model.displayName.trim())
.find()
}
override fun getHeader(): GenericTextHeaderItem? = header
override fun getHeader(): GenericTextHeaderItem? {
return header
}
override fun setHeader(header: GenericTextHeaderItem?) {
this.header = header
@ -423,9 +406,9 @@ class ConversationItem(
)
return lastMessage
} else if (MessageType.SINGLE_NC_ATTACHMENT_MESSAGE == chatMessage?.getCalculateMessageType()) {
var attachmentName = chatMessage.text
var attachmentName = chatMessage.message
if (attachmentName == "{file}") {
attachmentName = chatMessage.messageParameters?.get("file")?.get("name") ?: ""
attachmentName = chatMessage.messageParameters?.get("file")?.get("name")
}
val author = authorName(chatMessage)

View File

@ -14,4 +14,5 @@ object FlexibleItemViewType {
const val POLL_RESULT_HEADER_ITEM: Int = 1120391234
const val POLL_RESULT_VOTER_ITEM: Int = 1120391235
const val POLL_RESULT_VOTERS_OVERVIEW_ITEM: Int = 1120391236
const val CONTACT_ITEM: Int = 2131558687
}

View File

@ -38,14 +38,20 @@ open class GenericTextHeaderItem(title: String, viewThemeUtils: ViewThemeUtils)
return false
}
override fun hashCode(): Int = Objects.hash(model)
override fun hashCode(): Int {
return Objects.hash(model)
}
override fun getLayoutRes(): Int = R.layout.rv_item_title_header
override fun getLayoutRes(): Int {
return R.layout.rv_item_title_header
}
override fun createViewHolder(
view: View?,
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>?
): HeaderViewHolder = HeaderViewHolder(view, adapter)
): HeaderViewHolder {
return HeaderViewHolder(view, adapter)
}
override fun bindViewHolder(
adapter: FlexibleAdapter<IFlexible<*>?>?,

View File

@ -23,8 +23,13 @@ object LoadMoreResultsItem :
// layout is used as view type for uniqueness
const val VIEW_TYPE = FlexibleItemViewType.LOAD_MORE_RESULTS_ITEM
class ViewHolder(view: View, adapter: FlexibleAdapter<*>) : FlexibleViewHolder(view, adapter) {
var binding: RvItemLoadMoreBinding = RvItemLoadMoreBinding.bind(view)
class ViewHolder(view: View, adapter: FlexibleAdapter<*>) :
FlexibleViewHolder(view, adapter) {
var binding: RvItemLoadMoreBinding
init {
binding = RvItemLoadMoreBinding.bind(view)
}
}
override fun getLayoutRes(): Int = R.layout.rv_item_load_more
@ -45,9 +50,15 @@ object LoadMoreResultsItem :
override fun filter(constraint: String?): Boolean = true
override fun getItemViewType(): Int = VIEW_TYPE
override fun getItemViewType(): Int {
return VIEW_TYPE
}
override fun equals(other: Any?): Boolean = other is LoadMoreResultsItem
override fun equals(other: Any?): Boolean {
return other is LoadMoreResultsItem
}
override fun hashCode(): Int = 0
override fun hashCode(): Int {
return 0
}
}

View File

@ -13,7 +13,8 @@ import android.content.Context
import android.view.View
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.content.res.ResourcesCompat
import com.nextcloud.talk.PhoneUtils.isPhoneNumber
import coil.Coil
import coil.request.ImageRequest
import com.nextcloud.talk.R
import com.nextcloud.talk.adapters.items.ParticipantItem.ParticipantItemViewHolder
import com.nextcloud.talk.data.user.model.User
@ -39,8 +40,7 @@ class MentionAutocompleteItem(
private val context: Context,
@JvmField val roomToken: String,
private val viewThemeUtils: ViewThemeUtils
) : AbstractFlexibleItem<ParticipantItemViewHolder>(),
IFilterable<String?> {
) : AbstractFlexibleItem<ParticipantItemViewHolder>(), IFilterable<String?> {
@JvmField
var source: String?
@ -74,19 +74,25 @@ class MentionAutocompleteItem(
statusMessage = mention.statusMessage
}
override fun equals(o: Any?): Boolean =
if (o is MentionAutocompleteItem) {
override fun equals(o: Any?): Boolean {
return if (o is MentionAutocompleteItem) {
objectId == o.objectId && displayName == o.displayName
} else {
false
}
}
override fun hashCode(): Int = Objects.hash(objectId, displayName)
override fun hashCode(): Int {
return Objects.hash(objectId, displayName)
}
override fun getLayoutRes(): Int = R.layout.rv_item_conversation_info_participant
override fun getLayoutRes(): Int {
return R.layout.rv_item_conversation_info_participant
}
override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<*>?>?): ParticipantItemViewHolder =
ParticipantItemViewHolder(view, adapter)
override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<*>?>?): ParticipantItemViewHolder {
return ParticipantItemViewHolder(view, adapter)
}
@SuppressLint("SetTextI18n")
override fun bindViewHolder(
@ -123,32 +129,20 @@ class MentionAutocompleteItem(
private fun setAvatar(holder: ParticipantItemViewHolder, objectId: String?) {
when (source) {
SOURCE_CALLS -> {
run {}
run {
if (isPhoneNumber(displayName)) {
holder.binding.avatarView.loadUserAvatar(
viewThemeUtils.talk.themePlaceholderAvatar(
holder.binding.avatarView,
R.drawable.ic_phone_small
)
holder.binding.avatarView.loadUserAvatar(
viewThemeUtils.talk.themePlaceholderAvatar(
holder.binding.avatarView,
R.drawable.ic_avatar_group
)
} else {
holder.binding.avatarView.loadUserAvatar(
viewThemeUtils.talk.themePlaceholderAvatar(
holder.binding.avatarView,
R.drawable.ic_avatar_group_small
)
)
}
)
}
}
SOURCE_GROUPS -> {
holder.binding.avatarView.loadUserAvatar(
viewThemeUtils.talk.themePlaceholderAvatar(
holder.binding.avatarView,
R.drawable
.ic_avatar_group_small
)
viewThemeUtils.talk.themePlaceholderAvatar(holder.binding.avatarView, R.drawable.ic_avatar_group)
)
}
@ -174,13 +168,19 @@ class MentionAutocompleteItem(
}
SOURCE_TEAMS -> {
holder.binding.avatarView.loadUserAvatar(
viewThemeUtils.talk.themePlaceholderAvatar(
holder.binding.avatarView,
R.drawable
.ic_avatar_team_small
)
)
holder.binding.avatarView.post {
val imageViewWidth = holder.binding.avatarView.width
val imageViewHeight = holder.binding.avatarView.height
val request = ImageRequest.Builder(context)
.data(R.drawable.icon_team)
.size(imageViewWidth, imageViewHeight)
.scale(coil.size.Scale.FILL)
.target(holder.binding.avatarView)
.build()
Coil.imageLoader(context).enqueue(request)
}
}
else -> {
@ -234,8 +234,8 @@ class MentionAutocompleteItem(
holder.binding.nameText.setLayoutParams(layoutParams)
}
override fun filter(constraint: String?): Boolean =
objectId != null &&
override fun filter(constraint: String?): Boolean {
return objectId != null &&
Pattern
.compile(constraint, Pattern.CASE_INSENSITIVE or Pattern.LITERAL)
.matcher(objectId)
@ -245,6 +245,7 @@ class MentionAutocompleteItem(
.compile(constraint, Pattern.CASE_INSENSITIVE or Pattern.LITERAL)
.matcher(displayName)
.find()
}
companion object {
private const val STATUS_SIZE_IN_DP = 9f

View File

@ -31,11 +31,13 @@ data class MessageResultItem(
val messageEntry: SearchMessageEntry,
var showHeader: Boolean = false,
private val viewThemeUtils: ViewThemeUtils
) : AbstractFlexibleItem<MessageResultItem.ViewHolder>(),
) :
AbstractFlexibleItem<MessageResultItem.ViewHolder>(),
IFilterable<String>,
ISectionable<MessageResultItem.ViewHolder, GenericTextHeaderItem> {
class ViewHolder(view: View, adapter: FlexibleAdapter<*>) : FlexibleViewHolder(view, adapter) {
class ViewHolder(view: View, adapter: FlexibleAdapter<*>) :
FlexibleViewHolder(view, adapter) {
var binding: RvItemSearchMessageBinding
init {
@ -71,7 +73,9 @@ data class MessageResultItem(
override fun filter(constraint: String?): Boolean = true
override fun getItemViewType(): Int = VIEW_TYPE
override fun getItemViewType(): Int {
return VIEW_TYPE
}
companion object {
const val VIEW_TYPE = FlexibleItemViewType.MESSAGE_RESULT_ITEM

View File

@ -49,25 +49,31 @@ class ParticipantItem(
private val user: User,
private val viewThemeUtils: ViewThemeUtils,
private val conversation: ConversationModel
) : AbstractFlexibleItem<ParticipantItemViewHolder>(),
IFilterable<String?> {
) : AbstractFlexibleItem<ParticipantItemViewHolder>(), IFilterable<String?> {
var isOnline = true
override fun equals(o: Any?): Boolean =
if (o is ParticipantItem) {
override fun equals(o: Any?): Boolean {
return if (o is ParticipantItem) {
model.calculatedActorType == o.model.calculatedActorType &&
model.calculatedActorId == o.model.calculatedActorId
} else {
false
}
}
override fun hashCode(): Int = model.hashCode()
override fun hashCode(): Int {
return model.hashCode()
}
override fun getLayoutRes(): Int = R.layout.rv_item_conversation_info_participant
override fun getLayoutRes(): Int {
return R.layout.rv_item_conversation_info_participant
}
override fun createViewHolder(
view: View?,
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>?
): ParticipantItemViewHolder = ParticipantItemViewHolder(view, adapter)
): ParticipantItemViewHolder {
return ParticipantItemViewHolder(view, adapter)
}
@SuppressLint("SetTextI18n")
override fun bindViewHolder(
@ -100,8 +106,7 @@ class ParticipantItem(
@SuppressLint("SetTextI18n")
private fun setParticipantInfo(holder: ParticipantItemViewHolder) {
if (TextUtils.isEmpty(model.displayName) &&
(
if (TextUtils.isEmpty(model.displayName) && (
model.type == Participant.ParticipantType.GUEST ||
model.type == Participant.ParticipantType.USER_FOLLOWING_LINK
)
@ -284,14 +289,14 @@ class ParticipantItem(
holder.binding.nameText.setLayoutParams(layoutParams)
}
override fun filter(constraint: String?): Boolean =
model.displayName != null &&
(
override fun filter(constraint: String?): Boolean {
return model.displayName != null && (
Pattern.compile(constraint, Pattern.CASE_INSENSITIVE or Pattern.LITERAL)
.matcher(model.displayName!!.trim()).find() ||
Pattern.compile(constraint, Pattern.CASE_INSENSITIVE or Pattern.LITERAL)
.matcher(model.displayName!!.trim()).find() ||
Pattern.compile(constraint, Pattern.CASE_INSENSITIVE or Pattern.LITERAL)
.matcher(model.calculatedActorId!!.trim()).find()
)
.matcher(model.calculatedActorId!!.trim()).find()
)
}
class ParticipantItemViewHolder internal constructor(view: View?, adapter: FlexibleAdapter<*>?) :
FlexibleViewHolder(view, adapter) {

View File

@ -1,37 +0,0 @@
/*
* Nextcloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2025 Marcel Hibbe <dev@mhibbe.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.nextcloud.talk.adapters.items
import android.view.View
import com.nextcloud.talk.R
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
import eu.davidea.flexibleadapter.items.IFlexible
import eu.davidea.viewholders.FlexibleViewHolder
class SpacerItem(private val height: Int) : AbstractFlexibleItem<SpacerItem.ViewHolder>() {
override fun getLayoutRes(): Int = R.layout.item_spacer
override fun createViewHolder(view: View?, adapter: FlexibleAdapter<IFlexible<*>?>?): ViewHolder =
ViewHolder(view!!, adapter!!)
override fun bindViewHolder(
adapter: FlexibleAdapter<IFlexible<*>?>?,
holder: ViewHolder,
position: Int,
payloads: MutableList<Any>?
) {
holder.itemView.layoutParams.height = height
}
override fun equals(other: Any?) = other is SpacerItem
override fun hashCode(): Int = 0
class ViewHolder(view: View, adapter: FlexibleAdapter<*>) : FlexibleViewHolder(view, adapter)
}

View File

@ -40,9 +40,8 @@ import kotlinx.coroutines.withContext
import javax.inject.Inject
@AutoInjector(NextcloudTalkApplication::class)
class IncomingDeckCardViewHolder(incomingView: View, payload: Any) :
MessageHolders
.IncomingTextMessageViewHolder<ChatMessage>(incomingView, payload) {
class IncomingDeckCardViewHolder(incomingView: View, payload: Any) : MessageHolders
.IncomingTextMessageViewHolder<ChatMessage>(incomingView, payload) {
private val binding: ItemCustomIncomingDeckCardMessageBinding =
ItemCustomIncomingDeckCardMessageBinding.bind(itemView)

View File

@ -220,13 +220,15 @@ class IncomingLocationMessageViewHolder(incomingView: View, payload: Any) :
binding.webview.webViewClient = object : WebViewClient() {
@Deprecated("Use shouldOverrideUrlLoading(WebView view, WebResourceRequest request)")
override fun shouldOverrideUrlLoading(view: WebView?, url: String?): Boolean =
if (url != null && UriUtils.hasHttpProtocolPrefixed(url)) {
override fun shouldOverrideUrlLoading(view: WebView?, url: String?): Boolean {
return if (url != null && UriUtils.hasHttpProtocolPrefixed(url)
) {
view?.context?.startActivity(Intent(Intent.ACTION_VIEW, url.toUri()))
true
} else {
false
}
}
}
val urlStringBuffer = StringBuffer("file:///android_asset/leafletMapMessagePreview.html")
@ -266,7 +268,9 @@ class IncomingLocationMessageViewHolder(incomingView: View, payload: Any) :
}
}
private fun addMarkerToGeoLink(locationGeoLink: String): String = locationGeoLink.replace("geo:", "geo:0,0?q=")
private fun addMarkerToGeoLink(locationGeoLink: String): String {
return locationGeoLink.replace("geo:", "geo:0,0?q=")
}
fun assignCommonMessageInterface(commonMessageInterface: CommonMessageInterface) {
this.commonMessageInterface = commonMessageInterface

View File

@ -20,7 +20,6 @@ import autodagger.AutoInjector
import coil.load
import com.google.android.flexbox.FlexboxLayout
import com.google.android.material.snackbar.Snackbar
import com.nextcloud.android.common.ui.theme.utils.ColorRole
import com.nextcloud.talk.R
import com.nextcloud.talk.application.NextcloudTalkApplication
import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
@ -158,7 +157,7 @@ class IncomingTextMessageViewHolder(itemView: View, payload: Any) :
binding.messageEditIndicator.visibility = View.GONE
binding.messageTime.text = dateUtils.getLocalTimeStringFromTimestamp(message.timestamp)
}
viewThemeUtils.platform.colorTextView(binding.messageTime, ColorRole.ON_SURFACE_VARIANT)
binding.messageTime.setTextColor(ContextCompat.getColor(context, R.color.no_emphasis_text))
// parent message handling
if (!message.isDeleted && message.parentMessageId != null) {
processParentMessage(message)
@ -203,10 +202,7 @@ class IncomingTextMessageViewHolder(itemView: View, payload: Any) :
val firstPart = message.toString().substringBefore("\n- [")
messageTextView.text = messageUtils.enrichChatMessageText(
binding.messageText.context,
firstPart,
true,
viewThemeUtils
binding.messageText.context, firstPart, true, viewThemeUtils
)
val checkboxList = mutableListOf<CheckBox>()
@ -222,8 +218,7 @@ class IncomingTextMessageViewHolder(itemView: View, payload: Any) :
this.isEnabled = (
chatMessage.actorType == "bots" ||
chatActivity.userAllowedByPrivilages(chatMessage)
) &&
messageIsEditable
) && messageIsEditable
setTextColor(ContextCompat.getColor(context, R.color.no_emphasis_text))

View File

@ -42,8 +42,9 @@ import kotlinx.coroutines.withContext
import javax.inject.Inject
@AutoInjector(NextcloudTalkApplication::class)
class OutcomingDeckCardViewHolder(outcomingView: View) :
MessageHolders.OutcomingTextMessageViewHolder<ChatMessage>(outcomingView),
class OutcomingDeckCardViewHolder(
outcomingView: View
) : MessageHolders.OutcomingTextMessageViewHolder<ChatMessage>(outcomingView),
AdjustableMessageHolderInterface {
override val binding: ItemCustomOutcomingDeckCardMessageBinding = ItemCustomOutcomingDeckCardMessageBinding.bind(

View File

@ -157,13 +157,15 @@ class OutcomingLocationMessageViewHolder(incomingView: View) :
binding.webview.webViewClient = object : WebViewClient() {
@Deprecated("Use shouldOverrideUrlLoading(WebView view, WebResourceRequest request)")
override fun shouldOverrideUrlLoading(view: WebView?, url: String?): Boolean =
if (url != null && UriUtils.hasHttpProtocolPrefixed(url)) {
override fun shouldOverrideUrlLoading(view: WebView?, url: String?): Boolean {
return if (url != null && UriUtils.hasHttpProtocolPrefixed(url)
) {
view?.context?.startActivity(Intent(Intent.ACTION_VIEW, url.toUri()))
true
} else {
false
}
}
}
val urlStringBuffer = StringBuffer("file:///android_asset/leafletMapMessagePreview.html")
@ -266,7 +268,9 @@ class OutcomingLocationMessageViewHolder(incomingView: View) :
}
}
private fun addMarkerToGeoLink(locationGeoLink: String): String = locationGeoLink.replace("geo:", "geo:0,0?q=")
private fun addMarkerToGeoLink(locationGeoLink: String): String {
return locationGeoLink.replace("geo:", "geo:0,0?q=")
}
fun assignCommonMessageInterface(commonMessageInterface: CommonMessageInterface) {
this.commonMessageInterface = commonMessageInterface

View File

@ -29,7 +29,6 @@ import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedA
import com.nextcloud.talk.chat.ChatActivity
import com.nextcloud.talk.chat.data.ChatMessageRepository
import com.nextcloud.talk.chat.data.model.ChatMessage
import com.nextcloud.talk.data.database.model.SendStatus
import com.nextcloud.talk.data.network.NetworkMonitor
import com.nextcloud.talk.data.user.model.User
import com.nextcloud.talk.databinding.ItemCustomOutcomingTextMessageBinding
@ -106,6 +105,7 @@ class OutcomingTextMessageViewHolder(itemView: View) :
if (!hasCheckboxes) {
realView.isSelected = false
layoutParams.isWrapBefore = false
viewThemeUtils.platform.colorTextView(binding.messageTime, ColorRole.ON_SURFACE_VARIANT)
binding.messageText.visibility = View.VISIBLE
binding.checkboxContainer.visibility = View.GONE
@ -172,7 +172,7 @@ class OutcomingTextMessageViewHolder(itemView: View) :
binding.messageEditIndicator.visibility = View.GONE
binding.messageTime.text = dateUtils.getLocalTimeStringFromTimestamp(message.timestamp)
}
viewThemeUtils.platform.colorTextView(binding.messageTime, ColorRole.ON_SURFACE_VARIANT)
binding.messageTime.setTextColor(ContextCompat.getColor(context, R.color.no_emphasis_text))
setBubbleOnChatMessage(message)
// parent message handling
if (!message.isDeleted && message.parentMessageId != null) {
@ -185,7 +185,7 @@ class OutcomingTextMessageViewHolder(itemView: View) :
binding.checkMark.visibility = View.INVISIBLE
binding.sendingProgress.visibility = View.GONE
if (message.sendStatus == SendStatus.FAILED) {
if (message.sendingFailed) {
updateStatus(R.drawable.baseline_error_outline_24, context.resources?.getString(R.string.nc_message_failed))
} else if (message.isTemporary) {
updateStatus(R.drawable.baseline_schedule_24, context.resources?.getString(R.string.nc_message_sending))
@ -231,15 +231,13 @@ class OutcomingTextMessageViewHolder(itemView: View) :
val messageIsEditable = hasSpreedFeatureCapability(
user.capabilities?.spreedCapability!!,
SpreedFeatures.EDIT_MESSAGES
) &&
!isOlderThanTwentyFourHours
) && !isOlderThanTwentyFourHours
val isNoTimeLimitOnNoteToSelf = hasSpreedFeatureCapability(
user.capabilities?.spreedCapability!!,
SpreedFeatures
.EDIT_MESSAGES_NOTE_TO_SELF
) &&
chatActivity.currentConversation?.type == ConversationEnums.ConversationType.NOTE_TO_SELF
) && chatActivity.currentConversation?.type == ConversationEnums.ConversationType.NOTE_TO_SELF
checkBoxContainer.removeAllViews()
val regex = """(- \[(X|x| )])\s*(.+)""".toRegex(RegexOption.MULTILINE)
@ -249,10 +247,7 @@ class OutcomingTextMessageViewHolder(itemView: View) :
val firstPart = message.toString().substringBefore("\n- [")
messageTextView.text = messageUtils.enrichChatMessageText(
binding.messageText.context,
firstPart,
true,
viewThemeUtils
binding.messageText.context, firstPart, true, viewThemeUtils
)
val checkboxList = mutableListOf<CheckBox>()

View File

@ -316,7 +316,9 @@ abstract class PreviewMessageViewHolder(itemView: View?, payload: Any?) :
this.previewMessageInterface = previewMessageInterface
}
fun hasBubbleBackground(message: ChatMessage): Boolean = !message.isVoiceMessage && message.message != "{file}"
fun hasBubbleBackground(message: ChatMessage): Boolean {
return !message.isVoiceMessage && message.message != "{file}"
}
abstract val messageText: EmojiTextView
abstract val messageCaption: EmojiTextView

View File

@ -33,9 +33,8 @@ import com.stfalcon.chatkit.messages.MessageHolders
import javax.inject.Inject
@AutoInjector(NextcloudTalkApplication::class)
class SystemMessageViewHolder(itemView: View) :
MessageHolders
.IncomingTextMessageViewHolder<ChatMessage>(itemView) {
class SystemMessageViewHolder(itemView: View) : MessageHolders
.IncomingTextMessageViewHolder<ChatMessage>(itemView) {
private val binding: ItemSystemMessageBinding = ItemSystemMessageBinding.bind(itemView)

View File

@ -179,18 +179,6 @@ interface NcApiCoroutines {
@Url url: String
): GenericOverall
@POST
suspend fun markConversationAsImportant(
@Header("Authorization") authorization: String,
@Url url: String
): GenericOverall
@DELETE
suspend fun markConversationAsUnimportant(
@Header("Authorization") authorization: String,
@Url url: String
): GenericOverall
@DELETE
suspend fun removeConversationFromFavorites(
@Header("Authorization") authorization: String,

View File

@ -58,6 +58,8 @@ import com.vanniktech.emoji.EmojiManager
import com.vanniktech.emoji.google.GoogleEmojiProvider
import de.cotech.hw.SecurityKeyManager
import de.cotech.hw.SecurityKeyManagerConfig
import net.sqlcipher.database.SQLiteDatabase
import net.sqlcipher.database.SQLiteDatabaseHook
import okhttp3.OkHttpClient
import org.conscrypt.Conscrypt
import org.webrtc.PeerConnectionFactory
@ -84,9 +86,7 @@ import javax.inject.Singleton
)
@Singleton
@AutoInjector(NextcloudTalkApplication::class)
class NextcloudTalkApplication :
MultiDexApplication(),
LifecycleObserver {
class NextcloudTalkApplication : MultiDexApplication(), LifecycleObserver {
//region Fields (components)
lateinit var componentApplication: NextcloudTalkApplicationComponent
private set
@ -101,6 +101,18 @@ class NextcloudTalkApplication :
lateinit var okHttpClient: OkHttpClient
//endregion
val hook: SQLiteDatabaseHook = object : SQLiteDatabaseHook {
override fun preKey(database: SQLiteDatabase) {
// unused atm
}
override fun postKey(database: SQLiteDatabase) {
Log.i("TalkApplication", "DB cipher_migrate START")
database.rawExecSQL("PRAGMA cipher_migrate;")
Log.i("TalkApplication", "DB cipher_migrate END")
}
}
//region private methods
private fun initializeWebRtc() {
try {

View File

@ -15,9 +15,11 @@ class ArbitraryStorageManager(private val arbitraryStoragesRepository: Arbitrary
arbitraryStoragesRepository.saveArbitraryStorage(ArbitraryStorage(accountIdentifier, key, objectString, value))
}
fun getStorageSetting(accountIdentifier: Long, key: String, objectString: String): Maybe<ArbitraryStorage> =
arbitraryStoragesRepository.getStorageSetting(accountIdentifier, key, objectString)
fun getStorageSetting(accountIdentifier: Long, key: String, objectString: String): Maybe<ArbitraryStorage> {
return arbitraryStoragesRepository.getStorageSetting(accountIdentifier, key, objectString)
}
fun deleteAllEntriesForAccountIdentifier(accountIdentifier: Long): Int =
arbitraryStoragesRepository.deleteArbitraryStorage(accountIdentifier)
fun deleteAllEntriesForAccountIdentifier(accountIdentifier: Long): Int {
return arbitraryStoragesRepository.deleteArbitraryStorage(accountIdentifier)
}
}

View File

@ -14,7 +14,10 @@ interface ListItemWithImage {
fun populateIcon(imageView: ImageView)
}
data class BasicListItemWithImage(@DrawableRes val iconRes: Int, override val title: String) : ListItemWithImage {
data class BasicListItemWithImage(
@DrawableRes val iconRes: Int,
override val title: String
) : ListItemWithImage {
override fun populateIcon(imageView: ImageView) {
imageView.setImageResource(iconRes)

View File

@ -6,7 +6,6 @@
*/
package com.nextcloud.talk.bottomsheet.items
import android.annotation.SuppressLint
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
@ -23,9 +22,10 @@ import com.nextcloud.talk.R
private const val KEY_ACTIVATED_INDEX = "activated_index"
internal class ListItemViewHolder(itemView: View, private val adapter: ListIconDialogAdapter<*>) :
RecyclerView.ViewHolder(itemView),
View.OnClickListener {
internal class ListItemViewHolder(
itemView: View,
private val adapter: ListIconDialogAdapter<*>
) : RecyclerView.ViewHolder(itemView), View.OnClickListener {
init {
itemView.setOnClickListener(this)
}
@ -42,8 +42,7 @@ internal class ListIconDialogAdapter<IT : ListItemWithImage>(
disabledItems: IntArray?,
private var waitForPositiveButton: Boolean,
private var selection: ListItemListener<IT>
) : RecyclerView.Adapter<ListItemViewHolder>(),
DialogAdapter<IT, ListItemListener<IT>> {
) : RecyclerView.Adapter<ListItemViewHolder>(), DialogAdapter<IT, ListItemListener<IT>> {
private var disabledIndices: IntArray = disabledItems ?: IntArray(0)
@ -66,7 +65,6 @@ internal class ListIconDialogAdapter<IT : ListItemWithImage>(
}
}
@SuppressLint("RestrictedApi")
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ListItemViewHolder {
val listItemView: View = parent.inflate(dialog.windowContext, R.layout.menu_item_sheet)
val viewHolder = ListItemViewHolder(

View File

@ -163,4 +163,7 @@ class ReactionAnimator(
private const val BOTTOM_MARGIN: Int = 5
}
}
data class CallReaction(var emoji: String, var userName: String)
data class CallReaction(
var emoji: String,
var userName: String
)

View File

@ -9,15 +9,14 @@
package com.nextcloud.talk.call.components
import android.annotation.SuppressLint
import android.content.res.Configuration
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.PaddingValues
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.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
@ -30,7 +29,6 @@ import com.nextcloud.talk.adapters.ParticipantUiState
import org.webrtc.EglBase
import kotlin.math.ceil
@SuppressLint("UnusedBoxWithConstraintsScope")
@Suppress("LongParameterList")
@Composable
fun ParticipantGrid(
@ -38,6 +36,7 @@ fun ParticipantGrid(
eglBase: EglBase?,
participantUiStates: List<ParticipantUiState>,
isVoiceOnlyCall: Boolean,
isInPipMode: Boolean,
onClick: () -> Unit
) {
val configuration = LocalConfiguration.current
@ -45,61 +44,63 @@ fun ParticipantGrid(
val minItemHeight = 100.dp
if (participantUiStates.isEmpty()) return
val columns = if (isPortrait) {
when (participantUiStates.size) {
1, 2, 3 -> 1
else -> 2
val columns =
if (isPortrait) {
when (participantUiStates.size) {
1, 2, 3 -> 1
else -> 2
}
} else {
when (participantUiStates.size) {
1 -> 1
2, 4 -> 2
else -> 3
}
}
val rows = ceil(participantUiStates.size / columns.toFloat()).toInt()
val heightForNonGridComponents = if (isVoiceOnlyCall && !isInPipMode) {
// this is a workaround for now. It should ~summarize the height of callInfosLinearLayout and callControls
// Once everything is migrated to jetpack, this workaround should be obsolete or solved in a better way
240.dp
} else {
when (participantUiStates.size) {
1 -> 1
2, 4 -> 2
else -> 3
}
}.coerceAtLeast(1) // Prevent 0
val rows = ceil(participantUiStates.size / columns.toFloat()).toInt().coerceAtLeast(1)
0.dp
}
val gridHeight = LocalConfiguration.current.screenHeightDp.dp - heightForNonGridComponents
val itemSpacing = 8.dp
val edgePadding = 8.dp
val totalVerticalSpacing = itemSpacing * (rows - 1)
val totalVerticalPadding = edgePadding * 2
val availableHeight = gridHeight - totalVerticalSpacing - totalVerticalPadding
BoxWithConstraints(
modifier = modifier
val rawItemHeight = availableHeight / rows
val itemHeight = maxOf(rawItemHeight, minItemHeight)
LazyVerticalGrid(
columns = GridCells.Fixed(columns),
modifier = Modifier
.fillMaxSize()
.clickable { onClick() }
.padding(horizontal = edgePadding)
.clickable { onClick() },
verticalArrangement = Arrangement.spacedBy(itemSpacing),
horizontalArrangement = Arrangement.spacedBy(itemSpacing),
contentPadding = PaddingValues(vertical = edgePadding)
) {
val availableHeight = maxHeight
val gridAvailableHeight = availableHeight - totalVerticalSpacing - totalVerticalPadding
val rawItemHeight = gridAvailableHeight / rows
val itemHeight = maxOf(rawItemHeight, minItemHeight)
LazyVerticalGrid(
columns = GridCells.Fixed(columns),
modifier = Modifier
.fillMaxWidth()
.height(availableHeight),
verticalArrangement = Arrangement.spacedBy(itemSpacing),
horizontalArrangement = Arrangement.spacedBy(itemSpacing),
contentPadding = PaddingValues(vertical = edgePadding, horizontal = edgePadding)
) {
items(
participantUiStates,
key = { it.sessionKey }
) { participant ->
ParticipantTile(
participantUiState = participant,
modifier = Modifier
.height(itemHeight)
.fillMaxWidth(),
eglBase = eglBase,
isVoiceOnlyCall = isVoiceOnlyCall
)
}
items(
participantUiStates,
key = { it.sessionKey }
) { participant ->
ParticipantTile(
participantUiState = participant,
modifier = Modifier
.height(itemHeight)
.fillMaxWidth(),
eglBase = eglBase,
isVoiceOnlyCall = isVoiceOnlyCall
)
}
}
}
@ -110,7 +111,8 @@ fun ParticipantGridPreview() {
ParticipantGrid(
participantUiStates = getTestParticipants(1),
eglBase = null,
isVoiceOnlyCall = false
isVoiceOnlyCall = false,
isInPipMode = false
) {}
}
@ -120,7 +122,8 @@ fun TwoParticipants() {
ParticipantGrid(
participantUiStates = getTestParticipants(2),
eglBase = null,
isVoiceOnlyCall = false
isVoiceOnlyCall = false,
isInPipMode = false
) {}
}
@ -130,7 +133,8 @@ fun ThreeParticipants() {
ParticipantGrid(
participantUiStates = getTestParticipants(3),
eglBase = null,
isVoiceOnlyCall = false
isVoiceOnlyCall = false,
isInPipMode = false
) {}
}
@ -140,7 +144,8 @@ fun FourParticipants() {
ParticipantGrid(
participantUiStates = getTestParticipants(4),
eglBase = null,
isVoiceOnlyCall = false
isVoiceOnlyCall = false,
isInPipMode = false
) {}
}
@ -150,7 +155,8 @@ fun FiveParticipants() {
ParticipantGrid(
participantUiStates = getTestParticipants(5),
eglBase = null,
isVoiceOnlyCall = false
isVoiceOnlyCall = false,
isInPipMode = false
) {}
}
@ -160,7 +166,8 @@ fun SevenParticipants() {
ParticipantGrid(
participantUiStates = getTestParticipants(7),
eglBase = null,
isVoiceOnlyCall = false
isVoiceOnlyCall = false,
isInPipMode = false
) {}
}
@ -170,7 +177,8 @@ fun FiftyParticipants() {
ParticipantGrid(
participantUiStates = getTestParticipants(50),
eglBase = null,
isVoiceOnlyCall = false
isVoiceOnlyCall = false,
isInPipMode = false
) {}
}
@ -184,7 +192,8 @@ fun OneParticipantLandscape() {
ParticipantGrid(
participantUiStates = getTestParticipants(1),
eglBase = null,
isVoiceOnlyCall = false
isVoiceOnlyCall = false,
isInPipMode = false
) {}
}
@ -198,7 +207,8 @@ fun TwoParticipantsLandscape() {
ParticipantGrid(
participantUiStates = getTestParticipants(2),
eglBase = null,
isVoiceOnlyCall = false
isVoiceOnlyCall = false,
isInPipMode = false
) {}
}
@ -212,7 +222,8 @@ fun ThreeParticipantsLandscape() {
ParticipantGrid(
participantUiStates = getTestParticipants(3),
eglBase = null,
isVoiceOnlyCall = false
isVoiceOnlyCall = false,
isInPipMode = false
) {}
}
@ -226,7 +237,8 @@ fun FourParticipantsLandscape() {
ParticipantGrid(
participantUiStates = getTestParticipants(4),
eglBase = null,
isVoiceOnlyCall = false
isVoiceOnlyCall = false,
isInPipMode = false
) {}
}
@ -240,7 +252,8 @@ fun SevenParticipantsLandscape() {
ParticipantGrid(
participantUiStates = getTestParticipants(7),
eglBase = null,
isVoiceOnlyCall = false
isVoiceOnlyCall = false,
isInPipMode = false
) {}
}
@ -254,7 +267,8 @@ fun FiftyParticipantsLandscape() {
ParticipantGrid(
participantUiStates = getTestParticipants(50),
eglBase = null,
isVoiceOnlyCall = false
isVoiceOnlyCall = false,
isInPipMode = false
) {}
}

View File

@ -7,7 +7,6 @@
package com.nextcloud.talk.call.components
import android.annotation.SuppressLint
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
@ -41,7 +40,6 @@ const val NICK_OFFSET = 4f
const val NICK_BLUR_RADIUS = 4f
const val AVATAR_SIZE_FACTOR = 0.6f
@SuppressLint("UnusedBoxWithConstraintsScope")
@Suppress("Detekt.LongMethod")
@Composable
fun ParticipantTile(

View File

@ -16,6 +16,7 @@ package com.nextcloud.talk.chat
import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
@ -66,8 +67,6 @@ import androidx.core.graphics.drawable.toDrawable
import androidx.core.net.toUri
import androidx.core.os.bundleOf
import androidx.core.text.bold
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.emoji2.text.EmojiCompat
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.commit
@ -277,8 +276,6 @@ class ChatActivity :
lateinit var conversationInfoViewModel: ConversationInfoViewModel
lateinit var messageInputViewModel: MessageInputViewModel
private var chatMenu: Menu? = null
private val startSelectContactForResult = registerForActivityResult(
ActivityResultContracts
.StartActivityForResult()
@ -310,7 +307,12 @@ class ChatActivity :
runBlocking {
val id = intent?.getStringExtra(MessageSearchActivity.RESULT_KEY_MESSAGE_ID)
id?.let {
startContextChatWindowForMessage(id)
val isSaved = chatViewModel.isMessageSaved(id.toLong())
if (isSaved) {
onMessageSearchResult(intent)
} else {
startContextChatWindowForMessage(id)
}
}
}
}
@ -364,7 +366,6 @@ class ChatActivity :
var startCallFromRoomSwitch: Boolean = false
var voiceOnly: Boolean = true
var focusInput: Boolean = false
private lateinit var path: String
var myFirstMessage: CharSequence? = null
@ -392,6 +393,7 @@ class ChatActivity :
private val onBackPressedCallback = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
val intent = Intent(this@ChatActivity, ConversationsListActivity::class.java)
intent.putExtras(Bundle())
startActivity(intent)
}
}
@ -459,28 +461,7 @@ class ChatActivity :
binding = ActivityChatBinding.inflate(layoutInflater)
setupActionBar()
setContentView(binding.root)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.chat_container)) { view, insets ->
val statusBarInsets = insets.getInsets(WindowInsetsCompat.Type.statusBars())
val navBarInsets = insets.getInsets(WindowInsetsCompat.Type.navigationBars())
val imeInsets = insets.getInsets(WindowInsetsCompat.Type.ime())
val isKeyboardVisible = insets.isVisible(WindowInsetsCompat.Type.ime())
val bottomPadding = if (isKeyboardVisible) imeInsets.bottom else navBarInsets.bottom
view.setPadding(
view.paddingLeft,
statusBarInsets.top,
view.paddingRight,
bottomPadding
)
WindowInsetsCompat.CONSUMED
}
} else {
colorizeStatusBar()
colorizeNavigationBar()
}
setupSystemColors()
conversationUser = currentUserProvider.currentUser.blockingGet()
handleIntent(intent)
@ -508,7 +489,7 @@ class ChatActivity :
initObservers()
pickMultipleMedia = registerForActivityResult(
ActivityResultContracts.PickMultipleVisualMedia(MAX_AMOUNT_MEDIA_FILE_PICKER)
ActivityResultContracts.PickMultipleVisualMedia(5)
) { uris ->
if (uris.isNotEmpty()) {
onChooseFileResult(uris)
@ -568,8 +549,6 @@ class ChatActivity :
startCallFromRoomSwitch = extras?.getBoolean(KEY_START_CALL_AFTER_ROOM_SWITCH, false) == true
voiceOnly = extras?.getBoolean(KEY_CALL_VOICE_ONLY, false) == true
focusInput = extras?.getBoolean(BundleKeys.KEY_FOCUS_INPUT) == true
}
override fun onStart() {
@ -661,17 +640,12 @@ class ChatActivity :
supportFragmentManager.commit {
setReorderingAllowed(true) // optimizes out redundant replace operations
replace(R.id.fragment_container_activity_chat, messageInputFragment)
runOnCommit {
if (focusInput) {
messageInputFragment.binding.fragmentMessageInputView.requestFocus()
}
}
}
joinRoomWithPassword()
if (conversationUser?.userId != "?" &&
hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.MENTION_FLAG)
CapabilitiesUtil.hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.MENTION_FLAG)
) {
binding.chatToolbar.setOnClickListener { _ -> showConversationInfoScreen() }
}
@ -706,7 +680,7 @@ class ChatActivity :
?.split("#")
?.getOrNull(1)
?.toLongOrNull()
val currentTimeStamp = (System.currentTimeMillis() / ONE_SECOND_IN_MILLIS).toLong()
val currentTimeStamp = (System.currentTimeMillis() / 1000).toLong()
val retentionPeriod = retentionOfEventRooms(spreedCapabilities)
val isPastEvent = eventEndTimeStamp?.let { it < currentTimeStamp }
if (isPastEvent == true && retentionPeriod != 0) {
@ -722,8 +696,7 @@ class ChatActivity :
) {
val retentionPeriod = retentionOfSIPRoom(spreedCapabilities)
val systemMessage = currentConversation?.lastMessage?.systemMessageType
if (retentionPeriod != 0 &&
(
if (retentionPeriod != 0 && (
systemMessage == ChatMessage.SystemMessageType.CALL_ENDED ||
systemMessage == ChatMessage.SystemMessageType.CALL_ENDED_EVERYONE
)
@ -740,8 +713,7 @@ class ChatActivity :
) {
val retentionPeriod = retentionOfInstantMeetingRoom(spreedCapabilities)
val systemMessage = currentConversation?.lastMessage?.systemMessageType
if (retentionPeriod != 0 &&
(
if (retentionPeriod != 0 && (
systemMessage == ChatMessage.SystemMessageType.CALL_ENDED ||
systemMessage == ChatMessage.SystemMessageType.CALL_ENDED_EVERYONE
)
@ -1130,8 +1102,6 @@ class ChatActivity :
context.getString(R.string.nc_room_retention),
Snackbar.LENGTH_LONG
).show()
chatMenu?.removeItem(R.id.conversation_event)
}
is ChatViewModel.UnbindRoomUiState.Error -> {
Snackbar.make(
@ -1267,17 +1237,11 @@ class ChatActivity :
bringToFront()
}
val deleteNoticeText = binding.conversationDeleteNotice.findViewById<TextView>(R.id.deletion_message)
viewThemeUtils.material.themeCardView(binding.conversationDeleteNotice)
deleteNoticeText.text = resources.getQuantityString(
R.plurals.nc_conversation_auto_delete_info,
retentionPeriod,
deleteNoticeText.text = String.format(
resources.getString(R.string.nc_conversation_auto_delete_notice),
retentionPeriod
)
viewThemeUtils.material.colorMaterialButtonPrimaryTonal(
binding.conversationDeleteNotice
.findViewById<MaterialButton>(R.id.keep_button)
)
if (ConversationUtils.isParticipantOwnerOrModerator(currentConversation!!)) {
binding.conversationDeleteNotice.findViewById<MaterialButton>(R.id.delete_now_button).visibility =
@ -1994,8 +1958,8 @@ class ChatActivity :
WorkManager.getInstance().enqueue(downloadWorker)
WorkManager.getInstance(context).getWorkInfoByIdLiveData(downloadWorker.id)
.observeForever { workInfo: WorkInfo? ->
if (workInfo?.state == WorkInfo.State.SUCCEEDED) {
.observeForever { workInfo: WorkInfo ->
if (workInfo.state == WorkInfo.State.SUCCEEDED) {
funToCallWhenDownloadSuccessful()
}
}
@ -2078,7 +2042,7 @@ class ChatActivity :
private fun shouldShowLobby(): Boolean {
if (currentConversation != null) {
return hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.WEBINARY_LOBBY) &&
return CapabilitiesUtil.hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.WEBINARY_LOBBY) &&
currentConversation?.lobbyState == ConversationEnums.LobbyState.LOBBY_STATE_MODERATORS_ONLY &&
!ConversationUtils.canModerate(currentConversation!!, spreedCapabilities) &&
!participantPermissions.canIgnoreLobby()
@ -2333,8 +2297,15 @@ class ChatActivity :
}
}
private fun onMessageSearchResult(intent: Intent?) {
val messageId = intent?.getStringExtra(MessageSearchActivity.RESULT_KEY_MESSAGE_ID)
messageId?.let { id ->
scrollToAndCenterMessageWithId(id)
}
}
private fun executeIfResultOk(result: ActivityResult, onResult: (intent: Intent?) -> Unit) {
if (result.resultCode == RESULT_OK) {
if (result.resultCode == Activity.RESULT_OK) {
onResult(result.data)
} else {
Log.e(TAG, "resultCode for received intent was != ok")
@ -2348,7 +2319,7 @@ class ChatActivity :
if (position != null && position >= 0) {
binding.messagesListView.scrollToPosition(position)
} else {
Log.d(TAG, "message $messageId that should be scrolled to was not found (scrollToMessageWithId)")
startContextChatWindowForMessage(messageId)
}
}
@ -2361,12 +2332,10 @@ class ChatActivity :
binding.messagesListView.height / 2
)
} else {
Log.d(
TAG,
"message $messageId that should be scrolled " +
"to was not found (scrollToAndCenterMessageWithId)"
)
startContextChatWindowForMessage(messageId)
}
} ?: run {
startContextChatWindowForMessage(messageId)
}
}
@ -2828,7 +2797,7 @@ class ChatActivity :
}
if (this::spreedCapabilities.isInitialized) {
if (hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.MESSAGE_EXPIRATION)) {
if (CapabilitiesUtil.hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.MESSAGE_EXPIRATION)) {
deleteExpiredMessages()
}
} else {
@ -3075,7 +3044,6 @@ class ChatActivity :
override fun onCreateOptionsMenu(menu: Menu): Boolean {
super.onCreateOptionsMenu(menu)
menuInflater.inflate(R.menu.menu_conversation, menu)
chatMenu = menu
if (currentConversation?.objectType == ConversationEnums.ObjectType.EVENT) {
eventConversationMenuItem = menu.findItem(R.id.conversation_event)
@ -3089,6 +3057,7 @@ class ChatActivity :
loadAvatarForStatusBar()
setActionBarTitle()
}
return true
}
@ -3096,7 +3065,7 @@ class ChatActivity :
super.onPrepareOptionsMenu(menu)
if (this::spreedCapabilities.isInitialized) {
if (hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.READ_ONLY_ROOMS)) {
if (CapabilitiesUtil.hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.READ_ONLY_ROOMS)) {
checkShowCallButtons()
}
@ -3117,7 +3086,7 @@ class ChatActivity :
}.collect()
}
if (hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.SILENT_CALL)) {
if (CapabilitiesUtil.hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.SILENT_CALL)) {
Handler().post {
findViewById<View?>(R.id.conversation_voice_call)?.setOnLongClickListener {
showCallButtonMenu(true)
@ -3176,10 +3145,10 @@ class ChatActivity :
else -> super.onOptionsItemSelected(item)
}
@SuppressLint("InflateParams")
private fun showPopupWindow(anchorView: View) {
val popupView = layoutInflater.inflate(R.layout.item_event_schedule, null)
val titleTextView = popupView.findViewById<TextView>(R.id.event_scheduled)
val subtitleTextView = popupView.findViewById<TextView>(R.id.meetingTime)
val popupWindow = PopupWindow(
@ -3628,7 +3597,7 @@ class ChatActivity :
fun copyMessage(message: IMessage?) {
val clipboardManager =
getSystemService(CLIPBOARD_SERVICE) as ClipboardManager
getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clipData = ClipData.newPlainText(
resources?.getString(R.string.nc_app_product_name),
message?.text
@ -3942,7 +3911,7 @@ class ChatActivity :
val isOlderThanSixHours = message
.createdAt
.before(Date(System.currentTimeMillis() - AGE_THRESHOLD_FOR_DELETE_MESSAGE))
val hasDeleteMessagesUnlimitedCapability = hasSpreedFeatureCapability(
val hasDeleteMessagesUnlimitedCapability = CapabilitiesUtil.hasSpreedFeatureCapability(
spreedCapabilities,
SpreedFeatures.DELETE_MESSAGES_UNLIMITED
)
@ -3952,7 +3921,7 @@ class ChatActivity :
!hasDeleteMessagesUnlimitedCapability && isOlderThanSixHours -> false
message.systemMessageType != ChatMessage.SystemMessageType.DUMMY -> false
message.isDeleted -> false
!hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.DELETE_MESSAGES) -> false
!CapabilitiesUtil.hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.DELETE_MESSAGES) -> false
!participantPermissions.hasChatPermission() -> false
hasDeleteMessagesUnlimitedCapability -> true
else -> true
@ -4217,6 +4186,5 @@ class ChatActivity :
const val OUT_OF_OFFICE_ALPHA = 76
const val ZERO_INDEX = 0
const val ONE_INDEX = 1
const val MAX_AMOUNT_MEDIA_FILE_PICKER = 10
}
}

View File

@ -64,7 +64,6 @@ import com.nextcloud.talk.chat.viewmodels.ChatViewModel
import com.nextcloud.talk.data.network.NetworkMonitor
import com.nextcloud.talk.databinding.FragmentMessageInputBinding
import com.nextcloud.talk.jobs.UploadAndShareFilesWorker
import com.nextcloud.talk.models.json.chat.ChatUtils
import com.nextcloud.talk.models.json.mention.Mention
import com.nextcloud.talk.models.json.signaling.NCSignalingMessage
import com.nextcloud.talk.presenters.MentionAutocompletePresenter
@ -78,7 +77,6 @@ import com.nextcloud.talk.utils.CharPolicy
import com.nextcloud.talk.utils.ImageEmojiEditText
import com.nextcloud.talk.utils.SpreedFeatures
import com.nextcloud.talk.utils.database.user.CurrentUserProviderNew
import com.nextcloud.talk.utils.message.MessageUtils
import com.nextcloud.talk.utils.text.Spans
import com.otaliastudios.autocomplete.Autocomplete
import com.stfalcon.chatkit.commons.models.IMessage
@ -126,9 +124,6 @@ class MessageInputFragment : Fragment() {
@Inject
lateinit var networkMonitor: NetworkMonitor
@Inject
lateinit var messageUtils: MessageUtils
lateinit var binding: FragmentMessageInputBinding
private lateinit var conversationInternalId: String
private var typedWhileTypingTimerIsRunning: Boolean = false
@ -205,7 +200,7 @@ class MessageInputFragment : Fragment() {
val connectionGained = (!wasOnline && isOnline)
Log.d(TAG, "isOnline: $isOnline\nwasOnline: $wasOnline\nconnectionGained: $connectionGained")
if (connectionGained) {
chatActivity.messageInputViewModel.sendUnsentMessages(
chatActivity.messageInputViewModel.sendTempMessages(
chatActivity.conversationUser!!.getCredentials(),
ApiUtils.getUrlForChat(
chatActivity.chatApiVersion,
@ -417,22 +412,10 @@ class MessageInputFragment : Fragment() {
}
binding.fragmentMessageInputView.editMessageButton.setOnClickListener {
val editable = binding.fragmentMessageInputView.inputEditText!!.editableText
replaceMentionChipSpans(editable)
val inputEditText = editable.toString()
val text = binding.fragmentMessageInputView.inputEditText.text.toString()
val message = chatActivity.messageInputViewModel.getEditChatMessage.value as ChatMessage
if (message.message!!.trim() != inputEditText.trim()) {
if (message.messageParameters != null) {
val editedMessage = messageUtils.processEditMessageParameters(
message.messageParameters!!,
message,
inputEditText
)
editMessageAPI(message, editedMessage.toString())
} else {
editMessageAPI(message, inputEditText.toString())
}
if (message.message!!.trim() != text.trim()) {
editMessageAPI(message, text)
}
clearEditUI()
}
@ -854,7 +837,27 @@ class MessageInputFragment : Fragment() {
private fun submitMessage(sendWithoutNotification: Boolean) {
if (binding.fragmentMessageInputView.inputEditText != null) {
val editable = binding.fragmentMessageInputView.inputEditText!!.editableText
replaceMentionChipSpans(editable)
val mentionSpans = editable.getSpans(
0,
editable.length,
Spans.MentionChipSpan::class.java
)
var mentionSpan: Spans.MentionChipSpan
for (i in mentionSpans.indices) {
mentionSpan = mentionSpans[i]
var mentionId = mentionSpan.id
val shouldQuote = mentionId.contains(" ") ||
mentionId.contains("@") ||
mentionId.startsWith("guest/") ||
mentionId.startsWith("group/") ||
mentionId.startsWith("email/") ||
mentionId.startsWith("team/")
if (shouldQuote) {
mentionId = "\"" + mentionId + "\""
}
editable.replace(editable.getSpanStart(mentionSpan), editable.getSpanEnd(mentionSpan), "@$mentionId")
}
binding.fragmentMessageInputView.inputEditText?.setText("")
sendStopTypingMessage()
val replyMessageId = binding.fragmentMessageInputView
@ -884,31 +887,6 @@ class MessageInputFragment : Fragment() {
)
}
private fun replaceMentionChipSpans(editable: Editable) {
val mentionSpans = editable.getSpans(
0,
editable.length,
Spans.MentionChipSpan::class.java
)
for (mentionSpan in mentionSpans) {
var mentionId = mentionSpan.id
val shouldQuote = mentionId.contains(" ") ||
mentionId.contains("@") ||
mentionId.startsWith("guest/") ||
mentionId.startsWith("group/") ||
mentionId.startsWith("email/") ||
mentionId.startsWith("team/")
if (shouldQuote) {
mentionId = "\"$mentionId\""
}
editable.replace(
editable.getSpanStart(mentionSpan),
editable.getSpanEnd(mentionSpan),
"@$mentionId"
)
}
}
private fun showSendButtonMenu() {
val popupMenu = PopupMenu(
ContextThemeWrapper(requireContext(), R.style.ChatSendButtonMenu),
@ -954,12 +932,8 @@ class MessageInputFragment : Fragment() {
}
private fun setEditUI(message: ChatMessage) {
val editedMessage = ChatUtils.getParsedMessage(message.message, message.messageParameters)
binding.fragmentEditView.editMessage.text = editedMessage
binding.fragmentMessageInputView.inputEditText.setText(editedMessage)
if (mentionAutocomplete != null && mentionAutocomplete!!.isPopupShowing) {
mentionAutocomplete?.dismissPopup()
}
binding.fragmentEditView.editMessage.text = message.message
binding.fragmentMessageInputView.inputEditText.setText(message.message)
val end = binding.fragmentMessageInputView.inputEditText.text.length
binding.fragmentMessageInputView.inputEditText.setSelection(end)
binding.fragmentMessageInputView.messageSendButton.visibility = View.GONE

View File

@ -76,6 +76,8 @@ interface ChatMessageRepository : LifecycleAwareManager {
*/
suspend fun getMessage(messageId: Long, bundle: Bundle): Flow<ChatMessage>
suspend fun checkIfMessageIsSaved(messageId: Long): Boolean
@Suppress("LongParameterList")
suspend fun sendChatMessage(
credentials: String,
@ -110,7 +112,7 @@ interface ChatMessageRepository : LifecycleAwareManager {
suspend fun editTempChatMessage(message: ChatMessage, editedMessageText: String): Flow<Boolean>
suspend fun sendUnsentChatMessages(credentials: String, url: String)
suspend fun sendTempChatMessages(credentials: String, url: String)
suspend fun deleteTempMessage(chatMessage: ChatMessage)
}

View File

@ -183,26 +183,19 @@ class MediaPlayerManager : LifecycleAwareManager {
continue
}
mediaPlayer?.let { player ->
try {
if (!player.isPlaying) return@let
} catch (e: IllegalStateException) {
Log.e(TAG, "Seekbar updated during an improper state: $e")
return@let
}
val pos = player.currentPosition
if (mediaPlayer != null && mediaPlayer?.isPlaying == true) {
val pos = mediaPlayer!!.currentPosition
mediaPlayerPosition = pos
val progress = (pos.toFloat() / mediaPlayerDuration) * DIVIDER
val progressI = ceil(progress).toInt()
val seconds = (pos / ONE_SEC)
_mediaPlayerSeekBarPosition.emit(progressI)
currentCycledMessage?.let { msg ->
msg.isPlayingVoiceMessage = true
msg.voiceMessageSeekbarProgress = progressI
msg.voiceMessagePlayedSeconds = seconds
if (progressI >= IS_PLAYED_CUTOFF) msg.wasPlayedVoiceMessage = true
_mediaPlayerSeekBarPositionMsg.emit(msg)
currentCycledMessage?.let {
it.isPlayingVoiceMessage = true
it.voiceMessageSeekbarProgress = progressI
it.voiceMessagePlayedSeconds = seconds
if (progressI >= IS_PLAYED_CUTOFF) it.wasPlayedVoiceMessage = true
_mediaPlayerSeekBarPositionMsg.emit(it)
}
}

View File

@ -14,7 +14,6 @@ import android.util.Log
import com.bluelinelabs.logansquare.annotation.JsonIgnore
import com.nextcloud.talk.R
import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
import com.nextcloud.talk.data.database.model.SendStatus
import com.nextcloud.talk.data.user.model.User
import com.nextcloud.talk.models.json.chat.ChatUtils.Companion.getParsedMessage
import com.nextcloud.talk.models.json.chat.ReadStatus
@ -120,7 +119,7 @@ data class ChatMessage(
var referenceId: String? = null,
var sendStatus: SendStatus? = null,
var sendingFailed: Boolean = true,
var silent: Boolean = false

View File

@ -19,7 +19,6 @@ import com.nextcloud.talk.data.database.mappers.asEntity
import com.nextcloud.talk.data.database.mappers.asModel
import com.nextcloud.talk.data.database.model.ChatBlockEntity
import com.nextcloud.talk.data.database.model.ChatMessageEntity
import com.nextcloud.talk.data.database.model.SendStatus
import com.nextcloud.talk.data.network.NetworkMonitor
import com.nextcloud.talk.data.user.model.User
import com.nextcloud.talk.extensions.toIntOrZero
@ -215,8 +214,7 @@ class OfflineFirstChatRepository @Inject constructor(
)
}
// this call could be deleted when we have a worker to send messages..
sendUnsentChatMessages(credentials, urlForChatting)
sendTempChatMessages(credentials, urlForChatting)
// delay is a dirty workaround to make sure messages are added to adapter on initial load before dealing
// with them (otherwise there is a race condition).
@ -367,18 +365,11 @@ class OfflineFirstChatRepository @Inject constructor(
lookIntoFuture: Boolean,
showUnreadMessagesMarker: Boolean
) {
receivedChatMessages.forEach {
Log.d(TAG, "receivedChatMessage: " + it.message)
}
// remove all temp messages from UI
val oldTempMessages = chatDao.getTempMessagesForConversation(internalConversationId)
.first()
.map(ChatMessageEntity::asModel)
oldTempMessages.forEach {
Log.d(TAG, "oldTempMessage to be removed from UI: " + it.message)
_removeMessageFlow.emit(it)
}
oldTempMessages.forEach { _removeMessageFlow.emit(it) }
// add new messages to UI
val tripleChatMessages = Triple(lookIntoFuture, showUnreadMessagesMarker, receivedChatMessages)
@ -387,9 +378,6 @@ class OfflineFirstChatRepository @Inject constructor(
// remove temp messages from DB that are now found in the new messages
val chatMessagesReferenceIds = receivedChatMessages.mapTo(HashSet(receivedChatMessages.size)) { it.referenceId }
val tempChatMessagesThatCanBeReplaced = oldTempMessages.filter { it.referenceId in chatMessagesReferenceIds }
tempChatMessagesThatCanBeReplaced.forEach {
Log.d(TAG, "oldTempMessage that was identified in newMessages: " + it.message)
}
chatDao.deleteTempChatMessages(
internalConversationId,
tempChatMessagesThatCanBeReplaced.map { it.referenceId!! }
@ -401,10 +389,6 @@ class OfflineFirstChatRepository @Inject constructor(
.sortedBy { it.internalId }
.map(ChatMessageEntity::asModel)
remainingTempMessages.forEach {
Log.d(TAG, "remainingTempMessage: " + it.message)
}
val triple = Triple(true, false, remainingTempMessages)
_messageFlow.emit(triple)
}
@ -491,6 +475,15 @@ class OfflineFirstChatRepository @Inject constructor(
.map(ChatMessageEntity::asModel)
}
override suspend fun checkIfMessageIsSaved(messageId: Long): Boolean {
try {
chatDao.getChatMessageForConversation(internalConversationId, messageId)
return true
} catch (_: Exception) {
return false
}
}
@Suppress("UNCHECKED_CAST", "MagicNumber", "Detekt.TooGenericExceptionCaught")
private fun getMessagesFromServer(bundle: Bundle): Pair<Int, List<ChatMessageJson>>? {
val fieldMap = bundle.getSerializable(BundleKeys.KEY_FIELD_MAP) as HashMap<String, Int>
@ -859,17 +852,6 @@ class OfflineFirstChatRepository @Inject constructor(
val chatMessageModel = response.ocs?.data?.asModel()
val sentMessage = chatDao.getTempMessageForConversation(
internalConversationId,
referenceId
).firstOrNull()
sentMessage?.let {
it.sendStatus = SendStatus.SENT_PENDING_ACK
chatDao.updateChatMessage(it)
}
Log.d(TAG, "sending chat message succeeded: " + message)
emit(Result.success(chatMessageModel))
}
.catch { e ->
@ -880,7 +862,7 @@ class OfflineFirstChatRepository @Inject constructor(
referenceId
).firstOrNull()
failedMessage?.let {
it.sendStatus = SendStatus.FAILED
it.sendingFailed = true
chatDao.updateChatMessage(it)
val failedMessageModel = it.asModel()
@ -900,28 +882,22 @@ class OfflineFirstChatRepository @Inject constructor(
sendWithoutNotification: Boolean,
referenceId: String
): Flow<Result<ChatMessage?>> {
val messageToResend = chatDao.getTempMessageForConversation(internalConversationId, referenceId).firstOrNull()
return if (messageToResend != null) {
messageToResend.sendStatus = SendStatus.PENDING
chatDao.updateChatMessage(messageToResend)
val messageToResend = chatDao.getTempMessageForConversation(internalConversationId, referenceId).first()
messageToResend.sendingFailed = false
chatDao.updateChatMessage(messageToResend)
val messageToResendModel = messageToResend.asModel()
_updateMessageFlow.emit(messageToResendModel)
val messageToResendModel = messageToResend.asModel()
_updateMessageFlow.emit(messageToResendModel)
sendChatMessage(
credentials,
url,
message,
displayName,
replyTo,
sendWithoutNotification,
referenceId
)
} else {
flow {
emit(Result.failure(IllegalStateException("No temporary message found to resend")))
}
}
return sendChatMessage(
credentials,
url,
message,
displayName,
replyTo,
sendWithoutNotification,
referenceId
)
}
@Suppress("Detekt.TooGenericExceptionCaught")
@ -963,8 +939,8 @@ class OfflineFirstChatRepository @Inject constructor(
}
}
override suspend fun sendUnsentChatMessages(credentials: String, url: String) {
val tempMessages = chatDao.getTempUnsentMessagesForConversation(internalConversationId).first()
override suspend fun sendTempChatMessages(credentials: String, url: String) {
val tempMessages = chatDao.getTempMessagesForConversation(internalConversationId).first()
tempMessages.sortedBy { it.internalId }.onEach {
sendChatMessage(
credentials,
@ -1058,7 +1034,7 @@ class OfflineFirstChatRepository @Inject constructor(
actorDisplayName = currentUser.displayName!!,
referenceId = referenceId,
isTemporary = true,
sendStatus = SendStatus.PENDING,
sendingFailed = false,
silent = sendWithoutNotification
)
return entity

View File

@ -23,8 +23,10 @@ import com.nextcloud.talk.utils.message.SendMessageUtils
import io.reactivex.Observable
import retrofit2.Response
class RetrofitChatNetwork(private val ncApi: NcApi, private val ncApiCoroutines: NcApiCoroutines) :
ChatNetworkDataSource {
class RetrofitChatNetwork(
private val ncApi: NcApi,
private val ncApiCoroutines: NcApiCoroutines
) : ChatNetworkDataSource {
override fun getRoom(user: User, roomToken: String): Observable<ConversationModel> {
val credentials: String = ApiUtils.getCredentials(user.username, user.token)!!
val apiVersion = ApiUtils.getConversationApiVersion(user, intArrayOf(ApiUtils.API_V4, ApiUtils.API_V3, 1))
@ -185,11 +187,12 @@ class RetrofitChatNetwork(private val ncApi: NcApi, private val ncApiCoroutines:
credentials: String,
baseUrl: String,
userId: String
): UserAbsenceOverall =
ncApiCoroutines.getOutOfOfficeStatusForUser(
): UserAbsenceOverall {
return ncApiCoroutines.getOutOfOfficeStatusForUser(
credentials,
ApiUtils.getUrlForOutOfOffice(baseUrl, userId)
)
}
override suspend fun getContextForChatMessage(
credentials: String,

View File

@ -85,7 +85,9 @@ class ChatViewModel @Inject constructor(
var mediaPlayerDuration = mediaPlayerManager.mediaPlayerDuration
val mediaPlayerPosition = mediaPlayerManager.mediaPlayerPosition
fun getChatRepository(): ChatMessageRepository = chatRepository
fun getChatRepository(): ChatMessageRepository {
return chatRepository
}
override fun onResume(owner: LifecycleOwner) {
super.onResume(owner)
@ -283,6 +285,10 @@ class ChatViewModel @Inject constructor(
conversationRepository.getRoom(token)
}
suspend fun isMessageSaved(messageId: Long): Boolean {
return chatRepository.checkIfMessageIsSaved(messageId)
}
fun getCapabilities(user: User, token: String, conversationModel: ConversationModel) {
Log.d(TAG, "Remote server ${conversationModel.remoteServer}")
if (conversationModel.remoteServer.isNullOrEmpty()) {

View File

@ -169,9 +169,9 @@ class MessageInputViewModel @Inject constructor(
}
}
fun sendUnsentMessages(credentials: String, url: String) {
fun sendTempMessages(credentials: String, url: String) {
viewModelScope.launch {
chatRepository.sendUnsentChatMessages(
chatRepository.sendTempChatMessages(
credentials,
url
)

View File

@ -1,6 +1,7 @@
/*
* Nextcloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2024 Sowjanya Kota <sowjanya.kch@gmail.com>
* SPDX-FileCopyrightText: 2025 Marcel Hibbe <dev@mhibbe.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
@ -8,41 +9,16 @@
package com.nextcloud.talk.components
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.windowInsetsTopHeight
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
@Composable
fun ColoredStatusBar() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
Box(modifier = Modifier.fillMaxSize()) {
Box(
Modifier
.windowInsetsTopHeight(WindowInsets.statusBars)
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface)
)
}
} else {
ColorLegacyStatusBar()
}
}
@Composable
private fun ColorLegacyStatusBar() {
fun SetupSystemBars() {
val view = LocalView.current
val isDarkMode = isSystemInDarkTheme()
val statusBarColor = MaterialTheme.colorScheme.surface.toArgb()

View File

@ -1,24 +0,0 @@
/*
* Nextcloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2025 Marcel Hibbe <dev@mhibbe.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.nextcloud.talk.components
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@Composable
fun VerticallyCenteredRow(content: @Composable RowScope.() -> Unit) {
Row(
modifier = Modifier.fillMaxHeight(),
verticalAlignment = Alignment.CenterVertically,
content = content
)
}

View File

@ -18,9 +18,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import autodagger.AutoInjector
import com.nextcloud.talk.activities.BaseActivity
import com.nextcloud.talk.application.NextcloudTalkApplication
import com.nextcloud.talk.components.ColoredStatusBar
import com.nextcloud.talk.contacts.CompanionClass.Companion.KEY_HIDE_ALREADY_EXISTING_PARTICIPANTS
import com.nextcloud.talk.extensions.getParcelableArrayListExtraProvider
import com.nextcloud.talk.components.SetupSystemBars
import com.nextcloud.talk.models.json.autocomplete.AutocompleteUser
import com.nextcloud.talk.utils.bundle.BundleKeys
import javax.inject.Inject
@ -64,11 +64,11 @@ class ContactsActivity : BaseActivity() {
MaterialTheme(
colorScheme = colorScheme
) {
ColoredStatusBar()
ContactsScreen(
contactsViewModel = contactsViewModel,
uiState = uiState.value
)
SetupSystemBars()
}
}
}

View File

@ -15,9 +15,7 @@ import coil.memory.MemoryCache
import coil.util.DebugLogger
import com.nextcloud.talk.utils.ContactUtils
class ContactsApplication :
Application(),
ImageLoaderFactory {
class ContactsApplication : Application(), ImageLoaderFactory {
override fun newImageLoader(): ImageLoader {
val imageLoader = ImageLoader.Builder(this)
.memoryCache {

View File

@ -86,12 +86,13 @@ class ContactsRepositoryImpl @Inject constructor(
return response
}
override fun getImageUri(avatarId: String, requestBigSize: Boolean): String =
ApiUtils.getUrlForAvatar(
override fun getImageUri(avatarId: String, requestBigSize: Boolean): String {
return ApiUtils.getUrlForAvatar(
_currentUser.baseUrl,
avatarId,
requestBigSize
)
}
companion object {
private val TAG = ContactsRepositoryImpl::class.simpleName

View File

@ -11,18 +11,16 @@ package com.nextcloud.talk.contacts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.nextcloud.talk.R
import com.nextcloud.talk.contacts.components.ContactsAppBar
import com.nextcloud.talk.contacts.components.AppBar
import com.nextcloud.talk.contacts.components.ContactsList
import com.nextcloud.talk.contacts.components.ContactsSearchAppBar
import com.nextcloud.talk.contacts.components.ConversationCreationOptions
@Composable
@ -31,40 +29,33 @@ fun ContactsScreen(contactsViewModel: ContactsViewModel, uiState: ContactsUiStat
val isSearchActive by contactsViewModel.isSearchActive.collectAsStateWithLifecycle()
val isAddParticipants by contactsViewModel.isAddParticipantsView.collectAsStateWithLifecycle()
val autocompleteUsers by contactsViewModel.selectedParticipantsList.collectAsStateWithLifecycle()
val enableAddButton by contactsViewModel.enableAddButton.collectAsStateWithLifecycle()
Scaffold(
modifier = Modifier
.statusBarsPadding(),
topBar = {
if (isSearchActive) {
ContactsSearchAppBar(
searchQuery = searchQuery,
onTextChange = {
contactsViewModel.updateSearchQuery(it)
contactsViewModel.getContactsFromSearchParams()
},
onCloseSearch = {
contactsViewModel.updateSearchQuery("")
contactsViewModel.setSearchActive(false)
contactsViewModel.getContactsFromSearchParams()
},
enableAddButton = enableAddButton,
isAddParticipants = isAddParticipants,
clickAddButton = { contactsViewModel.modifyClickAddButton(true) }
)
} else {
ContactsAppBar(
isAddParticipants = isAddParticipants,
autocompleteUsers = autocompleteUsers,
onStartSearch = { contactsViewModel.setSearchActive(true) }
)
}
AppBar(
title = stringResource(R.string.nc_app_product_name),
searchQuery = searchQuery,
isSearchActive = isSearchActive,
isAddParticipants = isAddParticipants,
autocompleteUsers = autocompleteUsers,
onEnableSearch = {
contactsViewModel.setSearchActive(true)
},
onDisableSearch = {
contactsViewModel.setSearchActive(false)
},
onUpdateSearchQuery = {
contactsViewModel.updateSearchQuery(query = it)
},
onUpdateAutocompleteUsers = {
contactsViewModel.getContactsFromSearchParams()
}
)
},
content = { paddingValues ->
content = {
Column(
Modifier
.padding(0.dp, paddingValues.calculateTopPadding(), 0.dp, 0.dp)
.padding(it)
.background(colorResource(id = R.color.bg_default))
) {
if (!isAddParticipants) {

View File

@ -17,7 +17,9 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
class ContactsViewModel @Inject constructor(private val repository: ContactsRepository) : ViewModel() {
class ContactsViewModel @Inject constructor(
private val repository: ContactsRepository
) : ViewModel() {
private val _contactsViewState = MutableStateFlow<ContactsUiState>(ContactsUiState.None)
val contactsViewState: StateFlow<ContactsUiState> = _contactsViewState
@ -34,15 +36,6 @@ class ContactsViewModel @Inject constructor(private val repository: ContactsRepo
private val _isAddParticipantsView = MutableStateFlow(false)
val isAddParticipantsView: StateFlow<Boolean> = _isAddParticipantsView
private val _enableAddButton = MutableStateFlow(false)
val enableAddButton: StateFlow<Boolean> = _enableAddButton
@Suppress("PropertyName")
private val _selectedContacts = MutableStateFlow<List<AutocompleteUser>>(emptyList())
@Suppress("PropertyName")
private val _clickAddButton = MutableStateFlow(false)
private var hideAlreadyAddedParticipants: Boolean = false
init {
@ -53,28 +46,14 @@ class ContactsViewModel @Inject constructor(private val repository: ContactsRepo
_searchQuery.value = query
}
fun modifyClickAddButton(value: Boolean) {
_clickAddButton.value = value
}
fun selectContact(contact: AutocompleteUser) {
val updatedParticipants = selectedParticipants.value + contact
selectedParticipants.value = updatedParticipants
_selectedContacts.value = _selectedContacts.value + contact
}
fun updateAddButtonState() {
if (_selectedContacts.value.isEmpty()) {
_enableAddButton.value = false
} else {
_enableAddButton.value = true
}
}
fun deselectContact(contact: AutocompleteUser) {
val updatedParticipants = selectedParticipants.value - contact
selectedParticipants.value = updatedParticipants
_selectedContacts.value = _selectedContacts.value - contact
}
fun updateSelectedParticipants(participants: List<AutocompleteUser>) {
@ -97,23 +76,20 @@ class ContactsViewModel @Inject constructor(private val repository: ContactsRepo
}
@Suppress("Detekt.TooGenericExceptionCaught")
fun getContactsFromSearchParams(query: String = "") {
fun getContactsFromSearchParams() {
_contactsViewState.value = ContactsUiState.Loading
viewModelScope.launch {
try {
val contacts = repository.getContacts(
if (query != "") query else searchQuery.value,
searchQuery.value,
shareTypeList
)
val contactsList: MutableList<AutocompleteUser>? = contacts.ocs!!.data?.toMutableList()
if (hideAlreadyAddedParticipants && !_clickAddButton.value) {
if (hideAlreadyAddedParticipants) {
contactsList?.removeAll(selectedParticipants.value)
}
if (_clickAddButton.value) {
contactsList?.removeAll(selectedParticipants.value)
contactsList?.addAll(_selectedContacts.value)
}
_contactsViewState.value = ContactsUiState.Success(contactsList)
} catch (exception: Exception) {
_contactsViewState.value = ContactsUiState.Error(exception.message ?: "")
@ -139,8 +115,9 @@ class ContactsViewModel @Inject constructor(private val repository: ContactsRepo
}
}
}
fun getImageUri(avatarId: String, requestBigSize: Boolean): String =
repository.getImageUri(avatarId, requestBigSize)
fun getImageUri(avatarId: String, requestBigSize: Boolean): String {
return repository.getImageUri(avatarId, requestBigSize)
}
}
sealed class ContactsUiState {

View File

@ -0,0 +1,89 @@
/*
* Nextcloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2024 Sowjanya Kota <sowjanya.kch@gmail.com>
* SPDX-FileCopyrightText: 2025 Marcel Hibbe <dev@mhibbe.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.nextcloud.talk.contacts.components
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Intent
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import com.nextcloud.talk.R
import com.nextcloud.talk.models.json.autocomplete.AutocompleteUser
@SuppressLint("UnrememberedMutableState")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppBar(
title: String,
searchQuery: String,
isSearchActive: Boolean,
isAddParticipants: Boolean,
autocompleteUsers: List<AutocompleteUser>,
onEnableSearch: () -> Unit,
onDisableSearch: () -> Unit,
onUpdateSearchQuery: (String) -> Unit,
onUpdateAutocompleteUsers: () -> Unit
) {
val context = LocalContext.current
TopAppBar(
title = { Text(text = title) },
navigationIcon = {
IconButton(onClick = {
(context as? Activity)?.finish()
}) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back_button))
}
},
actions = {
IconButton(onClick = onEnableSearch) {
Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.search_icon))
}
if (isAddParticipants) {
Text(
text = stringResource(id = R.string.nc_contacts_done),
modifier = Modifier.clickable {
val resultIntent = Intent().apply {
putParcelableArrayListExtra(
"selectedParticipants",
ArrayList(autocompleteUsers)
)
}
(context as? Activity)?.setResult(Activity.RESULT_OK, resultIntent)
(context as? Activity)?.finish()
}
)
}
}
)
if (isSearchActive) {
Row {
SearchComponent(
text = searchQuery,
onTextChange = { searchQuery ->
onUpdateSearchQuery(searchQuery)
onUpdateAutocompleteUsers()
},
onDisableSearch = onDisableSearch
)
}
}
}

View File

@ -65,10 +65,8 @@ fun ContactItemRow(contact: AutocompleteUser, contactsViewModel: ContactsViewMod
isSelected = !isSelected
if (isSelected) {
contactsViewModel.selectContact(contact)
contactsViewModel.updateAddButtonState()
} else {
contactsViewModel.deselectContact(contact)
contactsViewModel.updateAddButtonState()
}
}
}

View File

@ -1,77 +0,0 @@
/*
* Nextcloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2024 Sowjanya Kota <sowjanya.kch@gmail.com>
* SPDX-FileCopyrightText: 2025 Marcel Hibbe <dev@mhibbe.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.nextcloud.talk.contacts.components
import android.app.Activity
import android.content.Intent
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.height
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.nextcloud.talk.R
import com.nextcloud.talk.components.VerticallyCenteredRow
import com.nextcloud.talk.models.json.autocomplete.AutocompleteUser
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ContactsAppBar(isAddParticipants: Boolean, autocompleteUsers: List<AutocompleteUser>, onStartSearch: () -> Unit) {
val context = LocalContext.current
TopAppBar(
modifier = Modifier
.height(60.dp),
title = {
VerticallyCenteredRow {
Text(
text = if (isAddParticipants) {
stringResource(R.string.nc_participants_add)
} else {
stringResource(R.string.nc_new_conversation)
}
)
}
},
navigationIcon = {
VerticallyCenteredRow {
IconButton(onClick = { (context as? Activity)?.finish() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back_button))
}
}
},
actions = {
VerticallyCenteredRow {
IconButton(onClick = onStartSearch) {
Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.search_icon))
}
if (isAddParticipants) {
Text(
text = stringResource(id = R.string.nc_contacts_done),
modifier = Modifier.clickable {
val resultIntent = Intent().apply {
putParcelableArrayListExtra("selectedParticipants", ArrayList(autocompleteUsers))
}
(context as? Activity)?.setResult(Activity.RESULT_OK, resultIntent)
(context as? Activity)?.finish()
}
)
}
}
}
)
}

View File

@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.HorizontalDivider
@ -43,13 +44,9 @@ fun ContactsItem(contacts: List<AutocompleteUser>, contactsViewModel: ContactsVi
}
LazyColumn(
modifier = Modifier
.padding(8.dp)
.fillMaxWidth(),
contentPadding = PaddingValues(
top = 10.dp,
bottom = 40.dp,
start = 10.dp,
end = 10.dp
),
contentPadding = PaddingValues(all = 10.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
groupedContacts.forEach { (initial, contactsForInitial) ->

View File

@ -1,117 +0,0 @@
/*
* Nextcloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2024 Sowjanya Kota <sowjanya.kch@gmail.com>
* SPDX-FileCopyrightText: 2025 Marcel Hibbe <dev@mhibbe.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.nextcloud.talk.contacts.components
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.SoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import com.nextcloud.talk.R
import com.nextcloud.talk.components.VerticallyCenteredRow
@Suppress("LongParameterList")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ContactsSearchAppBar(
searchQuery: String,
onTextChange: (String) -> Unit,
onCloseSearch: () -> Unit,
enableAddButton: Boolean,
isAddParticipants: Boolean,
clickAddButton: (Boolean) -> Unit
) {
val keyboardController = LocalSoftwareKeyboardController.current
Surface(
modifier = Modifier.height(60.dp)
) {
VerticallyCenteredRow {
IconButton(
modifier = Modifier.padding(start = 4.dp),
onClick = onCloseSearch
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.back_button)
)
}
TextField(
value = searchQuery,
onValueChange = onTextChange,
placeholder = { Text(text = stringResource(R.string.nc_search)) },
singleLine = true,
modifier = Modifier
.weight(1f)
.padding(horizontal = 8.dp),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = searchKeyboardActions(searchQuery, keyboardController),
colors = searchTextFieldColors(),
trailingIcon = {
if (searchQuery.isNotEmpty()) {
IconButton(onClick = { onTextChange("") }) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(R.string.nc_search_clear)
)
}
}
}
)
if (isAddParticipants) {
TextButton(
onClick = {
onCloseSearch()
clickAddButton(true)
},
enabled = enableAddButton
) {
Text(text = stringResource(R.string.add_participants))
}
}
}
}
}
@Composable
fun searchTextFieldColors() =
TextFieldDefaults.colors(
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent
)
fun searchKeyboardActions(text: String, keyboardController: SoftwareKeyboardController?) =
KeyboardActions(
onSearch = {
if (text.trim().isNotEmpty()) {
keyboardController?.hide()
}
}
)

View File

@ -0,0 +1,103 @@
/*
* Nextcloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2025 Julius Linus <juliuslinus1@gmail.com>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.nextcloud.talk.contacts.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.SoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.nextcloud.talk.R
@Composable
fun SearchComponent(text: String, onTextChange: (String) -> Unit, onDisableSearch: () -> Unit) {
val keyboardController = LocalSoftwareKeyboardController.current
TextField(
modifier = Modifier
.background(MaterialTheme.colorScheme.background)
.fillMaxWidth()
.height(60.dp),
value = text,
onValueChange = { onTextChange(it) },
placeholder = { Text(text = stringResource(R.string.nc_search)) },
textStyle = TextStyle(fontSize = 16.sp),
singleLine = true,
leadingIcon = { LeadingIcon(onTextChange, onDisableSearch) },
trailingIcon = { TrailingIcon(text, onTextChange) },
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = searchKeyboardActions(text, keyboardController),
colors = searchTextFieldColors(),
maxLines = 1
)
}
@Composable
fun searchTextFieldColors() =
TextFieldDefaults.colors(
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent
)
@Composable
fun LeadingIcon(onTextChange: (String) -> Unit, onDisableSearch: () -> Unit) {
IconButton(
onClick = {
onTextChange("")
onDisableSearch()
}
) {
Icon(
imageVector = Icons.AutoMirrored.Default.ArrowBack,
contentDescription = stringResource(R.string.back_button)
)
}
}
@Composable
fun TrailingIcon(text: String, onTextChange: (String) -> Unit) {
if (text.isNotEmpty()) {
IconButton(
onClick = { onTextChange("") }
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(R.string.close_icon)
)
}
}
}
fun searchKeyboardActions(text: String, keyboardController: SoftwareKeyboardController?) =
KeyboardActions(
onSearch = {
if (text.trim().isNotEmpty()) {
keyboardController?.hide()
}
}
)

View File

@ -82,8 +82,9 @@ class RenameConversationDialogFragment : DialogFragment() {
return dialogBuilder.create()
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View =
binding.root
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)

View File

@ -84,8 +84,8 @@ import com.nextcloud.talk.R
import com.nextcloud.talk.activities.BaseActivity
import com.nextcloud.talk.application.NextcloudTalkApplication
import com.nextcloud.talk.chat.ChatActivity
import com.nextcloud.talk.components.ColoredStatusBar
import com.nextcloud.talk.contacts.ContactsActivity
import com.nextcloud.talk.components.SetupSystemBars
import com.nextcloud.talk.contacts.loadImage
import com.nextcloud.talk.extensions.getParcelableArrayListExtraProvider
import com.nextcloud.talk.models.json.autocomplete.AutocompleteUser
@ -117,6 +117,7 @@ class ConversationCreationActivity : BaseActivity() {
colorScheme = colorScheme
) {
ConversationCreationScreen(conversationCreationViewModel, context, pickImage)
SetupSystemBars()
}
}
}
@ -171,7 +172,6 @@ fun ConversationCreationScreen(
}
)
ColoredStatusBar()
Scaffold(
topBar = {
TopAppBar(
@ -191,7 +191,7 @@ fun ConversationCreationScreen(
content = { paddingValues ->
Column(
modifier = Modifier
.padding(0.dp, paddingValues.calculateTopPadding(), 0.dp, 0.dp)
.padding(paddingValues)
.background(colorResource(id = R.color.bg_default))
.fillMaxSize()
.verticalScroll(rememberScrollState())
@ -289,7 +289,7 @@ fun UploadAvatar(
}
) {
Icon(
painter = painterResource(id = R.drawable.ic_folder),
painter = painterResource(id = R.drawable.ic_mimetype_folder),
contentDescription = null,
modifier = Modifier.size(24.dp)
)

View File

@ -34,8 +34,8 @@ class ConversationCreationRepositoryImpl @Inject constructor(
val credentials = ApiUtils.getCredentials(_currentUser.username, _currentUser.token)
val apiVersion = ApiUtils.getConversationApiVersion(_currentUser, intArrayOf(ApiUtils.API_V4, ApiUtils.API_V1))
override suspend fun renameConversation(roomToken: String, roomNameNew: String?): GenericOverall =
ncApiCoroutines.renameRoom(
override suspend fun renameConversation(roomToken: String, roomNameNew: String?): GenericOverall {
return ncApiCoroutines.renameRoom(
credentials,
ApiUtils.getUrlForRoom(
apiVersion,
@ -44,9 +44,10 @@ class ConversationCreationRepositoryImpl @Inject constructor(
),
roomNameNew
)
}
override suspend fun setConversationDescription(roomToken: String, description: String?): GenericOverall =
ncApiCoroutines.setConversationDescription(
override suspend fun setConversationDescription(roomToken: String, description: String?): GenericOverall {
return ncApiCoroutines.setConversationDescription(
credentials,
ApiUtils.getUrlForConversationDescription(
apiVersion,
@ -55,9 +56,10 @@ class ConversationCreationRepositoryImpl @Inject constructor(
),
description
)
}
override suspend fun openConversation(roomToken: String, scope: Int): GenericOverall =
ncApiCoroutines.openConversation(
override suspend fun openConversation(roomToken: String, scope: Int): GenericOverall {
return ncApiCoroutines.openConversation(
credentials,
ApiUtils.getUrlForOpeningConversations(
apiVersion,
@ -66,6 +68,7 @@ class ConversationCreationRepositoryImpl @Inject constructor(
),
scope
)
}
override suspend fun addParticipants(
conversationToken: String?,
@ -107,12 +110,13 @@ class ConversationCreationRepositoryImpl @Inject constructor(
return response
}
override fun getImageUri(avatarId: String, requestBigSize: Boolean): String =
ApiUtils.getUrlForAvatar(
override fun getImageUri(avatarId: String, requestBigSize: Boolean): String {
return ApiUtils.getUrlForAvatar(
_currentUser.baseUrl,
avatarId,
requestBigSize
)
}
override suspend fun setPassword(roomToken: String, password: String): GenericOverall {
val result = ncApiCoroutines.setPassword(

View File

@ -139,8 +139,9 @@ class ConversationCreationViewModel @Inject constructor(
}
}
fun getImageUri(avatarId: String, requestBigSize: Boolean): String =
repository.getImageUri(avatarId, requestBigSize)
fun getImageUri(avatarId: String, requestBigSize: Boolean): String {
return repository.getImageUri(avatarId, requestBigSize)
}
}
sealed class AllowGuestsUiState {

View File

@ -190,7 +190,7 @@ class ConversationInfoActivity :
binding = ActivityConversationInfoBinding.inflate(layoutInflater)
setupActionBar()
setContentView(binding.root)
initSystemBars()
setupSystemColors()
viewModel =
ViewModelProvider(this, viewModelFactory)[ConversationInfoViewModel::class.java]
@ -252,8 +252,6 @@ class ConversationInfoActivity :
initClearChatHistoryObserver()
initMarkConversationAsSensitiveObserver()
initMarkConversationAsInsensitiveObserver()
initMarkConversationAsImportantObserver()
initMarkConversationAsUnimportantObserver()
}
private fun initMarkConversationAsSensitiveObserver() {
@ -383,47 +381,7 @@ class ConversationInfoActivity :
}
}
private fun initMarkConversationAsImportantObserver() {
viewModel.markAsImportantResult.observe(this) { uiState ->
when (uiState) {
is ConversationInfoViewModel.MarkConversationAsImportantViewState.Success -> {
Snackbar.make(
binding.root,
context.getString(R.string.nc_mark_conversation_as_important),
Snackbar.LENGTH_LONG
).show()
}
is ConversationInfoViewModel.MarkConversationAsImportantViewState.Error -> {
Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show()
Log.e(TAG, "failed to mark conversation as important", uiState.exception)
}
else -> {
}
}
}
}
private fun initMarkConversationAsUnimportantObserver() {
viewModel.markAsUnimportantResult.observe(this) { uiState ->
when (uiState) {
is ConversationInfoViewModel.MarkConversationAsUnimportantViewState.Success -> {
Snackbar.make(
binding.root,
context.getString(R.string.nc_mark_conversation_as_unimportant),
Snackbar.LENGTH_LONG
).show()
}
is ConversationInfoViewModel.MarkConversationAsUnimportantViewState.Error -> {
Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show()
Log.e(TAG, "failed to mark conversation as unimportant", uiState.exception)
}
else -> {
}
}
}
}
@Suppress("Detekt.TooGenericExceptionCaught")
@SuppressLint("SetTextI18n")
private fun initViewStateObserver() {
viewModel.viewState.observe(this) { state ->
when (state) {
@ -444,10 +402,8 @@ class ConversationInfoActivity :
)
}
conversation?.let {
if (it.type == ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL) {
viewModel.getProfileData(conversationUser, it.name)
}
if (conversation!!.type == ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL) {
viewModel.getProfileData(conversationUser, conversation!!.name)
}
}
@ -462,48 +418,28 @@ class ConversationInfoActivity :
viewModel.getProfileViewState.observe(this) { state ->
when (state) {
is ConversationInfoViewModel.GetProfileSuccessState -> {
try {
// Pronouns
val profile = state.profile
val pronouns = profile.pronouns ?: ""
binding.pronouns.text = pronouns
val profile = state.profile
val pronouns = profile.pronouns ?: ""
binding.pronouns.text = pronouns
// Role @ Organization
val concat1 = if (profile.role != null && profile.company != null) " @ " else ""
val role = profile.role ?: ""
val company = profile.company ?: ""
val professionCompanyText = "$role$concat1$company"
binding.professionCompany.text = professionCompanyText
val concat1 = if (profile.role != null && profile.company != null) " @ " else ""
val role = profile.role ?: ""
val company = profile.company ?: ""
val professionCompanyText = "$role$concat1$company"
binding.professionCompany.text = professionCompanyText
// Local Time: xX:xX · Address
val profileZoneOffset = ZoneOffset.ofTotalSeconds(0)
val secondsToAdd = profile.timezoneOffset?.toLong() ?: 0
val localTime = ZonedDateTime.ofInstant(
Instant.now().plusSeconds(secondsToAdd),
profileZoneOffset
)
val localTimeString = localTime.format(
DateTimeFormatter
.ofLocalizedTime(FormatStyle.SHORT)
.withLocale(Locale.getDefault())
)
val concat2 = if (profile.address != null) " · " else ""
val address = profile.address ?: ""
val localTimeLocation = "$localTimeString$concat2$address"
binding.locationTime.text = resources.getString(R.string.local_time, localTimeLocation)
val profileZoneOffset = ZoneOffset.ofTotalSeconds(0)
val secondsToAdd = profile.timezoneOffset?.toLong() ?: 0
val localTime = ZonedDateTime.ofInstant(Instant.now().plusSeconds(secondsToAdd), profileZoneOffset)
val localTimeString = localTime.format(DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT))
val concat2 = if (profile.address != null) " · " else ""
val address = profile.address ?: ""
val localTimeLocation = "$localTimeString$concat2$address"
binding.locationTime.text = resources.getString(R.string.local_time, localTimeLocation)
binding.pronouns.visibility = VISIBLE
binding.professionCompany.visibility = if (professionCompanyText.isNotEmpty()) VISIBLE else GONE
binding.locationTime.visibility = VISIBLE
} catch (e: Exception) {
Log.e(TAG, "Exception getting profile information", e)
Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show()
}
}
is ConversationInfoViewModel.GetProfileErrorState -> {
Log.e(TAG, "Network error occurred getting profile information")
Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show()
binding.pronouns.visibility = VISIBLE
binding.professionCompany.visibility = if (professionCompanyText.isNotEmpty()) VISIBLE else GONE
binding.locationTime.visibility = VISIBLE
}
else -> {}
@ -568,8 +504,7 @@ class ConversationInfoActivity :
binding.guestAccessView.allowGuestsSwitch,
binding.guestAccessView.passwordProtectionSwitch,
binding.recordingConsentView.recordingConsentForConversationSwitch,
binding.lockConversationSwitch,
binding.notificationSettingsView.sensitiveConversationSwitch
binding.lockConversationSwitch
).forEach(viewThemeUtils.talk::colorSwitch)
}
}
@ -882,13 +817,16 @@ class ConversationInfoActivity :
private fun selectParticipantsToAdd() {
val bundle = Bundle()
val existingParticipants = ArrayList<AutocompleteUser>()
for (userItem in userItems) {
val user = AutocompleteUser(
userItem.model.calculatedActorId!!,
userItem.model.displayName,
userItem.model.calculatedActorType.name.lowercase()
)
existingParticipants.add(user)
if (userItem.model.calculatedActorType == USERS) {
val user = AutocompleteUser(
userItem.model.calculatedActorId!!,
userItem.model.displayName,
userItem.model.calculatedActorType.name.lowercase()
)
existingParticipants.add(user)
}
}
bundle.putBoolean(BundleKeys.KEY_ADD_PARTICIPANTS, true)
@ -1065,13 +1003,21 @@ class ConversationInfoActivity :
) {
binding.addParticipantsAction.visibility = GONE
binding.startGroupChat.visibility = VISIBLE
showDeleteAllMessagesOption(conversationCopy)
} else if (ConversationUtils.canModerate(conversationCopy, spreedCapabilities)) {
binding.addParticipantsAction.visibility = VISIBLE
showDeleteAllMessagesOption(conversationCopy)
if (hasSpreedFeatureCapability(
spreedCapabilities,
SpreedFeatures.CLEAR_HISTORY
) && conversationCopy.canDeleteConversation
) {
binding.clearConversationHistory.visibility = VISIBLE
} else {
binding.clearConversationHistory.visibility = GONE
}
showOptionsMenu()
} else {
binding.addParticipantsAction.visibility = GONE
if (ConversationUtils.isNoteToSelfConversation(conversation)) {
binding.notificationSettingsView.notificationSettings.visibility = VISIBLE
} else {
@ -1079,31 +1025,6 @@ class ConversationInfoActivity :
}
}
binding.notificationSettingsView.importantConversationSwitch.isChecked = conversation!!.hasImportant
binding.notificationSettingsView.notificationSettingsImportantConversation.setOnClickListener {
val isChecked = binding.notificationSettingsView.importantConversationSwitch.isChecked
binding.notificationSettingsView.importantConversationSwitch.isChecked = !isChecked
if (!isChecked) {
viewModel.markConversationAsImportant(
credentials,
conversationUser.baseUrl!!,
conversation?.token!!
)
} else {
viewModel.markConversationAsUnimportant(
credentials,
conversationUser.baseUrl!!,
conversation?.token!!
)
}
}
if (hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.IMPORTANT_CONVERSATIONS)) {
binding.notificationSettingsView.notificationSettingsImportantConversation.visibility = VISIBLE
} else {
binding.notificationSettingsView.notificationSettingsImportantConversation.visibility = GONE
}
if (!hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.ARCHIVE_CONVERSATIONS)) {
binding.archiveConversationBtn.visibility = GONE
binding.archiveConversationTextHint.visibility = GONE
@ -1303,19 +1224,6 @@ class ConversationInfoActivity :
}
}
fun showDeleteAllMessagesOption(conversationCopy: ConversationModel) {
if (hasSpreedFeatureCapability(
spreedCapabilities,
SpreedFeatures.CLEAR_HISTORY
) &&
conversationCopy.canDeleteConversation
) {
binding.clearConversationHistory.visibility = VISIBLE
} else {
binding.clearConversationHistory.visibility = GONE
}
}
private fun submitRecordingConsentChanges() {
val state = if (binding.recordingConsentView.recordingConsentForConversationSwitch.isChecked) {
RECORDING_CONSENT_REQUIRED_FOR_CONVERSATION
@ -1848,6 +1756,13 @@ class ConversationInfoActivity :
}
private fun setUpNotificationSettings(module: DatabaseStorageModule) {
binding.notificationSettingsView.notificationSettingsImportantConversation.setOnClickListener {
val isChecked = binding.notificationSettingsView.importantConversationSwitch.isChecked
binding.notificationSettingsView.importantConversationSwitch.isChecked = !isChecked
lifecycleScope.launch {
module.saveBoolean("important_conversation_switch", !isChecked)
}
}
binding.notificationSettingsView.notificationSettingsCallNotifications.setOnClickListener {
val isChecked = binding.notificationSettingsView.callNotificationsSwitch.isChecked
binding.notificationSettingsView.callNotificationsSwitch.isChecked = !isChecked
@ -1865,6 +1780,9 @@ class ConversationInfoActivity :
}
}
binding.notificationSettingsView.importantConversationSwitch.isChecked = module
.getBoolean("important_conversation_switch", false)
if (conversation!!.remoteServer.isNullOrEmpty()) {
binding.notificationSettingsView.notificationSettingsCallNotifications.visibility = VISIBLE
binding.notificationSettingsView.callNotificationsSwitch.isChecked = module

View File

@ -124,18 +124,6 @@ class ConversationInfoViewModel @Inject constructor(
val getConversationReadOnlyState: LiveData<SetConversationReadOnlyViewState>
get() = _getConversationReadOnlyState
@Suppress("PropertyName")
private val _markConversationAsImportantResult =
MutableLiveData<MarkConversationAsImportantViewState>(MarkConversationAsImportantViewState.None)
val markAsImportantResult: LiveData<MarkConversationAsImportantViewState>
get() = _markConversationAsImportantResult
@Suppress("PropertyName")
private val _markConversationAsUnimportantResult =
MutableLiveData<MarkConversationAsUnimportantViewState>(MarkConversationAsUnimportantViewState.None)
val markAsUnimportantResult: LiveData<MarkConversationAsUnimportantViewState>
get() = _markConversationAsUnimportantResult
private val _createRoomViewState = MutableLiveData<CreateRoomUIState>(CreateRoomUIState.None)
val createRoomViewState: LiveData<CreateRoomUIState>
get() = _createRoomViewState
@ -319,19 +307,14 @@ class ConversationInfoViewModel @Inject constructor(
}
}
@Suppress("Detekt.TooGenericExceptionCaught")
fun getProfileData(user: User, userId: String) {
val url = ApiUtils.getUrlForProfile(user.baseUrl!!, userId)
viewModelScope.launch {
try {
val profile = conversationsRepository.getProfile(user.getCredentials(), url)
if (profile != null) {
_getProfileViewState.value = GetProfileSuccessState(profile)
} else {
_getProfileViewState.value = GetProfileErrorState
}
} catch (e: Exception) {
Log.w(TAG, "Failed to get profile data (if not supported there wil be http405)", e)
val profile = conversationsRepository.getProfile(user.getCredentials(), url)
if (profile != null) {
_getProfileViewState.value = GetProfileSuccessState(profile)
} else {
_getProfileViewState.value = GetProfileErrorState
}
}
}
@ -373,34 +356,6 @@ class ConversationInfoViewModel @Inject constructor(
conversationsRepository.unarchiveConversation(user.getCredentials(), url)
}
@Suppress("Detekt.TooGenericExceptionCaught")
fun markConversationAsImportant(credentials: String, baseUrl: String, roomToken: String) {
viewModelScope.launch {
try {
val response = conversationsRepository.markConversationAsImportant(credentials, baseUrl, roomToken)
_markConversationAsImportantResult.value =
MarkConversationAsImportantViewState.Success(response.ocs?.meta?.statusCode!!)
} catch (exception: Exception) {
_markConversationAsImportantResult.value =
MarkConversationAsImportantViewState.Error(exception)
}
}
}
@Suppress("Detekt.TooGenericExceptionCaught")
fun markConversationAsUnimportant(credentials: String, baseUrl: String, roomToken: String) {
viewModelScope.launch {
try {
val response = conversationsRepository.markConversationAsUnImportant(credentials, baseUrl, roomToken)
_markConversationAsUnimportantResult.value =
MarkConversationAsUnimportantViewState.Success(response.ocs?.meta?.statusCode!!)
} catch (exception: Exception) {
_markConversationAsUnimportantResult.value =
MarkConversationAsUnimportantViewState.Error(exception)
}
}
}
@Suppress("Detekt.TooGenericExceptionCaught")
fun clearChatHistory(apiVersion: Int, roomToken: String) {
viewModelScope.launch {
@ -525,16 +480,4 @@ class ConversationInfoViewModel @Inject constructor(
data object Success : PasswordUiState()
data class Error(val exception: Exception) : PasswordUiState()
}
sealed class MarkConversationAsImportantViewState {
data object None : MarkConversationAsImportantViewState()
data class Success(val statusCode: Int) : MarkConversationAsImportantViewState()
data class Error(val exception: Exception) : MarkConversationAsImportantViewState()
}
sealed class MarkConversationAsUnimportantViewState {
data object None : MarkConversationAsUnimportantViewState()
data class Success(val statusCode: Int) : MarkConversationAsUnimportantViewState()
data class Error(val exception: Exception) : MarkConversationAsUnimportantViewState()
}
}

View File

@ -99,7 +99,7 @@ class ConversationInfoEditActivity : BaseActivity() {
binding = ActivityConversationInfoEditBinding.inflate(layoutInflater)
setupActionBar()
setContentView(binding.root)
initSystemBars()
setupSystemColors()
val extras: Bundle? = intent.extras

View File

@ -24,7 +24,8 @@ class ConversationInfoEditRepositoryImpl(
private val ncApi: NcApi,
private val ncApiCoroutines: NcApiCoroutines,
currentUserProvider: CurrentUserProviderNew
) : ConversationInfoEditRepository {
) :
ConversationInfoEditRepository {
val currentUser: User = currentUserProvider.currentUser.blockingGet()
val credentials: String = ApiUtils.getCredentials(currentUser.username, currentUser.token)!!
@ -52,11 +53,12 @@ class ConversationInfoEditRepositoryImpl(
).map { ConversationModel.mapToConversationModel(it.ocs?.data!!, user) }
}
override fun deleteConversationAvatar(user: User, roomToken: String): Observable<ConversationModel> =
ncApi.deleteConversationAvatar(
override fun deleteConversationAvatar(user: User, roomToken: String): Observable<ConversationModel> {
return ncApi.deleteConversationAvatar(
credentials,
ApiUtils.getUrlForConversationAvatar(1, user.baseUrl!!, roomToken)
).map { ConversationModel.mapToConversationModel(it.ocs?.data!!, user) }
}
override suspend fun renameConversation(roomToken: String, newRoomName: String): GenericOverall {
val apiVersion = ApiUtils.getConversationApiVersion(currentUser, intArrayOf(ApiUtils.API_V4, ApiUtils.API_V1))
@ -75,8 +77,8 @@ class ConversationInfoEditRepositoryImpl(
override suspend fun setConversationDescription(
roomToken: String,
conversationDescription: String?
): GenericOverall =
ncApiCoroutines.setConversationDescription(
): GenericOverall {
return ncApiCoroutines.setConversationDescription(
credentials,
ApiUtils.getUrlForConversationDescription(
apiVersion,
@ -85,4 +87,5 @@ class ConversationInfoEditRepositoryImpl(
),
conversationDescription
)
}
}

View File

@ -16,6 +16,7 @@ import android.animation.AnimatorInflater
import android.annotation.SuppressLint
import android.app.SearchManager
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.drawable.Drawable
@ -40,9 +41,6 @@ import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.SearchView
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.core.graphics.drawable.toDrawable
import androidx.core.net.toUri
import androidx.core.os.bundleOf
@ -70,8 +68,8 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import com.nextcloud.android.common.ui.theme.utils.ColorRole
import com.nextcloud.talk.R
import com.nextcloud.talk.account.BrowserLoginActivity
import com.nextcloud.talk.account.ServerSelectionActivity
import com.nextcloud.talk.account.WebViewLoginActivity
import com.nextcloud.talk.activities.BaseActivity
import com.nextcloud.talk.activities.CallActivity
import com.nextcloud.talk.activities.MainActivity
@ -81,7 +79,6 @@ import com.nextcloud.talk.adapters.items.GenericTextHeaderItem
import com.nextcloud.talk.adapters.items.LoadMoreResultsItem
import com.nextcloud.talk.adapters.items.MessageResultItem
import com.nextcloud.talk.adapters.items.MessagesTextHeaderItem
import com.nextcloud.talk.adapters.items.SpacerItem
import com.nextcloud.talk.api.NcApi
import com.nextcloud.talk.application.NextcloudTalkApplication
import com.nextcloud.talk.arbitrarystorage.ArbitraryStorageManager
@ -117,9 +114,6 @@ import com.nextcloud.talk.ui.dialog.ChooseAccountShareToDialogFragment
import com.nextcloud.talk.ui.dialog.ContextChatCompose
import com.nextcloud.talk.ui.dialog.ConversationsListBottomDialog
import com.nextcloud.talk.ui.dialog.FilterConversationFragment
import com.nextcloud.talk.ui.dialog.FilterConversationFragment.Companion.ARCHIVE
import com.nextcloud.talk.ui.dialog.FilterConversationFragment.Companion.MENTION
import com.nextcloud.talk.ui.dialog.FilterConversationFragment.Companion.UNREAD
import com.nextcloud.talk.users.UserManager
import com.nextcloud.talk.utils.ApiUtils
import com.nextcloud.talk.utils.BrandingUtils
@ -163,6 +157,7 @@ import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import retrofit2.HttpException
import java.io.File
import java.util.Objects
import java.util.concurrent.TimeUnit
import javax.inject.Inject
@ -213,7 +208,7 @@ class ConversationsListActivity :
private var adapter: FlexibleAdapter<AbstractFlexibleItem<*>>? = null
private var conversationItems: MutableList<AbstractFlexibleItem<*>> = ArrayList()
private var conversationItemsWithHeader: MutableList<AbstractFlexibleItem<*>> = ArrayList()
private var searchableConversationItems: MutableList<AbstractFlexibleItem<*>> = ArrayList()
private val searchableConversationItems: MutableList<AbstractFlexibleItem<*>> = ArrayList()
private var filterableConversationItems: MutableList<AbstractFlexibleItem<*>> = ArrayList()
private var nearFutureEventConversationItems: MutableList<AbstractFlexibleItem<*>> = ArrayList()
private var searchItem: MenuItem? = null
@ -237,9 +232,9 @@ class ConversationsListActivity :
private var searchViewDisposable: Disposable? = null
private var filterState =
mutableMapOf(
MENTION to false,
UNREAD to false,
ARCHIVE to false,
FilterConversationFragment.MENTION to false,
FilterConversationFragment.UNREAD to false,
FilterConversationFragment.ARCHIVE to false,
FilterConversationFragment.DEFAULT to true
)
val searchBehaviorSubject = BehaviorSubject.createDefault(false)
@ -266,11 +261,9 @@ class ConversationsListActivity :
binding = ActivityConversationsBinding.inflate(layoutInflater)
setupActionBar()
setContentView(binding.root)
initSystemBars()
viewThemeUtils.material.themeSearchCardView(binding.searchToolbar)
viewThemeUtils.material.colorMaterialButtonContent(binding.menuButton, ColorRole.ON_SURFACE)
viewThemeUtils.platform.colorTextView(binding.searchText, ColorRole.ON_SURFACE_VARIANT)
setupSystemColors()
viewThemeUtils.material.themeCardView(binding.searchToolbar)
viewThemeUtils.material.themeSearchBarText(binding.searchText)
forwardMessage = intent.getBooleanExtra(KEY_FORWARD_MSG_FLAG, false)
onBackPressedDispatcher.addCallback(this, onBackPressedCallback)
@ -295,13 +288,14 @@ class ConversationsListActivity :
override fun onResume() {
super.onResume()
// actionBar?.show()
if (adapter == null) {
adapter = FlexibleAdapter(conversationItems, this, true)
addEmptyItemForEdgeToEdgeIfNecessary()
} else {
binding.loadingContent.visibility = View.GONE
}
adapter?.addListener(this)
adapter!!.addListener(this)
prepareViews()
showNotificationWarning()
@ -317,10 +311,8 @@ class ConversationsListActivity :
showServerEOLDialog()
return
}
currentUser?.capabilities?.spreedCapability?.let { spreedCapabilities ->
if (isUnifiedSearchAvailable(spreedCapabilities)) {
searchHelper = MessageSearchHelper(unifiedSearchRepository)
}
if (isUnifiedSearchAvailable(currentUser!!.capabilities!!.spreedCapability!!)) {
searchHelper = MessageSearchHelper(unifiedSearchRepository)
}
credentials = ApiUtils.getCredentials(currentUser!!.username, currentUser!!.token)
@ -345,24 +337,6 @@ class ConversationsListActivity :
showSearchOrToolbar()
}
override fun onPause() {
super.onPause()
val firstVisible = layoutManager?.findFirstVisibleItemPosition() ?: 0
val firstItem = adapter?.getItem(firstVisible)
val firstTop = (firstItem as ConversationItem).mHolder?.itemView?.top
val firstOffset = firstTop?.minus(CONVERSATION_ITEM_HEIGHT) ?: 0
appPreferences.setConversationListPositionAndOffset(firstVisible, firstOffset)
}
// if edge to edge is used, add an empty item at the bottom of the list
@Suppress("MagicNumber")
private fun addEmptyItemForEdgeToEdgeIfNecessary() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
adapter?.addScrollableFooter(SpacerItem(200))
}
}
@Suppress("LongMethod")
private fun initObservers() {
this.lifecycleScope.launch {
@ -432,13 +406,6 @@ class ConversationsListActivity :
conversationsListViewModel.getRoomsFlow
.onEach { list ->
setConversationList(list)
val noteToSelf = list
.firstOrNull { ConversationUtils.isNoteToSelfConversation(it) }
val isNoteToSelfAvailable = noteToSelf != null
handleNoteToSelfShortcut(isNoteToSelfAvailable, noteToSelf?.token ?: "")
val pair = appPreferences.conversationListPositionAndOffset
layoutManager?.scrollToPositionWithOffset(pair.first, pair.second)
}.collect()
}
@ -491,13 +458,7 @@ class ConversationsListActivity :
userItems.add(contactItem)
}
val list = searchableConversationItems.filter {
it !is ContactItem
}.toMutableList()
list.addAll(userItems)
searchableConversationItems = list
searchableConversationItems.addAll(userItems)
}
else -> {}
@ -555,29 +516,6 @@ class ConversationsListActivity :
}
}
private fun handleNoteToSelfShortcut(noteToSelfAvailable: Boolean, noteToSelfToken: String) {
if (noteToSelfAvailable) {
val bundle = Bundle()
bundle.putString(KEY_ROOM_TOKEN, noteToSelfToken)
bundle.putBoolean(BundleKeys.KEY_FOCUS_INPUT, true)
val intent = Intent(context, ChatActivity::class.java)
intent.putExtras(bundle)
intent.action = Intent.ACTION_VIEW
val openNotesString = resources.getString(R.string.open_notes)
val shortcut = ShortcutInfoCompat.Builder(context, NOTE_TO_SELF_SHORTCUT_ID)
.setShortLabel(openNotesString)
.setLongLabel(openNotesString)
.setIcon(IconCompat.createWithResource(context, R.drawable.ic_pencil_grey600_24dp))
.setIntent(intent)
.build()
ShortcutManagerCompat.pushDynamicShortcut(context, shortcut)
} else {
ShortcutManagerCompat.removeDynamicShortcuts(context, listOf(NOTE_TO_SELF_SHORTCUT_ID))
}
}
private fun setConversationList(list: List<ConversationModel>) {
// Update Conversations
conversationItems.clear()
@ -585,29 +523,26 @@ class ConversationsListActivity :
nearFutureEventConversationItems.clear()
for (conversation in list) {
if (!isFutureEvent(conversation) && !conversation.hasArchived) {
if (!isFutureEvent(conversation)) {
addToNearFutureEventConversationItems(conversation)
}
addToConversationItems(conversation)
}
getFilterStates()
val noFiltersActive = !(
filterState[MENTION] == true ||
filterState[UNREAD] == true ||
filterState[ARCHIVE] == true
)
sortConversations(conversationItems)
sortConversations(conversationItemsWithHeader)
sortConversations(nearFutureEventConversationItems)
if (noFiltersActive && searchBehaviorSubject.value == false) {
if (!hasFilterEnabled() && searchBehaviorSubject.value == false) {
adapter?.updateDataSet(nearFutureEventConversationItems, false)
} else {
applyFilter()
// Filter Conversations
if (!hasFilterEnabled()) {
filterableConversationItems = conversationItems
}
filterConversation()
adapter?.updateDataSet(filterableConversationItems, false)
}
Handler().postDelayed({ checkToShowUnreadBubble() }, UNREAD_BUBBLE_DELAY.toLong())
// Fetch Open Conversations
@ -616,14 +551,9 @@ class ConversationsListActivity :
intArrayOf(ApiUtils.API_V4, ApiUtils.API_V3, 1)
)
fetchOpenConversations(apiVersion)
}
fun applyFilter() {
if (!hasFilterEnabled()) {
filterableConversationItems = conversationItems
}
filterConversation()
adapter?.updateDataSet(filterableConversationItems, false)
// Get users
fetchUsers()
}
private fun hasFilterEnabled(): Boolean {
@ -655,35 +585,32 @@ class ConversationsListActivity :
nearFutureEventConversationItems.add(conversationItem)
}
fun getFilterStates() {
val accountId = UserIdUtils.getIdForUser(currentUser)
filterState[UNREAD] = (
arbitraryStorageManager.getStorageSetting(
accountId,
UNREAD,
""
).blockingGet()?.value ?: ""
) == "true"
filterState[MENTION] = (
arbitraryStorageManager.getStorageSetting(
accountId,
MENTION,
""
).blockingGet()?.value ?: ""
) == "true"
filterState[ARCHIVE] = (
arbitraryStorageManager.getStorageSetting(
accountId,
ARCHIVE,
""
).blockingGet()?.value ?: ""
) == "true"
}
fun filterConversation() {
getFilterStates()
val accountId = UserIdUtils.getIdForUser(currentUser)
filterState[FilterConversationFragment.UNREAD] = (
arbitraryStorageManager.getStorageSetting(
accountId,
FilterConversationFragment.UNREAD,
""
).blockingGet()?.value ?: ""
) == "true"
filterState[FilterConversationFragment.MENTION] = (
arbitraryStorageManager.getStorageSetting(
accountId,
FilterConversationFragment.MENTION,
""
).blockingGet()?.value ?: ""
) == "true"
filterState[FilterConversationFragment.ARCHIVE] = (
arbitraryStorageManager.getStorageSetting(
accountId,
FilterConversationFragment.ARCHIVE,
""
).blockingGet()?.value ?: ""
) == "true"
val newItems: MutableList<AbstractFlexibleItem<*>> = ArrayList()
val items = conversationItems
for (i in items) {
@ -693,7 +620,7 @@ class ConversationsListActivity :
}
}
val archiveFilterOn = filterState[ARCHIVE] == true
val archiveFilterOn = filterState[FilterConversationFragment.ARCHIVE] ?: false
if (archiveFilterOn && newItems.isEmpty()) {
binding.noArchivedConversationLayout.visibility = View.VISIBLE
} else {
@ -715,7 +642,7 @@ class ConversationsListActivity :
for ((k, v) in filterState) {
if (v) {
when (k) {
MENTION -> result = (result && conversation.unreadMention) ||
FilterConversationFragment.MENTION -> result = (result && conversation.unreadMention) ||
(
result &&
(
@ -725,10 +652,10 @@ class ConversationsListActivity :
(conversation.unreadMessages > 0)
)
UNREAD -> result = result && (conversation.unreadMessages > 0)
FilterConversationFragment.UNREAD -> result = result && (conversation.unreadMessages > 0)
FilterConversationFragment.DEFAULT -> {
result = if (filterState[ARCHIVE] == true) {
result = if (filterState[FilterConversationFragment.ARCHIVE] == true) {
result && conversation.hasArchived
} else {
result && !conversation.hasArchived
@ -808,7 +735,7 @@ class ConversationsListActivity :
}
private fun initSearchView() {
val searchManager = getSystemService(SEARCH_SERVICE) as SearchManager?
val searchManager = getSystemService(Context.SEARCH_SERVICE) as SearchManager?
if (searchItem != null) {
searchView = MenuItemCompat.getActionView(searchItem) as SearchView
viewThemeUtils.talk.themeSearchView(searchView!!)
@ -987,7 +914,8 @@ class ConversationsListActivity :
} else {
showToolbar()
}
initSystemBars()
colorizeStatusBar()
colorizeNavigationBar()
}
}
@ -1223,8 +1151,8 @@ class ConversationsListActivity :
}
}
private fun fetchUsers(query: String = "") {
contactsViewModel.getContactsFromSearchParams(query)
private fun fetchUsers() {
contactsViewModel.getContactsFromSearchParams()
}
private fun handleHttpExceptions(throwable: Throwable) {
@ -1269,7 +1197,7 @@ class ConversationsListActivity :
})
binding.recyclerView.setOnTouchListener { v: View, _: MotionEvent? ->
if (!isDestroyed) {
val imm = getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager
val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(v.windowToken, 0)
}
false
@ -1424,15 +1352,12 @@ class ConversationsListActivity :
clearMessageSearchResults()
binding.noArchivedConversationLayout.visibility = View.GONE
fetchUsers(filter)
if (hasFilterEnabled()) {
adapter?.updateDataSet(conversationItems)
adapter?.setFilter(filter)
adapter?.filterItems()
adapter?.updateDataSet(filterableConversationItems)
} else {
adapter?.updateDataSet(searchableConversationItems)
adapter?.setFilter(filter)
adapter?.filterItems()
}
@ -1447,10 +1372,9 @@ class ConversationsListActivity :
private fun resetSearchResults() {
clearMessageSearchResults()
adapter?.updateDataSet(conversationItems)
adapter?.setFilter("")
adapter?.filterItems()
val archiveFilterOn = filterState[ARCHIVE] == true
val archiveFilterOn = filterState[FilterConversationFragment.ARCHIVE] ?: false
if (archiveFilterOn && adapter!!.isEmpty) {
binding.noArchivedConversationLayout.visibility = View.VISIBLE
} else {
@ -1497,9 +1421,10 @@ class ConversationsListActivity :
override fun onItemClick(view: View, position: Int): Boolean {
val item = adapter?.getItem(position)
if (item != null) {
when (item) {
is MessageResultItem -> {
val token = item.messageEntry.conversationToken
when (item.itemViewType) {
MessageResultItem.VIEW_TYPE -> {
val messageItem: MessageResultItem = item as MessageResultItem
val token = messageItem.messageEntry.conversationToken
val conversationName = (
conversationItems.first {
(it is ConversationItem) && it.model.token == token
@ -1513,26 +1438,27 @@ class ConversationsListActivity :
bundle.putString(BundleKeys.KEY_CREDENTIALS, credentials!!)
bundle.putString(BundleKeys.KEY_BASE_URL, currentUser!!.baseUrl)
bundle.putString(KEY_ROOM_TOKEN, token)
bundle.putString(BundleKeys.KEY_MESSAGE_ID, item.messageEntry.messageId)
bundle.putString(BundleKeys.KEY_MESSAGE_ID, messageItem.messageEntry.messageId)
bundle.putString(BundleKeys.KEY_CONVERSATION_NAME, conversationName)
ContextChatCompose(bundle).GetDialogView(shouldDismiss, context)
}
}
}
is LoadMoreResultsItem -> {
LoadMoreResultsItem.VIEW_TYPE -> {
loadMoreMessages()
}
is ConversationItem -> {
handleConversation(item.model)
ConversationItem.VIEW_TYPE -> {
handleConversation((Objects.requireNonNull(item) as ConversationItem).model)
}
is ContactItem -> {
ContactItem.VIEW_TYPE -> {
val contact = item as ContactItem
contactsViewModel.createRoom(
ROOM_TYPE_ONE_ONE,
null,
item.model.actorId!!,
contact.model.actorId!!,
null
)
}
@ -1859,7 +1785,7 @@ class ConversationsListActivity :
val callsChannelNotEnabled = !NotificationUtils.isCallsNotificationChannelEnabled(this)
val serverNotificationAppInstalled =
currentUser?.capabilities?.notificationsCapability?.features?.isNotEmpty() == true
currentUser?.capabilities?.notificationsCapability?.features?.isNotEmpty() ?: false
val settingsOfUserAreWrong = notificationPermissionNotGranted ||
batteryOptimizationNotIgnored ||
@ -1886,6 +1812,7 @@ class ConversationsListActivity :
val bundle = Bundle()
bundle.putString(KEY_ROOM_TOKEN, selectedConversation!!.token)
// bundle.putString(KEY_ROOM_ID, selectedConversation!!.roomId)
bundle.putString(KEY_SHARED_TEXT, textToPaste)
if (selectedMessageId != null) {
bundle.putString(BundleKeys.KEY_MESSAGE_ID, selectedMessageId)
@ -1963,7 +1890,7 @@ class ConversationsListActivity :
deleteUserAndRestartApp()
}
.setNegativeButton(R.string.nc_settings_reauthorize) { _, _ ->
val intent = Intent(context, BrowserLoginActivity::class.java)
val intent = Intent(context, WebViewLoginActivity::class.java)
val bundle = Bundle()
bundle.putString(BundleKeys.KEY_BASE_URL, currentUser!!.baseUrl!!)
bundle.putBoolean(BundleKeys.KEY_REAUTHORIZE_ACCOUNT, true)
@ -1987,9 +1914,9 @@ class ConversationsListActivity :
WorkManager.getInstance(applicationContext).enqueue(accountRemovalWork)
WorkManager.getInstance(context).getWorkInfoByIdLiveData(accountRemovalWork.id)
.observeForever { workInfo: WorkInfo? ->
.observeForever { workInfo: WorkInfo ->
when (workInfo?.state) {
when (workInfo.state) {
WorkInfo.State.SUCCEEDED -> {
val text = String.format(
context.resources.getString(R.string.nc_deleted_user),
@ -2190,8 +2117,8 @@ class ConversationsListActivity :
}
fun updateFilterState(mention: Boolean, unread: Boolean) {
filterState[MENTION] = mention
filterState[UNREAD] = unread
filterState[FilterConversationFragment.MENTION] = mention
filterState[FilterConversationFragment.UNREAD] = unread
}
fun setFilterableItems(items: MutableList<AbstractFlexibleItem<*>>) {
@ -2205,7 +2132,7 @@ class ConversationsListActivity :
binding.filterConversationsButton.let {
viewThemeUtils.platform.colorImageView(
it,
ColorRole.ON_SURFACE
ColorRole.ON_SURFACE_VARIANT
)
}
}
@ -2232,7 +2159,5 @@ class ConversationsListActivity :
const val ROOM_TYPE_ONE_ONE = "1"
private const val SIXTEEN_HOURS_IN_SECONDS: Long = 57600
const val LONG_1000: Long = 1000
private const val NOTE_TO_SELF_SHORTCUT_ID = "NOTE_TO_SELF_SHORTCUT_ID"
private const val CONVERSATION_ITEM_HEIGHT = 44
}
}

View File

@ -26,7 +26,8 @@ import javax.inject.Inject
class ConversationsListViewModel @Inject constructor(
private val repository: OfflineConversationsRepository,
var userManager: UserManager
) : ViewModel() {
) :
ViewModel() {
@Inject
lateinit var invitationsRepository: InvitationsRepository

View File

@ -42,8 +42,9 @@ public class DatabaseModule {
@Provides
@Singleton
public TalkDatabase provideTalkDatabase(@NonNull final Context context) {
return TalkDatabase.getInstance(context);
public TalkDatabase provideTalkDatabase(@NonNull final Context context,
@NonNull final AppPreferences appPreferences) {
return TalkDatabase.getInstance(context, appPreferences);
}
@Provides

View File

@ -20,17 +20,24 @@ import dagger.Provides
class ManagerModule {
@Provides
fun provideMediaRecorderManager(): MediaRecorderManager = MediaRecorderManager()
fun provideMediaRecorderManager(): MediaRecorderManager {
return MediaRecorderManager()
}
@Provides
fun provideAudioRecorderManager(): AudioRecorderManager = AudioRecorderManager()
fun provideAudioRecorderManager(): AudioRecorderManager {
return AudioRecorderManager()
}
@Provides
fun provideMediaPlayerManager(preferences: AppPreferences): MediaPlayerManager =
MediaPlayerManager().apply {
fun provideMediaPlayerManager(preferences: AppPreferences): MediaPlayerManager {
return MediaPlayerManager().apply {
appPreferences = preferences
}
}
@Provides
fun provideAudioFocusManager(context: Context): AudioFocusRequestManager = AudioFocusRequestManager(context)
fun provideAudioFocusManager(context: Context): AudioFocusRequestManager {
return AudioFocusRequestManager(context)
}
}

View File

@ -136,7 +136,9 @@ class RepositoryModule {
ncApi: NcApi,
ncApiCoroutines: NcApiCoroutines,
userProvider: CurrentUserProviderNew
): ConversationInfoEditRepository = ConversationInfoEditRepositoryImpl(ncApi, ncApiCoroutines, userProvider)
): ConversationInfoEditRepository {
return ConversationInfoEditRepositoryImpl(ncApi, ncApiCoroutines, userProvider)
}
@Provides
fun provideInvitationsRepository(ncApi: NcApi): InvitationsRepository = InvitationsRepositoryImpl(ncApi)

View File

@ -20,13 +20,19 @@ import dagger.Reusable
class UtilsModule {
@Provides
@Reusable
fun providePermissionUtil(context: Context): PlatformPermissionUtil = PlatformPermissionUtilImpl(context)
fun providePermissionUtil(context: Context): PlatformPermissionUtil {
return PlatformPermissionUtilImpl(context)
}
@Provides
@Reusable
fun provideDateUtils(context: Context): DateUtils = DateUtils(context)
fun provideDateUtils(context: Context): DateUtils {
return DateUtils(context)
}
@Provides
@Reusable
fun provideMessageUtils(context: Context): MessageUtils = MessageUtils(context)
fun provideMessageUtils(context: Context): MessageUtils {
return MessageUtils(context)
}
}

View File

@ -50,18 +50,6 @@ interface ChatMessagesDao {
)
fun getTempMessagesForConversation(internalConversationId: String): Flow<List<ChatMessageEntity>>
@Query(
"""
SELECT *
FROM ChatMessages
WHERE internalConversationId = :internalConversationId
AND isTemporary = 1
AND sendStatus != 'SENT_PENDING_ACK'
ORDER BY timestamp DESC, id DESC
"""
)
fun getTempUnsentMessagesForConversation(internalConversationId: String): Flow<List<ChatMessageEntity>>
@Query(
"""
SELECT *
@ -72,7 +60,7 @@ interface ChatMessagesDao {
ORDER BY timestamp DESC, id DESC
"""
)
fun getTempMessageForConversation(internalConversationId: String, referenceId: String): Flow<ChatMessageEntity?>
fun getTempMessageForConversation(internalConversationId: String, referenceId: String): Flow<ChatMessageEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertChatMessages(chatMessages: List<ChatMessageEntity>)

View File

@ -68,7 +68,7 @@ fun ChatMessageEntity.asModel() =
isDeleted = deleted,
referenceId = referenceId,
isTemporary = isTemporary,
sendStatus = sendStatus,
sendingFailed = sendingFailed,
readStatus = ReadStatus.NONE,
silent = silent
)

View File

@ -62,8 +62,7 @@ fun ConversationModel.asEntity() =
remoteServer = remoteServer,
remoteToken = remoteToken,
hasArchived = hasArchived,
hasSensitive = hasSensitive,
hasImportant = hasImportant
hasSensitive = hasSensitive
)
fun ConversationEntity.asModel() =
@ -116,8 +115,7 @@ fun ConversationEntity.asModel() =
remoteServer = remoteServer,
remoteToken = remoteToken,
hasArchived = hasArchived,
hasSensitive = hasSensitive,
hasImportant = hasImportant
hasSensitive = hasSensitive
)
fun Conversation.asEntity(accountId: Long) =
@ -169,6 +167,5 @@ fun Conversation.asEntity(accountId: Long) =
remoteServer = remoteServer,
remoteToken = remoteToken,
hasArchived = hasArchived,
hasSensitive = hasSensitive,
hasImportant = hasImportant
hasSensitive = hasSensitive
)

View File

@ -64,7 +64,7 @@ data class ChatMessageEntity(
@ColumnInfo(name = "reactions") var reactions: LinkedHashMap<String, Int>? = null,
@ColumnInfo(name = "reactionsSelf") var reactionsSelf: ArrayList<String>? = null,
@ColumnInfo(name = "referenceId") var referenceId: String? = null,
@ColumnInfo(name = "sendStatus") var sendStatus: SendStatus? = null,
@ColumnInfo(name = "sendingFailed") var sendingFailed: Boolean = false,
@ColumnInfo(name = "silent") var silent: Boolean = false,
@ColumnInfo(name = "systemMessage") var systemMessageType: ChatMessage.SystemMessageType,
@ColumnInfo(name = "timestamp") var timestamp: Long = 0

View File

@ -95,8 +95,7 @@ data class ConversationEntity(
@ColumnInfo(name = "unreadMentionDirect") var unreadMentionDirect: Boolean,
@ColumnInfo(name = "unreadMessages") var unreadMessages: Int = 0,
@ColumnInfo(name = "hasArchived") var hasArchived: Boolean = false,
@ColumnInfo(name = "hasSensitive") var hasSensitive: Boolean = false,
@ColumnInfo(name = "hasImportant") var hasImportant: Boolean = false
@ColumnInfo(name = "hasSensitive") var hasSensitive: Boolean = false
// missing/not needed: attendeeId
// missing/not needed: attendeePin
// missing/not needed: attendeePermissions

Some files were not shown because too many files have changed in this diff Show More