Add ContentProvider for user avatars.

This commit is contained in:
Alex Hart
2023-06-28 13:57:30 -03:00
committed by Nicholas
parent 04a5e56da7
commit a17033dff4
8 changed files with 289 additions and 66 deletions

View File

@@ -1158,6 +1158,11 @@
<receiver android:name=".payments.backup.phrase.ClearClipboardAlarmReceiver" />
<provider android:name=".providers.AvatarProvider"
android:authorities="${applicationId}.avatar"
android:exported="false"
android:grantUriPermissions="true" />
<provider android:name=".providers.PartProvider"
android:grantUriPermissions="true"
android:exported="false"

View File

@@ -202,7 +202,7 @@ public class AvatarHelper {
}
}
private static @NonNull File getAvatarFile(@NonNull Context context, @NonNull RecipientId recipientId) {
public static @NonNull File getAvatarFile(@NonNull Context context, @NonNull RecipientId recipientId) {
File profileAvatar = getAvatarFile(context, recipientId, false);
boolean profileAvatarExists = profileAvatar.exists() && profileAvatar.length() > 0;
File syncAvatar = getAvatarFile(context, recipientId, true);

View File

@@ -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<out String>?, selection: String?, selectionArgs: Array<out String>?, 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<out String>?): 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<out String>?): 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)
}
}
}

View File

@@ -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};

View File

@@ -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();
}

View File

@@ -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;
}

View File

@@ -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();
}

View File

@@ -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<Notification> 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();
}
}