Implement Story onboarding download job and message insertion.

This commit is contained in:
Alex Hart 2022-07-01 14:29:50 -03:00 committed by Greyson Parrelli
parent 2270dfaf21
commit 36ccf9ca54
17 changed files with 365 additions and 56 deletions

View file

@ -61,6 +61,7 @@ import org.thoughtcrime.securesms.jobs.PushNotificationReceiveJob;
import org.thoughtcrime.securesms.jobs.RefreshPreKeysJob; import org.thoughtcrime.securesms.jobs.RefreshPreKeysJob;
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob; import org.thoughtcrime.securesms.jobs.RetrieveProfileJob;
import org.thoughtcrime.securesms.jobs.RetrieveRemoteAnnouncementsJob; import org.thoughtcrime.securesms.jobs.RetrieveRemoteAnnouncementsJob;
import org.thoughtcrime.securesms.jobs.StoryOnboardingDownloadJob;
import org.thoughtcrime.securesms.jobs.SubscriptionKeepAliveJob; import org.thoughtcrime.securesms.jobs.SubscriptionKeepAliveJob;
import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.logging.CustomSignalProtocolLogger; import org.thoughtcrime.securesms.logging.CustomSignalProtocolLogger;
@ -203,6 +204,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
.addPostRender(() -> ApplicationDependencies.getJobManager().add(new FontDownloaderJob())) .addPostRender(() -> ApplicationDependencies.getJobManager().add(new FontDownloaderJob()))
.addPostRender(CheckServiceReachabilityJob::enqueueIfNecessary) .addPostRender(CheckServiceReachabilityJob::enqueueIfNecessary)
.addPostRender(GroupV2UpdateSelfProfileKeyJob::enqueueForGroupsIfNecessary) .addPostRender(GroupV2UpdateSelfProfileKeyJob::enqueueForGroupsIfNecessary)
.addPostRender(StoryOnboardingDownloadJob.Companion::enqueueIfNeeded)
.execute(); .execute();
Log.d(TAG, "onCreate() took " + (System.currentTimeMillis() - startTime) + " ms"); Log.d(TAG, "onCreate() took " + (System.currentTimeMillis() - startTime) + " ms");

View file

@ -38,7 +38,7 @@ class InternalSettingsRepository(context: Context) {
val recipientId = SignalStore.releaseChannelValues().releaseChannelRecipientId!! val recipientId = SignalStore.releaseChannelValues().releaseChannelRecipientId!!
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(recipientId)) val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(recipientId))
val insertResult: MessageDatabase.InsertResult? = ReleaseChannel.insertAnnouncement( val insertResult: MessageDatabase.InsertResult? = ReleaseChannel.insertReleaseChannelMessage(
recipientId = recipientId, recipientId = recipientId,
body = body, body = body,
threadId = threadId, threadId = threadId,

View file

@ -201,7 +201,7 @@ public abstract class MessageDatabase extends Database implements MmsSmsColumns
public abstract boolean hasSelfReplyInGroupStory(long parentStoryId); public abstract boolean hasSelfReplyInGroupStory(long parentStoryId);
public abstract @NonNull Cursor getStoryReplies(long parentStoryId); public abstract @NonNull Cursor getStoryReplies(long parentStoryId);
public abstract @Nullable Long getOldestStorySendTimestamp(); 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 @NonNull MessageDatabase.Reader getUnreadStories(@NonNull RecipientId recipientId, int limit);
public abstract @Nullable ParentStoryId.GroupReply getParentStoryIdForGroupReply(long messageId); public abstract @Nullable ParentStoryId.GroupReply getParentStoryIdForGroupReply(long messageId);
public abstract void deleteGroupStoryReplies(long parentStoryId); public abstract void deleteGroupStoryReplies(long parentStoryId);
@ -728,9 +728,38 @@ public abstract class MessageDatabase extends Database implements MmsSmsColumns
void onComplete(); 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<MessageRecord> {
/**
* @deprecated Use the Iterable interface instead.
*/
@Deprecated
MessageRecord getNext(); MessageRecord getNext();
/**
* @deprecated Use the Iterable interface instead.
*/
@Deprecated
MessageRecord getCurrent(); MessageRecord getCurrent();
/**
* From the {@link Closeable} interface, removing the IOException requirement.
*/
void close(); void close();
} }

View file

@ -98,9 +98,11 @@ import java.util.Collection;
import java.util.Collections; import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedList; import java.util.LinkedList;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
@ -917,26 +919,28 @@ public class MmsDatabase extends MessageDatabase {
} }
@Override @Override
public int deleteStoriesOlderThan(long timestamp) { public int deleteStoriesOlderThan(long timestamp, boolean hasSeenReleaseChannelStories) {
SQLiteDatabase db = databaseHelper.getSignalWritableDatabase(); SQLiteDatabase db = databaseHelper.getSignalWritableDatabase();
db.beginTransaction(); db.beginTransaction();
try { try {
String storiesBeforeTimestampWhere = IS_STORY_CLAUSE + " AND " + DATE_SENT + " < ?"; RecipientId releaseChannelRecipient = hasSeenReleaseChannelStories ? null : SignalStore.releaseChannelValues().getReleaseChannelRecipientId();
String[] sharedArgs = SqlUtil.buildArgs(timestamp); long releaseChannelThreadId = releaseChannelRecipient != null ? SignalDatabase.threads().getOrCreateThreadIdFor(Recipient.resolved(releaseChannelRecipient)) : -1;
String deleteStoryRepliesQuery = "DELETE FROM " + TABLE_NAME + " " + String storiesBeforeTimestampWhere = IS_STORY_CLAUSE + " AND " + DATE_SENT + " < ? AND " + THREAD_ID + " != ?";
"WHERE " + PARENT_STORY_ID + " > 0 AND " + PARENT_STORY_ID + " IN (" + String[] sharedArgs = SqlUtil.buildArgs(timestamp, releaseChannelThreadId);
"SELECT " + ID + " " + String deleteStoryRepliesQuery = "DELETE FROM " + TABLE_NAME + " " +
"FROM " + TABLE_NAME + " " + "WHERE " + PARENT_STORY_ID + " > 0 AND " + PARENT_STORY_ID + " IN (" +
"WHERE " + storiesBeforeTimestampWhere + "SELECT " + ID + " " +
")"; "FROM " + TABLE_NAME + " " +
String disassociateQuoteQuery = "UPDATE " + TABLE_NAME + " " + "WHERE " + storiesBeforeTimestampWhere +
"SET " + QUOTE_MISSING + " = 1, " + QUOTE_BODY + " = '' " + ")";
"WHERE " + PARENT_STORY_ID + " < 0 AND ABS(" + PARENT_STORY_ID + ") IN (" + String disassociateQuoteQuery = "UPDATE " + TABLE_NAME + " " +
"SELECT " + ID + " " + "SET " + QUOTE_MISSING + " = 1, " + QUOTE_BODY + " = '' " +
"FROM " + TABLE_NAME + " " + "WHERE " + PARENT_STORY_ID + " < 0 AND ABS(" + PARENT_STORY_ID + ") IN (" +
"WHERE " + storiesBeforeTimestampWhere + "SELECT " + ID + " " +
")"; "FROM " + TABLE_NAME + " " +
"WHERE " + storiesBeforeTimestampWhere +
")";
db.execSQL(deleteStoryRepliesQuery, sharedArgs); db.execSQL(deleteStoryRepliesQuery, sharedArgs);
db.execSQL(disassociateQuoteQuery, 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 { public static class Reader implements MessageDatabase.Reader {
private final Cursor cursor; private final Cursor cursor;
@ -2854,6 +2867,29 @@ public class MmsDatabase extends MessageDatabase {
cursor.close(); cursor.close();
} }
} }
@NonNull
@Override
public Iterator<MessageRecord> iterator() {
return new ReaderIterator();
}
private class ReaderIterator implements Iterator<MessageRecord> {
@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) { private long generatePduCompatTimestamp(long time) {

View file

@ -1477,7 +1477,7 @@ public class SmsDatabase extends MessageDatabase {
} }
@Override @Override
public int deleteStoriesOlderThan(long timestamp) { public int deleteStoriesOlderThan(long timestamp, boolean hasSeenReleaseChannelStories) {
throw new UnsupportedOperationException(); throw new UnsupportedOperationException();
} }

View file

@ -184,6 +184,7 @@ public final class JobManagerFactories {
put(StorageSyncJob.KEY, new StorageSyncJob.Factory()); put(StorageSyncJob.KEY, new StorageSyncJob.Factory());
put(SubscriptionKeepAliveJob.KEY, new SubscriptionKeepAliveJob.Factory()); put(SubscriptionKeepAliveJob.KEY, new SubscriptionKeepAliveJob.Factory());
put(SubscriptionReceiptRequestResponseJob.KEY, new SubscriptionReceiptRequestResponseJob.Factory()); put(SubscriptionReceiptRequestResponseJob.KEY, new SubscriptionReceiptRequestResponseJob.Factory());
put(StoryOnboardingDownloadJob.KEY, new StoryOnboardingDownloadJob.Factory());
put(SubmitRateLimitPushChallengeJob.KEY, new SubmitRateLimitPushChallengeJob.Factory()); put(SubmitRateLimitPushChallengeJob.KEY, new SubmitRateLimitPushChallengeJob.Factory());
put(ThreadUpdateJob.KEY, new ThreadUpdateJob.Factory()); put(ThreadUpdateJob.KEY, new ThreadUpdateJob.Factory());
put(TrimThreadJob.KEY, new TrimThreadJob.Factory()); put(TrimThreadJob.KEY, new TrimThreadJob.Factory());

View file

@ -199,7 +199,7 @@ class RetrieveRemoteAnnouncementsJob private constructor(private val force: Bool
} }
ThreadUtil.sleep(5) ThreadUtil.sleep(5)
val insertResult: MessageDatabase.InsertResult? = ReleaseChannel.insertAnnouncement( val insertResult: MessageDatabase.InsertResult? = ReleaseChannel.insertReleaseChannelMessage(
recipientId = values.releaseChannelRecipientId!!, recipientId = values.releaseChannelRecipientId!!,
body = body, body = body,
threadId = threadId, threadId = threadId,

View file

@ -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<String> = 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<MessageDatabase.InsertResult> = (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<String> {
val localeList: LocaleListCompat = LocaleListCompat.getDefault()
val potentialOnboardingUrlLanguages = mutableListOf<String>()
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<StoryOnboardingDownloadJob> {
override fun create(parameters: Parameters, data: Data): StoryOnboardingDownloadJob {
return StoryOnboardingDownloadJob(parameters)
}
}
}

View file

@ -39,6 +39,16 @@ internal class StoryValues(store: KeyValueStore) : SignalStoreValues(store) {
* Whether or not the user has see the "Navigation education" view * 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" 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 override fun onFirstEverAppLaunch() = Unit
@ -48,7 +58,8 @@ internal class StoryValues(store: KeyValueStore) : SignalStoreValues(store) {
USER_HAS_ADDED_TO_A_STORY, USER_HAS_ADDED_TO_A_STORY,
VIDEO_TOOLTIP_SEEN_MARKER, VIDEO_TOOLTIP_SEEN_MARKER,
CANNOT_SEND_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) 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 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) { fun setLatestStorySend(storySend: StorySend) {
synchronized(this) { synchronized(this) {
val storySends: List<StorySend> = getList(LATEST_STORY_SENDS, StorySendSerializer) val storySends: List<StorySend> = getList(LATEST_STORY_SENDS, StorySendSerializer)

View file

@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.releasechannel
import org.thoughtcrime.securesms.attachments.PointerAttachment import org.thoughtcrime.securesms.attachments.PointerAttachment
import org.thoughtcrime.securesms.database.MessageDatabase import org.thoughtcrime.securesms.database.MessageDatabase
import org.thoughtcrime.securesms.database.SignalDatabase 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.database.model.databaseprotos.BodyRangeList
import org.thoughtcrime.securesms.mms.IncomingMediaMessage import org.thoughtcrime.securesms.mms.IncomingMediaMessage
import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.recipients.RecipientId
@ -19,7 +20,7 @@ object ReleaseChannel {
const val CDN_NUMBER = -1 const val CDN_NUMBER = -1
fun insertAnnouncement( fun insertReleaseChannelMessage(
recipientId: RecipientId, recipientId: RecipientId,
body: String, body: String,
threadId: Long, threadId: Long,
@ -27,7 +28,8 @@ object ReleaseChannel {
imageWidth: Int = 0, imageWidth: Int = 0,
imageHeight: Int = 0, imageHeight: Int = 0,
serverUuid: String? = UUID.randomUUID().toString(), serverUuid: String? = UUID.randomUUID().toString(),
messageRanges: BodyRangeList? = null messageRanges: BodyRangeList? = null,
storyType: StoryType = StoryType.NONE
): MessageDatabase.InsertResult? { ): MessageDatabase.InsertResult? {
val attachments: Optional<List<SignalServiceAttachment>> = if (image != null) { val attachments: Optional<List<SignalServiceAttachment>> = if (image != null) {
@ -63,7 +65,8 @@ object ReleaseChannel {
body = body, body = body,
attachments = PointerAttachment.forPointers(attachments), attachments = PointerAttachment.forPointers(attachments),
serverGuid = serverUuid, serverGuid = serverUuid,
messageRanges = messageRanges messageRanges = messageRanges,
storyType = storyType
) )
return SignalDatabase.mms.insertSecureDecryptedMessageInbox(message, threadId).orElse(null) return SignalDatabase.mms.insertSecureDecryptedMessageInbox(message, threadId).orElse(null)

View file

@ -8,6 +8,7 @@ import androidx.annotation.WorkerThread
import org.signal.core.util.logging.Log import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
/** /**
@ -43,7 +44,7 @@ class ExpiringStoriesManager(
@WorkerThread @WorkerThread
override fun executeEvent(event: Event) { override fun executeEvent(event: Event) {
val threshold = System.currentTimeMillis() - STORY_LIFESPAN 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") Log.i(TAG, "Deleted $deletes stories before $threshold")
} }

View file

@ -88,6 +88,7 @@ object StoryContextMenu {
anchorView = anchorView, anchorView = anchorView,
isFromSelf = model.data.primaryStory.messageRecord.isOutgoing, isFromSelf = model.data.primaryStory.messageRecord.isOutgoing,
isToGroup = model.data.storyRecipient.isGroup, isToGroup = model.data.storyRecipient.isGroup,
isFromReleaseChannel = model.data.storyRecipient.isReleaseNotes,
canHide = !model.data.isHidden, canHide = !model.data.isHidden,
callbacks = object : Callbacks { callbacks = object : Callbacks {
override fun onHide() = model.onHideStory(model) override fun onHide() = model.onHideStory(model)
@ -120,6 +121,7 @@ object StoryContextMenu {
anchorView = anchorView, anchorView = anchorView,
isFromSelf = selectedStory.sender.isSelf, isFromSelf = selectedStory.sender.isSelf,
isToGroup = selectedStory.group != null, isToGroup = selectedStory.group != null,
isFromReleaseChannel = selectedStory.sender.isReleaseNotes,
canHide = true, canHide = true,
callbacks = object : Callbacks { callbacks = object : Callbacks {
override fun onHide() = onHide(selectedStory) override fun onHide() = onHide(selectedStory)
@ -145,6 +147,7 @@ object StoryContextMenu {
anchorView = anchorView, anchorView = anchorView,
isFromSelf = true, isFromSelf = true,
isToGroup = false, isToGroup = false,
isFromReleaseChannel = false,
canHide = false, canHide = false,
callbacks = object : Callbacks { callbacks = object : Callbacks {
override fun onHide() = throw NotImplementedError() override fun onHide() = throw NotImplementedError()
@ -164,6 +167,7 @@ object StoryContextMenu {
anchorView: View, anchorView: View,
isFromSelf: Boolean, isFromSelf: Boolean,
isToGroup: Boolean, isToGroup: Boolean,
isFromReleaseChannel: Boolean,
rootView: ViewGroup = anchorView.rootView as ViewGroup, rootView: ViewGroup = anchorView.rootView as ViewGroup,
canHide: Boolean, canHide: Boolean,
callbacks: Callbacks callbacks: Callbacks
@ -208,7 +212,7 @@ object StoryContextMenu {
) )
} }
if (isToGroup || !isFromSelf) { if ((isToGroup || !isFromSelf) && !isFromReleaseChannel) {
add( add(
ActionItem(R.drawable.ic_open_24_tinted, context.getString(R.string.StoriesLandingItem__go_to_chat)) { ActionItem(R.drawable.ic_open_24_tinted, context.getString(R.string.StoriesLandingItem__go_to_chat)) {
callbacks.onGoToChat() callbacks.onGoToChat()

View file

@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.stories.landing
import android.graphics.Color import android.graphics.Color
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.text.SpannableStringBuilder
import android.view.View import android.view.View
import android.widget.ImageView import android.widget.ImageView
import android.widget.TextView import android.widget.TextView
@ -19,6 +20,7 @@ import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.stories.StoryTextPostModel import org.thoughtcrime.securesms.stories.StoryTextPostModel
import org.thoughtcrime.securesms.stories.dialogs.StoryContextMenu import org.thoughtcrime.securesms.stories.dialogs.StoryContextMenu
import org.thoughtcrime.securesms.util.ContextUtil
import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.SpanUtil import org.thoughtcrime.securesms.util.SpanUtil
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
@ -188,6 +190,7 @@ object StoriesLandingItem {
sender.text = when { sender.text = when {
model.data.storyRecipient.isMyStory -> context.getText(R.string.StoriesLandingFragment__my_stories) model.data.storyRecipient.isMyStory -> context.getText(R.string.StoriesLandingFragment__my_stories)
model.data.storyRecipient.isGroup -> getGroupPresentation(model) model.data.storyRecipient.isGroup -> getGroupPresentation(model)
model.data.storyRecipient.isReleaseNotes -> getReleaseNotesPresentation(model)
else -> model.data.storyRecipient.getDisplayName(context) 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 { private fun getIndividualPresentation(model: Model): String {
return if (model.data.primaryStory.messageRecord.isOutgoing) { return if (model.data.primaryStory.messageRecord.isOutgoing) {
context.getString(R.string.Recipient_you) context.getString(R.string.Recipient_you)

View file

@ -21,16 +21,14 @@ data class StoriesLandingItemData(
val failureCount: Long = 0 val failureCount: Long = 0
) : Comparable<StoriesLandingItemData> { ) : Comparable<StoriesLandingItemData> {
override fun compareTo(other: StoriesLandingItemData): Int { override fun compareTo(other: StoriesLandingItemData): Int {
return if (storyRecipient.isMyStory && !other.storyRecipient.isMyStory) { return when {
-1 storyRecipient.isMyStory && !other.storyRecipient.isMyStory -> -1
} else if (!storyRecipient.isMyStory && other.storyRecipient.isMyStory) { !storyRecipient.isMyStory && other.storyRecipient.isMyStory -> 1
1 storyRecipient.isReleaseNotes && !other.storyRecipient.isReleaseNotes -> 1
} else if (storyViewState == StoryViewState.UNVIEWED && other.storyViewState != StoryViewState.UNVIEWED) { !storyRecipient.isReleaseNotes && other.storyRecipient.isReleaseNotes -> -1
-1 storyViewState == StoryViewState.UNVIEWED && other.storyViewState != StoryViewState.UNVIEWED -> -1
} else if (storyViewState != StoryViewState.UNVIEWED && other.storyViewState == StoryViewState.UNVIEWED) { storyViewState != StoryViewState.UNVIEWED && other.storyViewState == StoryViewState.UNVIEWED -> 1
1 else -> -dateInMilliseconds.compareTo(other.dateInMilliseconds)
} else {
-dateInMilliseconds.compareTo(other.dateInMilliseconds)
} }
} }
} }

View file

@ -5,6 +5,7 @@ import io.reactivex.rxjava3.schedulers.Schedulers
import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.DistributionListId import org.thoughtcrime.securesms.database.model.DistributionListId
import org.thoughtcrime.securesms.database.model.StoryViewState import org.thoughtcrime.securesms.database.model.StoryViewState
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.recipients.RecipientId
@ -16,6 +17,7 @@ open class StoryViewerRepository {
return Single.create<List<RecipientId>> { emitter -> return Single.create<List<RecipientId>> { emitter ->
val myStoriesId = SignalDatabase.recipients.getOrInsertFromDistributionListId(DistributionListId.MY_STORY) val myStoriesId = SignalDatabase.recipients.getOrInsertFromDistributionListId(DistributionListId.MY_STORY)
val myStories = Recipient.resolved(myStoriesId) val myStories = Recipient.resolved(myStoriesId)
val releaseChannelId = SignalStore.releaseChannelValues().releaseChannelRecipientId
val recipientIds = SignalDatabase.mms.orderedStoryRecipientsAndIds.groupBy { val recipientIds = SignalDatabase.mms.orderedStoryRecipientsAndIds.groupBy {
val recipient = Recipient.resolved(it.recipientId) val recipient = Recipient.resolved(it.recipientId)
if (recipient.isDistributionList) { if (recipient.isDistributionList) {
@ -42,12 +44,16 @@ open class StoryViewerRepository {
}.map { it.id } }.map { it.id }
emitter.onSuccess( emitter.onSuccess(
if (recipientIds.contains(myStoriesId)) { recipientIds.floatToTop(releaseChannelId).floatToTop(myStoriesId)
listOf(myStoriesId) + (recipientIds - myStoriesId)
} else {
recipientIds
}
) )
}.subscribeOn(Schedulers.io()) }.subscribeOn(Schedulers.io())
} }
private fun List<RecipientId>.floatToTop(recipientId: RecipientId?): List<RecipientId> {
return if (recipientId != null && contains(recipientId)) {
listOf(recipientId) + (this - recipientId)
} else {
this
}
}
} }

View file

@ -19,6 +19,7 @@ import org.thoughtcrime.securesms.database.model.databaseprotos.StoryTextPost
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobs.MultiDeviceViewedUpdateJob import org.thoughtcrime.securesms.jobs.MultiDeviceViewedUpdateJob
import org.thoughtcrime.securesms.jobs.SendViewedReceiptJob import org.thoughtcrime.securesms.jobs.SendViewedReceiptJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.stories.Stories import org.thoughtcrime.securesms.stories.Stories
@ -173,19 +174,24 @@ open class StoryViewerPageRepository(context: Context) {
val markedMessageInfo = SignalDatabase.mms.setIncomingMessageViewed(storyPost.id) val markedMessageInfo = SignalDatabase.mms.setIncomingMessageViewed(storyPost.id)
if (markedMessageInfo != null) { if (markedMessageInfo != null) {
ApplicationDependencies.getDatabaseObserver().notifyConversationListListeners() 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 if (storyPost.sender.isReleaseNotes) {
SignalDatabase.recipients.updateLastStoryViewTimestamp(recipientId) SignalStore.storyValues().userHasSeenOnboardingStory = true
Stories.enqueueNextStoriesForDownload(recipientId, 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)
}
} }
} }
} }

View file

@ -5,7 +5,7 @@ import android.graphics.drawable.Drawable;
import androidx.annotation.DrawableRes; import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.core.content.ContextCompat; import androidx.appcompat.content.res.AppCompatResources;
import java.util.Objects; import java.util.Objects;
@ -13,7 +13,7 @@ public final class ContextUtil {
private ContextUtil() {} private ContextUtil() {}
public static @NonNull Drawable requireDrawable(@NonNull Context context, @DrawableRes int drawable) { 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));
} }
} }