integrate voice recorder

Daniel Gultsch created

Change summary

README.md                                                                      |   1 
src/main/AndroidManifest.xml                                                   |  12 
src/main/java/eu/siacs/conversations/persistance/FileBackend.java              |  40 
src/main/java/eu/siacs/conversations/ui/ConversationFragment.java              |  77 
src/main/java/eu/siacs/conversations/ui/RecordingActivity.java                 | 192 
src/main/java/eu/siacs/conversations/ui/util/AttachmentTool.java               |   4 
src/main/java/eu/siacs/conversations/ui/util/ConversationMenuConfigurator.java |  14 
src/main/java/eu/siacs/conversations/utils/ThemeHelper.java                    |  15 
src/main/res/layout/activity_recording.xml                                     |  47 
src/main/res/values/attrs.xml                                                  |   1 
src/main/res/values/strings.xml                                                |   3 
src/main/res/values/styles.xml                                                 |   6 
src/main/res/values/themes.xml                                                 |  43 
13 files changed, 369 insertions(+), 86 deletions(-)

Detailed changes

README.md πŸ”—

@@ -35,7 +35,6 @@
   privacy
 * Rely on existing, well established protocols (XMPP)
 * Do not require a Google Account or specifically Google Cloud Messaging (GCM)
-* Require as few permissions as possible
 
 ## Features
 

src/main/AndroidManifest.xml πŸ”—

@@ -22,6 +22,7 @@
     <uses-feature android:name="android.hardware.location.network" android:required="false" />
 
     <uses-permission android:name="android.permission.CAMERA" />
+    <uses-permission android:name="android.permission.RECORD_AUDIO"/>
 
     <uses-permission
         android:name="android.permission.READ_PHONE_STATE"
@@ -58,12 +59,11 @@
         </receiver>
         <activity
             android:name=".ui.ShareLocationActivity"
-            android:label="@string/title_activity_share_location" >
-            <intent-filter>
-                <action android:name="eu.siacs.conversations.location.request" />
-                <category android:name="android.intent.category.DEFAULT" />
-            </intent-filter>
-        </activity>
+            android:label="@string/title_activity_share_location"/>
+        <activity
+            android:name=".ui.RecordingActivity"
+            android:theme="@style/ConversationsTheme.Dialog"
+            android:configChanges="orientation|screenSize"/>
         <activity
             android:name=".ui.ShowLocationActivity"
             android:label="@string/title_activity_show_location" >

src/main/java/eu/siacs/conversations/persistance/FileBackend.java πŸ”—

@@ -55,6 +55,7 @@ import eu.siacs.conversations.R;
 import eu.siacs.conversations.entities.DownloadableFile;
 import eu.siacs.conversations.entities.Message;
 import eu.siacs.conversations.services.XmppConnectionService;
+import eu.siacs.conversations.ui.RecordingActivity;
 import eu.siacs.conversations.utils.CryptoHelper;
 import eu.siacs.conversations.utils.ExifHelper;
 import eu.siacs.conversations.utils.FileUtils;
@@ -76,11 +77,13 @@ public class FileBackend {
 		this.mXmppConnectionService = service;
 	}
 
-	private void createNoMedia() {
-		final File nomedia = new File(getConversationsDirectory("Files") + ".nomedia");
-		if (!nomedia.exists()) {
+	private void createNoMedia(File diretory) {
+		final File noMedia = new File(diretory,".nomedia");
+		if (!noMedia.exists()) {
 			try {
-				nomedia.createNewFile();
+				if (!noMedia.createNewFile()) {
+					Log.d(Config.LOGTAG,"created nomedia file "+noMedia.getAbsolutePath());
+				}
 			} catch (Exception e) {
 				Log.d(Config.LOGTAG, "could not create nomedia file");
 			}
@@ -88,16 +91,25 @@ public class FileBackend {
 	}
 
 	public void updateMediaScanner(File file) {
-		String path = file.getAbsolutePath();
-		if (!path.startsWith(getConversationsDirectory("Files"))) {
+		if (!isInDirectoryThatShouldNotBeScanned(mXmppConnectionService, file)) {
 			Intent intent = new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE);
 			intent.setData(Uri.fromFile(file));
 			mXmppConnectionService.sendBroadcast(intent);
-		} else {
-			createNoMedia();
+		} else if (file.getAbsolutePath().startsWith(getAppMediaDirectory(mXmppConnectionService))) {
+			createNoMedia(file.getParentFile());
 		}
 	}
 
+	private static boolean isInDirectoryThatShouldNotBeScanned(Context context, File file) {
+		String path = file.getAbsolutePath();
+		for(String type : new String[]{RecordingActivity.STORAGE_DIRECTORY_TYPE_NAME, "Files"}) {
+			if (path.startsWith(getConversationsDirectory(context, type))) {
+				return true;
+			}
+		}
+		return false;
+	}
+
 	public boolean deleteFile(Message message) {
 		File file = getFile(message);
 		if (file.delete()) {
@@ -186,13 +198,21 @@ public class FileBackend {
 	}
 
 	public String getConversationsDirectory(final String type) {
+		return getConversationsDirectory(mXmppConnectionService, type);
+	}
+
+	public static String getConversationsDirectory(Context context, final String type) {
 		if (Config.ONLY_INTERNAL_STORAGE) {
-			return mXmppConnectionService.getFilesDir().getAbsolutePath() + "/" + type + "/";
+			return context.getFilesDir().getAbsolutePath() + "/" + type + "/";
 		} else {
-			return Environment.getExternalStorageDirectory() + "/Conversations/Media/Conversations " + type + "/";
+			return getAppMediaDirectory(context)+context.getString(R.string.app_name)+" " + type + "/";
 		}
 	}
 
+	public static String getAppMediaDirectory(Context context) {
+		return Environment.getExternalStorageDirectory().getAbsolutePath()+"/"+context.getString(R.string.app_name)+"/Media/";
+	}
+
 	public static String getConversationsLogsDirectory() {
 		return Environment.getExternalStorageDirectory().getAbsolutePath() + "/Conversations/";
 	}

src/main/java/eu/siacs/conversations/ui/ConversationFragment.java πŸ”—

@@ -279,7 +279,7 @@ public class ConversationFragment extends XmppFragment implements EditMessage.Ke
 					return false;
 				}
 			}
-			if (hasStoragePermission(REQUEST_ADD_EDITOR_CONTENT)) {
+			if (hasPermissions(REQUEST_ADD_EDITOR_CONTENT, Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
 				attachImageToConversation(inputContentInfo.getContentUri());
 			} else {
 				mPendingEditorContent = inputContentInfo.getContentUri();
@@ -1284,12 +1284,16 @@ public class ConversationFragment extends XmppFragment implements EditMessage.Ke
 	}
 
 	public void attachFile(final int attachmentChoice) {
-		if (attachmentChoice == ATTACHMENT_CHOICE_TAKE_PHOTO || attachmentChoice == ATTACHMENT_CHOICE_RECORD_VIDEO) {
-			if (!hasStorageAndCameraPermission(attachmentChoice)) {
+		if (attachmentChoice == ATTACHMENT_CHOICE_RECORD_VOICE) {
+			if (!hasPermissions(attachmentChoice, Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.RECORD_AUDIO)) {
+				return;
+			}
+		} else if (attachmentChoice == ATTACHMENT_CHOICE_TAKE_PHOTO || attachmentChoice == ATTACHMENT_CHOICE_RECORD_VIDEO) {
+			if (!hasPermissions(attachmentChoice, Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.CAMERA)) {
 				return;
 			}
 		} else if (attachmentChoice != ATTACHMENT_CHOICE_LOCATION) {
-			if (!Config.ONLY_INTERNAL_STORAGE && !hasStoragePermission(attachmentChoice)) {
+			if (!hasPermissions(attachmentChoice, Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
 				return;
 			}
 		}
@@ -1365,7 +1369,10 @@ public class ConversationFragment extends XmppFragment implements EditMessage.Ke
 				}
 			} else {
 				@StringRes int res;
-				if (Manifest.permission.CAMERA.equals(getFirstDenied(grantResults, permissions))) {
+				String firstDenied = getFirstDenied(grantResults, permissions);
+				if (Manifest.permission.RECORD_AUDIO.equals(firstDenied)) {
+					res = R.string.no_microphone_permission;
+				} else if (Manifest.permission.CAMERA.equals(firstDenied)) {
 					res = R.string.no_camera_permission;
 				} else {
 					res = R.string.no_storage_permission;
@@ -1375,7 +1382,7 @@ public class ConversationFragment extends XmppFragment implements EditMessage.Ke
 	}
 
 	public void startDownloadable(Message message) {
-		if (!Config.ONLY_INTERNAL_STORAGE && !hasStoragePermission(REQUEST_START_DOWNLOAD)) {
+		if (!hasPermissions(REQUEST_START_DOWNLOAD, Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
 			this.mPendingDownloadableMessage = message;
 			return;
 		}
@@ -1443,27 +1450,16 @@ public class ConversationFragment extends XmppFragment implements EditMessage.Ke
 		builder.create().show();
 	}
 
-	private boolean hasStoragePermission(int requestCode) {
-		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
-			if (activity.checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
-				requestPermissions(new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, requestCode);
-				return false;
-			} else {
-				return true;
-			}
-		} else {
-			return true;
-		}
-	}
-
-	private boolean hasStorageAndCameraPermission(int requestCode) {
+	private boolean hasPermissions(int requestCode, String... permissions) {
 		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
-			List<String> missingPermissions = new ArrayList<>();
-			if (!Config.ONLY_INTERNAL_STORAGE && activity.checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
-				missingPermissions.add(Manifest.permission.WRITE_EXTERNAL_STORAGE);
-			}
-			if (activity.checkSelfPermission(Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) {
-				missingPermissions.add(Manifest.permission.CAMERA);
+			final List<String> missingPermissions = new ArrayList<>();
+			for(String permission : permissions) {
+				if (Config.ONLY_INTERNAL_STORAGE && permission.equals(Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
+					continue;
+				}
+				if (activity.checkSelfPermission(permission) != PackageManager.PERMISSION_GRANTED) {
+					missingPermissions.add(permission);
+				}
 			}
 			if (missingPermissions.size() == 0) {
 				return true;
@@ -1489,7 +1485,6 @@ public class ConversationFragment extends XmppFragment implements EditMessage.Ke
 		final PresenceSelector.OnPresenceSelected callback = () -> {
 			Intent intent = new Intent();
 			boolean chooser = false;
-			String fallbackPackageId = null;
 			switch (attachmentChoice) {
 				case ATTACHMENT_CHOICE_CHOOSE_IMAGE:
 					intent.setAction(Intent.ACTION_GET_CONTENT);
@@ -1515,12 +1510,10 @@ public class ConversationFragment extends XmppFragment implements EditMessage.Ke
 					intent.setAction(Intent.ACTION_GET_CONTENT);
 					break;
 				case ATTACHMENT_CHOICE_RECORD_VOICE:
-					intent.setAction(MediaStore.Audio.Media.RECORD_SOUND_ACTION);
-					fallbackPackageId = "eu.siacs.conversations.voicerecorder";
+					intent = new Intent(getActivity(), RecordingActivity.class);
 					break;
 				case ATTACHMENT_CHOICE_LOCATION:
-					intent.setAction("eu.siacs.conversations.location.request");
-					fallbackPackageId = "eu.siacs.conversations.sharelocation";
+					intent = new Intent(getActivity(), ShareLocationActivity.class);
 					break;
 			}
 			if (intent.resolveActivity(getActivity().getPackageManager()) != null) {
@@ -1531,8 +1524,6 @@ public class ConversationFragment extends XmppFragment implements EditMessage.Ke
 				} else {
 					startActivityForResult(intent, attachmentChoice);
 				}
-			} else if (fallbackPackageId != null) {
-				startActivity(getInstallApkIntent(fallbackPackageId));
 			}
 		};
 		if (account.httpUploadAvailable() || attachmentChoice == ATTACHMENT_CHOICE_LOCATION) {
@@ -1543,28 +1534,8 @@ public class ConversationFragment extends XmppFragment implements EditMessage.Ke
 		}
 	}
 
-	private Intent getInstallApkIntent(final String packageId) {
-		Intent intent = new Intent(Intent.ACTION_VIEW);
-		intent.setData(Uri.parse("market://details?id=" + packageId));
-		if (intent.resolveActivity(getActivity().getPackageManager()) != null) {
-			return intent;
-		} else {
-			intent.setData(Uri.parse("http://play.google.com/store/apps/details?id=" + packageId));
-			return intent;
-		}
-	}
-
 	@Override
 	public void onResume() {
-		new Handler().post(() -> {
-			final Activity activity = getActivity();
-			if (activity == null) {
-				return;
-			}
-			final PackageManager packageManager = activity.getPackageManager();
-			ConversationMenuConfigurator.updateAttachmentAvailability(packageManager);
-			getActivity().invalidateOptionsMenu();
-		});
 		super.onResume();
 		binding.messagesView.post(this::fireReadEvent);
 	}

src/main/java/eu/siacs/conversations/ui/RecordingActivity.java πŸ”—

@@ -0,0 +1,192 @@
+package eu.siacs.conversations.ui;
+
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+import android.media.MediaRecorder;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.Environment;
+import android.os.FileObserver;
+import android.os.Handler;
+import android.os.SystemClock;
+import android.util.Log;
+import android.view.View;
+import android.view.WindowManager;
+import android.widget.Button;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import java.io.File;
+import java.io.IOException;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.Locale;
+
+import eu.siacs.conversations.Config;
+import eu.siacs.conversations.R;
+import eu.siacs.conversations.persistance.FileBackend;
+import eu.siacs.conversations.utils.ThemeHelper;
+
+public class RecordingActivity extends Activity implements View.OnClickListener {
+
+	public static String STORAGE_DIRECTORY_TYPE_NAME = "Recordings";
+
+	private TextView mTimerTextView;
+	private Button mCancelButton;
+	private Button mStopButton;
+
+	private MediaRecorder mRecorder;
+	private long mStartTime = 0;
+
+	private Handler mHandler = new Handler();
+	private Runnable mTickExecutor = new Runnable() {
+		@Override
+		public void run() {
+			tick();
+			mHandler.postDelayed(mTickExecutor, 100);
+		}
+	};
+
+	private File mOutputFile;
+	private boolean mShouldFinishAfterWrite = false;
+
+	private FileObserver mFileObserver;
+
+	@Override
+	protected void onCreate(Bundle savedInstanceState) {
+		setTheme(ThemeHelper.findDialog(this));
+		super.onCreate(savedInstanceState);
+		setContentView(R.layout.activity_recording);
+		this.mTimerTextView = (TextView) this.findViewById(R.id.timer);
+		this.mCancelButton = (Button) this.findViewById(R.id.cancel_button);
+		this.mCancelButton.setOnClickListener(this);
+		this.mStopButton = (Button) this.findViewById(R.id.share_button);
+		this.mStopButton.setOnClickListener(this);
+		this.setFinishOnTouchOutside(false);
+		getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
+	}
+
+	@Override
+	protected void onStart() {
+		super.onStart();
+		if (!startRecording()) {
+			mStopButton.setEnabled(false);
+			Toast.makeText(this, R.string.unable_to_start_recording, Toast.LENGTH_SHORT).show();
+		}
+	}
+
+	@Override
+	protected void onStop() {
+		super.onStop();
+		if (mRecorder != null) {
+			mHandler.removeCallbacks(mTickExecutor);
+			stopRecording(false);
+		}
+		if (mFileObserver != null) {
+			mFileObserver.stopWatching();
+		}
+	}
+
+	private boolean startRecording() {
+		mRecorder = new MediaRecorder();
+		mRecorder.setAudioSource(MediaRecorder.AudioSource.MIC);
+		mRecorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4);
+		mRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.HE_AAC);
+		mRecorder.setAudioEncodingBitRate(48000);
+		mRecorder.setAudioSamplingRate(16000);
+		setupOutputFile();
+		mRecorder.setOutputFile(mOutputFile.getAbsolutePath());
+
+		try {
+			mRecorder.prepare();
+			mRecorder.start();
+			mStartTime = SystemClock.elapsedRealtime();
+			mHandler.postDelayed(mTickExecutor, 100);
+			Log.d("Voice Recorder", "started recording to " + mOutputFile.getAbsolutePath());
+			return true;
+		} catch (Exception e) {
+			Log.e("Voice Recorder", "prepare() failed " + e.getMessage());
+			return false;
+		}
+	}
+
+	protected void stopRecording(boolean saveFile) {
+		mShouldFinishAfterWrite = saveFile;
+		mRecorder.stop();
+		mRecorder.release();
+		mRecorder = null;
+		mStartTime = 0;
+		if (!saveFile && mOutputFile != null) {
+			if (mOutputFile.delete()) {
+				Log.d(Config.LOGTAG,"deleted canceled recording");
+			}
+		}
+	}
+
+	private static File generateOutputFilename(Context context) {
+		SimpleDateFormat dateFormat = new SimpleDateFormat("yyyyMMdd_HHmmssSSS", Locale.US);
+		String filename = "RECORDING_" + dateFormat.format(new Date()) + ".m4a";
+		return new File(FileBackend.getConversationsDirectory(context, STORAGE_DIRECTORY_TYPE_NAME) + "/" + filename);
+	}
+
+	private void setupOutputFile() {
+		mOutputFile = generateOutputFilename(this);
+		File parentDirectory = mOutputFile.getParentFile();
+		if (parentDirectory.mkdirs()) {
+			Log.d(Config.LOGTAG, "created " + parentDirectory.getAbsolutePath());
+		}
+		File noMedia = new File(parentDirectory, ".nomedia");
+		if (!noMedia.exists()) {
+			try {
+				if (noMedia.createNewFile()) {
+					Log.d(Config.LOGTAG, "created nomedia file in " + parentDirectory.getAbsolutePath());
+				}
+			} catch (IOException e) {
+				Log.d(Config.LOGTAG, "unable to create nomedia file in " + parentDirectory.getAbsolutePath(), e);
+			}
+		}
+		setupFileObserver(parentDirectory);
+	}
+
+	private void setupFileObserver(File directory) {
+		mFileObserver = new FileObserver(directory.getAbsolutePath()) {
+			@Override
+			public void onEvent(int event, String s) {
+				if (s != null && s.equals(mOutputFile.getName()) && event == FileObserver.CLOSE_WRITE) {
+					if (mShouldFinishAfterWrite) {
+						setResult(Activity.RESULT_OK, new Intent().setData(Uri.fromFile(mOutputFile)));
+						finish();
+					}
+				}
+			}
+		};
+		mFileObserver.startWatching();
+	}
+
+	private void tick() {
+		long time = (mStartTime < 0) ? 0 : (SystemClock.elapsedRealtime() - mStartTime);
+		int minutes = (int) (time / 60000);
+		int seconds = (int) (time / 1000) % 60;
+		int milliseconds = (int) (time / 100) % 10;
+		mTimerTextView.setText(minutes + ":" + (seconds < 10 ? "0" + seconds : seconds) + "." + milliseconds);
+	}
+
+	@Override
+	public void onClick(View view) {
+		switch (view.getId()) {
+			case R.id.cancel_button:
+				mHandler.removeCallbacks(mTickExecutor);
+				stopRecording(false);
+				setResult(RESULT_CANCELED);
+				finish();
+				break;
+			case R.id.share_button:
+				mStopButton.setEnabled(false);
+				mStopButton.setText(R.string.please_wait);
+				mHandler.removeCallbacks(mTickExecutor);
+				mHandler.postDelayed(() -> stopRecording(true), 500);
+				break;
+		}
+	}
+}

src/main/java/eu/siacs/conversations/ui/util/AttachmentTool.java πŸ”—

@@ -45,8 +45,8 @@ public class AttachmentTool {
 		if (intent == null) {
 			return uris;
 		}
-		Uri uri = intent.getData();
-		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2 && uri == null) {
+		final Uri uri = intent.getData();
+		if (uri == null) {
 			final ClipData clipData = intent.getClipData();
 			if (clipData != null) {
 				for (int i = 0; i < clipData.getItemCount(); ++i) {

src/main/java/eu/siacs/conversations/ui/util/ConversationMenuConfigurator.java πŸ”—

@@ -45,13 +45,7 @@ import eu.siacs.conversations.entities.Message;
 
 public class ConversationMenuConfigurator {
 
-	private static boolean showSoundRecorderAttachment = false;
-	private static boolean showLocationAttachment = false;
-
-
 	public static void configureAttachmentMenu(@NonNull Conversation conversation, Menu menu) {
-		final MenuItem menuAttachSoundRecorder = menu.findItem(R.id.attach_record_voice);
-		final MenuItem menuAttachLocation = menu.findItem(R.id.attach_location);
 		final MenuItem menuAttach = menu.findItem(R.id.action_attach_file);
 
 		final boolean visible;
@@ -66,9 +60,6 @@ public class ConversationMenuConfigurator {
 		if (!visible) {
 			return;
 		}
-
-		menuAttachLocation.setVisible(showLocationAttachment);
-		menuAttachSoundRecorder.setVisible(showSoundRecorderAttachment);
 	}
 
 	public static void configureEncryptionMenu(@NonNull Conversation conversation, Menu menu) {
@@ -118,9 +109,4 @@ public class ConversationMenuConfigurator {
 				break;
 		}
 	}
-
-	public static void updateAttachmentAvailability(PackageManager packageManager) {
-		showSoundRecorderAttachment = new Intent(MediaStore.Audio.Media.RECORD_SOUND_ACTION).resolveActivity(packageManager) != null;
-		showLocationAttachment = new Intent("eu.siacs.conversations.location.request").resolveActivity(packageManager) != null;
-	}
 }

src/main/java/eu/siacs/conversations/utils/ThemeHelper.java πŸ”—

@@ -61,6 +61,21 @@ public class ThemeHelper {
 		}
 	}
 
+	public static int findDialog(Context context) {
+		final SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context);
+		final Resources resources = context.getResources();
+		final boolean dark = sharedPreferences.getString(SettingsActivity.THEME, resources.getString(R.string.theme)).equals("dark");
+		final String fontSize = sharedPreferences.getString("font_size", resources.getString(R.string.default_font_size));
+		switch (fontSize) {
+			case "medium":
+				return dark ? R.style.ConversationsTheme_Dark_Dialog_Medium : R.style.ConversationsTheme_Dialog_Medium;
+			case "large":
+				return dark ? R.style.ConversationsTheme_Dark_Dialog_Large : R.style.ConversationsTheme_Dialog_Large;
+			default:
+				return dark ? R.style.ConversationsTheme_Dark_Dialog : R.style.ConversationsTheme_Dialog;
+		}
+	}
+
 	public static boolean isDark(@StyleRes int id) {
 		switch (id) {
 			case R.style.ConversationsTheme_Dark:

src/main/res/layout/activity_recording.xml πŸ”—

@@ -0,0 +1,47 @@
+<?xml version="1.0" encoding="utf-8"?>
+<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:background="?attr/color_background_primary">
+
+    <LinearLayout
+        android:id="@+id/button_bar"
+        android:layout_width="fill_parent"
+        android:layout_height="wrap_content"
+        android:layout_alignParentLeft="true"
+        android:layout_alignParentStart="true"
+        android:layout_below="@+id/timer">
+
+        <Button
+            android:id="@+id/cancel_button"
+            style="@style/Widget.Conversations.Button.Borderless"
+            android:layout_width="0dp"
+            android:layout_height="wrap_content"
+            android:layout_weight="1"
+            android:text="@string/cancel"/>
+
+        <View
+            android:layout_width="1dp"
+            android:layout_height="fill_parent"
+            android:layout_marginBottom="7dp"
+            android:layout_marginTop="7dp"
+            android:background="?attr/divider"/>
+
+        <Button
+            android:id="@+id/share_button"
+            style="@style/Widget.Conversations.Button.Borderless"
+            android:layout_width="0dp"
+            android:layout_height="wrap_content"
+            android:layout_weight="1"
+            android:text="@string/share"/>
+    </LinearLayout>
+
+    <TextView
+        android:id="@+id/timer"
+        android:textAppearance="@style/TextAppearance.Conversations.Display2.Monospace"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_alignParentTop="true"
+        android:layout_centerHorizontal="true"
+        android:text="0:00.0"/>
+</RelativeLayout>

src/main/res/values/attrs.xml πŸ”—

@@ -6,6 +6,7 @@
     <attr name="TextSizeSubhead" format="dimension" />
     <attr name="TextSizeBody1" format="dimension" />
     <attr name="TextSizeBody2" format="dimension" />
+    <attr name="TextSizeDisplay2" format="dimension" />
     <attr name="TextSizeInput" format="dimension" />
     <attr name="TextSeparation" format="dimension"/>
 

src/main/res/values/strings.xml πŸ”—

@@ -699,4 +699,7 @@
     <string name="title_activity_share_location">Share location</string>
     <string name="title_activity_show_location">Show location</string>
     <string name="share">Share</string>
+    <string name="unable_to_start_recording">Unable to start recording</string>
+    <string name="please_wait">Please wait…</string>
+    <string name="no_microphone_permission">Conversations needs access to the microphone</string>
 </resources>

src/main/res/values/styles.xml πŸ”—

@@ -1,5 +1,11 @@
 <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
 
+    <style name="TextAppearance.Conversations.Display2.Monospace" parent="TextAppearance.AppCompat.Display2">
+        <item name="android:textSize">?TextSizeDisplay2</item>
+        <item name="android:fontFamily" tools:targetApi="jelly_bean">monospace</item>
+        <item name="android:typeface">monospace</item>
+    </style>
+
     <style name="TextAppearance.Conversations.Title" parent="TextAppearance.AppCompat.Title">
         <item name="android:textSize">?TextSizeTitle</item>
     </style>

src/main/res/values/themes.xml πŸ”—

@@ -239,4 +239,47 @@
         <item name="colorPrimaryDark">@color/grey300</item>
         <item name="android:windowBackground">@drawable/background</item>
     </style>
+
+    <style name="ConversationsTheme.Dialog" parent="@style/Theme.AppCompat.Light.Dialog">
+        <item name="colorPrimary">@color/primary500</item>
+        <item name="colorPrimaryDark">@color/primary700</item>
+        <item name="colorAccent">@color/accent</item>
+        <item name="color_background_primary">@color/grey50</item>
+        <item name="divider">@color/black12</item>
+        <item name="TextSizeBody2">14sp</item>
+        <item name="TextSizeDisplay2">45sp</item>
+        <item name="android:windowNoTitle">true</item>
+    </style>
+
+    <style name="ConversationsTheme.Dark.Dialog" parent="@style/Theme.AppCompat.Dialog">
+        <item name="colorPrimary">@color/primary500</item>
+        <item name="colorPrimaryDark">@color/primary700</item>
+        <item name="colorAccent">@color/accent</item>
+        <item name="color_background_primary">@color/grey800</item>
+        <item name="divider">@color/white12</item>
+        <item name="TextSizeBody2">14sp</item>
+        <item name="TextSizeDisplay2">45sp</item>
+        <item name="android:windowNoTitle">true</item>
+    </style>
+
+    <style name="ConversationsTheme.Dialog.Medium" parent="ConversationsTheme.Dialog">
+        <item name="TextSizeBody2">16sp</item>
+        <item name="TextSizeDisplay2">51sp</item>
+    </style>
+
+    <style name="ConversationsTheme.Dark.Dialog.Medium" parent="ConversationsTheme.Dark.Dialog">
+        <item name="TextSizeBody2">16sp</item>
+        <item name="TextSizeDisplay2">51sp</item>
+    </style>
+
+    <style name="ConversationsTheme.Dialog.Large" parent="ConversationsTheme.Dialog">
+        <item name="TextSizeBody2">18sp</item>
+        <item name="TextSizeDisplay2">56sp</item>
+    </style>
+
+    <style name="ConversationsTheme.Dark.Dialog.Large" parent="ConversationsTheme.Dark.Dialog">
+        <item name="TextSizeBody2">18sp</item>
+        <item name="TextSizeTitle">56sp</item>
+    </style>
+
 </resources>