Support for quick reply from notifications.

Fixes #483
Closes #3455
// FREEBIE
This commit is contained in:
Moxie Marlinspike 2015-06-22 08:46:43 -07:00
parent dc60c011a6
commit 2016fa315b
24 changed files with 354 additions and 24 deletions

View File

@ -112,6 +112,15 @@
<activity android:name=".ConversationActivity"
android:windowSoftInputMode="stateUnchanged"
android:launchMode="singleTask"
android:theme="@style/TextSecure.LightTheme.Popup"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize" />
<activity android:name=".ConversationPopupActivity"
android:windowSoftInputMode="stateVisible"
android:launchMode="singleTask"
android:taskAffinity=""
android:excludeFromRecents="true"
android:theme="@style/TextSecure.LightTheme.Popup"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize" />
<activity android:name=".MessageDetailsActivity"

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android" >
<translate
android:duration="250"
android:fromYDelta="-100%"
android:toYDelta="0%" />
</set>

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">
<translate
android:duration="250"
android:fromYDelta="0%"
android:toYDelta="-100%" />
</set>

Binary file not shown.

After

Width:  |  Height:  |  Size: 357 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 467 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 350 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 368 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 567 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 533 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 814 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 666 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto">
<item android:title="@string/conversation_popup__menu_expand_popup"
android:id="@+id/menu_expand"
android:icon="?menu_popup_expand"
app:showAsAction="ifRoom" />
</menu>

View File

@ -95,6 +95,7 @@
<attr name="menu_new_conversation_icon" format="reference" />
<attr name="menu_search_icon" format="reference" />
<attr name="menu_call_icon" format="reference" />
<attr name="menu_popup_expand" format="reference"/>
<attr name="menu_unlock_icon" format="reference" />
<attr name="menu_lock_icon" format="reference" />
<attr name="menu_lock_icon_small" format="reference" />

View File

@ -436,6 +436,9 @@
<string name="MmsMessageRecord_bad_encrypted_mms_message">Bad encrypted MMS message...</string>
<string name="MmsMessageRecord_mms_message_encrypted_for_non_existing_session">MMS message encrypted for non-existing session...</string>
<!-- MuteDialog -->
<string name="MuteDialog_mute_notifications">Mute notifications</string>
<!-- ApplicationMigrationService -->
<string name="ApplicationMigrationService_import_in_progress">Import in progress</string>
<string name="ApplicationMigrationService_importing_text_messages">Importing text messages</string>
@ -458,6 +461,7 @@
<string name="MessageNotifier_mark_all_as_read">Mark all as read</string>
<string name="MessageNotifier_mark_as_read">Mark as read</string>
<string name="MessageNotifier_media_message">Media message</string>
<string name="MessageNotifier_reply">Reply</string>
<!-- QuickResponseService -->
<string name="QuickResponseService_quick_response_unavailable_when_TextSecure_is_locked">Quick response unavailable when TextSecure is locked!</string>
@ -901,6 +905,9 @@
<string name="conversation__menu_delete_thread">Delete thread</string>
<string name="conversation__menu_view_media">All images</string>
<!-- conversation_popup -->
<string name="conversation_popup__menu_expand_popup">Expand popup</string>
<!-- conversation_callable -->
<string name="conversation_add_to_contacts__menu_add_to_contacts">Add to contacts</string>
@ -950,7 +957,6 @@
<!-- transport_selection_list_item -->
<string name="transport_selection_list_item__transport_icon">Transport icon</string>
<string name="MuteDialog_mute_notifications">Mute notifications</string>
<!-- EOF -->

View File

@ -39,6 +39,23 @@
<item name="android:windowBackground">@color/black</item>
</style>
<style name="PopupAnimation" parent="@android:style/Animation">
<item name="android:windowEnterAnimation">@anim/slide_from_top</item>
<item name="android:windowExitAnimation">@anim/slide_to_top</item>
</style>
<style name="TextSecure.LightTheme.Popup" parent="TextSecure.LightTheme">
<item name="android:windowIsFloating">false</item>
<item name="android:windowSoftInputMode">stateUnchanged</item>
<item name="android:windowContentOverlay">@null</item>
<item name="android:windowActionModeOverlay" tools:targetApi="honeycomb">true</item>
<item name="android:windowIsTranslucent">true</item>
<item name="android:windowFrame">@null</item>
<item name="android:windowAnimationStyle">@style/PopupAnimation</item>
<item name="android:windowCloseOnTouchOutside" tools:targetApi="honeycomb">true</item>
<item name="android:backgroundDimEnabled">false</item>
</style>
<style name="TextSecure.LightTheme" parent="@style/Theme.AppCompat.Light.DarkActionBar">
<item name="actionBarStyle">@style/TextSecure.LightActionBar</item>
<item name="actionBarTabBarStyle">@style/TextSecure.LightActionBar.TabBar</item>
@ -131,6 +148,7 @@
<item name="menu_group_icon">@drawable/ic_group_white_24dp</item>
<item name="menu_search_icon">@drawable/ic_search_white_24dp</item>
<item name="menu_call_icon">@drawable/ic_call_white_24dp</item>
<item name="menu_popup_expand">@drawable/ic_launch_white_24dp</item>
<item name="menu_unlock_icon">@drawable/ic_unlocked_white_24dp</item>
<item name="menu_lock_icon">@drawable/ic_lock_white_24dp</item>
<item name="menu_lock_icon_small">@drawable/ic_lock_white_18dp</item>
@ -254,6 +272,7 @@
<item name="menu_group_icon">@drawable/ic_group_white_24dp</item>
<item name="menu_search_icon">@drawable/ic_search_white_24dp</item>
<item name="menu_call_icon">@drawable/ic_call_white_24dp</item>
<item name="menu_popup_expand">@drawable/ic_launch_white_24dp</item>
<item name="menu_unlock_icon">@drawable/ic_unlocked_white_24dp</item>
<item name="menu_lock_icon">@drawable/ic_lock_white_24dp</item>
<item name="menu_lock_icon_small">@drawable/ic_lock_white_18dp</item>

View File

@ -97,6 +97,8 @@ import org.thoughtcrime.securesms.util.DynamicTheme;
import org.thoughtcrime.securesms.util.GroupUtil;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.concurrent.ListenableFuture;
import org.thoughtcrime.securesms.util.concurrent.SettableFuture;
import org.whispersystems.libaxolotl.InvalidMessageException;
import org.whispersystems.libaxolotl.util.guava.Optional;
@ -138,16 +140,16 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
private static final int GROUP_EDIT = 5;
private static final int CAPTURE_PHOTO = 6;
private MasterSecret masterSecret;
private ComposeText composeText;
private AnimatingToggle buttonToggle;
private SendButton sendButton;
private ImageButton attachButton;
private ConversationTitleView titleView;
private TextView charactersLeft;
private ConversationFragment fragment;
private Button unblockButton;
private View composePanel;
private MasterSecret masterSecret;
protected ComposeText composeText;
private AnimatingToggle buttonToggle;
private SendButton sendButton;
private ImageButton attachButton;
private ConversationTitleView titleView;
private TextView charactersLeft;
private ConversationFragment fragment;
private Button unblockButton;
private View composePanel;
private AttachmentTypeSelectorAdapter attachmentAdapter;
private AttachmentManager attachmentManager;
@ -169,7 +171,6 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
protected void onPreCreate() {
dynamicTheme.onCreate(this);
dynamicLanguage.onCreate(this);
overridePendingTransition(R.anim.slide_from_right, R.anim.fade_scale_out);
}
@Override
@ -768,7 +769,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
emojiToggle.setOnClickListener(new EmojiToggleListener());
}
private void initializeActionBar() {
protected void initializeActionBar() {
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
getSupportActionBar().setCustomView(R.layout.conversation_title_view);
getSupportActionBar().setDisplayShowCustomEnabled(true);
@ -950,18 +951,22 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
return drafts;
}
private void saveDraft() {
if (this.recipients == null || this.recipients.isEmpty())
return;
protected ListenableFuture<Long> saveDraft() {
final SettableFuture<Long> future = new SettableFuture<>();
if (this.recipients == null || this.recipients.isEmpty()) {
future.set(threadId);
return future;
}
final Drafts drafts = getDraftsForCurrentState();
final long thisThreadId = this.threadId;
final MasterSecret thisMasterSecret = this.masterSecret.parcelClone();
final int thisDistributionType = this.distributionType;
new AsyncTask<Long, Void, Void>() {
new AsyncTask<Long, Void, Long>() {
@Override
protected Void doInBackground(Long... params) {
protected Long doInBackground(Long... params) {
ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(ConversationActivity.this);
DraftDatabase draftDatabase = DatabaseFactory.getDraftDatabase(ConversationActivity.this);
long threadId = params[0];
@ -974,9 +979,18 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
} else if (threadId > 0) {
threadDatabase.update(threadId);
}
return null;
return threadId;
}
@Override
protected void onPostExecute(Long result) {
future.set(result);
}
}.execute(thisThreadId);
return future;
}
private void setBlockedUserState(Recipients recipients) {
@ -1031,10 +1045,14 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
return getRecipients() != null && getRecipients().isGroupRecipient();
}
private Recipients getRecipients() {
protected Recipients getRecipients() {
return this.recipients;
}
protected long getThreadId() {
return this.threadId;
}
private String getMessage() throws InvalidMessageException {
String rawText = composeText.getText().toString();
@ -1055,7 +1073,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
}.execute(threadId);
}
private void sendComplete(long threadId) {
protected void sendComplete(long threadId) {
boolean refreshFragment = (threadId != this.threadId);
this.threadId = threadId;

View File

@ -174,6 +174,7 @@ public class ConversationListActivity extends PassphraseRequiredActionBarActivit
intent.putExtra(ConversationActivity.DISTRIBUTION_TYPE_EXTRA, distributionType);
startActivity(intent);
overridePendingTransition(R.anim.slide_from_right, R.anim.fade_scale_out);
}
private void handleDisplaySettings() {

View File

@ -0,0 +1,117 @@
package org.thoughtcrime.securesms;
import android.content.Intent;
import android.os.Build;
import android.os.Bundle;
import android.support.annotation.NonNull;
import android.support.v4.app.ActivityOptionsCompat;
import android.util.Log;
import android.view.Display;
import android.view.Gravity;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.WindowManager;
import org.thoughtcrime.securesms.crypto.MasterSecret;
import org.thoughtcrime.securesms.util.concurrent.ListenableFuture;
import java.util.concurrent.ExecutionException;
public class ConversationPopupActivity extends ConversationActivity {
private static final String TAG = ConversationPopupActivity.class.getSimpleName();
@Override
protected void onPreCreate() {
super.onPreCreate();
overridePendingTransition(R.anim.slide_from_top, R.anim.slide_to_top);
}
@Override
protected void onCreate(Bundle bundle, @NonNull MasterSecret masterSecret) {
getWindow().setFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND,
WindowManager.LayoutParams.FLAG_DIM_BEHIND);
WindowManager.LayoutParams params = getWindow().getAttributes();
params.alpha = 1.0f;
params.dimAmount = 0.1f;
params.gravity = Gravity.TOP;
getWindow().setAttributes(params);
Display display = getWindowManager().getDefaultDisplay();
int width = display.getWidth();
int height = display.getHeight();
if (height > width) getWindow().setLayout((int) (width * .85), (int) (height * .5));
else getWindow().setLayout((int) (width * .7), (int) (height * .75));
super.onCreate(bundle, masterSecret);
}
@Override
protected void onResume() {
super.onResume();
composeText.requestFocus();
}
@Override
protected void onPause() {
super.onPause();
if (isFinishing()) overridePendingTransition(R.anim.slide_from_top, R.anim.slide_to_top);
}
@Override
public boolean onPrepareOptionsMenu(Menu menu) {
MenuInflater inflater = this.getMenuInflater();
menu.clear();
inflater.inflate(R.menu.conversation_popup, menu);
return true;
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
switch (item.getItemId()) {
case R.id.menu_expand:
saveDraft().addListener(new ListenableFuture.Listener<Long>() {
@Override
public void onSuccess(Long result) {
ActivityOptionsCompat transition = ActivityOptionsCompat.makeScaleUpAnimation(getWindow().getDecorView(), 0, 0, getWindow().getAttributes().width, getWindow().getAttributes().height);
Intent intent = new Intent(ConversationPopupActivity.this, ConversationActivity.class);
intent.putExtra(ConversationActivity.RECIPIENTS_EXTRA, getRecipients().getIds());
intent.putExtra(ConversationActivity.THREAD_ID_EXTRA, result);
if ( Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
startActivity(intent, transition.toBundle());
} else {
startActivity(intent);
overridePendingTransition(R.anim.fade_scale_in, R.anim.slide_to_right);
}
finish();
}
@Override
public void onFailure(ExecutionException e) {
Log.w(TAG, e);
}
});
return true;
}
return false;
}
@Override
protected void initializeActionBar() {
super.initializeActionBar();
getSupportActionBar().setDisplayHomeAsUpEnabled(false);
}
@Override
protected void sendComplete(long threadId) {
super.sendComplete(threadId);
finish();
}
}

View File

@ -94,7 +94,6 @@ public class MessageNotifier {
sendInThreadNotification(context, recipients);
} else {
Intent intent = new Intent(context, ConversationActivity.class);
intent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP);
intent.putExtra("recipients", recipients.getIds());
intent.putExtra("thread_id", threadId);
intent.setData((Uri.parse("custom://"+System.currentTimeMillis())));
@ -221,6 +220,7 @@ public class MessageNotifier {
context.getString(R.string.MessageNotifier_mark_as_read),
notificationState.getMarkAsReadIntent(context, masterSecret));
builder.addAction(markAsReadAction);
builder.addAction(new Action(R.drawable.ic_reply_white_36dp, context.getString(R.string.MessageNotifier_reply), notifications.get(0).getReplyIntent(context)));
builder.extend(new NotificationCompat.WearableExtender().addAction(markAsReadAction));
}

View File

@ -8,6 +8,7 @@ import android.support.annotation.Nullable;
import android.text.SpannableStringBuilder;
import org.thoughtcrime.securesms.ConversationActivity;
import org.thoughtcrime.securesms.ConversationPopupActivity;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.Recipients;
import org.thoughtcrime.securesms.util.Util;
@ -81,8 +82,7 @@ public class NotificationItem {
}
public PendingIntent getPendingIntent(Context context) {
Intent intent = new Intent(context, ConversationActivity.class);
Intent intent = new Intent(context, ConversationActivity.class);
Recipients notifyRecipients = threadRecipients != null ? threadRecipients : recipients;
if (notifyRecipients != null) intent.putExtra("recipients", notifyRecipients.getIds());
@ -92,4 +92,16 @@ public class NotificationItem {
return PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT);
}
public PendingIntent getReplyIntent(Context context) {
Intent intent = new Intent(context, ConversationPopupActivity.class);
Recipients notifyRecipients = threadRecipients != null ? threadRecipients : recipients;
if (notifyRecipients != null) intent.putExtra(ConversationActivity.RECIPIENTS_EXTRA, notifyRecipients.getIds());
intent.putExtra(ConversationActivity.THREAD_ID_EXTRA, threadId);
intent.setData((Uri.parse("custom://"+System.currentTimeMillis())));
return PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT);
}
}

View File

@ -0,0 +1,12 @@
package org.thoughtcrime.securesms.util.concurrent;
import java.util.concurrent.ExecutionException;
public interface ListenableFuture<T> {
void addListener(Listener<T> listener);
public interface Listener<T> {
public void onSuccess(T result);
public void onFailure(ExecutionException e);
}
}

View File

@ -0,0 +1,112 @@
package org.thoughtcrime.securesms.util.concurrent;
import java.util.LinkedList;
import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
public class SettableFuture<T> implements Future<T>, ListenableFuture<T> {
private final List<Listener<T>> listeners = new LinkedList<>();
private boolean completed;
private boolean canceled;
private volatile T result;
private volatile Throwable exception;
@Override
public synchronized boolean cancel(boolean mayInterruptIfRunning) {
if (!completed && !canceled) {
canceled = true;
return true;
}
return false;
}
@Override
public synchronized boolean isCancelled() {
return canceled;
}
@Override
public synchronized boolean isDone() {
return completed;
}
public boolean set(T result) {
synchronized (this) {
if (completed || canceled) return false;
this.result = result;
this.completed = true;
}
notifyAllListeners();
return true;
}
public boolean setException(Throwable throwable) {
synchronized (this) {
if (completed || canceled) return false;
this.exception = throwable;
this.completed = true;
}
notifyAllListeners();
return true;
}
@Override
public synchronized T get() throws InterruptedException, ExecutionException {
while (!completed) wait();
if (exception != null) throw new ExecutionException(exception);
else return result;
}
@Override
public synchronized T get(long timeout, TimeUnit unit)
throws InterruptedException, ExecutionException, TimeoutException
{
long startTime = System.currentTimeMillis();
while (!completed && System.currentTimeMillis() - startTime > unit.toMillis(timeout)) {
wait(unit.toMillis(timeout));
}
if (!completed) throw new TimeoutException();
else return get();
}
@Override
public void addListener(Listener<T> listener) {
synchronized (this) {
listeners.add(listener);
if (!completed) return;
}
notifyListener(listener);
}
private void notifyAllListeners() {
List<Listener<T>> localListeners;
synchronized (this) {
localListeners = new LinkedList<>(listeners);
}
for (Listener<T> listener : localListeners) {
notifyListener(listener);
}
}
private void notifyListener(Listener<T> listener) {
if (exception != null) listener.onFailure(new ExecutionException(exception));
else listener.onSuccess(result);
}
}