From a17033dff4050d02144bbd2bbc2ba041b694d583 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Wed, 28 Jun 2023 13:57:30 -0300 Subject: [PATCH] Add ContentProvider for user avatars. --- app/src/main/AndroidManifest.xml | 5 + .../securesms/profiles/AvatarHelper.java | 2 +- .../securesms/providers/AvatarProvider.kt | 248 ++++++++++++++++++ .../providers/BaseContentProvider.java | 2 +- .../service/webrtc/WebRtcCallService.java | 24 +- .../securesms/util/AvatarUtil.java | 17 +- .../securesms/util/ConversationUtil.java | 3 +- .../webrtc/CallNotificationBuilder.java | 54 ++-- 8 files changed, 289 insertions(+), 66 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/providers/AvatarProvider.kt 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(); } }