diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java index aac98e4536..d761731666 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java @@ -31,6 +31,7 @@ import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; import org.thoughtcrime.securesms.database.model.ThreadRecord; import org.thoughtcrime.securesms.database.model.databaseprotos.DeviceLastResetTime; import org.thoughtcrime.securesms.database.model.databaseprotos.ProfileKeyCredentialColumnData; +import org.thoughtcrime.securesms.database.model.databaseprotos.Wallpaper; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.groups.v2.ProfileKeySet; @@ -38,6 +39,7 @@ import org.thoughtcrime.securesms.groups.v2.processing.GroupsV2StateProcessor; import org.thoughtcrime.securesms.jobs.RefreshAttributesJob; import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob; import org.thoughtcrime.securesms.jobs.RetrieveProfileJob; +import org.thoughtcrime.securesms.mms.PartAuthority; import org.thoughtcrime.securesms.profiles.AvatarHelper; import org.thoughtcrime.securesms.profiles.ProfileName; import org.thoughtcrime.securesms.recipients.Recipient; @@ -53,6 +55,9 @@ import org.thoughtcrime.securesms.util.IdentityUtil; import org.thoughtcrime.securesms.util.SqlUtil; import org.thoughtcrime.securesms.util.StringUtil; import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.wallpaper.ChatWallpaper; +import org.thoughtcrime.securesms.wallpaper.ChatWallpaperFactory; +import org.thoughtcrime.securesms.wallpaper.WallpaperStorage; import org.whispersystems.libsignal.IdentityKey; import org.whispersystems.libsignal.InvalidKeyException; import org.whispersystems.libsignal.util.Pair; @@ -131,6 +136,8 @@ public class RecipientDatabase extends Database { private static final String STORAGE_PROTO = "storage_proto"; private static final String LAST_GV1_MIGRATE_REMINDER = "last_gv1_migrate_reminder"; private static final String LAST_SESSION_RESET = "last_session_reset"; + private static final String WALLPAPER = "wallpaper"; + private static final String WALLPAPER_URI = "wallpaper_file"; public static final String SEARCH_PROFILE_NAME = "search_signal_profile"; private static final String SORT_NAME = "sort_name"; @@ -155,7 +162,7 @@ public class RecipientDatabase extends Database { FORCE_SMS_SELECTION, CAPABILITIES, STORAGE_SERVICE_ID, DIRTY, - MENTION_SETTING + MENTION_SETTING, WALLPAPER, WALLPAPER_URI }; private static final String[] ID_PROJECTION = new String[]{ID}; @@ -350,7 +357,9 @@ public class RecipientDatabase extends Database { STORAGE_PROTO + " TEXT DEFAULT NULL, " + CAPABILITIES + " INTEGER DEFAULT 0, " + LAST_GV1_MIGRATE_REMINDER + " INTEGER DEFAULT 0, " + - LAST_SESSION_RESET + " BLOB DEFAULT NULL);"; + LAST_SESSION_RESET + " BLOB DEFAULT NULL, " + + WALLPAPER + " BLOB DEFAULT NULL, " + + WALLPAPER_URI + " TEXT DEFAULT NULL);"; private static final String INSIGHTS_INVITEE_LIST = "SELECT " + TABLE_NAME + "." + ID + " FROM " + TABLE_NAME + @@ -1264,6 +1273,7 @@ public class RecipientDatabase extends Database { long capabilities = CursorUtil.requireLong(cursor, CAPABILITIES); String storageKeyRaw = CursorUtil.requireString(cursor, STORAGE_SERVICE_ID); int mentionSettingId = CursorUtil.requireInt(cursor, MENTION_SETTING); + byte[] wallpaper = CursorUtil.requireBlob(cursor, WALLPAPER); MaterialColor color; byte[] profileKey = null; @@ -1303,6 +1313,16 @@ public class RecipientDatabase extends Database { byte[] storageKey = storageKeyRaw != null ? Base64.decodeOrThrow(storageKeyRaw) : null; + ChatWallpaper chatWallpaper = null; + + if (wallpaper != null) { + try { + chatWallpaper = ChatWallpaperFactory.create(Wallpaper.parseFrom(wallpaper)); + } catch (InvalidProtocolBufferException e) { + Log.w(TAG, "Failed to parse wallpaper.", e); + } + } + return new RecipientSettings(RecipientId.from(id), uuid, username, @@ -1338,6 +1358,7 @@ public class RecipientDatabase extends Database { InsightsBannerTier.fromId(insightsBannerTier), storageKey, MentionSetting.fromId(mentionSettingId), + chatWallpaper, getSyncExtras(cursor)); } @@ -1778,6 +1799,60 @@ public class RecipientDatabase extends Database { } } + public void setWallpaper(@NonNull RecipientId id, @NonNull ChatWallpaper chatWallpaper) { + Wallpaper wallpaper = chatWallpaper.serialize(); + Uri existingWallpaperUri = getWallpaperUri(id); + + ContentValues values = new ContentValues(); + values.put(WALLPAPER, wallpaper.toByteArray()); + + if (wallpaper.hasFile()) { + values.put(WALLPAPER_URI, wallpaper.getFile().getUri()); + } else { + values.putNull(WALLPAPER_URI); + } + + if (update(id, values)) { + Recipient.live(id).refresh(); + } + + if (existingWallpaperUri != null) { + WallpaperStorage.onWallpaperDeselected(context, existingWallpaperUri); + } + } + + private @Nullable Uri getWallpaperUri(@NonNull RecipientId id) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + + try (Cursor cursor = db.query(TABLE_NAME, new String[] {WALLPAPER_URI}, ID_WHERE, SqlUtil.buildArgs(id), null, null, null)) { + if (cursor.moveToFirst()) { + String raw = CursorUtil.requireString(cursor, WALLPAPER_URI); + + if (raw != null) { + return Uri.parse(raw); + } else { + return null; + } + } + } + + return null; + } + + public int getWallpaperUriUsageCount(@NonNull Uri uri) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + String query = WALLPAPER_URI + " = ?"; + String[] args = SqlUtil.buildArgs(uri); + + try (Cursor cursor = db.query(TABLE_NAME, new String[] { "COUNT(*)"}, query, args, null, null, null)) { + if (cursor.moveToFirst()) { + return cursor.getInt(0); + } + } + + return 0; + } + /** * @return True if setting the phone number resulted in changed recipientId, otherwise false. */ @@ -2788,6 +2863,7 @@ public class RecipientDatabase extends Database { private final InsightsBannerTier insightsBannerTier; private final byte[] storageId; private final MentionSetting mentionSetting; + private final ChatWallpaper wallpaper; private final SyncExtras syncExtras; RecipientSettings(@NonNull RecipientId id, @@ -2825,6 +2901,7 @@ public class RecipientDatabase extends Database { @NonNull InsightsBannerTier insightsBannerTier, @Nullable byte[] storageId, @NonNull MentionSetting mentionSetting, + @Nullable ChatWallpaper wallpaper, @NonNull SyncExtras syncExtras) { this.id = id; @@ -2864,6 +2941,7 @@ public class RecipientDatabase extends Database { this.insightsBannerTier = insightsBannerTier; this.storageId = storageId; this.mentionSetting = mentionSetting; + this.wallpaper = wallpaper; this.syncExtras = syncExtras; } @@ -3011,6 +3089,10 @@ public class RecipientDatabase extends Database { return mentionSetting; } + public @Nullable ChatWallpaper getWallpaper() { + return wallpaper; + } + public @NonNull SyncExtras getSyncExtras() { return syncExtras; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java index eed3e27b79..8304fb12df 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java @@ -169,8 +169,9 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper implements SignalDatab private static final int GV1_MIGRATION_REFACTOR = 85; private static final int CLEAR_PROFILE_KEY_CREDENTIALS = 86; private static final int LAST_RESET_SESSION_TIME = 87; + private static final int WALLPAPER = 88; - private static final int DATABASE_VERSION = 87; + private static final int DATABASE_VERSION = 88; private static final String DATABASE_NAME = "signal.db"; private final Context context; @@ -1246,6 +1247,11 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper implements SignalDatab db.execSQL("ALTER TABLE recipient ADD COLUMN last_session_reset BLOB DEFAULT NULL"); } + if (oldVersion < WALLPAPER) { + db.execSQL("ALTER TABLE recipient ADD COLUMN wallpaper BLOB DEFAULT NULL"); + db.execSQL("ALTER TABLE recipient ADD COLUMN wallpaper_file TEXT DEFAULT NULL"); + } + db.setTransactionSuccessful(); } finally { db.endTransaction(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.java index 17603ea04e..d6a31b436c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.java +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.java @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.keyvalue; import androidx.annotation.NonNull; import androidx.preference.PreferenceDataStore; +import org.thoughtcrime.securesms.database.model.databaseprotos.Wallpaper; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.util.SignalUncaughtExceptionHandler; @@ -28,6 +29,7 @@ public final class SignalStore { private final CertificateValues certificateValues; private final PhoneNumberPrivacyValues phoneNumberPrivacyValues; private final OnboardingValues onboardingValues; + private final WallpaperValues wallpaperValues; private SignalStore() { this.store = new KeyValueStore(ApplicationDependencies.getApplication()); @@ -45,6 +47,7 @@ public final class SignalStore { this.certificateValues = new CertificateValues(store); this.phoneNumberPrivacyValues = new PhoneNumberPrivacyValues(store); this.onboardingValues = new OnboardingValues(store); + this.wallpaperValues = new WallpaperValues(store); } public static void onFirstEverAppLaunch() { @@ -61,6 +64,7 @@ public final class SignalStore { certificateValues().onFirstEverAppLaunch(); phoneNumberPrivacy().onFirstEverAppLaunch(); onboarding().onFirstEverAppLaunch(); + wallpaper().onFirstEverAppLaunch(); } public static @NonNull KbsValues kbsValues() { @@ -119,6 +123,10 @@ public final class SignalStore { return INSTANCE.onboardingValues; } + public static @NonNull WallpaperValues wallpaper() { + return INSTANCE.wallpaperValues; + } + public static @NonNull GroupsV2AuthorizationSignalStoreCache groupsV2AuthorizationCache() { return new GroupsV2AuthorizationSignalStoreCache(getStore()); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/WallpaperValues.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/WallpaperValues.java new file mode 100644 index 0000000000..2688d1e4fd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/WallpaperValues.java @@ -0,0 +1,84 @@ +package org.thoughtcrime.securesms.keyvalue; + +import android.content.Context; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.protobuf.InvalidProtocolBufferException; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.database.model.databaseprotos.Wallpaper; +import org.thoughtcrime.securesms.mms.PartAuthority; +import org.thoughtcrime.securesms.wallpaper.ChatWallpaper; +import org.thoughtcrime.securesms.wallpaper.ChatWallpaperFactory; +import org.thoughtcrime.securesms.wallpaper.WallpaperStorage; + +public final class WallpaperValues extends SignalStoreValues { + + private static final String TAG = Log.tag(WallpaperValues.class); + + private static final String KEY_WALLPAPER = "wallpaper.wallpaper"; + + WallpaperValues(@NonNull KeyValueStore store) { + super(store); + } + + @Override + void onFirstEverAppLaunch() { + + } + + public void setWallpaper(@NonNull Context context, @Nullable ChatWallpaper wallpaper) { + Wallpaper currentWallpaper = getCurrentWallpaper(); + Uri currentUri = null; + + if (currentWallpaper != null && currentWallpaper.hasFile()) { + currentUri = Uri.parse(currentWallpaper.getFile().getUri()); + } + + if (wallpaper != null) { + putBlob(KEY_WALLPAPER, wallpaper.serialize().toByteArray()); + } else { + getStore().beginWrite().remove(KEY_WALLPAPER).apply(); + } + + WallpaperStorage.onWallpaperDeselected(context, currentUri); + } + + public @Nullable ChatWallpaper getWallpaper() { + Wallpaper currentWallpaper = getCurrentWallpaper(); + + if (currentWallpaper != null) { + return ChatWallpaperFactory.create(currentWallpaper); + } else { + return null; + } + } + + public @Nullable Uri getCurrentWallpaperUri() { + Wallpaper currentWallpaper = getCurrentWallpaper(); + + if (currentWallpaper != null && currentWallpaper.hasFile()) { + return Uri.parse(currentWallpaper.getFile().getUri()); + } else { + return null; + } + } + + private @Nullable Wallpaper getCurrentWallpaper() { + byte[] serialized = getBlob(KEY_WALLPAPER, null); + + if (serialized != null) { + try { + return Wallpaper.parseFrom(serialized); + } catch (InvalidProtocolBufferException e) { + Log.w(TAG, "Invalid proto stored for wallpaper!"); + return null; + } + } else { + return null; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/PartAuthority.java b/app/src/main/java/org/thoughtcrime/securesms/mms/PartAuthority.java index daf19ab7e6..57f3056804 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/PartAuthority.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/PartAuthority.java @@ -15,22 +15,26 @@ import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.providers.BlobProvider; import org.thoughtcrime.securesms.providers.DeprecatedPersistentBlobProvider; import org.thoughtcrime.securesms.providers.PartProvider; +import org.thoughtcrime.securesms.wallpaper.WallpaperStorage; import java.io.IOException; import java.io.InputStream; public class PartAuthority { - private static final String AUTHORITY = BuildConfig.APPLICATION_ID; - private static final String PART_URI_STRING = "content://" + AUTHORITY + "/part"; - private static final String STICKER_URI_STRING = "content://" + AUTHORITY + "/sticker"; - private static final Uri PART_CONTENT_URI = Uri.parse(PART_URI_STRING); - private static final Uri STICKER_CONTENT_URI = Uri.parse(STICKER_URI_STRING); + private static final String AUTHORITY = BuildConfig.APPLICATION_ID; + private static final String PART_URI_STRING = "content://" + AUTHORITY + "/part"; + private static final String STICKER_URI_STRING = "content://" + AUTHORITY + "/sticker"; + private static final String WALLPAPER_URI_STRING = "content://" + AUTHORITY + "/wallpaper"; + private static final Uri PART_CONTENT_URI = Uri.parse(PART_URI_STRING); + private static final Uri STICKER_CONTENT_URI = Uri.parse(STICKER_URI_STRING); + private static final Uri WALLPAPER_CONTENT_URI = Uri.parse(WALLPAPER_URI_STRING); private static final int PART_ROW = 1; private static final int PERSISTENT_ROW = 2; private static final int BLOB_ROW = 3; private static final int STICKER_ROW = 4; + private static final int WALLPAPER_ROW = 5; private static final UriMatcher uriMatcher; @@ -38,6 +42,7 @@ public class PartAuthority { uriMatcher = new UriMatcher(UriMatcher.NO_MATCH); uriMatcher.addURI(AUTHORITY, "part/*/#", PART_ROW); uriMatcher.addURI(AUTHORITY, "sticker/#", STICKER_ROW); + uriMatcher.addURI(AUTHORITY, "wallpaper/*", WALLPAPER_ROW); uriMatcher.addURI(DeprecatedPersistentBlobProvider.AUTHORITY, DeprecatedPersistentBlobProvider.EXPECTED_PATH_OLD, PERSISTENT_ROW); uriMatcher.addURI(DeprecatedPersistentBlobProvider.AUTHORITY, DeprecatedPersistentBlobProvider.EXPECTED_PATH_NEW, PERSISTENT_ROW); uriMatcher.addURI(BlobProvider.AUTHORITY, BlobProvider.PATH, BLOB_ROW); @@ -59,6 +64,7 @@ public class PartAuthority { case STICKER_ROW: return DatabaseFactory.getStickerDatabase(context).getStickerStream(ContentUris.parseId(uri)); case PERSISTENT_ROW: return DeprecatedPersistentBlobProvider.getInstance(context).getStream(context, ContentUris.parseId(uri)); case BLOB_ROW: return BlobProvider.getInstance().getStream(context, uri); + case WALLPAPER_ROW: return WallpaperStorage.read(context, getWallpaperFilename(uri)); default: return context.getContentResolver().openInputStream(uri); } } catch (SecurityException se) { @@ -138,6 +144,14 @@ public class PartAuthority { return ContentUris.withAppendedId(STICKER_CONTENT_URI, id); } + public static Uri getWallpaperUri(String filename) { + return Uri.withAppendedPath(WALLPAPER_CONTENT_URI, filename); + } + + public static String getWallpaperFilename(Uri uri) { + return uri.getPathSegments().get(1); + } + public static boolean isLocalUri(final @NonNull Uri uri) { int match = uriMatcher.match(uri); switch (match) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java index 9d787ef14b..1604829ade 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java @@ -43,6 +43,7 @@ import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.util.StringUtil; import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.wallpaper.ChatWallpaper; import org.whispersystems.libsignal.util.guava.Optional; import org.whispersystems.libsignal.util.guava.Preconditions; import org.whispersystems.signalservice.api.push.SignalServiceAddress; @@ -106,6 +107,7 @@ public class Recipient { private final InsightsBannerTier insightsBannerTier; private final byte[] storageId; private final MentionSetting mentionSetting; + private final ChatWallpaper wallpaper; /** @@ -339,6 +341,7 @@ public class Recipient { this.groupsV1MigrationCapability = Capability.UNKNOWN; this.storageId = null; this.mentionSetting = MentionSetting.ALWAYS_NOTIFY; + this.wallpaper = null; } public Recipient(@NonNull RecipientId id, @NonNull RecipientDetails details, boolean resolved) { @@ -381,6 +384,7 @@ public class Recipient { this.groupsV1MigrationCapability = details.groupsV1MigrationCapability; this.storageId = details.storageId; this.mentionSetting = details.mentionSetting; + this.wallpaper = details.wallpaper; } public @NonNull RecipientId getId() { @@ -843,6 +847,10 @@ public class Recipient { return unidentifiedAccessMode; } + public @Nullable ChatWallpaper getWallpaper() { + return wallpaper; + } + public boolean isSystemContact() { return contactUri != null; } @@ -961,7 +969,8 @@ public class Recipient { groupsV1MigrationCapability == other.groupsV1MigrationCapability && insightsBannerTier == other.insightsBannerTier && Arrays.equals(storageId, other.storageId) && - mentionSetting == other.mentionSetting; + mentionSetting == other.mentionSetting && + Objects.equals(wallpaper, other.wallpaper); } private static boolean allContentsAreTheSame(@NonNull List a, @NonNull List b) { @@ -999,7 +1008,6 @@ public class Recipient { public @NonNull FallbackContactPhoto getPhotoForRecipientWithoutName() { return new ResourceContactPhoto(R.drawable.ic_profile_outline_40, R.drawable.ic_profile_outline_20, R.drawable.ic_profile_outline_48); } - } private static class MissingAddressError extends AssertionError { diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientDetails.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientDetails.java index ae26408e61..c46b547eff 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientDetails.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientDetails.java @@ -19,6 +19,7 @@ import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.profiles.ProfileName; import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.wallpaper.ChatWallpaper; import org.whispersystems.libsignal.util.guava.Optional; import java.util.LinkedList; @@ -65,6 +66,7 @@ public class RecipientDetails { final InsightsBannerTier insightsBannerTier; final byte[] storageId; final MentionSetting mentionSetting; + final ChatWallpaper wallpaper; public RecipientDetails(@Nullable String name, @NonNull Optional groupAvatarId, @@ -110,6 +112,7 @@ public class RecipientDetails { this.insightsBannerTier = settings.getInsightsBannerTier(); this.storageId = settings.getStorageId(); this.mentionSetting = settings.getMentionSetting(); + this.wallpaper = settings.getWallpaper(); if (name == null) this.name = settings.getSystemDisplayName(); else this.name = name; @@ -157,6 +160,7 @@ public class RecipientDetails { this.groupsV1MigrationCapability = Recipient.Capability.UNKNOWN; this.storageId = null; this.mentionSetting = MentionSetting.ALWAYS_NOTIFY; + this.wallpaper = null; } public static @NonNull RecipientDetails forIndividual(@NonNull Context context, @NonNull RecipientSettings settings) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaper.java b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaper.java index 5a0710e7c8..62a43e1320 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaper.java @@ -5,6 +5,8 @@ import android.widget.ImageView; import androidx.annotation.NonNull; +import org.thoughtcrime.securesms.database.model.databaseprotos.Wallpaper; + import java.util.Arrays; import java.util.List; @@ -26,4 +28,6 @@ public interface ChatWallpaper extends Parcelable { GradientChatWallpaper.GRADIENT_2); void loadInto(@NonNull ImageView imageView); + + @NonNull Wallpaper serialize(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperFactory.java b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperFactory.java new file mode 100644 index 0000000000..90556237b8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/ChatWallpaperFactory.java @@ -0,0 +1,48 @@ +package org.thoughtcrime.securesms.wallpaper; + +import android.net.Uri; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.database.model.databaseprotos.Wallpaper; + +/** + * Converts persisted models of wallpaper into usable {@link ChatWallpaper} instances. + */ +public class ChatWallpaperFactory { + + public static @NonNull ChatWallpaper create(@NonNull Wallpaper model) { + if (model.hasSingleColor()) { + return new GradientChatWallpaper(model.getSingleColor().getColor()); + } else if (model.hasLinearGradient()) { + return buildForLinearGradinent(model.getLinearGradient()); + } else if (model.hasFile()) { + return buildForFile(model.getFile()); + } else { + throw new IllegalArgumentException(); + } + } + + public static @NonNull ChatWallpaper create(@NonNull Uri uri) { + return new UriChatWallpaper(uri); + } + + private static @NonNull ChatWallpaper buildForLinearGradinent(@NonNull Wallpaper.LinearGradient gradient) { + int[] colors = new int[gradient.getColorsCount()]; + for (int i = 0; i < colors.length; i++) { + colors[i] = gradient.getColors(i); + } + + float[] positions = new float[gradient.getPositionsCount()]; + for (int i = 0; i < positions.length; i++) { + positions[i] = gradient.getPositions(i); + } + + return new GradientChatWallpaper(gradient.getRotation(), colors, positions); + } + + private static @NonNull ChatWallpaper buildForFile(@NonNull Wallpaper.File file) { + Uri uri = Uri.parse(file.getUri()); + return new UriChatWallpaper(uri); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/GradientChatWallpaper.java b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/GradientChatWallpaper.java index b1db74de5e..6fc7b91711 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/GradientChatWallpaper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/GradientChatWallpaper.java @@ -16,6 +16,8 @@ import android.widget.ImageView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import org.thoughtcrime.securesms.database.model.databaseprotos.Wallpaper; + import java.util.Arrays; import java.util.Objects; @@ -81,6 +83,25 @@ final class GradientChatWallpaper implements ChatWallpaper, Parcelable { imageView.setImageDrawable(buildDrawable()); } + @Override + public @NonNull Wallpaper serialize() { + Wallpaper.LinearGradient.Builder builder = Wallpaper.LinearGradient.newBuilder(); + + builder.setRotation(degrees); + + for (int color : colors) { + builder.addColors(color); + } + + for (float position : positions) { + builder.addPositions(position); + } + + return Wallpaper.newBuilder() + .setLinearGradient(builder) + .build(); + } + @Override public boolean equals(Object o) { if (this == o) return true; diff --git a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/UriChatWallpaper.java b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/UriChatWallpaper.java index c273407316..f199018825 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/UriChatWallpaper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/UriChatWallpaper.java @@ -7,46 +7,50 @@ import android.widget.ImageView; import androidx.annotation.NonNull; +import org.thoughtcrime.securesms.database.model.databaseprotos.Wallpaper; import org.thoughtcrime.securesms.mms.GlideApp; final class UriChatWallpaper implements ChatWallpaper, Parcelable { private final Uri uri; - UriChatWallpaper(@NonNull Uri uri) { + public UriChatWallpaper(@NonNull Uri uri) { this.uri = uri; } - protected UriChatWallpaper(Parcel in) { - uri = in.readParcelable(Uri.class.getClassLoader()); - } - - @Override - public void writeToParcel(Parcel dest, int flags) { - dest.writeParcelable(uri, flags); - } - - @Override - public int describeContents() { - return 0; - } - - public static final Creator CREATOR = new Creator() { - @Override - public UriChatWallpaper createFromParcel(Parcel in) { - return new UriChatWallpaper(in); - } - - @Override - public UriChatWallpaper[] newArray(int size) { - return new UriChatWallpaper[size]; - } - }; - @Override public void loadInto(@NonNull ImageView imageView) { GlideApp.with(imageView) .load(uri) .into(imageView); } + + @Override + public @NonNull Wallpaper serialize() { + return Wallpaper.newBuilder() + .setFile(Wallpaper.File.newBuilder().setUri(uri.toString())) + .build(); + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(uri.toString()); + } + + public static final Creator CREATOR = new Creator() { + @Override + public UriChatWallpaper createFromParcel(Parcel in) { + return new UriChatWallpaper(Uri.parse(in.readString())); + } + + @Override + public UriChatWallpaper[] newArray(int size) { + return new UriChatWallpaper[size]; + } + }; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/wallpaper/WallpaperStorage.java b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/WallpaperStorage.java new file mode 100644 index 0000000000..eb3e32032d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/wallpaper/WallpaperStorage.java @@ -0,0 +1,105 @@ +package org.thoughtcrime.securesms.wallpaper; + +import android.content.Context; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; + +import com.annimon.stream.Stream; + +import org.signal.core.util.StreamUtil; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.crypto.AttachmentSecret; +import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider; +import org.thoughtcrime.securesms.crypto.ModernDecryptingPartInputStream; +import org.thoughtcrime.securesms.crypto.ModernEncryptingPartOutputStream; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.mms.PartAuthority; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.List; +import java.util.Objects; + +/** + * Manages the storage of custom wallpaper files. + */ +public final class WallpaperStorage { + + private static final String TAG = Log.tag(WallpaperStorage.class); + + private static final String DIRECTORY = "wallpapers"; + private static final String FILENAME_BASE = "wallpaper"; + + /** + * Saves the provided input stream as a new wallpaper file. + */ + @WorkerThread + public static @NonNull ChatWallpaper save(@NonNull Context context, @NonNull InputStream wallpaperStream) throws IOException { + File directory = context.getDir(DIRECTORY, Context.MODE_PRIVATE); + File file = File.createTempFile(FILENAME_BASE, "", directory); + + StreamUtil.copy(wallpaperStream, getOutputStream(context, file)); + + return ChatWallpaperFactory.create(PartAuthority.getWallpaperUri(file.getName())); + } + + @WorkerThread + public static @NonNull InputStream read(@NonNull Context context, String filename) throws IOException { + File directory = context.getDir(DIRECTORY, Context.MODE_PRIVATE); + File wallpaperFile = new File(directory, filename); + + return getInputStream(context, wallpaperFile); + } + + @WorkerThread + public static @NonNull List getAll(@NonNull Context context) { + File directory = context.getDir(DIRECTORY, Context.MODE_PRIVATE); + File[] allFiles = directory.listFiles(pathname -> pathname.getName().contains(FILENAME_BASE)); + + return Stream.of(allFiles) + .map(File::getName) + .map(PartAuthority::getWallpaperUri) + .map(ChatWallpaperFactory::create) + .toList(); + } + + /** + * Called when wallpaper is deselected. This will check anywhere the wallpaper could be used, and + * if we discover it's unused, we'll delete the file. + */ + @WorkerThread + public static void onWallpaperDeselected(@NonNull Context context, @NonNull Uri uri) { + Uri globalUri = SignalStore.wallpaper().getCurrentWallpaperUri(); + if (Objects.equals(uri, globalUri)) { + return; + } + + int recipientCount = DatabaseFactory.getRecipientDatabase(context).getWallpaperUriUsageCount(uri); + if (recipientCount > 0) { + return; + } + + String filename = PartAuthority.getWallpaperFilename(uri); + File directory = context.getDir(DIRECTORY, Context.MODE_PRIVATE); + File wallpaperFile = new File(directory, filename); + + if (!wallpaperFile.delete()) { + Log.w(TAG, "Failed to delete " + filename + "!"); + } + } + + private static @NonNull OutputStream getOutputStream(@NonNull Context context, File outputFile) throws IOException { + AttachmentSecret attachmentSecret = AttachmentSecretProvider.getInstance(context).getOrCreateAttachmentSecret(); + return ModernEncryptingPartOutputStream.createFor(attachmentSecret, outputFile, true).second; + } + + private static @NonNull InputStream getInputStream(@NonNull Context context, File inputFile) throws IOException { + AttachmentSecret attachmentSecret = AttachmentSecretProvider.getInstance(context).getOrCreateAttachmentSecret(); + return ModernDecryptingPartInputStream.createFor(attachmentSecret, inputFile, 0); + } +} diff --git a/app/src/main/proto/Database.proto b/app/src/main/proto/Database.proto index 691c4d40cf..11bc7fe7e8 100644 --- a/app/src/main/proto/Database.proto +++ b/app/src/main/proto/Database.proto @@ -90,4 +90,26 @@ message DeviceLastResetTime { } repeated Pair resetTime = 1; +} + +message Wallpaper { + message SingleColor { + int32 color = 1; + } + message LinearGradient { + float rotation = 1; + repeated int32 colors = 2; + repeated float positions = 3; + } + message File { + string uri = 1; + } + + oneof wallpaper { + SingleColor singleColor = 1; + LinearGradient linearGradient = 2; + File file = 3; + } + + float dimLevelInDarkMode = 4; } \ No newline at end of file