diff --git a/app/build.gradle b/app/build.gradle index 11bd95a6a..667380c42 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -87,7 +87,7 @@ dependencies { testImplementation "org.robolectric:robolectric:3.8" testImplementation "org.mockito:mockito-inline:2.17.0" - androidTestImplementation('com.android.support.test.espresso:espresso-core:2.2.2', { + androidTestImplementation('com.android.support.test.espresso:espresso-core:3.0.1', { exclude group: 'com.android.support', module: 'support-annotations' }) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 5232166b7..fafd824dd 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -111,6 +111,7 @@ + mediaQueued = new ArrayList<>(); private CountUpDownLatch waitForMediaLatch; - private boolean showMarkSensitive; private Status.Visibility statusVisibility; // The current values of the options that will be applied private boolean statusMarkSensitive; // to the status being composed. private boolean statusHideText; - private boolean statusAlreadyInFlight; // to prevent duplicate sends by mashing the send button private InputContentInfoCompat currentInputContentInfo; private int currentFlags; private Uri photoUploadUri; private int savedTootUid = 0; + private SaveTootHelper saveTootHelper; + @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_compose); - replyTextView = findViewById(R.id.reply_tv); - replyContentTextView = findViewById(R.id.reply_content_tv); - textEditor = findViewById(R.id.compose_edit_field); + replyTextView = findViewById(R.id.composeReplyView); + replyContentTextView = findViewById(R.id.composeReplyContentView); + textEditor = findViewById(R.id.composeEditField); mediaPreviewBar = findViewById(R.id.compose_media_preview_bar); - contentWarningBar = findViewById(R.id.compose_content_warning_bar); - contentWarningEditor = findViewById(R.id.field_content_warning); - charactersLeft = findViewById(R.id.characters_left); - floatingBtn = findViewById(R.id.floating_btn); - pickButton = findViewById(R.id.compose_photo_pick); - visibilityBtn = findViewById(R.id.action_toggle_visibility); - saveButton = findViewById(R.id.compose_save_draft); - hideMediaToggle = findViewById(R.id.action_hide_media); - postProgress = findViewById(R.id.postProgress); + contentWarningBar = findViewById(R.id.composeContentWarningBar); + contentWarningEditor = findViewById(R.id.composeContentWarningField); + charactersLeft = findViewById(R.id.composeCharactersLeftView); + tootButton = findViewById(R.id.composeTootButton); + pickButton = findViewById(R.id.composeAddMediaButton); + visibilityButton = findViewById(R.id.composeToggleVisibilityButton); + contentWarningButton = findViewById(R.id.composeContentWarningButton); + emojiButton = findViewById(R.id.composeEmojiButton); + hideMediaToggle = findViewById(R.id.composeHideMediaButton); + emojiView = findViewById(R.id.emojiView); + + saveTootHelper = new SaveTootHelper(TuskyApplication.getDB().tootDao(), this); // Setup the toolbar. Toolbar toolbar = findViewById(R.id.toolbar); @@ -241,17 +264,65 @@ public final class ComposeActivity extends BaseActivity return; } + composeOptionsView = findViewById(R.id.composeOptionsBottomSheet); + composeOptionsView.setListener(this); + + composeOptionsBehavior = BottomSheetBehavior.from(composeOptionsView); + composeOptionsBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); + + addMediaBehavior = BottomSheetBehavior.from(findViewById(R.id.addMediaBottomSheet)); + + emojiBehavior = BottomSheetBehavior.from(emojiView); + + emojiView.setLayoutManager(new GridLayoutManager(this, 3, GridLayoutManager.HORIZONTAL, false)); + + mastodonApi.getCustomEmojis().enqueue(new Callback>() { + @Override + public void onResponse(@NonNull Call> call, @NonNull Response> response) { + List emojiList = response.body(); + + if (emojiList != null) { + + emojiView.setAdapter(new EmojiAdapter(emojiList, ComposeActivity.this)); + + EmojiListEntity emojiListEntity = new EmojiListEntity(activeAccount.getDomain(), emojiList); + + TuskyApplication.getDB().emojiListDao().insertOrReplace(emojiListEntity); + } + } + + @Override + public void onFailure(@NonNull Call> call, @NonNull Throwable t) { + Log.w(TAG, "error loading custom emojis", t); + EmojiListEntity emojiListEntity = TuskyApplication.getDB().emojiListDao().loadEmojisForInstance(activeAccount.getDomain()); + + if(emojiListEntity != null) { + emojiView.setAdapter(new EmojiAdapter(emojiListEntity.getEmojiList(), ComposeActivity.this)); + } + } + }); + // Setup the interface buttons. - floatingBtn.setOnClickListener(v -> onSendClicked()); - floatingBtn.setOnLongClickListener(v -> saveDraft()); + tootButton.setOnClickListener(v -> onSendClicked()); pickButton.setOnClickListener(v -> openPickDialog()); - visibilityBtn.setOnClickListener(v -> showComposeOptions()); - saveButton.setOnClickListener(v -> saveDraft()); + visibilityButton.setOnClickListener(v -> showComposeOptions()); + contentWarningButton.setOnClickListener(v-> onContentWarningChanged()); + emojiButton.setOnClickListener(v -> showEmojis()); hideMediaToggle.setOnClickListener(v -> toggleHideMedia()); - //fix a bug with autocomplete and some keyboards - int newInputType = textEditor.getInputType() & (textEditor.getInputType() ^ InputType.TYPE_TEXT_FLAG_AUTO_COMPLETE); - textEditor.setInputType(newInputType); + TextView actionPhotoTake = findViewById(R.id.action_photo_take); + TextView actionPhotoPick = findViewById(R.id.action_photo_pick); + + int textColor = ThemeUtils.getColor(this, android.R.attr.textColorTertiary); + + Drawable cameraIcon = new IconicsDrawable(this, GoogleMaterial.Icon.gmd_camera_alt).color(textColor).sizeDp(18); + TextViewCompat.setCompoundDrawablesRelativeWithIntrinsicBounds(actionPhotoTake, cameraIcon, null, null, null); + + Drawable imageIcon = new IconicsDrawable(this, GoogleMaterial.Icon.gmd_image).color(textColor).sizeDp(18); + TextViewCompat.setCompoundDrawablesRelativeWithIntrinsicBounds(actionPhotoPick, imageIcon, null, null, null); + + actionPhotoTake.setOnClickListener(v -> initiateCameraApp()); + actionPhotoPick.setOnClickListener(v -> onMediaPick()); /* Initialise all the state, or restore it from a previous run, to determine a "starting" * state. */ @@ -260,7 +331,6 @@ public final class ComposeActivity extends BaseActivity String startingContentWarning = null; ArrayList savedMediaQueued = null; if (savedInstanceState != null) { - showMarkSensitive = savedInstanceState.getBoolean("showMarkSensitive"); startingVisibility = Status.Visibility.byNum( savedInstanceState.getInt("statusVisibility", Status.Visibility.PUBLIC.getNum()) @@ -278,7 +348,6 @@ public final class ComposeActivity extends BaseActivity } photoUploadUri = savedInstanceState.getParcelable("photoUploadUri"); } else { - showMarkSensitive = false; statusMarkSensitive = false; startingHideText = false; photoUploadUri = null; @@ -342,11 +411,24 @@ public final class ComposeActivity extends BaseActivity replyTextView.setVisibility(View.VISIBLE); String username = intent.getStringExtra(REPLYING_STATUS_AUTHOR_USERNAME_EXTRA); replyTextView.setText(getString(R.string.replying_to, username)); + Drawable arrowDownIcon = new IconicsDrawable(this, GoogleMaterial.Icon.gmd_arrow_drop_down).sizeDp(12); + + ThemeUtils.setDrawableTint(this, arrowDownIcon, android.R.attr.textColorTertiary); + TextViewCompat.setCompoundDrawablesRelativeWithIntrinsicBounds(replyTextView, null, null, arrowDownIcon, null); + replyTextView.setOnClickListener(v -> { + TransitionManager.beginDelayedTransition((ViewGroup)replyContentTextView.getParent()); + if (replyContentTextView.getVisibility() != View.VISIBLE) { replyContentTextView.setVisibility(View.VISIBLE); + Drawable arrowUpIcon = new IconicsDrawable(this, GoogleMaterial.Icon.gmd_arrow_drop_up).sizeDp(12); + + ThemeUtils.setDrawableTint(this, arrowUpIcon, android.R.attr.textColorTertiary); + TextViewCompat.setCompoundDrawablesRelativeWithIntrinsicBounds(replyTextView, null, null, arrowUpIcon, null); } else { replyContentTextView.setVisibility(View.GONE); + + TextViewCompat.setCompoundDrawablesRelativeWithIntrinsicBounds(replyTextView, null, null, arrowDownIcon, null); } }); } @@ -359,12 +441,11 @@ public final class ComposeActivity extends BaseActivity // After the starting state is finalised, the interface can be set to reflect this state. setStatusVisibility(startingVisibility); - postProgress.setVisibility(View.INVISIBLE); - updateHideMediaToggleColor(); + updateHideMediaToggle(); updateVisibleCharactersLeft(); // Setup the main text field. - setEditTextMimeTypes(); // new String[] { "image/gif", "image/webp" } + textEditor.setOnCommitContentListener(this); final int mentionColour = ThemeUtils.getColor(this, R.attr.compose_mention_color); SpanUtils.highlightSpans(textEditor.getText(), mentionColour); textEditor.addTextChangedListener(new TextWatcher() { @@ -421,7 +502,6 @@ public final class ComposeActivity extends BaseActivity // Initialise the empty media queue state. waitForMediaLatch = new CountUpDownLatch(); - statusAlreadyInFlight = false; // These can only be added after everything affected by the media queue is initialized. if (!ListUtils.isEmpty(loadedDraftMediaUris)) { @@ -497,7 +577,6 @@ public final class ComposeActivity extends BaseActivity item.mediaSize, item.readyStage, item.description)); } outState.putParcelableArrayList("savedMediaQueued", savedMediaQueued); - outState.putBoolean("showMarkSensitive", showMarkSensitive); outState.putBoolean("statusMarkSensitive", statusMarkSensitive); outState.putBoolean("statusHideText", statusHideText); if (currentInputContentInfo != null) { @@ -517,274 +596,98 @@ public final class ComposeActivity extends BaseActivity Snackbar bar = Snackbar.make(findViewById(R.id.activity_compose), getString(descriptionId), Snackbar.LENGTH_SHORT); bar.setAction(actionId, listener); + //necessary so snackbar is shown over everything + ViewCompat.setElevation(bar.getView(), getResources().getDimensionPixelSize(R.dimen.compose_activity_snackbar_elevation)); bar.show(); } private void displayTransientError(@StringRes int stringId) { - Snackbar.make(findViewById(R.id.activity_compose), stringId, Snackbar.LENGTH_LONG).show(); + Snackbar bar = Snackbar.make(findViewById(R.id.activity_compose), stringId, Snackbar.LENGTH_LONG); + //necessary so snackbar is shown over everything + ViewCompat.setElevation(bar.getView(), getResources().getDimensionPixelSize(R.dimen.compose_activity_snackbar_elevation)); + bar.show(); } private void toggleHideMedia() { statusMarkSensitive = !statusMarkSensitive; - updateHideMediaToggleColor(); + updateHideMediaToggle(); } - private void updateHideMediaToggleColor() { - @AttrRes int attribute; - if (statusMarkSensitive) { - attribute = R.attr.compose_hide_media_button_selected_color; + private void updateHideMediaToggle() { + TransitionManager.beginDelayedTransition((ViewGroup)hideMediaToggle.getParent()); + + @ColorInt int color; + if(mediaQueued.size() == 0) { + hideMediaToggle.setVisibility(View.GONE); } else { - attribute = R.attr.compose_hide_media_button_color; + hideMediaToggle.setVisibility(View.VISIBLE); + if (statusMarkSensitive) { + hideMediaToggle.setImageResource(R.drawable.ic_hide_media_24dp); + if (statusHideText) { + hideMediaToggle.setClickable(false); + color = ContextCompat.getColor(this, R.color.compose_media_visible_button_disabled_blue); + } else { + hideMediaToggle.setClickable(true); + color = ContextCompat.getColor(this, R.color.primary); + } + } else { + hideMediaToggle.setClickable(true); + hideMediaToggle.setImageResource(R.drawable.ic_eye_24dp); + color = ThemeUtils.getColor(this, android.R.attr.textColorTertiary); + } + hideMediaToggle.getDrawable().setColorFilter(color, PorterDuff.Mode.SRC_IN); } - ThemeUtils.setDrawableTint(this, hideMediaToggle.getDrawable(), attribute); } private void disableButtons() { pickButton.setClickable(false); - visibilityBtn.setClickable(false); - saveButton.setClickable(false); + visibilityButton.setClickable(false); + emojiButton.setClickable(false); hideMediaToggle.setClickable(false); - floatingBtn.setEnabled(false); + tootButton.setEnabled(false); } private void enableButtons() { pickButton.setClickable(true); - visibilityBtn.setClickable(true); - saveButton.setClickable(true); + visibilityButton.setClickable(true); + emojiButton.setClickable(true); hideMediaToggle.setClickable(true); - floatingBtn.setEnabled(true); - } - - private boolean saveDraft() { - String contentWarning = null; - if (statusHideText) { - contentWarning = contentWarningEditor.getText().toString(); - } - Editable textToSave = textEditor.getEditableText(); - /* Discard any upload URLs embedded in the text because they'll be re-uploaded when - * the draft is loaded and replaced with new URLs. */ - if (mediaQueued != null) { - for (QueuedMedia item : mediaQueued) { - textToSave = removeUrlFromEditable(textToSave, item.uploadUrl); - } - } - boolean didSaveSuccessfully = saveTheToot(textToSave.toString(), contentWarning); - if (didSaveSuccessfully) { - Toast.makeText(ComposeActivity.this, R.string.action_save_one_toot, Toast.LENGTH_SHORT) - .show(); - } - return didSaveSuccessfully; - } - - private static boolean copyToFile(ContentResolver contentResolver, Uri uri, File file) { - InputStream from; - FileOutputStream to; - try { - from = contentResolver.openInputStream(uri); - to = new FileOutputStream(file); - } catch (FileNotFoundException e) { - return false; - } - if (from == null) { - return false; - } - byte[] chunk = new byte[16384]; - try { - while (true) { - int bytes = from.read(chunk, 0, chunk.length); - if (bytes < 0) { - break; - } - to.write(chunk, 0, bytes); - } - } catch (IOException e) { - return false; - } - IOUtils.closeQuietly(from); - IOUtils.closeQuietly(to); - return true; - } - - @Nullable - private List saveMedia(@Nullable ArrayList existingUris) { - File imageDirectory = getExternalFilesDir(Environment.DIRECTORY_PICTURES); - File videoDirectory = getExternalFilesDir(Environment.DIRECTORY_MOVIES); - if (imageDirectory == null || !(imageDirectory.exists() || imageDirectory.mkdirs())) { - Log.e(TAG, "Image directory is not created."); - return null; - } - if (videoDirectory == null || !(videoDirectory.exists() || videoDirectory.mkdirs())) { - Log.e(TAG, "Video directory is not created."); - return null; - } - ContentResolver contentResolver = getContentResolver(); - ArrayList filesSoFar = new ArrayList<>(); - ArrayList results = new ArrayList<>(); - for (QueuedMedia item : mediaQueued) { - /* If the media was already saved in a previous draft, there's no need to save another - * copy, just add the existing URI to the results. */ - if (existingUris != null) { - String uri = item.uri.toString(); - int index = existingUris.indexOf(uri); - if (index != -1) { - results.add(uri); - continue; - } - } - // Otherwise, save the media. - File directory; - switch (item.type) { - default: - case IMAGE: - directory = imageDirectory; - break; - case VIDEO: - directory = videoDirectory; - break; - } - String timeStamp = new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US) - .format(new Date()); - String mimeType = contentResolver.getType(item.uri); - MimeTypeMap map = MimeTypeMap.getSingleton(); - String fileExtension = map.getExtensionFromMimeType(mimeType); - String filename = String.format("Tusky_Draft_Media_%s.%s", timeStamp, fileExtension); - File file = new File(directory, filename); - filesSoFar.add(file); - boolean copied = copyToFile(contentResolver, item.uri, file); - if (!copied) { - /* If any media files were created in prior iterations, delete those before - * returning. */ - for (File earlierFile : filesSoFar) { - boolean deleted = earlierFile.delete(); - if (!deleted) { - Log.i(TAG, "Could not delete the file " + earlierFile.toString()); - } - } - return null; - } - Uri uri = FileProvider.getUriForFile(this, "com.keylesspalace.tusky.fileprovider", - file); - results.add(uri.toString()); - } - return results; - } - - private void deleteMedia(List mediaUris) { - for (String uriString : mediaUris) { - Uri uri = Uri.parse(uriString); - if (getContentResolver().delete(uri, null, null) == 0) { - Log.e(TAG, String.format("Did not delete file %s.", uriString)); - } - } - } - - /** - * A∖B={x∈A|x∉B} - * - * @return all elements of set A that are not in set B. - */ - private static List setDifference(List a, List b) { - List c = new ArrayList<>(); - for (String s : a) { - if (!b.contains(s)) { - c.add(s); - } - } - return c; - } - - @SuppressLint("StaticFieldLeak") - private boolean saveTheToot(String s, @Nullable String contentWarning) { - if (TextUtils.isEmpty(s)) { - return false; - } - - // Get any existing file's URIs. - ArrayList existingUris = null; - String savedJsonUrls = getIntent().getStringExtra("saved_json_urls"); - if (!TextUtils.isEmpty(savedJsonUrls)) { - existingUris = new Gson().fromJson(savedJsonUrls, - new TypeToken>() { - }.getType()); - } - - String mediaUrlsSerialized = null; - if (!ListUtils.isEmpty(mediaQueued)) { - List savedList = saveMedia(existingUris); - if (!ListUtils.isEmpty(savedList)) { - mediaUrlsSerialized = new Gson().toJson(savedList); - if (!ListUtils.isEmpty(existingUris)) { - deleteMedia(setDifference(existingUris, savedList)); - } - } else { - return false; - } - } else if (!ListUtils.isEmpty(existingUris)) { - /* If there were URIs in the previous draft, but they've now been removed, those files - * can be deleted. */ - 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() { - @Override - protected Void doInBackground(Void... params) { - tootDao.insertOrReplace(toot); - return null; - } - }.execute(); - return true; - } - - private void addLockToSendButton() { - floatingBtn.setText(R.string.action_send); - Drawable lock = AppCompatResources.getDrawable(this, R.drawable.send_private); - if (lock != null) { - lock.setBounds(0, 0, lock.getIntrinsicWidth(), lock.getIntrinsicHeight()); - floatingBtn.setCompoundDrawables(null, null, lock, null); - } + tootButton.setEnabled(true); } private void setStatusVisibility(Status.Visibility visibility) { statusVisibility = visibility; + composeOptionsView.setStatusVisibility(visibility); + tootButton.setStatusVisibility(visibility); + switch (visibility) { case PUBLIC: { - floatingBtn.setText(R.string.action_send_public); - floatingBtn.setCompoundDrawables(null, null, null, null); Drawable globe = AppCompatResources.getDrawable(this, R.drawable.ic_public_24dp); if (globe != null) { - visibilityBtn.setImageDrawable(globe); + visibilityButton.setImageDrawable(globe); } break; } case PRIVATE: { - addLockToSendButton(); Drawable lock = AppCompatResources.getDrawable(this, R.drawable.ic_lock_outline_24dp); if (lock != null) { - visibilityBtn.setImageDrawable(lock); + visibilityButton.setImageDrawable(lock); } break; } case DIRECT: { - addLockToSendButton(); Drawable envelope = AppCompatResources.getDrawable(this, R.drawable.ic_email_24dp); if (envelope != null) { - visibilityBtn.setImageDrawable(envelope); + visibilityButton.setImageDrawable(envelope); } break; } case UNLISTED: default: { - floatingBtn.setText(R.string.action_send); - floatingBtn.setCompoundDrawables(null, null, null, null); - Drawable openLock = AppCompatResources.getDrawable(this, - R.drawable.ic_lock_open_24dp); + Drawable openLock = AppCompatResources.getDrawable(this, R.drawable.ic_lock_open_24dp); if (openLock != null) { - visibilityBtn.setImageDrawable(openLock); + visibilityButton.setImageDrawable(openLock); } break; } @@ -792,59 +695,79 @@ public final class ComposeActivity extends BaseActivity } private void showComposeOptions() { - ComposeOptionsFragment fragment = ComposeOptionsFragment.newInstance( - statusVisibility, statusHideText); - fragment.show(getSupportFragmentManager(), null); + if (composeOptionsBehavior.getState() == BottomSheetBehavior.STATE_HIDDEN || composeOptionsBehavior.getState() == BottomSheetBehavior.STATE_COLLAPSED) { + composeOptionsBehavior.setState(BottomSheetBehavior.STATE_EXPANDED); + addMediaBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); + emojiBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); + + } else { + composeOptionsBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); + } + } + + private void showEmojis() { + if (emojiBehavior.getState() == BottomSheetBehavior.STATE_HIDDEN || emojiBehavior.getState() == BottomSheetBehavior.STATE_COLLAPSED) { + emojiBehavior.setState(BottomSheetBehavior.STATE_EXPANDED); + composeOptionsBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); + addMediaBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); + + } else { + emojiBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); + } + } + + private void openPickDialog() { + if (addMediaBehavior.getState() == BottomSheetBehavior.STATE_HIDDEN || addMediaBehavior.getState() == BottomSheetBehavior.STATE_COLLAPSED) { + addMediaBehavior.setState(BottomSheetBehavior.STATE_EXPANDED); + composeOptionsBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); + emojiBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); + + } else { + addMediaBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); + } + + } + + private void onMediaPick() { + addMediaBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED); + + if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) + != PackageManager.PERMISSION_GRANTED) { + ActivityCompat.requestPermissions(this, + new String[]{Manifest.permission.READ_EXTERNAL_STORAGE}, + PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE); + } else { + initiateMediaPicking(); + } } @Override - public void onVisibilityChanged(Status.Visibility visibility) { + public void onVisibilityChanged(@NonNull Status.Visibility visibility) { + composeOptionsBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED); setStatusVisibility(visibility); } private void updateVisibleCharactersLeft() { - int left = STATUS_CHARACTER_LIMIT - textEditor.length(); + int charactersLeft = STATUS_CHARACTER_LIMIT - textEditor.length(); if (statusHideText) { - left -= contentWarningEditor.length(); + charactersLeft -= contentWarningEditor.length(); } - charactersLeft.setText(String.format(Locale.getDefault(), "%d", left)); + this.charactersLeft.setText(String.format(Locale.getDefault(), "%d", charactersLeft)); } - public void onContentWarningChanged(boolean hideText) { - showContentWarning(hideText); + private void onContentWarningChanged() { + boolean showWarning = contentWarningBar.getVisibility() != View.VISIBLE; + showContentWarning(showWarning); updateVisibleCharactersLeft(); } - private void setStateToReadying() { - statusAlreadyInFlight = true; - disableButtons(); - postProgress.setVisibility(View.VISIBLE); - } - - private void setStateToNotReadying() { - postProgress.setVisibility(View.INVISIBLE); - statusAlreadyInFlight = false; - enableButtons(); - } - private void onSendClicked() { - if (statusAlreadyInFlight) { - return; - } - setStateToReadying(); + disableButtons(); readyStatus(statusVisibility, statusMarkSensitive); } - private void setEditTextMimeTypes() { - final String[] mimeTypes = new String[]{"image/*"}; - textEditor.setMimeTypes(mimeTypes, - (inputContentInfo, flags, opts) -> - ComposeActivity.this.onCommitContent(inputContentInfo, flags, - mimeTypes)); - } - - private boolean onCommitContent(InputContentInfoCompat inputContentInfo, int flags, - String[] mimeTypes) { + @Override + public boolean onCommitContent(InputContentInfoCompat inputContentInfo, int flags, Bundle opts) { try { if (currentInputContentInfo != null) { currentInputContentInfo.releasePermission(); @@ -855,14 +778,8 @@ public final class ComposeActivity extends BaseActivity currentInputContentInfo = null; } - // Verify the returned content's type is actually in the list of MIME types requested. - boolean supported = false; - for (final String mimeType : mimeTypes) { - if (inputContentInfo.getDescription().hasMimeType(mimeType)) { - supported = true; - break; - } - } + // Verify the returned content's type is of the correct MIME type + boolean supported = inputContentInfo.getDescription().hasMimeType("image/*"); return supported && onCommitContentInternal(inputContentInfo, flags); } @@ -908,68 +825,23 @@ public final class ComposeActivity extends BaseActivity private void sendStatus(String content, Status.Visibility visibility, boolean sensitive, String spoilerText) { ArrayList mediaIds = new ArrayList<>(); - + ArrayList mediaUris = new ArrayList<>(); for (QueuedMedia item : mediaQueued) { mediaIds.add(item.id); + mediaUris.add(item.uri.toString()); } - Callback callback = new Callback() { - @Override - public void onResponse(@NonNull Call call, @NonNull Response response) { - if (response.isSuccessful()) { - onSendSuccess(); - } else { - onSendFailure(response); - } - } + Intent sendIntent = SendTootService.sendTootIntent(this, content, spoilerText, + visibility, sensitive, mediaIds, mediaUris, inReplyToId, + getIntent().getStringExtra(REPLYING_STATUS_CONTENT_EXTRA), + getIntent().getStringExtra(REPLYING_STATUS_AUTHOR_USERNAME_EXTRA), + getIntent().getStringExtra(SAVED_JSON_URLS_EXTRA), + accountManager.getActiveAccount(), savedTootUid); - @Override - public void onFailure(@NonNull Call call, @NonNull Throwable t) { - onSendFailure(null); - } - }; - mastodonApi.createStatus(content, inReplyToId, spoilerText, visibility.serverString(), - sensitive, mediaIds).enqueue(callback); - } + startService(sendIntent); - private void onSendSuccess() { - // If the status was loaded from a draft, delete the draft and associated media files. - if (savedTootUid != 0) { - tootDao.delete(savedTootUid); - for (QueuedMedia item : mediaQueued) { - try { - if (getContentResolver().delete(item.uri, null, null) == 0) { - Log.e(TAG, String.format("Did not delete file %s.", item.uri.toString())); - } - } catch (SecurityException e) { - Log.e(TAG, String.format("Did not delete file %s.", item.uri.toString()), e); - } - - } - } - Snackbar bar = Snackbar.make(findViewById(R.id.activity_compose), - getString(R.string.confirmation_send), Snackbar.LENGTH_SHORT); - bar.show(); - setResult(COMPOSE_SUCCESS); finish(); - } - private void onSendFailure(@Nullable Response response) { - 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, (dialog, 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 Status.Visibility visibility, final boolean sensitive) { @@ -1003,7 +875,7 @@ public final class ComposeActivity extends BaseActivity @Override protected void onCancelled() { removeAllMediaFromQueue(); - setStateToNotReadying(); + enableButtons(); super.onCancelled(); } }; @@ -1026,56 +898,22 @@ public final class ComposeActivity extends BaseActivity spoilerText = contentWarningEditor.getText().toString(); } int characterCount = contentText.length() + spoilerText.length(); - if (characterCount > 0 && characterCount <= STATUS_CHARACTER_LIMIT) { - sendStatus(contentText, visibility, sensitive, spoilerText); - } else if (characterCount <= 0) { + if (characterCount <= 0 && mediaQueued.size()==0) { textEditor.setError(getString(R.string.error_empty)); - setStateToNotReadying(); + enableButtons(); + } else if (characterCount <= STATUS_CHARACTER_LIMIT) { + sendStatus(contentText, visibility, sensitive, spoilerText); + } else { textEditor.setError(getString(R.string.error_compose_character_limit)); - setStateToNotReadying(); + enableButtons(); } } private void onReadyFailure(final Status.Visibility visibility, final boolean sensitive) { doErrorDialog(R.string.error_media_upload_sending, R.string.action_retry, v -> readyStatus(visibility, sensitive)); - setStateToNotReadying(); - } - - private void openPickDialog() { - final int CHOICE_TAKE = 0; - final int CHOICE_PICK = 1; - CharSequence[] choices = new CharSequence[2]; - choices[CHOICE_TAKE] = getString(R.string.action_photo_take); - choices[CHOICE_PICK] = getString(R.string.action_photo_pick); - DialogInterface.OnClickListener listener = (dialog, which) -> { - switch (which) { - case CHOICE_TAKE: { - initiateCameraApp(); - break; - } - case CHOICE_PICK: { - onMediaPick(); - break; - } - } - }; - AlertDialog dialog = new AlertDialog.Builder(this) - .setItems(choices, listener) - .create(); - dialog.show(); - } - - private void onMediaPick() { - if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) - != PackageManager.PERMISSION_GRANTED) { - ActivityCompat.requestPermissions(this, - new String[]{Manifest.permission.READ_EXTERNAL_STORAGE}, - PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE); - } else { - initiateMediaPicking(); - } + enableButtons(); } @Override @@ -1109,6 +947,8 @@ public final class ComposeActivity extends BaseActivity } private void initiateCameraApp() { + addMediaBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED); + // We don't need to ask for permission in this case, because the used calls require // android.permission.WRITE_EXTERNAL_STORAGE only on SDKs *older* than Kitkat, which was // way before permission dialogues have been introduced. @@ -1123,7 +963,7 @@ public final class ComposeActivity extends BaseActivity // Continue only if the File was successfully created if (photoFile != null) { photoUploadUri = FileProvider.getUriForFile(this, - "com.keylesspalace.tusky.fileprovider", + BuildConfig.APPLICATION_ID+".fileprovider", photoFile); intent.putExtra(MediaStore.EXTRA_OUTPUT, photoUploadUri); startActivityForResult(intent, MEDIA_TAKE_PHOTO_RESULT); @@ -1132,7 +972,12 @@ public final class ComposeActivity extends BaseActivity } private void initiateMediaPicking() { - Intent intent = new Intent(Intent.ACTION_GET_CONTENT); + Intent intent; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); + } else { + intent = new Intent(Intent.ACTION_GET_CONTENT); + } intent.addCategory(Intent.CATEGORY_OPENABLE); if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) { intent.setType("image/* video/*"); @@ -1147,7 +992,7 @@ public final class ComposeActivity extends BaseActivity private void enableMediaButtons() { pickButton.setEnabled(true); ThemeUtils.setDrawableTint(this, pickButton.getDrawable(), - R.attr.compose_media_button_tint); + android.R.attr.textColorTertiary); } private void disableMediaButtons() { @@ -1178,12 +1023,6 @@ public final class ComposeActivity extends BaseActivity mediaQueued.add(item); int queuedCount = mediaQueued.size(); if (queuedCount == 1) { - /* The media preview bar is actually not inset in the EditText, it just overlays it and - * is aligned to the bottom. But, so that text doesn't get hidden under it, extra - * padding is added at the bottom of the EditText. */ - int totalHeight = side + margin + marginBottom; - textEditor.setPadding(textEditor.getPaddingLeft(), textEditor.getPaddingTop(), - textEditor.getPaddingRight(), totalHeight); // If there's one video in the queue it is full, so disable the button to queue more. if (item.type == QueuedMedia.Type.VIDEO) { disableMediaButtons(); @@ -1192,9 +1031,9 @@ public final class ComposeActivity extends BaseActivity // Limit the total media attachments, also. disableMediaButtons(); } - if (queuedCount >= 1) { - showMarkSensitive(true); - } + + updateHideMediaToggle(); + if (item.readyStage != QueuedMedia.ReadyStage.UPLOADED) { waitForMediaLatch.countUp(); if (mediaSize > STATUS_MEDIA_SIZE_LIMIT && type == QueuedMedia.Type.IMAGE) { @@ -1279,7 +1118,6 @@ public final class ComposeActivity extends BaseActivity Window window = dialog.getWindow(); if (window != null) { - //noinspection ConstantConditions window.setSoftInputMode( WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE); } @@ -1295,13 +1133,9 @@ public final class ComposeActivity extends BaseActivity mediaPreviewBar.removeView(item.preview); mediaQueued.remove(item); if (mediaQueued.size() == 0) { - showMarkSensitive(false); - /* If there are no image previews to show, the extra padding that was added to the - * EditText can be removed so there isn't unnecessary empty space. */ - textEditor.setPadding(textEditor.getPaddingLeft(), textEditor.getPaddingTop(), - textEditor.getPaddingRight(), 0); + updateHideMediaToggle(); } - textEditor.setText(removeUrlFromEditable(textEditor.getEditableText(), item.uploadUrl)); + enableMediaButtons(); cancelReadyingMedia(item); } @@ -1314,19 +1148,6 @@ public final class ComposeActivity extends BaseActivity } } - private static Editable removeUrlFromEditable(Editable editable, @Nullable URLSpan urlSpan) { - if (urlSpan == null) { - return editable; - } - SpannableStringBuilder builder = new SpannableStringBuilder(editable); - int start = builder.getSpanStart(urlSpan); - int end = builder.getSpanEnd(urlSpan); - if (start != -1 && end != -1) { - builder.delete(start, end); - } - return builder; - } - private void downsizeMedia(final QueuedMedia item) { item.readyStage = QueuedMedia.ReadyStage.DOWNSIZING; @@ -1428,19 +1249,6 @@ public final class ComposeActivity extends BaseActivity item.preview.setProgress(-1); item.readyStage = QueuedMedia.ReadyStage.UPLOADED; - /* Add the upload URL to the text field. Also, keep a reference to the span so if the user - * chooses to remove the media, the URL is also automatically removed. */ - item.uploadUrl = new URLSpan(media.getTextUrl()); - int end = 1 + media.getTextUrl().length(); - SpannableStringBuilder builder = new SpannableStringBuilder(); - builder.append(' '); - builder.append(media.getTextUrl()); - builder.setSpan(item.uploadUrl, 1, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - int cursorStart = textEditor.getSelectionStart(); - int cursorEnd = textEditor.getSelectionEnd(); - textEditor.append(builder); - textEditor.setSelection(cursorStart, cursorEnd); - waitForMediaLatch.countDown(); } @@ -1471,10 +1279,15 @@ public final class ComposeActivity extends BaseActivity } @Override - protected void onActivityResult(int requestCode, int resultCode, Intent data) { - super.onActivityResult(requestCode, resultCode, data); - if (resultCode == RESULT_OK && requestCode == MEDIA_PICK_RESULT && data != null) { - Uri uri = data.getData(); + protected void onActivityResult(int requestCode, int resultCode, Intent intent) { + super.onActivityResult(requestCode, resultCode, intent); + if (resultCode == RESULT_OK && requestCode == MEDIA_PICK_RESULT && intent != null) { + Uri uri = intent.getData(); + if (uri != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + // this is necessary so the SendTootService can access the uri later + final int takeFlags = intent.getFlags() & Intent.FLAG_GRANT_READ_URI_PERMISSION; + getContentResolver().takePersistableUriPermission(uri, takeFlags); + } long mediaSize = MediaUtils.getMediaSize(getContentResolver(), uri); pickMedia(uri, mediaSize); } else if (resultCode == RESULT_OK && requestCode == MEDIA_TAKE_PHOTO_RESULT) { @@ -1531,29 +1344,19 @@ public final class ComposeActivity extends BaseActivity } } - private void showMarkSensitive(boolean show) { - showMarkSensitive = show; - - if (!showMarkSensitive) { - statusMarkSensitive = false; - ThemeUtils.setDrawableTint(this, hideMediaToggle.getDrawable(), - R.attr.compose_hide_media_button_color); - } - - if (show) { - hideMediaToggle.setVisibility(View.VISIBLE); - } else { - hideMediaToggle.setVisibility(View.GONE); - } - } - private void showContentWarning(boolean show) { statusHideText = show; + TransitionManager.beginDelayedTransition((ViewGroup)contentWarningBar.getParent()); if (show) { + statusMarkSensitive = true; contentWarningBar.setVisibility(View.VISIBLE); + contentWarningButton.setTextColor(ContextCompat.getColor(this, R.color.primary)); } else { contentWarningBar.setVisibility(View.GONE); + contentWarningButton.setTextColor(ThemeUtils.getColor(this, android.R.attr.textColorTertiary)); } + updateHideMediaToggle(); + } @Override @@ -1578,15 +1381,33 @@ public final class ComposeActivity extends BaseActivity || !TextUtils.isEmpty(contentWarningEditor.getText()) || !mediaQueued.isEmpty()) { new AlertDialog.Builder(this) - .setTitle("Close the toot without saving?") - .setPositiveButton(android.R.string.yes, (d, w) -> finish()) - .setNegativeButton(android.R.string.no, null) + .setMessage(R.string.compose_save_draft) + .setPositiveButton(R.string.action_save, (d, w) -> saveDraftAndFinish()) + .setNegativeButton(R.string.action_delete, (d, w) -> finish()) .show(); } else { finish(); } } + private void saveDraftAndFinish() { + ArrayList mediaUris = new ArrayList<>(); + for (QueuedMedia item : mediaQueued) { + mediaUris.add(item.uri.toString()); + } + + saveTootHelper.saveToot(textEditor.getText().toString(), + contentWarningEditor.getText().toString(), + getIntent().getStringExtra("saved_json_urls"), + mediaUris, + savedTootUid, + inReplyToId, + getIntent().getStringExtra(REPLYING_STATUS_CONTENT_EXTRA), + getIntent().getStringExtra(REPLYING_STATUS_AUTHOR_USERNAME_EXTRA), + statusVisibility); + finish(); + } + @Override public List searchAccounts(String mention) { ArrayList resultList = new ArrayList<>(); @@ -1603,13 +1424,17 @@ public final class ComposeActivity extends BaseActivity return resultList; } - private static final class QueuedMedia { + @Override + public void onEmojiSelected(@NotNull String shortcode) { + textEditor.getText().insert(textEditor.getSelectionStart(), ":"+shortcode+": "); + } + + public static final class QueuedMedia { Type type; ProgressImageView preview; Uri uri; String id; Call uploadRequest; - URLSpan uploadUrl; ReadyStage readyStage; byte[] content; long mediaSize; @@ -1624,7 +1449,7 @@ public final class ComposeActivity extends BaseActivity this.description = description; } - enum Type { + public enum Type { IMAGE, VIDEO } @@ -1687,7 +1512,6 @@ public final class ComposeActivity extends BaseActivity } } - @SuppressWarnings("WeakerAccess") public static final class IntentBuilder { @Nullable private Integer savedTootUid; diff --git a/app/src/main/java/com/keylesspalace/tusky/MainActivity.java b/app/src/main/java/com/keylesspalace/tusky/MainActivity.java index bf45bd968..b24517a61 100644 --- a/app/src/main/java/com/keylesspalace/tusky/MainActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/MainActivity.java @@ -29,7 +29,6 @@ import android.support.design.widget.TabLayout; import android.support.graphics.drawable.VectorDrawableCompat; import android.support.v4.app.Fragment; import android.support.v4.content.ContextCompat; -import android.support.v4.content.LocalBroadcastManager; import android.support.v4.view.ViewPager; import android.support.v7.app.AlertDialog; import android.util.Log; @@ -43,7 +42,6 @@ import com.keylesspalace.tusky.entity.Account; import com.keylesspalace.tusky.interfaces.ActionButtonActivity; import com.keylesspalace.tusky.network.MastodonApi; import com.keylesspalace.tusky.pager.TimelinePagerAdapter; -import com.keylesspalace.tusky.receiver.TimelineReceiver; import com.keylesspalace.tusky.util.NotificationHelper; import com.keylesspalace.tusky.util.ThemeUtils; import com.mikepenz.google_material_typeface_library.GoogleMaterial; @@ -242,16 +240,6 @@ public class MainActivity extends BaseActivity implements ActionButtonActivity, } - @Override - protected void onActivityResult(int requestCode, int resultCode, Intent data) { - if (requestCode == COMPOSE_RESULT && resultCode == ComposeActivity.RESULT_OK) { - Intent intent = new Intent(TimelineReceiver.Types.STATUS_COMPOSED); - LocalBroadcastManager.getInstance(getApplicationContext()) - .sendBroadcast(intent); - } - super.onActivityResult(requestCode, resultCode, data); - } - @Override public void onBackPressed() { if (drawer != null && drawer.isDrawerOpen()) { diff --git a/app/src/main/java/com/keylesspalace/tusky/SavedTootActivity.java b/app/src/main/java/com/keylesspalace/tusky/SavedTootActivity.java index cd1d60573..d0b2dd615 100644 --- a/app/src/main/java/com/keylesspalace/tusky/SavedTootActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/SavedTootActivity.java @@ -15,27 +15,29 @@ package com.keylesspalace.tusky; +import android.content.BroadcastReceiver; +import android.content.Context; import android.content.Intent; +import android.content.IntentFilter; import android.graphics.drawable.Drawable; -import android.net.Uri; import android.os.AsyncTask; import android.os.Bundle; import android.support.annotation.Nullable; +import android.support.v4.content.LocalBroadcastManager; import android.support.v7.app.ActionBar; import android.support.v7.widget.DividerItemDecoration; import android.support.v7.widget.LinearLayoutManager; import android.support.v7.widget.RecyclerView; import android.support.v7.widget.Toolbar; -import android.util.Log; import android.view.MenuItem; import android.view.View; import android.widget.TextView; -import com.google.gson.Gson; -import com.google.gson.reflect.TypeToken; import com.keylesspalace.tusky.adapter.SavedTootAdapter; import com.keylesspalace.tusky.db.TootDao; import com.keylesspalace.tusky.db.TootEntity; +import com.keylesspalace.tusky.receiver.TimelineReceiver; +import com.keylesspalace.tusky.util.SaveTootHelper; import com.keylesspalace.tusky.util.ThemeUtils; import java.lang.ref.WeakReference; @@ -43,11 +45,12 @@ import java.util.ArrayList; import java.util.List; public class SavedTootActivity extends BaseActivity implements SavedTootAdapter.SavedTootAction { - private static final String TAG = "SavedTootActivity"; // logging tag // dao private static TootDao tootDao = TuskyApplication.getDB().tootDao(); + private SaveTootHelper saveTootHelper; + // ui private SavedTootAdapter adapter; private TextView noContent; @@ -55,9 +58,27 @@ public class SavedTootActivity extends BaseActivity implements SavedTootAdapter. private List toots = new ArrayList<>(); @Nullable private AsyncTask asyncTask; + private BroadcastReceiver broadcastReceiver; + @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); + + saveTootHelper = new SaveTootHelper(tootDao, this); + + broadcastReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + fetchToots(); + } + }; + + IntentFilter intentFilter = new IntentFilter(); + intentFilter.addAction(TimelineReceiver.Types.STATUS_COMPOSED); + + LocalBroadcastManager.getInstance(this) + .registerReceiver(broadcastReceiver, intentFilter); + setContentView(R.layout.activity_saved_toot); Toolbar toolbar = findViewById(R.id.toolbar); @@ -96,6 +117,12 @@ public class SavedTootActivity extends BaseActivity implements SavedTootAdapter. if (asyncTask != null) asyncTask.cancel(true); } + @Override + protected void onDestroy() { + super.onDestroy(); + LocalBroadcastManager.getInstance(this).unregisterReceiver(broadcastReceiver); + } + @Override public boolean onOptionsItemSelected(MenuItem item) { switch (item.getItemId()) { @@ -122,19 +149,9 @@ public class SavedTootActivity extends BaseActivity implements SavedTootAdapter. @Override public void delete(int position, TootEntity item) { - // Delete any media files associated with the status. - ArrayList uris = new Gson().fromJson(item.getUrls(), - new TypeToken>() {}.getType()); - if (uris != null) { - for (String uriString : uris) { - Uri uri = Uri.parse(uriString); - if (getContentResolver().delete(uri, null, null) == 0) { - Log.e(TAG, String.format("Did not delete file %s.", uriString)); - } - } - } - // update DB - tootDao.delete(item.getUid()); + + saveTootHelper.deleteDraft(item); + toots.remove(position); // update adapter if (adapter != null) { diff --git a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.java b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.java index db04465bf..4d6089c20 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.java +++ b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.java @@ -17,6 +17,7 @@ package com.keylesspalace.tusky; import android.app.Activity; import android.app.Application; +import android.app.Service; import android.app.UiModeManager; import android.arch.persistence.room.Room; import android.content.Context; @@ -39,10 +40,11 @@ import javax.inject.Inject; import dagger.android.AndroidInjector; import dagger.android.DispatchingAndroidInjector; import dagger.android.HasActivityInjector; +import dagger.android.HasServiceInjector; import okhttp3.Cache; import okhttp3.OkHttpClient; -public class TuskyApplication extends Application implements HasActivityInjector { +public class TuskyApplication extends Application implements HasActivityInjector, HasServiceInjector { public static final String APP_THEME_DEFAULT = ThemeUtils.THEME_NIGHT; private static AppDatabase db; @@ -50,6 +52,8 @@ public class TuskyApplication extends Application implements HasActivityInjector @Inject DispatchingAndroidInjector dispatchingAndroidInjector; @Inject + DispatchingAndroidInjector dispatchingServiceInjector; + @Inject NotificationPullJobCreator notificationPullJobCreator; public static AppDatabase getDB() { @@ -75,7 +79,7 @@ public class TuskyApplication extends Application implements HasActivityInjector db = Room.databaseBuilder(getApplicationContext(), AppDatabase.class, "tuskyDB") .allowMainThreadQueries() - .addMigrations(AppDatabase.MIGRATION_2_3, AppDatabase.MIGRATION_3_4, AppDatabase.MIGRATION_4_5) + .addMigrations(AppDatabase.MIGRATION_2_3, AppDatabase.MIGRATION_3_4, AppDatabase.MIGRATION_4_5, AppDatabase.MIGRATION_5_6) .build(); accountManager = new AccountManager(db); serviceLocator = new ServiceLocator() { @@ -90,7 +94,7 @@ public class TuskyApplication extends Application implements HasActivityInjector } }; - AppInjector.INSTANCE.init(this); + initAppInjector(); initPicasso(); JobManager.create(this).addJobCreator(notificationPullJobCreator); @@ -100,6 +104,10 @@ public class TuskyApplication extends Application implements HasActivityInjector AppCompatDelegate.setCompatVectorFromResourcesEnabled(true); } + protected void initAppInjector() { + AppInjector.INSTANCE.init(this); + } + protected void initPicasso() { // Initialize Picasso configuration Picasso.Builder builder = new Picasso.Builder(this); @@ -128,6 +136,11 @@ public class TuskyApplication extends Application implements HasActivityInjector return dispatchingAndroidInjector; } + @Override + public AndroidInjector serviceInjector() { + return dispatchingServiceInjector; + } + public interface ServiceLocator { T get(Class clazz); } diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/EmojiAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/EmojiAdapter.kt new file mode 100644 index 000000000..61e131b4e --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/EmojiAdapter.kt @@ -0,0 +1,55 @@ +/* Copyright 2018 Conny Duck + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.adapter + +import android.support.v7.widget.RecyclerView +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.ImageView +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.entity.Emoji +import com.squareup.picasso.Picasso + +class EmojiAdapter(private val emojiList: List, private val onEmojiSelectedListener: OnEmojiSelectedListener) : RecyclerView.Adapter() { + + override fun getItemCount(): Int { + return emojiList.size + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): EmojiAdapter.EmojiHolder { + + val view = LayoutInflater.from(parent.context).inflate(R.layout.item_emoji_button, parent, false) as ImageView + return EmojiHolder(view) + + } + + override fun onBindViewHolder(viewHolder: EmojiAdapter.EmojiHolder, position: Int) { + Picasso.with(viewHolder.emojiImageView.context) + .load(emojiList[position].url) + .into(viewHolder.emojiImageView) + + viewHolder.emojiImageView.setOnClickListener { + onEmojiSelectedListener.onEmojiSelected(emojiList[position].shortcode) + } + } + + class EmojiHolder(val emojiImageView: ImageView) : RecyclerView.ViewHolder(emojiImageView) + +} + +interface OnEmojiSelectedListener { + fun onEmojiSelected(shortcode: String) +} diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java index 694230124..3f727c8fc 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java @@ -37,8 +37,8 @@ import android.widget.TextView; import android.widget.ToggleButton; import com.keylesspalace.tusky.R; +import com.keylesspalace.tusky.entity.Emoji; import com.keylesspalace.tusky.entity.Notification; -import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.interfaces.LinkListener; import com.keylesspalace.tusky.interfaces.StatusActionListener; import com.keylesspalace.tusky.util.CustomEmojiHelper; @@ -485,7 +485,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter { } Spanned content = statusViewData.getContent(); - List emojis = statusViewData.getEmojis(); + List emojis = statusViewData.getEmojis(); Spanned emojifiedText = CustomEmojiHelper.emojifyText(content, emojis, statusContent); diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java index 6c51e74d1..4f2bedbf3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java @@ -19,6 +19,7 @@ import android.widget.ToggleButton; import com.keylesspalace.tusky.R; import com.keylesspalace.tusky.entity.Attachment; +import com.keylesspalace.tusky.entity.Emoji; import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.interfaces.StatusActionListener; import com.keylesspalace.tusky.util.CustomEmojiHelper; @@ -106,7 +107,7 @@ abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { username.setText(usernameText); } - private void setContent(Spanned content, Status.Mention[] mentions, List emojis, + private void setContent(Spanned content, Status.Mention[] mentions, List emojis, StatusActionListener listener) { Spanned emojifiedText = CustomEmojiHelper.emojifyText(content, emojis, this.content); @@ -384,7 +385,7 @@ abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { sensitiveMediaShow.setVisibility(View.GONE); } - private void setSpoilerText(String spoilerText, List emojis, + private void setSpoilerText(String spoilerText, List emojis, final boolean expanded, final StatusActionListener listener) { CharSequence emojiSpoiler = CustomEmojiHelper.emojifyString(spoilerText, emojis, contentWarningDescription); diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt b/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt index e295ee1fd..7e6c5c736 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt @@ -15,9 +15,7 @@ package com.keylesspalace.tusky.db -import android.arch.persistence.room.Database import android.util.Log -import com.keylesspalace.tusky.TuskyApplication import com.keylesspalace.tusky.entity.Account /** diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java index 54bf45946..e6e50fda8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java +++ b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java @@ -25,11 +25,12 @@ import android.support.annotation.NonNull; * DB version & declare DAO */ -@Database(entities = {TootEntity.class, AccountEntity.class}, version = 5, exportSchema = false) +@Database(entities = {TootEntity.class, AccountEntity.class, EmojiListEntity.class}, version = 6, exportSchema = false) public abstract class AppDatabase extends RoomDatabase { public abstract TootDao tootDao(); public abstract AccountDao accountDao(); + public abstract EmojiListDao emojiListDao(); public static final Migration MIGRATION_2_3 = new Migration(2, 3) { @Override @@ -74,4 +75,11 @@ public abstract class AppDatabase extends RoomDatabase { database.execSQL("CREATE UNIQUE INDEX `index_AccountEntity_domain_accountId` ON `AccountEntity` (`domain`, `accountId`)"); } }; + + public static final Migration MIGRATION_5_6 = new Migration(5, 6) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + database.execSQL("CREATE TABLE IF NOT EXISTS `EmojiListEntity` (`instance` TEXT NOT NULL, `emojiList` TEXT NOT NULL, PRIMARY KEY(`instance`))"); + } + }; } \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/db/EmojiListDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/EmojiListDao.kt new file mode 100644 index 000000000..8630b1656 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/db/EmojiListDao.kt @@ -0,0 +1,31 @@ +/* Copyright 2018 Conny Duck + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.db + +import android.arch.persistence.room.Dao +import android.arch.persistence.room.Insert +import android.arch.persistence.room.OnConflictStrategy +import android.arch.persistence.room.Query + +@Dao +interface EmojiListDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insertOrReplace(emojiList: EmojiListEntity) + + @Query("SELECT * FROM EmojiListEntity WHERE instance = :instance LIMIT 1") + fun loadEmojisForInstance(instance: String): EmojiListEntity? + +} diff --git a/app/src/main/java/com/keylesspalace/tusky/db/EmojiListEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/EmojiListEntity.kt new file mode 100644 index 000000000..ac87e8bf6 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/db/EmojiListEntity.kt @@ -0,0 +1,43 @@ +/* Copyright 2018 Conny Duck + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.db + +import android.arch.persistence.room.Entity +import android.arch.persistence.room.PrimaryKey +import android.arch.persistence.room.TypeConverter +import android.arch.persistence.room.TypeConverters +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import com.keylesspalace.tusky.entity.Emoji + +@Entity +@TypeConverters(Converters::class) +data class EmojiListEntity(@field:PrimaryKey var instance: String, + val emojiList: List) + + +class Converters { + + @TypeConverter + fun jsonToList(emojiListJson: String): List { + return Gson().fromJson(emojiListJson, object : TypeToken>() {}.type) + } + + @TypeConverter + fun listToJson(emojiList: List): String { + return Gson().toJson(emojiList) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/db/TootDao.java b/app/src/main/java/com/keylesspalace/tusky/db/TootDao.java index bec94622a..d84721630 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/TootDao.java +++ b/app/src/main/java/com/keylesspalace/tusky/db/TootDao.java @@ -38,4 +38,7 @@ public interface TootDao { @Query("DELETE FROM TootEntity WHERE uid = :uid") int delete(int uid); + + @Query("SELECT * FROM TootEntity WHERE uid = :uid") + TootEntity find(int uid); } diff --git a/app/src/main/java/com/keylesspalace/tusky/di/AppComponent.kt b/app/src/main/java/com/keylesspalace/tusky/di/AppComponent.kt index f06186763..b2ad4d12d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/AppComponent.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/AppComponent.kt @@ -31,7 +31,8 @@ import javax.inject.Singleton AppModule::class, NetworkModule::class, AndroidInjectionModule::class, - ActivitiesModule::class + ActivitiesModule::class, + ServicesModule::class ]) interface AppComponent { @Component.Builder diff --git a/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt index cb02adf2a..fdf7d8dfc 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt @@ -21,7 +21,6 @@ import android.content.Context import android.content.SharedPreferences import android.preference.PreferenceManager import android.support.v4.content.LocalBroadcastManager -import com.keylesspalace.tusky.R import com.keylesspalace.tusky.TuskyApplication import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.network.MastodonApi diff --git a/app/src/main/java/com/keylesspalace/tusky/di/ServicesModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/ServicesModule.kt new file mode 100644 index 000000000..f024df6b2 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/di/ServicesModule.kt @@ -0,0 +1,26 @@ +/* Copyright 2018 Conny Duck + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.di + +import com.keylesspalace.tusky.service.SendTootService +import dagger.Module +import dagger.android.ContributesAndroidInjector + +@Module +abstract class ServicesModule { + @ContributesAndroidInjector + abstract fun contributeMyService(): SendTootService +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Emoji.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Emoji.kt new file mode 100644 index 000000000..1c0cb4dc0 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Emoji.kt @@ -0,0 +1,25 @@ +/* Copyright 2018 Conny Duck + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.entity + +import android.os.Parcelable +import kotlinx.android.parcel.Parcelize + +@Parcelize +data class Emoji( + val shortcode: String, + val url: String +) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt index 8e687e49a..09096e5be 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt @@ -131,11 +131,6 @@ data class Status( var website: String? = null } - class Emoji { - val shortcode: String? = null - val url: String? = null - } - companion object { const val MAX_MEDIA_ATTACHMENTS = 4 } diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/AccountListFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/AccountListFragment.java index 862c11095..01d331281 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/AccountListFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/AccountListFragment.java @@ -31,7 +31,6 @@ import android.view.View; import android.view.ViewGroup; import com.keylesspalace.tusky.AccountActivity; -import com.keylesspalace.tusky.BaseActivity; import com.keylesspalace.tusky.R; import com.keylesspalace.tusky.adapter.AccountAdapter; import com.keylesspalace.tusky.adapter.BlocksAdapter; diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/ComposeOptionsFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/ComposeOptionsFragment.java deleted file mode 100644 index 6fbbd47d2..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/ComposeOptionsFragment.java +++ /dev/null @@ -1,161 +0,0 @@ -/* 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 . */ - -package com.keylesspalace.tusky.fragment; - -import android.content.Context; -import android.content.res.ColorStateList; -import android.graphics.drawable.Drawable; -import android.os.Build; -import android.os.Bundle; -import android.support.annotation.DrawableRes; -import android.support.annotation.Nullable; -import android.support.design.widget.BottomSheetDialogFragment; -import android.support.graphics.drawable.VectorDrawableCompat; -import android.support.v4.graphics.drawable.DrawableCompat; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.CheckBox; -import android.widget.CompoundButton; -import android.widget.RadioButton; -import android.widget.RadioGroup; - -import com.keylesspalace.tusky.R; -import com.keylesspalace.tusky.entity.Status; -import com.keylesspalace.tusky.util.ThemeUtils; - -public class ComposeOptionsFragment extends BottomSheetDialogFragment { - public interface Listener { - void onVisibilityChanged(Status.Visibility visibility); - void onContentWarningChanged(boolean hideText); - } - - private RadioGroup radio; - private CheckBox hideText; - private Listener listener; - - public static ComposeOptionsFragment newInstance(Status.Visibility visibility, boolean hideText) { - Bundle arguments = new Bundle(); - ComposeOptionsFragment fragment = new ComposeOptionsFragment(); - arguments.putInt("visibilityNum", visibility.getNum()); - arguments.putBoolean("hideText", hideText); - fragment.setArguments(arguments); - return fragment; - } - - @Override - public void onAttach(Context context) { - super.onAttach(context); - listener = (Listener) context; - } - - @Nullable - @Override - public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, - @Nullable Bundle savedInstanceState) { - View rootView = inflater.inflate(R.layout.fragment_compose_options, container, false); - - Bundle arguments = getArguments(); - Status.Visibility visibility = Status.Visibility.byNum( - arguments.getInt("visibilityNum", 0) - ); - boolean statusHideText = arguments.getBoolean("hideText"); - - radio = rootView.findViewById(R.id.radio_visibility); - int radioCheckedId = R.id.radio_public; - switch (visibility) { - case PUBLIC: radioCheckedId = R.id.radio_public; break; - case PRIVATE: radioCheckedId = R.id.radio_private; break; - case UNLISTED: radioCheckedId = R.id.radio_unlisted; break; - case DIRECT: radioCheckedId = R.id.radio_direct; break; - } - radio.check(radioCheckedId); - - RadioButton publicButton = rootView.findViewById(R.id.radio_public); - RadioButton unlistedButton = rootView.findViewById(R.id.radio_unlisted); - RadioButton privateButton = rootView.findViewById(R.id.radio_private); - RadioButton directButton = rootView.findViewById(R.id.radio_direct); - setRadioButtonDrawable(getContext(), publicButton, R.drawable.ic_public_24dp); - setRadioButtonDrawable(getContext(), unlistedButton, R.drawable.ic_lock_open_24dp); - setRadioButtonDrawable(getContext(), privateButton, R.drawable.ic_lock_outline_24dp); - setRadioButtonDrawable(getContext(), directButton, R.drawable.ic_email_24dp); - - hideText = rootView.findViewById(R.id.compose_hide_text); - hideText.setChecked(statusHideText); - - return rootView; - } - - @Override - public void onActivityCreated(Bundle savedInstanceState) { - super.onActivityCreated(savedInstanceState); - radio.setOnCheckedChangeListener(new RadioGroup.OnCheckedChangeListener() { - @Override - public void onCheckedChanged(RadioGroup group, int checkedId) { - Status.Visibility visibility; - switch (checkedId) { - default: - case R.id.radio_public: { - visibility = Status.Visibility.PUBLIC; - break; - } - case R.id.radio_unlisted: { - visibility = Status.Visibility.UNLISTED; - break; - } - case R.id.radio_private: { - visibility = Status.Visibility.PRIVATE; - break; - } - case R.id.radio_direct: { - visibility = Status.Visibility.DIRECT; - break; - } - } - listener.onVisibilityChanged(visibility); - } - }); - hideText.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { - @Override - public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { - listener.onContentWarningChanged(isChecked); - } - }); - } - - private static void setRadioButtonDrawable(Context context, RadioButton button, - @DrawableRes int id) { - ColorStateList list = new ColorStateList(new int[][] { - new int[] { -android.R.attr.state_checked }, - new int[] { android.R.attr.state_checked } - }, new int[] { - ThemeUtils.getColor(context, R.attr.compose_image_button_tint), - ThemeUtils.getColor(context, R.attr.colorAccent) - }); - Drawable drawable = VectorDrawableCompat.create(context.getResources(), id, - context.getTheme()); - if (drawable == null) { - return; - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - button.setButtonTintList(list); - } else { - drawable = DrawableCompat.wrap(drawable); - DrawableCompat.setTintList(drawable, list); - } - button.setButtonDrawable(drawable); - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java index 8b23caa86..c17a58b2d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java @@ -20,18 +20,14 @@ import android.content.ClipboardManager; import android.content.Context; import android.content.Intent; import android.os.Bundle; -import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.support.v4.app.ActivityOptionsCompat; -import android.support.v4.content.LocalBroadcastManager; import android.support.v4.view.ViewCompat; import android.support.v7.widget.PopupMenu; import android.text.Spanned; -import android.view.MenuItem; import android.view.View; import com.keylesspalace.tusky.AccountActivity; -import com.keylesspalace.tusky.BaseActivity; import com.keylesspalace.tusky.ComposeActivity; import com.keylesspalace.tusky.R; import com.keylesspalace.tusky.ReportActivity; @@ -41,27 +37,16 @@ import com.keylesspalace.tusky.ViewTagActivity; import com.keylesspalace.tusky.ViewThreadActivity; import com.keylesspalace.tusky.ViewVideoActivity; import com.keylesspalace.tusky.db.AccountEntity; -import com.keylesspalace.tusky.di.Injectable; import com.keylesspalace.tusky.db.AccountManager; import com.keylesspalace.tusky.entity.Attachment; -import com.keylesspalace.tusky.entity.Relationship; import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.interfaces.AdapterItemRemover; -import com.keylesspalace.tusky.network.MastodonApi; import com.keylesspalace.tusky.network.TimelineCases; -import com.keylesspalace.tusky.receiver.TimelineReceiver; import com.keylesspalace.tusky.util.HtmlUtils; import java.util.ArrayList; import java.util.List; -import javax.inject.Inject; - -import okhttp3.ResponseBody; -import retrofit2.Call; -import retrofit2.Callback; -import retrofit2.Response; - /* Note from Andrew on Jan. 22, 2017: This class is a design problem for me, so I left it with an * awkward name. TimelineFragment and NotificationFragment have significant overlap but the nature * of that is complicated by how they're coupled with Status and Notification and the corresponding diff --git a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.java b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.java index 06f2aaaa1..74fde5878 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.java +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.java @@ -22,6 +22,7 @@ import com.keylesspalace.tusky.entity.Account; import com.keylesspalace.tusky.entity.AppCredentials; import com.keylesspalace.tusky.entity.Attachment; import com.keylesspalace.tusky.entity.Card; +import com.keylesspalace.tusky.entity.Emoji; import com.keylesspalace.tusky.entity.MastoList; import com.keylesspalace.tusky.entity.Notification; import com.keylesspalace.tusky.entity.Relationship; @@ -101,12 +102,15 @@ public interface MastodonApi { @FormUrlEncoded @POST("api/v1/statuses") Call createStatus( + @Header("Authorization") String auth, + @Header(DOMAIN_HEADER) String domain, @Field("status") String text, @Field("in_reply_to_id") String inReplyToId, @Field("spoiler_text") String warningText, @Field("visibility") String visibility, @Field("sensitive") Boolean sensitive, - @Field("media_ids[]") List mediaIds); + @Field("media_ids[]") List mediaIds, + @Header("Idempotency-Key") String idempotencyKey); @GET("api/v1/statuses/{id}") Call status(@Path("id") String statusId); @GET("api/v1/statuses/{id}/context") @@ -263,4 +267,7 @@ public interface MastodonApi { @GET("/api/v1/lists") Call> getLists(); + + @GET("/api/v1/custom_emojis") + Call> getCustomEmojis(); } diff --git a/app/src/main/java/com/keylesspalace/tusky/service/SendTootService.kt b/app/src/main/java/com/keylesspalace/tusky/service/SendTootService.kt new file mode 100644 index 000000000..cf919219c --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/service/SendTootService.kt @@ -0,0 +1,329 @@ +package com.keylesspalace.tusky.service + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.IBinder +import android.os.Parcelable +import android.support.v4.app.NotificationCompat +import android.support.v4.app.ServiceCompat +import android.support.v4.content.ContextCompat +import android.support.v4.content.LocalBroadcastManager +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.TuskyApplication +import com.keylesspalace.tusky.db.AccountEntity +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.di.Injectable +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.receiver.TimelineReceiver +import com.keylesspalace.tusky.util.SaveTootHelper +import com.keylesspalace.tusky.util.StringUtils +import dagger.android.AndroidInjection +import kotlinx.android.parcel.Parcelize +import retrofit2.Call +import retrofit2.Callback +import retrofit2.Response +import java.util.* +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject + +class SendTootService: Service(), Injectable { + + @Inject + lateinit var mastodonApi: MastodonApi + @Inject + lateinit var accountManager: AccountManager + + private lateinit var saveTootHelper: SaveTootHelper + + private val tootsToSend = ConcurrentHashMap() + private val sendCalls = ConcurrentHashMap>() + + private val timer = Timer() + + + override fun onCreate() { + AndroidInjection.inject(this) + saveTootHelper = SaveTootHelper(TuskyApplication.getDB().tootDao(), this) + super.onCreate() + } + + override fun onBind(intent: Intent): IBinder? { + return null + } + + override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { + + if(intent.hasExtra(KEY_TOOT)) { + + val tootToSend = intent.getParcelableExtra(KEY_TOOT) + + if (tootToSend == null) { + throw IllegalStateException("SendTootService started without $KEY_TOOT extra") + } + + val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel(CHANNEL_ID, getString(R.string.send_toot_notification_channel_name), NotificationManager.IMPORTANCE_LOW) + notificationManager.createNotificationChannel(channel) + + } + + var notificationText = tootToSend.warningText + if (notificationText.isBlank()) { + notificationText = tootToSend.text + } + + val builder = NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notify) + .setContentTitle(getString(R.string.send_toot_notification_title)) + .setContentText(notificationText) + .setProgress(1, 0, true) + .setOngoing(true) + .setColor(ContextCompat.getColor(this, R.color.primary)) + .addAction(0, getString(android.R.string.cancel), cancelSendingIntent(notificationId)) + + if(tootsToSend.size == 0 || Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_DETACH) + startForeground(notificationId, builder.build()) + } else { + notificationManager.notify(notificationId, builder.build()) + } + + tootsToSend[notificationId] = tootToSend + sendToot(notificationId) + + notificationId-- + + } else { + + if(intent.hasExtra(KEY_CANCEL)) { + cancelSending(intent.getIntExtra(KEY_CANCEL, 0)) + stopSelf(intent.getIntExtra(KEY_CANCEL, 0)) + } + + } + + return START_NOT_STICKY + + } + + private fun sendToot(tootId: Int) { + + // when tootToSend == null, sending has been canceled + val tootToSend = tootsToSend[tootId] ?: return + + // when account == null, user has logged out, cancel sending + val account = accountManager.getAccountById(tootToSend.accountId) + + if(account == null) { + tootsToSend.remove(tootId) + return + } + + tootToSend.retries++ + + val sendCall = mastodonApi.createStatus( + "Bearer " + account.accessToken, + account.domain, + tootToSend.text, + tootToSend.inReplyToId, + tootToSend.warningText, + tootToSend.visibility, + tootToSend.sensitive, + tootToSend.mediaIds, + tootToSend.idempotencyKey + ) + + + sendCalls[tootId] = sendCall + + val callback = object: Callback { + override fun onResponse(call: Call, response: Response) { + + tootsToSend.remove(tootId) + val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + if (response.isSuccessful) { + + val intent = Intent(TimelineReceiver.Types.STATUS_COMPOSED) + LocalBroadcastManager.getInstance(applicationContext).sendBroadcast(intent) + + // If the status was loaded from a draft, delete the draft and associated media files. + if(tootToSend.savedTootUid != 0) { + saveTootHelper.deleteDraft(tootToSend.savedTootUid) + } + + if (tootsToSend.isEmpty()) { + ServiceCompat.stopForeground(this@SendTootService, ServiceCompat.STOP_FOREGROUND_REMOVE) + stopSelf() + } + + notificationManager.cancel(tootId) + + } else { + // the server refused to accept the toot, save toot & show error message + saveTootToDrafts(tootToSend) + + val builder = NotificationCompat.Builder(this@SendTootService, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notify) + .setContentTitle(getString(R.string.send_toot_notification_error_title)) + .setContentText(getString(R.string.send_toot_notification_saved_content)) + .setColor(ContextCompat.getColor(this@SendTootService, R.color.primary)) + + notificationManager.notify(tootId, builder.build()) + + if (tootsToSend.isEmpty()) { + ServiceCompat.stopForeground(this@SendTootService, ServiceCompat.STOP_FOREGROUND_DETACH) + stopSelf() + } + + } + } + + override fun onFailure(call: Call, t: Throwable) { + var backoff = 1000L*tootToSend.retries + if (backoff > MAX_RETRY_INTERVAL) { + backoff = MAX_RETRY_INTERVAL + } + + timer.schedule(object : TimerTask() { + override fun run() { + sendToot(tootId) + } + }, backoff) + } + } + + sendCall.enqueue(callback) + + } + + private fun cancelSending(tootId: Int) { + val tootToCancel = tootsToSend.remove(tootId) + if(tootToCancel != null) { + val sendCall = sendCalls.remove(tootId) + sendCall?.cancel() + + saveTootToDrafts(tootToCancel) + + val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + val builder = NotificationCompat.Builder(this@SendTootService, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notify) + .setContentTitle(getString(R.string.send_toot_notification_cancel_title)) + .setContentText(getString(R.string.send_toot_notification_saved_content)) + .setColor(ContextCompat.getColor(this@SendTootService, R.color.primary)) + + notificationManager.notify(tootId, builder.build()) + + timer.schedule(object : TimerTask() { + override fun run() { + notificationManager.cancel(tootId) + } + }, 5000) + + if (tootsToSend.isEmpty()) { + ServiceCompat.stopForeground(this@SendTootService, ServiceCompat.STOP_FOREGROUND_DETACH) + stopSelf() + } + + } + } + + private fun saveTootToDrafts(toot: TootToSend) { + + saveTootHelper.saveToot(toot.text, + toot.warningText, + toot.savedJsonUrls, + toot.mediaUris, + toot.savedTootUid, + toot.inReplyToId, + toot.replyingStatusContent, + toot.replyingStatusAuthorUsername, + Status.Visibility.byString(toot.visibility)) + } + + private fun cancelSendingIntent(tootId: Int): PendingIntent { + + val intent = Intent(this, SendTootService::class.java) + + intent.putExtra(KEY_CANCEL, tootId) + + return PendingIntent.getService(this, tootId, intent, PendingIntent.FLAG_UPDATE_CURRENT) + } + + + companion object { + + private const val KEY_TOOT = "toot" + private const val KEY_CANCEL = "cancel_id" + private const val CHANNEL_ID = "send_toots" + + private const val MAX_RETRY_INTERVAL = 60*1000L // 1 minute + + private var notificationId = -1 // use negative ids to not clash with other notis + + @JvmStatic + fun sendTootIntent(context: Context, + text: String, + warningText: String, + visibility: Status.Visibility, + sensitive: Boolean, + mediaIds: List, + mediaUris: List, + inReplyToId: String?, + replyingStatusContent: String?, + replyingStatusAuthorUsername: String?, + savedJsonUrls: String?, + account: AccountEntity, + savedTootUid: Int + ): Intent { + val intent = Intent(context, SendTootService::class.java) + + val idempotencyKey = StringUtils.randomAlphanumericString(16) + + val tootToSend = TootToSend(text, + warningText, + visibility.serverString(), + sensitive, + mediaIds, + mediaUris, + inReplyToId, + replyingStatusContent, + replyingStatusAuthorUsername, + savedJsonUrls, + account.id, + savedTootUid, + idempotencyKey, + 0) + + intent.putExtra(KEY_TOOT, tootToSend) + + return intent + } + + } +} + +@Parcelize +data class TootToSend(val text: String, + val warningText: String, + val visibility: String, + val sensitive: Boolean, + val mediaIds: List, + val mediaUris: List, + val inReplyToId: String?, + val replyingStatusContent: String?, + val replyingStatusAuthorUsername: String?, + val savedJsonUrls: String?, + val accountId: Long, + val savedTootUid: Int, + val idempotencyKey: String, + var retries: Int): Parcelable diff --git a/app/src/main/java/com/keylesspalace/tusky/util/CustomEmojiHelper.java b/app/src/main/java/com/keylesspalace/tusky/util/CustomEmojiHelper.java index 03208429f..5c33991bd 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/CustomEmojiHelper.java +++ b/app/src/main/java/com/keylesspalace/tusky/util/CustomEmojiHelper.java @@ -28,7 +28,7 @@ import android.text.SpannedString; import android.text.style.ReplacementSpan; import android.widget.TextView; -import com.keylesspalace.tusky.entity.Status; +import com.keylesspalace.tusky.entity.Emoji; import com.squareup.picasso.Picasso; import com.squareup.picasso.Target; @@ -46,12 +46,12 @@ public class CustomEmojiHelper { * @param textView a reference to the textView the emojis will be shown in * @return the text with the shortcodes replaced by EmojiSpans */ - public static Spanned emojifyText(Spanned text, List emojis, final TextView textView) { + public static Spanned emojifyText(Spanned text, List emojis, final TextView textView) { if (!emojis.isEmpty()) { SpannableStringBuilder builder = new SpannableStringBuilder(text); - for (Status.Emoji emoji : emojis) { + for (Emoji emoji : emojis) { CharSequence pattern = new StringBuilder(":").append(emoji.getShortcode()).append(':'); Matcher matcher = Pattern.compile(pattern.toString()).matcher(text); while (matcher.find()) { @@ -71,7 +71,7 @@ public class CustomEmojiHelper { return text; } - public static Spanned emojifyString(String string, List emojis, final TextView textView) { + public static Spanned emojifyString(String string, List emojis, final TextView textView) { return emojifyText(new SpannedString(string), emojis, textView); } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/IOUtils.java b/app/src/main/java/com/keylesspalace/tusky/util/IOUtils.java index 2b17eeeb1..36321f6f2 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/IOUtils.java +++ b/app/src/main/java/com/keylesspalace/tusky/util/IOUtils.java @@ -15,14 +15,22 @@ package com.keylesspalace.tusky.util; +import android.content.ContentResolver; +import android.net.Uri; import android.support.annotation.Nullable; +import java.io.Closeable; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; -import java.io.OutputStream; public class IOUtils { - public static void closeQuietly(@Nullable InputStream stream) { + + private static final int DEFAULT_BLOCKSIZE = 16384; + + public static void closeQuietly(@Nullable Closeable stream) { try { if (stream != null) { stream.close(); @@ -32,13 +40,32 @@ public class IOUtils { } } - public static void closeQuietly(@Nullable OutputStream stream) { + public static boolean copyToFile(ContentResolver contentResolver, Uri uri, File file) { + InputStream from; + FileOutputStream to; try { - if (stream != null) { - stream.close(); + from = contentResolver.openInputStream(uri); + to = new FileOutputStream(file); + } catch (FileNotFoundException e) { + return false; + } + if (from == null) { + return false; + } + byte[] chunk = new byte[DEFAULT_BLOCKSIZE]; + try { + while (true) { + int bytes = from.read(chunk, 0, chunk.length); + if (bytes < 0) { + break; + } + to.write(chunk, 0, bytes); } } catch (IOException e) { - // intentionally unhandled + return false; } + closeQuietly(from); + closeQuietly(to); + return true; } } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/SaveTootHelper.java b/app/src/main/java/com/keylesspalace/tusky/util/SaveTootHelper.java new file mode 100644 index 000000000..87e12da0f --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/SaveTootHelper.java @@ -0,0 +1,197 @@ +package com.keylesspalace.tusky.util; + +import android.annotation.SuppressLint; +import android.content.ContentResolver; +import android.content.Context; +import android.net.Uri; +import android.os.AsyncTask; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v4.content.FileProvider; +import android.text.TextUtils; +import android.util.Log; +import android.webkit.MimeTypeMap; + +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; +import com.keylesspalace.tusky.BuildConfig; +import com.keylesspalace.tusky.db.TootDao; +import com.keylesspalace.tusky.db.TootEntity; +import com.keylesspalace.tusky.entity.Status; + +import java.io.File; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Locale; + +public final class SaveTootHelper { + + private static final String TAG = "SaveTootHelper"; + + private TootDao tootDao; + private Context context; + + public SaveTootHelper(@NonNull TootDao tootDao, @NonNull Context context) { + this.tootDao = tootDao; + this.context = context; + } + + @SuppressLint("StaticFieldLeak") + public boolean saveToot(@NonNull String content, + @NonNull String contentWarning, + @Nullable String savedJsonUrls, + @NonNull List mediaUris, + int savedTootUid, + @Nullable String inReplyToId, + @Nullable String replyingStatusContent, + @Nullable String replyingStatusAuthorUsername, + @NonNull Status.Visibility statusVisibility) { + + if (TextUtils.isEmpty(content) && mediaUris.isEmpty()) { + return false; + } + + // Get any existing file's URIs. + ArrayList existingUris = null; + if (!TextUtils.isEmpty(savedJsonUrls)) { + existingUris = new Gson().fromJson(savedJsonUrls, + new TypeToken>() { + }.getType()); + } + + String mediaUrlsSerialized = null; + if (!ListUtils.isEmpty(mediaUris)) { + List savedList = saveMedia(mediaUris, existingUris); + if (!ListUtils.isEmpty(savedList)) { + mediaUrlsSerialized = new Gson().toJson(savedList); + if (!ListUtils.isEmpty(existingUris)) { + deleteMedia(setDifference(existingUris, savedList)); + } + } else { + return false; + } + } else if (!ListUtils.isEmpty(existingUris)) { + /* If there were URIs in the previous draft, but they've now been removed, those files + * can be deleted. */ + deleteMedia(existingUris); + } + final TootEntity toot = new TootEntity(savedTootUid, content, mediaUrlsSerialized, contentWarning, + inReplyToId, + replyingStatusContent, + replyingStatusAuthorUsername, + statusVisibility); + + new AsyncTask() { + @Override + protected Void doInBackground(Void... params) { + tootDao.insertOrReplace(toot); + return null; + } + }.execute(); + return true; + } + + public void deleteDraft(int tootId) { + TootEntity item = tootDao.find(tootId); + if(item != null) { + deleteDraft(item); + } + } + + public void deleteDraft(@NonNull TootEntity item){ + // Delete any media files associated with the status. + ArrayList uris = new Gson().fromJson(item.getUrls(), + new TypeToken>() {}.getType()); + if (uris != null) { + for (String uriString : uris) { + Uri uri = Uri.parse(uriString); + if (context.getContentResolver().delete(uri, null, null) == 0) { + Log.e(TAG, String.format("Did not delete file %s.", uriString)); + } + } + } + // update DB + tootDao.delete(item.getUid()); + } + + @Nullable + private List saveMedia(@NonNull List mediaUris, + @Nullable List existingUris) { + + File directory = context.getExternalFilesDir("Tusky"); + + if (directory == null || !(directory.exists())) { + Log.e(TAG, "Error obtaining directory to save media."); + return null; + } + + ContentResolver contentResolver = context.getContentResolver(); + ArrayList filesSoFar = new ArrayList<>(); + ArrayList results = new ArrayList<>(); + for (String mediaUri : mediaUris) { + /* If the media was already saved in a previous draft, there's no need to save another + * copy, just add the existing URI to the results. */ + if (existingUris != null) { + int index = existingUris.indexOf(mediaUri); + if (index != -1) { + results.add(mediaUri); + continue; + } + } + // Otherwise, save the media. + + Uri uri = Uri.parse(mediaUri); + + String timeStamp = new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(new Date()); + + String mimeType = contentResolver.getType(uri); + MimeTypeMap map = MimeTypeMap.getSingleton(); + String fileExtension = map.getExtensionFromMimeType(mimeType); + String filename = String.format("Tusky_Draft_Media_%s.%s", timeStamp, fileExtension); + File file = new File(directory, filename); + filesSoFar.add(file); + boolean copied = IOUtils.copyToFile(contentResolver, uri, file); + if (!copied) { + /* If any media files were created in prior iterations, delete those before + * returning. */ + for (File earlierFile : filesSoFar) { + boolean deleted = earlierFile.delete(); + if (!deleted) { + Log.i(TAG, "Could not delete the file " + earlierFile.toString()); + } + } + return null; + } + Uri resultUri = FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID+".fileprovider", file); + results.add(resultUri.toString()); + } + return results; + } + + private void deleteMedia(List mediaUris) { + for (String uriString : mediaUris) { + Uri uri = Uri.parse(uriString); + if (context.getContentResolver().delete(uri, null, null) == 0) { + Log.e(TAG, String.format("Did not delete file %s.", uriString)); + } + } + } + + /** + * A∖B={x∈A|x∉B} + * + * @return all elements of set A that are not in set B. + */ + private static List setDifference(List a, List b) { + List c = new ArrayList<>(); + for (String s : a) { + if (!b.contains(s)) { + c.add(s); + } + } + return c; + } + +} diff --git a/app/src/main/java/com/keylesspalace/tusky/view/ComposeOptionsView.kt b/app/src/main/java/com/keylesspalace/tusky/view/ComposeOptionsView.kt new file mode 100644 index 000000000..becbb8317 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/view/ComposeOptionsView.kt @@ -0,0 +1,80 @@ +/* Copyright 2018 Conny Duck + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.view + +import android.content.Context +import android.os.Build +import android.util.AttributeSet +import android.widget.LinearLayout +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.entity.Status +import kotlinx.android.synthetic.main.view_compose_options.view.* + + +class ComposeOptionsView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : LinearLayout(context, attrs, defStyleAttr) { + + var listener: ComposeOptionsListener? = null + + init { + inflate(context, R.layout.view_compose_options, this) + + if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + publicRadioButton.setButtonDrawable(R.drawable.ic_public_24dp) + unlistedRadioButton.setButtonDrawable(R.drawable.ic_lock_open_24dp) + privateRadioButton.setButtonDrawable(R.drawable.ic_lock_outline_24dp) + directRadioButton.setButtonDrawable(R.drawable.ic_email_24dp) + } + + visibilityRadioGroup.setOnCheckedChangeListener({ _, checkedId -> + val visibility = when (checkedId) { + R.id.publicRadioButton -> + Status.Visibility.PUBLIC + R.id.unlistedRadioButton -> + Status.Visibility.UNLISTED + R.id.privateRadioButton -> + Status.Visibility.PRIVATE + R.id.directRadioButton -> + Status.Visibility.DIRECT + else -> + Status.Visibility.PUBLIC + } + listener?.onVisibilityChanged(visibility) + }) + } + + fun setStatusVisibility(visibility: Status.Visibility) { + val selectedButton = when (visibility) { + Status.Visibility.PUBLIC -> + R.id.publicRadioButton + Status.Visibility.UNLISTED -> + R.id.unlistedRadioButton + Status.Visibility.PRIVATE -> + R.id.privateRadioButton + Status.Visibility.DIRECT -> + R.id.directRadioButton + else -> + R.id.directRadioButton + + } + + visibilityRadioGroup.check(selectedButton) + } + +} + +interface ComposeOptionsListener { + fun onVisibilityChanged(visibility: Status.Visibility) +} diff --git a/app/src/main/java/com/keylesspalace/tusky/view/EditTextTyped.java b/app/src/main/java/com/keylesspalace/tusky/view/EditTextTyped.java deleted file mode 100644 index 8de38513e..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/view/EditTextTyped.java +++ /dev/null @@ -1,59 +0,0 @@ -/* 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 . */ - -package com.keylesspalace.tusky.view; - -import android.content.Context; -import android.support.v13.view.inputmethod.EditorInfoCompat; -import android.support.v13.view.inputmethod.InputConnectionCompat; -import android.support.v7.widget.AppCompatMultiAutoCompleteTextView; -import android.util.AttributeSet; -import android.view.inputmethod.EditorInfo; -import android.view.inputmethod.InputConnection; - -import com.keylesspalace.tusky.util.Assert; - -public class EditTextTyped extends AppCompatMultiAutoCompleteTextView { - - private InputConnectionCompat.OnCommitContentListener onCommitContentListener; - private String[] mimeTypes; - - public EditTextTyped(Context context) { - super(context); - } - - public EditTextTyped(Context context, AttributeSet attributeSet) { - super(context, attributeSet); - } - - public void setMimeTypes(String[] types, - InputConnectionCompat.OnCommitContentListener listener) { - mimeTypes = types; - onCommitContentListener = listener; - } - - @Override - public InputConnection onCreateInputConnection(EditorInfo editorInfo) { - InputConnection connection = super.onCreateInputConnection(editorInfo); - if (onCommitContentListener != null) { - Assert.expect(mimeTypes != null); - EditorInfoCompat.setContentMimeTypes(editorInfo, mimeTypes); - return InputConnectionCompat.createWrapper(connection, editorInfo, - onCommitContentListener); - } else { - return connection; - } - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/view/EditTextTyped.kt b/app/src/main/java/com/keylesspalace/tusky/view/EditTextTyped.kt new file mode 100644 index 000000000..fb837b153 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/view/EditTextTyped.kt @@ -0,0 +1,53 @@ +/* Copyright 2018 Conny Duck + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.view + +import android.content.Context +import android.support.v13.view.inputmethod.EditorInfoCompat +import android.support.v13.view.inputmethod.InputConnectionCompat +import android.support.v7.widget.AppCompatMultiAutoCompleteTextView +import android.text.InputType +import android.util.AttributeSet +import android.view.inputmethod.EditorInfo +import android.view.inputmethod.InputConnection + +class EditTextTyped @JvmOverloads constructor(context: Context, + attributeSet: AttributeSet? = null) + : AppCompatMultiAutoCompleteTextView(context, attributeSet) { + + private var onCommitContentListener: InputConnectionCompat.OnCommitContentListener? = null + + init { + //fix a bug with autocomplete and some keyboards + val newInputType = inputType and (inputType xor InputType.TYPE_TEXT_FLAG_AUTO_COMPLETE) + inputType = newInputType + } + + fun setOnCommitContentListener(listener: InputConnectionCompat.OnCommitContentListener) { + onCommitContentListener = listener + } + + override fun onCreateInputConnection(editorInfo: EditorInfo): InputConnection { + val connection = super.onCreateInputConnection(editorInfo) + return if (onCommitContentListener != null) { + EditorInfoCompat.setContentMimeTypes(editorInfo, arrayOf("image/*")) + InputConnectionCompat.createWrapper(connection, editorInfo, + onCommitContentListener!!) + } else { + connection + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/view/TootButton.kt b/app/src/main/java/com/keylesspalace/tusky/view/TootButton.kt new file mode 100644 index 000000000..088fc051e --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/view/TootButton.kt @@ -0,0 +1,76 @@ +/* Copyright 2018 Conny Duck + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.view + +import android.content.Context +import android.graphics.Color +import android.support.v7.widget.AppCompatButton +import android.util.AttributeSet +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.entity.Status +import com.mikepenz.google_material_typeface_library.GoogleMaterial +import com.mikepenz.iconics.IconicsDrawable + +class TootButton +@JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : AppCompatButton(context, attrs, defStyleAttr) { + + private val smallStyle: Boolean = context.resources.getBoolean(R.bool.show_small_toot_button) + + init { + if(smallStyle) { + setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_send_24dp, 0, 0, 0) + } else { + compoundDrawablePadding = context.resources.getDimensionPixelSize(R.dimen.toot_button_drawable_padding) + setText(R.string.action_send) + } + } + + fun setStatusVisibility(visibility: Status.Visibility) { + if(!smallStyle) { + + when (visibility) { + Status.Visibility.PUBLIC -> { + setText(R.string.action_send_public) + setCompoundDrawables(null, null, null, null) + } + Status.Visibility.UNLISTED -> { + setText(R.string.action_send) + setCompoundDrawables(null, null, null, null) + } + Status.Visibility.PRIVATE, + Status.Visibility.DIRECT -> { + addLock() + } + else -> { + setCompoundDrawables(null, null, null, null) + } + } + } + + } + + private fun addLock() { + setText(R.string.action_send) + val lock = IconicsDrawable(context, GoogleMaterial.Icon.gmd_lock).sizeDp(18).color(Color.WHITE) + setCompoundDrawablesWithIntrinsicBounds(lock, null, null, null) + } + +} + diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.java b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.java index 46c62f7b6..639677bcd 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.java +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.java @@ -20,6 +20,7 @@ import android.text.Spanned; import com.keylesspalace.tusky.entity.Attachment; import com.keylesspalace.tusky.entity.Card; +import com.keylesspalace.tusky.entity.Emoji; import com.keylesspalace.tusky.entity.Status; import java.util.Collections; @@ -68,7 +69,7 @@ public abstract class StatusViewData { private final String senderId; private final boolean rebloggingEnabled; private final Status.Application application; - private final List emojis; + private final List emojis; @Nullable private final Card card; @@ -78,7 +79,7 @@ public abstract class StatusViewData { boolean isShowingContent, String userFullName, String nickname, String avatar, Date createdAt, int reblogsCount, int favouritesCount, @Nullable String inReplyToId, @Nullable Status.Mention[] mentions, String senderId, boolean rebloggingEnabled, - Status.Application application, List emojis, @Nullable Card card) { + Status.Application application, List emojis, @Nullable Card card) { this.id = id; this.content = content; this.reblogged = reblogged; @@ -203,7 +204,7 @@ public abstract class StatusViewData { return application; } - public List getEmojis() { + public List getEmojis() { return emojis; } @@ -250,7 +251,7 @@ public abstract class StatusViewData { private String senderId; private boolean rebloggingEnabled; private Status.Application application; - private List emojis; + private List emojis; private Card card; public Builder() { @@ -399,7 +400,7 @@ public abstract class StatusViewData { return this; } - public Builder setEmojis(List emojis) { + public Builder setEmojis(List emojis) { this.emojis = emojis; return this; } diff --git a/app/src/main/res/color/compound_button_color_dark.xml b/app/src/main/res/color/compound_button_color_dark.xml new file mode 100644 index 000000000..c3a3d0710 --- /dev/null +++ b/app/src/main/res/color/compound_button_color_dark.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/color/compound_button_color_light.xml b/app/src/main/res/color/compound_button_color_light.xml new file mode 100644 index 000000000..8c113f771 --- /dev/null +++ b/app/src/main/res/color/compound_button_color_light.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/compose_button_colors.xml b/app/src/main/res/drawable/compose_button_colors.xml deleted file mode 100644 index 2487c7a34..000000000 --- a/app/src/main/res/drawable/compose_button_colors.xml +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/favourite_inactive_dark.xml b/app/src/main/res/drawable/favourite_inactive_dark.xml index 803131bc1..cc1dc796a 100644 --- a/app/src/main/res/drawable/favourite_inactive_dark.xml +++ b/app/src/main/res/drawable/favourite_inactive_dark.xml @@ -4,6 +4,6 @@ android:height="24dp" android:viewportWidth="24.0" android:viewportHeight="24.0"> diff --git a/app/src/main/res/drawable/favourite_inactive_light.xml b/app/src/main/res/drawable/favourite_inactive_light.xml index 06ccf499e..0683a47bd 100644 --- a/app/src/main/res/drawable/favourite_inactive_light.xml +++ b/app/src/main/res/drawable/favourite_inactive_light.xml @@ -4,7 +4,7 @@ android:height="24dp" android:viewportWidth="24.0" android:viewportHeight="24.0"> diff --git a/app/src/main/res/drawable/ic_email_24dp.xml b/app/src/main/res/drawable/ic_email_24dp.xml index a050d6f8f..1bcee1be0 100644 --- a/app/src/main/res/drawable/ic_email_24dp.xml +++ b/app/src/main/res/drawable/ic_email_24dp.xml @@ -1,9 +1,7 @@ - - + android:width="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_emoji_24dp.xml b/app/src/main/res/drawable/ic_emoji_24dp.xml new file mode 100644 index 000000000..5a73c89d5 --- /dev/null +++ b/app/src/main/res/drawable/ic_emoji_24dp.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_remove_red_eye_black_24dp.xml b/app/src/main/res/drawable/ic_eye_24dp.xml similarity index 66% rename from app/src/main/res/drawable/ic_remove_red_eye_black_24dp.xml rename to app/src/main/res/drawable/ic_eye_24dp.xml index e58240d25..408cb26f6 100644 --- a/app/src/main/res/drawable/ic_remove_red_eye_black_24dp.xml +++ b/app/src/main/res/drawable/ic_eye_24dp.xml @@ -1,9 +1,9 @@ + android:width="24dp" + android:height="24dp" + android:viewportHeight="24.0" + android:viewportWidth="24.0"> + android:fillColor="#fff" + android:pathData="M12,4.5C7,4.5 2.73,7.61 1,12c1.73,4.39 6,7.5 11,7.5s9.27,-3.11 11,-7.5c-1.73,-4.39 -6,-7.5 -11,-7.5zM12,17c-2.76,0 -5,-2.24 -5,-5s2.24,-5 5,-5 5,2.24 5,5 -2.24,5 -5,5zM12,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3 3,-1.34 3,-3 -1.34,-3 -3,-3z" /> diff --git a/app/src/main/res/drawable/ic_hide_media_24dp.xml b/app/src/main/res/drawable/ic_hide_media_24dp.xml index 8b64ae1a6..ed10b8cc9 100644 --- a/app/src/main/res/drawable/ic_hide_media_24dp.xml +++ b/app/src/main/res/drawable/ic_hide_media_24dp.xml @@ -1,7 +1,9 @@ - - - + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_save_24dp.xml b/app/src/main/res/drawable/ic_save_24dp.xml deleted file mode 100644 index 7e982fe5c..000000000 --- a/app/src/main/res/drawable/ic_save_24dp.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/reblog_inactive_dark.xml b/app/src/main/res/drawable/reblog_inactive_dark.xml index 74d6b8fe5..ca335ebbf 100644 --- a/app/src/main/res/drawable/reblog_inactive_dark.xml +++ b/app/src/main/res/drawable/reblog_inactive_dark.xml @@ -4,6 +4,6 @@ android:viewportWidth="24.0" android:viewportHeight="24.0"> diff --git a/app/src/main/res/drawable/reblog_inactive_light.xml b/app/src/main/res/drawable/reblog_inactive_light.xml index 7b6497917..cfeb3ce67 100644 --- a/app/src/main/res/drawable/reblog_inactive_light.xml +++ b/app/src/main/res/drawable/reblog_inactive_light.xml @@ -4,6 +4,6 @@ android:viewportWidth="24.0" android:viewportHeight="24.0"> diff --git a/app/src/main/res/drawable/send_private.xml b/app/src/main/res/drawable/send_private.xml deleted file mode 100644 index 2b5117ca9..000000000 --- a/app/src/main/res/drawable/send_private.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/layout/activity_compose.xml b/app/src/main/res/layout/activity_compose.xml index 674542684..debf87a4f 100644 --- a/app/src/main/res/layout/activity_compose.xml +++ b/app/src/main/res/layout/activity_compose.xml @@ -6,115 +6,122 @@ android:layout_width="match_parent" android:layout_height="match_parent"> - + + + + + + - - - - - - - - - - + android:layout_marginBottom="52dp" + android:layout_marginTop="?attr/actionBarSize"> - + + + android:paddingTop="4dp" + android:textSize="?attr/status_text_small" + android:visibility="gone" + tools:text="Post content which may be preeettyy long, so please, make sure there's enough room for everything, okay? Not kidding. I wish Eugen answered me more often, sigh." + tools:visibility="visible" /> - + android:layout_height="wrap_content" + android:orientation="vertical"> - + - + + + + android:scrollbars="none"> + android:orientation="horizontal" + android:paddingLeft="16dp" + android:paddingRight="16dp"> @@ -122,104 +129,162 @@ - - - - - - - - - - - - - - - - - - -