diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java index b103e7434b..ef4783386b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java @@ -61,6 +61,7 @@ import org.thoughtcrime.securesms.jobs.PushNotificationReceiveJob; import org.thoughtcrime.securesms.jobs.RefreshPreKeysJob; import org.thoughtcrime.securesms.jobs.RetrieveProfileJob; import org.thoughtcrime.securesms.jobs.RetrieveRemoteAnnouncementsJob; +import org.thoughtcrime.securesms.jobs.StoryOnboardingDownloadJob; import org.thoughtcrime.securesms.jobs.SubscriptionKeepAliveJob; import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.logging.CustomSignalProtocolLogger; @@ -203,6 +204,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr .addPostRender(() -> ApplicationDependencies.getJobManager().add(new FontDownloaderJob())) .addPostRender(CheckServiceReachabilityJob::enqueueIfNecessary) .addPostRender(GroupV2UpdateSelfProfileKeyJob::enqueueForGroupsIfNecessary) + .addPostRender(StoryOnboardingDownloadJob.Companion::enqueueIfNeeded) .execute(); Log.d(TAG, "onCreate() took " + (System.currentTimeMillis() - startTime) + " ms"); diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsRepository.kt index 88afc0e3ec..c0cda821bb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsRepository.kt @@ -38,7 +38,7 @@ class InternalSettingsRepository(context: Context) { val recipientId = SignalStore.releaseChannelValues().releaseChannelRecipientId!! val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(recipientId)) - val insertResult: MessageDatabase.InsertResult? = ReleaseChannel.insertAnnouncement( + val insertResult: MessageDatabase.InsertResult? = ReleaseChannel.insertReleaseChannelMessage( recipientId = recipientId, body = body, threadId = threadId, diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MessageDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MessageDatabase.java index 5eea83929a..9cfcd7301c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessageDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessageDatabase.java @@ -201,7 +201,7 @@ public abstract class MessageDatabase extends Database implements MmsSmsColumns public abstract boolean hasSelfReplyInGroupStory(long parentStoryId); public abstract @NonNull Cursor getStoryReplies(long parentStoryId); public abstract @Nullable Long getOldestStorySendTimestamp(); - public abstract int deleteStoriesOlderThan(long timestamp); + public abstract int deleteStoriesOlderThan(long timestamp, boolean hasSeenReleaseChannelStories); public abstract @NonNull MessageDatabase.Reader getUnreadStories(@NonNull RecipientId recipientId, int limit); public abstract @Nullable ParentStoryId.GroupReply getParentStoryIdForGroupReply(long messageId); public abstract void deleteGroupStoryReplies(long parentStoryId); @@ -728,9 +728,38 @@ public abstract class MessageDatabase extends Database implements MmsSmsColumns void onComplete(); } - public interface Reader extends Closeable { + /** + * Allows the developer to safely iterate over and close a cursor containing + * data for MessageRecord objects. Supports for-each loops as well as try-with-resources + * blocks. + * + * Readers are considered "one-shot" and it's on the caller to decide what needs + * to be done with the data. Once read, a reader cannot be read from again. This + * is by design, since reading data out of a cursor involves object creations and + * lookups, so it is in the best interest of app performance to only read out the + * data once. If you need to parse the list multiple times, it is recommended that + * you copy the iterable out into a normal List, or use extension methods such as + * partition. + * + * This reader does not support removal, since this would be considered a destructive + * database call. + */ + public interface Reader extends Closeable, Iterable { + /** + * @deprecated Use the Iterable interface instead. + */ + @Deprecated MessageRecord getNext(); + + /** + * @deprecated Use the Iterable interface instead. + */ + @Deprecated MessageRecord getCurrent(); + + /** + * From the {@link Closeable} interface, removing the IOException requirement. + */ void close(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java index 4f12782091..b2e053b590 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java @@ -98,9 +98,11 @@ import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; +import java.util.Iterator; import java.util.LinkedList; import java.util.List; import java.util.Map; +import java.util.NoSuchElementException; import java.util.Optional; import java.util.Set; import java.util.UUID; @@ -917,26 +919,28 @@ public class MmsDatabase extends MessageDatabase { } @Override - public int deleteStoriesOlderThan(long timestamp) { + public int deleteStoriesOlderThan(long timestamp, boolean hasSeenReleaseChannelStories) { SQLiteDatabase db = databaseHelper.getSignalWritableDatabase(); db.beginTransaction(); try { - String storiesBeforeTimestampWhere = IS_STORY_CLAUSE + " AND " + DATE_SENT + " < ?"; - String[] sharedArgs = SqlUtil.buildArgs(timestamp); - String deleteStoryRepliesQuery = "DELETE FROM " + TABLE_NAME + " " + - "WHERE " + PARENT_STORY_ID + " > 0 AND " + PARENT_STORY_ID + " IN (" + - "SELECT " + ID + " " + - "FROM " + TABLE_NAME + " " + - "WHERE " + storiesBeforeTimestampWhere + - ")"; - String disassociateQuoteQuery = "UPDATE " + TABLE_NAME + " " + - "SET " + QUOTE_MISSING + " = 1, " + QUOTE_BODY + " = '' " + - "WHERE " + PARENT_STORY_ID + " < 0 AND ABS(" + PARENT_STORY_ID + ") IN (" + - "SELECT " + ID + " " + - "FROM " + TABLE_NAME + " " + - "WHERE " + storiesBeforeTimestampWhere + - ")"; + RecipientId releaseChannelRecipient = hasSeenReleaseChannelStories ? null : SignalStore.releaseChannelValues().getReleaseChannelRecipientId(); + long releaseChannelThreadId = releaseChannelRecipient != null ? SignalDatabase.threads().getOrCreateThreadIdFor(Recipient.resolved(releaseChannelRecipient)) : -1; + String storiesBeforeTimestampWhere = IS_STORY_CLAUSE + " AND " + DATE_SENT + " < ? AND " + THREAD_ID + " != ?"; + String[] sharedArgs = SqlUtil.buildArgs(timestamp, releaseChannelThreadId); + String deleteStoryRepliesQuery = "DELETE FROM " + TABLE_NAME + " " + + "WHERE " + PARENT_STORY_ID + " > 0 AND " + PARENT_STORY_ID + " IN (" + + "SELECT " + ID + " " + + "FROM " + TABLE_NAME + " " + + "WHERE " + storiesBeforeTimestampWhere + + ")"; + String disassociateQuoteQuery = "UPDATE " + TABLE_NAME + " " + + "SET " + QUOTE_MISSING + " = 1, " + QUOTE_BODY + " = '' " + + "WHERE " + PARENT_STORY_ID + " < 0 AND ABS(" + PARENT_STORY_ID + ") IN (" + + "SELECT " + ID + " " + + "FROM " + TABLE_NAME + " " + + "WHERE " + storiesBeforeTimestampWhere + + ")"; db.execSQL(deleteStoryRepliesQuery, sharedArgs); db.execSQL(disassociateQuoteQuery, sharedArgs); @@ -2631,6 +2635,15 @@ public class MmsDatabase extends MessageDatabase { } } + /** + * MessageRecord reader which implements the Iterable interface. This allows it to + * be used with many Kotlin Extension Functions as well as with for-each loops. + * + * Note that it's the responsibility of the developer using the reader to ensure that: + * + * 1. They only utilize one of the two interfaces (legacy or iterator) + * 1. They close this reader after use, preferably via try-with-resources or a use block. + */ public static class Reader implements MessageDatabase.Reader { private final Cursor cursor; @@ -2854,6 +2867,29 @@ public class MmsDatabase extends MessageDatabase { cursor.close(); } } + + @NonNull + @Override + public Iterator iterator() { + return new ReaderIterator(); + } + + private class ReaderIterator implements Iterator { + @Override + public boolean hasNext() { + return cursor != null && cursor.getCount() != 0 && !cursor.isLast(); + } + + @Override + public MessageRecord next() { + MessageRecord record = getNext(); + if (record == null) { + throw new NoSuchElementException(); + } + + return record; + } + } } private long generatePduCompatTimestamp(long time) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java index b8274740e5..93f29bf277 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java @@ -1477,7 +1477,7 @@ public class SmsDatabase extends MessageDatabase { } @Override - public int deleteStoriesOlderThan(long timestamp) { + public int deleteStoriesOlderThan(long timestamp, boolean hasSeenReleaseChannelStories) { throw new UnsupportedOperationException(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java index 4455da317b..3c86805fea 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java @@ -184,6 +184,7 @@ public final class JobManagerFactories { put(StorageSyncJob.KEY, new StorageSyncJob.Factory()); put(SubscriptionKeepAliveJob.KEY, new SubscriptionKeepAliveJob.Factory()); put(SubscriptionReceiptRequestResponseJob.KEY, new SubscriptionReceiptRequestResponseJob.Factory()); + put(StoryOnboardingDownloadJob.KEY, new StoryOnboardingDownloadJob.Factory()); put(SubmitRateLimitPushChallengeJob.KEY, new SubmitRateLimitPushChallengeJob.Factory()); put(ThreadUpdateJob.KEY, new ThreadUpdateJob.Factory()); put(TrimThreadJob.KEY, new TrimThreadJob.Factory()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveRemoteAnnouncementsJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveRemoteAnnouncementsJob.kt index 587ca49979..8c40762414 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveRemoteAnnouncementsJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveRemoteAnnouncementsJob.kt @@ -199,7 +199,7 @@ class RetrieveRemoteAnnouncementsJob private constructor(private val force: Bool } ThreadUtil.sleep(5) - val insertResult: MessageDatabase.InsertResult? = ReleaseChannel.insertAnnouncement( + val insertResult: MessageDatabase.InsertResult? = ReleaseChannel.insertReleaseChannelMessage( recipientId = values.releaseChannelRecipientId!!, body = body, threadId = threadId, diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/StoryOnboardingDownloadJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/StoryOnboardingDownloadJob.kt new file mode 100644 index 0000000000..44a38549bd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/StoryOnboardingDownloadJob.kt @@ -0,0 +1,196 @@ +package org.thoughtcrime.securesms.jobs + +import androidx.core.os.LocaleListCompat +import com.fasterxml.jackson.core.JsonParseException +import org.json.JSONArray +import org.json.JSONObject +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.database.MessageDatabase +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.StoryType +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies +import org.thoughtcrime.securesms.jobmanager.Data +import org.thoughtcrime.securesms.jobmanager.Job +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.releasechannel.ReleaseChannel +import org.thoughtcrime.securesms.s3.S3 +import org.thoughtcrime.securesms.stories.Stories +import org.thoughtcrime.securesms.transport.RetryLaterException +import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException +import java.util.Locale + +/** + * Kicks off the necessary work to download the resources for the onboarding story. + */ +class StoryOnboardingDownloadJob private constructor(parameters: Parameters) : BaseJob(parameters) { + + companion object { + + private const val ONBOARDING_MANIFEST_ENDPOINT = "${S3.DYNAMIC_PATH}/android/stories/onboarding/manifest.json" + private const val ONBOARDING_IMAGE_PATH = "${S3.STATIC_PATH}/android/stories/onboarding" + private const val ONBOARDING_EXTENSION = ".jpg" + private const val ONBOARDING_IMAGE_COUNT = 5 + private const val ONBOARDING_IMAGE_WIDTH = 1125 + private const val ONBOARDING_IMAGE_HEIGHT = 1998 + + const val KEY = "StoryOnboardingDownloadJob" + + private val TAG = Log.tag(StoryOnboardingDownloadJob::class.java) + + private fun create(): Job { + return StoryOnboardingDownloadJob( + Parameters.Builder() + .addConstraint(NetworkConstraint.KEY) + .setQueue("StoryOnboardingDownloadJob") + .setMaxInstancesForFactory(1) + .setMaxAttempts(3) + .build() + ) + } + + fun enqueueIfNeeded() { + if (SignalStore.storyValues().hasDownloadedOnboardingStory || !Stories.isFeatureAvailable()) { + return + } + + Log.d(TAG, "Attempting to enqueue StoryOnboardingDownloadJob...") + ApplicationDependencies.getJobManager() + .startChain(CreateReleaseChannelJob.create()) + .then(create()) + .enqueue() + } + } + + override fun serialize(): Data = Data.EMPTY + override fun getFactoryKey(): String = KEY + override fun onFailure() = Unit + + override fun onRun() { + if (SignalStore.storyValues().hasDownloadedOnboardingStory) { + Log.i(TAG, "Already downloaded onboarding story. Exiting.") + return + } + + val releaseChannelRecipientId = SignalStore.releaseChannelValues().releaseChannelRecipientId + if (releaseChannelRecipientId == null) { + Log.w(TAG, "Cannot create story onboarding without release channel recipient.") + throw Exception("No release channel recipient.") + } + + SignalDatabase.mms.getAllStoriesFor(releaseChannelRecipientId).use { reader -> + reader.forEach { messageRecord -> + SignalDatabase.mms.deleteMessage(messageRecord.id) + } + } + + val manifest: JSONObject = try { + JSONObject(S3.getString(ONBOARDING_MANIFEST_ENDPOINT)) + } catch (e: JsonParseException) { + Log.w(TAG, "Returned data could not be parsed into JSON", e) + throw e + } catch (e: NonSuccessfulResponseCodeException) { + Log.w(TAG, "Returned non-successful response code from server.", e) + throw RetryLaterException() + } + + if (!manifest.has("languages")) { + Log.w(TAG, "Could not find languages set in manifest.") + throw Exception("Could not find languages set in manifest.") + } + + if (!manifest.has("version")) { + Log.w(TAG, "Could not find version in manifest") + throw Exception("Could not find version in manifest.") + } + + val version = manifest.getString("version") + Log.i(TAG, "Using images for manifest version $version") + + val languages = manifest.getJSONObject("languages") + val languageCodeCandidates: List = getLocaleCodes() + var candidateArray: JSONArray? = null + for (candidate in languageCodeCandidates) { + if (languages.has(candidate)) { + candidateArray = languages.getJSONArray(candidate) + break + } + } + + if (candidateArray == null) { + Log.w(TAG, "Could not find a candidate for the language set: $languageCodeCandidates") + throw Exception("Failed to locate onboarding image set.") + } + + val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(releaseChannelRecipientId)) + + Log.i(TAG, "Inserting messages...") + val insertResults: List = (0 until candidateArray.length()).mapNotNull { + val insertResult: MessageDatabase.InsertResult? = ReleaseChannel.insertReleaseChannelMessage( + releaseChannelRecipientId, + "", + threadId, + "$ONBOARDING_IMAGE_PATH/$version/${candidateArray.getString(it)}$ONBOARDING_EXTENSION", + ONBOARDING_IMAGE_WIDTH, + ONBOARDING_IMAGE_HEIGHT, + storyType = StoryType.STORY_WITHOUT_REPLIES + ) + + Thread.sleep(5) + + insertResult + } + + if (insertResults.size != ONBOARDING_IMAGE_COUNT) { + Log.w(TAG, "Failed to insert some search results. Deleting the ones we added and trying again later.") + insertResults.forEach { + SignalDatabase.mms.deleteMessage(it.messageId) + } + + throw RetryLaterException() + } + + Log.d(TAG, "Marking onboarding story downloaded.") + SignalStore.storyValues().hasDownloadedOnboardingStory = true + + Log.i(TAG, "Enqueueing download jobs...") + insertResults.forEach { insertResult -> + SignalDatabase.attachments.getAttachmentsForMessage(insertResult.messageId).forEach { + ApplicationDependencies.getJobManager().add(AttachmentDownloadJob(insertResult.messageId, it.attachmentId, true)) + } + } + } + + override fun onShouldRetry(e: Exception): Boolean = e is RetryLaterException + + private fun getLocaleCodes(): List { + val localeList: LocaleListCompat = LocaleListCompat.getDefault() + + val potentialOnboardingUrlLanguages = mutableListOf() + + if (SignalStore.settings().language != "zz") { + potentialOnboardingUrlLanguages += SignalStore.settings().language + } + + for (index in 0 until localeList.size()) { + val locale: Locale = localeList.get(index) + if (locale.language.isNotEmpty()) { + if (locale.country.isNotEmpty()) { + potentialOnboardingUrlLanguages += "${locale.language}_${locale.country}" + } + potentialOnboardingUrlLanguages += locale.language + } + } + + potentialOnboardingUrlLanguages += "en" + + return potentialOnboardingUrlLanguages + } + + class Factory : Job.Factory { + override fun create(parameters: Parameters, data: Data): StoryOnboardingDownloadJob { + return StoryOnboardingDownloadJob(parameters) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/StoryValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/StoryValues.kt index bb7a157903..22a8c5a34a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/StoryValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/StoryValues.kt @@ -39,6 +39,16 @@ internal class StoryValues(store: KeyValueStore) : SignalStoreValues(store) { * Whether or not the user has see the "Navigation education" view */ private const val USER_HAS_SEEN_FIRST_NAV_VIEW = "stories.user.has.seen.first.navigation.view" + + /** + * Whether or not the onboarding story has been downloaded. + */ + private const val HAS_DOWNLOADED_ONBOARDING_STORY = "stories.has.downloaded.onboarding" + + /** + * Marks whether the user has seen the onboarding story + */ + private const val USER_HAS_SEEN_ONBOARDING_STORY = "stories.user.has.seen.onboarding" } override fun onFirstEverAppLaunch() = Unit @@ -48,7 +58,8 @@ internal class StoryValues(store: KeyValueStore) : SignalStoreValues(store) { USER_HAS_ADDED_TO_A_STORY, VIDEO_TOOLTIP_SEEN_MARKER, CANNOT_SEND_SEEN_MARKER, - USER_HAS_SEEN_FIRST_NAV_VIEW + USER_HAS_SEEN_FIRST_NAV_VIEW, + HAS_DOWNLOADED_ONBOARDING_STORY ) var isFeatureDisabled: Boolean by booleanValue(MANUAL_FEATURE_DISABLE, false) @@ -63,6 +74,10 @@ internal class StoryValues(store: KeyValueStore) : SignalStoreValues(store) { var userHasSeenFirstNavView: Boolean by booleanValue(USER_HAS_SEEN_FIRST_NAV_VIEW, false) + var hasDownloadedOnboardingStory: Boolean by booleanValue(HAS_DOWNLOADED_ONBOARDING_STORY, false) + + var userHasSeenOnboardingStory: Boolean by booleanValue(USER_HAS_SEEN_ONBOARDING_STORY, false) + fun setLatestStorySend(storySend: StorySend) { synchronized(this) { val storySends: List = getList(LATEST_STORY_SENDS, StorySendSerializer) diff --git a/app/src/main/java/org/thoughtcrime/securesms/releasechannel/ReleaseChannel.kt b/app/src/main/java/org/thoughtcrime/securesms/releasechannel/ReleaseChannel.kt index 14c2cd6402..c6d609b491 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/releasechannel/ReleaseChannel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/releasechannel/ReleaseChannel.kt @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.releasechannel import org.thoughtcrime.securesms.attachments.PointerAttachment import org.thoughtcrime.securesms.database.MessageDatabase import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.StoryType import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList import org.thoughtcrime.securesms.mms.IncomingMediaMessage import org.thoughtcrime.securesms.recipients.RecipientId @@ -19,7 +20,7 @@ object ReleaseChannel { const val CDN_NUMBER = -1 - fun insertAnnouncement( + fun insertReleaseChannelMessage( recipientId: RecipientId, body: String, threadId: Long, @@ -27,7 +28,8 @@ object ReleaseChannel { imageWidth: Int = 0, imageHeight: Int = 0, serverUuid: String? = UUID.randomUUID().toString(), - messageRanges: BodyRangeList? = null + messageRanges: BodyRangeList? = null, + storyType: StoryType = StoryType.NONE ): MessageDatabase.InsertResult? { val attachments: Optional> = if (image != null) { @@ -63,7 +65,8 @@ object ReleaseChannel { body = body, attachments = PointerAttachment.forPointers(attachments), serverGuid = serverUuid, - messageRanges = messageRanges + messageRanges = messageRanges, + storyType = storyType ) return SignalDatabase.mms.insertSecureDecryptedMessageInbox(message, threadId).orElse(null) diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringStoriesManager.kt b/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringStoriesManager.kt index 9dc6b5a59f..3a188a2388 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringStoriesManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringStoriesManager.kt @@ -8,6 +8,7 @@ import androidx.annotation.WorkerThread import org.signal.core.util.logging.Log import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.dependencies.ApplicationDependencies +import org.thoughtcrime.securesms.keyvalue.SignalStore import java.util.concurrent.TimeUnit /** @@ -43,7 +44,7 @@ class ExpiringStoriesManager( @WorkerThread override fun executeEvent(event: Event) { val threshold = System.currentTimeMillis() - STORY_LIFESPAN - val deletes = mmsDatabase.deleteStoriesOlderThan(threshold) + val deletes = mmsDatabase.deleteStoriesOlderThan(threshold, SignalStore.storyValues().userHasSeenOnboardingStory) Log.i(TAG, "Deleted $deletes stories before $threshold") } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/dialogs/StoryContextMenu.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/dialogs/StoryContextMenu.kt index 949e73e82c..6eae421fd7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/dialogs/StoryContextMenu.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/dialogs/StoryContextMenu.kt @@ -88,6 +88,7 @@ object StoryContextMenu { anchorView = anchorView, isFromSelf = model.data.primaryStory.messageRecord.isOutgoing, isToGroup = model.data.storyRecipient.isGroup, + isFromReleaseChannel = model.data.storyRecipient.isReleaseNotes, canHide = !model.data.isHidden, callbacks = object : Callbacks { override fun onHide() = model.onHideStory(model) @@ -120,6 +121,7 @@ object StoryContextMenu { anchorView = anchorView, isFromSelf = selectedStory.sender.isSelf, isToGroup = selectedStory.group != null, + isFromReleaseChannel = selectedStory.sender.isReleaseNotes, canHide = true, callbacks = object : Callbacks { override fun onHide() = onHide(selectedStory) @@ -145,6 +147,7 @@ object StoryContextMenu { anchorView = anchorView, isFromSelf = true, isToGroup = false, + isFromReleaseChannel = false, canHide = false, callbacks = object : Callbacks { override fun onHide() = throw NotImplementedError() @@ -164,6 +167,7 @@ object StoryContextMenu { anchorView: View, isFromSelf: Boolean, isToGroup: Boolean, + isFromReleaseChannel: Boolean, rootView: ViewGroup = anchorView.rootView as ViewGroup, canHide: Boolean, callbacks: Callbacks @@ -208,7 +212,7 @@ object StoryContextMenu { ) } - if (isToGroup || !isFromSelf) { + if ((isToGroup || !isFromSelf) && !isFromReleaseChannel) { add( ActionItem(R.drawable.ic_open_24_tinted, context.getString(R.string.StoriesLandingItem__go_to_chat)) { callbacks.onGoToChat() diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingItem.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingItem.kt index c8c6fc40a3..71821fe412 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingItem.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingItem.kt @@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.stories.landing import android.graphics.Color import android.graphics.drawable.Drawable +import android.text.SpannableStringBuilder import android.view.View import android.widget.ImageView import android.widget.TextView @@ -19,6 +20,7 @@ import org.thoughtcrime.securesms.mms.GlideApp import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.stories.StoryTextPostModel import org.thoughtcrime.securesms.stories.dialogs.StoryContextMenu +import org.thoughtcrime.securesms.util.ContextUtil import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.SpanUtil import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory @@ -188,6 +190,7 @@ object StoriesLandingItem { sender.text = when { model.data.storyRecipient.isMyStory -> context.getText(R.string.StoriesLandingFragment__my_stories) model.data.storyRecipient.isGroup -> getGroupPresentation(model) + model.data.storyRecipient.isReleaseNotes -> getReleaseNotesPresentation(model) else -> model.data.storyRecipient.getDisplayName(context) } @@ -245,6 +248,15 @@ object StoriesLandingItem { ) } + private fun getReleaseNotesPresentation(model: Model): CharSequence { + val official = ContextUtil.requireDrawable(context, R.drawable.ic_official_20) + + val name = SpannableStringBuilder(model.data.storyRecipient.getDisplayName(context)) + SpanUtil.appendCenteredImageSpan(name, official, 20, 20) + + return name + } + private fun getIndividualPresentation(model: Model): String { return if (model.data.primaryStory.messageRecord.isOutgoing) { context.getString(R.string.Recipient_you) diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingItemData.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingItemData.kt index 1edf8b6c4d..829e128de4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingItemData.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingItemData.kt @@ -21,16 +21,14 @@ data class StoriesLandingItemData( val failureCount: Long = 0 ) : Comparable { override fun compareTo(other: StoriesLandingItemData): Int { - return if (storyRecipient.isMyStory && !other.storyRecipient.isMyStory) { - -1 - } else if (!storyRecipient.isMyStory && other.storyRecipient.isMyStory) { - 1 - } else if (storyViewState == StoryViewState.UNVIEWED && other.storyViewState != StoryViewState.UNVIEWED) { - -1 - } else if (storyViewState != StoryViewState.UNVIEWED && other.storyViewState == StoryViewState.UNVIEWED) { - 1 - } else { - -dateInMilliseconds.compareTo(other.dateInMilliseconds) + return when { + storyRecipient.isMyStory && !other.storyRecipient.isMyStory -> -1 + !storyRecipient.isMyStory && other.storyRecipient.isMyStory -> 1 + storyRecipient.isReleaseNotes && !other.storyRecipient.isReleaseNotes -> 1 + !storyRecipient.isReleaseNotes && other.storyRecipient.isReleaseNotes -> -1 + storyViewState == StoryViewState.UNVIEWED && other.storyViewState != StoryViewState.UNVIEWED -> -1 + storyViewState != StoryViewState.UNVIEWED && other.storyViewState == StoryViewState.UNVIEWED -> 1 + else -> -dateInMilliseconds.compareTo(other.dateInMilliseconds) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerRepository.kt index 3ce6fab23f..3fdaa846bc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerRepository.kt @@ -5,6 +5,7 @@ import io.reactivex.rxjava3.schedulers.Schedulers import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.model.DistributionListId import org.thoughtcrime.securesms.database.model.StoryViewState +import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId @@ -16,6 +17,7 @@ open class StoryViewerRepository { return Single.create> { emitter -> val myStoriesId = SignalDatabase.recipients.getOrInsertFromDistributionListId(DistributionListId.MY_STORY) val myStories = Recipient.resolved(myStoriesId) + val releaseChannelId = SignalStore.releaseChannelValues().releaseChannelRecipientId val recipientIds = SignalDatabase.mms.orderedStoryRecipientsAndIds.groupBy { val recipient = Recipient.resolved(it.recipientId) if (recipient.isDistributionList) { @@ -42,12 +44,16 @@ open class StoryViewerRepository { }.map { it.id } emitter.onSuccess( - if (recipientIds.contains(myStoriesId)) { - listOf(myStoriesId) + (recipientIds - myStoriesId) - } else { - recipientIds - } + recipientIds.floatToTop(releaseChannelId).floatToTop(myStoriesId) ) }.subscribeOn(Schedulers.io()) } + + private fun List.floatToTop(recipientId: RecipientId?): List { + return if (recipientId != null && contains(recipientId)) { + listOf(recipientId) + (this - recipientId) + } else { + this + } + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageRepository.kt index 95f805f146..e15f79aa47 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageRepository.kt @@ -19,6 +19,7 @@ import org.thoughtcrime.securesms.database.model.databaseprotos.StoryTextPost import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.jobs.MultiDeviceViewedUpdateJob import org.thoughtcrime.securesms.jobs.SendViewedReceiptJob +import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.stories.Stories @@ -173,19 +174,24 @@ open class StoryViewerPageRepository(context: Context) { val markedMessageInfo = SignalDatabase.mms.setIncomingMessageViewed(storyPost.id) if (markedMessageInfo != null) { ApplicationDependencies.getDatabaseObserver().notifyConversationListListeners() - ApplicationDependencies.getJobManager().add( - SendViewedReceiptJob( - markedMessageInfo.threadId, - storyPost.sender.id, - markedMessageInfo.syncMessageId.timetamp, - MessageId(storyPost.id, true) - ) - ) - MultiDeviceViewedUpdateJob.enqueue(listOf(markedMessageInfo.syncMessageId)) - val recipientId = storyPost.group?.id ?: storyPost.sender.id - SignalDatabase.recipients.updateLastStoryViewTimestamp(recipientId) - Stories.enqueueNextStoriesForDownload(recipientId, true) + if (storyPost.sender.isReleaseNotes) { + SignalStore.storyValues().userHasSeenOnboardingStory = true + } else { + ApplicationDependencies.getJobManager().add( + SendViewedReceiptJob( + markedMessageInfo.threadId, + storyPost.sender.id, + markedMessageInfo.syncMessageId.timetamp, + MessageId(storyPost.id, true) + ) + ) + MultiDeviceViewedUpdateJob.enqueue(listOf(markedMessageInfo.syncMessageId)) + + val recipientId = storyPost.group?.id ?: storyPost.sender.id + SignalDatabase.recipients.updateLastStoryViewTimestamp(recipientId) + Stories.enqueueNextStoriesForDownload(recipientId, true) + } } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ContextUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/ContextUtil.java index f89cf738e8..fc0d7d68f7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ContextUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ContextUtil.java @@ -5,7 +5,7 @@ import android.graphics.drawable.Drawable; import androidx.annotation.DrawableRes; import androidx.annotation.NonNull; -import androidx.core.content.ContextCompat; +import androidx.appcompat.content.res.AppCompatResources; import java.util.Objects; @@ -13,7 +13,7 @@ public final class ContextUtil { private ContextUtil() {} public static @NonNull Drawable requireDrawable(@NonNull Context context, @DrawableRes int drawable) { - return Objects.requireNonNull(ContextCompat.getDrawable(context, drawable)); + return Objects.requireNonNull(AppCompatResources.getDrawable(context, drawable)); } }