Save reply info in draft, refactor (#449)

* Save reply info in draft, refactor

* Handle replying to deleted status
main
Ivan Kupalov 7 years ago committed by Konrad Pozniak
parent a589e6c290
commit 3106250930
  1. 10
      app/build.gradle
  2. 389
      app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java
  3. 73
      app/src/main/java/com/keylesspalace/tusky/SavedTootActivity.java
  4. 1
      app/src/main/java/com/keylesspalace/tusky/TuskyApplication.java
  5. 143
      app/src/main/java/com/keylesspalace/tusky/adapter/MentionAutoCompleteAdapter.java
  6. 31
      app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java
  7. 38
      app/src/main/java/com/keylesspalace/tusky/db/TootDao.java
  8. 102
      app/src/main/java/com/keylesspalace/tusky/db/TootEntity.java
  9. 43
      app/src/main/java/com/keylesspalace/tusky/entity/Status.java
  10. 33
      app/src/main/java/com/keylesspalace/tusky/fragment/ComposeOptionsFragment.java
  11. 4
      app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java
  12. 4
      app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java
  13. 92
      app/src/main/java/com/keylesspalace/tusky/util/MediaUtils.java
  14. 1
      app/src/main/res/values-ru/strings.xml
  15. 1
      app/src/main/res/values/strings.xml

@ -1,4 +1,5 @@
apply plugin: 'com.android.application' apply plugin: 'com.android.application'
apply plugin: 'kotlin-kapt'
apply plugin: 'kotlin-android' apply plugin: 'kotlin-android'
android { android {
@ -65,13 +66,14 @@ dependencies {
compile 'com.evernote:android-job:1.2.0' compile 'com.evernote:android-job:1.2.0'
implementation 'com.android.support.constraint:constraint-layout:1.0.2' implementation 'com.android.support.constraint:constraint-layout:1.0.2'
//room //room
compile 'android.arch.persistence.room:runtime:1.0.0' implementation "android.arch.persistence.room:runtime:1.0.0"
annotationProcessor 'android.arch.persistence.room:compiler:1.0.0' kapt 'android.arch.persistence.room:compiler:1.0.0'
testCompile 'junit:junit:4.12'
testCompile "junit:junit:4.12"
androidTestCompile('com.android.support.test.espresso:espresso-core:2.2.2', { androidTestCompile('com.android.support.test.espresso:espresso-core:2.2.2', {
exclude group: 'com.android.support', module: 'support-annotations' exclude group: 'com.android.support', module: 'support-annotations'
}) })
compile "org.jetbrains.kotlin:kotlin-stdlib-jre7:$kotlin_version" implementation "org.jetbrains.kotlin:kotlin-stdlib-jre7:$kotlin_version"
} }
repositories { repositories {
mavenCentral() mavenCentral()

@ -16,6 +16,7 @@
package com.keylesspalace.tusky; package com.keylesspalace.tusky;
import android.Manifest; import android.Manifest;
import android.annotation.SuppressLint;
import android.app.ProgressDialog; import android.app.ProgressDialog;
import android.content.ContentResolver; import android.content.ContentResolver;
import android.content.Context; import android.content.Context;
@ -26,10 +27,7 @@ import android.content.pm.PackageManager;
import android.content.res.AssetFileDescriptor; import android.content.res.AssetFileDescriptor;
import android.content.res.Resources; import android.content.res.Resources;
import android.graphics.Bitmap; import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.drawable.Drawable; import android.graphics.drawable.Drawable;
import android.media.MediaMetadataRetriever;
import android.media.ThumbnailUtils;
import android.net.Uri; import android.net.Uri;
import android.os.AsyncTask; import android.os.AsyncTask;
import android.os.Build; import android.os.Build;
@ -39,9 +37,9 @@ import android.os.Parcel;
import android.os.Parcelable; import android.os.Parcelable;
import android.provider.MediaStore; import android.provider.MediaStore;
import android.support.annotation.AttrRes; import android.support.annotation.AttrRes;
import android.support.annotation.LayoutRes;
import android.support.annotation.NonNull; import android.support.annotation.NonNull;
import android.support.annotation.Nullable; import android.support.annotation.Nullable;
import android.support.annotation.Px;
import android.support.annotation.StringRes; import android.support.annotation.StringRes;
import android.support.design.widget.Snackbar; import android.support.design.widget.Snackbar;
import android.support.v13.view.inputmethod.InputConnectionCompat; import android.support.v13.view.inputmethod.InputConnectionCompat;
@ -61,16 +59,11 @@ import android.text.TextUtils;
import android.text.TextWatcher; import android.text.TextWatcher;
import android.text.style.URLSpan; import android.text.style.URLSpan;
import android.util.Log; import android.util.Log;
import android.view.LayoutInflater;
import android.view.MenuItem; import android.view.MenuItem;
import android.view.View; import android.view.View;
import android.view.ViewGroup;
import android.webkit.MimeTypeMap; import android.webkit.MimeTypeMap;
import android.widget.ArrayAdapter;
import android.widget.Button; import android.widget.Button;
import android.widget.EditText; import android.widget.EditText;
import android.widget.Filter;
import android.widget.Filterable;
import android.widget.ImageButton; import android.widget.ImageButton;
import android.widget.ImageView; import android.widget.ImageView;
import android.widget.LinearLayout; import android.widget.LinearLayout;
@ -80,6 +73,7 @@ import android.widget.Toast;
import com.google.gson.Gson; import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken; import com.google.gson.reflect.TypeToken;
import com.keylesspalace.tusky.adapter.MentionAutoCompleteAdapter;
import com.keylesspalace.tusky.db.TootDao; import com.keylesspalace.tusky.db.TootDao;
import com.keylesspalace.tusky.db.TootEntity; import com.keylesspalace.tusky.db.TootEntity;
import com.keylesspalace.tusky.entity.Account; import com.keylesspalace.tusky.entity.Account;
@ -98,9 +92,6 @@ import com.keylesspalace.tusky.util.StringUtils;
import com.keylesspalace.tusky.util.ThemeUtils; import com.keylesspalace.tusky.util.ThemeUtils;
import com.keylesspalace.tusky.view.EditTextTyped; import com.keylesspalace.tusky.view.EditTextTyped;
import com.keylesspalace.tusky.view.ProgressImageView; import com.keylesspalace.tusky.view.ProgressImageView;
import com.keylesspalace.tusky.view.RoundedTransformation;
import com.squareup.picasso.Picasso;
import com.squareup.picasso.Target;
import java.io.File; import java.io.File;
import java.io.FileNotFoundException; import java.io.FileNotFoundException;
@ -121,7 +112,8 @@ import retrofit2.Call;
import retrofit2.Callback; import retrofit2.Callback;
import retrofit2.Response; import retrofit2.Response;
public final class ComposeActivity extends BaseActivity implements ComposeOptionsFragment.Listener { public final class ComposeActivity extends BaseActivity
implements ComposeOptionsFragment.Listener, MentionAutoCompleteAdapter.AccountSearchProvider {
private static final String TAG = "ComposeActivity"; // logging tag private static final String TAG = "ComposeActivity"; // logging tag
private static final int STATUS_CHARACTER_LIMIT = 500; private static final int STATUS_CHARACTER_LIMIT = 500;
private static final int STATUS_MEDIA_SIZE_LIMIT = 8388608; // 8MiB private static final int STATUS_MEDIA_SIZE_LIMIT = 8388608; // 8MiB
@ -129,7 +121,8 @@ public final class ComposeActivity extends BaseActivity implements ComposeOption
private static final int MEDIA_TAKE_PHOTO_RESULT = 2; private static final int MEDIA_TAKE_PHOTO_RESULT = 2;
private static final int PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE = 1; private static final int PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE = 1;
private static final int COMPOSE_SUCCESS = -1; private static final int COMPOSE_SUCCESS = -1;
private static final int THUMBNAIL_SIZE = 128; // pixels @Px
private static final int THUMBNAIL_SIZE = 128;
private static final String SAVED_TOOT_UID_EXTRA = "saved_toot_uid"; private static final String SAVED_TOOT_UID_EXTRA = "saved_toot_uid";
private static final String SAVED_TOOT_TEXT_EXTRA = "saved_toot_text"; private static final String SAVED_TOOT_TEXT_EXTRA = "saved_toot_text";
@ -140,8 +133,11 @@ public final class ComposeActivity extends BaseActivity implements ComposeOption
private static final String MENTIONED_USERNAMES_EXTRA = "netnioned_usernames"; private static final String MENTIONED_USERNAMES_EXTRA = "netnioned_usernames";
private static final String REPLYING_STATUS_AUTHOR_USERNAME_EXTRA = "replying_author_nickname_extra"; private static final String REPLYING_STATUS_AUTHOR_USERNAME_EXTRA = "replying_author_nickname_extra";
private static final String REPLYING_STATUS_CONTENT_EXTRA = "replying_status_content"; private static final String REPLYING_STATUS_CONTENT_EXTRA = "replying_status_content";
private static final String REMEMBERED_VISIBILITY_PREF = "rememberedVisibilityNum";
private static TootDao tootDao = TuskyApplication.getDB().tootDao(); private static TootDao tootDao = TuskyApplication.getDB().tootDao();
private TextView replyTextView;
private TextView replyContentTextView; private TextView replyContentTextView;
private EditTextTyped textEditor; private EditTextTyped textEditor;
private LinearLayout mediaPreviewBar; private LinearLayout mediaPreviewBar;
@ -160,27 +156,21 @@ public final class ComposeActivity extends BaseActivity implements ComposeOption
private ArrayList<QueuedMedia> mediaQueued; private ArrayList<QueuedMedia> mediaQueued;
private CountUpDownLatch waitForMediaLatch; private CountUpDownLatch waitForMediaLatch;
private boolean showMarkSensitive; private boolean showMarkSensitive;
private String statusVisibility; // The current values of the options that will be applied private Status.Visibility statusVisibility; // The current values of the options that will be applied
private boolean statusMarkSensitive; // to the status being composed. private boolean statusMarkSensitive; // to the status being composed.
private boolean statusHideText; // private boolean statusHideText;
private boolean statusAlreadyInFlight; // to prevent duplicate sends by mashing the send button private boolean statusAlreadyInFlight; // to prevent duplicate sends by mashing the send button
private InputContentInfoCompat currentInputContentInfo; private InputContentInfoCompat currentInputContentInfo;
private int currentFlags; private int currentFlags;
private Uri photoUploadUri; private Uri photoUploadUri;
private int savedTootUid = 0; private int savedTootUid = 0;
/**
* The Target object must be stored as a member field or method and cannot be an anonymous class otherwise this won't work as expected. The reason is that Picasso accepts this parameter as a weak memory reference. Because anonymous classes are eligible for garbage collection when there are no more references, the network request to fetch the image may finish after this anonymous class has already been reclaimed. See this Stack Overflow discussion for more details.
*/
@SuppressWarnings("FieldCanBeLocal")
private Target target;
@Override @Override
public void onCreate(Bundle savedInstanceState) { public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
setContentView(R.layout.activity_compose); setContentView(R.layout.activity_compose);
TextView replyTextView = findViewById(R.id.reply_tv); replyTextView = findViewById(R.id.reply_tv);
replyContentTextView = findViewById(R.id.reply_content_tv); replyContentTextView = findViewById(R.id.reply_content_tv);
textEditor = findViewById(R.id.compose_edit_field); textEditor = findViewById(R.id.compose_edit_field);
mediaPreviewBar = findViewById(R.id.compose_media_preview_bar); mediaPreviewBar = findViewById(R.id.compose_media_preview_bar);
@ -253,13 +243,16 @@ public final class ComposeActivity extends BaseActivity implements ComposeOption
* state. */ * state. */
SharedPreferences preferences = getPrivatePreferences(); SharedPreferences preferences = getPrivatePreferences();
String startingVisibility; Status.Visibility startingVisibility;
boolean startingHideText; boolean startingHideText;
String startingContentWarning = null; String startingContentWarning = null;
ArrayList<SavedQueuedMedia> savedMediaQueued = null; ArrayList<SavedQueuedMedia> savedMediaQueued = null;
if (savedInstanceState != null) { if (savedInstanceState != null) {
showMarkSensitive = savedInstanceState.getBoolean("showMarkSensitive"); showMarkSensitive = savedInstanceState.getBoolean("showMarkSensitive");
startingVisibility = savedInstanceState.getString("statusVisibility"); startingVisibility = Status.Visibility.byNum(
savedInstanceState.getInt("statusVisibility",
Status.Visibility.PUBLIC.getNum())
);
statusMarkSensitive = savedInstanceState.getBoolean("statusMarkSensitive"); statusMarkSensitive = savedInstanceState.getBoolean("statusMarkSensitive");
startingHideText = savedInstanceState.getBoolean("statusHideText"); startingHideText = savedInstanceState.getBoolean("statusHideText");
// Keep these until everything needed to put them in the queue is finished initializing. // Keep these until everything needed to put them in the queue is finished initializing.
@ -274,7 +267,10 @@ public final class ComposeActivity extends BaseActivity implements ComposeOption
photoUploadUri = savedInstanceState.getParcelable("photoUploadUri"); photoUploadUri = savedInstanceState.getParcelable("photoUploadUri");
} else { } else {
showMarkSensitive = false; showMarkSensitive = false;
startingVisibility = preferences.getString("rememberedVisibility", "public"); startingVisibility = Status.Visibility.byNum(
preferences.getInt(REMEMBERED_VISIBILITY_PREF,
Status.Visibility.UNKNOWN.getNum())
);
statusMarkSensitive = false; statusMarkSensitive = false;
startingHideText = false; startingHideText = false;
photoUploadUri = null; photoUploadUri = null;
@ -287,37 +283,20 @@ public final class ComposeActivity extends BaseActivity implements ComposeOption
String[] mentionedUsernames = null; String[] mentionedUsernames = null;
ArrayList<String> loadedDraftMediaUris = null; ArrayList<String> loadedDraftMediaUris = null;
inReplyToId = null; inReplyToId = null;
Status.Visibility replyVisibility = Status.Visibility.UNKNOWN;
if (intent != null) { if (intent != null) {
inReplyToId = intent.getStringExtra(IN_REPLY_TO_ID_EXTRA); inReplyToId = intent.getStringExtra(IN_REPLY_TO_ID_EXTRA);
String replyVisibility = intent.getStringExtra(REPLY_VISIBILITY_EXTRA); replyVisibility = Status.Visibility.byNum(
intent.getIntExtra(REPLY_VISIBILITY_EXTRA, Status.Visibility.UNKNOWN.getNum())
if (replyVisibility != null && startingVisibility != null) { );
// Lowest possible visibility setting in response
if (startingVisibility.equals("direct") || replyVisibility.equals("direct")) {
startingVisibility = "direct";
} else if (startingVisibility.equals("private") || replyVisibility.equals("private")) {
startingVisibility = "private";
} else if (startingVisibility.equals("unlisted") || replyVisibility.equals("unlisted")) {
startingVisibility = "unlisted";
} else {
startingVisibility = replyVisibility;
}
}
mentionedUsernames = intent.getStringArrayExtra(MENTIONED_USERNAMES_EXTRA); mentionedUsernames = intent.getStringArrayExtra(MENTIONED_USERNAMES_EXTRA);
if (inReplyToId != null) { String contentWarning = intent.getStringExtra(CONTENT_WARNING_EXTRA);
startingHideText = !intent.getStringExtra(CONTENT_WARNING_EXTRA).equals(""); if (contentWarning != null) {
startingHideText = !contentWarning.isEmpty();
if (startingHideText) { if (startingHideText) {
startingContentWarning = intent.getStringExtra(CONTENT_WARNING_EXTRA); startingContentWarning = contentWarning;
}
} else {
String contentWarning = intent.getStringExtra(CONTENT_WARNING_EXTRA);
if (contentWarning != null) {
startingHideText = !contentWarning.isEmpty();
if (startingHideText) {
startingContentWarning = contentWarning;
}
} }
} }
@ -361,14 +340,12 @@ public final class ComposeActivity extends BaseActivity implements ComposeOption
} }
} }
/* If the currently logged in account is locked, its posts should default to private. This Status.Visibility pickedVisibility = pickVisibility(startingVisibility, replyVisibility,
* should override even the reply settings, so this must be done after those are set up. */ preferences.getBoolean("loggedInAccountLocked", false));
if (preferences.getBoolean("loggedInAccountLocked", false)) {
startingVisibility = "private";
}
// After the starting state is finalised, the interface can be set to reflect this state. // After the starting state is finalised, the interface can be set to reflect this state.
setStatusVisibility(startingVisibility); setStatusVisibility(pickedVisibility);
postProgress.setVisibility(View.INVISIBLE); postProgress.setVisibility(View.INVISIBLE);
updateHideMediaToggleColor(); updateHideMediaToggleColor();
updateVisibleCharactersLeft(); updateVisibleCharactersLeft();
@ -393,7 +370,8 @@ public final class ComposeActivity extends BaseActivity implements ComposeOption
} }
}); });
textEditor.setAdapter(new MentionAutoCompleteAdapter(this, R.layout.item_autocomplete)); textEditor.setAdapter(
new MentionAutoCompleteAdapter(this, R.layout.item_autocomplete, this));
textEditor.setTokenizer(new MentionTokenizer()); textEditor.setTokenizer(new MentionTokenizer());
// Add any mentions to the text field when a reply is first composed. // Add any mentions to the text field when a reply is first composed.
@ -442,7 +420,7 @@ public final class ComposeActivity extends BaseActivity implements ComposeOption
} }
} else if (savedMediaQueued != null) { } else if (savedMediaQueued != null) {
for (SavedQueuedMedia item : savedMediaQueued) { for (SavedQueuedMedia item : savedMediaQueued) {
Bitmap preview = getImageThumbnail(getContentResolver(), item.uri); Bitmap preview = MediaUtils.getImageThumbnail(getContentResolver(), item.uri, THUMBNAIL_SIZE);
addMediaToQueue(item.type, preview, item.uri, item.mediaSize, item.readyStage); addMediaToQueue(item.type, preview, item.uri, item.mediaSize, item.readyStage);
} }
} else if (intent != null && savedInstanceState == null) { } else if (intent != null && savedInstanceState == null) {
@ -453,25 +431,27 @@ public final class ComposeActivity extends BaseActivity implements ComposeOption
if (type != null) { if (type != null) {
if (type.startsWith("image/")) { if (type.startsWith("image/")) {
List<Uri> uriList = new ArrayList<>(); List<Uri> uriList = new ArrayList<>();
switch (intent.getAction()) { if (intent.getAction() != null) {
case Intent.ACTION_SEND: { switch (intent.getAction()) {
Uri uri = intent.getParcelableExtra(Intent.EXTRA_STREAM); case Intent.ACTION_SEND: {
if (uri != null) { Uri uri = intent.getParcelableExtra(Intent.EXTRA_STREAM);
uriList.add(uri); if (uri != null) {
uriList.add(uri);
}
break;
} }
break; case Intent.ACTION_SEND_MULTIPLE: {
} ArrayList<Uri> list = intent.getParcelableArrayListExtra(
case Intent.ACTION_SEND_MULTIPLE: { Intent.EXTRA_STREAM);
ArrayList<Uri> list = intent.getParcelableArrayListExtra( if (list != null) {
Intent.EXTRA_STREAM); for (Uri uri : list) {
if (list != null) { if (uri != null) {
for (Uri uri : list) { uriList.add(uri);
if (uri != null) { }
uriList.add(uri);
} }
} }
break;
} }
break;
} }
} }
for (Uri uri : uriList) { for (Uri uri : uriList) {
@ -506,7 +486,6 @@ public final class ComposeActivity extends BaseActivity implements ComposeOption
} }
outState.putParcelableArrayList("savedMediaQueued", savedMediaQueued); outState.putParcelableArrayList("savedMediaQueued", savedMediaQueued);
outState.putBoolean("showMarkSensitive", showMarkSensitive); outState.putBoolean("showMarkSensitive", showMarkSensitive);
outState.putString("statusVisibility", statusVisibility);
outState.putBoolean("statusMarkSensitive", statusMarkSensitive); outState.putBoolean("statusMarkSensitive", statusMarkSensitive);
outState.putBoolean("statusHideText", statusHideText); outState.putBoolean("statusHideText", statusHideText);
if (currentInputContentInfo != null) { if (currentInputContentInfo != null) {
@ -701,6 +680,7 @@ public final class ComposeActivity extends BaseActivity implements ComposeOption
return c; return c;
} }
@SuppressLint("StaticFieldLeak")
private boolean saveTheToot(String s, @Nullable String contentWarning) { private boolean saveTheToot(String s, @Nullable String contentWarning) {
if (TextUtils.isEmpty(s)) { if (TextUtils.isEmpty(s)) {
return false; return false;
@ -715,14 +695,11 @@ public final class ComposeActivity extends BaseActivity implements ComposeOption
}.getType()); }.getType());
} }
final TootEntity toot = new TootEntity(); String mediaUrlsSerialized = null;
toot.setText(s);
toot.setContentWarning(contentWarning);
if (!ListUtils.isEmpty(mediaQueued)) { if (!ListUtils.isEmpty(mediaQueued)) {
List<String> savedList = saveMedia(existingUris); List<String> savedList = saveMedia(existingUris);
if (!ListUtils.isEmpty(savedList)) { if (!ListUtils.isEmpty(savedList)) {
String json = new Gson().toJson(savedList); mediaUrlsSerialized = new Gson().toJson(savedList);
toot.setUrls(json);
if (!ListUtils.isEmpty(existingUris)) { if (!ListUtils.isEmpty(existingUris)) {
deleteMedia(setDifference(existingUris, savedList)); deleteMedia(setDifference(existingUris, savedList));
} }
@ -734,16 +711,15 @@ public final class ComposeActivity extends BaseActivity implements ComposeOption
* can be deleted. */ * can be deleted. */
deleteMedia(existingUris); deleteMedia(existingUris);
} }
final TootEntity toot = new TootEntity(savedTootUid, s, mediaUrlsSerialized, contentWarning,
inReplyToId,
getIntent().getStringExtra(REPLYING_STATUS_CONTENT_EXTRA),
getIntent().getStringExtra(REPLYING_STATUS_AUTHOR_USERNAME_EXTRA), statusVisibility);
new AsyncTask<Void, Void, Void>() { new AsyncTask<Void, Void, Void>() {
@Override @Override
protected Void doInBackground(Void... params) { protected Void doInBackground(Void... params) {
if (savedTootUid != 0) { tootDao.insertOrReplace(toot);
toot.setUid(savedTootUid);
tootDao.updateToot(toot);
} else {
tootDao.insert(toot);
}
return null; return null;
} }
}.execute(); }.execute();
@ -759,10 +735,10 @@ public final class ComposeActivity extends BaseActivity implements ComposeOption
} }
} }
private void setStatusVisibility(String visibility) { private void setStatusVisibility(Status.Visibility visibility) {
statusVisibility = visibility; statusVisibility = visibility;
switch (visibility) { switch (visibility) {
case "public": { case PUBLIC: {
floatingBtn.setText(R.string.action_send_public); floatingBtn.setText(R.string.action_send_public);
floatingBtn.setCompoundDrawables(null, null, null, null); floatingBtn.setCompoundDrawables(null, null, null, null);
Drawable globe = AppCompatResources.getDrawable(this, R.drawable.ic_public_24dp); Drawable globe = AppCompatResources.getDrawable(this, R.drawable.ic_public_24dp);
@ -771,7 +747,7 @@ public final class ComposeActivity extends BaseActivity implements ComposeOption
} }
break; break;
} }
case "private": { case PRIVATE: {
addLockToSendButton(); addLockToSendButton();
Drawable lock = AppCompatResources.getDrawable(this, Drawable lock = AppCompatResources.getDrawable(this,
R.drawable.ic_lock_outline_24dp); R.drawable.ic_lock_outline_24dp);
@ -780,7 +756,7 @@ public final class ComposeActivity extends BaseActivity implements ComposeOption
} }
break; break;
} }
case "direct": { case DIRECT: {
addLockToSendButton(); addLockToSendButton();
Drawable envelope = AppCompatResources.getDrawable(this, R.drawable.ic_email_24dp); Drawable envelope = AppCompatResources.getDrawable(this, R.drawable.ic_email_24dp);
if (envelope != null) { if (envelope != null) {
@ -788,7 +764,7 @@ public final class ComposeActivity extends BaseActivity implements ComposeOption
} }
break; break;
} }
case "unlisted": case UNLISTED:
default: { default: {
floatingBtn.setText(R.string.action_send); floatingBtn.setText(R.string.action_send);
floatingBtn.setCompoundDrawables(null, null, null, null); floatingBtn.setCompoundDrawables(null, null, null, null);
@ -808,7 +784,8 @@ public final class ComposeActivity extends BaseActivity implements ComposeOption
fragment.show(getSupportFragmentManager(), null); fragment.show(getSupportFragmentManager(), null);
} }
public void onVisibilityChanged(String visibility) { @Override
public void onVisibilityChanged(Status.Visibility visibility) {
setStatusVisibility(visibility); setStatusVisibility(visibility);
} }
@ -848,18 +825,17 @@ public final class ComposeActivity extends BaseActivity implements ComposeOption
@Override @Override
protected void onStop() { protected void onStop() {
super.onStop(); super.onStop();
if (inReplyToId != null) { // Don't save the visibility setting for replies because they adopt the visibility of
/* Don't save the visibility setting for replies because they adopt the visibility of // the status they reply to and that behaviour needs to be kept separate.
* the status they reply to and that behaviour needs to be kept separate. */ if (inReplyToId == null) {
return; getPrivatePreferences().edit()
.putInt(REMEMBERED_VISIBILITY_PREF, statusVisibility.getNum())
.apply();
} }
getPrivatePreferences().edit()
.putString("rememberedVisibility", statusVisibility)
.apply();
} }
private void setEditTextMimeTypes() { private void setEditTextMimeTypes() {
final String[] mimeTypes = new String[] {"image/*"}; final String[] mimeTypes = new String[]{"image/*"};
textEditor.setMimeTypes(mimeTypes, new InputConnectionCompat.OnCommitContentListener() { textEditor.setMimeTypes(mimeTypes, new InputConnectionCompat.OnCommitContentListener() {
@Override @Override
public boolean onCommitContent(InputContentInfoCompat inputContentInfo, public boolean onCommitContent(InputContentInfoCompat inputContentInfo,
@ -932,7 +908,7 @@ public final class ComposeActivity extends BaseActivity implements ComposeOption
return true; return true;
} }
private void sendStatus(String content, String visibility, boolean sensitive, private void sendStatus(String content, Status.Visibility visibility, boolean sensitive,
String spoilerText) { String spoilerText) {
ArrayList<String> mediaIds = new ArrayList<>(); ArrayList<String> mediaIds = new ArrayList<>();
@ -946,25 +922,23 @@ public final class ComposeActivity extends BaseActivity implements ComposeOption
if (response.isSuccessful()) { if (response.isSuccessful()) {
onSendSuccess(); onSendSuccess();
} else { } else {
onSendFailure(); onSendFailure(response);
} }
} }
@Override @Override
public void onFailure(@NonNull Call<Status> call, @NonNull Throwable t) { public void onFailure(@NonNull Call<Status> call, @NonNull Throwable t) {
onSendFailure(); onSendFailure(null);
} }
}; };
mastodonApi.createStatus(content, inReplyToId, spoilerText, visibility, sensitive, mediaIds) mastodonApi.createStatus(content, inReplyToId, spoilerText, visibility.serverString(),
.enqueue(callback); sensitive, mediaIds).enqueue(callback);
} }
private void onSendSuccess() { private void onSendSuccess() {
// If the status was loaded from a draft, delete the draft and associated media files. // If the status was loaded from a draft, delete the draft and associated media files.
if (savedTootUid != 0) { if (savedTootUid != 0) {
TootEntity status = new TootEntity(); tootDao.delete(savedTootUid);
status.setUid(savedTootUid);
tootDao.delete(status);
for (QueuedMedia item : mediaQueued) { for (QueuedMedia item : mediaQueued) {
try { try {
if (getContentResolver().delete(item.uri, null, null) == 0) { if (getContentResolver().delete(item.uri, null, null) == 0) {
@ -983,16 +957,32 @@ public final class ComposeActivity extends BaseActivity implements ComposeOption
finish(); finish();
} }
private void onSendFailure() { private void onSendFailure(@Nullable Response<Status> response) {
textEditor.setError(getString(R.string.error_generic));
setStateToNotReadying(); setStateToNotReadying();
if (response != null && inReplyToId != null && response.code() == 404) {
new AlertDialog.Builder(this)
.setMessage(R.string.dialog_reply_not_found)
.setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
inReplyToId = null;
replyContentTextView.setVisibility(View.GONE);
replyTextView.setVisibility(View.GONE);
}
})
.setNegativeButton(android.R.string.cancel, null)
.show();
} else {
textEditor.setError(getString(R.string.error_generic));
}
} }
private void readyStatus(final String visibility, final boolean sensitive) { private void readyStatus(final Status.Visibility visibility, final boolean sensitive) {
finishingUploadDialog = ProgressDialog.show( finishingUploadDialog = ProgressDialog.show(
this, getString(R.string.dialog_title_finishing_media_upload), this, getString(R.string.dialog_title_finishing_media_upload),
getString(R.string.dialog_message_uploading_media), true, true); getString(R.string.dialog_message_uploading_media), true, true);
final AsyncTask<Void, Void, Boolean> waitForMediaTask = @SuppressLint("StaticFieldLeak") final AsyncTask<Void, Void, Boolean> waitForMediaTask =
new AsyncTask<Void, Void, Boolean>() { new AsyncTask<Void, Void, Boolean>() {
@Override @Override
protected Boolean doInBackground(Void... params) { protected Boolean doInBackground(Void... params) {
@ -1035,7 +1025,7 @@ public final class ComposeActivity extends BaseActivity implements ComposeOption
waitForMediaTask.execute(); waitForMediaTask.execute();
} }
private void onReadySuccess(String visibility, boolean sensitive) { private void onReadySuccess(Status.Visibility visibility, boolean sensitive) {
/* Validate the status meets the character limit. This has to be delayed until after all /* Validate the status meets the character limit. This has to be delayed until after all
* uploads finish because their links are added when the upload succeeds and that affects * uploads finish because their links are added when the upload succeeds and that affects
* whether the limit is met or not. */ * whether the limit is met or not. */
@ -1056,7 +1046,7 @@ public final class ComposeActivity extends BaseActivity implements ComposeOption
} }
} }
private void onReadyFailure(final String visibility, final boolean sensitive) { private void onReadyFailure(final Status.Visibility visibility, final boolean sensitive) {
doErrorDialog(R.string.error_media_upload_sending, R.string.action_retry, doErrorDialog(R.string.error_media_upload_sending, R.string.action_retry,
new View.OnClickListener() { new View.OnClickListener() {
@Override @Override
@ -1439,44 +1429,6 @@ public final class ComposeActivity extends BaseActivity implements ComposeOption
} }
} }
@Nullable
private static Bitmap getImageThumbnail(ContentResolver contentResolver, Uri uri) {
InputStream stream;
try {
stream = contentResolver.openInputStream(uri);
} catch (FileNotFoundException e) {
return null;
}
Bitmap source = BitmapFactory.decodeStream(stream);
if (source == null) {
IOUtils.closeQuietly(stream);
return null;
}
Bitmap bitmap = ThumbnailUtils.extractThumbnail(source, THUMBNAIL_SIZE, THUMBNAIL_SIZE);
source.recycle();
try {
if (stream != null) {
stream.close();
}
} catch (IOException e) {
bitmap.recycle();
return null;
}
return bitmap;
}
@Nullable
private static Bitmap getVideoThumbnail(Context context, Uri uri) {
MediaMetadataRetriever retriever = new MediaMetadataRetriever();
retriever.setDataSource(context, uri);
Bitmap source = retriever.getFrameAtTime();
if (source == null) {
return null;
}
Bitmap bitmap = ThumbnailUtils.extractThumbnail(source, THUMBNAIL_SIZE, THUMBNAIL_SIZE);
source.recycle();
return bitmap;
}
private void pickMedia(Uri uri, long mediaSize) { private void pickMedia(Uri uri, long mediaSize) {
ContentResolver contentResolver = getContentResolver(); ContentResolver contentResolver = getContentResolver();
@ -1498,7 +1450,7 @@ public final class ComposeActivity extends BaseActivity implements ComposeOption
displayTransientError(R.string.error_media_upload_image_or_video); displayTransientError(R.string.error_media_upload_image_or_video);
return; return;
} }
Bitmap bitmap = getVideoThumbnail(this, uri); Bitmap bitmap = MediaUtils.getVideoThumbnail(this, uri, THUMBNAIL_SIZE);
if (bitmap != null) { if (bitmap != null) {
addMediaToQueue(QueuedMedia.Type.VIDEO, bitmap, uri, mediaSize, null); addMediaToQueue(QueuedMedia.Type.VIDEO, bitmap, uri, mediaSize, null);
} else { } else {
@ -1507,7 +1459,7 @@ public final class ComposeActivity extends BaseActivity implements ComposeOption
break; break;
} }
case "image": { case "image": {
Bitmap bitmap = getImageThumbnail(contentResolver, uri); Bitmap bitmap = MediaUtils.getImageThumbnail(contentResolver, uri, THUMBNAIL_SIZE);
if (bitmap != null) { if (bitmap != null) {
addMediaToQueue(QueuedMedia.Type.IMAGE, bitmap, uri, mediaSize, null); addMediaToQueue(QueuedMedia.Type.IMAGE, bitmap, uri, mediaSize, null);
} else { } else {
@ -1562,10 +1514,8 @@ public final class ComposeActivity extends BaseActivity implements ComposeOption
return super.onOptionsItemSelected(item); return super.onOptionsItemSelected(item);
} }
/** @Override
* Does a synchronous search request for accounts fulfilling the given partial mention text. public List<Account> searchAccounts(String mention) {
*/
private ArrayList<Account> autocompleteMention(String mention) {
ArrayList<Account> resultList = new ArrayList<>(); ArrayList<Account> resultList = new ArrayList<>();
try { try {
List<Account> accountList = mastodonApi.searchAccounts(mention, false, 40) List<Account> accountList = mastodonApi.searchAccounts(mention, false, 40)
@ -1580,7 +1530,7 @@ public final class ComposeActivity extends BaseActivity implements ComposeOption
return resultList; return resultList;
} }
private static class QueuedMedia { private static final class QueuedMedia {
Type type; Type type;
ProgressImageView preview; ProgressImageView preview;
Uri uri; Uri uri;
@ -1657,98 +1607,42 @@ public final class ComposeActivity extends BaseActivity implements ComposeOption
} }
} }
private class MentionAutoCompleteAdapter extends ArrayAdapter<Account> implements Filterable { /**
private ArrayList<Account> resultList; * Function to decide which visibility should be used for posting a status
@LayoutRes *
private int layoutId; * @return {@code PRIVATE} if account is locked, {@code PUBLIC} if both start and reply
* visibilities are unknown or minimal known visibility of two of them.
MentionAutoCompleteAdapter(Context context, @LayoutRes int resource) { */
super(context, resource); private static Status.Visibility pickVisibility(final Status.Visibility startVisibility,
layoutId = resource; final Status.Visibility replyVisibility,
resultList = new ArrayList<>(); boolean isAccountLocked) {
// If the currently logged in account is locked, its posts should default to private.
// This should override even the reply settings.
if (isAccountLocked) {
return Status.Visibility.PRIVATE;
} }
@Override if (startVisibility == Status.Visibility.UNKNOWN &&
public int getCount() { replyVisibility == Status.Visibility.UNKNOWN) {
return resultList.size(); return Status.Visibility.PUBLIC;
} }
@Override if (replyVisibility == Status.Visibility.UNKNOWN) {
public Account getItem(int index) { return startVisibility;
return resultList.get(index);
} }
@Override if (startVisibility == Status.Visibility.UNKNOWN) {
@NonNull return replyVisibility;
public Filter getFilter() {
return new Filter() {
@Override
public CharSequence convertResultToString(Object resultValue) {
return ((Account) resultValue).username;
}
// This method is invoked in a worker thread.
@Override
protected FilterResults performFiltering(CharSequence constraint) {
FilterResults filterResults = new FilterResults();
if (constraint != null) {
ArrayList<Account> accounts = autocompleteMention(constraint.toString());
filterResults.values = accounts;
filterResults.count = accounts.size();
}
return filterResults;
}
@SuppressWarnings("unchecked")
@Override
protected void publishResults(CharSequence constraint, FilterResults results) {
if (results != null && results.count > 0) {
resultList.clear();
ArrayList<Account> newResults = (ArrayList<Account>) results.values;
resultList.addAll(newResults);
notifyDataSetChanged();
} else {
notifyDataSetInvalidated();
}
}
};
} }
@Override if (startVisibility.getNum() > replyVisibility.getNum()) {
@NonNull return startVisibility;
public View getView(int position, @Nullable View convertView, @NonNull ViewGroup parent) { } else {
View view = convertView; return replyVisibility;
Context context = getContext();
if (convertView == null) {
LayoutInflater layoutInflater =
(LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
view = layoutInflater.inflate(layoutId, null);
}
Account account = getItem(position);
if (account != null) {
TextView username = view.findViewById(R.id.username);
TextView displayName = view.findViewById(R.id.display_name);
ImageView avatar = view.findViewById(R.id.avatar);
String format = getContext().getString(R.string.status_username_format);
String formattedUsername = String.format(format, account.username);
username.setText(formattedUsername);
displayName.setText(account.getDisplayName());
if (!account.avatar.isEmpty()) {
Picasso.with(context)
.load(account.avatar)
.placeholder(R.drawable.avatar_default)
.transform(new RoundedTransformation(7, 0))
.into(avatar);
}
}
return view;
} }
} }
@SuppressWarnings("WeakerAccess")
public static final class IntentBuilder { public static final class IntentBuilder {
@Nullable @Nullable
private Integer savedTootUid; private Integer savedTootUid;
@ -1761,11 +1655,11 @@ public final class ComposeActivity extends BaseActivity implements ComposeOption
@Nullable @Nullable
private String inReplyToId; private String inReplyToId;
@Nullable @Nullable
private String replyVisibility; private Status.Visibility replyVisibility;
@Nullable @Nullable
private String contentWarning; private String contentWarning;
@Nullable @Nullable
private Account replyingStatusAuthor; private String replyingStatusAuthor;
@Nullable @Nullable
private String replyingStatusContent; private String replyingStatusContent;
@ -1794,7 +1688,7 @@ public final class ComposeActivity extends BaseActivity implements ComposeOption
return this; return this;
} }
public IntentBuilder replyVisibility(String replyVisibility) { public IntentBuilder replyVisibility(Status.Visibility replyVisibility) {
this.replyVisibility = replyVisibility; this.replyVisibility = replyVisibility;
return this; return this;
} }
@ -1804,8 +1698,8 @@ public final class ComposeActivity extends BaseActivity implements ComposeOption
return this; return this;
} }
public IntentBuilder repyingStatusAuthor(Account author) { public IntentBuilder repyingStatusAuthor(String username) {
this.replyingStatusAuthor = author; this.replyingStatusAuthor = username;
return this; return this;
} }
@ -1834,7 +1728,7 @@ public final class ComposeActivity extends BaseActivity implements ComposeOption
intent.putExtra(IN_REPLY_TO_ID_EXTRA, inReplyToId); intent.putExtra(IN_REPLY_TO_ID_EXTRA, inReplyToId);
} }
if (replyVisibility != null) { if (replyVisibility != null) {
intent.putExtra(REPLY_VISIBILITY_EXTRA, replyVisibility); intent.putExtra(REPLY_VISIBILITY_EXTRA, replyVisibility.getNum());
} }
if (contentWarning != null) { if (contentWarning != null) {
intent.putExtra(CONTENT_WARNING_EXTRA, contentWarning); intent.putExtra(CONTENT_WARNING_EXTRA, contentWarning);
@ -1843,8 +1737,7 @@ public final class ComposeActivity extends BaseActivity implements ComposeOption
intent.putExtra(REPLYING_STATUS_CONTENT_EXTRA, replyingStatusContent); intent.putExtra(REPLYING_STATUS_CONTENT_EXTRA, replyingStatusContent);
} }
if (replyingStatusAuthor != null) { if (replyingStatusAuthor != null) {
intent.putExtra(REPLYING_STATUS_AUTHOR_USERNAME_EXTRA, intent.putExtra(REPLYING_STATUS_AUTHOR_USERNAME_EXTRA, replyingStatusAuthor);
replyingStatusAuthor.localUsername);
} }
return intent; return intent;
} }

@ -20,6 +20,7 @@ import android.graphics.drawable.Drawable;
import android.net.Uri; import android.net.Uri;
import android.os.AsyncTask; import android.os.AsyncTask;
import android.os.Bundle; import android.os.Bundle;
import android.support.annotation.Nullable;
import android.support.v7.app.ActionBar; import android.support.v7.app.ActionBar;
import android.support.v7.widget.DividerItemDecoration; import android.support.v7.widget.DividerItemDecoration;
import android.support.v7.widget.LinearLayoutManager; import android.support.v7.widget.LinearLayoutManager;
@ -37,6 +38,7 @@ import com.keylesspalace.tusky.db.TootDao;
import com.keylesspalace.tusky.db.TootEntity; import com.keylesspalace.tusky.db.TootEntity;
import com.keylesspalace.tusky.util.ThemeUtils; import com.keylesspalace.tusky.util.ThemeUtils;
import java.lang.ref.WeakReference;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
@ -50,6 +52,9 @@ public class SavedTootActivity extends BaseActivity implements SavedTootAdapter.
private SavedTootAdapter adapter; private SavedTootAdapter adapter;
private TextView noContent; private TextView noContent;
private List<TootEntity> toots = new ArrayList<>();
@Nullable private AsyncTask<?, ?, ?> asyncTask;
@Override @Override
protected void onCreate(Bundle savedInstanceState) { protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
@ -82,9 +87,13 @@ public class SavedTootActivity extends BaseActivity implements SavedTootAdapter.
@Override @Override
protected void onResume() { protected void onResume() {
super.onResume(); super.onResume();
fetchToots();
}
// req @Override
getAllToot(); protected void onPause() {
super.onPause();
if (asyncTask != null) asyncTask.cancel(true);
} }
@Override @Override
@ -98,24 +107,9 @@ public class SavedTootActivity extends BaseActivity implements SavedTootAdapter.
return super.onOptionsItemSelected(item); return super.onOptionsItemSelected(item);
} }
private void getAllToot() { private void fetchToots() {
new AsyncTask<Void, Void, List<TootEntity>>() { asyncTask = new FetchPojosTask(this)
@Override .executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
protected List<TootEntity> doInBackground(Void... params) {
return tootDao.loadAll();
}
@Override
protected void onPostExecute(List<TootEntity> tootEntities) {
super.onPostExecute(tootEntities);
// set ui
setNoContent(tootEntities.size());
if (adapter != null) {
adapter.setItems(tootEntities);
adapter.notifyDataSetChanged();
}
}
}.execute();
} }
private void setNoContent(int size) { private void setNoContent(int size) {
@ -140,11 +134,12 @@ public class SavedTootActivity extends BaseActivity implements SavedTootAdapter.
} }
} }
// update DB // update DB
tootDao.delete(item); tootDao.delete(item.getUid());
toots.remove(position);
// update adapter // update adapter
if (adapter != null) { if (adapter != null) {
adapter.removeItem(position); adapter.removeItem(position);
setNoContent(adapter.getItemCount()); setNoContent(toots.size());
} }
} }
@ -155,7 +150,41 @@ public class SavedTootActivity extends BaseActivity implements SavedTootAdapter.
.savedTootText(item.getText()) .savedTootText(item.getText())
.contentWarning(item.getContentWarning()) .contentWarning(item.getContentWarning())
.savedJsonUrls(item.getUrls()) .savedJsonUrls(item.getUrls())
.inReplyToId(item.getInReplyToId())
.repyingStatusAuthor(item.getInReplyToUsername())
.replyingStatusContent(item.getInReplyToText())
.replyVisibility(item.getVisibility())
.build(this); .build(this);
startActivity(intent); startActivity(intent);
} }
static final class FetchPojosTask extends AsyncTask<Void, Void, List<TootEntity>> {
private final WeakReference<SavedTootActivity> activityRef;
FetchPojosTask(SavedTootActivity activity) {
this.activityRef = new WeakReference<>(activity);
}
@Override
protected List<TootEntity> doInBackground(Void... voids) {
return tootDao.loadAll();
}
@Override
protected void onPostExecute(List<TootEntity> pojos) {
super.onPostExecute(pojos);
SavedTootActivity activity = activityRef.get();
if (activity == null) return;
activity.toots.addAll(pojos);
// set ui
activity.setNoContent(pojos.size());
List<TootEntity> toots = new ArrayList<>(pojos.size());
toots.addAll(pojos);
activity.adapter.setItems(toots);
activity.adapter.notifyDataSetChanged();
}
}
} }

@ -56,6 +56,7 @@ public class TuskyApplication extends Application {
db = Room.databaseBuilder(getApplicationContext(), AppDatabase.class, "tuskyDB") db = Room.databaseBuilder(getApplicationContext(), AppDatabase.class, "tuskyDB")
.allowMainThreadQueries() .allowMainThreadQueries()
.addMigrations(AppDatabase.MIGRATION_2_3) .addMigrations(AppDatabase.MIGRATION_2_3)
.addMigrations(AppDatabase.MIGRATION_3_4)
.build(); .build();
JobManager.create(this).addJobCreator(new NotificationPullJobCreator(this)); JobManager.create(this).addJobCreator(new NotificationPullJobCreator(this));

@ -0,0 +1,143 @@
/* Copyright 2017 Andrew Dawson
*
* 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.adapter;
import android.content.Context;
import android.support.annotation.LayoutRes;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ArrayAdapter;
import android.widget.Filter;
import android.widget.Filterable;
import android.widget.ImageView;
import android.widget.TextView;
import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.entity.Account;
import com.keylesspalace.tusky.view.RoundedTransformation;
import com.squareup.picasso.Picasso;
import java.util.ArrayList;
import java.util.List;
/**
* Created by charlag on 12/11/17.
*/
public class MentionAutoCompleteAdapter extends ArrayAdapter<Account>
implements Filterable {
private ArrayList<Account> resultList;
@LayoutRes
private int layoutId;
private final AccountSearchProvider accountSearchProvider;
public MentionAutoCompleteAdapter(Context context, @LayoutRes int resource,
AccountSearchProvider accountSearchProvider) {
super(context, resource);
layoutId = resource;
resultList = new ArrayList<>();
this.accountSearchProvider = accountSearchProvider;
}
@Override
public int getCount() {
return resultList.size();
}
@Override
public Account getItem(int index) {
return resultList.get(index);
}
@Override
@NonNull
public Filter getFilter() {
return new Filter() {
@Override
public CharSequence convertResultToString(Object resultValue) {
return ((Account) resultValue).username;
}
// This method is invoked in a worker thread.
@Override
protected FilterResults performFiltering(CharSequence constraint) {
FilterResults filterResults = new FilterResults();
if (constraint != null) {
List<Account> accounts =
accountSearchProvider.searchAccounts(constraint.toString());
filterResults.values = accounts;
filterResults.count = accounts.size();
}
return filterResults;
}
@SuppressWarnings("unchecked")
@Override
protected void publishResults(CharSequence constraint, FilterResults results) {
if (results != null && results.count > 0) {
resultList.clear();
ArrayList<Account> newResults = (ArrayList<Account>) results.values;
resultList.addAll(newResults);
notifyDataSetChanged();
} else {
notifyDataSetInvalidated();
}
}
};
}
@Override
@NonNull
public View getView(int position, @Nullable View convertView, @NonNull ViewGroup parent) {
View view = convertView;
Context context = getContext();
if (convertView == null) {
LayoutInflater layoutInflater =
(LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
//noinspection ConstantConditions
view = layoutInflater.inflate(layoutId, parent, false);
}
Account account = getItem(position);
if (account != null) {
TextView username = view.findViewById(R.id.username);
TextView displayName = view.findViewById(R.id.display_name);
ImageView avatar = view.findViewById(R.id.avatar);
String format = getContext().getString(R.string.status_username_format);
String formattedUsername = String.format(format, account.username);
username.setText(formattedUsername);
displayName.setText(account.getDisplayName());
if (!account.avatar.isEmpty()) {
Picasso.with(context)
.load(account.avatar)
.placeholder(R.drawable.avatar_default)
.transform(new RoundedTransformation(7, 0))
.into(avatar);
}
}
return view;
}
public interface AccountSearchProvider {
List<Account> searchAccounts(String mention);
}
}

@ -1,25 +1,39 @@
/* Copyright 2017 Andrew Dawson
*
* 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.db; package com.keylesspalace.tusky.db;
import android.arch.persistence.db.SupportSQLiteDatabase; import android.arch.persistence.db.SupportSQLiteDatabase;
import android.arch.persistence.room.Database; import android.arch.persistence.room.Database;
import android.arch.persistence.room.RoomDatabase; import android.arch.persistence.room.RoomDatabase;
import android.arch.persistence.room.migration.Migration; import android.arch.persistence.room.migration.Migration;
import android.support.annotation.NonNull;
/** /**
* DB version & declare DAO * DB version & declare DAO
*/ */
@Database(entities = {TootEntity.class}, version = 3, exportSchema = false) @Database(entities = {TootEntity.class}, version = 4, exportSchema = false)
public abstract class AppDatabase extends RoomDatabase { public abstract class AppDatabase extends RoomDatabase {
public abstract TootDao tootDao(); public abstract TootDao tootDao();
public static final Migration MIGRATION_2_3 = new Migration(2, 3) { public static final Migration MIGRATION_2_3 = new Migration(2, 3) {
@Override @Override
public void migrate(SupportSQLiteDatabase database) { public void migrate(@NonNull SupportSQLiteDatabase database) {
//this migration is necessary because of a change in the room library
database.execSQL("CREATE TABLE TootEntity2 (uid INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, text TEXT, urls TEXT, contentWarning TEXT);"); database.execSQL("CREATE TABLE TootEntity2 (uid INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, text TEXT, urls TEXT, contentWarning TEXT);");
database.execSQL("INSERT INTO TootEntity2 SELECT * FROM TootEntity;"); database.execSQL("INSERT INTO TootEntity2 SELECT * FROM TootEntity;");
database.execSQL("DROP TABLE TootEntity;"); database.execSQL("DROP TABLE TootEntity;");
database.execSQL("ALTER TABLE TootEntity2 RENAME TO TootEntity;"); database.execSQL("ALTER TABLE TootEntity2 RENAME TO TootEntity;");
@ -27,4 +41,13 @@ public abstract class AppDatabase extends RoomDatabase {
} }
}; };
public static final Migration MIGRATION_3_4 = new Migration(3, 4) {
@Override
public void migrate(@NonNull SupportSQLiteDatabase database) {
database.execSQL("ALTER TABLE TootEntity ADD COLUMN inReplyToId TEXT");
database.execSQL("ALTER TABLE TootEntity ADD COLUMN inReplyToText TEXT");
database.execSQL("ALTER TABLE TootEntity ADD COLUMN inReplyToUsername TEXT");
database.execSQL("ALTER TABLE TootEntity ADD COLUMN visibility INTEGER");
}
};
} }

@ -1,33 +1,43 @@
/* Copyright 2017 Andrew Dawson
*
* 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.db; package com.keylesspalace.tusky.db;
import android.arch.persistence.room.Dao; import android.arch.persistence.room.Dao;
import android.arch.persistence.room.Delete;
import android.arch.persistence.room.Insert; import android.arch.persistence.room.Insert;
import android.arch.persistence.room.OnConflictStrategy;
import android.arch.persistence.room.Query; import android.arch.persistence.room.Query;
import android.arch.persistence.room.Transaction;
import android.arch.persistence.room.Update; import android.arch.persistence.room.Update;
import java.util.List; import java.util.List;
/** /**
* Created by cto3543 on 28/06/2017. * Created by cto3543 on 28/06/2017.
* crud interface on this Toot DB *
* DAO to fetch and update toots in the DB.
*/ */
@Dao @Dao
public interface TootDao { public interface TootDao {
// c @Insert(onConflict = OnConflictStrategy.REPLACE)
@Insert void insertOrReplace(TootEntity users);
long insert(TootEntity users);
// r @Query("SELECT * FROM TootEntity ORDER BY uid DESC")
@Query("SELECT * FROM TootEntity")
List<TootEntity> loadAll(); List<TootEntity> loadAll();
// u @Query("DELETE FROM TootEntity WHERE uid = :uid")
@Update int delete(int uid);
void updateToot(TootEntity toot);
// d
@Delete
int delete(TootEntity user);
} }

@ -1,57 +1,119 @@
/* Copyright 2017 Andrew Dawson
*
* 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.db; package com.keylesspalace.tusky.db;
import android.arch.persistence.room.ColumnInfo; import android.arch.persistence.room.ColumnInfo;
import android.arch.persistence.room.Entity; import android.arch.persistence.room.Entity;
import android.arch.persistence.room.PrimaryKey; import android.arch.persistence.room.PrimaryKey;
import android.arch.persistence.room.TypeConverter;
import android.arch.persistence.room.TypeConverters;
import android.support.annotation.Nullable;
import com.keylesspalace.tusky.entity.Status;
/** /**
* toot model * Toot model.
*/ */
@Entity @Entity
@TypeConverters(TootEntity.Converters.class)
public class TootEntity { public class TootEntity {
@PrimaryKey(autoGenerate = true) @PrimaryKey(autoGenerate = true)
private int uid; private final int uid;
@ColumnInfo(name = "text") @ColumnInfo(name = "text")
private String text; private final String text;
@ColumnInfo(name = "urls") @ColumnInfo(name = "urls")
private String urls; private final String urls;
@ColumnInfo(name = "contentWarning") @ColumnInfo(name = "contentWarning")
private String contentWarning; private final String contentWarning;
// getter setter @ColumnInfo(name = "inReplyToId")
public String getText() { private final String inReplyToId;
return text;
} @Nullable
@ColumnInfo(name = "inReplyToText")
private final String inReplyToText;
@Nullable
@ColumnInfo(name = "inReplyToUsername")
private final String inReplyToUsername;
@ColumnInfo(name = "visibility")
private final Status.Visibility visibility;
public void setText(String text) { public TootEntity(int uid, String text, String urls, String contentWarning, String inReplyToId,
@Nullable String inReplyToText, @Nullable String inReplyToUsername,
Status.Visibility visibility) {
this.uid = uid;
this.text = text; this.text = text;
this.urls = urls;
this.contentWarning = contentWarning;
this.inReplyToId = inReplyToId;
this.inReplyToText = inReplyToText;
this.inReplyToUsername = inReplyToUsername;
this.visibility = visibility;
} }
public String getContentWarning() { public String getText() {
return contentWarning; return text;
} }
public void setContentWarning(String contentWarning) { public String getContentWarning() {
this.contentWarning = contentWarning; return contentWarning;
} }
public int getUid() { public int getUid() {
return uid; return uid;
} }
public void setUid(int uid) {
this.uid = uid;
}
public String getUrls() { public String getUrls() {
return urls; return urls;
} }
public void setUrls(String urls) { public String getInReplyToId() {
this.urls = urls; return inReplyToId;
}
@Nullable
public String getInReplyToText() {
return inReplyToText;
}
@Nullable
public String getInReplyToUsername() {
return inReplyToUsername;
}
public Status.Visibility getVisibility() {
return visibility;
}
public static final class Converters {
@TypeConverter
public Status.Visibility visibilityFromInt(int number) {
return Status.Visibility.byNum(number);
}
@TypeConverter
public int intToVisibility(Status.Visibility visibility) {
return visibility.getNum();
}
} }
} }

@ -50,15 +50,45 @@ public class Status {
} }
public enum Visibility { public enum Visibility {
UNKNOWN, UNKNOWN(0),
@SerializedName("public") @SerializedName("public")
PUBLIC, PUBLIC(1),
@SerializedName("unlisted") @SerializedName("unlisted")
UNLISTED, UNLISTED(2),
@SerializedName("private") @SerializedName("private")
PRIVATE, PRIVATE(3),
@SerializedName("direct") @SerializedName("direct")
DIRECT, DIRECT(4);
private final int num;
Visibility(int num) {
this.num = num;
}
public int getNum() {
return num;
}
public static Visibility byNum(int num) {
switch (num) {
case 4: return DIRECT;
case 3: return PRIVATE;
case 2: return UNLISTED;
case 1: return PUBLIC;
case 0: default: return UNKNOWN;
}
}
public String serverString() {
switch (this) {
case PUBLIC: return "public";
case UNLISTED: return "unlisted";
case PRIVATE: return "private";
case DIRECT: return "direct";
case UNKNOWN: default: return "unknown";
}
}
} }
public String id; public String id;
@ -162,7 +192,7 @@ public class Status {
} }
} }
public static class Mention { public static final class Mention {
public String id; public String id;
public String url; public String url;
@ -179,6 +209,7 @@ public class Status {
public String website; public String website;
} }
@SuppressWarnings("unused")
public static class Emoji { public static class Emoji {
private String shortcode; private String shortcode;
private String url; private String url;

@ -34,11 +34,12 @@ import android.widget.RadioButton;
import android.widget.RadioGroup; import android.widget.RadioGroup;
import com.keylesspalace.tusky.R; import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.util.ThemeUtils; import com.keylesspalace.tusky.util.ThemeUtils;
public class ComposeOptionsFragment extends BottomSheetDialogFragment { public class ComposeOptionsFragment extends BottomSheetDialogFragment {
public interface Listener { public interface Listener {
void onVisibilityChanged(String visibility); void onVisibilityChanged(Status.Visibility visibility);
void onContentWarningChanged(boolean hideText); void onContentWarningChanged(boolean hideText);
} }
@ -46,10 +47,10 @@ public class ComposeOptionsFragment extends BottomSheetDialogFragment {
private CheckBox hideText; private CheckBox hideText;
private Listener listener; private Listener listener;
public static ComposeOptionsFragment newInstance(String visibility, boolean hideText) { public static ComposeOptionsFragment newInstance(Status.Visibility visibility, boolean hideText) {
Bundle arguments = new Bundle(); Bundle arguments = new Bundle();
ComposeOptionsFragment fragment = new ComposeOptionsFragment(); ComposeOptionsFragment fragment = new ComposeOptionsFragment();
arguments.putString("visibility", visibility); arguments.putInt("visibilityNum", visibility.getNum());
arguments.putBoolean("hideText", hideText); arguments.putBoolean("hideText", hideText);
fragment.setArguments(arguments); fragment.setArguments(arguments);
return fragment; return fragment;
@ -68,18 +69,18 @@ public class ComposeOptionsFragment extends BottomSheetDialogFragment {
View rootView = inflater.inflate(R.layout.fragment_compose_options, container, false); View rootView = inflater.inflate(R.layout.fragment_compose_options, container, false);
Bundle arguments = getArguments(); Bundle arguments = getArguments();
String statusVisibility = arguments.getString("visibility"); Status.Visibility visibility = Status.Visibility.byNum(
arguments.getInt("visibilityNum", 0)
);
boolean statusHideText = arguments.getBoolean("hideText"); boolean statusHideText = arguments.getBoolean("hideText");
radio = rootView.findViewById(R.id.radio_visibility); radio = rootView.findViewById(R.id.radio_visibility);
int radioCheckedId = R.id.radio_public; int radioCheckedId = R.id.radio_public;
if (statusVisibility != null) { switch (visibility) {
switch (statusVisibility) { case PUBLIC: radioCheckedId = R.id.radio_public; break;
case "public": radioCheckedId = R.id.radio_public; break; case PRIVATE: radioCheckedId = R.id.radio_private; break;
case "private": radioCheckedId = R.id.radio_private; break; case UNLISTED: radioCheckedId = R.id.radio_unlisted; break;
case "unlisted": radioCheckedId = R.id.radio_unlisted; break; case DIRECT: radioCheckedId = R.id.radio_direct; break;
case "direct": radioCheckedId = R.id.radio_direct; break;
}
} }
radio.check(radioCheckedId); radio.check(radioCheckedId);
@ -104,23 +105,23 @@ public class ComposeOptionsFragment extends BottomSheetDialogFragment {
radio.setOnCheckedChangeListener(new RadioGroup.OnCheckedChangeListener() { radio.setOnCheckedChangeListener(new RadioGroup.OnCheckedChangeListener() {
@Override @Override
public void onCheckedChanged(RadioGroup group, int checkedId) { public void onCheckedChanged(RadioGroup group, int checkedId) {
String visibility; Status.Visibility visibility;
switch (checkedId) { switch (checkedId) {
default: default:
case R.id.radio_public: { case R.id.radio_public: {
visibility = "public"; visibility = Status.Visibility.PUBLIC;
break; break;
} }
case R.id.radio_unlisted: { case R.id.radio_unlisted: {
visibility = "unlisted"; visibility = Status.Visibility.UNLISTED;
break; break;
} }
case R.id.radio_private: { case R.id.radio_private: {
visibility = "private"; visibility = Status.Visibility.PRIVATE;
break; break;
} }
case R.id.radio_direct: { case R.id.radio_direct: {
visibility = "direct"; visibility = Status.Visibility.DIRECT;
break; break;
} }
} }

@ -93,7 +93,7 @@ public abstract class SFragment extends BaseFragment implements AdapterItemRemov
protected void reply(Status status) { protected void reply(Status status) {
String inReplyToId = status.getActionableId(); String inReplyToId = status.getActionableId();
Status actionableStatus = status.getActionableStatus(); Status actionableStatus = status.getActionableStatus();
String replyVisibility = actionableStatus.getVisibility().toString().toLowerCase(); Status.Visibility replyVisibility = actionableStatus.getVisibility();
String contentWarning = actionableStatus.spoilerText; String contentWarning = actionableStatus.spoilerText;
Status.Mention[] mentions = actionableStatus.mentions; Status.Mention[] mentions = actionableStatus.mentions;
List<String> mentionedUsernames = new ArrayList<>(); List<String> mentionedUsernames = new ArrayList<>();
@ -107,7 +107,7 @@ public abstract class SFragment extends BaseFragment implements AdapterItemRemov
.replyVisibility(replyVisibility) .replyVisibility(replyVisibility)
.contentWarning(contentWarning) .contentWarning(contentWarning)
.mentionedUsernames(mentionedUsernames) .mentionedUsernames(mentionedUsernames)
.repyingStatusAuthor(actionableStatus.account) .repyingStatusAuthor(actionableStatus.account.localUsername)
.replyingStatusContent(actionableStatus.content.toString()) .replyingStatusContent(actionableStatus.content.toString())
.build(getContext()); .build(getContext());
startActivityForResult(intent, COMPOSE_RESULT); startActivityForResult(intent, COMPOSE_RESULT);

@ -320,9 +320,9 @@ public class ViewThreadFragment extends SFragment implements
call.enqueue(new Callback<StatusContext>() { call.enqueue(new Callback<StatusContext>() {
@Override @Override
public void onResponse(@NonNull Call<StatusContext> call, @NonNull Response<StatusContext> response) { public void onResponse(@NonNull Call<StatusContext> call, @NonNull Response<StatusContext> response) {
if (response.isSuccessful()) { StatusContext context = response.body();
if (response.isSuccessful() && context != null) {
swipeRefreshLayout.setRefreshing(false); swipeRefreshLayout.setRefreshing(false);
StatusContext context = response.body();
setContext(context.ancestors, context.descendants); setContext(context.ancestors, context.descendants);
} else { } else {
onThreadRequestFailure(id); onThreadRequestFailure(id);

@ -19,11 +19,15 @@ import android.content.ContentResolver;
import android.content.Context; import android.content.Context;
import android.database.Cursor; import android.database.Cursor;
import android.graphics.Bitmap; import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.drawable.Drawable; import android.graphics.drawable.Drawable;
import android.media.MediaMetadataRetriever;
import android.media.ThumbnailUtils;
import android.net.Uri; import android.net.Uri;
import android.os.Environment; import android.os.Environment;
import android.provider.OpenableColumns; import android.provider.OpenableColumns;
import android.support.annotation.Nullable; import android.support.annotation.Nullable;
import android.support.annotation.Px;
import android.support.v4.content.FileProvider; import android.support.v4.content.FileProvider;
import com.squareup.picasso.Picasso; import com.squareup.picasso.Picasso;
@ -31,6 +35,7 @@ import com.squareup.picasso.Target;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.File; import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream; import java.io.FileOutputStream;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
@ -83,60 +88,43 @@ public class MediaUtils {
return mediaSize; return mediaSize;
} }
/** Download an image with picasso asynchronously and call the given listener when completed. */ @Nullable
public static Target picassoImageTarget(final Context context, final MediaListener mediaListener) { public static Bitmap getImageThumbnail(ContentResolver contentResolver, Uri uri,
final String imageName = "temp"; @Px int thumbnailSize) {
return new Target() { InputStream stream;
@Override try {
public void onBitmapLoaded(final Bitmap bitmap, Picasso.LoadedFrom from) { stream = contentResolver.openInputStream(uri);
new Thread(new Runnable() { } catch (FileNotFoundException e) {
@Override return null;
public void run() { }
FileOutputStream fos = null; Bitmap source = BitmapFactory.decodeStream(stream);
Uri uriForFile; if (source == null) {
try { IOUtils.closeQuietly(stream);
// we download only a "temp" file return null;
File storageDir = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES); }
File tempFile = File.createTempFile( Bitmap bitmap = ThumbnailUtils.extractThumbnail(source, thumbnailSize, thumbnailSize);
imageName, source.recycle();
".jpg", try {
storageDir if (stream != null) {
); stream.close();
fos = new FileOutputStream(tempFile);
bitmap.compress(Bitmap.CompressFormat.PNG, 100, fos);
uriForFile = FileProvider.getUriForFile(context,
"com.keylesspalace.tusky.fileprovider",
tempFile);
// giving to the activity the URI callback
mediaListener.onCallback(uriForFile);
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
if (fos != null) {
fos.close();
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
}).start();
}
@Override
public void onBitmapFailed(Drawable errorDrawable) {
}
@Override
public void onPrepareLoad(Drawable placeHolderDrawable) {
} }
}; } catch (IOException e) {
bitmap.recycle();
return null;
}
return bitmap;
} }
public interface MediaListener { @Nullable
void onCallback(Uri headerInfo); public static Bitmap getVideoThumbnail(Context context, Uri uri, @Px int thumbnailSize) {
MediaMetadataRetriever retriever = new MediaMetadataRetriever();
retriever.setDataSource(context, uri);
Bitmap source = retriever.getFrameAtTime();
if (source == null) {
return null;
}
Bitmap bitmap = ThumbnailUtils.extractThumbnail(source, thumbnailSize, thumbnailSize);
source.recycle();
return bitmap;
} }
} }

@ -138,6 +138,7 @@
<string name="dialog_download_image">Скачать</string> <string name="dialog_download_image">Скачать</string>
<string name="dialog_message_follow_request">Статус запроса на подписку: ожидается ответ</string> <string name="dialog_message_follow_request">Статус запроса на подписку: ожидается ответ</string>
<string name="dialog_unfollow_warning">Отписаться от этого аккаунта?</string> <string name="dialog_unfollow_warning">Отписаться от этого аккаунта?</string>
<string name="dialog_reply_not_found">Не удалось опубликовать статус. Статус, на который вы отвечаете, может быть недоступен. Убрать информацию об ответе?</string>
<string name="visibility_public">Публичный: Показать в публичных лентах</string> <string name="visibility_public">Публичный: Показать в публичных лентах</string>
<string name="visibility_unlisted">Скрытый: Не показывать в лентах</string> <string name="visibility_unlisted">Скрытый: Не показывать в лентах</string>

@ -146,6 +146,7 @@
<string name="dialog_download_image">Download</string> <string name="dialog_download_image">Download</string>
<string name="dialog_message_follow_request">Follow request pending: awaiting their response</string> <string name="dialog_message_follow_request">Follow request pending: awaiting their response</string>
<string name="dialog_unfollow_warning">Unfollow this account?</string> <string name="dialog_unfollow_warning">Unfollow this account?</string>
<string name="dialog_reply_not_found">Couldn\'t post this status. The status you\'re replying to might not be available. Remove reply info?</string>
<string name="visibility_public">Public: Post to public timelines</string> <string name="visibility_public">Public: Post to public timelines</string>
<string name="visibility_unlisted">Unlisted: Do not show in public timelines</string> <string name="visibility_unlisted">Unlisted: Do not show in public timelines</string>

Loading…
Cancel
Save