/* * 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.Space; 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.android.dialerbind.ObjectFactory; 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 mIsArchiving = new ArrayList<>(); private SeekBar mPlaybackSeek; private ImageButton mStartStopButton; private ImageButton mPlaybackSpeakerphone; private ImageButton mDeleteButton; private ImageButton mArchiveButton; private Space mArchiveSpace; 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; if (ObjectFactory.isVoicemailArchiveEnabled(mContext)) { 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); mArchiveSpace = (Space) findViewById(R.id.space_before_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}. *

* 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() { mArchiveSpace.setVisibility(View.GONE); 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() { @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 { mArchiveSpace.setVisibility(View.VISIBLE); mArchiveButton.setVisibility(View.VISIBLE); mArchiveButton.setClickable(true); mArchiveButton.setEnabled(true); } } }); } @VisibleForTesting public String getStateText() { return mStateText.getText().toString(); } }