Android 12 support, update AndroidX libraries (#2367)

* Android 12 support, update AndroidX libraries

* fix ktlint

* add Android 12 splash screen support

* fix comments in MainActivity

* remove deprecated Intent.ACTION_CLOSE_SYSTEM_DIALOGS

* delete TimelineViewModelTest

* fix notifications on Android 12

* improve splash screen

* handle pending intent flags in a dedicated function
This commit is contained in:
Konrad Pozniak 2022-03-09 20:50:23 +01:00 committed by GitHub
parent 221cdb3611
commit 55513e8e2b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 260 additions and 488 deletions

View File

@ -15,11 +15,11 @@ def getGitSha = {
}
android {
compileSdkVersion 30
compileSdkVersion 31
defaultConfig {
applicationId APP_ID
minSdkVersion 21
targetSdkVersion 30
targetSdkVersion 31
versionCode 87
versionName "16.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@ -89,8 +89,8 @@ android {
}
ext.coroutinesVersion = "1.6.0"
ext.lifecycleVersion = "2.3.1"
ext.roomVersion = '2.3.0'
ext.lifecycleVersion = "2.4.1"
ext.roomVersion = '2.4.2'
ext.retrofitVersion = '2.9.0'
ext.okhttpVersion = '4.9.3'
ext.glideVersion = '4.12.0'
@ -104,31 +104,33 @@ dependencies {
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-rx3:$coroutinesVersion"
implementation "androidx.core:core-ktx:1.5.0"
implementation "androidx.appcompat:appcompat:1.3.0"
implementation "androidx.fragment:fragment-ktx:1.3.4"
implementation "androidx.browser:browser:1.3.0"
implementation "androidx.core:core-ktx:1.7.0"
implementation "androidx.appcompat:appcompat:1.4.1"
implementation "androidx.fragment:fragment-ktx:1.4.1"
implementation "androidx.browser:browser:1.4.0"
implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0"
implementation "androidx.recyclerview:recyclerview:1.2.1"
implementation "androidx.exifinterface:exifinterface:1.3.3"
implementation "androidx.cardview:cardview:1.0.0"
implementation "androidx.preference:preference-ktx:1.1.1"
implementation "androidx.sharetarget:sharetarget:1.1.0"
implementation "androidx.preference:preference-ktx:1.2.0"
implementation "androidx.sharetarget:sharetarget:1.2.0-rc01"
implementation "androidx.emoji:emoji:1.1.0"
implementation "androidx.emoji:emoji-appcompat:1.1.0"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-reactivestreams-ktx:$lifecycleVersion"
implementation "androidx.constraintlayout:constraintlayout:2.1.2"
implementation "androidx.paging:paging-runtime-ktx:3.0.0"
implementation "androidx.constraintlayout:constraintlayout:2.1.3"
implementation "androidx.paging:paging-runtime-ktx:3.1.0"
implementation "androidx.viewpager2:viewpager2:1.0.0"
implementation "androidx.work:work-runtime:2.5.0"
implementation "androidx.work:work-runtime:2.7.1"
implementation "androidx.room:room-ktx:$roomVersion"
implementation "androidx.room:room-paging:$roomVersion"
implementation "androidx.room:room-rxjava3:$roomVersion"
kapt "androidx.room:room-compiler:$roomVersion"
implementation 'androidx.core:core-splashscreen:1.0.0-beta01'
implementation "com.google.android.material:material:1.4.0"
implementation "com.google.android.material:material:1.5.0"
implementation "com.google.code.gson:gson:2.8.9"

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="notification_color">#19A341</color>
</resources>

View File

@ -20,20 +20,7 @@
android:supportsRtl="true"
android:theme="@style/TuskyTheme"
android:usesCleartextTraffic="false">
<activity
android:name=".SplashActivity"
android:theme="@style/SplashTheme">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/share_shortcuts" />
</activity>
<activity
android:name=".components.login.LoginActivity"
android:windowSoftInputMode="adjustResize">
@ -41,7 +28,15 @@
<activity android:name=".components.login.LoginWebViewActivity" />
<activity
android:name=".MainActivity"
android:configChanges="orientation|screenSize|keyboardHidden|screenLayout|smallestScreenSize">
android:configChanges="orientation|screenSize|keyboardHidden|screenLayout|smallestScreenSize"
android:theme="@style/SplashTheme"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
@ -88,6 +83,9 @@
<meta-data
android:name="android.service.chooser.chooser_target_service"
android:value="androidx.sharetarget.ChooserTargetServiceCompat" />
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/share_shortcuts" />
</activity>
<activity
@ -97,7 +95,6 @@
<activity
android:name=".ViewThreadActivity"
android:configChanges="orientation|screenSize" />
<activity android:name=".ViewTagActivity" />
<activity
android:name=".ViewMediaActivity"
android:theme="@style/TuskyBaseTheme" />
@ -115,7 +112,8 @@
android:theme="@style/Base.Theme.AppCompat" />
<activity
android:name=".components.search.SearchActivity"
android:launchMode="singleTop">
android:launchMode="singleTop"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.SEARCH" />
</intent-filter>
@ -125,7 +123,6 @@
android:resource="@xml/searchable" />
</activity>
<activity android:name=".ListsActivity" />
<activity android:name=".ModalTimelineActivity" />
<activity android:name=".LicenseActivity" />
<activity android:name=".FiltersActivity" />
<activity
@ -136,7 +133,8 @@
<activity android:name=".components.announcements.AnnouncementsActivity" />
<activity android:name=".components.drafts.DraftsActivity" />
<receiver android:name=".receiver.NotificationClearBroadcastReceiver" />
<receiver android:name=".receiver.NotificationClearBroadcastReceiver"
android:exported="false" />
<receiver
android:name=".receiver.SendStatusBroadcastReceiver"
android:enabled="true"
@ -147,13 +145,15 @@
android:icon="@drawable/ic_tusky"
android:label="Compose Toot"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
android:exported="true"
tools:targetApi="24">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
</service>
<service android:name=".service.SendTootService" />
<service android:name=".service.SendTootService"
android:exported="false" />
<provider
android:name="androidx.core.content.FileProvider"
@ -167,10 +167,16 @@
<!-- disable automatic WorkManager initialization -->
<provider
android:name="androidx.work.impl.WorkManagerInitializer"
android:authorities="${applicationId}.workmanager-init"
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="remove" />
tools:node="merge">
<meta-data
android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup"
tools:node="remove" />
</provider>
</application>
</manifest>

View File

@ -35,6 +35,7 @@ import androidx.appcompat.app.AlertDialog
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.content.ContextCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.emoji.text.EmojiCompat
import androidx.emoji.text.EmojiCompat.InitCallback
import androidx.lifecycle.Lifecycle
@ -159,8 +160,12 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
}
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
super.onCreate(savedInstanceState)
// delete old notification channels
NotificationHelper.deleteLegacyNotificationChannels(this, accountManager)
val activeAccount = accountManager.activeAccount
?: return // will be redirected to LoginActivity by BaseActivity

View File

@ -1,49 +0,0 @@
/* Copyright 2018 Conny Duck
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky
import android.content.Intent
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import com.keylesspalace.tusky.components.login.LoginActivity
import com.keylesspalace.tusky.components.notifications.NotificationHelper
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.di.Injectable
import javax.inject.Inject
class SplashActivity : AppCompatActivity(), Injectable {
@Inject
lateinit var accountManager: AccountManager
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
/** delete old notification channels */
NotificationHelper.deleteLegacyNotificationChannels(this, accountManager)
/** Determine whether the user is currently logged in, and if so go ahead and load the
* timeline. Otherwise, start the activity_login screen. */
val intent = if (accountManager.activeAccount != null) {
Intent(this, MainActivity::class.java)
} else {
LoginActivity.getIntent(this, false)
}
startActivity(intent)
finish()
}
}

View File

@ -16,7 +16,9 @@
package com.keylesspalace.tusky.components.compose
import android.Manifest
import android.app.NotificationManager
import android.app.ProgressDialog
import android.content.ClipData
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
@ -45,8 +47,8 @@ import androidx.appcompat.app.AlertDialog
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.core.view.inputmethod.InputConnectionCompat
import androidx.core.view.inputmethod.InputContentInfoCompat
import androidx.core.view.ContentInfoCompat
import androidx.core.view.OnReceiveContentListener
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.preference.PreferenceManager
@ -105,7 +107,7 @@ class ComposeActivity :
ComposeAutoCompleteAdapter.AutocompletionProvider,
OnEmojiSelectedListener,
Injectable,
InputConnectionCompat.OnCommitContentListener,
OnReceiveContentListener,
ComposeScheduleView.OnTimeSetListener {
@Inject
@ -149,6 +151,18 @@ class ComposeActivity :
public override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val notificationId = intent.getIntExtra(NOTIFICATION_ID_EXTRA, -1)
if (notificationId != -1) {
// ComposeActivity was opened from a notification, delete the notification
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
notificationManager.cancel(notificationId)
}
val accountId = intent.getLongExtra(ACCOUNT_ID_EXTRA, -1)
if (accountId != -1L) {
accountManager.setActiveAccount(accountId)
}
val preferences = PreferenceManager.getDefaultSharedPreferences(this)
val theme = preferences.getString("appTheme", ThemeUtils.APP_THEME_DEFAULT)
if (theme == "black") {
@ -282,7 +296,7 @@ class ComposeActivity :
}
private fun setupComposeField(preferences: SharedPreferences, startingText: String?) {
binding.composeEditField.setOnCommitContentListener(this)
binding.composeEditField.setOnReceiveContentListener(this)
binding.composeEditField.setOnKeyListener { _, keyCode, event -> this.onKeyDown(keyCode, event) }
@ -742,26 +756,18 @@ class ComposeActivity :
}
}
/** This is for the fancy keyboards which can insert images and stuff. */
override fun onCommitContent(inputContentInfo: InputContentInfoCompat, flags: Int, opts: Bundle?): Boolean {
// Verify the returned content's type is of the correct MIME type
val supported = inputContentInfo.description.hasMimeType("image/*")
if (supported) {
val lacksPermission = (flags and InputConnectionCompat.INPUT_CONTENT_GRANT_READ_URI_PERMISSION) != 0
if (lacksPermission) {
try {
inputContentInfo.requestPermission()
} catch (e: Exception) {
Log.e(TAG, "InputContentInfoCompat#requestPermission() failed." + e.message)
return false
/** This is for the fancy keyboards which can insert images and stuff, and drag&drop etc */
override fun onReceiveContent(view: View, contentInfo: ContentInfoCompat): ContentInfoCompat? {
if (contentInfo.clip.description.hasMimeType("image/*")) {
val split = contentInfo.partition { item: ClipData.Item -> item.uri != null }
split.first?.let { content ->
for (i in 0 until content.clip.itemCount) {
pickMedia(content.clip.getItemAt(i).uri)
}
}
pickMedia(inputContentInfo.contentUri, inputContentInfo)
return true
return split.second
}
return false
return contentInfo
}
private fun sendStatus() {
@ -784,12 +790,11 @@ class ComposeActivity :
}
viewModel.sendStatus(contentText, spoilerText).observe(
this,
{
finishingUploadDialog?.dismiss()
deleteDraftAndFinish()
}
)
this
) {
finishingUploadDialog?.dismiss()
deleteDraftAndFinish()
}
} else {
binding.composeEditField.error = getString(R.string.error_compose_character_limit)
enableButtons(true)
@ -859,12 +864,9 @@ class ComposeActivity :
viewModel.removeMediaFromQueue(item)
}
private fun pickMedia(uri: Uri, contentInfoCompat: InputContentInfoCompat? = null) {
private fun pickMedia(uri: Uri) {
withLifecycleContext {
viewModel.pickMedia(uri).observe { exceptionOrItem ->
contentInfoCompat?.releasePermission()
exceptionOrItem.asLeftOrNull()?.let {
val errorId = when (it) {
is VideoSizeException -> {
@ -1043,12 +1045,32 @@ class ComposeActivity :
private const val PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE = 1
internal const val COMPOSE_OPTIONS_EXTRA = "COMPOSE_OPTIONS"
private const val NOTIFICATION_ID_EXTRA = "NOTIFICATION_ID"
private const val ACCOUNT_ID_EXTRA = "ACCOUNT_ID"
private const val PHOTO_UPLOAD_URI_KEY = "PHOTO_UPLOAD_URI"
/**
* @param options ComposeOptions to configure the ComposeActivity
* @param notificationId the id of the notification that starts the Activity
* @param accountId the id of the account to compose with, null for the current account
* @return an Intent to start the ComposeActivity
*/
@JvmStatic
fun startIntent(context: Context, options: ComposeOptions): Intent {
@JvmOverloads
fun startIntent(
context: Context,
options: ComposeOptions,
notificationId: Int? = null,
accountId: Long? = null
): Intent {
return Intent(context, ComposeActivity::class.java).apply {
putExtra(COMPOSE_OPTIONS_EXTRA, options)
if (notificationId != null) {
putExtra(NOTIFICATION_ID_EXTRA, notificationId)
}
if (accountId != null) {
putExtra(ACCOUNT_ID_EXTRA, accountId)
}
}
}

View File

@ -22,6 +22,8 @@ import android.util.AttributeSet
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputConnection
import androidx.appcompat.widget.AppCompatMultiAutoCompleteTextView
import androidx.core.view.OnReceiveContentListener
import androidx.core.view.ViewCompat
import androidx.core.view.inputmethod.EditorInfoCompat
import androidx.core.view.inputmethod.InputConnectionCompat
import androidx.emoji.widget.EmojiEditTextHelper
@ -32,41 +34,33 @@ class EditTextTyped @JvmOverloads constructor(
) :
AppCompatMultiAutoCompleteTextView(context, attributeSet) {
private var onCommitContentListener: InputConnectionCompat.OnCommitContentListener? = null
private val emojiEditTextHelper: EmojiEditTextHelper = EmojiEditTextHelper(this)
init {
// fix a bug with autocomplete and some keyboards
val newInputType = inputType and (inputType xor InputType.TYPE_TEXT_FLAG_AUTO_COMPLETE)
inputType = newInputType
super.setKeyListener(getEmojiEditTextHelper().getKeyListener(keyListener))
super.setKeyListener(emojiEditTextHelper.getKeyListener(keyListener))
}
override fun setKeyListener(input: KeyListener) {
super.setKeyListener(getEmojiEditTextHelper().getKeyListener(input))
override fun setKeyListener(input: KeyListener?) {
if (input != null) {
super.setKeyListener(emojiEditTextHelper.getKeyListener(input))
} else {
super.setKeyListener(input)
}
}
fun setOnCommitContentListener(listener: InputConnectionCompat.OnCommitContentListener) {
onCommitContentListener = listener
fun setOnReceiveContentListener(listener: OnReceiveContentListener) {
ViewCompat.setOnReceiveContentListener(this, arrayOf("image/*"), listener)
}
override fun onCreateInputConnection(editorInfo: EditorInfo): InputConnection {
val connection = super.onCreateInputConnection(editorInfo)
return if (onCommitContentListener != null) {
EditorInfoCompat.setContentMimeTypes(editorInfo, arrayOf("image/*"))
getEmojiEditTextHelper().onCreateInputConnection(
InputConnectionCompat.createWrapper(
connection, editorInfo,
onCommitContentListener!!
),
editorInfo
)!!
} else {
connection
}
}
private fun getEmojiEditTextHelper(): EmojiEditTextHelper {
return emojiEditTextHelper
EditorInfoCompat.setContentMimeTypes(editorInfo, arrayOf("image/*"))
return emojiEditTextHelper.onCreateInputConnection(
InputConnectionCompat.createWrapper(this, connection, editorInfo),
editorInfo
)!!
}
}

View File

@ -24,7 +24,6 @@ import android.content.Context;
import android.content.Intent;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Color;
import android.os.Build;
import android.provider.Settings;
import android.text.TextUtils;
@ -46,9 +45,9 @@ import androidx.work.WorkRequest;
import com.bumptech.glide.Glide;
import com.bumptech.glide.load.resource.bitmap.RoundedCorners;
import com.bumptech.glide.request.FutureTarget;
import com.keylesspalace.tusky.BuildConfig;
import com.keylesspalace.tusky.MainActivity;
import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.components.compose.ComposeActivity;
import com.keylesspalace.tusky.db.AccountEntity;
import com.keylesspalace.tusky.db.AccountManager;
import com.keylesspalace.tusky.entity.Notification;
@ -67,6 +66,7 @@ import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
@ -88,8 +88,6 @@ public class NotificationHelper {
public static final String REPLY_ACTION = "REPLY_ACTION";
public static final String COMPOSE_ACTION = "COMPOSE_ACTION";
public static final String KEY_REPLY = "KEY_REPLY";
public static final String KEY_SENDER_ACCOUNT_ID = "KEY_SENDER_ACCOUNT_ID";
@ -108,10 +106,6 @@ public class NotificationHelper {
public static final String KEY_MENTIONS = "KEY_MENTIONS";
public static final String KEY_CITED_TEXT = "KEY_CITED_TEXT";
public static final String KEY_CITED_AUTHOR_LOCAL = "KEY_CITED_AUTHOR_LOCAL";
/**
* notification channels used on Android O+
**/
@ -206,21 +200,24 @@ public class NotificationHelper {
.setLabel(context.getString(R.string.label_quick_reply))
.build();
PendingIntent quickReplyPendingIntent = getStatusReplyIntent(REPLY_ACTION, context, body, account);
PendingIntent quickReplyPendingIntent = getStatusReplyIntent(context, body, account);
NotificationCompat.Action quickReplyAction =
new NotificationCompat.Action.Builder(R.drawable.ic_reply_24dp,
context.getString(R.string.action_quick_reply), quickReplyPendingIntent)
context.getString(R.string.action_quick_reply),
quickReplyPendingIntent)
.addRemoteInput(replyRemoteInput)
.build();
builder.addAction(quickReplyAction);
PendingIntent composePendingIntent = getStatusReplyIntent(COMPOSE_ACTION, context, body, account);
PendingIntent composeIntent = getStatusComposeIntent(context, body, account);
NotificationCompat.Action composeAction =
new NotificationCompat.Action.Builder(R.drawable.ic_reply_24dp,
context.getString(R.string.action_compose_shortcut), composePendingIntent)
context.getString(R.string.action_compose_shortcut),
composeIntent)
.setShowsUserInterface(true)
.build();
builder.addAction(composeAction);
@ -237,7 +234,6 @@ public class NotificationHelper {
}
// Summary
// =======
final NotificationCompat.Builder summaryBuilder = newNotification(context, body, account, true);
if (currentNotifications.length() != 1) {
@ -275,7 +271,7 @@ public class NotificationHelper {
summaryStackBuilder.addNextIntent(summaryResultIntent);
PendingIntent summaryResultPendingIntent = summaryStackBuilder.getPendingIntent((int) (notificationId + account.getId() * 10000),
PendingIntent.FLAG_UPDATE_CURRENT);
pendingIntentFlags(false));
// we have to switch account here
Intent eventResultIntent = new Intent(context, MainActivity.class);
@ -285,18 +281,18 @@ public class NotificationHelper {
eventStackBuilder.addNextIntent(eventResultIntent);
PendingIntent eventResultPendingIntent = eventStackBuilder.getPendingIntent((int) account.getId(),
PendingIntent.FLAG_UPDATE_CURRENT);
pendingIntentFlags(false));
Intent deleteIntent = new Intent(context, NotificationClearBroadcastReceiver.class);
deleteIntent.putExtra(ACCOUNT_ID, account.getId());
PendingIntent deletePendingIntent = PendingIntent.getBroadcast(context, summary ? (int) account.getId() : notificationId, deleteIntent,
PendingIntent.FLAG_UPDATE_CURRENT);
pendingIntentFlags(false));
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, getChannelId(account, body))
.setSmallIcon(R.drawable.ic_notify)
.setContentIntent(summary ? summaryResultPendingIntent : eventResultPendingIntent)
.setDeleteIntent(deletePendingIntent)
.setColor(BuildConfig.FLAVOR == "green" ? Color.parseColor("#19A341") : ContextCompat.getColor(context, R.color.tusky_blue))
.setColor(ContextCompat.getColor(context, R.color.notification_color))
.setGroup(account.getAccountId())
.setAutoCancel(true)
.setShortcutId(Long.toString(account.getId()))
@ -307,11 +303,9 @@ public class NotificationHelper {
return builder;
}
private static PendingIntent getStatusReplyIntent(String action, Context context, Notification body, AccountEntity account) {
private static PendingIntent getStatusReplyIntent(Context context, Notification body, AccountEntity account) {
Status status = body.getStatus();
String citedLocalAuthor = status.getAccount().getLocalUsername();
String citedText = status.getContent().toString();
String inReplyToId = status.getId();
Status actionableStatus = status.getActionableStatus();
Status.Visibility replyVisibility = actionableStatus.getVisibility();
@ -326,9 +320,7 @@ public class NotificationHelper {
mentionedUsernames = new ArrayList<>(new LinkedHashSet<>(mentionedUsernames));
Intent replyIntent = new Intent(context, SendStatusBroadcastReceiver.class)
.setAction(action)
.putExtra(KEY_CITED_AUTHOR_LOCAL, citedLocalAuthor)
.putExtra(KEY_CITED_TEXT, citedText)
.setAction(REPLY_ACTION)
.putExtra(KEY_SENDER_ACCOUNT_ID, account.getId())
.putExtra(KEY_SENDER_ACCOUNT_IDENTIFIER, account.getIdentifier())
.putExtra(KEY_SENDER_ACCOUNT_FULL_NAME, account.getFullName())
@ -341,7 +333,50 @@ public class NotificationHelper {
return PendingIntent.getBroadcast(context.getApplicationContext(),
notificationId,
replyIntent,
PendingIntent.FLAG_UPDATE_CURRENT);
pendingIntentFlags(true));
}
private static PendingIntent getStatusComposeIntent(Context context, Notification body, AccountEntity account) {
Status status = body.getStatus();
String citedLocalAuthor = status.getAccount().getLocalUsername();
String citedText = status.getContent().toString();
String inReplyToId = status.getId();
Status actionableStatus = status.getActionableStatus();
Status.Visibility replyVisibility = actionableStatus.getVisibility();
String contentWarning = actionableStatus.getSpoilerText();
List<Status.Mention> mentions = actionableStatus.getMentions();
Set<String> mentionedUsernames = new LinkedHashSet<>();
mentionedUsernames.add(actionableStatus.getAccount().getUsername());
for (Status.Mention mention : mentions) {
String mentionedUsername = mention.getUsername();
if (!mentionedUsername.equals(account.getUsername())) {
mentionedUsernames.add(mention.getUsername());
}
}
ComposeActivity.ComposeOptions composeOptions = new ComposeActivity.ComposeOptions();
composeOptions.setInReplyToId(inReplyToId);
composeOptions.setReplyVisibility(replyVisibility);
composeOptions.setContentWarning(contentWarning);
composeOptions.setReplyingStatusAuthor(citedLocalAuthor);
composeOptions.setReplyingStatusContent(citedText);
composeOptions.setMentionedUsernames(mentionedUsernames);
composeOptions.setModifiedInitialState(true);
Intent composeIntent = ComposeActivity.startIntent(
context,
composeOptions,
notificationId,
account.getId()
);
composeIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
return PendingIntent.getActivity(context.getApplicationContext(),
notificationId,
composeIntent,
pendingIntentFlags(false));
}
public static void createNotificationChannelsForAccount(@NonNull AccountEntity account, @NonNull Context context) {
@ -409,9 +444,7 @@ public class NotificationHelper {
NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
//noinspection ConstantConditions
notificationManager.deleteNotificationChannelGroup(account.getIdentifier());
}
}
@ -421,7 +454,6 @@ public class NotificationHelper {
NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
// used until Tusky 1.4
//noinspection ConstantConditions
notificationManager.deleteNotificationChannel(CHANNEL_MENTION);
notificationManager.deleteNotificationChannel(CHANNEL_FAVOURITE);
notificationManager.deleteNotificationChannel(CHANNEL_BOOST);
@ -440,7 +472,6 @@ public class NotificationHelper {
// on Android >= O, notifications are enabled, if at least one channel is enabled
NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
//noinspection ConstantConditions
if (notificationManager.areNotificationsEnabled()) {
for (NotificationChannel channel : notificationManager.getNotificationChannels()) {
if (channel.getImportance() > NotificationManager.IMPORTANCE_NONE) {
@ -491,7 +522,6 @@ public class NotificationHelper {
accountManager.saveAccount(account);
NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
//noinspection ConstantConditions
notificationManager.cancel((int) account.getId());
return true;
})
@ -511,7 +541,6 @@ public class NotificationHelper {
// unknown notificationtype
return false;
}
//noinspection ConstantConditions
NotificationChannel channel = notificationManager.getNotificationChannel(channelId);
return channel.getImportance() > NotificationManager.IMPORTANCE_NONE;
}
@ -674,4 +703,11 @@ public class NotificationHelper {
return null;
}
public static int pendingIntentFlags(boolean mutable) {
if (mutable) {
return PendingIntent.FLAG_UPDATE_CURRENT | (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ? PendingIntent.FLAG_MUTABLE : 0);
} else {
return PendingIntent.FLAG_UPDATE_CURRENT | (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_IMMUTABLE : 0);
}
}
}

View File

@ -13,8 +13,8 @@ import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.preference.Preference
import androidx.preference.PreferenceManager
import com.keylesspalace.tusky.MainActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.SplashActivity
import com.keylesspalace.tusky.databinding.DialogEmojicompatBinding
import com.keylesspalace.tusky.databinding.ItemEmojiPrefBinding
import com.keylesspalace.tusky.util.EmojiCompatFont
@ -215,7 +215,7 @@ class EmojiPreference(
.setPositiveButton(R.string.restart) { _, _ ->
// Restart the app
// From https://stackoverflow.com/a/17166729/5070653
val launchIntent = Intent(context, SplashActivity::class.java)
val launchIntent = Intent(context, MainActivity::class.java)
val mPendingIntent = PendingIntent.getActivity(
context,
0x1f973, // This is the codepoint of the party face emoji :D

View File

@ -280,23 +280,24 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable {
}
private fun updateHttpProxySummary() {
val sharedPreferences = preferenceManager.sharedPreferences
val httpProxyEnabled = sharedPreferences.getBoolean(PrefKeys.HTTP_PROXY_ENABLED, false)
val httpServer = sharedPreferences.getNonNullString(PrefKeys.HTTP_PROXY_SERVER, "")
preferenceManager.sharedPreferences?.let { sharedPreferences ->
val httpProxyEnabled = sharedPreferences.getBoolean(PrefKeys.HTTP_PROXY_ENABLED, false)
val httpServer = sharedPreferences.getNonNullString(PrefKeys.HTTP_PROXY_SERVER, "")
try {
val httpPort = sharedPreferences.getNonNullString(PrefKeys.HTTP_PROXY_PORT, "-1")
.toInt()
try {
val httpPort = sharedPreferences.getNonNullString(PrefKeys.HTTP_PROXY_PORT, "-1")
.toInt()
if (httpProxyEnabled && httpServer.isNotBlank() && httpPort > 0 && httpPort < 65535) {
httpProxyPref?.summary = "$httpServer:$httpPort"
return
if (httpProxyEnabled && httpServer.isNotBlank() && httpPort > 0 && httpPort < 65535) {
httpProxyPref?.summary = "$httpServer:$httpPort"
return
}
} catch (e: NumberFormatException) {
// user has entered wrong port, fall back to empty summary
}
} catch (e: NumberFormatException) {
// user has entered wrong port, fall back to empty summary
}
httpProxyPref?.summary = ""
httpProxyPref?.summary = ""
}
}
companion object {

View File

@ -90,9 +90,9 @@ class TimelineFragment :
private val viewModel: TimelineViewModel by lazy {
if (kind == TimelineViewModel.Kind.HOME) {
ViewModelProvider(this, viewModelFactory).get(CachedTimelineViewModel::class.java)
ViewModelProvider(this, viewModelFactory)[CachedTimelineViewModel::class.java]
} else {
ViewModelProvider(this, viewModelFactory).get(NetworkTimelineViewModel::class.java)
ViewModelProvider(this, viewModelFactory)[NetworkTimelineViewModel::class.java]
}
}
@ -136,7 +136,7 @@ class TimelineFragment :
isSwipeToRefreshEnabled = arguments.getBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, true)
val preferences = PreferenceManager.getDefaultSharedPreferences(activity)
val preferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
val statusDisplayOptions = StatusDisplayOptions(
animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false),
mediaPreviewEnabled = accountManager.activeAccount!!.mediaPreviewEnabled,
@ -224,7 +224,7 @@ class TimelineFragment :
}
if (actionButtonPresent()) {
val preferences = PreferenceManager.getDefaultSharedPreferences(context)
val preferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
hideFab = preferences.getBoolean("fabHide", false)
scrollListener = object : RecyclerView.OnScrollListener() {
override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) {
@ -401,7 +401,7 @@ class TimelineFragment :
}
private fun onPreferenceChanged(key: String) {
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context)
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
when (key) {
PrefKeys.FAB_HIDE -> {
hideFab = sharedPreferences.getBoolean(PrefKeys.FAB_HIDE, false)
@ -468,7 +468,7 @@ class TimelineFragment :
* Auto dispose observable on pause
*/
private fun startUpdateTimestamp() {
val preferences = PreferenceManager.getDefaultSharedPreferences(activity)
val preferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
val useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false)
if (!useAbsoluteTime) {
Observable.interval(1, TimeUnit.MINUTES)

View File

@ -23,7 +23,6 @@ import com.keylesspalace.tusky.FiltersActivity
import com.keylesspalace.tusky.LicenseActivity
import com.keylesspalace.tusky.ListsActivity
import com.keylesspalace.tusky.MainActivity
import com.keylesspalace.tusky.SplashActivity
import com.keylesspalace.tusky.StatusListActivity
import com.keylesspalace.tusky.TabPreferenceActivity
import com.keylesspalace.tusky.ViewMediaActivity
@ -88,9 +87,6 @@ abstract class ActivitiesModule {
@ContributesAndroidInjector
abstract fun contributesLoginWebViewActivity(): LoginWebViewActivity
@ContributesAndroidInjector
abstract fun contributesSplashActivity(): SplashActivity
@ContributesAndroidInjector(modules = [FragmentBuildersModule::class])
abstract fun contributesPreferencesActivity(): PreferencesActivity

View File

@ -18,14 +18,14 @@ package com.keylesspalace.tusky.receiver
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.graphics.Color
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.RemoteInput
import androidx.core.content.ContextCompat
import com.keylesspalace.tusky.BuildConfig
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.compose.ComposeActivity
import com.keylesspalace.tusky.components.compose.ComposeActivity.ComposeOptions
import com.keylesspalace.tusky.components.notifications.NotificationHelper
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.entity.Status
@ -45,22 +45,19 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
AndroidInjection.inject(this, context)
val notificationId = intent.getIntExtra(NotificationHelper.KEY_NOTIFICATION_ID, -1)
val senderId = intent.getLongExtra(NotificationHelper.KEY_SENDER_ACCOUNT_ID, -1)
val senderIdentifier = intent.getStringExtra(NotificationHelper.KEY_SENDER_ACCOUNT_IDENTIFIER)
val senderFullName = intent.getStringExtra(NotificationHelper.KEY_SENDER_ACCOUNT_FULL_NAME)
val citedStatusId = intent.getStringExtra(NotificationHelper.KEY_CITED_STATUS_ID)
val visibility = intent.getSerializableExtra(NotificationHelper.KEY_VISIBILITY) as Status.Visibility
val spoiler = intent.getStringExtra(NotificationHelper.KEY_SPOILER) ?: ""
val mentions = intent.getStringArrayExtra(NotificationHelper.KEY_MENTIONS) ?: emptyArray()
val citedText = intent.getStringExtra(NotificationHelper.KEY_CITED_TEXT)
val localAuthorId = intent.getStringExtra(NotificationHelper.KEY_CITED_AUTHOR_LOCAL)
val account = accountManager.getAccountById(senderId)
val notificationManager = NotificationManagerCompat.from(context)
if (intent.action == NotificationHelper.REPLY_ACTION) {
val notificationId = intent.getIntExtra(NotificationHelper.KEY_NOTIFICATION_ID, -1)
val senderId = intent.getLongExtra(NotificationHelper.KEY_SENDER_ACCOUNT_ID, -1)
val senderIdentifier = intent.getStringExtra(NotificationHelper.KEY_SENDER_ACCOUNT_IDENTIFIER)
val senderFullName = intent.getStringExtra(NotificationHelper.KEY_SENDER_ACCOUNT_FULL_NAME)
val citedStatusId = intent.getStringExtra(NotificationHelper.KEY_CITED_STATUS_ID)
val visibility = intent.getSerializableExtra(NotificationHelper.KEY_VISIBILITY) as Status.Visibility
val spoiler = intent.getStringExtra(NotificationHelper.KEY_SPOILER) ?: ""
val mentions = intent.getStringArrayExtra(NotificationHelper.KEY_MENTIONS) ?: emptyArray()
val account = accountManager.getAccountById(senderId)
val notificationManager = NotificationManagerCompat.from(context)
val message = getReplyMessage(intent)
@ -109,9 +106,15 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() {
context.startService(sendIntent)
val color = if (BuildConfig.FLAVOR == "green") {
Color.parseColor("#19A341")
} else {
ContextCompat.getColor(context, R.color.tusky_blue)
}
val builder = NotificationCompat.Builder(context, NotificationHelper.CHANNEL_MENTION + senderIdentifier)
.setSmallIcon(R.drawable.ic_notify)
.setColor(ContextCompat.getColor(context, (R.color.tusky_blue)))
.setColor(color)
.setGroup(senderFullName)
.setDefaults(0) // So it doesn't ring twice, notify only in Target callback
@ -125,29 +128,6 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() {
notificationManager.notify(notificationId, builder.build())
}
} else if (intent.action == NotificationHelper.COMPOSE_ACTION) {
context.sendBroadcast(Intent(Intent.ACTION_CLOSE_SYSTEM_DIALOGS))
notificationManager.cancel(notificationId)
accountManager.setActiveAccount(senderId)
val composeIntent = ComposeActivity.startIntent(
context,
ComposeOptions(
inReplyToId = citedStatusId,
replyVisibility = visibility,
contentWarning = spoiler,
mentionedUsernames = mentions.toSet(),
replyingStatusAuthor = localAuthorId,
replyingStatusContent = citedText
)
)
composeIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(composeIntent)
}
}

View File

@ -19,8 +19,8 @@ import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.StatusComposedEvent
import com.keylesspalace.tusky.appstore.StatusScheduledEvent
import com.keylesspalace.tusky.components.drafts.DraftHelper
import com.keylesspalace.tusky.components.notifications.NotificationHelper
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.entity.NewPoll
import com.keylesspalace.tusky.entity.NewStatus
@ -50,8 +50,6 @@ class SendTootService : Service(), Injectable {
@Inject
lateinit var eventHub: EventHub
@Inject
lateinit var database: AppDatabase
@Inject
lateinit var draftHelper: DraftHelper
private val supervisorJob = SupervisorJob()
@ -95,7 +93,7 @@ class SendTootService : Service(), Injectable {
.setContentText(notificationText)
.setProgress(1, 0, true)
.setOngoing(true)
.setColor(ContextCompat.getColor(this, R.color.tusky_blue))
.setColor(ContextCompat.getColor(this, R.color.notification_color))
.addAction(0, getString(android.R.string.cancel), cancelSendingIntent(sendingNotificationId))
if (tootsToSend.size == 0 || Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
@ -183,7 +181,7 @@ class SendTootService : Service(), Injectable {
.setSmallIcon(R.drawable.ic_notify)
.setContentTitle(getString(R.string.send_toot_notification_error_title))
.setContentText(getString(R.string.send_toot_notification_saved_content))
.setColor(ContextCompat.getColor(this@SendTootService, R.color.tusky_blue))
.setColor(ContextCompat.getColor(this@SendTootService, R.color.notification_color))
notificationManager.cancel(tootId)
notificationManager.notify(errorNotificationId--, builder.build())
@ -232,7 +230,7 @@ class SendTootService : Service(), Injectable {
.setSmallIcon(R.drawable.ic_notify)
.setContentTitle(getString(R.string.send_toot_notification_cancel_title))
.setContentText(getString(R.string.send_toot_notification_saved_content))
.setColor(ContextCompat.getColor(this@SendTootService, R.color.tusky_blue))
.setColor(ContextCompat.getColor(this, R.color.notification_color))
notificationManager.notify(tootId, builder.build())
@ -267,12 +265,9 @@ class SendTootService : Service(), Injectable {
}
private fun cancelSendingIntent(tootId: Int): PendingIntent {
val intent = Intent(this, SendTootService::class.java)
intent.putExtra(KEY_CANCEL, tootId)
return PendingIntent.getService(this, tootId, intent, PendingIntent.FLAG_UPDATE_CURRENT)
return PendingIntent.getService(this, tootId, intent, NotificationHelper.pendingIntentFlags(false))
}
override fun onDestroy() {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

View File

@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<color android:color="@color/tusky_grey_20"/>
</item>
<item>
<bitmap
android:src="@drawable/splash"
android:gravity="center" />
</item>
</layer-list>

View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_height="240dp"
android:layout_width="240dp">
<item android:drawable="@drawable/ic_launcher_background" />
<item
android:top="40dp"
android:bottom="40dp"
android:left="40dp"
android:right="40dp"
android:drawable="@drawable/ic_launcher_foreground" />
</layer-list>

View File

@ -1,15 +1,5 @@
<resources>
<style name="SplashTheme" parent="Theme.AppCompat.NoActionBar">
<item name="android:windowBackground">@drawable/background_splash</item>
<item name="colorPrimary">@color/tusky_grey_20</item>
<item name="colorPrimaryDark">@color/tusky_grey_20</item>
<item name="android:windowNoTitle">true</item>
<item name="android:windowLightNavigationBar">false</item>
<item name="android:navigationBarColor">@color/tusky_grey_20</item>
<item name="android:navigationBarDividerColor">@color/tusky_grey_25</item>
</style>
<style name="TuskyTheme" parent="TuskyBaseTheme">
<item name="android:windowLightNavigationBar">@bool/lightNavigationBar</item>
<item name="android:navigationBarColor">@color/colorBackground</item>

View File

@ -11,6 +11,8 @@
<color name="white">#fff</color>
<color name="black">#000</color>
<color name="notification_color">@color/tusky_blue</color>
<!-- the number roughly corresponds to the % lightness of the grey -->
<color name="tusky_grey_05">#070b14</color>
<color name="tusky_grey_10">#16191f</color>
@ -24,7 +26,6 @@
<color name="tusky_grey_90">#d9e1e8</color>
<color name="tusky_grey_95">#ebeff4</color>
<color name="transparent_tusky_blue">#8c2b90d9</color>
<color name="transparent_black">#8f000000</color>
<color name="header_background_filter_dark">#44000000</color>

View File

@ -30,11 +30,10 @@
<item name="status_text_large">22sp</item>
</style>
<style name="SplashTheme" parent="Theme.MaterialComponents.Light.NoActionBar">
<item name="android:windowBackground">@drawable/background_splash</item>
<item name="colorPrimary">@color/tusky_grey_10</item>
<item name="colorPrimaryDark">@color/tusky_grey_10</item>
<item name="android:windowNoTitle">true</item>
<style name="SplashTheme" parent="Theme.SplashScreen">
<item name="windowSplashScreenAnimatedIcon">@drawable/ic_splash</item>
<item name="windowSplashScreenBackground">@color/tusky_grey_20</item>
<item name="postSplashScreenTheme">@style/TuskyTheme</item>
</style>
<style name="TuskyTheme" parent="TuskyBaseTheme" />

View File

@ -1,216 +0,0 @@
package com.keylesspalace.tusky.components.timeline
import android.os.Looper
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import androidx.paging.AsyncPagingDataDiffer
import androidx.paging.ExperimentalPagingApi
import androidx.recyclerview.widget.ListUpdateCallback
import androidx.room.Room
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.google.gson.Gson
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.components.timeline.TimelinePagingAdapter.Companion.TimelineDifferCallback
import com.keylesspalace.tusky.components.timeline.viewmodel.CachedTimelineViewModel
import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineViewModel
import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel
import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.db.Converters
import com.keylesspalace.tusky.network.FilterModel
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.network.TimelineCases
import com.nhaarman.mockitokotlin2.doReturn
import com.nhaarman.mockitokotlin2.mock
import io.reactivex.rxjava3.core.Single
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.TestCoroutineDispatcher
import kotlinx.coroutines.test.TestCoroutineScope
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import okhttp3.Headers
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
import retrofit2.Response
import java.util.concurrent.Executors
@ExperimentalCoroutinesApi
@Config(sdk = [29])
@RunWith(AndroidJUnit4::class)
class TimelineViewModelTest {
@get:Rule
val instantRule = InstantTaskExecutorRule()
private val testDispatcher = TestCoroutineDispatcher()
private val testScope = TestCoroutineScope(testDispatcher)
private val accountManager: AccountManager = mock {
on { activeAccount } doReturn AccountEntity(
id = 1,
domain = "mastodon.example",
accessToken = "token",
isActive = true
)
}
private val eventHub = EventHub()
private lateinit var db: AppDatabase
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
shadowOf(Looper.getMainLooper()).idle()
val context = InstrumentationRegistry.getInstrumentation().targetContext
db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java)
.addTypeConverter(Converters(Gson()))
.setTransactionExecutor(Executors.newSingleThreadExecutor())
.allowMainThreadQueries()
.build()
}
@After
fun tearDown() {
Dispatchers.resetMain()
testDispatcher.cleanupTestCoroutines()
db.close()
}
@Test
@ExperimentalPagingApi
fun shouldLoadNetworkTimeline() = runBlocking {
val api: MastodonApi = mock {
on { publicTimeline(local = true, maxId = null, sinceId = null, limit = 30) } doReturn Single.just(
Response.success(
listOf(
mockStatus("6"),
mockStatus("5"),
mockStatus("4")
),
Headers.headersOf(
"Link", "<https://mastodon.examples/api/v1/favourites?limit=30&max_id=1>; rel=\"next\", <https://mastodon.example/api/v1/favourites?limit=30&min_id=5>; rel=\"prev\""
)
)
)
on { publicTimeline(local = true, maxId = "1", sinceId = null, limit = 30) } doReturn Single.just(
Response.success(emptyList())
)
on { getFilters() } doReturn Single.just(emptyList())
}
val viewModel = NetworkTimelineViewModel(
TimelineCases(api, eventHub),
api,
eventHub,
accountManager,
mock(),
FilterModel()
)
viewModel.init(TimelineViewModel.Kind.PUBLIC_LOCAL, null, emptyList())
val differ = AsyncPagingDataDiffer(
diffCallback = TimelineDifferCallback,
updateCallback = NoopListCallback(),
workerDispatcher = testDispatcher
)
viewModel.statuses.take(2).collectLatest {
testScope.launch {
differ.submitData(it)
}
}
assertEquals(
listOf(
mockStatusViewData("6"),
mockStatusViewData("5"),
mockStatusViewData("4")
),
differ.snapshot().items
)
}
// ToDo: Find out why Room & coroutines are not playing nice here
// @Test
@ExperimentalPagingApi
fun shouldLoadCachedTimeline() = runBlocking {
val api: MastodonApi = mock {
on { homeTimeline(limit = 30) } doReturn Single.just(
Response.success(
listOf(
mockStatus("6"),
mockStatus("5"),
mockStatus("4")
)
)
)
on { homeTimeline(maxId = "1", sinceId = null, limit = 30) } doReturn Single.just(
Response.success(emptyList())
)
on { getFilters() } doReturn Single.just(emptyList())
}
val viewModel = CachedTimelineViewModel(
TimelineCases(api, eventHub),
api,
eventHub,
accountManager,
mock(),
FilterModel(),
db,
Gson()
)
viewModel.init(TimelineViewModel.Kind.HOME, null, emptyList())
val differ = AsyncPagingDataDiffer(
diffCallback = TimelineDifferCallback,
updateCallback = NoopListCallback(),
workerDispatcher = testDispatcher
)
viewModel.statuses.take(1000).collectLatest {
testScope.launch {
differ.submitData(it)
}
}
assertEquals(
listOf(
mockStatusViewData("6"),
mockStatusViewData("5"),
mockStatusViewData("4")
),
differ.snapshot().items
)
}
}
class NoopListCallback : ListUpdateCallback {
override fun onChanged(position: Int, count: Int, payload: Any?) {}
override fun onMoved(fromPosition: Int, toPosition: Int) {}
override fun onInserted(position: Int, count: Int) {}
override fun onRemoved(position: Int, count: Int) {}
}