fixed save some images in fanfou
This commit is contained in:
parent
3a9c3cef8d
commit
f2993fc488
|
@ -0,0 +1,18 @@
|
||||||
|
package org.mariotaku.twidere.annotation;
|
||||||
|
|
||||||
|
import android.support.annotation.StringDef;
|
||||||
|
|
||||||
|
import java.lang.annotation.Retention;
|
||||||
|
import java.lang.annotation.RetentionPolicy;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Created by mariotaku on 2017/2/4.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@StringDef({CacheFileType.IMAGE, CacheFileType.VIDEO, CacheFileType.JSON})
|
||||||
|
@Retention(RetentionPolicy.SOURCE)
|
||||||
|
public @interface CacheFileType {
|
||||||
|
String IMAGE = "image";
|
||||||
|
String VIDEO = "video";
|
||||||
|
String JSON = "json";
|
||||||
|
}
|
|
@ -1,239 +0,0 @@
|
||||||
package org.mariotaku.twidere.provider;
|
|
||||||
|
|
||||||
import android.content.ContentProvider;
|
|
||||||
import android.content.ContentResolver;
|
|
||||||
import android.content.ContentValues;
|
|
||||||
import android.content.Context;
|
|
||||||
import android.database.Cursor;
|
|
||||||
import android.net.Uri;
|
|
||||||
import android.os.ParcelFileDescriptor;
|
|
||||||
import android.support.annotation.NonNull;
|
|
||||||
import android.support.annotation.Nullable;
|
|
||||||
import android.support.annotation.StringDef;
|
|
||||||
import android.webkit.MimeTypeMap;
|
|
||||||
|
|
||||||
import com.bluelinelabs.logansquare.JsonMapper;
|
|
||||||
import com.nostra13.universalimageloader.cache.disc.DiskCache;
|
|
||||||
|
|
||||||
import org.mariotaku.commons.logansquare.LoganSquareMapperFinder;
|
|
||||||
import org.mariotaku.restfu.RestFuUtils;
|
|
||||||
import org.mariotaku.twidere.TwidereConstants;
|
|
||||||
import org.mariotaku.twidere.model.CacheMetadata;
|
|
||||||
import org.mariotaku.twidere.task.SaveFileTask;
|
|
||||||
import org.mariotaku.twidere.util.BitmapUtils;
|
|
||||||
import org.mariotaku.twidere.util.dagger.GeneralComponentHelper;
|
|
||||||
|
|
||||||
import java.io.File;
|
|
||||||
import java.io.FileInputStream;
|
|
||||||
import java.io.FileNotFoundException;
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.util.Locale;
|
|
||||||
|
|
||||||
import javax.inject.Inject;
|
|
||||||
|
|
||||||
import okio.ByteString;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Created by mariotaku on 16/1/1.
|
|
||||||
*/
|
|
||||||
public class CacheProvider extends ContentProvider implements TwidereConstants {
|
|
||||||
@Inject
|
|
||||||
DiskCache mSimpleDiskCache;
|
|
||||||
|
|
||||||
public static Uri getCacheUri(String key, @Type String type) {
|
|
||||||
final Uri.Builder builder = new Uri.Builder();
|
|
||||||
builder.scheme(ContentResolver.SCHEME_CONTENT);
|
|
||||||
builder.authority(TwidereConstants.AUTHORITY_TWIDERE_CACHE);
|
|
||||||
builder.appendPath(ByteString.encodeUtf8(key).base64Url());
|
|
||||||
if (type != null) {
|
|
||||||
builder.appendQueryParameter(QUERY_PARAM_TYPE, type);
|
|
||||||
}
|
|
||||||
return builder.build();
|
|
||||||
}
|
|
||||||
|
|
||||||
@NonNull
|
|
||||||
public static String getCacheKey(Uri uri) {
|
|
||||||
if (!ContentResolver.SCHEME_CONTENT.equals(uri.getScheme()))
|
|
||||||
throw new IllegalArgumentException(uri.toString());
|
|
||||||
if (!TwidereConstants.AUTHORITY_TWIDERE_CACHE.equals(uri.getAuthority()))
|
|
||||||
throw new IllegalArgumentException(uri.toString());
|
|
||||||
return ByteString.decodeBase64(uri.getLastPathSegment()).utf8();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public static String getMetadataKey(Uri uri) {
|
|
||||||
if (!ContentResolver.SCHEME_CONTENT.equals(uri.getScheme()))
|
|
||||||
throw new IllegalArgumentException(uri.toString());
|
|
||||||
if (!TwidereConstants.AUTHORITY_TWIDERE_CACHE.equals(uri.getAuthority()))
|
|
||||||
throw new IllegalArgumentException(uri.toString());
|
|
||||||
return getExtraKey(ByteString.decodeBase64(uri.getLastPathSegment()).utf8());
|
|
||||||
}
|
|
||||||
|
|
||||||
public static String getExtraKey(String key) {
|
|
||||||
return key + ".extra";
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean onCreate() {
|
|
||||||
final Context context = getContext();
|
|
||||||
assert context != null;
|
|
||||||
GeneralComponentHelper.build(context).inject(this);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Nullable
|
|
||||||
@Override
|
|
||||||
public Cursor query(@NonNull Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Nullable
|
|
||||||
@Override
|
|
||||||
public String getType(@NonNull Uri uri) {
|
|
||||||
final CacheMetadata metadata = getMetadata(uri);
|
|
||||||
if (metadata != null) {
|
|
||||||
return metadata.getContentType();
|
|
||||||
}
|
|
||||||
final String type = uri.getQueryParameter(QUERY_PARAM_TYPE);
|
|
||||||
if (type != null) {
|
|
||||||
switch (type) {
|
|
||||||
case Type.IMAGE: {
|
|
||||||
final File file = mSimpleDiskCache.get(getCacheKey(uri));
|
|
||||||
if (file == null) return null;
|
|
||||||
return BitmapUtils.getImageMimeType(file);
|
|
||||||
}
|
|
||||||
case Type.VIDEO: {
|
|
||||||
return "video/mp4";
|
|
||||||
}
|
|
||||||
case Type.JSON: {
|
|
||||||
return "application/json";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public CacheMetadata getMetadata(@NonNull Uri uri) {
|
|
||||||
final File file = mSimpleDiskCache.get(getMetadataKey(uri));
|
|
||||||
if (file == null) return null;
|
|
||||||
FileInputStream is = null;
|
|
||||||
try {
|
|
||||||
final JsonMapper<CacheMetadata> mapper = LoganSquareMapperFinder.mapperFor(CacheMetadata.class);
|
|
||||||
is = new FileInputStream(file);
|
|
||||||
return mapper.parse(is);
|
|
||||||
} catch (IOException e) {
|
|
||||||
return null;
|
|
||||||
} finally {
|
|
||||||
RestFuUtils.closeSilently(is);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Nullable
|
|
||||||
@Override
|
|
||||||
public Uri insert(@NonNull Uri uri, ContentValues values) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public int delete(@NonNull Uri uri, String selection, String[] selectionArgs) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public int update(@NonNull Uri uri, ContentValues values, String selection, String[] selectionArgs) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Nullable
|
|
||||||
@Override
|
|
||||||
public ParcelFileDescriptor openFile(@NonNull Uri uri, @NonNull String mode) throws FileNotFoundException {
|
|
||||||
try {
|
|
||||||
final File file = mSimpleDiskCache.get(getCacheKey(uri));
|
|
||||||
if (file == null) throw new FileNotFoundException();
|
|
||||||
final int modeBits = modeToMode(mode);
|
|
||||||
if (modeBits != ParcelFileDescriptor.MODE_READ_ONLY)
|
|
||||||
throw new IllegalArgumentException("Cache can't be opened for write");
|
|
||||||
return ParcelFileDescriptor.open(file, modeBits);
|
|
||||||
} catch (IOException e) {
|
|
||||||
throw new FileNotFoundException();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Copied from ContentResolver.java
|
|
||||||
*/
|
|
||||||
private static int modeToMode(String mode) {
|
|
||||||
int modeBits;
|
|
||||||
if ("r".equals(mode)) {
|
|
||||||
modeBits = ParcelFileDescriptor.MODE_READ_ONLY;
|
|
||||||
} else if ("w".equals(mode) || "wt".equals(mode)) {
|
|
||||||
modeBits = ParcelFileDescriptor.MODE_WRITE_ONLY
|
|
||||||
| ParcelFileDescriptor.MODE_CREATE
|
|
||||||
| ParcelFileDescriptor.MODE_TRUNCATE;
|
|
||||||
} else if ("wa".equals(mode)) {
|
|
||||||
modeBits = ParcelFileDescriptor.MODE_WRITE_ONLY
|
|
||||||
| ParcelFileDescriptor.MODE_CREATE
|
|
||||||
| ParcelFileDescriptor.MODE_APPEND;
|
|
||||||
} else if ("rw".equals(mode)) {
|
|
||||||
modeBits = ParcelFileDescriptor.MODE_READ_WRITE
|
|
||||||
| ParcelFileDescriptor.MODE_CREATE;
|
|
||||||
} else if ("rwt".equals(mode)) {
|
|
||||||
modeBits = ParcelFileDescriptor.MODE_READ_WRITE
|
|
||||||
| ParcelFileDescriptor.MODE_CREATE
|
|
||||||
| ParcelFileDescriptor.MODE_TRUNCATE;
|
|
||||||
} else {
|
|
||||||
throw new IllegalArgumentException("Invalid mode: " + mode);
|
|
||||||
}
|
|
||||||
return modeBits;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static final class CacheFileTypeCallback implements SaveFileTask.FileInfoCallback {
|
|
||||||
private final Context context;
|
|
||||||
private final String type;
|
|
||||||
|
|
||||||
public CacheFileTypeCallback(Context context, @Type String type) {
|
|
||||||
this.context = context;
|
|
||||||
this.type = type;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
@NonNull
|
|
||||||
public String getFilename(@NonNull Uri source) {
|
|
||||||
String cacheKey = getCacheKey(source);
|
|
||||||
final int indexOfSsp = cacheKey.indexOf("://");
|
|
||||||
if (indexOfSsp != -1) {
|
|
||||||
cacheKey = cacheKey.substring(indexOfSsp + 3);
|
|
||||||
}
|
|
||||||
return cacheKey.replaceAll("[^\\w\\d_]", String.valueOf(getSpecialCharacter()));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
@Nullable
|
|
||||||
public String getMimeType(@NonNull Uri source) {
|
|
||||||
if (type == null || source.getQueryParameter(QUERY_PARAM_TYPE) != null) {
|
|
||||||
return context.getContentResolver().getType(source);
|
|
||||||
}
|
|
||||||
final Uri.Builder builder = source.buildUpon();
|
|
||||||
builder.appendQueryParameter(QUERY_PARAM_TYPE, type);
|
|
||||||
return context.getContentResolver().getType(builder.build());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public String getExtension(@Nullable String mimeType) {
|
|
||||||
if (mimeType == null) return null;
|
|
||||||
return MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType.toLowerCase(Locale.US));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public char getSpecialCharacter() {
|
|
||||||
return '_';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@StringDef({Type.IMAGE, Type.VIDEO, Type.JSON})
|
|
||||||
public @interface Type {
|
|
||||||
String IMAGE = "image";
|
|
||||||
String VIDEO = "video";
|
|
||||||
String JSON = "json";
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -293,6 +293,15 @@ public final class Utils implements Constants {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static String sanitizeMimeType(@Nullable final String contentType) {
|
||||||
|
if (contentType == null) return null;
|
||||||
|
switch (contentType) {
|
||||||
|
case "image/jpg":
|
||||||
|
return "image/jpeg";
|
||||||
|
}
|
||||||
|
return contentType;
|
||||||
|
}
|
||||||
|
|
||||||
public static class NoAccountException extends Exception {
|
public static class NoAccountException extends Exception {
|
||||||
String accountHost;
|
String accountHost;
|
||||||
|
|
||||||
|
|
|
@ -36,6 +36,7 @@ import org.mariotaku.twidere.util.JsonSerializer;
|
||||||
import org.mariotaku.twidere.util.MicroBlogAPIFactory;
|
import org.mariotaku.twidere.util.MicroBlogAPIFactory;
|
||||||
import org.mariotaku.twidere.util.SharedPreferencesWrapper;
|
import org.mariotaku.twidere.util.SharedPreferencesWrapper;
|
||||||
import org.mariotaku.twidere.util.UserAgentUtils;
|
import org.mariotaku.twidere.util.UserAgentUtils;
|
||||||
|
import org.mariotaku.twidere.util.Utils;
|
||||||
import org.mariotaku.twidere.util.media.preview.PreviewMediaExtractor;
|
import org.mariotaku.twidere.util.media.preview.PreviewMediaExtractor;
|
||||||
import org.mariotaku.twidere.util.net.NoIntercept;
|
import org.mariotaku.twidere.util.net.NoIntercept;
|
||||||
|
|
||||||
|
@ -176,7 +177,7 @@ public class TwidereMediaDownloader implements MediaDownloader, Constants {
|
||||||
}
|
}
|
||||||
final Body body = resp.getBody();
|
final Body body = resp.getBody();
|
||||||
final CacheMetadata metadata = new CacheMetadata();
|
final CacheMetadata metadata = new CacheMetadata();
|
||||||
metadata.setContentType(body.contentType().getContentType());
|
metadata.setContentType(Utils.sanitizeMimeType(body.contentType().getContentType()));
|
||||||
return new TwidereDownloadResult(body, metadata);
|
return new TwidereDownloadResult(body, metadata);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -45,19 +45,19 @@ public class UILFileCache implements FileCache {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (extra != null) {
|
if (extra != null) {
|
||||||
cache.save(CacheProvider.getExtraKey(key), new ByteArrayInputStream(extra), null);
|
cache.save(CacheProvider.Companion.getExtraKey(key), new ByteArrayInputStream(extra), null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@NonNull
|
@NonNull
|
||||||
@Override
|
@Override
|
||||||
public Uri toUri(@NonNull final String key) {
|
public Uri toUri(@NonNull final String key) {
|
||||||
return CacheProvider.getCacheUri(key, null);
|
return CacheProvider.Companion.getCacheUri(key, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@NonNull
|
@NonNull
|
||||||
@Override
|
@Override
|
||||||
public String fromUri(@NonNull final Uri uri) {
|
public String fromUri(@NonNull final Uri uri) {
|
||||||
return CacheProvider.getCacheKey(uri);
|
return CacheProvider.Companion.getCacheKey(uri);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -48,6 +48,7 @@ import org.mariotaku.twidere.R
|
||||||
import org.mariotaku.twidere.TwidereConstants.*
|
import org.mariotaku.twidere.TwidereConstants.*
|
||||||
import org.mariotaku.twidere.activity.iface.IControlBarActivity.ControlBarShowHideHelper
|
import org.mariotaku.twidere.activity.iface.IControlBarActivity.ControlBarShowHideHelper
|
||||||
import org.mariotaku.twidere.activity.iface.IExtendedActivity
|
import org.mariotaku.twidere.activity.iface.IExtendedActivity
|
||||||
|
import org.mariotaku.twidere.annotation.CacheFileType
|
||||||
import org.mariotaku.twidere.fragment.*
|
import org.mariotaku.twidere.fragment.*
|
||||||
import org.mariotaku.twidere.fragment.iface.IBaseFragment
|
import org.mariotaku.twidere.fragment.iface.IBaseFragment
|
||||||
import org.mariotaku.twidere.model.ParcelableMedia
|
import org.mariotaku.twidere.model.ParcelableMedia
|
||||||
|
@ -428,9 +429,9 @@ class MediaViewerActivity : BaseActivity(), IMediaViewerActivity, MediaSwipeClos
|
||||||
val destination = ShareProvider.getFilesDir(this) ?: return
|
val destination = ShareProvider.getFilesDir(this) ?: return
|
||||||
val type: String
|
val type: String
|
||||||
when (f) {
|
when (f) {
|
||||||
is VideoPageFragment -> type = CacheProvider.Type.VIDEO
|
is VideoPageFragment -> type = CacheFileType.VIDEO
|
||||||
is ImagePageFragment -> type = CacheProvider.Type.IMAGE
|
is ImagePageFragment -> type = CacheFileType.IMAGE
|
||||||
is GifPageFragment -> type = CacheProvider.Type.IMAGE
|
is GifPageFragment -> type = CacheFileType.IMAGE
|
||||||
else -> throw UnsupportedOperationException("Unsupported fragment $f")
|
else -> throw UnsupportedOperationException("Unsupported fragment $f")
|
||||||
}
|
}
|
||||||
val task = object : SaveFileTask(this@MediaViewerActivity, cacheUri, destination,
|
val task = object : SaveFileTask(this@MediaViewerActivity, cacheUri, destination,
|
||||||
|
@ -488,9 +489,9 @@ class MediaViewerActivity : BaseActivity(), IMediaViewerActivity, MediaSwipeClos
|
||||||
val f = adapter.instantiateItem(viewPager, saveToStoragePosition) as? CacheDownloadMediaViewerFragment ?: return
|
val f = adapter.instantiateItem(viewPager, saveToStoragePosition) as? CacheDownloadMediaViewerFragment ?: return
|
||||||
val cacheUri = f.downloadResult?.cacheUri ?: return
|
val cacheUri = f.downloadResult?.cacheUri ?: return
|
||||||
val task: SaveFileTask = when (f) {
|
val task: SaveFileTask = when (f) {
|
||||||
is ImagePageFragment -> SaveMediaToGalleryTask.create(this, cacheUri, CacheProvider.Type.IMAGE)
|
is ImagePageFragment -> SaveMediaToGalleryTask.create(this, cacheUri, CacheFileType.IMAGE)
|
||||||
is VideoPageFragment -> SaveMediaToGalleryTask.create(this, cacheUri, CacheProvider.Type.VIDEO)
|
is VideoPageFragment -> SaveMediaToGalleryTask.create(this, cacheUri, CacheFileType.VIDEO)
|
||||||
is GifPageFragment -> SaveMediaToGalleryTask.create(this, cacheUri, CacheProvider.Type.IMAGE)
|
is GifPageFragment -> SaveMediaToGalleryTask.create(this, cacheUri, CacheFileType.IMAGE)
|
||||||
else -> throw UnsupportedOperationException()
|
else -> throw UnsupportedOperationException()
|
||||||
}
|
}
|
||||||
AsyncTaskUtils.executeTask(task)
|
AsyncTaskUtils.executeTask(task)
|
||||||
|
|
|
@ -93,16 +93,15 @@ class WebLinkHandlerActivity : Activity() {
|
||||||
return Pair(Intent(Intent.ACTION_VIEW, builder.build()), true)
|
return Pair(Intent(Intent.ACTION_VIEW, builder.build()), true)
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
if (!ArrayUtils.contains(FANFOU_RESERVED_PATHS, pathSegments[0])) {
|
if (FANFOU_RESERVED_PATHS.contains(pathSegments[0])) return Pair(null, false)
|
||||||
if (pathSegments.size == 1) {
|
if (pathSegments.size == 1) {
|
||||||
val builder = Uri.Builder()
|
val builder = Uri.Builder()
|
||||||
builder.scheme(SCHEME_TWIDERE)
|
builder.scheme(SCHEME_TWIDERE)
|
||||||
builder.authority(AUTHORITY_USER)
|
builder.authority(AUTHORITY_USER)
|
||||||
builder.appendQueryParameter(QUERY_PARAM_ACCOUNT_HOST, USER_TYPE_FANFOU_COM)
|
builder.appendQueryParameter(QUERY_PARAM_ACCOUNT_HOST, USER_TYPE_FANFOU_COM)
|
||||||
val userKey = UserKey(pathSegments[0], USER_TYPE_FANFOU_COM)
|
val userKey = UserKey(pathSegments[0], USER_TYPE_FANFOU_COM)
|
||||||
builder.appendQueryParameter(QUERY_PARAM_USER_KEY, userKey.toString())
|
builder.appendQueryParameter(QUERY_PARAM_USER_KEY, userKey.toString())
|
||||||
return Pair(Intent(Intent.ACTION_VIEW, builder.build()), true)
|
return Pair(Intent(Intent.ACTION_VIEW, builder.build()), true)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return Pair(null, false)
|
return Pair(null, false)
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,6 +26,7 @@ import android.support.v4.content.AsyncTaskLoader
|
||||||
import org.mariotaku.ktextension.set
|
import org.mariotaku.ktextension.set
|
||||||
import org.mariotaku.microblog.library.MicroBlogException
|
import org.mariotaku.microblog.library.MicroBlogException
|
||||||
import org.mariotaku.microblog.library.twitter.model.ErrorInfo
|
import org.mariotaku.microblog.library.twitter.model.ErrorInfo
|
||||||
|
import org.mariotaku.restfu.http.RestHttpClient
|
||||||
import org.mariotaku.twidere.constant.IntentConstants
|
import org.mariotaku.twidere.constant.IntentConstants
|
||||||
import org.mariotaku.twidere.constant.IntentConstants.EXTRA_ACCOUNT
|
import org.mariotaku.twidere.constant.IntentConstants.EXTRA_ACCOUNT
|
||||||
import org.mariotaku.twidere.model.ParcelableStatus
|
import org.mariotaku.twidere.model.ParcelableStatus
|
||||||
|
@ -51,7 +52,9 @@ class ParcelableStatusLoader(
|
||||||
) : AsyncTaskLoader<SingleResponse<ParcelableStatus>>(context) {
|
) : AsyncTaskLoader<SingleResponse<ParcelableStatus>>(context) {
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var userColorNameManager: UserColorNameManager
|
internal lateinit var userColorNameManager: UserColorNameManager
|
||||||
|
@Inject
|
||||||
|
internal lateinit var restHttpClient: RestHttpClient
|
||||||
|
|
||||||
init {
|
init {
|
||||||
GeneralComponentHelper.build(context).inject(this)
|
GeneralComponentHelper.build(context).inject(this)
|
||||||
|
@ -81,8 +84,7 @@ class ParcelableStatusLoader(
|
||||||
if (e.errorCode == ErrorInfo.STATUS_NOT_FOUND) {
|
if (e.errorCode == ErrorInfo.STATUS_NOT_FOUND) {
|
||||||
// Delete all deleted status
|
// Delete all deleted status
|
||||||
val cr = context.contentResolver
|
val cr = context.contentResolver
|
||||||
DataStoreUtils.deleteStatus(cr, accountKey,
|
DataStoreUtils.deleteStatus(cr, accountKey, statusId, null)
|
||||||
statusId, null)
|
|
||||||
DataStoreUtils.deleteActivityStatus(cr, accountKey, statusId, null)
|
DataStoreUtils.deleteActivityStatus(cr, accountKey, statusId, null)
|
||||||
}
|
}
|
||||||
return SingleResponse(e)
|
return SingleResponse(e)
|
||||||
|
@ -94,4 +96,5 @@ class ParcelableStatusLoader(
|
||||||
forceLoad()
|
forceLoad()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,194 @@
|
||||||
|
package org.mariotaku.twidere.provider
|
||||||
|
|
||||||
|
import android.content.ContentProvider
|
||||||
|
import android.content.ContentResolver
|
||||||
|
import android.content.ContentValues
|
||||||
|
import android.content.Context
|
||||||
|
import android.database.Cursor
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.ParcelFileDescriptor
|
||||||
|
import android.webkit.MimeTypeMap
|
||||||
|
import com.nostra13.universalimageloader.cache.disc.DiskCache
|
||||||
|
import okio.ByteString
|
||||||
|
import org.mariotaku.commons.logansquare.LoganSquareMapperFinder
|
||||||
|
import org.mariotaku.restfu.RestFuUtils
|
||||||
|
import org.mariotaku.twidere.TwidereConstants
|
||||||
|
import org.mariotaku.twidere.annotation.CacheFileType
|
||||||
|
import org.mariotaku.twidere.model.CacheMetadata
|
||||||
|
import org.mariotaku.twidere.task.SaveFileTask
|
||||||
|
import org.mariotaku.twidere.util.BitmapUtils
|
||||||
|
import org.mariotaku.twidere.util.dagger.GeneralComponentHelper
|
||||||
|
import java.io.FileInputStream
|
||||||
|
import java.io.FileNotFoundException
|
||||||
|
import java.io.IOException
|
||||||
|
import java.util.*
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Created by mariotaku on 16/1/1.
|
||||||
|
*/
|
||||||
|
class CacheProvider : ContentProvider() {
|
||||||
|
@Inject
|
||||||
|
internal lateinit var simpleDiskCache: DiskCache
|
||||||
|
|
||||||
|
override fun onCreate(): Boolean {
|
||||||
|
GeneralComponentHelper.build(context).inject(this)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun query(uri: Uri, projection: Array<String>?, selection: String?,
|
||||||
|
selectionArgs: Array<String>?, sortOrder: String?): Cursor? {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getType(uri: Uri): String? {
|
||||||
|
val metadata = getMetadata(uri)
|
||||||
|
if (metadata != null) {
|
||||||
|
return metadata.contentType
|
||||||
|
}
|
||||||
|
val type = uri.getQueryParameter(TwidereConstants.QUERY_PARAM_TYPE)
|
||||||
|
when (type) {
|
||||||
|
CacheFileType.IMAGE -> {
|
||||||
|
val file = simpleDiskCache.get(getCacheKey(uri)) ?: return null
|
||||||
|
return BitmapUtils.getImageMimeType(file)
|
||||||
|
}
|
||||||
|
CacheFileType.VIDEO -> {
|
||||||
|
return "video/mp4"
|
||||||
|
}
|
||||||
|
CacheFileType.JSON -> {
|
||||||
|
return "application/json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getMetadata(uri: Uri): CacheMetadata? {
|
||||||
|
val file = simpleDiskCache.get(getMetadataKey(uri)) ?: return null
|
||||||
|
var `is`: FileInputStream? = null
|
||||||
|
try {
|
||||||
|
val mapper = LoganSquareMapperFinder.mapperFor(CacheMetadata::class.java)
|
||||||
|
`is` = FileInputStream(file)
|
||||||
|
return mapper.parse(`is`)
|
||||||
|
} catch (e: IOException) {
|
||||||
|
return null
|
||||||
|
} finally {
|
||||||
|
RestFuUtils.closeSilently(`is`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun insert(uri: Uri, values: ContentValues?): Uri? {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<String>?): Int {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<String>?): Int {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(FileNotFoundException::class)
|
||||||
|
override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor? {
|
||||||
|
try {
|
||||||
|
val file = simpleDiskCache.get(getCacheKey(uri)) ?: throw FileNotFoundException()
|
||||||
|
val modeBits = modeToMode(mode)
|
||||||
|
if (modeBits != ParcelFileDescriptor.MODE_READ_ONLY)
|
||||||
|
throw IllegalArgumentException("Cache can't be opened for write")
|
||||||
|
return ParcelFileDescriptor.open(file, modeBits)
|
||||||
|
} catch (e: IOException) {
|
||||||
|
throw FileNotFoundException()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
class CacheFileTypeCallback(private val context: Context, @CacheFileType private val type: String?) : SaveFileTask.FileInfoCallback {
|
||||||
|
|
||||||
|
override fun getFilename(source: Uri): String {
|
||||||
|
var cacheKey = getCacheKey(source)
|
||||||
|
val indexOfSsp = cacheKey.indexOf("://")
|
||||||
|
if (indexOfSsp != -1) {
|
||||||
|
cacheKey = cacheKey.substring(indexOfSsp + 3)
|
||||||
|
}
|
||||||
|
return cacheKey.replace("[^\\w\\d_]".toRegex(), specialCharacter.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getMimeType(source: Uri): String? {
|
||||||
|
if (type == null || source.getQueryParameter(TwidereConstants.QUERY_PARAM_TYPE) != null) {
|
||||||
|
return context.contentResolver.getType(source)
|
||||||
|
}
|
||||||
|
val builder = source.buildUpon()
|
||||||
|
builder.appendQueryParameter(TwidereConstants.QUERY_PARAM_TYPE, type)
|
||||||
|
return context.contentResolver.getType(builder.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getExtension(mimeType: String?): String? {
|
||||||
|
val typeLowered = mimeType?.toLowerCase(Locale.US) ?: return null
|
||||||
|
return when (typeLowered) {
|
||||||
|
// Hack for fanfou image type
|
||||||
|
"image/jpg" -> "jpg"
|
||||||
|
else -> MimeTypeMap.getSingleton().getExtensionFromMimeType(typeLowered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override val specialCharacter: Char
|
||||||
|
get() = '_'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
fun getCacheUri(key: String, @CacheFileType type: String?): Uri {
|
||||||
|
val builder = Uri.Builder()
|
||||||
|
builder.scheme(ContentResolver.SCHEME_CONTENT)
|
||||||
|
builder.authority(TwidereConstants.AUTHORITY_TWIDERE_CACHE)
|
||||||
|
builder.appendPath(ByteString.encodeUtf8(key).base64Url())
|
||||||
|
if (type != null) {
|
||||||
|
builder.appendQueryParameter(TwidereConstants.QUERY_PARAM_TYPE, type)
|
||||||
|
}
|
||||||
|
return builder.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCacheKey(uri: Uri): String {
|
||||||
|
if (ContentResolver.SCHEME_CONTENT != uri.scheme)
|
||||||
|
throw IllegalArgumentException(uri.toString())
|
||||||
|
if (TwidereConstants.AUTHORITY_TWIDERE_CACHE != uri.authority)
|
||||||
|
throw IllegalArgumentException(uri.toString())
|
||||||
|
return ByteString.decodeBase64(uri.lastPathSegment).utf8()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fun getMetadataKey(uri: Uri): String {
|
||||||
|
if (ContentResolver.SCHEME_CONTENT != uri.scheme)
|
||||||
|
throw IllegalArgumentException(uri.toString())
|
||||||
|
if (TwidereConstants.AUTHORITY_TWIDERE_CACHE != uri.authority)
|
||||||
|
throw IllegalArgumentException(uri.toString())
|
||||||
|
return getExtraKey(ByteString.decodeBase64(uri.lastPathSegment).utf8())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getExtraKey(key: String): String {
|
||||||
|
return key + ".extra"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copied from ContentResolver.java
|
||||||
|
*/
|
||||||
|
private fun modeToMode(mode: String): Int {
|
||||||
|
val modeBits: Int
|
||||||
|
if ("r" == mode) {
|
||||||
|
modeBits = ParcelFileDescriptor.MODE_READ_ONLY
|
||||||
|
} else if ("w" == mode || "wt" == mode) {
|
||||||
|
modeBits = ParcelFileDescriptor.MODE_WRITE_ONLY or ParcelFileDescriptor.MODE_CREATE or ParcelFileDescriptor.MODE_TRUNCATE
|
||||||
|
} else if ("wa" == mode) {
|
||||||
|
modeBits = ParcelFileDescriptor.MODE_WRITE_ONLY or ParcelFileDescriptor.MODE_CREATE or ParcelFileDescriptor.MODE_APPEND
|
||||||
|
} else if ("rw" == mode) {
|
||||||
|
modeBits = ParcelFileDescriptor.MODE_READ_WRITE or ParcelFileDescriptor.MODE_CREATE
|
||||||
|
} else if ("rwt" == mode) {
|
||||||
|
modeBits = ParcelFileDescriptor.MODE_READ_WRITE or ParcelFileDescriptor.MODE_CREATE or ParcelFileDescriptor.MODE_TRUNCATE
|
||||||
|
} else {
|
||||||
|
throw IllegalArgumentException("Invalid mode: " + mode)
|
||||||
|
}
|
||||||
|
return modeBits
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -33,8 +33,12 @@ import java.io.File
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.lang.ref.WeakReference
|
import java.lang.ref.WeakReference
|
||||||
|
|
||||||
abstract class SaveFileTask(context: Context, private val source: Uri,
|
abstract class SaveFileTask(
|
||||||
private val destination: File, private val getMimeType: SaveFileTask.FileInfoCallback) : AsyncTask<Any, Any, SaveFileTask.SaveFileResult>() {
|
context: Context,
|
||||||
|
private val source: Uri,
|
||||||
|
private val destination: File,
|
||||||
|
private val getMimeType: SaveFileTask.FileInfoCallback
|
||||||
|
) : AsyncTask<Any, Any, SaveFileTask.SaveFileResult>() {
|
||||||
|
|
||||||
private val contextRef: WeakReference<Context>
|
private val contextRef: WeakReference<Context>
|
||||||
|
|
||||||
|
@ -44,7 +48,7 @@ abstract class SaveFileTask(context: Context, private val source: Uri,
|
||||||
|
|
||||||
override fun doInBackground(vararg args: Any): SaveFileResult? {
|
override fun doInBackground(vararg args: Any): SaveFileResult? {
|
||||||
val context = contextRef.get() ?: return null
|
val context = contextRef.get() ?: return null
|
||||||
return saveFile(context, source, getMimeType, destination)
|
return saveFile(context, source, getMimeType, destination, requiresValidExtension)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCancelled() {
|
override fun onCancelled() {
|
||||||
|
@ -72,6 +76,7 @@ abstract class SaveFileTask(context: Context, private val source: Uri,
|
||||||
|
|
||||||
protected abstract fun dismissProgress()
|
protected abstract fun dismissProgress()
|
||||||
|
|
||||||
|
open val requiresValidExtension: Boolean = false
|
||||||
|
|
||||||
protected val context: Context?
|
protected val context: Context?
|
||||||
get() = contextRef.get()
|
get() = contextRef.get()
|
||||||
|
@ -102,7 +107,7 @@ abstract class SaveFileTask(context: Context, private val source: Uri,
|
||||||
|
|
||||||
fun saveFile(context: Context, source: Uri,
|
fun saveFile(context: Context, source: Uri,
|
||||||
fileInfoCallback: FileInfoCallback,
|
fileInfoCallback: FileInfoCallback,
|
||||||
destinationDir: File): SaveFileResult? {
|
destinationDir: File, requiresValidExtension: Boolean): SaveFileResult? {
|
||||||
val cr = context.contentResolver
|
val cr = context.contentResolver
|
||||||
var ioSrc: Source? = null
|
var ioSrc: Source? = null
|
||||||
var sink: BufferedSink? = null
|
var sink: BufferedSink? = null
|
||||||
|
@ -114,6 +119,9 @@ abstract class SaveFileTask(context: Context, private val source: Uri,
|
||||||
}
|
}
|
||||||
val mimeType = fileInfoCallback.getMimeType(source) ?: return null
|
val mimeType = fileInfoCallback.getMimeType(source) ?: return null
|
||||||
val extension = fileInfoCallback.getExtension(mimeType)
|
val extension = fileInfoCallback.getExtension(mimeType)
|
||||||
|
if (requiresValidExtension && extension == null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
if (!destinationDir.isDirectory && !destinationDir.mkdirs()) return null
|
if (!destinationDir.isDirectory && !destinationDir.mkdirs()) return null
|
||||||
var nameToSave = getFileNameWithExtension(name, extension,
|
var nameToSave = getFileNameWithExtension(name, extension,
|
||||||
fileInfoCallback.specialCharacter, null)
|
fileInfoCallback.specialCharacter, null)
|
||||||
|
|
|
@ -25,6 +25,7 @@ import android.net.Uri
|
||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import org.mariotaku.twidere.R
|
import org.mariotaku.twidere.R
|
||||||
|
import org.mariotaku.twidere.annotation.CacheFileType
|
||||||
import org.mariotaku.twidere.provider.CacheProvider
|
import org.mariotaku.twidere.provider.CacheProvider
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
|
@ -52,14 +53,13 @@ class SaveMediaToGalleryTask(
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
fun create(activity: Activity, source: Uri,
|
fun create(activity: Activity, source: Uri, @CacheFileType type: String): SaveFileTask {
|
||||||
@CacheProvider.Type type: String): SaveFileTask {
|
|
||||||
val pubDir: File
|
val pubDir: File
|
||||||
when (type) {
|
when (type) {
|
||||||
CacheProvider.Type.VIDEO -> {
|
CacheFileType.VIDEO -> {
|
||||||
pubDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES)
|
pubDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES)
|
||||||
}
|
}
|
||||||
CacheProvider.Type.IMAGE -> {
|
CacheFileType.IMAGE -> {
|
||||||
pubDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES)
|
pubDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES)
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
|
|
|
@ -42,6 +42,7 @@ import org.mariotaku.twidere.constant.newDocumentApiKey
|
||||||
import org.mariotaku.twidere.model.UserKey
|
import org.mariotaku.twidere.model.UserKey
|
||||||
import org.mariotaku.twidere.model.util.ParcelableMediaUtils
|
import org.mariotaku.twidere.model.util.ParcelableMediaUtils
|
||||||
import org.mariotaku.twidere.util.TwidereLinkify.OnLinkClickListener
|
import org.mariotaku.twidere.util.TwidereLinkify.OnLinkClickListener
|
||||||
|
import org.mariotaku.twidere.util.TwidereLinkify.USER_TYPE_FANFOU_COM
|
||||||
import org.mariotaku.twidere.util.media.preview.PreviewMediaExtractor
|
import org.mariotaku.twidere.util.media.preview.PreviewMediaExtractor
|
||||||
|
|
||||||
open class OnLinkClickHandler(
|
open class OnLinkClickHandler(
|
||||||
|
@ -72,16 +73,16 @@ open class OnLinkClickHandler(
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
TwidereLinkify.LINK_TYPE_LINK_IN_TEXT -> {
|
TwidereLinkify.LINK_TYPE_LINK_IN_TEXT -> {
|
||||||
if (isMedia(link, extraId)) {
|
if (accountKey != null && isMedia(link, extraId)) {
|
||||||
openMedia(accountKey!!, extraId, sensitive, link, start, end)
|
openMedia(accountKey, extraId, sensitive, link, start, end)
|
||||||
} else {
|
} else {
|
||||||
openLink(link)
|
openLink(link)
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
TwidereLinkify.LINK_TYPE_ENTITY_URL -> {
|
TwidereLinkify.LINK_TYPE_ENTITY_URL -> {
|
||||||
if (isMedia(link, extraId)) {
|
if (accountKey != null && isMedia(link, extraId)) {
|
||||||
openMedia(accountKey!!, extraId, sensitive, link, start, end)
|
openMedia(accountKey, extraId, sensitive, link, start, end)
|
||||||
} else {
|
} else {
|
||||||
val authority = UriUtils.getAuthority(link)
|
val authority = UriUtils.getAuthority(link)
|
||||||
if (authority == null) {
|
if (authority == null) {
|
||||||
|
@ -89,36 +90,12 @@ open class OnLinkClickHandler(
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
when (authority) {
|
when (authority) {
|
||||||
"fanfou.com" -> {
|
"fanfou.com" -> if (accountKey != null && handleFanfouLink(link, orig, accountKey)) {
|
||||||
if (orig != null) {
|
return true
|
||||||
// Process special case for fanfou
|
|
||||||
val ch = orig[0]
|
|
||||||
// Extend selection
|
|
||||||
val length = orig.length
|
|
||||||
if (TwidereLinkify.isAtSymbol(ch)) {
|
|
||||||
var id = UriUtils.getPath(link)
|
|
||||||
if (id != null) {
|
|
||||||
val idxOfSlash = id.indexOf('/')
|
|
||||||
if (idxOfSlash == 0) {
|
|
||||||
id = id.substring(1)
|
|
||||||
}
|
|
||||||
val screenName = orig.substring(1, length)
|
|
||||||
IntentUtils.openUserProfile(context, accountKey, UserKey.valueOf(id),
|
|
||||||
screenName, preferences[newDocumentApiKey], Referral.USER_MENTION,
|
|
||||||
null)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
} else if (TwidereLinkify.isHashSymbol(ch) && TwidereLinkify.isHashSymbol(orig[length - 1])) {
|
|
||||||
IntentUtils.openSearch(context, accountKey, orig.substring(1, length - 1))
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else -> {
|
else -> if (IntentUtils.isWebLinkHandled(context, Uri.parse(link))) {
|
||||||
if (IntentUtils.isWebLinkHandled(context, Uri.parse(link))) {
|
openTwitterLink(link, accountKey!!)
|
||||||
openTwitterLink(link, accountKey!!)
|
return true
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
openLink(link)
|
openLink(link)
|
||||||
|
@ -203,4 +180,36 @@ open class OnLinkClickHandler(
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun handleFanfouLink(link: String, orig: String?, accountKey: UserKey): Boolean {
|
||||||
|
if (orig == null) return false
|
||||||
|
// Process special case for fanfou
|
||||||
|
val ch = orig[0]
|
||||||
|
// Extend selection
|
||||||
|
val length = orig.length
|
||||||
|
if (TwidereLinkify.isAtSymbol(ch)) {
|
||||||
|
var id = UriUtils.getPath(link)
|
||||||
|
if (id != null) {
|
||||||
|
val idxOfSlash = id.indexOf('/')
|
||||||
|
if (idxOfSlash == 0) {
|
||||||
|
id = id.substring(1)
|
||||||
|
}
|
||||||
|
val screenName = orig.substring(1, length)
|
||||||
|
IntentUtils.openUserProfile(context, accountKey, UserKey.valueOf(id),
|
||||||
|
screenName, preferences[newDocumentApiKey], Referral.USER_MENTION,
|
||||||
|
null)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
} else if (TwidereLinkify.isHashSymbol(ch) && TwidereLinkify.isHashSymbol(orig[length - 1])) {
|
||||||
|
IntentUtils.openSearch(context, accountKey, orig.substring(1, length - 1))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link))
|
||||||
|
intent.setClass(context, WebLinkHandlerActivity::class.java)
|
||||||
|
if (accountKey.host == USER_TYPE_FANFOU_COM) {
|
||||||
|
intent.putExtra(EXTRA_ACCOUNT_KEY, accountKey)
|
||||||
|
}
|
||||||
|
context.startActivity(intent)
|
||||||
|
return true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -42,9 +42,6 @@ class NameView(context: Context, attrs: AttributeSet? = null) : AppCompatTextVie
|
||||||
|
|
||||||
var nameFirst: Boolean = false
|
var nameFirst: Boolean = false
|
||||||
var twoLine: Boolean = false
|
var twoLine: Boolean = false
|
||||||
get() {
|
|
||||||
return maxLines >= 2
|
|
||||||
}
|
|
||||||
set(value) {
|
set(value) {
|
||||||
field = value
|
field = value
|
||||||
if (value) {
|
if (value) {
|
||||||
|
|
|
@ -287,7 +287,7 @@
|
||||||
<string name="cards">Cards</string>
|
<string name="cards">Cards</string>
|
||||||
|
|
||||||
<string name="clear_cache">Clear cache</string>
|
<string name="clear_cache">Clear cache</string>
|
||||||
<string name="clear_cache_summary">Clear stored profile image cache.</string>
|
<string name="clear_cache_summary">Clear stored media cache.</string>
|
||||||
<string name="clear_databases">Clear databases</string>
|
<string name="clear_databases">Clear databases</string>
|
||||||
<string name="clear_databases_summary">Clear all tweets, profiles, messages. Your account credentials won\'t be lost.</string>
|
<string name="clear_databases_summary">Clear all tweets, profiles, messages. Your account credentials won\'t be lost.</string>
|
||||||
<string name="clear_search_history">Clear search history</string>
|
<string name="clear_search_history">Clear search history</string>
|
||||||
|
|
Loading…
Reference in New Issue