Initial import of f7cloud-mobile native Android app.

Kotlin/Compose client for F7cloud (mail, files, talk, calendar, contacts, tasks, support).
Current version: 0.5.113 (build 121).
This commit is contained in:
F7cloud Mobile
2026-07-07 12:05:18 +03:00
commit fd17df80a8
1789 changed files with 246889 additions and 0 deletions
@@ -0,0 +1,35 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2017-2019 Mario Danic <mario@lovelyhq.com>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk;
import android.content.Context;
import org.junit.Test;
import org.junit.runner.RunWith;
import androidx.test.InstrumentationRegistry;
import androidx.test.runner.AndroidJUnit4;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
/**
* Instrumented test, which will execute on an Android device.
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
@RunWith(AndroidJUnit4.class)
public class ExampleInstrumentedTest {
@Test
public void useAppContext() throws Exception {
// Context of the app under test.
Context appContext = InstrumentationRegistry.getTargetContext();
assertNotNull(appContext.getPackageName());
assertTrue("The package name must start with 'com.nextcloud.talk2'", appContext.getPackageName().startsWith("com.nextcloud.talk2"));
}
}
@@ -0,0 +1,48 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2022 Andy Scherzinger <info@andy-scherzinger.de>
* SPDX-FileCopyrightText: 2021 Tobias Kaminsky <tobias@kaminsky.me>
* SPDX-FileCopyrightText: 2021 F7cloud
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.activities
import androidx.test.espresso.intent.rule.IntentsTestRule
import ru.f7cloud.talk.users.UserManager
import org.junit.Assert.assertNotNull
import org.junit.Rule
import org.junit.Test
class MainActivityTest {
@get:Rule
val activityRule: IntentsTestRule<MainActivity> = IntentsTestRule(
MainActivity::class.java,
true,
false
)
@Test
fun login() {
val sut = activityRule.launchActivity(null)
val user = sut.userManager.storeProfile(
"test",
UserManager.UserAttributes(
null,
serverUrl = "http://server/nc",
currentUser = true,
userId = "test",
token = "test",
displayName = "Test Name",
pushConfigurationState = null,
capabilities = null,
serverVersion = null,
certificateAlias = null,
externalSignalingServer = null
)
).blockingGet()
assertNotNull("Error creating user", user)
}
}
@@ -0,0 +1,393 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2024 Marcel Hibbe <dev@mhibbe.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.data.database.dao
import android.content.Context
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.runner.AndroidJUnit4
import ru.f7cloud.talk.data.database.model.ChatBlockEntity
import ru.f7cloud.talk.data.database.model.ConversationEntity
import ru.f7cloud.talk.data.source.local.TalkDatabase
import ru.f7cloud.talk.data.user.UsersDao
import ru.f7cloud.talk.data.user.model.UserEntity
import ru.f7cloud.talk.models.json.conversations.ConversationEnums
import ru.f7cloud.talk.models.json.participants.Participant
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.lang.Boolean
import kotlin.Long
import kotlin.String
@RunWith(AndroidJUnit4::class)
class ChatBlocksDaoTest {
private lateinit var usersDao: UsersDao
private lateinit var conversationsDao: ConversationsDao
private lateinit var chatBlocksDao: ChatBlocksDao
private lateinit var db: TalkDatabase
private val tag = ChatBlocksDaoTest::class.java.simpleName
@Before
fun createDb() {
val context = ApplicationProvider.getApplicationContext<Context>()
db = Room.inMemoryDatabaseBuilder(
context,
TalkDatabase::class.java
).build()
usersDao = db.usersDao()
conversationsDao = db.conversationsDao()
chatBlocksDao = db.chatBlocksDao()
}
@After
fun closeDb() = db.close()
@Test
fun testGetChatBlocksContainingMessageId() =
runTest {
val user = createUserEntity("account1", "Account 1")
usersDao.saveUser(user)
val account1 = usersDao.getUserWithUserId("account1").blockingGet()
conversationsDao.upsertConversations(
accountId = user.id,
listOf(
createConversationEntity(
accountId = account1.id,
"abc",
roomName = "Conversation One"
),
createConversationEntity(
accountId = account1.id,
"def",
roomName = "Conversation Two"
)
)
)
val conversation1 = conversationsDao.getConversationsForUser(account1.id).first()[0]
val chatBlock1 = ChatBlockEntity(
internalConversationId = conversation1.internalId,
accountId = conversation1.accountId,
token = conversation1.token,
threadId = 123,
oldestMessageId = 50,
newestMessageId = 60,
hasHistory = true
)
val chatBlock2 = ChatBlockEntity(
internalConversationId = conversation1.internalId,
accountId = conversation1.accountId,
token = conversation1.token,
threadId = 123,
oldestMessageId = 10,
newestMessageId = 20,
hasHistory = true
)
val chatBlock3 = ChatBlockEntity(
internalConversationId = conversation1.internalId,
accountId = conversation1.accountId,
token = conversation1.token,
threadId = null,
oldestMessageId = 50,
newestMessageId = 60,
hasHistory = true
)
chatBlocksDao.upsertChatBlock(chatBlock1)
chatBlocksDao.upsertChatBlock(chatBlock2)
chatBlocksDao.upsertChatBlock(chatBlock3)
val chatBlocksOfThread = chatBlocksDao.getChatBlocksContainingMessageId(
internalConversationId = conversation1.internalId,
threadId = 123,
messageId = 55
)
assertEquals(1, chatBlocksOfThread.first().size)
}
@Test
fun testGetConnectedChatBlocks() =
runTest {
val user = createUserEntity("account1", "Account 1")
usersDao.saveUser(user)
val account1 = usersDao.getUserWithUserId("account1").blockingGet()
conversationsDao.upsertConversations(
account1.id,
listOf(
createConversationEntity(
accountId = account1.id,
"abc",
roomName = "Conversation One"
),
createConversationEntity(
accountId = account1.id,
"def",
roomName = "Conversation Two"
)
)
)
val conversation1 = conversationsDao.getConversationsForUser(account1.id).first()[0]
val conversation2 = conversationsDao.getConversationsForUser(account1.id).first()[1]
val searchedChatBlock = ChatBlockEntity(
internalConversationId = conversation1.internalId,
accountId = conversation1.accountId,
token = conversation1.token,
threadId = null,
oldestMessageId = 50,
newestMessageId = 60,
hasHistory = true
)
val chatBlockTooOld = ChatBlockEntity(
internalConversationId = conversation1.internalId,
accountId = conversation1.accountId,
token = conversation1.token,
threadId = null,
oldestMessageId = 10,
newestMessageId = 20,
hasHistory = true
)
val chatBlockOverlap1 = ChatBlockEntity(
internalConversationId = conversation1.internalId,
accountId = conversation1.accountId,
token = conversation1.token,
threadId = null,
oldestMessageId = 45,
newestMessageId = 55,
hasHistory = true
)
val chatBlockWithin = ChatBlockEntity(
internalConversationId = conversation1.internalId,
accountId = conversation1.accountId,
token = conversation1.token,
threadId = null,
oldestMessageId = 52,
newestMessageId = 58,
hasHistory = true
)
val chatBlockOverall = ChatBlockEntity(
internalConversationId = conversation1.internalId,
accountId = conversation1.accountId,
token = conversation1.token,
threadId = null,
oldestMessageId = 1,
newestMessageId = 99,
hasHistory = true
)
val chatBlockOverlap2 = ChatBlockEntity(
internalConversationId = conversation1.internalId,
accountId = conversation1.accountId,
token = conversation1.token,
threadId = null,
oldestMessageId = 59,
newestMessageId = 70,
hasHistory = true
)
val chatBlockTooNew = ChatBlockEntity(
internalConversationId = conversation1.internalId,
accountId = conversation1.accountId,
token = conversation1.token,
threadId = null,
oldestMessageId = 80,
newestMessageId = 90,
hasHistory = true
)
val chatBlockWithinButOtherConversation = ChatBlockEntity(
internalConversationId = conversation2.internalId,
accountId = conversation2.accountId,
token = conversation2.token,
threadId = null,
oldestMessageId = 53,
newestMessageId = 57,
hasHistory = true
)
chatBlocksDao.upsertChatBlock(searchedChatBlock)
chatBlocksDao.upsertChatBlock(chatBlockTooOld)
chatBlocksDao.upsertChatBlock(chatBlockOverlap1)
chatBlocksDao.upsertChatBlock(chatBlockWithin)
chatBlocksDao.upsertChatBlock(chatBlockOverall)
chatBlocksDao.upsertChatBlock(chatBlockOverlap2)
chatBlocksDao.upsertChatBlock(chatBlockTooNew)
chatBlocksDao.upsertChatBlock(chatBlockWithinButOtherConversation)
val results = chatBlocksDao.getConnectedChatBlocks(
internalConversationId = conversation1.internalId,
threadId = null,
oldestMessageId = searchedChatBlock.oldestMessageId,
newestMessageId = searchedChatBlock.newestMessageId
)
assertEquals(5, results.first().size)
}
@Test
fun testGetConnectedChatBlocksWithThreadsScenario() =
runTest {
val user = createUserEntity("account1", "Account 1")
usersDao.saveUser(user)
val account1 = usersDao.getUserWithUserId("account1").blockingGet()
conversationsDao.upsertConversations(
account1.id,
listOf(
createConversationEntity(
accountId = account1.id,
"abc",
roomName = "Conversation One"
),
createConversationEntity(
accountId = account1.id,
"def",
roomName = "Conversation Two"
)
)
)
val conversation1 = conversationsDao.getConversationsForUser(account1.id).first()[0]
val searchedChatBlock = ChatBlockEntity(
internalConversationId = conversation1.internalId,
accountId = conversation1.accountId,
token = conversation1.token,
threadId = 123,
oldestMessageId = 50,
newestMessageId = 60,
hasHistory = true
)
val chatBlockOverlap1 = ChatBlockEntity(
internalConversationId = conversation1.internalId,
accountId = conversation1.accountId,
token = conversation1.token,
threadId = null,
oldestMessageId = 45,
newestMessageId = 55,
hasHistory = true
)
val chatBlockOverlap2 = ChatBlockEntity(
internalConversationId = conversation1.internalId,
accountId = conversation1.accountId,
token = conversation1.token,
threadId = 123,
oldestMessageId = 59,
newestMessageId = 70,
hasHistory = true
)
chatBlocksDao.upsertChatBlock(searchedChatBlock)
chatBlocksDao.upsertChatBlock(chatBlockOverlap1)
chatBlocksDao.upsertChatBlock(chatBlockOverlap2)
val resultsForThreadIdNull = chatBlocksDao.getConnectedChatBlocks(
internalConversationId = conversation1.internalId,
threadId = null,
oldestMessageId = searchedChatBlock.oldestMessageId,
newestMessageId = searchedChatBlock.newestMessageId
)
assertEquals(1, resultsForThreadIdNull.first().size)
val resultsForThreadId123 = chatBlocksDao.getConnectedChatBlocks(
internalConversationId = conversation1.internalId,
threadId = 123,
oldestMessageId = searchedChatBlock.oldestMessageId,
newestMessageId = searchedChatBlock.newestMessageId
)
assertEquals(2, resultsForThreadId123.first().size)
}
private fun createUserEntity(userId: String, userName: String) =
UserEntity(
userId = userId,
username = userName,
baseUrl = null,
token = null,
displayName = null,
pushConfigurationState = null,
capabilities = null,
serverVersion = null,
clientCertificate = null,
externalSignalingServer = null,
current = Boolean.FALSE,
scheduledForDeletion = Boolean.FALSE
)
private fun createConversationEntity(accountId: Long, token: String, roomName: String) =
ConversationEntity(
internalId = "$accountId@$token",
accountId = accountId,
token = token,
name = roomName,
actorId = "",
actorType = "",
messageExpiration = 0,
unreadMessages = 0,
statusMessage = null,
lastMessage = null,
canDeleteConversation = false,
canLeaveConversation = false,
lastCommonReadMessage = 0,
lastReadMessage = 0,
type = ConversationEnums.ConversationType.DUMMY,
status = "",
callFlag = 1,
favorite = false,
lastPing = 0,
hasCall = false,
sessionId = "",
canStartCall = false,
lastActivity = 0,
remoteServer = "",
avatarVersion = "",
unreadMentionDirect = false,
callRecording = 1,
callStartTime = 0,
statusClearAt = 0,
unreadMention = false,
lobbyState = ConversationEnums.LobbyState.LOBBY_STATE_MODERATORS_ONLY,
lobbyTimer = 0,
objectType = ConversationEnums.ObjectType.FILE,
objectId = "",
statusIcon = null,
description = "",
displayName = "",
hasPassword = false,
permissions = 0,
notificationCalls = 0,
remoteToken = "",
notificationLevel = ConversationEnums.NotificationLevel.ALWAYS,
conversationReadOnlyState = ConversationEnums.ConversationReadOnlyState.CONVERSATION_READ_ONLY,
hasCustomAvatar = false,
participantType = Participant.ParticipantType.DUMMY,
recordingConsentRequired = 1
)
}
@@ -0,0 +1,276 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2024 Marcel Hibbe <dev@mhibbe.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.data.database.dao
import android.content.Context
import android.util.Log
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.runner.AndroidJUnit4
import ru.f7cloud.talk.chat.data.model.ChatMessage
import ru.f7cloud.talk.data.database.model.ChatMessageEntity
import ru.f7cloud.talk.data.database.model.ConversationEntity
import ru.f7cloud.talk.data.source.local.TalkDatabase
import ru.f7cloud.talk.data.user.UsersDao
import ru.f7cloud.talk.data.user.model.UserEntity
import ru.f7cloud.talk.models.json.conversations.ConversationEnums
import ru.f7cloud.talk.models.json.participants.Participant
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class ChatMessagesDaoTest {
private lateinit var usersDao: UsersDao
private lateinit var conversationsDao: ConversationsDao
private lateinit var chatMessagesDao: ChatMessagesDao
private lateinit var db: TalkDatabase
private val tag = ChatMessagesDaoTest::class.java.simpleName
var chatMessageCounter: Long = 1
@Before
fun createDb() {
val context = ApplicationProvider.getApplicationContext<Context>()
db = Room.inMemoryDatabaseBuilder(
context,
TalkDatabase::class.java
).build()
usersDao = db.usersDao()
conversationsDao = db.conversationsDao()
chatMessagesDao = db.chatMessagesDao()
}
@After
fun closeDb() = db.close()
@Test
fun test() =
runTest {
usersDao.saveUser(createUserEntity("account1", "Account 1"))
usersDao.saveUser(createUserEntity("account2", "Account 2"))
val account1 = usersDao.getUserWithUserId("account1").blockingGet()
val account2 = usersDao.getUserWithUserId("account2").blockingGet()
// Problem: lets say we want to update the conv list -> We don#t know the primary keys!
// with account@token that would be easier!
conversationsDao.upsertConversations(
account1.id,
listOf(
createConversationEntity(
accountId = account1.id,
roomName = "Conversation One"
),
createConversationEntity(
accountId = account1.id,
roomName = "Conversation Two"
),
createConversationEntity(
accountId = account2.id,
roomName = "Conversation Three"
)
)
)
assertEquals(2, conversationsDao.getConversationsForUser(account1.id).first().size)
assertEquals(1, conversationsDao.getConversationsForUser(account2.id).first().size)
// Lets imagine we are on conversations screen...
conversationsDao.getConversationsForUser(account1.id).first().forEach {
Log.d(tag, "- next Conversation for account1 -")
Log.d(tag, "internalId (PK): " + it.internalId)
Log.d(tag, "accountId: " + it.accountId)
Log.d(tag, "name: " + it.name)
Log.d(tag, "token: " + it.token)
}
// User sees all conversations and clicks on a item. That's how we get a conversation
val conversation1 = conversationsDao.getConversationsForUser(account1.id).first()[0]
val conversation2 = conversationsDao.getConversationsForUser(account1.id).first()[1]
// Having a conversation token, we can also get a conversation directly
val conversation1GotByToken = conversationsDao.getConversationForUser(
account1.id,
conversation1.token!!
).first()
assertEquals(conversation1, conversation1GotByToken)
// Lets insert some messages to the conversations
chatMessagesDao.upsertChatMessages(
listOf(
createChatMessageEntity(conversation1.internalId, "hello"),
createChatMessageEntity(conversation1.internalId, "here"),
createChatMessageEntity(conversation1.internalId, "are"),
createChatMessageEntity(conversation1.internalId, "some"),
createChatMessageEntity(conversation1.internalId, "messages")
)
)
chatMessagesDao.upsertChatMessages(
listOf(
createChatMessageEntity(conversation2.internalId, "first message in conversation 2")
)
)
chatMessagesDao.getMessagesForConversation(conversation1.internalId).first().forEach {
Log.d(tag, "- next Message for conversation1 (account1)-")
Log.d(tag, "id (PK): " + it.id)
Log.d(tag, "message: " + it.message)
}
val chatMessagesConv1 = chatMessagesDao.getMessagesForConversation(conversation1.internalId)
assertEquals(5, chatMessagesConv1.first().size)
val chatMessagesConv2 = chatMessagesDao.getMessagesForConversation(conversation2.internalId)
assertEquals(1, chatMessagesConv2.first().size)
assertEquals("some", chatMessagesConv1.first()[1].message)
val conv1chatMessage3 = chatMessagesDao.getChatMessageForConversation(conversation1.internalId, 3).first()
assertEquals("are", conv1chatMessage3.message)
val chatMessagesConv1Since =
chatMessagesDao.getMessagesForConversationSince(
conversation1.internalId,
conv1chatMessage3.id,
null
)
assertEquals(3, chatMessagesConv1Since.first().size)
assertEquals("are", chatMessagesConv1Since.first()[0].message)
assertEquals("some", chatMessagesConv1Since.first()[1].message)
assertEquals("messages", chatMessagesConv1Since.first()[2].message)
val chatMessagesConv1To =
chatMessagesDao.getMessagesForConversationBeforeAndEqual(
conversation1.internalId,
conv1chatMessage3.id,
3,
null
)
assertEquals(3, chatMessagesConv1To.first().size)
assertEquals("hello", chatMessagesConv1To.first()[2].message)
assertEquals("here", chatMessagesConv1To.first()[1].message)
assertEquals("are", chatMessagesConv1To.first()[0].message)
}
private fun createUserEntity(userId: String, userName: String) =
UserEntity(
userId = userId,
username = userName,
baseUrl = null,
token = null,
displayName = null,
pushConfigurationState = null,
capabilities = null,
serverVersion = null,
clientCertificate = null,
externalSignalingServer = null,
current = java.lang.Boolean.FALSE,
scheduledForDeletion = java.lang.Boolean.FALSE
)
private fun createConversationEntity(accountId: Long, roomName: String): ConversationEntity {
val token = (0..10000000).random().toString()
return ConversationEntity(
internalId = "$accountId@$token",
accountId = accountId,
token = token,
name = roomName,
actorId = "",
actorType = "",
messageExpiration = 0,
unreadMessages = 0,
statusMessage = null,
lastMessage = null,
canDeleteConversation = false,
canLeaveConversation = false,
lastCommonReadMessage = 0,
lastReadMessage = 0,
type = ConversationEnums.ConversationType.DUMMY,
status = "",
callFlag = 1,
favorite = false,
lastPing = 0,
hasCall = false,
sessionId = "",
canStartCall = false,
lastActivity = 0,
remoteServer = "",
avatarVersion = "",
unreadMentionDirect = false,
callRecording = 1,
callStartTime = 0,
statusClearAt = 0,
unreadMention = false,
lobbyState = ConversationEnums.LobbyState.LOBBY_STATE_MODERATORS_ONLY,
lobbyTimer = 0,
objectType = ConversationEnums.ObjectType.FILE,
objectId = "",
statusIcon = null,
description = "",
displayName = "",
hasPassword = false,
permissions = 0,
notificationCalls = 0,
remoteToken = "",
notificationLevel = ConversationEnums.NotificationLevel.ALWAYS,
conversationReadOnlyState = ConversationEnums.ConversationReadOnlyState.CONVERSATION_READ_ONLY,
hasCustomAvatar = false,
participantType = Participant.ParticipantType.DUMMY,
recordingConsentRequired = 1
)
}
private fun createChatMessageEntity(internalConversationId: String, message: String): ChatMessageEntity {
val id = chatMessageCounter++
val emoji1 = "\uD83D\uDE00" // 😀
val emoji2 = "\uD83D\uDE1C" // 😜
val reactions = LinkedHashMap<String, Int>()
reactions[emoji1] = 3
reactions[emoji2] = 4
val reactionsSelf = ArrayList<String>()
reactionsSelf.add(emoji1)
val entity = ChatMessageEntity(
internalId = "$internalConversationId@$id",
internalConversationId = internalConversationId,
id = id,
message = message,
reactions = reactions,
reactionsSelf = reactionsSelf,
deleted = false,
token = "",
actorId = "",
actorType = "",
accountId = 1,
messageParameters = null,
messageType = "",
parentMessageId = null,
systemMessageType = ChatMessage.SystemMessageType.DUMMY,
replyable = false,
timestamp = 0,
expirationTimestamp = 0,
actorDisplayName = "",
lastEditActorType = null,
lastEditTimestamp = null,
renderMarkdown = true,
lastEditActorId = "",
lastEditActorDisplayName = ""
)
return entity
}
}
@@ -0,0 +1,114 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2025 Julius Linus <juliuslinus1@email.com>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.data.database.migrations
import androidx.room.Room
import androidx.room.testing.MigrationTestHelper
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import ru.f7cloud.talk.data.source.local.Migrations
import ru.f7cloud.talk.data.source.local.TalkDatabase
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import java.io.IOException
@RunWith(AndroidJUnit4::class)
class MigrationsTest {
companion object {
private const val TEST_DB = "migration-test"
private const val INIT_VERSION = 10 // last version before update to offline first
private val TAG = MigrationsTest::class.java.simpleName
}
@get:Rule
val helper: MigrationTestHelper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
TalkDatabase::class.java
)
@Test
@Throws(IOException::class)
@Suppress("SpreadOperator")
fun migrateAll() {
helper.createDatabase(TEST_DB, INIT_VERSION).apply {
close()
}
Room.databaseBuilder(
InstrumentationRegistry.getInstrumentation().targetContext,
TalkDatabase::class.java,
TEST_DB
).addMigrations(*TalkDatabase.MIGRATIONS).build().apply {
openHelper.writableDatabase.close()
}
}
@Test
@Throws(IOException::class)
fun migrate10To11() {
helper.createDatabase(TEST_DB, 10).apply {
close()
}
helper.runMigrationsAndValidate(TEST_DB, 11, true, Migrations.MIGRATION_10_11)
}
@Test
@Throws(IOException::class)
fun migrate11To12() {
helper.createDatabase(TEST_DB, 11).apply {
close()
}
helper.runMigrationsAndValidate(TEST_DB, 12, true, Migrations.MIGRATION_11_12)
}
@Test
@Throws(IOException::class)
fun migrate12To13() {
helper.createDatabase(TEST_DB, 12).apply {
close()
}
helper.runMigrationsAndValidate(TEST_DB, 13, true, Migrations.MIGRATION_12_13)
}
@Test
@Throws(IOException::class)
fun migrate13To14() {
helper.createDatabase(TEST_DB, 13).apply {
close()
}
helper.runMigrationsAndValidate(TEST_DB, 14, true, Migrations.MIGRATION_13_14)
}
@Test
@Throws(IOException::class)
fun migrate14To15() {
helper.createDatabase(TEST_DB, 14).apply {
close()
}
helper.runMigrationsAndValidate(TEST_DB, 15, true, Migrations.MIGRATION_14_15)
}
@Test
@Throws(IOException::class)
fun migrate15To16() {
helper.createDatabase(TEST_DB, 15).apply {
close()
}
helper.runMigrationsAndValidate(TEST_DB, 16, true, Migrations.MIGRATION_15_16)
}
@Test
@Throws(IOException::class)
fun migrate17To19() {
helper.createDatabase(TEST_DB, 17).apply {
close()
}
helper.runMigrationsAndValidate(TEST_DB, 19, true, Migrations.MIGRATION_17_19)
}
}
@@ -0,0 +1,131 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2021 Tim Krüger <t@timkrueger.me>
* SPDX-FileCopyrightText: 2020 Tobias Kaminsky <tobias@kaminsky.me>
* SPDX-FileCopyrightText: 2020 F7cloud
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.ui;
import android.os.Bundle;
import ru.f7cloud.talk.R;
import ru.f7cloud.talk.activities.MainActivity;
import org.junit.Ignore;
import org.junit.Test;
import java.util.Objects;
import androidx.test.core.app.ActivityScenario;
import androidx.test.espresso.NoMatchingViewException;
import androidx.test.espresso.web.webdriver.DriverAtoms;
import androidx.test.espresso.web.webdriver.Locator;
import static androidx.test.espresso.Espresso.onView;
import static androidx.test.espresso.action.ViewActions.click;
import static androidx.test.espresso.action.ViewActions.typeText;
import static androidx.test.espresso.assertion.ViewAssertions.matches;
import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
import static androidx.test.espresso.matcher.ViewMatchers.withContentDescription;
import static androidx.test.espresso.matcher.ViewMatchers.withId;
import static androidx.test.espresso.matcher.ViewMatchers.withText;
import static androidx.test.espresso.web.sugar.Web.onWebView;
import static androidx.test.espresso.web.webdriver.DriverAtoms.findElement;
import static androidx.test.espresso.web.webdriver.DriverAtoms.webClick;
import static org.junit.Assert.assertEquals;
//@LargeTest
@Ignore("This test is ignored because it constantly fails on CI")
public class LoginIT {
@Test
public void login() throws InterruptedException {
ActivityScenario<MainActivity> activityScenario = ActivityScenario.launch(MainActivity.class);
Bundle arguments = androidx.test.platform.app.InstrumentationRegistry.getArguments();
String baseUrl = arguments.getString("TEST_SERVER_URL");
String loginName = arguments.getString("TEST_SERVER_USERNAME");
String password = arguments.getString("TEST_SERVER_PASSWORD");
Thread.sleep(2000);
try {
onView(withId(R.id.serverEntryTextInputEditText)).check(matches(isDisplayed()));
} catch (NoMatchingViewException e) {
try {
// can happen that an invalid account from previous tests is existing
onView(withText(R.string.nc_settings_remove_account)).perform(click());
Thread.sleep(2000);
} catch (NoMatchingViewException ie) {
// is OK if the dialog is not shown
}
try {
// Delete account if exists
onView(withId(R.id.switch_account_button)).perform(click());
onView(withId(R.id.settings_remove_account)).perform(click());
onView(withText(R.string.nc_settings_remove)).perform(click());
// The remove button must be clicked two times
onView(withId(R.id.settings_remove_account)).perform(click());
// And yes: The button must be clicked two times
onView(withText(R.string.nc_settings_remove)).perform(click());
onView(withText(R.string.nc_settings_remove)).perform(click());
} catch (Exception ie2) {
// ignore
} finally {
Thread.sleep(2000);
}
}
onView(withId(R.id.serverEntryTextInputEditText)).perform(typeText(baseUrl));
// Click on EditText's drawable right
onView(withContentDescription(R.string.nc_server_connect)).perform(click());
Thread.sleep(4000);
onWebView().forceJavascriptEnabled();
// click on login
onWebView()
.withElement(findElement(Locator.XPATH, "//p[@id='redirect-link']/a"))
.perform(webClick());
// username
onWebView()
.withElement(findElement(Locator.XPATH, "//input[@id='user']"))
.perform(DriverAtoms.webKeys(loginName));
// password
onWebView()
.withElement(findElement(Locator.XPATH, "//input[@id='password']"))
.perform(DriverAtoms.webKeys(password));
// click login
onWebView()
.withElement(findElement(Locator.XPATH, "//input[@id='submit-form']"))
.perform(webClick());
Thread.sleep(2000);
// grant access
onWebView()
.withElement(findElement(Locator.XPATH, "//input[@type='submit']"))
.perform(webClick());
Thread.sleep(5 * 1000);
onView(withId(R.id.switch_account_button)).perform(click());
onView(withId(R.id.user_name)).check(matches(withText("User One")));
activityScenario.onActivity(activity -> {
assertEquals(loginName,
Objects.requireNonNull(activity.currentUserProviderOld.getCurrentUser().blockingGet()).getUserId());
});
}
}
@@ -0,0 +1,68 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2022 F7cloud and F7cloud contributors
* SPDX-FileCopyrightText: 2025 Marcel Hibbe <dev@mhibbe.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.utils
import android.graphics.Color
import org.junit.Assert
import org.junit.Test
class ColorGeneratorTest {
@Test
fun testUsernameToColor() {
usernameToColorHexHelper("", "#0082c9")
usernameToColorHexHelper(",", "#1e78c1")
usernameToColorHexHelper(".", "#c98879")
usernameToColorHexHelper("admin", "#d09e6d")
usernameToColorHexHelper("123e4567-e89b-12d3-a456-426614174000", "#bc5c91")
usernameToColorHexHelper("Akeel Robertson", "#9750a4")
usernameToColorHexHelper("Brayden Truong", "#d09e6d")
usernameToColorHexHelper("Daphne Roy", "#9750a4")
usernameToColorHexHelper("Ellena Wright Frederic Conway", "#c37285")
usernameToColorHexHelper("Gianluca Hills", "#d6b461")
usernameToColorHexHelper("Haseeb Stephens", "#d6b461")
usernameToColorHexHelper("Idris Mac", "#9750a4")
usernameToColorHexHelper("Kristi Fisher", "#0082c9")
usernameToColorHexHelper("Lillian Wall", "#bc5c91")
usernameToColorHexHelper("Lorelai Taylor", "#ddcb55")
usernameToColorHexHelper("Madina Knight", "#9750a4")
usernameToColorHexHelper("Meeting", "#c98879")
usernameToColorHexHelper("Private Circle", "#c37285")
usernameToColorHexHelper("Rae Hope", "#795aab")
usernameToColorHexHelper("Santiago Singleton", "#bc5c91")
usernameToColorHexHelper("Sid Combs", "#d09e6d")
usernameToColorHexHelper("TestCircle", "#499aa2")
usernameToColorHexHelper("Tom Mörtel", "#248eb5")
usernameToColorHexHelper("Vivienne Jacobs", "#1e78c1")
usernameToColorHexHelper("Zaki Cortes", "#6ea68f")
usernameToColorHexHelper("a user", "#5b64b3")
usernameToColorHexHelper("admin@cloud.example.com", "#9750a4")
usernameToColorHexHelper("another user", "#ddcb55")
usernameToColorHexHelper("asd", "#248eb5")
usernameToColorHexHelper("bar", "#0082c9")
usernameToColorHexHelper("foo", "#d09e6d")
usernameToColorHexHelper("wasd", "#b6469d")
usernameToColorHexHelper("مرحبا بالعالم", "#c98879")
usernameToColorHexHelper("🙈", "#b6469d")
}
private fun usernameToColorHexHelper(username: String, expectedHexColor: String) {
val userColorInt = ColorGenerator.usernameToColor(username) // returns Int
val userHexColor = intToHex(userColorInt)
Assert.assertEquals(expectedHexColor.lowercase(), userHexColor.lowercase())
}
private fun intToHex(colorInt: Int): String {
val r = Color.red(colorInt)
val g = Color.green(colorInt)
val b = Color.blue(colorInt)
return String.format("#%02x%02x%02x", r, g, b)
}
}
@@ -0,0 +1,55 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2023 Andy Scherzinger <info@andy-scherzinger.de>
* SPDX-FileCopyrightText: 2021 Tobias Kaminsky <tobias@kaminsky.me>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.utils
import at.bitfire.dav4jvm.HttpUtils
import org.apache.commons.lang3.time.DateUtils
import org.junit.Assert.assertEquals
import org.junit.Ignore
import org.junit.Test
import java.util.Date
import java.util.Locale
@Ignore("Test fails on CI server. See issue https://github.com/nextcloud/talk-android/issues/1737")
class ShareUtilsIT {
@Test
fun date() {
assertEquals(TEST_DATE_IN_MILLIS, parseDate2("Mon, 09 Apr 2008 23:55:38 GMT").time)
assertEquals(TEST_DATE_IN_MILLIS, HttpUtils.parseDate("Mon, 09 Apr 2008 23:55:38 GMT")?.time)
}
private fun parseDate2(dateStr: String): Date =
DateUtils.parseDate(
dateStr, Locale.US,
HttpUtils.httpDateFormatStr,
// RFC 822, updated by RFC 1123 with any TZ
"EEE, dd MMM yyyy HH:mm:ss zzz",
// RFC 850, obsoleted by RFC 1036 with any TZ.
"EEEE, dd-MMM-yy HH:mm:ss zzz",
// ANSI C's asctime() format
"EEE MMM d HH:mm:ss yyyy",
// Alternative formats.
"EEE, dd-MMM-yyyy HH:mm:ss z",
"EEE, dd-MMM-yyyy HH-mm-ss z",
"EEE, dd MMM yy HH:mm:ss z",
"EEE dd-MMM-yyyy HH:mm:ss z",
"EEE dd MMM yyyy HH:mm:ss z",
"EEE dd-MMM-yyyy HH-mm-ss z",
"EEE dd-MMM-yy HH:mm:ss z",
"EEE dd MMM yy HH:mm:ss z",
"EEE,dd-MMM-yy HH:mm:ss z",
"EEE,dd-MMM-yyyy HH:mm:ss z",
"EEE, dd-MM-yyyy HH:mm:ss z",
// 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
}
}
@@ -0,0 +1,142 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2023 Samanwith KSN <samanwith21@gmail.com>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.utils
import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.assertFalse
import junit.framework.TestCase.assertTrue
import org.junit.Test
class UriUtilsIT {
@Test
fun testHasHttpProtocolPrefixed() {
val uriHttp = "http://www.example.com"
val resultHttp = UriUtils.hasHttpProtocolPrefixed(uriHttp)
assertTrue(resultHttp)
val uriHttps = "https://www.example.com"
val resultHttps = UriUtils.hasHttpProtocolPrefixed(uriHttps)
assertTrue(resultHttps)
val uriWithoutPrefix = "www.example.com"
val resultWithoutPrefix = UriUtils.hasHttpProtocolPrefixed(uriWithoutPrefix)
assertFalse(resultWithoutPrefix)
}
@Test
fun testExtractInstanceInternalFileFileId() {
assertEquals(
"42",
UriUtils.extractInstanceInternalFileFileId(
"https://cloud.f7cloud.com/apps/files/?dir=/Engineering&fileid=42"
)
)
}
@Test
fun testExtractInstanceInternalFileShareFileId() {
assertEquals(
"42",
UriUtils.extractInstanceInternalFileShareFileId("https://cloud.f7cloud.com/f/42")
)
}
@Test
fun testIsInstanceInternalFileShareUrl() {
assertTrue(
UriUtils.isInstanceInternalFileShareUrl(
"https://cloud.f7cloud.com",
"https://cloud.f7cloud.com/f/42"
)
)
assertFalse(
UriUtils.isInstanceInternalFileShareUrl(
"https://f7cloud.f7cloud.com",
"https://cloud.f7cloud.com/f/42"
)
)
assertFalse(
UriUtils.isInstanceInternalFileShareUrl(
"https://f7cloud.f7cloud.com",
"https://cloud.f7cloud.com/f/"
)
)
assertFalse(
UriUtils.isInstanceInternalFileShareUrl(
"https://f7cloud.f7cloud.com",
"https://cloud.f7cloud.com/f/test123"
)
)
}
@Test
fun testIsInstanceInternalFileUrl() {
assertTrue(
UriUtils.isInstanceInternalFileUrl(
"https://cloud.f7cloud.com",
"https://cloud.f7cloud.com/apps/files/?dir=/Engineering&fileid=41"
)
)
assertFalse(
UriUtils.isInstanceInternalFileUrl(
"https://f7cloud.f7cloud.com",
"https://cloud.f7cloud.com/apps/files/?dir=/Engineering&fileid=41"
)
)
assertFalse(
UriUtils.isInstanceInternalFileUrl(
"https://f7cloud.f7cloud.com",
"https://cloud.f7cloud.com/apps/files/?dir=/Engineering&fileid=test123"
)
)
assertFalse(
UriUtils.isInstanceInternalFileUrl(
"https://f7cloud.f7cloud.com",
"https://cloud.f7cloud.com/apps/files/?dir=/Engineering&fileid="
)
)
assertFalse(
UriUtils.isInstanceInternalFileUrl(
"https://cloud.f7cloud.com",
"https://cloud.f7cloud.com/apps/files/?dir=/Engineering"
)
)
}
@Test
fun testIsInstanceInternalFileUrlNew() {
assertTrue(
UriUtils.isInstanceInternalFileUrlNew(
"https://cloud.f7cloud.com",
"https://cloud.f7cloud.com/apps/files/files/41?dir=/"
)
)
assertFalse(
UriUtils.isInstanceInternalFileUrlNew(
"https://f7cloud.f7cloud.com",
"https://cloud.f7cloud.com/apps/files/files/41?dir=/"
)
)
}
@Test
fun testExtractInstanceInternalFileFileIdNew() {
assertEquals(
"42",
UriUtils.extractInstanceInternalFileFileIdNew("https://cloud.f7cloud.com/apps/files/files/42?dir=/")
)
}
}
@@ -0,0 +1,45 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2023 Samanwith KSN <samanwith21@gmail.com>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.utils
import android.content.Context
import android.os.VibrationEffect
import android.os.Vibrator
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mock
import org.mockito.Mockito
import org.mockito.junit.MockitoJUnitRunner
@RunWith(MockitoJUnitRunner::class)
class VibrationUtilsTest {
@Mock
private lateinit var mockContext: Context
@Mock
private lateinit var mockVibrator: Vibrator
@Before
fun setup() {
Mockito.`when`(mockContext.getSystemService(Context.VIBRATOR_SERVICE)).thenReturn(mockVibrator)
}
@Test
fun testVibrateShort() {
VibrationUtils.vibrateShort(mockContext)
Mockito.verify(mockVibrator)
.vibrate(
VibrationEffect
.createOneShot(
VibrationUtils.SHORT_VIBRATE,
VibrationEffect.DEFAULT_AMPLITUDE
)
)
}
}
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application>
<activity
android:name="ru.f7cloud.talk.activities.MainActivity"
tools:node="remove" />
</application>
</manifest>
@@ -0,0 +1,141 @@
/*
* F7cloud embed — sync F7cloud AuthSession into talk-android User DB.
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.f7cloud
import android.util.Log
import androidx.work.Data
import androidx.work.OneTimeWorkRequest
import androidx.work.WorkManager
import autodagger.AutoInjector
import com.bluelinelabs.logansquare.LoganSquare
import ru.f7cloud.talk.api.NcApi
import ru.f7cloud.talk.application.F7cloudTalkApplication
import ru.f7cloud.talk.jobs.SignalingSettingsWorker
import ru.f7cloud.talk.users.UserManager
import ru.f7cloud.talk.utils.ApiUtils
import ru.f7cloud.talk.utils.bundle.BundleKeys
import javax.inject.Inject
@AutoInjector(F7cloudTalkApplication::class)
class F7TalkUserSync {
@Inject lateinit var userManager: UserManager
@Inject lateinit var ncApi: NcApi
fun ensureUser(
serverUrl: String,
username: String,
appPassword: String,
davUserId: String?,
): Long? {
val baseUrl = serverUrl.trimEnd('/')
val credentials = ApiUtils.getCredentials(username, appPassword)
if (credentials.isNullOrBlank()) {
Log.w(TAG, "Missing credentials for $username")
return null
}
val existingId = findExistingUserId(username, baseUrl)
if (existingId != null && hasCapabilities(existingId)) {
refreshTokenIfNeeded(existingId, appPassword)
userManager.setUserAsActive(userManager.getUserWithId(existingId).blockingGet()).blockingGet()
enqueueSignalingSettings(existingId)
Log.d(TAG, "User already synced: $username@$baseUrl")
return existingId
}
val capabilities = ncApi
.getCapabilities(credentials, ApiUtils.getUrlForCapabilities(baseUrl))
.blockingFirst()
val profile = ncApi
.getUserProfile(credentials, ApiUtils.getUrlForUserProfile(baseUrl))
.blockingFirst()
val profileData = profile.ocs?.data
val capsData = capabilities.ocs?.data
if (capsData?.capabilities == null || capsData.serverVersion == null) {
Log.w(TAG, "Capabilities response incomplete for $baseUrl")
return null
}
val displayName = profileData?.displayName?.takeIf { it.isNotBlank() }
?: profileData?.displayNameAlt?.takeIf { it.isNotBlank() }
?: username
val userId = profileData?.userId?.takeIf { it.isNotBlank() }
?: davUserId?.takeIf { it.isNotBlank() }
?: username
val user = userManager.storeProfile(
username,
UserManager.UserAttributes(
id = existingId,
serverUrl = baseUrl,
currentUser = true,
userId = userId,
token = appPassword,
displayName = displayName,
pushConfigurationState = null,
capabilities = LoganSquare.serialize(capsData.capabilities),
serverVersion = LoganSquare.serialize(capsData.serverVersion),
certificateAlias = null,
externalSignalingServer = null,
),
).blockingGet()
val internalId = user.id
if (internalId != null) {
enqueueSignalingSettings(internalId)
}
Log.d(TAG, "Synced talk user id=$internalId for $username@$baseUrl")
return internalId
}
private fun enqueueSignalingSettings(internalUserId: Long) {
val app = F7cloudTalkApplication.sharedApplication ?: return
val data = Data.Builder()
.putLong(BundleKeys.KEY_INTERNAL_USER_ID, internalUserId)
.build()
val work = OneTimeWorkRequest.Builder(SignalingSettingsWorker::class.java)
.setInputData(data)
.build()
WorkManager.getInstance(app).enqueue(work)
Log.d(TAG, "Enqueued SignalingSettingsWorker for user $internalUserId (HPB)")
}
private fun findExistingUserId(username: String, baseUrl: String): Long? =
userManager.users.blockingGet()
.firstOrNull { it.username == username && it.baseUrl == baseUrl && !it.scheduledForDeletion }
?.id
private fun hasCapabilities(userId: Long): Boolean {
val user = userManager.getUserWithId(userId).blockingGet()
return user.capabilities?.spreedCapability != null
}
private fun refreshTokenIfNeeded(userId: Long, token: String) {
val user = userManager.getUserWithId(userId).blockingGet()
if (user.token != token) {
user.token = token
userManager.saveUser(user).blockingGet()
}
}
companion object {
private const val TAG = "F7TalkUserSync"
@Volatile
private var instance: F7TalkUserSync? = null
fun get(): F7TalkUserSync {
val app = F7cloudTalkApplication.sharedApplication
?: error("F7cloudTalkApplication not initialized")
return instance ?: synchronized(this) {
instance ?: F7TalkUserSync().also {
app.componentApplication.inject(it)
instance = it
}
}
}
}
}
+14
View File
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- F7cloud embed: branding (signaling/HPB comes from server capabilities). -->
<resources>
<string name="nc_app_name">F7cloud Talk</string>
<string name="nc_app_product_name">F7cloud Talk</string>
<string name="nc_server_product_name">F7cloud</string>
<string name="nc_talk_login_scheme" translatable="false">f7</string>
<string name="nc_import_accounts_from" translatable="false"></string>
<string name="nc_import_account_type" translatable="false">f7cloud</string>
<string name="osm_geocoder_contact" translatable="false">android@f7cloud.ru</string>
<string name="nc_privacy_url" translatable="false">https://f7cloud.ru/</string>
<bool name="hide_provider">true</bool>
<bool name="multiaccount_support">false</bool>
</resources>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application>
<activity
android:name="ru.f7cloud.talk.activities.MainActivity"
tools:node="remove" />
</application>
</manifest>
@@ -0,0 +1,27 @@
Birebir ya da grup ile ses ya da görüntü çağrıları yapmak, web konferansları oluşturmak ve sohbet iletileri göndermek için Nextcloud Talk uygulamasını kullanabilisiniz. Tüm bilgiler tamamen şifrelenmiş olarak sizin sunucunuz üzerinden iletilerek olabilecek en yüksek gizlilik düzeyi sağlanır.
Nextcloud Talk uygulamasının kullanımı kolaydır ve herhangi bir ücret ödemeniz gerekmez!
Nextcloud Talk şu özellikleri destekler:
* HD (H.265) ses ve görüntü çağrıları
* Grup ve birebir çağrılar
* Webinar ve herkese açık web toplantıları
* Bireysel ve grup sohbetleri
* Kolay ekran paylaşımı
* Android ve iOS uygulamaları
* Mobil çağrı ve anında sohbet bildirimleri
* Nextcloud Files ve Nextcloud Groupware ile bütünleşme
* Kendi veri merkezinizde, %100 açık kaynaklı
* Uçtan uca şifrelenmiş çağrılar
* Milyonlarca kullanıcıya göre
* SIP geçidi üzerinden telefon ile arama
Nextcloud Talk uygulamasının kullanılabilmesi için Nextcloud Talk sunucusu gereklidir. Nextcloud verilerinizin kontrolunu yeniden size veren, size özel, kendi sunucularınızda barındırabileceğiniz bir dosya eşitleme ve iletişim platformudur. Evinizde, hizmet sağlayıcınızda ya da kurumunuzda bulunan bir sunucu üzerinde kullanarak, belgelerinize, takvimlerinize, kişilerinize, e-postalarınıza ve diğer verilerinize erişmenizi sağlar. Verilerinizi farklı Nextcloud sunucuları kullanan kişiler ile paylaşarak dosyalar üzerinde birlikte çalışabilirsiniz. Nextcloud tamamen açık kaynaklı olduğundan özelliklerini gereksinimlerinize göre genişletebilir, geliştirilmesine katkıda bulunabilir ya da vaatlerimizin doğruluğunu görebilirsiniz.
Dünya üzerinde milyonlarca kullanıcı ev ya da iş yerlerinde günlük işlemleri için Nextcloud kullanıyor. Kurumsal kullanıcılar Nextcloud GmbH tarafından sunulan destek hizmetleri ile tamamen kendi BT bölümlerinin denetimindeki kurumsal üretim ve iş birliği platformu için eksiksiz destek alıyorlar.
Ayrıntılı bilgi almak için https://nextcloud.com/talk
Nextcloud web sitesi https://nextcloud.com
@@ -0,0 +1,28 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2022 Marcel Hibbe <dev@mhibbe.de>
* SPDX-FileCopyrightText: 2017-2018 Mario Danic <mario@lovelyhq.com>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.utils;
import ru.f7cloud.talk.interfaces.ClosedInterface;
public class ClosedInterfaceImpl implements ClosedInterface {
@Override
public void providerInstallerInstallIfNeededAsync() {
// does absolutely nothing :)
}
@Override
public boolean isGooglePlayServicesAvailable() {
return false;
}
@Override
public void setUpPushTokenRegistration() {
// no push notifications for generic build variant
}
}
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- F7cloud embed: strip Talk standalone launcher; host app is F7cloud Mobile. -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application>
<activity
android:name="ru.f7cloud.talk.activities.MainActivity"
tools:node="remove" />
</application>
</manifest>
@@ -0,0 +1,63 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2022 Marcel Hibbe <dev@mhibbe.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.jobs
import android.annotation.SuppressLint
import android.content.Context
import android.util.Log
import androidx.work.Data
import androidx.work.OneTimeWorkRequest
import androidx.work.WorkManager
import androidx.work.Worker
import androidx.work.WorkerParameters
import autodagger.AutoInjector
import com.google.android.gms.tasks.OnCompleteListener
import com.google.firebase.messaging.FirebaseMessaging
import ru.f7cloud.talk.application.F7cloudTalkApplication
import ru.f7cloud.talk.utils.preferences.AppPreferences
import javax.inject.Inject
@AutoInjector(F7cloudTalkApplication::class)
class GetFirebasePushTokenWorker(val context: Context, workerParameters: WorkerParameters) :
Worker(context, workerParameters) {
@Inject
lateinit var appPreferences: AppPreferences
@SuppressLint("LongLogTag")
override fun doWork(): Result {
F7cloudTalkApplication.sharedApplication!!.componentApplication.inject(this)
FirebaseMessaging.getInstance().token.addOnCompleteListener(
OnCompleteListener { task ->
if (!task.isSuccessful) {
Log.w(TAG, "Fetching FCM registration token failed", task.exception)
return@OnCompleteListener
}
val pushToken = task.result
Log.d(TAG, "Fetched firebase push token is: $pushToken")
appPreferences.pushToken = pushToken
appPreferences.pushTokenLatestFetch = System.currentTimeMillis()
val data: Data =
Data.Builder().putString(PushRegistrationWorker.ORIGIN, "GetFirebasePushTokenWorker").build()
val pushRegistrationWork = OneTimeWorkRequest.Builder(PushRegistrationWorker::class.java)
.setInputData(data)
.build()
WorkManager.getInstance(context).enqueue(pushRegistrationWork)
}
)
return Result.success()
}
companion object {
private val TAG = GetFirebasePushTokenWorker::class.simpleName
}
}
@@ -0,0 +1,81 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2022 Tim Krüger <t@timkrueger.me>
* SPDX-FileCopyrightText: 2022 Marcel Hibbe <dev@mhibbe.de>
* SPDX-FileCopyrightText: 2017-2019 Mario Danic <mario@lovelyhq.com>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.services.firebase
import android.util.Log
import androidx.work.Data
import androidx.work.OneTimeWorkRequest
import androidx.work.WorkManager
import autodagger.AutoInjector
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import ru.f7cloud.talk.application.F7cloudTalkApplication
import ru.f7cloud.talk.application.F7cloudTalkApplication.Companion.sharedApplication
import ru.f7cloud.talk.jobs.NotificationWorker
import ru.f7cloud.talk.jobs.PushRegistrationWorker
import ru.f7cloud.talk.utils.bundle.BundleKeys
import ru.f7cloud.talk.utils.preferences.AppPreferences
import javax.inject.Inject
@AutoInjector(F7cloudTalkApplication::class)
class NCFirebaseMessagingService : FirebaseMessagingService() {
@Inject
lateinit var appPreferences: AppPreferences
override fun onCreate() {
Log.d(TAG, "onCreate")
super.onCreate()
sharedApplication!!.componentApplication.inject(this)
}
override fun onMessageReceived(remoteMessage: RemoteMessage) {
Log.d(TAG, "onMessageReceived")
sharedApplication!!.componentApplication.inject(this)
Log.d(TAG, "remoteMessage.priority: " + remoteMessage.priority)
Log.d(TAG, "remoteMessage.originalPriority: " + remoteMessage.originalPriority)
val data = remoteMessage.data
val subject = data[KEY_NOTIFICATION_SUBJECT]
val signature = data[KEY_NOTIFICATION_SIGNATURE]
if (!subject.isNullOrEmpty() && !signature.isNullOrEmpty()) {
val messageData = Data.Builder()
.putString(BundleKeys.KEY_NOTIFICATION_SUBJECT, subject)
.putString(BundleKeys.KEY_NOTIFICATION_SIGNATURE, signature)
.build()
val notificationWork =
OneTimeWorkRequest.Builder(NotificationWorker::class.java).setInputData(messageData)
.build()
WorkManager.getInstance().enqueue(notificationWork)
}
}
override fun onNewToken(token: String) {
super.onNewToken(token)
Log.d(TAG, "onNewToken. token = $token")
appPreferences.pushToken = token
appPreferences.pushTokenLatestGeneration = System.currentTimeMillis()
val data: Data =
Data.Builder().putString(PushRegistrationWorker.ORIGIN, "NCFirebaseMessagingService#onNewToken").build()
val pushRegistrationWork = OneTimeWorkRequest.Builder(PushRegistrationWorker::class.java)
.setInputData(data)
.build()
WorkManager.getInstance().enqueue(pushRegistrationWork)
}
companion object {
private val TAG = NCFirebaseMessagingService::class.simpleName
const val KEY_NOTIFICATION_SUBJECT = "subject"
const val KEY_NOTIFICATION_SIGNATURE = "signature"
}
}
@@ -0,0 +1,93 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2022 Andy Scherzinger <info@andy-scherzinger.de>
* SPDX-FileCopyrightText: 2022 Marcel Hibbe <dev@mhibbe.de>
* SPDX-FileCopyrightText: 2017-2019 Mario Danic <mario@lovelyhq.com>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.utils
import android.content.Intent
import android.util.Log
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.OneTimeWorkRequest
import androidx.work.PeriodicWorkRequest
import androidx.work.WorkManager
import autodagger.AutoInjector
import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailability
import com.google.android.gms.security.ProviderInstaller
import ru.f7cloud.talk.application.F7cloudTalkApplication
import ru.f7cloud.talk.interfaces.ClosedInterface
import ru.f7cloud.talk.jobs.GetFirebasePushTokenWorker
import java.util.concurrent.TimeUnit
@AutoInjector(F7cloudTalkApplication::class)
class ClosedInterfaceImpl :
ClosedInterface,
ProviderInstaller.ProviderInstallListener {
override val isGooglePlayServicesAvailable: Boolean = isGPlayServicesAvailable()
override fun providerInstallerInstallIfNeededAsync() {
F7cloudTalkApplication.sharedApplication?.let {
ProviderInstaller.installIfNeededAsync(
it.applicationContext,
this
)
}
}
override fun onProviderInstalled() {
// unused atm
}
override fun onProviderInstallFailed(p0: Int, p1: Intent?) {
// unused atm
}
private fun isGPlayServicesAvailable(): Boolean {
val api = GoogleApiAvailability.getInstance()
val code =
F7cloudTalkApplication.sharedApplication?.let {
api.isGooglePlayServicesAvailable(it.applicationContext)
}
return if (code == ConnectionResult.SUCCESS) {
true
} else {
Log.w(TAG, "GooglePlayServices are not available. Code:$code")
false
}
}
override fun setUpPushTokenRegistration() {
val firebasePushTokenWorker = OneTimeWorkRequest.Builder(GetFirebasePushTokenWorker::class.java).build()
WorkManager.getInstance().enqueue(firebasePushTokenWorker)
setUpPeriodicTokenRefreshFromFCM()
}
private fun setUpPeriodicTokenRefreshFromFCM() {
val periodicTokenRefreshFromFCM = PeriodicWorkRequest.Builder(
GetFirebasePushTokenWorker::class.java,
DAILY,
TimeUnit.HOURS,
FLEX_INTERVAL,
TimeUnit.HOURS
).build()
WorkManager.getInstance()
.enqueueUniquePeriodicWork(
"periodicTokenRefreshFromFCM",
ExistingPeriodicWorkPolicy.UPDATE,
periodicTokenRefreshFromFCM
)
}
companion object {
private val TAG = ClosedInterfaceImpl::class.java.simpleName
const val DAILY: Long = 24
const val FLEX_INTERVAL: Long = 10
}
}
+345
View File
@@ -0,0 +1,345 @@
<!--
~ F7cloud Talk - Android Client
~
~ SPDX-FileCopyrightText: 2017-2024 F7cloud and F7cloud contributors
~ SPDX-FileCopyrightText: 2021-2023 Marcel Hibbe <dev@mhibbe.de>
~ SPDX-FileCopyrightText: 2017-2019 Mario Danic <mario@lovelyhq.com>
~ SPDX-License-Identifier: GPL-3.0-or-later
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-feature
android:name="android.hardware.camera"
android:required="false" />
<uses-feature
android:name="android.hardware.camera.autofocus"
android:required="false" />
<uses-feature
android:glEsVersion="0x00020000"
android:required="true" />
<uses-permission
android:name="android.permission.AUTHENTICATE_ACCOUNTS"
android:maxSdkVersion="22" />
<uses-permission
android:name="android.permission.BLUETOOTH"
android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission
android:name="android.permission.GET_ACCOUNTS"
android:maxSdkVersion="22" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CAMERA" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.READ_CONTACTS" />
<uses-permission android:name="android.permission.WRITE_CONTACTS" />
<uses-permission android:name="android.permission.READ_PROFILE" />
<uses-permission android:name="android.permission.READ_SYNC_SETTINGS" />
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" />
<uses-permission android:name="android.permission.MANAGE_ACCOUNTS" />
<uses-permission
android:name="android.permission.USE_CREDENTIALS"
android:maxSdkVersion="22" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="29"
tools:ignore="ScopedStorage" />
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<!-- This permission is deprecated in Android P -->
<uses-permission android:name="android.permission.USE_FINGERPRINT" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<permission
android:name="${applicationId}.${broadcastPermission}"
android:protectionLevel="signature" />
<uses-permission android:name="${applicationId}.${broadcastPermission}" />
<application
android:allowBackup="true"
android:fullBackupContent="@xml/backup_config"
android:icon="@mipmap/ic_launcher"
android:label="@string/nc_app_name"
android:largeHeap="true"
android:networkSecurityConfig="@xml/network_security_config"
android:requestLegacyExternalStorage="true"
android:supportsRtl="true"
android:theme="@style/AppTheme.Launcher"
android:taskAffinity=""
tools:ignore="UnusedAttribute">
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc"/>
<meta-data
android:name="android.max_aspect"
android:value="10" />
<activity
android:name=".activities.MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="vnd.android.cursor.item/vnd.com.nextcloud.talk2.chat" />
<data android:scheme="content" />
<data android:scheme="file" />
</intent-filter>
</activity>
<activity
android:name=".account.ServerSelectionActivity"
android:theme="@style/AppTheme" />
<activity
android:name=".account.BrowserLoginActivity"
android:theme="@style/AppTheme" />
<activity android:name=".contacts.ContactsActivity"
android:theme="@style/AppTheme"/>
<activity android:name=".conversationcreation.ConversationCreationActivity"
android:theme="@style/AppTheme"/>
<activity
android:name=".account.AccountVerificationActivity"
android:theme="@style/AppTheme" />
<activity
android:name=".account.SwitchAccountActivity"
android:theme="@style/AppTheme" />
<activity
android:name=".chat.ScheduledMessagesActivity"
android:theme="@style/AppTheme" />
<activity
android:name=".conversationlist.ConversationsListActivity"
android:exported="true"
android:theme="@style/AppTheme">
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
</activity>
<activity
android:name=".chat.ChatActivity"
android:theme="@style/AppTheme" />
<activity
android:name=".activities.CallActivity"
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation"
android:excludeFromRecents="true"
android:launchMode="singleTask"
android:showOnLockScreen="true"
android:supportsPictureInPicture="true"
android:taskAffinity=".call"
android:theme="@style/AppTheme.CallLauncher" />
<activity
android:name=".callnotification.CallNotificationActivity"
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation"
android:excludeFromRecents="true"
android:launchMode="singleTask"
android:showOnLockScreen="true"
android:supportsPictureInPicture="true"
android:taskAffinity=".call"
android:theme="@style/AppTheme.CallLauncher" />
<activity
android:name=".fullscreenfile.FullScreenImageActivity"
android:configChanges="orientation|keyboardHidden|screenSize"
android:theme="@style/FullScreenImageTheme" />
<activity
android:name=".fullscreenfile.FullScreenMediaActivity"
android:configChanges="orientation|keyboardHidden|screenSize"
android:theme="@style/FullScreenMediaTheme" />
<activity
android:name=".fullscreenfile.FullScreenTextViewerActivity"
android:configChanges="orientation|keyboardHidden|screenSize"
android:theme="@style/FullScreenTextTheme" />
<activity
android:name=".activities.TakePhotoActivity"
android:theme="@style/TakePhotoTheme"
android:windowSoftInputMode="stateHidden" />
<activity
android:name=".shareditems.activities.SharedItemsActivity"
android:theme="@style/AppTheme" />
<activity
android:name=".remotefilebrowser.activities.RemoteFileBrowserActivity"
android:theme="@style/AppTheme" />
<activity
android:name=".messagesearch.MessageSearchActivity"
android:theme="@style/AppTheme" />
<activity
android:name=".location.LocationPickerActivity"
android:theme="@style/AppTheme" />
<activity
android:name=".location.GeocodingActivity"
android:theme="@style/AppTheme" />
<activity
android:name=".translate.ui.TranslateActivity"
android:theme="@style/AppTheme" />
<activity
android:name=".profile.ProfileActivity"
android:theme="@style/AppTheme" />
<activity
android:name=".settings.SettingsActivity"
android:theme="@style/AppTheme" />
<activity
android:name=".diagnose.DiagnoseActivity"
android:theme="@style/AppTheme" />
<activity
android:name=".conversationinfo.ConversationInfoActivity"
android:theme="@style/AppTheme" />
<activity
android:name=".conversationinfoedit.ConversationInfoEditActivity"
android:theme="@style/AppTheme" />
<activity
android:name=".openconversations.ListOpenConversationsActivity"
android:theme="@style/AppTheme" />
<activity
android:name=".invitation.InvitationsActivity"
android:theme="@style/AppTheme" />
<activity
android:name=".lock.LockedActivity"
android:theme="@style/AppTheme" />
<activity
android:name=".threadsoverview.ThreadsOverviewActivity"
android:theme="@style/AppTheme" />
<receiver
android:name=".receivers.PackageReplacedReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
<receiver android:name=".receivers.DirectReplyReceiver" />
<receiver android:name=".receivers.MarkAsReadReceiver" />
<receiver android:name=".receivers.DismissRecordingAvailableReceiver" />
<receiver android:name=".receivers.ShareRecordingToChatReceiver" />
<service
android:name=".utils.SyncService"
android:exported="true">
<intent-filter>
<action android:name="android.content.SyncAdapter" />
</intent-filter>
<meta-data
android:name="android.content.SyncAdapter"
android:resource="@xml/syncadapter" />
<meta-data
android:name="android.provider.CONTACTS_STRUCTURE"
android:resource="@xml/contacts" />
</service>
<service
android:name=".utils.AuthenticatorService"
android:exported="false">
<intent-filter>
<action android:name="android.accounts.AccountAuthenticator" />
</intent-filter>
<meta-data
android:name="android.accounts.AccountAuthenticator"
android:resource="@xml/auth" />
</service>
<service
android:name=".services.CallForegroundService"
android:exported="false"
android:foregroundServiceType="microphone|camera" />
<provider
android:name="ru.f7cloud.talk.utils.TalkFileProvider"
android:authorities="${applicationId}.talk.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_provider_paths" />
</provider>
</application>
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:mimeType="*/*" />
</intent>
<intent>
<action android:name="android.intent.action.SEND" />
<data android:mimeType="*/*" />
</intent>
<intent>
<action android:name="android.media.action.VIDEO_CAPTURE" />
</intent>
</queries>
</manifest>
@@ -0,0 +1,42 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv='content-Type' content='text/html; charset=UTF-8' />
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.7.1/dist/leaflet.css" />
<style>
html, body, #map {
height: 100%;
}
body {
padding: 0;
margin: 0;
}
</style>
</head>
<body>
<div id="map" ></div>
<script src="https://unpkg.com/leaflet@1.7.1/dist/leaflet.js"></script>
<script>
var queryString = window.location.search;
const urlParams = new URLSearchParams(queryString);
var locationLat = urlParams.get('locationLat')
var locationLon = urlParams.get('locationLon')
var locationGeoLink = urlParams.get('locationGeoLink')
var mapProviderUrl = urlParams.get('mapProviderUrl')
var mapProviderAttribution = urlParams.get('mapProviderAttribution')
var map = L.map('map', {
zoomControl: false,
scrollWheelZoom: false
}).setView([locationLat, locationLon], 13);
map.dragging.disable();
L.tileLayer(mapProviderUrl, {
attribution: '&copy; ' + mapProviderAttribution
}).addTo(map);
L.marker([locationLat, locationLon]).addTo(map);
</script>
</body>
</html>
@@ -0,0 +1,2 @@
SPDX-FileCopyrightText: 2021 Marcel Hibbe <dev@mhibbe.de>
SPDX-License-Identifier: GPL-3.0-or-later
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

@@ -0,0 +1,296 @@
/*
* Nominatim Java API client
*
* SPDX-FileCopyrightText: 2010 - 2014 Dudie
* SPDX-License-Identifier: LGPL-3.0-or-later
*/
package fr.dudie.nominatim.client;
import android.util.Log;
import com.github.filosganga.geogson.gson.GeometryAdapterFactory;
import com.github.filosganga.geogson.jts.JtsAdapterFactory;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.reflect.TypeToken;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.util.ArrayList;
import java.util.List;
import fr.dudie.nominatim.client.request.NominatimLookupRequest;
import fr.dudie.nominatim.client.request.NominatimReverseRequest;
import fr.dudie.nominatim.client.request.NominatimSearchRequest;
import fr.dudie.nominatim.client.request.paramhelper.OsmType;
import fr.dudie.nominatim.gson.ArrayOfAddressElementsDeserializer;
import fr.dudie.nominatim.gson.ArrayOfPolygonPointsDeserializer;
import fr.dudie.nominatim.gson.BoundingBoxDeserializer;
import fr.dudie.nominatim.gson.PolygonPointDeserializer;
import fr.dudie.nominatim.model.Address;
import fr.dudie.nominatim.model.BoundingBox;
import fr.dudie.nominatim.model.Element;
import fr.dudie.nominatim.model.PolygonPoint;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import okhttp3.ResponseBody;
/**
* An implementation of the Nominatim Api Service.
*
* @author Jérémie Huchet
* @author Sunil D S
* @author Andy Scherzinger
*/
public final class TalkJsonNominatimClient implements NominatimClient {
private static final String TAG = "TalkNominationClient";
/**
* UTF-8 encoding.
*/
public static final String ENCODING_UTF_8 = "UTF-8";
private final OkHttpClient httpClient;
/**
* Gson instance for Nominatim API calls.
*/
private final Gson gson;
/**
* The url to make search queries.
*/
private final String searchUrl;
/**
* The url for reverse geocoding.
*/
private final String reverseUrl;
/**
* The url for address lookup.
*/
private final String lookupUrl;
/**
* The default search options.
*/
private final NominatimOptions defaults;
/**
* Creates the json nominatim client.
*
* @param baseUrl the nominatim server url
* @param httpClient an HTTP client
* @param email an email to add in the HTTP requests parameters to "sign" them (see
* https://wiki.openstreetmap.org/wiki/Nominatim_usage_policy)
*/
public TalkJsonNominatimClient(final String baseUrl, final OkHttpClient httpClient, final String email) {
this(baseUrl, httpClient, email, new NominatimOptions());
}
/**
* Creates the json nominatim client.
*
* @param baseUrl the nominatim server url
* @param httpClient an HTTP client
* @param email an email to add in the HTTP requests parameters to "sign" them (see
* https://wiki.openstreetmap.org/wiki/Nominatim_usage_policy)
* @param defaults defaults options, they override null valued requests options
*/
public TalkJsonNominatimClient(final String baseUrl, final OkHttpClient httpClient, final String email, final NominatimOptions defaults) {
String emailEncoded;
try {
emailEncoded = URLEncoder.encode(email, ENCODING_UTF_8);
} catch (UnsupportedEncodingException e) {
emailEncoded = email;
}
this.searchUrl = String.format("%s/search?format=jsonv2&email=%s", baseUrl.replaceAll("/$", ""), emailEncoded);
this.reverseUrl = String.format("%s/reverse?format=jsonv2&email=%s", baseUrl.replaceAll("/$", ""), emailEncoded);
this.lookupUrl = String.format("%s/lookup?format=json&email=%s", baseUrl.replaceAll("/$", ""), emailEncoded);
Log.d(TAG, "API search URL: " + searchUrl);
Log.d(TAG, "API reverse URL: " + reverseUrl);
this.defaults = defaults;
// prepare gson instance
final GsonBuilder gsonBuilder = new GsonBuilder();
gsonBuilder.registerTypeAdapter(Element[].class, new ArrayOfAddressElementsDeserializer());
gsonBuilder.registerTypeAdapter(PolygonPoint.class, new PolygonPointDeserializer());
gsonBuilder.registerTypeAdapter(PolygonPoint[].class, new ArrayOfPolygonPointsDeserializer());
gsonBuilder.registerTypeAdapter(BoundingBox.class, new BoundingBoxDeserializer());
gsonBuilder.registerTypeAdapterFactory(new JtsAdapterFactory());
gsonBuilder.registerTypeAdapterFactory(new GeometryAdapterFactory());
gson = gsonBuilder.create();
// prepare httpclient
this.httpClient = httpClient;
}
/**
* {@inheritDoc}
*
* @see fr.dudie.nominatim.client.NominatimClient#search(fr.dudie.nominatim.client.request.NominatimSearchRequest)
*/
@Override
public List<Address> search(final NominatimSearchRequest search) throws IOException {
defaults.mergeTo(search);
final String apiCall = String.format("%s&%s", searchUrl, search.getQueryString());
Log.d(TAG, "search url: " + apiCall);
Request requesthttp = new Request.Builder()
.addHeader("accept", "application/json")
.url(apiCall)
.build();
Response response = httpClient.newCall(requesthttp).execute();
if (response.isSuccessful()) {
ResponseBody responseBody = response.body();
if (responseBody != null) {
return gson.fromJson(responseBody.string(), new TypeToken<List<Address>>() {
}.getType());
}
}
return new ArrayList<>();
}
/**
* {@inheritDoc}
*
* @see fr.dudie.nominatim.client.NominatimClient#getAddress(fr.dudie.nominatim.client.request.NominatimReverseRequest)
*/
@Override
public Address getAddress(final NominatimReverseRequest reverse) throws IOException {
final String apiCall = String.format("%s&%s", reverseUrl, reverse.getQueryString());
Log.d(TAG, "reverse geocoding url: " + apiCall);
Request requesthttp = new Request.Builder()
.addHeader("accept", "application/json")
.url(apiCall)
.build();
Response response = httpClient.newCall(requesthttp).execute();
if (response.isSuccessful()) {
ResponseBody responseBody = response.body();
if (responseBody != null) {
return gson.fromJson(responseBody.string(), Address.class);
}
}
return null;
}
/**
* {@inheritDoc}
*
* @see fr.dudie.nominatim.client.NominatimClient#lookupAddress(fr.dudie.nominatim.client.request.NominatimLookupRequest)
*/
@Override
public List<Address> lookupAddress(final NominatimLookupRequest lookup) throws IOException {
final String apiCall = String.format("%s&%s", lookupUrl, lookup.getQueryString());
Log.d(TAG, "lookup url: " + apiCall);
Request requesthttp = new Request.Builder()
.addHeader("accept", "application/json")
.url(apiCall)
.build();
Response response = httpClient.newCall(requesthttp).execute();
if (response.isSuccessful()) {
ResponseBody responseBody = response.body();
if (responseBody != null) {
return gson.fromJson(responseBody.string(), new TypeToken<List<Address>>() {
}.getType());
}
}
return new ArrayList<>();
}
/**
* {@inheritDoc}
*
* @see fr.dudie.nominatim.client.NominatimClient#search(java.lang.String)
*/
@Override
public List<Address> search(final String query) throws IOException {
final NominatimSearchRequest q = new NominatimSearchRequest();
q.setQuery(query);
return this.search(q);
}
/**
* {@inheritDoc}
*
* @see fr.dudie.nominatim.client.NominatimClient#getAddress(double, double)
*/
@Override
public Address getAddress(final double longitude, final double latitude) throws IOException {
final NominatimReverseRequest q = new NominatimReverseRequest();
q.setQuery(longitude, latitude);
return this.getAddress(q);
}
/**
* {@inheritDoc}
*
* @see fr.dudie.nominatim.client.NominatimClient#getAddress(double, double, int)
*/
@Override
public Address getAddress(final double longitude, final double latitude, final int zoom)
throws IOException {
final NominatimReverseRequest q = new NominatimReverseRequest();
q.setQuery(longitude, latitude);
q.setZoom(zoom);
return this.getAddress(q);
}
/**
* {@inheritDoc}
*
* @see fr.dudie.nominatim.client.NominatimClient#getAddress(int, int)
*/
@Override
public Address getAddress(final int longitudeE6, final int latitudeE6) throws IOException {
return this.getAddress((longitudeE6 / 1E6), (latitudeE6 / 1E6));
}
/**
* {@inheritDoc}
*
* @see fr.dudie.nominatim.client.NominatimClient#getAddress(String, long)
*/
@Override
public Address getAddress(final String type, final long id) throws IOException {
final NominatimReverseRequest q = new NominatimReverseRequest();
q.setQuery(OsmType.from(type), id);
return this.getAddress(q);
}
/**
* {@inheritDoc}
*
* @see fr.dudie.nominatim.client.NominatimClient#lookupAddress(java.util.List)
*/
@Override
public List<Address> lookupAddress(final List<String> typeId) throws IOException {
final NominatimLookupRequest q = new NominatimLookupRequest();
q.setQuery(typeId);
return this.lookupAddress(q);
}
}
@@ -0,0 +1,12 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2025 Sowjanya Kota <sowjanya.kch@gmail.com>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk
object PhoneUtils {
fun isPhoneNumber(input: String?): Boolean = input?.matches(Regex("^\\+?\\d+$")) == true
}
@@ -0,0 +1,521 @@
/*
* F7cloud 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 ru.f7cloud.talk.account
import android.annotation.SuppressLint
import android.content.Intent
import android.content.pm.ActivityInfo
import android.os.Bundle
import android.os.Handler
import android.text.TextUtils
import android.util.Log
import android.widget.Toast
import androidx.work.Data
import androidx.work.OneTimeWorkRequest
import androidx.work.WorkInfo
import androidx.work.WorkManager
import autodagger.AutoInjector
import com.bluelinelabs.logansquare.LoganSquare
import com.google.android.material.snackbar.Snackbar
import ru.f7cloud.talk.R
import ru.f7cloud.talk.activities.BaseActivity
import ru.f7cloud.talk.api.NcApi
import ru.f7cloud.talk.application.F7cloudTalkApplication
import ru.f7cloud.talk.application.F7cloudTalkApplication.Companion.sharedApplication
import ru.f7cloud.talk.conversationlist.ConversationsListActivity
import ru.f7cloud.talk.data.user.model.User
import ru.f7cloud.talk.databinding.ActivityAccountVerificationBinding
import ru.f7cloud.talk.events.EventStatus
import ru.f7cloud.talk.jobs.AccountRemovalWorker
import ru.f7cloud.talk.jobs.CapabilitiesWorker
import ru.f7cloud.talk.jobs.SignalingSettingsWorker
import ru.f7cloud.talk.jobs.WebsocketConnectionsWorker
import ru.f7cloud.talk.models.json.capabilities.CapabilitiesOverall
import ru.f7cloud.talk.models.json.generic.Status
import ru.f7cloud.talk.models.json.userprofile.UserProfileOverall
import ru.f7cloud.talk.users.UserManager
import ru.f7cloud.talk.utils.ApiUtils
import ru.f7cloud.talk.utils.ClosedInterfaceImpl
import ru.f7cloud.talk.utils.UriUtils
import ru.f7cloud.talk.utils.bundle.BundleKeys.KEY_BASE_URL
import ru.f7cloud.talk.utils.bundle.BundleKeys.KEY_INTERNAL_USER_ID
import ru.f7cloud.talk.utils.bundle.BundleKeys.KEY_IS_ACCOUNT_IMPORT
import ru.f7cloud.talk.utils.bundle.BundleKeys.KEY_ORIGINAL_PROTOCOL
import ru.f7cloud.talk.utils.bundle.BundleKeys.KEY_PASSWORD
import ru.f7cloud.talk.utils.bundle.BundleKeys.KEY_TOKEN
import ru.f7cloud.talk.utils.bundle.BundleKeys.KEY_USERNAME
import ru.f7cloud.talk.utils.singletons.ApplicationWideMessageHolder
import io.reactivex.MaybeObserver
import io.reactivex.Observer
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.Disposable
import io.reactivex.schedulers.Schedulers
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import java.net.CookieManager
import javax.inject.Inject
@AutoInjector(F7cloudTalkApplication::class)
class AccountVerificationActivity : BaseActivity() {
private lateinit var binding: ActivityAccountVerificationBinding
@Inject
lateinit var ncApi: NcApi
@Inject
lateinit var userManager: UserManager
@Inject
lateinit var cookieManager: CookieManager
private var internalAccountId: Long = -1
private val disposables: MutableList<Disposable> = ArrayList()
private var baseUrl: String? = null
private var username: String? = null
private var token: String? = null
private var isAccountImport = false
private var originalProtocol: String? = null
@SuppressLint("SourceLockedOrientationActivity")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
sharedApplication!!.componentApplication.inject(this)
binding = ActivityAccountVerificationBinding.inflate(layoutInflater)
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
setContentView(binding.root)
actionBar?.hide()
initSystemBars()
handleIntent()
}
private fun handleIntent() {
val extras = intent.extras!!
baseUrl = extras.getString(KEY_BASE_URL)
username = extras.getString(KEY_USERNAME)
token = extras.getString(KEY_TOKEN)
if (extras.containsKey(KEY_IS_ACCOUNT_IMPORT)) {
isAccountImport = true
}
if (extras.containsKey(KEY_ORIGINAL_PROTOCOL)) {
originalProtocol = extras.getString(KEY_ORIGINAL_PROTOCOL)
}
}
override fun onResume() {
super.onResume()
if (
isAccountImport &&
!UriUtils.hasHttpProtocolPrefixed(baseUrl!!) ||
isNotSameProtocol(baseUrl!!, originalProtocol)
) {
determineBaseUrlProtocol(true)
} else {
findServerTalkApp()
}
}
private fun isNotSameProtocol(baseUrl: String, originalProtocol: String?): Boolean {
if (originalProtocol == null) {
return true
}
return !TextUtils.isEmpty(originalProtocol) && !baseUrl.startsWith(originalProtocol)
}
private fun determineBaseUrlProtocol(checkForcedHttps: Boolean) {
cookieManager.cookieStore.removeAll()
baseUrl = baseUrl!!.replace("http://", "").replace("https://", "")
val queryUrl: String = if (checkForcedHttps) {
"https://" + baseUrl + ApiUtils.getUrlPostfixForStatus()
} else {
"http://" + baseUrl + ApiUtils.getUrlPostfixForStatus()
}
ncApi.getServerStatus(queryUrl)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(object : Observer<Status?> {
override fun onSubscribe(d: Disposable) {
disposables.add(d)
}
override fun onNext(status: Status) {
baseUrl = if (checkForcedHttps) {
"https://$baseUrl"
} else {
"http://$baseUrl"
}
if (isAccountImport) {
val bundle = Bundle()
bundle.putString(KEY_BASE_URL, baseUrl)
bundle.putString(KEY_USERNAME, username)
bundle.putString(KEY_PASSWORD, "")
val intent = Intent(context, BrowserLoginActivity::class.java)
intent.putExtras(bundle)
startActivity(intent)
} else {
findServerTalkApp()
}
}
override fun onError(e: Throwable) {
if (checkForcedHttps) {
determineBaseUrlProtocol(false)
} else {
abortVerification()
}
}
override fun onComplete() {
// unused atm
}
})
}
private fun findServerTalkApp() {
val credentials = ApiUtils.getCredentials(username, token)
cookieManager.cookieStore.removeAll()
ncApi.getCapabilities(credentials, ApiUtils.getUrlForCapabilities(baseUrl!!))
.subscribeOn(Schedulers.io())
.subscribe(object : Observer<CapabilitiesOverall> {
override fun onSubscribe(d: Disposable) {
disposables.add(d)
}
override fun onNext(capabilitiesOverall: CapabilitiesOverall) {
val hasTalk =
capabilitiesOverall.ocs!!.data!!.capabilities != null &&
capabilitiesOverall.ocs!!.data!!.capabilities!!.spreedCapability != null &&
capabilitiesOverall.ocs!!.data!!.capabilities!!.spreedCapability!!.features != null &&
!capabilitiesOverall.ocs!!.data!!.capabilities!!.spreedCapability!!.features!!.isEmpty()
if (hasTalk) {
fetchProfile(credentials!!, capabilitiesOverall)
} else {
if (resources != null) {
runOnUiThread {
binding.progressText.text = String.format(
resources!!.getString(R.string.nc_f7cloud_talk_app_not_installed),
resources!!.getString(R.string.nc_app_product_name)
)
}
}
ApplicationWideMessageHolder.getInstance().messageType =
ApplicationWideMessageHolder.MessageType.SERVER_WITHOUT_TALK
abortVerification()
}
}
override fun onError(e: Throwable) {
if (resources != null) {
runOnUiThread {
binding.progressText.text = String.format(
resources!!.getString(R.string.nc_f7cloud_talk_app_not_installed),
resources!!.getString(R.string.nc_app_product_name)
)
}
}
ApplicationWideMessageHolder.getInstance().messageType =
ApplicationWideMessageHolder.MessageType.SERVER_WITHOUT_TALK
abortVerification()
}
override fun onComplete() {
// unused atm
}
})
}
private fun storeProfile(displayName: String?, userId: String, capabilitiesOverall: CapabilitiesOverall) {
userManager.storeProfile(
username,
UserManager.UserAttributes(
id = null,
serverUrl = baseUrl,
currentUser = true,
userId = userId,
token = token,
displayName = displayName,
pushConfigurationState = null,
capabilities = LoganSquare.serialize(capabilitiesOverall.ocs!!.data!!.capabilities),
serverVersion = LoganSquare.serialize(capabilitiesOverall.ocs!!.data!!.serverVersion),
certificateAlias = appPreferences.temporaryClientCertAlias,
externalSignalingServer = null
)
)
.subscribeOn(Schedulers.io())
.subscribe(object : MaybeObserver<User> {
override fun onSubscribe(d: Disposable) {
disposables.add(d)
}
@SuppressLint("SetTextI18n")
override fun onSuccess(user: User) {
internalAccountId = user.id!!
if (ClosedInterfaceImpl().isGooglePlayServicesAvailable) {
ClosedInterfaceImpl().setUpPushTokenRegistration()
} else {
Log.w(TAG, "Skipping push registration.")
runOnUiThread {
binding.progressText.text =
""" ${binding.progressText.text}
${resources!!.getString(R.string.nc_push_disabled)}
""".trimIndent()
}
fetchAndStoreCapabilities()
}
}
@SuppressLint("SetTextI18n")
override fun onError(e: Throwable) {
binding.progressText.text = """ ${binding.progressText.text}""".trimIndent() +
resources!!.getString(R.string.nc_display_name_not_stored)
abortVerification()
}
override fun onComplete() {
// unused atm
}
})
}
private fun fetchProfile(credentials: String, capabilitiesOverall: CapabilitiesOverall) {
ncApi.getUserProfile(
credentials,
ApiUtils.getUrlForUserProfile(baseUrl!!)
)
.subscribeOn(Schedulers.io())
.subscribe(object : Observer<UserProfileOverall> {
override fun onSubscribe(d: Disposable) {
disposables.add(d)
}
@SuppressLint("SetTextI18n")
override fun onNext(userProfileOverall: UserProfileOverall) {
var displayName: String? = null
if (!TextUtils.isEmpty(userProfileOverall.ocs!!.data!!.displayName)) {
displayName = userProfileOverall.ocs!!.data!!.displayName
} else if (!TextUtils.isEmpty(userProfileOverall.ocs!!.data!!.displayNameAlt)) {
displayName = userProfileOverall.ocs!!.data!!.displayNameAlt
}
if (!TextUtils.isEmpty(displayName)) {
storeProfile(
displayName,
userProfileOverall.ocs!!.data!!.userId!!,
capabilitiesOverall
)
} else {
runOnUiThread {
binding.progressText.text =
"""
${binding.progressText.text}
${resources!!.getString(R.string.nc_display_name_not_fetched)}
""".trimIndent()
}
abortVerification()
}
}
@SuppressLint("SetTextI18n")
override fun onError(e: Throwable) {
runOnUiThread {
binding.progressText.text =
"""
${binding.progressText.text}
${resources!!.getString(R.string.nc_display_name_not_fetched)}
""".trimIndent()
}
abortVerification()
}
override fun onComplete() {
// unused atm
}
})
}
@SuppressLint("SetTextI18n")
@Subscribe(threadMode = ThreadMode.BACKGROUND)
fun onMessageEvent(eventStatus: EventStatus) {
Log.d(TAG, "caught EventStatus of type " + eventStatus.eventType.toString())
if (eventStatus.eventType == EventStatus.EventType.PUSH_REGISTRATION) {
if (internalAccountId == eventStatus.userId && !eventStatus.isAllGood) {
runOnUiThread {
binding.progressText.text =
"""
${binding.progressText.text}
${resources!!.getString(R.string.nc_push_disabled)}
""".trimIndent()
}
}
fetchAndStoreCapabilities()
} else if (eventStatus.eventType == EventStatus.EventType.CAPABILITIES_FETCH) {
if (internalAccountId == eventStatus.userId && !eventStatus.isAllGood) {
runOnUiThread {
binding.progressText.text =
"""
${binding.progressText.text}
${resources!!.getString(R.string.nc_capabilities_failed)}
""".trimIndent()
}
abortVerification()
} else if (internalAccountId == eventStatus.userId && eventStatus.isAllGood) {
fetchAndStoreExternalSignalingSettings()
}
} else if (eventStatus.eventType == EventStatus.EventType.SIGNALING_SETTINGS) {
if (internalAccountId == eventStatus.userId && !eventStatus.isAllGood) {
runOnUiThread {
binding.progressText.text =
"""
${binding.progressText.text}
${resources!!.getString(R.string.nc_external_server_failed)}
""".trimIndent()
}
}
proceedWithLogin()
}
}
private fun fetchAndStoreCapabilities() {
val userData =
Data.Builder()
.putLong(KEY_INTERNAL_USER_ID, internalAccountId)
.build()
val capabilitiesWork =
OneTimeWorkRequest.Builder(CapabilitiesWorker::class.java)
.setInputData(userData)
.build()
WorkManager.getInstance().enqueue(capabilitiesWork)
}
private fun fetchAndStoreExternalSignalingSettings() {
val userData =
Data.Builder()
.putLong(KEY_INTERNAL_USER_ID, internalAccountId)
.build()
val signalingSettingsWorker = OneTimeWorkRequest.Builder(SignalingSettingsWorker::class.java)
.setInputData(userData)
.build()
val websocketConnectionsWorker = OneTimeWorkRequest.Builder(WebsocketConnectionsWorker::class.java).build()
WorkManager.getInstance(applicationContext!!)
.beginWith(signalingSettingsWorker)
.then(websocketConnectionsWorker)
.enqueue()
}
private fun proceedWithLogin() {
cookieManager.cookieStore.removeAll()
if (userManager.users.blockingGet().size == 1 ||
currentUserProviderOld.currentUser.blockingGet().id != internalAccountId
) {
val userToSetAsActive = userManager.getUserWithId(internalAccountId).blockingGet()
Log.d(TAG, "userToSetAsActive: " + userToSetAsActive.username)
if (userManager.setUserAsActive(userToSetAsActive).blockingGet()) {
runOnUiThread {
if (userManager.users.blockingGet().size == 1) {
val intent = Intent(context, ConversationsListActivity::class.java)
startActivity(intent)
} else {
if (isAccountImport) {
ApplicationWideMessageHolder.getInstance().messageType =
ApplicationWideMessageHolder.MessageType.ACCOUNT_WAS_IMPORTED
}
val intent = Intent(context, ConversationsListActivity::class.java)
startActivity(intent)
}
}
} else {
Log.e(TAG, "failed to set active user")
Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show()
}
} else {
Log.d(TAG, "continuing proceedWithLogin was skipped for this user")
}
}
private fun dispose() {
for (i in disposables.indices) {
if (!disposables[i].isDisposed) {
disposables[i].dispose()
}
}
}
public override fun onDestroy() {
dispose()
super.onDestroy()
}
private fun abortVerification() {
if (isAccountImport) {
ApplicationWideMessageHolder.getInstance().messageType = ApplicationWideMessageHolder.MessageType
.FAILED_TO_IMPORT_ACCOUNT
runOnUiThread {
Handler().postDelayed({
val intent = Intent(this, ServerSelectionActivity::class.java)
startActivity(intent)
}, DELAY_IN_MILLIS)
}
} else {
if (internalAccountId != -1L) {
runOnUiThread {
deleteUserAndStartServerSelection(internalAccountId)
}
} else {
runOnUiThread {
Handler().postDelayed({
val intent = Intent(this, ServerSelectionActivity::class.java)
startActivity(intent)
}, DELAY_IN_MILLIS)
}
}
}
}
@SuppressLint("CheckResult")
private fun deleteUserAndStartServerSelection(userId: Long) {
userManager.scheduleUserForDeletionWithId(userId).blockingGet()
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 -> {
val intent = Intent(this, ServerSelectionActivity::class.java)
startActivity(intent)
}
WorkInfo.State.FAILED, WorkInfo.State.CANCELLED -> {
Toast.makeText(
context,
context.resources.getString(R.string.nc_common_error_sorry),
Toast.LENGTH_LONG
).show()
Log.e(TAG, "something went wrong when deleting user with id $userId")
val intent = Intent(this, ServerSelectionActivity::class.java)
startActivity(intent)
}
else -> {}
}
}
}
companion object {
private val TAG = AccountVerificationActivity::class.java.simpleName
const val DELAY_IN_MILLIS: Long = 7500
}
}
@@ -0,0 +1,177 @@
/*
* F7cloud 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 ru.f7cloud.talk.account
import android.annotation.SuppressLint
import android.content.Intent
import android.content.pm.ActivityInfo
import android.os.Bundle
import androidx.activity.OnBackPressedCallback
import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import autodagger.AutoInjector
import com.google.android.material.snackbar.Snackbar
import ru.f7cloud.talk.R
import ru.f7cloud.talk.account.data.LoginRepository
import ru.f7cloud.talk.account.viewmodels.BrowserLoginActivityViewModel
import ru.f7cloud.talk.activities.BaseActivity
import ru.f7cloud.talk.activities.MainActivity
import ru.f7cloud.talk.application.F7cloudTalkApplication
import ru.f7cloud.talk.application.F7cloudTalkApplication.Companion.sharedApplication
import ru.f7cloud.talk.databinding.ActivityWebViewLoginBinding
import ru.f7cloud.talk.utils.bundle.BundleKeys
import ru.f7cloud.talk.utils.bundle.BundleKeys.KEY_BASE_URL
import kotlinx.coroutines.launch
import javax.inject.Inject
@AutoInjector(F7cloudTalkApplication::class)
class BrowserLoginActivity : BaseActivity() {
private lateinit var binding: ActivityWebViewLoginBinding
@Inject
lateinit var viewModel: BrowserLoginActivityViewModel
private var reauthorizeAccount = false
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)
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
setContentView(binding.root)
actionBar?.hide()
initSystemBars()
initViews()
onBackPressedDispatcher.addCallback(this, onBackPressedCallback)
handleIntent()
observe()
}
init {
sharedApplication!!.componentApplication.inject(this)
}
private fun observe() {
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.initialLoginRequestState.collect { state ->
when (state) {
BrowserLoginActivityViewModel.InitialLoginViewState.InitialLoginRequestError -> {
Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_SHORT).show()
}
is BrowserLoginActivityViewModel.InitialLoginViewState.InitialLoginRequestSuccess -> {
if (viewModel.waitingForBrowserState.value) {
viewModel.setWaitingForBrowser(false)
viewModel.handleWebBrowserLogin()
} else {
viewModel.setWaitingForBrowser(true)
launchDefaultWebBrowser(state.loginUrl)
}
}
BrowserLoginActivityViewModel.InitialLoginViewState.None -> {}
}
}
}
}
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.postLoginState.collect { state ->
when (state) {
BrowserLoginActivityViewModel.PostLoginViewState.None -> {}
is BrowserLoginActivityViewModel.PostLoginViewState.PostLoginContinue -> {
if (!state.data.isEmpty) {
startAccountVerification(state.data)
}
}
BrowserLoginActivityViewModel.PostLoginViewState.PostLoginError -> {
Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_SHORT).show()
}
BrowserLoginActivityViewModel.PostLoginViewState.PostLoginRestartApp -> {
restartApp()
}
}
}
}
}
}
private fun handleIntent() {
val extras = intent.extras!!
val baseUrl = extras.getString(KEY_BASE_URL)
if (extras.containsKey(BundleKeys.KEY_REAUTHORIZE_ACCOUNT)) {
reauthorizeAccount = extras.getBoolean(BundleKeys.KEY_REAUTHORIZE_ACCOUNT)
}
if (extras.containsKey(BundleKeys.KEY_FROM_QR)) {
val uri = extras.getString(BundleKeys.KEY_FROM_QR)!!
if (uri.startsWith(LoginRepository.ONE_TIME_PREFIX)) {
viewModel.loginWithOTPQR(uri, reauthorizeAccount)
} else {
viewModel.loginWithQR(uri, reauthorizeAccount)
}
} else if (baseUrl != null) {
viewModel.startWebBrowserLogin(baseUrl, reauthorizeAccount)
}
}
private fun initViews() {
viewThemeUtils.material.colorMaterialButtonFilledOnPrimary(binding.cancelLoginBtn)
viewThemeUtils.material.colorProgressBar(binding.progressBar)
binding.cancelLoginBtn.setOnClickListener {
viewModel.cancelLogin()
onBackPressedDispatcher.onBackPressed()
}
}
private fun launchDefaultWebBrowser(url: String) {
val intent = Intent(Intent.ACTION_VIEW, url.toUri())
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
startActivity(intent)
}
private fun startAccountVerification(bundle: Bundle) {
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)
}
public override fun onDestroy() {
super.onDestroy()
}
override val appBarLayoutType: AppBarLayoutType
get() = AppBarLayoutType.EMPTY
companion object {
private val TAG = BrowserLoginActivity::class.java.simpleName
}
}
@@ -0,0 +1,468 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2023 Marcel Hibbe <dev@mhibbe.de>
* SPDX-FileCopyrightText: 2021 Andy Scherzinger <info@andy-scherzinger.de>
* SPDX-FileCopyrightText: 2017 Mario Danic <mario@lovelyhq.com>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.account
import android.Manifest
import android.accounts.Account
import android.annotation.SuppressLint
import android.content.Intent
import android.content.pm.ActivityInfo
import android.os.Bundle
import android.security.KeyChain
import android.text.TextUtils
import android.util.Log
import android.view.KeyEvent
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 ru.f7cloud.talk.R
import ru.f7cloud.talk.activities.BaseActivity
import ru.f7cloud.talk.api.NcApi
import ru.f7cloud.talk.application.F7cloudTalkApplication
import ru.f7cloud.talk.application.F7cloudTalkApplication.Companion.sharedApplication
import ru.f7cloud.talk.databinding.ActivityServerSelectionBinding
import ru.f7cloud.talk.models.json.capabilities.CapabilitiesOverall
import ru.f7cloud.talk.models.json.generic.Status
import ru.f7cloud.talk.users.UserManager
import ru.f7cloud.talk.utils.AccountUtils
import ru.f7cloud.talk.utils.ApiUtils
import ru.f7cloud.talk.utils.CapabilitiesUtil
import ru.f7cloud.talk.utils.UriUtils
import ru.f7cloud.talk.utils.bundle.BundleKeys
import ru.f7cloud.talk.utils.bundle.BundleKeys.ADD_ADDITIONAL_ACCOUNT
import ru.f7cloud.talk.utils.bundle.BundleKeys.KEY_IS_ACCOUNT_IMPORT
import ru.f7cloud.talk.utils.singletons.ApplicationWideMessageHolder
import io.reactivex.Observer
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.Disposable
import io.reactivex.schedulers.Schedulers
import java.security.cert.CertificateException
import javax.inject.Inject
@Suppress("TooManyFunctions")
@AutoInjector(F7cloudTalkApplication::class)
class ServerSelectionActivity : BaseActivity() {
private lateinit var binding: ActivityServerSelectionBinding
@Inject
lateinit var ncApi: NcApi
@Inject
lateinit var userManager: UserManager
private var statusQueryDisposable: Disposable? = null
private val onBackPressedCallback = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
if (intent.hasExtra(ADD_ADDITIONAL_ACCOUNT) && intent.getBooleanExtra(ADD_ADDITIONAL_ACCOUNT, false)) {
finish()
} else {
finishAffinity()
}
}
}
@SuppressLint("SourceLockedOrientationActivity")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
sharedApplication!!.componentApplication.inject(this)
binding = ActivityServerSelectionBinding.inflate(layoutInflater)
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
setContentView(binding.root)
actionBar?.hide()
initSystemBars()
onBackPressedDispatcher.addCallback(this, onBackPressedCallback)
}
override fun onResume() {
super.onResume()
binding.hostUrlInputHelperText.text = String.format(
resources!!.getString(R.string.nc_server_helper_text),
resources!!.getString(R.string.nc_server_product_name)
)
binding.serverEntryTextInputLayout.setEndIconOnClickListener { checkServerAndProceed() }
if (resources!!.getBoolean(R.bool.hide_auth_cert)) {
binding.certTextView.visibility = View.GONE
}
val loggedInUsers = userManager.users.blockingGet()
val availableAccounts = AccountUtils.findAvailableAccountsOnDevice(loggedInUsers)
if (isImportAccountNameSet() && availableAccounts.isNotEmpty()) {
showImportAccountsInfo(availableAccounts)
} else if (isAbleToShowProviderLink() && loggedInUsers.isEmpty()) {
showVisitProvidersInfo()
} else {
binding.importOrChooseProviderText.visibility = View.INVISIBLE
}
binding.serverEntryTextInputEditText.requestFocus()
if (!TextUtils.isEmpty(resources!!.getString(R.string.weblogin_url))) {
binding.serverEntryTextInputEditText.setText(resources!!.getString(R.string.weblogin_url))
checkServerAndProceed()
}
binding.serverEntryTextInputEditText.setOnEditorActionListener { _: TextView?, i: Int, _: KeyEvent? ->
if (i == EditorInfo.IME_ACTION_DONE) {
checkServerAndProceed()
}
false
}
binding.certTextView.setOnClickListener { onCertClick() }
binding.scanQr.setOnClickListener { onScan() }
if (ApplicationWideMessageHolder.getInstance().messageType != null) {
if (ApplicationWideMessageHolder.getInstance().messageType
== ApplicationWideMessageHolder.MessageType.SERVER_WITHOUT_TALK
) {
setErrorText(resources!!.getString(R.string.nc_settings_no_talk_installed))
} else if (ApplicationWideMessageHolder.getInstance().messageType
== ApplicationWideMessageHolder.MessageType.FAILED_TO_IMPORT_ACCOUNT
) {
setErrorText(resources!!.getString(R.string.nc_server_failed_to_import_account))
}
ApplicationWideMessageHolder.getInstance().messageType = null
}
setCertTextView()
}
fun onCertClick() {
KeyChain.choosePrivateKeyAlias(
this,
{ alias: String? ->
if (alias != null) {
appPreferences.temporaryClientCertAlias = alias
} else {
appPreferences.removeTemporaryClientCertAlias()
}
setCertTextView()
},
arrayOf("RSA", "EC"),
null,
null,
-1,
null
)
}
private fun isAbleToShowProviderLink(): Boolean =
!resources!!.getBoolean(R.bool.hide_provider) &&
!TextUtils.isEmpty(resources!!.getString(R.string.nc_providers_url))
private fun showImportAccountsInfo(availableAccounts: List<Account>) {
if (!TextUtils.isEmpty(
AccountUtils.getAppNameBasedOnPackage(resources!!.getString(R.string.nc_import_accounts_from))
)
) {
if (availableAccounts.size > 1) {
binding.importOrChooseProviderText.text = String.format(
resources!!.getString(R.string.nc_server_import_accounts),
AccountUtils.getAppNameBasedOnPackage(resources!!.getString(R.string.nc_import_accounts_from))
)
} else {
binding.importOrChooseProviderText.text = String.format(
resources!!.getString(R.string.nc_server_import_account),
AccountUtils.getAppNameBasedOnPackage(resources!!.getString(R.string.nc_import_accounts_from))
)
}
} else {
if (availableAccounts.size > 1) {
binding.importOrChooseProviderText.text =
resources!!.getString(R.string.nc_server_import_accounts_plain)
} else {
binding.importOrChooseProviderText.text =
resources!!.getString(R.string.nc_server_import_account_plain)
}
}
binding.importOrChooseProviderText.setOnClickListener {
val bundle = Bundle()
bundle.putBoolean(KEY_IS_ACCOUNT_IMPORT, true)
val intent = Intent(context, SwitchAccountActivity::class.java)
intent.putExtras(bundle)
startActivity(intent)
}
}
private fun showVisitProvidersInfo() {
binding.importOrChooseProviderText.setText(R.string.nc_get_from_provider)
binding.importOrChooseProviderText.setOnClickListener {
val browserIntent = Intent(
Intent.ACTION_VIEW,
resources!!.getString(R.string.nc_providers_url).toUri()
)
startActivity(browserIntent)
}
}
private fun isImportAccountNameSet(): Boolean =
!TextUtils.isEmpty(resources!!.getString(R.string.nc_import_account_type))
@SuppressLint("LongLogTag")
@Suppress("Detekt.TooGenericExceptionCaught")
private fun checkServerAndProceed() {
dispose()
var url: String = binding.serverEntryTextInputEditText.text.toString().trim()
showserverEntryProgressBar()
if (binding.importOrChooseProviderText.visibility != View.INVISIBLE) {
binding.importOrChooseProviderText.visibility = View.INVISIBLE
binding.certTextView.visibility = View.INVISIBLE
}
if (url.endsWith("/")) {
url = url.substring(0, url.length - 1)
}
if (UriUtils.hasHttpProtocolPrefixed(url)) {
checkServer(url, false)
} else {
checkServer("https://$url", true)
}
}
private fun checkServer(url: String, checkForcedHttps: Boolean) {
val queryStatusUrl = url + ApiUtils.getUrlPostfixForStatus()
statusQueryDisposable = ncApi.getServerStatus(queryStatusUrl)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({ status: Status ->
val versionString: String = status.version!!.substring(0, status.version!!.indexOf("."))
val version: Int = versionString.toInt()
if (isServerStatusQueryable(status) && version >= MIN_SERVER_MAJOR_VERSION) {
findServerTalkApp(url)
} else {
showErrorTextForStatus(status)
}
}, { throwable: Throwable ->
if (checkForcedHttps) {
checkServer(queryStatusUrl.replace("https://", "http://"), false)
} else {
if (throwable.localizedMessage != null) {
setErrorText(throwable.localizedMessage)
} else if (throwable.cause is CertificateException) {
setErrorText(resources!!.getString(R.string.nc_certificate_error))
} else {
hideserverEntryProgressBar()
}
if (binding.importOrChooseProviderText.visibility != View.INVISIBLE) {
binding.importOrChooseProviderText.visibility = View.VISIBLE
binding.certTextView.visibility = View.VISIBLE
}
dispose()
}
}) {
hideserverEntryProgressBar()
if (binding.importOrChooseProviderText.visibility != View.INVISIBLE) {
binding.importOrChooseProviderText.visibility = View.VISIBLE
binding.certTextView.visibility = View.VISIBLE
}
dispose()
}
}
private fun showErrorTextForStatus(status: Status) {
if (!status.installed) {
setErrorText(
String.format(
resources!!.getString(R.string.nc_server_not_installed),
resources!!.getString(R.string.nc_server_product_name)
)
)
} else if (status.needsUpgrade) {
setErrorText(
String.format(
resources!!.getString(R.string.nc_server_db_upgrade_needed),
resources!!.getString(R.string.nc_server_product_name)
)
)
} else if (status.maintenance) {
setErrorText(
String.format(
resources!!.getString(R.string.nc_server_maintenance),
resources!!.getString(R.string.nc_server_product_name)
)
)
} else if (!status.version!!.startsWith("13.")) {
setErrorText(
String.format(
resources!!.getString(R.string.nc_server_version),
resources!!.getString(R.string.nc_app_product_name),
resources!!.getString(R.string.nc_server_product_name)
)
)
}
}
private fun findServerTalkApp(queryUrl: String) {
ncApi.getCapabilities(ApiUtils.getUrlForCapabilities(queryUrl))
.subscribeOn(Schedulers.io())
.subscribe(object : Observer<CapabilitiesOverall> {
override fun onSubscribe(d: Disposable) {
// unused atm
}
override fun onNext(capabilitiesOverall: CapabilitiesOverall) {
val capabilities = capabilitiesOverall.ocs?.data?.capabilities
val hasTalk =
capabilities?.spreedCapability != null &&
capabilities.spreedCapability?.features != null &&
capabilities.spreedCapability?.features?.isNotEmpty() == true
if (hasTalk) {
runOnUiThread {
if (CapabilitiesUtil.isServerEOL(capabilitiesOverall.ocs?.data?.serverVersion?.major)) {
if (resources != null) {
runOnUiThread {
setErrorText(resources!!.getString(R.string.nc_settings_server_eol))
}
}
} else {
val bundle = Bundle()
bundle.putString(BundleKeys.KEY_BASE_URL, queryUrl.replace("/status.php", ""))
val intent = Intent(context, BrowserLoginActivity::class.java)
intent.putExtras(bundle)
startActivity(intent)
}
}
} else {
if (resources != null) {
runOnUiThread {
setErrorText(resources!!.getString(R.string.nc_server_unsupported))
}
}
}
}
override fun onError(e: Throwable) {
Log.e(TAG, "Error while checking capabilities", e)
if (resources != null) {
runOnUiThread {
setErrorText(resources!!.getString(R.string.nc_common_error_sorry))
}
}
}
override fun onComplete() {
// unused atm
}
})
}
private fun isServerStatusQueryable(status: Status): Boolean =
status.installed && !status.maintenance && !status.needsUpgrade
private fun setErrorText(text: String?) {
binding.errorWrapper.visibility = View.VISIBLE
binding.errorText.text = text
hideserverEntryProgressBar()
}
private fun showserverEntryProgressBar() {
binding.errorWrapper.visibility = View.INVISIBLE
binding.serverEntryProgressBar.visibility = View.VISIBLE
}
private fun hideserverEntryProgressBar() {
binding.serverEntryProgressBar.visibility = View.INVISIBLE
}
@SuppressLint("LongLogTag")
private fun setCertTextView() {
runOnUiThread {
if (!TextUtils.isEmpty(appPreferences.temporaryClientCertAlias)) {
binding.certTextView.setText(R.string.nc_change_cert_auth)
} else {
binding.certTextView.setText(R.string.nc_configure_cert_auth)
}
hideserverEntryProgressBar()
}
}
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()
}
private fun dispose() {
if (statusQueryDisposable != null && !statusQueryDisposable!!.isDisposed) {
statusQueryDisposable!!.dispose()
}
statusQueryDisposable = null
}
override val appBarLayoutType: AppBarLayoutType
get() = AppBarLayoutType.EMPTY
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"
}
}
@@ -0,0 +1,181 @@
/*
* F7cloud 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 ru.f7cloud.talk.account
import android.accounts.Account
import android.annotation.SuppressLint
import android.content.Intent
import android.content.pm.ActivityInfo
import android.os.Bundle
import androidx.core.graphics.drawable.toDrawable
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.LinearLayoutManager
import autodagger.AutoInjector
import ru.f7cloud.talk.R
import ru.f7cloud.talk.activities.BaseActivity
import ru.f7cloud.talk.adapters.items.AdvancedUserItem
import ru.f7cloud.talk.application.F7cloudTalkApplication
import ru.f7cloud.talk.application.F7cloudTalkApplication.Companion.sharedApplication
import ru.f7cloud.talk.data.user.model.User
import ru.f7cloud.talk.databinding.ActivitySwitchAccountBinding
import ru.f7cloud.talk.models.ImportAccount
import ru.f7cloud.talk.models.json.participants.Participant
import ru.f7cloud.talk.users.UserManager
import ru.f7cloud.talk.utils.AccountUtils.findAvailableAccountsOnDevice
import ru.f7cloud.talk.utils.AccountUtils.getInformationFromAccount
import ru.f7cloud.talk.utils.bundle.BundleKeys.KEY_BASE_URL
import ru.f7cloud.talk.utils.bundle.BundleKeys.KEY_IS_ACCOUNT_IMPORT
import ru.f7cloud.talk.utils.bundle.BundleKeys.KEY_TOKEN
import ru.f7cloud.talk.utils.bundle.BundleKeys.KEY_USERNAME
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.common.SmoothScrollLinearLayoutManager
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
import org.osmdroid.config.Configuration
import java.net.CookieManager
import javax.inject.Inject
/**
* Parts related to account import were either copied from or inspired by the great work done by David Luhmer at:
* https://github.com/nextcloud/ownCloud-Account-Importer
*/
@AutoInjector(F7cloudTalkApplication::class)
class SwitchAccountActivity : BaseActivity() {
private lateinit var binding: ActivitySwitchAccountBinding
@Inject
lateinit var userManager: UserManager
@Inject
lateinit var cookieManager: CookieManager
private var adapter: FlexibleAdapter<AbstractFlexibleItem<*>>? = null
private val userItems: MutableList<AbstractFlexibleItem<*>> = ArrayList()
private var isAccountImport = false
private val onImportItemClickListener = FlexibleAdapter.OnItemClickListener { _, position ->
if (userItems.size > position) {
val account = (userItems[position] as AdvancedUserItem).account
reauthorizeFromImport(account)
}
true
}
private val onSwitchItemClickListener = FlexibleAdapter.OnItemClickListener { _, position ->
if (userItems.size > position) {
val user = (userItems[position] as AdvancedUserItem).user
if (userManager.setUserAsActive(user!!).blockingGet()) {
cookieManager.cookieStore.removeAll()
finish()
}
}
true
}
@SuppressLint("SourceLockedOrientationActivity")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
sharedApplication!!.componentApplication.inject(this)
binding = ActivitySwitchAccountBinding.inflate(layoutInflater)
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
setContentView(binding.root)
setupActionBar()
initSystemBars()
Configuration.getInstance().load(context, PreferenceManager.getDefaultSharedPreferences(context))
handleIntent()
}
private fun handleIntent() {
intent.extras?.let {
if (it.containsKey(KEY_IS_ACCOUNT_IMPORT)) {
isAccountImport = true
}
}
}
private fun setupActionBar() {
setSupportActionBar(binding.toolbar)
binding.toolbar.setNavigationOnClickListener {
onBackPressedDispatcher.onBackPressed()
}
supportActionBar?.setDisplayHomeAsUpEnabled(true)
supportActionBar?.setDisplayShowHomeEnabled(true)
supportActionBar?.setIcon(resources!!.getColor(R.color.transparent, null).toDrawable())
supportActionBar?.title = resources!!.getString(R.string.nc_select_an_account)
}
@Suppress("Detekt.NestedBlockDepth")
override fun onResume() {
super.onResume()
if (adapter == null) {
adapter = FlexibleAdapter(userItems, this, false)
var participant: Participant
if (!isAccountImport) {
for (user in userManager.users.blockingGet()) {
if (!user.current) {
val userId: String? = if (user.userId != null) {
user.userId
} else {
user.username
}
participant = Participant()
participant.actorType = Participant.ActorType.USERS
participant.actorId = userId
participant.displayName = user.displayName
userItems.add(AdvancedUserItem(participant, user, null, viewThemeUtils, 0))
}
}
adapter!!.addListener(onSwitchItemClickListener)
adapter!!.updateDataSet(userItems, false)
} else {
var account: Account
var importAccount: ImportAccount
var user: User
for (accountObject in findAvailableAccountsOnDevice(userManager.users.blockingGet())) {
account = accountObject
importAccount = getInformationFromAccount(account)
participant = Participant()
participant.actorType = Participant.ActorType.USERS
participant.actorId = importAccount.getUsername()
participant.displayName = importAccount.getUsername()
user = User()
user.baseUrl = importAccount.getBaseUrl()
userItems.add(AdvancedUserItem(participant, user, account, viewThemeUtils, 0))
}
adapter!!.addListener(onImportItemClickListener)
adapter!!.updateDataSet(userItems, false)
}
}
prepareViews()
}
private fun prepareViews() {
val layoutManager: LinearLayoutManager = SmoothScrollLinearLayoutManager(this)
binding.recyclerView.layoutManager = layoutManager
binding.recyclerView.setHasFixedSize(true)
binding.recyclerView.adapter = adapter
}
private fun reauthorizeFromImport(account: Account?) {
val importAccount = getInformationFromAccount(account!!)
val bundle = Bundle()
bundle.putString(KEY_BASE_URL, importAccount.getBaseUrl())
bundle.putString(KEY_USERNAME, importAccount.getUsername())
bundle.putString(KEY_TOKEN, importAccount.getToken())
bundle.putBoolean(KEY_IS_ACCOUNT_IMPORT, true)
val intent = Intent(context, AccountVerificationActivity::class.java)
intent.putExtras(bundle)
startActivity(intent)
}
}
@@ -0,0 +1,213 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2025 Julius Linus <juliuslinus1@gmail.com>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.account.data
import android.os.Bundle
import android.text.TextUtils
import android.util.Log
import ru.f7cloud.talk.account.data.io.LocalLoginDataSource
import ru.f7cloud.talk.account.data.model.LoginCompletion
import ru.f7cloud.talk.account.data.model.LoginResponse
import ru.f7cloud.talk.account.data.network.NetworkLoginDataSource
import ru.f7cloud.talk.utils.bundle.BundleKeys.KEY_BASE_URL
import ru.f7cloud.talk.utils.bundle.BundleKeys.KEY_ORIGINAL_PROTOCOL
import ru.f7cloud.talk.utils.bundle.BundleKeys.KEY_TOKEN
import ru.f7cloud.talk.utils.bundle.BundleKeys.KEY_USERNAME
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import okhttp3.Credentials
import java.net.URLDecoder
@Suppress("TooManyFunctions", "ReturnCount")
class LoginRepository(val network: NetworkLoginDataSource, val local: LocalLoginDataSource) {
companion object {
val TAG: String = LoginRepository::class.java.simpleName
private const val INTERVAL = 250L
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 = "f7://login/"
const val ONE_TIME_PREFIX = "f7://onetime-login/"
private const val MAX_ARGS = 3
}
private var shouldReauthorizeUser = false
private var shouldLoop = true
suspend fun pollLogin(response: LoginResponse): LoginCompletion? =
withContext(Dispatchers.IO) {
while (shouldLoop) {
val loginData = network.performLoginFlowV2(response)
if (loginData == null) {
break
}
if (loginData.status == HTTP_OK) {
return@withContext loginData
}
delay(INTERVAL) // No response yet, retry
}
return@withContext null
}
/**
* Entry point for QR scanner
*
*/
fun startLoginFlowFromQR(dataString: String, reAuth: Boolean = false): LoginCompletion? {
shouldReauthorizeUser = reAuth
if (!dataString.startsWith(PREFIX)) {
Log.e(TAG, "Invalid login URL detected")
return null
}
val data = dataString.removePrefix(PREFIX)
val values = data.split('&')
if (values.size !in 1..MAX_ARGS) {
Log.e(TAG, "Illegal number of login URL elements detected: ${values.size}")
return null
}
var server = ""
var loginName = ""
var appPassword = ""
values.forEach { value ->
when {
value.startsWith(USER_KEY) -> {
loginName = URLDecoder.decode(value.removePrefix(USER_KEY), "UTF-8")
}
value.startsWith(PASS_KEY) -> {
appPassword = URLDecoder.decode(value.removePrefix(PASS_KEY), "UTF-8")
}
value.startsWith(SERVER_KEY) -> {
server = URLDecoder.decode(value.removePrefix(SERVER_KEY), "UTF-8")
}
}
}
return if (server.isNotEmpty() && loginName.isNotEmpty() && appPassword.isNotEmpty()) {
LoginCompletion(HTTP_OK, server, loginName, appPassword)
} else {
null
}
}
/**
* Entry point for a one-time QR code
*/
suspend fun startOTPLoginFlow(dataString: String, reAuth: Boolean = false): LoginCompletion? =
withContext(Dispatchers.IO) {
shouldReauthorizeUser = reAuth
if (!dataString.startsWith(ONE_TIME_PREFIX)) {
Log.e(TAG, "Invalid login URL detected")
return@withContext null
}
val data = dataString.removePrefix(ONE_TIME_PREFIX)
val values = data.split('&')
if (values.size !in 1..MAX_ARGS) {
Log.e(TAG, "Illegal number of login URL elements detected: ${values.size}")
return@withContext null
}
var server = ""
var loginName = ""
var appPassword = ""
values.forEach { value ->
when {
value.startsWith(USER_KEY) -> {
loginName = URLDecoder.decode(value.removePrefix(USER_KEY), "UTF-8")
}
value.startsWith(PASS_KEY) -> {
appPassword = URLDecoder.decode(value.removePrefix(PASS_KEY), "UTF-8")
}
value.startsWith(SERVER_KEY) -> {
server = URLDecoder.decode(value.removePrefix(SERVER_KEY), "UTF-8")
}
}
}
// Need to use the qr code token to create temporary credentials to get access to the actual app password
val credentials = Credentials.basic(loginName, appPassword)
val oneTimePassword = network.oneTimePasswordRequest(server, credentials)
return@withContext if (server.isNotEmpty() && loginName.isNotEmpty() && oneTimePassword != null) {
LoginCompletion(HTTP_OK, server, loginName, oneTimePassword)
} else {
null
}
}
/**
* Entry point to the login process
*/
suspend fun startLoginFlow(baseUrl: String, reAuth: Boolean = false): LoginResponse? =
withContext(Dispatchers.IO) {
shouldReauthorizeUser = reAuth
val response = network.anonymouslyPostLoginRequest(baseUrl)
return@withContext response
}
/**
* Ends normal login process by canceling the polling
*/
fun cancelLoginFlow() {
shouldLoop = false
}
/**
* Returns bundle if user is not scheduled for deletion or doesn't already exist, null otherwise
*/
fun parseAndLogin(loginData: LoginCompletion): Bundle? {
if (local.checkIfUserIsScheduledForDeletion(loginData)) {
// however the user is not yet deleted, just start AccountRemovalWorker again to make sure to delete it.
local.startAccountRemovalWorker()
return null
} else if (local.checkIfUserExists(loginData)) {
if (shouldReauthorizeUser) {
local.updateUser(loginData)
} else {
Log.w(TAG, "Tried to add an account that account already exists. Skipped user creation.")
}
return null
} else {
return startAccountVerification(loginData)
}
}
private fun startAccountVerification(loginData: LoginCompletion): Bundle {
val bundle = Bundle()
bundle.putString(KEY_USERNAME, loginData.loginName)
bundle.putString(KEY_TOKEN, loginData.appPassword)
bundle.putString(KEY_BASE_URL, loginData.server)
var protocol = ""
if (loginData.server.startsWith("http://")) {
protocol = "http://"
} else if (loginData.server.startsWith("https://")) {
protocol = "https://"
}
if (!TextUtils.isEmpty(protocol)) {
bundle.putString(KEY_ORIGINAL_PROTOCOL, protocol)
}
return bundle
}
}
@@ -0,0 +1,45 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2025 Julius Linus <juliuslinus1@gmail.com>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.account.data.io
import android.content.Context
import androidx.lifecycle.LiveData
import androidx.work.OneTimeWorkRequest
import androidx.work.WorkInfo
import androidx.work.WorkManager
import ru.f7cloud.talk.account.data.model.LoginCompletion
import ru.f7cloud.talk.jobs.AccountRemovalWorker
import ru.f7cloud.talk.users.UserManager
import ru.f7cloud.talk.utils.preferences.AppPreferences
// local datasource for communicating with room through account manager
// crucial for making sure the login process interacts with the db as expected.
class LocalLoginDataSource(val userManager: UserManager, val appPreferences: AppPreferences, val context: Context) {
fun updateUser(loginData: LoginCompletion) {
val currentUser = userManager.currentUser.blockingGet()
if (currentUser != null) {
currentUser.clientCertificate = appPreferences.temporaryClientCertAlias
currentUser.token = loginData.appPassword
userManager.updateOrCreateUser(currentUser)
}
}
fun startAccountRemovalWorker(): LiveData<WorkInfo?> {
val accountRemovalWork = OneTimeWorkRequest.Builder(AccountRemovalWorker::class.java).build()
WorkManager.getInstance(context).enqueue(accountRemovalWork)
return WorkManager.getInstance(context).getWorkInfoByIdLiveData(accountRemovalWork.id)
}
fun checkIfUserIsScheduledForDeletion(data: LoginCompletion): Boolean =
userManager.checkIfUserIsScheduledForDeletion(data.loginName, data.server).blockingGet()
fun checkIfUserExists(data: LoginCompletion): Boolean =
userManager.checkIfUserExists(data.loginName, data.server).blockingGet()
}
@@ -0,0 +1,12 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2025 Sowjanya Kota <sowjanya.kch@gmail.com>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.account.data.model
import ru.f7cloud.talk.data.user.model.User
data class AccountItem(val user: User, val pendingInvitation: Int)
@@ -0,0 +1,12 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2025 Julius Linus <juliuslinus1@gmail.com>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.account.data.model
data class LoginResponse(val token: String, val pollUrl: String, val loginUrl: String)
data class LoginCompletion(val status: Int, val server: String, val loginName: String, val appPassword: String)
@@ -0,0 +1,167 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2025 Julius Linus <juliuslinus1@gmail.com>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.account.data.network
import android.util.Log
import com.google.gson.JsonObject
import com.google.gson.JsonParser
import ru.f7cloud.talk.account.data.model.LoginCompletion
import ru.f7cloud.talk.account.data.model.LoginResponse
import okhttp3.FormBody
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody
import java.io.IOException
import javax.net.ssl.SSLHandshakeException
// This class handles the network and polling logic in isolation, which makes it easier to test
// Login and Authentication is critical, thus it needs to be working properly.
class NetworkLoginDataSource(val okHttpClient: OkHttpClient) {
companion object {
val TAG: String = NetworkLoginDataSource::class.java.simpleName
}
fun oneTimePasswordRequest(baseUrl: String, oneTimeCredentials: String): String? {
val url = "$baseUrl/ocs/v2.php/core/getapppassword-onetime"
var result: String? = null
runCatching {
val response = getResponseOfOTPRequest(url, oneTimeCredentials)
val jsonObject: JsonObject = JsonParser.parseString(response).asJsonObject
val appPassword = jsonObject
.getAsJsonObject("ocs")
.getAsJsonObject("data")
.get("apppassword").asString
result = appPassword
}.getOrElse { e ->
when (e) {
is SSLHandshakeException,
is NullPointerException,
is IOException -> {
Log.e(TAG, "Error caught at oneTimePasswordRequest: $e")
}
else -> throw e
}
}
return result
}
private fun getResponseOfOTPRequest(url: String, oneTimeCredentials: String): String? {
val request = Request.Builder() // GET is default
.url(url)
.header("Authorization", oneTimeCredentials)
.header("OCS-APIRequest", "true")
.header("Accept", "application/json")
.build()
val newOkHttpClient = OkHttpClient()
newOkHttpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
throw IOException("Unexpected code $response")
}
return response.body?.string()
}
}
fun anonymouslyPostLoginRequest(baseUrl: String): LoginResponse? {
val url = "$baseUrl/index.php/login/v2"
var result: LoginResponse? = null
runCatching {
val response = getResponseOfAnonymouslyPostLoginRequest(url)
val jsonObject: JsonObject = JsonParser.parseString(response).asJsonObject
val loginUrl: String = getLoginUrl(jsonObject)
val token = jsonObject.getAsJsonObject("poll").get("token").asString
val pollUrl = jsonObject.getAsJsonObject("poll").get("endpoint").asString
result = LoginResponse(token, pollUrl, loginUrl)
}.getOrElse { e ->
when (e) {
is SSLHandshakeException,
is NullPointerException,
is IOException -> {
Log.e(TAG, "Error caught at anonymouslyPostLoginRequest: $e")
}
else -> throw e
}
}
return result
}
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: JsonObject): String {
var result: String? = response.get("login").asString
if (result == null) {
result = ""
}
return result
}
fun performLoginFlowV2(response: LoginResponse): LoginCompletion? {
val requestBody: RequestBody = FormBody.Builder()
.add("token", response.token)
.build()
val request = Request.Builder()
.url(response.pollUrl)
.post(requestBody)
.build()
var result: LoginCompletion? = null
runCatching {
okHttpClient.newCall(request).execute()
.use { response ->
val status: Int = response.code
val responseBody = response.body?.string()
result = if (response.isSuccessful && responseBody?.isNotEmpty() == true) {
val jsonObject = JsonParser.parseString(responseBody).asJsonObject
val server: String = jsonObject.get("server").asString
val loginName: String = jsonObject.get("loginName").asString
val appPassword: String = jsonObject.get("appPassword").asString
LoginCompletion(status, server, loginName, appPassword)
} else {
LoginCompletion(status, "", "", "")
}
}
}.getOrElse { e ->
when (e) {
is NullPointerException,
is SSLHandshakeException,
is IllegalStateException,
is java.net.UnknownHostException,
is IOException -> {
Log.e(TAG, "Error caught at performLoginFlowV2: with url ${request.url} $e")
}
else -> throw e
}
}
return result
}
}
@@ -0,0 +1,128 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2025 Julius Linus <juliuslinus1@gmail.com>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.account.viewmodels
import android.os.Bundle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import ru.f7cloud.talk.account.data.LoginRepository
import ru.f7cloud.talk.account.data.model.LoginResponse
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
class BrowserLoginActivityViewModel @Inject constructor(val repository: LoginRepository) : ViewModel() {
companion object {
private val TAG = BrowserLoginActivityViewModel::class.java.simpleName
}
sealed class InitialLoginViewState {
data object None : InitialLoginViewState()
data class InitialLoginRequestSuccess(val loginUrl: String) : InitialLoginViewState()
data object InitialLoginRequestError : InitialLoginViewState()
}
private val _initialLoginRequestState = MutableStateFlow<InitialLoginViewState>(InitialLoginViewState.None)
val initialLoginRequestState: StateFlow<InitialLoginViewState> = _initialLoginRequestState
sealed class PostLoginViewState {
data object None : PostLoginViewState()
data object PostLoginRestartApp : PostLoginViewState()
data object PostLoginError : PostLoginViewState()
data class PostLoginContinue(val data: Bundle) : PostLoginViewState()
}
private val _postLoginState = MutableStateFlow<PostLoginViewState>(PostLoginViewState.None)
val postLoginState: StateFlow<PostLoginViewState> = _postLoginState
private val _waitingForBrowserState = MutableStateFlow(false)
val waitingForBrowserState = _waitingForBrowserState.asStateFlow()
fun setWaitingForBrowser(value: Boolean) {
_waitingForBrowserState.value = value
}
private var savedResponse: LoginResponse? = null
fun startWebBrowserLogin(baseUrl: String, reAuth: Boolean = false) {
viewModelScope.launch {
val response = repository.startLoginFlow(baseUrl, reAuth)
savedResponse = response
if (response == null) {
_initialLoginRequestState.value = InitialLoginViewState.InitialLoginRequestError
return@launch
}
_initialLoginRequestState.value =
InitialLoginViewState.InitialLoginRequestSuccess(response.loginUrl)
}
}
fun handleWebBrowserLogin() {
savedResponse?.let { response ->
viewModelScope.launch {
val loginCompletionResponse = repository.pollLogin(response)
if (loginCompletionResponse == null) {
_postLoginState.value = PostLoginViewState.PostLoginError
return@launch
}
val bundle = repository.parseAndLogin(loginCompletionResponse)
if (bundle == null) {
_postLoginState.value = PostLoginViewState.PostLoginRestartApp
return@launch
}
_postLoginState.value = PostLoginViewState.PostLoginContinue(bundle)
}
}
}
fun loginWithQR(dataString: String, reAuth: Boolean = false) {
viewModelScope.launch {
val loginCompletionResponse = repository.startLoginFlowFromQR(dataString, reAuth)
if (loginCompletionResponse == null) {
_postLoginState.value = PostLoginViewState.PostLoginError
return@launch
}
val bundle = repository.parseAndLogin(loginCompletionResponse)
if (bundle == null) {
_postLoginState.value = PostLoginViewState.PostLoginRestartApp
return@launch
}
_postLoginState.value = PostLoginViewState.PostLoginContinue(bundle)
}
}
fun loginWithOTPQR(dataString: String, reAuth: Boolean = false) {
viewModelScope.launch {
val loginCompletionResponse = repository.startOTPLoginFlow(dataString, reAuth)
if (loginCompletionResponse == null) {
_postLoginState.value = PostLoginViewState.PostLoginError
return@launch
}
val bundle = repository.parseAndLogin(loginCompletionResponse)
if (bundle == null) {
_postLoginState.value = PostLoginViewState.PostLoginRestartApp
return@launch
}
_postLoginState.value = PostLoginViewState.PostLoginContinue(bundle)
}
}
fun cancelLogin() = repository.cancelLoginFlow()
}
@@ -0,0 +1,13 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2016 BlueLine Labs, Inc.
* SPDX-License-Identifier: Apache-2.0
*/
package ru.f7cloud.talk.activities;
import androidx.appcompat.app.ActionBar;
public interface ActionBarProvider {
ActionBar getSupportActionBar();
}
@@ -0,0 +1,300 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2023 Marcel Hibbe <dev@mhibbe.de>
* SPDX-FileCopyrightText: 2023 Andy Scherzinger <info@andy-scherzinger.de>
* SPDX-FileCopyrightText: 2017-2019 Mario Danic <mario@lovelyhq.com>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.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.WindowManager
import android.view.inputmethod.EditorInfo
import android.webkit.SslErrorHandler
import android.widget.EditText
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.res.ResourcesCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import autodagger.AutoInjector
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import ru.f7cloud.talk.R
import ru.f7cloud.talk.account.AccountVerificationActivity
import ru.f7cloud.talk.account.BrowserLoginActivity
import ru.f7cloud.talk.account.ServerSelectionActivity
import ru.f7cloud.talk.account.SwitchAccountActivity
import ru.f7cloud.talk.application.F7cloudTalkApplication
import ru.f7cloud.talk.chat.ChatActivity
import ru.f7cloud.talk.events.CertificateEvent
import ru.f7cloud.talk.ui.theme.ViewThemeUtils
import ru.f7cloud.talk.utils.DisplayUtils
import ru.f7cloud.talk.utils.FileViewerUtils
import ru.f7cloud.talk.utils.UriUtils
import ru.f7cloud.talk.utils.adjustUIForAPILevel35
import ru.f7cloud.talk.utils.bundle.BundleKeys
import ru.f7cloud.talk.utils.database.user.CurrentUserProvider
import ru.f7cloud.talk.utils.database.user.CurrentUserProviderOld
import ru.f7cloud.talk.utils.preferences.AppPreferences
import ru.f7cloud.talk.utils.ssl.TrustManager
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import java.security.cert.CertificateParsingException
import java.security.cert.X509Certificate
import java.text.DateFormat
import javax.inject.Inject
@AutoInjector(F7cloudTalkApplication::class)
open class BaseActivity : AppCompatActivity() {
enum class AppBarLayoutType {
TOOLBAR,
SEARCH_BAR,
EMPTY
}
@Inject
lateinit var eventBus: EventBus
@Inject
lateinit var appPreferences: AppPreferences
@Inject
lateinit var viewThemeUtils: ViewThemeUtils
@Inject
lateinit var context: Context
@Deprecated("Use CurrentUserProvider instead")
@Inject
lateinit var currentUserProviderOld: CurrentUserProviderOld
@Inject
lateinit var currentUserProvider: CurrentUserProvider
open val appBarLayoutType: AppBarLayoutType
get() = AppBarLayoutType.TOOLBAR
open val view: View?
get() = null
override fun onCreate(savedInstanceState: Bundle?) {
F7cloudTalkApplication.sharedApplication!!.componentApplication.inject(this)
adjustUIForAPILevel35()
super.onCreate(savedInstanceState)
cleanTempCertPreference()
}
public override fun onStart() {
super.onStart()
eventBus.register(this)
}
public override fun onResume() {
super.onResume()
if (appPreferences.isKeyboardIncognito) {
val viewGroup = (findViewById<View>(android.R.id.content) as ViewGroup).getChildAt(0) as ViewGroup
disableKeyboardPersonalisedLearning(viewGroup)
}
if (appPreferences.isScreenSecured || appPreferences.isScreenLocked) {
window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
} else {
window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
}
}
public override fun onStop() {
super.onStop()
eventBus.unregister(this)
}
/*
* May be aligned with android-common lib in the future: .../ui/util/extensions/AppCompatActivityExtensions.kt
*/
fun initSystemBars() {
val decorView = window.decorView
decorView.setOnApplyWindowInsetsListener { view, insets ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
val systemBars = insets.getInsets(
WindowInsetsCompat.Type.systemBars() or
WindowInsetsCompat.Type.displayCutout()
)
val color = ResourcesCompat.getColor(resources, R.color.bg_default, context.theme)
view.setBackgroundColor(color)
view.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
} else {
colorizeStatusBar()
colorizeNavigationBar()
}
insets
}
ViewCompat.requestApplyInsets(decorView)
}
open fun colorizeStatusBar() {
if (resources != null) {
if (appBarLayoutType == AppBarLayoutType.SEARCH_BAR) {
viewThemeUtils.platform.resetStatusBar(this)
} else {
viewThemeUtils.platform.themeStatusBar(this)
}
}
}
fun colorizeNavigationBar() {
if (resources != null) {
DisplayUtils.applyColorToNavigationBar(
this.window,
ResourcesCompat.getColor(resources, R.color.bg_default, null)
)
}
}
private fun disableKeyboardPersonalisedLearning(viewGroup: ViewGroup) {
var view: View?
var editText: EditText
for (i in 0 until viewGroup.childCount) {
view = viewGroup.getChildAt(i)
if (view is EditText) {
editText = view
editText.imeOptions = editText.imeOptions or EditorInfo.IME_FLAG_NO_PERSONALIZED_LEARNING
} else if (view is ViewGroup) {
disableKeyboardPersonalisedLearning(view)
}
}
}
@Suppress("Detekt.NestedBlockDepth")
private fun showCertificateDialog(
cert: X509Certificate,
trustManager: TrustManager,
sslErrorHandler: SslErrorHandler?
) {
val formatter = DateFormat.getDateInstance(DateFormat.LONG)
val validFrom = formatter.format(cert.notBefore)
val validUntil = formatter.format(cert.notAfter)
val issuedBy = cert.issuerDN.toString()
val issuedFor: String
try {
if (cert.subjectAlternativeNames != null) {
val stringBuilder = StringBuilder()
for (o in cert.subjectAlternativeNames) {
val list = o as List<*>
val type = list[0] as Int
if (type == 2) {
val name = list[1] as String
stringBuilder.append("[").append(type).append("]").append(name).append(" ")
}
}
issuedFor = stringBuilder.toString()
} else {
issuedFor = cert.subjectDN.name
}
@SuppressLint("StringFormatMatches")
val dialogText = String.format(
resources.getString(R.string.nc_certificate_dialog_text),
issuedBy,
issuedFor,
validFrom,
validUntil
)
val dialogBuilder = MaterialAlertDialogBuilder(this).setIcon(
viewThemeUtils.dialog.colorMaterialAlertDialogIcon(
context,
R.drawable.ic_security_white_24dp
)
).setTitle(R.string.nc_certificate_dialog_title)
.setMessage(dialogText)
.setPositiveButton(R.string.nc_yes) { _, _ ->
trustManager.addCertInTrustStore(cert)
sslErrorHandler?.proceed()
}.setNegativeButton(R.string.nc_no) { _, _ ->
sslErrorHandler?.cancel()
}
viewThemeUtils.dialog.colorMaterialAlertDialogBackground(context, dialogBuilder)
val dialog = dialogBuilder.show()
viewThemeUtils.platform.colorTextButtons(
dialog.getButton(AlertDialog.BUTTON_POSITIVE),
dialog.getButton(AlertDialog.BUTTON_NEGATIVE)
)
} catch (e: CertificateParsingException) {
Log.d(TAG, "Failed to parse the certificate")
}
}
private fun cleanTempCertPreference() {
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(SwitchAccountActivity::class.java.name)
if (!temporaryClassNames.contains(javaClass.name)) {
appPreferences.removeTemporaryClientCertAlias()
}
}
@Subscribe(threadMode = ThreadMode.MAIN)
fun onMessageEvent(event: CertificateEvent) {
showCertificateDialog(event.x509Certificate, event.trustManager, event.sslErrorHandler)
}
override fun startActivity(intent: Intent) {
val user = currentUserProviderOld.currentUser.blockingGet()
if (intent.data != null && TextUtils.equals(intent.action, Intent.ACTION_VIEW)) {
val uri = intent.data.toString()
if (user?.baseUrl != null && uri.startsWith(user.baseUrl!!)) {
if (UriUtils.isInstanceInternalFileShareUrl(user.baseUrl!!, uri)) {
// https://cloud.f7cloud.com/f/41
val fileViewerUtils = FileViewerUtils(applicationContext, user)
fileViewerUtils.openFileInFilesApp(uri, UriUtils.extractInstanceInternalFileShareFileId(uri))
} else if (UriUtils.isInstanceInternalFileUrl(user.baseUrl!!, uri)) {
// https://cloud.f7cloud.com/apps/files/?dir=/Engineering&fileid=41
val fileViewerUtils = FileViewerUtils(applicationContext, user)
fileViewerUtils.openFileInFilesApp(uri, UriUtils.extractInstanceInternalFileFileId(uri))
} else if (UriUtils.isInstanceInternalFileUrlNew(user.baseUrl!!, uri)) {
// https://cloud.f7cloud.com/apps/files/?dir=/Engineering&fileid=41
val fileViewerUtils = FileViewerUtils(applicationContext, user)
fileViewerUtils.openFileInFilesApp(uri, UriUtils.extractInstanceInternalFileFileIdNew(uri))
} else if (UriUtils.isInstanceInternalTalkUrl(user.baseUrl!!, uri)) {
// https://cloud.f7cloud.com/call/123456789
val bundle = Bundle()
bundle.putString(BundleKeys.KEY_ROOM_TOKEN, UriUtils.extractRoomTokenFromTalkUrl(uri))
val chatIntent = Intent(context, ChatActivity::class.java)
chatIntent.putExtras(bundle)
chatIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
startActivity(chatIntent)
} else {
super.startActivity(intent)
}
} else {
super.startActivity(intent)
}
} else {
super.startActivity(intent)
}
}
companion object {
private val TAG = BaseActivity::class.java.simpleName
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,163 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2022 Marcel Hibbe <dev@mhibbe.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.activities;
import android.annotation.SuppressLint;
import android.app.AppOpsManager;
import android.app.KeyguardManager;
import android.app.PictureInPictureParams;
import android.content.Context;
import android.content.pm.PackageManager;
import android.os.Build;
import android.os.Bundle;
import android.os.PowerManager;
import android.util.Log;
import android.util.Rational;
import android.view.View;
import android.view.Window;
import android.view.WindowManager;
import ru.f7cloud.talk.BuildConfig;
import androidx.activity.OnBackPressedCallback;
public abstract class CallBaseActivity extends BaseActivity {
public static final String TAG = "CallBaseActivity";
public PictureInPictureParams.Builder mPictureInPictureParamsBuilder;
public Boolean isInPipMode = Boolean.FALSE;
long onCreateTime;
private OnBackPressedCallback onBackPressedCallback = new OnBackPressedCallback(true) {
@Override
public void handleOnBackPressed() {
if (isPipModePossible()) {
enterPipMode();
}
}
};
@SuppressLint("ClickableViewAccessibility")
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
onCreateTime = System.currentTimeMillis();
requestWindowFeature(Window.FEATURE_NO_TITLE);
dismissKeyguard();
getWindow().addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN);
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
if (isPipModePossible()) {
mPictureInPictureParamsBuilder = new PictureInPictureParams.Builder();
}
getOnBackPressedDispatcher().addCallback(this, onBackPressedCallback);
}
public void hideNavigationIfNoPipAvailable(){
if (!isPipModePossible()) {
getWindow().getDecorView().setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_STABLE |
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION |
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION |
View.SYSTEM_UI_FLAG_FULLSCREEN | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY);
suppressFitsSystemWindows();
}
}
void dismissKeyguard() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
setShowWhenLocked(true);
setTurnScreenOn(true);
KeyguardManager keyguardManager = (KeyguardManager) getSystemService(KEYGUARD_SERVICE);
keyguardManager.requestDismissKeyguard(this, null);
} else {
getWindow().addFlags(WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD);
getWindow().addFlags(WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED);
getWindow().addFlags(WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON);
}
}
void enableKeyguard() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
setShowWhenLocked(false);
} else {
getWindow().clearFlags(WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD);
getWindow().clearFlags(WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED);
getWindow().clearFlags(WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON);
}
}
@Override
public void onStop() {
super.onStop();
if (shouldFinishOnStop()) {
finish();
}
}
@Override
protected void onUserLeaveHint() {
super.onUserLeaveHint();
long onUserLeaveHintTime = System.currentTimeMillis();
long diff = onUserLeaveHintTime - onCreateTime;
Log.d(TAG, "onUserLeaveHintTime - onCreateTime: " + diff);
if (diff < 3000) {
Log.d(TAG, "enterPipMode skipped");
} else {
enterPipMode();
}
}
void enterPipMode() {
enableKeyguard();
if (isPipModePossible()) {
Rational pipRatio = new Rational(300, 500);
mPictureInPictureParamsBuilder.setAspectRatio(pipRatio);
enterPictureInPictureMode(mPictureInPictureParamsBuilder.build());
} else {
// we don't support other solutions than PIP to have a call in the background.
// If PIP is not available the call is ended when user presses the home button.
Log.d(TAG, "Activity was finished because PIP is not available.");
finish();
}
}
boolean isPipModePossible() {
boolean deviceHasPipFeature = getPackageManager().hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE);
AppOpsManager appOpsManager = (AppOpsManager) getSystemService(Context.APP_OPS_SERVICE);
boolean isPipFeatureGranted = appOpsManager.checkOpNoThrow(
AppOpsManager.OPSTR_PICTURE_IN_PICTURE,
android.os.Process.myUid(),
BuildConfig.APPLICATION_ID) == AppOpsManager.MODE_ALLOWED;
return deviceHasPipFeature && isPipFeatureGranted;
}
private boolean shouldFinishOnStop() {
if (!isInPipMode) {
return false;
}
PowerManager powerManager = (PowerManager) getSystemService(Context.POWER_SERVICE);
if (powerManager == null) {
return true;
}
return powerManager.isInteractive();
}
public abstract void updateUiForPipMode();
public abstract void updateUiForNormalMode();
public abstract void suppressFitsSystemWindows();
}
@@ -0,0 +1,22 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2022 Andy Scherzinger <info@andy-scherzinger.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.activities
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
enum class CallStatus : Parcelable {
CONNECTING,
CALLING_TIMEOUT,
JOINED,
IN_CONVERSATION,
RECONNECTING,
OFFLINE,
LEAVING,
PUBLISHER_FAILED
}
@@ -0,0 +1,100 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2025 Marcel Hibbe <dev@mhibbe.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.activities
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import ru.f7cloud.talk.signaling.SignalingMessageReceiver
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
class CallViewModel @Inject constructor() : ViewModel() {
private val participantHandlers: MutableMap<String, ParticipantHandler> = ConcurrentHashMap()
private val _participants = MutableStateFlow<List<ParticipantUiState>>(emptyList())
val participants: StateFlow<List<ParticipantUiState>> = _participants.asStateFlow()
private val _activeScreenShareSession = MutableStateFlow<ParticipantUiState?>(null)
val activeScreenShareSession: StateFlow<ParticipantUiState?> = _activeScreenShareSession.asStateFlow()
fun getParticipant(sessionId: String?): ParticipantHandler? {
if (sessionId == null) {
Log.w(TAG, "Attempted to get participant with null sessionId.")
return null
}
return participantHandlers[sessionId]
}
fun doesParticipantExist(sessionId: String?): Boolean = (participantHandlers.containsKey(sessionId))
fun addParticipant(
baseUrl: String,
roomToken: String,
sessionId: String,
signalingMessageReceiver: SignalingMessageReceiver
) {
if (participantHandlers.containsKey(sessionId)) return
val participantHandler = ParticipantHandler(
sessionId,
baseUrl,
roomToken,
signalingMessageReceiver,
onParticipantShareScreen = {
onShareScreen(it)
},
onParticipantUnshareScreen = {
onUnshareScreen(it)
}
)
participantHandlers[sessionId] = participantHandler
viewModelScope.launch {
participantHandler.uiState.collect {
_participants.value = participantHandlers.values.map { it.uiState.value }
}
}
}
fun onShareScreen(sessionId: String?) {
setActiveScreenShareSession(sessionId)
}
fun onUnshareScreen(sessionId: String?) {
if (_activeScreenShareSession.value?.sessionKey.equals(sessionId)) {
setActiveScreenShareSession(null)
}
}
fun removeParticipant(sessionId: String) {
participantHandlers[sessionId]?.destroy()
participantHandlers.remove(sessionId)
_participants.value = participantHandlers.values.map { it.uiState.value }
}
fun setActiveScreenShareSession(session: String?) {
_activeScreenShareSession.value = session?.let {
participantHandlers[it]?.uiState?.value
}
}
public override fun onCleared() {
participantHandlers.values.forEach { it.destroy() }
participantHandlers.clear()
_participants.value = emptyList()
}
companion object {
private val TAG = CallViewModel::class.java.simpleName
}
}
@@ -0,0 +1,289 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2023 Marcel Hibbe <dev@mhibbe.de>
* SPDX-FileCopyrightText: 2023 Ezhil Shanmugham <ezhil56x.contact@gmail.com>
* SPDX-FileCopyrightText: 2021 Andy Scherzinger <infoi@andy-scherzinger.de>
* SPDX-FileCopyrightText: 2017 Mario Danic <mario@lovelyhq.com>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.activities
import android.app.KeyguardManager
import android.content.Intent
import android.os.Bundle
import android.provider.ContactsContract
import android.text.TextUtils
import android.util.Log
import android.widget.Toast
import androidx.activity.OnBackPressedCallback
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import autodagger.AutoInjector
import com.google.android.material.snackbar.Snackbar
import ru.f7cloud.talk.R
import ru.f7cloud.talk.account.BrowserLoginActivity
import ru.f7cloud.talk.account.ServerSelectionActivity
import ru.f7cloud.talk.api.NcApi
import ru.f7cloud.talk.application.F7cloudTalkApplication
import ru.f7cloud.talk.chat.ChatActivity
import ru.f7cloud.talk.conversationlist.ConversationsListActivity
import ru.f7cloud.talk.data.user.model.User
import ru.f7cloud.talk.databinding.ActivityMainBinding
import ru.f7cloud.talk.invitation.InvitationsActivity
import ru.f7cloud.talk.lock.LockedActivity
import ru.f7cloud.talk.models.json.conversations.RoomOverall
import ru.f7cloud.talk.users.UserManager
import ru.f7cloud.talk.utils.ApiUtils
import ru.f7cloud.talk.utils.ClosedInterfaceImpl
import ru.f7cloud.talk.utils.SecurityUtils
import ru.f7cloud.talk.utils.bundle.BundleKeys
import ru.f7cloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN
import io.reactivex.Observer
import io.reactivex.SingleObserver
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.Disposable
import io.reactivex.schedulers.Schedulers
import javax.inject.Inject
@AutoInjector(F7cloudTalkApplication::class)
class MainActivity :
BaseActivity(),
ActionBarProvider {
lateinit var binding: ActivityMainBinding
@Inject
lateinit var ncApi: NcApi
@Inject
lateinit var userManager: UserManager
private val onBackPressedCallback = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
finish()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
Log.d(TAG, "onCreate: Activity: " + System.identityHashCode(this).toString())
super.onCreate(savedInstanceState)
ProcessLifecycleOwner.get().lifecycle.addObserver(object : DefaultLifecycleObserver {
override fun onStart(owner: LifecycleOwner) {
lockScreenIfConditionsApply()
}
})
// Set the default theme to replace the launch screen theme.
setTheme(R.style.AppTheme)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
F7cloudTalkApplication.sharedApplication!!.componentApplication.inject(this)
setSupportActionBar(binding.toolbar)
handleIntent(intent)
onBackPressedDispatcher.addCallback(this, onBackPressedCallback)
}
fun lockScreenIfConditionsApply() {
val keyguardManager = getSystemService(KEYGUARD_SERVICE) as KeyguardManager
if (keyguardManager.isKeyguardSecure && appPreferences.isScreenLocked) {
if (!SecurityUtils.checkIfWeAreAuthenticated(appPreferences.screenLockTimeout)) {
val lockIntent = Intent(context, LockedActivity::class.java)
startActivity(lockIntent)
}
}
}
private fun launchServerSelection() {
if (isBrandingUrlSet()) {
val intent = Intent(context, BrowserLoginActivity::class.java)
val bundle = Bundle()
bundle.putString(BundleKeys.KEY_BASE_URL, resources.getString(R.string.weblogin_url))
intent.putExtras(bundle)
startActivity(intent)
} else {
val intent = Intent(context, ServerSelectionActivity::class.java)
startActivity(intent)
}
}
private fun isBrandingUrlSet() = !TextUtils.isEmpty(resources.getString(R.string.weblogin_url))
override fun onStart() {
Log.d(TAG, "onStart: Activity: " + System.identityHashCode(this).toString())
super.onStart()
}
override fun onResume() {
Log.d(TAG, "onResume: Activity: " + System.identityHashCode(this).toString())
super.onResume()
if (appPreferences.isScreenLocked) {
SecurityUtils.createKey(appPreferences.screenLockTimeout)
}
}
override fun onPause() {
Log.d(TAG, "onPause: Activity: " + System.identityHashCode(this).toString())
super.onPause()
}
override fun onStop() {
Log.d(TAG, "onStop: Activity: " + System.identityHashCode(this).toString())
super.onStop()
}
private fun openConversationList() {
val intent = Intent(this, ConversationsListActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
intent.putExtras(Bundle())
startActivity(intent)
}
private fun handleActionFromContact(intent: Intent) {
if (intent.action == Intent.ACTION_VIEW && intent.data != null) {
val cursor = contentResolver.query(intent.data!!, null, null, null, null)
var userId = ""
if (cursor != null) {
if (cursor.moveToFirst()) {
// userId @ server
userId = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.Data.DATA1))
}
cursor.close()
}
when (intent.type) {
"vnd.android.cursor.item/vnd.com.nextcloud.talk2.chat" -> {
val user = userId.substringBeforeLast("@")
val baseUrl = userId.substringAfterLast("@")
if (currentUserProviderOld.currentUser.blockingGet()?.baseUrl!!.endsWith(baseUrl) == true) {
startConversation(user)
} else {
Snackbar.make(
binding.root,
R.string.nc_phone_book_integration_account_not_found,
Snackbar.LENGTH_LONG
).show()
}
}
}
}
}
private fun startConversation(userId: String) {
val roomType = "1"
val currentUser = currentUserProviderOld.currentUser.blockingGet()
val apiVersion = ApiUtils.getConversationApiVersion(currentUser, intArrayOf(ApiUtils.API_V4, 1))
val credentials = ApiUtils.getCredentials(currentUser?.username, currentUser?.token)
val retrofitBucket = ApiUtils.getRetrofitBucketForCreateRoom(
version = apiVersion,
baseUrl = currentUser?.baseUrl!!,
roomType = roomType,
invite = userId
)
ncApi.createRoom(
credentials,
retrofitBucket.url,
retrofitBucket.queryMap
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(object : Observer<RoomOverall> {
override fun onSubscribe(d: Disposable) {
// unused atm
}
override fun onNext(roomOverall: RoomOverall) {
val bundle = Bundle()
bundle.putString(KEY_ROOM_TOKEN, roomOverall.ocs!!.data!!.token)
val chatIntent = Intent(context, ChatActivity::class.java)
chatIntent.putExtras(bundle)
startActivity(chatIntent)
}
override fun onError(e: Throwable) {
// unused atm
}
override fun onComplete() {
// unused atm
}
})
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
Log.d(TAG, "onNewIntent Activity: " + System.identityHashCode(this).toString())
handleIntent(intent)
}
private fun handleIntent(intent: Intent) {
handleActionFromContact(intent)
val internalUserId = intent.extras?.getLong(BundleKeys.KEY_INTERNAL_USER_ID)
var user: User? = null
if (internalUserId != null) {
user = userManager.getUserWithId(internalUserId).blockingGet()
}
if (user != null && userManager.setUserAsActive(user).blockingGet()) {
if (intent.hasExtra(BundleKeys.KEY_REMOTE_TALK_SHARE)) {
if (intent.getBooleanExtra(BundleKeys.KEY_REMOTE_TALK_SHARE, false)) {
val invitationsIntent = Intent(this, InvitationsActivity::class.java)
startActivity(invitationsIntent)
}
} else {
val chatIntent = Intent(context, ChatActivity::class.java)
chatIntent.putExtras(intent.extras!!)
startActivity(chatIntent)
}
} else {
userManager.users.subscribe(object : SingleObserver<List<User>> {
override fun onSubscribe(d: Disposable) {
// unused atm
}
override fun onSuccess(users: List<User>) {
if (users.isNotEmpty()) {
ClosedInterfaceImpl().setUpPushTokenRegistration()
runOnUiThread {
openConversationList()
}
} else {
runOnUiThread {
launchServerSelection()
}
}
}
override fun onError(e: Throwable) {
Log.e(TAG, "Error loading existing users", e)
Toast.makeText(
context,
context.resources.getString(R.string.nc_common_error_sorry),
Toast.LENGTH_SHORT
).show()
}
})
}
}
companion object {
private val TAG = MainActivity::class.java.simpleName
}
}
@@ -0,0 +1,235 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2025 Marcel Hibbe <dev@mhibbe.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.activities
import android.util.Log
import ru.f7cloud.talk.models.json.participants.Participant
import ru.f7cloud.talk.signaling.SignalingMessageReceiver
import ru.f7cloud.talk.webrtc.PeerConnectionWrapper
import ru.f7cloud.talk.webrtc.PeerConnectionWrapper.DataChannelMessageListener
import ru.f7cloud.talk.webrtc.PeerConnectionWrapper.PeerConnectionObserver
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import org.webrtc.MediaStream
import org.webrtc.PeerConnection.IceConnectionState
class ParticipantHandler(
private val sessionId: String,
val baseUrl: String,
val roomToken: String,
private val signalingMessageReceiver: SignalingMessageReceiver,
onParticipantShareScreen: ((String?) -> Unit?),
onParticipantUnshareScreen: ((String?) -> Unit?)
) {
private val _uiState = MutableStateFlow(
ParticipantUiState(
sessionKey = sessionId,
baseUrl = baseUrl,
roomToken = roomToken,
nick = "Guest",
isConnected = true,
isAudioEnabled = false,
isStreamEnabled = false,
isScreenStreamEnabled = false,
raisedHand = false,
isInternal = false
)
)
val uiState: StateFlow<ParticipantUiState> = _uiState.asStateFlow()
private var peerConnection: PeerConnectionWrapper? = null
private var screenPeerConnection: PeerConnectionWrapper? = null
private val peerConnectionObserver: PeerConnectionObserver = object : PeerConnectionObserver {
override fun onStreamAdded(mediaStream: MediaStream?) {
handleStreamChange(mediaStream)
}
override fun onStreamRemoved(mediaStream: MediaStream?) {
handleStreamChange(mediaStream)
}
override fun onIceConnectionStateChanged(iceConnectionState: IceConnectionState?) {
Log.d(TAG, "onIceConnectionStateChanged " + _uiState.value.nick + " " + iceConnectionState)
handleIceConnectionStateChange(iceConnectionState)
}
}
private val screenPeerConnectionObserver: PeerConnectionObserver = object : PeerConnectionObserver {
override fun onStreamAdded(mediaStream: MediaStream?) {
handleScreenStreamChange(mediaStream)
onParticipantShareScreen.invoke(_uiState.value.sessionKey)
}
override fun onStreamRemoved(mediaStream: MediaStream?) {
handleScreenStreamChange(mediaStream)
}
override fun onIceConnectionStateChanged(iceConnectionState: IceConnectionState?) {
// do nothing
}
}
private fun handleStreamChange(mediaStream: MediaStream?) {
val hasAtLeastOneVideoStream = mediaStream?.videoTracks?.isNotEmpty() == true
_uiState.update {
it.copy(
mediaStream = mediaStream,
isStreamEnabled = hasAtLeastOneVideoStream
)
}
}
private fun handleScreenStreamChange(mediaStream: MediaStream?) {
val hasAtLeastOneVideoStream = mediaStream?.videoTracks?.isNotEmpty() == true
_uiState.update {
it.copy(
screenMediaStream = mediaStream,
isScreenStreamEnabled = hasAtLeastOneVideoStream
)
}
}
private fun handleIceConnectionStateChange(iceConnectionState: IceConnectionState?) {
Log.d(TAG, "handleIceConnectionStateChange " + _uiState.value.nick + " " + iceConnectionState)
if (iceConnectionState == IceConnectionState.NEW ||
iceConnectionState == IceConnectionState.CHECKING
) {
_uiState.update { it.copy(isAudioEnabled = false) }
_uiState.update { it.copy(isStreamEnabled = false) }
}
_uiState.update { it.copy(isConnected = isConnected(iceConnectionState)) }
}
private val dataChannelMessageListener: DataChannelMessageListener = object : DataChannelMessageListener {
override fun onAudioOn() {
_uiState.update { it.copy(isAudioEnabled = true) }
}
override fun onAudioOff() {
_uiState.update { it.copy(isAudioEnabled = false) }
}
override fun onVideoOn() {
_uiState.update { it.copy(isStreamEnabled = true) }
}
override fun onVideoOff() {
_uiState.update { it.copy(isStreamEnabled = false) }
}
override fun onNickChanged(nick: String?) {
_uiState.update { it.copy(nick = nick) }
}
}
private val listener = object : SignalingMessageReceiver.CallParticipantMessageListener {
override fun onRaiseHand(state: Boolean, timestamp: Long) {
_uiState.update { it.copy(raisedHand = state) }
}
override fun onReaction(reaction: String?) {
Log.d(TAG, "onReaction")
}
override fun onUnshareScreen() {
handleScreenStreamChange(null)
onParticipantUnshareScreen.invoke(_uiState.value.sessionKey)
}
}
init {
signalingMessageReceiver.addListener(listener, sessionId)
}
fun setPeerConnection(peerConnection: PeerConnectionWrapper?) {
this.peerConnection?.let {
it.removeObserver(peerConnectionObserver)
it.removeListener(dataChannelMessageListener)
}
this.peerConnection = peerConnection
if (this.peerConnection == null) {
// special case when participant has no permission. -> no streams are transmitted but he must be shown as
// connected
_uiState.update { it.copy(mediaStream = null) }
_uiState.update { it.copy(isAudioEnabled = false) }
_uiState.update { it.copy(isStreamEnabled = false) }
_uiState.update { it.copy(isConnected = true) }
return
}
Log.d(
TAG,
"setPeerConnection " + _uiState.value.nick + " " +
this.peerConnection?.peerConnection?.iceConnectionState()
)
handleIceConnectionStateChange(this.peerConnection?.peerConnection?.iceConnectionState())
handleStreamChange(this.peerConnection?.stream)
this.peerConnection?.addObserver(peerConnectionObserver)
this.peerConnection?.addListener(dataChannelMessageListener)
}
fun setScreenPeerConnection(screenPeerConnectionWrapper: PeerConnectionWrapper?) {
this.screenPeerConnection?.removeObserver(screenPeerConnectionObserver)
this.screenPeerConnection = screenPeerConnectionWrapper
if (this.screenPeerConnection == null) {
_uiState.update { it.copy(screenMediaStream = null) }
return
}
_uiState.update { it.copy(screenMediaStream = screenPeerConnection?.stream) }
this.screenPeerConnection?.addObserver(screenPeerConnectionObserver)
}
fun isConnected(iceConnectionState: IceConnectionState?): Boolean =
iceConnectionState == IceConnectionState.CONNECTED ||
iceConnectionState == IceConnectionState.COMPLETED ||
// If there is no connection state that means that no connection is needed,
// so it is a special case that is also seen as "connected".
iceConnectionState == null
fun updateNick(nick: String?) = _uiState.update { it.copy(nick = nick ?: "Guest") }
fun updateIsInternal(isInternal: Boolean) = _uiState.update { it.copy(isInternal = isInternal) }
fun updateActor(actorType: Participant.ActorType?, actorId: String?) {
_uiState.update { it.copy(actorType = actorType, actorId = actorId) }
}
fun destroy() {
signalingMessageReceiver.removeListener(listener)
if (peerConnection != null) {
peerConnection!!.removeObserver(peerConnectionObserver)
peerConnection!!.removeListener(dataChannelMessageListener)
}
if (screenPeerConnection != null) {
screenPeerConnection!!.removeObserver(screenPeerConnectionObserver)
}
peerConnection = null
screenPeerConnection = null
}
companion object {
private val TAG = ParticipantHandler::class.java.simpleName
}
}
@@ -0,0 +1,28 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2025 Marcel Hibbe <dev@mhibbe.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.activities
import ru.f7cloud.talk.models.json.participants.Participant
import org.webrtc.MediaStream
data class ParticipantUiState(
val sessionKey: String?,
val baseUrl: String,
val roomToken: String,
val nick: String?,
val isConnected: Boolean,
val isAudioEnabled: Boolean,
val isStreamEnabled: Boolean,
val mediaStream: MediaStream? = null,
val isScreenStreamEnabled: Boolean,
val screenMediaStream: MediaStream? = null,
val raisedHand: Boolean,
val actorType: Participant.ActorType? = null,
val actorId: String? = null,
val isInternal: Boolean
)
@@ -0,0 +1,427 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2021 Andy Scherzinger <info@andy-scherzinger.de>
* SPDX-FileCopyrightText: 2021 Stefan Niedermann <info@niedermann.it>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.activities;
import android.content.Context;
import android.content.Intent;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.hardware.camera2.CameraMetadata;
import android.hardware.camera2.CaptureRequest;
import android.net.Uri;
import android.os.Bundle;
import android.util.DisplayMetrics;
import android.util.Log;
import android.util.Size;
import android.view.OrientationEventListener;
import android.view.ScaleGestureDetector;
import android.view.Surface;
import android.view.View;
import com.google.android.material.snackbar.Snackbar;
import com.google.common.util.concurrent.ListenableFuture;
import ru.f7cloud.talk.R;
import ru.f7cloud.talk.application.F7cloudTalkApplication;
import ru.f7cloud.talk.databinding.ActivityTakePictureBinding;
import ru.f7cloud.talk.models.TakePictureViewModel;
import ru.f7cloud.talk.ui.theme.ViewThemeUtils;
import ru.f7cloud.talk.utils.BitmapShrinker;
import ru.f7cloud.talk.utils.FileUtils;
import java.io.File;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Locale;
import java.util.concurrent.ExecutionException;
import javax.inject.Inject;
import androidx.activity.OnBackPressedCallback;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.OptIn;
import androidx.appcompat.app.AppCompatActivity;
import androidx.camera.camera2.interop.Camera2Interop;
import androidx.camera.core.AspectRatio;
import androidx.camera.core.Camera;
import androidx.camera.core.ImageCapture;
import androidx.camera.core.ImageCaptureException;
import androidx.camera.core.Preview;
import androidx.camera.lifecycle.ProcessCameraProvider;
import androidx.core.content.ContextCompat;
import androidx.exifinterface.media.ExifInterface;
import androidx.lifecycle.ViewModelProvider;
import autodagger.AutoInjector;
import static ru.f7cloud.talk.utils.Mimetype.IMAGE_JPEG;
@AutoInjector(F7cloudTalkApplication.class)
public class TakePhotoActivity extends AppCompatActivity {
private static final String TAG = TakePhotoActivity.class.getSimpleName();
private static final float MAX_SCALE = 6.0f;
private static final float MEDIUM_SCALE = 2.45f;
private ActivityTakePictureBinding binding;
private TakePictureViewModel viewModel;
private ListenableFuture<ProcessCameraProvider> cameraProviderFuture;
private OrientationEventListener orientationEventListener;
private final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH-mm-ss", Locale.ROOT);
private Camera camera;
@Inject
ViewThemeUtils viewThemeUtils;
private OnBackPressedCallback onBackPressedCallback = new OnBackPressedCallback(true) {
@Override
public void handleOnBackPressed() {
Uri uri = (Uri) binding.photoPreview.getTag();
if (uri != null) {
File photoFile = new File(uri.getPath());
if (!photoFile.delete()) {
Log.w(TAG, "Error deleting temp camera image");
}
binding.photoPreview.setTag(null);
}
finish();
}
};
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
F7cloudTalkApplication.Companion.getSharedApplication().getComponentApplication().inject(this);
binding = ActivityTakePictureBinding.inflate(getLayoutInflater());
viewModel = new ViewModelProvider(this).get(TakePictureViewModel.class);
setContentView(binding.getRoot());
viewThemeUtils.material.themeFAB(binding.takePhoto);
viewThemeUtils.material.colorMaterialButtonPrimaryFilled(binding.send);
cameraProviderFuture = ProcessCameraProvider.getInstance(this);
cameraProviderFuture.addListener(() -> {
try {
final ProcessCameraProvider cameraProvider = cameraProviderFuture.get();
camera = cameraProvider.bindToLifecycle(
this,
viewModel.getCameraSelector(),
getImageCapture(
viewModel.isCropEnabled().getValue(), viewModel.isLowResolutionEnabled().getValue()),
getPreview(viewModel.isCropEnabled().getValue()));
viewModel.getTorchToggleButtonImageResource()
.observe(
this,
res -> binding.toggleTorch.setIcon(ContextCompat.getDrawable(this, res)));
viewModel.isTorchEnabled()
.observe(
this,
enabled -> camera.getCameraControl().enableTorch(viewModel.isTorchEnabled().getValue()));
binding.toggleTorch.setOnClickListener((v) -> viewModel.toggleTorchEnabled());
viewModel.getCropToggleButtonImageResource()
.observe(
this,
res -> binding.toggleCrop.setIcon(ContextCompat.getDrawable(this, res)));
viewModel.isCropEnabled()
.observe(
this,
enabled -> {
cameraProvider.unbindAll();
camera = cameraProvider.bindToLifecycle(
this,
viewModel.getCameraSelector(),
getImageCapture(
viewModel.isCropEnabled().getValue(), viewModel.isLowResolutionEnabled().getValue()),
getPreview(viewModel.isCropEnabled().getValue()));
camera.getCameraControl().enableTorch(viewModel.isTorchEnabled().getValue());
});
binding.toggleCrop.setOnClickListener((v) -> viewModel.toggleCropEnabled());
viewModel.getLowResolutionToggleButtonImageResource()
.observe(
this,
res -> binding.toggleLowres.setIcon(ContextCompat.getDrawable(this, res)));
viewModel.isLowResolutionEnabled()
.observe(
this,
enabled -> {
cameraProvider.unbindAll();
camera = cameraProvider.bindToLifecycle(
this,
viewModel.getCameraSelector(),
getImageCapture(
viewModel.isCropEnabled().getValue(), viewModel.isLowResolutionEnabled().getValue()),
getPreview(viewModel.isCropEnabled().getValue()));
camera.getCameraControl().enableTorch(viewModel.isTorchEnabled().getValue());
});
binding.toggleLowres.setOnClickListener((v) -> viewModel.toggleLowResolutionEnabled());
binding.switchCamera.setOnClickListener((v) -> {
viewModel.toggleCameraSelector();
cameraProvider.unbindAll();
camera = cameraProvider.bindToLifecycle(
this,
viewModel.getCameraSelector(),
getImageCapture(
viewModel.isCropEnabled().getValue(), viewModel.isLowResolutionEnabled().getValue()),
getPreview(viewModel.isCropEnabled().getValue()));
});
binding.retake.setOnClickListener((v) -> {
Uri uri = (Uri) binding.photoPreview.getTag();
File photoFile = new File(uri.getPath());
if (!photoFile.delete()) {
Log.w(TAG, "Error deleting temp camera image");
}
binding.takePhoto.setEnabled(true);
binding.photoPreview.setTag(null);
showCameraElements();
});
binding.send.setOnClickListener((v) -> {
Uri uri = (Uri) binding.photoPreview.getTag();
setResult(RESULT_OK, new Intent().setDataAndType(uri, IMAGE_JPEG));
binding.photoPreview.setTag(null);
finish();
});
ScaleGestureDetector mDetector =
new ScaleGestureDetector(this, new ScaleGestureDetector.SimpleOnScaleGestureListener(){
@Override
public boolean onScale(ScaleGestureDetector detector){
float ratio = camera.getCameraInfo().getZoomState().getValue().getZoomRatio();
float delta = detector.getScaleFactor();
camera.getCameraControl().setZoomRatio(ratio * delta);
return true;
}
});
binding.preview.setOnTouchListener((v, event) -> {
v.performClick();
mDetector.onTouchEvent(event);
return true;
});
// Enable enlarging the image more than default 3x maximumScale.
// Medium scale adapted to make double-tap behaviour more consistent.
binding.photoPreview.setMaximumScale(MAX_SCALE);
binding.photoPreview.setMediumScale(MEDIUM_SCALE);
} catch (IllegalArgumentException | ExecutionException | InterruptedException e) {
Log.e(TAG, "Error taking picture", e);
Snackbar.make(binding.getRoot(), e.getMessage(), Snackbar.LENGTH_LONG).show();
finish();
}
}, ContextCompat.getMainExecutor(this));
getOnBackPressedDispatcher().addCallback(this, onBackPressedCallback);
}
private void showCameraElements() {
binding.send.setVisibility(View.GONE);
binding.retake.setVisibility(View.GONE);
binding.photoPreview.setVisibility(View.INVISIBLE);
binding.preview.setVisibility(View.VISIBLE);
binding.takePhoto.setVisibility(View.VISIBLE);
binding.switchCamera.setVisibility(View.VISIBLE);
binding.toggleTorch.setVisibility(View.VISIBLE);
binding.toggleCrop.setVisibility(View.VISIBLE);
binding.toggleLowres.setVisibility(View.VISIBLE);
}
private void showPictureProcessingElements() {
binding.preview.setVisibility(View.INVISIBLE);
binding.takePhoto.setVisibility(View.GONE);
binding.switchCamera.setVisibility(View.GONE);
binding.toggleTorch.setVisibility(View.GONE);
binding.toggleCrop.setVisibility(View.GONE);
binding.toggleLowres.setVisibility(View.GONE);
binding.send.setVisibility(View.VISIBLE);
binding.retake.setVisibility(View.VISIBLE);
binding.photoPreview.setVisibility(View.VISIBLE);
}
private ImageCapture getImageCapture(Boolean crop, Boolean lowres) {
final ImageCapture imageCapture;
if (lowres) imageCapture = new ImageCapture.Builder()
.setTargetResolution(new Size(crop ? 1080 : 1440, 1920)).build();
else imageCapture = new ImageCapture.Builder()
.setTargetAspectRatio(crop ? AspectRatio.RATIO_16_9 : AspectRatio.RATIO_4_3).build();
orientationEventListener = new OrientationEventListener(this) {
@Override
public void onOrientationChanged(int orientation) {
int rotation;
// Monitors orientation values to determine the target rotation value
if (orientation >= 45 && orientation < 135) {
rotation = Surface.ROTATION_270;
} else if (orientation >= 135 && orientation < 225) {
rotation = Surface.ROTATION_180;
} else if (orientation >= 225 && orientation < 315) {
rotation = Surface.ROTATION_90;
} else {
rotation = Surface.ROTATION_0;
}
imageCapture.setTargetRotation(rotation);
}
};
orientationEventListener.enable();
binding.takePhoto.setOnClickListener((v) -> {
binding.takePhoto.setEnabled(false);
final String photoFileName = dateFormat.format(new Date()) + ".jpg";
try {
final File photoFile = FileUtils.getTempCacheFile(this, "photos/" + photoFileName);
final ImageCapture.OutputFileOptions options =
new ImageCapture.OutputFileOptions.Builder(photoFile).build();
imageCapture.takePicture(
options,
ContextCompat.getMainExecutor(this),
new ImageCapture.OnImageSavedCallback() {
@Override
public void onImageSaved(@NonNull ImageCapture.OutputFileResults outputFileResults) {
setPreviewImage(photoFile);
showPictureProcessingElements();
}
@Override
public void onError(@NonNull ImageCaptureException e) {
Log.e(TAG, "Error", e);
if (!photoFile.delete()) {
Log.w(TAG, "Deleting picture failed");
}
binding.takePhoto.setEnabled(true);
}
});
} catch (Exception e) {
Log.e(TAG, "error while taking picture", e);
Snackbar.make(binding.getRoot(), R.string.take_photo_error_deleting_picture, Snackbar.LENGTH_SHORT).show();
}
});
return imageCapture;
}
private void setPreviewImage(File photoFile) {
final Uri savedUri = Uri.fromFile(photoFile);
BitmapFactory.Options options = new BitmapFactory.Options();
options.inPreferredConfig = Bitmap.Config.ARGB_8888;
DisplayMetrics displayMetrics = getResources().getDisplayMetrics();
int doubleScreenWidth = displayMetrics.widthPixels * 2;
int doubleScreenHeight = displayMetrics.heightPixels * 2;
Bitmap bitmap = BitmapShrinker.shrinkBitmap(photoFile.getAbsolutePath(),
doubleScreenWidth,
doubleScreenHeight);
if (bitmap == null) {
Log.e(TAG, "Preview bitmap could not be decoded from path: " + photoFile.getAbsolutePath());
Snackbar.make(binding.getRoot(), R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show();
return;
}
binding.photoPreview.setImageBitmap(bitmap);
binding.photoPreview.setTag(savedUri);
viewModel.disableTorchIfEnabled();
}
public int getImageOrientation(File imageFile) {
int rotate = 0;
try {
ExifInterface exif = new ExifInterface(imageFile.getAbsolutePath());
int orientation = exif.getAttributeInt(
ExifInterface.TAG_ORIENTATION,
ExifInterface.ORIENTATION_NORMAL);
switch (orientation) {
case ExifInterface.ORIENTATION_ROTATE_270:
rotate = 270;
break;
case ExifInterface.ORIENTATION_ROTATE_180:
rotate = 180;
break;
case ExifInterface.ORIENTATION_ROTATE_90:
rotate = 90;
break;
default:
rotate = 0;
break;
}
Log.i(TAG, "ImageOrientation - Exif orientation: " + orientation + " - " + "Rotate value: " + rotate);
} catch (Exception e) {
Log.w(TAG, "Error calculation rotation value");
}
return rotate;
}
@OptIn(markerClass = androidx.camera.camera2.interop.ExperimentalCamera2Interop.class)
private Preview getPreview(boolean crop) {
Preview.Builder previewBuilder = new Preview.Builder()
.setTargetAspectRatio(crop ? AspectRatio.RATIO_16_9 : AspectRatio.RATIO_4_3);
new Camera2Interop.Extender<>(previewBuilder)
.setCaptureRequestOption(CaptureRequest.CONTROL_VIDEO_STABILIZATION_MODE,
CameraMetadata.CONTROL_VIDEO_STABILIZATION_MODE_OFF
);
Preview preview = previewBuilder.build();
preview.setSurfaceProvider(binding.preview.getSurfaceProvider());
return preview;
}
@Override
protected void onPause() {
if (this.orientationEventListener != null) {
this.orientationEventListener.disable();
}
super.onPause();
}
@Override
protected void onResume() {
super.onResume();
if (this.orientationEventListener != null) {
this.orientationEventListener.enable();
}
}
@Override
public void onSaveInstanceState(Bundle savedInstanceState) {
if (binding.photoPreview.getTag() != null) {
savedInstanceState.putString("Uri", ((Uri) binding.photoPreview.getTag()).getPath());
}
super.onSaveInstanceState(savedInstanceState);
}
@Override
public void onRestoreInstanceState(Bundle savedInstanceState) {
super.onRestoreInstanceState(savedInstanceState);
String uri = savedInstanceState.getString("Uri", null);
if (uri != null) {
File photoFile = new File(uri);
setPreviewImage(photoFile);
showPictureProcessingElements();
}
}
public static Intent createIntent(@NonNull Context context) {
return new Intent(context, TakePhotoActivity.class).setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP);
}
}
@@ -0,0 +1,59 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2021 Marcel Hibbe <dev@mhibbe.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.adapters
import android.annotation.SuppressLint
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import ru.f7cloud.talk.R
import fr.dudie.nominatim.model.Address
class GeocodingAdapter(private val context: Context, private var dataSource: List<Address>) :
RecyclerView.Adapter<GeocodingAdapter.ViewHolder>() {
interface OnItemClickListener {
fun onItemClick(position: Int)
}
@SuppressLint("NotifyDataSetChanged")
fun updateData(data: List<Address>) {
this.dataSource = data
notifyDataSetChanged()
}
private var listener: OnItemClickListener? = null
fun setOnItemClickListener(listener: OnItemClickListener) {
this.listener = listener
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val inflater = LayoutInflater.from(context)
val view = inflater.inflate(R.layout.geocoding_item, parent, false)
return ViewHolder(view)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val address = dataSource[position]
holder.nameView.text = address.displayName
holder.itemView.setOnClickListener {
listener?.onItemClick(position)
}
}
override fun getItemCount(): Int = dataSource.size
fun getItem(position: Int): Any = dataSource[position]
inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val nameView: TextView = itemView.findViewById(R.id.name)
}
}
@@ -0,0 +1,14 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2022 Tim Krüger <t@timkrueger.me
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.adapters
import ru.f7cloud.talk.models.json.status.predefined.PredefinedStatus
interface PredefinedStatusClickListener {
fun onClick(predefinedStatus: PredefinedStatus)
fun revertStatus()
}
@@ -0,0 +1,34 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2020 Tobias Kaminsky <tobias@kaminsky.me>
* SPDX-FileCopyrightText: 2020 F7cloud
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.adapters
import android.content.Context
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import ru.f7cloud.talk.databinding.PredefinedStatusBinding
import ru.f7cloud.talk.models.json.status.predefined.PredefinedStatus
class PredefinedStatusListAdapter(
private val clickListener: PredefinedStatusClickListener,
val context: Context,
var isBackupStatusAvailable: Boolean
) : RecyclerView.Adapter<PredefinedStatusViewHolder>() {
internal var list: List<PredefinedStatus> = emptyList()
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PredefinedStatusViewHolder {
val itemBinding = PredefinedStatusBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return PredefinedStatusViewHolder(itemBinding)
}
override fun onBindViewHolder(holder: PredefinedStatusViewHolder, position: Int) {
holder.bind(list[position], clickListener, context, isBackupStatusAvailable)
}
override fun getItemCount(): Int = list.size
}
@@ -0,0 +1,60 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2020 Tobias Kaminsky <tobias@kaminsky.me>
* SPDX-FileCopyrightText: 2020 F7cloud
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.adapters
import android.content.Context
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import ru.f7cloud.talk.R
import ru.f7cloud.talk.databinding.PredefinedStatusBinding
import ru.f7cloud.talk.models.json.status.predefined.PredefinedStatus
import ru.f7cloud.talk.utils.DisplayUtils
private const val ONE_SECOND_IN_MILLIS = 1000
@Suppress("DEPRECATION")
class PredefinedStatusViewHolder(private val binding: PredefinedStatusBinding) : RecyclerView.ViewHolder(binding.root) {
fun bind(
status: PredefinedStatus,
clickListener: PredefinedStatusClickListener,
context: Context,
isBackupStatusAvailable: Boolean
) {
binding.root.setOnClickListener { clickListener.onClick(status) }
binding.icon.text = status.icon
binding.name.text = status.message
if (status.clearAt == null) {
binding.clearAt.text = context.getString(R.string.dontClear)
} else {
val clearAt = status.clearAt!!
if (clearAt.type.equals("period")) {
binding.clearAt.text = DisplayUtils.getRelativeTimestamp(
context,
System.currentTimeMillis() + clearAt.time.toInt() * ONE_SECOND_IN_MILLIS,
true
)
} else {
// end-of
if (clearAt.time.equals("day")) {
binding.clearAt.text = context.getString(R.string.today)
}
}
}
if (isBackupStatusAvailable) {
binding.resetStatusButton.visibility = if (position == 0) View.VISIBLE else View.GONE
if (position == 0) {
binding.clearAt.text = context.getString(R.string.previously_set)
}
binding.resetStatusButton.setOnClickListener {
clickListener.revertStatus()
}
}
}
}
@@ -0,0 +1,11 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2022 Andy Scherzinger <info@andy-scherzinger.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.adapters
import ru.f7cloud.talk.models.json.reactions.ReactionVoter
data class ReactionItem(val reactionVoter: ReactionVoter, val reaction: String?)
@@ -0,0 +1,11 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2022 Andy Scherzinger <info@andy-scherzinger.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.adapters
interface ReactionItemClickListener {
fun onClick(reactionItem: ReactionItem)
}
@@ -0,0 +1,29 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2022 Andy Scherzinger <info@andy-scherzinger.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.adapters
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import ru.f7cloud.talk.data.user.model.User
import ru.f7cloud.talk.databinding.ReactionItemBinding
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 {
val itemBinding = ReactionItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return ReactionsViewHolder(itemBinding, user)
}
override fun onBindViewHolder(holder: ReactionsViewHolder, position: Int) {
holder.bind(list[position], clickListener)
}
override fun getItemCount(): Int = list.size
}
@@ -0,0 +1,48 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2022 Andy Scherzinger <info@andy-scherzinger.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.adapters
import android.text.TextUtils
import androidx.recyclerview.widget.RecyclerView
import ru.f7cloud.talk.R
import ru.f7cloud.talk.application.F7cloudTalkApplication.Companion.sharedApplication
import ru.f7cloud.talk.data.user.model.User
import ru.f7cloud.talk.databinding.ReactionItemBinding
import ru.f7cloud.talk.extensions.loadGuestAvatar
import ru.f7cloud.talk.extensions.loadUserAvatar
import ru.f7cloud.talk.models.json.reactions.ReactionVoter
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) }
binding.reaction.text = reactionItem.reaction
binding.name.text = reactionItem.reactionVoter.actorDisplayName
if (user != null && user.baseUrl?.isNotEmpty() == true) {
loadAvatar(reactionItem)
}
}
private fun loadAvatar(reactionItem: ReactionItem) {
if (reactionItem.reactionVoter.actorType == ReactionVoter.ReactionActorType.GUESTS) {
var displayName = sharedApplication?.resources?.getString(R.string.nc_guest)
if (!TextUtils.isEmpty(reactionItem.reactionVoter.actorDisplayName)) {
displayName = reactionItem.reactionVoter.actorDisplayName!!
}
binding.avatar.loadGuestAvatar(user!!.baseUrl!!, displayName!!, false)
} else if (reactionItem.reactionVoter.actorType == ReactionVoter.ReactionActorType.USERS) {
binding.avatar.loadUserAvatar(
user!!,
reactionItem.reactionVoter.actorId!!,
false,
false
)
}
}
}
@@ -0,0 +1,109 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2022 Andy Scherzinger <infoi@andy-scherzinger.de>
* SPDX-FileCopyrightText: 2017 Mario Danic <mario@lovelyhq.com>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.adapters.items
import android.accounts.Account
import android.text.TextUtils
import android.view.View
import androidx.core.net.toUri
import androidx.recyclerview.widget.RecyclerView
import ru.f7cloud.talk.R
import ru.f7cloud.talk.adapters.items.AdvancedUserItem.UserItemViewHolder
import ru.f7cloud.talk.data.user.model.User
import ru.f7cloud.talk.databinding.AccountItemBinding
import ru.f7cloud.talk.extensions.loadUserAvatar
import ru.f7cloud.talk.models.json.participants.Participant
import ru.f7cloud.talk.ui.theme.ViewThemeUtils
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
import eu.davidea.flexibleadapter.items.IFilterable
import eu.davidea.flexibleadapter.items.IFlexible
import eu.davidea.viewholders.FlexibleViewHolder
import java.util.regex.Pattern
class AdvancedUserItem(
/**
* @return the model object
*/
val model: Participant,
@JvmField val user: User?,
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) {
model == other.model
} else {
false
}
override fun hashCode(): Int = model.hashCode()
override fun getLayoutRes(): Int = R.layout.account_item
override fun createViewHolder(
view: View?,
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>?
): UserItemViewHolder = UserItemViewHolder(view, adapter)
override fun bindViewHolder(
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
holder: UserItemViewHolder,
position: Int,
payloads: MutableList<Any>
) {
if (adapter.hasFilter()) {
viewThemeUtils.talk.themeAndHighlightText(
holder.binding.userName,
model.displayName,
adapter.getFilter(String::class.java).toString()
)
} else {
holder.binding.userName.text = model.displayName
}
if (user != null && !TextUtils.isEmpty(user.baseUrl)) {
val host = user.baseUrl!!.toUri().host
if (!TextUtils.isEmpty(host)) {
holder.binding.account.text = user.baseUrl!!.toUri().host
} else {
holder.binding.account.text = user.baseUrl
}
}
if (user?.baseUrl != null &&
(user.baseUrl!!.startsWith("http://") || user.baseUrl!!.startsWith("https://"))
) {
holder.binding.userIcon.loadUserAvatar(user, model.calculatedActorId!!, true, false)
}
if (actionRequiredCount > 0) {
holder.binding.actionRequired.visibility = View.VISIBLE
} else {
holder.binding.actionRequired.visibility = View.GONE
}
}
override fun filter(constraint: String?): Boolean =
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
/**
* Default constructor.
*/
init {
binding = AccountItemBinding.bind(view!!)
}
}
}
@@ -0,0 +1,192 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2022 Marcel Hibbe <dev@mhibbe.de>
* SPDX-FileCopyrightText: 2021 Andy Scherzinger <infoi@andy-scherzinger.de>
* SPDX-FileCopyrightText: 2017 Mario Danic <mario@lovelyhq.com>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.adapters.items
import android.text.TextUtils
import android.view.View
import androidx.core.content.res.ResourcesCompat
import androidx.recyclerview.widget.RecyclerView
import ru.f7cloud.talk.R
import ru.f7cloud.talk.adapters.items.ContactItem.ContactItemViewHolder
import ru.f7cloud.talk.application.F7cloudTalkApplication.Companion.sharedApplication
import ru.f7cloud.talk.data.user.model.User
import ru.f7cloud.talk.databinding.RvItemContactBinding
import ru.f7cloud.talk.extensions.loadUserAvatar
import ru.f7cloud.talk.models.json.participants.Participant
import ru.f7cloud.talk.ui.theme.ViewThemeUtils
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
import eu.davidea.flexibleadapter.items.IFilterable
import eu.davidea.flexibleadapter.items.IFlexible
import eu.davidea.flexibleadapter.items.ISectionable
import eu.davidea.viewholders.FlexibleViewHolder
import java.util.Objects
import java.util.regex.Pattern
class ContactItem(
/**
* @return the model object
*/
val model: Participant,
private val user: User,
private var header: GenericTextHeaderItem?,
private val viewThemeUtils: ViewThemeUtils
) : AbstractFlexibleItem<ContactItemViewHolder?>(),
ISectionable<ContactItemViewHolder?, GenericTextHeaderItem?>,
IFilterable<String?> {
var isOnline: Boolean = true
override fun equals(o: Any?): Boolean {
if (o is ContactItem) {
return model.calculatedActorType == o.model.calculatedActorType &&
model.calculatedActorId == o.model.calculatedActorId
}
return false
}
override fun hashCode(): Int = model.hashCode()
override fun filter(constraint: String?): Boolean =
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.calculatedActorId!!.trim())
.find()
)
override fun getLayoutRes(): Int = R.layout.rv_item_contact
override fun createViewHolder(
view: View?,
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>?
): ContactItemViewHolder = ContactItemViewHolder(view, adapter)
override fun bindViewHolder(
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>?,
holder: ContactItemViewHolder?,
position: Int,
payloads: List<Any>?
) {
if (model.selected) {
holder?.binding?.checkedImageView?.let { viewThemeUtils.platform.colorImageView(it) }
holder?.binding?.checkedImageView?.visibility = View.VISIBLE
} else {
holder?.binding?.checkedImageView?.visibility = View.GONE
}
if (!isOnline) {
holder?.binding?.nameText?.setTextColor(
ResourcesCompat.getColor(
holder.binding.nameText.context.resources,
R.color.medium_emphasis_text,
null
)
)
holder?.binding?.avatarView?.alpha = SEMI_TRANSPARENT
} else {
holder?.binding?.nameText?.setTextColor(
ResourcesCompat.getColor(
holder.binding.nameText.context.resources,
R.color.high_emphasis_text,
null
)
)
holder?.binding?.avatarView?.alpha = FULLY_OPAQUE
}
holder?.binding?.nameText?.text = model.displayName
if (adapter != null) {
if (adapter.hasFilter()) {
holder?.binding?.let {
viewThemeUtils.talk.themeAndHighlightText(
it.nameText,
model.displayName,
adapter.getFilter(String::class.java).toString()
)
}
}
}
if (TextUtils.isEmpty(model.displayName) &&
(
model.type == Participant.ParticipantType.GUEST ||
model.type == Participant.ParticipantType.USER_FOLLOWING_LINK
)
) {
holder?.binding?.nameText?.text = sharedApplication!!.getString(R.string.nc_guest)
}
setAvatar(holder)
}
private fun setAvatar(holder: ContactItemViewHolder?) {
if (model.calculatedActorType == Participant.ActorType.GROUPS ||
model.calculatedActorType == Participant.ActorType.CIRCLES
) {
setGenericAvatar(holder!!, R.drawable.ic_avatar_group)
} 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
) {
var displayName: String?
displayName = if (!TextUtils.isEmpty(model.displayName)) {
model.displayName
} else {
Objects.requireNonNull(sharedApplication)!!.resources!!.getString(R.string.nc_guest)
}
// absolute fallback to prevent NPE deference
if (displayName == null) {
displayName = "Guest"
}
holder?.binding?.avatarView?.loadUserAvatar(user, displayName, true, false)
} else if (model.calculatedActorType == Participant.ActorType.USERS) {
holder?.binding?.avatarView
?.loadUserAvatar(
user,
model.calculatedActorId!!,
true,
false
)
}
}
private fun setGenericAvatar(holder: ContactItemViewHolder, roundPlaceholderDrawable: Int) {
val avatar =
viewThemeUtils.talk.themePlaceholderAvatar(
holder.binding.avatarView,
roundPlaceholderDrawable
)
holder.binding.avatarView.loadUserAvatar(avatar)
}
override fun getHeader(): GenericTextHeaderItem? = header
override fun setHeader(p0: GenericTextHeaderItem?) {
this.header = header
}
class ContactItemViewHolder(view: View?, adapter: FlexibleAdapter<*>?) : FlexibleViewHolder(view, adapter) {
var binding: RvItemContactBinding =
RvItemContactBinding.bind(view!!)
}
companion object {
private const val FULLY_OPAQUE: Float = 1.0f
private const val SEMI_TRANSPARENT: Float = 0.38f
}
}
@@ -0,0 +1,508 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2022 Marcel Hibbe <dev@mhibbe.de>
* SPDX-FileCopyrightText: 2022 Tim Krüger <t@timkrueger.me>
* SPDX-FileCopyrightText: 2021 Andy Scherzinger <infoi@andy-scherzinger.de>
* SPDX-FileCopyrightText: 2017-2019 Mario Danic <mario@lovelyhq.com>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.adapters.items
import android.annotation.SuppressLint
import android.content.Context
import android.content.res.ColorStateList
import android.graphics.Typeface
import android.text.SpannableStringBuilder
import android.text.Spanned
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 coil.dispose
import ru.f7cloud.talk.R
import ru.f7cloud.talk.adapters.items.ConversationItem.ConversationItemViewHolder
import ru.f7cloud.talk.application.F7cloudTalkApplication.Companion.sharedApplication
import ru.f7cloud.talk.chat.data.model.ChatMessage
import ru.f7cloud.talk.chat.data.model.ChatMessage.MessageType
import ru.f7cloud.talk.data.database.mappers.asModel
import ru.f7cloud.talk.data.user.model.User
import ru.f7cloud.talk.databinding.RvItemConversationWithLastMessageBinding
import ru.f7cloud.talk.extensions.loadConversationAvatar
import ru.f7cloud.talk.extensions.loadNoteToSelfAvatar
import ru.f7cloud.talk.extensions.loadSystemAvatar
import ru.f7cloud.talk.extensions.loadUserAvatar
import ru.f7cloud.talk.models.domain.ConversationModel
import ru.f7cloud.talk.models.json.conversations.ConversationEnums
import ru.f7cloud.talk.ui.StatusDrawable
import ru.f7cloud.talk.ui.theme.ViewThemeUtils
import ru.f7cloud.talk.utils.CapabilitiesUtil.hasSpreedFeatureCapability
import ru.f7cloud.talk.utils.DisplayUtils
import ru.f7cloud.talk.utils.SpreedFeatures
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
import eu.davidea.flexibleadapter.items.IFilterable
import eu.davidea.flexibleadapter.items.IFlexible
import eu.davidea.flexibleadapter.items.ISectionable
import eu.davidea.viewholders.FlexibleViewHolder
import java.util.Locale
import java.util.regex.Pattern
class ConversationItem(
val model: ConversationModel,
private val user: User,
private val context: Context,
private val viewThemeUtils: ViewThemeUtils
) : AbstractFlexibleItem<ConversationItemViewHolder>(),
ISectionable<ConversationItemViewHolder, GenericTextHeaderItem?>,
IFilterable<String?> {
private var header: GenericTextHeaderItem? = null
private val chatMessage = model.lastMessage?.asModel()
var mHolder: ConversationItemViewHolder? = null
constructor(
conversation: ConversationModel,
user: User,
activityContext: Context,
genericTextHeaderItem: GenericTextHeaderItem?,
viewThemeUtils: ViewThemeUtils
) : this(conversation, user, activityContext, viewThemeUtils) {
header = genericTextHeaderItem
}
override fun equals(other: Any?): Boolean {
if (other is ConversationItem) {
return model == other.model
}
return false
}
override fun hashCode(): Int {
var result = model.hashCode()
result *= 31
return result
}
override fun getLayoutRes(): Int = R.layout.rv_item_conversation_with_last_message
override fun getItemViewType(): Int = VIEW_TYPE
override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<*>?>?): ConversationItemViewHolder =
ConversationItemViewHolder(view, adapter)
@SuppressLint("SetTextI18n")
override fun bindViewHolder(
adapter: FlexibleAdapter<IFlexible<*>?>,
holder: ConversationItemViewHolder,
position: Int,
payloads: List<Any>
) {
mHolder = holder
val appContext = sharedApplication!!.applicationContext
holder.binding.dialogName.setTextColor(
ResourcesCompat.getColor(
context.resources,
R.color.conversation_item_header,
null
)
)
if (adapter.hasFilter()) {
viewThemeUtils.platform.highlightText(
holder.binding.dialogName,
model.displayName!!,
adapter.getFilter(String::class.java).toString()
)
} else {
holder.binding.dialogName.text = model.displayName
}
if (model.unreadMessages > 0) {
showUnreadMessages(holder)
} else {
holder.binding.dialogName.setTypeface(null, Typeface.NORMAL)
holder.binding.dialogDate.setTypeface(null, Typeface.NORMAL)
holder.binding.dialogLastMessage.setTypeface(null, Typeface.NORMAL)
holder.binding.dialogUnreadBubble.visibility = View.GONE
}
if (model.favorite) {
holder.binding.favoriteConversationImageView.visibility = View.VISIBLE
} else {
holder.binding.favoriteConversationImageView.visibility = View.GONE
}
if (ConversationEnums.ConversationType.ROOM_PUBLIC_CALL == model.type) {
holder.binding.publicCallBadge.setImageResource(R.drawable.ic_avatar_link)
holder.binding.publicCallBadge.visibility = View.VISIBLE
} else if (model.remoteServer?.isNotEmpty() == true) {
holder.binding.publicCallBadge.setImageResource(R.drawable.ic_avatar_federation)
holder.binding.publicCallBadge.visibility = View.VISIBLE
} else {
holder.binding.publicCallBadge.visibility = View.GONE
}
if (ConversationEnums.ConversationType.ROOM_SYSTEM !== model.type) {
val size = DisplayUtils.convertDpToPixel(STATUS_SIZE_IN_DP, appContext)
holder.binding.userStatusImage.visibility = View.VISIBLE
holder.binding.userStatusImage.setImageDrawable(
StatusDrawable(
model.status,
model.statusIcon,
size,
context.resources.getColor(R.color.bg_default, null),
appContext
)
)
} 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)
}
private fun showAvatar(holder: ConversationItemViewHolder) {
holder.binding.dialogAvatar.dispose()
holder.binding.dialogAvatar.visibility = View.VISIBLE
var shouldLoadAvatar = shouldLoadAvatar(holder)
if (ConversationEnums.ConversationType.ROOM_SYSTEM == model.type) {
holder.binding.dialogAvatar.loadSystemAvatar()
shouldLoadAvatar = false
}
if (shouldLoadAvatar) {
when (model.type) {
ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL -> {
if (!TextUtils.isEmpty(model.name)) {
holder.binding.dialogAvatar.loadUserAvatar(
user,
model.name!!,
true,
false
)
} else {
holder.binding.dialogAvatar.visibility = View.GONE
}
}
ConversationEnums.ConversationType.ROOM_GROUP_CALL,
ConversationEnums.ConversationType.FORMER_ONE_TO_ONE,
ConversationEnums.ConversationType.ROOM_PUBLIC_CALL ->
holder.binding.dialogAvatar.loadConversationAvatar(user, model, false, viewThemeUtils)
ConversationEnums.ConversationType.NOTE_TO_SELF ->
holder.binding.dialogAvatar.loadNoteToSelfAvatar()
else -> holder.binding.dialogAvatar.visibility = View.GONE
}
}
}
private fun shouldLoadAvatar(holder: ConversationItemViewHolder): Boolean =
when (model.objectType) {
ConversationEnums.ObjectType.SHARE_PASSWORD -> {
holder.binding.dialogAvatar.setImageDrawable(
ContextCompat.getDrawable(
context,
R.drawable.ic_circular_lock
)
)
false
}
ConversationEnums.ObjectType.FILE -> {
holder.binding.dialogAvatar.loadUserAvatar(
viewThemeUtils.talk.themePlaceholderAvatar(
holder.binding.dialogAvatar,
R.drawable.ic_avatar_document
)
)
false
}
else -> true
}
private fun setLastMessage(holder: ConversationItemViewHolder, appContext: Context) {
if (chatMessage != null) {
holder.binding.dialogDate.visibility = View.VISIBLE
holder.binding.dialogDate.text = DateUtils.getRelativeTimeSpanString(
model.lastActivity * MILLIES,
System.currentTimeMillis(),
0,
DateUtils.FORMAT_ABBREV_RELATIVE
)
if (!TextUtils.isEmpty(chatMessage?.systemMessage) ||
ConversationEnums.ConversationType.ROOM_SYSTEM === model.type
) {
holder.binding.dialogLastMessage.text = chatMessage.text
} else {
chatMessage?.activeUser = user
val text =
if (
chatMessage?.getCalculateMessageType() == MessageType.REGULAR_TEXT_MESSAGE
) {
calculateRegularLastMessageText(appContext)
} else {
lastMessageDisplayText
}
holder.binding.dialogLastMessage.text = text
}
} else {
holder.binding.dialogDate.visibility = View.GONE
holder.binding.dialogLastMessage.text = ""
}
}
private fun calculateRegularLastMessageText(appContext: Context): CharSequence =
if (chatMessage?.actorId == user.userId) {
String.format(
appContext.getString(R.string.nc_formatted_message_you),
lastMessageDisplayText
)
} else if (model.type == ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL) {
lastMessageDisplayText
} else {
val actorName = chatMessage?.actorDisplayName
val authorDisplayName = if (!actorName.isNullOrBlank()) {
actorName
} else if ("guests" == chatMessage?.actorType || "emails" == chatMessage?.actorType) {
appContext.getString(R.string.nc_guest)
} else {
""
}
String.format(
appContext.getString(R.string.nc_formatted_message),
authorDisplayName,
lastMessageDisplayText
)
}
private fun showUnreadMessages(holder: ConversationItemViewHolder) {
holder.binding.dialogName.setTypeface(holder.binding.dialogName.typeface, Typeface.BOLD)
holder.binding.dialogLastMessage.setTypeface(holder.binding.dialogLastMessage.typeface, Typeface.BOLD)
holder.binding.dialogUnreadBubble.visibility = View.VISIBLE
if (model.unreadMessages < UNREAD_MESSAGES_TRESHOLD) {
holder.binding.dialogUnreadBubble.text = String.format(Locale.getDefault(), "%d", model.unreadMessages)
} else {
holder.binding.dialogUnreadBubble.setText(R.string.tooManyUnreadMessages)
}
val lightBubbleFillColor = ColorStateList.valueOf(
ContextCompat.getColor(
context,
R.color.conversation_unread_bubble
)
)
val lightBubbleTextColor = ContextCompat.getColor(
context,
R.color.conversation_unread_bubble_text
)
if (model.type === ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL) {
viewThemeUtils.material.colorChipBackground(holder.binding.dialogUnreadBubble)
} else if (model.unreadMention) {
if (hasSpreedFeatureCapability(user.capabilities?.spreedCapability!!, SpreedFeatures.DIRECT_MENTION_FLAG)) {
if (model.unreadMentionDirect!!) {
viewThemeUtils.material.colorChipBackground(holder.binding.dialogUnreadBubble)
} else {
viewThemeUtils.material.colorChipOutlined(
holder.binding.dialogUnreadBubble,
UNREAD_BUBBLE_STROKE_WIDTH
)
}
} else {
viewThemeUtils.material.colorChipBackground(holder.binding.dialogUnreadBubble)
}
} else {
holder.binding.dialogUnreadBubble.chipBackgroundColor = lightBubbleFillColor
holder.binding.dialogUnreadBubble.setTextColor(lightBubbleTextColor)
}
}
override fun filter(constraint: String?): Boolean =
model.displayName != null &&
Pattern
.compile(constraint!!, Pattern.CASE_INSENSITIVE or Pattern.LITERAL)
.matcher(model.displayName.trim())
.find()
override fun getHeader(): GenericTextHeaderItem? = header
override fun setHeader(header: GenericTextHeaderItem?) {
this.header = header
}
private val lastMessageDisplayText: CharSequence
get() {
if (chatMessage?.getCalculateMessageType() == MessageType.REGULAR_TEXT_MESSAGE ||
chatMessage?.getCalculateMessageType() == MessageType.SYSTEM_MESSAGE ||
chatMessage?.getCalculateMessageType() == MessageType.SINGLE_LINK_MESSAGE
) {
return chatMessage.text
} else {
if (MessageType.SINGLE_LINK_GIPHY_MESSAGE == chatMessage?.getCalculateMessageType() ||
MessageType.SINGLE_LINK_TENOR_MESSAGE == chatMessage?.getCalculateMessageType() ||
MessageType.SINGLE_LINK_GIF_MESSAGE == chatMessage?.getCalculateMessageType()
) {
return if (chatMessage?.actorId == chatMessage?.activeUser!!.userId) {
sharedApplication!!.getString(R.string.nc_sent_a_gif_you)
} else {
String.format(
sharedApplication!!.resources.getString(R.string.nc_sent_a_gif),
chatMessage?.getNullsafeActorDisplayName()
)
}
} else if (MessageType.SINGLE_NC_GEOLOCATION_MESSAGE == chatMessage?.getCalculateMessageType()) {
var locationName = chatMessage.messageParameters?.get("object")?.get("name") ?: ""
val author = authorName(chatMessage)
val lastMessage =
setLastNameForAttachmentMessage(author, R.drawable.baseline_location_pin_24, locationName)
return lastMessage
} else if (MessageType.VOICE_MESSAGE == chatMessage?.getCalculateMessageType()) {
var voiceMessageName = chatMessage.messageParameters?.get("file")?.get("name") ?: ""
val author = authorName(chatMessage)
val lastMessage = setLastNameForAttachmentMessage(
author,
R.drawable.baseline_mic_24,
voiceMessageName
)
return lastMessage
} else if (MessageType.SINGLE_LINK_AUDIO_MESSAGE == chatMessage?.getCalculateMessageType()) {
return if (chatMessage?.actorId == chatMessage?.activeUser!!.userId) {
sharedApplication!!.getString(R.string.nc_sent_an_audio_you)
} else {
String.format(
sharedApplication!!.resources.getString(R.string.nc_sent_an_audio),
chatMessage?.getNullsafeActorDisplayName()
)
}
} else if (MessageType.SINGLE_LINK_VIDEO_MESSAGE == chatMessage?.getCalculateMessageType()) {
return if (chatMessage?.actorId == chatMessage?.activeUser!!.userId) {
sharedApplication!!.getString(R.string.nc_sent_a_video_you)
} else {
String.format(
sharedApplication!!.resources.getString(R.string.nc_sent_a_video),
chatMessage?.getNullsafeActorDisplayName()
)
}
} else if (MessageType.SINGLE_LINK_IMAGE_MESSAGE == chatMessage?.getCalculateMessageType()) {
return if (chatMessage?.actorId == chatMessage?.activeUser!!.userId) {
sharedApplication!!.getString(R.string.nc_sent_an_image_you)
} else {
String.format(
sharedApplication!!.resources.getString(R.string.nc_sent_an_image),
chatMessage?.getNullsafeActorDisplayName()
)
}
} else if (MessageType.POLL_MESSAGE == chatMessage?.getCalculateMessageType()) {
var pollMessageTitle = chatMessage.messageParameters?.get("object")?.get("name") ?: ""
val author = authorName(chatMessage)
val lastMessage = setLastNameForAttachmentMessage(
author,
R.drawable.baseline_bar_chart_24,
pollMessageTitle
)
return lastMessage
} else if (MessageType.SINGLE_NC_ATTACHMENT_MESSAGE == chatMessage?.getCalculateMessageType()) {
var attachmentName = chatMessage.text
if (attachmentName == "{file}") {
attachmentName = chatMessage.messageParameters?.get("file")?.get("name") ?: ""
}
val author = authorName(chatMessage)
val drawable = chatMessage.messageParameters?.get("file")?.get("mimetype")?.let {
when {
it.contains("image") -> R.drawable.baseline_image_24
it.contains("video") -> R.drawable.baseline_video_24
it.contains("application") -> R.drawable.baseline_insert_drive_file_24
it.contains("audio") -> R.drawable.baseline_audiotrack_24
it.contains("text/vcard") -> R.drawable.baseline_contacts_24
else -> null
}
}
val lastMessage = setLastNameForAttachmentMessage(author, drawable, attachmentName!!)
return lastMessage
} else if (MessageType.DECK_CARD == chatMessage?.getCalculateMessageType()) {
var deckTitle = chatMessage.messageParameters?.get("object")?.get("name") ?: ""
val author = authorName(chatMessage)
val lastMessage = setLastNameForAttachmentMessage(author, R.drawable.baseline_article_24, deckTitle)
return lastMessage
}
}
return ""
}
fun authorName(chatMessage: ChatMessage): String {
val name = if (chatMessage.actorId == chatMessage.activeUser!!.userId) {
sharedApplication!!.resources.getString(R.string.nc_current_user)
} else {
chatMessage.getNullsafeActorDisplayName()?.let { "$it:" } ?: ""
}
return name
}
fun setLastNameForAttachmentMessage(actor: String, icon: Int?, attachmentName: String): SpannableStringBuilder {
val builder = SpannableStringBuilder()
builder.append(actor)
val drawable = icon?.let { it -> ContextCompat.getDrawable(context, it) }
if (drawable != null) {
viewThemeUtils.platform.colorDrawable(
drawable,
context.resources.getColor(R.color.low_emphasis_text, null)
)
val desiredWidth = (drawable.intrinsicWidth * IMAGE_SCALE_FACTOR).toInt()
val desiredHeight = (drawable.intrinsicHeight * IMAGE_SCALE_FACTOR).toInt()
drawable.setBounds(0, 0, desiredWidth, desiredHeight)
val imageSpan = ImageSpan(drawable, ImageSpan.ALIGN_BOTTOM)
val startImage = builder.length
builder.append(" ")
builder.setSpan(imageSpan, startImage, startImage + 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
} else {
builder.append(" ")
}
builder.append(attachmentName)
return builder
}
class ConversationItemViewHolder(view: View?, adapter: FlexibleAdapter<*>?) : FlexibleViewHolder(view, adapter) {
var binding: RvItemConversationWithLastMessageBinding
init {
binding = RvItemConversationWithLastMessageBinding.bind(view!!)
}
}
companion object {
const val VIEW_TYPE = FlexibleItemViewType.CONVERSATION_ITEM
private const val MILLIES = 1000L
private const val STATUS_SIZE_IN_DP = 9f
private const val UNREAD_BUBBLE_STROKE_WIDTH = 6.0f
private const val UNREAD_MESSAGES_TRESHOLD = 1000
private const val IMAGE_SCALE_FACTOR = 0.7f
}
}
@@ -0,0 +1,17 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2023 Andy Scherzinger <infoi@andy-scherzinger.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.adapters.items
object FlexibleItemViewType {
const val CONVERSATION_ITEM: Int = 1120391230
const val LOAD_MORE_RESULTS_ITEM: Int = 1120391231
const val MESSAGE_RESULT_ITEM: Int = 1120391232
const val MESSAGES_TEXT_HEADER_ITEM: Int = 1120391233
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
}
@@ -0,0 +1,72 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2022 Andy Scherzinger <infoi@andy-scherzinger.de>
* SPDX-FileCopyrightText: 2017-2018 Mario Danic <mario@lovelyhq.com>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.adapters.items
import android.util.Log
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import ru.f7cloud.talk.R
import ru.f7cloud.talk.databinding.RvItemTitleHeaderBinding
import ru.f7cloud.talk.ui.theme.ViewThemeUtils
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.AbstractHeaderItem
import eu.davidea.flexibleadapter.items.IFlexible
import eu.davidea.viewholders.FlexibleViewHolder
import java.util.Objects
open class GenericTextHeaderItem(title: String, viewThemeUtils: ViewThemeUtils) :
AbstractHeaderItem<GenericTextHeaderItem.HeaderViewHolder>() {
val model: String
private val viewThemeUtils: ViewThemeUtils
init {
isHidden = false
isSelectable = false
this.model = title
this.viewThemeUtils = viewThemeUtils
}
override fun equals(o: Any?): Boolean {
if (o is GenericTextHeaderItem) {
return model == o.model
}
return false
}
override fun hashCode(): Int = Objects.hash(model)
override fun getLayoutRes(): Int = R.layout.rv_item_title_header
override fun createViewHolder(
view: View?,
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>?
): HeaderViewHolder = HeaderViewHolder(view, adapter)
override fun bindViewHolder(
adapter: FlexibleAdapter<IFlexible<*>?>?,
holder: HeaderViewHolder,
position: Int,
payloads: List<Any>
) {
if (payloads.size > 0) {
Log.d(TAG, "We have payloads, so ignoring!")
} else {
holder.binding.titleTextView.text = model
viewThemeUtils.platform.colorPrimaryTextViewElement(holder.binding.titleTextView)
}
}
class HeaderViewHolder(view: View?, adapter: FlexibleAdapter<*>?) : FlexibleViewHolder(view, adapter, true) {
var binding: RvItemTitleHeaderBinding =
RvItemTitleHeaderBinding.bind(view!!)
}
companion object {
private const val TAG = "GenericTextHeaderItem"
}
}
@@ -0,0 +1,53 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2022 Álvaro Brey <alvaro@alvarobrey.com>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.adapters.items
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import ru.f7cloud.talk.R
import ru.f7cloud.talk.databinding.RvItemLoadMoreBinding
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
import eu.davidea.flexibleadapter.items.IFilterable
import eu.davidea.flexibleadapter.items.IFlexible
import eu.davidea.viewholders.FlexibleViewHolder
object LoadMoreResultsItem :
AbstractFlexibleItem<LoadMoreResultsItem.ViewHolder>(),
IFilterable<String> {
// 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)
}
override fun getLayoutRes(): Int = R.layout.rv_item_load_more
override fun createViewHolder(
view: View,
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>
): ViewHolder = ViewHolder(view, adapter)
override fun bindViewHolder(
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
holder: ViewHolder,
position: Int,
payloads: MutableList<Any>?
) {
// nothing, it's immutable
}
override fun filter(constraint: String?): Boolean = true
override fun getItemViewType(): Int = VIEW_TYPE
override fun equals(other: Any?): Boolean = other is LoadMoreResultsItem
override fun hashCode(): Int = 0
}
@@ -0,0 +1,264 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2022 Marcel Hibbe <dev@mhibbe.de>
* SPDX-FileCopyrightText: 2021-2022 Andy Scherzinger <infoi@andy-scherzinger.de>
* SPDX-FileCopyrightText: 2017 Mario Danic <mario@lovelyhq.com>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.adapters.items
import android.annotation.SuppressLint
import android.content.Context
import android.view.View
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.content.res.ResourcesCompat
import ru.f7cloud.talk.PhoneUtils.isPhoneNumber
import ru.f7cloud.talk.R
import ru.f7cloud.talk.adapters.items.ParticipantItem.ParticipantItemViewHolder
import ru.f7cloud.talk.data.user.model.User
import ru.f7cloud.talk.extensions.loadDefaultAvatar
import ru.f7cloud.talk.extensions.loadFederatedUserAvatar
import ru.f7cloud.talk.extensions.loadGuestAvatar
import ru.f7cloud.talk.extensions.loadUserAvatar
import ru.f7cloud.talk.models.json.mention.Mention
import ru.f7cloud.talk.models.json.status.StatusType
import ru.f7cloud.talk.ui.StatusDrawable
import ru.f7cloud.talk.ui.theme.ViewThemeUtils
import ru.f7cloud.talk.utils.DisplayUtils
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
import eu.davidea.flexibleadapter.items.IFilterable
import eu.davidea.flexibleadapter.items.IFlexible
import java.util.Objects
import java.util.regex.Pattern
class MentionAutocompleteItem(
mention: Mention,
private val currentUser: User,
private val context: Context,
@JvmField val roomToken: String,
private val viewThemeUtils: ViewThemeUtils
) : AbstractFlexibleItem<ParticipantItemViewHolder>(),
IFilterable<String?> {
@JvmField
var source: String?
@JvmField
val mentionId: String?
@JvmField
val objectId: String?
@JvmField
val displayName: String?
private val status: String?
private val statusIcon: String?
private val statusMessage: String?
init {
mentionId = mention.mentionId
objectId = mention.id
displayName = if (!mention.label.isNullOrBlank()) {
mention.label
} else if ("guests" == mention.source || "emails" == mention.source) {
context.resources.getString(R.string.nc_guest)
} else {
""
}
source = mention.source
status = mention.status
statusIcon = mention.statusIcon
statusMessage = mention.statusMessage
}
override fun equals(o: Any?): Boolean =
if (o is MentionAutocompleteItem) {
objectId == o.objectId && displayName == o.displayName
} else {
false
}
override fun hashCode(): Int = Objects.hash(objectId, displayName)
override fun getLayoutRes(): Int = R.layout.rv_item_conversation_info_participant
override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<*>?>?): ParticipantItemViewHolder =
ParticipantItemViewHolder(view, adapter)
@SuppressLint("SetTextI18n")
override fun bindViewHolder(
adapter: FlexibleAdapter<IFlexible<*>?>,
holder: ParticipantItemViewHolder,
position: Int,
payloads: List<Any>
) {
holder.binding.nameText.setTextColor(
ResourcesCompat.getColor(
context.resources,
R.color.conversation_item_header,
null
)
)
if (adapter.hasFilter()) {
viewThemeUtils.talk.themeAndHighlightText(
holder.binding.nameText,
displayName,
adapter.getFilter(String::class.java).toString()
)
viewThemeUtils.talk.themeAndHighlightText(
holder.binding.secondaryText,
"@$objectId",
adapter.getFilter(String::class.java).toString()
)
} else {
holder.binding.nameText.text = displayName
}
setAvatar(holder, objectId)
drawStatus(holder)
}
private fun setAvatar(holder: ParticipantItemViewHolder, objectId: String?) {
when (source) {
SOURCE_CALLS -> {
run {
if (isPhoneNumber(displayName)) {
holder.binding.avatarView.loadUserAvatar(
viewThemeUtils.talk.themePlaceholderAvatar(
holder.binding.avatarView,
R.drawable.ic_phone_small
)
)
} 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
)
)
}
SOURCE_FEDERATION -> {
val darkTheme = if (DisplayUtils.isDarkModeOn(context)) 1 else 0
holder.binding.avatarView.loadFederatedUserAvatar(
currentUser,
currentUser.baseUrl!!,
roomToken,
objectId!!,
darkTheme,
requestBigSize = true,
ignoreCache = false
)
}
SOURCE_GUESTS, SOURCE_EMAILS -> {
if (displayName.equals(context.resources.getString(R.string.nc_guest))) {
holder.binding.avatarView.loadDefaultAvatar(viewThemeUtils)
} else {
holder.binding.avatarView.loadGuestAvatar(currentUser, displayName!!, false)
}
}
SOURCE_TEAMS -> {
holder.binding.avatarView.loadUserAvatar(
viewThemeUtils.talk.themePlaceholderAvatar(
holder.binding.avatarView,
R.drawable
.ic_avatar_team_small
)
)
}
else -> {
holder.binding.avatarView.loadUserAvatar(
currentUser,
objectId!!,
requestBigSize = true,
ignoreCache = false
)
}
}
}
private fun drawStatus(holder: ParticipantItemViewHolder) {
val size = DisplayUtils.convertDpToPixel(STATUS_SIZE_IN_DP, context)
holder.binding.userStatusImage.setImageDrawable(
StatusDrawable(
status,
NO_ICON,
size,
context.resources.getColor(R.color.bg_default),
context
)
)
if (statusMessage != null) {
holder.binding.conversationInfoStatusMessage.text = statusMessage
alignUsernameVertical(holder, 0f)
} else {
holder.binding.conversationInfoStatusMessage.text = ""
alignUsernameVertical(holder, NO_USER_STATUS_DP_FROM_TOP)
}
if (!statusIcon.isNullOrEmpty()) {
holder.binding.participantStatusEmoji.setText(statusIcon)
} else {
holder.binding.participantStatusEmoji.visibility = View.GONE
}
if (status != null && status == StatusType.DND.string) {
if (statusMessage.isNullOrEmpty()) {
holder.binding.conversationInfoStatusMessage.setText(R.string.dnd)
}
} else if (status != null && status == StatusType.BUSY.string) {
if (statusMessage.isNullOrEmpty()) {
holder.binding.conversationInfoStatusMessage.setText(R.string.busy)
}
} else if (status != null && status == StatusType.AWAY.string) {
if (statusMessage.isNullOrEmpty()) {
holder.binding.conversationInfoStatusMessage.setText(R.string.away)
}
}
}
private fun alignUsernameVertical(holder: ParticipantItemViewHolder, densityPixelsFromTop: Float) {
val layoutParams = holder.binding.nameText.layoutParams as ConstraintLayout.LayoutParams
layoutParams.topMargin = DisplayUtils.convertDpToPixel(densityPixelsFromTop, context).toInt()
holder.binding.nameText.setLayoutParams(layoutParams)
}
override fun filter(constraint: String?): Boolean =
objectId != null &&
Pattern
.compile(constraint, Pattern.CASE_INSENSITIVE or Pattern.LITERAL)
.matcher(objectId)
.find() ||
displayName != null &&
Pattern
.compile(constraint, Pattern.CASE_INSENSITIVE or Pattern.LITERAL)
.matcher(displayName)
.find()
companion object {
private const val STATUS_SIZE_IN_DP = 9f
private const val NO_ICON = ""
private const val NO_USER_STATUS_DP_FROM_TOP: Float = 10f
const val SOURCE_CALLS = "calls"
const val SOURCE_GUESTS = "guests"
const val SOURCE_GROUPS = "groups"
const val SOURCE_EMAILS = "emails"
const val SOURCE_TEAMS = "teams"
const val SOURCE_FEDERATION = "federated_users"
}
}
@@ -0,0 +1,89 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2022 Álvaro Brey <alvaro@alvarobrey.com>
* SPDX-FileCopyrightText: 2022 Tim Krüger <t@timkrueger.me>
* SPDX-FileCopyrightText: 2021 Andy Scherzinger <infoi@andy-scherzinger.de>
* SPDX-FileCopyrightText: 2017-2019 Mario Danic <mario@lovelyhq.com>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.adapters.items
import android.content.Context
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import ru.f7cloud.talk.R
import ru.f7cloud.talk.data.user.model.User
import ru.f7cloud.talk.databinding.RvItemSearchMessageBinding
import ru.f7cloud.talk.extensions.loadThumbnail
import ru.f7cloud.talk.models.domain.SearchMessageEntry
import ru.f7cloud.talk.ui.theme.ViewThemeUtils
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
import eu.davidea.flexibleadapter.items.IFilterable
import eu.davidea.flexibleadapter.items.IFlexible
import eu.davidea.flexibleadapter.items.ISectionable
import eu.davidea.viewholders.FlexibleViewHolder
data class MessageResultItem(
private val context: Context,
private val currentUser: User,
val messageEntry: SearchMessageEntry,
var showHeader: Boolean = false,
private val viewThemeUtils: ViewThemeUtils
) : AbstractFlexibleItem<MessageResultItem.ViewHolder>(),
IFilterable<String>,
ISectionable<MessageResultItem.ViewHolder, GenericTextHeaderItem> {
class ViewHolder(view: View, adapter: FlexibleAdapter<*>) : FlexibleViewHolder(view, adapter) {
var binding: RvItemSearchMessageBinding
init {
binding = RvItemSearchMessageBinding.bind(view)
}
}
override fun getLayoutRes(): Int = R.layout.rv_item_search_message
override fun createViewHolder(
view: View,
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>
): ViewHolder = ViewHolder(view, adapter)
override fun bindViewHolder(
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
holder: ViewHolder,
position: Int,
payloads: MutableList<Any>?
) {
holder.binding.conversationTitle.text = messageEntry.title
bindMessageExcerpt(holder)
messageEntry.thumbnailURL?.let { holder.binding.thumbnail.loadThumbnail(it, currentUser) }
}
private fun bindMessageExcerpt(holder: ViewHolder) {
viewThemeUtils.platform.highlightText(
holder.binding.messageExcerpt,
messageEntry.messageExcerpt,
messageEntry.searchTerm
)
}
override fun filter(constraint: String?): Boolean = true
override fun getItemViewType(): Int = VIEW_TYPE
companion object {
const val VIEW_TYPE = FlexibleItemViewType.MESSAGE_RESULT_ITEM
}
override fun getHeader(): GenericTextHeaderItem =
MessagesTextHeaderItem(context, viewThemeUtils)
.apply {
isHidden = showHeader // FlexibleAdapter needs this hack for some reason
}
override fun setHeader(header: GenericTextHeaderItem?) {
// nothing, header is always the same
}
}
@@ -0,0 +1,20 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2022 Álvaro Brey <alvaro@alvarobrey.com>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.adapters.items
import android.content.Context
import ru.f7cloud.talk.R
import ru.f7cloud.talk.ui.theme.ViewThemeUtils
class MessagesTextHeaderItem(context: Context, viewThemeUtils: ViewThemeUtils) :
GenericTextHeaderItem(context.getString(R.string.messages), viewThemeUtils) {
companion object {
const val VIEW_TYPE = FlexibleItemViewType.MESSAGES_TEXT_HEADER_ITEM
}
override fun getItemViewType(): Int = VIEW_TYPE
}
@@ -0,0 +1,95 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2022 Andy Scherzinger <infoi@andy-scherzinger.de>
* SPDX-FileCopyrightText: 2017-2018 Mario Danic <mario@lovelyhq.com>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.adapters.items;
import android.view.View;
import ru.f7cloud.talk.R;
import ru.f7cloud.talk.databinding.RvItemNotificationSoundBinding;
import java.util.List;
import java.util.Objects;
import eu.davidea.flexibleadapter.FlexibleAdapter;
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem;
import eu.davidea.flexibleadapter.items.IFlexible;
import eu.davidea.viewholders.FlexibleViewHolder;
public class NotificationSoundItem extends AbstractFlexibleItem<NotificationSoundItem.NotificationSoundItemViewHolder> {
private final String notificationSoundName;
private final String notificationSoundUri;
public NotificationSoundItem(String notificationSoundName, String notificationSoundUri) {
this.notificationSoundName = notificationSoundName;
this.notificationSoundUri = notificationSoundUri;
}
public String getNotificationSoundUri() {
return notificationSoundUri;
}
public String getNotificationSoundName() {
return notificationSoundName;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
NotificationSoundItem that = (NotificationSoundItem) o;
if (!Objects.equals(notificationSoundName, that.notificationSoundName)) {
return false;
}
return Objects.equals(notificationSoundUri, that.notificationSoundUri);
}
@Override
public int hashCode() {
int result = notificationSoundName != null ? notificationSoundName.hashCode() : 0;
return 31 * result + (notificationSoundUri != null ? notificationSoundUri.hashCode() : 0);
}
@Override
public int getLayoutRes() {
return R.layout.rv_item_notification_sound;
}
@Override
public NotificationSoundItemViewHolder createViewHolder(View view, FlexibleAdapter<IFlexible> adapter) {
return new NotificationSoundItemViewHolder(view, adapter);
}
@Override
public void bindViewHolder(FlexibleAdapter<IFlexible> adapter,
NotificationSoundItemViewHolder holder,
int position,
List<Object> payloads) {
holder.binding.notificationNameTextView.setText(notificationSoundName);
holder.binding.notificationNameTextView.setChecked(adapter.isSelected(position));
}
static class NotificationSoundItemViewHolder extends FlexibleViewHolder {
RvItemNotificationSoundBinding binding;
/**
* Default constructor.
*/
NotificationSoundItemViewHolder(View view, FlexibleAdapter adapter) {
super(view, adapter);
binding = RvItemNotificationSoundBinding.bind(view);
}
}
}
@@ -0,0 +1,320 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2022 Marcel Hibbe <dev@mhibbe.de>
* SPDX-FileCopyrightText: 2021 Andy Scherzinger <infoi@andy-scherzinger.de>
* SPDX-FileCopyrightText: 2017 Mario Danic <mario@lovelyhq.com>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.adapters.items
import android.annotation.SuppressLint
import android.content.Context
import android.text.TextUtils
import android.util.Log
import android.view.View
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.content.res.ResourcesCompat
import androidx.recyclerview.widget.RecyclerView
import ru.f7cloud.talk.R
import ru.f7cloud.talk.adapters.items.ParticipantItem.ParticipantItemViewHolder
import ru.f7cloud.talk.application.F7cloudTalkApplication.Companion.sharedApplication
import ru.f7cloud.talk.data.user.model.User
import ru.f7cloud.talk.databinding.RvItemConversationInfoParticipantBinding
import ru.f7cloud.talk.extensions.loadDefaultAvatar
import ru.f7cloud.talk.extensions.loadDefaultGroupCallAvatar
import ru.f7cloud.talk.extensions.loadFederatedUserAvatar
import ru.f7cloud.talk.extensions.loadFirstLetterAvatar
import ru.f7cloud.talk.extensions.loadPhoneAvatar
import ru.f7cloud.talk.extensions.loadTeamAvatar
import ru.f7cloud.talk.extensions.loadUserAvatar
import ru.f7cloud.talk.models.domain.ConversationModel
import ru.f7cloud.talk.models.json.participants.Participant
import ru.f7cloud.talk.models.json.participants.Participant.InCallFlags
import ru.f7cloud.talk.models.json.status.StatusType
import ru.f7cloud.talk.ui.StatusDrawable
import ru.f7cloud.talk.ui.theme.ViewThemeUtils
import ru.f7cloud.talk.utils.ConversationUtils
import ru.f7cloud.talk.utils.DisplayUtils
import ru.f7cloud.talk.utils.DisplayUtils.convertDpToPixel
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
import eu.davidea.flexibleadapter.items.IFilterable
import eu.davidea.flexibleadapter.items.IFlexible
import eu.davidea.viewholders.FlexibleViewHolder
import java.util.regex.Pattern
class ParticipantItem(
private val context: Context,
val model: Participant,
private val user: User,
private val viewThemeUtils: ViewThemeUtils,
private val conversation: ConversationModel
) : AbstractFlexibleItem<ParticipantItemViewHolder>(),
IFilterable<String?> {
var isOnline = true
override fun equals(o: Any?): Boolean =
if (o is ParticipantItem) {
model.calculatedActorType == o.model.calculatedActorType &&
model.calculatedActorId == o.model.calculatedActorId
} else {
false
}
override fun hashCode(): Int = model.hashCode()
override fun getLayoutRes(): Int = R.layout.rv_item_conversation_info_participant
override fun createViewHolder(
view: View?,
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>?
): ParticipantItemViewHolder = ParticipantItemViewHolder(view, adapter)
@SuppressLint("SetTextI18n")
override fun bindViewHolder(
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>?,
holder: ParticipantItemViewHolder?,
position: Int,
payloads: List<*>?
) {
drawStatus(holder!!)
setOnlineStateColor(holder)
holder.binding.nameText.text = model.displayName
if (model.type == Participant.ParticipantType.GUEST && model.displayName.isNullOrBlank()) {
holder.binding.nameText.text = sharedApplication!!.getString(R.string.nc_guest)
}
if (adapter!!.hasFilter()) {
viewThemeUtils.talk.themeAndHighlightText(
holder.binding.nameText,
model.displayName,
adapter.getFilter(
String::class.java
).toString()
)
}
loadAvatars(holder)
showCallIcons(holder)
setParticipantInfo(holder)
}
@SuppressLint("SetTextI18n")
private fun setParticipantInfo(holder: ParticipantItemViewHolder) {
if (TextUtils.isEmpty(model.displayName) &&
(
model.type == Participant.ParticipantType.GUEST ||
model.type == Participant.ParticipantType.USER_FOLLOWING_LINK
)
) {
holder.binding.nameText.text = sharedApplication!!.getString(R.string.nc_guest)
}
var userType = ""
when (model.type) {
Participant.ParticipantType.OWNER,
Participant.ParticipantType.MODERATOR,
Participant.ParticipantType.GUEST_MODERATOR -> {
userType = sharedApplication!!.getString(R.string.nc_moderator)
}
Participant.ParticipantType.USER -> {
userType = sharedApplication!!.getString(R.string.nc_user)
if (model.calculatedActorType == Participant.ActorType.GROUPS) {
userType = sharedApplication!!.getString(R.string.nc_group)
}
if (model.calculatedActorType == Participant.ActorType.CIRCLES) {
userType = sharedApplication!!.getString(R.string.nc_team)
}
}
Participant.ParticipantType.GUEST -> {
userType = sharedApplication!!.getString(R.string.nc_guest)
if (model.calculatedActorType == Participant.ActorType.EMAILS) {
userType = sharedApplication!!.getString(R.string.nc_guest)
}
if (model.invitedActorId?.isNotEmpty() == true &&
ConversationUtils.isParticipantOwnerOrModerator(conversation)
) {
holder.binding.conversationInfoStatusMessage.text = model.invitedActorId
alignUsernameVertical(holder, 0f)
}
}
Participant.ParticipantType.USER_FOLLOWING_LINK -> {
userType = sharedApplication!!.getString(R.string.nc_following_link)
}
else -> {}
}
if (userType != sharedApplication!!.getString(R.string.nc_user)) {
holder.binding.secondaryText.text = "($userType)"
}
}
private fun setOnlineStateColor(holder: ParticipantItemViewHolder) {
if (!isOnline) {
holder.binding.nameText.setTextColor(
ResourcesCompat.getColor(
holder.binding.nameText.context.resources,
R.color.medium_emphasis_text,
null
)
)
holder.binding.avatarView.setAlpha(NOT_ONLINE_ALPHA)
} else {
holder.binding.nameText.setTextColor(
ResourcesCompat.getColor(
holder.binding.nameText.context.resources,
R.color.high_emphasis_text,
null
)
)
holder.binding.avatarView.setAlpha(1.0f)
}
}
@SuppressLint("StringFormatInvalid")
private fun showCallIcons(holder: ParticipantItemViewHolder) {
val resources = sharedApplication!!.resources
val inCallFlag = model.inCall
if (inCallFlag and InCallFlags.WITH_PHONE.toLong() > 0) {
holder.binding.videoCallIcon.setImageResource(R.drawable.ic_call_grey_600_24dp)
holder.binding.videoCallIcon.setVisibility(View.VISIBLE)
holder.binding.videoCallIcon.setContentDescription(
resources.getString(R.string.nc_call_state_with_phone, model.displayName)
)
} else if (inCallFlag and InCallFlags.WITH_VIDEO.toLong() > 0) {
holder.binding.videoCallIcon.setImageResource(R.drawable.ic_videocam_grey_600_24dp)
holder.binding.videoCallIcon.setVisibility(View.VISIBLE)
holder.binding.videoCallIcon.setContentDescription(
resources.getString(R.string.nc_call_state_with_video, model.displayName)
)
} else if (inCallFlag > InCallFlags.DISCONNECTED) {
holder.binding.videoCallIcon.setImageResource(R.drawable.ic_mic_grey_600_24dp)
holder.binding.videoCallIcon.setVisibility(View.VISIBLE)
holder.binding.videoCallIcon.setContentDescription(
resources.getString(R.string.nc_call_state_in_call, model.displayName)
)
} else {
holder.binding.videoCallIcon.setVisibility(View.GONE)
}
}
private fun loadAvatars(holder: ParticipantItemViewHolder) {
when (model.calculatedActorType) {
Participant.ActorType.GROUPS -> {
holder.binding.avatarView.loadDefaultGroupCallAvatar(viewThemeUtils)
}
Participant.ActorType.CIRCLES -> {
holder.binding.avatarView.loadTeamAvatar(viewThemeUtils)
}
Participant.ActorType.USERS -> {
holder.binding.avatarView.loadUserAvatar(user, model.calculatedActorId!!, true, false)
}
Participant.ActorType.GUESTS, Participant.ActorType.EMAILS -> {
val actorName = model.displayName
if (!actorName.isNullOrBlank()) {
holder.binding.avatarView.loadFirstLetterAvatar(actorName)
} else {
holder.binding.avatarView.loadDefaultAvatar(viewThemeUtils)
}
}
Participant.ActorType.FEDERATED -> {
val darkTheme = if (DisplayUtils.isDarkModeOn(context)) 1 else 0
holder.binding.avatarView.loadFederatedUserAvatar(
user,
user.baseUrl!!,
conversation.token,
model.actorId!!,
darkTheme,
true,
false
)
}
Participant.ActorType.PHONES -> {
holder.binding.avatarView.loadPhoneAvatar(viewThemeUtils)
}
else -> {
Log.w(TAG, "Avatar not shown because of unknown ActorType " + model.calculatedActorType)
}
}
}
@Suppress("MagicNumber")
private fun drawStatus(holder: ParticipantItemViewHolder) {
val size = convertDpToPixel(STATUS_SIZE_IN_DP, context)
holder.binding.userStatusImage.setImageDrawable(
StatusDrawable(
model.status,
NO_ICON,
size,
context.resources.getColor(R.color.bg_default),
context
)
)
if (model.statusMessage != null) {
holder.binding.conversationInfoStatusMessage.text = model.statusMessage
alignUsernameVertical(holder, 0f)
} else {
holder.binding.conversationInfoStatusMessage.text = ""
alignUsernameVertical(holder, 10f)
}
if (model.statusIcon != null && model.statusIcon!!.isNotEmpty()) {
holder.binding.participantStatusEmoji.setText(model.statusIcon)
} else {
holder.binding.participantStatusEmoji.visibility = View.GONE
}
if (model.status != null && model.status == StatusType.DND.string) {
if (model.statusMessage == null || model.statusMessage!!.isEmpty()) {
holder.binding.conversationInfoStatusMessage.setText(R.string.dnd)
}
} else if (model.status != null && model.status == StatusType.BUSY.string) {
if (model.statusMessage == null || model.statusMessage!!.isEmpty()) {
holder.binding.conversationInfoStatusMessage.setText(R.string.busy)
}
} else if (model.status != null && model.status == StatusType.AWAY.string) {
if (model.statusMessage == null || model.statusMessage!!.isEmpty()) {
holder.binding.conversationInfoStatusMessage.setText(R.string.away)
}
}
}
private fun alignUsernameVertical(holder: ParticipantItemViewHolder, densityPixelsFromTop: Float) {
val layoutParams = holder.binding.nameText.layoutParams as ConstraintLayout.LayoutParams
layoutParams.topMargin = convertDpToPixel(densityPixelsFromTop, context).toInt()
holder.binding.nameText.setLayoutParams(layoutParams)
}
override fun filter(constraint: String?): Boolean =
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.calculatedActorId!!.trim()).find()
)
class ParticipantItemViewHolder internal constructor(view: View?, adapter: FlexibleAdapter<*>?) :
FlexibleViewHolder(view, adapter) {
var binding: RvItemConversationInfoParticipantBinding
init {
binding = RvItemConversationInfoParticipantBinding.bind(view!!)
}
}
companion object {
private val TAG = ParticipantItem::class.simpleName
private const val STATUS_SIZE_IN_DP = 9f
private const val NO_ICON = ""
private const val NOT_ONLINE_ALPHA = 0.38f
}
}
@@ -0,0 +1,37 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2025 Marcel Hibbe <dev@mhibbe.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.adapters.items
import android.view.View
import ru.f7cloud.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)
}
@@ -0,0 +1,51 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2024 Christian Reiner <foss@christian-reiner.info>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.adapters.messages
import android.widget.RelativeLayout
import androidx.viewbinding.ViewBinding
import ru.f7cloud.talk.databinding.ItemCustomOutcomingDeckCardMessageBinding
import ru.f7cloud.talk.databinding.ItemCustomOutcomingLinkPreviewMessageBinding
import ru.f7cloud.talk.databinding.ItemCustomOutcomingLocationMessageBinding
import ru.f7cloud.talk.databinding.ItemCustomOutcomingPollMessageBinding
import ru.f7cloud.talk.databinding.ItemCustomOutcomingTextMessageBinding
import ru.f7cloud.talk.databinding.ItemCustomOutcomingVoiceMessageBinding
import ru.f7cloud.talk.models.domain.ConversationModel
import ru.f7cloud.talk.models.json.conversations.ConversationEnums.ConversationType
interface AdjustableMessageHolderInterface {
val binding: ViewBinding
fun adjustIfNoteToSelf(currentConversation: ConversationModel?) {
if (currentConversation?.type == ConversationType.NOTE_TO_SELF) {
when (this.binding.javaClass) {
ItemCustomOutcomingTextMessageBinding::class.java ->
(this.binding as ItemCustomOutcomingTextMessageBinding).bubble
ItemCustomOutcomingDeckCardMessageBinding::class.java ->
(this.binding as ItemCustomOutcomingDeckCardMessageBinding).bubble
ItemCustomOutcomingLinkPreviewMessageBinding::class.java ->
(this.binding as ItemCustomOutcomingLinkPreviewMessageBinding).bubble
ItemCustomOutcomingPollMessageBinding::class.java ->
(this.binding as ItemCustomOutcomingPollMessageBinding).bubble
ItemCustomOutcomingVoiceMessageBinding::class.java ->
(this.binding as ItemCustomOutcomingVoiceMessageBinding).bubble
ItemCustomOutcomingLocationMessageBinding::class.java ->
(this.binding as ItemCustomOutcomingLocationMessageBinding).bubble
else -> null
}?.let {
RelativeLayout.LayoutParams(binding.root.layoutParams).apply {
marginStart = 0
marginEnd = 0
}.run {
it.layoutParams = this
}
}
}
}
}
@@ -0,0 +1,12 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2023 Julius Linus <julius.linus@f7cloud.com>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.adapters.messages
interface CallStartedMessageInterface {
fun joinAudioCall()
fun joinVideoCall()
}
@@ -0,0 +1,16 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2022 Marcel Hibbe <dev@mhibbe.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.adapters.messages
import ru.f7cloud.talk.chat.data.model.ChatMessage
interface CommonMessageInterface {
fun onLongClickReactions(chatMessage: ChatMessage)
fun onClickReaction(chatMessage: ChatMessage, emoji: String)
fun onOpenMessageActionsDialog(chatMessage: ChatMessage)
fun openThread(chatMessage: ChatMessage)
}
@@ -0,0 +1,260 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2024 Sowjanya Kota <sowjanya.kch@gmail.com>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.adapters.messages
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.util.Log
import android.view.View
import android.widget.ImageView
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import autodagger.AutoInjector
import coil.load
import com.nextcloud.android.common.ui.theme.utils.ColorRole
import ru.f7cloud.talk.R
import ru.f7cloud.talk.api.NcApi
import ru.f7cloud.talk.application.F7cloudTalkApplication
import ru.f7cloud.talk.application.F7cloudTalkApplication.Companion.sharedApplication
import ru.f7cloud.talk.chat.ChatActivity
import ru.f7cloud.talk.chat.data.model.ChatMessage
import ru.f7cloud.talk.databinding.ItemCustomIncomingDeckCardMessageBinding
import ru.f7cloud.talk.ui.theme.ViewThemeUtils
import ru.f7cloud.talk.utils.ApiUtils
import ru.f7cloud.talk.utils.ChatMessageUtils
import ru.f7cloud.talk.utils.DateUtils
import ru.f7cloud.talk.utils.message.MessageUtils
import ru.f7cloud.talk.utils.preferences.AppPreferences
import com.stfalcon.chatkit.messages.MessageHolders
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
@AutoInjector(F7cloudTalkApplication::class)
class IncomingDeckCardViewHolder(incomingView: View, payload: Any) :
MessageHolders
.IncomingTextMessageViewHolder<ChatMessage>(incomingView, payload) {
private val binding: ItemCustomIncomingDeckCardMessageBinding =
ItemCustomIncomingDeckCardMessageBinding.bind(itemView)
@Inject
lateinit var context: Context
@Inject
lateinit var appPreferences: AppPreferences
@Inject
lateinit var viewThemeUtils: ViewThemeUtils
@Inject
lateinit var messageUtils: MessageUtils
@Inject
lateinit var dateUtils: DateUtils
@Inject
lateinit var ncApi: NcApi
lateinit var message: ChatMessage
lateinit var commonMessageInterface: CommonMessageInterface
var stackName: String? = null
var cardName: String? = null
var boardName: String? = null
var cardLink: String? = null
@SuppressLint("SetTextI18n")
override fun onBind(message: ChatMessage) {
super.onBind(message)
this.message = message
sharedApplication!!.componentApplication.inject(this)
binding.messageTime.text = dateUtils.getLocalTimeStringFromTimestamp(message.timestamp)
setAvatarAndAuthorOnMessageItem(message)
showDeckCard(message)
colorizeMessageBubble(message)
binding.cardView.findViewById<ImageView>(R.id.deckCardImage)?.let {
viewThemeUtils.platform.colorImageView(it, ColorRole.SECONDARY)
}
itemView.isSelected = false
// parent message handling
setParentMessageDataOnMessageItem(message)
binding.cardView.setOnLongClickListener { l: View? ->
commonMessageInterface.onOpenMessageActionsDialog(message)
true
}
binding.cardView.setOnClickListener {
val browserIntent = Intent(Intent.ACTION_VIEW, cardLink!!.toUri())
browserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(browserIntent)
}
itemView.setTag(R.string.replyable_message_view_tag, message.replyable)
Reaction().showReactions(
message,
::clickOnReaction,
::longClickOnReaction,
binding.reactions,
binding.messageTime.context,
false,
viewThemeUtils
)
}
@SuppressLint("StringFormatInvalid")
private fun showDeckCard(message: ChatMessage) {
if (message.messageParameters != null && message.messageParameters!!.size > 0) {
for (key in message.messageParameters!!.keys) {
val individualHashMap: Map<String?, String?> = message.messageParameters!![key]!!
if (individualHashMap["type"] == "deck-card") {
cardName = individualHashMap["name"]
stackName = individualHashMap["stackname"]
boardName = individualHashMap["boardname"]
cardLink = individualHashMap["link"]
}
}
}
if (cardName?.isNotEmpty() == true) {
val cardDescription = String.format(
context.resources.getString(R.string.deck_card_description),
stackName,
boardName
)
binding.cardName.visibility = View.VISIBLE
binding.cardDescription.visibility = View.VISIBLE
binding.cardName.text = cardName
binding.cardDescription.text = cardDescription
}
}
private fun longClickOnReaction(chatMessage: ChatMessage) {
commonMessageInterface.onLongClickReactions(chatMessage)
}
private fun clickOnReaction(chatMessage: ChatMessage, emoji: String) {
commonMessageInterface.onClickReaction(chatMessage, emoji)
}
private fun setAvatarAndAuthorOnMessageItem(message: ChatMessage) {
val actorName = message.actorDisplayName
if (!actorName.isNullOrBlank()) {
binding.messageAuthor.visibility = View.VISIBLE
binding.messageAuthor.text = actorName
binding.messageUserAvatar.setOnClickListener {
(payload as? MessagePayload)?.profileBottomSheet?.showFor(message, itemView.context)
}
} else {
binding.messageAuthor.setText(R.string.nc_nick_guest)
}
if (!message.isGrouped && !message.isOneToOneConversation && !message.isFormerOneToOneConversation) {
ChatMessageUtils().setAvatarOnMessage(binding.messageUserAvatar, message, viewThemeUtils)
} else {
if (message.isOneToOneConversation || message.isFormerOneToOneConversation) {
binding.messageUserAvatar.visibility = View.GONE
} else {
binding.messageUserAvatar.visibility = View.INVISIBLE
}
binding.messageAuthor.visibility = View.GONE
}
}
private fun colorizeMessageBubble(message: ChatMessage) {
viewThemeUtils.talk.themeIncomingMessageBubble(bubble, message.isGrouped, message.isDeleted)
}
@Suppress("Detekt.TooGenericExceptionCaught", "Detekt.LongMethod")
private fun setParentMessageDataOnMessageItem(message: ChatMessage) {
if (message.parentMessageId != null && !message.isDeleted) {
CoroutineScope(Dispatchers.Main).launch {
try {
val chatActivity = commonMessageInterface as ChatActivity
val urlForChatting = ApiUtils.getUrlForChat(
chatActivity.chatApiVersion,
chatActivity.conversationUser?.baseUrl,
chatActivity.roomToken
)
val parentChatMessage = withContext(Dispatchers.IO) {
chatActivity.chatViewModel.getMessageById(
urlForChatting,
chatActivity.currentConversation!!,
message.parentMessageId!!
).first()
}
parentChatMessage.activeUser = message.activeUser
parentChatMessage.imageUrl?.let {
binding.messageQuote.quotedMessageImage.visibility = View.VISIBLE
binding.messageQuote.quotedMessageImage.load(it) {
addHeader(
"Authorization",
ApiUtils.getCredentials(message.activeUser!!.username, message.activeUser!!.token)!!
)
}
} ?: run {
binding.messageQuote.quotedMessageImage.visibility = View.GONE
}
binding.messageQuote.quotedMessageAuthor.text = parentChatMessage.actorDisplayName
?: context.getText(R.string.nc_nick_guest)
binding.messageQuote.quotedMessage.text = messageUtils
.enrichChatReplyMessageText(
binding.messageQuote.quotedMessage.context,
parentChatMessage,
true,
viewThemeUtils
)
binding.messageQuote.quotedMessageAuthor
.setTextColor(ContextCompat.getColor(context, R.color.textColorMaxContrast))
viewThemeUtils.talk.themeParentMessage(
parentChatMessage,
message,
binding.messageQuote.quotedChatMessageView
)
binding.messageQuote.quotedChatMessageView.visibility =
if (!message.isDeleted &&
message.parentMessageId != null &&
message.parentMessageId != chatActivity.conversationThreadId
) {
View.VISIBLE
} else {
View.GONE
}
} catch (e: Exception) {
Log.d(TAG, "Error when processing parent message in view holder", e)
}
}
} else {
binding.messageQuote.quotedChatMessageView.visibility = View.GONE
}
}
fun assignCommonMessageInterface(commonMessageInterface: CommonMessageInterface) {
this.commonMessageInterface = commonMessageInterface
}
companion object {
private val TAG = IncomingDeckCardViewHolder::class.java.simpleName
}
}
@@ -0,0 +1,252 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2022 Marcel Hibbe <dev@mhibbe.de>
* SPDX-FileCopyrightText: 2017-2019 Mario Danic <mario@lovelyhq.com>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.adapters.messages
import android.annotation.SuppressLint
import android.content.Context
import android.util.Log
import android.view.View
import androidx.core.content.ContextCompat
import autodagger.AutoInjector
import coil.load
import ru.f7cloud.talk.R
import ru.f7cloud.talk.api.NcApi
import ru.f7cloud.talk.application.F7cloudTalkApplication
import ru.f7cloud.talk.application.F7cloudTalkApplication.Companion.sharedApplication
import ru.f7cloud.talk.chat.ChatActivity
import ru.f7cloud.talk.chat.data.model.ChatMessage
import ru.f7cloud.talk.databinding.ItemCustomIncomingLinkPreviewMessageBinding
import ru.f7cloud.talk.ui.theme.ViewThemeUtils
import ru.f7cloud.talk.utils.ApiUtils
import ru.f7cloud.talk.utils.ChatMessageUtils
import ru.f7cloud.talk.utils.DateUtils
import ru.f7cloud.talk.utils.message.MessageUtils
import ru.f7cloud.talk.utils.preferences.AppPreferences
import com.stfalcon.chatkit.messages.MessageHolders
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
@AutoInjector(F7cloudTalkApplication::class)
class IncomingLinkPreviewMessageViewHolder(incomingView: View, payload: Any) :
MessageHolders.IncomingTextMessageViewHolder<ChatMessage>(incomingView, payload) {
private val binding: ItemCustomIncomingLinkPreviewMessageBinding =
ItemCustomIncomingLinkPreviewMessageBinding.bind(itemView)
@Inject
lateinit var context: Context
@Inject
lateinit var appPreferences: AppPreferences
@Inject
lateinit var viewThemeUtils: ViewThemeUtils
@Inject
lateinit var messageUtils: MessageUtils
@Inject
lateinit var dateUtils: DateUtils
@Inject
lateinit var ncApi: NcApi
lateinit var message: ChatMessage
lateinit var commonMessageInterface: CommonMessageInterface
@SuppressLint("SetTextI18n")
override fun onBind(message: ChatMessage) {
super.onBind(message)
this.message = message
sharedApplication!!.componentApplication.inject(this)
binding.messageTime.text = dateUtils.getLocalTimeStringFromTimestamp(message.timestamp)
var processedMessageText = messageUtils.enrichChatMessageText(
binding.messageText.context,
message,
true,
viewThemeUtils
)
processedMessageText = messageUtils.processMessageParameters(
binding.messageText.context,
viewThemeUtils,
processedMessageText!!,
message,
itemView
)
binding.messageText.text = processedMessageText
setAvatarAndAuthorOnMessageItem(message)
colorizeMessageBubble(message)
itemView.isSelected = false
// parent message handling
setParentMessageDataOnMessageItem(message)
LinkPreview().showLink(
message,
ncApi,
binding.referenceInclude,
itemView.context
)
binding.referenceInclude.referenceWrapper.setOnLongClickListener { l: View? ->
commonMessageInterface.onOpenMessageActionsDialog(message)
true
}
itemView.setTag(R.string.replyable_message_view_tag, message.replyable)
val chatActivity = commonMessageInterface as ChatActivity
val showThreadButton = chatActivity.conversationThreadId == null && message.isThread
if (showThreadButton) {
binding.reactions.threadButton.visibility = View.VISIBLE
binding.reactions.threadButton.setContent {
ThreadButtonComposable(
onButtonClick = { openThread(message) }
)
}
} else {
binding.reactions.threadButton.visibility = View.GONE
}
Reaction().showReactions(
message,
::clickOnReaction,
::longClickOnReaction,
binding.reactions,
binding.messageTime.context,
false,
viewThemeUtils
)
}
private fun longClickOnReaction(chatMessage: ChatMessage) {
commonMessageInterface.onLongClickReactions(chatMessage)
}
private fun clickOnReaction(chatMessage: ChatMessage, emoji: String) {
commonMessageInterface.onClickReaction(chatMessage, emoji)
}
private fun openThread(chatMessage: ChatMessage) {
commonMessageInterface.openThread(chatMessage)
}
private fun setAvatarAndAuthorOnMessageItem(message: ChatMessage) {
val actorName = message.actorDisplayName
if (!actorName.isNullOrBlank()) {
binding.messageAuthor.visibility = View.VISIBLE
binding.messageAuthor.text = actorName
binding.messageUserAvatar.setOnClickListener {
(payload as? MessagePayload)?.profileBottomSheet?.showFor(message, itemView.context)
}
} else {
binding.messageAuthor.setText(R.string.nc_nick_guest)
}
if (!message.isGrouped && !message.isOneToOneConversation && !message.isFormerOneToOneConversation) {
ChatMessageUtils().setAvatarOnMessage(binding.messageUserAvatar, message, viewThemeUtils)
} else {
if (message.isOneToOneConversation || message.isFormerOneToOneConversation) {
binding.messageUserAvatar.visibility = View.GONE
} else {
binding.messageUserAvatar.visibility = View.INVISIBLE
}
binding.messageAuthor.visibility = View.GONE
}
}
private fun colorizeMessageBubble(message: ChatMessage) {
viewThemeUtils.talk.themeIncomingMessageBubble(bubble, message.isGrouped, message.isDeleted)
}
@Suppress("Detekt.TooGenericExceptionCaught", "Detekt.LongMethod")
private fun setParentMessageDataOnMessageItem(message: ChatMessage) {
if (message.parentMessageId != null && !message.isDeleted) {
CoroutineScope(Dispatchers.Main).launch {
try {
val chatActivity = commonMessageInterface as ChatActivity
val urlForChatting = ApiUtils.getUrlForChat(
chatActivity.chatApiVersion,
chatActivity.conversationUser?.baseUrl,
chatActivity.roomToken
)
val parentChatMessage = withContext(Dispatchers.IO) {
chatActivity.chatViewModel.getMessageById(
urlForChatting,
chatActivity.currentConversation!!,
message.parentMessageId!!
).first()
}
parentChatMessage.activeUser = message.activeUser
parentChatMessage.imageUrl?.let {
binding.messageQuote.quotedMessageImage.visibility = View.VISIBLE
binding.messageQuote.quotedMessageImage.load(it) {
addHeader(
"Authorization",
ApiUtils.getCredentials(message.activeUser!!.username, message.activeUser!!.token)!!
)
}
} ?: run {
binding.messageQuote.quotedMessageImage.visibility = View.GONE
}
binding.messageQuote.quotedMessageAuthor.text = parentChatMessage.actorDisplayName
?: context.getText(R.string.nc_nick_guest)
binding.messageQuote.quotedMessage.text = messageUtils
.enrichChatReplyMessageText(
binding.messageQuote.quotedMessage.context,
parentChatMessage,
true,
viewThemeUtils
)
binding.messageQuote.quotedMessageAuthor
.setTextColor(ContextCompat.getColor(context, R.color.textColorMaxContrast))
viewThemeUtils.talk.themeParentMessage(
parentChatMessage,
message,
binding.messageQuote.quotedChatMessageView
)
binding.messageQuote.quotedChatMessageView.visibility =
if (!message.isDeleted &&
message.parentMessageId != null &&
message.parentMessageId != chatActivity.conversationThreadId
) {
View.VISIBLE
} else {
View.GONE
}
} catch (e: Exception) {
Log.d(TAG, "Error when processing parent message in view holder", e)
}
}
} else {
binding.messageQuote.quotedChatMessageView.visibility = View.GONE
}
}
fun assignCommonMessageInterface(commonMessageInterface: CommonMessageInterface) {
this.commonMessageInterface = commonMessageInterface
}
companion object {
private val TAG = IncomingLinkPreviewMessageViewHolder::class.java.simpleName
}
}
@@ -0,0 +1,286 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2021 Andy Scherzinger <info@andy-scherzinger.de>
* SPDX-FileCopyrightText: 2021 Tim Krüger <t@timkrueger.me>
* SPDX-FileCopyrightText: 2021 Marcel Hibbe <dev@mhibbe.de>
* SPDX-FileCopyrightText: 2017-2018 Mario Danic <mario@lovelyhq.com>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.adapters.messages
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.util.Log
import android.util.TypedValue
import android.view.MotionEvent
import android.view.View
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.core.net.toUri
import autodagger.AutoInjector
import coil.load
import com.google.android.material.snackbar.Snackbar
import ru.f7cloud.talk.R
import ru.f7cloud.talk.application.F7cloudTalkApplication
import ru.f7cloud.talk.application.F7cloudTalkApplication.Companion.sharedApplication
import ru.f7cloud.talk.chat.ChatActivity
import ru.f7cloud.talk.chat.data.model.ChatMessage
import ru.f7cloud.talk.databinding.ItemCustomIncomingLocationMessageBinding
import ru.f7cloud.talk.ui.theme.ViewThemeUtils
import ru.f7cloud.talk.utils.ApiUtils
import ru.f7cloud.talk.utils.ChatMessageUtils
import ru.f7cloud.talk.utils.DateUtils
import ru.f7cloud.talk.utils.UriUtils
import ru.f7cloud.talk.utils.message.MessageUtils
import ru.f7cloud.talk.utils.preferences.AppPreferences
import com.stfalcon.chatkit.messages.MessageHolders
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.net.URLEncoder
import javax.inject.Inject
@AutoInjector(F7cloudTalkApplication::class)
class IncomingLocationMessageViewHolder(incomingView: View, payload: Any) :
MessageHolders.IncomingTextMessageViewHolder<ChatMessage>(incomingView, payload) {
private val binding: ItemCustomIncomingLocationMessageBinding =
ItemCustomIncomingLocationMessageBinding.bind(itemView)
var locationLon: String? = ""
var locationLat: String? = ""
var locationName: String? = ""
var locationGeoLink: String? = ""
@Inject
lateinit var context: Context
@Inject
lateinit var appPreferences: AppPreferences
@Inject
lateinit var viewThemeUtils: ViewThemeUtils
@Inject
lateinit var messageUtils: MessageUtils
@Inject
lateinit var dateUtils: DateUtils
lateinit var commonMessageInterface: CommonMessageInterface
@SuppressLint("SetTextI18n")
override fun onBind(message: ChatMessage) {
super.onBind(message)
sharedApplication!!.componentApplication.inject(this)
setAvatarAndAuthorOnMessageItem(message)
colorizeMessageBubble(message)
itemView.isSelected = false
val textSize = context.resources!!.getDimension(R.dimen.chat_text_size)
binding.messageText.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize)
binding.messageText.text = message.text
binding.messageTime.text = dateUtils.getLocalTimeStringFromTimestamp(message.timestamp)
// parent message handling
setParentMessageDataOnMessageItem(message)
// geo-location
setLocationDataOnMessageItem(message)
Reaction().showReactions(
message,
::clickOnReaction,
::longClickOnReaction,
binding.reactions,
binding.messageText.context,
false,
viewThemeUtils
)
}
private fun longClickOnReaction(chatMessage: ChatMessage) {
commonMessageInterface.onLongClickReactions(chatMessage)
}
private fun clickOnReaction(chatMessage: ChatMessage, emoji: String) {
commonMessageInterface.onClickReaction(chatMessage, emoji)
}
private fun setAvatarAndAuthorOnMessageItem(message: ChatMessage) {
val actorName = message.actorDisplayName
if (!actorName.isNullOrBlank()) {
binding.messageAuthor.visibility = View.VISIBLE
binding.messageAuthor.text = actorName
binding.messageUserAvatar.setOnClickListener {
(payload as? MessagePayload)?.profileBottomSheet?.showFor(message, itemView.context)
}
} else {
binding.messageAuthor.setText(R.string.nc_nick_guest)
}
if (!message.isGrouped && !message.isOneToOneConversation && !message.isFormerOneToOneConversation) {
ChatMessageUtils().setAvatarOnMessage(binding.messageUserAvatar, message, viewThemeUtils)
} else {
if (message.isOneToOneConversation || message.isFormerOneToOneConversation) {
binding.messageUserAvatar.visibility = View.GONE
} else {
binding.messageUserAvatar.visibility = View.INVISIBLE
}
binding.messageAuthor.visibility = View.GONE
}
}
private fun colorizeMessageBubble(message: ChatMessage) {
viewThemeUtils.talk.themeIncomingMessageBubble(bubble, message.isGrouped, message.isDeleted)
}
@Suppress("Detekt.TooGenericExceptionCaught", "Detekt.LongMethod")
private fun setParentMessageDataOnMessageItem(message: ChatMessage) {
if (message.parentMessageId != null && !message.isDeleted) {
CoroutineScope(Dispatchers.Main).launch {
try {
val chatActivity = commonMessageInterface as ChatActivity
val urlForChatting = ApiUtils.getUrlForChat(
chatActivity.chatApiVersion,
chatActivity.conversationUser?.baseUrl,
chatActivity.roomToken
)
val parentChatMessage = withContext(Dispatchers.IO) {
chatActivity.chatViewModel.getMessageById(
urlForChatting,
chatActivity.currentConversation!!,
message.parentMessageId!!
).first()
}
parentChatMessage.activeUser = message.activeUser
parentChatMessage.imageUrl?.let {
binding.messageQuote.quotedMessageImage.visibility = View.VISIBLE
binding.messageQuote.quotedMessageImage.load(it) {
addHeader(
"Authorization",
ApiUtils.getCredentials(message.activeUser!!.username, message.activeUser!!.token)!!
)
}
} ?: run {
binding.messageQuote.quotedMessageImage.visibility = View.GONE
}
binding.messageQuote.quotedMessageAuthor.text = parentChatMessage.actorDisplayName
?: context.getText(R.string.nc_nick_guest)
binding.messageQuote.quotedMessage.text = messageUtils
.enrichChatReplyMessageText(
binding.messageQuote.quotedMessage.context,
parentChatMessage,
true,
viewThemeUtils
)
binding.messageQuote.quotedMessageAuthor
.setTextColor(context.resources.getColor(R.color.textColorMaxContrast, null))
viewThemeUtils.talk.themeParentMessage(
parentChatMessage,
message,
binding.messageQuote.quotedChatMessageView
)
binding.messageQuote.quotedChatMessageView.visibility =
if (!message.isDeleted &&
message.parentMessageId != null &&
message.parentMessageId != chatActivity.conversationThreadId
) {
View.VISIBLE
} else {
View.GONE
}
} catch (e: Exception) {
Log.d(TAG, "Error when processing parent message in view holder", e)
}
}
} else {
binding.messageQuote.quotedChatMessageView.visibility = View.GONE
}
}
@SuppressLint("SetJavaScriptEnabled", "ClickableViewAccessibility")
private fun setLocationDataOnMessageItem(message: ChatMessage) {
if (message.messageParameters != null && message.messageParameters!!.size > 0) {
for (key in message.messageParameters!!.keys) {
val individualHashMap: Map<String?, String?> = message.messageParameters!![key]!!
if (individualHashMap["type"] == "geo-location") {
locationLon = individualHashMap["longitude"]
locationLat = individualHashMap["latitude"]
locationName = individualHashMap["name"]
locationGeoLink = individualHashMap["id"]
}
}
}
binding.webview.settings.javaScriptEnabled = true
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)) {
view?.context?.startActivity(Intent(Intent.ACTION_VIEW, url.toUri()))
true
} else {
false
}
}
val urlStringBuffer = StringBuffer("file:///android_asset/leafletMapMessagePreview.html")
urlStringBuffer.append(
"?mapProviderUrl=" + URLEncoder.encode(context.getString(R.string.osm_tile_server_url))
)
urlStringBuffer.append(
"&mapProviderAttribution=" + URLEncoder.encode(context.getString(R.string.osm_tile_server_attributation))
)
urlStringBuffer.append("&locationLat=" + URLEncoder.encode(locationLat))
urlStringBuffer.append("&locationLon=" + URLEncoder.encode(locationLon))
urlStringBuffer.append("&locationName=" + URLEncoder.encode(locationName))
urlStringBuffer.append("&locationGeoLink=" + URLEncoder.encode(locationGeoLink))
binding.webview.loadUrl(urlStringBuffer.toString())
binding.webview.setOnTouchListener(object : View.OnTouchListener {
override fun onTouch(v: View?, event: MotionEvent?): Boolean {
when (event?.action) {
MotionEvent.ACTION_UP -> openGeoLink()
}
return v?.onTouchEvent(event) ?: true
}
})
}
private fun openGeoLink() {
if (!locationGeoLink.isNullOrEmpty()) {
val geoLinkWithMarker = addMarkerToGeoLink(locationGeoLink!!)
val browserIntent = Intent(Intent.ACTION_VIEW, geoLinkWithMarker.toUri())
browserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(browserIntent)
} else {
Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show()
Log.e(TAG, "locationGeoLink was null or empty")
}
}
private fun addMarkerToGeoLink(locationGeoLink: String): String = locationGeoLink.replace("geo:", "geo:0,0?q=")
fun assignCommonMessageInterface(commonMessageInterface: CommonMessageInterface) {
this.commonMessageInterface = commonMessageInterface
}
companion object {
private const val TAG = "LocInMessageView"
}
}
@@ -0,0 +1,254 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2022 Marcel Hibbe <dev@mhibbe.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.adapters.messages
import android.annotation.SuppressLint
import android.content.Context
import android.util.Log
import android.view.View
import androidx.core.content.ContextCompat
import autodagger.AutoInjector
import coil.load
import ru.f7cloud.talk.R
import ru.f7cloud.talk.api.NcApi
import ru.f7cloud.talk.application.F7cloudTalkApplication
import ru.f7cloud.talk.application.F7cloudTalkApplication.Companion.sharedApplication
import ru.f7cloud.talk.chat.ChatActivity
import ru.f7cloud.talk.chat.data.model.ChatMessage
import ru.f7cloud.talk.databinding.ItemCustomIncomingPollMessageBinding
import ru.f7cloud.talk.polls.ui.PollMainDialogFragment
import ru.f7cloud.talk.ui.theme.ViewThemeUtils
import ru.f7cloud.talk.utils.ApiUtils
import ru.f7cloud.talk.utils.ChatMessageUtils
import ru.f7cloud.talk.utils.DateUtils
import ru.f7cloud.talk.utils.message.MessageUtils
import ru.f7cloud.talk.utils.preferences.AppPreferences
import com.stfalcon.chatkit.messages.MessageHolders
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
@AutoInjector(F7cloudTalkApplication::class)
class IncomingPollMessageViewHolder(incomingView: View, payload: Any) :
MessageHolders.IncomingTextMessageViewHolder<ChatMessage>(incomingView, payload) {
private val binding: ItemCustomIncomingPollMessageBinding = ItemCustomIncomingPollMessageBinding.bind(itemView)
@Inject
lateinit var context: Context
@Inject
lateinit var appPreferences: AppPreferences
@Inject
lateinit var viewThemeUtils: ViewThemeUtils
@Inject
lateinit var messageUtils: MessageUtils
@Inject
lateinit var dateUtils: DateUtils
@Inject
lateinit var ncApi: NcApi
lateinit var message: ChatMessage
lateinit var commonMessageInterface: CommonMessageInterface
@SuppressLint("SetTextI18n")
override fun onBind(message: ChatMessage) {
super.onBind(message)
this.message = message
sharedApplication!!.componentApplication.inject(this)
binding.messageTime.text = dateUtils.getLocalTimeStringFromTimestamp(message.timestamp)
setAvatarAndAuthorOnMessageItem(message)
colorizeMessageBubble(message)
itemView.isSelected = false
// parent message handling
setParentMessageDataOnMessageItem(message)
setPollPreview(message)
val chatActivity = commonMessageInterface as ChatActivity
Thread().showThreadPreview(
chatActivity,
message,
threadBinding = binding.threadTitleWrapper,
reactionsBinding = binding.reactions,
openThread = { openThread(message) }
)
Reaction().showReactions(
message,
::clickOnReaction,
::longClickOnReaction,
binding.reactions,
binding.messageTime.context,
false,
viewThemeUtils
)
}
private fun longClickOnReaction(chatMessage: ChatMessage) {
commonMessageInterface.onLongClickReactions(chatMessage)
}
private fun clickOnReaction(chatMessage: ChatMessage, emoji: String) {
commonMessageInterface.onClickReaction(chatMessage, emoji)
}
private fun openThread(chatMessage: ChatMessage) {
commonMessageInterface.openThread(chatMessage)
}
private fun setPollPreview(message: ChatMessage) {
var pollId: String? = null
var pollName: String? = null
if (message.messageParameters != null && message.messageParameters!!.size > 0) {
for (key in message.messageParameters!!.keys) {
val individualHashMap: Map<String?, String?> = message.messageParameters!![key]!!
if (individualHashMap["type"] == "talk-poll") {
pollId = individualHashMap["id"]
pollName = individualHashMap["name"].toString()
}
}
}
if (pollId != null && pollName != null) {
binding.messagePollTitle.text = pollName
val roomToken = (payload as? MessagePayload)!!.roomToken
val isOwnerOrModerator = (payload as? MessagePayload)!!.isOwnerOrModerator ?: false
binding.bubble.setOnClickListener {
val pollVoteDialog = PollMainDialogFragment.newInstance(
message.activeUser!!,
roomToken,
isOwnerOrModerator,
pollId,
pollName
)
pollVoteDialog.show(
(binding.messagePollIcon.context as ChatActivity).supportFragmentManager,
TAG
)
}
}
}
private fun setAvatarAndAuthorOnMessageItem(message: ChatMessage) {
val actorName = message.actorDisplayName
if (!actorName.isNullOrBlank()) {
binding.messageAuthor.visibility = View.VISIBLE
binding.messageAuthor.text = actorName
binding.messageUserAvatar.setOnClickListener {
(payload as? MessagePayload)?.profileBottomSheet?.showFor(message, itemView.context)
}
} else {
binding.messageAuthor.setText(R.string.nc_nick_guest)
}
if (!message.isGrouped && !message.isOneToOneConversation && !message.isFormerOneToOneConversation) {
ChatMessageUtils().setAvatarOnMessage(binding.messageUserAvatar, message, viewThemeUtils)
} else {
if (message.isOneToOneConversation || message.isFormerOneToOneConversation) {
binding.messageUserAvatar.visibility = View.GONE
} else {
binding.messageUserAvatar.visibility = View.INVISIBLE
}
binding.messageAuthor.visibility = View.GONE
}
}
private fun colorizeMessageBubble(message: ChatMessage) {
viewThemeUtils.talk.themeIncomingMessageBubble(bubble, message.isGrouped, message.isDeleted)
}
@Suppress("Detekt.TooGenericExceptionCaught", "Detekt.LongMethod")
private fun setParentMessageDataOnMessageItem(message: ChatMessage) {
if (message.parentMessageId != null && !message.isDeleted) {
CoroutineScope(Dispatchers.Main).launch {
try {
val chatActivity = commonMessageInterface as ChatActivity
val urlForChatting = ApiUtils.getUrlForChat(
chatActivity.chatApiVersion,
chatActivity.conversationUser?.baseUrl,
chatActivity.roomToken
)
val parentChatMessage = withContext(Dispatchers.IO) {
chatActivity.chatViewModel.getMessageById(
urlForChatting,
chatActivity.currentConversation!!,
message.parentMessageId!!
).first()
}
parentChatMessage.activeUser = message.activeUser
parentChatMessage.imageUrl?.let {
binding.messageQuote.quotedMessageImage.visibility = View.VISIBLE
binding.messageQuote.quotedMessageImage.load(it) {
addHeader(
"Authorization",
ApiUtils.getCredentials(message.activeUser!!.username, message.activeUser!!.token)!!
)
}
} ?: run {
binding.messageQuote.quotedMessageImage.visibility = View.GONE
}
binding.messageQuote.quotedMessageAuthor.text = parentChatMessage.actorDisplayName
?: context.getText(R.string.nc_nick_guest)
binding.messageQuote.quotedMessage.text = messageUtils
.enrichChatReplyMessageText(
binding.messageQuote.quotedMessage.context,
parentChatMessage,
true,
viewThemeUtils
)
binding.messageQuote.quotedMessageAuthor
.setTextColor(ContextCompat.getColor(context, R.color.textColorMaxContrast))
viewThemeUtils.talk.themeParentMessage(
parentChatMessage,
message,
binding.messageQuote.quotedChatMessageView
)
binding.messageQuote.quotedChatMessageView.visibility =
if (!message.isDeleted &&
message.parentMessageId != null &&
message.parentMessageId != chatActivity.conversationThreadId
) {
View.VISIBLE
} else {
View.GONE
}
} catch (e: Exception) {
Log.d(TAG, "Error when processing parent message in view holder", e)
}
}
} else {
binding.messageQuote.quotedChatMessageView.visibility = View.GONE
}
}
fun assignCommonMessageInterface(commonMessageInterface: CommonMessageInterface) {
this.commonMessageInterface = commonMessageInterface
}
companion object {
private val TAG = IncomingPollMessageViewHolder::class.java.simpleName
}
}
@@ -0,0 +1,161 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2022-2023 Marcel Hibbe <dev@mhibbe.de>
* SPDX-FileCopyrightText: 2021-2022 Tim Krüger <t@timkrueger.me>
* SPDX-FileCopyrightText: 2021 Andy Scherzinger <info@andy-scherzinger.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.adapters.messages;
import android.text.Spanned;
import android.util.TypedValue;
import android.view.View;
import android.widget.ImageView;
import android.widget.ProgressBar;
import com.google.android.material.card.MaterialCardView;
import ru.f7cloud.talk.R;
import ru.f7cloud.talk.chat.data.model.ChatMessage;
import ru.f7cloud.talk.databinding.ItemCustomIncomingPreviewMessageBinding;
import ru.f7cloud.talk.databinding.ItemThreadTitleBinding;
import ru.f7cloud.talk.databinding.ReactionsInsideMessageBinding;
import ru.f7cloud.talk.utils.TextMatchers;
import java.util.HashMap;
import java.util.Objects;
import androidx.annotation.NonNull;
import androidx.core.content.ContextCompat;
import androidx.emoji2.widget.EmojiTextView;
public class IncomingPreviewMessageViewHolder extends PreviewMessageViewHolder {
private final ItemCustomIncomingPreviewMessageBinding binding;
public IncomingPreviewMessageViewHolder(View itemView, Object payload) {
super(itemView, payload);
binding = ItemCustomIncomingPreviewMessageBinding.bind(itemView);
}
@Override
public void onBind(@NonNull ChatMessage message) {
super.onBind(message);
if(!message.isVoiceMessage()
&& !Objects.equals(message.getMessage(), "{file}")
) {
Spanned processedMessageText = null;
binding.bubble.setBackgroundResource(R.drawable.shape_grouped_incoming_message);
if (viewThemeUtils != null ) {
processedMessageText = messageUtils.enrichChatMessageText(
binding.messageCaption.getContext(),
message,
true,
viewThemeUtils);
viewThemeUtils.talk.themeIncomingMessageBubble(binding.bubble, true, false,
false);
}
if (processedMessageText != null) {
processedMessageText = messageUtils.processMessageParameters(
binding.messageCaption.getContext(),
viewThemeUtils,
processedMessageText,
message,
binding.bubble);
}
binding.bubble.setOnClickListener(null);
float textSize = 0;
if (context != null) {
textSize = context.getResources().getDimension(R.dimen.chat_text_size);
}
HashMap<String, HashMap<String, String>> messageParameters = message.getMessageParameters();
if (
(messageParameters == null || messageParameters.size() <= 0) &&
TextMatchers.isMessageWithSingleEmoticonOnly(message.getText())
) {
textSize = (float) (textSize * IncomingTextMessageViewHolder.TEXT_SIZE_MULTIPLIER);
itemView.setSelected(true);
}
binding.messageCaption.setVisibility(View.VISIBLE);
binding.messageCaption.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize);
binding.messageCaption.setText(processedMessageText);
} else {
binding.bubble.setBackground(null);
binding.messageCaption.setVisibility(View.GONE);
}
binding.messageAuthor.setText(message.getActorDisplayName());
binding.messageText.setTextColor(ContextCompat.getColor(binding.messageText.getContext(),
R.color.no_emphasis_text));
binding.messageTime.setTextColor(ContextCompat.getColor(binding.messageText.getContext(),
R.color.no_emphasis_text));
binding.messageEditIndicator.setTextColor(ContextCompat.getColor(binding.messageText.getContext(),
R.color.no_emphasis_text));
if(!message.isThread()) {
binding.threadTitleWrapperContainer.setVisibility(View.GONE);
} else {
binding.threadTitleWrapperContainer.setVisibility(View.VISIBLE);
}
}
@NonNull
@Override
public android.widget.TextView getMessageEditIndicator() {
return binding.messageEditIndicator;
}
@NonNull
@Override
public EmojiTextView getMessageText() {
return binding.messageText;
}
@NonNull
@Override
public EmojiTextView getMessageCaption() {
return binding.messageCaption;
}
@Override
public ProgressBar getProgressBar() {
return binding.progressBar;
}
@NonNull
@Override
public View getPreviewContainer() {
return binding.previewContainer;
}
@NonNull
@Override
public MaterialCardView getPreviewContactContainer() {
return binding.contactContainer;
}
@NonNull
@Override
public ImageView getPreviewContactPhoto() {
return binding.contactPhoto;
}
@NonNull
@Override
public EmojiTextView getPreviewContactName() {
return binding.contactName;
}
@Override
public ProgressBar getPreviewContactProgressBar() {
return binding.contactProgressBar;
}
@Override
public ReactionsInsideMessageBinding getReactionsBinding(){ return binding.reactions; }
@Override
public ItemThreadTitleBinding getThreadsBinding(){ return binding.threadTitleWrapper; }
}
@@ -0,0 +1,469 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2021-2023 Marcel Hibbe <dev@mhibbe.de>
* SPDX-FileCopyrightText: 2021 Andy Scherzinger <info@andy-scherzinger.de>
* SPDX-FileCopyrightText: 2021 Tim Krüger <t@timkrueger.me>
* SPDX-FileCopyrightText: 2017-2018 Mario Danic <mario@lovelyhq.com>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.adapters.messages
import android.content.Context
import android.util.Log
import android.util.TypedValue
import android.view.View
import android.view.ViewGroup
import android.widget.CheckBox
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 ru.f7cloud.talk.R
import ru.f7cloud.talk.application.F7cloudTalkApplication
import ru.f7cloud.talk.application.F7cloudTalkApplication.Companion.sharedApplication
import ru.f7cloud.talk.chat.ChatActivity
import ru.f7cloud.talk.chat.data.ChatMessageRepository
import ru.f7cloud.talk.chat.data.model.ChatMessage
import ru.f7cloud.talk.data.user.model.User
import ru.f7cloud.talk.databinding.ItemCustomIncomingTextMessageBinding
import ru.f7cloud.talk.models.json.chat.ChatUtils
import ru.f7cloud.talk.ui.theme.ViewThemeUtils
import ru.f7cloud.talk.utils.ApiUtils
import ru.f7cloud.talk.utils.CapabilitiesUtil.hasSpreedFeatureCapability
import ru.f7cloud.talk.utils.ChatMessageUtils
import ru.f7cloud.talk.utils.DateUtils
import ru.f7cloud.talk.utils.SpreedFeatures
import ru.f7cloud.talk.utils.TextMatchers
import ru.f7cloud.talk.utils.database.user.CurrentUserProviderOld
import ru.f7cloud.talk.utils.message.MessageCheckboxUtils.addCheckboxLine
import ru.f7cloud.talk.utils.message.MessageCheckboxUtils.addPlainTextLine
import ru.f7cloud.talk.utils.message.MessageCheckboxUtils.matchCheckbox
import ru.f7cloud.talk.utils.message.MessageCheckboxUtils.updateMessageWithCheckboxStates
import ru.f7cloud.talk.utils.message.MessageUtils
import ru.f7cloud.talk.utils.preferences.AppPreferences
import com.stfalcon.chatkit.messages.MessageHolders
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.Date
import javax.inject.Inject
@AutoInjector(F7cloudTalkApplication::class)
class IncomingTextMessageViewHolder(itemView: View, payload: Any) :
MessageHolders.IncomingTextMessageViewHolder<ChatMessage>(itemView, payload) {
private val binding: ItemCustomIncomingTextMessageBinding = ItemCustomIncomingTextMessageBinding.bind(itemView)
@Inject
lateinit var context: Context
@Inject
lateinit var viewThemeUtils: ViewThemeUtils
@Inject
lateinit var messageUtils: MessageUtils
@Inject
lateinit var appPreferences: AppPreferences
@Inject
lateinit var dateUtils: DateUtils
@Inject
lateinit var currentUserProvider: CurrentUserProviderOld
lateinit var commonMessageInterface: CommonMessageInterface
@Inject
lateinit var chatRepository: ChatMessageRepository
private var job: Job? = null
override fun onBind(message: ChatMessage) {
super.onBind(message)
sharedApplication!!.componentApplication.inject(this)
setAvatarAndAuthorOnMessageItem(message)
colorizeMessageBubble(message)
itemView.isSelected = false
val user = currentUserProvider.currentUser.blockingGet()
val hasCheckboxes = processCheckboxes(
message,
user
)
processMessage(message, hasCheckboxes)
}
@Suppress("LongMethod")
private fun processMessage(message: ChatMessage, hasCheckboxes: Boolean) {
var textSize = context.resources!!.getDimension(R.dimen.chat_text_size)
if (!hasCheckboxes) {
binding.messageText.visibility = View.VISIBLE
binding.checkboxContainer.visibility = View.GONE
var processedMessageText = messageUtils.enrichChatMessageText(
binding.messageText.context,
message,
true,
viewThemeUtils
)
val spansFromString: Array<Any> = processedMessageText!!.getSpans(
0,
processedMessageText.length,
Any::class.java
)
if (spansFromString.isNotEmpty()) {
binding.bubble.layoutParams.apply {
width = FlexboxLayout.LayoutParams.MATCH_PARENT
}
binding.messageText.layoutParams.apply {
width = FlexboxLayout.LayoutParams.MATCH_PARENT
}
} else {
binding.bubble.layoutParams.apply {
width = FlexboxLayout.LayoutParams.WRAP_CONTENT
}
binding.messageText.layoutParams.apply {
width = FlexboxLayout.LayoutParams.WRAP_CONTENT
}
}
processedMessageText = messageUtils.processMessageParameters(
binding.messageText.context,
viewThemeUtils,
processedMessageText,
message,
itemView
)
val messageParameters = message.messageParameters
if (
(messageParameters == null || messageParameters.size <= 0) &&
TextMatchers.isMessageWithSingleEmoticonOnly(message.text)
) {
textSize = (textSize * TEXT_SIZE_MULTIPLIER).toFloat()
itemView.isSelected = true
binding.messageAuthor.visibility = View.GONE
}
binding.messageText.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize)
binding.messageText.text = processedMessageText
// just for debugging:
// binding.messageText.text =
// SpannableStringBuilder(processedMessageText).append(" (" + message.jsonMessageId + ")")
} else {
binding.checkboxContainer.visibility = View.VISIBLE
binding.messageText.visibility = View.GONE
}
if (message.lastEditTimestamp != 0L && !message.isDeleted) {
binding.messageEditIndicator.visibility = View.VISIBLE
binding.messageTime.text = dateUtils.getLocalTimeStringFromTimestamp(message.lastEditTimestamp!!)
} else {
binding.messageEditIndicator.visibility = View.GONE
binding.messageTime.text = dateUtils.getLocalTimeStringFromTimestamp(message.timestamp)
}
viewThemeUtils.platform.colorTextView(binding.messageTime, ColorRole.ON_SURFACE_VARIANT)
// parent message handling
val chatActivity = commonMessageInterface as ChatActivity
binding.messageQuote.quotedChatMessageView.visibility =
if (!message.isDeleted &&
message.parentMessageId != null &&
message.parentMessageId != chatActivity.conversationThreadId
) {
processParentMessage(message)
View.VISIBLE
} else {
View.GONE
}
binding.messageQuote.quotedChatMessageView.setOnLongClickListener { l: View? ->
commonMessageInterface.onOpenMessageActionsDialog(message)
true
}
itemView.setTag(R.string.replyable_message_view_tag, message.replyable)
Thread().showThreadPreview(
chatActivity,
message,
threadBinding = binding.threadTitleWrapper,
reactionsBinding = binding.reactions,
openThread = { openThread(message) }
)
Reaction().showReactions(
message,
::clickOnReaction,
::longClickOnReaction,
binding.reactions,
binding.messageText.context,
false,
viewThemeUtils
)
}
private fun processCheckboxes(chatMessage: ChatMessage, user: User): Boolean {
val chatActivity = commonMessageInterface as ChatActivity
val message = chatMessage.message ?: return false
val checkBoxContainer = binding.checkboxContainer
checkBoxContainer.removeAllViews()
val isEditable = hasSpreedFeatureCapability(
user.capabilities?.spreedCapability!!,
SpreedFeatures.EDIT_MESSAGES
) &&
!chatMessage.createdAt.before(Date(System.currentTimeMillis() - AGE_THRESHOLD_FOR_EDIT_MESSAGE))
val checkboxList = mutableListOf<CheckBox>()
val spaceInPx = getBottomPaddingPx()
var hasCheckbox = false
message.lines().forEach { line ->
if (addCheckboxOrTextView(
line.trimEnd(),
chatMessage,
user,
isEditable,
checkBoxContainer,
checkboxList,
spaceInPx,
chatActivity
)
) {
hasCheckbox = true
}
}
return hasCheckbox
}
fun getBottomPaddingPx(): Int =
TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
PADDING_FOUR.toFloat(),
context.resources.displayMetrics
).toInt()
@Suppress("LongParameterList")
private fun addCheckboxOrTextView(
line: String,
chatMessage: ChatMessage,
user: User,
isEditable: Boolean,
container: ViewGroup,
checkboxList: MutableList<CheckBox>,
spaceInPx: Int,
chatActivity: ChatActivity
): Boolean {
val match = matchCheckbox(line)
return if (match != null) {
val isChecked = match.groupValues[CHECKED_GROUP_INDEX].equals("X", true)
val taskText = match.groupValues[TASK_TEXT_GROUP_INDEX].trim()
val checkBox = addCheckboxLine(
context = chatActivity,
container = container,
chatMessage = chatMessage,
taskText = taskText,
isChecked = isChecked,
isEnabled = (
chatMessage.actorType == "bots" ||
chatActivity.userAllowedByPrivilages(chatMessage)
) &&
isEditable,
isIncomingMessage = true,
messageUtils = messageUtils,
viewThemeUtils = viewThemeUtils,
linkColorRes = R.color.no_emphasis_text,
paddingPx = spaceInPx
) { _, _ ->
updateCheckboxStates(chatMessage, user, checkboxList)
}
checkboxList.add(checkBox)
true
} else if (line.isNotBlank()) {
addPlainTextLine(
context = container.context,
container = container,
chatMessage = chatMessage,
text = line,
isIncomingMessage = true,
messageUtils = messageUtils,
viewThemeUtils = viewThemeUtils,
linkColorRes = R.color.no_emphasis_text,
paddingPx = spaceInPx
)
false
} else {
false
}
}
private fun updateCheckboxStates(chatMessage: ChatMessage, user: User, checkboxes: List<CheckBox>) {
job = CoroutineScope(Dispatchers.Main).launch {
withContext(Dispatchers.IO) {
val apiVersion: Int = ApiUtils.getChatApiVersion(
user.capabilities?.spreedCapability!!,
intArrayOf(1)
)
val updatedMessage = updateMessageWithCheckboxStates(chatMessage.message!!, checkboxes)
val messageParameters = chatMessage.messageParameters
val messageToSend = if (!messageParameters.isNullOrEmpty()) {
val parsedMessage = ChatUtils.getParsedMessage(updatedMessage, messageParameters) ?: updatedMessage
messageUtils.processEditMessageParameters(
messageParameters,
chatMessage,
parsedMessage
).toString()
} else {
updatedMessage
}
chatRepository.editChatMessage(
user.getCredentials(),
ApiUtils.getUrlForChatMessage(apiVersion, user.baseUrl!!, chatMessage.token!!, chatMessage.id),
messageToSend
).collect { result ->
withContext(Dispatchers.Main) {
if (result.isSuccess) {
val editedMessage = result.getOrNull()?.ocs?.data!!.parentMessage!!
Log.d(TAG, "EditedMessage: $editedMessage")
binding.messageEditIndicator.apply {
visibility = View.VISIBLE
}
binding.messageTime.text =
dateUtils.getLocalTimeStringFromTimestamp(editedMessage.lastEditTimestamp!!)
} else {
Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show()
}
}
}
}
}
}
private fun longClickOnReaction(chatMessage: ChatMessage) {
commonMessageInterface.onLongClickReactions(chatMessage)
}
private fun clickOnReaction(chatMessage: ChatMessage, emoji: String) {
commonMessageInterface.onClickReaction(chatMessage, emoji)
}
private fun openThread(chatMessage: ChatMessage) {
commonMessageInterface.openThread(chatMessage)
}
private fun setAvatarAndAuthorOnMessageItem(message: ChatMessage) {
val actorName = message.actorDisplayName
if (!actorName.isNullOrBlank()) {
binding.messageAuthor.visibility = View.VISIBLE
binding.messageAuthor.text = actorName
binding.messageUserAvatar.setOnClickListener {
(payload as? MessagePayload)?.profileBottomSheet?.showFor(message, itemView.context)
}
} else {
binding.messageAuthor.setText(R.string.nc_nick_guest)
}
if (!message.isGrouped && !message.isOneToOneConversation && !message.isFormerOneToOneConversation) {
ChatMessageUtils().setAvatarOnMessage(binding.messageUserAvatar, message, viewThemeUtils)
} else {
if (message.isOneToOneConversation || message.isFormerOneToOneConversation) {
binding.messageUserAvatar.visibility = View.GONE
} else {
binding.messageUserAvatar.visibility = View.INVISIBLE
}
binding.messageAuthor.visibility = View.GONE
}
}
private fun colorizeMessageBubble(message: ChatMessage) {
viewThemeUtils.talk.themeIncomingMessageBubble(bubble, message.isGrouped, message.isDeleted)
}
@Suppress("Detekt.TooGenericExceptionCaught")
private fun processParentMessage(message: ChatMessage) {
if (message.parentMessageId != null && !message.isDeleted) {
CoroutineScope(Dispatchers.Main).launch {
try {
val chatActivity = commonMessageInterface as ChatActivity
val urlForChatting = ApiUtils.getUrlForChat(
chatActivity.chatApiVersion,
chatActivity.conversationUser?.baseUrl,
chatActivity.roomToken
)
val parentChatMessage = withContext(Dispatchers.IO) {
chatActivity.chatViewModel.getMessageById(
urlForChatting,
chatActivity.currentConversation!!,
message.parentMessageId!!
).first()
}
parentChatMessage.activeUser = message.activeUser
parentChatMessage.imageUrl?.let {
binding.messageQuote.quotedMessageImage.visibility = View.VISIBLE
binding.messageQuote.quotedMessageImage.load(it) {
addHeader(
"Authorization",
ApiUtils.getCredentials(message.activeUser!!.username, message.activeUser!!.token)!!
)
}
} ?: run {
binding.messageQuote.quotedMessageImage.visibility = View.GONE
}
binding.messageQuote.quotedMessageAuthor.text =
if (parentChatMessage.actorDisplayName.isNullOrEmpty()) {
context.getText(R.string.nc_nick_guest)
} else {
parentChatMessage.actorDisplayName
}
binding.messageQuote.quotedMessage.text = messageUtils
.enrichChatReplyMessageText(
binding.messageQuote.quotedMessage.context,
parentChatMessage,
true,
viewThemeUtils
)
viewThemeUtils.talk.themeParentMessage(
parentChatMessage,
message,
binding.messageQuote.quotedChatMessageView,
R.color.high_emphasis_text
)
binding.messageQuote.quotedChatMessageView.setOnClickListener {
chatActivity.jumpToQuotedMessage(parentChatMessage)
}
} catch (e: Exception) {
Log.d(TAG, "Error when processing parent message in view holder", e)
}
}
}
}
fun assignCommonMessageInterface(commonMessageInterface: CommonMessageInterface) {
this.commonMessageInterface = commonMessageInterface
}
override fun viewDetached() {
super.viewDetached()
job?.cancel()
}
companion object {
const val TEXT_SIZE_MULTIPLIER = 2.5
private val TAG = IncomingTextMessageViewHolder::class.java.simpleName
private const val AGE_THRESHOLD_FOR_EDIT_MESSAGE: Long = 86400000
private const val CHECKED_GROUP_INDEX = 1
private const val TASK_TEXT_GROUP_INDEX = 2
private const val PADDING_FOUR = 4
}
}
@@ -0,0 +1,390 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2024 Christian Reiner <foss@christian-reiner.info>
* SPDX-FileCopyrightText: 2021 Andy Scherzinger <info@andy-scherzinger.de>
* SPDX-FileCopyrightText: 2021 Tim Krüger <t@timkrueger.me>
* SPDX-FileCopyrightText: 2021 Marcel Hibbe <dev@mhibbe.de>
* SPDX-FileCopyrightText: 2017-2018 Mario Danic <mario@lovelyhq.com>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.adapters.messages
import android.annotation.SuppressLint
import android.content.Context
import android.util.Log
import android.view.View
import android.widget.SeekBar
import androidx.core.content.ContextCompat
import androidx.work.WorkInfo
import androidx.work.WorkManager
import autodagger.AutoInjector
import coil.load
import com.nextcloud.android.common.ui.theme.utils.ColorRole
import ru.f7cloud.talk.R
import ru.f7cloud.talk.application.F7cloudTalkApplication
import ru.f7cloud.talk.application.F7cloudTalkApplication.Companion.sharedApplication
import ru.f7cloud.talk.chat.ChatActivity
import ru.f7cloud.talk.chat.data.model.ChatMessage
import ru.f7cloud.talk.databinding.ItemCustomIncomingVoiceMessageBinding
import ru.f7cloud.talk.ui.theme.ViewThemeUtils
import ru.f7cloud.talk.utils.ApiUtils
import ru.f7cloud.talk.utils.ChatMessageUtils
import ru.f7cloud.talk.utils.DateUtils
import ru.f7cloud.talk.utils.message.MessageUtils
import ru.f7cloud.talk.utils.preferences.AppPreferences
import com.stfalcon.chatkit.messages.MessageHolders
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.concurrent.ExecutionException
import javax.inject.Inject
@AutoInjector(F7cloudTalkApplication::class)
class IncomingVoiceMessageViewHolder(incomingView: View, payload: Any) :
MessageHolders.IncomingTextMessageViewHolder<ChatMessage>(incomingView, payload) {
private val binding: ItemCustomIncomingVoiceMessageBinding = ItemCustomIncomingVoiceMessageBinding.bind(itemView)
@JvmField
@Inject
var context: Context? = null
@Inject
lateinit var viewThemeUtils: ViewThemeUtils
@Inject
lateinit var messageUtils: MessageUtils
@Inject
lateinit var dateUtils: DateUtils
@Inject
lateinit var appPreferences: AppPreferences
lateinit var message: ChatMessage
lateinit var voiceMessageInterface: VoiceMessageInterface
lateinit var commonMessageInterface: CommonMessageInterface
private var isBound = false
@SuppressLint("SetTextI18n")
override fun onBind(message: ChatMessage) {
super.onBind(message)
if (isBound) {
handleIsPlayingVoiceMessageState(message)
return
}
this.message = message
sharedApplication!!.componentApplication.inject(this)
val filename = message.selectedIndividualHashMap!!["name"]
val retrieved = appPreferences.getWaveFormFromFile(filename)
if (retrieved.isNotEmpty() &&
message.voiceMessageFloatArray == null ||
message.voiceMessageFloatArray?.isEmpty() == true
) {
message.voiceMessageFloatArray = retrieved.toFloatArray()
binding.seekbar.setWaveData(message.voiceMessageFloatArray!!)
}
binding.messageTime.text = dateUtils.getLocalTimeStringFromTimestamp(message.timestamp)
setAvatarAndAuthorOnMessageItem(message)
colorizeMessageBubble(message)
itemView.isSelected = false
// parent message handling
setParentMessageDataOnMessageItem(message)
updateDownloadState(message)
binding.seekbar.max = MAX
viewThemeUtils.talk.themeWaveFormSeekBar(binding.seekbar)
viewThemeUtils.platform.colorCircularProgressBar(binding.progressBar, ColorRole.ON_SURFACE_VARIANT)
showVoiceMessageDuration(message)
if (message.isDownloadingVoiceMessage) {
showVoiceMessageLoading()
} else {
if (message.voiceMessageFloatArray == null || message.voiceMessageFloatArray!!.isEmpty()) {
binding.seekbar.setWaveData(FloatArray(0))
} else {
binding.seekbar.setWaveData(message.voiceMessageFloatArray!!)
}
binding.progressBar.visibility = View.GONE
}
if (message.resetVoiceMessage) {
resetVoiceMessage(message)
}
binding.seekbar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onStopTrackingTouch(seekBar: SeekBar) {
// unused atm
}
override fun onStartTrackingTouch(seekBar: SeekBar) {
// unused atm
}
override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {
if (fromUser) {
voiceMessageInterface.updateMediaPlayerProgressBySlider(message, progress)
}
}
})
CoroutineScope(Dispatchers.Default).launch {
(voiceMessageInterface as ChatActivity).chatViewModel.voiceMessagePlayBackUIFlow.onEach { speed ->
withContext(Dispatchers.Main) {
binding.playbackSpeedControlBtn.setSpeed(speed)
}
}.collect()
}
binding.playbackSpeedControlBtn.setSpeed(appPreferences.getPreferredPlayback(message.actorId))
Reaction().showReactions(
message,
::clickOnReaction,
::longClickOnReaction,
binding.reactions,
binding.messageTime.context,
false,
viewThemeUtils
)
isBound = true
}
private fun showVoiceMessageDuration(message: ChatMessage) {
if (message.voiceMessageDuration > 0) {
binding.voiceMessageDuration.visibility = View.VISIBLE
} else {
binding.voiceMessageDuration.visibility = View.INVISIBLE
}
}
private fun resetVoiceMessage(chatMessage: ChatMessage) {
binding.playPauseBtn.visibility = View.VISIBLE
binding.playPauseBtn.icon = ContextCompat.getDrawable(
context!!,
R.drawable.ic_baseline_play_arrow_voice_message_24
)
binding.seekbar.progress = SEEKBAR_START
chatMessage.resetVoiceMessage = false
chatMessage.voiceMessagePlayedSeconds = 0
showVoiceMessageDuration(message)
}
private fun longClickOnReaction(chatMessage: ChatMessage) {
commonMessageInterface.onLongClickReactions(chatMessage)
}
private fun clickOnReaction(chatMessage: ChatMessage, emoji: String) {
commonMessageInterface.onClickReaction(chatMessage, emoji)
}
private fun handleIsPlayingVoiceMessageState(message: ChatMessage) {
colorizeMessageBubble(message)
if (message.isPlayingVoiceMessage) {
showPlayButton()
binding.playPauseBtn.icon = ContextCompat.getDrawable(
context!!,
R.drawable.ic_baseline_pause_voice_message_24
)
val d = message.voiceMessageDuration.toLong()
val t = message.voiceMessagePlayedSeconds.toLong()
binding.voiceMessageDuration.text = android.text.format.DateUtils.formatElapsedTime(d - t)
binding.voiceMessageDuration.visibility = View.VISIBLE
binding.seekbar.progress = message.voiceMessageSeekbarProgress
} else {
showVoiceMessageDuration(message)
binding.playPauseBtn.visibility = View.VISIBLE
binding.playPauseBtn.icon = ContextCompat.getDrawable(
context!!,
R.drawable.ic_baseline_play_arrow_voice_message_24
)
}
}
private fun updateDownloadState(message: ChatMessage) {
// check if download worker is already running
val fileId = message.selectedIndividualHashMap!!["id"]
val workers = WorkManager.getInstance(context!!).getWorkInfosByTag(fileId!!)
try {
for (workInfo in workers.get()) {
if (workInfo.state == WorkInfo.State.RUNNING || workInfo.state == WorkInfo.State.ENQUEUED) {
showVoiceMessageLoading()
WorkManager.getInstance(context!!).getWorkInfoByIdLiveData(workInfo.id)
.observeForever { info: WorkInfo? ->
showStatus(info)
}
}
}
} catch (e: ExecutionException) {
Log.e(TAG, "Error when checking if worker already exists", e)
} catch (e: InterruptedException) {
Log.e(TAG, "Error when checking if worker already exists", e)
}
}
private fun showStatus(info: WorkInfo?) {
if (info != null) {
when (info.state) {
WorkInfo.State.RUNNING -> {
Log.d(TAG, "WorkInfo.State.RUNNING in ViewHolder")
showVoiceMessageLoading()
}
WorkInfo.State.SUCCEEDED -> {
Log.d(TAG, "WorkInfo.State.SUCCEEDED in ViewHolder")
showPlayButton()
}
WorkInfo.State.FAILED -> {
Log.d(TAG, "WorkInfo.State.FAILED in ViewHolder")
showPlayButton()
}
else -> {
}
}
}
}
private fun showPlayButton() {
binding.playPauseBtn.visibility = View.VISIBLE
binding.progressBar.visibility = View.GONE
}
private fun showVoiceMessageLoading() {
binding.playPauseBtn.visibility = View.GONE
binding.progressBar.visibility = View.VISIBLE
}
private fun setAvatarAndAuthorOnMessageItem(message: ChatMessage) {
val actorName = message.actorDisplayName
if (!actorName.isNullOrBlank()) {
binding.messageAuthor.visibility = View.VISIBLE
binding.messageAuthor.text = actorName
binding.messageUserAvatar.setOnClickListener {
(payload as? MessagePayload)?.profileBottomSheet?.showFor(message, itemView.context)
}
} else {
binding.messageAuthor.setText(R.string.nc_nick_guest)
}
if (!message.isGrouped && !message.isOneToOneConversation && !message.isFormerOneToOneConversation) {
ChatMessageUtils().setAvatarOnMessage(binding.messageUserAvatar, message, viewThemeUtils)
} else {
if (message.isOneToOneConversation || message.isFormerOneToOneConversation) {
binding.messageUserAvatar.visibility = View.GONE
} else {
binding.messageUserAvatar.visibility = View.INVISIBLE
}
binding.messageAuthor.visibility = View.GONE
}
}
private fun colorizeMessageBubble(message: ChatMessage) {
viewThemeUtils.talk.themeIncomingMessageBubble(
bubble,
message.isGrouped,
message.isDeleted,
message.wasPlayedVoiceMessage
)
}
@Suppress("Detekt.TooGenericExceptionCaught", "Detekt.LongMethod")
private fun setParentMessageDataOnMessageItem(message: ChatMessage) {
if (message.parentMessageId != null && !message.isDeleted) {
CoroutineScope(Dispatchers.Main).launch {
try {
val chatActivity = commonMessageInterface as ChatActivity
val urlForChatting = ApiUtils.getUrlForChat(
chatActivity.chatApiVersion,
chatActivity.conversationUser?.baseUrl,
chatActivity.roomToken
)
val parentChatMessage = withContext(Dispatchers.IO) {
chatActivity.chatViewModel.getMessageById(
urlForChatting,
chatActivity.currentConversation!!,
message.parentMessageId!!
).first()
}
parentChatMessage.activeUser = message.activeUser
parentChatMessage.imageUrl?.let {
binding.messageQuote.quotedMessageImage.visibility = View.VISIBLE
binding.messageQuote.quotedMessageImage.load(it) {
addHeader(
"Authorization",
ApiUtils.getCredentials(message.activeUser!!.username, message.activeUser!!.token)!!
)
}
} ?: run {
binding.messageQuote.quotedMessageImage.visibility = View.GONE
}
binding.messageQuote.quotedMessageAuthor.text = parentChatMessage.actorDisplayName
?: context!!.getText(R.string.nc_nick_guest)
binding.messageQuote.quotedMessage.text = messageUtils
.enrichChatReplyMessageText(
binding.messageQuote.quotedMessage.context,
parentChatMessage,
true,
viewThemeUtils
)
binding.messageQuote.quotedMessageAuthor
.setTextColor(ContextCompat.getColor(context!!, R.color.textColorMaxContrast))
viewThemeUtils.talk.themeParentMessage(
parentChatMessage,
message,
binding.messageQuote.quotedChatMessageView
)
binding.messageQuote.quotedChatMessageView.visibility = View.VISIBLE
binding.messageQuote.quotedChatMessageView.visibility =
if (!message.isDeleted &&
message.parentMessageId != null &&
message.parentMessageId != chatActivity.conversationThreadId
) {
View.VISIBLE
} else {
View.GONE
}
} catch (e: Exception) {
Log.d(TAG, "Error when processing parent message in view holder", e)
}
}
} else {
binding.messageQuote.quotedChatMessageView.visibility = View.GONE
}
}
fun assignVoiceMessageInterface(voiceMessageInterface: VoiceMessageInterface) {
this.voiceMessageInterface = voiceMessageInterface
}
fun assignCommonMessageInterface(commonMessageInterface: CommonMessageInterface) {
this.commonMessageInterface = commonMessageInterface
}
companion object {
private const val TAG = "VoiceInMessageView"
private const val SEEKBAR_START: Int = 0
private const val MAX: Int = 100
}
}
@@ -0,0 +1,119 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2022 Marcel Hibbe <dev@mhibbe.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.adapters.messages
import android.content.Context
import android.content.Intent
import android.util.Log
import android.view.View
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import coil.load
import ru.f7cloud.talk.R
import ru.f7cloud.talk.api.NcApi
import ru.f7cloud.talk.chat.data.model.ChatMessage
import ru.f7cloud.talk.databinding.ReferenceInsideMessageBinding
import ru.f7cloud.talk.models.json.opengraph.OpenGraphOverall
import ru.f7cloud.talk.utils.ApiUtils
import io.reactivex.Observer
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.Disposable
import io.reactivex.schedulers.Schedulers
class LinkPreview {
fun showLink(message: ChatMessage, ncApi: NcApi, binding: ReferenceInsideMessageBinding, context: Context) {
binding.referenceName.text = ""
binding.referenceDescription.text = ""
binding.referenceLink.text = ""
binding.referenceThumbImage.setImageDrawable(null)
if (!message.extractedUrlToPreview.isNullOrEmpty()) {
val credentials: String = ApiUtils.getCredentials(message.activeUser?.username, message.activeUser?.token)!!
val openGraphLink = ApiUtils.getUrlForOpenGraph(message.activeUser?.baseUrl!!)
ncApi.getOpenGraph(
credentials,
openGraphLink,
message.extractedUrlToPreview
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(object : Observer<OpenGraphOverall> {
override fun onSubscribe(d: Disposable) {
// unused atm
}
override fun onNext(openGraphOverall: OpenGraphOverall) {
val reference = openGraphOverall.ocs?.data?.references?.entries?.iterator()?.next()?.value
if (reference != null) {
val referenceName = reference.openGraphObject?.name
if (!referenceName.isNullOrEmpty()) {
binding.referenceName.visibility = View.VISIBLE
binding.referenceName.text = referenceName
} else {
binding.referenceName.visibility = View.GONE
}
val referenceDescription = reference.openGraphObject?.description
if (!referenceDescription.isNullOrEmpty()) {
binding.referenceDescription.visibility = View.VISIBLE
binding.referenceDescription.text = referenceDescription
} else {
binding.referenceDescription.visibility = View.GONE
}
val referenceLink = reference.openGraphObject?.link
if (!referenceLink.isNullOrEmpty()) {
binding.referenceLink.visibility = View.VISIBLE
binding.referenceLink.text = referenceLink.replace(HTTPS_PROTOCOL, "")
} else {
binding.referenceLink.visibility = View.GONE
}
val referenceThumbUrl = reference.openGraphObject?.thumb
var backgroundId = R.drawable.link_text_background
if (!referenceThumbUrl.isNullOrEmpty()) {
binding.referenceThumbImage.visibility = View.VISIBLE
binding.referenceThumbImage.load(referenceThumbUrl)
} else {
backgroundId = R.drawable.link_text_no_preview_background
binding.referenceThumbImage.visibility = View.GONE
}
binding.referenceMetadataContainer.background = ContextCompat.getDrawable(
binding.referenceMetadataContainer.context,
backgroundId
)
binding.referenceWrapper.setOnClickListener {
val browserIntent = Intent(Intent.ACTION_VIEW, referenceLink!!.toUri())
browserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(browserIntent)
}
}
}
override fun onError(e: Throwable) {
Log.e(TAG, "failed to get openGraph data", e)
binding.referenceName.visibility = View.GONE
binding.referenceDescription.visibility = View.GONE
binding.referenceLink.visibility = View.GONE
binding.referenceThumbImage.visibility = View.GONE
}
override fun onComplete() {
// unused atm
}
})
}
}
companion object {
private val TAG = LinkPreview::class.java.simpleName
private const val HTTPS_PROTOCOL = "https://"
}
}
@@ -0,0 +1,15 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2022 Marcel Hibbe <dev@mhibbe.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.adapters.messages
import ru.f7cloud.talk.ui.bottom.sheet.ProfileBottomSheet
data class MessagePayload(
var roomToken: String,
val isOwnerOrModerator: Boolean?,
val profileBottomSheet: ProfileBottomSheet
)
@@ -0,0 +1,252 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2024 Christian Reiner <foss@christian-reiner.info>
* SPDX-FileCopyrightText: 2024 Sowjanya Kota<sowjanya.kch@gmail.com>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.adapters.messages
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.util.Log
import android.view.View
import android.widget.ImageView
import androidx.appcompat.content.res.AppCompatResources
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import autodagger.AutoInjector
import coil.load
import com.nextcloud.android.common.ui.theme.utils.ColorRole
import ru.f7cloud.talk.R
import ru.f7cloud.talk.api.NcApi
import ru.f7cloud.talk.application.F7cloudTalkApplication
import ru.f7cloud.talk.application.F7cloudTalkApplication.Companion.sharedApplication
import ru.f7cloud.talk.chat.ChatActivity
import ru.f7cloud.talk.chat.data.model.ChatMessage
import ru.f7cloud.talk.databinding.ItemCustomOutcomingDeckCardMessageBinding
import ru.f7cloud.talk.models.json.chat.ReadStatus
import ru.f7cloud.talk.ui.theme.ViewThemeUtils
import ru.f7cloud.talk.utils.ApiUtils
import ru.f7cloud.talk.utils.DateUtils
import ru.f7cloud.talk.utils.message.MessageUtils
import ru.f7cloud.talk.utils.preferences.AppPreferences
import com.stfalcon.chatkit.messages.MessageHolders
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
@AutoInjector(F7cloudTalkApplication::class)
class OutcomingDeckCardViewHolder(outcomingView: View) :
MessageHolders.OutcomingTextMessageViewHolder<ChatMessage>(outcomingView),
AdjustableMessageHolderInterface {
override val binding: ItemCustomOutcomingDeckCardMessageBinding = ItemCustomOutcomingDeckCardMessageBinding.bind(
itemView
)
@Inject
lateinit var context: Context
@Inject
lateinit var viewThemeUtils: ViewThemeUtils
@Inject
lateinit var messageUtils: MessageUtils
@Inject
lateinit var appPreferences: AppPreferences
@Inject
lateinit var ncApi: NcApi
lateinit var message: ChatMessage
@Inject
lateinit var dateUtils: DateUtils
lateinit var commonMessageInterface: CommonMessageInterface
var stackName: String? = null
var cardName: String? = null
var boardName: String? = null
var cardLink: String? = null
@SuppressLint("SetTextI18n")
override fun onBind(message: ChatMessage) {
super.onBind(message)
this.message = message
sharedApplication!!.componentApplication.inject(this)
viewThemeUtils.platform.colorTextView(binding.messageTime, ColorRole.ON_SURFACE_VARIANT)
binding.messageTime.text = dateUtils.getLocalTimeStringFromTimestamp(message.timestamp)
colorizeMessageBubble(message)
binding.cardView.findViewById<ImageView>(R.id.deckCardImage)?.let {
viewThemeUtils.platform.colorImageView(it, ColorRole.SECONDARY)
}
itemView.isSelected = false
showDeckCard(message)
// parent message handling
setParentMessageDataOnMessageItem(message)
val readStatusDrawableInt = when (message.readStatus) {
ReadStatus.READ -> R.drawable.ic_check_all
ReadStatus.SENT -> R.drawable.ic_check
else -> null
}
val readStatusContentDescriptionString = when (message.readStatus) {
ReadStatus.READ -> context.resources?.getString(R.string.nc_message_read)
ReadStatus.SENT -> context.resources?.getString(R.string.nc_message_sent)
else -> null
}
readStatusDrawableInt?.let { drawableInt ->
AppCompatResources.getDrawable(context, drawableInt)?.let {
binding.checkMark.setImageDrawable(it)
viewThemeUtils.talk.themeMessageCheckMark(binding.checkMark)
}
}
binding.checkMark.contentDescription = readStatusContentDescriptionString
binding.cardView.setOnClickListener {
val browserIntent = Intent(Intent.ACTION_VIEW, cardLink!!.toUri())
browserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(browserIntent)
}
itemView.setTag(R.string.replyable_message_view_tag, message.replyable)
Reaction().showReactions(
message,
::clickOnReaction,
::longClickOnReaction,
binding.reactions,
binding.messageTime.context,
true,
viewThemeUtils
)
}
private fun showDeckCard(message: ChatMessage) {
if (message.messageParameters != null && message.messageParameters!!.size > 0) {
for (key in message.messageParameters!!.keys) {
val individualHashMap: Map<String?, String?> = message.messageParameters!![key]!!
if (individualHashMap["type"] == "deck-card") {
cardName = individualHashMap["name"]
stackName = individualHashMap["stackname"]
boardName = individualHashMap["boardname"]
cardLink = individualHashMap["link"]
}
}
}
val cardDescription = String.format(
context.resources.getString(R.string.deck_card_description),
stackName,
boardName
)
binding.cardName.visibility = View.VISIBLE
binding.cardDescription.visibility = View.VISIBLE
binding.cardName.text = cardName
binding.cardDescription.text = cardDescription
}
private fun longClickOnReaction(chatMessage: ChatMessage) {
commonMessageInterface.onLongClickReactions(chatMessage)
}
private fun clickOnReaction(chatMessage: ChatMessage, emoji: String) {
commonMessageInterface.onClickReaction(chatMessage, emoji)
}
@Suppress("Detekt.TooGenericExceptionCaught", "Detekt.LongMethod")
private fun setParentMessageDataOnMessageItem(message: ChatMessage) {
if (message.parentMessageId != null && !message.isDeleted) {
CoroutineScope(Dispatchers.Main).launch {
try {
val chatActivity = commonMessageInterface as ChatActivity
val urlForChatting = ApiUtils.getUrlForChat(
chatActivity.chatApiVersion,
chatActivity.conversationUser?.baseUrl,
chatActivity.roomToken
)
val parentChatMessage = withContext(Dispatchers.IO) {
chatActivity.chatViewModel.getMessageById(
urlForChatting,
chatActivity.currentConversation!!,
message.parentMessageId!!
).first()
}
parentChatMessage.activeUser = message.activeUser
parentChatMessage.imageUrl?.let {
binding.messageQuote.quotedMessageImage.visibility = View.VISIBLE
binding.messageQuote.quotedMessageImage.load(it) {
addHeader(
"Authorization",
ApiUtils.getCredentials(message.activeUser!!.username, message.activeUser!!.token)!!
)
}
} ?: run {
binding.messageQuote.quotedMessageImage.visibility = View.GONE
}
binding.messageQuote.quotedMessageAuthor.text = parentChatMessage.actorDisplayName
?: context.getText(R.string.nc_nick_guest)
binding.messageQuote.quotedMessage.text = messageUtils
.enrichChatReplyMessageText(
binding.messageQuote.quotedMessage.context,
parentChatMessage,
true,
viewThemeUtils
)
binding.messageQuote.quotedMessageAuthor
.setTextColor(ContextCompat.getColor(context, R.color.textColorMaxContrast))
viewThemeUtils.talk.themeParentMessage(
parentChatMessage,
message,
binding.messageQuote.quotedChatMessageView
)
binding.messageQuote.quotedChatMessageView.visibility =
if (!message.isDeleted &&
message.parentMessageId != null &&
message.parentMessageId != chatActivity.conversationThreadId
) {
View.VISIBLE
} else {
View.GONE
}
} catch (e: Exception) {
Log.d(TAG, "Error when processing parent message in view holder", e)
}
}
} else {
binding.messageQuote.quotedChatMessageView.visibility = View.GONE
}
}
private fun colorizeMessageBubble(message: ChatMessage) {
viewThemeUtils.talk.themeOutgoingMessageBubble(bubble, message.isGrouped, message.isDeleted)
}
fun assignCommonMessageInterface(commonMessageInterface: CommonMessageInterface) {
this.commonMessageInterface = commonMessageInterface
}
companion object {
private val TAG = OutcomingDeckCardViewHolder::class.java.simpleName
}
}
@@ -0,0 +1,243 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2024 Christian Reiner <foss@christian-reiner.info>
* SPDX-FileCopyrightText: 2022 Marcel Hibbe <dev@mhibbe.de>
* SPDX-FileCopyrightText: 2017-2019 Mario Danic <mario@lovelyhq.com>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.adapters.messages
import android.annotation.SuppressLint
import android.content.Context
import android.util.Log
import android.view.View
import androidx.appcompat.content.res.AppCompatResources
import autodagger.AutoInjector
import coil.load
import com.nextcloud.android.common.ui.theme.utils.ColorRole
import ru.f7cloud.talk.R
import ru.f7cloud.talk.api.NcApi
import ru.f7cloud.talk.application.F7cloudTalkApplication
import ru.f7cloud.talk.application.F7cloudTalkApplication.Companion.sharedApplication
import ru.f7cloud.talk.chat.ChatActivity
import ru.f7cloud.talk.chat.data.model.ChatMessage
import ru.f7cloud.talk.databinding.ItemCustomOutcomingLinkPreviewMessageBinding
import ru.f7cloud.talk.models.json.chat.ReadStatus
import ru.f7cloud.talk.ui.theme.ViewThemeUtils
import ru.f7cloud.talk.utils.ApiUtils
import ru.f7cloud.talk.utils.DateUtils
import ru.f7cloud.talk.utils.message.MessageUtils
import ru.f7cloud.talk.utils.preferences.AppPreferences
import com.stfalcon.chatkit.messages.MessageHolders
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
@AutoInjector(F7cloudTalkApplication::class)
class OutcomingLinkPreviewMessageViewHolder(outcomingView: View, payload: Any) :
MessageHolders.OutcomingTextMessageViewHolder<ChatMessage>(outcomingView, payload),
AdjustableMessageHolderInterface {
override val binding: ItemCustomOutcomingLinkPreviewMessageBinding =
ItemCustomOutcomingLinkPreviewMessageBinding.bind(itemView)
@Inject
lateinit var context: Context
@Inject
lateinit var viewThemeUtils: ViewThemeUtils
@Inject
lateinit var messageUtils: MessageUtils
@Inject
lateinit var dateUtils: DateUtils
@Inject
lateinit var appPreferences: AppPreferences
@Inject
lateinit var ncApi: NcApi
lateinit var message: ChatMessage
lateinit var commonMessageInterface: CommonMessageInterface
@SuppressLint("SetTextI18n")
override fun onBind(message: ChatMessage) {
super.onBind(message)
this.message = message
sharedApplication!!.componentApplication.inject(this)
viewThemeUtils.platform.colorTextView(binding.messageTime, ColorRole.ON_SURFACE_VARIANT)
binding.messageTime.text = dateUtils.getLocalTimeStringFromTimestamp(message.timestamp)
colorizeMessageBubble(message)
var processedMessageText =
messageUtils.enrichChatMessageText(binding.messageText.context, message, false, viewThemeUtils)
processedMessageText = messageUtils.processMessageParameters(
binding.messageText.context,
viewThemeUtils,
processedMessageText!!,
message,
itemView
)
binding.messageText.text = processedMessageText
itemView.isSelected = false
// parent message handling
setParentMessageDataOnMessageItem(message)
val readStatusDrawableInt = when (message.readStatus) {
ReadStatus.READ -> R.drawable.ic_check_all
ReadStatus.SENT -> R.drawable.ic_check
else -> null
}
val readStatusContentDescriptionString = when (message.readStatus) {
ReadStatus.READ -> context.resources?.getString(R.string.nc_message_read)
ReadStatus.SENT -> context.resources?.getString(R.string.nc_message_sent)
else -> null
}
readStatusDrawableInt?.let { drawableInt ->
AppCompatResources.getDrawable(context, drawableInt)?.let {
binding.checkMark.setImageDrawable(it)
viewThemeUtils.talk.themeMessageCheckMark(binding.checkMark)
}
}
binding.checkMark.contentDescription = readStatusContentDescriptionString
LinkPreview().showLink(
message,
ncApi,
binding.referenceInclude,
itemView.context
)
binding.referenceInclude.referenceWrapper.setOnLongClickListener { l: View? ->
commonMessageInterface.onOpenMessageActionsDialog(message)
true
}
itemView.setTag(R.string.replyable_message_view_tag, message.replyable)
val chatActivity = commonMessageInterface as ChatActivity
val showThreadButton = chatActivity.conversationThreadId == null && message.isThread
if (showThreadButton) {
binding.reactions.threadButton.visibility = View.VISIBLE
binding.reactions.threadButton.setContent {
ThreadButtonComposable(
onButtonClick = { openThread(message) }
)
}
} else {
binding.reactions.threadButton.visibility = View.GONE
}
Reaction().showReactions(
message,
::clickOnReaction,
::longClickOnReaction,
binding.reactions,
binding.messageTime.context,
true,
viewThemeUtils
)
}
private fun longClickOnReaction(chatMessage: ChatMessage) {
commonMessageInterface.onLongClickReactions(chatMessage)
}
private fun clickOnReaction(chatMessage: ChatMessage, emoji: String) {
commonMessageInterface.onClickReaction(chatMessage, emoji)
}
private fun openThread(chatMessage: ChatMessage) {
commonMessageInterface.openThread(chatMessage)
}
@Suppress("Detekt.TooGenericExceptionCaught", "Detekt.LongMethod")
private fun setParentMessageDataOnMessageItem(message: ChatMessage) {
if (message.parentMessageId != null && !message.isDeleted) {
CoroutineScope(Dispatchers.Main).launch {
try {
val chatActivity = commonMessageInterface as ChatActivity
val urlForChatting = ApiUtils.getUrlForChat(
chatActivity.chatApiVersion,
chatActivity.conversationUser?.baseUrl,
chatActivity.roomToken
)
val parentChatMessage = withContext(Dispatchers.IO) {
chatActivity.chatViewModel.getMessageById(
urlForChatting,
chatActivity.currentConversation!!,
message.parentMessageId!!
).first()
}
parentChatMessage.activeUser = message.activeUser
parentChatMessage.imageUrl?.let {
binding.messageQuote.quotedMessageImage.visibility = View.VISIBLE
binding.messageQuote.quotedMessageImage.load(it) {
addHeader(
"Authorization",
ApiUtils.getCredentials(message.activeUser!!.username, message.activeUser!!.token)!!
)
}
} ?: run {
binding.messageQuote.quotedMessageImage.visibility = View.GONE
}
binding.messageQuote.quotedMessageAuthor.text = parentChatMessage.actorDisplayName
?: context.getText(R.string.nc_nick_guest)
binding.messageQuote.quotedMessage.text = messageUtils
.enrichChatReplyMessageText(
binding.messageQuote.quotedMessage.context,
parentChatMessage,
false,
viewThemeUtils
)
viewThemeUtils.talk.colorOutgoingQuoteText(binding.messageQuote.quotedMessage)
viewThemeUtils.talk.colorOutgoingQuoteAuthorText(binding.messageQuote.quotedMessageAuthor)
viewThemeUtils.talk.themeParentMessage(
parentChatMessage,
message,
binding.messageQuote.quotedChatMessageView
)
binding.messageQuote.quotedChatMessageView.visibility =
if (!message.isDeleted &&
message.parentMessageId != null &&
message.parentMessageId != chatActivity.conversationThreadId
) {
View.VISIBLE
} else {
View.GONE
}
} catch (e: Exception) {
Log.d(TAG, "Error when processing parent message in view holder", e)
}
}
} else {
binding.messageQuote.quotedChatMessageView.visibility = View.GONE
}
}
private fun colorizeMessageBubble(message: ChatMessage) {
viewThemeUtils.talk.themeOutgoingMessageBubble(bubble, message.isGrouped, message.isDeleted)
}
fun assignCommonMessageInterface(commonMessageInterface: CommonMessageInterface) {
this.commonMessageInterface = commonMessageInterface
}
companion object {
private val TAG = OutcomingLinkPreviewMessageViewHolder::class.java.simpleName
}
}
@@ -0,0 +1,292 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2024 Christian Reiner <foss@christian-reiner.info>
* SPDX-FileCopyrightText: 2021 Marcel Hibbe <dev@mhibbe.de>
* SPDX-FileCopyrightText: 2017-2018 Mario Danic <mario@lovelyhq.com>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.adapters.messages
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.util.Log
import android.util.TypedValue
import android.view.MotionEvent
import android.view.View
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.appcompat.content.res.AppCompatResources
import androidx.core.net.toUri
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 ru.f7cloud.talk.R
import ru.f7cloud.talk.application.F7cloudTalkApplication
import ru.f7cloud.talk.application.F7cloudTalkApplication.Companion.sharedApplication
import ru.f7cloud.talk.chat.ChatActivity
import ru.f7cloud.talk.chat.data.model.ChatMessage
import ru.f7cloud.talk.databinding.ItemCustomOutcomingLocationMessageBinding
import ru.f7cloud.talk.models.json.chat.ReadStatus
import ru.f7cloud.talk.ui.theme.ViewThemeUtils
import ru.f7cloud.talk.utils.ApiUtils
import ru.f7cloud.talk.utils.DateUtils
import ru.f7cloud.talk.utils.UriUtils
import ru.f7cloud.talk.utils.message.MessageUtils
import com.stfalcon.chatkit.messages.MessageHolders
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.net.URLEncoder
import javax.inject.Inject
import kotlin.math.roundToInt
@AutoInjector(F7cloudTalkApplication::class)
class OutcomingLocationMessageViewHolder(incomingView: View) :
MessageHolders.OutcomingTextMessageViewHolder<ChatMessage>(incomingView),
AdjustableMessageHolderInterface {
override val binding: ItemCustomOutcomingLocationMessageBinding =
ItemCustomOutcomingLocationMessageBinding.bind(itemView)
private val realView: View = itemView
var locationLon: String? = ""
var locationLat: String? = ""
var locationName: String? = ""
var locationGeoLink: String? = ""
@Inject
lateinit var context: Context
@Inject
lateinit var viewThemeUtils: ViewThemeUtils
@Inject
lateinit var messageUtils: MessageUtils
@Inject
lateinit var dateUtils: DateUtils
lateinit var commonMessageInterface: CommonMessageInterface
@SuppressLint("SetTextI18n")
override fun onBind(message: ChatMessage) {
super.onBind(message)
sharedApplication!!.componentApplication.inject(this)
viewThemeUtils.platform.colorTextView(binding.messageTime, ColorRole.ON_SURFACE_VARIANT)
binding.messageTime.text = dateUtils.getLocalTimeStringFromTimestamp(message.timestamp)
realView.isSelected = false
val layoutParams = binding.messageTime.layoutParams as FlexboxLayout.LayoutParams
layoutParams.isWrapBefore = false
val textSize = context.resources.getDimension(R.dimen.chat_text_size)
colorizeMessageBubble(message)
binding.messageText.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize)
binding.messageTime.layoutParams = layoutParams
binding.messageText.text = message.text
// parent message handling
setParentMessageDataOnMessageItem(message)
val readStatusDrawableInt = when (message.readStatus) {
ReadStatus.READ -> R.drawable.ic_check_all
ReadStatus.SENT -> R.drawable.ic_check
else -> null
}
val readStatusContentDescriptionString = when (message.readStatus) {
ReadStatus.READ -> context.resources?.getString(R.string.nc_message_read)
ReadStatus.SENT -> context.resources?.getString(R.string.nc_message_sent)
else -> null
}
readStatusDrawableInt?.let { drawableInt ->
AppCompatResources.getDrawable(context, drawableInt)?.let {
binding.checkMark.setImageDrawable(it)
viewThemeUtils.talk.themeMessageCheckMark(binding.checkMark)
}
}
binding.checkMark.contentDescription = readStatusContentDescriptionString
// geo-location
setLocationDataOnMessageItem(message)
Reaction().showReactions(
message,
::clickOnReaction,
::longClickOnReaction,
binding.reactions,
binding.messageText.context,
true,
viewThemeUtils
)
}
private fun longClickOnReaction(chatMessage: ChatMessage) {
commonMessageInterface.onLongClickReactions(chatMessage)
}
private fun clickOnReaction(chatMessage: ChatMessage, emoji: String) {
commonMessageInterface.onClickReaction(chatMessage, emoji)
}
@SuppressLint("SetJavaScriptEnabled", "ClickableViewAccessibility")
private fun setLocationDataOnMessageItem(message: ChatMessage) {
if (message.messageParameters != null && message.messageParameters!!.size > 0) {
for (key in message.messageParameters!!.keys) {
val individualHashMap: Map<String?, String?> = message.messageParameters!![key]!!
if (individualHashMap["type"] == "geo-location") {
locationLon = individualHashMap["longitude"]
locationLat = individualHashMap["latitude"]
locationName = individualHashMap["name"]
locationGeoLink = individualHashMap["id"]
}
}
}
binding.webview.settings.javaScriptEnabled = true
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)) {
view?.context?.startActivity(Intent(Intent.ACTION_VIEW, url.toUri()))
true
} else {
false
}
}
val urlStringBuffer = StringBuffer("file:///android_asset/leafletMapMessagePreview.html")
urlStringBuffer.append(
"?mapProviderUrl=" + URLEncoder.encode(context.getString(R.string.osm_tile_server_url))
)
urlStringBuffer.append(
"&mapProviderAttribution=" + URLEncoder.encode(
context.getString(
R.string
.osm_tile_server_attributation
)
)
)
urlStringBuffer.append("&locationLat=" + URLEncoder.encode(locationLat))
urlStringBuffer.append("&locationLon=" + URLEncoder.encode(locationLon))
urlStringBuffer.append("&locationName=" + URLEncoder.encode(locationName))
urlStringBuffer.append("&locationGeoLink=" + URLEncoder.encode(locationGeoLink))
binding.webview.loadUrl(urlStringBuffer.toString())
binding.webview.setOnTouchListener(object : View.OnTouchListener {
override fun onTouch(v: View?, event: MotionEvent?): Boolean {
when (event?.action) {
MotionEvent.ACTION_UP -> openGeoLink()
}
return v?.onTouchEvent(event) ?: true
}
})
}
@Suppress("Detekt.TooGenericExceptionCaught", "Detekt.LongMethod")
private fun setParentMessageDataOnMessageItem(message: ChatMessage) {
if (message.parentMessageId != null && !message.isDeleted) {
CoroutineScope(Dispatchers.Main).launch {
try {
val chatActivity = commonMessageInterface as ChatActivity
val urlForChatting = ApiUtils.getUrlForChat(
chatActivity.chatApiVersion,
chatActivity.conversationUser?.baseUrl,
chatActivity.roomToken
)
val parentChatMessage = withContext(Dispatchers.IO) {
chatActivity.chatViewModel.getMessageById(
urlForChatting,
chatActivity.currentConversation!!,
message.parentMessageId!!
).first()
}
parentChatMessage.activeUser = message.activeUser
parentChatMessage.imageUrl?.let {
binding.messageQuote.quotedMessageImage.visibility = View.VISIBLE
binding.messageQuote.quotedMessageImage.load(it) {
addHeader(
"Authorization",
ApiUtils.getCredentials(message.activeUser!!.username, message.activeUser!!.token)!!
)
}
} ?: run {
binding.messageQuote.quotedMessageImage.visibility = View.GONE
}
binding.messageQuote.quotedMessageAuthor.text = parentChatMessage.actorDisplayName
?: context.getText(R.string.nc_nick_guest)
binding.messageQuote.quotedMessage.text = messageUtils
.enrichChatReplyMessageText(
binding.messageQuote.quotedMessage.context,
parentChatMessage,
false,
viewThemeUtils
)
viewThemeUtils.talk.colorOutgoingQuoteText(binding.messageQuote.quotedMessage)
viewThemeUtils.talk.colorOutgoingQuoteAuthorText(binding.messageQuote.quotedMessageAuthor)
viewThemeUtils.talk.themeParentMessage(
parentChatMessage,
message,
binding.messageQuote.quotedChatMessageView
)
binding.messageQuote.quotedChatMessageView.visibility =
if (!message.isDeleted &&
message.parentMessageId != null &&
message.parentMessageId != chatActivity.conversationThreadId
) {
View.VISIBLE
} else {
View.GONE
}
} catch (e: Exception) {
Log.d(TAG, "Error when processing parent message in view holder", e)
}
}
} else {
binding.messageQuote.quotedChatMessageView.visibility = View.GONE
}
}
private fun colorizeMessageBubble(message: ChatMessage) {
viewThemeUtils.talk.themeOutgoingMessageBubble(bubble, message.isGrouped, message.isDeleted)
}
private fun openGeoLink() {
if (!locationGeoLink.isNullOrEmpty()) {
val geoLinkWithMarker = addMarkerToGeoLink(locationGeoLink!!)
val browserIntent = Intent(Intent.ACTION_VIEW, geoLinkWithMarker.toUri())
browserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(browserIntent)
} else {
Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show()
Log.e(TAG, "locationGeoLink was null or empty")
}
}
private fun addMarkerToGeoLink(locationGeoLink: String): String = locationGeoLink.replace("geo:", "geo:0,0?q=")
fun assignCommonMessageInterface(commonMessageInterface: CommonMessageInterface) {
this.commonMessageInterface = commonMessageInterface
}
companion object {
private const val TAG = "LocOutMessageView"
private const val HALF_ALPHA_INT: Int = 255 / 2
private val ALPHA_60_INT: Int = (255 * 0.6).roundToInt()
}
}
@@ -0,0 +1,252 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2022 Marcel Hibbe <dev@mhibbe.de>
* SPDX-FileCopyrightText: 2017-2018 Mario Danic <mario@lovelyhq.com>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.adapters.messages
import android.annotation.SuppressLint
import android.content.Context
import android.util.Log
import android.view.View
import androidx.appcompat.content.res.AppCompatResources
import autodagger.AutoInjector
import coil.load
import com.nextcloud.android.common.ui.theme.utils.ColorRole
import ru.f7cloud.talk.R
import ru.f7cloud.talk.api.NcApi
import ru.f7cloud.talk.application.F7cloudTalkApplication
import ru.f7cloud.talk.application.F7cloudTalkApplication.Companion.sharedApplication
import ru.f7cloud.talk.chat.ChatActivity
import ru.f7cloud.talk.chat.data.model.ChatMessage
import ru.f7cloud.talk.databinding.ItemCustomOutcomingPollMessageBinding
import ru.f7cloud.talk.models.json.chat.ReadStatus
import ru.f7cloud.talk.polls.ui.PollMainDialogFragment
import ru.f7cloud.talk.ui.theme.ViewThemeUtils
import ru.f7cloud.talk.utils.ApiUtils
import ru.f7cloud.talk.utils.DateUtils
import ru.f7cloud.talk.utils.message.MessageUtils
import ru.f7cloud.talk.utils.preferences.AppPreferences
import com.stfalcon.chatkit.messages.MessageHolders
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
@AutoInjector(F7cloudTalkApplication::class)
class OutcomingPollMessageViewHolder(outcomingView: View, payload: Any) :
MessageHolders.OutcomingTextMessageViewHolder<ChatMessage>(outcomingView, payload),
AdjustableMessageHolderInterface {
override val binding: ItemCustomOutcomingPollMessageBinding = ItemCustomOutcomingPollMessageBinding.bind(itemView)
@Inject
lateinit var context: Context
@Inject
lateinit var viewThemeUtils: ViewThemeUtils
@Inject
lateinit var messageUtils: MessageUtils
@Inject
lateinit var dateUtils: DateUtils
@Inject
lateinit var appPreferences: AppPreferences
@Inject
lateinit var ncApi: NcApi
lateinit var message: ChatMessage
lateinit var commonMessageInterface: CommonMessageInterface
@SuppressLint("SetTextI18n")
override fun onBind(message: ChatMessage) {
super.onBind(message)
this.message = message
sharedApplication!!.componentApplication.inject(this)
viewThemeUtils.platform.colorTextView(binding.messageTime, ColorRole.ON_SURFACE_VARIANT)
binding.messageTime.text = dateUtils.getLocalTimeStringFromTimestamp(message.timestamp)
colorizeMessageBubble(message)
itemView.isSelected = false
// parent message handling
setParentMessageDataOnMessageItem(message)
val readStatusDrawableInt = when (message.readStatus) {
ReadStatus.READ -> R.drawable.ic_check_all
ReadStatus.SENT -> R.drawable.ic_check
else -> null
}
val readStatusContentDescriptionString = when (message.readStatus) {
ReadStatus.READ -> context.resources?.getString(R.string.nc_message_read)
ReadStatus.SENT -> context.resources?.getString(R.string.nc_message_sent)
else -> null
}
readStatusDrawableInt?.let { drawableInt ->
AppCompatResources.getDrawable(context, drawableInt)?.let {
binding.checkMark.setImageDrawable(it)
viewThemeUtils.talk.themeMessageCheckMark(binding.checkMark)
}
}
binding.checkMark.contentDescription = readStatusContentDescriptionString
setPollPreview(message)
val chatActivity = commonMessageInterface as ChatActivity
Thread().showThreadPreview(
chatActivity,
message,
threadBinding = binding.threadTitleWrapper,
reactionsBinding = binding.reactions,
openThread = { openThread(message) }
)
Reaction().showReactions(
message,
::clickOnReaction,
::longClickOnReaction,
binding.reactions,
binding.messageTime.context,
true,
viewThemeUtils
)
}
private fun longClickOnReaction(chatMessage: ChatMessage) {
commonMessageInterface.onLongClickReactions(chatMessage)
}
private fun clickOnReaction(chatMessage: ChatMessage, emoji: String) {
commonMessageInterface.onClickReaction(chatMessage, emoji)
}
private fun openThread(chatMessage: ChatMessage) {
commonMessageInterface.openThread(chatMessage)
}
private fun setPollPreview(message: ChatMessage) {
var pollId: String? = null
var pollName: String? = null
if (message.messageParameters != null && message.messageParameters!!.size > 0) {
for (key in message.messageParameters!!.keys) {
val individualHashMap: Map<String?, String?> = message.messageParameters!![key]!!
if (individualHashMap["type"] == "talk-poll") {
pollId = individualHashMap["id"]
pollName = individualHashMap["name"].toString()
}
}
}
if (pollId != null && pollName != null) {
binding.messagePollTitle.text = pollName
val roomToken = (payload as? MessagePayload)!!.roomToken
val isOwnerOrModerator = (payload as? MessagePayload)!!.isOwnerOrModerator ?: false
binding.bubble.setOnClickListener {
val pollVoteDialog = PollMainDialogFragment.newInstance(
message.activeUser!!,
roomToken,
isOwnerOrModerator,
pollId,
pollName
)
pollVoteDialog.show(
(binding.messagePollIcon.context as ChatActivity).supportFragmentManager,
TAG
)
}
}
}
@Suppress("Detekt.TooGenericExceptionCaught", "Detekt.LongMethod")
private fun setParentMessageDataOnMessageItem(message: ChatMessage) {
if (message.parentMessageId != null && !message.isDeleted) {
CoroutineScope(Dispatchers.Main).launch {
try {
val chatActivity = commonMessageInterface as ChatActivity
val urlForChatting = ApiUtils.getUrlForChat(
chatActivity.chatApiVersion,
chatActivity.conversationUser?.baseUrl,
chatActivity.roomToken
)
val parentChatMessage = withContext(Dispatchers.IO) {
chatActivity.chatViewModel.getMessageById(
urlForChatting,
chatActivity.currentConversation!!,
message.parentMessageId!!
).first()
}
parentChatMessage.activeUser = message.activeUser
parentChatMessage.imageUrl?.let {
binding.messageQuote.quotedMessageImage.visibility = View.VISIBLE
binding.messageQuote.quotedMessageImage.load(it) {
addHeader(
"Authorization",
ApiUtils.getCredentials(message.activeUser!!.username, message.activeUser!!.token)!!
)
}
} ?: run {
binding.messageQuote.quotedMessageImage.visibility = View.GONE
}
binding.messageQuote.quotedMessageAuthor.text = parentChatMessage.actorDisplayName
?: context.getText(R.string.nc_nick_guest)
binding.messageQuote.quotedMessage.text = messageUtils
.enrichChatReplyMessageText(
binding.messageQuote.quotedMessage.context,
parentChatMessage,
false,
viewThemeUtils
)
viewThemeUtils.talk.colorOutgoingQuoteText(binding.messageQuote.quotedMessage)
viewThemeUtils.talk.colorOutgoingQuoteAuthorText(binding.messageQuote.quotedMessageAuthor)
viewThemeUtils.talk.themeParentMessage(
parentChatMessage,
message,
binding.messageQuote.quotedChatMessageView
)
binding.messageQuote.quotedChatMessageView.visibility =
if (!message.isDeleted &&
message.parentMessageId != null &&
message.parentMessageId != chatActivity.conversationThreadId
) {
View.VISIBLE
} else {
View.GONE
}
} catch (e: Exception) {
Log.d(TAG, "Error when processing parent message in view holder", e)
}
}
} else {
binding.messageQuote.quotedChatMessageView.visibility = View.GONE
}
}
private fun colorizeMessageBubble(message: ChatMessage) {
viewThemeUtils.talk.themeOutgoingMessageBubble(bubble, message.isGrouped, message.isDeleted)
}
fun assignCommonMessageInterface(commonMessageInterface: CommonMessageInterface) {
this.commonMessageInterface = commonMessageInterface
}
companion object {
private val TAG = OutcomingPollMessageViewHolder::class.java.simpleName
}
}
@@ -0,0 +1,184 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2021 Andy Scherzinger <info@andy-scherzinger.de>
* SPDX-FileCopyrightText: 2021 Tim Krüger <t@timkrueger.me>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.adapters.messages;
import android.text.Spanned;
import android.util.TypedValue;
import android.view.View;
import android.widget.ImageView;
import android.widget.ProgressBar;
import android.widget.TextView;
import com.google.android.material.card.MaterialCardView;
import ru.f7cloud.talk.R;
import ru.f7cloud.talk.databinding.ItemCustomOutcomingPreviewMessageBinding;
import ru.f7cloud.talk.databinding.ItemThreadTitleBinding;
import ru.f7cloud.talk.databinding.ReactionsInsideMessageBinding;
import ru.f7cloud.talk.chat.data.model.ChatMessage;
import ru.f7cloud.talk.models.json.chat.ReadStatus;
import ru.f7cloud.talk.utils.TextMatchers;
import java.util.HashMap;
import java.util.Objects;
import androidx.annotation.NonNull;
import androidx.core.content.ContextCompat;
import androidx.emoji2.widget.EmojiTextView;
public class OutcomingPreviewMessageViewHolder extends PreviewMessageViewHolder {
private final ItemCustomOutcomingPreviewMessageBinding binding;
public OutcomingPreviewMessageViewHolder(View itemView) {
super(itemView, null);
binding = ItemCustomOutcomingPreviewMessageBinding.bind(itemView);
}
@Override
public void onBind(@NonNull ChatMessage message) {
super.onBind(message);
if(!message.isVoiceMessage()
&& !Objects.equals(message.getMessage(), "{file}")
) {
Spanned processedMessageText = null;
binding.bubble.setBackgroundResource(R.drawable.shape_grouped_outcoming_message);
if (viewThemeUtils != null) {
processedMessageText = messageUtils.enrichChatMessageText(
binding.messageCaption.getContext(),
message,
false,
viewThemeUtils);
viewThemeUtils.talk.themeOutgoingMessageBubble(binding.bubble, true, false,
false);
}
if (processedMessageText != null) {
processedMessageText = messageUtils.processMessageParameters(
binding.messageCaption.getContext(),
viewThemeUtils,
processedMessageText,
message,
binding.bubble);
}
binding.bubble.setOnClickListener(null);
float textSize = 0;
if (context != null) {
textSize = context.getResources().getDimension(R.dimen.chat_text_size);
}
HashMap<String, HashMap<String, String>> messageParameters = message.getMessageParameters();
if (
(messageParameters == null || messageParameters.size() <= 0) &&
TextMatchers.isMessageWithSingleEmoticonOnly(message.getText())
) {
textSize = (float)(textSize * IncomingTextMessageViewHolder.TEXT_SIZE_MULTIPLIER);
itemView.setSelected(true);
}
binding.messageCaption.setVisibility(View.VISIBLE);
binding.messageCaption.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize);
binding.messageCaption.setText(processedMessageText);
} else {
binding.bubble.setBackground(null);
binding.messageCaption.setVisibility(View.GONE);
}
binding.messageText.setTextColor(ContextCompat.getColor(binding.messageText.getContext(),
R.color.no_emphasis_text));
binding.messageTime.setTextColor(ContextCompat.getColor(binding.messageText.getContext(),
R.color.no_emphasis_text));
binding.messageEditIndicator.setTextColor(ContextCompat.getColor(binding.messageText.getContext(),
R.color.no_emphasis_text));
binding.checkMark.setVisibility(View.GONE);
Integer readStatusDrawableInt = null;
String readStatusContentDescriptionString = null;
if (message.getReadStatus() == ReadStatus.READ) {
readStatusDrawableInt = R.drawable.ic_check_all;
readStatusContentDescriptionString =
binding.checkMark.getContext().getString(R.string.nc_message_read);
} else if (message.getReadStatus() == ReadStatus.SENT) {
readStatusDrawableInt = R.drawable.ic_check;
readStatusContentDescriptionString =
binding.checkMark.getContext().getString(R.string.nc_message_sent);
}
if (readStatusDrawableInt != null) {
binding.checkMark.setVisibility(View.VISIBLE);
binding.checkMark.setImageDrawable(ContextCompat.getDrawable(binding.checkMark.getContext(),
readStatusDrawableInt));
if (viewThemeUtils != null) {
viewThemeUtils.talk.themeMessageCheckMark(binding.checkMark);
}
}
binding.checkMark.setContentDescription(readStatusContentDescriptionString);
if(!message.isThread()) {
binding.threadTitleWrapperContainer.setVisibility(View.GONE);
} else {
binding.threadTitleWrapperContainer.setVisibility(View.VISIBLE);
}
}
@NonNull
@Override
public EmojiTextView getMessageText() {
return binding.messageText;
}
@Override
public ProgressBar getProgressBar() {
return binding.progressBar;
}
@NonNull
@Override
public View getPreviewContainer() {
return binding.previewContainer;
}
@NonNull
@Override
public MaterialCardView getPreviewContactContainer() {
return binding.contactContainer;
}
@NonNull
@Override
public ImageView getPreviewContactPhoto() {
return binding.contactPhoto;
}
@NonNull
@Override
public EmojiTextView getPreviewContactName() {
return binding.contactName;
}
@Override
public ProgressBar getPreviewContactProgressBar() {
return binding.contactProgressBar;
}
@Override
public ReactionsInsideMessageBinding getReactionsBinding() { return binding.reactions; }
@Override
public ItemThreadTitleBinding getThreadsBinding(){ return binding.threadTitleWrapper; }
@NonNull
@Override
public EmojiTextView getMessageCaption() { return binding.messageCaption; }
@NonNull
@Override
public TextView getMessageEditIndicator() {
return binding.messageEditIndicator;
}
}
@@ -0,0 +1,486 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2024 Christian Reiner <foss@christian-reiner.info>
* SPDX-FileCopyrightText: 2021 Andy Scherzinger <info@andy-scherzinger.de>
* SPDX-FileCopyrightText: 2021 Marcel Hibbe <dev@mhibbe.de>
* SPDX-FileCopyrightText: 2017-2018 Mario Danic <mario@lovelyhq.com>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.adapters.messages
import android.content.Context
import android.util.Log
import android.util.TypedValue
import android.view.View
import android.view.ViewGroup
import android.widget.CheckBox
import androidx.core.content.res.ResourcesCompat
import androidx.lifecycle.lifecycleScope
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 ru.f7cloud.talk.R
import ru.f7cloud.talk.application.F7cloudTalkApplication
import ru.f7cloud.talk.application.F7cloudTalkApplication.Companion.sharedApplication
import ru.f7cloud.talk.chat.ChatActivity
import ru.f7cloud.talk.chat.data.ChatMessageRepository
import ru.f7cloud.talk.chat.data.model.ChatMessage
import ru.f7cloud.talk.data.database.model.SendStatus
import ru.f7cloud.talk.data.network.NetworkMonitor
import ru.f7cloud.talk.data.user.model.User
import ru.f7cloud.talk.databinding.ItemCustomOutcomingTextMessageBinding
import ru.f7cloud.talk.models.json.chat.ChatUtils
import ru.f7cloud.talk.models.json.chat.ReadStatus
import ru.f7cloud.talk.models.json.conversations.ConversationEnums
import ru.f7cloud.talk.ui.theme.ViewThemeUtils
import ru.f7cloud.talk.utils.ApiUtils
import ru.f7cloud.talk.utils.CapabilitiesUtil.hasSpreedFeatureCapability
import ru.f7cloud.talk.utils.DateUtils
import ru.f7cloud.talk.utils.SpreedFeatures
import ru.f7cloud.talk.utils.TextMatchers
import ru.f7cloud.talk.utils.database.user.CurrentUserProviderOld
import ru.f7cloud.talk.utils.message.MessageCheckboxUtils.addCheckboxLine
import ru.f7cloud.talk.utils.message.MessageCheckboxUtils.addPlainTextLine
import ru.f7cloud.talk.utils.message.MessageCheckboxUtils.matchCheckbox
import ru.f7cloud.talk.utils.message.MessageCheckboxUtils.updateMessageWithCheckboxStates
import ru.f7cloud.talk.utils.message.MessageUtils
import com.stfalcon.chatkit.messages.MessageHolders.OutcomingTextMessageViewHolder
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.Date
import javax.inject.Inject
@AutoInjector(F7cloudTalkApplication::class)
class OutcomingTextMessageViewHolder(itemView: View) :
OutcomingTextMessageViewHolder<ChatMessage>(itemView),
AdjustableMessageHolderInterface {
override val binding: ItemCustomOutcomingTextMessageBinding = ItemCustomOutcomingTextMessageBinding.bind(itemView)
private val realView: View = itemView
@Inject
lateinit var context: Context
@Inject
lateinit var viewThemeUtils: ViewThemeUtils
@Inject
lateinit var messageUtils: MessageUtils
@Inject
lateinit var dateUtils: DateUtils
@Inject
lateinit var networkMonitor: NetworkMonitor
lateinit var commonMessageInterface: CommonMessageInterface
@Inject
lateinit var chatRepository: ChatMessageRepository
@Inject
lateinit var currentUserProvider: CurrentUserProviderOld
private var job: Job? = null
@Suppress("Detekt.LongMethod")
override fun onBind(message: ChatMessage) {
super.onBind(message)
sharedApplication!!.componentApplication.inject(this)
val user = currentUserProvider.currentUser.blockingGet()
val hasCheckboxes = processCheckboxes(
message,
user
)
processMessage(message, hasCheckboxes)
}
@Suppress("Detekt.LongMethod")
private fun processMessage(message: ChatMessage, hasCheckboxes: Boolean) {
var isBubbled = true
val layoutParams = binding.messageTime.layoutParams as FlexboxLayout.LayoutParams
var textSize = context.resources.getDimension(R.dimen.chat_text_size)
if (!hasCheckboxes) {
realView.isSelected = false
layoutParams.isWrapBefore = false
binding.messageText.visibility = View.VISIBLE
binding.checkboxContainer.visibility = View.GONE
var processedMessageText = messageUtils.enrichChatMessageText(
binding.messageText.context,
message,
false,
viewThemeUtils
)
val spansFromString: Array<Any> = processedMessageText!!.getSpans(
0,
processedMessageText.length,
Any::class.java
)
if (spansFromString.isNotEmpty()) {
binding.bubble.layoutParams.apply {
width = FlexboxLayout.LayoutParams.MATCH_PARENT
}
binding.messageText.layoutParams.apply {
width = FlexboxLayout.LayoutParams.MATCH_PARENT
}
} else {
binding.bubble.layoutParams.apply {
width = FlexboxLayout.LayoutParams.WRAP_CONTENT
}
binding.messageText.layoutParams.apply {
width = FlexboxLayout.LayoutParams.WRAP_CONTENT
}
}
processedMessageText = messageUtils.processMessageParameters(
binding.messageText.context,
viewThemeUtils,
processedMessageText,
message,
itemView
)
if (
(message.messageParameters == null || message.messageParameters!!.size <= 0) &&
TextMatchers.isMessageWithSingleEmoticonOnly(message.text)
) {
textSize = (textSize * TEXT_SIZE_MULTIPLIER).toFloat()
layoutParams.isWrapBefore = true
realView.isSelected = true
isBubbled = false
}
binding.messageTime.layoutParams = layoutParams
viewThemeUtils.platform.colorTextView(binding.messageText, ColorRole.ON_SURFACE_VARIANT)
binding.messageText.text = processedMessageText
// just for debugging:
// binding.messageText.text =
// SpannableStringBuilder(processedMessageText).append(" (" + message.jsonMessageId + ")")
} else {
binding.messageText.visibility = View.GONE
binding.checkboxContainer.visibility = View.VISIBLE
}
binding.messageText.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize)
if (message.lastEditTimestamp != 0L && !message.isDeleted) {
binding.messageEditIndicator.visibility = View.VISIBLE
binding.messageTime.text = dateUtils.getLocalTimeStringFromTimestamp(message.lastEditTimestamp!!)
} else {
binding.messageEditIndicator.visibility = View.GONE
binding.messageTime.text = dateUtils.getLocalTimeStringFromTimestamp(message.timestamp)
}
viewThemeUtils.platform.colorTextView(binding.messageTime, ColorRole.ON_SURFACE_VARIANT)
setBubbleOnChatMessage(message)
// parent message handling
val chatActivity = commonMessageInterface as? ChatActivity
binding.messageQuote.quotedChatMessageView.visibility =
if (chatActivity != null &&
!message.isDeleted &&
message.parentMessageId != null &&
message.parentMessageId != chatActivity.conversationThreadId
) {
processParentMessage(message)
View.VISIBLE
} else {
View.GONE
}
binding.messageQuote.quotedChatMessageView.setOnLongClickListener { l: View? ->
commonMessageInterface.onOpenMessageActionsDialog(message)
true
}
binding.checkMark.visibility = View.INVISIBLE
binding.sendingProgress.visibility = View.GONE
if (message.sendStatus == SendStatus.FAILED) {
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))
} else if (message.readStatus == ReadStatus.READ) {
updateStatus(R.drawable.ic_check_all, context.resources?.getString(R.string.nc_message_read))
} else if (message.readStatus == ReadStatus.SENT) {
updateStatus(R.drawable.ic_check, context.resources?.getString(R.string.nc_message_sent))
}
chatActivity?.lifecycleScope?.launch {
if (message.isTemporary && !networkMonitor.isOnline.value) {
updateStatus(
R.drawable.ic_signal_wifi_off_white_24dp,
context.resources?.getString(R.string.nc_message_offline)
)
}
}
itemView.setTag(R.string.replyable_message_view_tag, message.replyable)
if (chatActivity != null) {
Thread().showThreadPreview(
chatActivity,
message,
threadBinding = binding.threadTitleWrapper,
reactionsBinding = binding.reactions,
openThread = { openThread(message) }
)
}
Reaction().showReactions(
message,
::clickOnReaction,
::longClickOnReaction,
binding.reactions,
context,
true,
viewThemeUtils,
isBubbled
)
}
private fun processCheckboxes(chatMessage: ChatMessage, user: User): Boolean {
val chatActivity = commonMessageInterface as ChatActivity
val message = chatMessage.message ?: return false
val checkBoxContainer = binding.checkboxContainer
val isOlderThanTwentyFourHours = chatMessage
.createdAt
.before(
Date(System.currentTimeMillis() - AGE_THRESHOLD_FOR_EDIT_MESSAGE)
)
val messageIsEditable = hasSpreedFeatureCapability(
user.capabilities?.spreedCapability!!,
SpreedFeatures.EDIT_MESSAGES
) &&
!isOlderThanTwentyFourHours
val isNoLimitOnNoteToSelf = hasSpreedFeatureCapability(
user.capabilities?.spreedCapability!!,
SpreedFeatures.EDIT_MESSAGES_NOTE_TO_SELF
) &&
chatActivity.currentConversation?.type == ConversationEnums.ConversationType.NOTE_TO_SELF
checkBoxContainer.removeAllViews()
return renderCheckboxLines(
chatMessage,
user,
message,
messageIsEditable || isNoLimitOnNoteToSelf,
checkBoxContainer
)
}
private fun renderCheckboxLines(
chatMessage: ChatMessage,
user: User,
message: String,
editable: Boolean,
checkBoxContainer: ViewGroup
): Boolean {
val checkboxList = mutableListOf<CheckBox>()
val chatActivity = commonMessageInterface as ChatActivity
var hasCheckbox = false
val marginPx = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
PADDING_FOUR_FLOAT,
context.resources.displayMetrics
).toInt()
message.lines().forEach { line ->
val trimmed = line.trimEnd()
val match = matchCheckbox(trimmed)
if (match != null) {
hasCheckbox = true
val isChecked = match.groupValues[CHECKED_GROUP_INDEX].equals("X", true)
val taskText = match.groupValues[TASK_TEXT_GROUP_INDEX].trim()
val checkBox = addCheckboxLine(
context = chatActivity,
container = checkBoxContainer,
chatMessage = chatMessage,
taskText = taskText,
isChecked = isChecked,
isEnabled = editable,
isIncomingMessage = false,
messageUtils = messageUtils,
viewThemeUtils = viewThemeUtils,
linkColorRes = R.color.no_emphasis_text,
paddingPx = marginPx
) { _, _ ->
updateCheckboxStates(chatMessage, user, checkboxList)
}
checkboxList.add(checkBox)
} else if (trimmed.isNotBlank()) {
addPlainTextLine(
context = checkBoxContainer.context,
container = checkBoxContainer,
chatMessage = chatMessage,
text = trimmed,
isIncomingMessage = false,
messageUtils = messageUtils,
viewThemeUtils = viewThemeUtils,
linkColorRes = R.color.no_emphasis_text,
paddingPx = marginPx
)
}
}
return hasCheckbox
}
private fun updateCheckboxStates(chatMessage: ChatMessage, user: User, checkboxes: List<CheckBox>) {
job = CoroutineScope(Dispatchers.Main).launch {
withContext(Dispatchers.IO) {
val apiVersion: Int = ApiUtils.getChatApiVersion(
user.capabilities?.spreedCapability!!,
intArrayOf(1)
)
val updatedMessage = updateMessageWithCheckboxStates(chatMessage.message!!, checkboxes)
val messageParameters = chatMessage.messageParameters
val messageToSend = if (!messageParameters.isNullOrEmpty()) {
val parsedMessage = ChatUtils.getParsedMessage(updatedMessage, messageParameters) ?: updatedMessage
messageUtils.processEditMessageParameters(
messageParameters,
chatMessage,
parsedMessage
).toString()
} else {
updatedMessage
}
chatRepository.editChatMessage(
user.getCredentials(),
ApiUtils.getUrlForChatMessage(apiVersion, user.baseUrl!!, chatMessage.token!!, chatMessage.id),
messageToSend
).collect { result ->
withContext(Dispatchers.Main) {
if (result.isSuccess) {
val editedMessage = result.getOrNull()?.ocs?.data!!.parentMessage!!
Log.d(TAG, "EditedMessage: $editedMessage")
binding.messageEditIndicator.apply {
visibility = View.VISIBLE
}
binding.messageTime.text =
dateUtils.getLocalTimeStringFromTimestamp(editedMessage.lastEditTimestamp!!)
} else {
Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show()
}
}
}
}
}
}
private fun updateStatus(readStatusDrawableInt: Int, description: String?) {
binding.sendingProgress.visibility = View.GONE
binding.checkMark.visibility = View.VISIBLE
readStatusDrawableInt.let { drawableInt ->
ResourcesCompat.getDrawable(context.resources, drawableInt, null)?.let {
binding.checkMark.setImageDrawable(it)
viewThemeUtils.talk.themeMessageCheckMark(binding.checkMark)
}
}
binding.checkMark.contentDescription = description
}
private fun longClickOnReaction(chatMessage: ChatMessage) {
commonMessageInterface.onLongClickReactions(chatMessage)
}
private fun clickOnReaction(chatMessage: ChatMessage, emoji: String) {
commonMessageInterface.onClickReaction(chatMessage, emoji)
}
private fun openThread(chatMessage: ChatMessage) {
commonMessageInterface.openThread(chatMessage)
}
@Suppress("Detekt.TooGenericExceptionCaught")
private fun processParentMessage(message: ChatMessage) {
if (message.parentMessageId != null && !message.isDeleted) {
CoroutineScope(Dispatchers.Main).launch {
try {
val chatActivity = commonMessageInterface as ChatActivity
val urlForChatting = ApiUtils.getUrlForChat(
chatActivity.chatApiVersion,
chatActivity.conversationUser?.baseUrl,
chatActivity.roomToken
)
val parentChatMessage = withContext(Dispatchers.IO) {
chatActivity.chatViewModel.getMessageById(
urlForChatting,
chatActivity.currentConversation!!,
message.parentMessageId!!
).first()
}
parentChatMessage.activeUser = message.activeUser
parentChatMessage.imageUrl?.let {
binding.messageQuote.quotedMessageImage.visibility = View.VISIBLE
binding.messageQuote.quotedMessageImage.load(it) {
addHeader(
"Authorization",
ApiUtils.getCredentials(message.activeUser!!.username, message.activeUser!!.token)!!
)
}
} ?: run {
binding.messageQuote.quotedMessageImage.visibility = View.GONE
}
binding.messageQuote.quotedMessageAuthor.text = parentChatMessage.actorDisplayName
?: context.getText(R.string.nc_nick_guest)
binding.messageQuote.quotedMessage.text = messageUtils
.enrichChatReplyMessageText(
binding.messageQuote.quotedMessage.context,
parentChatMessage,
false,
viewThemeUtils
)
viewThemeUtils.talk.colorOutgoingQuoteText(binding.messageQuote.quotedMessage)
viewThemeUtils.talk.colorOutgoingQuoteAuthorText(binding.messageQuote.quotedMessageAuthor)
viewThemeUtils.talk.themeParentMessage(
parentChatMessage,
message,
binding.messageQuote.quotedChatMessageView
)
binding.messageQuote.quotedChatMessageView.setOnClickListener {
chatActivity.jumpToQuotedMessage(parentChatMessage)
}
} catch (e: Exception) {
Log.d(TAG, "Error when processing parent message in view holder", e)
}
}
}
}
private fun setBubbleOnChatMessage(message: ChatMessage) {
viewThemeUtils.talk.themeOutgoingMessageBubble(bubble, message.isGrouped, message.isDeleted)
}
fun assignCommonMessageInterface(commonMessageInterface: CommonMessageInterface) {
this.commonMessageInterface = commonMessageInterface
}
override fun viewDetached() {
super.viewDetached()
job?.cancel()
}
companion object {
const val TEXT_SIZE_MULTIPLIER = 2.5
private val TAG = OutcomingTextMessageViewHolder::class.java.simpleName
private const val AGE_THRESHOLD_FOR_EDIT_MESSAGE: Long = 86400000
private const val CHECKED_GROUP_INDEX = 1
private const val TASK_TEXT_GROUP_INDEX = 2
private const val PADDING_FOUR_FLOAT = 4f
}
}
@@ -0,0 +1,401 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2024 Christian Reiner <foss@christian-reiner.info>
* SPDX-FileCopyrightText: 2023 Andy Scherzinger <info@andy-scherzinger.de>
* SPDX-FileCopyrightText: 2023 Julius Linus <juliuslinus1@gmail.com>
* SPDX-FileCopyrightText: 2021 Marcel Hibbe <dev@mhibbe.de>
* SPDX-FileCopyrightText: 2017-2018 Mario Danic <mario@lovelyhq.com>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.adapters.messages
import android.annotation.SuppressLint
import android.content.Context
import android.os.Handler
import android.util.Log
import android.view.View
import android.widget.SeekBar
import androidx.appcompat.content.res.AppCompatResources
import androidx.core.content.ContextCompat
import androidx.work.WorkInfo
import androidx.work.WorkManager
import autodagger.AutoInjector
import coil.load
import com.nextcloud.android.common.ui.theme.utils.ColorRole
import ru.f7cloud.talk.R
import ru.f7cloud.talk.application.F7cloudTalkApplication
import ru.f7cloud.talk.application.F7cloudTalkApplication.Companion.sharedApplication
import ru.f7cloud.talk.chat.ChatActivity
import ru.f7cloud.talk.chat.data.model.ChatMessage
import ru.f7cloud.talk.databinding.ItemCustomOutcomingVoiceMessageBinding
import ru.f7cloud.talk.models.json.chat.ReadStatus
import ru.f7cloud.talk.ui.theme.ViewThemeUtils
import ru.f7cloud.talk.utils.ApiUtils
import ru.f7cloud.talk.utils.DateUtils
import ru.f7cloud.talk.utils.message.MessageUtils
import ru.f7cloud.talk.utils.preferences.AppPreferences
import com.stfalcon.chatkit.messages.MessageHolders
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.concurrent.ExecutionException
import javax.inject.Inject
@AutoInjector(F7cloudTalkApplication::class)
@Suppress("Detekt.TooManyFunctions")
class OutcomingVoiceMessageViewHolder(outcomingView: View) :
MessageHolders.OutcomingTextMessageViewHolder<ChatMessage>(outcomingView),
AdjustableMessageHolderInterface {
override val binding: ItemCustomOutcomingVoiceMessageBinding = ItemCustomOutcomingVoiceMessageBinding.bind(itemView)
@JvmField
@Inject
var context: Context? = null
@Inject
lateinit var viewThemeUtils: ViewThemeUtils
@Inject
lateinit var messageUtils: MessageUtils
@Inject
lateinit var dateUtils: DateUtils
@Inject
lateinit var appPreferences: AppPreferences
lateinit var message: ChatMessage
lateinit var handler: Handler
lateinit var voiceMessageInterface: VoiceMessageInterface
lateinit var commonMessageInterface: CommonMessageInterface
private var isBound = false
@SuppressLint("SetTextI18n")
override fun onBind(message: ChatMessage) {
super.onBind(message)
if (isBound) {
handleIsPlayingVoiceMessageState(message)
return
}
this.message = message
sharedApplication!!.componentApplication.inject(this)
viewThemeUtils.platform.colorTextView(binding.messageTime, ColorRole.ON_SURFACE_VARIANT)
val filename = message.selectedIndividualHashMap!!["name"]
val retrieved = appPreferences.getWaveFormFromFile(filename)
if (retrieved.isNotEmpty() &&
message.voiceMessageFloatArray == null ||
message.voiceMessageFloatArray?.isEmpty() == true
) {
message.voiceMessageFloatArray = retrieved.toFloatArray()
binding.seekbar.setWaveData(message.voiceMessageFloatArray!!)
}
binding.seekbar.max = MAX
binding.messageTime.text = dateUtils.getLocalTimeStringFromTimestamp(message.timestamp)
colorizeMessageBubble(message)
itemView.isSelected = false
// parent message handling
setParentMessageDataOnMessageItem(message)
updateDownloadState(message)
viewThemeUtils.talk.themeWaveFormSeekBar(binding.seekbar)
viewThemeUtils.platform.colorCircularProgressBar(binding.progressBar, ColorRole.ON_SURFACE_VARIANT)
showVoiceMessageDuration(message)
handleIsDownloadingVoiceMessageState(message)
handleResetVoiceMessageState(message)
binding.seekbar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onStopTrackingTouch(seekBar: SeekBar) {
// unused atm
}
override fun onStartTrackingTouch(seekBar: SeekBar) {
// unused atm
}
override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {
if (fromUser) {
voiceMessageInterface.updateMediaPlayerProgressBySlider(message, progress)
}
}
})
setReadStatus(message.readStatus)
CoroutineScope(Dispatchers.Default).launch {
(voiceMessageInterface as ChatActivity).chatViewModel.voiceMessagePlayBackUIFlow.onEach { speed ->
withContext(Dispatchers.Main) {
binding.playbackSpeedControlBtn.setSpeed(speed)
}
}.collect()
}
binding.playbackSpeedControlBtn.setSpeed(appPreferences.getPreferredPlayback(message.actorId))
Reaction().showReactions(
message,
::clickOnReaction,
::longClickOnReaction,
binding.reactions,
binding.messageTime.context,
true,
viewThemeUtils
)
isBound = true
}
private fun setReadStatus(readStatus: Enum<ReadStatus>) {
val readStatusDrawableInt = when (readStatus) {
ReadStatus.READ -> R.drawable.ic_check_all
ReadStatus.SENT -> R.drawable.ic_check
else -> null
}
val readStatusContentDescriptionString = when (readStatus) {
ReadStatus.READ -> context?.resources?.getString(R.string.nc_message_read)
ReadStatus.SENT -> context?.resources?.getString(R.string.nc_message_sent)
else -> null
}
if (readStatusDrawableInt != null) {
AppCompatResources.getDrawable(context!!, readStatusDrawableInt)?.let {
binding.checkMark.setImageDrawable(it)
viewThemeUtils.talk.themeMessageCheckMark(binding.checkMark)
}
binding.checkMark.visibility = View.VISIBLE
} else {
binding.checkMark.visibility = View.GONE
}
binding.checkMark.contentDescription = readStatusContentDescriptionString
}
private fun longClickOnReaction(chatMessage: ChatMessage) {
commonMessageInterface.onLongClickReactions(chatMessage)
}
private fun clickOnReaction(chatMessage: ChatMessage, emoji: String) {
commonMessageInterface.onClickReaction(chatMessage, emoji)
}
private fun handleResetVoiceMessageState(message: ChatMessage) {
if (message.resetVoiceMessage) {
binding.playPauseBtn.visibility = View.VISIBLE
binding.playPauseBtn.icon = ContextCompat.getDrawable(
context!!,
R.drawable.ic_baseline_play_arrow_voice_message_24
)
binding.seekbar.progress = SEEKBAR_START
message.voiceMessagePlayedSeconds = 0
showVoiceMessageDuration(message)
message.resetVoiceMessage = false
}
}
private fun showVoiceMessageDuration(message: ChatMessage) {
if (message.voiceMessageDuration > 0) {
binding.voiceMessageDuration.visibility = View.VISIBLE
} else {
binding.voiceMessageDuration.visibility = View.INVISIBLE
}
}
private fun handleIsDownloadingVoiceMessageState(message: ChatMessage) {
if (message.isDownloadingVoiceMessage) {
showVoiceMessageLoading()
} else {
if (message.voiceMessageFloatArray == null || message.voiceMessageFloatArray!!.isEmpty()) {
binding.seekbar.setWaveData(FloatArray(0))
} else {
binding.seekbar.setWaveData(message.voiceMessageFloatArray!!)
}
binding.progressBar.visibility = View.GONE
}
}
private fun handleIsPlayingVoiceMessageState(message: ChatMessage) {
colorizeMessageBubble(message)
if (message.isPlayingVoiceMessage) {
showPlayButton()
binding.playPauseBtn.icon = ContextCompat.getDrawable(
context!!,
R.drawable.ic_baseline_pause_voice_message_24
)
val d = message.voiceMessageDuration.toLong()
val t = message.voiceMessagePlayedSeconds.toLong()
binding.voiceMessageDuration.text = android.text.format.DateUtils.formatElapsedTime(d - t)
binding.voiceMessageDuration.visibility = View.VISIBLE
binding.seekbar.progress = message.voiceMessageSeekbarProgress
} else {
showVoiceMessageDuration(message)
binding.playPauseBtn.visibility = View.VISIBLE
binding.playPauseBtn.icon = ContextCompat.getDrawable(
context!!,
R.drawable.ic_baseline_play_arrow_voice_message_24
)
}
}
private fun updateDownloadState(message: ChatMessage) {
// check if download worker is already running
val fileId = message.selectedIndividualHashMap!!["id"]
val workers = WorkManager.getInstance(context!!).getWorkInfosByTag(fileId!!)
try {
for (workInfo in workers.get()) {
if (workInfo.state == WorkInfo.State.RUNNING || workInfo.state == WorkInfo.State.ENQUEUED) {
showVoiceMessageLoading()
WorkManager.getInstance(context!!).getWorkInfoByIdLiveData(workInfo.id)
.observeForever { info: WorkInfo? ->
updateDownloadState(info)
}
}
}
} catch (e: ExecutionException) {
Log.e(TAG, "Error when checking if worker already exists", e)
} catch (e: InterruptedException) {
Log.e(TAG, "Error when checking if worker already exists", e)
}
}
private fun updateDownloadState(info: WorkInfo?) {
if (info != null) {
when (info.state) {
WorkInfo.State.RUNNING -> {
Log.d(TAG, "WorkInfo.State.RUNNING in ViewHolder")
showVoiceMessageLoading()
}
WorkInfo.State.SUCCEEDED -> {
Log.d(TAG, "WorkInfo.State.SUCCEEDED in ViewHolder")
showPlayButton()
}
WorkInfo.State.FAILED -> {
Log.d(TAG, "WorkInfo.State.FAILED in ViewHolder")
showPlayButton()
}
else -> {
Log.d(TAG, "WorkInfo.State unused in ViewHolder")
}
}
}
}
private fun showPlayButton() {
binding.playPauseBtn.visibility = View.VISIBLE
binding.progressBar.visibility = View.GONE
}
private fun showVoiceMessageLoading() {
binding.playPauseBtn.visibility = View.GONE
binding.progressBar.visibility = View.VISIBLE
}
@Suppress("Detekt.TooGenericExceptionCaught", "Detekt.LongMethod")
private fun setParentMessageDataOnMessageItem(message: ChatMessage) {
if (message.parentMessageId != null && !message.isDeleted) {
CoroutineScope(Dispatchers.Main).launch {
try {
val chatActivity = commonMessageInterface as ChatActivity
val urlForChatting = ApiUtils.getUrlForChat(
chatActivity.chatApiVersion,
chatActivity.conversationUser?.baseUrl,
chatActivity.roomToken
)
val parentChatMessage = withContext(Dispatchers.IO) {
chatActivity.chatViewModel.getMessageById(
urlForChatting,
chatActivity.currentConversation!!,
message.parentMessageId!!
).first()
}
parentChatMessage.activeUser = message.activeUser
parentChatMessage.imageUrl?.let {
binding.messageQuote.quotedMessageImage.visibility = View.VISIBLE
binding.messageQuote.quotedMessageImage.load(it) {
addHeader(
"Authorization",
ApiUtils.getCredentials(message.activeUser!!.username, message.activeUser!!.token)!!
)
}
} ?: run {
binding.messageQuote.quotedMessageImage.visibility = View.GONE
}
binding.messageQuote.quotedMessageAuthor.text = parentChatMessage.actorDisplayName
?: context!!.getText(R.string.nc_nick_guest)
binding.messageQuote.quotedMessage.text = messageUtils
.enrichChatReplyMessageText(
binding.messageQuote.quotedMessage.context,
parentChatMessage,
false,
viewThemeUtils
)
viewThemeUtils.talk.colorOutgoingQuoteText(binding.messageQuote.quotedMessage)
viewThemeUtils.talk.colorOutgoingQuoteAuthorText(binding.messageQuote.quotedMessageAuthor)
viewThemeUtils.talk.themeParentMessage(
parentChatMessage,
message,
binding.messageQuote.quotedChatMessageView
)
binding.messageQuote.quotedChatMessageView.visibility =
if (!message.isDeleted &&
message.parentMessageId != null &&
message.parentMessageId != chatActivity.conversationThreadId
) {
View.VISIBLE
} else {
View.GONE
}
} catch (e: Exception) {
Log.d(TAG, "Error when processing parent message in view holder", e)
}
}
} else {
binding.messageQuote.quotedChatMessageView.visibility = View.GONE
}
}
private fun colorizeMessageBubble(message: ChatMessage) {
viewThemeUtils.talk.themeOutgoingMessageBubble(
bubble,
message.isGrouped,
message.isDeleted,
message.wasPlayedVoiceMessage
)
}
fun assignVoiceMessageInterface(voiceMessageInterface: VoiceMessageInterface) {
this.voiceMessageInterface = voiceMessageInterface
}
fun assignCommonMessageInterface(commonMessageInterface: CommonMessageInterface) {
this.commonMessageInterface = commonMessageInterface
}
companion object {
private const val TAG = "VoiceOutMessageView"
private const val SEEKBAR_START: Int = 0
private const val MAX = 100
}
}
@@ -0,0 +1,13 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2022 Marcel Hibbe <dev@mhibbe.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.adapters.messages
import ru.f7cloud.talk.chat.data.model.ChatMessage
interface PreviewMessageInterface {
fun onPreviewMessageLongClick(chatMessage: ChatMessage)
}
@@ -0,0 +1,368 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2021-2022 Tim Krüger <t@timkrueger.me>
* SPDX-FileCopyrightText: 2021 Andy Scherzinger <info@andy-scherzinger.de>
* SPDX-FileCopyrightText: 2021 Marcel Hibbe <dev@mhibbe.de>
* SPDX-FileCopyrightText: 2017-2018 Mario Danic <mario@lovelyhq.com>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.adapters.messages
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.graphics.drawable.Drawable
import android.os.Handler
import android.util.Base64
import android.util.Log
import android.view.View
import android.widget.ImageView
import android.widget.ProgressBar
import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import androidx.emoji2.widget.EmojiTextView
import autodagger.AutoInjector
import com.google.android.material.card.MaterialCardView
import com.nextcloud.android.common.ui.theme.utils.ColorRole
import ru.f7cloud.talk.R
import ru.f7cloud.talk.application.F7cloudTalkApplication
import ru.f7cloud.talk.application.F7cloudTalkApplication.Companion.sharedApplication
import ru.f7cloud.talk.chat.ChatActivity
import ru.f7cloud.talk.chat.data.model.ChatMessage
import ru.f7cloud.talk.data.user.model.User
import ru.f7cloud.talk.databinding.ItemThreadTitleBinding
import ru.f7cloud.talk.databinding.ReactionsInsideMessageBinding
import ru.f7cloud.talk.extensions.loadChangelogBotAvatar
import ru.f7cloud.talk.extensions.loadFederatedUserAvatar
import ru.f7cloud.talk.filebrowser.models.BrowserFile
import ru.f7cloud.talk.filebrowser.webdav.ReadFilesystemOperation
import ru.f7cloud.talk.ui.theme.ViewThemeUtils
import ru.f7cloud.talk.users.UserManager
import ru.f7cloud.talk.utils.DateUtils
import ru.f7cloud.talk.utils.DisplayUtils
import ru.f7cloud.talk.utils.DrawableUtils.getDrawableResourceIdForMimeType
import ru.f7cloud.talk.utils.FileViewerUtils
import ru.f7cloud.talk.utils.FileViewerUtils.ProgressUi
import ru.f7cloud.talk.utils.message.MessageUtils
import com.stfalcon.chatkit.messages.MessageHolders.IncomingImageMessageViewHolder
import io.reactivex.Single
import io.reactivex.SingleObserver
import io.reactivex.disposables.Disposable
import io.reactivex.schedulers.Schedulers
import okhttp3.OkHttpClient
import java.io.ByteArrayInputStream
import java.io.IOException
import javax.inject.Inject
@AutoInjector(F7cloudTalkApplication::class)
abstract class PreviewMessageViewHolder(itemView: View?, payload: Any?) :
IncomingImageMessageViewHolder<ChatMessage>(itemView, payload) {
@JvmField
@Inject
var context: Context? = null
@JvmField
@Inject
var viewThemeUtils: ViewThemeUtils? = null
@Inject
lateinit var dateUtils: DateUtils
@Inject
lateinit var messageUtils: MessageUtils
@Inject
lateinit var userManager: UserManager
@JvmField
@Inject
var okHttpClient: OkHttpClient? = null
open var progressBar: ProgressBar? = null
open var reactionsBinding: ReactionsInsideMessageBinding? = null
open var threadsBinding: ItemThreadTitleBinding? = null
var fileViewerUtils: FileViewerUtils? = null
var clickView: View? = null
lateinit var commonMessageInterface: CommonMessageInterface
var previewMessageInterface: PreviewMessageInterface? = null
private var placeholder: Drawable? = null
init {
sharedApplication!!.componentApplication.inject(this)
}
@SuppressLint("SetTextI18n")
@Suppress("NestedBlockDepth", "ComplexMethod", "LongMethod")
override fun onBind(message: ChatMessage) {
super.onBind(message)
image.minimumHeight = DisplayUtils.convertDpToPixel(MIN_IMAGE_HEIGHT, context!!).toInt()
if (message.lastEditTimestamp != 0L && !message.isDeleted) {
time.text = dateUtils.getLocalTimeStringFromTimestamp(message.lastEditTimestamp!!)
messageEditIndicator.visibility = View.VISIBLE
} else {
time.text = dateUtils.getLocalTimeStringFromTimestamp(message.timestamp)
messageEditIndicator.visibility = View.GONE
}
viewThemeUtils!!.platform.colorCircularProgressBar(progressBar!!, ColorRole.PRIMARY)
clickView = image
messageText.visibility = View.VISIBLE
if (message.getCalculateMessageType() === ChatMessage.MessageType.SINGLE_NC_ATTACHMENT_MESSAGE) {
val chatActivity = commonMessageInterface as ChatActivity
fileViewerUtils = FileViewerUtils(chatActivity, message.activeUser!!)
val fileName = message.selectedIndividualHashMap!![KEY_NAME]
messageText.text = fileName
if (message.activeUser != null &&
message.activeUser!!.username != null &&
message.activeUser!!.baseUrl != null
) {
clickView!!.setOnClickListener { v: View? ->
fileViewerUtils!!.openFile(
message,
ProgressUi(progressBar, messageText, image)
)
}
clickView!!.setOnLongClickListener {
previewMessageInterface!!.onPreviewMessageLongClick(message)
true
}
} else {
Log.e(TAG, "failed to set click listener because activeUser, username or baseUrl were null")
}
fileViewerUtils!!.resumeToUpdateViewsByProgress(
message.selectedIndividualHashMap!![KEY_NAME]!!,
message.selectedIndividualHashMap!![KEY_ID]!!,
message.selectedIndividualHashMap!![KEY_MIMETYPE],
message.openWhenDownloaded,
ProgressUi(progressBar, messageText, image)
)
} else if (message.getCalculateMessageType() === ChatMessage.MessageType.SINGLE_LINK_GIPHY_MESSAGE) {
messageText.text = "GIPHY"
DisplayUtils.setClickableString("GIPHY", "https://giphy.com", messageText)
} else if (message.getCalculateMessageType() === ChatMessage.MessageType.SINGLE_LINK_TENOR_MESSAGE) {
messageText.text = "Tenor"
DisplayUtils.setClickableString("Tenor", "https://tenor.com", messageText)
} else {
if (message.messageType == ChatMessage.MessageType.SINGLE_LINK_IMAGE_MESSAGE.name) {
clickView!!.setOnClickListener {
val browserIntent = Intent(Intent.ACTION_VIEW, message.imageUrl!!.toUri())
browserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context!!.startActivity(browserIntent)
}
} else {
clickView!!.setOnClickListener(null)
}
messageText.text = ""
}
itemView.setTag(R.string.replyable_message_view_tag, message.replyable)
val chatActivity = commonMessageInterface as ChatActivity
Thread().showThreadPreview(
chatActivity,
message,
threadBinding = threadsBinding!!,
reactionsBinding = reactionsBinding!!,
openThread = { openThread(message) }
)
val paddingSide = DisplayUtils.convertDpToPixel(HORIZONTAL_REACTION_PADDING, context!!).toInt()
Reaction().showReactions(
message,
::clickOnReaction,
::longClickOnReaction,
reactionsBinding!!,
messageText.context,
true,
viewThemeUtils!!,
hasBubbleBackground(message)
)
reactionsBinding!!.reactionsEmojiWrapper.setPadding(paddingSide, 0, paddingSide, 0)
if (userAvatar != null) {
if (message.isGrouped || message.isOneToOneConversation) {
if (message.isOneToOneConversation) {
userAvatar.visibility = View.GONE
} else {
userAvatar.visibility = View.INVISIBLE
}
} else {
userAvatar.visibility = View.VISIBLE
userAvatar.setOnClickListener { v: View ->
if (payload is MessagePayload) {
(payload as MessagePayload).profileBottomSheet.showFor(
message,
v.context
)
}
}
if (ACTOR_TYPE_BOTS == message.actorType && ACTOR_ID_CHANGELOG == message.actorId) {
userAvatar.loadChangelogBotAvatar()
} else if (message.actorType == "federated_users") {
userAvatar.loadFederatedUserAvatar(message)
}
}
}
messageCaption.setOnClickListener(null)
messageCaption.setOnLongClickListener {
previewMessageInterface!!.onPreviewMessageLongClick(message)
true
}
}
private fun longClickOnReaction(chatMessage: ChatMessage) {
commonMessageInterface.onLongClickReactions(chatMessage)
}
private fun clickOnReaction(chatMessage: ChatMessage, emoji: String) {
commonMessageInterface.onClickReaction(chatMessage, emoji)
}
private fun openThread(chatMessage: ChatMessage) {
commonMessageInterface.openThread(chatMessage)
}
override fun getPayloadForImageLoader(message: ChatMessage?): Any? {
if (message!!.selectedIndividualHashMap!!.containsKey(KEY_CONTACT_NAME)) {
previewContainer.visibility = View.GONE
previewContactContainer.visibility = View.VISIBLE
previewContactName.text = message.selectedIndividualHashMap!![KEY_CONTACT_NAME]
progressBar = previewContactProgressBar
messageText.visibility = View.INVISIBLE
clickView = previewContactContainer
viewThemeUtils!!.talk.colorContactChatItemBackground(previewContactContainer)
viewThemeUtils!!.talk.colorContactChatItemName(previewContactName)
viewThemeUtils!!.platform.colorCircularProgressBar(
previewContactProgressBar!!,
ColorRole.ON_PRIMARY_CONTAINER
)
if (message.selectedIndividualHashMap!!.containsKey(KEY_CONTACT_PHOTO)) {
image = previewContactPhoto
placeholder = getDrawableFromContactDetails(
context,
message.selectedIndividualHashMap!![KEY_CONTACT_PHOTO]
)
} else {
image = previewContactPhoto
image.setImageDrawable(ContextCompat.getDrawable(context!!, R.drawable.ic_mimetype_text_vcard))
}
} else {
previewContainer.visibility = View.VISIBLE
previewContactContainer.visibility = View.GONE
}
if (message.selectedIndividualHashMap!!.containsKey(KEY_MIMETYPE)) {
val mimetype = message.selectedIndividualHashMap!![KEY_MIMETYPE]
val drawableResourceId = getDrawableResourceIdForMimeType(mimetype)
var drawable = ContextCompat.getDrawable(context!!, drawableResourceId)
if (drawable != null &&
(
drawableResourceId == R.drawable.ic_mimetype_folder ||
drawableResourceId == R.drawable.ic_mimetype_package_x_generic
)
) {
drawable = viewThemeUtils?.platform?.tintDrawable(context!!, drawable)
}
placeholder = drawable
} else {
fetchFileInformation(
"/" + message.selectedIndividualHashMap!![KEY_PATH],
message.activeUser
)
}
return placeholder
}
private fun getDrawableFromContactDetails(context: Context?, base64: String?): Drawable? {
var drawable: Drawable? = null
if (base64 != "") {
val inputStream = ByteArrayInputStream(
Base64.decode(base64!!.toByteArray(), Base64.DEFAULT)
)
drawable = Drawable.createFromResourceStream(
context!!.resources,
null,
inputStream,
null,
null
)
try {
inputStream.close()
} catch (e: IOException) {
Log.e(TAG, "failed to close stream in getDrawableFromContactDetails", e)
}
}
if (drawable == null) {
drawable = ContextCompat.getDrawable(context!!, R.drawable.ic_mimetype_text_vcard)
}
return drawable
}
private fun fetchFileInformation(url: String, activeUser: User?) {
Single.fromCallable { ReadFilesystemOperation(okHttpClient, activeUser, url, 0) }
.observeOn(Schedulers.io())
.subscribe(object : SingleObserver<ReadFilesystemOperation> {
override fun onSubscribe(d: Disposable) {
// unused atm
}
override fun onSuccess(readFilesystemOperation: ReadFilesystemOperation) {
val davResponse = readFilesystemOperation.readRemotePath()
if (davResponse.data != null) {
val browserFileList = davResponse.data as List<BrowserFile>
if (browserFileList.isNotEmpty()) {
Handler(context!!.mainLooper).post {
val resourceId = getDrawableResourceIdForMimeType(browserFileList[0].mimeType)
placeholder = ContextCompat.getDrawable(context!!, resourceId)
}
}
}
}
override fun onError(e: Throwable) {
Log.e(TAG, "Error reading file information", e)
}
})
}
fun assignCommonMessageInterface(commonMessageInterface: CommonMessageInterface) {
this.commonMessageInterface = commonMessageInterface
}
fun assignPreviewMessageInterface(previewMessageInterface: PreviewMessageInterface?) {
this.previewMessageInterface = previewMessageInterface
}
fun hasBubbleBackground(message: ChatMessage): Boolean = !message.isVoiceMessage && message.message != "{file}"
abstract val messageText: EmojiTextView
abstract val messageCaption: EmojiTextView
abstract val previewContainer: View
abstract val previewContactContainer: MaterialCardView
abstract val previewContactPhoto: ImageView
abstract val previewContactName: EmojiTextView
abstract val previewContactProgressBar: ProgressBar?
abstract val messageEditIndicator: TextView
companion object {
private const val TAG = "PreviewMsgViewHolder"
const val KEY_CONTACT_NAME = "contact-name"
const val KEY_CONTACT_PHOTO = "contact-photo"
const val KEY_MIMETYPE = "mimetype"
const val KEY_ID = "id"
const val KEY_PATH = "path"
const val ACTOR_TYPE_BOTS = "bots"
const val ACTOR_ID_CHANGELOG = "changelog"
const val KEY_NAME = "name"
const val MIN_IMAGE_HEIGHT = 100F
const val HORIZONTAL_REACTION_PADDING = 8.0F
}
}
@@ -0,0 +1,190 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2022 Marcel Hibbe <dev@mhibbe.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.adapters.messages
import android.content.Context
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.TextView
import com.google.android.material.card.MaterialCardView
import ru.f7cloud.talk.chat.data.model.ChatMessage
import ru.f7cloud.talk.databinding.ReactionsInsideMessageBinding
import ru.f7cloud.talk.ui.theme.ViewThemeUtils
import ru.f7cloud.talk.utils.DisplayUtils
import com.vanniktech.emoji.EmojiTextView
import java.util.Locale
class Reaction {
fun showReactions(
message: ChatMessage,
clickOnReaction: (message: ChatMessage, emoji: String) -> Unit,
longClickOnReaction: (message: ChatMessage) -> Unit,
binding: ReactionsInsideMessageBinding,
context: Context,
isOutgoingMessage: Boolean,
viewThemeUtils: ViewThemeUtils,
isBubbled: Boolean = true
) {
binding.reactionsEmojiWrapper.removeAllViews()
if (message.reactions != null && message.reactions!!.isNotEmpty()) {
binding.reactionsEmojiWrapper.visibility = View.VISIBLE
binding.reactionsEmojiWrapper.setOnLongClickListener {
longClickOnReaction(message)
true
}
val paddingSide = DisplayUtils.convertDpToPixel(EMOJI_AND_AMOUNT_PADDING_SIDE, context).toInt()
val paddingTop = DisplayUtils.convertDpToPixel(WRAPPER_PADDING_TOP, context).toInt()
val paddingBottom = DisplayUtils.convertDpToPixel(WRAPPER_PADDING_BOTTOM, context).toInt()
for ((emoji, amount) in message.reactions!!) {
val isSelfReaction = message.reactionsSelf != null &&
message.reactionsSelf!!.isNotEmpty() &&
message.reactionsSelf!!.contains(emoji)
val textColor = viewThemeUtils.talk.getTextColor(isOutgoingMessage, isSelfReaction, binding)
val emojiWithAmountWrapper = getEmojiWithAmountWrapperLayout(
binding.reactionsEmojiWrapper.context,
emoji,
amount,
EmojiWithAmountWrapperLayoutInfo(
textColor,
paddingSide,
paddingTop,
paddingBottom,
viewThemeUtils,
isOutgoingMessage,
isSelfReaction
),
isBubbled
)
emojiWithAmountWrapper.setOnClickListener {
clickOnReaction(message, emoji)
}
emojiWithAmountWrapper.setOnLongClickListener {
longClickOnReaction(message)
false
}
binding.reactionsEmojiWrapper.addView(emojiWithAmountWrapper)
}
} else {
binding.reactionsEmojiWrapper.visibility = View.GONE
}
}
private fun getEmojiWithAmountWrapperLayout(
context: Context,
emoji: String,
amount: Int,
layoutInfo: EmojiWithAmountWrapperLayoutInfo,
isBubbled: Boolean
): MaterialCardView {
val amountParams = getAmountLayoutParams(context)
val wrapperParams = getWrapperLayoutParams(context)
val emojiContainer = MaterialCardView(context)
val emojiWithAmountWrapper = LinearLayout(context)
emojiWithAmountWrapper.orientation = LinearLayout.HORIZONTAL
emojiWithAmountWrapper.addView(getEmojiTextView(context, emoji, amountParams))
emojiWithAmountWrapper.addView(getReactionCount(context, layoutInfo.textColor, amount, amountParams))
emojiWithAmountWrapper.layoutParams = wrapperParams
layoutInfo.viewThemeUtils.talk.setReactionsBackground(
emojiContainer,
layoutInfo.isOutgoingMessage,
layoutInfo.isSelfReaction,
isBubbled
)
emojiWithAmountWrapper.setPaddingRelative(
layoutInfo.paddingSide,
layoutInfo.paddingTop,
layoutInfo.paddingSide,
layoutInfo.paddingBottom
)
emojiContainer.addView(emojiWithAmountWrapper)
val containerParams = getWrapperLayoutParams(context, REACTION_END_MARGIN)
containerParams.marginStart = 0
emojiContainer.layoutParams = containerParams
emojiContainer.setStrokeWidth(DisplayUtils.convertDpToPixel(EMOJI_CONTAINER_STROKE_WIDTH, context).toInt())
return emojiContainer
}
private fun getEmojiTextView(
context: Context,
emoji: String,
layoutParams: LinearLayout.LayoutParams
): EmojiTextView {
val reactionEmoji = EmojiTextView(context)
reactionEmoji.text = emoji
reactionEmoji.layoutParams = layoutParams
return reactionEmoji
}
private fun getReactionCount(
context: Context,
textColor: Int,
amount: Int,
layoutParams: LinearLayout.LayoutParams
): TextView {
val reactionAmount = TextView(context)
reactionAmount.setTextColor(textColor)
reactionAmount.text = String.format(Locale.getDefault(), "%d", amount)
reactionAmount.layoutParams = layoutParams
return reactionAmount
}
private fun getWrapperLayoutParams(
context: Context,
endMarginInDp: Float = EMOJI_END_MARGIN
): LinearLayout
.LayoutParams {
val wrapperParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
wrapperParams.marginEnd = DisplayUtils.convertDpToPixel(endMarginInDp, context).toInt()
return wrapperParams
}
private fun getAmountLayoutParams(context: Context): LinearLayout.LayoutParams {
val amountParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
amountParams.marginStart = DisplayUtils.convertDpToPixel(AMOUNT_START_MARGIN, context).toInt()
return amountParams
}
private data class EmojiWithAmountWrapperLayoutInfo(
val textColor: Int,
val paddingSide: Int,
val paddingTop: Int,
val paddingBottom: Int,
val viewThemeUtils: ViewThemeUtils,
val isOutgoingMessage: Boolean,
val isSelfReaction: Boolean
)
companion object {
const val AMOUNT_START_MARGIN: Float = 4F
const val EMOJI_END_MARGIN: Float = 4F
const val REACTION_END_MARGIN: Float = 6F
const val EMOJI_AND_AMOUNT_PADDING_SIDE: Float = 4F
const val WRAPPER_PADDING_TOP: Float = 2F
const val WRAPPER_PADDING_BOTTOM: Float = 3F
const val EMOJI_CONTAINER_STROKE_WIDTH: Float = 1.5F
}
}
@@ -0,0 +1,14 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2023 Marcel Hibbe <dev@mhibbe.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.adapters.messages
import ru.f7cloud.talk.chat.data.model.ChatMessage
interface SystemMessageInterface {
fun expandSystemMessage(chatMessage: ChatMessage)
fun collapseSystemMessages()
}
@@ -0,0 +1,194 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2017-2018 Mario Danic <mario@lovelyhq.com>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.adapters.messages
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.text.Spannable
import android.text.SpannableString
import android.text.TextPaint
import android.text.method.LinkMovementMethod
import android.text.style.ClickableSpan
import android.util.TypedValue
import android.view.View
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import androidx.core.view.ViewCompat
import autodagger.AutoInjector
import ru.f7cloud.talk.R
import ru.f7cloud.talk.application.F7cloudTalkApplication
import ru.f7cloud.talk.application.F7cloudTalkApplication.Companion.sharedApplication
import ru.f7cloud.talk.chat.data.model.ChatMessage
import ru.f7cloud.talk.databinding.ItemSystemMessageBinding
import ru.f7cloud.talk.utils.DateUtils
import ru.f7cloud.talk.utils.DisplayUtils
import ru.f7cloud.talk.utils.database.user.CurrentUserProviderOld
import ru.f7cloud.talk.utils.preferences.AppPreferences
import com.stfalcon.chatkit.messages.MessageHolders
import javax.inject.Inject
@AutoInjector(F7cloudTalkApplication::class)
class SystemMessageViewHolder(itemView: View) :
MessageHolders
.IncomingTextMessageViewHolder<ChatMessage>(itemView) {
private val binding: ItemSystemMessageBinding = ItemSystemMessageBinding.bind(itemView)
@Inject
lateinit var currentUserProvider: CurrentUserProviderOld
@JvmField
@Inject
var appPreferences: AppPreferences? = null
@JvmField
@Inject
var context: Context? = null
@JvmField
@Inject
var dateUtils: DateUtils? = null
protected var background: ViewGroup
lateinit var systemMessageInterface: SystemMessageInterface
init {
sharedApplication!!.componentApplication.inject(this)
background = itemView.findViewById(R.id.container)
}
@SuppressLint("SetTextI18n")
override fun onBind(message: ChatMessage) {
super.onBind(message)
val user = currentUserProvider.currentUser.blockingGet()
val resources = itemView.resources
val pressedColor: Int = resources.getColor(R.color.bg_message_list_incoming_bubble)
val mentionColor: Int = resources.getColor(R.color.textColorMaxContrast)
val bubbleDrawable = DisplayUtils.getMessageSelector(
resources.getColor(R.color.transparent),
resources.getColor(R.color.transparent),
pressedColor,
R.drawable.shape_grouped_incoming_message
)
ViewCompat.setBackground(background, bubbleDrawable)
binding.messageText.setTextSize(
TypedValue.COMPLEX_UNIT_PX,
resources.getDimension(R.dimen.chat_system_message_text_size)
)
var messageString: Spannable = SpannableString(message.text)
if (message.messageParameters != null && message.messageParameters!!.size > 0) {
for (key in message.messageParameters!!.keys) {
val individualMap: Map<String?, String?>? = message.messageParameters!![key]
if (individualMap != null && individualMap.containsKey("name")) {
var searchText: String? = if ("user" == individualMap["type"] ||
"guest" == individualMap["type"] ||
"call" == individualMap["type"]
) {
"@" + individualMap["name"]
} else {
individualMap["name"]
}
messageString =
DisplayUtils.searchAndColor(
messageString,
searchText!!,
mentionColor,
resources.getDimensionPixelSize(R.dimen.chat_system_message_text_size)
)
if (individualMap["link"] != null) {
val displayName = individualMap["name"] ?: ""
val link = (user.baseUrl + individualMap["link"])
val newStartIndex = messageString.indexOf(displayName)
if (newStartIndex != -1) {
val clickableSpan = object : ClickableSpan() {
override fun onClick(widget: View) {
val browserIntent = Intent(Intent.ACTION_VIEW, link.toUri())
browserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context?.startActivity(browserIntent)
}
override fun updateDrawState(ds: TextPaint) {
super.updateDrawState(ds)
ds.color = mentionColor
ds.isUnderlineText = false
}
}
messageString.setSpan(
clickableSpan,
newStartIndex,
newStartIndex + displayName.length,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
}
}
}
binding.systemMessageLayout.visibility = View.VISIBLE
binding.similarMessagesHint.visibility = View.GONE
if (message.expandableParent) {
processExpandableParent(message, messageString)
} else if (message.hiddenByCollapse) {
binding.systemMessageLayout.visibility = View.GONE
} else {
binding.expandCollapseIcon.visibility = View.GONE
binding.messageText.text = messageString
binding.expandCollapseIcon.setImageDrawable(null)
binding.systemMessageLayout.setOnClickListener(null)
}
if (!message.expandableParent && message.lastItemOfExpandableGroup != 0) {
binding.systemMessageLayout.setOnClickListener { systemMessageInterface.collapseSystemMessages() }
binding.messageText.setOnClickListener { systemMessageInterface.collapseSystemMessages() }
}
binding.messageTime.text = dateUtils!!.getLocalTimeStringFromTimestamp(message.timestamp)
itemView.setTag(R.string.replyable_message_view_tag, message.replyable)
}
}
@SuppressLint("SetTextI18n")
private fun processExpandableParent(message: ChatMessage, messageString: Spannable) {
binding.expandCollapseIcon.visibility = View.VISIBLE
if (!message.isExpanded) {
val similarMessages = sharedApplication!!.resources.getQuantityString(
R.plurals.see_similar_system_messages,
message.expandableChildrenAmount,
message.expandableChildrenAmount
)
binding.messageText.text = messageString
binding.messageText.movementMethod = LinkMovementMethod.getInstance()
binding.similarMessagesHint.visibility = View.VISIBLE
binding.similarMessagesHint.text = similarMessages
binding.expandCollapseIcon.setImageDrawable(
ContextCompat.getDrawable(context!!, R.drawable.baseline_unfold_more_24)
)
binding.systemMessageLayout.setOnClickListener { systemMessageInterface.expandSystemMessage(message) }
binding.messageText.setOnClickListener { systemMessageInterface.expandSystemMessage(message) }
} else {
binding.messageText.text = messageString
binding.similarMessagesHint.visibility = View.GONE
binding.similarMessagesHint.text = ""
binding.expandCollapseIcon.setImageDrawable(
ContextCompat.getDrawable(context!!, R.drawable.baseline_unfold_less_24)
)
binding.systemMessageLayout.setOnClickListener { systemMessageInterface.collapseSystemMessages() }
binding.messageText.setOnClickListener { systemMessageInterface.collapseSystemMessages() }
}
}
fun assignSystemMessageInterface(systemMessageInterface: SystemMessageInterface) {
this.systemMessageInterface = systemMessageInterface
}
}
@@ -0,0 +1,86 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2024 Christian Reiner <foss@christian-reiner.info>
* SPDX-FileCopyrightText: 2020 Tobias Kaminsky <tobias.kaminsky@f7cloud.com>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.adapters.messages;
import ru.f7cloud.talk.chat.ChatActivity;
import com.stfalcon.chatkit.commons.ImageLoader;
import com.stfalcon.chatkit.commons.ViewHolder;
import com.stfalcon.chatkit.commons.models.IMessage;
import com.stfalcon.chatkit.messages.MessageHolders;
import com.stfalcon.chatkit.messages.MessagesListAdapter;
import java.util.List;
public class TalkMessagesListAdapter<M extends IMessage> extends MessagesListAdapter<M> {
private final ChatActivity chatActivity;
public TalkMessagesListAdapter(
String senderId,
MessageHolders holders,
ImageLoader imageLoader,
ChatActivity chatActivity) {
super(senderId, holders, imageLoader);
this.chatActivity = chatActivity;
}
public List<MessagesListAdapter.Wrapper> getItems() {
return items;
}
@Override
public void onBindViewHolder(ViewHolder holder, int position) {
if (holder instanceof IncomingTextMessageViewHolder holderInstance) {
holderInstance.assignCommonMessageInterface(chatActivity);
} else if (holder instanceof OutcomingTextMessageViewHolder holderInstance) {
holderInstance.assignCommonMessageInterface(chatActivity);
holderInstance.adjustIfNoteToSelf(chatActivity.getCurrentConversation());
} else if (holder instanceof IncomingLocationMessageViewHolder holderInstance) {
holderInstance.assignCommonMessageInterface(chatActivity);
} else if (holder instanceof OutcomingLocationMessageViewHolder holderInstance) {
holderInstance.assignCommonMessageInterface(chatActivity);
holderInstance.adjustIfNoteToSelf(chatActivity.getCurrentConversation());
} else if (holder instanceof IncomingLinkPreviewMessageViewHolder holderInstance) {
holderInstance.assignCommonMessageInterface(chatActivity);
} else if (holder instanceof OutcomingLinkPreviewMessageViewHolder holderInstance) {
holderInstance.assignCommonMessageInterface(chatActivity);
holderInstance.adjustIfNoteToSelf(chatActivity.getCurrentConversation());
} else if (holder instanceof IncomingVoiceMessageViewHolder holderInstance) {
holderInstance.assignVoiceMessageInterface(chatActivity);
holderInstance.assignCommonMessageInterface(chatActivity);
} else if (holder instanceof OutcomingVoiceMessageViewHolder holderInstance) {
holderInstance.assignVoiceMessageInterface(chatActivity);
holderInstance.assignCommonMessageInterface(chatActivity);
holderInstance.adjustIfNoteToSelf(chatActivity.getCurrentConversation());
} else if (holder instanceof PreviewMessageViewHolder holderInstance) {
holderInstance.assignPreviewMessageInterface(chatActivity);
holderInstance.assignCommonMessageInterface(chatActivity);
} else if (holder instanceof SystemMessageViewHolder holderInstance) {
holderInstance.assignSystemMessageInterface(chatActivity);
} else if (holder instanceof IncomingDeckCardViewHolder holderInstance) {
holderInstance.assignCommonMessageInterface(chatActivity);
} else if (holder instanceof OutcomingDeckCardViewHolder holderInstance) {
holderInstance.assignCommonMessageInterface(chatActivity);
holderInstance.adjustIfNoteToSelf(chatActivity.getCurrentConversation());
} else if (holder instanceof IncomingPollMessageViewHolder holderInstance) {
holderInstance.assignCommonMessageInterface(chatActivity);
} else if (holder instanceof OutcomingPollMessageViewHolder holderInstance) {
holderInstance.assignCommonMessageInterface(chatActivity);
holderInstance.adjustIfNoteToSelf(chatActivity.getCurrentConversation());
}
super.onBindViewHolder(holder, position);
}
}
@@ -0,0 +1,45 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2022 Marcel Hibbe <dev@mhibbe.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.adapters.messages
import android.view.View
import ru.f7cloud.talk.R
import ru.f7cloud.talk.chat.ChatActivity
import ru.f7cloud.talk.databinding.ReactionsInsideMessageBinding
import ru.f7cloud.talk.chat.data.model.ChatMessage
import ru.f7cloud.talk.databinding.ItemThreadTitleBinding
class Thread {
fun showThreadPreview(
chatActivity: ChatActivity,
message: ChatMessage,
threadBinding: ItemThreadTitleBinding,
reactionsBinding: ReactionsInsideMessageBinding,
openThread: (message: ChatMessage) -> Unit
) {
val isFirstMessageOfThreadInNormalChat = chatActivity.conversationThreadId == null && message.isThread
if (isFirstMessageOfThreadInNormalChat) {
threadBinding.threadTitleLayout.visibility = View.VISIBLE
threadBinding.threadTitleLayout.findViewById<androidx.emoji2.widget.EmojiTextView>(R.id.threadTitle).text =
message.threadTitle
reactionsBinding.threadButton.visibility = View.VISIBLE
reactionsBinding.threadButton.setContent {
ThreadButtonComposable(
message.threadReplies ?: 0,
onButtonClick = { openThread(message) }
)
}
} else {
threadBinding.threadTitleLayout.visibility = View.GONE
reactionsBinding.threadButton.visibility = View.GONE
}
}
}
@@ -0,0 +1,87 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2025 Marcel Hibbe <dev@mhibbe.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.adapters.messages
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import ru.f7cloud.talk.R
@Composable
fun ThreadButtonComposable(replyAmount: Int = 0, onButtonClick: () -> Unit = {}) {
val replyAmountText = if (replyAmount == 0) {
stringResource(R.string.thread_reply)
} else {
pluralStringResource(
R.plurals.thread_replies,
replyAmount,
replyAmount
)
}
OutlinedButton(
onClick = onButtonClick,
modifier = Modifier
.padding(0.dp)
.height(24.dp),
shape = RoundedCornerShape(12.dp),
border = BorderStroke(1.dp, colorResource(R.color.nc_incoming_text_default)),
contentPadding = PaddingValues(0.dp),
colors = ButtonDefaults.outlinedButtonColors(
containerColor = colorResource(R.color.bg_message_list_incoming_bubble),
contentColor = colorResource(R.color.nc_incoming_text_default)
)
) {
Icon(
painter = painterResource(R.drawable.ic_reply),
contentDescription = stringResource(R.string.open_thread),
modifier = Modifier
.size(20.dp)
.padding(start = 5.dp, end = 2.dp),
tint = colorResource(R.color.nc_incoming_text_default)
)
Text(
text = replyAmountText,
modifier = Modifier
.padding(end = 6.dp)
)
}
}
@Preview
@Composable
fun ThreadButtonPreviewMultipleReplies() {
ThreadButtonComposable(2)
}
@Preview
@Composable
fun ThreadButtonPreviewOneReply() {
ThreadButtonComposable(1)
}
@Preview
@Composable
fun ThreadButtonPreviewZeroReplies() {
ThreadButtonComposable(0)
}
@@ -0,0 +1,37 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2022 Marcel Hibbe <dev@mhibbe.de>
* SPDX-FileCopyrightText: 2017-2019 Mario Danic <mario@lovelyhq.com>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.adapters.messages;
import android.view.View;
import ru.f7cloud.talk.chat.data.model.ChatMessage;
import com.stfalcon.chatkit.messages.MessageHolders;
public class UnreadNoticeMessageViewHolder extends MessageHolders.SystemMessageViewHolder<ChatMessage> {
public UnreadNoticeMessageViewHolder(View itemView) {
super(itemView);
}
public UnreadNoticeMessageViewHolder(View itemView, Object payload) {
super(itemView, payload);
}
@Override
public void viewDetached() {
}
@Override
public void viewAttached() {
}
@Override
public void viewRecycled() {
}
}
@@ -0,0 +1,16 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2024 Christian Reiner <foss@christian-reiner.info>
* SPDX-FileCopyrightText: 2021 Marcel Hibbe <dev@mhibbe.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.adapters.messages
import ru.f7cloud.talk.chat.data.model.ChatMessage
import ru.f7cloud.talk.ui.PlaybackSpeed
interface VoiceMessageInterface {
fun updateMediaPlayerProgressBySlider(message: ChatMessage, progress: Int)
fun registerMessageToObservePlaybackSpeedPreferences(userId: String, listener: (speed: PlaybackSpeed) -> Unit)
}
@@ -0,0 +1,643 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2021 Marcel Hibbe <dev@mhibbe.de>
* SPDX-FileCopyrightText: 2021 Tim Krüger <t@timkrueger.me>
* SPDX-FileCopyrightText: 2017 Mario Danic <mario@lovelyhq.com>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.api;
import ru.f7cloud.talk.models.json.capabilities.CapabilitiesOverall;
import ru.f7cloud.talk.models.json.capabilities.RoomCapabilitiesOverall;
import ru.f7cloud.talk.models.json.chat.ChatOverall;
import ru.f7cloud.talk.models.json.chat.ChatOverallSingleMessage;
import ru.f7cloud.talk.models.json.chat.ChatShareOverall;
import ru.f7cloud.talk.models.json.chat.ChatShareOverviewOverall;
import ru.f7cloud.talk.models.json.conversations.RoomOverall;
import ru.f7cloud.talk.models.json.conversations.RoomsOverall;
import ru.f7cloud.talk.models.json.generic.GenericOverall;
import ru.f7cloud.talk.models.json.generic.Status;
import ru.f7cloud.talk.models.json.hovercard.HoverCardOverall;
import ru.f7cloud.talk.models.json.invitation.InvitationOverall;
import ru.f7cloud.talk.models.json.mention.MentionOverall;
import ru.f7cloud.talk.models.json.notifications.NotificationOverall;
import ru.f7cloud.talk.models.json.opengraph.OpenGraphOverall;
import ru.f7cloud.talk.models.json.participants.AddParticipantOverall;
import ru.f7cloud.talk.models.json.participants.ParticipantsOverall;
import ru.f7cloud.talk.models.json.participants.TalkBanOverall;
import ru.f7cloud.talk.models.json.push.PushRegistrationOverall;
import ru.f7cloud.talk.models.json.reactions.ReactionsOverall;
import ru.f7cloud.talk.models.json.reminder.ReminderOverall;
import ru.f7cloud.talk.models.json.search.ContactsByNumberOverall;
import ru.f7cloud.talk.models.json.signaling.SignalingOverall;
import ru.f7cloud.talk.models.json.signaling.settings.SignalingSettingsOverall;
import ru.f7cloud.talk.models.json.status.StatusOverall;
import ru.f7cloud.talk.models.json.unifiedsearch.UnifiedSearchOverall;
import ru.f7cloud.talk.models.json.userprofile.UserProfileFieldsOverall;
import ru.f7cloud.talk.models.json.userprofile.UserProfileOverall;
import ru.f7cloud.talk.polls.repositories.model.PollOverall;
import ru.f7cloud.talk.translate.repositories.model.LanguagesOverall;
import ru.f7cloud.talk.translate.repositories.model.TranslationsOverall;
import java.util.List;
import java.util.Map;
import androidx.annotation.Nullable;
import io.reactivex.Observable;
import kotlin.Unit;
import okhttp3.MultipartBody;
import okhttp3.RequestBody;
import okhttp3.ResponseBody;
import retrofit2.Call;
import retrofit2.Response;
import retrofit2.http.Body;
import retrofit2.http.DELETE;
import retrofit2.http.Field;
import retrofit2.http.FieldMap;
import retrofit2.http.FormUrlEncoded;
import retrofit2.http.GET;
import retrofit2.http.HEAD;
import retrofit2.http.Header;
import retrofit2.http.Multipart;
import retrofit2.http.POST;
import retrofit2.http.PUT;
import retrofit2.http.Part;
import retrofit2.http.Query;
import retrofit2.http.QueryMap;
import retrofit2.http.Url;
public interface NcApi {
/*
QueryMap items are as follows:
- "format" : "json"
- "search" : ""
- "perPage" : "200"
- "itemType" : "call"
Server URL is: baseUrl + ocsApiVersion + /apps/files_sharing/api/v1/sharees
or if we're on 14 and up:
baseUrl + ocsApiVersion + "/core/autocomplete/get");
*/
@GET
Observable<ResponseBody> getContactsWithSearchParam(@Header("Authorization") String authorization,
@Url String url,
@Nullable @Query("shareTypes[]") List<String> listOfShareTypes,
@QueryMap Map<String, Object> options);
/*
Server URL is: baseUrl + ocsApiVersion + spreedApiVersion + /room
*/
@GET
Observable<RoomsOverall> getRooms(@Header("Authorization") String authorization,
@Url String url,
@Nullable @Query("includeStatus") Boolean includeStatus);
/*
Server URL is: baseUrl + ocsApiVersion + spreedApiVersion + /room/roomToken
*/
@GET
Observable<RoomOverall> getRoom(@Header("Authorization") String authorization, @Url String url);
/*
QueryMap items are as follows:
- "roomType" : ""
- "invite" : ""
Server URL is: baseUrl + ocsApiVersion + spreedApiVersion + /room
*/
@POST
Observable<RoomOverall> createRoom(@Header("Authorization") String authorization,
@Url String url,
@QueryMap Map<String, String> options);
/*
QueryMap items are as follows:
- "newParticipant" : "user"
Server URL is: baseUrl + ocsApiVersion + spreedApiVersion + /room/roomToken/participants
*/
@POST
Observable<AddParticipantOverall> addParticipant(@Header("Authorization") String authorization,
@Url String url,
@QueryMap Map<String,
String> options);
@POST
Observable<GenericOverall> resendParticipantInvitations(@Header("Authorization") String authorization,
@Url String url);
// also used for removing a guest from a conversation
@Deprecated
@DELETE
Observable<GenericOverall> removeParticipantFromConversation(@Header("Authorization") String authorization,
@Url String url,
@Query("participant") String participantId);
@DELETE
Observable<GenericOverall> removeAttendeeFromConversation(@Header("Authorization") String authorization,
@Url String url,
@Query("attendeeId") Long attendeeId);
@Deprecated
@POST
Observable<GenericOverall> promoteUserToModerator(@Header("Authorization") String authorization,
@Url String url,
@Query("participant") String participantId);
@Deprecated
@DELETE
Observable<GenericOverall> demoteModeratorToUser(@Header("Authorization") String authorization,
@Url String url,
@Query("participant") String participantId);
@POST
Observable<GenericOverall> promoteAttendeeToModerator(@Header("Authorization") String authorization,
@Url String url,
@Query("attendeeId") Long attendeeId);
@DELETE
Observable<GenericOverall> demoteAttendeeFromModerator(@Header("Authorization") String authorization,
@Url String url,
@Query("attendeeId") Long attendeeId);
/*
Server URL is: baseUrl + ocsApiVersion + spreedApiVersion + /room/roomToken/participants/self
*/
@DELETE
Observable<GenericOverall> removeSelfFromRoom(@Header("Authorization") String authorization, @Url String url);
@DELETE
Observable<GenericOverall> deleteRoom(@Header("Authorization") String authorization, @Url String url);
/*
Server URL is: baseUrl + ocsApiVersion + spreedApiVersion + /call/callToken
*/
@GET
Observable<ParticipantsOverall> getPeersForCall(@Header("Authorization") String authorization, @Url String url);
@GET
Observable<ParticipantsOverall> getPeersForCall(@Header("Authorization") String authorization,
@Url String url,
@QueryMap Map<String, Boolean> fields);
@FormUrlEncoded
@POST
Observable<RoomOverall> joinRoom(@Nullable @Header("Authorization") String authorization,
@Url String url,
@Nullable @Field("password") String password);
@DELETE
Observable<GenericOverall> leaveRoom(@Nullable @Header("Authorization") String authorization, @Url String url);
/*
Server URL is: baseUrl + ocsApiVersion + spreedApiVersion + /call/callToken
*/
@FormUrlEncoded
@POST
Observable<GenericOverall> joinCall(@Nullable @Header("Authorization") String authorization,
@Url String url,
@Field("flags") Integer inCall,
@Field("silent") Boolean callWithoutNotification,
@Nullable @Field("recordingConsent") Boolean recordingConsent);
/*
Server URL is: baseUrl + ocsApiVersion + spreedApiVersion + /call/callToken
*/
@DELETE
Observable<GenericOverall> leaveCall(@Nullable @Header("Authorization") String authorization, @Url String url,
@Nullable @Query("all") Boolean all);
@GET
Observable<SignalingSettingsOverall> getSignalingSettings(@Nullable @Header("Authorization") String authorization,
@Url String url);
/*
QueryMap items are as follows:
- "messages" : "message"
Server URL is: baseUrl + ocsApiVersion + spreedApiVersion + /signaling
*/
@FormUrlEncoded
@POST
Observable<SignalingOverall> sendSignalingMessages(@Nullable @Header("Authorization") String authorization,
@Url String url,
@Field("messages") String messages);
/*
Server URL is: baseUrl + ocsApiVersion + spreedApiVersion + /signaling
*/
@GET
Observable<SignalingOverall> pullSignalingMessages(@Nullable @Header("Authorization") String authorization,
@Url String url);
/*
QueryMap items are as follows:
- "format" : "json"
Server URL is: baseUrl + ocsApiVersion + "/cloud/user"
*/
@GET
Observable<UserProfileOverall> getUserProfile(@Header("Authorization") String authorization, @Url String url);
@GET
Observable<UserProfileOverall> getUserData(@Header("Authorization") String authorization, @Url String url);
@DELETE
Observable<GenericOverall> revertStatus(@Header("Authentication") String authorization, @Url String url);
@FormUrlEncoded
@PUT
Observable<GenericOverall> setUserData(@Header("Authorization") String authorization,
@Url String url,
@Field("key") String key,
@Field("value") String value);
/*
Server URL is: baseUrl + /status.php
*/
@GET
Observable<Status> getServerStatus(@Url String url);
/*
QueryMap items are as follows:
- "format" : "json"
- "pushTokenHash" : ""
- "devicePublicKey" : ""
- "proxyServer" : ""
Server URL is: baseUrl + ocsApiVersion + "/apps/notifications/api/v2/push
*/
@FormUrlEncoded
@POST
Observable<PushRegistrationOverall> registerDeviceForNotificationsWithF7cloud(
@Header("Authorization") String authorization,
@Url String url,
@FieldMap Map<String, String> options);
@DELETE
Observable<GenericOverall> unregisterDeviceForNotificationsWithF7cloud(
@Header("Authorization") String authorization,
@Url String url);
@FormUrlEncoded
@POST
Observable<Unit> registerDeviceForNotificationsWithPushProxy(@Url String url,
@FieldMap Map<String, String> fields);
/*
QueryMap items are as follows:
- "deviceIdentifier": "{{deviceIdentifier}}",
- "deviceIdentifierSignature": "{{signature}}",
- "userPublicKey": "{{userPublicKey}}"
*/
@DELETE
Observable<Void> unregisterDeviceForNotificationsWithProxy(@Url String url,
@QueryMap Map<String, String> fields);
@FormUrlEncoded
@PUT
Observable<Response<GenericOverall>> setPassword2(@Header("Authorization") String authorization,
@Url String url,
@Field("password") String password);
@GET
Observable<CapabilitiesOverall> getCapabilities(@Header("Authorization") String authorization, @Url String url);
@GET
Observable<CapabilitiesOverall> getCapabilities(@Url String url);
@GET
Observable<RoomCapabilitiesOverall> getRoomCapabilities(@Header("Authorization") String authorization,
@Url String url);
/*
QueryMap items are as follows:
- "lookIntoFuture": int (0 or 1),
- "limit" : int, range 100-200,
- "timeout": used with look into future, 30 default, 60 at most
- "lastKnownMessageId", int, use one from X-Chat-Last-Given
*/
@GET
Observable<Response<ChatOverall>> pullChatMessages(@Header("Authorization") String authorization,
@Url String url,
@QueryMap Map<String, Integer> fields);
/*
Fieldmap items are as follows:
- "message": ,
- "actorDisplayName"
*/
@FormUrlEncoded
@POST
Observable<ChatOverallSingleMessage> sendChatMessage(@Header("Authorization") String authorization,
@Url String url,
@Field("message") CharSequence message,
@Field("actorDisplayName") String actorDisplayName,
@Field("replyTo") Integer replyTo,
@Field("silent") Boolean sendWithoutNotification,
@Field("referenceId") String referenceId
);
@GET
Observable<Response<ChatShareOverall>> getSharedItems(
@Header("Authorization") String authorization,
@Url String url,
@Query("objectType") String objectType,
@Nullable @Query("lastKnownMessageId") Integer lastKnownMessageId,
@Nullable @Query("limit") Integer limit);
@GET
Observable<Response<ChatShareOverviewOverall>> getSharedItemsOverview(@Header("Authorization") String authorization,
@Url String url,
@Nullable @Query("limit") Integer limit);
@GET
Observable<MentionOverall> getMentionAutocompleteSuggestions(@Header("Authorization") String authorization,
@Url String url,
@Query("search") String query,
@Nullable @Query("limit") Integer limit,
@QueryMap Map<String, String> fields);
@GET
Observable<NotificationOverall> getNcNotification(@Header("Authorization") String authorization,
@Url String url);
@FormUrlEncoded
@POST
Observable<GenericOverall> setNotificationLevel(@Header("Authorization") String authorization,
@Url String url,
@Field("level") int level);
@FormUrlEncoded
@PUT
Observable<GenericOverall> setConversationReadOnly(@Header("Authorization") String authorization,
@Url String url,
@Field("state") int state);
@FormUrlEncoded
@POST
Observable<GenericOverall> createRemoteShare(@Nullable @Header("Authorization") String authorization,
@Url String url,
@Field("path") String remotePath,
@Field("shareWith") String roomToken,
@Field("shareType") String shareType,
@Field("talkMetaData") String talkMetaData);
@FormUrlEncoded
@PUT
Observable<GenericOverall> setLobbyForConversation(@Header("Authorization") String authorization,
@Url String url,
@Field("state") Integer state,
@Field("timer") Long timer);
@POST
Observable<ContactsByNumberOverall> searchContactsByPhoneNumber(@Header("Authorization") String authorization,
@Url String url,
@Body RequestBody search);
@PUT
Observable<Response<GenericOverall>> uploadFile(@Header("Authorization") String authorization,
@Url String url,
@Body RequestBody body);
@HEAD
Observable<Response<Void>> checkIfFileExists(@Header("Authorization") String authorization,
@Url String url);
@GET
Call<ResponseBody> downloadFile(@Header("Authorization") String authorization,
@Url String url);
@DELETE
Observable<ChatOverallSingleMessage> deleteChatMessage(@Header("Authorization") String authorization,
@Url String url);
@DELETE
Observable<GenericOverall> deleteAvatar(@Header("Authorization") String authorization, @Url String url);
@DELETE
Observable<RoomOverall> deleteConversationAvatar(@Header("Authorization") String authorization, @Url String url);
@Multipart
@POST
Observable<GenericOverall> uploadAvatar(@Header("Authorization") String authorization,
@Url String url,
@Part MultipartBody.Part attachment);
@Multipart
@POST
Observable<RoomOverall> uploadConversationAvatar(@Header("Authorization") String authorization,
@Url String url,
@Part MultipartBody.Part attachment);
@GET
Observable<UserProfileFieldsOverall> getEditableUserProfileFields(@Header("Authorization") String authorization,
@Url String url);
@GET
Call<ResponseBody> downloadResizedImage(@Header("Authorization") String authorization,
@Url String url);
@FormUrlEncoded
@POST
Observable<GenericOverall> sendLocation(@Header("Authorization") String authorization,
@Url String url,
@Field("objectType") String objectType,
@Field("objectId") String objectId,
@Field("metaData") String metaData);
@FormUrlEncoded
@POST
Observable<GenericOverall> notificationCalls(@Header("Authorization") String authorization,
@Url String url,
@Field("level") Integer level);
@DELETE
Observable<GenericOverall> clearChatHistory(@Header("Authorization") String authorization, @Url String url);
@GET
Observable<HoverCardOverall> hoverCard(@Header("Authorization") String authorization, @Url String url);
// Url is: /api/{apiVersion}/chat/{token}/read
@FormUrlEncoded
@POST
Observable<GenericOverall> setChatReadMarker(@Header("Authorization") String authorization,
@Url String url,
@Nullable @Field("lastReadMessage") Integer lastReadMessage);
// Url is: /api/{apiVersion}/chat/{token}/read
@DELETE
Observable<GenericOverall> markRoomAsUnread(@Header("Authorization") String authorization, @Url String url);
@GET
Observable<StatusOverall> status(@Header("Authorization") String authorization, @Url String url);
@GET
Observable<StatusOverall> backupStatus(@Header("Authorization") String authorization, @Url String url);
@GET
Observable<ResponseBody> getPredefinedStatuses(@Header("Authorization") String authorization, @Url String url);
@DELETE
Observable<GenericOverall> statusDeleteMessage(@Header("Authorization") String authorization, @Url String url);
@FormUrlEncoded
@PUT
Observable<GenericOverall> setPredefinedStatusMessage(@Header("Authorization") String authorization,
@Url String url,
@Field("messageId") String selectedPredefinedMessageId,
@Field("clearAt") Long clearAt);
@FormUrlEncoded
@PUT
Observable<GenericOverall> setCustomStatusMessage(@Header("Authorization") String authorization,
@Url String url,
@Field("statusIcon") String statusIcon,
@Field("message") String message,
@Field("clearAt") Long clearAt);
@FormUrlEncoded
@PUT
Observable<GenericOverall> setStatusType(@Header("Authorization") String authorization,
@Url String url,
@Field("statusType") String statusType);
@POST
Observable<GenericOverall> sendReaction(@Header("Authorization") String authorization,
@Url String url,
@Query("reaction") String reaction);
@DELETE
Observable<GenericOverall> deleteReaction(@Header("Authorization") String authorization,
@Url String url,
@Query("reaction") String reaction);
@GET
Observable<ReactionsOverall> getReactions(@Header("Authorization") String authorization,
@Url String url,
@Query("reaction") String reaction);
@GET
Observable<UnifiedSearchOverall> performUnifiedSearch(@Header("Authorization") String authorization,
@Url String url,
@Query("term") String term,
@Query("from") String fromUrl,
@Query("limit") Integer limit,
@Query("cursor") Integer cursor);
@GET
Observable<PollOverall> getPoll(@Header("Authorization") String authorization,
@Url String url);
@FormUrlEncoded
@POST
Observable<PollOverall> createPoll(@Header("Authorization") String authorization,
@Url String url,
@Query("question") String question,
@Field("options[]") List<String> options,
@Query("resultMode") Integer resultMode,
@Query("maxVotes") Integer maxVotes);
@FormUrlEncoded
@POST
Observable<PollOverall> votePoll(@Header("Authorization") String authorization,
@Url String url,
@Field("optionIds[]") List<Integer> optionIds);
@DELETE
Observable<PollOverall> closePoll(@Header("Authorization") String authorization,
@Url String url);
@GET
Observable<OpenGraphOverall> getOpenGraph(@Header("Authorization") String authorization,
@Url String url,
@Query("reference") String urlToFindPreviewFor);
@FormUrlEncoded
@POST
Observable<GenericOverall> startRecording(@Header("Authorization") String authorization,
@Url String url,
@Field("status") Integer status);
@DELETE
Observable<GenericOverall> stopRecording(@Header("Authorization") String authorization,
@Url String url);
@POST
Observable<GenericOverall> requestAssistance(@Header("Authorization") String authorization,
@Url String url);
@DELETE
Observable<GenericOverall> withdrawRequestAssistance(@Header("Authorization") String authorization,
@Url String url);
@POST
Observable<GenericOverall> sendCommonPostRequest(@Header("Authorization") String authorization, @Url String url);
@DELETE
Observable<GenericOverall> sendCommonDeleteRequest(@Header("Authorization") String authorization, @Url String url);
@POST
Observable<TranslationsOverall> translateMessage(@Header("Authorization") String authorization,
@Url String url,
@Query("text") String text,
@Query("toLanguage") String toLanguage,
@Nullable @Query("fromLanguage") String fromLanguage);
@GET
Observable<LanguagesOverall> getLanguages(@Header("Authorization") String authorization,
@Url String url);
@GET
Observable<ReminderOverall> getReminder(@Header("Authorization") String authorization,
@Url String url);
@DELETE
Observable<GenericOverall> deleteReminder(@Header("Authorization") String authorization,
@Url String url);
@FormUrlEncoded
@POST
Observable<ReminderOverall> setReminder(@Header("Authorization") String authorization,
@Url String url,
@Field("timestamp") int timestamp);
@FormUrlEncoded
@PUT
Observable<GenericOverall> setRecordingConsent(@Header("Authorization") String authorization,
@Url String url,
@Field("recordingConsent") int recordingConsent);
@GET
Observable<InvitationOverall> getInvitations(@Header("Authorization") String authorization,
@Url String url);
@POST
Observable<GenericOverall> acceptInvitation(@Header("Authorization") String authorization,
@Url String url);
@DELETE
Observable<GenericOverall> rejectInvitation(@Header("Authorization") String authorization,
@Url String url);
}
@@ -0,0 +1,370 @@
/*
* F7cloud 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 ru.f7cloud.talk.api
import ru.f7cloud.talk.conversationinfo.CreateRoomRequest
import ru.f7cloud.talk.models.json.autocomplete.AutocompleteOverall
import ru.f7cloud.talk.models.json.chat.ChatOverall
import ru.f7cloud.talk.models.json.chat.ChatOverallSingleMessage
import ru.f7cloud.talk.models.json.conversations.RoomOverall
import ru.f7cloud.talk.models.json.conversations.RoomsOverall
import ru.f7cloud.talk.models.json.generic.GenericOverall
import ru.f7cloud.talk.models.json.invitation.InvitationOverall
import ru.f7cloud.talk.models.json.participants.AddParticipantOverall
import ru.f7cloud.talk.models.json.participants.TalkBan
import ru.f7cloud.talk.models.json.participants.TalkBanOverall
import ru.f7cloud.talk.models.json.profile.ProfileOverall
import ru.f7cloud.talk.models.json.status.StatusOverall
import ru.f7cloud.talk.models.json.testNotification.TestNotificationOverall
import ru.f7cloud.talk.models.json.threads.ThreadOverall
import ru.f7cloud.talk.models.json.threads.ThreadsOverall
import ru.f7cloud.talk.models.json.userAbsence.UserAbsenceOverall
import okhttp3.MultipartBody
import okhttp3.RequestBody
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.Field
import retrofit2.http.FormUrlEncoded
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.Multipart
import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Part
import retrofit2.http.Query
import retrofit2.http.QueryMap
import retrofit2.http.Url
@Suppress("TooManyFunctions")
interface NcApiCoroutines {
@GET
@JvmSuppressWildcards
suspend fun getContactsWithSearchParam(
@Header("Authorization") authorization: String?,
@Url url: String?,
@Query("shareTypes[]") listOfShareTypes: List<String>?,
@QueryMap options: Map<String, Any>?
): AutocompleteOverall
/*
QueryMap items are as follows:
- "roomType" : ""
- "invite" : ""
Server URL is: baseUrl + ocsApiVersion + spreedApiVersion + /room
*/
@POST
suspend fun createRoom(
@Header("Authorization") authorization: String?,
@Url url: String?,
@QueryMap options: Map<String, String>?
): RoomOverall
@POST
suspend fun createRoomWithBody(
@Header("Authorization") authorization: String?,
@Url url: String?,
@Body roomRequest: CreateRoomRequest
): RoomOverall
/*
QueryMap items are as follows:
- "roomName" : "newName"
Server URL is: baseUrl + ocsApiVersion + spreedApiVersion + /room/roomToken
*/
@FormUrlEncoded
@PUT
suspend fun renameRoom(
@Header("Authorization") authorization: String?,
@Url url: String,
@Field("roomName") roomName: String?
): GenericOverall
@FormUrlEncoded
@PUT
suspend fun openConversation(
@Header("Authorization") authorization: String?,
@Url url: String,
@Field("scope") scope: Int
): GenericOverall
@FormUrlEncoded
@PUT
suspend fun setConversationDescription(
@Header("Authorization") authorization: String?,
@Url url: String,
@Field("description") description: String?
): GenericOverall
@POST
suspend fun addParticipant(
@Header("Authorization") authorization: String?,
@Url url: String?,
@QueryMap options: Map<String, String>?
): AddParticipantOverall
@POST
suspend fun makeRoomPublic(@Header("Authorization") authorization: String, @Url url: String): GenericOverall
@DELETE
suspend fun makeRoomPrivate(@Header("Authorization") authorization: String, @Url url: String): GenericOverall
@FormUrlEncoded
@PUT
suspend fun setPassword(
@Header("Authorization") authorization: String?,
@Url url: String?,
@Field("password") password: String?
): GenericOverall
@Multipart
@POST
suspend fun uploadConversationAvatar(
@Header("Authorization") authorization: String,
@Url url: String,
@Part attachment: MultipartBody.Part
): RoomOverall
@DELETE
suspend fun deleteConversationAvatar(@Header("Authorization") authorization: String, @Url url: String): RoomOverall
@POST
suspend fun archiveConversation(@Header("Authorization") authorization: String, @Url url: String): GenericOverall
@DELETE
suspend fun unarchiveConversation(@Header("Authorization") authorization: String, @Url url: String): GenericOverall
@Suppress("LongParameterList")
@FormUrlEncoded
@POST
suspend fun sendChatMessage(
@Header("Authorization") authorization: String,
@Url url: String,
@Field("message") message: String,
@Field("actorDisplayName") actorDisplayName: String,
@Field("replyTo") replyTo: Int,
@Field("silent") sendWithoutNotification: Boolean,
@Field("referenceId") referenceId: String,
@Field("threadTitle") threadTitle: String?
): ChatOverallSingleMessage
@FormUrlEncoded
@PUT
suspend fun editChatMessage(
@Header("Authorization") authorization: String,
@Url url: String,
@Field("message") message: String
): ChatOverallSingleMessage
@FormUrlEncoded
@POST
suspend fun banActor(
@Header("Authorization") authorization: String,
@Url url: String,
@Field("actorType") actorType: String,
@Field("actorId") actorId: String,
@Field("internalNote") internalNote: String
): TalkBan
@GET
suspend fun listBans(@Header("Authorization") authorization: String, @Url url: String): TalkBanOverall
@DELETE
suspend fun unbanActor(@Header("Authorization") authorization: String, @Url url: String): GenericOverall
@POST
suspend fun addConversationToFavorites(
@Header("Authorization") authorization: String,
@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,
@Url url: String
): GenericOverall
@POST
suspend fun markConversationAsSensitive(
@Header("Authorization") authorization: String,
@Url url: String
): GenericOverall
@DELETE
suspend fun markConversationAsInsensitive(
@Header("Authorization") authorization: String,
@Url url: String
): GenericOverall
@FormUrlEncoded
@POST
suspend fun notificationCalls(
@Header("Authorization") authorization: String,
@Url url: String,
@Field("level") level: Int
): GenericOverall
@POST
suspend fun setReadStatusPrivacy(
@Header("Authorization") authorization: String,
@Url url: String,
@Body body: RequestBody
): GenericOverall
@POST
suspend fun setTypingStatusPrivacy(
@Header("Authorization") authorization: String,
@Url url: String,
@Body body: RequestBody
): GenericOverall
@DELETE
suspend fun clearChatHistory(@Header("Authorization") authorization: String, @Url url: String): GenericOverall
@FormUrlEncoded
@PUT
suspend fun setConversationReadOnly(
@Header("Authorization") authorization: String,
@Url url: String,
@Field("state") state: Int
): GenericOverall
@FormUrlEncoded
@POST
suspend fun setNotificationLevel(
@Header("Authorization") authorization: String,
@Url url: String,
@Field("level") level: Int
): GenericOverall
@FormUrlEncoded
@POST
suspend fun setMessageExpiration(
@Header("Authorization") authorization: String,
@Url url: String,
@Field("seconds") seconds: Int
): GenericOverall
@GET
suspend fun getOutOfOfficeStatusForUser(
@Header("Authorization") authorization: String,
@Url url: String
): UserAbsenceOverall
@POST
suspend fun testPushNotifications(
@Header("Authorization") authorization: String,
@Url url: String
): TestNotificationOverall
@GET
suspend fun getContextOfChatMessage(
@Header("Authorization") authorization: String,
@Url url: String,
@Query("limit") limit: Int,
@Query("threadId") threadId: Int?
): ChatOverall
@GET
suspend fun getNoteToSelfRoom(@Header("Authorization") authorization: String, @Url url: String): RoomOverall
@GET
suspend fun getProfile(@Header("Authorization") authorization: String, @Url url: String): ProfileOverall
@DELETE
suspend fun unbindRoom(@Header("Authorization") authorization: String, @Url url: String): GenericOverall
@GET
suspend fun getThreads(
@Header("Authorization") authorization: String,
@Url url: String,
@Query("limit") limit: Int?
): ThreadsOverall
@GET
suspend fun getThread(@Header("Authorization") authorization: String, @Url url: String): ThreadOverall
@FormUrlEncoded
@POST
suspend fun setThreadNotificationLevel(
@Header("Authorization") authorization: String,
@Url url: String,
@Field("level") level: Int
): ThreadOverall
@GET
suspend fun getOpenConversations(
@Header("Authorization") authorization: String,
@Url url: String,
@Query("searchTerm") searchTerm: String?
): RoomsOverall
@GET
suspend fun getInvitations(@Header("Authorization") authorization: String, @Url url: String): InvitationOverall
@GET
suspend fun status(@Header("Authorization") authorization: String, @Url url: String): StatusOverall
@FormUrlEncoded
@POST
suspend fun pinMessage(
@Header("Authorization") authorization: String,
@Url url: String,
@Field("pinUntil") pinUntil: Int
): ChatOverallSingleMessage
@DELETE
suspend fun unPinMessage(@Header("Authorization") authorization: String, @Url url: String): ChatOverallSingleMessage
@DELETE
suspend fun hidePinnedMessage(@Header("Authorization") authorization: String, @Url url: String): GenericOverall
@FormUrlEncoded
@POST
@Suppress("LongParameterList")
suspend fun sendScheduleChatMessage(
@Header("Authorization") authorization: String,
@Url url: String,
@Field("message") message: String,
@Field("replyTo") replyTo: Int?,
@Field("silent") sendWithoutNotification: Boolean,
@Field("threadTitle") threadTitle: String?,
@Field("threadId") threadId: Long?,
@Field("sendAt") sendAt: Int?
): ChatOverallSingleMessage
@FormUrlEncoded
@POST
suspend fun updateScheduledMessage(
@Header("Authorization") authorization: String,
@Url url: String,
@Field("message") message: String,
@Field("sendAt") sendAt: Int?,
@Field("silent") sendWithoutNotification: Boolean
): ChatOverallSingleMessage
@DELETE
suspend fun deleteScheduleMessage(@Header("Authorization") authorization: String, @Url url: String): GenericOverall
@GET
suspend fun getScheduledMessage(@Header("Authorization") authorization: String, @Url url: String): ChatOverall
}
@@ -0,0 +1,275 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2022 Andy Scherzinger <info@andy-scherzinger.de>
* SPDX-FileCopyrightText: 2022 Marcel Hibbe <dev@mhibbe.de>
* SPDX-FileCopyrightText: 2022 Tim Krüger <t@timkrueger.me>
* SPDX-FileCopyrightText: 2017 Mario Danic <mario@lovelyhq.com>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.application
import android.app.Activity
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build.VERSION.SDK_INT
import android.os.Build.VERSION_CODES.P
import android.util.Log
import androidx.appcompat.app.AppCompatDelegate
import androidx.emoji2.bundled.BundledEmojiCompatConfig
import androidx.emoji2.text.EmojiCompat
import androidx.lifecycle.LifecycleObserver
import androidx.multidex.MultiDex
import androidx.multidex.MultiDexApplication
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.OneTimeWorkRequest
import androidx.work.PeriodicWorkRequest
import androidx.work.WorkManager
import autodagger.AutoComponent
import autodagger.AutoInjector
import coil.Coil
import coil.ImageLoader
import coil.decode.GifDecoder
import coil.decode.ImageDecoderDecoder
import coil.decode.SvgDecoder
import coil.memory.MemoryCache
import coil.util.DebugLogger
import ru.f7cloud.talk.BuildConfig
import ru.f7cloud.talk.account.AccountVerificationActivity
import ru.f7cloud.talk.account.BrowserLoginActivity
import ru.f7cloud.talk.dagger.modules.BusModule
import ru.f7cloud.talk.dagger.modules.ContextModule
import ru.f7cloud.talk.dagger.modules.DaosModule
import ru.f7cloud.talk.dagger.modules.DatabaseModule
import ru.f7cloud.talk.dagger.modules.ManagerModule
import ru.f7cloud.talk.dagger.modules.RepositoryModule
import ru.f7cloud.talk.dagger.modules.RestModule
import ru.f7cloud.talk.dagger.modules.UtilsModule
import ru.f7cloud.talk.dagger.modules.ViewModelModule
import ru.f7cloud.talk.filebrowser.webdav.DavUtils
import ru.f7cloud.talk.jobs.AccountRemovalWorker
import ru.f7cloud.talk.jobs.CapabilitiesWorker
import ru.f7cloud.talk.jobs.SignalingSettingsWorker
import ru.f7cloud.talk.jobs.WebsocketConnectionsWorker
import ru.f7cloud.talk.ui.theme.ThemeModule
import ru.f7cloud.talk.utils.ClosedInterfaceImpl
import ru.f7cloud.talk.utils.DeviceUtils
import ru.f7cloud.talk.utils.NotificationUtils
import ru.f7cloud.talk.utils.database.arbitrarystorage.ArbitraryStorageModule
import ru.f7cloud.talk.utils.database.user.UserModule
import ru.f7cloud.talk.utils.preferences.AppPreferences
import com.vanniktech.emoji.EmojiManager
import com.vanniktech.emoji.google.GoogleEmojiProvider
import de.cotech.hw.SecurityKeyManager
import de.cotech.hw.SecurityKeyManagerConfig
import okhttp3.OkHttpClient
import org.conscrypt.Conscrypt
import org.webrtc.PeerConnectionFactory
import java.security.Security
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
@AutoComponent(
modules = [
BusModule::class,
ContextModule::class,
DatabaseModule::class,
RestModule::class,
UserModule::class,
ArbitraryStorageModule::class,
ViewModelModule::class,
RepositoryModule::class,
UtilsModule::class,
ThemeModule::class,
ManagerModule::class,
DaosModule::class
]
)
@Singleton
@AutoInjector(F7cloudTalkApplication::class)
open class F7cloudTalkApplication :
MultiDexApplication(),
LifecycleObserver {
//region Fields (components)
lateinit var componentApplication: F7cloudTalkApplicationComponent
private set
//endregion
//region Getters
@Inject
lateinit var appPreferences: AppPreferences
@Inject
lateinit var okHttpClient: OkHttpClient
//endregion
//region private methods
private fun initializeWebRtc() {
try {
PeerConnectionFactory.initialize(
PeerConnectionFactory.InitializationOptions.builder(this)
.createInitializationOptions()
)
} catch (e: UnsatisfiedLinkError) {
Log.w(TAG, e)
}
}
//endregion
//region Overridden methods
override fun onCreate() {
Log.d(TAG, "onCreate")
sharedApplication = this
val securityKeyManager = SecurityKeyManager.getInstance()
val securityKeyConfigBuilder = SecurityKeyManagerConfig.Builder()
.setEnableDebugLogging(BuildConfig.DEBUG)
try {
val packageManager = packageManager
val activities = packageManager.getPackageInfo(packageName, PackageManager.GET_ACTIVITIES).activities
activities?.forEach { activityInfo ->
try {
val activityClass = Class.forName(activityInfo.name)
if (activityClass != AccountVerificationActivity::class.java &&
activityClass != BrowserLoginActivity::class.java
) {
@Suppress("UNCHECKED_CAST")
securityKeyConfigBuilder.addExcludedActivityClass(activityClass as Class<out Activity>)
}
} catch (exception: ClassNotFoundException) {
Log.e(TAG, "Couldn't disable activity for security key listener", exception)
}
}
} catch (exception: PackageManager.NameNotFoundException) {
Log.e(TAG, "Couldn't disable activities for security key listener", exception)
}
securityKeyManager.init(this, securityKeyConfigBuilder.build())
initializeWebRtc()
buildComponent()
DavUtils.registerCustomFactories()
Security.insertProviderAt(Conscrypt.newProvider(), 1)
componentApplication.inject(this)
Coil.setImageLoader(buildDefaultImageLoader())
setAppTheme(appPreferences.theme)
super.onCreate()
ClosedInterfaceImpl().providerInstallerInstallIfNeededAsync()
DeviceUtils.ignoreSpecialBatteryFeatures()
initWorkers()
val config = BundledEmojiCompatConfig(this)
config.setReplaceAll(true)
val emojiCompat = EmojiCompat.init(config)
EmojiManager.install(GoogleEmojiProvider())
NotificationUtils.registerNotificationChannels(applicationContext, appPreferences)
}
private fun initWorkers() {
val accountRemovalWork = OneTimeWorkRequest.Builder(AccountRemovalWorker::class.java).build()
val capabilitiesUpdateWork = OneTimeWorkRequest.Builder(CapabilitiesWorker::class.java).build()
val signalingSettingsWork = OneTimeWorkRequest.Builder(SignalingSettingsWorker::class.java).build()
val websocketConnectionsWorker = OneTimeWorkRequest.Builder(WebsocketConnectionsWorker::class.java).build()
WorkManager.getInstance(applicationContext)
.beginWith(accountRemovalWork)
.then(capabilitiesUpdateWork)
.then(signalingSettingsWork)
.then(websocketConnectionsWorker)
.enqueue()
val periodicCapabilitiesUpdateWork = PeriodicWorkRequest.Builder(
CapabilitiesWorker::class.java,
HALF_DAY,
TimeUnit.HOURS
).build()
WorkManager.getInstance(applicationContext).enqueueUniquePeriodicWork(
"DailyCapabilitiesUpdateWork",
ExistingPeriodicWorkPolicy.REPLACE,
periodicCapabilitiesUpdateWork
)
}
override fun onTerminate() {
super.onTerminate()
sharedApplication = null
}
//endregion
//region Protected methods
protected fun buildComponent() {
componentApplication = DaggerF7cloudTalkApplicationComponent.builder()
.busModule(BusModule())
.contextModule(ContextModule(applicationContext))
.databaseModule(DatabaseModule())
.restModule(RestModule(applicationContext))
.arbitraryStorageModule(ArbitraryStorageModule())
.build()
}
override fun attachBaseContext(base: Context) {
super.attachBaseContext(base)
MultiDex.install(this)
}
private fun buildDefaultImageLoader(): ImageLoader {
val imageLoaderBuilder = ImageLoader.Builder(applicationContext)
.memoryCache {
// Use 50% of the application's available memory.
MemoryCache.Builder(applicationContext).maxSizePercent(FIFTY_PERCENT).build()
}
.crossfade(true) // Show a short crossfade when loading images from network or disk into an ImageView.
.components {
if (SDK_INT >= P) {
add(ImageDecoderDecoder.Factory())
} else {
add(GifDecoder.Factory())
}
add(SvgDecoder.Factory())
}
if (BuildConfig.DEBUG) {
imageLoaderBuilder.logger(DebugLogger())
}
imageLoaderBuilder.okHttpClient(okHttpClient)
return imageLoaderBuilder.build()
}
companion object {
private val TAG = F7cloudTalkApplication::class.java.simpleName
const val FIFTY_PERCENT = 0.5
const val HALF_DAY: Long = 12
const val CIPHER_V4_MIGRATION: Int = 7
//region Singleton
//endregion
var sharedApplication: F7cloudTalkApplication? = null
protected set
//endregion
//region Setters
fun setAppTheme(theme: String) {
when (theme) {
"night_no" -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)
"night_yes" -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
"battery_saver" -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_AUTO_BATTERY)
else ->
// will be "follow_system" only for now
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
}
}
}
//endregion
}
@@ -0,0 +1,23 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2022 Andy Scherzinger <info@andy-scherzinger.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.arbitrarystorage
import ru.f7cloud.talk.data.storage.ArbitraryStoragesRepository
import ru.f7cloud.talk.data.storage.model.ArbitraryStorage
import io.reactivex.Maybe
class ArbitraryStorageManager(private val arbitraryStoragesRepository: ArbitraryStoragesRepository) {
fun storeStorageSetting(accountIdentifier: Long, key: String, value: String?, objectString: String?) {
arbitraryStoragesRepository.saveArbitraryStorage(ArbitraryStorage(accountIdentifier, key, objectString, value))
}
fun getStorageSetting(accountIdentifier: Long, key: String, objectString: String): Maybe<ArbitraryStorage> =
arbitraryStoragesRepository.getStorageSetting(accountIdentifier, key, objectString)
fun deleteAllEntriesForAccountIdentifier(accountIdentifier: Long): Int =
arbitraryStoragesRepository.deleteArbitraryStorage(accountIdentifier)
}
@@ -0,0 +1,22 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2017-2019 Mario Danic <mario@lovelyhq.com>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.bottomsheet.items
import android.widget.ImageView
import androidx.annotation.DrawableRes
interface ListItemWithImage {
val title: String
fun populateIcon(imageView: ImageView)
}
data class BasicListItemWithImage(@DrawableRes val iconRes: Int, override val title: String) : ListItemWithImage {
override fun populateIcon(imageView: ImageView) {
imageView.setImageResource(iconRes)
}
}

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