() {
+ @Override
+ public SmimeDecryptionResult createFromParcel(Parcel source) {
+ int version = source.readInt();
+ int parcelableSize = source.readInt();
+ int startPosition = source.dataPosition();
+
+ int result = source.readInt();
+
+ source.setDataPosition(startPosition + parcelableSize);
+ return new SmimeDecryptionResult(result);
+ }
+
+ @Override
+ public SmimeDecryptionResult[] newArray(int size) {
+ return new SmimeDecryptionResult[size];
+ }
+ };
+}
diff --git a/plugins/smime-api/smime-api/src/main/java/com/ciphermail/smime/api/SmimeError.java b/plugins/smime-api/smime-api/src/main/java/com/ciphermail/smime/api/SmimeError.java
new file mode 100644
index 00000000000..e5fd5a294e1
--- /dev/null
+++ b/plugins/smime-api/smime-api/src/main/java/com/ciphermail/smime/api/SmimeError.java
@@ -0,0 +1,100 @@
+package com.ciphermail.smime.api;
+
+import android.os.Parcel;
+import android.os.Parcelable;
+
+/**
+ * Error result returned from the S/MIME service when {@code RESULT_CODE} is
+ * {@code SmimeApi.RESULT_CODE_ERROR}.
+ *
+ * The integer error identifier ({@link #getErrorId()}) is stable across
+ * versions and can be used to drive client-side messaging without parsing
+ * the human-readable {@link #getMessage()} string.
+ *
+ * Construction is asymmetric on purpose: the service produces these and
+ * the client only reads them. New error codes may be added in future API
+ * versions; clients should treat unknown ids as equivalent to
+ * {@link #GENERIC_ERROR}.
+ */
+public class SmimeError implements Parcelable {
+
+ public static final int PARCELABLE_VERSION = 1;
+
+ /**
+ * Error that originated in the client-side helper (e.g. failed to set up
+ * the ParcelFileDescriptor pipe) rather than in the provider service.
+ */
+ public static final int CLIENT_SIDE_ERROR = -1;
+
+ /** Unspecified failure in the provider. Inspect {@link #getMessage()}. */
+ public static final int GENERIC_ERROR = 0;
+
+ /**
+ * The caller supplied an {@code EXTRA_API_VERSION} that the provider
+ * cannot serve. The client should not retry without code changes.
+ */
+ public static final int INCOMPATIBLE_API_VERSIONS = 1;
+
+ /**
+ * One or more recipient addresses (passed via {@code EXTRA_USER_IDS}) have
+ * no usable certificate. Specific to {@code ACTION_SIGN_AND_ENCRYPT}.
+ */
+ public static final int NO_CERTIFICATE_FOR_RECIPIENT = 2;
+
+ /**
+ * Provider's keystore is locked and the user did not complete the
+ * passphrase dialog. Clients normally see
+ * {@code RESULT_CODE_USER_INTERACTION_REQUIRED} instead of this code;
+ * this code is only set when interaction was offered and declined.
+ */
+ public static final int KEYSTORE_LOCKED = 3;
+
+ private final int errorId;
+ private final String message;
+
+ public SmimeError(int errorId, String message) {
+ this.errorId = errorId;
+ this.message = message;
+ }
+
+ public int getErrorId() { return errorId; }
+ public String getMessage() { return message; }
+
+ @Override
+ public int describeContents() { return 0; }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ dest.writeInt(PARCELABLE_VERSION);
+ int sizePosition = dest.dataPosition();
+ dest.writeInt(0);
+ int startPosition = dest.dataPosition();
+ // version 1
+ dest.writeInt(errorId);
+ dest.writeString(message);
+ int parcelableSize = dest.dataPosition() - startPosition;
+ dest.setDataPosition(sizePosition);
+ dest.writeInt(parcelableSize);
+ dest.setDataPosition(startPosition + parcelableSize);
+ }
+
+ public static final Creator CREATOR = new Creator() {
+ @Override
+ public SmimeError createFromParcel(Parcel source) {
+ int version = source.readInt();
+ int parcelableSize = source.readInt();
+ int startPosition = source.dataPosition();
+
+ int errorId = source.readInt();
+ String message = source.readString();
+
+ source.setDataPosition(startPosition + parcelableSize);
+ return new SmimeError(errorId, message);
+ }
+
+ @Override
+ public SmimeError[] newArray(int size) {
+ return new SmimeError[size];
+ }
+ };
+}
diff --git a/plugins/smime-api/smime-api/src/main/java/com/ciphermail/smime/api/SmimeSignatureResult.java b/plugins/smime-api/smime-api/src/main/java/com/ciphermail/smime/api/SmimeSignatureResult.java
new file mode 100644
index 00000000000..3106d723b6a
--- /dev/null
+++ b/plugins/smime-api/smime-api/src/main/java/com/ciphermail/smime/api/SmimeSignatureResult.java
@@ -0,0 +1,99 @@
+package com.ciphermail.smime.api;
+
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import androidx.annotation.Nullable;
+
+/**
+ * Signature verification result returned alongside
+ * {@code ACTION_DECRYPT_VERIFY}.
+ *
+ * The {@link #getResult()} code summarises the cryptographic outcome and
+ * the trust state of the signer certificate. {@link #getSignerEmail()} and
+ * {@link #getSignerSubjectDn()} carry identity information extracted from
+ * the signer certificate for UI display; both may be {@code null} when no
+ * signature is present.
+ *
+ * Clients should treat unknown result codes as
+ * {@link #RESULT_INVALID_SIGNATURE} to fail safe.
+ */
+public class SmimeSignatureResult implements Parcelable {
+
+ public static final int PARCELABLE_VERSION = 1;
+
+ /** Message was not signed. */
+ public static final int RESULT_NO_SIGNATURE = -1;
+ /** Signature is cryptographically invalid. */
+ public static final int RESULT_INVALID_SIGNATURE = 0;
+ /** Valid signature; certificate chain traces to a trusted root. */
+ public static final int RESULT_VALID_TRUSTED = 1;
+ /** Valid signature; certificate is not trusted (unknown CA, self-signed, etc.). */
+ public static final int RESULT_VALID_UNTRUSTED = 2;
+ /** Signer certificate could not be found. */
+ public static final int RESULT_CERT_MISSING = 3;
+ /** Signer certificate has expired. */
+ public static final int RESULT_CERT_EXPIRED = 4;
+ /** Signer certificate has been revoked. */
+ public static final int RESULT_CERT_REVOKED = 5;
+
+ private final int result;
+ /** Email address from the signer's certificate SubjectAltName / Subject CN. */
+ @Nullable private final String signerEmail;
+ /** Human-readable Subject DN of the signer's certificate. */
+ @Nullable private final String signerSubjectDn;
+
+ public SmimeSignatureResult(int result, @Nullable String signerEmail, @Nullable String signerSubjectDn) {
+ this.result = result;
+ this.signerEmail = signerEmail;
+ this.signerSubjectDn = signerSubjectDn;
+ }
+
+ public int getResult() { return result; }
+
+ @Nullable
+ public String getSignerEmail() { return signerEmail; }
+
+ @Nullable
+ public String getSignerSubjectDn() { return signerSubjectDn; }
+
+ @Override
+ public int describeContents() { return 0; }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ dest.writeInt(PARCELABLE_VERSION);
+ int sizePosition = dest.dataPosition();
+ dest.writeInt(0);
+ int startPosition = dest.dataPosition();
+ // version 1
+ dest.writeInt(result);
+ dest.writeString(signerEmail);
+ dest.writeString(signerSubjectDn);
+ int parcelableSize = dest.dataPosition() - startPosition;
+ dest.setDataPosition(sizePosition);
+ dest.writeInt(parcelableSize);
+ dest.setDataPosition(startPosition + parcelableSize);
+ }
+
+ public static final Creator CREATOR = new Creator() {
+ @Override
+ public SmimeSignatureResult createFromParcel(Parcel source) {
+ int version = source.readInt();
+ int parcelableSize = source.readInt();
+ int startPosition = source.dataPosition();
+
+ int result = source.readInt();
+ String signerEmail = source.readString();
+ String signerSubjectDn = source.readString();
+
+ source.setDataPosition(startPosition + parcelableSize);
+ return new SmimeSignatureResult(result, signerEmail, signerSubjectDn);
+ }
+
+ @Override
+ public SmimeSignatureResult[] newArray(int size) {
+ return new SmimeSignatureResult[size];
+ }
+ };
+}
diff --git a/plugins/smime-api/smime-api/src/main/java/com/ciphermail/smime/api/util/SmimeApi.java b/plugins/smime-api/smime-api/src/main/java/com/ciphermail/smime/api/util/SmimeApi.java
new file mode 100644
index 00000000000..0998d2c5e8f
--- /dev/null
+++ b/plugins/smime-api/smime-api/src/main/java/com/ciphermail/smime/api/util/SmimeApi.java
@@ -0,0 +1,260 @@
+package com.ciphermail.smime.api.util;
+
+import android.content.Intent;
+import android.os.AsyncTask;
+import android.os.ParcelFileDescriptor;
+
+import com.ciphermail.smime.api.ISmimeService;
+import com.ciphermail.smime.api.SmimeError;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * Client-side helper for the S/MIME companion service API.
+ *
+ * Usage pattern (mirrors OpenPgpApi):
+ *
+ * SmimeApi api = new SmimeApi(context, boundService);
+ *
+ * Intent request = new Intent(SmimeApi.ACTION_DECRYPT_VERIFY);
+ * request.putExtra(SmimeApi.EXTRA_API_VERSION, SmimeApi.API_VERSION);
+ *
+ * api.executeApiAsync(request, inputStream, outputStream, result -> {
+ * int code = result.getIntExtra(SmimeApi.RESULT_CODE, SmimeApi.RESULT_CODE_ERROR);
+ * // handle code
+ * });
+ */
+public class SmimeApi {
+
+ // -------------------------------------------------------------------------
+ // Service binding intent action — used to bind to CipherMail's service
+ // -------------------------------------------------------------------------
+ public static final String SERVICE_INTENT = "com.ciphermail.smime.api.ISmimeService";
+
+ public static final int API_VERSION = 1;
+
+ // -------------------------------------------------------------------------
+ // Actions
+ // -------------------------------------------------------------------------
+
+ /**
+ * Check whether the caller has permission to use the API.
+ * Returns RESULT_CODE_SUCCESS or RESULT_CODE_USER_INTERACTION_REQUIRED (first-run consent).
+ * No input stream needed.
+ */
+ public static final String ACTION_CHECK_PERMISSION =
+ "com.ciphermail.smime.api.action.CHECK_PERMISSION";
+
+ /**
+ * Decrypt and/or verify an S/MIME message part.
+ *
+ * Input stream: raw bytes of the MIME part (application/pkcs7-mime or multipart/signed).
+ * Output stream: decrypted/verified MIME content.
+ *
+ * Returned extras: RESULT_DECRYPTION, RESULT_SIGNATURE (both Parcelable).
+ */
+ public static final String ACTION_DECRYPT_VERIFY =
+ "com.ciphermail.smime.api.action.DECRYPT_VERIFY";
+
+ /**
+ * Sign and/or encrypt an outgoing MIME message.
+ *
+ * Required extras: EXTRA_USER_IDS (recipient email addresses).
+ * Optional extras: EXTRA_SIGN (default true), EXTRA_ENCRYPT (default true),
+ * EXTRA_FROM (sender address; selects the signing identity).
+ *
+ * Input stream: raw MIME bytes of the message to protect.
+ * Output stream: S/MIME wrapped MIME bytes ready for transport.
+ */
+ public static final String ACTION_SIGN_AND_ENCRYPT =
+ "com.ciphermail.smime.api.action.SIGN_AND_ENCRYPT";
+
+ /**
+ * Query certificate availability for a list of email addresses.
+ * Used by the compose screen to determine per-recipient lock icon state.
+ *
+ * Required extras: EXTRA_USER_IDS.
+ * No input stream needed; no output stream produced.
+ *
+ * Returned extras: RESULT_CERTIFICATES (SmimeCertificateInfo[]).
+ */
+ public static final String ACTION_GET_CERTIFICATES =
+ "com.ciphermail.smime.api.action.GET_CERTIFICATES";
+
+ /**
+ * Import a certificate from a byte stream (DER or PEM encoded).
+ *
+ * Input stream: certificate bytes.
+ * No output stream produced.
+ */
+ public static final String ACTION_IMPORT_CERTIFICATE =
+ "com.ciphermail.smime.api.action.IMPORT_CERTIFICATE";
+
+ // -------------------------------------------------------------------------
+ // Request extras
+ // -------------------------------------------------------------------------
+
+ /** int — always required. Must equal API_VERSION. */
+ public static final String EXTRA_API_VERSION = "api_version";
+
+ /** String[] — recipient email addresses (for SIGN_AND_ENCRYPT and GET_CERTIFICATES). */
+ public static final String EXTRA_USER_IDS = "user_ids";
+
+ /** boolean — whether to sign the outgoing message (default true). */
+ public static final String EXTRA_SIGN = "sign";
+
+ /** boolean — whether to encrypt the outgoing message (default true). */
+ public static final String EXTRA_ENCRYPT = "encrypt";
+
+ /**
+ * String — sender (From) email address (for SIGN_AND_ENCRYPT). Selects which
+ * personal certificate is used to sign the message and to encrypt it to self.
+ * Optional; when absent the service falls back to its configured default identity.
+ */
+ public static final String EXTRA_FROM = "from";
+
+ // -------------------------------------------------------------------------
+ // Result codes
+ // -------------------------------------------------------------------------
+
+ /** int extra key carrying the result code. */
+ public static final String RESULT_CODE = "result_code";
+
+ public static final int RESULT_CODE_ERROR = 0;
+ public static final int RESULT_CODE_SUCCESS = 1;
+ /** Service needs user interaction (e.g. keystore unlock). Launch RESULT_INTENT. */
+ public static final int RESULT_CODE_USER_INTERACTION_REQUIRED = 2;
+
+ // -------------------------------------------------------------------------
+ // Result extras
+ // -------------------------------------------------------------------------
+
+ /** PendingIntent — present when RESULT_CODE == RESULT_CODE_USER_INTERACTION_REQUIRED. */
+ public static final String RESULT_INTENT = "intent";
+
+ /** SmimeError Parcelable — present when RESULT_CODE == RESULT_CODE_ERROR. */
+ public static final String RESULT_ERROR = "error";
+
+ /** SmimeDecryptionResult Parcelable — present after ACTION_DECRYPT_VERIFY. */
+ public static final String RESULT_DECRYPTION = "decryption_result";
+
+ /** SmimeSignatureResult Parcelable — present after ACTION_DECRYPT_VERIFY. */
+ public static final String RESULT_SIGNATURE = "signature_result";
+
+ /** SmimeCertificateInfo[] Parcelable array — present after ACTION_GET_CERTIFICATES. */
+ public static final String RESULT_CERTIFICATES = "certificates";
+
+ // -------------------------------------------------------------------------
+ // Async execution
+ // -------------------------------------------------------------------------
+
+ public interface SmimeCallback {
+ void onReturn(Intent result);
+ }
+
+ private final ISmimeService mService;
+ private static final AtomicInteger sPipeIdCounter = new AtomicInteger(0);
+
+ public SmimeApi(ISmimeService service) {
+ this.mService = service;
+ }
+
+ /**
+ * Execute an API call asynchronously, streaming data through pipes.
+ *
+ * @param data Request Intent with action and extras.
+ * @param inputStream Source bytes (the MIME message to process), or null.
+ * @param outputStream Destination for processed bytes, or null.
+ * @param callback Called on the calling thread with the result Intent.
+ */
+ public void executeApiAsync(final Intent data,
+ final InputStream inputStream,
+ final OutputStream outputStream,
+ final SmimeCallback callback) {
+ new AsyncTask() {
+ @Override
+ protected Intent doInBackground(Void... params) {
+ return executeApi(data, inputStream, outputStream);
+ }
+
+ @Override
+ protected void onPostExecute(Intent result) {
+ callback.onReturn(result);
+ }
+ }.execute();
+ }
+
+ /**
+ * Execute an API call synchronously. Must not be called on the main thread.
+ */
+ public Intent executeApi(Intent data, InputStream inputStream, OutputStream outputStream) {
+ ParcelFileDescriptor inputFd = null;
+ ParcelFileDescriptor outputFd = null;
+ Thread inputThread = null;
+ Thread outputThread = null;
+
+ try {
+ final int pipeId = sPipeIdCounter.incrementAndGet();
+
+ // Wire up the output pipe (service writes → we read)
+ if (outputStream != null) {
+ outputFd = mService.createOutputPipe(pipeId);
+ final ParcelFileDescriptor readEnd = outputFd;
+ outputThread = new Thread(() -> {
+ try (ParcelFileDescriptor.AutoCloseInputStream in =
+ new ParcelFileDescriptor.AutoCloseInputStream(readEnd)) {
+ byte[] buf = new byte[8192];
+ int n;
+ while ((n = in.read(buf)) != -1) {
+ outputStream.write(buf, 0, n);
+ }
+ } catch (IOException ignored) {}
+ });
+ outputThread.start();
+ }
+
+ // Wire up the input pipe (we write → service reads)
+ if (inputStream != null) {
+ ParcelFileDescriptor[] inputPipe = ParcelFileDescriptor.createPipe();
+ final ParcelFileDescriptor writeEnd = inputPipe[1];
+ inputFd = inputPipe[0];
+ inputThread = new Thread(() -> {
+ try (ParcelFileDescriptor.AutoCloseOutputStream out =
+ new ParcelFileDescriptor.AutoCloseOutputStream(writeEnd)) {
+ byte[] buf = new byte[8192];
+ int n;
+ while ((n = inputStream.read(buf)) != -1) {
+ out.write(buf, 0, n);
+ }
+ } catch (IOException ignored) {}
+ });
+ inputThread.start();
+ }
+
+ Intent result = mService.execute(data, inputFd, pipeId);
+
+ if (outputThread != null) outputThread.join();
+ if (inputThread != null) inputThread.join();
+
+ return result;
+
+ } catch (Exception e) {
+ Intent error = new Intent();
+ error.putExtra(RESULT_CODE, RESULT_CODE_ERROR);
+ error.putExtra(RESULT_ERROR, new SmimeError(SmimeError.CLIENT_SIDE_ERROR, e.getMessage()));
+ return error;
+ } finally {
+ closeQuietly(inputFd);
+ closeQuietly(outputFd);
+ }
+ }
+
+ private static void closeQuietly(ParcelFileDescriptor fd) {
+ if (fd != null) {
+ try { fd.close(); } catch (IOException ignored) {}
+ }
+ }
+}
diff --git a/plugins/smime-api/smime-api/src/main/java/com/ciphermail/smime/api/util/SmimeServiceConnection.java b/plugins/smime-api/smime-api/src/main/java/com/ciphermail/smime/api/util/SmimeServiceConnection.java
new file mode 100644
index 00000000000..9e13fad9902
--- /dev/null
+++ b/plugins/smime-api/smime-api/src/main/java/com/ciphermail/smime/api/util/SmimeServiceConnection.java
@@ -0,0 +1,104 @@
+package com.ciphermail.smime.api.util;
+
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.ServiceConnection;
+import android.os.IBinder;
+
+import com.ciphermail.smime.api.ISmimeService;
+
+/**
+ * Manages binding to the CipherMail S/MIME service.
+ * Mirrors OpenPgpServiceConnection so Thunderbird's integration layer can
+ * follow the same lifecycle pattern for both crypto providers.
+ */
+public class SmimeServiceConnection {
+
+ /**
+ * Listener fired on successful bind or bind failure. Methods run on the
+ * main thread (delivered by {@link android.content.ServiceConnection}).
+ */
+ public interface OnBound {
+ void onBound(ISmimeService service);
+ void onError(Exception e);
+ }
+
+ private final Context mApplicationContext;
+ private final String mProviderPackageName;
+ private final OnBound mOnBoundListener;
+
+ private ISmimeService mService;
+
+ /** Build a connection without a listener — use {@link #isBound()} to poll. */
+ public SmimeServiceConnection(Context context, String providerPackageName) {
+ this(context, providerPackageName, null);
+ }
+
+ /**
+ * @param context Any context; the application context is kept internally.
+ * @param providerPackageName Package id of the S/MIME provider (e.g.
+ * {@code "com.ciphermail.android"} or
+ * {@code "com.ciphermail.android.debug"}).
+ * @param onBoundListener Optional bind-result callback.
+ */
+ public SmimeServiceConnection(Context context, String providerPackageName, OnBound onBoundListener) {
+ this.mApplicationContext = context.getApplicationContext();
+ this.mProviderPackageName = providerPackageName;
+ this.mOnBoundListener = onBoundListener;
+ }
+
+ /** @return the bound {@link ISmimeService}, or {@code null} if not yet bound. */
+ public ISmimeService getService() { return mService; }
+
+ /** @return {@code true} once {@code onServiceConnected} has fired. */
+ public boolean isBound() { return mService != null; }
+
+ private final ServiceConnection mServiceConnection = new ServiceConnection() {
+ @Override
+ public void onServiceConnected(ComponentName name, IBinder service) {
+ mService = ISmimeService.Stub.asInterface(service);
+ if (mOnBoundListener != null) {
+ mOnBoundListener.onBound(mService);
+ }
+ }
+
+ @Override
+ public void onServiceDisconnected(ComponentName name) {
+ mService = null;
+ }
+ };
+
+ /**
+ * Bind to the provider's S/MIME service. Idempotent: if already bound,
+ * fires {@code onBound} again on the listener without rebinding.
+ * On failure the listener's {@code onError} is invoked synchronously.
+ */
+ public void bindToService() {
+ if (mService != null) {
+ if (mOnBoundListener != null) mOnBoundListener.onBound(mService);
+ return;
+ }
+ try {
+ Intent serviceIntent = new Intent(SmimeApi.SERVICE_INTENT);
+ serviceIntent.setPackage(mProviderPackageName);
+ boolean connected = mApplicationContext.bindService(
+ serviceIntent, mServiceConnection, Context.BIND_AUTO_CREATE);
+ if (!connected) {
+ throw new Exception("bindService() returned false for package: " + mProviderPackageName);
+ }
+ } catch (Exception e) {
+ if (mOnBoundListener != null) mOnBoundListener.onError(e);
+ }
+ }
+
+ /**
+ * Unbind from the provider. Safe to call multiple times; safe to call
+ * before {@link #bindToService()} has succeeded only if the connection
+ * has been at least registered (otherwise Android throws).
+ */
+ public void unbindFromService() {
+ mApplicationContext.unbindService(mServiceConnection);
+ mService = null;
+ }
+}
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 1e0ed3eb8d7..e0cdde8bb86 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -248,6 +248,7 @@ include(
)
include(":plugins:openpgp-api-lib:openpgp-api")
+include(":plugins:smime-api:smime-api")
include(
":cli:autodiscovery-cli",