summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/com/android/dialer/DialtactsActivity.java5
-rw-r--r--src/com/android/dialer/calllog/CallLogAsyncTaskUtil.java25
-rw-r--r--src/com/android/dialer/database/VoicemailArchiveProvider.java10
-rw-r--r--src/com/android/dialer/voicemail/VoicemailArchiveActivity.java160
-rw-r--r--src/com/android/dialer/voicemail/VoicemailArchivePlaybackPresenter.java85
-rw-r--r--src/com/android/dialer/voicemail/VoicemailPlaybackLayout.java194
-rw-r--r--src/com/android/dialer/voicemail/VoicemailPlaybackPresenter.java281
7 files changed, 696 insertions, 64 deletions
diff --git a/src/com/android/dialer/DialtactsActivity.java b/src/com/android/dialer/DialtactsActivity.java
index d12cf24df..e775b0ad1 100644
--- a/src/com/android/dialer/DialtactsActivity.java
+++ b/src/com/android/dialer/DialtactsActivity.java
@@ -16,6 +16,7 @@
package com.android.dialer;
+import com.android.dialer.voicemail.VoicemailArchiveActivity;
import com.google.common.annotations.VisibleForTesting;
import android.app.Fragment;
@@ -690,6 +691,10 @@ public class DialtactsActivity extends TransactionSafeActivity implements View.O
handleMenuSettings();
Logger.logScreenView(ScreenEvent.SETTINGS, this);
return true;
+ } else if (resId == R.id.menu_archive) {
+ final Intent intent = new Intent(this, VoicemailArchiveActivity.class);
+ startActivity(intent);
+ return true;
}
return false;
}
diff --git a/src/com/android/dialer/calllog/CallLogAsyncTaskUtil.java b/src/com/android/dialer/calllog/CallLogAsyncTaskUtil.java
index 982591814..13de0775d 100644
--- a/src/com/android/dialer/calllog/CallLogAsyncTaskUtil.java
+++ b/src/com/android/dialer/calllog/CallLogAsyncTaskUtil.java
@@ -16,6 +16,7 @@
package com.android.dialer.calllog;
+import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
import android.content.Intent;
@@ -32,6 +33,7 @@ import com.android.contacts.common.GeoUtil;
import com.android.contacts.common.util.PermissionsUtil;
import com.android.dialer.DialtactsActivity;
import com.android.dialer.PhoneCallDetails;
+import com.android.dialer.database.VoicemailArchiveContract;
import com.android.dialer.util.AppCompatConstants;
import com.android.dialer.util.AsyncTaskExecutor;
import com.android.dialer.util.AsyncTaskExecutors;
@@ -413,16 +415,16 @@ public class CallLogAsyncTaskUtil {
}
/**
- * Updates the duration of a voicemail call log entry.
+ * Updates the duration of a voicemail call log entry if the duration given is greater than 0,
+ * and if if the duration currently in the database is less than or equal to 0 (non-existent).
*/
public static void updateVoicemailDuration(
final Context context,
final Uri voicemailUri,
- final int duration) {
- if (!PermissionsUtil.hasPhonePermissions(context)) {
+ final long duration) {
+ if (duration <= 0 || !PermissionsUtil.hasPhonePermissions(context)) {
return;
}
-
if (sAsyncTaskExecutor == null) {
initTaskExecutor();
}
@@ -430,9 +432,18 @@ public class CallLogAsyncTaskUtil {
sAsyncTaskExecutor.submit(Tasks.UPDATE_DURATION, new AsyncTask<Void, Void, Void>() {
@Override
public Void doInBackground(Void... params) {
- ContentValues values = new ContentValues(1);
- values.put(CallLog.Calls.DURATION, duration);
- context.getContentResolver().update(voicemailUri, values, null, null);
+ ContentResolver contentResolver = context.getContentResolver();
+ Cursor cursor = contentResolver.query(
+ voicemailUri,
+ new String[] { VoicemailArchiveContract.VoicemailArchive.DURATION },
+ null, null, null);
+ if (cursor != null && cursor.moveToFirst() && cursor.getInt(
+ cursor.getColumnIndex(
+ VoicemailArchiveContract.VoicemailArchive.DURATION)) <= 0) {
+ ContentValues values = new ContentValues(1);
+ values.put(CallLog.Calls.DURATION, duration);
+ context.getContentResolver().update(voicemailUri, values, null, null);
+ }
return null;
}
});
diff --git a/src/com/android/dialer/database/VoicemailArchiveProvider.java b/src/com/android/dialer/database/VoicemailArchiveProvider.java
index ae73670b8..79b7a7630 100644
--- a/src/com/android/dialer/database/VoicemailArchiveProvider.java
+++ b/src/com/android/dialer/database/VoicemailArchiveProvider.java
@@ -115,11 +115,13 @@ public class VoicemailArchiveProvider extends ContentProvider {
// Create the directory for archived voicemails if it doesn't already exist
File directory = new File(getFilesDir(), VOICEMAIL_FOLDER);
directory.mkdirs();
-
- // Update the row's _data column with a file path in the voicemails folder
Uri newUri = ContentUris.withAppendedId(uri, id);
- File voicemailFile = new File(directory, Long.toString(id));
- values.put(VoicemailArchiveContract.VoicemailArchive._DATA, voicemailFile.getPath());
+
+ // Create new file only if path is not provided to one
+ if (!values.containsKey(VoicemailArchiveContract.VoicemailArchive._DATA)) {
+ File voicemailFile = new File(directory, Long.toString(id));
+ values.put(VoicemailArchiveContract.VoicemailArchive._DATA, voicemailFile.getPath());
+ }
update(newUri, values, null, null);
return newUri;
}
diff --git a/src/com/android/dialer/voicemail/VoicemailArchiveActivity.java b/src/com/android/dialer/voicemail/VoicemailArchiveActivity.java
new file mode 100644
index 000000000..16b947cd3
--- /dev/null
+++ b/src/com/android/dialer/voicemail/VoicemailArchiveActivity.java
@@ -0,0 +1,160 @@
+/*
+ * Copyright (C) 2016 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.Intent;
+import android.database.Cursor;
+import android.os.Bundle;
+import android.support.v7.app.ActionBar;
+import android.support.v7.widget.LinearLayoutManager;
+import android.support.v7.widget.RecyclerView;
+import android.view.MenuItem;
+import android.view.View;
+
+import com.android.contacts.common.GeoUtil;
+import com.android.dialer.DialtactsActivity;
+import com.android.dialer.R;
+import com.android.dialer.TransactionSafeActivity;
+import com.android.dialer.calllog.CallLogAdapter;
+import com.android.dialer.calllog.CallLogQueryHandler;
+import com.android.dialer.calllog.ContactInfoHelper;
+import com.android.dialer.widget.EmptyContentView;
+import com.android.dialerbind.ObjectFactory;
+
+/**
+ * This activity manages all the voicemails archived by the user.
+ */
+public class VoicemailArchiveActivity extends TransactionSafeActivity
+ implements CallLogAdapter.CallFetcher, CallLogQueryHandler.Listener {
+ private RecyclerView mRecyclerView;
+ private LinearLayoutManager mLayoutManager;
+ private EmptyContentView mEmptyListView;
+ private CallLogAdapter mAdapter;
+ private VoicemailPlaybackPresenter mVoicemailPlaybackPresenter;
+ private CallLogQueryHandler mCallLogQueryHandler;
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ if (!isSafeToCommitTransactions()) {
+ return true;
+ }
+
+ switch (item.getItemId()) {
+ case android.R.id.home:
+ Intent intent = new Intent(this, DialtactsActivity.class);
+ // Clears any activities between VoicemailArchiveActivity and DialtactsActivity
+ // on the activity stack and reuses the existing instance of DialtactsActivity
+ intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
+ startActivity(intent);
+ return true;
+ }
+ return super.onOptionsItemSelected(item);
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ setContentView(R.layout.call_log_fragment);
+
+ // Make window opaque to reduce overdraw
+ getWindow().setBackgroundDrawable(null);
+
+ ActionBar actionBar = getSupportActionBar();
+ actionBar.setDisplayShowHomeEnabled(true);
+ actionBar.setDisplayHomeAsUpEnabled(true);
+ actionBar.setDisplayShowTitleEnabled(true);
+ actionBar.setElevation(0);
+
+ mCallLogQueryHandler = new CallLogQueryHandler(this, getContentResolver(), this);
+ mVoicemailPlaybackPresenter = VoicemailArchivePlaybackPresenter
+ .getInstance(this, savedInstanceState);
+
+ mRecyclerView = (RecyclerView) findViewById(R.id.recycler_view);
+ mRecyclerView.setHasFixedSize(true);
+ mLayoutManager = new LinearLayoutManager(this);
+ mRecyclerView.setLayoutManager(mLayoutManager);
+ mEmptyListView = (EmptyContentView) findViewById(R.id.empty_list_view);
+ mEmptyListView.setDescription(R.string.voicemail_archive_empty);
+ mEmptyListView.setImage(R.drawable.empty_call_log);
+
+ mAdapter = ObjectFactory.newCallLogAdapter(
+ this,
+ this,
+ new ContactInfoHelper(this, GeoUtil.getCurrentCountryIso(this)),
+ mVoicemailPlaybackPresenter,
+ CallLogAdapter.ACTIVITY_TYPE_ARCHIVE);
+ mRecyclerView.setAdapter(mAdapter);
+ fetchCalls();
+ }
+
+ @Override
+ protected void onPause() {
+ mVoicemailPlaybackPresenter.onPause();
+ mAdapter.onPause();
+ super.onPause();
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ mAdapter.onResume();
+ mVoicemailPlaybackPresenter.onResume();
+ }
+
+ @Override
+ public void onDestroy() {
+ mVoicemailPlaybackPresenter.onDestroy();
+ mAdapter.changeCursor(null);
+ super.onDestroy();
+ }
+
+ @Override
+ public void onSaveInstanceState(Bundle outState) {
+ super.onSaveInstanceState(outState);
+ mVoicemailPlaybackPresenter.onSaveInstanceState(outState);
+ }
+
+ @Override
+ public void fetchCalls() {
+ mCallLogQueryHandler.fetchVoicemailArchive();
+ }
+
+ @Override
+ public void onVoicemailStatusFetched(Cursor statusCursor) {
+ // Do nothing
+ }
+
+ @Override
+ public void onVoicemailUnreadCountFetched(Cursor cursor) {
+ // Do nothing
+ }
+
+ @Override
+ public void onMissedCallsUnreadCountFetched(Cursor cursor) {
+ // Do nothing
+ }
+
+ @Override
+ public boolean onCallsFetched(Cursor cursor) {
+ mAdapter.changeCursorVoicemail(cursor);
+ boolean showListView = cursor != null && cursor.getCount() > 0;
+ mRecyclerView.setVisibility(showListView ? View.VISIBLE : View.GONE);
+ mEmptyListView.setVisibility(!showListView ? View.VISIBLE : View.GONE);
+ return true;
+ }
+}
diff --git a/src/com/android/dialer/voicemail/VoicemailArchivePlaybackPresenter.java b/src/com/android/dialer/voicemail/VoicemailArchivePlaybackPresenter.java
new file mode 100644
index 000000000..050b8ac62
--- /dev/null
+++ b/src/com/android/dialer/voicemail/VoicemailArchivePlaybackPresenter.java
@@ -0,0 +1,85 @@
+/*
+ * Copyright (C) 2016 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.app.Activity;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.AsyncTask;
+import android.os.Bundle;
+import android.util.Log;
+import com.android.dialer.calllog.CallLogAsyncTaskUtil;
+import com.android.dialer.database.VoicemailArchiveContract;
+import java.io.FileNotFoundException;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Similar to the {@link VoicemailPlaybackPresenter}, but for the archive voicemail tab. It checks
+ * whether the voicemail file exists locally before preparing it.
+ */
+public class VoicemailArchivePlaybackPresenter extends VoicemailPlaybackPresenter {
+ private static final String TAG = "VMPlaybackPresenter";
+ private static VoicemailPlaybackPresenter sInstance;
+
+ public VoicemailArchivePlaybackPresenter(Activity activity) {
+ super(activity);
+ }
+
+ public static VoicemailPlaybackPresenter getInstance(
+ Activity activity, Bundle savedInstanceState) {
+ if (sInstance == null) {
+ sInstance = new VoicemailArchivePlaybackPresenter(activity);
+ }
+
+ sInstance.init(activity, savedInstanceState);
+ return sInstance;
+ }
+
+ @Override
+ protected void checkForContent(final OnContentCheckedListener callback) {
+ mAsyncTaskExecutor.submit(Tasks.CHECK_FOR_CONTENT, new AsyncTask<Void, Void, Boolean>() {
+ @Override
+ public Boolean doInBackground(Void... params) {
+ try {
+ // Check if the _data column of the archived voicemail is valid
+ if (mVoicemailUri != null) {
+ mContext.getContentResolver().openInputStream(mVoicemailUri);
+ return true;
+ }
+ } catch (FileNotFoundException e) {
+ Log.d(TAG, "Voicemail file not found for " + mVoicemailUri);
+ handleError(e);
+ }
+ return false;
+ }
+
+ @Override
+ public void onPostExecute(Boolean hasContent) {
+ callback.onContentChecked(hasContent);
+ }
+ });
+ }
+
+ @Override
+ protected boolean requestContent(int code) {
+ if (mContext == null || mVoicemailUri == null) {
+ return false;
+ }
+ prepareContent();
+ return true;
+ }
+}
diff --git a/src/com/android/dialer/voicemail/VoicemailPlaybackLayout.java b/src/com/android/dialer/voicemail/VoicemailPlaybackLayout.java
index 19b592d50..436fc7952 100644
--- a/src/com/android/dialer/voicemail/VoicemailPlaybackLayout.java
+++ b/src/com/android/dialer/voicemail/VoicemailPlaybackLayout.java
@@ -16,40 +16,44 @@
package com.android.dialer.voicemail;
-import android.app.Activity;
-import android.app.Fragment;
+import android.content.ContentUris;
import android.content.Context;
+import android.content.Intent;
+import android.database.Cursor;
import android.graphics.drawable.Drawable;
-import android.media.MediaPlayer;
import android.net.Uri;
-import android.os.Bundle;
+import android.os.AsyncTask;
import android.os.Handler;
-import android.os.PowerManager;
-import android.provider.VoicemailContract;
import android.util.AttributeSet;
-import android.util.Log;
import android.support.design.widget.Snackbar;
import android.view.LayoutInflater;
import android.view.View;
-import android.view.ViewGroup;
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 com.google.common.base.Preconditions;
+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;
@@ -67,6 +71,12 @@ public class VoicemailPlaybackLayout extends LinearLayout
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.
@@ -202,7 +212,7 @@ public class VoicemailPlaybackLayout extends LinearLayout
final Runnable deleteCallback = new Runnable() {
@Override
public void run() {
- if (mVoicemailUri == deleteUri) {
+ if (Objects.equals(deleteUri, mVoicemailUri)) {
CallLogAsyncTaskUtil.deleteVoicemail(mContext, deleteUri,
VoicemailPlaybackLayout.this);
}
@@ -214,8 +224,6 @@ public class VoicemailPlaybackLayout extends LinearLayout
// window.
handler.postDelayed(deleteCallback, VOICEMAIL_DELETE_DELAY_MS + 50);
- final int actionTextColor =
- mContext.getResources().getColor(R.color.dialer_snackbar_action_text_color);
Snackbar.make(VoicemailPlaybackLayout.this, R.string.snackbar_voicemail_deleted,
Snackbar.LENGTH_LONG)
.setDuration(VOICEMAIL_DELETE_DELAY_MS)
@@ -227,21 +235,44 @@ public class VoicemailPlaybackLayout extends LinearLayout
handler.removeCallbacks(deleteCallback);
}
})
- .setActionTextColor(actionTextColor)
+ .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;
@@ -256,7 +287,6 @@ public class VoicemailPlaybackLayout extends LinearLayout
public VoicemailPlaybackLayout(Context context, AttributeSet attrs) {
super(context, attrs);
-
mContext = context;
LayoutInflater inflater =
(LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
@@ -267,6 +297,8 @@ public class VoicemailPlaybackLayout extends LinearLayout
public void setPresenter(VoicemailPlaybackPresenter presenter, Uri voicemailUri) {
mPresenter = presenter;
mVoicemailUri = voicemailUri;
+ updateArchiveUI(mVoicemailUri);
+ updateArchiveButton(mVoicemailUri);
}
@Override
@@ -277,6 +309,7 @@ public class VoicemailPlaybackLayout extends LinearLayout
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);
@@ -285,6 +318,7 @@ public class VoicemailPlaybackLayout extends LinearLayout
mStartStopButton.setOnClickListener(mStartStopButtonListener);
mPlaybackSpeakerphone.setOnClickListener(mSpeakerphoneListener);
mDeleteButton.setOnClickListener(mDeleteButtonListener);
+ mArchiveButton.setOnClickListener(mArchiveButtonListener);
mPositionText.setText(formatAsMinutesAndSeconds(0));
mTotalDurationText.setText(formatAsMinutesAndSeconds(0));
@@ -358,7 +392,6 @@ public class VoicemailPlaybackLayout extends LinearLayout
mPositionText.setText(formatAsMinutesAndSeconds(seekBarPositionMs));
mTotalDurationText.setText(formatAsMinutesAndSeconds(durationMs));
- mStateText.setText(null);
}
@Override
@@ -386,6 +419,7 @@ public class VoicemailPlaybackLayout extends LinearLayout
@Override
public void enableUiElements() {
+ mDeleteButton.setEnabled(true);
mStartStopButton.setEnabled(true);
mPlaybackSeek.setEnabled(true);
mPlaybackSeek.setThumb(mVoicemailSeekHandleEnabled);
@@ -429,6 +463,134 @@ public class VoicemailPlaybackLayout extends LinearLayout
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();
diff --git a/src/com/android/dialer/voicemail/VoicemailPlaybackPresenter.java b/src/com/android/dialer/voicemail/VoicemailPlaybackPresenter.java
index fcb35e57b..3151a5ea5 100644
--- a/src/com/android/dialer/voicemail/VoicemailPlaybackPresenter.java
+++ b/src/com/android/dialer/voicemail/VoicemailPlaybackPresenter.java
@@ -19,6 +19,9 @@ package com.android.dialer.voicemail;
import com.google.common.annotations.VisibleForTesting;
import android.app.Activity;
+import android.content.ContentUris;
+import android.content.ContentValues;
+import android.content.Context;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
@@ -30,20 +33,30 @@ import android.os.AsyncTask;
import android.os.Bundle;
import android.os.Handler;
import android.os.PowerManager;
+import android.provider.CallLog;
import android.provider.VoicemailContract;
import android.support.annotation.Nullable;
import android.util.Log;
import android.view.WindowManager.LayoutParams;
-import com.android.common.io.MoreCloseables;
import com.android.dialer.calllog.CallLogAsyncTaskUtil;
+import com.android.dialer.calllog.CallLogQuery;
+import com.android.dialer.database.VoicemailArchiveContract;
import com.android.dialer.util.AsyncTaskExecutor;
import com.android.dialer.util.AsyncTaskExecutors;
-
+import com.android.common.io.MoreCloseables;
+import com.android.dialer.util.TelecomUtil;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.io.ByteStreams;
import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.ArrayList;
+import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
@@ -81,6 +94,8 @@ public class VoicemailPlaybackPresenter implements MediaPlayer.OnPreparedListene
void setClipPosition(int clipPositionInMillis, int clipLengthInMillis);
void setFetchContentTimeout();
void setIsFetchingContent();
+ void onVoicemailArchiveSucceded(Uri voicemailUri);
+ void onVoicemailArchiveFailed(Uri voicemailUri);
void setPresenter(VoicemailPlaybackPresenter presenter, Uri voicemailUri);
void resetSeekBar();
}
@@ -95,10 +110,10 @@ public class VoicemailPlaybackPresenter implements MediaPlayer.OnPreparedListene
public enum Tasks {
CHECK_FOR_CONTENT,
CHECK_CONTENT_AFTER_CHANGE,
+ ARCHIVE_VOICEMAIL
}
- private interface OnContentCheckedListener {
-
+ protected interface OnContentCheckedListener {
void onContentChecked(boolean hasContent);
}
@@ -123,6 +138,8 @@ public class VoicemailPlaybackPresenter implements MediaPlayer.OnPreparedListene
VoicemailPlaybackPresenter.class.getName() + ".CLIP_POSITION_KEY";
private static final String IS_SPEAKERPHONE_ON_KEY =
VoicemailPlaybackPresenter.class.getName() + ".IS_SPEAKER_PHONE_ON";
+ public static final int PLAYBACK_REQUEST = 0;
+ public static final int ARCHIVE_REQUEST = 1;
/**
* The most recently cached duration. We cache this since we don't want to keep requesting it
@@ -134,11 +151,11 @@ public class VoicemailPlaybackPresenter implements MediaPlayer.OnPreparedListene
private static VoicemailPlaybackPresenter sInstance;
private Activity mActivity;
- private Context mContext;
+ protected Context mContext;
private PlaybackView mView;
- private Uri mVoicemailUri;
+ protected Uri mVoicemailUri;
- private MediaPlayer mMediaPlayer;
+ protected MediaPlayer mMediaPlayer;
private int mPosition;
private boolean mIsPlaying;
// MediaPlayer crashes on some method calls if not prepared but does not have a method which
@@ -150,7 +167,7 @@ public class VoicemailPlaybackPresenter implements MediaPlayer.OnPreparedListene
private int mInitialOrientation;
// Used to run async tasks that need to interact with the UI.
- private AsyncTaskExecutor mAsyncTaskExecutor;
+ protected AsyncTaskExecutor mAsyncTaskExecutor;
private static ScheduledExecutorService mScheduledExecutorService;
/**
* Used to handle the result of a successful or time-out fetch result.
@@ -158,6 +175,7 @@ public class VoicemailPlaybackPresenter implements MediaPlayer.OnPreparedListene
* This variable is thread-contained, accessed only on the ui thread.
*/
private FetchResultHandler mFetchResultHandler;
+ private final List<FetchResultHandler> mArchiveResultHandlers = new ArrayList<>();
private Handler mHandler = new Handler();
private PowerManager.WakeLock mProximityWakeLock;
private VoicemailAudioManager mVoicemailAudioManager;
@@ -186,11 +204,10 @@ public class VoicemailPlaybackPresenter implements MediaPlayer.OnPreparedListene
/**
* Initialize variables which are activity-independent and state-independent.
*/
- private VoicemailPlaybackPresenter(Activity activity) {
+ protected VoicemailPlaybackPresenter(Activity activity) {
Context context = activity.getApplicationContext();
mAsyncTaskExecutor = AsyncTaskExecutors.createAsyncTaskExecutor();
mVoicemailAudioManager = new VoicemailAudioManager(context, this);
-
PowerManager powerManager =
(PowerManager) context.getSystemService(Context.POWER_SERVICE);
if (powerManager.isWakeLockLevelSupported(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK)) {
@@ -202,7 +219,7 @@ public class VoicemailPlaybackPresenter implements MediaPlayer.OnPreparedListene
/**
* Update variables which are activity-dependent or state-dependent.
*/
- private void init(Activity activity, Bundle savedInstanceState) {
+ protected void init(Activity activity, Bundle savedInstanceState) {
mActivity = activity;
mContext = activity;
@@ -274,11 +291,9 @@ public class VoicemailPlaybackPresenter implements MediaPlayer.OnPreparedListene
public void onContentChecked(boolean hasContent) {
if (hasContent) {
prepareContent();
- } else {
- if (mView != null) {
- mView.resetSeekBar();
- mView.setClipPosition(0, mDuration.get());
- }
+ } else if (mView != null) {
+ mView.resetSeekBar();
+ mView.setClipPosition(0, mDuration.get());
}
}
});
@@ -377,6 +392,13 @@ public class VoicemailPlaybackPresenter implements MediaPlayer.OnPreparedListene
mScheduledExecutorService = null;
}
+ if (!mArchiveResultHandlers.isEmpty()) {
+ for (FetchResultHandler fetchResultHandler : mArchiveResultHandlers) {
+ fetchResultHandler.destroy();
+ }
+ mArchiveResultHandlers.clear();
+ }
+
if (mFetchResultHandler != null) {
mFetchResultHandler.destroy();
mFetchResultHandler = null;
@@ -386,7 +408,7 @@ public class VoicemailPlaybackPresenter implements MediaPlayer.OnPreparedListene
/**
* Checks to see if we have content available for this voicemail.
*/
- private void checkForContent(final OnContentCheckedListener callback) {
+ protected void checkForContent(final OnContentCheckedListener callback) {
mAsyncTaskExecutor.submit(Tasks.CHECK_FOR_CONTENT, new AsyncTask<Void, Void, Boolean>() {
@Override
public Boolean doInBackground(Void... params) {
@@ -438,18 +460,26 @@ public class VoicemailPlaybackPresenter implements MediaPlayer.OnPreparedListene
*
* @return whether issued request to fetch content
*/
- private boolean requestContent() {
+ protected boolean requestContent(int code) {
if (mContext == null || mVoicemailUri == null) {
return false;
}
- if (mFetchResultHandler != null) {
- mFetchResultHandler.destroy();
- }
-
- mFetchResultHandler = new FetchResultHandler(new Handler(), mVoicemailUri);
+ FetchResultHandler tempFetchResultHandler =
+ new FetchResultHandler(new Handler(), mVoicemailUri, code);
- mView.setIsFetchingContent();
+ switch (code) {
+ case ARCHIVE_REQUEST:
+ mArchiveResultHandlers.add(tempFetchResultHandler);
+ break;
+ default:
+ if (mFetchResultHandler != null) {
+ mFetchResultHandler.destroy();
+ }
+ mView.setIsFetchingContent();
+ mFetchResultHandler = tempFetchResultHandler;
+ break;
+ }
// Send voicemail fetch request.
Intent intent = new Intent(VoicemailContract.ACTION_FETCH_VOICEMAIL, mVoicemailUri);
@@ -461,14 +491,18 @@ public class VoicemailPlaybackPresenter implements MediaPlayer.OnPreparedListene
private class FetchResultHandler extends ContentObserver implements Runnable {
private AtomicBoolean mIsWaitingForResult = new AtomicBoolean(true);
private final Handler mFetchResultHandler;
+ private final Uri mVoicemailUri;
+ private final int mRequestCode;
+ private Uri mArchivedVoicemailUri;
- public FetchResultHandler(Handler handler, Uri voicemailUri) {
+ public FetchResultHandler(Handler handler, Uri uri, int code) {
super(handler);
mFetchResultHandler = handler;
-
+ mRequestCode = code;
+ mVoicemailUri = uri;
if (mContext != null) {
mContext.getContentResolver().registerContentObserver(
- voicemailUri, false, this);
+ mVoicemailUri, false, this);
mFetchResultHandler.postDelayed(this, FETCH_CONTENT_TIMEOUT_MS);
}
}
@@ -481,7 +515,11 @@ public class VoicemailPlaybackPresenter implements MediaPlayer.OnPreparedListene
if (mIsWaitingForResult.getAndSet(false) && mContext != null) {
mContext.getContentResolver().unregisterContentObserver(this);
if (mView != null) {
- mView.setFetchContentTimeout();
+ if (mRequestCode == ARCHIVE_REQUEST) {
+ notifyUiOfArchiveResult(mVoicemailUri, false);
+ } else {
+ mView.setFetchContentTimeout();
+ }
}
}
}
@@ -497,9 +535,16 @@ public class VoicemailPlaybackPresenter implements MediaPlayer.OnPreparedListene
public void onChange(boolean selfChange) {
mAsyncTaskExecutor.submit(Tasks.CHECK_CONTENT_AFTER_CHANGE,
new AsyncTask<Void, Void, Boolean>() {
+
@Override
public Boolean doInBackground(Void... params) {
- return queryHasContent(mVoicemailUri);
+ boolean hasContent = queryHasContent(mVoicemailUri);
+ if (hasContent && mRequestCode == ARCHIVE_REQUEST) {
+ mArchivedVoicemailUri =
+ performArchiveVoicemailOnBackgroundThread(mVoicemailUri, true);
+ return mArchivedVoicemailUri != null;
+ }
+ return hasContent;
}
@Override
@@ -507,7 +552,12 @@ public class VoicemailPlaybackPresenter implements MediaPlayer.OnPreparedListene
if (hasContent && mContext != null && mIsWaitingForResult.getAndSet(false)) {
mContext.getContentResolver().unregisterContentObserver(
FetchResultHandler.this);
- prepareContent();
+ switch (mRequestCode) {
+ case ARCHIVE_REQUEST:
+ notifyUiOfArchiveResult(mVoicemailUri, true);
+ default:
+ prepareContent();
+ }
}
}
});
@@ -522,7 +572,7 @@ public class VoicemailPlaybackPresenter implements MediaPlayer.OnPreparedListene
* media player. If preparation is successful, the media player will {@link #onPrepared()},
* and it will call {@link #onError()} otherwise.
*/
- private void prepareContent() {
+ protected void prepareContent() {
if (mView == null) {
return;
}
@@ -564,10 +614,8 @@ public class VoicemailPlaybackPresenter implements MediaPlayer.OnPreparedListene
mIsPrepared = true;
// Update the duration in the database if it was not previously retrieved
- if (mDuration.get() == 0) {
- CallLogAsyncTaskUtil.updateVoicemailDuration(mContext, mVoicemailUri,
- mMediaPlayer.getDuration() / 1000);
- }
+ CallLogAsyncTaskUtil.updateVoicemailDuration(mContext, mVoicemailUri,
+ TimeUnit.MILLISECONDS.toSeconds(mMediaPlayer.getDuration()));
mDuration.set(mMediaPlayer.getDuration());
@@ -593,7 +641,7 @@ public class VoicemailPlaybackPresenter implements MediaPlayer.OnPreparedListene
return true;
}
- private void handleError(Exception e) {
+ protected void handleError(Exception e) {
Log.d(TAG, "handleError: Could not play voicemail " + e);
if (mIsPrepared) {
@@ -664,7 +712,7 @@ public class VoicemailPlaybackPresenter implements MediaPlayer.OnPreparedListene
if (!hasContent) {
// No local content, download from server. Queue playing if the request was
// issued,
- mIsPlaying = requestContent();
+ mIsPlaying = requestContent(PLAYBACK_REQUEST);
} else {
// Queue playing once the media play loaded the content.
mIsPlaying = true;
@@ -831,6 +879,17 @@ public class VoicemailPlaybackPresenter implements MediaPlayer.OnPreparedListene
return mIsPrepared && mMediaPlayer != null ? mMediaPlayer.getCurrentPosition() : 0;
}
+ public void notifyUiOfArchiveResult(Uri voicemailUri, boolean archived) {
+ if (mView == null) {
+ return;
+ }
+ if (archived) {
+ mView.onVoicemailArchiveSucceded(voicemailUri);
+ } else {
+ mView.onVoicemailArchiveFailed(voicemailUri);
+ }
+ }
+
/* package */ void onVoicemailDeleted() {
// Trampoline the event notification to the interested listener.
if (mOnVoicemailDeletedListener != null) {
@@ -859,6 +918,154 @@ public class VoicemailPlaybackPresenter implements MediaPlayer.OnPreparedListene
return mScheduledExecutorService;
}
+ /**
+ * If voicemail has already been downloaded, go straight to archiving. Otherwise, request
+ * the voicemail content first.
+ */
+ public void archiveContent(Uri voicemailUri, boolean archivedByUser) {
+ if (!mIsPrepared) {
+ requestContent(ARCHIVE_REQUEST);
+ } else {
+ startArchiveVoicemailTask(voicemailUri, archivedByUser);
+ }
+ }
+
+ /**
+ * Asynchronous task used to archive a voicemail given its uri.
+ */
+ private void startArchiveVoicemailTask(final Uri voicemailUri, final boolean archivedByUser) {
+ mAsyncTaskExecutor.submit(Tasks.ARCHIVE_VOICEMAIL, new AsyncTask<Void, Void, Uri>() {
+ @Override
+ public Uri doInBackground(Void... params) {
+ return performArchiveVoicemailOnBackgroundThread(voicemailUri, archivedByUser);
+ }
+
+ @Override
+ public void onPostExecute(Uri archivedVoicemailUri) {
+ notifyUiOfArchiveResult(voicemailUri, archivedVoicemailUri != null);
+ }
+ });
+ }
+
+ /**
+ * Copy the voicemail information to the local dialer database, and copy
+ * the voicemail content to a local file in the dialer application's
+ * internal storage (voicemails directory).
+ *
+ * @param voicemailUri the uri of the voicemail to archive
+ * @return If archive was successful, archived voicemail URI, otherwise null.
+ */
+ private Uri performArchiveVoicemailOnBackgroundThread(Uri voicemailUri,
+ boolean archivedByUser) {
+ Cursor callLogInfo = mContext.getContentResolver().query(
+ ContentUris.withAppendedId(CallLog.Calls.CONTENT_URI_WITH_VOICEMAIL,
+ ContentUris.parseId(mVoicemailUri)),
+ CallLogQuery._PROJECTION, null, null, null);
+ Cursor contentInfo = mContext.getContentResolver().query(
+ voicemailUri, null, null, null, null);
+
+ if (callLogInfo == null || contentInfo == null) {
+ return null;
+ }
+
+ callLogInfo.moveToFirst();
+ contentInfo.moveToFirst();
+
+ // Create values to insert into database
+ ContentValues values = new ContentValues();
+ values.put(VoicemailArchiveContract.VoicemailArchive.NUMBER,
+ contentInfo.getString(contentInfo.getColumnIndex(
+ VoicemailContract.Voicemails.NUMBER)));
+
+ values.put(VoicemailArchiveContract.VoicemailArchive.DATE,
+ contentInfo.getLong(contentInfo.getColumnIndex(
+ VoicemailContract.Voicemails.DATE)));
+
+ values.put(VoicemailArchiveContract.VoicemailArchive.DURATION,
+ contentInfo.getLong(contentInfo.getColumnIndex(
+ VoicemailContract.Voicemails.DURATION)));
+
+ values.put(VoicemailArchiveContract.VoicemailArchive.MIME_TYPE,
+ contentInfo.getString(contentInfo.getColumnIndex(
+ VoicemailContract.Voicemails.MIME_TYPE)));
+
+ values.put(VoicemailArchiveContract.VoicemailArchive.COUNTRY_ISO,
+ callLogInfo.getString(CallLogQuery.COUNTRY_ISO));
+
+ values.put(VoicemailArchiveContract.VoicemailArchive.GEOCODED_LOCATION,
+ callLogInfo.getString(CallLogQuery.GEOCODED_LOCATION));
+
+ values.put(VoicemailArchiveContract.VoicemailArchive.CACHED_NAME,
+ callLogInfo.getString(CallLogQuery.CACHED_NAME));
+
+ values.put(VoicemailArchiveContract.VoicemailArchive.CACHED_NUMBER_TYPE,
+ callLogInfo.getInt(CallLogQuery.CACHED_NUMBER_TYPE));
+
+ values.put(VoicemailArchiveContract.VoicemailArchive.CACHED_NUMBER_LABEL,
+ callLogInfo.getString(CallLogQuery.CACHED_NUMBER_LABEL));
+
+ values.put(VoicemailArchiveContract.VoicemailArchive.CACHED_LOOKUP_URI,
+ callLogInfo.getString(CallLogQuery.CACHED_LOOKUP_URI));
+
+ values.put(VoicemailArchiveContract.VoicemailArchive.CACHED_MATCHED_NUMBER,
+ callLogInfo.getString(CallLogQuery.CACHED_MATCHED_NUMBER));
+
+ values.put(VoicemailArchiveContract.VoicemailArchive.CACHED_NORMALIZED_NUMBER,
+ callLogInfo.getString(CallLogQuery.CACHED_NORMALIZED_NUMBER));
+
+ values.put(VoicemailArchiveContract.VoicemailArchive.CACHED_FORMATTED_NUMBER,
+ callLogInfo.getString(CallLogQuery.CACHED_FORMATTED_NUMBER));
+
+ values.put(VoicemailArchiveContract.VoicemailArchive.ARCHIVED, archivedByUser);
+
+ values.put(VoicemailArchiveContract.VoicemailArchive.NUMBER_PRESENTATION,
+ callLogInfo.getInt(CallLogQuery.NUMBER_PRESENTATION));
+
+ values.put(VoicemailArchiveContract.VoicemailArchive.ACCOUNT_COMPONENT_NAME,
+ callLogInfo.getString(CallLogQuery.ACCOUNT_COMPONENT_NAME));
+
+ values.put(VoicemailArchiveContract.VoicemailArchive.ACCOUNT_ID,
+ callLogInfo.getString(CallLogQuery.ACCOUNT_ID));
+
+ values.put(VoicemailArchiveContract.VoicemailArchive.FEATURES,
+ callLogInfo.getInt(CallLogQuery.FEATURES));
+
+ values.put(VoicemailArchiveContract.VoicemailArchive.SERVER_ID,
+ contentInfo.getInt(contentInfo.getColumnIndex(
+ VoicemailContract.Voicemails._ID)));
+
+ values.put(VoicemailArchiveContract.VoicemailArchive.TRANSCRIPTION,
+ contentInfo.getString(contentInfo.getColumnIndex(
+ VoicemailContract.Voicemails.TRANSCRIPTION)));
+
+ values.put(VoicemailArchiveContract.VoicemailArchive.CACHED_PHOTO_URI,
+ callLogInfo.getLong(CallLogQuery.CACHED_PHOTO_URI));
+
+ callLogInfo.close();
+ contentInfo.close();
+
+ // Insert info into dialer database
+ Uri archivedVoicemailUri = mContext.getContentResolver().insert(
+ VoicemailArchiveContract.VoicemailArchive.CONTENT_URI, values);
+ try {
+ // Copy voicemail content to a local file
+ InputStream inputStream = mContext.getContentResolver()
+ .openInputStream(voicemailUri);
+ OutputStream outputStream = mContext.getContentResolver()
+ .openOutputStream(archivedVoicemailUri);
+
+ ByteStreams.copy(inputStream, outputStream);
+ inputStream.close();
+ outputStream.close();
+ } catch (IOException e) {
+ // Roll back insert if new file creation failed
+ mContext.getContentResolver().delete(archivedVoicemailUri, null, null);
+ Log.w(TAG, "Failed to copy voicemail content to temporary file");
+ return null;
+ }
+ return archivedVoicemailUri;
+ }
+
@VisibleForTesting
public boolean isPlaying() {
return mIsPlaying;