diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index f895a0a5b8..c15ddeef2c 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -1158,6 +1158,11 @@
+
+
0;
File syncAvatar = getAvatarFile(context, recipientId, true);
diff --git a/app/src/main/java/org/thoughtcrime/securesms/providers/AvatarProvider.kt b/app/src/main/java/org/thoughtcrime/securesms/providers/AvatarProvider.kt
new file mode 100644
index 0000000000..f38d2ad3e1
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/providers/AvatarProvider.kt
@@ -0,0 +1,248 @@
+/*
+ * Copyright 2023 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.providers
+
+import android.content.ContentUris
+import android.content.ContentValues
+import android.content.Context
+import android.content.Intent
+import android.content.UriMatcher
+import android.database.Cursor
+import android.graphics.Bitmap
+import android.net.Uri
+import android.os.Build
+import android.os.Handler
+import android.os.MemoryFile
+import android.os.ParcelFileDescriptor
+import android.os.ProxyFileDescriptorCallback
+import androidx.annotation.RequiresApi
+import org.signal.core.util.StreamUtil
+import org.signal.core.util.ThreadUtil
+import org.signal.core.util.concurrent.SignalExecutors
+import org.signal.core.util.logging.Log
+import org.thoughtcrime.securesms.BuildConfig
+import org.thoughtcrime.securesms.database.SignalDatabase
+import org.thoughtcrime.securesms.profiles.AvatarHelper
+import org.thoughtcrime.securesms.recipients.Recipient
+import org.thoughtcrime.securesms.recipients.RecipientId
+import org.thoughtcrime.securesms.service.KeyCachingService
+import org.thoughtcrime.securesms.util.AvatarUtil
+import org.thoughtcrime.securesms.util.DrawableUtil
+import org.thoughtcrime.securesms.util.MediaUtil
+import org.thoughtcrime.securesms.util.MemoryFileUtil
+import org.thoughtcrime.securesms.util.ServiceUtil
+import java.io.ByteArrayInputStream
+import java.io.ByteArrayOutputStream
+import java.io.File
+import java.io.FileNotFoundException
+import java.io.IOException
+
+/**
+ * Provides user avatar bitmaps to the android system service for use in notifications and shortcuts.
+ *
+ * This file heavily borrows from [PartProvider]
+ */
+class AvatarProvider : BaseContentProvider() {
+
+ companion object {
+ private val TAG = Log.tag(AvatarProvider::class.java)
+ private const val CONTENT_AUTHORITY = "${BuildConfig.APPLICATION_ID}.avatar"
+ private const val CONTENT_URI_STRING = "content://$CONTENT_AUTHORITY/avatar"
+ private const val AVATAR = 1
+ private val CONTENT_URI = Uri.parse(CONTENT_URI_STRING)
+ private val uriMatcher = UriMatcher(UriMatcher.NO_MATCH).apply {
+ addURI(CONTENT_AUTHORITY, "avatar/#", AVATAR)
+ }
+
+ @JvmStatic
+ fun getContentUri(context: Context, recipientId: RecipientId): Uri {
+ val uri = ContentUris.withAppendedId(CONTENT_URI, recipientId.toLong())
+ context.applicationContext.grantUriPermission("com.android.systemui", uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
+
+ return uri
+ }
+ }
+
+ override fun onCreate(): Boolean {
+ Log.i(TAG, "onCreate called")
+ return true
+ }
+
+ @Throws(FileNotFoundException::class)
+ override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor? {
+ Log.i(TAG, "openFile() called!")
+
+ if (KeyCachingService.isLocked(context)) {
+ Log.w(TAG, "masterSecret was null, abandoning.")
+ return null
+ }
+
+ if (uriMatcher.match(uri) == AVATAR) {
+ Log.i(TAG, "Loading avatar.")
+ try {
+ val recipient = getRecipientId(uri)?.let { Recipient.resolved(it) } ?: return null
+ return if (Build.VERSION.SDK_INT >= 26) {
+ getParcelStreamProxyForAvatar(recipient)
+ } else {
+ getParcelStreamForAvatar(recipient)
+ }
+ } catch (ioe: IOException) {
+ Log.w(TAG, ioe)
+ throw FileNotFoundException("Error opening file")
+ }
+ }
+
+ Log.w(TAG, "Bad request.")
+ throw FileNotFoundException("Request for bad avatar.")
+ }
+
+ override fun query(uri: Uri, projection: Array?, selection: String?, selectionArgs: Array?, sortOrder: String?): Cursor? {
+ Log.i(TAG, "query() called: $uri")
+
+ if (SignalDatabase.instance == null) {
+ Log.w(TAG, "SignalDatabase unavailable")
+ return null
+ }
+
+ if (uriMatcher.match(uri) == AVATAR) {
+ val recipientId = getRecipientId(uri) ?: return null
+
+ if (AvatarHelper.hasAvatar(context!!, recipientId)) {
+ val file: File = AvatarHelper.getAvatarFile(context!!, recipientId)
+ if (file.exists()) {
+ return createCursor(projection, file.name, file.length())
+ }
+ }
+
+ return createCursor(projection, "fallback-$recipientId.jpg", 0)
+ } else {
+ return null
+ }
+ }
+
+ override fun getType(uri: Uri): String? {
+ Log.i(TAG, "getType() called: $uri")
+
+ if (SignalDatabase.instance == null) {
+ Log.w(TAG, "SignalDatabase unavailable")
+ return null
+ }
+
+ if (uriMatcher.match(uri) == AVATAR) {
+ getRecipientId(uri) ?: return null
+
+ return MediaUtil.IMAGE_PNG
+ }
+
+ return null
+ }
+
+ override fun insert(uri: Uri, values: ContentValues?): Uri? {
+ Log.i(TAG, "insert() called")
+ return null
+ }
+
+ override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int {
+ Log.i(TAG, "delete() called")
+ context?.applicationContext?.revokeUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ return 0
+ }
+
+ override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array?): Int {
+ Log.i(TAG, "update() called")
+ return 0
+ }
+
+ private fun getRecipientId(uri: Uri): RecipientId? {
+ val rawRecipientId = ContentUris.parseId(uri)
+ if (rawRecipientId <= 0) {
+ Log.w(TAG, "Invalid recipient id.")
+ return null
+ }
+
+ val recipientId = RecipientId.from(rawRecipientId)
+ if (!SignalDatabase.recipients.containsId(recipientId)) {
+ Log.w(TAG, "Recipient does not exist.")
+ return null
+ }
+
+ return recipientId
+ }
+
+ @RequiresApi(26)
+ private fun getParcelStreamProxyForAvatar(recipient: Recipient): ParcelFileDescriptor {
+ val storageManager = requireNotNull(ServiceUtil.getStorageManager(context!!))
+ val handlerThread = SignalExecutors.getAndStartHandlerThread("avatarservice-proxy", ThreadUtil.PRIORITY_IMPORTANT_BACKGROUND_THREAD)
+ val handler = Handler(handlerThread.looper)
+
+ val parcelFileDescriptor = storageManager.openProxyFileDescriptor(
+ ParcelFileDescriptor.MODE_READ_ONLY,
+ ProxyCallback(context!!.applicationContext, recipient),
+ handler
+ )
+
+ Log.i(TAG, "${recipient.id}:createdProxy")
+ return parcelFileDescriptor
+ }
+
+ private fun getParcelStreamForAvatar(recipient: Recipient): ParcelFileDescriptor {
+ val outputStream = ByteArrayOutputStream()
+ AvatarUtil.getBitmapForNotification(context!!, recipient, DrawableUtil.SHORTCUT_INFO_WRAPPED_SIZE).apply {
+ compress(Bitmap.CompressFormat.PNG, 100, outputStream)
+ }
+
+ val memoryFile = MemoryFile("${recipient.id}-imf", outputStream.size())
+ StreamUtil.copy(ByteArrayInputStream(outputStream.toByteArray()), memoryFile.outputStream)
+ StreamUtil.close(memoryFile.outputStream)
+
+ return MemoryFileUtil.getParcelFileDescriptor(memoryFile)
+ }
+
+ @RequiresApi(26)
+ private class ProxyCallback(
+ private val context: Context,
+ private val recipient: Recipient
+ ) : ProxyFileDescriptorCallback() {
+
+ private var memoryFile: MemoryFile? = null
+
+ override fun onGetSize(): Long {
+ Log.i(TAG, "${recipient.id}:onGetSize:${Thread.currentThread().name}:${hashCode()}")
+ ensureResourceLoaded()
+ return memoryFile!!.length().toLong()
+ }
+
+ override fun onRead(offset: Long, size: Int, data: ByteArray?): Int {
+ Log.i(TAG, "${recipient.id}:onRead")
+ ensureResourceLoaded()
+
+ return memoryFile!!.readBytes(data, offset.toInt(), 0, size)
+ }
+
+ override fun onRelease() {
+ Log.i(TAG, "${recipient.id}:onRelease")
+ memoryFile = null
+ }
+
+ private fun ensureResourceLoaded() {
+ if (memoryFile != null) {
+ return
+ }
+
+ Log.i(TAG, "Reading ${recipient.id} icon into RAM.")
+
+ val outputStream = ByteArrayOutputStream()
+ val avatarBitmap = AvatarUtil.getBitmapForNotification(context, recipient, DrawableUtil.SHORTCUT_INFO_WRAPPED_SIZE)
+ avatarBitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream)
+
+ Log.i(TAG, "Writing ${recipient.id} icon to MemoryFile")
+
+ memoryFile = MemoryFile("${recipient.id}-imf", outputStream.size())
+ StreamUtil.copy(ByteArrayInputStream(outputStream.toByteArray()), memoryFile!!.outputStream)
+ StreamUtil.close(memoryFile!!.outputStream)
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/providers/BaseContentProvider.java b/app/src/main/java/org/thoughtcrime/securesms/providers/BaseContentProvider.java
index 5e4c6c287a..64a485d040 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/providers/BaseContentProvider.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/providers/BaseContentProvider.java
@@ -12,7 +12,7 @@ import androidx.annotation.Nullable;
import java.util.ArrayList;
-abstract class BaseContentProvider extends ContentProvider {
+public abstract class BaseContentProvider extends ContentProvider {
private static final String[] COLUMNS = {OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE};
diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcCallService.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcCallService.java
index faa2271bd6..40dc4cceb0 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcCallService.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcCallService.java
@@ -35,9 +35,6 @@ import java.util.Objects;
import java.util.Set;
import java.util.concurrent.TimeUnit;
-import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
-import io.reactivex.rxjava3.disposables.Disposable;
-
/**
* Provide a foreground service for {@link SignalCallManager} to leverage to run in the background when necessary. Also
* provides devices listeners needed for during a call (i.e., bluetooth, power button).
@@ -76,7 +73,6 @@ public final class WebRtcCallService extends Service implements SignalAudioManag
private int lastNotificationId;
private Notification lastNotification;
private boolean isGroup = true;
- private Disposable notificationDisposable = Disposable.empty();
private boolean stopping = false;
public static void update(@NonNull Context context, int type, @NonNull RecipientId recipientId, boolean isVideoCall) {
@@ -152,8 +148,6 @@ public final class WebRtcCallService extends Service implements SignalAudioManag
Log.v(TAG, "onDestroy");
super.onDestroy();
- notificationDisposable.dispose();
-
if (uncaughtExceptionHandlerManager != null) {
uncaughtExceptionHandlerManager.unregister();
}
@@ -236,21 +230,10 @@ public final class WebRtcCallService extends Service implements SignalAudioManag
}
public void setCallInProgressNotification(int type, @NonNull RecipientId id, boolean isVideoCall) {
- if (lastNotificationId == INVALID_NOTIFICATION_ID) {
- lastNotificationId = CallNotificationBuilder.getStartingStoppingNotificationId();
- lastNotification = CallNotificationBuilder.getStartingNotification(this);
- startForegroundCompat(lastNotificationId, lastNotification);
- }
+ lastNotificationId = CallNotificationBuilder.getNotificationId(type);
+ lastNotification = CallNotificationBuilder.getCallInProgressNotification(this, type, Recipient.resolved(id), isVideoCall);
- notificationDisposable.dispose();
- notificationDisposable = CallNotificationBuilder.getCallInProgressNotification(this, type, Recipient.resolved(id), isVideoCall)
- .observeOn(AndroidSchedulers.mainThread())
- .subscribe(notification -> {
- lastNotificationId = CallNotificationBuilder.getNotificationId(type);
- lastNotification = notification;
-
- startForegroundCompat(lastNotificationId, lastNotification);
- });
+ startForegroundCompat(lastNotificationId, lastNotification);
}
private synchronized void startForegroundCompat(int notificationId, Notification notification) {
@@ -267,7 +250,6 @@ public final class WebRtcCallService extends Service implements SignalAudioManag
private synchronized void stop() {
stopping = true;
- notificationDisposable.dispose();
stopForeground(true);
stopSelf();
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/AvatarUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/AvatarUtil.java
index 7a6111ec9f..4915b8f68a 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/util/AvatarUtil.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/util/AvatarUtil.java
@@ -24,7 +24,9 @@ import org.thoughtcrime.securesms.contacts.avatars.GeneratedContactPhoto;
import org.thoughtcrime.securesms.contacts.avatars.ProfileContactPhoto;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.mms.GlideRequest;
+import org.thoughtcrime.securesms.providers.AvatarProvider;
import org.thoughtcrime.securesms.recipients.Recipient;
+import org.thoughtcrime.securesms.recipients.RecipientId;
import java.util.Optional;
import java.util.concurrent.ExecutionException;
@@ -99,12 +101,8 @@ public final class AvatarUtil {
}
@WorkerThread
- public static IconCompat getIconForNotification(@NonNull Context context, @NonNull Recipient recipient) {
- try {
- return IconCompat.createWithBitmap(requestCircle(GlideApp.with(context).asBitmap(), context, recipient, UNDEFINED_SIZE).submit().get());
- } catch (ExecutionException | InterruptedException e) {
- return null;
- }
+ public static IconCompat getIconWithUriForNotification(@NonNull Context context, @NonNull RecipientId recipientId) {
+ return IconCompat.createWithContentUri(AvatarProvider.getContentUri(context, recipientId));
}
@WorkerThread
@@ -128,8 +126,13 @@ public final class AvatarUtil {
@WorkerThread
public static Bitmap getBitmapForNotification(@NonNull Context context, @NonNull Recipient recipient) {
+ return getBitmapForNotification(context, recipient, UNDEFINED_SIZE);
+ }
+
+ @WorkerThread
+ public static Bitmap getBitmapForNotification(@NonNull Context context, @NonNull Recipient recipient, int size) {
try {
- return requestCircle(GlideApp.with(context).asBitmap(), context, recipient, UNDEFINED_SIZE).submit().get();
+ return requestCircle(GlideApp.with(context).asBitmap(), context, recipient, size).submit().get();
} catch (ExecutionException | InterruptedException e) {
return null;
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ConversationUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/ConversationUtil.java
index 4f7f5dc0e6..879f0fa597 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/util/ConversationUtil.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/util/ConversationUtil.java
@@ -286,12 +286,11 @@ public final class ConversationUtil {
/**
* @return A Compat Library Person object representing the given Recipient
*/
- @WorkerThread
public static @NonNull Person buildPerson(@NonNull Context context, @NonNull Recipient recipient) {
return new Person.Builder()
.setKey(getShortcutId(recipient.getId()))
.setName(recipient.getDisplayName(context))
- .setIcon(AvatarUtil.getIconForNotification(context, recipient))
+ .setIcon(AvatarUtil.getIconWithUriForNotification(context, recipient.getId()))
.setUri(recipient.isSystemContact() ? recipient.getContactUri().toString() : null)
.build();
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallNotificationBuilder.java b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallNotificationBuilder.java
index 56981dbae2..20803e0ab0 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallNotificationBuilder.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallNotificationBuilder.java
@@ -9,6 +9,7 @@ import android.os.Build;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.app.NotificationCompat;
+import androidx.core.app.Person;
import org.signal.core.util.PendingIntentFlags;
import org.thoughtcrime.securesms.MainActivity;
@@ -19,10 +20,6 @@ import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.service.webrtc.WebRtcCallService;
import org.thoughtcrime.securesms.util.ConversationUtil;
-import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
-import io.reactivex.rxjava3.core.Single;
-import io.reactivex.rxjava3.schedulers.Schedulers;
-
/**
* Manages the state of the WebRtc items in the Android notification bar.
*
@@ -64,7 +61,7 @@ public class CallNotificationBuilder {
*/
public static final int API_LEVEL_CALL_STYLE = 29;
- public static Single getCallInProgressNotification(Context context, int type, Recipient recipient, boolean isVideoCall) {
+ public static Notification getCallInProgressNotification(Context context, int type, Recipient recipient, boolean isVideoCall) {
PendingIntent pendingIntent = getActivityPendingIntent(context, LaunchCallScreenIntentState.CONTENT);
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, getNotificationChannel(type))
.setSmallIcon(R.drawable.ic_call_secure_white_24dp)
@@ -76,33 +73,27 @@ public class CallNotificationBuilder {
builder.setContentText(context.getString(R.string.CallNotificationBuilder_connecting));
builder.setPriority(NotificationCompat.PRIORITY_MIN);
builder.setContentIntent(null);
- return Single.just(builder.build());
+ return builder.build();
} else if (type == TYPE_INCOMING_RINGING) {
builder.setContentText(getIncomingCallContentText(context, recipient, isVideoCall));
builder.setPriority(NotificationCompat.PRIORITY_HIGH);
builder.setCategory(NotificationCompat.CATEGORY_CALL);
builder.setFullScreenIntent(pendingIntent, true);
- if (deviceVersionSupportsIncomingCallStyle())
- {
- return Single.fromCallable(() -> ConversationUtil.buildPerson(context, recipient))
- .subscribeOn(Schedulers.io())
- .observeOn(AndroidSchedulers.mainThread())
- .map(person -> {
- builder.setStyle(NotificationCompat.CallStyle.forIncomingCall(
- person,
- getServicePendingIntent(context, WebRtcCallService.denyCallIntent(context)),
- getActivityPendingIntent(context, isVideoCall ? LaunchCallScreenIntentState.VIDEO : LaunchCallScreenIntentState.AUDIO)
- ).setIsVideo(isVideoCall));
- return builder.build();
- });
- } else {
- return Single.just(builder.build());
+ if (deviceVersionSupportsIncomingCallStyle()) {
+ Person person = ConversationUtil.buildPerson(context, recipient);
+ builder.setStyle(NotificationCompat.CallStyle.forIncomingCall(
+ person,
+ getServicePendingIntent(context, WebRtcCallService.denyCallIntent(context)),
+ getActivityPendingIntent(context, isVideoCall ? LaunchCallScreenIntentState.VIDEO : LaunchCallScreenIntentState.AUDIO)
+ ).setIsVideo(isVideoCall));
}
+
+ return builder.build();
} else if (type == TYPE_OUTGOING_RINGING) {
builder.setContentText(context.getString(R.string.NotificationBarManager__establishing_signal_call));
builder.addAction(getServiceNotificationAction(context, WebRtcCallService.hangupIntent(context), R.drawable.ic_call_end_grey600_32dp, R.string.NotificationBarManager__cancel_call));
- return Single.just(builder.build());
+ return builder.build();
} else {
builder.setContentText(getOngoingCallContentText(context, recipient, isVideoCall));
builder.setOnlyAlertOnce(true);
@@ -110,19 +101,14 @@ public class CallNotificationBuilder {
builder.setCategory(NotificationCompat.CATEGORY_CALL);
if (deviceVersionSupportsIncomingCallStyle()) {
- return Single.fromCallable(() -> ConversationUtil.buildPerson(context, recipient))
- .subscribeOn(Schedulers.io())
- .observeOn(AndroidSchedulers.mainThread())
- .map(person -> {
- builder.setStyle(NotificationCompat.CallStyle.forOngoingCall(
- person,
- getServicePendingIntent(context, WebRtcCallService.hangupIntent(context))
- ).setIsVideo(isVideoCall));
- return builder.build();
- });
- } else {
- return Single.just(builder.build());
+ Person person = ConversationUtil.buildPerson(context, recipient);
+ builder.setStyle(NotificationCompat.CallStyle.forOngoingCall(
+ person,
+ getServicePendingIntent(context, WebRtcCallService.hangupIntent(context))
+ ).setIsVideo(isVideoCall));
}
+
+ return builder.build();
}
}