| /* |
| * Copyright (C) 2011 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package com.android.dialer.voicemail; |
| |
| import android.content.ContentUris; |
| import android.content.Context; |
| import android.content.Intent; |
| import android.database.Cursor; |
| import android.graphics.drawable.Drawable; |
| import android.net.Uri; |
| import android.os.AsyncTask; |
| import android.os.Handler; |
| import android.util.AttributeSet; |
| import android.support.design.widget.Snackbar; |
| import android.view.LayoutInflater; |
| import android.view.View; |
| import android.widget.ImageButton; |
| import android.widget.LinearLayout; |
| import android.widget.SeekBar; |
| import android.widget.SeekBar.OnSeekBarChangeListener; |
| import android.widget.TextView; |
| import android.widget.Toast; |
| |
| import com.android.common.io.MoreCloseables; |
| import com.android.dialer.PhoneCallDetails; |
| import com.android.dialer.R; |
| import com.android.dialer.calllog.CallLogAsyncTaskUtil; |
| |
| import com.android.dialer.database.VoicemailArchiveContract; |
| import com.android.dialer.database.VoicemailArchiveContract.VoicemailArchive; |
| import com.android.dialer.util.AsyncTaskExecutor; |
| import com.android.dialer.util.AsyncTaskExecutors; |
| import com.google.common.annotations.VisibleForTesting; |
| |
| import java.util.ArrayList; |
| import java.util.HashMap; |
| import java.util.Objects; |
| import java.util.concurrent.TimeUnit; |
| import java.util.concurrent.ScheduledFuture; |
| import java.util.concurrent.ScheduledExecutorService; |
| |
| import javax.annotation.Nullable; |
| import javax.annotation.concurrent.GuardedBy; |
| import javax.annotation.concurrent.NotThreadSafe; |
| import javax.annotation.concurrent.ThreadSafe; |
| |
| /** |
| * Displays and plays a single voicemail. See {@link VoicemailPlaybackPresenter} for |
| * details on the voicemail playback implementation. |
| * |
| * This class is not thread-safe, it is thread-confined. All calls to all public |
| * methods on this class are expected to come from the main ui thread. |
| */ |
| @NotThreadSafe |
| public class VoicemailPlaybackLayout extends LinearLayout |
| implements VoicemailPlaybackPresenter.PlaybackView, |
| CallLogAsyncTaskUtil.CallLogAsyncTaskListener { |
| private static final String TAG = VoicemailPlaybackLayout.class.getSimpleName(); |
| private static final int VOICEMAIL_DELETE_DELAY_MS = 3000; |
| private static final int VOICEMAIL_ARCHIVE_DELAY_MS = 3000; |
| |
| /** The enumeration of {@link AsyncTask} objects we use in this class. */ |
| public enum Tasks { |
| QUERY_ARCHIVED_STATUS |
| } |
| |
| /** |
| * Controls the animation of the playback slider. |
| */ |
| @ThreadSafe |
| private final class PositionUpdater implements Runnable { |
| |
| /** Update rate for the slider, 30fps. */ |
| private static final int SLIDER_UPDATE_PERIOD_MILLIS = 1000 / 30; |
| |
| private int mDurationMs; |
| private final ScheduledExecutorService mExecutorService; |
| private final Object mLock = new Object(); |
| @GuardedBy("mLock") private ScheduledFuture<?> mScheduledFuture; |
| |
| private Runnable mUpdateClipPositionRunnable = new Runnable() { |
| @Override |
| public void run() { |
| int currentPositionMs = 0; |
| synchronized (mLock) { |
| if (mScheduledFuture == null || mPresenter == null) { |
| // This task has been canceled. Just stop now. |
| return; |
| } |
| currentPositionMs = mPresenter.getMediaPlayerPosition(); |
| } |
| setClipPosition(currentPositionMs, mDurationMs); |
| } |
| }; |
| |
| public PositionUpdater(int durationMs, ScheduledExecutorService executorService) { |
| mDurationMs = durationMs; |
| mExecutorService = executorService; |
| } |
| |
| @Override |
| public void run() { |
| post(mUpdateClipPositionRunnable); |
| } |
| |
| public void startUpdating() { |
| synchronized (mLock) { |
| cancelPendingRunnables(); |
| mScheduledFuture = mExecutorService.scheduleAtFixedRate( |
| this, 0, SLIDER_UPDATE_PERIOD_MILLIS, TimeUnit.MILLISECONDS); |
| } |
| } |
| |
| public void stopUpdating() { |
| synchronized (mLock) { |
| cancelPendingRunnables(); |
| } |
| } |
| |
| private void cancelPendingRunnables() { |
| if (mScheduledFuture != null) { |
| mScheduledFuture.cancel(true); |
| mScheduledFuture = null; |
| } |
| removeCallbacks(mUpdateClipPositionRunnable); |
| } |
| } |
| |
| /** |
| * Handle state changes when the user manipulates the seek bar. |
| */ |
| private final OnSeekBarChangeListener mSeekBarChangeListener = new OnSeekBarChangeListener() { |
| @Override |
| public void onStartTrackingTouch(SeekBar seekBar) { |
| if (mPresenter != null) { |
| mPresenter.pausePlaybackForSeeking(); |
| } |
| } |
| |
| @Override |
| public void onStopTrackingTouch(SeekBar seekBar) { |
| if (mPresenter != null) { |
| mPresenter.resumePlaybackAfterSeeking(seekBar.getProgress()); |
| } |
| } |
| |
| @Override |
| public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { |
| setClipPosition(progress, seekBar.getMax()); |
| // Update the seek position if user manually changed it. This makes sure position gets |
| // updated when user use volume button to seek playback in talkback mode. |
| if (fromUser) { |
| mPresenter.seek(progress); |
| } |
| } |
| }; |
| |
| /** |
| * Click listener to toggle speakerphone. |
| */ |
| private final View.OnClickListener mSpeakerphoneListener = new View.OnClickListener() { |
| @Override |
| public void onClick(View v) { |
| if (mPresenter != null) { |
| mPresenter.toggleSpeakerphone(); |
| } |
| } |
| }; |
| |
| /** |
| * Click listener to play or pause voicemail playback. |
| */ |
| private final View.OnClickListener mStartStopButtonListener = new View.OnClickListener() { |
| @Override |
| public void onClick(View view) { |
| if (mPresenter == null) { |
| return; |
| } |
| |
| if (mIsPlaying) { |
| mPresenter.pausePlayback(); |
| } else { |
| mPresenter.resumePlayback(); |
| } |
| } |
| }; |
| |
| private final View.OnClickListener mDeleteButtonListener = new View.OnClickListener() { |
| @Override |
| public void onClick(View view ) { |
| if (mPresenter == null) { |
| return; |
| } |
| mPresenter.pausePlayback(); |
| mPresenter.onVoicemailDeleted(); |
| |
| final Uri deleteUri = mVoicemailUri; |
| final Runnable deleteCallback = new Runnable() { |
| @Override |
| public void run() { |
| if (Objects.equals(deleteUri, mVoicemailUri)) { |
| CallLogAsyncTaskUtil.deleteVoicemail(mContext, deleteUri, |
| VoicemailPlaybackLayout.this); |
| } |
| } |
| }; |
| |
| final Handler handler = new Handler(); |
| // Add a little buffer time in case the user clicked "undo" at the end of the delay |
| // window. |
| handler.postDelayed(deleteCallback, VOICEMAIL_DELETE_DELAY_MS + 50); |
| |
| Snackbar.make(VoicemailPlaybackLayout.this, R.string.snackbar_voicemail_deleted, |
| Snackbar.LENGTH_LONG) |
| .setDuration(VOICEMAIL_DELETE_DELAY_MS) |
| .setAction(R.string.snackbar_voicemail_deleted_undo, |
| new View.OnClickListener() { |
| @Override |
| public void onClick(View view) { |
| mPresenter.onVoicemailDeleteUndo(); |
| handler.removeCallbacks(deleteCallback); |
| } |
| }) |
| .setActionTextColor( |
| mContext.getResources().getColor( |
| R.color.dialer_snackbar_action_text_color)) |
| .show(); |
| } |
| }; |
| |
| private final View.OnClickListener mArchiveButtonListener = new View.OnClickListener() { |
| @Override |
| public void onClick(View v) { |
| if (mPresenter == null || isArchiving(mVoicemailUri)) { |
| return; |
| } |
| mIsArchiving.add(mVoicemailUri); |
| mPresenter.pausePlayback(); |
| updateArchiveUI(mVoicemailUri); |
| disableUiElements(); |
| mPresenter.archiveContent(mVoicemailUri, true); |
| } |
| }; |
| |
| private Context mContext; |
| private VoicemailPlaybackPresenter mPresenter; |
| private Uri mVoicemailUri; |
| private final AsyncTaskExecutor mAsyncTaskExecutor = |
| AsyncTaskExecutors.createAsyncTaskExecutor(); |
| private boolean mIsPlaying = false; |
| /** |
| * Keeps track of which voicemails are currently being archived in order to update the voicemail |
| * card UI every time a user opens a new card. |
| */ |
| private static final ArrayList<Uri> mIsArchiving = new ArrayList<>(); |
| |
| private SeekBar mPlaybackSeek; |
| private ImageButton mStartStopButton; |
| private ImageButton mPlaybackSpeakerphone; |
| private ImageButton mDeleteButton; |
| private ImageButton mArchiveButton; |
| private TextView mStateText; |
| private TextView mPositionText; |
| private TextView mTotalDurationText; |
| |
| private PositionUpdater mPositionUpdater; |
| private Drawable mVoicemailSeekHandleEnabled; |
| private Drawable mVoicemailSeekHandleDisabled; |
| |
| public VoicemailPlaybackLayout(Context context) { |
| this(context, null); |
| } |
| |
| public VoicemailPlaybackLayout(Context context, AttributeSet attrs) { |
| super(context, attrs); |
| mContext = context; |
| LayoutInflater inflater = |
| (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); |
| inflater.inflate(R.layout.voicemail_playback_layout, this); |
| } |
| |
| @Override |
| public void setPresenter(VoicemailPlaybackPresenter presenter, Uri voicemailUri) { |
| mPresenter = presenter; |
| mVoicemailUri = voicemailUri; |
| updateArchiveUI(mVoicemailUri); |
| updateArchiveButton(mVoicemailUri); |
| } |
| |
| @Override |
| protected void onFinishInflate() { |
| super.onFinishInflate(); |
| |
| mPlaybackSeek = (SeekBar) findViewById(R.id.playback_seek); |
| mStartStopButton = (ImageButton) findViewById(R.id.playback_start_stop); |
| mPlaybackSpeakerphone = (ImageButton) findViewById(R.id.playback_speakerphone); |
| mDeleteButton = (ImageButton) findViewById(R.id.delete_voicemail); |
| mArchiveButton =(ImageButton) findViewById(R.id.archive_voicemail); |
| mStateText = (TextView) findViewById(R.id.playback_state_text); |
| mPositionText = (TextView) findViewById(R.id.playback_position_text); |
| mTotalDurationText = (TextView) findViewById(R.id.total_duration_text); |
| |
| mPlaybackSeek.setOnSeekBarChangeListener(mSeekBarChangeListener); |
| mStartStopButton.setOnClickListener(mStartStopButtonListener); |
| mPlaybackSpeakerphone.setOnClickListener(mSpeakerphoneListener); |
| mDeleteButton.setOnClickListener(mDeleteButtonListener); |
| mArchiveButton.setOnClickListener(mArchiveButtonListener); |
| |
| mPositionText.setText(formatAsMinutesAndSeconds(0)); |
| mTotalDurationText.setText(formatAsMinutesAndSeconds(0)); |
| |
| mVoicemailSeekHandleEnabled = getResources().getDrawable( |
| R.drawable.ic_voicemail_seek_handle, mContext.getTheme()); |
| mVoicemailSeekHandleDisabled = getResources().getDrawable( |
| R.drawable.ic_voicemail_seek_handle_disabled, mContext.getTheme()); |
| } |
| |
| @Override |
| public void onPlaybackStarted(int duration, ScheduledExecutorService executorService) { |
| mIsPlaying = true; |
| |
| mStartStopButton.setImageResource(R.drawable.ic_pause); |
| |
| if (mPositionUpdater != null) { |
| mPositionUpdater.stopUpdating(); |
| mPositionUpdater = null; |
| } |
| mPositionUpdater = new PositionUpdater(duration, executorService); |
| mPositionUpdater.startUpdating(); |
| } |
| |
| @Override |
| public void onPlaybackStopped() { |
| mIsPlaying = false; |
| |
| mStartStopButton.setImageResource(R.drawable.ic_play_arrow); |
| |
| if (mPositionUpdater != null) { |
| mPositionUpdater.stopUpdating(); |
| mPositionUpdater = null; |
| } |
| } |
| |
| @Override |
| public void onPlaybackError() { |
| if (mPositionUpdater != null) { |
| mPositionUpdater.stopUpdating(); |
| } |
| |
| disableUiElements(); |
| mStateText.setText(getString(R.string.voicemail_playback_error)); |
| } |
| |
| @Override |
| public void onSpeakerphoneOn(boolean on) { |
| if (on) { |
| mPlaybackSpeakerphone.setImageResource(R.drawable.ic_volume_up_24dp); |
| // Speaker is now on, tapping button will turn it off. |
| mPlaybackSpeakerphone.setContentDescription( |
| mContext.getString(R.string.voicemail_speaker_off)); |
| } else { |
| mPlaybackSpeakerphone.setImageResource(R.drawable.ic_volume_down_24dp); |
| // Speaker is now off, tapping button will turn it on. |
| mPlaybackSpeakerphone.setContentDescription( |
| mContext.getString(R.string.voicemail_speaker_on)); |
| } |
| } |
| |
| @Override |
| public void setClipPosition(int positionMs, int durationMs) { |
| int seekBarPositionMs = Math.max(0, positionMs); |
| int seekBarMax = Math.max(seekBarPositionMs, durationMs); |
| if (mPlaybackSeek.getMax() != seekBarMax) { |
| mPlaybackSeek.setMax(seekBarMax); |
| } |
| |
| mPlaybackSeek.setProgress(seekBarPositionMs); |
| |
| mPositionText.setText(formatAsMinutesAndSeconds(seekBarPositionMs)); |
| mTotalDurationText.setText(formatAsMinutesAndSeconds(durationMs)); |
| } |
| |
| @Override |
| public void setIsFetchingContent() { |
| disableUiElements(); |
| mStateText.setText(getString(R.string.voicemail_fetching_content)); |
| } |
| |
| @Override |
| public void setFetchContentTimeout() { |
| mStartStopButton.setEnabled(true); |
| mStateText.setText(getString(R.string.voicemail_fetching_timout)); |
| } |
| |
| @Override |
| public int getDesiredClipPosition() { |
| return mPlaybackSeek.getProgress(); |
| } |
| |
| @Override |
| public void disableUiElements() { |
| mStartStopButton.setEnabled(false); |
| resetSeekBar(); |
| } |
| |
| @Override |
| public void enableUiElements() { |
| mDeleteButton.setEnabled(true); |
| mStartStopButton.setEnabled(true); |
| mPlaybackSeek.setEnabled(true); |
| mPlaybackSeek.setThumb(mVoicemailSeekHandleEnabled); |
| } |
| |
| @Override |
| public void resetSeekBar() { |
| mPlaybackSeek.setProgress(0); |
| mPlaybackSeek.setEnabled(false); |
| mPlaybackSeek.setThumb(mVoicemailSeekHandleDisabled); |
| } |
| |
| @Override |
| public void onDeleteCall() {} |
| |
| @Override |
| public void onDeleteVoicemail() { |
| mPresenter.onVoicemailDeletedInDatabase(); |
| } |
| |
| @Override |
| public void onGetCallDetails(PhoneCallDetails[] details) {} |
| |
| private String getString(int resId) { |
| return mContext.getString(resId); |
| } |
| |
| /** |
| * Formats a number of milliseconds as something that looks like {@code 00:05}. |
| * <p> |
| * We always use four digits, two for minutes two for seconds. In the very unlikely event |
| * that the voicemail duration exceeds 99 minutes, the display is capped at 99 minutes. |
| */ |
| private String formatAsMinutesAndSeconds(int millis) { |
| int seconds = millis / 1000; |
| int minutes = seconds / 60; |
| seconds -= minutes * 60; |
| if (minutes > 99) { |
| minutes = 99; |
| } |
| return String.format("%02d:%02d", minutes, seconds); |
| } |
| |
| /** |
| * Called when a voicemail archive succeeded. If the expanded voicemail was being |
| * archived, update the card UI. Either way, display a snackbar linking user to archive. |
| */ |
| @Override |
| public void onVoicemailArchiveSucceded(Uri voicemailUri) { |
| if (isArchiving(voicemailUri)) { |
| mIsArchiving.remove(voicemailUri); |
| if (Objects.equals(voicemailUri, mVoicemailUri)) { |
| onVoicemailArchiveResult(); |
| hideArchiveButton(); |
| } |
| } |
| |
| Snackbar.make(this, R.string.snackbar_voicemail_archived, |
| Snackbar.LENGTH_LONG) |
| .setDuration(VOICEMAIL_ARCHIVE_DELAY_MS) |
| .setAction(R.string.snackbar_voicemail_archived_goto, |
| new View.OnClickListener() { |
| @Override |
| public void onClick(View view) { |
| Intent intent = new Intent(mContext, |
| VoicemailArchiveActivity.class); |
| mContext.startActivity(intent); |
| } |
| }) |
| .setActionTextColor( |
| mContext.getResources().getColor(R.color.dialer_snackbar_action_text_color)) |
| .show(); |
| } |
| |
| /** |
| * If a voicemail archive failed, and the expanded card was being archived, update the card UI. |
| * Either way, display a toast saying the voicemail archive failed. |
| */ |
| @Override |
| public void onVoicemailArchiveFailed(Uri voicemailUri) { |
| if (isArchiving(voicemailUri)) { |
| mIsArchiving.remove(voicemailUri); |
| if (Objects.equals(voicemailUri, mVoicemailUri)) { |
| onVoicemailArchiveResult(); |
| } |
| } |
| String toastStr = mContext.getString(R.string.voicemail_archive_failed); |
| Toast.makeText(mContext, toastStr, Toast.LENGTH_SHORT).show(); |
| } |
| |
| public void hideArchiveButton() { |
| mArchiveButton.setVisibility(View.GONE); |
| mArchiveButton.setClickable(false); |
| mArchiveButton.setEnabled(false); |
| } |
| |
| /** |
| * Whenever a voicemail archive succeeds or fails, clear the text displayed in the voicemail |
| * card. |
| */ |
| private void onVoicemailArchiveResult() { |
| enableUiElements(); |
| mStateText.setText(null); |
| mArchiveButton.setColorFilter(null); |
| } |
| |
| /** |
| * Whether or not the voicemail with the given uri is being archived. |
| */ |
| private boolean isArchiving(@Nullable Uri uri) { |
| return uri != null && mIsArchiving.contains(uri); |
| } |
| |
| /** |
| * Show the proper text and hide the archive button if the voicemail is still being archived. |
| */ |
| private void updateArchiveUI(@Nullable Uri voicemailUri) { |
| if (!Objects.equals(voicemailUri, mVoicemailUri)) { |
| return; |
| } |
| if (isArchiving(voicemailUri)) { |
| // If expanded card was in the middle of archiving, disable buttons and display message |
| disableUiElements(); |
| mDeleteButton.setEnabled(false); |
| mArchiveButton.setColorFilter(getResources().getColor(R.color.setting_disabled_color)); |
| mStateText.setText(getString(R.string.voicemail_archiving_content)); |
| } else { |
| onVoicemailArchiveResult(); |
| } |
| } |
| |
| /** |
| * Hides the archive button if the voicemail has already been archived, shows otherwise. |
| * @param voicemailUri the URI of the voicemail for which the archive button needs to be updated |
| */ |
| private void updateArchiveButton(@Nullable final Uri voicemailUri) { |
| if (voicemailUri == null || |
| !Objects.equals(voicemailUri, mVoicemailUri) || isArchiving(voicemailUri) || |
| Objects.equals(voicemailUri.getAuthority(),VoicemailArchiveContract.AUTHORITY)) { |
| return; |
| } |
| mAsyncTaskExecutor.submit(Tasks.QUERY_ARCHIVED_STATUS, |
| new AsyncTask<Void, Void, Boolean>() { |
| @Override |
| public Boolean doInBackground(Void... params) { |
| Cursor cursor = mContext.getContentResolver().query(VoicemailArchive.CONTENT_URI, |
| null, VoicemailArchive.SERVER_ID + "=" + ContentUris.parseId(mVoicemailUri) |
| + " AND " + VoicemailArchive.ARCHIVED + "= 1", null, null); |
| boolean archived = cursor != null && cursor.getCount() > 0; |
| cursor.close(); |
| return archived; |
| } |
| |
| @Override |
| public void onPostExecute(Boolean archived) { |
| if (!Objects.equals(voicemailUri, mVoicemailUri)) { |
| return; |
| } |
| |
| if (archived) { |
| hideArchiveButton(); |
| } else { |
| mArchiveButton.setVisibility(View.VISIBLE); |
| mArchiveButton.setClickable(true); |
| mArchiveButton.setEnabled(true); |
| } |
| |
| } |
| }); |
| } |
| |
| @VisibleForTesting |
| public String getStateText() { |
| return mStateText.getText().toString(); |
| } |
| } |