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
+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)
}
}
@@ -0,0 +1,63 @@
/*
* 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 androidx.annotation.CheckResult
import androidx.recyclerview.widget.LinearLayoutManager
import com.afollestad.materialdialogs.MaterialDialog
import com.afollestad.materialdialogs.internal.list.DialogAdapter
import com.afollestad.materialdialogs.list.customListAdapter
import com.afollestad.materialdialogs.list.getListAdapter
typealias ListItemListener<IT> =
((dialog: MaterialDialog, index: Int, item: IT) -> Unit)?
@CheckResult
fun <IT : ListItemWithImage> MaterialDialog.listItemsWithImage(
items: List<IT>,
disabledIndices: IntArray? = null,
waitForPositiveButton: Boolean = true,
selection: ListItemListener<IT> = null
): MaterialDialog {
if (getListAdapter() != null) {
return updateListItemsWithImage(
items = items,
disabledIndices = disabledIndices
)
}
val layoutManager = LinearLayoutManager(windowContext)
return customListAdapter(
adapter = ListIconDialogAdapter(
dialog = this,
items = items,
disabledItems = disabledIndices,
waitForPositiveButton = waitForPositiveButton,
selection = selection
),
layoutManager = layoutManager
)
}
fun MaterialDialog.updateListItemsWithImage(
items: List<ListItemWithImage>,
disabledIndices: IntArray? = null
): MaterialDialog {
val adapter = getListAdapter()
check(adapter != null) {
"updateGridItems(...) can't be used before you've created a bottom sheet grid dialog."
}
if (adapter is DialogAdapter<*, *>) {
@Suppress("UNCHECKED_CAST")
(adapter as DialogAdapter<ListItemWithImage, *>).replaceItems(items)
if (disabledIndices != null) {
adapter.disableItems(disabledIndices)
}
}
return this
}
@@ -0,0 +1,131 @@
/*
* 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.annotation.SuppressLint
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import androidx.recyclerview.widget.RecyclerView
import com.afollestad.materialdialogs.MaterialDialog
import com.afollestad.materialdialogs.WhichButton
import com.afollestad.materialdialogs.actions.hasActionButton
import com.afollestad.materialdialogs.actions.hasActionButtons
import com.afollestad.materialdialogs.internal.list.DialogAdapter
import com.afollestad.materialdialogs.internal.rtl.RtlTextView
import com.afollestad.materialdialogs.utils.MDUtil.inflate
import com.afollestad.materialdialogs.utils.MDUtil.maybeSetTextColor
import ru.f7cloud.talk.R
private const val KEY_ACTIVATED_INDEX = "activated_index"
internal class ListItemViewHolder(itemView: View, private val adapter: ListIconDialogAdapter<*>) :
RecyclerView.ViewHolder(itemView),
View.OnClickListener {
init {
itemView.setOnClickListener(this)
}
val iconView: ImageView = itemView.findViewById(R.id.icon)
val titleView: RtlTextView = itemView.findViewById(R.id.title)
override fun onClick(view: View) = adapter.itemClicked(adapterPosition)
}
internal class ListIconDialogAdapter<IT : ListItemWithImage>(
private var dialog: MaterialDialog,
private var items: List<IT>,
disabledItems: IntArray?,
private var waitForPositiveButton: Boolean,
private var selection: ListItemListener<IT>
) : RecyclerView.Adapter<ListItemViewHolder>(),
DialogAdapter<IT, ListItemListener<IT>> {
private var disabledIndices: IntArray = disabledItems ?: IntArray(0)
fun itemClicked(index: Int) {
if (waitForPositiveButton && dialog.hasActionButton(WhichButton.POSITIVE)) {
// Wait for positive action button, mark clicked item as activated so that we can call the
// selection listener when the button is pressed.
val lastActivated = dialog.config[KEY_ACTIVATED_INDEX] as? Int
dialog.config[KEY_ACTIVATED_INDEX] = index
if (lastActivated != null) {
notifyItemChanged(lastActivated)
}
notifyItemChanged(index)
} else {
// Don't wait for action buttons, call listener and dismiss if auto dismiss is applicable
this.selection?.invoke(dialog, index, this.items[index])
if (dialog.autoDismissEnabled && !dialog.hasActionButtons()) {
dialog.dismiss()
}
}
}
@SuppressLint("RestrictedApi")
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ListItemViewHolder {
val listItemView: View = parent.inflate(dialog.windowContext, R.layout.menu_item_sheet)
val viewHolder = ListItemViewHolder(
itemView = listItemView,
adapter = this
)
viewHolder.titleView.maybeSetTextColor(dialog.windowContext, R.attr.md_color_content)
return viewHolder
}
override fun getItemCount() = items.size
override fun onBindViewHolder(holder: ListItemViewHolder, position: Int) {
holder.itemView.isEnabled = !disabledIndices.contains(position)
val currentItem = items[position]
holder.titleView.text = currentItem.title
currentItem.populateIcon(holder.iconView)
val activatedIndex = dialog.config[KEY_ACTIVATED_INDEX] as? Int
holder.itemView.isActivated = activatedIndex != null && activatedIndex == position
if (dialog.bodyFont != null) {
holder.titleView.typeface = dialog.bodyFont
}
}
override fun positiveButtonClicked() {
val activatedIndex = dialog.config[KEY_ACTIVATED_INDEX] as? Int
if (activatedIndex != null) {
selection?.invoke(dialog, activatedIndex, items[activatedIndex])
dialog.config.remove(KEY_ACTIVATED_INDEX)
}
}
override fun replaceItems(items: List<IT>, listener: ListItemListener<IT>) {
this.items = items
if (listener != null) {
this.selection = listener
}
this.notifyDataSetChanged()
}
override fun disableItems(indices: IntArray) {
this.disabledIndices = indices
notifyDataSetChanged()
}
override fun checkItems(indices: IntArray) = Unit
override fun uncheckItems(indices: IntArray) = Unit
override fun toggleItems(indices: IntArray) = Unit
override fun checkAllItems() = Unit
override fun uncheckAllItems() = Unit
override fun toggleAllChecked() = Unit
override fun isItemChecked(index: Int) = false
}
@@ -0,0 +1,154 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2022 Daniel Calviño Sánchez <danxuliu@gmail.com>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.call;
import ru.f7cloud.talk.models.json.participants.Participant;
import ru.f7cloud.talk.signaling.SignalingMessageReceiver;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Helper class to keep track of the participants in a call based on the signaling messages.
* <p>
* The CallParticipantList adds a listener for participant list messages as soon as it is created and starts tracking
* the call participants until destroyed. Notifications about the changes can be received by adding an observer to the
* CallParticipantList; note that no sorting is guaranteed on the participants.
*/
public class CallParticipantList {
private final CallParticipantListNotifier callParticipantListNotifier = new CallParticipantListNotifier();
private final SignalingMessageReceiver signalingMessageReceiver;
public interface Observer {
void onCallParticipantsChanged(Collection<Participant> joined, Collection<Participant> updated,
Collection<Participant> left, Collection<Participant> unchanged);
void onCallEndedForAll();
}
private final SignalingMessageReceiver.ParticipantListMessageListener participantListMessageListener =
new SignalingMessageReceiver.ParticipantListMessageListener() {
private final Map<String, Participant> callParticipants = new HashMap<>();
@Override
public void onUsersInRoom(List<Participant> participants) {
processParticipantList(participants);
}
@Override
public void onParticipantsUpdate(List<Participant> participants) {
processParticipantList(participants);
}
private void processParticipantList(List<Participant> participants) {
Collection<Participant> joined = new ArrayList<>();
Collection<Participant> updated = new ArrayList<>();
Collection<Participant> left = new ArrayList<>();
Collection<Participant> unchanged = new ArrayList<>();
Collection<Participant> knownCallParticipantsNotFound = new ArrayList<>(callParticipants.values());
for (Participant participant : participants) {
String sessionId = participant.getSessionId();
Participant callParticipant = callParticipants.get(sessionId);
boolean knownCallParticipant = callParticipant != null;
if (!knownCallParticipant && participant.getInCall() != Participant.InCallFlags.DISCONNECTED) {
callParticipants.put(sessionId, copyParticipant(participant));
joined.add(copyParticipant(participant));
} else if (knownCallParticipant && participant.getInCall() == Participant.InCallFlags.DISCONNECTED) {
callParticipants.remove(sessionId);
// No need to copy it, as it will be no longer used.
callParticipant.setInCall(Participant.InCallFlags.DISCONNECTED);
left.add(callParticipant);
} else if (knownCallParticipant && callParticipant.getInCall() != participant.getInCall()) {
callParticipant.setInCall(participant.getInCall());
updated.add(copyParticipant(participant));
} else if (knownCallParticipant) {
unchanged.add(copyParticipant(participant));
}
if (knownCallParticipant) {
knownCallParticipantsNotFound.remove(callParticipant);
}
}
for (Participant callParticipant : knownCallParticipantsNotFound) {
callParticipants.remove(callParticipant.getSessionId());
// No need to copy it, as it will be no longer used.
callParticipant.setInCall(Participant.InCallFlags.DISCONNECTED);
}
left.addAll(knownCallParticipantsNotFound);
if (!joined.isEmpty() || !updated.isEmpty() || !left.isEmpty()) {
callParticipantListNotifier.notifyChanged(joined, updated, left, unchanged);
}
}
@Override
public void onAllParticipantsUpdate(long inCall) {
if (inCall != Participant.InCallFlags.DISCONNECTED) {
// Updating all participants is expected to happen only to disconnect them.
return;
}
callParticipantListNotifier.notifyCallEndedForAll();
Collection<Participant> joined = new ArrayList<>();
Collection<Participant> updated = new ArrayList<>();
Collection<Participant> left = new ArrayList<>(callParticipants.size());
Collection<Participant> unchanged = new ArrayList<>();
for (Participant callParticipant : callParticipants.values()) {
// No need to copy it, as it will be no longer used.
callParticipant.setInCall(Participant.InCallFlags.DISCONNECTED);
left.add(callParticipant);
}
callParticipants.clear();
if (!left.isEmpty()) {
callParticipantListNotifier.notifyChanged(joined, updated, left, unchanged);
}
}
private Participant copyParticipant(Participant participant) {
Participant copiedParticipant = new Participant();
copiedParticipant.setActorId(participant.getActorId());
copiedParticipant.setActorType(participant.getActorType());
copiedParticipant.setInCall(participant.getInCall());
copiedParticipant.setInternal(participant.getInternal());
copiedParticipant.setLastPing(participant.getLastPing());
copiedParticipant.setSessionId(participant.getSessionId());
copiedParticipant.setType(participant.getType());
copiedParticipant.setUserId(participant.getUserId());
return copiedParticipant;
}
};
public CallParticipantList(SignalingMessageReceiver signalingMessageReceiver) {
this.signalingMessageReceiver = signalingMessageReceiver;
this.signalingMessageReceiver.addListener(participantListMessageListener);
}
public void destroy() {
signalingMessageReceiver.removeListener(participantListMessageListener);
}
public void addObserver(Observer observer) {
callParticipantListNotifier.addObserver(observer);
}
public void removeObserver(Observer observer) {
callParticipantListNotifier.removeObserver(observer);
}
}
@@ -0,0 +1,50 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2022 Daniel Calviño Sánchez <danxuliu@gmail.com>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.call;
import ru.f7cloud.talk.models.json.participants.Participant;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.Set;
/**
* Helper class to register and notify CallParticipantList.Observers.
* <p>
* This class is only meant for internal use by CallParticipantList; listeners must register themselves against
* a CallParticipantList rather than against a CallParticipantListNotifier.
*/
class CallParticipantListNotifier {
private final Set<CallParticipantList.Observer> callParticipantListObservers = new LinkedHashSet<>();
public synchronized void addObserver(CallParticipantList.Observer observer) {
if (observer == null) {
throw new IllegalArgumentException("CallParticipantList.Observer can not be null");
}
callParticipantListObservers.add(observer);
}
public synchronized void removeObserver(CallParticipantList.Observer observer) {
callParticipantListObservers.remove(observer);
}
public synchronized void notifyChanged(Collection<Participant> joined, Collection<Participant> updated,
Collection<Participant> left, Collection<Participant> unchanged) {
for (CallParticipantList.Observer observer : new ArrayList<>(callParticipantListObservers)) {
observer.onCallParticipantsChanged(joined, updated, left, unchanged);
}
}
public synchronized void notifyCallEndedForAll() {
for (CallParticipantList.Observer observer : new ArrayList<>(callParticipantListObservers)) {
observer.onCallEndedForAll();
}
}
}
@@ -0,0 +1,114 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2024 Daniel Calviño Sánchez <danxuliu@gmail.com>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.call;
import android.os.Handler;
import java.util.Objects;
/**
* Read-only data model for local call participants.
* <p>
* Clients of the model can observe it with LocalCallParticipantModel.Observer to be notified when any value changes.
* Getters called after receiving a notification are guaranteed to provide at least the value that triggered the
* notification, but it may return even a more up to date one (so getting the value again on the following notification
* may return the same value as before).
*/
public class LocalCallParticipantModel {
protected final LocalCallParticipantModelNotifier localCallParticipantModelNotifier =
new LocalCallParticipantModelNotifier();
protected Data<Boolean> audioEnabled;
protected Data<Boolean> speaking;
protected Data<Boolean> speakingWhileMuted;
protected Data<Boolean> videoEnabled;
public interface Observer {
void onChange();
}
protected class Data<T> {
private T value;
public Data() {
}
public Data(T value) {
this.value = value;
}
public T getValue() {
return value;
}
public void setValue(T value) {
if (Objects.equals(this.value, value)) {
return;
}
this.value = value;
localCallParticipantModelNotifier.notifyChange();
}
}
public LocalCallParticipantModel() {
this.audioEnabled = new Data<>(Boolean.FALSE);
this.speaking = new Data<>(Boolean.FALSE);
this.speakingWhileMuted = new Data<>(Boolean.FALSE);
this.videoEnabled = new Data<>(Boolean.FALSE);
}
public Boolean isAudioEnabled() {
return audioEnabled.getValue();
}
public Boolean isSpeaking() {
return speaking.getValue();
}
public Boolean isSpeakingWhileMuted() {
return speakingWhileMuted.getValue();
}
public Boolean isVideoEnabled() {
return videoEnabled.getValue();
}
/**
* Adds an Observer to be notified when any value changes.
*
* @param observer the Observer
* @see LocalCallParticipantModel#addObserver(Observer, Handler)
*/
public void addObserver(Observer observer) {
addObserver(observer, null);
}
/**
* Adds an observer to be notified when any value changes.
* <p>
* The observer will be notified on the thread associated to the given handler. If no handler is given the
* observer will be immediately notified on the same thread that changed the value; the observer will be
* immediately notified too if the thread of the handler is the same thread that changed the value.
* <p>
* An observer is expected to be added only once. If the same observer is added again it will be notified just
* once on the thread of the last handler.
*
* @param observer the Observer
* @param handler a Handler for the thread to be notified on
*/
public void addObserver(Observer observer, Handler handler) {
localCallParticipantModelNotifier.addObserver(observer, handler);
}
public void removeObserver(Observer observer) {
localCallParticipantModelNotifier.removeObserver(observer);
}
}
@@ -0,0 +1,73 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2024 Daniel Calviño Sánchez <danxuliu@gmail.com>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.call;
import android.os.Handler;
import android.os.Looper;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
/**
* Helper class to register and notify LocalCallParticipantModel.Observers.
* <p>
* This class is only meant for internal use by LocalCallParticipantModel; observers must register themselves against a
* LocalCallParticipantModel rather than against a LocalCallParticipantModelNotifier.
*/
class LocalCallParticipantModelNotifier {
private final List<LocalCallParticipantModelObserverOn> localCallParticipantModelObserversOn = new ArrayList<>();
/**
* Helper class to associate a LocalCallParticipantModel.Observer with a Handler.
*/
private static class LocalCallParticipantModelObserverOn {
public final LocalCallParticipantModel.Observer observer;
public final Handler handler;
private LocalCallParticipantModelObserverOn(LocalCallParticipantModel.Observer observer, Handler handler) {
this.observer = observer;
this.handler = handler;
}
}
public synchronized void addObserver(LocalCallParticipantModel.Observer observer, Handler handler) {
if (observer == null) {
throw new IllegalArgumentException("LocalCallParticipantModel.Observer can not be null");
}
removeObserver(observer);
localCallParticipantModelObserversOn.add(new LocalCallParticipantModelObserverOn(observer, handler));
}
public synchronized void removeObserver(LocalCallParticipantModel.Observer observer) {
Iterator<LocalCallParticipantModelObserverOn> it = localCallParticipantModelObserversOn.iterator();
while (it.hasNext()) {
LocalCallParticipantModelObserverOn observerOn = it.next();
if (observerOn.observer == observer) {
it.remove();
return;
}
}
}
public synchronized void notifyChange() {
for (LocalCallParticipantModelObserverOn observerOn : new ArrayList<>(localCallParticipantModelObserversOn)) {
if (observerOn.handler == null || observerOn.handler.getLooper() == Looper.myLooper()) {
observerOn.observer.onChange();
} else {
observerOn.handler.post(() -> {
observerOn.observer.onChange();
});
}
}
}
}
@@ -0,0 +1,171 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2024 Daniel Calviño Sánchez <danxuliu@gmail.com>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.call;
import ru.f7cloud.talk.activities.ParticipantUiState;
import ru.f7cloud.talk.models.json.signaling.DataChannelMessage;
import ru.f7cloud.talk.models.json.signaling.NCMessagePayload;
import ru.f7cloud.talk.models.json.signaling.NCSignalingMessage;
import java.util.Objects;
/**
* Helper class to send the local participant state to the other participants in the call.
* <p>
* Once created, and until destroyed, the LocalStateBroadcaster will send the changes in the local participant state to
* all the participants in the call. Note that the LocalStateBroadcaster does not check whether the local participant
* is actually in the call or not; it is expected that the LocalStateBroadcaster will be created and destroyed when the
* local participant joins and leaves the call.
* <p>
* The LocalStateBroadcaster also sends the current state to remote participants when they join (which implicitly
* sends it to all remote participants when the local participant joins the call) so they can set an initial state
* for the local participant.
*/
public abstract class LocalStateBroadcaster {
private final LocalCallParticipantModel localCallParticipantModel;
private final LocalCallParticipantModelObserver localCallParticipantModelObserver;
private final MessageSender messageSender;
private class LocalCallParticipantModelObserver implements LocalCallParticipantModel.Observer {
private Boolean audioEnabled;
private Boolean speaking;
private Boolean videoEnabled;
public LocalCallParticipantModelObserver(LocalCallParticipantModel localCallParticipantModel) {
audioEnabled = localCallParticipantModel.isAudioEnabled();
speaking = localCallParticipantModel.isSpeaking();
videoEnabled = localCallParticipantModel.isVideoEnabled();
}
@Override
public void onChange() {
if (!Objects.equals(audioEnabled, localCallParticipantModel.isAudioEnabled())) {
audioEnabled = localCallParticipantModel.isAudioEnabled();
messageSender.sendToAll(getDataChannelMessageForAudioState());
messageSender.sendToAll(getSignalingMessageForAudioState());
}
if (!Objects.equals(speaking, localCallParticipantModel.isSpeaking())) {
speaking = localCallParticipantModel.isSpeaking();
messageSender.sendToAll(getDataChannelMessageForSpeakingState());
}
if (!Objects.equals(videoEnabled, localCallParticipantModel.isVideoEnabled())) {
videoEnabled = localCallParticipantModel.isVideoEnabled();
messageSender.sendToAll(getDataChannelMessageForVideoState());
messageSender.sendToAll(getSignalingMessageForVideoState());
}
}
}
public LocalStateBroadcaster(LocalCallParticipantModel localCallParticipantModel,
MessageSender messageSender) {
this.localCallParticipantModel = localCallParticipantModel;
this.localCallParticipantModelObserver = new LocalCallParticipantModelObserver(localCallParticipantModel);
this.messageSender = messageSender;
this.localCallParticipantModel.addObserver(localCallParticipantModelObserver);
}
public void destroy() {
this.localCallParticipantModel.removeObserver(localCallParticipantModelObserver);
}
public abstract void handleCallParticipantAdded(ParticipantUiState uiState);
public abstract void handleCallParticipantRemoved(String sessionId);
protected DataChannelMessage getDataChannelMessageForAudioState() {
String type = "audioOff";
if (localCallParticipantModel.isAudioEnabled() != null && localCallParticipantModel.isAudioEnabled()) {
type = "audioOn";
}
return new DataChannelMessage(type);
}
protected DataChannelMessage getDataChannelMessageForSpeakingState() {
String type = "stoppedSpeaking";
if (localCallParticipantModel.isSpeaking() != null && localCallParticipantModel.isSpeaking()) {
type = "speaking";
}
return new DataChannelMessage(type);
}
protected DataChannelMessage getDataChannelMessageForVideoState() {
String type = "videoOff";
if (localCallParticipantModel.isVideoEnabled() != null && localCallParticipantModel.isVideoEnabled()) {
type = "videoOn";
}
return new DataChannelMessage(type);
}
/**
* Returns a signaling message with the common fields set (type and room type).
*
* @param type the type of the signaling message
* @return the signaling message
*/
private NCSignalingMessage createBaseSignalingMessage(String type) {
NCSignalingMessage ncSignalingMessage = new NCSignalingMessage();
// "roomType" is not really relevant without a peer or when referring to the whole participant, but it is
// nevertheless expected in the message. As most of the signaling messages currently sent to all participants
// are related to audio/video state "video" is used as the room type.
ncSignalingMessage.setRoomType("video");
ncSignalingMessage.setType(type);
return ncSignalingMessage;
}
/**
* Returns a signaling message to notify current audio state.
*
* @return the signaling message
*/
protected NCSignalingMessage getSignalingMessageForAudioState() {
String type = "mute";
if (localCallParticipantModel.isAudioEnabled() != null && localCallParticipantModel.isAudioEnabled()) {
type = "unmute";
}
NCSignalingMessage ncSignalingMessage = createBaseSignalingMessage(type);
NCMessagePayload ncMessagePayload = new NCMessagePayload();
ncMessagePayload.setName("audio");
ncSignalingMessage.setPayload(ncMessagePayload);
return ncSignalingMessage;
}
/**
* Returns a signaling message to notify current video state.
*
* @return the signaling message
*/
protected NCSignalingMessage getSignalingMessageForVideoState() {
String type = "mute";
if (localCallParticipantModel.isVideoEnabled() != null && localCallParticipantModel.isVideoEnabled()) {
type = "unmute";
}
NCSignalingMessage ncSignalingMessage = createBaseSignalingMessage(type);
NCMessagePayload ncMessagePayload = new NCMessagePayload();
ncMessagePayload.setName("video");
ncSignalingMessage.setPayload(ncMessagePayload);
return ncSignalingMessage;
}
}
@@ -0,0 +1,118 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2024 Daniel Calviño Sánchez <danxuliu@gmail.com>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.call;
import ru.f7cloud.talk.activities.ParticipantUiState;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import io.reactivex.Observable;
import io.reactivex.disposables.Disposable;
import io.reactivex.schedulers.Schedulers;
/**
* Helper class to send the local participant state to the other participants in the call when an MCU is used.
* <p>
* Sending the state when it changes is handled by the base class; this subclass only handles sending the initial
* state when a remote participant is added.
* <p>
* When Janus is used data channel messages are sent to all remote participants (with a peer connection to receive from
* the local participant). Moreover, it is not possible to know when the remote participants open the data channel to
* receive the messages, or even when they establish the receiver connection; it is only possible to know when the
* data channel is open for the publisher connection of the local participant. Due to all that the state is sent
* several times with an increasing delay whenever a participant joins the call (which implicitly broadcasts the
* initial state when the local participant joins the call, as all the remote participants joined from the point of
* view of the local participant). If the state was already being sent the sending is restarted with each new
* participant that joins.
* <p>
* Similarly, in the case of signaling messages it is not possible either to know when the remote participants have
* "seen" the local participant and thus are ready to handle signaling messages about the state. However, in the case
* of signaling messages it is possible to send them to a specific participant, so the initial state is sent several
* times with an increasing delay directly to the participant that was added. Moreover, if the participant is removed
* the state is no longer directly sent.
* <p>
* In any case, note that the state is sent only when the remote participant joins the call. Even in case of
* temporary disconnections the normal state updates sent when the state changes are expected to be received by the
* other participant, as signaling messages are sent through a WebSocket and are therefore reliable. Moreover, even
* if the WebSocket is restarted and the connection resumed (rather than joining with a new session ID) the messages
* would be also received, as in that case they would be queued until the WebSocket is connected again.
* <p>
* Data channel messages, on the other hand, could be lost if the remote participant restarts the peer receiver
* connection (although they would be received in case of temporary disconnections, as data channels use a reliable
* transport by default). Therefore, as the speaking state is sent only through data channels, updates of the speaking
* state could be not received by remote participants.
*/
public class LocalStateBroadcasterMcu extends LocalStateBroadcaster {
private final MessageSender messageSender;
private final Map<String, Disposable> sendStateWithRepetitionByParticipant = new HashMap<>();
private Disposable sendStateWithRepetition;
public LocalStateBroadcasterMcu(LocalCallParticipantModel localCallParticipantModel,
MessageSender messageSender) {
super(localCallParticipantModel, messageSender);
this.messageSender = messageSender;
}
public void destroy() {
super.destroy();
if (sendStateWithRepetition != null) {
sendStateWithRepetition.dispose();
}
for (Disposable sendStateWithRepetitionForParticipant: sendStateWithRepetitionByParticipant.values()) {
sendStateWithRepetitionForParticipant.dispose();
}
}
@Override
public void handleCallParticipantAdded(ParticipantUiState uiState) {
if (sendStateWithRepetition != null) {
sendStateWithRepetition.dispose();
}
sendStateWithRepetition = Observable
.fromArray(new Integer[]{0, 1, 2, 4, 8, 16})
.concatMap(i -> Observable.just(i).delay(i, TimeUnit.SECONDS, Schedulers.io()))
.subscribe(value -> sendState());
Disposable sendStateWithRepetitionForParticipant = sendStateWithRepetitionByParticipant.get(uiState.getSessionKey());
if (sendStateWithRepetitionForParticipant != null) {
sendStateWithRepetitionForParticipant.dispose();
}
sendStateWithRepetitionByParticipant.put(uiState.getSessionKey(), Observable
.fromArray(new Integer[]{0, 1, 2, 4, 8, 16})
.concatMap(i -> Observable.just(i).delay(i, TimeUnit.SECONDS, Schedulers.io()))
.subscribe(value -> sendState(uiState.getSessionKey())));
}
@Override
public void handleCallParticipantRemoved(String sessionId) {
Disposable sendStateWithRepetitionForParticipant = sendStateWithRepetitionByParticipant.get(sessionId);
if (sendStateWithRepetitionForParticipant != null) {
sendStateWithRepetitionForParticipant.dispose();
}
}
private void sendState() {
messageSender.sendToAll(getDataChannelMessageForAudioState());
messageSender.sendToAll(getDataChannelMessageForSpeakingState());
messageSender.sendToAll(getDataChannelMessageForVideoState());
}
private void sendState(String sessionId) {
messageSender.send(getSignalingMessageForAudioState(), sessionId);
messageSender.send(getSignalingMessageForVideoState(), sessionId);
}
}
@@ -0,0 +1,101 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2024 Daniel Calviño Sánchez <danxuliu@gmail.com>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.call
/**
* Helper class to send the local participant state to the other participants in the call when an MCU is not used.
*
*
* Sending the state when it changes is handled by the base class; this subclass only handles sending the initial
* state when a remote participant is added.
*
*
* The state is sent when a connection with another participant is first established (which implicitly broadcasts the
* initial state when the local participant joins the call, as a connection is established with all the remote
* participants). Note that, as long as that participant stays in the call, the initial state is not sent again, even
* after a temporary disconnection; data channels use a reliable transport by default, so even if the state changes
* while the connection is temporarily interrupted the normal state update messages should be received by the other
* participant once the connection is restored.
*
*
* Nevertheless, in case of a failed connection and an ICE restart it is unclear whether the data channel messages
* would be received or not (as the data channel transport may be the one that failed and needs to be restarted).
* However, the state (except the speaking state) is also sent through signaling messages, which need to be
* explicitly fetched from the internal signaling server, so even in case of a failed connection they will be
* eventually received once the remote participant connects again.
*/
import ru.f7cloud.talk.activities.ParticipantUiState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import org.webrtc.PeerConnection.IceConnectionState
import java.util.concurrent.ConcurrentHashMap
class LocalStateBroadcasterNoMcu(
private val localCallParticipantModel: LocalCallParticipantModel,
private val messageSender: MessageSenderNoMcu,
private val scope: CoroutineScope = CoroutineScope(Dispatchers.Main.immediate + SupervisorJob())
) : LocalStateBroadcaster(localCallParticipantModel, messageSender) {
// Map sessionId -> observer wrapper (Flow collector job)
private val iceConnectionStateObservers = ConcurrentHashMap<String, IceConnectionStateObserver>()
private inner class IceConnectionStateObserver(val uiState: ParticipantUiState) {
private var job: Job? = null
init {
handleStateChange(uiState)
}
private fun handleStateChange(uiState: ParticipantUiState) {
// Determine ICE connection state
val iceState = if (uiState.isConnected) IceConnectionState.CONNECTED else IceConnectionState.NEW
if (iceState == IceConnectionState.CONNECTED) {
remove()
sendState(uiState.sessionKey)
}
}
fun remove() {
job?.cancel()
iceConnectionStateObservers.remove(uiState.sessionKey)
}
}
override fun handleCallParticipantAdded(uiState: ParticipantUiState) {
uiState.sessionKey?.let {
iceConnectionStateObservers[it]?.remove()
iceConnectionStateObservers[it] =
IceConnectionStateObserver(uiState)
}
}
override fun handleCallParticipantRemoved(sessionId: String) {
iceConnectionStateObservers[sessionId]?.remove()
}
override fun destroy() {
super.destroy()
// Cancel all collectors safely
val observersCopy = iceConnectionStateObservers.values.toList()
for (observer in observersCopy) {
observer.remove()
}
}
private fun sendState(sessionKey: String?) {
messageSender.send(getDataChannelMessageForAudioState(), sessionKey)
messageSender.send(getDataChannelMessageForSpeakingState(), sessionKey)
messageSender.send(getDataChannelMessageForVideoState(), sessionKey)
messageSender.send(getSignalingMessageForAudioState(), sessionKey)
messageSender.send(getSignalingMessageForVideoState(), sessionKey)
}
}
@@ -0,0 +1,49 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2025 Marcel Hibbe <dev@mhibbe.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.call
import org.webrtc.MediaConstraints
class MediaConstraintsHelper(constraints: MediaConstraints? = null) {
private val constraints: MediaConstraints = constraints ?: MediaConstraints()
fun copy(): MediaConstraintsHelper {
val newConstraints = MediaConstraints()
newConstraints.mandatory.addAll(
this.constraints.mandatory.map {
MediaConstraints.KeyValuePair(it.key, it.value)
}
)
newConstraints.optional.addAll(
this.constraints.optional.map {
MediaConstraints.KeyValuePair(it.key, it.value)
}
)
return MediaConstraintsHelper(newConstraints)
}
fun replaceOrAddConstraint(key: String, value: String, mandatoryList: Boolean = true): MediaConstraintsHelper {
val list = if (mandatoryList) constraints.mandatory else constraints.optional
val index = list.indexOfFirst { it.key == key }
val newPair = MediaConstraints.KeyValuePair(key, value)
if (index != -1) {
list[index] = newPair
} else {
list.add(newPair)
}
return this
}
fun applyIf(condition: Boolean, block: MediaConstraintsHelper.() -> Unit): MediaConstraintsHelper {
if (condition) block()
return this
}
fun build(): MediaConstraints = constraints
}
@@ -0,0 +1,93 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2024 Daniel Calviño Sánchez <danxuliu@gmail.com>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.call;
import ru.f7cloud.talk.models.json.signaling.DataChannelMessage;
import ru.f7cloud.talk.models.json.signaling.NCSignalingMessage;
import ru.f7cloud.talk.signaling.SignalingMessageSender;
import ru.f7cloud.talk.webrtc.PeerConnectionWrapper;
import java.util.List;
import java.util.Set;
/**
* Helper class to send messages to participants in a call.
* <p>
* A specific subclass has to be created depending on whether an MCU is being used or not.
* <p>
* Note that recipients of signaling messages are not validated, so no error will be triggered if trying to send a
* message to a participant with a session ID that does not exist or is not in the call.
* <p>
* Also note that, unlike signaling messages, data channel messages require a peer connection. Therefore data channel
* messages may not be received by a participant if there is no peer connection with that participant (for example, if
* neither the local and remote participants have publishing rights). Moreover, data channel messages are expected to
* be received only on peer connections with type "video", so data channel messages will not be sent on other peer
* connections.
*/
public abstract class MessageSender {
private final SignalingMessageSender signalingMessageSender;
private final Set<String> callParticipantSessionIds;
protected final List<PeerConnectionWrapper> peerConnectionWrappers;
public MessageSender(SignalingMessageSender signalingMessageSender,
Set<String> callParticipantSessionIds,
List<PeerConnectionWrapper> peerConnectionWrappers) {
this.signalingMessageSender = signalingMessageSender;
this.callParticipantSessionIds = callParticipantSessionIds;
this.peerConnectionWrappers = peerConnectionWrappers;
}
/**
* Sends the given data channel message to all the participants in the call.
*
* @param dataChannelMessage the message to send
*/
public abstract void sendToAll(DataChannelMessage dataChannelMessage);
/**
* Sends the given signaling message to the given session ID.
* <p>
* Note that the signaling message will be modified to set the recipient in the "to" field.
*
* @param ncSignalingMessage the message to send
* @param sessionId the signaling session ID of the participant to send the message to
*/
public void send(NCSignalingMessage ncSignalingMessage, String sessionId) {
ncSignalingMessage.setTo(sessionId);
signalingMessageSender.send(ncSignalingMessage);
}
/**
* Sends the given signaling message to all the participants in the call.
* <p>
* Note that the signaling message will be modified to set each of the recipients in the "to" field.
*
* @param ncSignalingMessage the message to send
*/
public void sendToAll(NCSignalingMessage ncSignalingMessage) {
for (String sessionId: callParticipantSessionIds) {
ncSignalingMessage.setTo(sessionId);
signalingMessageSender.send(ncSignalingMessage);
}
}
protected PeerConnectionWrapper getPeerConnectionWrapper(String sessionId) {
for (PeerConnectionWrapper peerConnectionWrapper: peerConnectionWrappers) {
if (peerConnectionWrapper.getSessionId().equals(sessionId)
&& "video".equals(peerConnectionWrapper.getVideoStreamType())) {
return peerConnectionWrapper;
}
}
return null;
}
}
@@ -0,0 +1,41 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2024 Daniel Calviño Sánchez <danxuliu@gmail.com>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.call;
import ru.f7cloud.talk.models.json.signaling.DataChannelMessage;
import ru.f7cloud.talk.signaling.SignalingMessageSender;
import ru.f7cloud.talk.webrtc.PeerConnectionWrapper;
import java.util.List;
import java.util.Set;
/**
* Helper class to send messages to participants in a call when an MCU is used.
* <p>
* Note that when Janus is used it is not possible to send a data channel message to a specific participant. Any data
* channel message will be broadcast to all the subscribers of the publisher peer connection (the own peer connection).
*/
public class MessageSenderMcu extends MessageSender {
private final String ownSessionId;
public MessageSenderMcu(SignalingMessageSender signalingMessageSender,
Set<String> callParticipantSessionIds,
List<PeerConnectionWrapper> peerConnectionWrappers,
String ownSessionId) {
super(signalingMessageSender, callParticipantSessionIds, peerConnectionWrappers);
this.ownSessionId = ownSessionId;
}
public void sendToAll(DataChannelMessage dataChannelMessage) {
PeerConnectionWrapper ownPeerConnectionWrapper = getPeerConnectionWrapper(ownSessionId);
if (ownPeerConnectionWrapper != null) {
ownPeerConnectionWrapper.send(dataChannelMessage);
}
}
}
@@ -0,0 +1,47 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2024 Daniel Calviño Sánchez <danxuliu@gmail.com>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.call;
import ru.f7cloud.talk.models.json.signaling.DataChannelMessage;
import ru.f7cloud.talk.signaling.SignalingMessageSender;
import ru.f7cloud.talk.webrtc.PeerConnectionWrapper;
import java.util.List;
import java.util.Set;
/**
* Helper class to send messages to participants in a call when an MCU is not used.
*/
public class MessageSenderNoMcu extends MessageSender {
public MessageSenderNoMcu(SignalingMessageSender signalingMessageSender,
Set<String> callParticipantSessionIds,
List<PeerConnectionWrapper> peerConnectionWrappers) {
super(signalingMessageSender, callParticipantSessionIds, peerConnectionWrappers);
}
/**
* Sends the given data channel message to the given signaling session ID.
*
* @param dataChannelMessage the message to send
* @param sessionId the signaling session ID of the participant to send the message to
*/
public void send(DataChannelMessage dataChannelMessage, String sessionId) {
PeerConnectionWrapper peerConnectionWrapper = getPeerConnectionWrapper(sessionId);
if (peerConnectionWrapper != null) {
peerConnectionWrapper.send(dataChannelMessage);
}
}
public void sendToAll(DataChannelMessage dataChannelMessage) {
for (PeerConnectionWrapper peerConnectionWrapper: peerConnectionWrappers) {
if ("video".equals(peerConnectionWrapper.getVideoStreamType())){
peerConnectionWrapper.send(dataChannelMessage);
}
}
}
}
@@ -0,0 +1,51 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2024 Daniel Calviño Sánchez <danxuliu@gmail.com>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.call;
import java.util.Objects;
/**
* Mutable data model for local call participants.
* <p>
* Setting "speaking" will automatically set "speaking" or "speakingWhileMuted" as needed, depending on whether audio is
* enabled or not. Similarly, setting whether the audio is enabled or disabled will automatically switch between
* "speaking" and "speakingWhileMuted" as needed.
* <p>
* There is no synchronization when setting the values; if needed, it should be handled by the clients of the model.
*/
public class MutableLocalCallParticipantModel extends LocalCallParticipantModel {
public void setAudioEnabled(Boolean audioEnabled) {
if (Objects.equals(this.audioEnabled.getValue(), audioEnabled)) {
return;
}
if (audioEnabled == null || !audioEnabled) {
this.speakingWhileMuted.setValue(this.speaking.getValue());
this.speaking.setValue(Boolean.FALSE);
}
this.audioEnabled.setValue(audioEnabled);
if (audioEnabled != null && audioEnabled) {
this.speaking.setValue(this.speakingWhileMuted.getValue());
this.speakingWhileMuted.setValue(Boolean.FALSE);
}
}
public void setSpeaking(Boolean speaking) {
if (this.audioEnabled.getValue() != null && this.audioEnabled.getValue()) {
this.speaking.setValue(speaking);
} else {
this.speakingWhileMuted.setValue(speaking);
}
}
public void setVideoEnabled(Boolean videoEnabled) {
this.videoEnabled.setValue(videoEnabled);
}
}
@@ -0,0 +1,9 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2023 Daniel Calviño Sánchez <danxuliu@gmail.com>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.call
data class RaisedHand(val state: Boolean, val timestamp: Long)
@@ -0,0 +1,166 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2023 Marcel Hibbe <dev@mhibbe.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.call
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.animation.AnimatorSet
import android.animation.ObjectAnimator
import android.annotation.SuppressLint
import android.content.Context
import android.content.res.ColorStateList
import android.view.ViewGroup
import android.view.animation.LinearInterpolator
import android.widget.LinearLayout
import android.widget.RelativeLayout
import android.widget.TextView
import androidx.appcompat.content.res.AppCompatResources
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.DrawableCompat
import ru.f7cloud.talk.R
import ru.f7cloud.talk.ui.theme.ViewThemeUtils
import com.vanniktech.emoji.EmojiTextView
class ReactionAnimator(
val context: Context,
private val startPointView: RelativeLayout,
val viewThemeUtils: ViewThemeUtils?
) {
private val reactionsList: MutableList<CallReaction> = ArrayList()
fun addReaction(emoji: String, displayName: String) {
val callReaction = CallReaction(emoji, displayName)
reactionsList.add(callReaction)
if (reactionsList.size == 1) {
animateReaction(reactionsList[0])
}
}
private fun animateReaction(callReaction: CallReaction) {
val reactionWrapper = getReactionWrapperView(callReaction)
val params = RelativeLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
LinearLayout.LayoutParams.WRAP_CONTENT
).apply {
leftMargin = 0
bottomMargin = 0
}
params.addRule(RelativeLayout.ALIGN_PARENT_BOTTOM, 1)
startPointView.addView(reactionWrapper, params)
val moveWithFullAlpha = ObjectAnimator.ofFloat(
reactionWrapper,
TRANSLATION_Y_PROPERTY,
POSITION_Y_WITH_FULL_ALPHA
)
moveWithFullAlpha.duration = DURATION_FULL_ALPHA
moveWithFullAlpha.interpolator = LinearInterpolator()
val moveWithDecreasingAlpha = ObjectAnimator.ofFloat(
reactionWrapper,
TRANSLATION_Y_PROPERTY,
POSITION_Y_WITH_DECREASING_ALPHA
)
moveWithDecreasingAlpha.duration = DURATION_DECREASING_ALPHA
moveWithDecreasingAlpha.interpolator = LinearInterpolator()
val decreasingAlpha: ObjectAnimator = ObjectAnimator.ofFloat(
reactionWrapper,
ALPHA_PROPERTY,
ZERO_ALPHA
)
decreasingAlpha.duration = DURATION_DECREASING_ALPHA
val animatorWithFullAlpha = AnimatorSet()
animatorWithFullAlpha.play(moveWithFullAlpha)
animatorWithFullAlpha.addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
reactionsList.remove(callReaction)
if (reactionsList.isNotEmpty()) {
animateReaction(reactionsList[0])
}
}
})
val animatorWithDecreasingAlpha = AnimatorSet()
animatorWithDecreasingAlpha.playTogether(moveWithDecreasingAlpha, decreasingAlpha)
val finalAnimator = AnimatorSet()
finalAnimator.play(animatorWithFullAlpha).before(animatorWithDecreasingAlpha)
finalAnimator.start()
}
private fun getReactionWrapperView(callReaction: CallReaction): LinearLayout {
val reactionWrapper = LinearLayout(context)
reactionWrapper.orientation = LinearLayout.HORIZONTAL
val emojiView = EmojiTextView(context)
emojiView.text = callReaction.emoji
emojiView.textSize = TEXT_SIZE
val nameView = getNameView(callReaction)
reactionWrapper.addView(emojiView)
reactionWrapper.addView(nameView)
return reactionWrapper
}
@SuppressLint("SetTextI18n")
private fun getNameView(callReaction: CallReaction): TextView {
val nameView = TextView(context)
val nameViewParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
nameViewParams.setMargins(HORIZONTAL_MARGIN, 0, HORIZONTAL_MARGIN, BOTTOM_MARGIN)
nameView.layoutParams = nameViewParams
nameView.text = " " + callReaction.userName + " "
nameView.setTextColor(context.resources.getColor(R.color.white, null))
val backgroundColor = ContextCompat.getColor(
context,
R.color.colorPrimary
)
val drawable = AppCompatResources
.getDrawable(context, R.drawable.reaction_self_background)!!
.mutate()
DrawableCompat.setTintList(
drawable,
ColorStateList.valueOf(backgroundColor)
)
nameView.background = drawable
return nameView
}
companion object {
private const val TRANSLATION_Y_PROPERTY = "translationY"
// 1333ms to move emoji up 400px with full alpha
private const val DURATION_FULL_ALPHA = 1333L
private const val POSITION_Y_WITH_FULL_ALPHA = -400f
// 666ms to move emoji up 200px while decreasing alpha
private const val DURATION_DECREASING_ALPHA = 666L
private const val POSITION_Y_WITH_DECREASING_ALPHA = -600f
private const val ZERO_ALPHA = 0f
private const val ALPHA_PROPERTY = "alpha"
private const val TEXT_SIZE = 20f
private const val HORIZONTAL_MARGIN: Int = 20
private const val BOTTOM_MARGIN: Int = 5
}
}
data class CallReaction(var emoji: String, var userName: String)
@@ -0,0 +1,103 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2025 Marcel Hibbe <dev@mhibbe.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.call.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import ru.f7cloud.talk.activities.ParticipantUiState
import ru.f7cloud.talk.models.json.participants.Participant
import ru.f7cloud.talk.utils.ApiUtils
import ru.f7cloud.talk.utils.DisplayUtils.isDarkModeOn
@Composable
fun AvatarWithFallback(participant: ParticipantUiState, displayName: String, modifier: Modifier = Modifier) {
Box(
modifier = modifier
.clip(CircleShape),
contentAlignment = Alignment.Center
) {
val avatarUrl = getUrlForAvatar(
participant = participant,
displayName = displayName
)
if (avatarUrl.isNotEmpty()) {
AsyncImage(
model = avatarUrl,
contentDescription = "Avatar",
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxSize()
.clip(CircleShape)
)
} else {
FallbackAvatar(participant = participant)
}
}
}
@Composable
private fun FallbackAvatar(participant: ParticipantUiState) {
val initials = participant.nick!!
.split(" ")
.mapNotNull { it.firstOrNull()?.uppercase() }
.take(2)
.joinToString("")
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.White, CircleShape),
contentAlignment = Alignment.Center
) {
Text(
text = initials.ifEmpty { "?" },
color = Color.Black,
fontSize = 24.sp
)
}
}
@Composable
fun getUrlForAvatar(participant: ParticipantUiState, displayName: String): String {
var url = ApiUtils.getUrlForAvatar(
participant.baseUrl,
participant.actorId,
true
)
if (Participant.ActorType.GUESTS == participant.actorType ||
Participant.ActorType.EMAILS == participant.actorType
) {
url = ApiUtils.getUrlForGuestAvatar(
participant.baseUrl,
displayName,
true
)
}
if (participant.actorType == Participant.ActorType.FEDERATED) {
val darkTheme = if (isDarkModeOn(LocalContext.current)) 1 else 0
url = ApiUtils.getUrlForFederatedAvatar(
participant.baseUrl,
participant.roomToken,
participant.actorId!!,
darkTheme,
true
)
}
return url
}
@@ -0,0 +1,332 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2025 Marcel Hibbe <dev@mhibbe.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
@file:Suppress("MagicNumber", "TooManyFunctions")
package ru.f7cloud.talk.call.components
import android.annotation.SuppressLint
import android.content.res.Configuration
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.isFinite
import org.webrtc.EglBase
import kotlin.math.ceil
import android.util.Log
import ru.f7cloud.talk.activities.ParticipantUiState
@SuppressLint("UnusedBoxWithConstraintsScope")
@Suppress("LongParameterList")
@Composable
fun ParticipantGrid(
modifier: Modifier = Modifier,
eglBase: EglBase?,
participantUiStates: List<ParticipantUiState>,
isVoiceOnlyCall: Boolean,
onClick: () -> Unit,
onScreenShareIconClick: ((String?) -> Unit?)?
) {
Log.d("ParticipantGrid", "participantUiStates.size in Grid:" + participantUiStates.size)
val configuration = LocalConfiguration.current
val isPortrait = configuration.orientation == Configuration.ORIENTATION_PORTRAIT
val minItemHeight = 100.dp
if (participantUiStates.isEmpty()) return
val columns = if (isPortrait) {
when (participantUiStates.size) {
1, 2, 3 -> 1
else -> 2
}
} else {
when (participantUiStates.size) {
1 -> 1
2, 4 -> 2
else -> 3
}
}.coerceAtLeast(1) // Prevent 0
val rows = ceil(participantUiStates.size / columns.toFloat()).toInt().coerceAtLeast(1)
val itemSpacing = 8.dp
val edgePadding = 8.dp
val totalVerticalSpacing = itemSpacing * (rows - 1)
val totalVerticalPadding = edgePadding * 2
BoxWithConstraints(
modifier = modifier
.fillMaxSize()
.clickable { onClick() }
) {
val hasBoundedHeight = constraints.hasBoundedHeight && maxHeight.isFinite
val itemHeight = if (hasBoundedHeight) {
val gridAvailableHeight = maxHeight - totalVerticalSpacing - totalVerticalPadding
val rawItemHeight = gridAvailableHeight / rows
maxOf(rawItemHeight, minItemHeight)
} else {
minItemHeight
}
val gridHeight = if (hasBoundedHeight) {
maxHeight
} else {
(itemHeight * rows) + totalVerticalSpacing + totalVerticalPadding
}
LazyVerticalGrid(
columns = GridCells.Fixed(columns),
modifier = Modifier
.fillMaxWidth()
.height(gridHeight),
verticalArrangement = Arrangement.spacedBy(itemSpacing),
horizontalArrangement = Arrangement.spacedBy(itemSpacing),
contentPadding = PaddingValues(vertical = edgePadding, horizontal = edgePadding)
) {
items(
items = participantUiStates.sortedWith(
compareBy(
{ !it.isScreenStreamEnabled },
{ !it.isStreamEnabled },
{ !it.isAudioEnabled },
{ !it.raisedHand },
{ !it.isConnected }
)
),
key = { it.sessionKey!! }
) { participant ->
ParticipantTile(
participantUiState = participant,
modifier = Modifier
.height(itemHeight)
.fillMaxWidth(),
eglBase = eglBase,
isVoiceOnlyCall = isVoiceOnlyCall,
onScreenShareIconClick = onScreenShareIconClick
)
}
}
}
}
@Preview
@Composable
fun ParticipantGridPreview() {
ParticipantGrid(
participantUiStates = getTestParticipants(1),
eglBase = null,
isVoiceOnlyCall = false,
onClick = {},
onScreenShareIconClick = {}
)
}
@Preview
@Composable
fun TwoParticipants() {
ParticipantGrid(
participantUiStates = getTestParticipants(2),
eglBase = null,
isVoiceOnlyCall = false,
onClick = {},
onScreenShareIconClick = {}
)
}
@Preview
@Composable
fun ThreeParticipants() {
ParticipantGrid(
participantUiStates = getTestParticipants(3),
eglBase = null,
isVoiceOnlyCall = false,
onClick = {},
onScreenShareIconClick = {}
)
}
@Preview
@Composable
fun FourParticipants() {
ParticipantGrid(
participantUiStates = getTestParticipants(4),
eglBase = null,
isVoiceOnlyCall = false,
onClick = {},
onScreenShareIconClick = {}
)
}
@Preview
@Composable
fun FiveParticipants() {
ParticipantGrid(
participantUiStates = getTestParticipants(5),
eglBase = null,
isVoiceOnlyCall = false,
onClick = {},
onScreenShareIconClick = {}
)
}
@Preview
@Composable
fun SevenParticipants() {
ParticipantGrid(
participantUiStates = getTestParticipants(7),
eglBase = null,
isVoiceOnlyCall = false,
onClick = {},
onScreenShareIconClick = {}
)
}
@Preview
@Composable
fun FiftyParticipants() {
ParticipantGrid(
participantUiStates = getTestParticipants(50),
eglBase = null,
isVoiceOnlyCall = false,
onClick = {},
onScreenShareIconClick = {}
)
}
@Preview(
showBackground = false,
heightDp = 360,
widthDp = 800
)
@Composable
fun OneParticipantLandscape() {
ParticipantGrid(
participantUiStates = getTestParticipants(1),
eglBase = null,
isVoiceOnlyCall = false,
onClick = {},
onScreenShareIconClick = {}
)
}
@Preview(
showBackground = false,
heightDp = 360,
widthDp = 800
)
@Composable
fun TwoParticipantsLandscape() {
ParticipantGrid(
participantUiStates = getTestParticipants(2),
eglBase = null,
isVoiceOnlyCall = false,
onClick = {},
onScreenShareIconClick = {}
)
}
@Preview(
showBackground = false,
heightDp = 360,
widthDp = 800
)
@Composable
fun ThreeParticipantsLandscape() {
ParticipantGrid(
participantUiStates = getTestParticipants(3),
eglBase = null,
isVoiceOnlyCall = false,
onClick = {},
onScreenShareIconClick = {}
)
}
@Preview(
showBackground = false,
heightDp = 360,
widthDp = 800
)
@Composable
fun FourParticipantsLandscape() {
ParticipantGrid(
participantUiStates = getTestParticipants(4),
eglBase = null,
isVoiceOnlyCall = false,
onClick = {},
onScreenShareIconClick = {}
)
}
@Preview(
showBackground = false,
heightDp = 360,
widthDp = 800
)
@Composable
fun SevenParticipantsLandscape() {
ParticipantGrid(
participantUiStates = getTestParticipants(7),
eglBase = null,
isVoiceOnlyCall = false,
onClick = {},
onScreenShareIconClick = {}
)
}
@Preview(
showBackground = false,
heightDp = 360,
widthDp = 800
)
@Composable
fun FiftyParticipantsLandscape() {
ParticipantGrid(
participantUiStates = getTestParticipants(50),
eglBase = null,
isVoiceOnlyCall = false,
onClick = {},
onScreenShareIconClick = {}
)
}
fun getTestParticipants(numberOfParticipants: Int): List<ParticipantUiState> {
val participantList = mutableListOf<ParticipantUiState>()
for (i: Int in 1..numberOfParticipants) {
val participant = ParticipantUiState(
sessionKey = i.toString(),
baseUrl = "",
roomToken = "",
nick = "test$i user",
isConnected = true,
isAudioEnabled = false,
isStreamEnabled = true,
isScreenStreamEnabled = true,
raisedHand = true,
mediaStream = null,
actorType = null,
actorId = null,
isInternal = false
)
participantList.add(participant)
}
return participantList
}
@@ -0,0 +1,187 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2025 Marcel Hibbe <dev@mhibbe.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.call.components
import android.annotation.SuppressLint
import androidx.annotation.DrawableRes
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shadow
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.min
import ru.f7cloud.talk.R
import ru.f7cloud.talk.activities.ParticipantUiState
import ru.f7cloud.talk.utils.ColorGenerator
import org.webrtc.EglBase
import kotlin.String
const val NICK_OFFSET = 4f
const val NICK_BLUR_RADIUS = 4f
const val AVATAR_SIZE_FACTOR = 0.6f
@SuppressLint("UnusedBoxWithConstraintsScope")
@Suppress("Detekt.LongMethod")
@Composable
fun ParticipantTile(
participantUiState: ParticipantUiState,
eglBase: EglBase?,
modifier: Modifier = Modifier,
isVoiceOnlyCall: Boolean,
onScreenShareIconClick: ((String?) -> Unit?)?
) {
val displayName = if (participantUiState.nick.isNullOrEmpty()) {
stringResource(R.string.nc_nick_guest)
} else {
participantUiState.nick
}
val color = Color(ColorGenerator.usernameToColor(displayName))
BoxWithConstraints(
modifier = modifier
.clip(RoundedCornerShape(12.dp))
.background(color)
) {
val avatarSize = min(maxWidth, maxHeight) * AVATAR_SIZE_FACTOR
if (!isVoiceOnlyCall && participantUiState.isStreamEnabled && participantUiState.mediaStream != null) {
WebRTCVideoView(participantUiState.mediaStream, eglBase)
} else {
AvatarWithFallback(
participant = participantUiState,
displayName = displayName,
modifier = Modifier
.size(avatarSize)
.align(Alignment.Center)
)
}
Box(
modifier = Modifier
.fillMaxSize()
.padding(8.dp)
) {
if (participantUiState.raisedHand) {
Icon(
painter = painterResource(id = R.drawable.ic_hand_back_left),
contentDescription = "Raised Hand",
modifier = Modifier
.align(Alignment.TopEnd)
.padding(6.dp)
.size(24.dp),
tint = Color.White
)
}
Row(
modifier = Modifier.align(Alignment.BottomEnd),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
if (participantUiState.isScreenStreamEnabled) {
OverlayIcon(
iconRes = R.drawable.outline_monitor_24,
description = "Screen Share",
onClick = { onScreenShareIconClick?.invoke(participantUiState.sessionKey) }
)
}
if (!participantUiState.isAudioEnabled) {
OverlayIcon(
iconRes = R.drawable.ic_mic_off_white_24px,
description = "Mic Off"
)
}
}
Text(
text = displayName,
color = Color.White,
modifier = Modifier
.align(Alignment.BottomStart),
style = MaterialTheme.typography.bodyMedium.copy(
shadow = Shadow(
color = Color.Black,
offset = Offset(NICK_OFFSET, NICK_OFFSET),
blurRadius = NICK_BLUR_RADIUS
)
)
)
if (!participantUiState.isConnected) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
}
}
}
}
@Composable
private fun OverlayIcon(@DrawableRes iconRes: Int, description: String, onClick: (() -> Unit)? = null) {
val clickableModifier = onClick?.let { Modifier.clickable { it() } } ?: Modifier
Icon(
painter = painterResource(id = iconRes),
contentDescription = description,
modifier = Modifier
.padding(6.dp)
.size(24.dp)
.then(clickableModifier),
tint = Color.White
)
}
@Preview(showBackground = false)
@Composable
fun ParticipantTilePreview() {
val participant = ParticipantUiState(
sessionKey = "",
baseUrl = "",
roomToken = "",
nick = "testuser one",
isConnected = true,
isAudioEnabled = false,
isStreamEnabled = true,
isScreenStreamEnabled = true,
raisedHand = true,
mediaStream = null,
actorType = null,
actorId = null,
isInternal = false
)
ParticipantTile(
participantUiState = participant,
modifier = Modifier
.fillMaxWidth()
.height(300.dp),
eglBase = null,
isVoiceOnlyCall = false,
onScreenShareIconClick = null
)
}
@@ -0,0 +1,72 @@
/*
* F7cloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2025 Marcel Hibbe <dev@mhibbe.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package ru.f7cloud.talk.call.components
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import org.webrtc.EglBase
import org.webrtc.RendererCommon
import org.webrtc.SurfaceViewRenderer
import org.webrtc.VideoTrack
@Composable
fun SelfVideoView(
eglBase: EglBase.Context,
videoTrack: VideoTrack?,
isFrontCamera: Boolean,
onSwitchCamera: () -> Unit
) {
var renderer: SurfaceViewRenderer? = remember { null }
Box(
modifier = Modifier
.size(120.dp, 160.dp)
) {
AndroidView(
factory = { context ->
SurfaceViewRenderer(context).apply {
init(eglBase, null)
setMirror(isFrontCamera)
setZOrderOnTop(true)
setEnableHardwareScaler(false)
setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FIT)
renderer = this
}
},
modifier = Modifier.fillMaxSize(),
update = { it.setMirror(isFrontCamera) },
onRelease = { view ->
videoTrack?.removeSink(view)
view.clearImage()
view.release()
}
)
Box(
modifier = Modifier
.matchParentSize()
.pointerInput(Unit) {
detectTapGestures(onTap = { onSwitchCamera() })
}
)
}
DisposableEffect(videoTrack) {
videoTrack?.addSink(renderer)
onDispose { videoTrack?.removeSink(renderer) }
}
}

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