diff --git a/app/build.gradle b/app/build.gradle index bd19c9e0..855ab4fd 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -84,6 +84,7 @@ dependencies { def jsoup_version = "1.11.3" def jcharset_version = "2.0" def dnsjava_version = "2.1.8" + def openpgp_version = "12.0" implementation "androidx.appcompat:appcompat:$androidx_version" implementation "androidx.recyclerview:recyclerview:$androidx_version" @@ -113,6 +114,9 @@ dependencies { // http://www.xbill.org/dnsjava/ implementation "dnsjava:dnsjava:$dnsjava_version" + // https://github.com/open-keychain/openpgp-api + implementation "org.sufficientlysecure:openpgp-api:$openpgp_version" + // git clone https://android.googlesource.com/platform/frameworks/opt/colorpicker implementation project(path: ':colorpicker') } diff --git a/app/src/main/java/eu/faircode/email/ActivityCompose.java b/app/src/main/java/eu/faircode/email/ActivityCompose.java index 1a5d8485..c1b4f12d 100644 --- a/app/src/main/java/eu/faircode/email/ActivityCompose.java +++ b/app/src/main/java/eu/faircode/email/ActivityCompose.java @@ -35,6 +35,7 @@ public class ActivityCompose extends ActivityBilling implements FragmentManager. static final int REQUEST_CONTACT_BCC = 3; static final int REQUEST_IMAGE = 4; static final int REQUEST_ATTACHMENT = 5; + static final int REQUEST_ENCRYPT = 6; @Override protected void onCreate(Bundle savedInstanceState) { diff --git a/app/src/main/java/eu/faircode/email/ActivityView.java b/app/src/main/java/eu/faircode/email/ActivityView.java index f45c5460..5852a281 100644 --- a/app/src/main/java/eu/faircode/email/ActivityView.java +++ b/app/src/main/java/eu/faircode/email/ActivityView.java @@ -20,6 +20,7 @@ package eu.faircode.email; */ import android.app.Activity; +import android.app.PendingIntent; import android.content.BroadcastReceiver; import android.content.Context; import android.content.DialogInterface; @@ -45,14 +46,22 @@ import android.widget.ListView; import android.widget.TextView; import android.widget.Toast; +import com.google.android.material.snackbar.Snackbar; + import org.json.JSONArray; import org.json.JSONObject; +import org.openintents.openpgp.OpenPgpError; +import org.openintents.openpgp.util.OpenPgpApi; +import org.openintents.openpgp.util.OpenPgpServiceConnection; import java.io.BufferedReader; +import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileInputStream; +import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.FileReader; +import java.io.IOException; import java.io.InputStreamReader; import java.net.URL; import java.text.Collator; @@ -76,7 +85,6 @@ import androidx.lifecycle.Lifecycle; import androidx.lifecycle.Observer; import androidx.lifecycle.ViewModelProviders; import androidx.localbroadcastmanager.content.LocalBroadcastManager; -import androidx.paging.PagedList; public class ActivityView extends ActivityBilling implements FragmentManager.OnBackStackChangedListener { private View view; @@ -85,7 +93,9 @@ public class ActivityView extends ActivityBilling implements FragmentManager.OnB private ActionBarDrawerToggle drawerToggle; private long attachment = -1; - private PagedList messages = null; + private long decryptId = -1; + private File decryptFile = null; + private OpenPgpServiceConnection pgpService; private static final int ATTACHMENT_BUFFER_SIZE = 8192; // bytes @@ -95,6 +105,7 @@ public class ActivityView extends ActivityBilling implements FragmentManager.OnB static final int REQUEST_ATTACHMENT = 1; static final int REQUEST_INVITE = 2; + static final int REQUEST_DECRYPT = 3; static final String ACTION_VIEW_MESSAGES = BuildConfig.APPLICATION_ID + ".VIEW_MESSAGES"; static final String ACTION_VIEW_THREAD = BuildConfig.APPLICATION_ID + ".VIEW_THREAD"; @@ -102,6 +113,7 @@ public class ActivityView extends ActivityBilling implements FragmentManager.OnB static final String ACTION_EDIT_FOLDER = BuildConfig.APPLICATION_ID + ".EDIT_FOLDER"; static final String ACTION_EDIT_ANSWER = BuildConfig.APPLICATION_ID + ".EDIT_ANSWER"; static final String ACTION_STORE_ATTACHMENT = BuildConfig.APPLICATION_ID + ".STORE_ATTACHMENT"; + static final String ACTION_DECRYPT = BuildConfig.APPLICATION_ID + ".DECRYPT"; static final String ACTION_SHOW_PRO = BuildConfig.APPLICATION_ID + ".SHOW_PRO"; static final String UPDATE_LATEST_API = "https://api.github.com/repos/M66B/open-source-email/releases/latest"; @@ -262,6 +274,9 @@ public class ActivityView extends ActivityBilling implements FragmentManager.OnB checkCrash(); if (!Helper.isPlayStoreInstall(this)) checkUpdate(); + + pgpService = new OpenPgpServiceConnection(this, "org.sufficientlysecure.keychain"); + pgpService.bindToService(); } @Override @@ -294,6 +309,7 @@ public class ActivityView extends ActivityBilling implements FragmentManager.OnB iff.addAction(ACTION_EDIT_FOLDER); iff.addAction(ACTION_EDIT_ANSWER); iff.addAction(ACTION_STORE_ATTACHMENT); + iff.addAction(ACTION_DECRYPT); iff.addAction(ACTION_SHOW_PRO); lbm.registerReceiver(receiver, iff); @@ -369,6 +385,14 @@ public class ActivityView extends ActivityBilling implements FragmentManager.OnB lbm.unregisterReceiver(receiver); } + @Override + protected void onDestroy() { + if (pgpService != null) + pgpService.unbindFromService(); + + super.onDestroy(); + } + @Override public void onConfigurationChanged(Configuration newConfig) { super.onConfigurationChanged(newConfig); @@ -801,6 +825,8 @@ public class ActivityView extends ActivityBilling implements FragmentManager.OnB onEditAnswer(intent); else if (ACTION_STORE_ATTACHMENT.equals(intent.getAction())) onStoreAttachment(intent); + else if (ACTION_DECRYPT.equals(intent.getAction())) + onDecrypt(intent); else if (ACTION_SHOW_PRO.equals(intent.getAction())) onShowPro(intent); } @@ -868,76 +894,207 @@ public class ActivityView extends ActivityBilling implements FragmentManager.OnB startActivityForResult(create, REQUEST_ATTACHMENT); } + private void onDecrypt(Intent intent) { + Bundle args = new Bundle(); + args.putLong("id", intent.getLongExtra("id", -1)); + + new SimpleTask() { + @Override + protected File onLoad(Context context, Bundle args) throws Throwable { + long id = args.getLong("id"); + + DB db = DB.getInstance(context); + try { + db.beginTransaction(); + + for (EntityAttachment attachment : db.attachment().getAttachments(id)) + if (attachment.available && "encrypted.asc".equals(attachment.name)) + return EntityAttachment.getFile(context, attachment.id); + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + return null; + } + + @Override + protected void onLoaded(Bundle args, File file) { + if (file != null) + try { + if (!pgpService.isBound()) + throw new IllegalArgumentException(getString(R.string.title_no_openpgp)); + + Intent data = new Intent(); + data.setAction(OpenPgpApi.ACTION_DECRYPT_VERIFY); + data.putExtra(OpenPgpApi.EXTRA_USER_IDS, new String[]{args.getString("to")}); + data.putExtra(OpenPgpApi.EXTRA_REQUEST_ASCII_ARMOR, true); + + decrypt(data, args.getLong("id"), file); + } catch (Throwable ex) { + if (ex instanceof IllegalArgumentException) + Snackbar.make(view, ex.getMessage(), Snackbar.LENGTH_LONG).show(); + else + Helper.unexpectedError(ActivityView.this, ex); + } + } + }.load(this, args); + } + private void onShowPro(Intent intent) { FragmentTransaction fragmentTransaction = getSupportFragmentManager().beginTransaction(); fragmentTransaction.replace(R.id.content_frame, new FragmentPro()).addToBackStack("pro"); fragmentTransaction.commit(); } + private void decrypt(Intent data, final long id, final File file) throws FileNotFoundException { + final OpenPgpApi api = new OpenPgpApi(this, pgpService.getService()); + final FileInputStream msg = new FileInputStream(file); + final ByteArrayOutputStream decrypted = new ByteArrayOutputStream(); + + api.executeApiAsync(data, msg, decrypted, new OpenPgpApi.IOpenPgpCallback() { + @Override + public void onReturn(Intent result) { + Log.i(Helper.TAG, "Pgp result=" + result); + Bundle extras = result.getExtras(); + for (String key : extras.keySet()) + Log.i(Helper.TAG, key + "=" + extras.get(key)); + + try { + switch (result.getIntExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_ERROR)) { + case OpenPgpApi.RESULT_CODE_SUCCESS: + Bundle args = new Bundle(); + args.putLong("id", id); + + new SimpleTask() { + @Override + protected Void onLoad(Context context, Bundle args) throws Throwable { + long id = args.getLong("id"); + + DB db = DB.getInstance(context); + EntityMessage message = db.message().getMessage(id); + message.write(context, decrypted.toString("UTF-8")); + db.message().setMessageStored(id, new Date().getTime()); + + return null; + } + + @Override + protected void onException(Bundle args, Throwable ex) { + Helper.unexpectedError(ActivityView.this, ex); + } + }.load(ActivityView.this, args); + + break; + + case OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED: + decryptId = id; + decryptFile = file; + PendingIntent pi = result.getParcelableExtra(OpenPgpApi.RESULT_INTENT); + startIntentSenderForResult( + pi.getIntentSender(), + ActivityView.REQUEST_DECRYPT, + null, 0, 0, 0, null); + break; + + case OpenPgpApi.RESULT_CODE_ERROR: + OpenPgpError error = result.getParcelableExtra(OpenPgpApi.RESULT_ERROR); + throw new IllegalArgumentException(error.getMessage()); + } + } catch (Throwable ex) { + if (ex instanceof IllegalArgumentException) + Snackbar.make(view, ex.getMessage(), Snackbar.LENGTH_LONG).show(); + else + Helper.unexpectedError(ActivityView.this, ex); + } finally { + try { + msg.close(); + } catch (IOException ex) { + Log.e(Helper.TAG, ex + "\n" + Log.getStackTraceString(ex)); + } + } + } + }); + } + @Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { Log.i(Helper.TAG, "View onActivityResult request=" + requestCode + " result=" + resultCode + " data=" + data); if (resultCode == Activity.RESULT_OK) if (requestCode == REQUEST_ATTACHMENT) { - Bundle args = new Bundle(); - args.putLong("id", attachment); - args.putParcelable("uri", data.getData()); - new SimpleTask() { - @Override - protected Void onLoad(Context context, Bundle args) throws Throwable { - long id = args.getLong("id"); - Uri uri = args.getParcelable("uri"); - - File file = EntityAttachment.getFile(context, id); - - ParcelFileDescriptor pfd = null; - FileOutputStream fos = null; - FileInputStream fis = null; - try { - pfd = context.getContentResolver().openFileDescriptor(uri, "w"); - fos = new FileOutputStream(pfd.getFileDescriptor()); - fis = new FileInputStream(file); - - byte[] buffer = new byte[ATTACHMENT_BUFFER_SIZE]; - int read; - while ((read = fis.read(buffer)) != -1) { - fos.write(buffer, 0, read); - } - } finally { - try { - if (pfd != null) - pfd.close(); - } catch (Throwable ex) { - Log.w(Helper.TAG, ex + "\n" + Log.getStackTraceString(ex)); - } - try { - if (fos != null) - fos.close(); - } catch (Throwable ex) { - Log.w(Helper.TAG, ex + "\n" + Log.getStackTraceString(ex)); - } + if (data != null) { + Bundle args = new Bundle(); + args.putLong("id", attachment); + args.putParcelable("uri", data.getData()); + + new SimpleTask() { + @Override + protected Void onLoad(Context context, Bundle args) throws Throwable { + long id = args.getLong("id"); + Uri uri = args.getParcelable("uri"); + + File file = EntityAttachment.getFile(context, id); + + ParcelFileDescriptor pfd = null; + FileOutputStream fos = null; + FileInputStream fis = null; try { - if (fis != null) - fis.close(); - } catch (Throwable ex) { - Log.w(Helper.TAG, ex + "\n" + Log.getStackTraceString(ex)); + pfd = context.getContentResolver().openFileDescriptor(uri, "w"); + fos = new FileOutputStream(pfd.getFileDescriptor()); + fis = new FileInputStream(file); + + byte[] buffer = new byte[ATTACHMENT_BUFFER_SIZE]; + int read; + while ((read = fis.read(buffer)) != -1) { + fos.write(buffer, 0, read); + } + } finally { + try { + if (pfd != null) + pfd.close(); + } catch (Throwable ex) { + Log.w(Helper.TAG, ex + "\n" + Log.getStackTraceString(ex)); + } + try { + if (fos != null) + fos.close(); + } catch (Throwable ex) { + Log.w(Helper.TAG, ex + "\n" + Log.getStackTraceString(ex)); + } + try { + if (fis != null) + fis.close(); + } catch (Throwable ex) { + Log.w(Helper.TAG, ex + "\n" + Log.getStackTraceString(ex)); + } } - } - return null; - } + return null; + } - @Override - protected void onLoaded(Bundle args, Void data) { - Toast.makeText(ActivityView.this, R.string.title_attachment_saved, Toast.LENGTH_LONG).show(); - } + @Override + protected void onLoaded(Bundle args, Void data) { + Toast.makeText(ActivityView.this, R.string.title_attachment_saved, Toast.LENGTH_LONG).show(); + } - @Override - protected void onException(Bundle args, Throwable ex) { - Log.e(Helper.TAG, ex + "\n" + Log.getStackTraceString(ex)); - Helper.unexpectedError(ActivityView.this, ex); + @Override + protected void onException(Bundle args, Throwable ex) { + Log.e(Helper.TAG, ex + "\n" + Log.getStackTraceString(ex)); + Helper.unexpectedError(ActivityView.this, ex); + } + }.load(this, args); + } + } else if (requestCode == REQUEST_DECRYPT) { + if (data != null) + try { + decrypt(data, decryptId, decryptFile); + } catch (Throwable ex) { + if (ex instanceof IllegalArgumentException) + Snackbar.make(view, ex.getMessage(), Snackbar.LENGTH_LONG).show(); + else + Helper.unexpectedError(ActivityView.this, ex); } - }.load(this, args); } } } diff --git a/app/src/main/java/eu/faircode/email/AdapterMessage.java b/app/src/main/java/eu/faircode/email/AdapterMessage.java index b2ee2259..d2d6b888 100644 --- a/app/src/main/java/eu/faircode/email/AdapterMessage.java +++ b/app/src/main/java/eu/faircode/email/AdapterMessage.java @@ -1071,6 +1071,14 @@ public class AdapterMessage extends PagedListAdapter 0); popupMenu.setOnMenuItemClickListener(new PopupMenu.OnMenuItemClickListener() { @Override @@ -1112,20 +1122,23 @@ public class AdapterMessage extends PagedListAdapter() { + @Override + protected Void onLoad(Context context, Bundle args) throws Throwable { + long id = args.getLong("id"); + EntityAttachment attachment = new EntityAttachment(); + + DB db = DB.getInstance(context); + try { + db.beginTransaction(); + + int seq = db.attachment().getAttachmentCount(id); + + attachment.message = id; + attachment.sequence = seq + 1; + attachment.name = "encrypted.asc"; + attachment.type = "application/octet-stream"; + attachment.id = db.attachment().insertAttachment(attachment); + + File file = EntityAttachment.getFile(context, attachment.id); + + OutputStream os = null; + try { + os = new BufferedOutputStream(new FileOutputStream(file)); + byte[] data = encrypted.toByteArray(); + os.write(data); + + attachment.size = data.length; + attachment.progress = null; + attachment.available = true; + db.attachment().updateAttachment(attachment); + } finally { + if (os != null) + os.close(); + } + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + return null; + } + + @Override + protected void onException(Bundle args, Throwable ex) { + Helper.unexpectedError(getContext(), ex); + } + }.load(FragmentCompose.this, args); + + break; + + case OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED: + PendingIntent pi = result.getParcelableExtra(OpenPgpApi.RESULT_INTENT); + startIntentSenderForResult( + pi.getIntentSender(), + ActivityCompose.REQUEST_ENCRYPT, + null, 0, 0, 0, null); + break; + + case OpenPgpApi.RESULT_CODE_ERROR: + OpenPgpError error = result.getParcelableExtra(OpenPgpApi.RESULT_ERROR); + throw new IllegalArgumentException(error.getMessage()); + } + } catch (Throwable ex) { + if (ex instanceof IllegalArgumentException) + Snackbar.make(view, ex.getMessage(), Snackbar.LENGTH_LONG).show(); + else + Helper.unexpectedError(getContext(), ex); + } finally { + try { + msg.close(); + } catch (IOException ex) { + Log.e(Helper.TAG, ex + "\n" + Log.getStackTraceString(ex)); + } + } + } + }); + } + @Override public void onActivityResult(int requestCode, int resultCode, Intent data) { Log.i(Helper.TAG, "Compose onActivityResult request=" + requestCode + " result=" + resultCode + " data=" + data); @@ -467,6 +616,16 @@ public class FragmentCompose extends FragmentEx { } else if (requestCode == ActivityCompose.REQUEST_ATTACHMENT) { if (data != null) handleAddAttachment(data, false); + } else if (requestCode == ActivityCompose.REQUEST_ENCRYPT) { + if (data != null) + try { + encrypt(data); + } catch (Throwable ex) { + if (ex instanceof IllegalArgumentException) + Snackbar.make(view, ex.getMessage(), Snackbar.LENGTH_LONG).show(); + else + Helper.unexpectedError(getContext(), ex); + } } else { if (data != null) handlePickContact(requestCode, data); @@ -626,7 +785,8 @@ public class FragmentCompose extends FragmentEx { actionLoader.load(this, args); } - private static EntityAttachment addAttachment(Context context, long id, Uri uri, boolean image) throws IOException { + private static EntityAttachment addAttachment(Context context, long id, Uri uri, + boolean image) throws IOException { EntityAttachment attachment = new EntityAttachment(); String name = null; @@ -1159,7 +1319,7 @@ public class FragmentCompose extends FragmentEx { Toast.makeText(context, R.string.title_draft_deleted, Toast.LENGTH_LONG).show(); } }); - } else if (action == R.id.action_save) { + } else if (action == R.id.action_save || action == R.id.action_encrypt) { db.message().updateMessage(draft); draft.write(context, body); @@ -1250,6 +1410,9 @@ public class FragmentCompose extends FragmentEx { } else if (action == R.id.action_save) { // Do nothing + } else if (action == R.id.action_encrypt) { + onEncrypt(); + } else if (action == R.id.action_send) { autosave = false; getFragmentManager().popBackStack(); diff --git a/app/src/main/java/eu/faircode/email/MessageHelper.java b/app/src/main/java/eu/faircode/email/MessageHelper.java index 9105a31f..67e4644b 100644 --- a/app/src/main/java/eu/faircode/email/MessageHelper.java +++ b/app/src/main/java/eu/faircode/email/MessageHelper.java @@ -195,7 +195,41 @@ public class MessageHelper { if (message.subject != null) imessage.setSubject(message.subject); - // TODO: plain message? + imessage.setSentDate(new Date()); + + for (final EntityAttachment attachment : attachments) + if (attachment.available && "encrypted.asc".equals(attachment.name)) { + Multipart multipart = new MimeMultipart("encrypted; protocol=\"application/pgp-encrypted\""); + + BodyPart pgp = new MimeBodyPart(); + pgp.setContent("", "application/pgp-encrypted"); + multipart.addBodyPart(pgp); + + BodyPart bpAttachment = new MimeBodyPart(); + bpAttachment.setFileName(attachment.name); + + File file = EntityAttachment.getFile(context, attachment.id); + FileDataSource dataSource = new FileDataSource(file); + dataSource.setFileTypeMap(new FileTypeMap() { + @Override + public String getContentType(File file) { + return attachment.type; + } + + @Override + public String getContentType(String filename) { + return attachment.type; + } + }); + bpAttachment.setDataHandler(new DataHandler(dataSource)); + bpAttachment.setDisposition(Part.INLINE); + + multipart.addBodyPart(bpAttachment); + + imessage.setContent(multipart); + + return imessage; + } String body = message.read(context); @@ -246,8 +280,6 @@ public class MessageHelper { imessage.setContent(multipart); } - imessage.setSentDate(new Date()); - return imessage; } diff --git a/app/src/main/res/drawable/baseline_lock_24.xml b/app/src/main/res/drawable/baseline_lock_24.xml new file mode 100644 index 00000000..61ed3b54 --- /dev/null +++ b/app/src/main/res/drawable/baseline_lock_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/menu/action_compose.xml b/app/src/main/res/menu/action_compose.xml index 83ee9356..236169d8 100644 --- a/app/src/main/res/menu/action_compose.xml +++ b/app/src/main/res/menu/action_compose.xml @@ -11,6 +11,11 @@ android:icon="@drawable/baseline_save_alt_24" android:title="@string/title_save" /> + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7711c610..a7ae643a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -182,6 +182,7 @@ Reply to all Show headers Show original + Decrypt Trash Delete @@ -211,6 +212,7 @@ Your message Discard Save + Encrypt Send Clipboard empty @@ -228,6 +230,8 @@ Draft saved Sending message + OpenPgp not found + Search Search on server Searching \'%1$s\'