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:
@@ -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: '© ' + 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 |
Vendored
+296
@@ -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
|
||||
}
|
||||
Vendored
+521
@@ -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
|
||||
}
|
||||
}
|
||||
+177
@@ -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
|
||||
}
|
||||
}
|
||||
+468
@@ -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"
|
||||
}
|
||||
}
|
||||
+181
@@ -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)
|
||||
}
|
||||
}
|
||||
+213
@@ -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
|
||||
}
|
||||
}
|
||||
Vendored
+45
@@ -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()
|
||||
}
|
||||
+12
@@ -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)
|
||||
Vendored
+12
@@ -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)
|
||||
vendor/talk-android/app/src/main/java/ru/f7cloud/talk/account/data/network/NetworkLoginDataSource.kt
Vendored
+167
@@ -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
|
||||
}
|
||||
}
|
||||
+128
@@ -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()
|
||||
}
|
||||
+13
@@ -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();
|
||||
}
|
||||
+300
@@ -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
|
||||
}
|
||||
}
|
||||
+3129
File diff suppressed because it is too large
Load Diff
+163
@@ -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();
|
||||
}
|
||||
+22
@@ -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
|
||||
}
|
||||
+100
@@ -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
|
||||
}
|
||||
}
|
||||
+289
@@ -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
|
||||
}
|
||||
}
|
||||
+235
@@ -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
|
||||
}
|
||||
}
|
||||
+28
@@ -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
|
||||
)
|
||||
+427
@@ -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);
|
||||
}
|
||||
}
|
||||
+59
@@ -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)
|
||||
}
|
||||
}
|
||||
Vendored
+14
@@ -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()
|
||||
}
|
||||
Vendored
+34
@@ -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
|
||||
}
|
||||
Vendored
+60
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -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?)
|
||||
Vendored
+11
@@ -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)
|
||||
}
|
||||
+29
@@ -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
|
||||
}
|
||||
+48
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+109
@@ -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!!)
|
||||
}
|
||||
}
|
||||
}
|
||||
+192
@@ -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
|
||||
}
|
||||
}
|
||||
+508
@@ -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
|
||||
}
|
||||
}
|
||||
Vendored
+17
@@ -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
|
||||
}
|
||||
Vendored
+72
@@ -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"
|
||||
}
|
||||
}
|
||||
Vendored
+53
@@ -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
|
||||
}
|
||||
Vendored
+264
@@ -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"
|
||||
}
|
||||
}
|
||||
+89
@@ -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
|
||||
}
|
||||
}
|
||||
Vendored
+20
@@ -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
|
||||
}
|
||||
Vendored
+95
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+320
@@ -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
|
||||
}
|
||||
}
|
||||
+37
@@ -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)
|
||||
}
|
||||
+51
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
@@ -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()
|
||||
}
|
||||
Vendored
+16
@@ -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)
|
||||
}
|
||||
+260
@@ -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
|
||||
}
|
||||
}
|
||||
+252
@@ -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
|
||||
}
|
||||
}
|
||||
+286
@@ -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"
|
||||
}
|
||||
}
|
||||
+254
@@ -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
|
||||
}
|
||||
}
|
||||
+161
@@ -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; }
|
||||
}
|
||||
+469
@@ -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
|
||||
}
|
||||
}
|
||||
+390
@@ -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
|
||||
}
|
||||
}
|
||||
+119
@@ -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://"
|
||||
}
|
||||
}
|
||||
+15
@@ -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
|
||||
)
|
||||
+252
@@ -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
|
||||
}
|
||||
}
|
||||
+243
@@ -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
|
||||
}
|
||||
}
|
||||
+292
@@ -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()
|
||||
}
|
||||
}
|
||||
+252
@@ -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
|
||||
}
|
||||
}
|
||||
+184
@@ -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;
|
||||
}
|
||||
}
|
||||
+486
@@ -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
|
||||
}
|
||||
}
|
||||
+401
@@ -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
|
||||
}
|
||||
}
|
||||
Vendored
+13
@@ -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)
|
||||
}
|
||||
Vendored
+368
@@ -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
|
||||
}
|
||||
}
|
||||
+190
@@ -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
|
||||
}
|
||||
}
|
||||
Vendored
+14
@@ -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()
|
||||
}
|
||||
Vendored
+194
@@ -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
|
||||
}
|
||||
}
|
||||
vendor/talk-android/app/src/main/java/ru/f7cloud/talk/adapters/messages/TalkMessagesListAdapter.java
Vendored
+86
@@ -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);
|
||||
}
|
||||
}
|
||||
+45
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+87
@@ -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)
|
||||
}
|
||||
+37
@@ -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() {
|
||||
|
||||
}
|
||||
}
|
||||
Vendored
+16
@@ -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);
|
||||
}
|
||||
+370
@@ -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
|
||||
}
|
||||
Vendored
+275
@@ -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
|
||||
}
|
||||
Vendored
+23
@@ -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)
|
||||
}
|
||||
Vendored
+22
@@ -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)
|
||||
}
|
||||
}
|
||||
+63
@@ -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
|
||||
}
|
||||
Vendored
+131
@@ -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
|
||||
}
|
||||
+154
@@ -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);
|
||||
}
|
||||
}
|
||||
Vendored
+50
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+114
@@ -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);
|
||||
}
|
||||
}
|
||||
Vendored
+73
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+171
@@ -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;
|
||||
}
|
||||
}
|
||||
+118
@@ -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);
|
||||
}
|
||||
}
|
||||
+101
@@ -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)
|
||||
}
|
||||
}
|
||||
+49
@@ -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
|
||||
}
|
||||
+93
@@ -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;
|
||||
}
|
||||
}
|
||||
+41
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+47
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+51
@@ -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)
|
||||
+166
@@ -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)
|
||||
Vendored
+103
@@ -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
|
||||
}
|
||||
+332
@@ -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
|
||||
}
|
||||
+187
@@ -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
|
||||
)
|
||||
}
|
||||
+72
@@ -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
Reference in New Issue
Block a user