file based drafts/filters sync now working

This commit is contained in:
Mariotaku Lee 2017-01-01 01:13:21 +08:00
parent d14ccb66f4
commit e0826633bb
32 changed files with 656 additions and 169 deletions

View File

@ -120,10 +120,12 @@ public interface TwidereConstants extends SharedPreferenceConstants, IntentConst
String AUTHORITY_ACCOUNTS = "accounts";
String AUTHORITY_DRAFTS = "drafts";
String AUTHORITY_FILTERS = "filters";
String AUTHORITY_FILTERS_IMPORT_BLOCKS = "filters/import/blocks";
String AUTHORITY_FILTERS_IMPORT_MUTES = "filters/import/mutes";
String AUTHORITY_PROFILE_EDITOR = "profile_editor";
String PATH_FILTERS_IMPORT_BLOCKS = "import/blocks";
String PATH_FILTERS_IMPORT_MUTES = "import/mutes";
String PATH_FILTERS_SUBSCRIPTIONS = "subscriptions";
String QUERY_PARAM_ACCOUNT_KEY = "account_key";
String QUERY_PARAM_ACCOUNT_HOST = "account_host";
String QUERY_PARAM_ACCOUNT_NAME = "account_name";

View File

@ -212,4 +212,6 @@ public interface IntentConstants {
String EXTRA_OBJECT = "object";
String EXTRA_SIMPLE_LAYOUT = "simple_layout";
String EXTRA_API_CONFIG = "api_config";
String EXTRA_COUNT = "count";
}

View File

@ -28,6 +28,7 @@ import com.hannesdorfmann.parcelableplease.annotation.ParcelablePlease;
import com.hannesdorfmann.parcelableplease.annotation.ParcelableThisPlease;
import org.mariotaku.commons.objectcursor.LoganSquareCursorFieldConverter;
import org.mariotaku.library.objectcursor.annotation.AfterCursorObjectCreated;
import org.mariotaku.library.objectcursor.annotation.CursorField;
import org.mariotaku.library.objectcursor.annotation.CursorObject;
import org.mariotaku.twidere.model.draft.ActionExtras;
@ -38,6 +39,7 @@ import org.mariotaku.twidere.provider.TwidereDataStore.Drafts;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.UUID;
@ParcelablePlease
@CursorObject(valuesCreator = true, tableInfo = true)
@ -70,7 +72,10 @@ public class Draft implements Parcelable {
@ParcelableThisPlease
@CursorField(value = Drafts.ACTION_EXTRAS, converter = DraftExtrasConverter.class)
public ActionExtras action_extras;
@Nullable
@ParcelableThisPlease
@CursorField(value = Drafts.UNIQUE_ID)
public String unique_id;
public Draft() {
@ -86,6 +91,13 @@ public class Draft implements Parcelable {
DraftParcelablePlease.writeToParcel(this, dest, flags);
}
@AfterCursorObjectCreated
void afterCursorObjectCreated() {
if (unique_id == null) {
unique_id = UUID.nameUUIDFromBytes((_id + ":" + timestamp).getBytes()).toString();
}
}
public static final Creator<Draft> CREATOR = new Creator<Draft>() {
@Override
public Draft createFromParcel(Parcel source) {

View File

@ -10,6 +10,7 @@ import org.mariotaku.twidere.model.util.UserKeyCursorFieldConverter;
import org.mariotaku.twidere.provider.TwidereDataStore;
import org.mariotaku.twidere.provider.TwidereDataStore.Filters;
import java.util.Collection;
import java.util.List;
/**
@ -69,6 +70,44 @@ public class FiltersData {
'}';
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
FiltersData that = (FiltersData) o;
if (!contentEquals(users, that.users)) return false;
if (!contentEquals(keywords, that.keywords))
return false;
if (!contentEquals(sources, that.sources)) return false;
if (!contentEquals(links, that.links)) return false;
return true;
}
private static <E> boolean contentEquals(Collection<E> collection1, Collection<E> collection2) {
if (collection1 == collection2) return true;
if (collection1 == null || collection2 == null) return false;
if (collection1.size() != collection2.size()) return false;
for (E item1 : collection1) {
if (!collection2.contains(item1)) return false;
}
for (E item2 : collection2) {
if (!collection1.contains(item2)) return false;
}
return true;
}
@Override
public int hashCode() {
int result = users != null ? users.hashCode() : 0;
result = 31 * result + (keywords != null ? keywords.hashCode() : 0);
result = 31 * result + (sources != null ? sources.hashCode() : 0);
result = 31 * result + (links != null ? links.hashCode() : 0);
return result;
}
@JsonObject
@CursorObject(valuesCreator = true, tableInfo = true)
public static class UserItem {

View File

@ -63,6 +63,9 @@ public class ParcelableStatusUpdate implements Parcelable {
@JsonField(name = "attachment_url")
@ParcelableThisPlease
public String attachment_url;
@JsonField(name = "draft_unique_id")
@ParcelableThisPlease
public String draft_unique_id;
public ParcelableStatusUpdate() {
}

View File

@ -553,6 +553,8 @@ public interface TwidereDataStore {
String ACTION_EXTRAS = "action_extras";
String UNIQUE_ID = "unique_id";
String[] COLUMNS = DraftTableInfo.COLUMNS;
String[] TYPES = DraftTableInfo.TYPES;

View File

@ -171,7 +171,7 @@ dependencies {
compile "com.github.mariotaku.CommonsLibrary:text:$mariotaku_commons_library_version"
compile "com.github.mariotaku.CommonsLibrary:text-kotlin:$mariotaku_commons_library_version"
compile 'com.github.mariotaku:KPreferences:0.9.5'
compile 'com.github.mariotaku:Chameleon:0.9.3'
compile 'com.github.mariotaku:Chameleon:0.9.4'
compile "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
compile 'nl.komponents.kovenant:kovenant:3.3.0'
compile 'nl.komponents.kovenant:kovenant-android:3.3.0'

View File

@ -4,20 +4,26 @@ import android.app.NotificationManager
import android.content.Context
import android.content.Intent
import android.support.v7.app.NotificationCompat
import android.util.Log
import android.util.Xml
import com.dropbox.core.DbxRequestConfig
import com.dropbox.core.NetworkIOException
import com.dropbox.core.v2.DbxClientV2
import com.dropbox.core.v2.files.DeleteArg
import com.dropbox.core.v2.files.FileMetadata
import com.dropbox.core.v2.files.ListFolderResult
import com.dropbox.core.v2.files.WriteMode
import org.mariotaku.kpreferences.get
import org.mariotaku.ktextension.map
import org.mariotaku.twidere.BuildConfig
import org.mariotaku.twidere.R
import org.mariotaku.twidere.dropboxAuthTokenKey
import org.mariotaku.twidere.extension.model.*
import org.mariotaku.twidere.model.DraftCursorIndices
import org.mariotaku.twidere.model.Draft
import org.mariotaku.twidere.model.FiltersData
import org.mariotaku.twidere.provider.TwidereDataStore.Drafts
import java.io.*
import org.mariotaku.twidere.util.sync.FileBasedDraftsSyncHelper
import org.mariotaku.twidere.util.sync.FileBasedFiltersDataSyncHelper
import java.io.IOException
import java.util.*
/**
* Created by mariotaku on 2016/12/7.
@ -44,98 +50,102 @@ class DropboxDataSyncService : BaseIntentService("dropbox_data_sync") {
}
private fun uploadDrafts(client: DbxClientV2) {
val cur = contentResolver.query(Drafts.CONTENT_URI, Drafts.COLUMNS, null, null, null) ?: return
cur.map(DraftCursorIndices(cur)).forEach { draft ->
client.newUploader("/Drafts/${draft.timestamp}.eml").use {
draft.writeMimeMessageTo(this, it.outputStream)
it.finish()
}
val helper = DropboxDraftsSyncHelper(this, client)
try {
helper.performSync()
} catch (e: IOException) {
Log.w(LOGTAG, e)
}
cur.close()
}
@Throws(IOException::class)
private fun syncFilters(client: DbxClientV2) {
val helper = DropboxFiltersDataSyncHelper(this, client)
helper.sync()
try {
helper.performSync()
} catch (e: IOException) {
Log.w(LOGTAG, e)
}
}
abstract class FileBasedFiltersDataSyncHelper(val context: Context) {
class DropboxDraftsSyncHelper(context: Context, val client: DbxClientV2) : FileBasedDraftsSyncHelper<FileMetadata>(context) {
@Throws(IOException::class)
protected abstract fun loadFromRemote(): FiltersData
@Throws(IOException::class)
protected abstract fun saveToRemote(data: FiltersData)
fun sync() {
val remoteFilters: FiltersData = loadFromRemote()
val filters: FiltersData = FiltersData().apply {
read(context.contentResolver)
initFields()
}
val syncDataDir: File = context.syncDataDir.apply {
if (!exists()) {
mkdirs()
}
}
val snapshotFile = File(syncDataDir, "filters.xml")
val deletedFilters: FiltersData? = try {
FileReader(snapshotFile).use {
val result = FiltersData()
val parser = Xml.newPullParser()
parser.setInput(it)
result.parse(parser)
result.removeAll(filters)
return@use result
}
} catch (e: FileNotFoundException) {
null
}
filters.addAll(remoteFilters, true)
if (deletedFilters != null) {
filters.removeAll(deletedFilters)
}
filters.write(context.contentResolver)
saveToRemote(filters)
override fun Draft.saveToRemote(): FileMetadata {
try {
FileWriter(snapshotFile).use {
val serializer = Xml.newSerializer()
serializer.setFeature("http://xmlpull.org/v1/doc/features.html#indent-output", true)
serializer.setOutput(it)
filters.serialize(serializer)
client.newUploader("/Drafts/$filename", this.timestamp).use {
this.writeMimeMessageTo(context, it.outputStream)
return it.finish()
}
} catch (e: FileNotFoundException) {
// Ignore
} catch (e: NetworkIOException) {
throw IOException(e)
}
}
private val Context.syncDataDir: File
get() = File(filesDir, "sync_data")
@Throws(IOException::class)
override fun Draft.loadFromRemote(info: FileMetadata): Boolean {
try {
client.files().download(info.pathLower).use {
val parsed = this.readMimeMessageFrom(context, it.inputStream)
if (parsed) {
this.timestamp = info.draftTimestamp
this.unique_id = info.draftFileName.substringBeforeLast(".eml")
}
return parsed
}
} catch (e: NetworkIOException) {
throw IOException(e)
}
}
override fun removeDrafts(list: List<FileMetadata>): Boolean {
return client.files().deleteBatch(list.map { DeleteArg(it.pathLower) }) != null
}
override fun removeDraft(info: FileMetadata): Boolean {
return client.files().delete(info.pathLower) != null
}
override val FileMetadata.draftTimestamp: Long get() = this.clientModified.time
override val FileMetadata.draftFileName: String get() = this.name
override fun listRemoteDrafts(): List<FileMetadata> {
val result = ArrayList<FileMetadata>()
var listResult: ListFolderResult = client.files().listFolder("/Drafts/")
while (true) {
// Do something with files
listResult.entries.mapNotNullTo(result) { it as? FileMetadata }
if (!listResult.hasMore) break
listResult = client.files().listFolderContinue(listResult.cursor)
}
return result
}
}
class DropboxFiltersDataSyncHelper(context: Context, val client: DbxClientV2) : FileBasedFiltersDataSyncHelper(context) {
override fun loadFromRemote(): FiltersData = client.newDownloader("/Common/filters.xml").use { downloader ->
val result = FiltersData()
val parser = Xml.newPullParser()
parser.setInput(downloader.inputStream, "UTF-8")
result.parse(parser)
result.initFields()
return@use result
override fun FiltersData.loadFromRemote(snapshotModifiedMillis: Long): Boolean {
client.newDownloader("/Common/filters.xml").use { downloader ->
// Local file is the same with remote version
if (Math.abs(downloader.result.clientModified.time - snapshotModifiedMillis) < 1000) {
return false
}
val parser = Xml.newPullParser()
parser.setInput(downloader.inputStream, "UTF-8")
this.parse(parser)
this.initFields()
return true
}
}
override fun saveToRemote(data: FiltersData) {
client.newUploader("/Common/filters.xml").use { uploader ->
override fun FiltersData.saveToRemote(localModifiedTime: Long): Boolean {
client.newUploader("/Common/filters.xml", localModifiedTime).use { uploader ->
val serializer = Xml.newSerializer()
serializer.setFeature("http://xmlpull.org/v1/doc/features.html#indent-output", true)
serializer.setOutput(uploader.outputStream, "UTF-8")
data.serialize(serializer)
this.serialize(serializer)
uploader.finish()
return true
}
}
@ -143,6 +153,8 @@ class DropboxDataSyncService : BaseIntentService("dropbox_data_sync") {
}
private const val LOGTAG = "Twidere.DBSync"
private fun DbxClientV2.newUploader(path: String, clientModified: Long) = files().uploadBuilder(path)
.withMode(WriteMode.OVERWRITE).withMute(true).withClientModified(Date(clientModified)).start()
private fun DbxClientV2.newUploader(path: String) = files().uploadBuilder(path).withMode(WriteMode.OVERWRITE).withMute(true).start()
private fun DbxClientV2.newDownloader(path: String) = files().downloadBuilder(path).start()

View File

@ -0,0 +1,9 @@
package android.support.v4.app;
/**
* Created by mariotaku on 2016/12/31.
*/
public class ListFragmentAccessor {
public static final int INTERNAL_PROGRESS_CONTAINER_ID = ListFragment.INTERNAL_PROGRESS_CONTAINER_ID;
}

View File

@ -94,6 +94,7 @@ public abstract class BaseEvent implements Parcelable, LogModel {
}
public void markStart(@NonNull Context context) {
if (!BuildConfig.HOTMOBI_LOG_ENABLED) return;
setStartTime(System.currentTimeMillis());
setTimeOffset(TimeZone.getDefault().getOffset(startTime));
setLocation(LocationUtils.getCachedLatLng(context));

View File

@ -34,7 +34,7 @@ import static org.mariotaku.twidere.annotation.PreferenceType.STRING;
public interface Constants extends TwidereConstants {
String DATABASES_NAME = "twidere.sqlite";
int DATABASES_VERSION = 156;
int DATABASES_VERSION = 157;
int MENU_GROUP_STATUS_EXTENSION = 10;
int MENU_GROUP_COMPOSE_EXTENSION = 11;
@ -78,6 +78,7 @@ public interface Constants extends TwidereConstants {
int LINK_ID_FILTERS = 110;
int LINK_ID_FILTERS_IMPORT_BLOCKS = 111;
int LINK_ID_FILTERS_IMPORT_MUTES = 112;
int LINK_ID_FILTERS_SUBSCRIPTIONS = 113;
int LINK_ID_PROFILE_EDITOR = 121;
String TWIDERE_PREVIEW_NICKNAME = "Twidere";

View File

@ -1,38 +0,0 @@
package org.mariotaku.twidere.model.util;
import android.accounts.AccountManager;
import android.content.Context;
import org.mariotaku.twidere.model.AccountDetails;
import org.mariotaku.twidere.model.Draft;
import org.mariotaku.twidere.model.ParcelableStatusUpdate;
import org.mariotaku.twidere.model.draft.UpdateStatusActionExtras;
/**
* Created by mariotaku on 16/2/12.
*/
public class ParcelableStatusUpdateUtils {
private ParcelableStatusUpdateUtils() {
}
public static ParcelableStatusUpdate fromDraftItem(final Context context, final Draft draft) {
ParcelableStatusUpdate statusUpdate = new ParcelableStatusUpdate();
if (draft.account_keys != null) {
statusUpdate.accounts = AccountUtils.getAllAccountDetails(AccountManager.get(context), draft.account_keys, true);
} else {
statusUpdate.accounts = new AccountDetails[0];
}
statusUpdate.text = draft.text;
statusUpdate.location = draft.location;
statusUpdate.media = draft.media;
if (draft.action_extras instanceof UpdateStatusActionExtras) {
final UpdateStatusActionExtras extra = (UpdateStatusActionExtras) draft.action_extras;
statusUpdate.in_reply_to_status = extra.getInReplyToStatus();
statusUpdate.is_possibly_sensitive = extra.isPossiblySensitive();
statusUpdate.display_coordinates = extra.getDisplayCoordinates();
statusUpdate.attachment_url = extra.getAttachmentUrl();
}
return statusUpdate;
}
}

View File

@ -0,0 +1,38 @@
package org.mariotaku.twidere.model.util
import android.accounts.AccountManager
import android.content.Context
import org.mariotaku.twidere.extension.model.unique_id_non_null
import org.mariotaku.twidere.model.AccountDetails
import org.mariotaku.twidere.model.Draft
import org.mariotaku.twidere.model.ParcelableStatusUpdate
import org.mariotaku.twidere.model.draft.UpdateStatusActionExtras
/**
* Created by mariotaku on 16/2/12.
*/
object ParcelableStatusUpdateUtils {
fun fromDraftItem(context: Context, draft: Draft): ParcelableStatusUpdate {
val statusUpdate = ParcelableStatusUpdate()
if (draft.account_keys != null) {
statusUpdate.accounts = AccountUtils.getAllAccountDetails(AccountManager.get(context), draft.account_keys!!, true)
} else {
statusUpdate.accounts = arrayOfNulls<AccountDetails>(0)
}
statusUpdate.text = draft.text
statusUpdate.location = draft.location
statusUpdate.media = draft.media
if (draft.action_extras is UpdateStatusActionExtras) {
val extra = draft.action_extras as UpdateStatusActionExtras?
statusUpdate.in_reply_to_status = extra!!.inReplyToStatus
statusUpdate.is_possibly_sensitive = extra.isPossiblySensitive
statusUpdate.display_coordinates = extra.displayCoordinates
statusUpdate.attachment_url = extra.attachmentUrl
}
statusUpdate.draft_unique_id = draft.unique_id_non_null
return statusUpdate
}
}

View File

@ -27,6 +27,7 @@ import android.os.Bundle;
import org.apache.commons.collections.primitives.ArrayIntList;
import org.apache.commons.collections.primitives.IntList;
import org.mariotaku.twidere.BuildConfig;
import org.mariotaku.twidere.activity.HomeActivity;
import edu.tsinghua.hotmobi.HotMobiLogger;
@ -66,7 +67,7 @@ public class ActivityTracker implements Application.ActivityLifecycleCallbacks {
mHomeActivityStarted = true;
}
// BEGIN HotMobi
if (mSessionEvent == null) {
if (mSessionEvent == null && BuildConfig.HOTMOBI_LOG_ENABLED) {
mSessionEvent = SessionEvent.create(activity);
}
// END HotMobi

View File

@ -196,8 +196,9 @@ public final class Utils implements Constants {
LINK_HANDLER_URI_MATCHER.addURI(AUTHORITY_ACCOUNTS, null, LINK_ID_ACCOUNTS);
LINK_HANDLER_URI_MATCHER.addURI(AUTHORITY_DRAFTS, null, LINK_ID_DRAFTS);
LINK_HANDLER_URI_MATCHER.addURI(AUTHORITY_FILTERS, null, LINK_ID_FILTERS);
LINK_HANDLER_URI_MATCHER.addURI(AUTHORITY_FILTERS_IMPORT_BLOCKS, null, LINK_ID_FILTERS_IMPORT_BLOCKS);
LINK_HANDLER_URI_MATCHER.addURI(AUTHORITY_FILTERS_IMPORT_MUTES, null, LINK_ID_FILTERS_IMPORT_MUTES);
LINK_HANDLER_URI_MATCHER.addURI(AUTHORITY_FILTERS, PATH_FILTERS_IMPORT_BLOCKS, LINK_ID_FILTERS_IMPORT_BLOCKS);
LINK_HANDLER_URI_MATCHER.addURI(AUTHORITY_FILTERS, PATH_FILTERS_IMPORT_MUTES, LINK_ID_FILTERS_IMPORT_MUTES);
LINK_HANDLER_URI_MATCHER.addURI(AUTHORITY_FILTERS, PATH_FILTERS_SUBSCRIPTIONS, LINK_ID_FILTERS_SUBSCRIPTIONS);
LINK_HANDLER_URI_MATCHER.addURI(AUTHORITY_PROFILE_EDITOR, null, LINK_ID_PROFILE_EDITOR);
HOME_TABS_URI_MATCHER.addURI(CustomTabType.HOME_TIMELINE, null, TAB_CODE_HOME_TIMELINE);

View File

@ -12,6 +12,10 @@ fun Collection<*>?.isNullOrEmpty(): Boolean {
return this == null || this.isEmpty()
}
fun <T> MutableCollection<T>.addAllIgnoreDuplicates(collection: Collection<T>) {
addAll(collection.filter { it !in this })
fun <T> MutableCollection<T>.addAllEnhanced(collection: Collection<T>, ignoreDuplicates: Boolean): Boolean {
if (ignoreDuplicates) {
return addAll(collection.filter { it !in this })
} else {
return addAll(collection)
}
}

View File

@ -1,5 +1,6 @@
package org.mariotaku.ktextension
import android.support.v4.util.LongSparseArray
import android.util.SparseBooleanArray
/**
@ -7,4 +8,8 @@ import android.util.SparseBooleanArray
*/
operator fun SparseBooleanArray.set(key: Int, value: Boolean) {
put(key, value)
}
operator fun <E> LongSparseArray<E>.set(key: Long, value: E) {
put(key, value)
}

View File

@ -68,10 +68,12 @@ import org.mariotaku.ktextension.toTypedArray
import org.mariotaku.twidere.BuildConfig
import org.mariotaku.twidere.Constants.*
import org.mariotaku.twidere.R
import org.mariotaku.twidere.TwidereConstants
import org.mariotaku.twidere.adapter.ArrayRecyclerAdapter
import org.mariotaku.twidere.adapter.BaseRecyclerViewAdapter
import org.mariotaku.twidere.constant.*
import org.mariotaku.twidere.extension.model.getAccountUser
import org.mariotaku.twidere.extension.model.unique_id_non_null
import org.mariotaku.twidere.fragment.BaseDialogFragment
import org.mariotaku.twidere.fragment.PermissionRequestDialog
import org.mariotaku.twidere.fragment.PermissionRequestDialog.PermissionRequestCancelCallback
@ -134,6 +136,7 @@ class ComposeActivity : BaseActivity(), OnMenuItemClickListener, OnClickListener
private var composeKeyMetaState: Int = 0
private var draft: Draft? = null
private var nameFirst: Boolean = false
private var draftUniqueId: String? = null
private var shouldSkipDraft: Boolean = false
// Listeners
@ -220,6 +223,7 @@ class ComposeActivity : BaseActivity(), OnMenuItemClickListener, OnClickListener
outState.putParcelable(EXTRA_DRAFT, draft)
outState.putBoolean(EXTRA_SHOULD_SAVE_ACCOUNTS, shouldSaveAccounts)
outState.putString(EXTRA_ORIGINAL_TEXT, originalText)
outState.putString(EXTRA_DRAFT_UNIQUE_ID, draftUniqueId)
super.onSaveInstanceState(outState)
}
@ -436,7 +440,7 @@ class ComposeActivity : BaseActivity(), OnMenuItemClickListener, OnClickListener
fun saveToDrafts(): Uri {
val text = editText.text.toString()
val draft = Draft()
draft.unique_id = this.draftUniqueId ?: UUID.randomUUID().toString()
draft.action_type = getDraftAction(intent.action)
draft.account_keys = accountsAdapter.selectedAccountKeys
draft.text = text
@ -575,11 +579,12 @@ class ComposeActivity : BaseActivity(), OnMenuItemClickListener, OnClickListener
if (mediaList != null) {
addMedia(mediaList)
}
inReplyToStatus = savedInstanceState.getParcelable<ParcelableStatus>(EXTRA_STATUS)
mentionUser = savedInstanceState.getParcelable<ParcelableUser>(EXTRA_USER)
draft = savedInstanceState.getParcelable<Draft>(EXTRA_DRAFT)
inReplyToStatus = savedInstanceState.getParcelable(EXTRA_STATUS)
mentionUser = savedInstanceState.getParcelable(EXTRA_USER)
draft = savedInstanceState.getParcelable(EXTRA_DRAFT)
shouldSaveAccounts = savedInstanceState.getBoolean(EXTRA_SHOULD_SAVE_ACCOUNTS)
originalText = savedInstanceState.getString(EXTRA_ORIGINAL_TEXT)
draftUniqueId = savedInstanceState.getString(EXTRA_DRAFT_UNIQUE_ID)
setLabel(intent)
} else {
// The context was first created
@ -842,6 +847,7 @@ class ComposeActivity : BaseActivity(), OnMenuItemClickListener, OnClickListener
private fun handleEditDraftIntent(draft: Draft?): Boolean {
if (draft == null) return false
draftUniqueId = draft.unique_id_non_null
editText.setText(draft.text)
val selectionEnd = editText.length()
editText.setSelection(selectionEnd)
@ -854,6 +860,8 @@ class ComposeActivity : BaseActivity(), OnMenuItemClickListener, OnClickListener
possiblySensitive = it.isPossiblySensitive
inReplyToStatus = it.inReplyToStatus
}
val tag = Uri.withAppendedPath(Drafts.CONTENT_URI, draft._id.toString()).toString()
notificationManager.cancel(tag, TwidereConstants.NOTIFICATION_ID_DRAFTS)
return true
}
@ -1908,9 +1916,10 @@ class ComposeActivity : BaseActivity(), OnMenuItemClickListener, OnClickListener
companion object {
// Constants
private val EXTRA_SHOULD_SAVE_ACCOUNTS = "should_save_accounts"
private val EXTRA_ORIGINAL_TEXT = "original_text"
private val DISCARD_STATUS_DIALOG_FRAGMENT_TAG = "discard_status"
private const val EXTRA_SHOULD_SAVE_ACCOUNTS = "should_save_accounts"
private const val EXTRA_ORIGINAL_TEXT = "original_text"
private const val EXTRA_DRAFT_UNIQUE_ID = "draft_unique_id"
private const val DISCARD_STATUS_DIALOG_FRAGMENT_TAG = "discard_status"
val LOCATION_VALUE_PLACE = "place"
val LOCATION_VALUE_COORDINATE = "coordinate"

View File

@ -51,6 +51,7 @@ import org.mariotaku.twidere.fragment.*
import org.mariotaku.twidere.fragment.filter.FiltersFragment
import org.mariotaku.twidere.fragment.filter.FiltersImportBlocksFragment
import org.mariotaku.twidere.fragment.filter.FiltersImportMutesFragment
import org.mariotaku.twidere.fragment.filter.FiltersSubscriptionsFragment
import org.mariotaku.twidere.fragment.iface.IBaseFragment
import org.mariotaku.twidere.fragment.iface.IBaseFragment.SystemWindowsInsetsCallback
import org.mariotaku.twidere.fragment.iface.IToolBarSupportFragment
@ -387,6 +388,9 @@ class LinkHandlerActivity : BaseActivity(), SystemWindowsInsetsCallback, IContro
LINK_ID_FILTERS_IMPORT_MUTES -> {
title = getString(R.string.title_select_users)
}
LINK_ID_FILTERS_SUBSCRIPTIONS -> {
title = getString(R.string.title_manage_filter_subscriptions)
}
else -> {
title = getString(R.string.app_name)
}
@ -728,6 +732,10 @@ class LinkHandlerActivity : BaseActivity(), SystemWindowsInsetsCallback, IContro
LINK_ID_FILTERS_IMPORT_MUTES -> {
fragment = FiltersImportMutesFragment()
}
LINK_ID_FILTERS_SUBSCRIPTIONS -> {
fragment = FiltersSubscriptionsFragment()
isAccountIdRequired = false
}
else -> {
return null
}

View File

@ -92,12 +92,14 @@ fun Draft.writeMimeMessageTo(context: Context, st: OutputStream) {
st.flush()
}
fun Draft.readMimeMessageFrom(context: Context, st: InputStream) {
fun Draft.readMimeMessageFrom(context: Context, st: InputStream): Boolean {
val config = MimeConfig()
val parser = MimeStreamParser(config)
parser.isContentDecoding = true
parser.setContentHandler(DraftContentHandler(context, this))
val handler = DraftContentHandler(context, this)
parser.setContentHandler(handler)
parser.parse(st)
return !handler.malformedData
}
fun Draft.getActionName(context: Context): String? {
@ -119,9 +121,16 @@ fun Draft.getActionName(context: Context): String? {
return null
}
val Draft.filename: String get() = "$unique_id_non_null.eml"
val Draft.unique_id_non_null: String
get() = unique_id ?: UUID.nameUUIDFromBytes(("$_id:$timestamp").toByteArray()).toString()
private class DraftContentHandler(private val context: Context, private val draft: Draft) : SimpleContentHandler() {
private val processingStack = Stack<SimpleContentHandler>()
private val mediaList: MutableList<ParcelableMediaUpdate> = ArrayList()
internal var malformedData: Boolean = false
override fun headers(header: Header) {
if (processingStack.isEmpty()) {
draft.timestamp = header.getField("Date")?.convert {
@ -159,6 +168,10 @@ private class DraftContentHandler(private val context: Context, private val draf
}
override fun body(bd: BodyDescriptor?, `is`: InputStream?) {
if (processingStack.isEmpty()) {
malformedData = true
return
}
processingStack.peek().body(bd, `is`)
}

View File

@ -2,7 +2,7 @@ package org.mariotaku.twidere.extension.model
import android.content.ContentResolver
import android.net.Uri
import org.mariotaku.ktextension.addAllIgnoreDuplicates
import org.mariotaku.ktextension.addAllEnhanced
import org.mariotaku.ktextension.convert
import org.mariotaku.ktextension.map
import org.mariotaku.sqliteqb.library.Expression
@ -150,31 +150,42 @@ fun FiltersData.parse(parser: XmlPullParser) {
}
fun FiltersData.addAll(data: FiltersData, ignoreDuplicates: Boolean = false) {
this.users = mergeList(this.users, data.users, ignoreDuplicates = ignoreDuplicates)
this.keywords = mergeList(this.keywords, data.keywords, ignoreDuplicates = ignoreDuplicates)
this.sources = mergeList(this.sources, data.sources, ignoreDuplicates = ignoreDuplicates)
this.links = mergeList(this.links, data.links, ignoreDuplicates = ignoreDuplicates)
}
fun FiltersData.removeAll(data: FiltersData) {
data.users?.let { this.users?.removeAll(it) }
data.keywords?.let { this.keywords?.removeAll(it) }
data.sources?.let { this.sources?.removeAll(it) }
data.links?.let { this.links?.removeAll(it) }
}
private fun <T> mergeList(vararg lists: List<T>?, ignoreDuplicates: Boolean): List<T> {
val result = ArrayList<T>()
lists.forEach {
if (it == null) return@forEach
if (ignoreDuplicates) {
result.addAllIgnoreDuplicates(it)
} else {
result.addAll(it)
}
fun FiltersData.addAll(data: FiltersData, ignoreDuplicates: Boolean = false): Boolean {
var changed: Boolean = false
if (this.users != null) {
changed = changed or this.users.addAllEnhanced(collection = data.users, ignoreDuplicates = ignoreDuplicates)
} else {
this.users = data.users
changed = true
}
return result
if (this.keywords != null) {
changed = changed or this.keywords.addAllEnhanced(collection = data.keywords, ignoreDuplicates = ignoreDuplicates)
} else {
this.keywords = data.keywords
changed = true
}
if (this.sources != null) {
changed = changed or this.sources.addAllEnhanced(collection = data.sources, ignoreDuplicates = ignoreDuplicates)
} else {
this.sources = data.sources
changed = true
}
if (this.links != null) {
changed = changed or this.links.addAllEnhanced(collection = data.links, ignoreDuplicates = ignoreDuplicates)
} else {
this.links = data.links
changed = true
}
return changed
}
fun FiltersData.removeAll(data: FiltersData): Boolean {
var changed: Boolean = false
changed = changed or (data.users?.let { this.users?.removeAll(it) } ?: false)
changed = changed or (data.keywords?.let { this.keywords?.removeAll(it) } ?: false)
changed = changed or (data.sources?.let { this.sources?.removeAll(it) } ?: false)
changed = changed or (data.links?.let { this.links?.removeAll(it) } ?: false)
return changed
}
fun FiltersData.initFields() {

View File

@ -24,8 +24,15 @@ import android.content.Context
import android.content.SharedPreferences
import android.os.Bundle
import android.support.v4.app.ListFragment
import android.support.v4.app.ListFragmentAccessor
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.AbsListView
import android.widget.AbsListView.OnScrollListener
import android.widget.ProgressBar
import org.mariotaku.chameleon.Chameleon
import org.mariotaku.chameleon.view.ChameleonProgressBar
import org.mariotaku.twidere.app.TwidereApplication
import org.mariotaku.twidere.constant.IntentConstants.EXTRA_TAB_POSITION
import org.mariotaku.twidere.fragment.iface.RefreshScrollTopInterface
@ -103,7 +110,14 @@ open class BaseListFragment : ListFragment(), OnScrollListener, RefreshScrollTop
activityFirstCreated = true
}
fun onPostStart() {
override fun onCreateView(inflater: LayoutInflater?, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val view = super.onCreateView(inflater, container, savedInstanceState)!!
((view.findViewById(ListFragmentAccessor.INTERNAL_PROGRESS_CONTAINER_ID) as ViewGroup).getChildAt(0) as ProgressBar).apply {
val appearance = ChameleonProgressBar.Appearance()
appearance.progressColor = Chameleon.getOverrideTheme(activity, activity).colorPrimary
ChameleonProgressBar.Appearance.apply(this, appearance)
}
return view
}
override fun onScroll(view: AbsListView, firstVisibleItem: Int, visibleItemCount: Int,
@ -129,7 +143,6 @@ open class BaseListFragment : ListFragment(), OnScrollListener, RefreshScrollTop
override fun onStart() {
super.onStart()
onPostStart()
}
override fun onStop() {

View File

@ -26,6 +26,7 @@ import org.mariotaku.twidere.adapter.iface.IContentCardAdapter
import org.mariotaku.twidere.adapter.iface.ILoadMoreSupportAdapter
import org.mariotaku.twidere.adapter.iface.ILoadMoreSupportAdapter.IndicatorPosition
import org.mariotaku.twidere.constant.*
import org.mariotaku.twidere.constant.IntentConstants.EXTRA_COUNT
import org.mariotaku.twidere.fragment.AbsContentListRecyclerViewFragment
import org.mariotaku.twidere.fragment.BaseDialogFragment
import org.mariotaku.twidere.fragment.MessageDialogFragment
@ -44,12 +45,10 @@ import java.lang.ref.WeakReference
/**
* Created by mariotaku on 2016/12/26.
*/
private const val EXTRA_COUNT = "count"
abstract class BaseFiltersImportFragment : AbsContentListRecyclerViewFragment<BaseFiltersImportFragment.SelectableUsersAdapter>(),
LoaderManager.LoaderCallbacks<List<ParcelableUser>?> {
protected var nextCursor: Long = -1
private set
protected var prevCursor: Long = -1

View File

@ -68,14 +68,14 @@ class FilteredUsersFragment : BaseFiltersFragment() {
REQUEST_IMPORT_BLOCKS_SELECT_ACCOUNT -> {
if (resultCode != FragmentActivity.RESULT_OK) return
val intent = Intent(context, LinkHandlerActivity::class.java)
intent.data = Uri.Builder().scheme(SCHEME_TWIDERE).authority(AUTHORITY_FILTERS_IMPORT_BLOCKS).build()
intent.data = Uri.Builder().scheme(SCHEME_TWIDERE).authority(AUTHORITY_FILTERS).path(PATH_FILTERS_IMPORT_BLOCKS).build()
intent.putExtra(EXTRA_ACCOUNT_KEY, data!!.getParcelableExtra<UserKey>(EXTRA_ACCOUNT_KEY))
startActivity(intent)
}
REQUEST_IMPORT_MUTES_SELECT_ACCOUNT -> {
if (resultCode != FragmentActivity.RESULT_OK) return
val intent = Intent(context, LinkHandlerActivity::class.java)
intent.data = Uri.Builder().scheme(SCHEME_TWIDERE).authority(AUTHORITY_FILTERS_IMPORT_MUTES).build()
intent.data = Uri.Builder().scheme(SCHEME_TWIDERE).authority(AUTHORITY_FILTERS).path(PATH_FILTERS_IMPORT_MUTES).build()
intent.putExtra(EXTRA_ACCOUNT_KEY, data!!.getParcelableExtra<UserKey>(EXTRA_ACCOUNT_KEY))
startActivity(intent)
}

View File

@ -0,0 +1,14 @@
package org.mariotaku.twidere.fragment.filter
import android.os.Bundle
import org.mariotaku.twidere.fragment.BaseListFragment
/**
* Created by mariotaku on 2016/12/31.
*/
class FiltersSubscriptionsFragment : BaseListFragment() {
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
}
}

View File

@ -130,11 +130,11 @@ class BackgroundOperationService : BaseIntentService("background_operation") {
val where = Expression.equals(Drafts._ID, draftId)
val cr = contentResolver
val c = cr.query(Drafts.CONTENT_URI, Drafts.COLUMNS, where.sql, null, null) ?: return
val i = DraftCursorIndices(c)
val item: Draft
try {
@Suppress("ConvertTryFinallyToUseCall")
val item: Draft = try {
val i = DraftCursorIndices(c)
if (!c.moveToFirst()) return
item = i.newObject(c)
i.newObject(c)
} finally {
c.close()
}

View File

@ -516,6 +516,7 @@ class UpdateStatusTask(
private fun saveDraft(@Draft.Action draftAction: String?, statusUpdate: ParcelableStatusUpdate): Long {
val draft = Draft()
draft.unique_id = statusUpdate.draft_unique_id ?: UUID.randomUUID().toString()
draft.account_keys = statusUpdate.accounts.map { it.key }.toTypedArray()
draft.action_type = draftAction ?: Draft.Action.UPDATE_STATUS
draft.text = statusUpdate.text

View File

@ -0,0 +1,209 @@
package org.mariotaku.twidere.util.sync
import android.content.Context
import android.support.v4.util.LongSparseArray
import android.util.Log
import org.mariotaku.ktextension.map
import org.mariotaku.ktextension.set
import org.mariotaku.sqliteqb.library.Expression
import org.mariotaku.twidere.BuildConfig
import org.mariotaku.twidere.extension.model.filename
import org.mariotaku.twidere.extension.model.unique_id_non_null
import org.mariotaku.twidere.model.Draft
import org.mariotaku.twidere.model.DraftCursorIndices
import org.mariotaku.twidere.model.DraftValuesCreator
import org.mariotaku.twidere.provider.TwidereDataStore.Drafts
import org.mariotaku.twidere.util.content.ContentResolverUtils
import java.io.File
import java.io.FileNotFoundException
import java.io.IOException
import java.util.*
/**
* Created by mariotaku on 2016/12/31.
*/
abstract class FileBasedDraftsSyncHelper<RemoteFileInfo>(val context: Context) {
fun performSync(): Boolean {
val syncDataDir: File = context.syncDataDir.mkdirIfNotExists() ?: return false
val snapshotsListFile = File(syncDataDir, "draft_ids.list")
// Read last synced id
val snapshotIds: List<String> = try {
snapshotsListFile.readLines()
} catch (e: FileNotFoundException) {
emptyList<String>()
}
val localDrafts = run {
val cur = context.contentResolver.query(Drafts.CONTENT_URI, Drafts.COLUMNS, null, null, null)!!
try {
return@run cur.map(DraftCursorIndices(cur))
} finally {
cur.close()
}
}
// Ids of draft removed locally, we will delete these from remote storage
val localRemovedIds = snapshotIds.filter { id -> localDrafts.none { draft -> draft.unique_id_non_null == id } }
val remoteDrafts = listRemoteDrafts()
// Download remote items
val downloadRemoteInfoList = ArrayList<RemoteFileInfo>()
// Remote remote items: snapshot has but database doesn't have
val removeRemoteInfoList = ArrayList<RemoteFileInfo>()
// Update local items using remote
val updateLocalInfoList = LongSparseArray<RemoteFileInfo>()
// Remove local items: snapshot has but remote doesn't have
val removeLocalIdsList = snapshotIds.filter { snapshotId ->
remoteDrafts.none { it.draftFileName == "$snapshotId.eml" }
}
val uploadLocalList = ArrayList<Draft>()
remoteDrafts.forEach { remoteDraft ->
val localDraft = localDrafts.find { it.filename == remoteDraft.draftFileName }
if (remoteDraft.draftFileName.substringBefore(".eml") in localRemovedIds) {
// Local removed, remove remote
removeRemoteInfoList.add(remoteDraft)
} else if (localDraft == null) {
// Local doesn't exist, download remote
downloadRemoteInfoList.add(remoteDraft)
} else if (remoteDraft.draftTimestamp - localDraft.timestamp > 1000) {
// Local is older, update from remote
updateLocalInfoList[localDraft._id] = remoteDraft
} else if (localDraft.timestamp - remoteDraft.draftTimestamp > 1000) {
// Local is newer, upload local
uploadLocalList.add(localDraft)
}
}
// Deal with local drafts that remote doesn't have
localDrafts.filterTo(uploadLocalList) { localDraft ->
if (remoteDrafts.any { it.draftFileName == localDraft.filename }) {
return@filterTo false
}
if (downloadRemoteInfoList.any { it.draftFileName == localDraft.filename }) {
return@filterTo false
}
if (removeRemoteInfoList.any { it.draftFileName == localDraft.filename }) {
return@filterTo false
}
if (localDraft.unique_id_non_null in removeLocalIdsList) {
return@filterTo false
}
if ((0 until updateLocalInfoList.size()).any { updateLocalInfoList.valueAt(it).draftFileName == localDraft.filename }) {
return@filterTo false
}
return@filterTo true
}
// Upload local items
if (BuildConfig.DEBUG) {
val fileList = uploadLocalList.joinToString(",") { it.filename }
Log.d(LOGTAG, "Uploading local drafts $fileList")
}
uploadDrafts(uploadLocalList)
// Download remote items
if (BuildConfig.DEBUG) {
val fileList = downloadRemoteInfoList.joinToString(",") { it.draftFileName }
Log.d(LOGTAG, "Downloading remote drafts $fileList")
}
ContentResolverUtils.bulkInsert(context.contentResolver, Drafts.CONTENT_URI,
downloadDrafts(downloadRemoteInfoList).map { DraftValuesCreator.create(it) })
// Update local items
if (BuildConfig.DEBUG) {
val fileList = (0 until updateLocalInfoList.size()).joinToString(",") { updateLocalInfoList.valueAt(it).draftFileName }
Log.d(LOGTAG, "Updating local drafts $fileList")
}
for (index in 0 until updateLocalInfoList.size()) {
val draft = Draft()
if (draft.loadFromRemote(updateLocalInfoList.valueAt(index))) {
val where = Expression.equalsArgs(Drafts._ID).sql
val whereArgs = arrayOf(updateLocalInfoList.keyAt(index).toString())
context.contentResolver.update(Drafts.CONTENT_URI, DraftValuesCreator.create(draft), where, whereArgs)
}
}
// Remove local items
if (BuildConfig.DEBUG) {
val fileList = removeLocalIdsList.joinToString(",") { "$it.eml" }
Log.d(LOGTAG, "Removing local drafts $fileList")
}
ContentResolverUtils.bulkDelete(context.contentResolver, Drafts.CONTENT_URI,
Drafts.UNIQUE_ID, removeLocalIdsList, null)
// Remove remote items
if (BuildConfig.DEBUG) {
val fileList = removeRemoteInfoList.joinToString(",") { it.draftFileName }
Log.d(LOGTAG, "Removing remote drafts $fileList")
}
removeDrafts(removeRemoteInfoList)
snapshotsListFile.writer().use { writer ->
val cur = context.contentResolver.query(Drafts.CONTENT_URI, Drafts.COLUMNS, null, null, null)!!
try {
cur.map(DraftCursorIndices(cur)).map { it.unique_id_non_null }.forEach { line ->
writer.write(line)
writer.write("\n")
}
} finally {
cur.close()
}
}
if (BuildConfig.DEBUG) {
Log.d(LOGTAG, "Drafts sync complete")
}
return true
}
@Throws(IOException::class)
abstract fun listRemoteDrafts(): List<RemoteFileInfo>
@Throws(IOException::class)
open fun downloadDrafts(list: List<RemoteFileInfo>): List<Draft> {
val result = ArrayList<Draft>()
list.forEach {
val draft = Draft()
if (draft.loadFromRemote(it)) {
result.add(draft)
}
}
return result
}
@Throws(IOException::class)
open fun removeDrafts(list: List<RemoteFileInfo>): Boolean {
var result = false
list.forEach { item ->
result = result or removeDraft(item)
}
return result
}
@Throws(IOException::class)
open fun uploadDrafts(list: List<Draft>): Boolean {
var result = false
list.forEach { item ->
result = result or (item.saveToRemote() != null)
}
return result
}
@Throws(IOException::class)
abstract fun Draft.loadFromRemote(info: RemoteFileInfo): Boolean
@Throws(IOException::class)
abstract fun removeDraft(info: RemoteFileInfo): Boolean
@Throws(IOException::class)
abstract fun Draft.saveToRemote(): RemoteFileInfo?
abstract val RemoteFileInfo.draftFileName: String
abstract val RemoteFileInfo.draftTimestamp: Long
}

View File

@ -0,0 +1,93 @@
package org.mariotaku.twidere.util.sync
import android.content.Context
import android.util.Log
import android.util.Xml
import org.mariotaku.twidere.BuildConfig
import org.mariotaku.twidere.extension.model.*
import org.mariotaku.twidere.model.FiltersData
import java.io.*
abstract class FileBasedFiltersDataSyncHelper(val context: Context) {
fun performSync(): Boolean {
val syncDataDir: File = context.syncDataDir.mkdirIfNotExists() ?: return false
val snapshotFile = File(syncDataDir, "filters.xml")
val remoteFilters = FiltersData()
if (BuildConfig.DEBUG) {
Log.d(LOGTAG, "Downloading remote filters")
}
val remoteModified = remoteFilters.loadFromRemote(snapshotFile.lastModified())
if (BuildConfig.DEBUG && !remoteModified) {
Log.d(LOGTAG, "Remote filter unchanged, skipped downloading")
}
val filters: FiltersData = FiltersData().apply {
read(context.contentResolver)
initFields()
}
var localModified = false
val deletedFilters: FiltersData? = try {
FileReader(snapshotFile).use {
val result = FiltersData()
val parser = Xml.newPullParser()
parser.setInput(it)
result.parse(parser)
localModified = localModified or (result != filters)
result.removeAll(filters)
return@use result
}
} catch (e: FileNotFoundException) {
localModified = true
null
}
if (remoteModified) {
localModified = localModified or filters.addAll(remoteFilters, true)
}
if (deletedFilters != null) {
localModified = localModified or filters.removeAll(deletedFilters)
}
filters.write(context.contentResolver)
val localModifiedTime = System.currentTimeMillis()
if (localModified) {
if (BuildConfig.DEBUG) {
Log.d(LOGTAG, "Uploading filters")
}
filters.saveToRemote(localModifiedTime)
} else if (BuildConfig.DEBUG) {
Log.d(LOGTAG, "Local not modified, skip upload")
}
try {
FileWriter(snapshotFile).use {
val serializer = Xml.newSerializer()
serializer.setFeature("http://xmlpull.org/v1/doc/features.html#indent-output", true)
serializer.setOutput(it)
filters.serialize(serializer)
}
snapshotFile.setLastModified(localModifiedTime)
} catch (e: FileNotFoundException) {
// Ignore
}
if (BuildConfig.DEBUG) {
Log.d(LOGTAG, "Filters sync complete")
}
return true
}
/**
* Return false if remote not changed
*/
@Throws(IOException::class)
protected abstract fun FiltersData.loadFromRemote(snapshotModifiedMillis: Long): Boolean
@Throws(IOException::class)
protected abstract fun FiltersData.saveToRemote(localModifiedTime: Long): Boolean
}

View File

@ -0,0 +1,17 @@
package org.mariotaku.twidere.util.sync
import android.content.Context
import java.io.File
/**
* Created by mariotaku on 2016/12/31.
*/
internal const val LOGTAG = "Twidere.Sync"
internal val Context.syncDataDir: File
get() = File(filesDir, "sync_data")
internal fun File.mkdirIfNotExists(): File? {
if (exists() || mkdirs()) return this
return null
}

View File

@ -869,6 +869,7 @@
<string name="message_no_user_selected">No user selected</string>
<string name="preference_title_advanced">Advanced</string>
<string name="preference_filter_possibility_sensitive_statuses">Filter sensitive tweets</string>
<string name="preference_title_filter_subscription">Filter subscription</string>
<string name="preference_title_filter_subscriptions">Filter subscriptions</string>
<string name="preference_title_filter_manage_subscriptions">Manage</string>
<string name="title_manage_filter_subscriptions">Filter subscriptions</string>
</resources>

View File

@ -8,7 +8,12 @@
android:defaultValue="false"
android:key="filter_possibility_sensitive_statuses"
android:title="@string/preference_filter_possibility_sensitive_statuses"/>
<PreferenceCategory android:title="@string/preference_title_filter_subscription">
<Preference android:title="@string/preference_title_filter_manage_subscriptions"/>
<PreferenceCategory android:title="@string/preference_title_filter_subscriptions">
<Preference android:title="@string/preference_title_filter_manage_subscriptions">
<intent
android:data="twidere://filters/subscriptions"
android:targetClass="org.mariotaku.twidere.activity.LinkHandlerActivity"
android:targetPackage="org.mariotaku.twidere"/>
</Preference>
</PreferenceCategory>
</PreferenceScreen>