(&adr), sizeof(adr)) == -1) {
+ int errnoBackup = errno;
+ env->ReleaseByteArrayElements(pathArray, path, JNI_ABORT);
+ if (checkJniException(env)) return NULL;
+ close(fd);
+ return getJniResult(env, logTitle, -1, errnoBackup,
+ "createServerSocketNative(): Bind to local socket at path \"" + string(adr.sun_path) + "\" with fd " + to_string(fd) + " failed");
+ }
+
+ // Start listening for client sockets on server socket
+ if (listen(fd, backlog) == -1) {
+ int errnoBackup = errno;
+ env->ReleaseByteArrayElements(pathArray, path, JNI_ABORT);
+ if (checkJniException(env)) return NULL;
+ close(fd);
+ return getJniResult(env, logTitle, -1, errnoBackup,
+ "createServerSocketNative(): Listen on local socket at path \"" + string(adr.sun_path) + "\" with fd " + to_string(fd) + " failed");
+ }
+
+ env->ReleaseByteArrayElements(pathArray, path, JNI_ABORT);
+ if (checkJniException(env)) return NULL;
+
+ // Return success and server socket fd in JniResult.intData field
+ return getJniResult(env, logTitle, fd);
+}
+
+extern "C"
+JNIEXPORT jobject JNICALL
+Java_com_termux_shared_net_socket_local_LocalSocketManager_closeSocketNative(JNIEnv *env, jclass clazz,
+ jstring logTitle, jint fd) {
+ if (fd < 0) {
+ return getJniResult(env, logTitle, -1, "closeSocketNative(): Invalid fd \"" + to_string(fd) + "\" passed");
+ }
+
+ if (close(fd) == -1) {
+ return getJniResult(env, logTitle, -1, errno, "closeSocketNative(): Failed to close socket fd " + to_string(fd));
+ }
+
+ // Return success
+ return getJniResult(env, logTitle);
+}
+
+extern "C"
+JNIEXPORT jobject JNICALL
+Java_com_termux_shared_net_socket_local_LocalSocketManager_acceptNative(JNIEnv *env, jclass clazz,
+ jstring logTitle, jint fd) {
+ if (fd < 0) {
+ return getJniResult(env, logTitle, -1, "acceptNative(): Invalid fd \"" + to_string(fd) + "\" passed");
+ }
+
+ // Accept client socket
+ int clientFd = accept(fd, nullptr, nullptr);
+ if (clientFd == -1) {
+ return getJniResult(env, logTitle, -1, errno, "acceptNative(): Failed to accept client on fd " + to_string(fd));
+ }
+
+ // Return success and client socket fd in JniResult.intData field
+ return getJniResult(env, logTitle, clientFd);
+}
+
+extern "C"
+JNIEXPORT jobject JNICALL
+Java_com_termux_shared_net_socket_local_LocalSocketManager_readNative(JNIEnv *env, jclass clazz,
+ jstring logTitle,
+ jint fd, jbyteArray dataArray,
+ jlong deadline) {
+ if (fd < 0) {
+ return getJniResult(env, logTitle, -1, "readNative(): Invalid fd \"" + to_string(fd) + "\" passed");
+ }
+
+ jbyte* data = env->GetByteArrayElements(dataArray, nullptr);
+ if (checkJniException(env)) return NULL;
+ if (data == nullptr) {
+ return getJniResult(env, logTitle, -1, "readNative(): data passed is null");
+ }
+
+ struct timespec time = {};
+ jbyte* current = data;
+ int bytes = env->GetArrayLength(dataArray);
+ if (checkJniException(env)) return NULL;
+ int bytesRead = 0;
+ while (bytesRead < bytes) {
+ if (deadline > 0) {
+ if (clock_gettime(CLOCK_REALTIME, &time) != -1) {
+ // If current time is greater than the time defined in deadline
+ if (timespec_to_milliseconds(&time) > deadline) {
+ env->ReleaseByteArrayElements(dataArray, data, 0);
+ if (checkJniException(env)) return NULL;
+ return getJniResult(env, logTitle, -1,
+ "readNative(): Deadline \"" + to_string(deadline) + "\" timeout");
+ }
+ } else {
+ log_warn(get_title_and_message(env, logTitle,
+ "readNative(): Deadline \"" + to_string(deadline) +
+ "\" timeout will not work since failed to get current time"));
+ }
+ }
+
+ // Read data from socket
+ int ret = read(fd, current, bytes);
+ if (ret == -1) {
+ int errnoBackup = errno;
+ env->ReleaseByteArrayElements(dataArray, data, 0);
+ if (checkJniException(env)) return NULL;
+ return getJniResult(env, logTitle, -1, errnoBackup, "readNative(): Failed to read on fd " + to_string(fd));
+ }
+ // EOF, peer closed writing end
+ if (ret == 0) {
+ break;
+ }
+
+ bytesRead += ret;
+ current += ret;
+ }
+
+ env->ReleaseByteArrayElements(dataArray, data, 0);
+ if (checkJniException(env)) return NULL;
+
+ // Return success and bytes read in JniResult.intData field
+ return getJniResult(env, logTitle, bytesRead);
+}
+
+
+extern "C"
+JNIEXPORT jobject JNICALL
+Java_com_termux_shared_net_socket_local_LocalSocketManager_sendNative(JNIEnv *env, jclass clazz,
+ jstring logTitle,
+ jint fd, jbyteArray dataArray,
+ jlong deadline) {
+ if (fd < 0) {
+ return getJniResult(env, logTitle, -1, "sendNative(): Invalid fd \"" + to_string(fd) + "\" passed");
+ }
+
+ jbyte* data = env->GetByteArrayElements(dataArray, nullptr);
+ if (checkJniException(env)) return NULL;
+ if (data == nullptr) {
+ return getJniResult(env, logTitle, -1, "sendNative(): data passed is null");
+ }
+
+ struct timespec time = {};
+ jbyte* current = data;
+ int bytes = env->GetArrayLength(dataArray);
+ if (checkJniException(env)) return NULL;
+ while (bytes > 0) {
+ if (deadline > 0) {
+ if (clock_gettime(CLOCK_REALTIME, &time) != -1) {
+ // If current time is greater than the time defined in deadline
+ if (timespec_to_milliseconds(&time) > deadline) {
+ env->ReleaseByteArrayElements(dataArray, data, JNI_ABORT);
+ if (checkJniException(env)) return NULL;
+ return getJniResult(env, logTitle, -1,
+ "sendNative(): Deadline \"" + to_string(deadline) + "\" timeout");
+ }
+ } else {
+ log_warn(get_title_and_message(env, logTitle,
+ "sendNative(): Deadline \"" + to_string(deadline) +
+ "\" timeout will not work since failed to get current time"));
+ }
+ }
+
+ // Send data to socket
+ int ret = send(fd, current, bytes, MSG_NOSIGNAL);
+ if (ret == -1) {
+ int errnoBackup = errno;
+ env->ReleaseByteArrayElements(dataArray, data, JNI_ABORT);
+ if (checkJniException(env)) return NULL;
+ return getJniResult(env, logTitle, -1, errnoBackup, "sendNative(): Failed to send on fd " + to_string(fd));
+ }
+
+ bytes -= ret;
+ current += ret;
+ }
+
+ env->ReleaseByteArrayElements(dataArray, data, JNI_ABORT);
+ if (checkJniException(env)) return NULL;
+
+ // Return success
+ return getJniResult(env, logTitle);
+}
+
+extern "C"
+JNIEXPORT jobject JNICALL
+Java_com_termux_shared_net_socket_local_LocalSocketManager_availableNative(JNIEnv *env, jclass clazz,
+ jstring logTitle, jint fd) {
+ if (fd < 0) {
+ return getJniResult(env, logTitle, -1, "availableNative(): Invalid fd \"" + to_string(fd) + "\" passed");
+ }
+
+ int available = 0;
+ if (ioctl(fd, SIOCINQ, &available) == -1) {
+ return getJniResult(env, logTitle, -1, errno,
+ "availableNative(): Failed to get number of unread bytes in the receive buffer of fd " + to_string(fd));
+ }
+
+ // Return success and bytes available in JniResult.intData field
+ return getJniResult(env, logTitle, available);
+}
+
+/* Sets socket option timeout in milliseconds. */
+int set_socket_timeout(int fd, int option, int timeout) {
+ struct timeval tv = milliseconds_to_timeval(timeout);
+ socklen_t len = sizeof(tv);
+ return setsockopt(fd, SOL_SOCKET, option, &tv, len);
+}
+
+extern "C"
+JNIEXPORT jobject JNICALL
+Java_com_termux_shared_net_socket_local_LocalSocketManager_setSocketReadTimeoutNative(JNIEnv *env, jclass clazz,
+ jstring logTitle,
+ jint fd, jint timeout) {
+ if (fd < 0) {
+ return getJniResult(env, logTitle, -1, "setSocketReadTimeoutNative(): Invalid fd \"" + to_string(fd) + "\" passed");
+ }
+
+ if (set_socket_timeout(fd, SO_RCVTIMEO, timeout) == -1) {
+ return getJniResult(env, logTitle, -1, errno,
+ "setSocketReadTimeoutNative(): Failed to set socket receiving (SO_RCVTIMEO) timeout for fd " + to_string(fd));
+ }
+
+ // Return success
+ return getJniResult(env, logTitle);
+}
+
+extern "C"
+JNIEXPORT jobject JNICALL
+Java_com_termux_shared_net_socket_local_LocalSocketManager_setSocketSendTimeoutNative(JNIEnv *env, jclass clazz,
+ jstring logTitle,
+ jint fd, jint timeout) {
+ if (fd < 0) {
+ return getJniResult(env, logTitle, -1, "setSocketSendTimeoutNative(): Invalid fd \"" +
+ to_string(fd) + "\" passed");
+ }
+
+ if (set_socket_timeout(fd, SO_SNDTIMEO, timeout) == -1) {
+ return getJniResult(env, logTitle, -1, errno,
+ "setSocketSendTimeoutNative(): Failed to set socket sending (SO_SNDTIMEO) timeout for fd " + to_string(fd));
+ }
+
+ // Return success
+ return getJniResult(env, logTitle);
+}
+
+extern "C"
+JNIEXPORT jobject JNICALL
+Java_com_termux_shared_net_socket_local_LocalSocketManager_getPeerCredNative(JNIEnv *env, jclass clazz,
+ jstring logTitle,
+ jint fd, jobject peerCred) {
+ if (fd < 0) {
+ return getJniResult(env, logTitle, -1, "getPeerCredNative(): Invalid fd \"" + to_string(fd) + "\" passed");
+ }
+
+ if (peerCred == nullptr) {
+ return getJniResult(env, logTitle, -1, "getPeerCredNative(): peerCred passed is null");
+ }
+
+ // Initialize to -1 instead of 0 in case a failed getsockopt() call somehow doesn't report failure and returns the uid of root
+ struct ucred cred = {};
+ cred.pid = -1; cred.uid = -1; cred.gid = -1;
+
+ socklen_t len = sizeof(cred);
+
+ if (getsockopt(fd, SOL_SOCKET, SO_PEERCRED, &cred, &len) == -1) {
+ return getJniResult(env, logTitle, -1, errno, "getPeerCredNative(): Failed to get peer credentials for fd " + to_string(fd));
+ }
+
+ // Fill "com.termux.shared.net.socket.local.PeerCred" object.
+ // The pid, uid and gid will always be set based on ucred.
+ // The pname and cmdline will only be set if current process has access to "/proc/[pid]/cmdline"
+ // of peer process. Processes of other users/apps are not normally accessible.
+ jclass peerCredClazz = env->GetObjectClass(peerCred);
+ if (checkJniException(env)) return NULL;
+ if (!peerCredClazz) {
+ return getJniResult(env, logTitle, -1, errno, "getPeerCredNative(): Failed to get PeerCred class");
+ }
+
+ string error;
+
+ error = setIntField(env, peerCred, peerCredClazz, "pid", cred.pid);
+ if (!error.empty()) {
+ if (error == JNI_EXCEPTION) return NULL;
+ return getJniResult(env, logTitle, -1, "getPeerCredNative(): " + error);
+ }
+
+ error = setIntField(env, peerCred, peerCredClazz, "uid", cred.uid);
+ if (!error.empty()) {
+ if (error == JNI_EXCEPTION) return NULL;
+ return getJniResult(env, logTitle, -1, "getPeerCredNative(): " + error);
+ }
+
+ error = setIntField(env, peerCred, peerCredClazz, "gid", cred.gid);
+ if (!error.empty()) {
+ if (error == JNI_EXCEPTION) return NULL;
+ return getJniResult(env, logTitle, -1, "getPeerCredNative(): " + error);
+ }
+
+ string cmdline = get_process_cmdline(cred.pid);
+ if (!cmdline.empty()) {
+ error = setStringField(env, peerCred, peerCredClazz, "pname", get_process_name_from_cmdline(cmdline));
+ if (!error.empty()) {
+ if (error == JNI_EXCEPTION) return NULL;
+ return getJniResult(env, logTitle, -1, "getPeerCredNative(): " + error);
+ }
+
+ error = setStringField(env, peerCred, peerCredClazz, "cmdline", get_process_cmdline_spaced(cmdline));
+ if (!error.empty()) {
+ if (error == JNI_EXCEPTION) return NULL;
+ return getJniResult(env, logTitle, -1, "getPeerCredNative(): " + error);
+ }
+ }
+
+ // Return success since PeerCred was filled successfully
+ return getJniResult(env, logTitle);
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/activities/ReportActivity.java b/termux/termux-shared/src/main/java/com/termux/shared/activities/ReportActivity.java
new file mode 100644
index 000000000..b9d9a9569
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/activities/ReportActivity.java
@@ -0,0 +1,544 @@
+package com.termux.shared.activities;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.os.Bundle;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.View;
+import androidx.annotation.NonNull;
+import androidx.appcompat.app.ActionBar;
+import androidx.appcompat.widget.Toolbar;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+import com.itsaky.androidide.app.BaseIDEActivity;
+import com.termux.shared.R;
+import com.termux.shared.data.DataUtils;
+import com.termux.shared.databinding.ActivityReportBinding;
+import com.termux.shared.errors.Error;
+import com.termux.shared.file.FileUtils;
+import com.termux.shared.file.filesystem.FileType;
+import com.termux.shared.interact.ShareUtils;
+import com.termux.shared.logger.Logger;
+import com.termux.shared.markdown.MarkdownUtils;
+import com.termux.shared.models.ReportInfo;
+import com.termux.shared.termux.TermuxConstants;
+import io.noties.markwon.Markwon;
+import io.noties.markwon.recycler.MarkwonAdapter;
+import io.noties.markwon.recycler.SimpleEntry;
+import org.commonmark.node.FencedCodeBlock;
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * An activity to show reports in markdown format as per CommonMark spec based on config passed as
+ * {@link ReportInfo}. Add Following to `AndroidManifest.xml` to use in an app:
+ * {@code `` } and
+ * {@code `` } Receiver **must not** be `exported="true"`!!!
+ *
+ * Also make an incremental call to
+ * {@link #deleteReportInfoFilesOlderThanXDays(Context, int, boolean)} in the app to cleanup cached
+ * files.
+ */
+public class ReportActivity extends BaseIDEActivity {
+
+ private ActivityReportBinding binding;
+
+ private static final String CLASS_NAME = ReportActivity.class.getCanonicalName();
+ private static final String ACTION_DELETE_REPORT_INFO_OBJECT_FILE =
+ CLASS_NAME + ".ACTION_DELETE_REPORT_INFO_OBJECT_FILE";
+
+ private static final String EXTRA_REPORT_INFO_OBJECT = CLASS_NAME + ".EXTRA_REPORT_INFO_OBJECT";
+ private static final String EXTRA_REPORT_INFO_OBJECT_FILE_PATH =
+ CLASS_NAME + ".EXTRA_REPORT_INFO_OBJECT_FILE_PATH";
+
+ private static final String CACHE_DIR_BASENAME = "report_activity";
+ private static final String CACHE_FILE_BASENAME_PREFIX = "report_info_";
+
+ public static final int REQUEST_GRANT_STORAGE_PERMISSION_FOR_SAVE_FILE = 1000;
+
+ public static final int ACTIVITY_TEXT_SIZE_LIMIT_IN_BYTES = 1000 * 1024; // 1MB
+
+ private ReportInfo mReportInfo;
+ private String mReportInfoFilePath;
+ private String mReportActivityMarkdownString;
+ private Bundle mBundle;
+
+ private static final String LOG_TAG = "ReportActivity";
+
+ @NonNull
+ @Override
+ protected View bindLayout() {
+ this.binding = ActivityReportBinding.inflate(getLayoutInflater());
+ return binding.getRoot();
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ Logger.logVerbose(LOG_TAG, "onCreate");
+ super.onCreate(savedInstanceState);
+
+ Toolbar toolbar = findViewById(R.id.toolbar);
+ if (toolbar != null) {
+ setSupportActionBar(toolbar);
+ }
+
+ mBundle = null;
+ Intent intent = getIntent();
+ if (intent != null) {
+ mBundle = intent.getExtras();
+ } else if (savedInstanceState != null) {
+ mBundle = savedInstanceState;
+ }
+
+ updateUI();
+
+ }
+
+ @Override
+ protected void onNewIntent(Intent intent) {
+ super.onNewIntent(intent);
+ Logger.logVerbose(LOG_TAG, "onNewIntent");
+
+ setIntent(intent);
+
+ if (intent != null) {
+ deleteReportInfoFile(this, mReportInfoFilePath);
+ mBundle = intent.getExtras();
+ updateUI();
+ }
+ }
+
+ private void updateUI() {
+
+ if (mBundle == null) {
+ finish();
+ return;
+ }
+
+ mReportInfo = null;
+ mReportInfoFilePath = null;
+
+ if (mBundle.containsKey(EXTRA_REPORT_INFO_OBJECT_FILE_PATH)) {
+ mReportInfoFilePath = mBundle.getString(EXTRA_REPORT_INFO_OBJECT_FILE_PATH);
+ Logger.logVerbose(LOG_TAG,
+ ReportInfo.class.getSimpleName() + " serialized object will be read from file at path \""
+ + mReportInfoFilePath + "\"");
+ if (mReportInfoFilePath != null) {
+ try {
+ FileUtils.ReadSerializableObjectResult result = FileUtils.readSerializableObjectFromFile(
+ ReportInfo.class.getSimpleName(), mReportInfoFilePath, ReportInfo.class, false);
+ if (result.error != null) {
+ Logger.logErrorExtended(LOG_TAG, result.error.toString());
+ Logger.showToast(this, Error.getMinimalErrorString(result.error), true);
+ finish();
+ return;
+ } else {
+ if (result.serializableObject != null) {
+ mReportInfo = (ReportInfo) result.serializableObject;
+ }
+ }
+ } catch (Exception e) {
+ Logger.logErrorAndShowToast(this, LOG_TAG, e.getMessage());
+ Logger.logStackTraceWithMessage(LOG_TAG,
+ "Failure while getting " + ReportInfo.class.getSimpleName()
+ + " serialized object from file at path \"" + mReportInfoFilePath + "\"", e);
+ }
+ }
+ } else {
+ mReportInfo = (ReportInfo) mBundle.getSerializable(EXTRA_REPORT_INFO_OBJECT);
+ }
+
+ if (mReportInfo == null) {
+ finish();
+ return;
+ }
+
+ final ActionBar actionBar = getSupportActionBar();
+ if (actionBar != null) {
+ if (mReportInfo.reportTitle != null) {
+ actionBar.setTitle(mReportInfo.reportTitle);
+ } else {
+ actionBar.setTitle(TermuxConstants.TERMUX_APP_NAME + " App Report");
+ }
+ }
+
+ RecyclerView recyclerView = findViewById(R.id.recycler_view);
+
+ final Markwon markwon = MarkdownUtils.getRecyclerMarkwonBuilder(this);
+
+ final MarkwonAdapter adapter = MarkwonAdapter.builderTextViewIsRoot(
+ R.layout.markdown_adapter_node_default)
+ .include(FencedCodeBlock.class,
+ SimpleEntry.create(R.layout.markdown_adapter_node_code_block, R.id.code_text_view))
+ .build();
+
+ recyclerView.setLayoutManager(new LinearLayoutManager(this));
+ recyclerView.setAdapter(adapter);
+
+ generateReportActivityMarkdownString();
+ adapter.setMarkdown(markwon, mReportActivityMarkdownString);
+ adapter.notifyDataSetChanged();
+ }
+
+
+ @Override
+ public void onSaveInstanceState(@NonNull Bundle outState) {
+ super.onSaveInstanceState(outState);
+ if (mBundle.containsKey(EXTRA_REPORT_INFO_OBJECT_FILE_PATH)) {
+ outState.putString(EXTRA_REPORT_INFO_OBJECT_FILE_PATH, mReportInfoFilePath);
+ } else {
+ outState.putSerializable(EXTRA_REPORT_INFO_OBJECT, mReportInfo);
+ }
+ }
+
+ @Override
+ protected void onDestroy() {
+ super.onDestroy();
+ Logger.logVerbose(LOG_TAG, "onDestroy");
+ binding = null;
+ deleteReportInfoFile(this, mReportInfoFilePath);
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(final Menu menu) {
+ final MenuInflater inflater = getMenuInflater();
+ inflater.inflate(R.menu.menu_report, menu);
+
+ if (mReportInfo.reportSaveFilePath == null) {
+ MenuItem item = menu.findItem(R.id.menu_item_save_report_to_file);
+ if (item != null) {
+ item.setEnabled(false);
+ }
+ }
+
+ return true;
+ }
+
+ @Override
+ public void onBackPressed() {
+ // Remove activity from recents menu on back button press
+ finishAndRemoveTask();
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(final MenuItem item) {
+ int id = item.getItemId();
+ if (id == R.id.menu_item_share_report) {
+ ShareUtils.shareText(this, getString(R.string.title_report_text),
+ ReportInfo.getReportInfoMarkdownString(mReportInfo));
+ } else if (id == R.id.menu_item_copy_report) {
+ ShareUtils.copyTextToClipboard(this, ReportInfo.getReportInfoMarkdownString(mReportInfo),
+ null);
+ } else if (id == R.id.menu_item_save_report_to_file) {
+ ShareUtils.saveTextToFile(this, mReportInfo.reportSaveFileLabel,
+ mReportInfo.reportSaveFilePath, ReportInfo.getReportInfoMarkdownString(mReportInfo),
+ true, REQUEST_GRANT_STORAGE_PERMISSION_FOR_SAVE_FILE);
+ }
+
+ return false;
+ }
+
+ @Override
+ public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions,
+ @NonNull int[] grantResults) {
+ super.onRequestPermissionsResult(requestCode, permissions, grantResults);
+ if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
+ Logger.logInfo(LOG_TAG, "Storage permission granted by user on request.");
+ if (requestCode == REQUEST_GRANT_STORAGE_PERMISSION_FOR_SAVE_FILE) {
+ ShareUtils.saveTextToFile(this, mReportInfo.reportSaveFileLabel,
+ mReportInfo.reportSaveFilePath, ReportInfo.getReportInfoMarkdownString(mReportInfo),
+ true, -1);
+ }
+ } else {
+ Logger.logInfo(LOG_TAG, "Storage permission denied by user on request.");
+ }
+ }
+
+ /**
+ * Generate the markdown {@link String} to be shown in {@link ReportActivity}.
+ */
+ private void generateReportActivityMarkdownString() {
+ // We need to reduce chances of OutOfMemoryError happening so reduce new allocations and
+ // do not keep output of getReportInfoMarkdownString in memory
+ StringBuilder reportString = new StringBuilder();
+
+ if (mReportInfo.reportStringPrefix != null) {
+ reportString.append(mReportInfo.reportStringPrefix);
+ }
+
+ String reportMarkdownString = ReportInfo.getReportInfoMarkdownString(mReportInfo);
+ int reportMarkdownStringSize = reportMarkdownString.getBytes().length;
+ boolean truncated = false;
+ if (reportMarkdownStringSize > ACTIVITY_TEXT_SIZE_LIMIT_IN_BYTES) {
+ Logger.logVerbose(LOG_TAG,
+ mReportInfo.reportTitle + " report string size " + reportMarkdownStringSize
+ + " is greater than " + ACTIVITY_TEXT_SIZE_LIMIT_IN_BYTES + " and will be truncated");
+ reportString.append(DataUtils.getTruncatedCommandOutput(reportMarkdownString,
+ ACTIVITY_TEXT_SIZE_LIMIT_IN_BYTES, true, false, true));
+ truncated = true;
+ } else {
+ reportString.append(reportMarkdownString);
+ }
+
+ // Free reference
+ reportMarkdownString = null;
+
+ if (mReportInfo.reportStringSuffix != null) {
+ reportString.append(mReportInfo.reportStringSuffix);
+ }
+
+ int reportStringSize = reportString.length();
+ if (reportStringSize > ACTIVITY_TEXT_SIZE_LIMIT_IN_BYTES) {
+ // This may break markdown formatting
+ Logger.logVerbose(LOG_TAG,
+ mReportInfo.reportTitle + " report string total size " + reportStringSize
+ + " is greater than " + ACTIVITY_TEXT_SIZE_LIMIT_IN_BYTES + " and will be truncated");
+ mReportActivityMarkdownString = this.getString(R.string.msg_report_truncated) +
+ DataUtils.getTruncatedCommandOutput(reportString.toString(),
+ ACTIVITY_TEXT_SIZE_LIMIT_IN_BYTES, true, false, false);
+ } else if (truncated) {
+ mReportActivityMarkdownString =
+ this.getString(R.string.msg_report_truncated) + reportString.toString();
+ } else {
+ mReportActivityMarkdownString = reportString.toString();
+ }
+
+ }
+
+
+ public static class NewInstanceResult {
+
+ /**
+ * An intent that can be used to start the {@link ReportActivity}.
+ */
+ public Intent contentIntent;
+ /**
+ * An intent that can should be adding as the {@link android.app.Notification#deleteIntent} by a
+ * call to {@link android.app.PendingIntent#getBroadcast(Context, int, Intent, int)} so that
+ * {@link ReportActivityBroadcastReceiver} can do cleanup of
+ * {@link #EXTRA_REPORT_INFO_OBJECT_FILE_PATH}.
+ */
+ public Intent deleteIntent;
+
+ NewInstanceResult(Intent contentIntent, Intent deleteIntent) {
+ this.contentIntent = contentIntent;
+ this.deleteIntent = deleteIntent;
+ }
+ }
+
+ /**
+ * Start the {@link ReportActivity}.
+ *
+ * @param context The {@link Context} for operations.
+ * @param reportInfo The {@link ReportInfo} containing info that needs to be displayed.
+ */
+ public static void startReportActivity(@NonNull final Context context,
+ @NonNull ReportInfo reportInfo) {
+ NewInstanceResult result = newInstance(context, reportInfo);
+ if (result.contentIntent == null) {
+ return;
+ }
+ context.startActivity(result.contentIntent);
+ }
+
+ /**
+ * Get content and delete intents for the {@link ReportActivity} that can be used to start it and
+ * do cleanup.
+ *
+ * If {@link ReportInfo} size is too large, then a TransactionTooLargeException will be thrown so
+ * its object may be saved to a file in the {@link Context#getCacheDir()}. Then when activity
+ * starts, its read back and the file is deleted in {@link #onDestroy()}. Note that files may
+ * still be left if {@link #onDestroy()} is not called or doesn't finish. A separate cleanup
+ * routine is implemented from that case by
+ * {@link #deleteReportInfoFilesOlderThanXDays(Context, int, boolean)} which should be called
+ * incrementally or at app startup.
+ *
+ * @param context The {@link Context} for operations.
+ * @param reportInfo The {@link ReportInfo} containing info that needs to be displayed.
+ * @return Returns {@link NewInstanceResult}.
+ */
+ @NonNull
+ public static NewInstanceResult newInstance(@NonNull final Context context,
+ @NonNull final ReportInfo reportInfo) {
+
+ long size = DataUtils.getSerializedSize(reportInfo);
+ if (size > DataUtils.TRANSACTION_SIZE_LIMIT_IN_BYTES) {
+ String reportInfoDirectoryPath = getReportInfoDirectoryPath(context);
+ String reportInfoFilePath =
+ reportInfoDirectoryPath + "/" + CACHE_FILE_BASENAME_PREFIX + reportInfo.reportTimestamp;
+ Logger.logVerbose(LOG_TAG, reportInfo.reportTitle + " " + ReportInfo.class.getSimpleName()
+ + " serialized object size " + size + " is greater than "
+ + DataUtils.TRANSACTION_SIZE_LIMIT_IN_BYTES + " and it will be written to file at path \""
+ + reportInfoFilePath + "\"");
+ Error error = FileUtils.writeSerializableObjectToFile(ReportInfo.class.getSimpleName(),
+ reportInfoFilePath, reportInfo);
+ if (error != null) {
+ Logger.logErrorExtended(LOG_TAG, error.toString());
+ Logger.showToast(context, Error.getMinimalErrorString(error), true);
+ return new NewInstanceResult(null, null);
+ }
+
+ return new NewInstanceResult(createContentIntent(context, null, reportInfoFilePath),
+ createDeleteIntent(context, reportInfoFilePath));
+ } else {
+ return new NewInstanceResult(createContentIntent(context, reportInfo, null),
+ null);
+ }
+ }
+
+ private static Intent createContentIntent(@NonNull final Context context,
+ final ReportInfo reportInfo, final String reportInfoFilePath) {
+ Intent intent = new Intent(context, ReportActivity.class);
+ Bundle bundle = new Bundle();
+
+ if (reportInfoFilePath != null) {
+ bundle.putString(EXTRA_REPORT_INFO_OBJECT_FILE_PATH, reportInfoFilePath);
+ } else {
+ bundle.putSerializable(EXTRA_REPORT_INFO_OBJECT, reportInfo);
+ }
+
+ intent.putExtras(bundle);
+
+ // Note that ReportActivity should have `documentLaunchMode="intoExisting"` set in `AndroidManifest.xml`
+ // which has equivalent behaviour to FLAG_ACTIVITY_NEW_DOCUMENT.
+ // FLAG_ACTIVITY_SINGLE_TOP must also be passed for onNewIntent to be called.
+ intent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_NEW_TASK
+ | Intent.FLAG_ACTIVITY_NEW_DOCUMENT);
+ return intent;
+ }
+
+
+ private static Intent createDeleteIntent(@NonNull final Context context,
+ final String reportInfoFilePath) {
+ if (reportInfoFilePath == null) {
+ return null;
+ }
+
+ Intent intent = new Intent(context, ReportActivityBroadcastReceiver.class);
+ intent.setAction(ACTION_DELETE_REPORT_INFO_OBJECT_FILE);
+
+ Bundle bundle = new Bundle();
+ bundle.putString(EXTRA_REPORT_INFO_OBJECT_FILE_PATH, reportInfoFilePath);
+ intent.putExtras(bundle);
+
+ return intent;
+ }
+
+
+ @NotNull
+ private static String getReportInfoDirectoryPath(Context context) {
+ // Canonicalize to solve /data/data and /data/user/0 issues when comparing with reportInfoFilePath
+ return FileUtils.getCanonicalPath(context.getCacheDir().getAbsolutePath(), null) + "/"
+ + CACHE_DIR_BASENAME;
+ }
+
+ private static void deleteReportInfoFile(Context context, String reportInfoFilePath) {
+ if (context == null || reportInfoFilePath == null) {
+ return;
+ }
+
+ // Extra protection for mainly if someone set `exported="true"` for ReportActivityBroadcastReceiver
+ String reportInfoDirectoryPath = getReportInfoDirectoryPath(context);
+ reportInfoFilePath = FileUtils.getCanonicalPath(reportInfoFilePath, null);
+ if (!reportInfoFilePath.equals(reportInfoDirectoryPath) && reportInfoFilePath.startsWith(
+ reportInfoDirectoryPath + "/")) {
+ Logger.logVerbose(LOG_TAG,
+ "Deleting " + ReportInfo.class.getSimpleName() + " serialized object file at path \""
+ + reportInfoFilePath + "\"");
+ Error error = FileUtils.deleteRegularFile(ReportInfo.class.getSimpleName(),
+ reportInfoFilePath, true);
+ if (error != null) {
+ Logger.logErrorExtended(LOG_TAG, error.toString());
+ }
+ } else {
+ Logger.logError(LOG_TAG,
+ "Not deleting " + ReportInfo.class.getSimpleName() + " serialized object file at path \""
+ + reportInfoFilePath + "\" since its not under \"" + reportInfoDirectoryPath + "\"");
+ }
+ }
+
+ /**
+ * Delete {@link ReportInfo} serialized object files from cache older than x days. If a
+ * notification has still not been opened after x days that's using a PendingIntent to
+ * ReportActivity, then opening the notification will throw a file not found error, so choose days
+ * value appropriately or check if a notification is still active if tracking notification ids.
+ * The {@link Context} object passed must be of the same package with which
+ * {@link #newInstance(Context, ReportInfo)} was called since a call to
+ * {@link Context#getCacheDir()} is made.
+ *
+ * @param context The {@link Context} for operations.
+ * @param days The x amount of days before which files should be deleted. This must be
+ * `>=0`.
+ * @param isSynchronous If set to {@code true}, then the command will be executed in the caller
+ * thread and results returned synchronously. If set to {@code false}, then a
+ * new thread is started run the commands asynchronously in the background
+ * and control is returned to the caller thread.
+ * @return Returns the {@code error} if deleting was not successful, otherwise {@code null}.
+ */
+ public static Error deleteReportInfoFilesOlderThanXDays(@NonNull final Context context, int days,
+ final boolean isSynchronous) {
+ if (isSynchronous) {
+ return deleteReportInfoFilesOlderThanXDaysInner(context, days);
+ } else {
+ new Thread() {
+ public void run() {
+ Error error = deleteReportInfoFilesOlderThanXDaysInner(context, days);
+ if (error != null) {
+ Logger.logErrorExtended(LOG_TAG, error.toString());
+ }
+ }
+ }.start();
+ return null;
+ }
+ }
+
+ private static Error deleteReportInfoFilesOlderThanXDaysInner(@NonNull final Context context,
+ int days) {
+ // Only regular files are deleted and subdirectories are not checked
+ String reportInfoDirectoryPath = getReportInfoDirectoryPath(context);
+ Logger.logVerbose(LOG_TAG, "Deleting " + ReportInfo.class.getSimpleName()
+ + " serialized object files under directory path \"" + reportInfoDirectoryPath
+ + "\" older than " + days + " days");
+ return FileUtils.deleteFilesOlderThanXDays(ReportInfo.class.getSimpleName(),
+ reportInfoDirectoryPath, null, days, true, FileType.REGULAR.getValue());
+ }
+
+
+ /**
+ * The {@link BroadcastReceiver} for {@link ReportActivity} that currently does cleanup when
+ * {@link android.app.Notification#deleteIntent} is called. It must be registered in
+ * `AndroidManifest.xml`.
+ */
+ public static class ReportActivityBroadcastReceiver extends BroadcastReceiver {
+
+ private static final String LOG_TAG = "ReportActivityBroadcastReceiver";
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ if (intent == null) {
+ return;
+ }
+
+ String action = intent.getAction();
+ Logger.logVerbose(LOG_TAG, "onReceive: \"" + action + "\" action");
+
+ if (ACTION_DELETE_REPORT_INFO_OBJECT_FILE.equals(action)) {
+ Bundle bundle = intent.getExtras();
+ if (bundle == null) {
+ return;
+ }
+ if (bundle.containsKey(EXTRA_REPORT_INFO_OBJECT_FILE_PATH)) {
+ deleteReportInfoFile(context, bundle.getString(EXTRA_REPORT_INFO_OBJECT_FILE_PATH));
+ }
+ }
+ }
+ }
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/activities/TextIOActivity.java b/termux/termux-shared/src/main/java/com/termux/shared/activities/TextIOActivity.java
new file mode 100644
index 000000000..7cdf6ab0e
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/activities/TextIOActivity.java
@@ -0,0 +1,278 @@
+package com.termux.shared.activities;
+
+import android.annotation.SuppressLint;
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+import android.graphics.Typeface;
+import android.os.Bundle;
+import android.text.Editable;
+import android.text.InputFilter;
+import android.text.TextWatcher;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.EditText;
+import android.widget.HorizontalScrollView;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.appcompat.app.ActionBar;
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.appcompat.widget.Toolbar;
+
+import com.termux.shared.interact.ShareUtils;
+import com.termux.shared.logger.Logger;
+import com.termux.shared.R;
+import com.termux.shared.models.TextIOInfo;
+import com.termux.shared.view.KeyboardUtils;
+
+import org.jetbrains.annotations.NotNull;
+
+import java.util.Locale;
+
+/**
+ * An activity to edit or view text based on config passed as {@link TextIOInfo}.
+ *
+ * Add Following to `AndroidManifest.xml` to use in an app:
+ *
+ * {@code ` ` }
+ */
+public class TextIOActivity extends AppCompatActivity {
+
+ private static final String CLASS_NAME = ReportActivity.class.getCanonicalName();
+ public static final String EXTRA_TEXT_IO_INFO_OBJECT = CLASS_NAME + ".EXTRA_TEXT_IO_INFO_OBJECT";
+
+ private TextView mTextIOLabel;
+ private View mTextIOLabelSeparator;
+ private EditText mTextIOText;
+ private HorizontalScrollView mTextIOHorizontalScrollView;
+ private LinearLayout mTextIOTextLinearLayout;
+ private TextView mTextIOTextCharacterUsage;
+
+ private TextIOInfo mTextIOInfo;
+ private Bundle mBundle;
+
+ private static final String LOG_TAG = "TextIOActivity";
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ Logger.logVerbose(LOG_TAG, "onCreate");
+
+ setContentView(R.layout.activity_text_io);
+
+ mTextIOLabel = findViewById(R.id.text_io_label);
+ mTextIOLabelSeparator = findViewById(R.id.text_io_label_separator);
+ mTextIOText = findViewById(R.id.text_io_text);
+ mTextIOHorizontalScrollView = findViewById(R.id.text_io_horizontal_scroll_view);
+ mTextIOTextLinearLayout = findViewById(R.id.text_io_text_linear_layout);
+ mTextIOTextCharacterUsage = findViewById(R.id.text_io_text_character_usage);
+
+ Toolbar toolbar = findViewById(R.id.toolbar);
+ if (toolbar != null) {
+ setSupportActionBar(toolbar);
+ }
+
+ mBundle = null;
+ Intent intent = getIntent();
+ if (intent != null)
+ mBundle = intent.getExtras();
+ else if (savedInstanceState != null)
+ mBundle = savedInstanceState;
+
+ updateUI();
+ }
+
+ @Override
+ protected void onNewIntent(Intent intent) {
+ super.onNewIntent(intent);
+ Logger.logVerbose(LOG_TAG, "onNewIntent");
+
+ // Views must be re-created since different configs for isEditingTextDisabled() and
+ // isHorizontallyScrollable() will not work or at least reliably
+ finish();
+ startActivity(intent);
+ }
+
+ @SuppressLint("ClickableViewAccessibility")
+ private void updateUI() {
+ if (mBundle == null) {
+ finish(); return;
+ }
+
+ mTextIOInfo = (TextIOInfo) mBundle.getSerializable(EXTRA_TEXT_IO_INFO_OBJECT);
+ if (mTextIOInfo == null) {
+ finish(); return;
+ }
+
+ final ActionBar actionBar = getSupportActionBar();
+ if (actionBar != null) {
+ if (mTextIOInfo.getTitle() != null)
+ actionBar.setTitle(mTextIOInfo.getTitle());
+ else
+ actionBar.setTitle("Text Input");
+
+ if (mTextIOInfo.shouldShowBackButtonInActionBar()) {
+ actionBar.setDisplayHomeAsUpEnabled(true);
+ actionBar.setDisplayShowHomeEnabled(true);
+ }
+ }
+
+ mTextIOLabel.setVisibility(View.GONE);
+ mTextIOLabelSeparator.setVisibility(View.GONE);
+ if (mTextIOInfo.isLabelEnabled()) {
+ mTextIOLabel.setVisibility(View.VISIBLE);
+ mTextIOLabelSeparator.setVisibility(View.VISIBLE);
+ mTextIOLabel.setText(mTextIOInfo.getLabel());
+ mTextIOLabel.setFilters(new InputFilter[] { new InputFilter.LengthFilter(TextIOInfo.LABEL_SIZE_LIMIT_IN_BYTES) });
+ mTextIOLabel.setTextSize(mTextIOInfo.getLabelSize());
+ mTextIOLabel.setTextColor(mTextIOInfo.getLabelColor());
+ mTextIOLabel.setTypeface(Typeface.create(mTextIOInfo.getLabelTypeFaceFamily(), mTextIOInfo.getLabelTypeFaceStyle()));
+ }
+
+
+ if (mTextIOInfo.isHorizontallyScrollable()) {
+ mTextIOHorizontalScrollView.setEnabled(true);
+ mTextIOText.setHorizontallyScrolling(true);
+ } else {
+ // Remove mTextIOHorizontalScrollView and add mTextIOText in its place
+ ViewGroup parent = (ViewGroup) mTextIOHorizontalScrollView.getParent();
+ if (parent != null && parent.indexOfChild(mTextIOText) < 0) {
+ ViewGroup.LayoutParams params = mTextIOHorizontalScrollView.getLayoutParams();
+ int index = parent.indexOfChild(mTextIOHorizontalScrollView);
+ mTextIOTextLinearLayout.removeAllViews();
+ mTextIOHorizontalScrollView.removeAllViews();
+ parent.removeView(mTextIOHorizontalScrollView);
+ parent.addView(mTextIOText, index, params);
+ mTextIOText.setHorizontallyScrolling(false);
+ }
+ }
+
+ mTextIOText.setText(mTextIOInfo.getText());
+ mTextIOText.setFilters(new InputFilter[] { new InputFilter.LengthFilter(mTextIOInfo.getTextLengthLimit()) });
+ mTextIOText.setTextSize(mTextIOInfo.getTextSize());
+ mTextIOText.setTextColor(mTextIOInfo.getTextColor());
+ mTextIOText.setTypeface(Typeface.create(mTextIOInfo.getTextTypeFaceFamily(), mTextIOInfo.getTextTypeFaceStyle()));
+
+ // setTextIsSelectable must be called after changing KeyListener to regain focusability and selectivity
+ if (mTextIOInfo.isEditingTextDisabled()) {
+ mTextIOText.setCursorVisible(false);
+ mTextIOText.setKeyListener(null);
+ mTextIOText.setTextIsSelectable(true);
+ }
+
+ if (mTextIOInfo.shouldShowTextCharacterUsage()) {
+ mTextIOTextCharacterUsage.setVisibility(View.VISIBLE);
+ updateTextIOTextCharacterUsage(mTextIOInfo.getText());
+
+ mTextIOText.addTextChangedListener(new TextWatcher() {
+ @Override
+ public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
+ @Override
+ public void onTextChanged(CharSequence s, int start, int before, int count) {}
+ @Override
+ public void afterTextChanged(Editable editable) {
+ if (editable != null)
+ updateTextIOTextCharacterUsage(editable.toString());
+ }
+ });
+ } else {
+ mTextIOTextCharacterUsage.setVisibility(View.GONE);
+ mTextIOText.addTextChangedListener(null);
+ }
+ }
+
+ private void updateTextIOInfoText() {
+ if (mTextIOText != null)
+ mTextIOInfo.setText(mTextIOText.getText().toString());
+ }
+
+ private void updateTextIOTextCharacterUsage(String text) {
+ if (text == null) text = "";
+ if (mTextIOTextCharacterUsage != null)
+ mTextIOTextCharacterUsage.setText(String.format(Locale.getDefault(), "%1$d/%2$d", text.length(), mTextIOInfo.getTextLengthLimit()));
+ }
+
+ @Override
+ public void onSaveInstanceState(@NonNull Bundle outState) {
+ super.onSaveInstanceState(outState);
+
+ updateTextIOInfoText();
+ outState.putSerializable(EXTRA_TEXT_IO_INFO_OBJECT, mTextIOInfo);
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(final Menu menu) {
+ final MenuInflater inflater = getMenuInflater();
+ inflater.inflate(R.menu.menu_text_io, menu);
+ return true;
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(final MenuItem item) {
+ String text = "";
+ if (mTextIOText != null)
+ text = mTextIOText.getText().toString();
+
+ int id = item.getItemId();
+ if (id == android.R.id.home) {
+ confirm();
+ } if (id == R.id.menu_item_cancel) {
+ cancel();
+ } else if (id == R.id.menu_item_share_text) {
+ ShareUtils.shareText(this, mTextIOInfo.getTitle(), text);
+ } else if (id == R.id.menu_item_copy_text) {
+ ShareUtils.copyTextToClipboard(this, text, null);
+ }
+
+ return false;
+ }
+
+ @Override
+ public void onBackPressed() {
+ confirm();
+ }
+
+ /** Confirm current text and send it back to calling {@link Activity}. */
+ private void confirm() {
+ updateTextIOInfoText();
+ KeyboardUtils.hideSoftKeyboard(this, mTextIOText);
+ setResult(Activity.RESULT_OK, getResultIntent());
+ finish();
+ }
+
+ /** Cancel current text and notify calling {@link Activity}. */
+ private void cancel() {
+ KeyboardUtils.hideSoftKeyboard(this, mTextIOText);
+ setResult(Activity.RESULT_CANCELED, getResultIntent());
+ finish();
+ }
+
+ @NotNull
+ private Intent getResultIntent() {
+ Intent intent = new Intent();
+ Bundle bundle = new Bundle();
+ bundle.putSerializable(EXTRA_TEXT_IO_INFO_OBJECT, mTextIOInfo);
+ intent.putExtras(bundle);
+ return intent;
+ }
+
+ /**
+ * Get the {@link Intent} that can be used to start the {@link TextIOActivity}.
+ *
+ * @param context The {@link Context} for operations.
+ * @param textIOInfo The {@link TextIOInfo} containing info for the edit text.
+ */
+ public static Intent newInstance(@NonNull final Context context, @NonNull final TextIOInfo textIOInfo) {
+ Intent intent = new Intent(context, TextIOActivity.class);
+ Bundle bundle = new Bundle();
+ bundle.putSerializable(EXTRA_TEXT_IO_INFO_OBJECT, textIOInfo);
+ intent.putExtras(bundle);
+ return intent;
+ }
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/activity/ActivityErrno.java b/termux/termux-shared/src/main/java/com/termux/shared/activity/ActivityErrno.java
new file mode 100644
index 000000000..96a0e9ce0
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/activity/ActivityErrno.java
@@ -0,0 +1,20 @@
+package com.termux.shared.activity;
+
+import com.termux.shared.errors.Errno;
+
+public class ActivityErrno extends Errno {
+
+ public static final String TYPE = "Activity Error";
+
+
+ /* Errors for starting activities (100-150) */
+ public static final Errno ERRNO_START_ACTIVITY_FAILED_WITH_EXCEPTION = new Errno(TYPE, 100, "Failed to start \"%1$s\" activity.\nException: %2$s");
+ public static final Errno ERRNO_START_ACTIVITY_FOR_RESULT_FAILED_WITH_EXCEPTION = new Errno(TYPE, 101, "Failed to start \"%1$s\" activity for result.\nException: %2$s");
+ public static final Errno ERRNO_STARTING_ACTIVITY_WITH_NULL_CONTEXT = new Errno(TYPE, 102, "Cannot start \"%1$s\" activity with null Context");
+
+
+ ActivityErrno(final String type, final int code, final String message) {
+ super(type, code, message);
+ }
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/activity/ActivityUtils.java b/termux/termux-shared/src/main/java/com/termux/shared/activity/ActivityUtils.java
new file mode 100644
index 000000000..0ff28126a
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/activity/ActivityUtils.java
@@ -0,0 +1,137 @@
+package com.termux.shared.activity;
+
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AppCompatActivity;
+
+import com.termux.shared.errors.Error;
+import com.termux.shared.errors.FunctionErrno;
+
+
+public class ActivityUtils {
+
+ private static final String LOG_TAG = "ActivityUtils";
+
+ /**
+ * Wrapper for {@link #startActivity(Context, Intent, boolean, boolean)}.
+ */
+ public static Error startActivity(@NonNull Context context, @NonNull Intent intent) {
+ return startActivity(context, intent, true, true);
+ }
+
+ /**
+ * Start an {@link Activity}.
+ *
+ * @param context The context for operations.
+ * @param intent The {@link Intent} to send to start the activity.
+ * @param logErrorMessage If an error message should be logged if failed to start activity.
+ * @param showErrorMessage If an error message toast should be shown if failed to start activity
+ * in addition to logging a message. The {@code context} must not be
+ * {@code null}.
+ * @return Returns the {@code error} if starting activity was not successful, otherwise {@code null}.
+ */
+ public static Error startActivity(Context context, @NonNull Intent intent,
+ boolean logErrorMessage, boolean showErrorMessage) {
+ Error error;
+ String activityName = intent.getComponent() != null ? intent.getComponent().getClassName() : "Unknown";
+
+ if (context == null) {
+ error = ActivityErrno.ERRNO_STARTING_ACTIVITY_WITH_NULL_CONTEXT.getError(activityName);
+ if (logErrorMessage)
+ error.logErrorAndShowToast(null, LOG_TAG);
+ return error;
+ }
+
+ try {
+ context.startActivity(intent);
+ } catch (Exception e) {
+ error = ActivityErrno.ERRNO_START_ACTIVITY_FAILED_WITH_EXCEPTION.getError(e, activityName, e.getMessage());
+ if (logErrorMessage)
+ error.logErrorAndShowToast(showErrorMessage ? context : null, LOG_TAG);
+ return error;
+ }
+
+ return null;
+ }
+
+
+
+ /**
+ * Wrapper for {@link #startActivityForResult(Context, int, Intent, boolean, boolean, ActivityResultLauncher)}.
+ */
+ public static Error startActivityForResult(Context context, int requestCode, @NonNull Intent intent) {
+ return startActivityForResult(context, requestCode, intent, true, true, null);
+ }
+
+ /**
+ * Wrapper for {@link #startActivityForResult(Context, int, Intent, boolean, boolean, ActivityResultLauncher)}.
+ */
+ public static Error startActivityForResult(Context context, int requestCode, @NonNull Intent intent,
+ boolean logErrorMessage, boolean showErrorMessage) {
+ return startActivityForResult(context, requestCode, intent, logErrorMessage, showErrorMessage, null);
+ }
+
+ /**
+ * Start an {@link Activity} for result.
+ *
+ * @param context The context for operations. It must be an instance of {@link Activity} or
+ * {@link AppCompatActivity}. It is ignored if {@code activityResultLauncher}
+ * is not {@code null}.
+ * @param requestCode The request code to use while sending intent. This must be >= 0, otherwise
+ * exception will be raised. This is ignored if {@code activityResultLauncher}
+ * is {@code null}.
+ * @param intent The {@link Intent} to send to start the activity.
+ * @param logErrorMessage If an error message should be logged if failed to start activity.
+ * @param showErrorMessage If an error message toast should be shown if failed to start activity
+ * in addition to logging a message. The {@code context} must not be
+ * {@code null}.
+ * @param activityResultLauncher The {@link ActivityResultLauncher} to use for start the
+ * activity. If this is {@code null}, then
+ * {@link Activity#startActivityForResult(Intent, int)} will be
+ * used instead.
+ * Note that later is deprecated.
+ * @return Returns the {@code error} if starting activity was not successful, otherwise {@code null}.
+ */
+ public static Error startActivityForResult(Context context, int requestCode, @NonNull Intent intent,
+ boolean logErrorMessage, boolean showErrorMessage,
+ @Nullable ActivityResultLauncher activityResultLauncher) {
+ Error error;
+ String activityName = intent.getComponent() != null ? intent.getComponent().getClassName() : "Unknown";
+ try {
+ if (activityResultLauncher != null) {
+ activityResultLauncher.launch(intent);
+ } else {
+ if (context == null) {
+ error = ActivityErrno.ERRNO_STARTING_ACTIVITY_WITH_NULL_CONTEXT.getError(activityName);
+ if (logErrorMessage)
+ error.logErrorAndShowToast(null, LOG_TAG);
+ return error;
+ }
+
+ if (context instanceof AppCompatActivity)
+ ((AppCompatActivity) context).startActivityForResult(intent, requestCode);
+ else if (context instanceof Activity)
+ ((Activity) context).startActivityForResult(intent, requestCode);
+ else {
+ error = FunctionErrno.ERRNO_PARAMETER_NOT_INSTANCE_OF.getError("context", "startActivityForResult", "Activity or AppCompatActivity");
+ if (logErrorMessage)
+ error.logErrorAndShowToast(showErrorMessage ? context : null, LOG_TAG);
+ return error;
+ }
+ }
+ } catch (Exception e) {
+ error = ActivityErrno.ERRNO_START_ACTIVITY_FOR_RESULT_FAILED_WITH_EXCEPTION.getError(e, activityName, e.getMessage());
+ if (logErrorMessage)
+ error.logErrorAndShowToast(showErrorMessage ? context : null, LOG_TAG);
+ return error;
+ }
+
+ return null;
+ }
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/activity/media/AppCompatActivityUtils.java b/termux/termux-shared/src/main/java/com/termux/shared/activity/media/AppCompatActivityUtils.java
new file mode 100644
index 000000000..0666d58ee
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/activity/media/AppCompatActivityUtils.java
@@ -0,0 +1,120 @@
+package com.termux.shared.activity.media;
+
+import androidx.annotation.IdRes;
+import androidx.annotation.NonNull;
+import androidx.annotation.StyleRes;
+import androidx.appcompat.app.ActionBar;
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.appcompat.app.AppCompatDelegate;
+import androidx.appcompat.widget.Toolbar;
+
+import com.termux.shared.logger.Logger;
+import com.termux.shared.theme.NightMode;
+
+public class AppCompatActivityUtils {
+
+ private static final String LOG_TAG = "AppCompatActivityUtils";
+
+
+ /** Set activity night mode.
+ *
+ * @param activity The host {@link AppCompatActivity}.
+ * @param name The {@link String} representing the name for a {@link NightMode}.
+ * @param local If set to {@code true}, then a call to {@link AppCompatDelegate#setLocalNightMode(int)}
+ * will be made, otherwise to {@link AppCompatDelegate#setDefaultNightMode(int)}.
+ */
+ public static void setNightMode(AppCompatActivity activity, String name, boolean local) {
+ if (name == null) return;
+ NightMode nightMode = NightMode.modeOf(name);
+ if (nightMode != null) {
+ if (local) {
+ if (activity != null) {
+ activity.getDelegate().setLocalNightMode(nightMode.getMode());
+ }
+ } else {
+ AppCompatDelegate.setDefaultNightMode(nightMode.getMode());
+ }
+ }
+
+ }
+
+ /** Set activity toolbar.
+ *
+ * @param activity The host {@link AppCompatActivity}.
+ * @param id The toolbar resource id.
+ */
+ public static void setToolbar(@NonNull AppCompatActivity activity, @IdRes int id) {
+ Toolbar toolbar = activity.findViewById(id);
+ if (toolbar != null)
+ activity.setSupportActionBar(toolbar);
+ }
+
+ /** Set activity toolbar title.
+ *
+ * @param activity The host {@link AppCompatActivity}.
+ * @param id The toolbar resource id.
+ * @param title The toolbar title {@link String}.
+ * @param titleAppearance The toolbar title TextAppearance resource id.
+ */
+ public static void setToolbarTitle(@NonNull AppCompatActivity activity, @IdRes int id,
+ String title, @StyleRes int titleAppearance) {
+ Toolbar toolbar = activity.findViewById(id);
+ if (toolbar != null) {
+ //toolbar.setTitle(title); // Does not work
+ final ActionBar actionBar = activity.getSupportActionBar();
+ if (actionBar != null)
+ actionBar.setTitle(title);
+
+ try {
+ if (titleAppearance != 0)
+ toolbar.setTitleTextAppearance(activity, titleAppearance);
+ } catch (Exception e) {
+ Logger.logStackTraceWithMessage(LOG_TAG, "Failed to set toolbar title appearance to style resource id " + titleAppearance, e);
+ }
+
+
+ }
+ }
+
+ /** Set activity toolbar subtitle.
+ *
+ * @param activity The host {@link AppCompatActivity}.
+ * @param id The toolbar resource id.
+ * @param subtitle The toolbar subtitle {@link String}.
+ * @param subtitleAppearance The toolbar subtitle TextAppearance resource id.
+ */
+ public static void setToolbarSubtitle(@NonNull AppCompatActivity activity, @IdRes int id,
+ String subtitle, @StyleRes int subtitleAppearance) {
+ Toolbar toolbar = activity.findViewById(id);
+ if (toolbar != null) {
+ toolbar.setSubtitle(subtitle);
+ try {
+ if (subtitleAppearance != 0)
+ toolbar.setSubtitleTextAppearance(activity, subtitleAppearance);
+ } catch (Exception e) {
+ Logger.logStackTraceWithMessage(LOG_TAG, "Failed to set toolbar subtitle appearance to style resource id " + subtitleAppearance, e);
+ }
+ }
+ }
+
+
+ /** Set whether back button should be shown in activity toolbar.
+ *
+ * @param activity The host {@link AppCompatActivity}.
+ * @param showBackButtonInActionBar Set to {@code true} to enable and {@code false} to disable.
+ */
+ public static void setShowBackButtonInActionBar(@NonNull AppCompatActivity activity,
+ boolean showBackButtonInActionBar) {
+ final ActionBar actionBar = activity.getSupportActionBar();
+ if (actionBar != null) {
+ if (showBackButtonInActionBar) {
+ actionBar.setDisplayHomeAsUpEnabled(true);
+ actionBar.setDisplayShowHomeEnabled(true);
+ } else {
+ actionBar.setDisplayHomeAsUpEnabled(false);
+ actionBar.setDisplayShowHomeEnabled(false);
+ }
+ }
+ }
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/android/AndroidUtils.java b/termux/termux-shared/src/main/java/com/termux/shared/android/AndroidUtils.java
new file mode 100644
index 000000000..bca47578e
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/android/AndroidUtils.java
@@ -0,0 +1,270 @@
+package com.termux.shared.android;
+
+import android.annotation.SuppressLint;
+import android.content.Context;
+import android.content.pm.ApplicationInfo;
+import android.content.pm.PackageInfo;
+import android.os.Build;
+
+import androidx.annotation.NonNull;
+
+import com.google.common.base.Joiner;
+import com.termux.shared.R;
+import com.termux.shared.data.DataUtils;
+import com.termux.shared.logger.Logger;
+import com.termux.shared.markdown.MarkdownUtils;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.Properties;
+import java.util.TimeZone;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+public class AndroidUtils {
+
+ /**
+ * Get a markdown {@link String} for the app info for the package associated with the {@code context}.
+ * This will contain additional info about the app in addition to the one returned by
+ * {@link #getAppInfoMarkdownString(Context, String)}, which will be got via the {@code context}
+ * object.
+ *
+ * @param context The context for operations for the package.
+ * @return Returns the markdown {@link String}.
+ */
+ public static String getAppInfoMarkdownString(@NonNull final Context context) {
+ StringBuilder markdownString = new StringBuilder();
+
+ String appInfo = getAppInfoMarkdownString(context, context.getPackageName());
+ if (appInfo == null)
+ return markdownString.toString();
+ else
+ markdownString.append(appInfo);
+
+ String filesDir = context.getFilesDir().getAbsolutePath();
+ if (!filesDir.equals("/data/user/0/" + context.getPackageName() + "/files") &&
+ !filesDir.equals("/data/data/" + context.getPackageName() + "/files"))
+ AndroidUtils.appendPropertyToMarkdown(markdownString,"FILES_DIR", filesDir);
+
+
+ if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ Long userId = PackageUtils.getUserIdForPackage(context);
+ if (userId == null || userId != 0)
+ AndroidUtils.appendPropertyToMarkdown(markdownString, "USER_ID", userId);
+ }
+
+ AndroidUtils.appendPropertyToMarkdownIfSet(markdownString,"PROFILE_OWNER", PackageUtils.getProfileOwnerPackageNameForUser(context));
+
+ return markdownString.toString();
+ }
+
+ /**
+ * Get a markdown {@link String} for the app info for the {@code packageName}.
+ *
+ * @param context The {@link Context} for operations.
+ * @param packageName The package name of the package.
+ * @return Returns the markdown {@link String}.
+ */
+ public static String getAppInfoMarkdownString(@NonNull final Context context, @NonNull final String packageName) {
+ PackageInfo packageInfo = PackageUtils.getPackageInfoForPackage(context, packageName);
+ if (packageInfo == null) return null;
+ ApplicationInfo applicationInfo = PackageUtils.getApplicationInfoForPackage(context, packageName);
+ if (applicationInfo == null) return null;
+
+ StringBuilder markdownString = new StringBuilder();
+
+ AndroidUtils.appendPropertyToMarkdown(markdownString,"APP_NAME", PackageUtils.getAppNameForPackage(context, applicationInfo));
+ AndroidUtils.appendPropertyToMarkdown(markdownString,"PACKAGE_NAME", PackageUtils.getPackageNameForPackage(applicationInfo));
+ AndroidUtils.appendPropertyToMarkdown(markdownString,"VERSION_NAME", PackageUtils.getVersionNameForPackage(packageInfo));
+ AndroidUtils.appendPropertyToMarkdown(markdownString,"VERSION_CODE", PackageUtils.getVersionCodeForPackage(packageInfo));
+ AndroidUtils.appendPropertyToMarkdown(markdownString,"UID", PackageUtils.getUidForPackage(applicationInfo));
+ AndroidUtils.appendPropertyToMarkdown(markdownString,"TARGET_SDK", PackageUtils.getTargetSDKForPackage(applicationInfo));
+ AndroidUtils.appendPropertyToMarkdown(markdownString,"IS_DEBUGGABLE_BUILD", PackageUtils.isAppForPackageADebuggableBuild(applicationInfo));
+
+ if (PackageUtils.isAppInstalledOnExternalStorage(applicationInfo)) {
+ AndroidUtils.appendPropertyToMarkdown(markdownString,"APK_PATH", PackageUtils.getBaseAPKPathForPackage(applicationInfo));
+ AndroidUtils.appendPropertyToMarkdown(markdownString,"IS_INSTALLED_ON_EXTERNAL_STORAGE", true);
+ }
+
+ AndroidUtils.appendPropertyToMarkdown(markdownString,"SE_PROCESS_CONTEXT", SELinuxUtils.getContext());
+ AndroidUtils.appendPropertyToMarkdown(markdownString,"SE_FILE_CONTEXT", SELinuxUtils.getFileContext(context.getFilesDir().getAbsolutePath()));
+
+ String seInfoUser = PackageUtils.getApplicationInfoSeInfoUserForPackage(applicationInfo);
+ AndroidUtils.appendPropertyToMarkdown(markdownString,"SE_INFO", PackageUtils.getApplicationInfoSeInfoForPackage(applicationInfo) +
+ (DataUtils.isNullOrEmpty(seInfoUser) ? "" : seInfoUser));
+
+ return markdownString.toString();
+ }
+
+ public static String getDeviceInfoMarkdownString(@NonNull final Context context) {
+ return getDeviceInfoMarkdownString(context, false);
+ }
+
+ /**
+ * Get a markdown {@link String} for the device info.
+ *
+ * @param context The context for operations.
+ * @param addPhantomProcessesInfo If phantom processes info should be added on Android >= 12.
+ * @return Returns the markdown {@link String}.
+ */
+ public static String getDeviceInfoMarkdownString(@NonNull final Context context, boolean addPhantomProcessesInfo) {
+ // Some properties cannot be read with {@link System#getProperty(String)} but can be read
+ // directly by running getprop command
+ Properties systemProperties = getSystemProperties();
+
+ StringBuilder markdownString = new StringBuilder();
+
+ markdownString.append("## Device Info");
+
+ markdownString.append("\n\n### Software\n");
+ appendPropertyToMarkdown(markdownString,"OS_VERSION", getSystemPropertyWithAndroidAPI("os.version"));
+ appendPropertyToMarkdown(markdownString, "SDK_INT", Build.VERSION.SDK_INT);
+ // If its a release version
+ if ("REL".equals(Build.VERSION.CODENAME))
+ appendPropertyToMarkdown(markdownString, "RELEASE", Build.VERSION.RELEASE);
+ else
+ appendPropertyToMarkdown(markdownString, "CODENAME", Build.VERSION.CODENAME);
+ appendPropertyToMarkdown(markdownString, "ID", Build.ID);
+ appendPropertyToMarkdown(markdownString, "DISPLAY", Build.DISPLAY);
+ appendPropertyToMarkdown(markdownString, "INCREMENTAL", Build.VERSION.INCREMENTAL);
+ appendPropertyToMarkdownIfSet(markdownString, "SECURITY_PATCH", systemProperties.getProperty("ro.build.version.security_patch"));
+ appendPropertyToMarkdownIfSet(markdownString, "IS_DEBUGGABLE", systemProperties.getProperty("ro.debuggable"));
+ appendPropertyToMarkdownIfSet(markdownString, "IS_EMULATOR", systemProperties.getProperty("ro.boot.qemu"));
+ appendPropertyToMarkdownIfSet(markdownString, "IS_TREBLE_ENABLED", systemProperties.getProperty("ro.treble.enabled"));
+ appendPropertyToMarkdown(markdownString, "TYPE", Build.TYPE);
+ appendPropertyToMarkdown(markdownString, "TAGS", Build.TAGS);
+
+ // If on Android >= 12
+ if (Build.VERSION.SDK_INT > Build.VERSION_CODES.R) {
+ Integer maxPhantomProcesses = PhantomProcessUtils.getActivityManagerMaxPhantomProcesses(context);
+ if (maxPhantomProcesses != null)
+ appendPropertyToMarkdown(markdownString, "MAX_PHANTOM_PROCESSES", maxPhantomProcesses);
+ else
+ appendLiteralPropertyToMarkdown(markdownString, "MAX_PHANTOM_PROCESSES", "- (*" + context.getString(R.string.msg_requires_dump_and_package_usage_stats_permissions) + "*)");
+
+ appendPropertyToMarkdown(markdownString, "MONITOR_PHANTOM_PROCS", PhantomProcessUtils.getFeatureFlagMonitorPhantomProcsValueString(context).getName());
+ appendPropertyToMarkdown(markdownString, "DEVICE_CONFIG_SYNC_DISABLED", PhantomProcessUtils.getSettingsGlobalDeviceConfigSyncDisabled(context));
+ }
+
+ markdownString.append("\n\n### Hardware\n");
+ appendPropertyToMarkdown(markdownString, "MANUFACTURER", Build.MANUFACTURER);
+ appendPropertyToMarkdown(markdownString, "BRAND", Build.BRAND);
+ appendPropertyToMarkdown(markdownString, "MODEL", Build.MODEL);
+ appendPropertyToMarkdown(markdownString, "PRODUCT", Build.PRODUCT);
+ appendPropertyToMarkdown(markdownString, "BOARD", Build.BOARD);
+ appendPropertyToMarkdown(markdownString, "HARDWARE", Build.HARDWARE);
+ appendPropertyToMarkdown(markdownString, "DEVICE", Build.DEVICE);
+ appendPropertyToMarkdown(markdownString, "SUPPORTED_ABIS", Joiner.on(", ").skipNulls().join(Build.SUPPORTED_ABIS));
+
+ markdownString.append("\n##\n");
+
+ return markdownString.toString();
+ }
+
+
+
+ public static Properties getSystemProperties() {
+ Properties systemProperties = new Properties();
+
+ // getprop commands returns values in the format `[key]: [value]`
+ // Regex matches string starting with a literal `[`,
+ // followed by one or more characters that do not match a closing square bracket as the key,
+ // followed by a literal `]: [`,
+ // followed by one or more characters as the value,
+ // followed by string ending with literal `]`
+ // multiline values will be ignored
+ Pattern propertiesPattern = Pattern.compile("^\\[([^]]+)]: \\[(.+)]$");
+
+ try {
+ Process process = new ProcessBuilder()
+ .command("/system/bin/getprop")
+ .redirectErrorStream(true)
+ .start();
+
+ InputStream inputStream = process.getInputStream();
+ BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream));
+ String line, key, value;
+
+ while ((line = bufferedReader.readLine()) != null) {
+ Matcher matcher = propertiesPattern.matcher(line);
+ if (matcher.matches()) {
+ key = matcher.group(1);
+ value = matcher.group(2);
+ if (key != null && value != null && !key.isEmpty() && !value.isEmpty())
+ systemProperties.put(key, value);
+ }
+ }
+
+ bufferedReader.close();
+ process.destroy();
+
+ } catch (IOException e) {
+ Logger.logStackTraceWithMessage("Failed to get run \"/system/bin/getprop\" to get system properties.", e);
+ }
+
+ //for (String key : systemProperties.stringPropertyNames()) {
+ // Logger.logVerbose(key + ": " + systemProperties.get(key));
+ //}
+
+ return systemProperties;
+ }
+
+ public static String getSystemPropertyWithAndroidAPI(@NonNull String property) {
+ try {
+ return System.getProperty(property);
+ } catch (Exception e) {
+ Logger.logVerbose("Failed to get system property \"" + property + "\":" + e.getMessage());
+ return null;
+ }
+ }
+
+ public static void appendPropertyToMarkdownIfSet(StringBuilder markdownString, String label, Object value) {
+ if (value == null) return;
+ if (value instanceof String && (((String) value).isEmpty()) || "REL".equals(value)) return;
+ markdownString.append("\n").append(getPropertyMarkdown(label, value));
+ }
+
+ public static void appendPropertyToMarkdown(StringBuilder markdownString, String label, Object value) {
+ markdownString.append("\n").append(getPropertyMarkdown(label, value));
+ }
+
+ public static String getPropertyMarkdown(String label, Object value) {
+ return MarkdownUtils.getSingleLineMarkdownStringEntry(label, value, "-");
+ }
+
+ public static void appendLiteralPropertyToMarkdown(StringBuilder markdownString, String label, Object value) {
+ markdownString.append("\n").append(getLiteralPropertyMarkdown(label, value));
+ }
+
+ public static String getLiteralPropertyMarkdown(String label, Object value) {
+ return MarkdownUtils.getLiteralSingleLineMarkdownStringEntry(label, value, "-");
+ }
+
+
+
+ public static String getCurrentTimeStamp() {
+ @SuppressLint("SimpleDateFormat")
+ final SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss z");
+ df.setTimeZone(TimeZone.getTimeZone("UTC"));
+ return df.format(new Date());
+ }
+
+ public static String getCurrentMilliSecondUTCTimeStamp() {
+ @SuppressLint("SimpleDateFormat")
+ final SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS z");
+ df.setTimeZone(TimeZone.getTimeZone("UTC"));
+ return df.format(new Date());
+ }
+
+ public static String getCurrentMilliSecondLocalTimeStamp() {
+ @SuppressLint("SimpleDateFormat")
+ final SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd_HH.mm.ss.SSS");
+ df.setTimeZone(TimeZone.getDefault());
+ return df.format(new Date());
+ }
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/android/FeatureFlagUtils.java b/termux/termux-shared/src/main/java/com/termux/shared/android/FeatureFlagUtils.java
new file mode 100644
index 000000000..14e6cd5b3
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/android/FeatureFlagUtils.java
@@ -0,0 +1,169 @@
+package com.termux.shared.android;
+
+import android.annotation.SuppressLint;
+import android.content.Context;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.termux.shared.logger.Logger;
+import com.termux.shared.reflection.ReflectionUtils;
+
+import java.lang.reflect.Method;
+import java.util.Map;
+
+/**
+ * Utils for Developer Options -> Feature Flags. The page won't show in user/production builds and
+ * is only shown in userdebug builds.
+ * https://cs.android.com/android/_/android/platform/frameworks/base/+/09dcdad5ebc159861920f090e07da60fac71ac0a:core/java/android/util/FeatureFlagUtils.java
+ * https://cs.android.com/android/platform/superproject/+/android-12.0.0_r31:packages/apps/Settings/src/com/android/settings/development/featureflags/FeatureFlagsPreferenceController.java;l=42
+ *
+ * The feature flags value can be modified in two ways.
+ *
+ * 1. sysprops with `setprop` command with root. Will be unset by default.
+ * Set value: `setprop persist.sys.fflag.override.settings_enable_monitor_phantom_procs false`
+ * Get value: `getprop persist.sys.fflag.override.settings_enable_monitor_phantom_procs`
+ * Unset value: `setprop persist.sys.fflag.override.settings_enable_monitor_phantom_procs ""`
+ * Running `setprop` command requires root and even adb `shell` user cannot modify the values
+ * since selinux will not allow it by default. Some props like `settings_dynamic_system` can be
+ * set since they are exempted for `shell` in sepolicy.
+ *
+ * init: Unable to set property 'persist.sys.fflag.override.settings_enable_monitor_phantom_procs' from uid:2000 gid:2000 pid:9576: SELinux permission check failed
+ * [ 1034.877067] type=1107 audit(1644436809.637:34): uid=0 auid=4294967295 ses=4294967295 subj=u:r:init:s0 msg='avc: denied { set } for property=persist.sys.fflag.override.settings_enable_monitor_phantom_procs pid=9576 uid=2000 gid=2000 scontext=u:r:shell:s0 tcontext=u:object_r:system_prop:s0 tclass=property_service permissive=0'
+ *
+ * https://cs.android.com/android/platform/superproject/+/android-12.0.0_r4:system/sepolicy/private/property_contexts;l=71
+ * https://cs.android.com/android/platform/superproject/+/android-12.0.0_r4:system/sepolicy/private/shell.te;l=149
+ *
+ * 2. settings global list with adb or root. Will be unset by default. This takes precedence over
+ * sysprop value since `FeatureFlagUtils.isEnabled()`
+ * checks its value first. Override precedence: Settings.Global -> sys.fflag.override.* -> static list.
+ * Set value: `adb shell settings put global settings_enable_monitor_phantom_procs false`
+ * Get value: adb shell settings get global settings_enable_monitor_phantom_procs`
+ * Unset value: `adb shell settings delete global settings_enable_monitor_phantom_procs`
+ *
+ * https://cs.android.com/android/_/android/platform/frameworks/base/+/refs/tags/android-12.0.0_r31:core/java/android/util/FeatureFlagUtils.java;l=113
+ *
+ * The feature flag values can be modified in user builds with settings global list, but since the
+ * developer options feature flags page is not shown and considering that getprop values for features
+ * will be unset by default and settings global list will not be set either and there is no shell API,
+ * it will require an android app process to check if feature is supported on a device and what its
+ * default value is with reflection after bypassing hidden api restrictions since {@link #FEATURE_FLAGS_CLASS}
+ * is annotated as `@hide`.
+ */
+public class FeatureFlagUtils {
+
+ public enum FeatureFlagValue {
+
+ /** Unknown like due to exception raised while getting value. */
+ UNKNOWN(""),
+
+ /** Flag is unsupported on current android build. */
+ UNSUPPORTED(""),
+
+ /** Flag is enabled. */
+ TRUE("true"),
+
+ /** Flag is not enabled. */
+ FALSE("false");
+
+ private final String name;
+
+ FeatureFlagValue(final String name) {
+ this.name = name;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ }
+
+ public static final String FEATURE_FLAGS_CLASS = "android.util.FeatureFlagUtils";
+
+ private static final String LOG_TAG = "FeatureFlagUtils";
+
+ /**
+ * Get all feature flags in their raw form.
+ */
+ @SuppressWarnings("unchecked")
+ public static Map getAllFeatureFlags() {
+ ReflectionUtils.bypassHiddenAPIReflectionRestrictions();
+ try {
+ @SuppressLint("PrivateApi") Class> clazz = Class.forName(FEATURE_FLAGS_CLASS);
+ Method getAllFeatureFlagsMethod = ReflectionUtils.getDeclaredMethod(clazz, "getAllFeatureFlags");
+ if (getAllFeatureFlagsMethod == null) return null;
+ return (Map) ReflectionUtils.invokeMethod(getAllFeatureFlagsMethod, null).value;
+ } catch (Exception e) {
+ // ClassCastException may be thrown
+ Logger.logStackTraceWithMessage(LOG_TAG, "Failed to get all feature flags", e);
+ return null;
+ }
+ }
+
+ /**
+ * Check if a feature flag exists.
+ *
+ * @return Returns {@code true} if flag exists, otherwise {@code false}. This will be
+ * {@code null} if an exception is raised.
+ */
+ @Nullable
+ public static Boolean featureFlagExists(@NonNull String feature) {
+ Map featureFlags = getAllFeatureFlags();
+ if (featureFlags == null) return null;
+ return featureFlags.containsKey(feature);
+ }
+
+ /**
+ * Get {@link FeatureFlagValue} for a feature.
+ *
+ * @param context The {@link Context} for operations.
+ * @param feature The {@link String} name for feature.
+ * @return Returns {@link FeatureFlagValue}.
+ */
+ @NonNull
+ public static FeatureFlagValue getFeatureFlagValueString(@NonNull Context context, @NonNull String feature) {
+ Boolean featureFlagExists = featureFlagExists(feature);
+ if (featureFlagExists == null) {
+ Logger.logError(LOG_TAG, "Failed to get feature flags \"" + feature + "\" value");
+ return FeatureFlagValue.UNKNOWN;
+ } else if (!featureFlagExists) {
+ return FeatureFlagValue.UNSUPPORTED;
+ }
+
+ Boolean featureFlagValue = isFeatureEnabled(context, feature);
+ if (featureFlagValue == null) {
+ Logger.logError(LOG_TAG, "Failed to get feature flags \"" + feature + "\" value");
+ return FeatureFlagValue.UNKNOWN;
+ } else {
+ return featureFlagValue ? FeatureFlagValue.TRUE : FeatureFlagValue.FALSE;
+ }
+ }
+
+ /**
+ * Check if a feature flag exists.
+ *
+ * @param context The {@link Context} for operations.
+ * @param feature The {@link String} name for feature.
+ * @return Returns {@code true} if flag exists, otherwise {@code false}. This will be
+ * {@code null} if an exception is raised.
+ */
+ @Nullable
+ public static Boolean isFeatureEnabled(@NonNull Context context, @NonNull String feature) {
+ ReflectionUtils.bypassHiddenAPIReflectionRestrictions();
+ try {
+ @SuppressLint("PrivateApi") Class> clazz = Class.forName(FEATURE_FLAGS_CLASS);
+ Method isFeatureEnabledMethod = ReflectionUtils.getDeclaredMethod(clazz, "isEnabled", Context.class, String.class);
+ if (isFeatureEnabledMethod == null) {
+ Logger.logError(LOG_TAG, "Failed to check if feature flag \"" + feature + "\" is enabled");
+ return null;
+ }
+
+ return (boolean) ReflectionUtils.invokeMethod(isFeatureEnabledMethod, null, context, feature).value;
+ } catch (Exception e) {
+ // ClassCastException may be thrown
+ Logger.logStackTraceWithMessage(LOG_TAG, "Failed to check if feature flag \"" + feature + "\" is enabled", e);
+ return null;
+ }
+ }
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/android/PackageUtils.java b/termux/termux-shared/src/main/java/com/termux/shared/android/PackageUtils.java
new file mode 100644
index 000000000..904c75cb5
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/android/PackageUtils.java
@@ -0,0 +1,830 @@
+package com.termux.shared.android;
+
+import android.app.ActivityManager;
+import android.app.admin.DevicePolicyManager;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.ApplicationInfo;
+import android.content.pm.PackageInfo;
+import android.content.pm.PackageManager;
+import android.os.Build;
+import android.os.UserHandle;
+import android.os.UserManager;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.RequiresApi;
+
+import com.termux.shared.R;
+import com.termux.shared.data.DataUtils;
+import com.termux.shared.interact.MessageDialogUtils;
+import com.termux.shared.logger.Logger;
+import com.termux.shared.reflection.ReflectionUtils;
+
+import java.lang.reflect.Field;
+import java.security.MessageDigest;
+import java.util.List;
+
+public class PackageUtils {
+
+ private static final String LOG_TAG = "PackageUtils";
+
+ /**
+ * Get the {@link Context} for the package name with {@link Context#CONTEXT_RESTRICTED} flags.
+ *
+ * @param context The {@link Context} to use to get the {@link Context} of the {@code packageName}.
+ * @param packageName The package name whose {@link Context} to get.
+ * @return Returns the {@link Context}. This will {@code null} if an exception is raised.
+ */
+ @Nullable
+ public static Context getContextForPackage(@NonNull final Context context, String packageName) {
+ return getContextForPackage(context, packageName, Context.CONTEXT_RESTRICTED);
+ }
+
+ /**
+ * Get the {@link Context} for the package name.
+ *
+ * @param context The {@link Context} to use to get the {@link Context} of the {@code packageName}.
+ * @param packageName The package name whose {@link Context} to get.
+ * @param flags The flags for {@link Context} type.
+ * @return Returns the {@link Context}. This will {@code null} if an exception is raised.
+ */
+ @Nullable
+ public static Context getContextForPackage(@NonNull final Context context, String packageName, int flags) {
+ try {
+ return context.createPackageContext(packageName, flags);
+ } catch (Exception e) {
+ Logger.logVerbose(LOG_TAG, "Failed to get \"" + packageName + "\" package context with flags " + flags + ": " + e.getMessage());
+ return null;
+ }
+ }
+
+ /**
+ * Get the {@link Context} for a package name.
+ *
+ * @param context The {@link Context} to use to get the {@link Context} of the {@code packageName}.
+ * @param packageName The package name whose {@link Context} to get.
+ * @param exitAppOnError If {@code true} and failed to get package context, then a dialog will
+ * be shown which when dismissed will exit the app.
+ * @param helpUrl The help user to add to {@link R.string#error_get_package_context_failed_help_url_message}.
+ * @return Returns the {@link Context}. This will {@code null} if an exception is raised.
+ */
+ @Nullable
+ public static Context getContextForPackageOrExitApp(@NonNull Context context, String packageName,
+ final boolean exitAppOnError, @Nullable String helpUrl) {
+ Context packageContext = getContextForPackage(context, packageName);
+
+ if (packageContext == null && exitAppOnError) {
+ String errorMessage = context.getString(R.string.error_get_package_context_failed_message,
+ packageName);
+ if (!DataUtils.isNullOrEmpty(helpUrl))
+ errorMessage += "\n" + context.getString(R.string.error_get_package_context_failed_help_url_message, helpUrl);
+ Logger.logError(LOG_TAG, errorMessage);
+ MessageDialogUtils.exitAppWithErrorMessage(context,
+ context.getString(R.string.error_get_package_context_failed_title),
+ errorMessage);
+ }
+
+ return packageContext;
+ }
+
+
+
+ /**
+ * Get the {@link PackageInfo} for the package associated with the {@code context}.
+ *
+ * @param context The {@link Context} for the package.
+ * @return Returns the {@link PackageInfo}. This will be {@code null} if an exception is raised.
+ */
+ public static PackageInfo getPackageInfoForPackage(@NonNull final Context context) {
+ return getPackageInfoForPackage(context, context.getPackageName());
+ }
+
+ /**
+ * Get the {@link PackageInfo} for the package associated with the {@code context}.
+ *
+ * @param context The {@link Context} for the package.
+ * @param flags The flags to pass to {@link PackageManager#getPackageInfo(String, int)}.
+ * @return Returns the {@link PackageInfo}. This will be {@code null} if an exception is raised.
+ */
+ @Nullable
+ public static PackageInfo getPackageInfoForPackage(@NonNull final Context context, final int flags) {
+ return getPackageInfoForPackage(context, context.getPackageName(), flags);
+ }
+
+ /**
+ * Get the {@link PackageInfo} for the package associated with the {@code packageName}.
+ *
+ * @param context The {@link Context} for operations.
+ * @param packageName The package name of the package.
+ * @return Returns the {@link PackageInfo}. This will be {@code null} if an exception is raised.
+ */
+ public static PackageInfo getPackageInfoForPackage(@NonNull final Context context, @NonNull final String packageName) {
+ return getPackageInfoForPackage(context, packageName, 0);
+ }
+
+ /**
+ * Get the {@link PackageInfo} for the package associated with the {@code packageName}.
+ *
+ * Also check {@link #isAppInstalled(Context, String, String) if targetting targeting sdk
+ * `30` (android `11`) since {@link PackageManager.NameNotFoundException} may be thrown.
+ *
+ * @param context The {@link Context} for operations.
+ * @param packageName The package name of the package.
+ * @param flags The flags to pass to {@link PackageManager#getPackageInfo(String, int)}.
+ * @return Returns the {@link PackageInfo}. This will be {@code null} if an exception is raised.
+ */
+ @Nullable
+ public static PackageInfo getPackageInfoForPackage(@NonNull final Context context, @NonNull final String packageName, final int flags) {
+ try {
+ return context.getPackageManager().getPackageInfo(packageName, flags);
+ } catch (final Exception e) {
+ return null;
+ }
+ }
+
+
+
+ /**
+ * Get the {@link ApplicationInfo} for the {@code packageName}.
+ *
+ * @param context The {@link Context} for operations.
+ * @param packageName The package name of the package.
+ * @return Returns the {@link ApplicationInfo}. This will be {@code null} if an exception is raised.
+ */
+ @Nullable
+ public static ApplicationInfo getApplicationInfoForPackage(@NonNull final Context context, @NonNull final String packageName) {
+ return getApplicationInfoForPackage(context, packageName, 0);
+ }
+
+ /**
+ * Get the {@link ApplicationInfo} for the {@code packageName}.
+ *
+ * Also check {@link #isAppInstalled(Context, String, String) if targetting targeting sdk
+ * `30` (android `11`) since {@link PackageManager.NameNotFoundException} may be thrown.
+ *
+ * @param context The {@link Context} for operations.
+ * @param packageName The package name of the package.
+ * @param flags The flags to pass to {@link PackageManager#getApplicationInfo(String, int)}.
+ * @return Returns the {@link ApplicationInfo}. This will be {@code null} if an exception is raised.
+ */
+ @Nullable
+ public static ApplicationInfo getApplicationInfoForPackage(@NonNull final Context context, @NonNull final String packageName, final int flags) {
+ try {
+ return context.getPackageManager().getApplicationInfo(packageName, flags);
+ } catch (final Exception e) {
+ return null;
+ }
+ }
+
+ /**
+ * Get the {@code privateFlags} {@link Field} of the {@link ApplicationInfo} class.
+ *
+ * @param applicationInfo The {@link ApplicationInfo} for the package.
+ * @return Returns the private flags or {@code null} if an exception was raised.
+ */
+ @Nullable
+ public static Integer getApplicationInfoPrivateFlagsForPackage(@NonNull final ApplicationInfo applicationInfo) {
+ ReflectionUtils.bypassHiddenAPIReflectionRestrictions();
+ try {
+ return (Integer) ReflectionUtils.invokeField(ApplicationInfo.class, "privateFlags", applicationInfo).value;
+ } catch (Exception e) {
+ // ClassCastException may be thrown
+ Logger.logStackTraceWithMessage(LOG_TAG, "Failed to get privateFlags field value for ApplicationInfo class", e);
+ return null;
+ }
+ }
+
+ /**
+ * Get the {@code seInfo} {@link Field} of the {@link ApplicationInfo} class.
+ *
+ * String retrieved from the seinfo tag found in selinux policy. This value can be set through
+ * the mac_permissions.xml policy construct. This value is used for setting an SELinux security
+ * context on the process as well as its data directory.
+ *
+ * https://cs.android.com/android/platform/superproject/+/android-7.1.0_r1:frameworks/base/core/java/android/content/pm/ApplicationInfo.java;l=609
+ * https://cs.android.com/android/platform/superproject/+/android-12.0.0_r32:frameworks/base/core/java/android/content/pm/ApplicationInfo.java;l=981
+ * https://cs.android.com/android/platform/superproject/+/android-7.0.0_r1:frameworks/base/services/core/java/com/android/server/pm/SELinuxMMAC.java;l=282
+ * https://cs.android.com/android/platform/superproject/+/android-12.0.0_r32:frameworks/base/services/core/java/com/android/server/pm/SELinuxMMAC.java;l=375
+ * https://cs.android.com/android/_/android/platform/frameworks/base/+/be0b8896d1bc385d4c8fb54c21929745935dcbea
+ *
+ * @param applicationInfo The {@link ApplicationInfo} for the package.
+ * @return Returns the selinux info or {@code null} if an exception was raised.
+ */
+ @Nullable
+ public static String getApplicationInfoSeInfoForPackage(@NonNull final ApplicationInfo applicationInfo) {
+ ReflectionUtils.bypassHiddenAPIReflectionRestrictions();
+ try {
+ return (String) ReflectionUtils.invokeField(ApplicationInfo.class, Build.VERSION.SDK_INT < Build.VERSION_CODES.O ? "seinfo" : "seInfo", applicationInfo).value;
+ } catch (Exception e) {
+ // ClassCastException may be thrown
+ Logger.logStackTraceWithMessage(LOG_TAG, "Failed to get seInfo field value for ApplicationInfo class", e);
+ return null;
+ }
+ }
+
+ /**
+ * Get the {@code seInfoUser} {@link Field} of the {@link ApplicationInfo} class.
+ *
+ * Also check {@link #getApplicationInfoSeInfoForPackage(ApplicationInfo)}.
+ *
+ * @param applicationInfo The {@link ApplicationInfo} for the package.
+ * @return Returns the selinux info user or {@code null} if an exception was raised.
+ */
+ @Nullable
+ public static String getApplicationInfoSeInfoUserForPackage(@NonNull final ApplicationInfo applicationInfo) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return null;
+ ReflectionUtils.bypassHiddenAPIReflectionRestrictions();
+ try {
+ return (String) ReflectionUtils.invokeField(ApplicationInfo.class, "seInfoUser", applicationInfo).value;
+ } catch (Exception e) {
+ // ClassCastException may be thrown
+ Logger.logStackTraceWithMessage(LOG_TAG, "Failed to get seInfoUser field value for ApplicationInfo class", e);
+ return null;
+ }
+ }
+
+ /**
+ * Get the {@code privateFlags} {@link Field} of the {@link ApplicationInfo} class.
+ *
+ * @param fieldName The name of the field to get.
+ * @return Returns the field value or {@code null} if an exception was raised.
+ */
+ @Nullable
+ public static Integer getApplicationInfoStaticIntFieldValue(@NonNull String fieldName) {
+ ReflectionUtils.bypassHiddenAPIReflectionRestrictions();
+ try {
+ return (Integer) ReflectionUtils.invokeField(ApplicationInfo.class, fieldName, null).value;
+ } catch (Exception e) {
+ // ClassCastException may be thrown
+ Logger.logStackTraceWithMessage(LOG_TAG, "Failed to get \"" + fieldName + "\" field value for ApplicationInfo class", e);
+ return null;
+ }
+ }
+
+ /**
+ * Check if the app associated with the {@code applicationInfo} has a specific flag set.
+ *
+ * @param flagToCheckName The name of the field for the flag to check.
+ * @param applicationInfo The {@link ApplicationInfo} for the package.
+ * @return Returns {@code true} if app has flag is set, otherwise {@code false}. This will be
+ * {@code null} if an exception is raised.
+ */
+ @Nullable
+ public static Boolean isApplicationInfoPrivateFlagSetForPackage(@NonNull String flagToCheckName, @NonNull final ApplicationInfo applicationInfo) {
+ Integer privateFlags = getApplicationInfoPrivateFlagsForPackage(applicationInfo);
+ if (privateFlags == null) return null;
+
+ Integer flagToCheck = getApplicationInfoStaticIntFieldValue(flagToCheckName);
+ if (flagToCheck == null) return null;
+
+ return ( 0 != ( privateFlags & flagToCheck ) );
+ }
+
+
+
+
+
+ /**
+ * Get the app name for the package associated with the {@code context}.
+ *
+ * @param context The {@link Context} for the package.
+ * @return Returns the {@code android:name} attribute.
+ */
+ public static String getAppNameForPackage(@NonNull final Context context) {
+ return getAppNameForPackage(context, context.getApplicationInfo());
+ }
+
+ /**
+ * Get the app name for the package associated with the {@code applicationInfo}.
+ *
+ * @param context The {@link Context} for operations.
+ * @param applicationInfo The {@link ApplicationInfo} for the package.
+ * @return Returns the {@code android:name} attribute.
+ */
+ public static String getAppNameForPackage(@NonNull final Context context, @NonNull final ApplicationInfo applicationInfo) {
+ return applicationInfo.loadLabel(context.getPackageManager()).toString();
+ }
+
+
+
+ /**
+ * Get the package name for the package associated with the {@code context}.
+ *
+ * @param context The {@link Context} for the package.
+ * @return Returns the package name.
+ */
+ public static String getPackageNameForPackage(@NonNull final Context context) {
+ return getPackageNameForPackage(context.getApplicationInfo());
+ }
+
+ /**
+ * Get the package name for the package associated with the {@code applicationInfo}.
+ *
+ * @param applicationInfo The {@link ApplicationInfo} for the package.
+ * @return Returns the package name.
+ */
+ public static String getPackageNameForPackage(@NonNull final ApplicationInfo applicationInfo) {
+ return applicationInfo.packageName;
+ }
+
+
+
+ /**
+ * Get the uid for the package associated with the {@code context}.
+ *
+ * @param context The {@link Context} for the package.
+ * @return Returns the uid.
+ */
+ public static int getUidForPackage(@NonNull final Context context) {
+ return getUidForPackage(context.getApplicationInfo());
+ }
+
+ /**
+ * Get the uid for the package associated with the {@code applicationInfo}.
+ *
+ * @param applicationInfo The {@link ApplicationInfo} for the package.
+ * @return Returns the uid.
+ */
+ public static int getUidForPackage(@NonNull final ApplicationInfo applicationInfo) {
+ return applicationInfo.uid;
+ }
+
+
+
+ /**
+ * Get the {@code targetSdkVersion} for the package associated with the {@code context}.
+ *
+ * @param context The {@link Context} for the package.
+ * @return Returns the {@code targetSdkVersion}.
+ */
+ public static int getTargetSDKForPackage(@NonNull final Context context) {
+ return getTargetSDKForPackage(context.getApplicationInfo());
+ }
+
+ /**
+ * Get the {@code targetSdkVersion} for the package associated with the {@code applicationInfo}.
+ *
+ * @param applicationInfo The {@link ApplicationInfo} for the package.
+ * @return Returns the {@code targetSdkVersion}.
+ */
+ public static int getTargetSDKForPackage(@NonNull final ApplicationInfo applicationInfo) {
+ return applicationInfo.targetSdkVersion;
+ }
+
+
+
+ /**
+ * Get the base apk path for the package associated with the {@code context}.
+ *
+ * @param context The {@link Context} for the package.
+ * @return Returns the base apk path.
+ */
+ public static String getBaseAPKPathForPackage(@NonNull final Context context) {
+ return getBaseAPKPathForPackage(context.getApplicationInfo());
+ }
+
+ /**
+ * Get the base apk path for the package associated with the {@code applicationInfo}.
+ *
+ * @param applicationInfo The {@link ApplicationInfo} for the package.
+ * @return Returns the base apk path.
+ */
+ public static String getBaseAPKPathForPackage(@NonNull final ApplicationInfo applicationInfo) {
+ return applicationInfo.publicSourceDir;
+ }
+
+
+
+ /**
+ * Check if the app associated with the {@code context} has {@link ApplicationInfo#FLAG_DEBUGGABLE}
+ * set.
+ *
+ * @param context The {@link Context} for the package.
+ * @return Returns {@code true} if app is debuggable, otherwise {@code false}.
+ */
+ public static boolean isAppForPackageADebuggableBuild(@NonNull final Context context) {
+ return isAppForPackageADebuggableBuild(context.getApplicationInfo());
+ }
+
+ /**
+ * Check if the app associated with the {@code applicationInfo} has {@link ApplicationInfo#FLAG_DEBUGGABLE}
+ * set.
+ *
+ * @param applicationInfo The {@link ApplicationInfo} for the package.
+ * @return Returns {@code true} if app is debuggable, otherwise {@code false}.
+ */
+ public static boolean isAppForPackageADebuggableBuild(@NonNull final ApplicationInfo applicationInfo) {
+ return ( 0 != ( applicationInfo.flags & ApplicationInfo.FLAG_DEBUGGABLE ) );
+ }
+
+
+
+ /**
+ * Check if the app associated with the {@code context} has {@link ApplicationInfo#FLAG_EXTERNAL_STORAGE}
+ * set.
+ *
+ * @param context The {@link Context} for the package.
+ * @return Returns {@code true} if app is installed on external storage, otherwise {@code false}.
+ */
+ public static boolean isAppInstalledOnExternalStorage(@NonNull final Context context) {
+ return isAppInstalledOnExternalStorage(context.getApplicationInfo());
+ }
+
+ /**
+ * Check if the app associated with the {@code applicationInfo} has {@link ApplicationInfo#FLAG_EXTERNAL_STORAGE}
+ * set.
+ *
+ * @param applicationInfo The {@link ApplicationInfo} for the package.
+ * @return Returns {@code true} if app is installed on external storage, otherwise {@code false}.
+ */
+ public static boolean isAppInstalledOnExternalStorage(@NonNull final ApplicationInfo applicationInfo) {
+ return ( 0 != ( applicationInfo.flags & ApplicationInfo.FLAG_EXTERNAL_STORAGE ) );
+ }
+
+
+
+ /**
+ * Check if the app associated with the {@code context} has
+ * ApplicationInfo.PRIVATE_FLAG_REQUEST_LEGACY_EXTERNAL_STORAGE (requestLegacyExternalStorage)
+ * set to {@code true} in app manifest.
+ *
+ * @param context The {@link Context} for the package.
+ * @return Returns {@code true} if app has requested legacy external storage, otherwise
+ * {@code false}. This will be {@code null} if an exception is raised.
+ */
+ @Nullable
+ public static Boolean hasRequestedLegacyExternalStorage(@NonNull final Context context) {
+ return hasRequestedLegacyExternalStorage(context.getApplicationInfo());
+ }
+
+ /**
+ * Check if the app associated with the {@code applicationInfo} has
+ * ApplicationInfo.PRIVATE_FLAG_REQUEST_LEGACY_EXTERNAL_STORAGE (requestLegacyExternalStorage)
+ * set to {@code true} in app manifest.
+ *
+ * @param applicationInfo The {@link ApplicationInfo} for the package.
+ * @return Returns {@code true} if app has requested legacy external storage, otherwise
+ * {@code false}. This will be {@code null} if an exception is raised.
+ */
+ @Nullable
+ public static Boolean hasRequestedLegacyExternalStorage(@NonNull final ApplicationInfo applicationInfo) {
+ return isApplicationInfoPrivateFlagSetForPackage("PRIVATE_FLAG_REQUEST_LEGACY_EXTERNAL_STORAGE", applicationInfo);
+ }
+
+
+
+ /**
+ * Get the {@code versionCode} for the package associated with the {@code context}.
+ *
+ * @param context The {@link Context} for the package.
+ * @return Returns the {@code versionCode}. This will be {@code null} if an exception is raised.
+ */
+ @Nullable
+ public static Integer getVersionCodeForPackage(@NonNull final Context context) {
+ return getVersionCodeForPackage(context, context.getPackageName());
+ }
+
+ /**
+ * Get the {@code versionCode} for the {@code packageName}.
+ *
+ * @param context The {@link Context} for operations.
+ * @param packageName The package name of the package.
+ * @return Returns the {@code versionCode}. This will be {@code null} if an exception is raised.
+ */
+ @Nullable
+ public static Integer getVersionCodeForPackage(@NonNull final Context context, @NonNull final String packageName) {
+ return getVersionCodeForPackage(getPackageInfoForPackage(context, packageName));
+ }
+
+ /**
+ * Get the {@code versionCode} for the {@code packageName}.
+ *
+ * @param packageInfo The {@link PackageInfo} for the package.
+ * @return Returns the {@code versionCode}. This will be {@code null} if an exception is raised.
+ */
+ @Nullable
+ public static Integer getVersionCodeForPackage(@Nullable final PackageInfo packageInfo) {
+ return packageInfo != null ? packageInfo.versionCode : null;
+ }
+
+
+
+ /**
+ * Get the {@code versionName} for the package associated with the {@code context}.
+ *
+ * @param context The {@link Context} for the package.
+ * @return Returns the {@code versionName}. This will be {@code null} if an exception is raised.
+ */
+ @Nullable
+ public static String getVersionNameForPackage(@NonNull final Context context) {
+ return getVersionNameForPackage(context, context.getPackageName());
+ }
+
+ /**
+ * Get the {@code versionName} for the {@code packageName}.
+ *
+ * @param context The {@link Context} for operations.
+ * @param packageName The package name of the package.
+ * @return Returns the {@code versionName}. This will be {@code null} if an exception is raised.
+ */
+ @Nullable
+ public static String getVersionNameForPackage(@NonNull final Context context, @NonNull final String packageName) {
+ return getVersionNameForPackage(getPackageInfoForPackage(context, packageName));
+ }
+
+ /**
+ * Get the {@code versionName} for the {@code packageName}.
+ *
+ * @param packageInfo The {@link PackageInfo} for the package.
+ * @return Returns the {@code versionName}. This will be {@code null} if an {@code packageInfo}
+ * is {@code null}.
+ */
+ @Nullable
+ public static String getVersionNameForPackage(@Nullable final PackageInfo packageInfo) {
+ return packageInfo != null ? packageInfo.versionName : null;
+ }
+
+
+
+ /**
+ * Get the {@code SHA-256 digest} of signing certificate for the package associated with the {@code context}.
+ *
+ * @param context The {@link Context} for the package.
+ * @return Returns the {@code SHA-256 digest}. This will be {@code null} if an exception is raised.
+ */
+ @Nullable
+ public static String getSigningCertificateSHA256DigestForPackage(@NonNull final Context context) {
+ return getSigningCertificateSHA256DigestForPackage(context, context.getPackageName());
+ }
+
+ /**
+ * Get the {@code SHA-256 digest} of signing certificate for the {@code packageName}.
+ *
+ * @param context The {@link Context} for operations.
+ * @param packageName The package name of the package.
+ * @return Returns the {@code SHA-256 digest}. This will be {@code null} if an exception is raised.
+ */
+ @Nullable
+ public static String getSigningCertificateSHA256DigestForPackage(@NonNull final Context context, @NonNull final String packageName) {
+ try {
+ /*
+ * Todo: We may need AndroidManifest queries entries if package is installed but with a different signature on android 11
+ * https://developer.android.com/training/package-visibility
+ * Need a device that allows (manual) installation of apk with mismatched signature of
+ * sharedUserId apps to test. Currently, if its done, PackageManager just doesn't load
+ * the package and removes its apk automatically if its installed as a user app instead of system app
+ * W/PackageManager: Failed to parse /path/to/com.termux.tasker.apk: Signature mismatch for shared user: SharedUserSetting{xxxxxxx com.termux/10xxx}
+ */
+ PackageInfo packageInfo = getPackageInfoForPackage(context, packageName, PackageManager.GET_SIGNATURES);
+ if (packageInfo == null) return null;
+ return DataUtils.bytesToHex(MessageDigest.getInstance("SHA-256").digest(packageInfo.signatures[0].toByteArray()));
+ } catch (final Exception e) {
+ return null;
+ }
+ }
+
+
+
+ /**
+ * Get the serial number for the user for the package associated with the {@code context}.
+ *
+ * @param context The {@link Context} for the package.
+ * @return Returns the serial number. This will be {@code null} if failed to get it.
+ */
+ @RequiresApi(api = Build.VERSION_CODES.N)
+ @Nullable
+ public static Long getUserIdForPackage(@NonNull Context context) {
+ UserManager userManager = (UserManager) context.getSystemService(Context.USER_SERVICE);
+ if (userManager == null) return null;
+ return userManager.getSerialNumberForUser(UserHandle.getUserHandleForUid(getUidForPackage(context)));
+ }
+
+ /**
+ * Check if the current user is the primary user. This is done by checking if the the serial
+ * number for the current user equals 0.
+ *
+ * @param context The {@link Context} for operations.
+ * @return Returns {@code true} if the current user is the primary user, otherwise [@code false}.
+ */
+ @RequiresApi(api = Build.VERSION_CODES.N)
+ public static boolean isCurrentUserThePrimaryUser(@NonNull Context context) {
+ Long userId = getUserIdForPackage(context);
+ return userId != null && userId == 0;
+ }
+
+ /**
+ * Get the profile owner package name for the current user.
+ *
+ * @param context The {@link Context} for operations.
+ * @return Returns the profile owner package name. This will be {@code null} if failed to get it
+ * or no profile owner for the current user.
+ */
+ @Nullable
+ public static String getProfileOwnerPackageNameForUser(@NonNull Context context) {
+ DevicePolicyManager devicePolicyManager = (DevicePolicyManager) context.getSystemService(Context.DEVICE_POLICY_SERVICE);
+ if (devicePolicyManager == null) return null;
+ List activeAdmins = devicePolicyManager.getActiveAdmins();
+ if (activeAdmins != null){
+ for (ComponentName admin:activeAdmins){
+ String packageName = admin.getPackageName();
+ if(devicePolicyManager.isProfileOwnerApp(packageName))
+ return packageName;
+ }
+ }
+ return null;
+ }
+
+
+
+ /**
+ * Get the process id of the main app process of a package. This will work for sharedUserId. Note
+ * that some apps have multiple processes for the app like with `android:process=":background"`
+ * attribute in AndroidManifest.xml.
+ *
+ * @param context The {@link Context} for operations.
+ * @param packageName The package name of the process.
+ * @return Returns the process if found and running, otherwise {@code null}.
+ */
+ @Nullable
+ public static String getPackagePID(final Context context, String packageName) {
+ ActivityManager activityManager = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE);
+ if (activityManager != null) {
+ List processInfos = activityManager.getRunningAppProcesses();
+ if (processInfos != null) {
+ ActivityManager.RunningAppProcessInfo processInfo;
+ for (int i = 0; i < processInfos.size(); i++) {
+ processInfo = processInfos.get(i);
+ if (processInfo.processName.equals(packageName))
+ return String.valueOf(processInfo.pid);
+ }
+ }
+ }
+ return null;
+ }
+
+
+
+ /**
+ * Check if app is installed and enabled. This can be used by external apps that don't
+ * share `sharedUserId` with the an app.
+ *
+ * If your third-party app is targeting sdk `30` (android `11`), then it needs to add package
+ * name to the `queries` element or request `QUERY_ALL_PACKAGES` permission in its
+ * `AndroidManifest.xml`. Otherwise it will get `PackageSetting{...... package_name/......} BLOCKED`
+ * errors in `logcat` and {@link PackageManager.NameNotFoundException} may be thrown.
+ * `RUN_COMMAND` intent won't work either.
+ * Check [package-visibility](https://developer.android.com/training/basics/intents/package-visibility#package-name),
+ * `QUERY_ALL_PACKAGES` [googleplay policy](https://support.google.com/googleplay/android-developer/answer/10158779
+ * and this [article](https://medium.com/androiddevelopers/working-with-package-visibility-dc252829de2d) for more info.
+ *
+ * {@code
+ *
+ *
+ *
+ *
+ *
+ *
+ * }
+ *
+ * @param context The context for operations.
+ * @param appName The name of the app.
+ * @param packageName The package name of the package.
+ * @return Returns {@code errmsg} if {@code packageName} is not installed or disabled, otherwise {@code null}.
+ */
+ public static String isAppInstalled(@NonNull final Context context, String appName, String packageName) {
+ String errmsg = null;
+
+ ApplicationInfo applicationInfo = getApplicationInfoForPackage(context, packageName);
+ boolean isAppEnabled = (applicationInfo != null && applicationInfo.enabled);
+
+ // If app is not installed or is disabled
+ if (!isAppEnabled)
+ errmsg = context.getString(R.string.error_app_not_installed_or_disabled_warning, appName, packageName);
+
+ return errmsg;
+ }
+
+
+ /** Wrapper for {@link #setComponentState(Context, String, String, boolean, String, boolean, boolean)} with
+ * {@code alwaysShowToast} {@code true}. */
+ public static String setComponentState(@NonNull final Context context, @NonNull String packageName,
+ @NonNull String className, boolean newState, String toastString,
+ boolean showErrorMessage) {
+ return setComponentState(context, packageName, className, newState, toastString, showErrorMessage, true);
+ }
+
+ /**
+ * Enable or disable a {@link ComponentName} with a call to
+ * {@link PackageManager#setComponentEnabledSetting(ComponentName, int, int)}.
+ *
+ * @param context The {@link Context} for operations.
+ * @param packageName The package name of the component.
+ * @param className The {@link Class} name of the component.
+ * @param newState If component should be enabled or disabled.
+ * @param toastString If this is not {@code null} or empty, then a toast before setting state.
+ * @param showErrorMessage If an error message toast should be shown.
+ * @param alwaysShowToast If toast should always be shown even if current state matches new state.
+ * @return Returns the errmsg if failed to set state, otherwise {@code null}.
+ */
+ @Nullable
+ public static String setComponentState(@NonNull final Context context, @NonNull String packageName,
+ @NonNull String className, boolean newState, String toastString,
+ boolean alwaysShowToast, boolean showErrorMessage) {
+ try {
+ PackageManager packageManager = context.getPackageManager();
+ if (packageManager != null) {
+ if (toastString != null && alwaysShowToast) {
+ Logger.showToast(context, toastString, true);
+ toastString = null;
+ }
+
+ Boolean currentlyDisabled = PackageUtils.isComponentDisabled(context, packageName, className, false);
+ if (currentlyDisabled == null)
+ throw new UnsupportedOperationException("Failed to find if component currently disabled");
+
+ Boolean setState = null;
+ if (newState && currentlyDisabled)
+ setState = true;
+ else if (!newState && !currentlyDisabled)
+ setState = false;
+
+ if (setState == null) return null;
+
+ if (toastString != null) Logger.showToast(context, toastString, true);
+ ComponentName componentName = new ComponentName(packageName, className);
+ packageManager.setComponentEnabledSetting(componentName,
+ setState ? PackageManager.COMPONENT_ENABLED_STATE_ENABLED : PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
+ PackageManager.DONT_KILL_APP);
+ }
+ return null;
+ } catch (final Exception e) {
+ String errmsg = context.getString(
+ newState ? R.string.error_enable_component_failed : R.string.error_disable_component_failed,
+ packageName, className) + ": " + e.getMessage();
+ if (showErrorMessage)
+ Logger.showToast(context, errmsg, true);
+ return errmsg;
+ }
+ }
+
+ /**
+ * Check if state of a {@link ComponentName} is {@link PackageManager#COMPONENT_ENABLED_STATE_DISABLED}
+ * with a call to {@link PackageManager#getComponentEnabledSetting(ComponentName)}.
+ *
+ * @param context The {@link Context} for operations.
+ * @param packageName The package name of the component.
+ * @param className The {@link Class} name of the component.
+ * @param logErrorMessage If an error message should be logged.
+ * @return Returns {@code true} if disabled, {@code false} if not and {@code null} if failed to
+ * get the state.
+ */
+ public static Boolean isComponentDisabled(@NonNull final Context context, @NonNull String packageName,
+ @NonNull String className, boolean logErrorMessage) {
+ try {
+ PackageManager packageManager = context.getPackageManager();
+ if (packageManager != null) {
+ ComponentName componentName = new ComponentName(packageName, className);
+ // Will throw IllegalArgumentException: Unknown component: ComponentInfo{} if app
+ // for context is not installed or component does not exist.
+ return packageManager.getComponentEnabledSetting(componentName) == PackageManager.COMPONENT_ENABLED_STATE_DISABLED;
+ }
+ } catch (final Exception e) {
+ if (logErrorMessage)
+ Logger.logStackTraceWithMessage(LOG_TAG, context.getString(R.string.error_get_component_state_failed, packageName, className), e);
+ }
+
+ return null;
+ }
+
+ /**
+ * Check if an {@link android.app.Activity} {@link ComponentName} can be called by calling
+ * {@link PackageManager#queryIntentActivities(Intent, int)}.
+ *
+ * @param context The {@link Context} for operations.
+ * @param packageName The package name of the component.
+ * @param className The {@link Class} name of the component.
+ * @param flags The flags to filter results.
+ * @return Returns {@code true} if it exists, otherwise {@code false}.
+ */
+ public static boolean doesActivityComponentExist(@NonNull final Context context, @NonNull String packageName,
+ @NonNull String className, int flags) {
+ try {
+ PackageManager packageManager = context.getPackageManager();
+ if (packageManager != null) {
+ Intent intent = new Intent();
+ intent.setClassName(packageName, className);
+ return packageManager.queryIntentActivities(intent, flags).size() > 0;
+ }
+ } catch (final Exception e) {
+ // ignore
+ }
+
+ return false;
+ }
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/android/PermissionUtils.java b/termux/termux-shared/src/main/java/com/termux/shared/android/PermissionUtils.java
new file mode 100644
index 000000000..4a15649df
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/android/PermissionUtils.java
@@ -0,0 +1,573 @@
+package com.termux.shared.android;
+
+import android.Manifest;
+import android.annotation.SuppressLint;
+import android.app.Activity;
+import android.app.Service;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageInfo;
+import android.content.pm.PackageManager;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Environment;
+import android.os.PowerManager;
+import android.provider.Settings;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.RequiresApi;
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.core.content.ContextCompat;
+
+import com.google.common.base.Joiner;
+import com.termux.shared.R;
+import com.termux.shared.file.FileUtils;
+import com.termux.shared.logger.Logger;
+import com.termux.shared.errors.Error;
+import com.termux.shared.errors.FunctionErrno;
+import com.termux.shared.activity.ActivityUtils;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+public class PermissionUtils {
+
+ public static final int REQUEST_GRANT_STORAGE_PERMISSION = 1000;
+
+ public static final int REQUEST_DISABLE_BATTERY_OPTIMIZATIONS = 2000;
+ public static final int REQUEST_GRANT_DISPLAY_OVER_OTHER_APPS_PERMISSION = 2001;
+
+ private static final String LOG_TAG = "PermissionUtils";
+
+
+ /**
+ * Check if app has been granted the required permission.
+ *
+ * @param context The context for operations.
+ * @param permission The {@link String} name for permission to check.
+ * @return Returns {@code true} if permission is granted, otherwise {@code false}.
+ */
+ public static boolean checkPermission(@NonNull Context context, @NonNull String permission) {
+ return checkPermissions(context, new String[]{permission});
+ }
+
+ /**
+ * Check if app has been granted the required permissions.
+ *
+ * @param context The context for operations.
+ * @param permissions The {@link String[]} names for permissions to check.
+ * @return Returns {@code true} if permissions are granted, otherwise {@code false}.
+ */
+ public static boolean checkPermissions(@NonNull Context context, @NonNull String[] permissions) {
+ // checkSelfPermission may return true for permissions not even requested
+ List permissionsNotRequested = getPermissionsNotRequested(context, permissions);
+ if (permissionsNotRequested.size() > 0) {
+ Logger.logError(LOG_TAG,
+ context.getString(R.string.error_attempted_to_check_for_permissions_not_requested,
+ Joiner.on(", ").join(permissionsNotRequested)));
+ return false;
+ }
+
+ int result;
+ for (String permission : permissions) {
+ result = ContextCompat.checkSelfPermission(context, permission);
+ if (result != PackageManager.PERMISSION_GRANTED) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+
+
+ /**
+ * Request user to grant required permissions to the app.
+ *
+ * @param context The context for operations. It must be an instance of {@link Activity} or
+ * {@link AppCompatActivity}.
+ * @param permission The {@link String} name for permission to request.
+ * @param requestCode The request code to use while asking for permission. It must be `>=0` or
+ * will fail silently and will log an exception.
+ * @return Returns {@code true} if requesting the permission was successful, otherwise {@code false}.
+ */
+ @RequiresApi(api = Build.VERSION_CODES.M)
+ public static boolean requestPermission(@NonNull Context context, @NonNull String permission,
+ int requestCode) {
+ return requestPermissions(context, new String[]{permission}, requestCode);
+ }
+
+ /**
+ * Request user to grant required permissions to the app.
+ *
+ * On sdk 30 (android 11), Activity.onRequestPermissionsResult() will pass
+ * {@link PackageManager#PERMISSION_DENIED} (-1) without asking the user for the permission
+ * if user previously denied the permission prompt. On sdk 29 (android 10),
+ * Activity.onRequestPermissionsResult() will pass {@link PackageManager#PERMISSION_DENIED} (-1)
+ * without asking the user for the permission if user previously selected "Deny & don't ask again"
+ * option in prompt. The user will have to manually enable permission in app info in Android
+ * settings. If user grants and then denies in settings, then next time prompt will shown.
+ *
+ * @param context The context for operations. It must be an instance of {@link Activity} or
+ * {@link AppCompatActivity}.
+ * @param permissions The {@link String[]} names for permissions to request.
+ * @param requestCode The request code to use while asking for permissions. It must be `>=0` or
+ * will fail silently and will log an exception.
+ * @return Returns {@code true} if requesting the permissions was successful, otherwise {@code false}.
+ */
+ @RequiresApi(api = Build.VERSION_CODES.M)
+ public static boolean requestPermissions(@NonNull Context context, @NonNull String[] permissions,
+ int requestCode) {
+ List permissionsNotRequested = getPermissionsNotRequested(context, permissions);
+ if (permissionsNotRequested.size() > 0) {
+ Logger.logErrorAndShowToast(context, LOG_TAG,
+ context.getString(R.string.error_attempted_to_ask_for_permissions_not_requested,
+ Joiner.on(", ").join(permissionsNotRequested)));
+ return false;
+ }
+
+ for (String permission : permissions) {
+ int result = ContextCompat.checkSelfPermission(context, permission);
+ // If at least one permission not granted
+ if (result != PackageManager.PERMISSION_GRANTED) {
+ Logger.logInfo(LOG_TAG, "Requesting Permissions: " + Arrays.toString(permissions));
+
+ try {
+ if (context instanceof AppCompatActivity)
+ ((AppCompatActivity) context).requestPermissions(permissions, requestCode);
+ else if (context instanceof Activity)
+ ((Activity) context).requestPermissions(permissions, requestCode);
+ else {
+ Error.logErrorAndShowToast(context, LOG_TAG,
+ FunctionErrno.ERRNO_PARAMETER_NOT_INSTANCE_OF.getError("context", "requestPermissions", "Activity or AppCompatActivity"));
+ return false;
+ }
+ } catch (Exception e) {
+ String errmsg = context.getString(R.string.error_failed_to_request_permissions, requestCode, Arrays.toString(permissions));
+ Logger.logStackTraceWithMessage(LOG_TAG, errmsg, e);
+ Logger.showToast(context, errmsg + "\n" + e.getMessage(), true);
+ return false;
+ }
+
+ break;
+ }
+ }
+
+ return true;
+ }
+
+
+
+
+ /**
+ * Check if app has requested the required permission in the manifest.
+ *
+ * @param context The context for operations.
+ * @param permission The {@link String} name for permission to check.
+ * @return Returns {@code true} if permission has been requested, otherwise {@code false}.
+ */
+ public static boolean isPermissionRequested(@NonNull Context context, @NonNull String permission) {
+ return getPermissionsNotRequested(context, new String[]{permission}).size() == 0;
+ }
+
+ /**
+ * Check if app has requested the required permissions or not in the manifest.
+ *
+ * @param context The context for operations.
+ * @param permissions The {@link String[]} names for permissions to check.
+ * @return Returns {@link List} of permissions that have not been requested. It will have
+ * size 0 if all permissions have been requested.
+ */
+ @NonNull
+ public static List getPermissionsNotRequested(@NonNull Context context, @NonNull String[] permissions) {
+ List permissionsNotRequested = new ArrayList<>();
+ Collections.addAll(permissionsNotRequested, permissions);
+
+ PackageInfo packageInfo = PackageUtils.getPackageInfoForPackage(context, PackageManager.GET_PERMISSIONS);
+ if (packageInfo == null) {
+ return permissionsNotRequested;
+ }
+
+ // If no permissions are requested, then nothing to check
+ if (packageInfo.requestedPermissions == null || packageInfo.requestedPermissions.length == 0)
+ return permissionsNotRequested;
+
+ List requestedPermissionsList = Arrays.asList(packageInfo.requestedPermissions);
+ for (String permission : permissions) {
+ if (requestedPermissionsList.contains(permission)) {
+ permissionsNotRequested.remove(permission);
+ }
+ }
+
+ return permissionsNotRequested;
+ }
+
+
+
+
+ /** If path is under primary external storage directory and storage permission is missing,
+ * then legacy or manage external storage permission will be requested from the user via a call
+ * to {@link #checkAndRequestLegacyOrManageExternalStoragePermission(Context, int, boolean)}.
+ *
+ * @param context The context for operations.
+ * @param filePath The path to check.
+ * @param requestCode The request code to use while asking for permission.
+ * @param showErrorMessage If an error message toast should be shown if permission is not granted.
+ * @return Returns {@code true} if permission is granted, otherwise {@code false}.
+ */
+ @SuppressLint("SdCardPath")
+ public static boolean checkAndRequestLegacyOrManageExternalStoragePermissionIfPathOnPrimaryExternalStorage(
+ @NonNull Context context, String filePath, int requestCode, boolean showErrorMessage) {
+ // If path is under primary external storage directory, then check for missing permissions.
+ if (!FileUtils.isPathInDirPaths(filePath,
+ Arrays.asList(Environment.getExternalStorageDirectory().getAbsolutePath(), "/sdcard"), true))
+ return true;
+
+ return checkAndRequestLegacyOrManageExternalStoragePermission(context, requestCode, showErrorMessage);
+ }
+
+ /**
+ * Check if legacy or manage external storage permissions has been granted. If
+ * {@link #isLegacyExternalStoragePossible(Context)} returns {@code true}, them it will be
+ * checked if app has has been granted {@link Manifest.permission#READ_EXTERNAL_STORAGE} and
+ * {@link Manifest.permission#WRITE_EXTERNAL_STORAGE} permissions, otherwise it will be checked
+ * if app has been granted the {@link Manifest.permission#MANAGE_EXTERNAL_STORAGE} permission.
+ *
+ * If storage permission is missing, it will be requested from the user if {@code context} is an
+ * instance of {@link Activity} or {@link AppCompatActivity} and {@code requestCode}
+ * is `>=0` and the function will automatically return. The caller should register for
+ * Activity.onActivityResult() and Activity.onRequestPermissionsResult() and call this function
+ * again but set {@code requestCode} to `-1` to check if permission was granted or not.
+ *
+ * Caller must add following to AndroidManifest.xml of the app, otherwise errors will be thrown.
+ * {@code
+ *
+ *
+ *
+ *
+ *
+ *
+ *}
+ * @param context The context for operations.
+ * @param requestCode The request code to use while asking for permission.
+ * @param showErrorMessage If an error message toast should be shown if permission is not granted.
+ * @return Returns {@code true} if permission is granted, otherwise {@code false}.
+ */
+ public static boolean checkAndRequestLegacyOrManageExternalStoragePermission(@NonNull Context context,
+ int requestCode,
+ boolean showErrorMessage) {
+ String errmsg;
+ boolean requestLegacyStoragePermission = isLegacyExternalStoragePossible(context);
+ boolean checkIfHasRequestedLegacyExternalStorage = checkIfHasRequestedLegacyExternalStorage(context);
+
+ if (requestLegacyStoragePermission && checkIfHasRequestedLegacyExternalStorage) {
+ // Check if requestLegacyExternalStorage is set to true in app manifest
+ if (!hasRequestedLegacyExternalStorage(context, showErrorMessage))
+ return false;
+ }
+
+ if (checkStoragePermission(context, requestLegacyStoragePermission)) {
+ return true;
+ }
+
+
+ errmsg = context.getString(R.string.msg_storage_permission_not_granted);
+ Logger.logError(LOG_TAG, errmsg);
+ if (showErrorMessage)
+ Logger.showToast(context, errmsg, false);
+
+ if (requestCode < 0 || Build.VERSION.SDK_INT < Build.VERSION_CODES.M)
+ return false;
+
+ if (requestLegacyStoragePermission || Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
+ requestLegacyStorageExternalPermission(context, requestCode);
+ } else {
+ requestManageStorageExternalPermission(context, requestCode);
+ }
+
+ return false;
+ }
+
+ /**
+ * Check if app has been granted storage permission.
+ *
+ * @param context The context for operations.
+ * @param checkLegacyStoragePermission If set to {@code true}, then it will be checked if app
+ * has been granted {@link Manifest.permission#READ_EXTERNAL_STORAGE}
+ * and {@link Manifest.permission#WRITE_EXTERNAL_STORAGE}
+ * permissions, otherwise it will be checked if app has been
+ * granted the {@link Manifest.permission#MANAGE_EXTERNAL_STORAGE}
+ * permission.
+ * @return Returns {@code true} if permission is granted, otherwise {@code false}.
+ */
+ public static boolean checkStoragePermission(@NonNull Context context, boolean checkLegacyStoragePermission) {
+ if (checkLegacyStoragePermission || Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
+ return checkPermissions(context,
+ new String[]{Manifest.permission.READ_EXTERNAL_STORAGE,
+ Manifest.permission.WRITE_EXTERNAL_STORAGE});
+ } else {
+ return Environment.isExternalStorageManager();
+ }
+ }
+
+ /**
+ * Request user to grant {@link Manifest.permission#READ_EXTERNAL_STORAGE} and
+ * {@link Manifest.permission#WRITE_EXTERNAL_STORAGE} permissions to the app.
+ *
+ * @param context The context for operations. It must be an instance of {@link Activity} or
+ * {@link AppCompatActivity}.
+ * @param requestCode The request code to use while asking for permission. It must be `>=0` or
+ * will fail silently and will log an exception.
+ * @return Returns {@code true} if requesting the permission was successful, otherwise {@code false}.
+ */
+ @RequiresApi(api = Build.VERSION_CODES.M)
+ public static boolean requestLegacyStorageExternalPermission(@NonNull Context context, int requestCode) {
+ Logger.logInfo(LOG_TAG, "Requesting legacy external storage permission");
+ return requestPermission(context, Manifest.permission.WRITE_EXTERNAL_STORAGE, requestCode);
+ }
+
+ /** Wrapper for {@link #requestManageStorageExternalPermission(Context, int)}. */
+ @RequiresApi(api = Build.VERSION_CODES.R)
+ public static Error requestManageStorageExternalPermission(@NonNull Context context) {
+ return requestManageStorageExternalPermission(context, -1);
+ }
+
+ /**
+ * Request user to grant {@link Manifest.permission#MANAGE_EXTERNAL_STORAGE} permission to the app.
+ *
+ * @param context The context for operations, like an {@link Activity} or {@link Service} context.
+ * It must be an instance of {@link Activity} or {@link AppCompatActivity} if
+ * result is required via the Activity#onActivityResult() callback and
+ * {@code requestCode} is `>=0`.
+ * @param requestCode The request code to use while asking for permission. It must be `>=0` if
+ * result it required.
+ * @return Returns the {@code error} if requesting the permission was not successful, otherwise {@code null}.
+ */
+ @RequiresApi(api = Build.VERSION_CODES.R)
+ public static Error requestManageStorageExternalPermission(@NonNull Context context, int requestCode) {
+ Logger.logInfo(LOG_TAG, "Requesting manage external storage permission");
+
+ Intent intent = new Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION);
+ intent.addCategory("android.intent.category.DEFAULT");
+ intent.setData(Uri.parse("package:" + context.getPackageName()));
+
+ // Flag must not be passed for activity contexts, otherwise onActivityResult() will not be called with permission grant result.
+ // Flag must be passed for non-activity contexts like services, otherwise "Calling startActivity() from outside of an Activity context requires the FLAG_ACTIVITY_NEW_TASK flag" exception will be raised.
+ if (!(context instanceof Activity))
+ intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+
+ Error error;
+ if (requestCode >=0)
+ error = ActivityUtils.startActivityForResult(context, requestCode, intent, true, false);
+ else
+ error = ActivityUtils.startActivity(context, intent, true, false);
+
+ // Use fallback if matching Activity did not exist for ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION.
+ if (error != null) {
+ intent = new Intent();
+ intent.setAction(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION);
+ if (requestCode >=0)
+ return ActivityUtils.startActivityForResult(context, requestCode, intent);
+ else
+ return ActivityUtils.startActivity(context, intent);
+ }
+
+ return null;
+ }
+
+ /**
+ * If app is targeting targetSdkVersion 30 (android 11) and running on sdk 30 (android 11) or
+ * higher, then {@link android.R.attr#requestLegacyExternalStorage} attribute is ignored.
+ * https://developer.android.com/training/data-storage/use-cases#opt-out-scoped-storage
+ */
+ public static boolean isLegacyExternalStoragePossible(@NonNull Context context) {
+ return !(Build.VERSION.SDK_INT >= Build.VERSION_CODES.R &&
+ PackageUtils.getTargetSDKForPackage(context) >= Build.VERSION_CODES.R);
+ }
+
+ /**
+ * Return whether it should be checked if app has set
+ * {@link android.R.attr#requestLegacyExternalStorage} attribute to {@code true}, if storage
+ * permissions are to be requested based on if {@link #isLegacyExternalStoragePossible(Context)}
+ * return {@code true}.
+ *
+ * If app is targeting targetSdkVersion 30 (android 11), then legacy storage can only be
+ * requested if running on sdk 29 (android 10).
+ * If app is targeting targetSdkVersion 29 (android 10), then legacy storage can only be
+ * requested if running on sdk 29 (android 10) and higher.
+ */
+ public static boolean checkIfHasRequestedLegacyExternalStorage(@NonNull Context context) {
+ int targetSdkVersion = PackageUtils.getTargetSDKForPackage(context);
+
+ if (targetSdkVersion >= Build.VERSION_CODES.R) {
+ return Build.VERSION.SDK_INT == Build.VERSION_CODES.Q;
+ } else if (targetSdkVersion == Build.VERSION_CODES.Q) {
+ return Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q;
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * Call to {@link Environment#isExternalStorageLegacy()} will not return the actual value defined
+ * in app manifest for {@link android.R.attr#requestLegacyExternalStorage} attribute,
+ * since an app may inherit its legacy state based on when it was first installed, target sdk and
+ * other factors. To provide consistent experience for all users regardless of current legacy
+ * state on a specific device, we directly use the value defined in app` manifest.
+ */
+ public static boolean hasRequestedLegacyExternalStorage(@NonNull Context context,
+ boolean showErrorMessage) {
+ String errmsg;
+ Boolean hasRequestedLegacyExternalStorage = PackageUtils.hasRequestedLegacyExternalStorage(context);
+ if (hasRequestedLegacyExternalStorage != null && !hasRequestedLegacyExternalStorage) {
+ errmsg = context.getString(R.string.error_has_not_requested_legacy_external_storage,
+ context.getPackageName(), PackageUtils.getTargetSDKForPackage(context), Build.VERSION.SDK_INT);
+ Logger.logError(LOG_TAG, errmsg);
+ if (showErrorMessage)
+ Logger.showToast(context, errmsg, true);
+ return false;
+ }
+
+ return true;
+ }
+
+
+
+
+
+ /**
+ * Check if {@link Manifest.permission#SYSTEM_ALERT_WINDOW} permission has been granted.
+ *
+ * @param context The context for operations.
+ * @return Returns {@code true} if permission is granted, otherwise {@code false}.
+ */
+ public static boolean checkDisplayOverOtherAppsPermission(@NonNull Context context) {
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M)
+ return Settings.canDrawOverlays(context);
+ else
+ return true;
+ }
+
+ /** Wrapper for {@link #requestDisplayOverOtherAppsPermission(Context, int)}. */
+ public static Error requestDisplayOverOtherAppsPermission(@NonNull Context context) {
+ return requestDisplayOverOtherAppsPermission(context, -1);
+ }
+
+ /**
+ * Request user to grant {@link Manifest.permission#SYSTEM_ALERT_WINDOW} permission to the app.
+ *
+ * @param context The context for operations, like an {@link Activity} or {@link Service} context.
+ * It must be an instance of {@link Activity} or {@link AppCompatActivity} if
+ * result is required via the Activity#onActivityResult() callback and
+ * {@code requestCode} is `>=0`.
+ * @param requestCode The request code to use while asking for permission. It must be `>=0` if
+ * result it required.
+ * @return Returns the {@code error} if requesting the permission was not successful, otherwise {@code null}.
+ */
+ public static Error requestDisplayOverOtherAppsPermission(@NonNull Context context, int requestCode) {
+ Logger.logInfo(LOG_TAG, "Requesting display over apps permission");
+
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M)
+ return null;
+
+ Intent intent = new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION);
+ intent.setData(Uri.parse("package:" + context.getPackageName()));
+
+ // Flag must not be passed for activity contexts, otherwise onActivityResult() will not be called with permission grant result.
+ // Flag must be passed for non-activity contexts like services, otherwise "Calling startActivity() from outside of an Activity context requires the FLAG_ACTIVITY_NEW_TASK flag" exception will be raised.
+ if (!(context instanceof Activity))
+ intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+
+ if (requestCode >=0)
+ return ActivityUtils.startActivityForResult(context, requestCode, intent);
+ else
+ return ActivityUtils.startActivity(context, intent);
+ }
+
+ /**
+ * Check if running on sdk 29 (android 10) or higher and {@link Manifest.permission#SYSTEM_ALERT_WINDOW}
+ * permission has been granted or not.
+ *
+ * @param context The context for operations.
+ * @param logResults If it should be logged that permission has been granted or not.
+ * @return Returns {@code true} if permission is granted, otherwise {@code false}.
+ */
+ public static boolean validateDisplayOverOtherAppsPermissionForPostAndroid10(@NonNull Context context,
+ boolean logResults) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return true;
+
+ if (!checkDisplayOverOtherAppsPermission(context)) {
+ if (logResults)
+ Logger.logWarn(LOG_TAG, context.getPackageName() + " does not have Display over other apps (SYSTEM_ALERT_WINDOW) permission");
+ return false;
+ } else {
+ if (logResults)
+ Logger.logDebug(LOG_TAG, context.getPackageName() + " already has Display over other apps (SYSTEM_ALERT_WINDOW) permission");
+ return true;
+ }
+ }
+
+
+
+
+
+ /**
+ * Check if {@link Manifest.permission#REQUEST_IGNORE_BATTERY_OPTIMIZATIONS} permission has been
+ * granted.
+ *
+ * @param context The context for operations.
+ * @return Returns {@code true} if permission is granted, otherwise {@code false}.
+ */
+ public static boolean checkIfBatteryOptimizationsDisabled(@NonNull Context context) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ PowerManager powerManager = (PowerManager) context.getSystemService(Context.POWER_SERVICE);
+ return powerManager.isIgnoringBatteryOptimizations(context.getPackageName());
+ } else
+ return true;
+ }
+
+ /** Wrapper for {@link #requestDisableBatteryOptimizations(Context, int)}. */
+ public static Error requestDisableBatteryOptimizations(@NonNull Context context) {
+ return requestDisableBatteryOptimizations(context, -1);
+ }
+
+ /**
+ * Request user to grant {@link Manifest.permission#REQUEST_IGNORE_BATTERY_OPTIMIZATIONS}
+ * permission to the app.
+ *
+ * @param context The context for operations, like an {@link Activity} or {@link Service} context.
+ * It must be an instance of {@link Activity} or {@link AppCompatActivity} if
+ * result is required via the Activity#onActivityResult() callback and
+ * {@code requestCode} is `>=0`.
+ * @param requestCode The request code to use while asking for permission. It must be `>=0` if
+ * result it required.
+ * @return Returns the {@code error} if requesting the permission was not successful, otherwise {@code null}.
+ */
+ @SuppressLint("BatteryLife")
+ public static Error requestDisableBatteryOptimizations(@NonNull Context context, int requestCode) {
+ Logger.logInfo(LOG_TAG, "Requesting to disable battery optimizations");
+
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M)
+ return null;
+
+ Intent intent = new Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS);
+ intent.setData(Uri.parse("package:" + context.getPackageName()));
+
+ // Flag must not be passed for activity contexts, otherwise onActivityResult() will not be called with permission grant result.
+ // Flag must be passed for non-activity contexts like services, otherwise "Calling startActivity() from outside of an Activity context requires the FLAG_ACTIVITY_NEW_TASK flag" exception will be raised.
+ if (!(context instanceof Activity))
+ intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+
+ if (requestCode >=0)
+ return ActivityUtils.startActivityForResult(context, requestCode, intent);
+ else
+ return ActivityUtils.startActivity(context, intent);
+ }
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/android/PhantomProcessUtils.java b/termux/termux-shared/src/main/java/com/termux/shared/android/PhantomProcessUtils.java
new file mode 100644
index 000000000..c4dc7790f
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/android/PhantomProcessUtils.java
@@ -0,0 +1,115 @@
+package com.termux.shared.android;
+
+import android.Manifest;
+import android.content.Context;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.termux.shared.logger.Logger;
+import com.termux.shared.shell.command.environment.AndroidShellEnvironment;
+import com.termux.shared.shell.command.ExecutionCommand;
+import com.termux.shared.shell.command.runner.app.AppShell;
+
+/**
+ * Utils for phantom processes added in android 12.
+ *
+ * https://github.com/termux/termux-app/issues/2366
+ * https://issuetracker.google.com/u/1/issues/205156966#comment28
+ * https://cs.android.com/android/_/android/platform/frameworks/base/+/09dcdad5
+ * https://github.com/agnostic-apollo/Android-Docs/tree/master/ocs/apps/processes/phantom-cached-and-empty-processes.md
+ */
+public class PhantomProcessUtils {
+
+ private static final String LOG_TAG = "PhantomProcessUtils";
+
+ /**
+ * If feature flag set to false, then will disable trimming of phantom process and processes using
+ * excessive CPU. Flag is available on Pixel Android 12L beta 3 and Android 13. Availability on
+ * other devices will depend on if other vendors merged the 09dcdad5 commit or not in their releases
+ * and if they actually want to support the flag. Check {@link FeatureFlagUtils} javadocs for
+ * more details.
+ */
+ public static final String FEATURE_FLAG_SETTINGS_ENABLE_MONITOR_PHANTOM_PROCS = "settings_enable_monitor_phantom_procs";
+
+ /**
+ * Maximum number of allowed phantom processes. It is also used as the label for the currently
+ * enforced ActivityManagerConstants MAX_PHANTOM_PROCESSES value in the `dumpsys activity settings`
+ * output.
+ *
+ * https://cs.android.com/android/platform/superproject/+/android-12.0.0_r4:frameworks/base/services/core/java/com/android/server/am/ActivityManagerConstants.java;l=574
+ * https://cs.android.com/android/platform/superproject/+/android-12.0.0_r4:frameworks/base/services/core/java/com/android/server/am/ActivityManagerConstants.java;l=172
+ */
+ public static final String KEY_MAX_PHANTOM_PROCESSES = "max_phantom_processes";
+
+ /**
+ * Whether or not syncs (bulk set operations) for DeviceConfig are disabled currently. The value
+ * is boolean (1 or 0). The value '1' means that DeviceConfig#setProperties(DeviceConfig.Properties)
+ * will return {@code false}.
+ *
+ * https://cs.android.com/android/platform/superproject/+/android-12.0.0_r4:frameworks/base/core/java/android/provider/DeviceConfig.java
+ * https://cs.android.com/android/platform/superproject/+/android-12.0.0_r4:frameworks/base/packages/SettingsProvider/src/com/android/providers/settings/SettingsProvider.java;l=1186
+ * https://cs.android.com/android/platform/superproject/+/android-12.0.0_r4:frameworks/base/packages/SettingsProvider/src/com/android/providers/settings/SettingsProvider.java;l=1142
+ */
+ public static final String SETTINGS_GLOBAL_DEVICE_CONFIG_SYNC_DISABLED = "device_config_sync_disabled";
+
+ /**
+ * Get {@link #FEATURE_FLAG_SETTINGS_ENABLE_MONITOR_PHANTOM_PROCS} feature flag value.
+ *
+ * @param context The {@link Context} for operations.
+ * @return Returns {@link FeatureFlagUtils.FeatureFlagValue}.
+ */
+ @NonNull
+ public static FeatureFlagUtils.FeatureFlagValue getFeatureFlagMonitorPhantomProcsValueString(@NonNull Context context) {
+ return FeatureFlagUtils.getFeatureFlagValueString(context, FEATURE_FLAG_SETTINGS_ENABLE_MONITOR_PHANTOM_PROCS);
+ }
+
+ /**
+ * Get currently enforced ActivityManagerConstants MAX_PHANTOM_PROCESSES value, defaults to 32.
+ * Can be changed by modifying device config activity_manager namespace "max_phantom_processes" value.
+ *
+ * @param context The {@link Context} for operations.
+ * @return Returns {@link Integer}.
+ */
+ @Nullable
+ public static Integer getActivityManagerMaxPhantomProcesses(@NonNull Context context) {
+ if (!PermissionUtils.checkPermissions(context, new String[]{Manifest.permission.DUMP, Manifest.permission.PACKAGE_USAGE_STATS})) {
+ return null;
+ }
+
+ // Dumpsys logs the currently enforced MAX_PHANTOM_PROCESSES value and not the device config setting.
+ String script = "/system/bin/dumpsys activity settings | /system/bin/grep -iE '^[\t ]+" + KEY_MAX_PHANTOM_PROCESSES + "=[0-9]+$' | /system/bin/cut -d = -f2";
+ ExecutionCommand executionCommand = new ExecutionCommand(-1, "/system/bin/sh", null,
+ script + "\n", "/", ExecutionCommand.Runner.APP_SHELL.getName(), true);
+ executionCommand.commandLabel = " ActivityManager " + KEY_MAX_PHANTOM_PROCESSES + " Command";
+ executionCommand.backgroundCustomLogLevel = Logger.LOG_LEVEL_OFF;
+ AppShell appShell = AppShell.execute(context, executionCommand, null, new AndroidShellEnvironment(), null, true);
+ boolean stderrSet = !executionCommand.resultData.stderr.toString().isEmpty();
+ if (appShell == null || !executionCommand.isSuccessful() || executionCommand.resultData.exitCode != 0 || stderrSet) {
+ Logger.logErrorExtended(LOG_TAG, executionCommand.toString());
+ return null;
+ }
+
+ try {
+ return Integer.parseInt(executionCommand.resultData.stdout.toString().trim());
+ } catch (NumberFormatException e) {
+ Logger.logStackTraceWithMessage(LOG_TAG, "The " + executionCommand.commandLabel + " did not return a valid integer", e);
+ Logger.logErrorExtended(LOG_TAG, executionCommand.toString());
+ }
+
+ return null;
+ }
+
+ /**
+ * Get {@link #SETTINGS_GLOBAL_DEVICE_CONFIG_SYNC_DISABLED} settings value.
+ *
+ * @param context The {@link Context} for operations.
+ * @return Returns {@link Integer}.
+ */
+ @Nullable
+ public static Integer getSettingsGlobalDeviceConfigSyncDisabled(@NonNull Context context) {
+ return (Integer) SettingsProviderUtils.getSettingsValue(context, SettingsProviderUtils.SettingNamespace.GLOBAL,
+ SettingsProviderUtils.SettingType.INT, SETTINGS_GLOBAL_DEVICE_CONFIG_SYNC_DISABLED, null);
+ }
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/android/ProcessUtils.java b/termux/termux-shared/src/main/java/com/termux/shared/android/ProcessUtils.java
new file mode 100644
index 000000000..c6d182b06
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/android/ProcessUtils.java
@@ -0,0 +1,58 @@
+package com.termux.shared.android;
+
+import android.app.ActivityManager;
+import android.content.Context;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.termux.shared.logger.Logger;
+
+import java.util.List;
+
+public class ProcessUtils {
+
+ public static final String LOG_TAG = "ProcessUtils";
+
+ /**
+ * Get the app process name for a pid with a call to {@link ActivityManager#getRunningAppProcesses()}.
+ *
+ * This will not return child process names. Android did not keep track of them before android 12
+ * phantom process addition, but there is no API via IActivityManager to get them.
+ *
+ * To get process name for pids of own app's child processes, check `get_process_name_from_cmdline()`
+ * in `local-socket.cpp`.
+ *
+ * https://cs.android.com/android/platform/superproject/+/android-12.0.0_r32:frameworks/base/core/java/android/app/ActivityManager.java;l=3362
+ * https://cs.android.com/android/platform/superproject/+/android-12.0.0_r32:frameworks/base/services/core/java/com/android/server/am/ActivityManagerService.java;l=8434
+ * https://cs.android.com/android/_/android/platform/frameworks/base/+/refs/tags/android-12.0.0_r32:services/core/java/com/android/server/am/PhantomProcessList.java
+ * https://cs.android.com/android/_/android/platform/frameworks/base/+/refs/tags/android-12.0.0_r32:services/core/java/com/android/server/am/PhantomProcessRecord.java
+ *
+ * @param context The {@link Context} for operations.
+ * @param pid The pid of the process.
+ * @return Returns the app process name if found, otherwise {@code null}.
+ */
+ @Nullable
+ public static String getAppProcessNameForPid(@NonNull Context context, int pid) {
+ if (pid < 0) return null;
+
+ ActivityManager activityManager = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE);
+ if (activityManager == null) return null;
+ try {
+ List runningApps = activityManager.getRunningAppProcesses();
+ if (runningApps == null) {
+ return null;
+ }
+ for (ActivityManager.RunningAppProcessInfo procInfo : runningApps) {
+ if (procInfo.pid == pid) {
+ return procInfo.processName;
+ }
+ }
+ } catch (Exception e) {
+ Logger.logStackTraceWithMessage(LOG_TAG, "Failed to get app process name for pid " + pid, e);
+ }
+
+ return null;
+ }
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/android/SELinuxUtils.java b/termux/termux-shared/src/main/java/com/termux/shared/android/SELinuxUtils.java
new file mode 100644
index 000000000..13810eb7b
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/android/SELinuxUtils.java
@@ -0,0 +1,96 @@
+package com.termux.shared.android;
+
+import android.annotation.SuppressLint;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.termux.shared.logger.Logger;
+import com.termux.shared.reflection.ReflectionUtils;
+
+import java.lang.reflect.Method;
+
+public class SELinuxUtils {
+
+ public static final String ANDROID_OS_SELINUX_CLASS = "android.os.SELinux";
+
+ private static final String LOG_TAG = "SELinuxUtils";
+
+ /**
+ * Gets the security context of the current process.
+ *
+ * @return Returns a {@link String} representing the security context of the current process.
+ * This will be {@code null} if an exception is raised.
+ */
+ @Nullable
+ public static String getContext() {
+ ReflectionUtils.bypassHiddenAPIReflectionRestrictions();
+ String methodName = "getContext";
+ try {
+ @SuppressLint("PrivateApi") Class> clazz = Class.forName(ANDROID_OS_SELINUX_CLASS);
+ Method method = ReflectionUtils.getDeclaredMethod(clazz, methodName);
+ if (method == null) {
+ Logger.logError(LOG_TAG, "Failed to get " + methodName + "() method of " + ANDROID_OS_SELINUX_CLASS + " class");
+ return null;
+ }
+
+ return (String) ReflectionUtils.invokeMethod(method, null).value;
+ } catch (Exception e) {
+ Logger.logStackTraceWithMessage(LOG_TAG, "Failed to call " + methodName + "() method of " + ANDROID_OS_SELINUX_CLASS + " class", e);
+ return null;
+ }
+ }
+
+ /**
+ * Get the security context of a given process id.
+ *
+ * @param pid The pid of process.
+ * @return Returns a {@link String} representing the security context of the given pid.
+ * This will be {@code null} if an exception is raised.
+ */
+ @Nullable
+ public static String getPidContext(int pid) {
+ ReflectionUtils.bypassHiddenAPIReflectionRestrictions();
+ String methodName = "getPidContext";
+ try {
+ @SuppressLint("PrivateApi") Class> clazz = Class.forName(ANDROID_OS_SELINUX_CLASS);
+ Method method = ReflectionUtils.getDeclaredMethod(clazz, methodName, int.class);
+ if (method == null) {
+ Logger.logError(LOG_TAG, "Failed to get " + methodName + "() method of " + ANDROID_OS_SELINUX_CLASS + " class");
+ return null;
+ }
+
+ return (String) ReflectionUtils.invokeMethod(method, null, pid).value;
+ } catch (Exception e) {
+ Logger.logStackTraceWithMessage(LOG_TAG, "Failed to call " + methodName + "() method of " + ANDROID_OS_SELINUX_CLASS + " class", e);
+ return null;
+ }
+ }
+
+ /**
+ * Get the security context of a file object.
+ *
+ * @param path The pathname of the file object.
+ * @return Returns a {@link String} representing the security context of the file.
+ * This will be {@code null} if an exception is raised.
+ */
+ @Nullable
+ public static String getFileContext(@NonNull String path) {
+ ReflectionUtils.bypassHiddenAPIReflectionRestrictions();
+ String methodName = "getFileContext";
+ try {
+ @SuppressLint("PrivateApi") Class> clazz = Class.forName(ANDROID_OS_SELINUX_CLASS);
+ Method method = ReflectionUtils.getDeclaredMethod(clazz, methodName, String.class);
+ if (method == null) {
+ Logger.logError(LOG_TAG, "Failed to get " + methodName + "() method of " + ANDROID_OS_SELINUX_CLASS + " class");
+ return null;
+ }
+
+ return (String) ReflectionUtils.invokeMethod(method, null, path).value;
+ } catch (Exception e) {
+ Logger.logStackTraceWithMessage(LOG_TAG, "Failed to call " + methodName + "() method of " + ANDROID_OS_SELINUX_CLASS + " class", e);
+ return null;
+ }
+ }
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/android/SettingsProviderUtils.java b/termux/termux-shared/src/main/java/com/termux/shared/android/SettingsProviderUtils.java
new file mode 100644
index 000000000..4b7cde597
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/android/SettingsProviderUtils.java
@@ -0,0 +1,99 @@
+package com.termux.shared.android;
+
+import android.content.Context;
+import android.provider.Settings;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.termux.shared.logger.Logger;
+
+public class SettingsProviderUtils {
+
+ private static final String LOG_TAG = "SettingsProviderUtils";
+
+ /** The namespaces for {@link Settings} provider. */
+ public enum SettingNamespace {
+ /** The {@link Settings.Global} namespace */
+ GLOBAL,
+
+ /** The {@link Settings.Secure} namespace */
+ SECURE,
+
+ /** The {@link Settings.System} namespace */
+ SYSTEM
+ }
+
+ /** The type of values for {@link Settings} provider. */
+ public enum SettingType {
+ FLOAT,
+ INT,
+ LONG,
+ STRING,
+ URI
+ }
+
+ /**
+ * Get settings key value from {@link SettingNamespace} namespace and of {@link SettingType} type.
+ *
+ * @param context The {@link Context} for operations.
+ * @param namespace The {@link SettingNamespace} in which to get key value from.
+ * @param type The {@link SettingType} for the key.
+ * @param key The {@link String} name for key.
+ * @param def The {@link Object} default value for key.
+ * @return Returns the key value. This will be {@code null} if an exception is raised.
+ */
+ @Nullable
+ public static Object getSettingsValue(@NonNull Context context, @NonNull SettingNamespace namespace,
+ @NonNull SettingType type, @NonNull String key, @Nullable Object def) {
+ try {
+ switch (namespace) {
+ case GLOBAL:
+ switch (type) {
+ case FLOAT:
+ return Settings.Global.getFloat(context.getContentResolver(), key);
+ case INT:
+ return Settings.Global.getInt(context.getContentResolver(), key);
+ case LONG:
+ return Settings.Global.getLong(context.getContentResolver(), key);
+ case STRING:
+ return Settings.Global.getString(context.getContentResolver(), key);
+ case URI:
+ return Settings.Global.getUriFor(key);
+ }
+ case SECURE:
+ switch (type) {
+ case FLOAT:
+ return Settings.Secure.getFloat(context.getContentResolver(), key);
+ case INT:
+ return Settings.Secure.getInt(context.getContentResolver(), key);
+ case LONG:
+ return Settings.Secure.getLong(context.getContentResolver(), key);
+ case STRING:
+ return Settings.Secure.getString(context.getContentResolver(), key);
+ case URI:
+ return Settings.Secure.getUriFor(key);
+ }
+ case SYSTEM:
+ switch (type) {
+ case FLOAT:
+ return Settings.System.getFloat(context.getContentResolver(), key);
+ case INT:
+ return Settings.System.getInt(context.getContentResolver(), key);
+ case LONG:
+ return Settings.System.getLong(context.getContentResolver(), key);
+ case STRING:
+ return Settings.System.getString(context.getContentResolver(), key);
+ case URI:
+ return Settings.System.getUriFor(key);
+ }
+ }
+ } catch (Settings.SettingNotFoundException e) {
+ // Ignore
+ } catch (Exception e) {
+ Logger.logError(LOG_TAG, "Failed to get \"" + key + "\" key value from settings \"" + namespace.name() + "\" namespace of type \"" + type.name() + "\"");
+ }
+ return def;
+ }
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/android/UserUtils.java b/termux/termux-shared/src/main/java/com/termux/shared/android/UserUtils.java
new file mode 100644
index 000000000..f819de3f4
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/android/UserUtils.java
@@ -0,0 +1,143 @@
+package com.termux.shared.android;
+
+import android.content.Context;
+import android.content.pm.PackageManager;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.termux.shared.logger.Logger;
+import com.termux.shared.reflection.ReflectionUtils;
+
+import java.lang.reflect.Method;
+
+public class UserUtils {
+
+ public static final String LOG_TAG = "UserUtils";
+
+ /**
+ * Get the user name for user id with a call to {@link #getNameForUidFromPackageManager(Context, int)}
+ * and if that fails, then a call to {@link #getNameForUidFromLibcore(int)}.
+ *
+ * @param context The {@link Context} for operations.
+ * @param uid The user id.
+ * @return Returns the user name if found, otherwise {@code null}.
+ */
+ @Nullable
+ public static String getNameForUid(@NonNull Context context, int uid) {
+ String name = getNameForUidFromPackageManager(context, uid);
+ if (name == null)
+ name = getNameForUidFromLibcore(uid);
+ return name;
+ }
+
+ /**
+ * Get the user name for user id with a call to {@link PackageManager#getNameForUid(int)}.
+ *
+ * This will not return user names for non app user id like for root user 0, use {@link #getNameForUidFromLibcore(int)}
+ * to get those.
+ *
+ * https://cs.android.com/android/platform/superproject/+/android-12.0.0_r32:frameworks/base/core/java/android/content/pm/PackageManager.java;l=5556
+ * https://cs.android.com/android/platform/superproject/+/android-12.0.0_r32:frameworks/base/core/java/android/app/ApplicationPackageManager.java;l=1028
+ * https://cs.android.com/android/platform/superproject/+/android-12.0.0_r32:frameworks/base/services/core/java/com/android/server/pm/PackageManagerService.java;l=10293
+ *
+ * @param context The {@link Context} for operations.
+ * @param uid The user id.
+ * @return Returns the user name if found, otherwise {@code null}.
+ */
+ @Nullable
+ public static String getNameForUidFromPackageManager(@NonNull Context context, int uid) {
+ if (uid < 0) return null;
+
+ try {
+ String name = context.getPackageManager().getNameForUid(uid);
+ if (name != null && name.endsWith(":" + uid))
+ name = name.replaceAll(":" + uid + "$", ""); // Remove ":" suffix
+ return name;
+ } catch (Exception e) {
+ Logger.logStackTraceWithMessage(LOG_TAG, "Failed to get name for uid \"" + uid + "\" from package manager", e);
+ return null;
+ }
+ }
+
+ /**
+ * Get the user name for user id with a call to `Libcore.os.getpwuid()`.
+ *
+ * This will return user names for non app user id like for root user 0 as well, but this call
+ * is expensive due to usage of reflection, and requires hidden API bypass, check
+ * {@link ReflectionUtils#bypassHiddenAPIReflectionRestrictions()} for details.
+ *
+ * `BlockGuardOs` implements the `Os` interface and its instance is stored in `Libcore` class static `os` field.
+ * The `getpwuid` method is implemented by `ForwardingOs`, which is the super class of `BlockGuardOs`.
+ * The `getpwuid` method returns `StructPasswd` object whose `pw_name` contains the user name for id.
+ *
+ * https://stackoverflow.com/a/28057167/14686958
+ * https://cs.android.com/android/platform/superproject/+/android-12.0.0_r32:libcore/luni/src/main/java/libcore/io/Libcore.java;l=39
+ * https://cs.android.com/android/platform/superproject/+/android-12.0.0_r32:libcore/luni/src/main/java/libcore/io/Os.java;l=279
+ * https://cs.android.com/android/platform/superproject/+/android-12.0.0_r32:libcore/luni/src/main/java/libcore/io/BlockGuardOs.java
+ * https://cs.android.com/android/platform/superproject/+/android-12.0.0_r32:libcore/luni/src/main/java/libcore/io/ForwardingOs.java;l=340
+ * https://cs.android.com/android/platform/superproject/+/android-12.0.0_r32:libcore/luni/src/main/java/android/system/StructPasswd.java
+ * https://cs.android.com/android/platform/superproject/+/android-12.0.0_r32:bionic/libc/bionic/grp_pwd.cpp;l=553
+ * https://cs.android.com/android/platform/superproject/+/android-12.0.0_r32:system/core/libcutils/include/private/android_filesystem_config.h;l=43
+ *
+ * @param uid The user id.
+ * @return Returns the user name if found, otherwise {@code null}.
+ */
+ @Nullable
+ public static String getNameForUidFromLibcore(int uid) {
+ if (uid < 0) return null;
+
+ ReflectionUtils.bypassHiddenAPIReflectionRestrictions();
+ try {
+ String libcoreClassName = "libcore.io.Libcore";
+ Class> clazz = Class.forName(libcoreClassName);
+ Object os; // libcore.io.BlockGuardOs
+ try {
+ os = ReflectionUtils.invokeField(Class.forName(libcoreClassName), "os", null).value;
+ } catch (Exception e) {
+ // ClassCastException may be thrown
+ Logger.logStackTraceWithMessage(LOG_TAG, "Failed to get \"os\" field value for " + libcoreClassName + " class", e);
+ return null;
+ }
+
+ if (os == null) {
+ Logger.logError(LOG_TAG, "Failed to get BlockGuardOs class obj from Libcore");
+ return null;
+ }
+
+ clazz = os.getClass().getSuperclass(); // libcore.io.ForwardingOs
+ if (clazz == null) {
+ Logger.logError(LOG_TAG, "Failed to find super class ForwardingOs from object of class " + os.getClass().getName());
+ return null;
+ }
+
+ Object structPasswd; // android.system.StructPasswd
+ try {
+ Method getpwuidMethod = ReflectionUtils.getDeclaredMethod(clazz, "getpwuid", int.class);
+ if (getpwuidMethod == null) return null;
+ structPasswd = ReflectionUtils.invokeMethod(getpwuidMethod, os, uid).value;
+ } catch (Exception e) {
+ Logger.logStackTraceWithMessage(LOG_TAG, "Failed to invoke getpwuid() method of " + clazz.getName() + " class", e);
+ return null;
+ }
+
+ if (structPasswd == null) {
+ Logger.logError(LOG_TAG, "Failed to get StructPasswd obj from call to ForwardingOs.getpwuid()");
+ return null;
+ }
+
+ try {
+ clazz = structPasswd.getClass();
+ return (String) ReflectionUtils.invokeField(clazz, "pw_name", structPasswd).value;
+ } catch (Exception e) {
+ // ClassCastException may be thrown
+ Logger.logStackTraceWithMessage(LOG_TAG, "Failed to get \"pw_name\" field value for " + clazz.getName() + " class", e);
+ return null;
+ }
+ } catch (Exception e) {
+ Logger.logStackTraceWithMessage(LOG_TAG, "Failed to get name for uid \"" + uid + "\" from Libcore", e);
+ return null;
+ }
+ }
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/android/resource/ResourceUtils.java b/termux/termux-shared/src/main/java/com/termux/shared/android/resource/ResourceUtils.java
new file mode 100644
index 000000000..67cfb662d
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/android/resource/ResourceUtils.java
@@ -0,0 +1,136 @@
+package com.termux.shared.android.resource;
+
+import android.content.Context;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.termux.shared.data.DataUtils;
+import com.termux.shared.logger.Logger;
+
+public class ResourceUtils {
+
+ public static final String RES_TYPE_COLOR = "color";
+ public static final String RES_TYPE_DRAWABLE = "drawable";
+ public static final String RES_TYPE_ID = "id";
+ public static final String RES_TYPE_LAYOUT = "layout";
+ public static final String RES_TYPE_STRING = "string";
+ public static final String RES_TYPE_STYLE = "style";
+
+
+ private static final String LOG_TAG = "ResourceUtils";
+
+
+ /** Wrapper for {@link #getResourceId(Context, String, String, String, boolean)} without {@code defPackage}. */
+ @Nullable
+ public static Integer getResourceId(@NonNull Context context, String name,
+ @Nullable String defType,
+ boolean logErrorMessage) {
+ return getResourceId(context, name, defType, null, logErrorMessage);
+ }
+
+ /**
+ * Get resource identifier for the given resource name. A fully qualified resource name is of
+ * the form "package:type/entry". The first two components (package and type) are optional if
+ * defType and defPackage, respectively, are specified here.
+ *
+ * @param context The {@link Context} for operations.
+ * @param name The name of the desired resource.
+ * @param defType Optional default resource type to find, if "type/" is not included in the name.
+ * Can be null to require an explicit type.
+ * @param defPackage Optional default package to find, if "package:" is not included in the name.
+ * Can be null to require an explicit package.
+ * @param logErrorMessage If an error message should be logged if failed to find resource.
+ * @return Returns the resource identifier if found. Otherwise {@code null} if an exception was
+ * raised or resource was not found.
+ */
+ @Nullable
+ public static Integer getResourceId(@NonNull Context context, String name,
+ @Nullable String defType, @Nullable String defPackage,
+ boolean logErrorMessage) {
+ if (DataUtils.isNullOrEmpty(name)) return null;
+
+ Integer resourceId = null;
+ try {
+ resourceId = context.getResources().getIdentifier(name, defType, defPackage);
+ if (resourceId == 0) resourceId = null;
+ } catch (Exception e) {
+ // Ignore
+ }
+
+ if (resourceId == null && logErrorMessage) {
+ Logger.logError(LOG_TAG, "Resource id not found. name: \"" + name + "\", type: \"" + defType+ "\", package: \"" + defPackage + "\", component \"" + context.getClass().getName() + "\"");
+ }
+
+ return resourceId;
+ }
+
+
+
+ /**
+ * Get resource identifier for the given {@link #RES_TYPE_COLOR} resource name.
+ *
+ * This is a wrapper for {@link #getResourceId(Context, String, String, String, boolean)}.
+ */
+ @Nullable
+ public static Integer getColorResourceId(@NonNull Context context, String name,
+ @Nullable String defPackage, boolean logErrorMessage) {
+ return getResourceId(context, name, RES_TYPE_COLOR, defPackage, logErrorMessage);
+ }
+
+ /**
+ * Get resource identifier for the given {@link #RES_TYPE_DRAWABLE} resource name.
+ *
+ * This is a wrapper for {@link #getResourceId(Context, String, String, String, boolean)}.
+ */
+ @Nullable
+ public static Integer getDrawableResourceId(@NonNull Context context, String name,
+ @Nullable String defPackage, boolean logErrorMessage) {
+ return getResourceId(context, name, RES_TYPE_DRAWABLE, defPackage, logErrorMessage);
+ }
+
+ /**
+ * Get resource identifier for the given {@link #RES_TYPE_ID} resource name.
+ *
+ * This is a wrapper for {@link #getResourceId(Context, String, String, String, boolean)}.
+ */
+ @Nullable
+ public static Integer getIdResourceId(@NonNull Context context, String name,
+ @Nullable String defPackage, boolean logErrorMessage) {
+ return getResourceId(context, name, RES_TYPE_ID, defPackage, logErrorMessage);
+ }
+
+ /**
+ * Get resource identifier for the given {@link #RES_TYPE_LAYOUT} resource name.
+ *
+ * This is a wrapper for {@link #getResourceId(Context, String, String, String, boolean)}.
+ */
+ @Nullable
+ public static Integer getLayoutResourceId(@NonNull Context context, String name,
+ @Nullable String defPackage, boolean logErrorMessage) {
+ return getResourceId(context, name, RES_TYPE_LAYOUT, defPackage, logErrorMessage);
+ }
+
+ /**
+ * Get resource identifier for the given {@link #RES_TYPE_STRING} resource name.
+ *
+ * This is a wrapper for {@link #getResourceId(Context, String, String, String, boolean)}.
+ */
+ @Nullable
+ public static Integer getStringResourceId(@NonNull Context context, String name,
+ @Nullable String defPackage, boolean logErrorMessage) {
+ return getResourceId(context, name, RES_TYPE_STRING, defPackage, logErrorMessage);
+ }
+
+ /**
+ * Get resource identifier for the given {@link #RES_TYPE_STYLE} resource name.
+ *
+ * This is a wrapper for {@link #getResourceId(Context, String, String, String, boolean)}.
+ */
+ @Nullable
+ public static Integer getStyleResourceId(@NonNull Context context, String name,
+ @Nullable String defPackage, boolean logErrorMessage) {
+ return getResourceId(context, name, RES_TYPE_STYLE, defPackage, logErrorMessage);
+ }
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/crash/CrashHandler.java b/termux/termux-shared/src/main/java/com/termux/shared/crash/CrashHandler.java
new file mode 100644
index 000000000..f67a76128
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/crash/CrashHandler.java
@@ -0,0 +1,158 @@
+package com.termux.shared.crash;
+
+import android.content.Context;
+
+import androidx.annotation.NonNull;
+
+import com.termux.shared.file.FileUtils;
+import com.termux.shared.logger.Logger;
+import com.termux.shared.markdown.MarkdownUtils;
+import com.termux.shared.errors.Error;
+import com.termux.shared.android.AndroidUtils;
+
+import java.nio.charset.Charset;
+
+/**
+ * Catches uncaught exceptions and logs them.
+ */
+public class CrashHandler implements Thread.UncaughtExceptionHandler {
+
+ private final Context mContext;
+ private final CrashHandlerClient mCrashHandlerClient;
+ private final Thread.UncaughtExceptionHandler mDefaultUEH;
+ private final boolean mIsDefaultHandler;
+
+ private static final String LOG_TAG = "CrashUtils";
+
+ private CrashHandler(@NonNull final Context context, @NonNull final CrashHandlerClient crashHandlerClient,
+ boolean isDefaultHandler) {
+ mContext = context;
+ mCrashHandlerClient = crashHandlerClient;
+ mDefaultUEH = Thread.getDefaultUncaughtExceptionHandler();
+ mIsDefaultHandler = isDefaultHandler;
+ }
+
+ public void uncaughtException(@NonNull Thread thread, @NonNull Throwable throwable) {
+ Logger.logInfo(LOG_TAG, "uncaughtException() for " + thread + ": " + throwable.getMessage());
+ logCrash(thread, throwable);
+
+ // Don't stop the app if not on the main thread
+ if (mIsDefaultHandler)
+ mDefaultUEH.uncaughtException(thread, throwable);
+ }
+
+ /**
+ * Set default uncaught crash handler for the app to {@link CrashHandler}.
+ */
+ public static void setDefaultCrashHandler(@NonNull final Context context, @NonNull final CrashHandlerClient crashHandlerClient) {
+ if (!(Thread.getDefaultUncaughtExceptionHandler() instanceof CrashHandler)) {
+ Thread.setDefaultUncaughtExceptionHandler(new CrashHandler(context, crashHandlerClient, true));
+ }
+ }
+
+ /**
+ * Set uncaught crash handler of current non-main thread to {@link CrashHandler}.
+ */
+ public static void setCrashHandler(@NonNull final Context context, @NonNull final CrashHandlerClient crashHandlerClient) {
+ Thread.currentThread().setUncaughtExceptionHandler(new CrashHandler(context, crashHandlerClient, false));
+ }
+
+ /**
+ * Get {@link CrashHandler} instance that can be set as uncaught crash handler of a non-main thread.
+ */
+ public static CrashHandler getCrashHandler(@NonNull final Context context, @NonNull final CrashHandlerClient crashHandlerClient) {
+ return new CrashHandler(context, crashHandlerClient, false);
+ }
+
+ /**
+ * Log a crash in the crash log file at path returned by {@link CrashHandlerClient#getCrashLogFilePath(Context)}.
+ *
+ * @param context The {@link Context} for operations.
+ * @param crashHandlerClient The {@link CrashHandlerClient} implementation.
+ * @param thread The {@link Thread} in which the crash happened.
+ * @param throwable The {@link Throwable} thrown for the crash.
+ */
+ public static void logCrash(@NonNull Context context,
+ @NonNull CrashHandlerClient crashHandlerClient,
+ @NonNull Thread thread, @NonNull Throwable throwable) {
+ Logger.logInfo(LOG_TAG, "logCrash() for " + thread + ": " + throwable.getMessage());
+ new CrashHandler(context, crashHandlerClient, false).logCrash(thread, throwable);
+ }
+
+ public void logCrash(@NonNull Thread thread, @NonNull Throwable throwable) {
+ if (!mCrashHandlerClient.onPreLogCrash(mContext, thread, throwable)) {
+ logCrashToFile(mContext, mCrashHandlerClient, thread, throwable);
+ mCrashHandlerClient.onPostLogCrash(mContext, thread, throwable);
+ }
+ }
+
+ public void logCrashToFile(@NonNull Context context,
+ @NonNull CrashHandlerClient crashHandlerClient,
+ @NonNull Thread thread, @NonNull Throwable throwable) {
+ StringBuilder reportString = new StringBuilder();
+
+ reportString.append("## Crash Details\n");
+ reportString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("Crash Thread", thread.toString(), "-"));
+ reportString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("Crash Timestamp", AndroidUtils.getCurrentMilliSecondUTCTimeStamp(), "-"));
+ reportString.append("\n\n").append(MarkdownUtils.getMultiLineMarkdownStringEntry("Crash Message", throwable.getMessage(), "-"));
+ reportString.append("\n\n").append(Logger.getStackTracesMarkdownString("Stacktrace", Logger.getStackTracesStringArray(throwable)));
+
+ String appInfoMarkdownString = crashHandlerClient.getAppInfoMarkdownString(context);
+ if (appInfoMarkdownString != null && !appInfoMarkdownString.isEmpty())
+ reportString.append("\n\n").append(appInfoMarkdownString);
+
+ reportString.append("\n\n").append(AndroidUtils.getDeviceInfoMarkdownString(context));
+
+ // Log report string to logcat
+ Logger.logError(reportString.toString());
+
+ // Write report string to crash log file
+ Error error = FileUtils.writeTextToFile("crash log", crashHandlerClient.getCrashLogFilePath(context),
+ Charset.defaultCharset(), reportString.toString(), false);
+ if (error != null) {
+ Logger.logErrorExtended(LOG_TAG, error.toString());
+ }
+ }
+
+ public interface CrashHandlerClient {
+
+ /**
+ * Called before {@link #logCrashToFile(Context, CrashHandlerClient, Thread, Throwable)} is called.
+ *
+ * @param context The {@link Context} passed to {@link CrashHandler#CrashHandler(Context, CrashHandlerClient, boolean)}.
+ * @param thread The {@link Thread} in which the crash happened.
+ * @param throwable The {@link Throwable} thrown for the crash.
+ * @return Should return {@code true} if crash has been handled and should not be logged,
+ * otherwise {@code false}.
+ */
+ boolean onPreLogCrash(Context context, Thread thread, Throwable throwable);
+
+ /**
+ * Called after {@link #logCrashToFile(Context, CrashHandlerClient, Thread, Throwable)} is called.
+ *
+ * @param context The {@link Context} passed to {@link CrashHandler#CrashHandler(Context, CrashHandlerClient, boolean)}.
+ * @param thread The {@link Thread} in which the crash happened.
+ * @param throwable The {@link Throwable} thrown for the crash.
+ */
+ void onPostLogCrash(Context context, Thread thread, Throwable throwable);
+
+ /**
+ * Get crash log file path.
+ *
+ * @param context The {@link Context} passed to {@link CrashHandler#CrashHandler(Context, CrashHandlerClient, boolean)}.
+ * @return Should return the crash log file path.
+ */
+ @NonNull
+ String getCrashLogFilePath(Context context);
+
+ /**
+ * Get app info markdown string to add to crash log.
+ *
+ * @param context The {@link Context} passed to {@link CrashHandler#CrashHandler(Context, CrashHandlerClient, boolean)}.
+ * @return Should return app info markdown string.
+ */
+ String getAppInfoMarkdownString(Context context);
+
+ }
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/data/DataUtils.java b/termux/termux-shared/src/main/java/com/termux/shared/data/DataUtils.java
new file mode 100644
index 000000000..f95f2b52f
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/data/DataUtils.java
@@ -0,0 +1,258 @@
+package com.termux.shared.data;
+
+import android.os.Bundle;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.google.common.base.Strings;
+
+import java.io.ByteArrayOutputStream;
+import java.io.ObjectOutputStream;
+import java.io.Serializable;
+import java.util.Collections;
+
+public class DataUtils {
+
+ /** Max safe limit of data size to prevent TransactionTooLargeException when transferring data
+ * inside or to other apps via transactions. */
+ public static final int TRANSACTION_SIZE_LIMIT_IN_BYTES = 100 * 1024; // 100KB
+
+ private static final char[] HEX_ARRAY = "0123456789ABCDEF".toCharArray();
+
+ public static String getTruncatedCommandOutput(String text, int maxLength, boolean fromEnd, boolean onNewline, boolean addPrefix) {
+ if (text == null) return null;
+
+ String prefix = "(truncated) ";
+
+ if (addPrefix)
+ maxLength = maxLength - prefix.length();
+
+ if (maxLength < 0 || text.length() < maxLength) return text;
+
+ if (fromEnd) {
+ text = text.substring(0, maxLength);
+ } else {
+ int cutOffIndex = text.length() - maxLength;
+
+ if (onNewline) {
+ int nextNewlineIndex = text.indexOf('\n', cutOffIndex);
+ if (nextNewlineIndex != -1 && nextNewlineIndex != text.length() - 1) {
+ cutOffIndex = nextNewlineIndex + 1;
+ }
+ }
+ text = text.substring(cutOffIndex);
+ }
+
+ if (addPrefix)
+ text = prefix + text;
+
+ return text;
+ }
+
+ /**
+ * Replace a sub string in each item of a {@link String[]}.
+ *
+ * @param array The {@link String[]} to replace in.
+ * @param find The sub string to replace.
+ * @param replace The sub string to replace with.
+ */
+ public static void replaceSubStringsInStringArrayItems(String[] array, String find, String replace) {
+ if(array == null || array.length == 0) return;
+
+ for (int i = 0; i < array.length; i++) {
+ array[i] = array[i].replace(find, replace);
+ }
+ }
+
+ /**
+ * Get the {@code float} from a {@link String}.
+ *
+ * @param value The {@link String} value.
+ * @param def The default value if failed to read a valid value.
+ * @return Returns the {@code float} value after parsing the {@link String} value, otherwise
+ * returns default if failed to read a valid value, like in case of an exception.
+ */
+ public static float getFloatFromString(String value, float def) {
+ if (value == null) return def;
+
+ try {
+ return Float.parseFloat(value);
+ }
+ catch (Exception e) {
+ return def;
+ }
+ }
+
+ /**
+ * Get the {@code int} from a {@link String}.
+ *
+ * @param value The {@link String} value.
+ * @param def The default value if failed to read a valid value.
+ * @return Returns the {@code int} value after parsing the {@link String} value, otherwise
+ * returns default if failed to read a valid value, like in case of an exception.
+ */
+ public static int getIntFromString(String value, int def) {
+ if (value == null) return def;
+
+ try {
+ return Integer.parseInt(value);
+ }
+ catch (Exception e) {
+ return def;
+ }
+ }
+
+ /**
+ * Get the {@code String} from an {@link Integer}.
+ *
+ * @param value The {@link Integer} value.
+ * @param def The default {@link String} value.
+ * @return Returns {@code value} if it is not {@code null}, otherwise returns {@code def}.
+ */
+ public static String getStringFromInteger(Integer value, String def) {
+ return (value == null) ? def : String.valueOf((int) value);
+ }
+
+ /**
+ * Get the {@code hex string} from a {@link byte[]}.
+ *
+ * @param bytes The {@link byte[]} value.
+ * @return Returns the {@code hex string} value.
+ */
+ public static String bytesToHex(byte[] bytes) {
+ char[] hexChars = new char[bytes.length * 2];
+ for (int j = 0; j < bytes.length; j++) {
+ int v = bytes[j] & 0xFF;
+ hexChars[j * 2] = HEX_ARRAY[v >>> 4];
+ hexChars[j * 2 + 1] = HEX_ARRAY[v & 0x0F];
+ }
+ return new String(hexChars);
+ }
+
+ /**
+ * Get an {@code int} from {@link Bundle} that is stored as a {@link String}.
+ *
+ * @param bundle The {@link Bundle} to get the value from.
+ * @param key The key for the value.
+ * @param def The default value if failed to read a valid value.
+ * @return Returns the {@code int} value after parsing the {@link String} value stored in
+ * {@link Bundle}, otherwise returns default if failed to read a valid value,
+ * like in case of an exception.
+ */
+ public static int getIntStoredAsStringFromBundle(Bundle bundle, String key, int def) {
+ if (bundle == null) return def;
+ return getIntFromString(bundle.getString(key, Integer.toString(def)), def);
+ }
+
+
+
+ /**
+ * If value is not in the range [min, max], set it to either min or max.
+ */
+ public static int clamp(int value, int min, int max) {
+ return Math.min(Math.max(value, min), max);
+ }
+
+ /**
+ * If value is not in the range [min, max], set it to default.
+ */
+ public static float rangedOrDefault(float value, float def, float min, float max) {
+ if (value < min || value > max)
+ return def;
+ else
+ return value;
+ }
+
+
+
+ /**
+ * Add a space indent to a {@link String}. Each indent is 4 space characters long.
+ *
+ * @param string The {@link String} to add indent to.
+ * @param count The indent count.
+ * @return Returns the indented {@link String}.
+ */
+ public static String getSpaceIndentedString(String string, int count) {
+ if (string == null || string.isEmpty())
+ return string;
+ else
+ return getIndentedString(string, " ", count);
+ }
+
+ /**
+ * Add a tab indent to a {@link String}. Each indent is 1 tab character long.
+ *
+ * @param string The {@link String} to add indent to.
+ * @param count The indent count.
+ * @return Returns the indented {@link String}.
+ */
+ public static String getTabIndentedString(String string, int count) {
+ if (string == null || string.isEmpty())
+ return string;
+ else
+ return getIndentedString(string, "\t", count);
+ }
+
+ /**
+ * Add an indent to a {@link String}.
+ *
+ * @param string The {@link String} to add indent to.
+ * @param indent The indent characters.
+ * @param count The indent count.
+ * @return Returns the indented {@link String}.
+ */
+ public static String getIndentedString(String string, @NonNull String indent, int count) {
+ if (string == null || string.isEmpty())
+ return string;
+ else
+ return string.replaceAll("(?m)^", Strings.repeat(indent, Math.max(count, 1)));
+ }
+
+
+
+ /**
+ * Get the object itself if it is not {@code null}, otherwise default.
+ *
+ * @param object The {@link Object} to check.
+ * @param def The default {@link Object}.
+ * @return Returns {@code object} if it is not {@code null}, otherwise returns {@code def}.
+ */
+ public static T getDefaultIfNull(@Nullable T object, @Nullable T def) {
+ return (object == null) ? def : object;
+ }
+
+ /**
+ * Get the {@link String} itself if it is not {@code null} or empty, otherwise default.
+ *
+ * @param value The {@link String} to check.
+ * @param def The default {@link String}.
+ * @return Returns {@code value} if it is not {@code null} or empty, otherwise returns {@code def}.
+ */
+ public static String getDefaultIfUnset(@Nullable String value, String def) {
+ return (value == null || value.isEmpty()) ? def : value;
+ }
+
+ /** Check if a string is null or empty. */
+ public static boolean isNullOrEmpty(String string) {
+ return string == null || string.isEmpty();
+ }
+
+
+
+ /** Get size of a serializable object. */
+ public static long getSerializedSize(Serializable object) {
+ if (object == null) return 0;
+ try {
+ ByteArrayOutputStream byteOutputStream = new ByteArrayOutputStream();
+ ObjectOutputStream objectOutputStream = new ObjectOutputStream(byteOutputStream);
+ objectOutputStream.writeObject(object);
+ objectOutputStream.flush();
+ objectOutputStream.close();
+ return byteOutputStream.toByteArray().length;
+ } catch (Exception e) {
+ return -1;
+ }
+ }
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/data/IntentUtils.java b/termux/termux-shared/src/main/java/com/termux/shared/data/IntentUtils.java
new file mode 100644
index 000000000..0c692fc35
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/data/IntentUtils.java
@@ -0,0 +1,166 @@
+package com.termux.shared.data;
+
+import android.content.Intent;
+import android.os.Bundle;
+import android.os.Parcelable;
+
+import androidx.annotation.NonNull;
+
+import java.util.Arrays;
+
+public class IntentUtils {
+
+ private static final String LOG_TAG = "IntentUtils";
+
+
+ /**
+ * Get a {@link String} extra from an {@link Intent} if its not {@code null} or empty.
+ *
+ * @param intent The {@link Intent} to get the extra from.
+ * @param key The {@link String} key name.
+ * @param def The default value if extra is not set.
+ * @param throwExceptionIfNotSet If set to {@code true}, then an exception will be thrown if extra
+ * is not set.
+ * @return Returns the {@link String} extra if set, otherwise {@code null}.
+ */
+ public static String getStringExtraIfSet(@NonNull Intent intent, String key, String def, boolean throwExceptionIfNotSet) throws Exception {
+ String value = getStringExtraIfSet(intent, key, def);
+ if (value == null && throwExceptionIfNotSet)
+ throw new Exception("The \"" + key + "\" key string value is null or empty");
+ return value;
+ }
+
+ /**
+ * Get a {@link String} extra from an {@link Intent} if its not {@code null} or empty.
+ *
+ * @param intent The {@link Intent} to get the extra from.
+ * @param key The {@link String} key name.
+ * @param def The default value if extra is not set.
+ * @return Returns the {@link String} extra if set, otherwise {@code null}.
+ */
+ public static String getStringExtraIfSet(@NonNull Intent intent, String key, String def) {
+ String value = intent.getStringExtra(key);
+ if (value == null || value.isEmpty()) {
+ if (def != null && !def.isEmpty())
+ return def;
+ else
+ return null;
+ }
+ return value;
+ }
+
+ /**
+ * Get an {@link Integer} from an {@link Intent} stored as a {@link String} extra if its not
+ * {@code null} or empty.
+ *
+ * @param intent The {@link Intent} to get the extra from.
+ * @param key The {@link String} key name.
+ * @param def The default value if extra is not set.
+ * @return Returns the {@link Integer} extra if set, otherwise {@code null}.
+ */
+ public static Integer getIntegerExtraIfSet(@NonNull Intent intent, String key, Integer def) {
+ try {
+ String value = intent.getStringExtra(key);
+ if (value == null || value.isEmpty()) {
+ return def;
+ }
+
+ return Integer.parseInt(value);
+ }
+ catch (Exception e) {
+ return def;
+ }
+ }
+
+
+
+ /**
+ * Get a {@link String[]} extra from an {@link Intent} if its not {@code null} or empty.
+ *
+ * @param intent The {@link Intent} to get the extra from.
+ * @param key The {@link String} key name.
+ * @param def The default value if extra is not set.
+ * @param throwExceptionIfNotSet If set to {@code true}, then an exception will be thrown if extra
+ * is not set.
+ * @return Returns the {@link String[]} extra if set, otherwise {@code null}.
+ */
+ public static String[] getStringArrayExtraIfSet(@NonNull Intent intent, String key, String[] def, boolean throwExceptionIfNotSet) throws Exception {
+ String[] value = getStringArrayExtraIfSet(intent, key, def);
+ if (value == null && throwExceptionIfNotSet)
+ throw new Exception("The \"" + key + "\" key string array is null or empty");
+ return value;
+ }
+
+ /**
+ * Get a {@link String[]} extra from an {@link Intent} if its not {@code null} or empty.
+ *
+ * @param intent The {@link Intent} to get the extra from.
+ * @param key The {@link String} key name.
+ * @param def The default value if extra is not set.
+ * @return Returns the {@link String[]} extra if set, otherwise {@code null}.
+ */
+ public static String[] getStringArrayExtraIfSet(Intent intent, String key, String[] def) {
+ String[] value = intent.getStringArrayExtra(key);
+ if (value == null || value.length == 0) {
+ if (def != null && def.length != 0)
+ return def;
+ else
+ return null;
+ }
+ return value;
+ }
+
+ public static String getIntentString(Intent intent) {
+ if (intent == null) return null;
+
+ return intent.toString() + "\n" + getBundleString(intent.getExtras());
+ }
+
+ public static String getBundleString(Bundle bundle) {
+ if (bundle == null || bundle.size() == 0) return "Bundle[]";
+
+ StringBuilder bundleString = new StringBuilder("Bundle[\n");
+ boolean first = true;
+ for (String key : bundle.keySet()) {
+ if (!first)
+ bundleString.append("\n");
+
+ bundleString.append(key).append(": `");
+
+ Object value = bundle.get(key);
+ if (value instanceof int[]) {
+ bundleString.append(Arrays.toString((int[]) value));
+ } else if (value instanceof byte[]) {
+ bundleString.append(Arrays.toString((byte[]) value));
+ } else if (value instanceof boolean[]) {
+ bundleString.append(Arrays.toString((boolean[]) value));
+ } else if (value instanceof short[]) {
+ bundleString.append(Arrays.toString((short[]) value));
+ } else if (value instanceof long[]) {
+ bundleString.append(Arrays.toString((long[]) value));
+ } else if (value instanceof float[]) {
+ bundleString.append(Arrays.toString((float[]) value));
+ } else if (value instanceof double[]) {
+ bundleString.append(Arrays.toString((double[]) value));
+ } else if (value instanceof String[]) {
+ bundleString.append(Arrays.toString((String[]) value));
+ } else if (value instanceof CharSequence[]) {
+ bundleString.append(Arrays.toString((CharSequence[]) value));
+ } else if (value instanceof Parcelable[]) {
+ bundleString.append(Arrays.toString((Parcelable[]) value));
+ } else if (value instanceof Bundle) {
+ bundleString.append(getBundleString((Bundle) value));
+ } else {
+ bundleString.append(value);
+ }
+
+ bundleString.append("`");
+
+ first = false;
+ }
+
+ bundleString.append("\n]");
+ return bundleString.toString();
+ }
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/errors/Errno.java b/termux/termux-shared/src/main/java/com/termux/shared/errors/Errno.java
new file mode 100644
index 000000000..5dfa745c2
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/errors/Errno.java
@@ -0,0 +1,118 @@
+package com.termux.shared.errors;
+
+import android.app.Activity;
+
+import androidx.annotation.NonNull;
+
+import com.termux.shared.logger.Logger;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+
+/** The {@link Class} that defines error messages and codes. */
+public class Errno {
+
+ private static final HashMap map = new HashMap<>();
+
+ public static final String TYPE = "Error";
+
+
+ public static final Errno ERRNO_SUCCESS = new Errno(TYPE, Activity.RESULT_OK, "Success");
+ public static final Errno ERRNO_CANCELLED = new Errno(TYPE, Activity.RESULT_CANCELED, "Cancelled");
+ public static final Errno ERRNO_MINOR_FAILURES = new Errno(TYPE, Activity.RESULT_FIRST_USER, "Minor failure");
+ public static final Errno ERRNO_FAILED = new Errno(TYPE, Activity.RESULT_FIRST_USER + 1, "Failed");
+
+ /** The errno type. */
+ protected final String type;
+ /** The errno code. */
+ protected final int code;
+ /** The errno message. */
+ protected final String message;
+
+ private static final String LOG_TAG = "Errno";
+
+
+ public Errno(@NonNull final String type, final int code, @NonNull final String message) {
+ this.type = type;
+ this.code = code;
+ this.message = message;
+ map.put(type + ":" + code, this);
+ }
+
+ @NonNull
+ @Override
+ public String toString() {
+ return "type=" + type + ", code=" + code + ", message=\"" + message + "\"";
+ }
+
+ @NonNull
+ public String getType() {
+ return type;
+ }
+
+ public int getCode() {
+ return code;
+ }
+
+ @NonNull
+ public String getMessage() {
+ return message;
+ }
+
+
+
+ /**
+ * Get the {@link Errno} of a specific type and code.
+ *
+ * @param type The unique type of the {@link Errno}.
+ * @param code The unique code of the {@link Errno}.
+ */
+ public static Errno valueOf(String type, Integer code) {
+ if (type == null || type.isEmpty() || code == null) return null;
+ return map.get(type + ":" + code);
+ }
+
+
+
+ public Error getError() {
+ return new Error(getType(), getCode(), getMessage());
+ }
+
+ public Error getError(Object... args) {
+ try {
+ return new Error(getType(), getCode(), String.format(getMessage(), args));
+ } catch (Exception e) {
+ Logger.logWarn(LOG_TAG, "Exception raised while calling String.format() for error message of errno " + this + " with args" + Arrays.toString(args) + "\n" + e.getMessage());
+ // Return unformatted message as a backup
+ return new Error(getType(), getCode(), getMessage() + ": " + Arrays.toString(args));
+ }
+ }
+
+ public Error getError(Throwable throwable, Object... args) {
+ if (throwable == null)
+ return getError(args);
+ else
+ return getError(Collections.singletonList(throwable), args);
+ }
+
+ public Error getError(List throwablesList, Object... args) {
+ try {
+ if (throwablesList == null)
+ return new Error(getType(), getCode(), String.format(getMessage(), args));
+ else
+ return new Error(getType(), getCode(), String.format(getMessage(), args), throwablesList);
+ } catch (Exception e) {
+ Logger.logWarn(LOG_TAG, "Exception raised while calling String.format() for error message of errno " + this + " with args" + Arrays.toString(args) + "\n" + e.getMessage());
+ // Return unformatted message as a backup
+ return new Error(getType(), getCode(), getMessage() + ": " + Arrays.toString(args), throwablesList);
+ }
+ }
+
+ public boolean equalsErrorTypeAndCode(Error error) {
+ if (error == null) return false;
+ return type.equals(error.getType()) && code == error.getCode();
+ }
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/errors/Error.java b/termux/termux-shared/src/main/java/com/termux/shared/errors/Error.java
new file mode 100644
index 000000000..be00e34ff
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/errors/Error.java
@@ -0,0 +1,298 @@
+package com.termux.shared.errors;
+
+import android.content.Context;
+
+import androidx.annotation.NonNull;
+
+import com.termux.shared.logger.Logger;
+import com.termux.shared.markdown.MarkdownUtils;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+public class Error implements Serializable {
+
+ /** The optional error label. */
+ private String label;
+ /** The error type. */
+ private String type;
+ /** The error code. */
+ private int code;
+ /** The error message. */
+ private String message;
+ /** The error exceptions. */
+ private List throwablesList = new ArrayList<>();
+
+ private static final String LOG_TAG = "Error";
+
+
+ public Error() {
+ InitError(null, null, null, null);
+ }
+
+ public Error(String type, Integer code, String message, List throwablesList) {
+ InitError(type, code, message, throwablesList);
+ }
+
+ public Error(String type, Integer code, String message, Throwable throwable) {
+ InitError(type, code, message, Collections.singletonList(throwable));
+ }
+
+ public Error(String type, Integer code, String message) {
+ InitError(type, code, message, null);
+ }
+
+ public Error(Integer code, String message, List throwablesList) {
+ InitError(null, code, message, throwablesList);
+ }
+
+ public Error(Integer code, String message, Throwable throwable) {
+ InitError(null, code, message, Collections.singletonList(throwable));
+ }
+
+ public Error(Integer code, String message) {
+ InitError(null, code, message, null);
+ }
+
+ public Error(String message, Throwable throwable) {
+ InitError(null, null, message, Collections.singletonList(throwable));
+ }
+
+ public Error(String message, List throwablesList) {
+ InitError(null, null, message, throwablesList);
+ }
+
+ public Error(String message) {
+ InitError(null, null, message, null);
+ }
+
+ private void InitError(String type, Integer code, String message, List throwablesList) {
+ if (type != null && !type.isEmpty())
+ this.type = type;
+ else
+ this.type = Errno.TYPE;
+
+ if (code != null && code > Errno.ERRNO_SUCCESS.getCode())
+ this.code = code;
+ else
+ this.code = Errno.ERRNO_SUCCESS.getCode();
+
+ this.message = message;
+
+ if (throwablesList != null)
+ this.throwablesList = throwablesList;
+ }
+
+ public Error setLabel(String label) {
+ this.label = label;
+ return this;
+ }
+
+ public String getLabel() {
+ return label;
+ }
+
+
+ public String getType() {
+ return type;
+ }
+
+ public Integer getCode() {
+ return code;
+ }
+
+ public String getMessage() {
+ return message;
+ }
+
+ public void prependMessage(String message) {
+ if (message != null && isStateFailed())
+ this.message = message + this.message;
+ }
+
+ public void appendMessage(String message) {
+ if (message != null && isStateFailed())
+ this.message = this.message + message;
+ }
+
+ public List getThrowablesList() {
+ return Collections.unmodifiableList(throwablesList);
+ }
+
+
+ public synchronized boolean setStateFailed(@NonNull Error error) {
+ return setStateFailed(error.getType(), error.getCode(), error.getMessage(), null);
+ }
+
+ public synchronized boolean setStateFailed(@NonNull Error error, Throwable throwable) {
+ return setStateFailed(error.getType(), error.getCode(), error.getMessage(), Collections.singletonList(throwable));
+ }
+ public synchronized boolean setStateFailed(@NonNull Error error, List throwablesList) {
+ return setStateFailed(error.getType(), error.getCode(), error.getMessage(), throwablesList);
+ }
+
+ public synchronized boolean setStateFailed(int code, String message) {
+ return setStateFailed(this.type, code, message, null);
+ }
+
+ public synchronized boolean setStateFailed(int code, String message, Throwable throwable) {
+ return setStateFailed(this.type, code, message, Collections.singletonList(throwable));
+ }
+
+ public synchronized boolean setStateFailed(int code, String message, List throwablesList) {
+ return setStateFailed(this.type, code, message, throwablesList);
+ }
+
+ public synchronized boolean setStateFailed(String type, int code, String message, List throwablesList) {
+ this.message = message;
+ this.throwablesList = throwablesList;
+
+ if (type != null && !type.isEmpty())
+ this.type = type;
+
+ if (code > Errno.ERRNO_SUCCESS.getCode()) {
+ this.code = code;
+ return true;
+ } else {
+ Logger.logWarn(LOG_TAG, "Ignoring invalid error code value \"" + code + "\". Force setting it to RESULT_CODE_FAILED \"" + Errno.ERRNO_FAILED.getCode() + "\"");
+ this.code = Errno.ERRNO_FAILED.getCode();
+ return false;
+ }
+ }
+
+ public boolean isStateFailed() {
+ return code > Errno.ERRNO_SUCCESS.getCode();
+ }
+
+
+ @NonNull
+ @Override
+ public String toString() {
+ return getErrorLogString(this);
+ }
+
+
+
+ /**
+ * Log the {@link Error} and show a toast for the minimal {@link String} for the {@link Error}.
+ *
+ * @param context The {@link Context} for operations.
+ * @param logTag The log tag to use for logging.
+ * @param error The {@link Error} to convert.
+ */
+ public static void logErrorAndShowToast(Context context, String logTag, Error error) {
+ if (error == null) return;
+ error.logErrorAndShowToast(context, logTag);
+ }
+
+ public void logErrorAndShowToast(Context context, String logTag) {
+ Logger.logErrorExtended(logTag, getErrorLogString());
+ Logger.showToast(context, getMinimalErrorLogString(), true);
+ }
+
+
+ /**
+ * Get a log friendly {@link String} for {@link Error} error parameters.
+ *
+ * @param error The {@link Error} to convert.
+ * @return Returns the log friendly {@link String}.
+ */
+ public static String getErrorLogString(final Error error) {
+ if (error == null) return "null";
+ return error.getErrorLogString();
+ }
+
+ public String getErrorLogString() {
+ StringBuilder logString = new StringBuilder();
+
+ logString.append(getCodeString());
+ logString.append("\n").append(getTypeAndMessageLogString());
+ if (throwablesList != null && throwablesList.size() > 0)
+ logString.append("\n").append(geStackTracesLogString());
+
+ return logString.toString();
+ }
+
+ /**
+ * Get a minimal log friendly {@link String} for {@link Error} error parameters.
+ *
+ * @param error The {@link Error} to convert.
+ * @return Returns the log friendly {@link String}.
+ */
+ public static String getMinimalErrorLogString(final Error error) {
+ if (error == null) return "null";
+ return error.getMinimalErrorLogString();
+ }
+
+ public String getMinimalErrorLogString() {
+ StringBuilder logString = new StringBuilder();
+
+ logString.append(getCodeString());
+ logString.append(getTypeAndMessageLogString());
+
+ return logString.toString();
+ }
+
+ /**
+ * Get a minimal {@link String} for {@link Error} error parameters.
+ *
+ * @param error The {@link Error} to convert.
+ * @return Returns the {@link String}.
+ */
+ public static String getMinimalErrorString(final Error error) {
+ if (error == null) return "null";
+ return error.getMinimalErrorString();
+ }
+
+ public String getMinimalErrorString() {
+ StringBuilder logString = new StringBuilder();
+
+ logString.append("(").append(getCode()).append(") ");
+ logString.append(getType()).append(": ").append(getMessage());
+
+ return logString.toString();
+ }
+
+ /**
+ * Get a markdown {@link String} for {@link Error}.
+ *
+ * @param error The {@link Error} to convert.
+ * @return Returns the markdown {@link String}.
+ */
+ public static String getErrorMarkdownString(final Error error) {
+ if (error == null) return "null";
+ return error.getErrorMarkdownString();
+ }
+
+ public String getErrorMarkdownString() {
+ StringBuilder markdownString = new StringBuilder();
+
+ markdownString.append(MarkdownUtils.getSingleLineMarkdownStringEntry("Error Code", getCode(), "-"));
+ markdownString.append("\n").append(MarkdownUtils.getMultiLineMarkdownStringEntry(
+ (Errno.TYPE.equals(getType()) ? "Error Message" : "Error Message (" + getType() + ")"), message, "-"));
+ if (throwablesList != null && throwablesList.size() > 0)
+ markdownString.append("\n\n").append(geStackTracesMarkdownString());
+
+ return markdownString.toString();
+ }
+
+
+ public String getCodeString() {
+ return Logger.getSingleLineLogStringEntry("Error Code", code, "-");
+ }
+
+ public String getTypeAndMessageLogString() {
+ return Logger.getMultiLineLogStringEntry(Errno.TYPE.equals(type) ? "Error Message" : "Error Message (" + type + ")", message, "-");
+ }
+
+ public String geStackTracesLogString() {
+ return Logger.getStackTracesString("StackTraces:", Logger.getStackTracesStringArray(throwablesList));
+ }
+
+ public String geStackTracesMarkdownString() {
+ return Logger.getStackTracesMarkdownString("StackTraces", Logger.getStackTracesStringArray(throwablesList));
+ }
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/errors/FunctionErrno.java b/termux/termux-shared/src/main/java/com/termux/shared/errors/FunctionErrno.java
new file mode 100644
index 000000000..533a9f5c1
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/errors/FunctionErrno.java
@@ -0,0 +1,22 @@
+package com.termux.shared.errors;
+
+/** The {@link Class} that defines function error messages and codes. */
+public class FunctionErrno extends Errno {
+
+ public static final String TYPE = "Function Error";
+
+
+ /* Errors for null or empty parameters (100-150) */
+ public static final Errno ERRNO_NULL_OR_EMPTY_PARAMETER = new Errno(TYPE, 100, "The %1$s parameter passed to \"%2$s\" is null or empty.");
+ public static final Errno ERRNO_NULL_OR_EMPTY_PARAMETERS = new Errno(TYPE, 101, "The %1$s parameters passed to \"%2$s\" are null or empty.");
+ public static final Errno ERRNO_UNSET_PARAMETER = new Errno(TYPE, 102, "The %1$s parameter passed to \"%2$s\" must be set.");
+ public static final Errno ERRNO_UNSET_PARAMETERS = new Errno(TYPE, 103, "The %1$s parameters passed to \"%2$s\" must be set.");
+ public static final Errno ERRNO_INVALID_PARAMETER = new Errno(TYPE, 104, "The %1$s parameter passed to \"%2$s\" is invalid.\"%3$s\"");
+ public static final Errno ERRNO_PARAMETER_NOT_INSTANCE_OF = new Errno(TYPE, 104, "The %1$s parameter passed to \"%2$s\" is not an instance of %3$s.");
+
+
+ FunctionErrno(final String type, final int code, final String message) {
+ super(type, code, message);
+ }
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/file/FileUtils.java b/termux/termux-shared/src/main/java/com/termux/shared/file/FileUtils.java
new file mode 100644
index 000000000..365613fe9
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/file/FileUtils.java
@@ -0,0 +1,2002 @@
+package com.termux.shared.file;
+
+import android.os.Build;
+import android.system.Os;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import com.termux.shared.data.DataUtils;
+import com.termux.shared.errors.Errno;
+import com.termux.shared.errors.Error;
+import com.termux.shared.errors.FunctionErrno;
+import com.termux.shared.file.filesystem.FileType;
+import com.termux.shared.file.filesystem.FileTypes;
+import com.termux.shared.logger.Logger;
+import java.io.BufferedReader;
+import java.io.BufferedWriter;
+import java.io.Closeable;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.io.OutputStreamWriter;
+import java.io.Serializable;
+import java.nio.charset.Charset;
+import java.nio.file.LinkOption;
+import java.nio.file.StandardCopyOption;
+import java.util.Calendar;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+import java.util.regex.Pattern;
+import kotlin.io.path.PathsKt;
+import org.apache.commons.io.filefilter.AgeFileFilter;
+import org.apache.commons.io.filefilter.IOFileFilter;
+
+public class FileUtils {
+
+ /** Required file permissions for the executable file for app usage. Executable file must have read and execute permissions */
+ public static final String APP_EXECUTABLE_FILE_PERMISSIONS = "r-x"; // Default: "r-x"
+ /** Required file permissions for the working directory for app usage. Working directory must have read and write permissions.
+ * Execute permissions should be attempted to be set, but ignored if they are missing */
+ public static final String APP_WORKING_DIRECTORY_PERMISSIONS = "rwx"; // Default: "rwx"
+
+ private static final String LOG_TAG = "FileUtils";
+
+ /**
+ * Get canonical path.
+ *
+ * If path is already an absolute path, then it is used as is to get canonical path.
+ * If path is not an absolute path and {code prefixForNonAbsolutePath} is not {@code null}, then
+ * {code prefixForNonAbsolutePath} + "/" is prefixed before path before getting canonical path.
+ * If path is not an absolute path and {code prefixForNonAbsolutePath} is {@code null}, then
+ * "/" is prefixed before path before getting canonical path.
+ *
+ * If an exception is raised to get the canonical path, then absolute path is returned.
+ *
+ * @param path The {@code path} to convert.
+ * @param prefixForNonAbsolutePath Optional prefix path to prefix before non-absolute paths. This
+ * can be set to {@code null} if non-absolute paths should
+ * be prefixed with "/". The call to {@link File#getCanonicalPath()}
+ * will automatically do this anyways.
+ * @return Returns the {@code canonical path}.
+ */
+ public static String getCanonicalPath(String path, final String prefixForNonAbsolutePath) {
+ if (path == null) path = "";
+
+ String absolutePath;
+
+ // If path is already an absolute path
+ if (path.startsWith("/")) {
+ absolutePath = path;
+ } else {
+ if (prefixForNonAbsolutePath != null)
+ absolutePath = prefixForNonAbsolutePath + "/" + path;
+ else
+ absolutePath = "/" + path;
+ }
+
+ try {
+ return new File(absolutePath).getCanonicalPath();
+ } catch(Exception e) {
+ }
+
+ return absolutePath;
+ }
+
+ /**
+ * Removes one or more forward slashes "//" with single slash "/"
+ * Removes "./"
+ * Removes trailing forward slash "/"
+ *
+ * @param path The {@code path} to convert.
+ * @return Returns the {@code normalized path}.
+ */
+ @Nullable
+ public static String normalizePath(String path) {
+ if (path == null) return null;
+
+ path = path.replaceAll("/+", "/");
+ path = path.replaceAll("\\./", "");
+
+ if (path.endsWith("/")) {
+ path = path.replaceAll("/+$", "");
+ }
+
+ return path;
+ }
+
+ /**
+ * Convert special characters `\/:*?"<>|` to underscore.
+ *
+ * @param fileName The name to sanitize.
+ * @param sanitizeWhitespaces If set to {@code true}, then white space characters ` \t\n` will be
+ * converted.
+ * @param toLower If set to {@code true}, then file name will be converted to lower case.
+ * @return Returns the {@code sanitized name}.
+ */
+ public static String sanitizeFileName(String fileName, boolean sanitizeWhitespaces, boolean toLower) {
+ if (fileName == null) return null;
+
+ if (sanitizeWhitespaces)
+ fileName = fileName.replaceAll("[\\\\/:*?\"<>| \t\n]", "_");
+ else
+ fileName = fileName.replaceAll("[\\\\/:*?\"<>|]", "_");
+
+ if (toLower)
+ return fileName.toLowerCase();
+ else
+ return fileName;
+ }
+
+ /**
+ * Determines whether path is in {@code dirPath}. The {@code dirPath} is not canonicalized and
+ * only normalized.
+ *
+ * @param path The {@code path} to check.
+ * @param dirPath The {@code directory path} to check in.
+ * @param ensureUnder If set to {@code true}, then it will be ensured that {@code path} is
+ * under the directory and does not equal it.
+ * @return Returns {@code true} if path in {@code dirPath}, otherwise returns {@code false}.
+ */
+ public static boolean isPathInDirPath(String path, final String dirPath, final boolean ensureUnder) {
+ return isPathInDirPaths(path, Collections.singletonList(dirPath), ensureUnder);
+ }
+
+ /**
+ * Determines whether path is in one of the {@code dirPaths}. The {@code dirPaths} are not
+ * canonicalized and only normalized.
+ *
+ * @param path The {@code path} to check.
+ * @param dirPaths The {@code directory paths} to check in.
+ * @param ensureUnder If set to {@code true}, then it will be ensured that {@code path} is
+ * under the directories and does not equal it.
+ * @return Returns {@code true} if path in {@code dirPaths}, otherwise returns {@code false}.
+ */
+ public static boolean isPathInDirPaths(String path, final List dirPaths, final boolean ensureUnder) {
+ if (path == null || path.isEmpty() || dirPaths == null || dirPaths.size() < 1) return false;
+
+ try {
+ path = new File(path).getCanonicalPath();
+ } catch(Exception e) {
+ return false;
+ }
+
+ boolean isPathInDirPaths;
+
+ for (String dirPath : dirPaths) {
+ String normalizedDirPath = normalizePath(dirPath);
+
+ if (ensureUnder)
+ isPathInDirPaths = !path.equals(normalizedDirPath) && path.startsWith(normalizedDirPath + "/");
+ else
+ isPathInDirPaths = path.startsWith(normalizedDirPath + "/");
+
+ if (isPathInDirPaths) return true;
+ }
+
+ return false;
+ }
+
+
+
+ /**
+ * Validate that directory is empty or contains only files in {@code ignoredSubFilePaths}.
+ *
+ * If parent path of an ignored file exists, but ignored file itself does not exist, then directory
+ * is not considered empty.
+ *
+ * @param label The optional label for directory to check. This can optionally be {@code null}.
+ * @param filePath The {@code path} for directory to check.
+ * @param ignoredSubFilePaths The list of absolute file paths under {@code filePath} dir.
+ * Validation is done for the paths.
+ * @param ignoreNonExistentFile The {@code boolean} that decides if it should be considered an
+ * error if file to be checked doesn't exist.
+ * @return Returns {@code null} if directory is empty or contains only files in {@code ignoredSubFilePaths}.
+ * Returns {@code FileUtilsErrno#ERRNO_NON_EMPTY_DIRECTORY_FILE} if a file was found that did not
+ * exist in the {@code ignoredSubFilePaths}, otherwise returns an appropriate {@code error} if
+ * checking was not successful.
+ */
+ public static Error validateDirectoryFileEmptyOrOnlyContainsSpecificFiles(String label, String filePath,
+ final List ignoredSubFilePaths,
+ final boolean ignoreNonExistentFile) {
+ label = (label == null || label.isEmpty() ? "" : label + " ");
+ if (filePath == null || filePath.isEmpty()) return FunctionErrno.ERRNO_NULL_OR_EMPTY_PARAMETER.getError(label + "file path", "isDirectoryFileEmptyOrOnlyContainsSpecificFiles");
+
+ try {
+ File file = new File(filePath);
+ FileType fileType = getFileType(filePath, false);
+
+ // If file exists but not a directory file
+ if (fileType != FileType.NO_EXIST && fileType != FileType.DIRECTORY) {
+ return FileUtilsErrno.ERRNO_NON_DIRECTORY_FILE_FOUND.getError(label + "directory", filePath).setLabel(label + "directory");
+ }
+
+ // If file does not exist
+ if (fileType == FileType.NO_EXIST) {
+ // If checking is to be ignored if file does not exist
+ if (ignoreNonExistentFile)
+ return null;
+ else {
+ label += "directory to check if is empty or only contains specific files";
+ return FileUtilsErrno.ERRNO_FILE_NOT_FOUND_AT_PATH.getError(label, filePath).setLabel(label);
+ }
+ }
+
+ File[] subFiles = file.listFiles();
+ if (subFiles == null || subFiles.length == 0)
+ return null;
+
+ // If sub files exists but no file should be ignored
+ if (ignoredSubFilePaths == null || ignoredSubFilePaths.size() == 0)
+ return FileUtilsErrno.ERRNO_NON_EMPTY_DIRECTORY_FILE.getError(label, filePath);
+
+ // If a sub file does not exist in ignored file path
+ if (nonIgnoredSubFileExists(subFiles, ignoredSubFilePaths)) {
+ return FileUtilsErrno.ERRNO_NON_EMPTY_DIRECTORY_FILE.getError(label, filePath);
+ }
+
+ } catch (Exception e) {
+ return FileUtilsErrno.ERRNO_VALIDATE_DIRECTORY_EMPTY_OR_ONLY_CONTAINS_SPECIFIC_FILES_FAILED_WITH_EXCEPTION.getError(e, label + "directory", filePath, e.getMessage());
+ }
+
+ return null;
+ }
+
+ /**
+ * Check if {@code subFiles} contains contains a file not in {@code ignoredSubFilePaths}.
+ *
+ * If parent path of an ignored file exists, but ignored file itself does not exist, then directory
+ * is not considered empty.
+ *
+ * This function should ideally not be called by itself but through
+ * {@link #validateDirectoryFileEmptyOrOnlyContainsSpecificFiles(String, String, List, boolean)}.
+ *
+ * @param subFiles The list of files of a directory to check.
+ * @param ignoredSubFilePaths The list of absolute file paths under {@code filePath} dir.
+ * Validation is done for the paths.
+ * @return Returns {@code true} if a file was found that did not exist in the {@code ignoredSubFilePaths},
+ * otherwise {@code false}.
+ */
+ public static boolean nonIgnoredSubFileExists(File[] subFiles, @NonNull List ignoredSubFilePaths) {
+ if (subFiles == null || subFiles.length == 0) return false;
+
+ String subFilePath;
+ for (File subFile : subFiles) {
+ subFilePath = subFile.getAbsolutePath();
+ // If sub file does not exist in ignored sub file paths
+ if (!ignoredSubFilePaths.contains(subFilePath)) {
+ boolean isParentPath = false;
+ for (String ignoredSubFilePath : ignoredSubFilePaths) {
+ if (ignoredSubFilePath.startsWith(subFilePath + "/") && fileExists(ignoredSubFilePath, false)) {
+ isParentPath = true;
+ break;
+ }
+ }
+ // If sub file is not a parent of any existing ignored sub file paths
+ if (!isParentPath) {
+ return true;
+ }
+ }
+
+ if (getFileType(subFilePath, false) == FileType.DIRECTORY) {
+ // If non ignored sub file found, then early exit, otherwise continue looking
+ if (nonIgnoredSubFileExists(subFile.listFiles(), ignoredSubFilePaths))
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+
+
+ /**
+ * Checks whether a regular file exists at {@code filePath}.
+ *
+ * @param filePath The {@code path} for regular file to check.
+ * @param followLinks The {@code boolean} that decides if symlinks will be followed while
+ * finding if file exists. Check {@link #getFileType(String, boolean)}
+ * for details.
+ * @return Returns {@code true} if regular file exists, otherwise {@code false}.
+ */
+ public static boolean regularFileExists(final String filePath, final boolean followLinks) {
+ return getFileType(filePath, followLinks) == FileType.REGULAR;
+ }
+
+ /**
+ * Checks whether a directory file exists at {@code filePath}.
+ *
+ * @param filePath The {@code path} for directory file to check.
+ * @param followLinks The {@code boolean} that decides if symlinks will be followed while
+ * finding if file exists. Check {@link #getFileType(String, boolean)}
+ * for details.
+ * @return Returns {@code true} if directory file exists, otherwise {@code false}.
+ */
+ public static boolean directoryFileExists(final String filePath, final boolean followLinks) {
+ return getFileType(filePath, followLinks) == FileType.DIRECTORY;
+ }
+
+ /**
+ * Checks whether a symlink file exists at {@code filePath}.
+ *
+ * @param filePath The {@code path} for symlink file to check.
+ * @return Returns {@code true} if symlink file exists, otherwise {@code false}.
+ */
+ public static boolean symlinkFileExists(final String filePath) {
+ return getFileType(filePath, false) == FileType.SYMLINK;
+ }
+
+ /**
+ * Checks whether a regular or directory file exists at {@code filePath}.
+ *
+ * @param filePath The {@code path} for regular file to check.
+ * @param followLinks The {@code boolean} that decides if symlinks will be followed while
+ * finding if file exists. Check {@link #getFileType(String, boolean)}
+ * for details.
+ * @return Returns {@code true} if regular or directory file exists, otherwise {@code false}.
+ */
+ public static boolean regularOrDirectoryFileExists(final String filePath, final boolean followLinks) {
+ FileType fileType = getFileType(filePath, followLinks);
+ return fileType == FileType.REGULAR || fileType == FileType.DIRECTORY;
+ }
+
+ /**
+ * Checks whether any file exists at {@code filePath}.
+ *
+ * @param filePath The {@code path} for file to check.
+ * @param followLinks The {@code boolean} that decides if symlinks will be followed while
+ * finding if file exists. Check {@link #getFileType(String, boolean)}
+ * for details.
+ * @return Returns {@code true} if file exists, otherwise {@code false}.
+ */
+ public static boolean fileExists(final String filePath, final boolean followLinks) {
+ return getFileType(filePath, followLinks) != FileType.NO_EXIST;
+ }
+
+ /**
+ * Get the type of file that exists at {@code filePath}.
+ *
+ * This function is a wrapper for
+ * {@link FileTypes#getFileType(String, boolean)}
+ *
+ * @param filePath The {@code path} for file to check.
+ * @param followLinks The {@code boolean} that decides if symlinks will be followed while
+ * finding type. If set to {@code true}, then type of symlink target will
+ * be returned if file at {@code filePath} is a symlink. If set to
+ * {@code false}, then type of file at {@code filePath} itself will be
+ * returned.
+ * @return Returns the {@link FileType} of file.
+ */
+ @NonNull
+ public static FileType getFileType(final String filePath, final boolean followLinks) {
+ return FileTypes.getFileType(filePath, followLinks);
+ }
+
+
+
+ /**
+ * Validate the existence and permissions of regular file at path.
+ *
+ * If the {@code parentDirPath} is not {@code null}, then setting of missing permissions will
+ * only be done if {@code path} is under {@code parentDirPath}.
+ *
+ * @param label The optional label for the regular file. This can optionally be {@code null}.
+ * @param filePath The {@code path} for file to validate. Symlinks will not be followed.
+ * @param parentDirPath The optional {@code parent directory path} to restrict operations to.
+ * This can optionally be {@code null}. It is not canonicalized and only normalized.
+ * @param permissionsToCheck The 3 character string that contains the "r", "w", "x" or "-" in-order.
+ * @param setPermissions The {@code boolean} that decides if permissions are to be
+ * automatically set defined by {@code permissionsToCheck}.
+ * @param setMissingPermissionsOnly The {@code boolean} that decides if only missing permissions
+ * are to be set or if they should be overridden.
+ * @param ignoreErrorsIfPathIsUnderParentDirPath The {@code boolean} that decides if permission
+ * errors are to be ignored if path is under
+ * {@code parentDirPath}.
+ * @return Returns the {@code error} if path is not a regular file, or validating permissions
+ * failed, otherwise {@code null}.
+ */
+ public static Error validateRegularFileExistenceAndPermissions(String label, final String filePath, final String parentDirPath,
+ final String permissionsToCheck, final boolean setPermissions, final boolean setMissingPermissionsOnly,
+ final boolean ignoreErrorsIfPathIsUnderParentDirPath) {
+ label = (label == null || label.isEmpty() ? "" : label + " ");
+ if (filePath == null || filePath.isEmpty()) return FunctionErrno.ERRNO_NULL_OR_EMPTY_PARAMETER.getError(label + "regular file path", "validateRegularFileExistenceAndPermissions");
+
+ try {
+ FileType fileType = getFileType(filePath, false);
+
+ // If file exists but not a regular file
+ if (fileType != FileType.NO_EXIST && fileType != FileType.REGULAR) {
+ return FileUtilsErrno.ERRNO_NON_REGULAR_FILE_FOUND.getError(label + "file", filePath).setLabel(label + "file");
+ }
+
+ boolean isPathUnderParentDirPath = false;
+ if (parentDirPath != null) {
+ // The path can only be under parent directory path
+ isPathUnderParentDirPath = isPathInDirPath(filePath, parentDirPath, true);
+ }
+
+ // If setPermissions is enabled and path is a regular file
+ if (setPermissions && permissionsToCheck != null && fileType == FileType.REGULAR) {
+ // If there is not parentDirPath restriction or path is under parentDirPath
+ if (parentDirPath == null || (isPathUnderParentDirPath && getFileType(parentDirPath, false) == FileType.DIRECTORY)) {
+ if (setMissingPermissionsOnly)
+ setMissingFilePermissions(label + "file", filePath, permissionsToCheck);
+ else
+ setFilePermissions(label + "file", filePath, permissionsToCheck);
+ }
+ }
+
+ // If path is not a regular file
+ // Regular files cannot be automatically created so we do not ignore if missing
+ if (fileType != FileType.REGULAR) {
+ label += "regular file";
+ return FileUtilsErrno.ERRNO_FILE_NOT_FOUND_AT_PATH.getError(label, filePath).setLabel(label);
+ }
+
+ // If there is not parentDirPath restriction or path is not under parentDirPath or
+ // if permission errors must not be ignored for paths under parentDirPath
+ if (parentDirPath == null || !isPathUnderParentDirPath || !ignoreErrorsIfPathIsUnderParentDirPath) {
+ if (permissionsToCheck != null) {
+ // Check if permissions are missing
+ return checkMissingFilePermissions(label + "regular", filePath, permissionsToCheck, false);
+ }
+ }
+ } catch (Exception e) {
+ return FileUtilsErrno.ERRNO_VALIDATE_FILE_EXISTENCE_AND_PERMISSIONS_FAILED_WITH_EXCEPTION.getError(e, label + "file", filePath, e.getMessage());
+ }
+
+ return null;
+
+ }
+
+ /**
+ * Validate the existence and permissions of directory file at path.
+ *
+ * If the {@code parentDirPath} is not {@code null}, then creation of missing directory and
+ * setting of missing permissions will only be done if {@code path} is under
+ * {@code parentDirPath} or equals {@code parentDirPath}.
+ *
+ * @param label The optional label for the directory file. This can optionally be {@code null}.
+ * @param filePath The {@code path} for file to validate or create. Symlinks will not be followed.
+ * @param parentDirPath The optional {@code parent directory path} to restrict operations to.
+ * This can optionally be {@code null}. It is not canonicalized and only normalized.
+ * @param createDirectoryIfMissing The {@code boolean} that decides if directory file
+ * should be created if its missing.
+ * @param permissionsToCheck The 3 character string that contains the "r", "w", "x" or "-" in-order.
+ * @param setPermissions The {@code boolean} that decides if permissions are to be
+ * automatically set defined by {@code permissionsToCheck}.
+ * @param setMissingPermissionsOnly The {@code boolean} that decides if only missing permissions
+ * are to be set or if they should be overridden.
+ * @param ignoreErrorsIfPathIsInParentDirPath The {@code boolean} that decides if existence
+ * and permission errors are to be ignored if path is
+ * in {@code parentDirPath}.
+ * @param ignoreIfNotExecutable The {@code boolean} that decides if missing executable permission
+ * error is to be ignored. This allows making an attempt to set
+ * executable permissions, but ignoring if it fails.
+ * @return Returns the {@code error} if path is not a directory file, failed to create it,
+ * or validating permissions failed, otherwise {@code null}.
+ */
+ public static Error validateDirectoryFileExistenceAndPermissions(String label, final String filePath, final String parentDirPath, final boolean createDirectoryIfMissing,
+ final String permissionsToCheck, final boolean setPermissions, final boolean setMissingPermissionsOnly,
+ final boolean ignoreErrorsIfPathIsInParentDirPath, final boolean ignoreIfNotExecutable) {
+ label = (label == null || label.isEmpty() ? "" : label + " ");
+ if (filePath == null || filePath.isEmpty()) return FunctionErrno.ERRNO_NULL_OR_EMPTY_PARAMETER.getError(label + "directory file path", "validateDirectoryExistenceAndPermissions");
+
+ try {
+ File file = new File(filePath);
+ FileType fileType = getFileType(filePath, false);
+
+ // If file exists but not a directory file
+ if (fileType != FileType.NO_EXIST && fileType != FileType.DIRECTORY) {
+ return FileUtilsErrno.ERRNO_NON_DIRECTORY_FILE_FOUND.getError(label + "directory", filePath).setLabel(label + "directory");
+ }
+
+ boolean isPathInParentDirPath = false;
+ if (parentDirPath != null) {
+ // The path can be equal to parent directory path or under it
+ isPathInParentDirPath = isPathInDirPath(filePath, parentDirPath, false);
+ }
+
+ if (createDirectoryIfMissing || setPermissions) {
+ // If there is not parentDirPath restriction or path is in parentDirPath
+ if (parentDirPath == null || (isPathInParentDirPath && getFileType(parentDirPath, false) == FileType.DIRECTORY)) {
+ // If createDirectoryIfMissing is enabled and no file exists at path, then create directory
+ if (createDirectoryIfMissing && fileType == FileType.NO_EXIST) {
+ Logger.logVerbose(LOG_TAG, "Creating " + label + "directory file at path \"" + filePath + "\"");
+ // Create directory and update fileType if successful, otherwise return with error
+ // It "might" be possible that mkdirs returns false even though directory was created
+ boolean result = file.mkdirs();
+ fileType = getFileType(filePath, false);
+ if (!result && fileType != FileType.DIRECTORY)
+ return FileUtilsErrno.ERRNO_CREATING_FILE_FAILED.getError(label + "directory file", filePath);
+ }
+
+ // If setPermissions is enabled and path is a directory
+ if (setPermissions && permissionsToCheck != null && fileType == FileType.DIRECTORY) {
+ if (setMissingPermissionsOnly)
+ setMissingFilePermissions(label + "directory", filePath, permissionsToCheck);
+ else
+ setFilePermissions(label + "directory", filePath, permissionsToCheck);
+ }
+ }
+ }
+
+ // If there is not parentDirPath restriction or path is not in parentDirPath or
+ // if existence or permission errors must not be ignored for paths in parentDirPath
+ if (parentDirPath == null || !isPathInParentDirPath || !ignoreErrorsIfPathIsInParentDirPath) {
+ // If path is not a directory
+ // Directories can be automatically created so we can ignore if missing with above check
+ if (fileType != FileType.DIRECTORY) {
+ label += "directory";
+ return FileUtilsErrno.ERRNO_FILE_NOT_FOUND_AT_PATH.getError(label, filePath).setLabel(label);
+ }
+
+ if (permissionsToCheck != null) {
+ // Check if permissions are missing
+ return checkMissingFilePermissions(label + "directory", filePath, permissionsToCheck, ignoreIfNotExecutable);
+ }
+ }
+ } catch (Exception e) {
+ return FileUtilsErrno.ERRNO_VALIDATE_DIRECTORY_EXISTENCE_AND_PERMISSIONS_FAILED_WITH_EXCEPTION.getError(e, label + "directory file", filePath, e.getMessage());
+ }
+
+ return null;
+ }
+
+
+
+ /**
+ * Create a regular file at path.
+ *
+ * This function is a wrapper for
+ * {@link #validateDirectoryFileExistenceAndPermissions(String, String, String, boolean, String, boolean, boolean, boolean, boolean)}.
+ *
+ * @param filePath The {@code path} for regular file to create.
+ * @return Returns the {@code error} if path is not a regular file or failed to create it,
+ * otherwise {@code null}.
+ */
+ public static Error createRegularFile(final String filePath) {
+ return createRegularFile(null, filePath);
+ }
+
+ /**
+ * Create a regular file at path.
+ *
+ * This function is a wrapper for
+ * {@link #validateDirectoryFileExistenceAndPermissions(String, String, String, boolean, String, boolean, boolean, boolean, boolean)}.
+ *
+ * @param label The optional label for the regular file. This can optionally be {@code null}.
+ * @param filePath The {@code path} for regular file to create.
+ * @return Returns the {@code error} if path is not a regular file or failed to create it,
+ * otherwise {@code null}.
+ */
+ public static Error createRegularFile(final String label, final String filePath) {
+ return createRegularFile(label, filePath,
+ null, false, false);
+ }
+
+ /**
+ * Create a regular file at path.
+ *
+ * This function is a wrapper for
+ * {@link #validateRegularFileExistenceAndPermissions(String, String, String, String, boolean, boolean, boolean)}.
+ *
+ * @param label The optional label for the regular file. This can optionally be {@code null}.
+ * @param filePath The {@code path} for regular file to create.
+ * @param permissionsToCheck The 3 character string that contains the "r", "w", "x" or "-" in-order.
+ * @param setPermissions The {@code boolean} that decides if permissions are to be
+ * automatically set defined by {@code permissionsToCheck}.
+ * @param setMissingPermissionsOnly The {@code boolean} that decides if only missing permissions
+ * are to be set or if they should be overridden.
+ * @return Returns the {@code error} if path is not a regular file, failed to create it,
+ * or validating permissions failed, otherwise {@code null}.
+ */
+ public static Error createRegularFile(String label, final String filePath,
+ final String permissionsToCheck, final boolean setPermissions, final boolean setMissingPermissionsOnly) {
+ label = (label == null || label.isEmpty() ? "" : label + " ");
+ if (filePath == null || filePath.isEmpty()) return FunctionErrno.ERRNO_NULL_OR_EMPTY_PARAMETER.getError(label + "file path", "createRegularFile");
+
+ Error error;
+
+ File file = new File(filePath);
+ FileType fileType = getFileType(filePath, false);
+
+ // If file exists but not a regular file
+ if (fileType != FileType.NO_EXIST && fileType != FileType.REGULAR) {
+ return FileUtilsErrno.ERRNO_NON_REGULAR_FILE_FOUND.getError(label + "file", filePath).setLabel(label + "file");
+ }
+
+ // If regular file already exists
+ if (fileType == FileType.REGULAR) {
+ return null;
+ }
+
+ // Create the file parent directory
+ error = createParentDirectoryFile(label + "regular file parent", filePath);
+ if (error != null)
+ return error;
+
+ try {
+ Logger.logVerbose(LOG_TAG, "Creating " + label + "regular file at path \"" + filePath + "\"");
+
+ if (!file.createNewFile())
+ return FileUtilsErrno.ERRNO_CREATING_FILE_FAILED.getError(label + "regular file", filePath);
+ } catch (Exception e) {
+ return FileUtilsErrno.ERRNO_CREATING_FILE_FAILED_WITH_EXCEPTION.getError(e, label + "regular file", filePath, e.getMessage());
+ }
+
+ return validateRegularFileExistenceAndPermissions(label, filePath,
+ null,
+ permissionsToCheck, setPermissions, setMissingPermissionsOnly,
+ false);
+ }
+
+
+
+ /**
+ * Create parent directory of file at path.
+ *
+ * This function is a wrapper for
+ * {@link #validateDirectoryFileExistenceAndPermissions(String, String, String, boolean, String, boolean, boolean, boolean, boolean)}.
+ *
+ * @param label The optional label for the parent directory file. This can optionally be {@code null}.
+ * @param filePath The {@code path} for file whose parent needs to be created.
+ * @return Returns the {@code error} if parent path is not a directory file or failed to create it,
+ * otherwise {@code null}.
+ */
+ public static Error createParentDirectoryFile(final String label, final String filePath) {
+ if (filePath == null || filePath.isEmpty()) return FunctionErrno.ERRNO_NULL_OR_EMPTY_PARAMETER.getError(label + "file path", "createParentDirectoryFile");
+
+ File file = new File(filePath);
+ String fileParentPath = file.getParent();
+
+ if (fileParentPath != null)
+ return createDirectoryFile(label, fileParentPath,
+ null, false, false);
+ else
+ return null;
+ }
+
+ /**
+ * Create a directory file at path.
+ *
+ * This function is a wrapper for
+ * {@link #validateDirectoryFileExistenceAndPermissions(String, String, String, boolean, String, boolean, boolean, boolean, boolean)}.
+ *
+ * @param filePath The {@code path} for directory file to create.
+ * @return Returns the {@code error} if path is not a directory file or failed to create it,
+ * otherwise {@code null}.
+ */
+ public static Error createDirectoryFile(final String filePath) {
+ return createDirectoryFile(null, filePath);
+ }
+
+ /**
+ * Create a directory file at path.
+ *
+ * This function is a wrapper for
+ * {@link #validateDirectoryFileExistenceAndPermissions(String, String, String, boolean, String, boolean, boolean, boolean, boolean)}.
+ *
+ * @param label The optional label for the directory file. This can optionally be {@code null}.
+ * @param filePath The {@code path} for directory file to create.
+ * @return Returns the {@code error} if path is not a directory file or failed to create it,
+ * otherwise {@code null}.
+ */
+ public static Error createDirectoryFile(final String label, final String filePath) {
+ return createDirectoryFile(label, filePath,
+ null, false, false);
+ }
+
+ /**
+ * Create a directory file at path.
+ *
+ * This function is a wrapper for
+ * {@link #validateDirectoryFileExistenceAndPermissions(String, String, String, boolean, String, boolean, boolean, boolean, boolean)}.
+ *
+ * @param label The optional label for the directory file. This can optionally be {@code null}.
+ * @param filePath The {@code path} for directory file to create.
+ * @param permissionsToCheck The 3 character string that contains the "r", "w", "x" or "-" in-order.
+ * @param setPermissions The {@code boolean} that decides if permissions are to be
+ * automatically set defined by {@code permissionsToCheck}.
+ * @param setMissingPermissionsOnly The {@code boolean} that decides if only missing permissions
+ * are to be set or if they should be overridden.
+ * @return Returns the {@code error} if path is not a directory file, failed to create it,
+ * or validating permissions failed, otherwise {@code null}.
+ */
+ public static Error createDirectoryFile(final String label, final String filePath,
+ final String permissionsToCheck, final boolean setPermissions, final boolean setMissingPermissionsOnly) {
+ return validateDirectoryFileExistenceAndPermissions(label, filePath,
+ null, true,
+ permissionsToCheck, setPermissions, setMissingPermissionsOnly,
+ false, false);
+ }
+
+
+
+ /**
+ * Create a symlink file at path.
+ *
+ * This function is a wrapper for
+ * {@link #createSymlinkFile(String, String, String, boolean, boolean, boolean)}.
+ *
+ * Dangling symlinks will be allowed.
+ * Symlink destination will be overwritten if it already exists but only if its a symlink.
+ *
+ * @param targetFilePath The {@code path} TO which the symlink file will be created.
+ * @param destFilePath The {@code path} AT which the symlink file will be created.
+ * @return Returns the {@code error} if path is not a symlink file, failed to create it,
+ * otherwise {@code null}.
+ */
+ public static Error createSymlinkFile(final String targetFilePath, final String destFilePath) {
+ return createSymlinkFile(null, targetFilePath, destFilePath,
+ true, true, true);
+ }
+
+ /**
+ * Create a symlink file at path.
+ *
+ * This function is a wrapper for
+ * {@link #createSymlinkFile(String, String, String, boolean, boolean, boolean)}.
+ *
+ * Dangling symlinks will be allowed.
+ * Symlink destination will be overwritten if it already exists but only if its a symlink.
+ *
+ * @param label The optional label for the symlink file. This can optionally be {@code null}.
+ * @param targetFilePath The {@code path} TO which the symlink file will be created.
+ * @param destFilePath The {@code path} AT which the symlink file will be created.
+ * @return Returns the {@code error} if path is not a symlink file, failed to create it,
+ * otherwise {@code null}.
+ */
+ public static Error createSymlinkFile(String label, final String targetFilePath, final String destFilePath) {
+ return createSymlinkFile(label, targetFilePath, destFilePath,
+ true, true, true);
+ }
+
+ /**
+ * Create a symlink file at path.
+ *
+ * @param label The optional label for the symlink file. This can optionally be {@code null}.
+ * @param targetFilePath The {@code path} TO which the symlink file will be created.
+ * @param destFilePath The {@code path} AT which the symlink file will be created.
+ * @param allowDangling The {@code boolean} that decides if it should be considered an
+ * error if source file doesn't exist.
+ * @param overwrite The {@code boolean} that decides if destination file should be overwritten if
+ * it already exists. If set to {@code true}, then destination file will be
+ * deleted before symlink is created.
+ * @param overwriteOnlyIfDestIsASymlink The {@code boolean} that decides if overwrite should
+ * only be done if destination file is also a symlink.
+ * @return Returns the {@code error} if path is not a symlink file, failed to create it,
+ * or validating permissions failed, otherwise {@code null}.
+ */
+ public static Error createSymlinkFile(String label, final String targetFilePath, final String destFilePath,
+ final boolean allowDangling, final boolean overwrite, final boolean overwriteOnlyIfDestIsASymlink) {
+ label = (label == null || label.isEmpty() ? "" : label + " ");
+ if (targetFilePath == null || targetFilePath.isEmpty()) return FunctionErrno.ERRNO_NULL_OR_EMPTY_PARAMETER.getError(label + "target file path", "createSymlinkFile");
+ if (destFilePath == null || destFilePath.isEmpty()) return FunctionErrno.ERRNO_NULL_OR_EMPTY_PARAMETER.getError(label + "destination file path", "createSymlinkFile");
+
+ Error error;
+
+ try {
+ File destFile = new File(destFilePath);
+
+ String targetFileAbsolutePath = targetFilePath;
+ // If target path is relative instead of absolute
+ if (!targetFilePath.startsWith("/")) {
+ String destFileParentPath = destFile.getParent();
+ if (destFileParentPath != null)
+ targetFileAbsolutePath = destFileParentPath + "/" + targetFilePath;
+ }
+
+ FileType targetFileType = getFileType(targetFileAbsolutePath, false);
+ FileType destFileType = getFileType(destFilePath, false);
+
+ // If target file does not exist
+ if (targetFileType == FileType.NO_EXIST) {
+ // If dangling symlink should not be allowed, then return with error
+ if (!allowDangling) {
+ label += "symlink target file";
+ return FileUtilsErrno.ERRNO_FILE_NOT_FOUND_AT_PATH.getError(label, targetFileAbsolutePath).setLabel(label);
+ }
+ }
+
+ // If destination exists
+ if (destFileType != FileType.NO_EXIST) {
+ // If destination must not be overwritten
+ if (!overwrite) {
+ return null;
+ }
+
+ // If overwriteOnlyIfDestIsASymlink is enabled but destination file is not a symlink
+ if (overwriteOnlyIfDestIsASymlink && destFileType != FileType.SYMLINK)
+ return FileUtilsErrno.ERRNO_CANNOT_OVERWRITE_A_NON_SYMLINK_FILE_TYPE.getError(label + " file", destFilePath, targetFilePath, destFileType.getName());
+
+ // Delete the destination file
+ error = deleteFile(label + "symlink destination", destFilePath, true);
+ if (error != null)
+ return error;
+ } else {
+ // Create the destination file parent directory
+ error = createParentDirectoryFile(label + "symlink destination file parent", destFilePath);
+ if (error != null)
+ return error;
+ }
+
+ // create a symlink at destFilePath to targetFilePath
+ Logger.logVerbose(LOG_TAG, "Creating " + label + "symlink file at path \"" + destFilePath + "\" to \"" + targetFilePath + "\"");
+ Os.symlink(targetFilePath, destFilePath);
+ } catch (Exception e) {
+ return FileUtilsErrno.ERRNO_CREATING_SYMLINK_FILE_FAILED_WITH_EXCEPTION.getError(e, label + "symlink file", destFilePath, targetFilePath, e.getMessage());
+ }
+
+ return null;
+ }
+
+
+
+ /**
+ * Copy a regular file from {@code sourceFilePath} to {@code destFilePath}.
+ *
+ * This function is a wrapper for
+ * {@link #copyOrMoveFile(String, String, String, boolean, boolean, int, boolean, boolean)}.
+ *
+ * If destination file already exists, then it will be overwritten, but only if its a regular
+ * file, otherwise an error will be returned.
+ *
+ * @param label The optional label for file to copy. This can optionally be {@code null}.
+ * @param srcFilePath The {@code source path} for file to copy.
+ * @param destFilePath The {@code destination path} for file to copy.
+ * @param ignoreNonExistentSrcFile The {@code boolean} that decides if it should be considered an
+ * error if source file to copied doesn't exist.
+ * @return Returns the {@code error} if copy was not successful, otherwise {@code null}.
+ */
+ public static Error copyRegularFile(final String label, final String srcFilePath, final String destFilePath, final boolean ignoreNonExistentSrcFile) {
+ return copyOrMoveFile(label, srcFilePath, destFilePath,
+ false, ignoreNonExistentSrcFile, FileType.REGULAR.getValue(),
+ true, true);
+ }
+
+ /**
+ * Move a regular file from {@code sourceFilePath} to {@code destFilePath}.
+ *
+ * This function is a wrapper for
+ * {@link #copyOrMoveFile(String, String, String, boolean, boolean, int, boolean, boolean)}.
+ *
+ * If destination file already exists, then it will be overwritten, but only if its a regular
+ * file, otherwise an error will be returned.
+ *
+ * @param label The optional label for file to move. This can optionally be {@code null}.
+ * @param srcFilePath The {@code source path} for file to move.
+ * @param destFilePath The {@code destination path} for file to move.
+ * @param ignoreNonExistentSrcFile The {@code boolean} that decides if it should be considered an
+ * error if source file to moved doesn't exist.
+ * @return Returns the {@code error} if move was not successful, otherwise {@code null}.
+ */
+ public static Error moveRegularFile(final String label, final String srcFilePath, final String destFilePath, final boolean ignoreNonExistentSrcFile) {
+ return copyOrMoveFile(label, srcFilePath, destFilePath,
+ true, ignoreNonExistentSrcFile, FileType.REGULAR.getValue(),
+ true, true);
+ }
+
+ /**
+ * Copy a directory file from {@code sourceFilePath} to {@code destFilePath}.
+ *
+ * This function is a wrapper for
+ * {@link #copyOrMoveFile(String, String, String, boolean, boolean, int, boolean, boolean)}.
+ *
+ * If destination file already exists, then it will be overwritten, but only if its a directory
+ * file, otherwise an error will be returned.
+ *
+ * @param label The optional label for file to copy. This can optionally be {@code null}.
+ * @param srcFilePath The {@code source path} for file to copy.
+ * @param destFilePath The {@code destination path} for file to copy.
+ * @param ignoreNonExistentSrcFile The {@code boolean} that decides if it should be considered an
+ * error if source file to copied doesn't exist.
+ * @return Returns the {@code error} if copy was not successful, otherwise {@code null}.
+ */
+ public static Error copyDirectoryFile(final String label, final String srcFilePath, final String destFilePath, final boolean ignoreNonExistentSrcFile) {
+ return copyOrMoveFile(label, srcFilePath, destFilePath,
+ false, ignoreNonExistentSrcFile, FileType.DIRECTORY.getValue(),
+ true, true);
+ }
+
+ /**
+ * Move a directory file from {@code sourceFilePath} to {@code destFilePath}.
+ *
+ * This function is a wrapper for
+ * {@link #copyOrMoveFile(String, String, String, boolean, boolean, int, boolean, boolean)}.
+ *
+ * If destination file already exists, then it will be overwritten, but only if its a directory
+ * file, otherwise an error will be returned.
+ *
+ * @param label The optional label for file to move. This can optionally be {@code null}.
+ * @param srcFilePath The {@code source path} for file to move.
+ * @param destFilePath The {@code destination path} for file to move.
+ * @param ignoreNonExistentSrcFile The {@code boolean} that decides if it should be considered an
+ * error if source file to moved doesn't exist.
+ * @return Returns the {@code error} if move was not successful, otherwise {@code null}.
+ */
+ public static Error moveDirectoryFile(final String label, final String srcFilePath, final String destFilePath, final boolean ignoreNonExistentSrcFile) {
+ return copyOrMoveFile(label, srcFilePath, destFilePath,
+ true, ignoreNonExistentSrcFile, FileType.DIRECTORY.getValue(),
+ true, true);
+ }
+
+ /**
+ * Copy a symlink file from {@code sourceFilePath} to {@code destFilePath}.
+ *
+ * This function is a wrapper for
+ * {@link #copyOrMoveFile(String, String, String, boolean, boolean, int, boolean, boolean)}.
+ *
+ * If destination file already exists, then it will be overwritten, but only if its a symlink
+ * file, otherwise an error will be returned.
+ *
+ * @param label The optional label for file to copy. This can optionally be {@code null}.
+ * @param srcFilePath The {@code source path} for file to copy.
+ * @param destFilePath The {@code destination path} for file to copy.
+ * @param ignoreNonExistentSrcFile The {@code boolean} that decides if it should be considered an
+ * error if source file to copied doesn't exist.
+ * @return Returns the {@code error} if copy was not successful, otherwise {@code null}.
+ */
+ public static Error copySymlinkFile(final String label, final String srcFilePath, final String destFilePath, final boolean ignoreNonExistentSrcFile) {
+ return copyOrMoveFile(label, srcFilePath, destFilePath,
+ false, ignoreNonExistentSrcFile, FileType.SYMLINK.getValue(),
+ true, true);
+ }
+
+ /**
+ * Move a symlink file from {@code sourceFilePath} to {@code destFilePath}.
+ *
+ * This function is a wrapper for
+ * {@link #copyOrMoveFile(String, String, String, boolean, boolean, int, boolean, boolean)}.
+ *
+ * If destination file already exists, then it will be overwritten, but only if its a symlink
+ * file, otherwise an error will be returned.
+ *
+ * @param label The optional label for file to move. This can optionally be {@code null}.
+ * @param srcFilePath The {@code source path} for file to move.
+ * @param destFilePath The {@code destination path} for file to move.
+ * @param ignoreNonExistentSrcFile The {@code boolean} that decides if it should be considered an
+ * error if source file to moved doesn't exist.
+ * @return Returns the {@code error} if move was not successful, otherwise {@code null}.
+ */
+ public static Error moveSymlinkFile(final String label, final String srcFilePath, final String destFilePath, final boolean ignoreNonExistentSrcFile) {
+ return copyOrMoveFile(label, srcFilePath, destFilePath,
+ true, ignoreNonExistentSrcFile, FileType.SYMLINK.getValue(),
+ true, true);
+ }
+
+ /**
+ * Copy a file from {@code sourceFilePath} to {@code destFilePath}.
+ *
+ * This function is a wrapper for
+ * {@link #copyOrMoveFile(String, String, String, boolean, boolean, int, boolean, boolean)}.
+ *
+ * If destination file already exists, then it will be overwritten, but only if its the same file
+ * type as the source, otherwise an error will be returned.
+ *
+ * @param label The optional label for file to copy. This can optionally be {@code null}.
+ * @param srcFilePath The {@code source path} for file to copy.
+ * @param destFilePath The {@code destination path} for file to copy.
+ * @param ignoreNonExistentSrcFile The {@code boolean} that decides if it should be considered an
+ * error if source file to copied doesn't exist.
+ * @return Returns the {@code error} if copy was not successful, otherwise {@code null}.
+ */
+ public static Error copyFile(final String label, final String srcFilePath, final String destFilePath, final boolean ignoreNonExistentSrcFile) {
+ return copyOrMoveFile(label, srcFilePath, destFilePath,
+ false, ignoreNonExistentSrcFile, FileTypes.FILE_TYPE_NORMAL_FLAGS,
+ true, true);
+ }
+
+ /**
+ * Move a file from {@code sourceFilePath} to {@code destFilePath}.
+ *
+ * This function is a wrapper for
+ * {@link #copyOrMoveFile(String, String, String, boolean, boolean, int, boolean, boolean)}.
+ *
+ * If destination file already exists, then it will be overwritten, but only if its the same file
+ * type as the source, otherwise an error will be returned.
+ *
+ * @param label The optional label for file to move. This can optionally be {@code null}.
+ * @param srcFilePath The {@code source path} for file to move.
+ * @param destFilePath The {@code destination path} for file to move.
+ * @param ignoreNonExistentSrcFile The {@code boolean} that decides if it should be considered an
+ * error if source file to moved doesn't exist.
+ * @return Returns the {@code error} if move was not successful, otherwise {@code null}.
+ */
+ public static Error moveFile(final String label, final String srcFilePath, final String destFilePath, final boolean ignoreNonExistentSrcFile) {
+ return copyOrMoveFile(label, srcFilePath, destFilePath,
+ true, ignoreNonExistentSrcFile, FileTypes.FILE_TYPE_NORMAL_FLAGS,
+ true, true);
+ }
+
+ /**
+ * Copy or move a file from {@code sourceFilePath} to {@code destFilePath}.
+ *
+ * The {@code sourceFilePath} and {@code destFilePath} must be the canonical path to the source
+ * and destination since symlinks will not be followed.
+ *
+ * If the {@code sourceFilePath} or {@code destFilePath} is a canonical path to a directory,
+ * then any symlink files found under the directory will be deleted, but not their targets when
+ * deleting source after move and deleting destination before copy/move.
+ *
+ * @param label The optional label for file to copy or move. This can optionally be {@code null}.
+ * @param srcFilePath The {@code source path} for file to copy or move.
+ * @param destFilePath The {@code destination path} for file to copy or move.
+ * @param moveFile The {@code boolean} that decides if source file needs to be copied or moved.
+ * If set to {@code true}, then source file will be moved, otherwise it will be
+ * copied.
+ * @param ignoreNonExistentSrcFile The {@code boolean} that decides if it should be considered an
+ * error if source file to copied or moved doesn't exist.
+ * @param allowedFileTypeFlags The flags that are matched against the source file's {@link FileType}
+ * to see if it should be copied/moved or not. This is a safety measure
+ * to prevent accidental copy/move/delete of the wrong type of file,
+ * like a directory instead of a regular file. You can pass
+ * {@link FileTypes#FILE_TYPE_ANY_FLAGS} to allow copy/move of any file type.
+ * @param overwrite The {@code boolean} that decides if destination file should be overwritten if
+ * it already exists. If set to {@code true}, then destination file will be
+ * deleted before source is copied or moved.
+ * @param overwriteOnlyIfDestSameFileTypeAsSrc The {@code boolean} that decides if overwrite should
+ * only be done if destination file is also the same file
+ * type as the source file.
+ * @return Returns the {@code error} if copy or move was not successful, otherwise {@code null}.
+ */
+ public static Error copyOrMoveFile(String label, final String srcFilePath, final String destFilePath,
+ final boolean moveFile, final boolean ignoreNonExistentSrcFile, int allowedFileTypeFlags,
+ final boolean overwrite, final boolean overwriteOnlyIfDestSameFileTypeAsSrc) {
+ label = (label == null || label.isEmpty() ? "" : label + " ");
+ if (srcFilePath == null || srcFilePath.isEmpty()) return FunctionErrno.ERRNO_NULL_OR_EMPTY_PARAMETER.getError(label + "source file path", "copyOrMoveFile");
+ if (destFilePath == null || destFilePath.isEmpty()) return FunctionErrno.ERRNO_NULL_OR_EMPTY_PARAMETER.getError(label + "destination file path", "copyOrMoveFile");
+
+ String mode = (moveFile ? "Moving" : "Copying");
+ String modePast = (moveFile ? "moved" : "copied");
+
+ Error error;
+
+ try {
+ Logger.logVerbose(LOG_TAG, mode + " " + label + "source file from \"" + srcFilePath + "\" to destination \"" + destFilePath + "\"");
+
+ File srcFile = new File(srcFilePath);
+ File destFile = new File(destFilePath);
+
+ FileType srcFileType = getFileType(srcFilePath, false);
+ FileType destFileType = getFileType(destFilePath, false);
+
+ String srcFileCanonicalPath = srcFile.getCanonicalPath();
+ String destFileCanonicalPath = destFile.getCanonicalPath();
+
+ // If source file does not exist
+ if (srcFileType == FileType.NO_EXIST) {
+ // If copy or move is to be ignored if source file is not found
+ if (ignoreNonExistentSrcFile)
+ return null;
+ // Else return with error
+ else {
+ label += "source file";
+ return FileUtilsErrno.ERRNO_FILE_NOT_FOUND_AT_PATH.getError(label, srcFilePath).setLabel(label);
+ }
+ }
+
+ // If the file type of the source file does not exist in the allowedFileTypeFlags, then return with error
+ if ((allowedFileTypeFlags & srcFileType.getValue()) <= 0)
+ return FileUtilsErrno.ERRNO_FILE_NOT_AN_ALLOWED_FILE_TYPE.getError(label + "source file meant to be " + modePast, srcFilePath, FileTypes.convertFileTypeFlagsToNamesString(allowedFileTypeFlags));
+
+ // If source and destination file path are the same
+ if (srcFileCanonicalPath.equals(destFileCanonicalPath))
+ return FileUtilsErrno.ERRNO_COPYING_OR_MOVING_FILE_TO_SAME_PATH.getError(mode + " " + label + "source file", srcFilePath, destFilePath);
+
+ // If destination exists
+ if (destFileType != FileType.NO_EXIST) {
+ // If destination must not be overwritten
+ if (!overwrite) {
+ return null;
+ }
+
+ // If overwriteOnlyIfDestSameFileTypeAsSrc is enabled but destination file does not match source file type
+ if (overwriteOnlyIfDestSameFileTypeAsSrc && destFileType != srcFileType)
+ return FileUtilsErrno.ERRNO_CANNOT_OVERWRITE_A_DIFFERENT_FILE_TYPE.getError(label + "source file", mode.toLowerCase(), srcFilePath, destFilePath, destFileType.getName(), srcFileType.getName());
+
+ // Delete the destination file
+ error = deleteFile(label + "destination", destFilePath, true);
+ if (error != null)
+ return error;
+ }
+
+
+ // Copy or move source file to dest
+ boolean copyFile = !moveFile;
+
+ // If moveFile is true
+ if (moveFile) {
+ // We first try to rename source file to destination file to save a copy operation in case both source and destination are on the same filesystem
+ Logger.logVerbose(LOG_TAG, "Attempting to rename source to destination.");
+
+ // https://cs.android.com/android/platform/superproject/+/android-11.0.0_r3:libcore/ojluni/src/main/java/java/io/UnixFileSystem.java;l=358
+ // https://cs.android.com/android/platform/superproject/+/android-11.0.0_r3:libcore/luni/src/main/java/android/system/Os.java;l=512
+ // Uses File.getPath() to get the path of source and destination and not the canonical path
+ if (!srcFile.renameTo(destFile)) {
+ // If destination directory is a subdirectory of the source directory
+ // Copying is still allowed by copyDirectory() by excluding destination directory files
+ if (srcFileType == FileType.DIRECTORY && destFileCanonicalPath.startsWith(srcFileCanonicalPath + File.separator))
+ return FileUtilsErrno.ERRNO_CANNOT_MOVE_DIRECTORY_TO_SUB_DIRECTORY_OF_ITSELF.getError(label + "source directory", srcFilePath, destFilePath);
+
+ // If rename failed, then we copy
+ Logger.logVerbose(LOG_TAG, "Renaming " + label + "source file to destination failed, attempting to copy.");
+ copyFile = true;
+ }
+ }
+
+ // If moveFile is false or renameTo failed while moving
+ if (copyFile) {
+ Logger.logVerbose(LOG_TAG, "Attempting to copy source to destination.");
+
+ // Create the dest file parent directory
+ error = createParentDirectoryFile(label + "dest file parent", destFilePath);
+ if (error != null)
+ return error;
+
+ if (srcFileType == FileType.DIRECTORY) {
+ // Will give runtime exceptions on android < 8 due to missing classes like java.nio.file.Path if org.apache.commons.io version > 2.5
+ org.apache.commons.io.FileUtils.copyDirectory(srcFile, destFile, true);
+ } else if (srcFileType == FileType.SYMLINK) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ java.nio.file.Files.copy(srcFile.toPath(), destFile.toPath(), LinkOption.NOFOLLOW_LINKS, StandardCopyOption.REPLACE_EXISTING);
+ } else {
+ // read the target for the source file and create a symlink at dest
+ // source file metadata will be lost
+ error = createSymlinkFile(label + "dest", Os.readlink(srcFilePath), destFilePath);
+ if (error != null)
+ return error;
+ }
+ } else {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ java.nio.file.Files.copy(srcFile.toPath(), destFile.toPath(), LinkOption.NOFOLLOW_LINKS, StandardCopyOption.REPLACE_EXISTING);
+ } else {
+ // Will give runtime exceptions on android < 8 due to missing classes like java.nio.file.Path if org.apache.commons.io version > 2.5
+ org.apache.commons.io.FileUtils.copyFile(srcFile, destFile, true);
+ }
+ }
+ }
+
+ // If source file had to be moved
+ if (moveFile) {
+ // Delete the source file since copying would have succeeded
+ error = deleteFile(label + "source", srcFilePath, true);
+ if (error != null)
+ return error;
+ }
+
+ Logger.logVerbose(LOG_TAG, mode + " successful.");
+ }
+ catch (Exception e) {
+ return FileUtilsErrno.ERRNO_COPYING_OR_MOVING_FILE_FAILED_WITH_EXCEPTION.getError(e, mode + " " + label + "file", srcFilePath, destFilePath, e.getMessage());
+ }
+
+ return null;
+ }
+
+
+
+ /**
+ * Delete regular file at path.
+ *
+ * This function is a wrapper for {@link #deleteFile(String, String, boolean, boolean, int)}.
+ *
+ * @param label The optional label for file to delete. This can optionally be {@code null}.
+ * @param filePath The {@code path} for file to delete.
+ * @param ignoreNonExistentFile The {@code boolean} that decides if it should be considered an
+ * error if file to deleted doesn't exist.
+ * @return Returns the {@code error} if deletion was not successful, otherwise {@code null}.
+ */
+ public static Error deleteRegularFile(String label, final String filePath, final boolean ignoreNonExistentFile) {
+ return deleteFile(label, filePath, ignoreNonExistentFile, false, FileType.REGULAR.getValue());
+ }
+
+ /**
+ * Delete directory file at path.
+ *
+ * This function is a wrapper for {@link #deleteFile(String, String, boolean, boolean, int)}.
+ *
+ * @param label The optional label for file to delete. This can optionally be {@code null}.
+ * @param filePath The {@code path} for file to delete.
+ * @param ignoreNonExistentFile The {@code boolean} that decides if it should be considered an
+ * error if file to deleted doesn't exist.
+ * @return Returns the {@code error} if deletion was not successful, otherwise {@code null}.
+ */
+ public static Error deleteDirectoryFile(String label, final String filePath, final boolean ignoreNonExistentFile) {
+ return deleteFile(label, filePath, ignoreNonExistentFile, false, FileType.DIRECTORY.getValue());
+ }
+
+ /**
+ * Delete symlink file at path.
+ *
+ * This function is a wrapper for {@link #deleteFile(String, String, boolean, boolean, int)}.
+ *
+ * @param label The optional label for file to delete. This can optionally be {@code null}.
+ * @param filePath The {@code path} for file to delete.
+ * @param ignoreNonExistentFile The {@code boolean} that decides if it should be considered an
+ * error if file to deleted doesn't exist.
+ * @return Returns the {@code error} if deletion was not successful, otherwise {@code null}.
+ */
+ public static Error deleteSymlinkFile(String label, final String filePath, final boolean ignoreNonExistentFile) {
+ return deleteFile(label, filePath, ignoreNonExistentFile, false, FileType.SYMLINK.getValue());
+ }
+
+ /**
+ * Delete socket file at path.
+ *
+ * This function is a wrapper for {@link #deleteFile(String, String, boolean, boolean, int)}.
+ *
+ * @param label The optional label for file to delete. This can optionally be {@code null}.
+ * @param filePath The {@code path} for file to delete.
+ * @param ignoreNonExistentFile The {@code boolean} that decides if it should be considered an
+ * error if file to deleted doesn't exist.
+ * @return Returns the {@code error} if deletion was not successful, otherwise {@code null}.
+ */
+ public static Error deleteSocketFile(String label, final String filePath, final boolean ignoreNonExistentFile) {
+ return deleteFile(label, filePath, ignoreNonExistentFile, false, FileType.SOCKET.getValue());
+ }
+
+ /**
+ * Delete regular, directory or symlink file at path.
+ *
+ * This function is a wrapper for {@link #deleteFile(String, String, boolean, boolean, int)}.
+ *
+ * @param label The optional label for file to delete. This can optionally be {@code null}.
+ * @param filePath The {@code path} for file to delete.
+ * @param ignoreNonExistentFile The {@code boolean} that decides if it should be considered an
+ * error if file to deleted doesn't exist.
+ * @return Returns the {@code error} if deletion was not successful, otherwise {@code null}.
+ */
+ public static Error deleteFile(String label, final String filePath, final boolean ignoreNonExistentFile) {
+ return deleteFile(label, filePath, ignoreNonExistentFile, false, FileTypes.FILE_TYPE_NORMAL_FLAGS);
+ }
+
+ /**
+ * Delete file at path.
+ *
+ * The {@code filePath} must be the canonical path to the file to be deleted since symlinks will
+ * not be followed.
+ * If the {@code filePath} is a canonical path to a directory, then any symlink files found under
+ * the directory will be deleted, but not their targets.
+ *
+ * @param label The optional label for file to delete. This can optionally be {@code null}.
+ * @param filePath The {@code path} for file to delete.
+ * @param ignoreNonExistentFile The {@code boolean} that decides if it should be considered an
+ * error if file to deleted doesn't exist.
+ * @param ignoreWrongFileType The {@code boolean} that decides if it should be considered an
+ * error if file type is not one from {@code allowedFileTypeFlags}.
+ * @param allowedFileTypeFlags The flags that are matched against the file's {@link FileType} to
+ * see if it should be deleted or not. This is a safety measure to
+ * prevent accidental deletion of the wrong type of file, like a
+ * directory instead of a regular file. You can pass
+ * {@link FileTypes#FILE_TYPE_ANY_FLAGS} to allow deletion of any file type.
+ * @return Returns the {@code error} if deletion was not successful, otherwise {@code null}.
+ */
+ public static Error deleteFile(String label, final String filePath, final boolean ignoreNonExistentFile, final boolean ignoreWrongFileType, int allowedFileTypeFlags) {
+ label = (label == null || label.isEmpty() ? "" : label + " ");
+ if (filePath == null || filePath.isEmpty()) return FunctionErrno.ERRNO_NULL_OR_EMPTY_PARAMETER.getError(label + "file path", "deleteFile");
+
+ try {
+ File file = new File(filePath);
+ FileType fileType = getFileType(filePath, false);
+
+ Logger.logVerbose(LOG_TAG, "Processing delete of " + label + "file at path \"" + filePath + "\" of type \"" + fileType.getName() + "\"");
+
+ // If file does not exist
+ if (fileType == FileType.NO_EXIST) {
+ // If delete is to be ignored if file does not exist
+ if (ignoreNonExistentFile)
+ return null;
+ // Else return with error
+ else {
+ label += "file meant to be deleted";
+ return FileUtilsErrno.ERRNO_FILE_NOT_FOUND_AT_PATH.getError(label, filePath).setLabel(label);
+ }
+ }
+
+ // If the file type of the file does not exist in the allowedFileTypeFlags
+ if ((allowedFileTypeFlags & fileType.getValue()) <= 0) {
+ // If wrong file type is to be ignored
+ if (ignoreWrongFileType) {
+ Logger.logVerbose(LOG_TAG, "Ignoring deletion of " + label + "file at path \"" + filePath + "\" of type \"" + fileType.getName() + "\" not matching allowed file types: " + FileTypes.convertFileTypeFlagsToNamesString(allowedFileTypeFlags));
+ return null;
+ }
+
+ // Else return with error
+ return FileUtilsErrno.ERRNO_FILE_NOT_AN_ALLOWED_FILE_TYPE.getError(label + "file meant to be deleted", filePath, fileType.getName(), FileTypes.convertFileTypeFlagsToNamesString(allowedFileTypeFlags));
+ }
+
+ Logger.logVerbose(LOG_TAG, "Deleting " + label + "file at path \"" + filePath + "\"");
+ PathsKt.deleteRecursively(file.toPath());
+
+ // If file still exists after deleting it
+ fileType = getFileType(filePath, false);
+ if (fileType != FileType.NO_EXIST)
+ return FileUtilsErrno.ERRNO_FILE_STILL_EXISTS_AFTER_DELETING.getError(label + "file meant to be deleted", filePath);
+ }
+ catch (Exception e) {
+ return FileUtilsErrno.ERRNO_DELETING_FILE_FAILED_WITH_EXCEPTION.getError(e, label + "file", filePath, e.getMessage());
+ }
+
+ return null;
+ }
+
+
+
+ /**
+ * Clear contents of directory at path without deleting the directory. If directory does not exist
+ * it will be created automatically.
+ *
+ * This function is a wrapper for
+ * {@link #clearDirectory(String, String)}.
+ *
+ * @param filePath The {@code path} for directory to clear.
+ * @return Returns the {@code error} if clearing was not successful, otherwise {@code null}.
+ */
+ public static Error clearDirectory(String filePath) {
+ return clearDirectory(null, filePath);
+ }
+
+ /**
+ * Clear contents of directory at path without deleting the directory. If directory does not exist
+ * it will be created automatically.
+ *
+ * The {@code filePath} must be the canonical path to a directory since symlinks will not be followed.
+ * Any symlink files found under the directory will be deleted, but not their targets.
+ *
+ * @param label The optional label for directory to clear. This can optionally be {@code null}.
+ * @param filePath The {@code path} for directory to clear.
+ * @return Returns the {@code error} if clearing was not successful, otherwise {@code null}.
+ */
+ public static Error clearDirectory(String label, final String filePath) {
+ label = (label == null || label.isEmpty() ? "" : label + " ");
+ if (filePath == null || filePath.isEmpty()) return FunctionErrno.ERRNO_NULL_OR_EMPTY_PARAMETER.getError(label + "file path", "clearDirectory");
+
+ Error error;
+
+ try {
+ Logger.logVerbose(LOG_TAG, "Clearing " + label + "directory at path \"" + filePath + "\"");
+
+ File file = new File(filePath);
+ FileType fileType = getFileType(filePath, false);
+
+ // If file exists but not a directory file
+ if (fileType != FileType.NO_EXIST && fileType != FileType.DIRECTORY) {
+ return FileUtilsErrno.ERRNO_NON_DIRECTORY_FILE_FOUND.getError(label + "directory", filePath).setLabel(label + "directory");
+ }
+
+ // If directory exists, clear its contents
+ if (fileType == FileType.DIRECTORY) {
+ /* If an exception is thrown, the exception message might not contain the full errors.
+ * Individual failures get added to suppressed throwables. */
+ PathsKt.deleteRecursively(file.toPath());
+ }
+ // Else create it
+ else {
+ error = createDirectoryFile(label, filePath);
+ if (error != null)
+ return error;
+ }
+ } catch (Exception e) {
+ return FileUtilsErrno.ERRNO_CLEARING_DIRECTORY_FAILED_WITH_EXCEPTION.getError(e, label + "directory", filePath, e.getMessage());
+ }
+
+ return null;
+ }
+
+ /**
+ * Delete files under a directory older than x days.
+ *
+ * The {@code filePath} must be the canonical path to a directory since symlinks will not be followed.
+ * Any symlink files found under the directory will be deleted, but not their targets.
+ *
+ * @param label The optional label for directory to clear. This can optionally be {@code null}.
+ * @param filePath The {@code path} for directory to clear.
+ * @param dirFilter The optional filter to apply when finding subdirectories.
+ * If this parameter is {@code null}, subdirectories will not be included in the
+ * search. Use TrueFileFilter.INSTANCE to match all directories.
+ * @param days The x amount of days before which files should be deleted. This must be `>=0`.
+ * @param ignoreNonExistentFile The {@code boolean} that decides if it should be considered an
+ * error if file to deleted doesn't exist.
+ * @param allowedFileTypeFlags The flags that are matched against the file's {@link FileType} to
+ * see if it should be deleted or not. This is a safety measure to
+ * prevent accidental deletion of the wrong type of file, like a
+ * directory instead of a regular file. You can pass
+ * {@link FileTypes#FILE_TYPE_ANY_FLAGS} to allow deletion of any file type.
+ * @return Returns the {@code error} if deleting was not successful, otherwise {@code null}.
+ */
+ public static Error deleteFilesOlderThanXDays(String label, final String filePath, final IOFileFilter dirFilter, int days, final boolean ignoreNonExistentFile, int allowedFileTypeFlags) {
+ label = (label == null || label.isEmpty() ? "" : label + " ");
+ if (filePath == null || filePath.isEmpty()) return FunctionErrno.ERRNO_NULL_OR_EMPTY_PARAMETER.getError(label + "file path", "deleteFilesOlderThanXDays");
+ if (days < 0) return FunctionErrno.ERRNO_INVALID_PARAMETER.getError(label + "days", "deleteFilesOlderThanXDays", " It must be >= 0.");
+
+ Error error;
+
+ try {
+ Logger.logVerbose(LOG_TAG, "Deleting files under " + label + "directory at path \"" + filePath + "\" older than " + days + " days");
+
+ File file = new File(filePath);
+ FileType fileType = getFileType(filePath, false);
+
+ // If file exists but not a directory file
+ if (fileType != FileType.NO_EXIST && fileType != FileType.DIRECTORY) {
+ return FileUtilsErrno.ERRNO_NON_DIRECTORY_FILE_FOUND.getError(label + "directory", filePath).setLabel(label + "directory");
+ }
+
+ // If file does not exist
+ if (fileType == FileType.NO_EXIST) {
+ // If delete is to be ignored if file does not exist
+ if (ignoreNonExistentFile)
+ return null;
+ // Else return with error
+ else {
+ label += "directory under which files had to be deleted";
+ return FileUtilsErrno.ERRNO_FILE_NOT_FOUND_AT_PATH.getError(label, filePath).setLabel(label);
+ }
+ }
+
+ // TODO: Use FileAttributes with support for atime (default), mtime, ctime. Add regex for ignoring file and dir absolute paths.
+ // FIXME: iterateFiles() does not return subdirectories even with TrueFileFilter for file and dir.
+ // FIXME: Empty directories remain
+
+ // If directory exists, delete its contents
+ Calendar calendar = Calendar.getInstance();
+ calendar.add(Calendar.DATE, -(days));
+ // AgeFileFilter seems to apply to symlink destination timestamp instead of symlink file itself
+ Iterator filesToDelete =
+ org.apache.commons.io.FileUtils.iterateFiles(file, new AgeFileFilter(calendar.getTime()), dirFilter);
+ while (filesToDelete.hasNext()) {
+ File subFile = filesToDelete.next();
+ error = deleteFile(label + " directory sub", subFile.getAbsolutePath(), true, true, allowedFileTypeFlags);
+ if (error != null)
+ return error;
+ }
+ } catch (Exception e) {
+ return FileUtilsErrno.ERRNO_DELETING_FILES_OLDER_THAN_X_DAYS_FAILED_WITH_EXCEPTION.getError(e, label + "directory", filePath, days, e.getMessage());
+ }
+
+ return null;
+
+ }
+
+
+
+
+
+ /**
+ * Read a text {@link String} from file at path with a specific {@link Charset} into {@code dataString}.
+ *
+ * @param label The optional label for file to read. This can optionally be {@code null}.
+ * @param filePath The {@code path} for file to read.
+ * @param charset The {@link Charset} of the file. If this is {@code null},
+ * then default {@link Charset} will be used.
+ * @param dataStringBuilder The {@code StringBuilder} to read data into.
+ * @param ignoreNonExistentFile The {@code boolean} that decides if it should be considered an
+ * error if file to read doesn't exist.
+ * @return Returns the {@code error} if reading was not successful, otherwise {@code null}.
+ */
+ public static Error readTextFromFile(String label, final String filePath, Charset charset, @NonNull final StringBuilder dataStringBuilder, final boolean ignoreNonExistentFile) {
+ label = (label == null || label.isEmpty() ? "" : label + " ");
+ if (filePath == null || filePath.isEmpty()) return FunctionErrno.ERRNO_NULL_OR_EMPTY_PARAMETER.getError(label + "file path", "readStringFromFile");
+
+ Logger.logVerbose(LOG_TAG, "Reading text from " + label + "file at path \"" + filePath + "\"");
+
+ Error error;
+
+ FileType fileType = getFileType(filePath, false);
+
+ // If file exists but not a regular file
+ if (fileType != FileType.NO_EXIST && fileType != FileType.REGULAR) {
+ return FileUtilsErrno.ERRNO_NON_REGULAR_FILE_FOUND.getError(label + "file", filePath).setLabel(label + "file");
+ }
+
+ // If file does not exist
+ if (fileType == FileType.NO_EXIST) {
+ // If reading is to be ignored if file does not exist
+ if (ignoreNonExistentFile)
+ return null;
+ // Else return with error
+ else {
+ label += "file meant to be read";
+ return FileUtilsErrno.ERRNO_FILE_NOT_FOUND_AT_PATH.getError(label, filePath).setLabel(label);
+ }
+ }
+
+ if (charset == null) charset = Charset.defaultCharset();
+
+ // Check if charset is supported
+ error = isCharsetSupported(charset);
+ if (error != null)
+ return error;
+
+ FileInputStream fileInputStream = null;
+ BufferedReader bufferedReader = null;
+ try {
+ // Read text from file
+ fileInputStream = new FileInputStream(filePath);
+ bufferedReader = new BufferedReader(new InputStreamReader(fileInputStream, charset));
+
+ String receiveString;
+
+ boolean firstLine = true;
+ while ((receiveString = bufferedReader.readLine()) != null ) {
+ if (!firstLine) dataStringBuilder.append("\n"); else firstLine = false;
+ dataStringBuilder.append(receiveString);
+ }
+
+ Logger.logVerbose(LOG_TAG, Logger.getMultiLineLogStringEntry("String", DataUtils.getTruncatedCommandOutput(dataStringBuilder.toString(), Logger.LOGGER_ENTRY_MAX_SAFE_PAYLOAD, true, false, true), "-"));
+ } catch (Exception e) {
+ return FileUtilsErrno.ERRNO_READING_TEXT_FROM_FILE_FAILED_WITH_EXCEPTION.getError(e, label + "file", filePath, e.getMessage());
+ } finally {
+ closeCloseable(fileInputStream);
+ closeCloseable(bufferedReader);
+ }
+
+ return null;
+ }
+
+ public static class ReadSerializableObjectResult {
+ public final Error error;
+ public final Serializable serializableObject;
+
+ ReadSerializableObjectResult(Error error, Serializable serializableObject) {
+ this.error = error;
+ this.serializableObject = serializableObject;
+ }
+ }
+
+ /**
+ * Read a {@link Serializable} object from file at path.
+ *
+ * @param label The optional label for file to read. This can optionally be {@code null}.
+ * @param filePath The {@code path} for file to read.
+ * @param readObjectType The {@link Class} of the object.
+ * @param ignoreNonExistentFile The {@code boolean} that decides if it should be considered an
+ * error if file to read doesn't exist.
+ * @return Returns the {@code error} if reading was not successful, otherwise {@code null}.
+ */
+ @NonNull
+ public static ReadSerializableObjectResult readSerializableObjectFromFile(String label, final String filePath, Class readObjectType, final boolean ignoreNonExistentFile) {
+ label = (label == null || label.isEmpty() ? "" : label + " ");
+ if (filePath == null || filePath.isEmpty()) return new ReadSerializableObjectResult(FunctionErrno.ERRNO_NULL_OR_EMPTY_PARAMETER.getError(label + "file path", "readSerializableObjectFromFile"), null);
+
+ Logger.logVerbose(LOG_TAG, "Reading serializable object from " + label + "file at path \"" + filePath + "\"");
+
+ T serializableObject;
+
+ FileType fileType = getFileType(filePath, false);
+
+ // If file exists but not a regular file
+ if (fileType != FileType.NO_EXIST && fileType != FileType.REGULAR) {
+ return new ReadSerializableObjectResult(FileUtilsErrno.ERRNO_NON_REGULAR_FILE_FOUND.getError(label + "file", filePath).setLabel(label + "file"), null);
+ }
+
+ // If file does not exist
+ if (fileType == FileType.NO_EXIST) {
+ // If reading is to be ignored if file does not exist
+ if (ignoreNonExistentFile)
+ return new ReadSerializableObjectResult(null, null);
+ // Else return with error
+ else {
+ label += "file meant to be read";
+ return new ReadSerializableObjectResult(FileUtilsErrno.ERRNO_FILE_NOT_FOUND_AT_PATH.getError(label, filePath).setLabel(label), null);
+ }
+ }
+
+ FileInputStream fileInputStream = null;
+ ObjectInputStream objectInputStream = null;
+ try {
+ // Read serializable object from file
+ fileInputStream = new FileInputStream(filePath);
+ objectInputStream = new ObjectInputStream(fileInputStream);
+ //serializableObject = (T) objectInputStream.readObject();
+ serializableObject = readObjectType.cast(objectInputStream.readObject());
+
+ //Logger.logVerbose(LOG_TAG, Logger.getMultiLineLogStringEntry("String", DataUtils.getTruncatedCommandOutput(dataStringBuilder.toString(), Logger.LOGGER_ENTRY_MAX_SAFE_PAYLOAD, true, false, true), "-"));
+ } catch (Exception e) {
+ return new ReadSerializableObjectResult(FileUtilsErrno.ERRNO_READING_SERIALIZABLE_OBJECT_TO_FILE_FAILED_WITH_EXCEPTION.getError(e, label + "file", filePath, e.getMessage()), null);
+ } finally {
+ closeCloseable(fileInputStream);
+ closeCloseable(objectInputStream);
+ }
+
+ return new ReadSerializableObjectResult(null, serializableObject);
+ }
+
+ /**
+ * Write text {@code dataString} with a specific {@link Charset} to file at path.
+ *
+ * @param label The optional label for file to write. This can optionally be {@code null}.
+ * @param filePath The {@code path} for file to write.
+ * @param charset The {@link Charset} of the {@code dataString}. If this is {@code null},
+ * then default {@link Charset} will be used.
+ * @param dataString The data to write to file.
+ * @param append The {@code boolean} that decides if file should be appended to or not.
+ * @return Returns the {@code error} if writing was not successful, otherwise {@code null}.
+ */
+ public static Error writeTextToFile(String label, final String filePath, Charset charset, final String dataString, final boolean append) {
+ label = (label == null || label.isEmpty() ? "" : label + " ");
+ if (filePath == null || filePath.isEmpty()) return FunctionErrno.ERRNO_NULL_OR_EMPTY_PARAMETER.getError(label + "file path", "writeStringToFile");
+
+ Logger.logVerbose(LOG_TAG, Logger.getMultiLineLogStringEntry("Writing text to " + label + "file at path \"" + filePath + "\"", DataUtils.getTruncatedCommandOutput(dataString, Logger.LOGGER_ENTRY_MAX_SAFE_PAYLOAD, true, false, true), "-"));
+
+ Error error;
+
+ error = preWriteToFile(label, filePath);
+ if (error != null)
+ return error;
+
+ if (charset == null) charset = Charset.defaultCharset();
+
+ // Check if charset is supported
+ error = isCharsetSupported(charset);
+ if (error != null)
+ return error;
+
+ FileOutputStream fileOutputStream = null;
+ BufferedWriter bufferedWriter = null;
+ try {
+ // Write text to file
+ fileOutputStream = new FileOutputStream(filePath, append);
+ bufferedWriter = new BufferedWriter(new OutputStreamWriter(fileOutputStream, charset));
+
+ bufferedWriter.write(dataString);
+ bufferedWriter.flush();
+ } catch (Exception e) {
+ return FileUtilsErrno.ERRNO_WRITING_TEXT_TO_FILE_FAILED_WITH_EXCEPTION.getError(e, label + "file", filePath, e.getMessage());
+ } finally {
+ closeCloseable(fileOutputStream);
+ closeCloseable(bufferedWriter);
+ }
+
+ return null;
+ }
+
+ /**
+ * Write the {@link Serializable} {@code serializableObject} to file at path.
+ *
+ * @param label The optional label for file to write. This can optionally be {@code null}.
+ * @param filePath The {@code path} for file to write.
+ * @param serializableObject The object to write to file.
+ * @return Returns the {@code error} if writing was not successful, otherwise {@code null}.
+ */
+ public static Error writeSerializableObjectToFile(String label, final String filePath, final T serializableObject) {
+ label = (label == null || label.isEmpty() ? "" : label + " ");
+ if (filePath == null || filePath.isEmpty()) return FunctionErrno.ERRNO_NULL_OR_EMPTY_PARAMETER.getError(label + "file path", "writeSerializableObjectToFile");
+
+ Logger.logVerbose(LOG_TAG, "Writing serializable object to " + label + "file at path \"" + filePath + "\"");
+
+ Error error;
+
+ error = preWriteToFile(label, filePath);
+ if (error != null)
+ return error;
+
+ FileOutputStream fileOutputStream = null;
+ ObjectOutputStream objectOutputStream = null;
+ try {
+ // Write serializable object to file
+ fileOutputStream = new FileOutputStream(filePath);
+ objectOutputStream = new ObjectOutputStream(fileOutputStream);
+
+ objectOutputStream.writeObject(serializableObject);
+ objectOutputStream.flush();
+ } catch (Exception e) {
+ return FileUtilsErrno.ERRNO_WRITING_SERIALIZABLE_OBJECT_TO_FILE_FAILED_WITH_EXCEPTION.getError(e, label + "file", filePath, e.getMessage());
+ } finally {
+ closeCloseable(fileOutputStream);
+ closeCloseable(objectOutputStream);
+ }
+
+ return null;
+ }
+
+ private static Error preWriteToFile(String label, String filePath) {
+ Error error;
+
+ FileType fileType = getFileType(filePath, false);
+
+ // If file exists but not a regular file
+ if (fileType != FileType.NO_EXIST && fileType != FileType.REGULAR) {
+ return FileUtilsErrno.ERRNO_NON_REGULAR_FILE_FOUND.getError(label + "file", filePath).setLabel(label + "file");
+ }
+
+ // Create the file parent directory
+ error = createParentDirectoryFile(label + "file parent", filePath);
+ if (error != null)
+ return error;
+
+ return null;
+ }
+
+
+
+ /**
+ * Check if a specific {@link Charset} is supported.
+ *
+ * @param charset The {@link Charset} to check.
+ * @return Returns the {@code error} if charset is not supported or failed to check it, otherwise {@code null}.
+ */
+ public static Error isCharsetSupported(final Charset charset) {
+ if (charset == null) return FunctionErrno.ERRNO_NULL_OR_EMPTY_PARAMETER.getError("charset", "isCharsetSupported");
+
+ try {
+ if (!Charset.isSupported(charset.name())) {
+ return FileUtilsErrno.ERRNO_UNSUPPORTED_CHARSET.getError(charset.name());
+ }
+ } catch (Exception e) {
+ return FileUtilsErrno.ERRNO_CHECKING_IF_CHARSET_SUPPORTED_FAILED.getError(e, charset.name(), e.getMessage());
+ }
+
+ return null;
+ }
+
+
+
+ /**
+ * Close a {@link Closeable} object if not {@code null} and ignore any exceptions raised.
+ *
+ * @param closeable The {@link Closeable} object to close.
+ */
+ public static void closeCloseable(final Closeable closeable) {
+ if (closeable != null) {
+ try {
+ closeable.close();
+ }
+ catch (IOException e) {
+ // ignore
+ }
+ }
+ }
+
+
+
+ /**
+ * Set permissions for file at path. Existing permission outside the {@code permissionsToSet}
+ * will be removed.
+ *
+ * @param filePath The {@code path} for file to set permissions to.
+ * @param permissionsToSet The 3 character string that contains the "r", "w", "x" or "-" in-order.
+ */
+ public static void setFilePermissions(final String filePath, final String permissionsToSet) {
+ setFilePermissions(null, filePath, permissionsToSet);
+ }
+
+ /**
+ * Set permissions for file at path. Existing permission outside the {@code permissionsToSet}
+ * will be removed.
+ *
+ * @param label The optional label for the file. This can optionally be {@code null}.
+ * @param filePath The {@code path} for file to set permissions to.
+ * @param permissionsToSet The 3 character string that contains the "r", "w", "x" or "-" in-order.
+ */
+ public static void setFilePermissions(String label, final String filePath, final String permissionsToSet) {
+ label = (label == null || label.isEmpty() ? "" : label + " ");
+ if (filePath == null || filePath.isEmpty()) return;
+
+ if (!isValidPermissionString(permissionsToSet)) {
+ Logger.logError(LOG_TAG, "Invalid permissionsToSet passed to setFilePermissions: \"" + permissionsToSet + "\"");
+ return;
+ }
+
+ File file = new File(filePath);
+
+ if (permissionsToSet.contains("r")) {
+ if (!file.canRead()) {
+ Logger.logVerbose(LOG_TAG, "Setting read permissions for " + label + "file at path \"" + filePath + "\"");
+ file.setReadable(true);
+ }
+ } else {
+ if (file.canRead()) {
+ Logger.logVerbose(LOG_TAG, "Removing read permissions for " + label + "file at path \"" + filePath + "\"");
+ file.setReadable(false);
+ }
+ }
+
+
+ if (permissionsToSet.contains("w")) {
+ if (!file.canWrite()) {
+ Logger.logVerbose(LOG_TAG, "Setting write permissions for " + label + "file at path \"" + filePath + "\"");
+ file.setWritable(true);
+ }
+ } else {
+ if (file.canWrite()) {
+ Logger.logVerbose(LOG_TAG, "Removing write permissions for " + label + "file at path \"" + filePath + "\"");
+ file.setWritable(false);
+ }
+ }
+
+
+ if (permissionsToSet.contains("x")) {
+ if (!file.canExecute()) {
+ Logger.logVerbose(LOG_TAG, "Setting execute permissions for " + label + "file at path \"" + filePath + "\"");
+ file.setExecutable(true);
+ }
+ } else {
+ if (file.canExecute()) {
+ Logger.logVerbose(LOG_TAG, "Removing execute permissions for " + label + "file at path \"" + filePath + "\"");
+ file.setExecutable(false);
+ }
+ }
+ }
+
+
+
+ /**
+ * Set missing permissions for file at path. Existing permission outside the {@code permissionsToSet}
+ * will not be removed.
+ *
+ * @param filePath The {@code path} for file to set permissions to.
+ * @param permissionsToSet The 3 character string that contains the "r", "w", "x" or "-" in-order.
+ */
+ public static void setMissingFilePermissions(final String filePath, final String permissionsToSet) {
+ setMissingFilePermissions(null, filePath, permissionsToSet);
+ }
+
+ /**
+ * Set missing permissions for file at path. Existing permission outside the {@code permissionsToSet}
+ * will not be removed.
+ *
+ * @param label The optional label for the file. This can optionally be {@code null}.
+ * @param filePath The {@code path} for file to set permissions to.
+ * @param permissionsToSet The 3 character string that contains the "r", "w", "x" or "-" in-order.
+ */
+ public static void setMissingFilePermissions(String label, final String filePath, final String permissionsToSet) {
+ label = (label == null || label.isEmpty() ? "" : label + " ");
+ if (filePath == null || filePath.isEmpty()) return;
+
+ if (!isValidPermissionString(permissionsToSet)) {
+ Logger.logError(LOG_TAG, "Invalid permissionsToSet passed to setMissingFilePermissions: \"" + permissionsToSet + "\"");
+ return;
+ }
+
+ File file = new File(filePath);
+
+ if (permissionsToSet.contains("r") && !file.canRead()) {
+ Logger.logVerbose(LOG_TAG, "Setting missing read permissions for " + label + "file at path \"" + filePath + "\"");
+ file.setReadable(true);
+ }
+
+ if (permissionsToSet.contains("w") && !file.canWrite()) {
+ Logger.logVerbose(LOG_TAG, "Setting missing write permissions for " + label + "file at path \"" + filePath + "\"");
+ file.setWritable(true);
+ }
+
+ if (permissionsToSet.contains("x") && !file.canExecute()) {
+ Logger.logVerbose(LOG_TAG, "Setting missing execute permissions for " + label + "file at path \"" + filePath + "\"");
+ file.setExecutable(true);
+ }
+ }
+
+
+
+ /**
+ * Checking missing permissions for file at path.
+ *
+ * @param filePath The {@code path} for file to check permissions for.
+ * @param permissionsToCheck The 3 character string that contains the "r", "w", "x" or "-" in-order.
+ * @param ignoreIfNotExecutable The {@code boolean} that decides if missing executable permission
+ * error is to be ignored.
+ * @return Returns the {@code error} if validating permissions failed, otherwise {@code null}.
+ */
+ public static Error checkMissingFilePermissions(final String filePath, final String permissionsToCheck, final boolean ignoreIfNotExecutable) {
+ return checkMissingFilePermissions(null, filePath, permissionsToCheck, ignoreIfNotExecutable);
+ }
+
+ /**
+ * Checking missing permissions for file at path.
+ *
+ * @param label The optional label for the file. This can optionally be {@code null}.
+ * @param filePath The {@code path} for file to check permissions for.
+ * @param permissionsToCheck The 3 character string that contains the "r", "w", "x" or "-" in-order.
+ * @param ignoreIfNotExecutable The {@code boolean} that decides if missing executable permission
+ * error is to be ignored.
+ * @return Returns the {@code error} if validating permissions failed, otherwise {@code null}.
+ */
+ public static Error checkMissingFilePermissions(String label, final String filePath, final String permissionsToCheck, final boolean ignoreIfNotExecutable) {
+ label = (label == null || label.isEmpty() ? "" : label + " ");
+ if (filePath == null || filePath.isEmpty()) return FunctionErrno.ERRNO_NULL_OR_EMPTY_PARAMETER.getError(label + "file path", "checkMissingFilePermissions");
+
+ if (!isValidPermissionString(permissionsToCheck)) {
+ Logger.logError(LOG_TAG, "Invalid permissionsToCheck passed to checkMissingFilePermissions: \"" + permissionsToCheck + "\"");
+ return FileUtilsErrno.ERRNO_INVALID_FILE_PERMISSIONS_STRING_TO_CHECK.getError();
+ }
+
+ File file = new File(filePath);
+
+ // If file is not readable
+ if (permissionsToCheck.contains("r") && !file.canRead()) {
+ return FileUtilsErrno.ERRNO_FILE_NOT_READABLE.getError(label + "file", filePath).setLabel(label + "file");
+ }
+
+ // If file is not writable
+ if (permissionsToCheck.contains("w") && !file.canWrite()) {
+ return FileUtilsErrno.ERRNO_FILE_NOT_WRITABLE.getError(label + "file", filePath).setLabel(label + "file");
+ }
+ // If file is not executable
+ // This canExecute() will give "avc: granted { execute }" warnings for target sdk 29
+ else if (permissionsToCheck.contains("x") && !file.canExecute() && !ignoreIfNotExecutable) {
+ return FileUtilsErrno.ERRNO_FILE_NOT_EXECUTABLE.getError(label + "file", filePath).setLabel(label + "file");
+ }
+
+ return null;
+ }
+
+
+
+ /**
+ * Checks whether string exactly matches the 3 character permission string that
+ * contains the "r", "w", "x" or "-" in-order.
+ *
+ * @param string The {@link String} to check.
+ * @return Returns {@code true} if string exactly matches a permission string, otherwise {@code false}.
+ */
+ public static boolean isValidPermissionString(final String string) {
+ if (string == null || string.isEmpty()) return false;
+ return Pattern.compile("^([r-])[w-][x-]$", 0).matcher(string).matches();
+ }
+
+
+
+ /**
+ * Get a {@link Error} that contains a shorter version of {@link Errno} message.
+ *
+ * @param error The original {@link Error} returned by one of the {@link FileUtils} functions.
+ * @return Returns the shorter {@link Error} if one exists, otherwise original {@code error}.
+ */
+ public static Error getShortFileUtilsError(final Error error) {
+ String type = error.getType();
+ if (!FileUtilsErrno.TYPE.equals(type)) return error;
+
+ Errno shortErrno = FileUtilsErrno.ERRNO_SHORT_MAPPING.get(Errno.valueOf(type, error.getCode()));
+ if (shortErrno == null) return error;
+
+ List throwables = error.getThrowablesList();
+ if (throwables.isEmpty())
+ return shortErrno.getError(DataUtils.getDefaultIfNull(error.getLabel(), "file"));
+ else
+ return shortErrno.getError(throwables, error.getLabel(), "file");
+ }
+
+
+ /**
+ * Get file dirname for file at {@code filePath}.
+ *
+ * @param filePath The {@code path} for file.
+ * @return Returns the file dirname if not {@code null}.
+ */
+ public static String getFileDirname(String filePath) {
+ if (DataUtils.isNullOrEmpty(filePath)) return null;
+ int lastSlash = filePath.lastIndexOf('/');
+ return (lastSlash == -1) ? null : filePath.substring(0, lastSlash);
+ }
+
+ /**
+ * Get file basename for file at {@code filePath}.
+ *
+ * @param filePath The {@code path} for file.
+ * @return Returns the file basename if not {@code null}.
+ */
+ public static String getFileBasename(String filePath) {
+ if (DataUtils.isNullOrEmpty(filePath)) return null;
+ int lastSlash = filePath.lastIndexOf('/');
+ return (lastSlash == -1) ? filePath : filePath.substring(lastSlash + 1);
+ }
+
+ /**
+ * Get file basename for file at {@code filePath} without extension.
+ *
+ * @param filePath The {@code path} for file.
+ * @return Returns the file basename without extension if not {@code null}.
+ */
+ public static String getFileBasenameWithoutExtension(String filePath) {
+ String fileBasename = getFileBasename(filePath);
+ if (DataUtils.isNullOrEmpty(fileBasename)) return null;
+ int lastDot = fileBasename.lastIndexOf('.');
+ return (lastDot == -1) ? fileBasename : fileBasename.substring(0, lastDot);
+ }
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/file/FileUtilsErrno.java b/termux/termux-shared/src/main/java/com/termux/shared/file/FileUtilsErrno.java
new file mode 100644
index 000000000..7e336aad3
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/file/FileUtilsErrno.java
@@ -0,0 +1,111 @@
+package com.termux.shared.file;
+
+import com.termux.shared.errors.Errno;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/** The {@link Class} that defines FileUtils error messages and codes. */
+public class FileUtilsErrno extends Errno {
+
+ public static final String TYPE = "FileUtils Error";
+
+
+ /* Errors for null or empty paths (100-150) */
+ public static final Errno ERRNO_EXECUTABLE_REQUIRED = new Errno(TYPE, 100, "Executable required.");
+ public static final Errno ERRNO_NULL_OR_EMPTY_REGULAR_FILE_PATH = new Errno(TYPE, 101, "The regular file path is null or empty.");
+ public static final Errno ERRNO_NULL_OR_EMPTY_REGULAR_FILE = new Errno(TYPE, 102, "The regular file is null or empty.");
+ public static final Errno ERRNO_NULL_OR_EMPTY_EXECUTABLE_FILE_PATH = new Errno(TYPE, 103, "The executable file path is null or empty.");
+ public static final Errno ERRNO_NULL_OR_EMPTY_EXECUTABLE_FILE = new Errno(TYPE, 104, "The executable file is null or empty.");
+ public static final Errno ERRNO_NULL_OR_EMPTY_DIRECTORY_FILE_PATH = new Errno(TYPE, 105, "The directory file path is null or empty.");
+ public static final Errno ERRNO_NULL_OR_EMPTY_DIRECTORY_FILE = new Errno(TYPE, 106, "The directory file is null or empty.");
+
+
+
+ /* Errors for invalid or not found files at path (150-200) */
+ public static final Errno ERRNO_FILE_NOT_FOUND_AT_PATH = new Errno(TYPE, 150, "The %1$s not found at path \"%2$s\".");
+ public static final Errno ERRNO_FILE_NOT_FOUND_AT_PATH_SHORT = new Errno(TYPE, 151, "The %1$s not found at path.");
+
+ public static final Errno ERRNO_NON_REGULAR_FILE_FOUND = new Errno(TYPE, 152, "Non-regular file found at %1$s path \"%2$s\".");
+ public static final Errno ERRNO_NON_REGULAR_FILE_FOUND_SHORT = new Errno(TYPE, 153, "Non-regular file found at %1$s path.");
+ public static final Errno ERRNO_NON_DIRECTORY_FILE_FOUND = new Errno(TYPE, 154, "Non-directory file found at %1$s path \"%2$s\".");
+ public static final Errno ERRNO_NON_DIRECTORY_FILE_FOUND_SHORT = new Errno(TYPE, 155, "Non-directory file found at %1$s path.");
+ public static final Errno ERRNO_NON_SYMLINK_FILE_FOUND = new Errno(TYPE, 156, "Non-symlink file found at %1$s path \"%2$s\".");
+ public static final Errno ERRNO_NON_SYMLINK_FILE_FOUND_SHORT = new Errno(TYPE, 157, "Non-symlink file found at %1$s path.");
+
+ public static final Errno ERRNO_FILE_NOT_AN_ALLOWED_FILE_TYPE = new Errno(TYPE, 158, "The %1$s found at path \"%2$s\" of type \"%3$s\" is not one of allowed file types \"%4$s\".");
+ public static final Errno ERRNO_NON_EMPTY_DIRECTORY_FILE = new Errno(TYPE, 159, "The %1$s directory at path \"%2$s\" is not empty.");
+
+ public static final Errno ERRNO_VALIDATE_FILE_EXISTENCE_AND_PERMISSIONS_FAILED_WITH_EXCEPTION = new Errno(TYPE, 160, "Validating file existence and permissions of %1$s at path \"%2$s\" failed.\nException: %3$s");
+ public static final Errno ERRNO_VALIDATE_DIRECTORY_EXISTENCE_AND_PERMISSIONS_FAILED_WITH_EXCEPTION = new Errno(TYPE, 161, "Validating directory existence and permissions of %1$s at path \"%2$s\" failed.\nException: %3$s");
+ public static final Errno ERRNO_VALIDATE_DIRECTORY_EMPTY_OR_ONLY_CONTAINS_SPECIFIC_FILES_FAILED_WITH_EXCEPTION = new Errno(TYPE, 162, "Validating directory is empty or only contains specific files of %1$s at path \"%2$s\" failed.\nException: %3$s");
+
+
+
+ /* Errors for file creation (200-250) */
+ public static final Errno ERRNO_CREATING_FILE_FAILED = new Errno(TYPE, 200, "Creating %1$s at path \"%2$s\" failed.");
+ public static final Errno ERRNO_CREATING_FILE_FAILED_WITH_EXCEPTION = new Errno(TYPE, 201, "Creating %1$s at path \"%2$s\" failed.\nException: %3$s");
+
+ public static final Errno ERRNO_CANNOT_OVERWRITE_A_NON_SYMLINK_FILE_TYPE = new Errno(TYPE, 202, "Cannot overwrite %1$s while creating symlink at \"%2$s\" to \"%3$s\" since destination file type \"%4$s\" is not a symlink.");
+ public static final Errno ERRNO_CREATING_SYMLINK_FILE_FAILED_WITH_EXCEPTION = new Errno(TYPE, 203, "Creating %1$s at path \"%2$s\" to \"%3$s\" failed.\nException: %4$s");
+
+
+
+ /* Errors for file copying and moving (250-300) */
+ public static final Errno ERRNO_COPYING_OR_MOVING_FILE_FAILED_WITH_EXCEPTION = new Errno(TYPE, 250, "%1$s from \"%2$s\" to \"%3$s\" failed.\nException: %4$s");
+ public static final Errno ERRNO_COPYING_OR_MOVING_FILE_TO_SAME_PATH = new Errno(TYPE, 251, "%1$s from \"%2$s\" to \"%3$s\" cannot be done since they point to the same path.");
+ public static final Errno ERRNO_CANNOT_OVERWRITE_A_DIFFERENT_FILE_TYPE = new Errno(TYPE, 252, "Cannot overwrite %1$s while %2$s it from \"%3$s\" to \"%4$s\" since destination file type \"%5$s\" is different from source file type \"%6$s\".");
+ public static final Errno ERRNO_CANNOT_MOVE_DIRECTORY_TO_SUB_DIRECTORY_OF_ITSELF = new Errno(TYPE, 253, "Cannot move %1$s from \"%2$s\" to \"%3$s\" since destination is a subdirectory of the source.");
+
+
+
+ /* Errors for file deletion (300-350) */
+ public static final Errno ERRNO_DELETING_FILE_FAILED = new Errno(TYPE, 300, "Deleting %1$s at path \"%2$s\" failed.");
+ public static final Errno ERRNO_DELETING_FILE_FAILED_WITH_EXCEPTION = new Errno(TYPE, 301, "Deleting %1$s at path \"%2$s\" failed.\nException: %3$s");
+ public static final Errno ERRNO_CLEARING_DIRECTORY_FAILED_WITH_EXCEPTION = new Errno(TYPE, 302, "Clearing %1$s at path \"%2$s\" failed.\nException: %3$s");
+ public static final Errno ERRNO_FILE_STILL_EXISTS_AFTER_DELETING = new Errno(TYPE, 303, "The %1$s still exists after deleting it from \"%2$s\".");
+ public static final Errno ERRNO_DELETING_FILES_OLDER_THAN_X_DAYS_FAILED_WITH_EXCEPTION = new Errno(TYPE, 304, "Deleting %1$s under directory at path \"%2$s\" old than %3$s days failed.\nException: %4$s");
+
+
+
+ /* Errors for file reading and writing (350-400) */
+ public static final Errno ERRNO_READING_TEXT_FROM_FILE_FAILED_WITH_EXCEPTION = new Errno(TYPE, 350, "Reading text from %1$s at path \"%2$s\" failed.\nException: %3$s");
+ public static final Errno ERRNO_WRITING_TEXT_TO_FILE_FAILED_WITH_EXCEPTION = new Errno(TYPE, 351, "Writing text to %1$s at path \"%2$s\" failed.\nException: %3$s");
+ public static final Errno ERRNO_UNSUPPORTED_CHARSET = new Errno(TYPE, 352, "Unsupported charset \"%1$s\"");
+ public static final Errno ERRNO_CHECKING_IF_CHARSET_SUPPORTED_FAILED = new Errno(TYPE, 353, "Checking if charset \"%1$s\" is supported failed.\nException: %2$s");
+ public static final Errno ERRNO_GET_CHARSET_FOR_NAME_FAILED = new Errno(TYPE, 354, "The \"%1$s\" charset is not supported.\nException: %2$s");
+ public static final Errno ERRNO_READING_SERIALIZABLE_OBJECT_TO_FILE_FAILED_WITH_EXCEPTION = new Errno(TYPE, 355, "Reading serializable object from %1$s at path \"%2$s\" failed.\nException: %3$s");
+ public static final Errno ERRNO_WRITING_SERIALIZABLE_OBJECT_TO_FILE_FAILED_WITH_EXCEPTION = new Errno(TYPE, 356, "Writing serializable object to %1$s at path \"%2$s\" failed.\nException: %3$s");
+
+
+
+ /* Errors for invalid file permissions (400-450) */
+ public static final Errno ERRNO_INVALID_FILE_PERMISSIONS_STRING_TO_CHECK = new Errno(TYPE, 400, "The file permission string to check is invalid.");
+ public static final Errno ERRNO_FILE_NOT_READABLE = new Errno(TYPE, 401, "The %1$s at path \"%2$s\" is not readable. Permission Denied.");
+ public static final Errno ERRNO_FILE_NOT_READABLE_SHORT = new Errno(TYPE, 402, "The %1$s at path is not readable. Permission Denied.");
+ public static final Errno ERRNO_FILE_NOT_WRITABLE = new Errno(TYPE, 403, "The %1$s at path \"%2$s\" is not writable. Permission Denied.");
+ public static final Errno ERRNO_FILE_NOT_WRITABLE_SHORT = new Errno(TYPE, 404, "The %1$s at path is not writable. Permission Denied.");
+ public static final Errno ERRNO_FILE_NOT_EXECUTABLE = new Errno(TYPE, 405, "The %1$s at path \"%2$s\" is not executable. Permission Denied.");
+ public static final Errno ERRNO_FILE_NOT_EXECUTABLE_SHORT = new Errno(TYPE, 406, "The %1$s at path is not executable. Permission Denied.");
+
+
+ FileUtilsErrno(final String type, final int code, final String message) {
+ super(type, code, message);
+ }
+
+
+
+ /** Defines the {@link Errno} mapping to get a shorter version of {@link FileUtilsErrno}. */
+ public static final Map ERRNO_SHORT_MAPPING = new HashMap() {{
+ put(ERRNO_FILE_NOT_FOUND_AT_PATH, ERRNO_FILE_NOT_FOUND_AT_PATH_SHORT);
+
+ put(ERRNO_NON_REGULAR_FILE_FOUND, ERRNO_NON_REGULAR_FILE_FOUND_SHORT);
+ put(ERRNO_NON_DIRECTORY_FILE_FOUND, ERRNO_NON_DIRECTORY_FILE_FOUND_SHORT);
+ put(ERRNO_NON_SYMLINK_FILE_FOUND, ERRNO_NON_SYMLINK_FILE_FOUND_SHORT);
+
+ put(ERRNO_FILE_NOT_READABLE, ERRNO_FILE_NOT_READABLE_SHORT);
+ put(ERRNO_FILE_NOT_WRITABLE, ERRNO_FILE_NOT_WRITABLE_SHORT);
+ put(ERRNO_FILE_NOT_EXECUTABLE, ERRNO_FILE_NOT_EXECUTABLE_SHORT);
+ }};
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/file/filesystem/FileAttributes.java b/termux/termux-shared/src/main/java/com/termux/shared/file/filesystem/FileAttributes.java
new file mode 100644
index 000000000..4565acc3d
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/file/filesystem/FileAttributes.java
@@ -0,0 +1,418 @@
+/*
+ * Copyright (c) 2008, 2013, Oracle and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ *
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Oracle designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
+ * or visit www.oracle.com if you need additional information or have any
+ * questions.
+ */
+
+package com.termux.shared.file.filesystem;
+
+import android.os.Build;
+import android.system.StructStat;
+
+import androidx.annotation.NonNull;
+
+import java.io.File;
+import java.io.FileDescriptor;
+import java.io.IOException;
+import java.util.concurrent.TimeUnit;
+import java.util.Set;
+import java.util.HashSet;
+
+/**
+ * Unix implementation of PosixFileAttributes.
+ * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r3:libcore/ojluni/src/main/java/sun/nio/fs/UnixFileAttributes.java
+ */
+
+public class FileAttributes {
+ private String filePath;
+ private FileDescriptor fileDescriptor;
+
+ private int st_mode;
+ private long st_ino;
+ private long st_dev;
+ private long st_rdev;
+ private long st_nlink;
+ private int st_uid;
+ private int st_gid;
+ private long st_size;
+ private long st_blksize;
+ private long st_blocks;
+ private long st_atime_sec;
+ private long st_atime_nsec;
+ private long st_mtime_sec;
+ private long st_mtime_nsec;
+ private long st_ctime_sec;
+ private long st_ctime_nsec;
+
+ // created lazily
+ private volatile String owner;
+ private volatile String group;
+ private volatile FileKey key;
+
+ private FileAttributes(String filePath) {
+ this.filePath = filePath;
+ }
+
+ private FileAttributes(FileDescriptor fileDescriptor) {
+ this.fileDescriptor = fileDescriptor;
+ }
+
+ // get the FileAttributes for a given file
+ public static FileAttributes get(String filePath, boolean followLinks) throws IOException {
+ FileAttributes fileAttributes;
+
+ if (filePath == null || filePath.isEmpty())
+ fileAttributes = new FileAttributes((String) null);
+ else
+ fileAttributes = new FileAttributes(new File(filePath).getAbsolutePath());
+
+ if (followLinks) {
+ NativeDispatcher.stat(filePath, fileAttributes);
+ } else {
+ NativeDispatcher.lstat(filePath, fileAttributes);
+ }
+
+ // Logger.logDebug(fileAttributes.toString());
+
+ return fileAttributes;
+ }
+
+ // get the FileAttributes for an open file
+ public static FileAttributes get(FileDescriptor fileDescriptor) throws IOException {
+ FileAttributes fileAttributes = new FileAttributes(fileDescriptor);
+ NativeDispatcher.fstat(fileDescriptor, fileAttributes);
+ return fileAttributes;
+ }
+
+ public String file() {
+ if (filePath != null)
+ return filePath;
+ else if (fileDescriptor != null)
+ return fileDescriptor.toString();
+ else
+ return null;
+ }
+
+ // package-private
+ public boolean isSameFile(FileAttributes attrs) {
+ return ((st_ino == attrs.st_ino) && (st_dev == attrs.st_dev));
+ }
+
+ // package-private
+ public int mode() {
+ return st_mode;
+ }
+
+ public long blksize() {
+ return st_blksize;
+ }
+
+ public long blocks() {
+ return st_blocks;
+ }
+
+ public long ino() {
+ return st_ino;
+ }
+
+ public long dev() {
+ return st_dev;
+ }
+
+ public long rdev() {
+ return st_rdev;
+ }
+
+ public long nlink() {
+ return st_nlink;
+ }
+
+ public int uid() {
+ return st_uid;
+ }
+
+ public int gid() {
+ return st_gid;
+ }
+
+ private static FileTime toFileTime(long sec, long nsec) {
+ if (nsec == 0) {
+ return FileTime.from(sec, TimeUnit.SECONDS);
+ } else {
+ // truncate to microseconds to avoid overflow with timestamps
+ // way out into the future. We can re-visit this if FileTime
+ // is updated to define a from(secs,nsecs) method.
+ long micro = sec * 1000000L + nsec / 1000L;
+ return FileTime.from(micro, TimeUnit.MICROSECONDS);
+ }
+ }
+
+ public FileTime lastAccessTime() {
+ return toFileTime(st_atime_sec, st_atime_nsec);
+ }
+
+ public FileTime lastModifiedTime() {
+ return toFileTime(st_mtime_sec, st_mtime_nsec);
+ }
+
+ public FileTime lastChangeTime() {
+ return toFileTime(st_ctime_sec, st_ctime_nsec);
+ }
+
+ public FileTime creationTime() {
+ return lastModifiedTime();
+ }
+
+ public boolean isRegularFile() {
+ return ((st_mode & UnixConstants.S_IFMT) == UnixConstants.S_IFREG);
+ }
+
+ public boolean isDirectory() {
+ return ((st_mode & UnixConstants.S_IFMT) == UnixConstants.S_IFDIR);
+ }
+
+ public boolean isSymbolicLink() {
+ return ((st_mode & UnixConstants.S_IFMT) == UnixConstants.S_IFLNK);
+ }
+
+ public boolean isCharacter() {
+ return ((st_mode & UnixConstants.S_IFMT) == UnixConstants.S_IFCHR);
+ }
+
+ public boolean isFifo() {
+ return ((st_mode & UnixConstants.S_IFMT) == UnixConstants.S_IFIFO);
+ }
+
+ public boolean isSocket() {
+ return ((st_mode & UnixConstants.S_IFMT) == UnixConstants.S_IFSOCK);
+ }
+
+ public boolean isBlock() {
+ return ((st_mode & UnixConstants.S_IFMT) == UnixConstants.S_IFBLK);
+ }
+
+ public boolean isOther() {
+ int type = st_mode & UnixConstants.S_IFMT;
+ return (type != UnixConstants.S_IFREG &&
+ type != UnixConstants.S_IFDIR &&
+ type != UnixConstants.S_IFLNK);
+ }
+
+ public boolean isDevice() {
+ int type = st_mode & UnixConstants.S_IFMT;
+ return (type == UnixConstants.S_IFCHR ||
+ type == UnixConstants.S_IFBLK ||
+ type == UnixConstants.S_IFIFO);
+ }
+
+ public long size() {
+ return st_size;
+ }
+
+ public FileKey fileKey() {
+ if (key == null) {
+ synchronized (this) {
+ if (key == null) {
+ key = new FileKey(st_dev, st_ino);
+ }
+ }
+ }
+ return key;
+ }
+
+ public String owner() {
+ if (owner == null) {
+ synchronized (this) {
+ if (owner == null) {
+ owner = Integer.toString(st_uid);
+ }
+ }
+ }
+ return owner;
+ }
+
+ public String group() {
+ if (group == null) {
+ synchronized (this) {
+ if (group == null) {
+ group = Integer.toString(st_gid);
+ }
+ }
+ }
+ return group;
+ }
+
+ public Set permissions() {
+ int bits = (st_mode & UnixConstants.S_IAMB);
+ HashSet perms = new HashSet<>();
+
+ if ((bits & UnixConstants.S_IRUSR) > 0)
+ perms.add(FilePermission.OWNER_READ);
+ if ((bits & UnixConstants.S_IWUSR) > 0)
+ perms.add(FilePermission.OWNER_WRITE);
+ if ((bits & UnixConstants.S_IXUSR) > 0)
+ perms.add(FilePermission.OWNER_EXECUTE);
+
+ if ((bits & UnixConstants.S_IRGRP) > 0)
+ perms.add(FilePermission.GROUP_READ);
+ if ((bits & UnixConstants.S_IWGRP) > 0)
+ perms.add(FilePermission.GROUP_WRITE);
+ if ((bits & UnixConstants.S_IXGRP) > 0)
+ perms.add(FilePermission.GROUP_EXECUTE);
+
+ if ((bits & UnixConstants.S_IROTH) > 0)
+ perms.add(FilePermission.OTHERS_READ);
+ if ((bits & UnixConstants.S_IWOTH) > 0)
+ perms.add(FilePermission.OTHERS_WRITE);
+ if ((bits & UnixConstants.S_IXOTH) > 0)
+ perms.add(FilePermission.OTHERS_EXECUTE);
+
+ return perms;
+ }
+
+ public void loadFromStructStat(StructStat structStat) {
+ this.st_mode = structStat.st_mode;
+ this.st_ino = structStat.st_ino;
+ this.st_dev = structStat.st_dev;
+ this.st_rdev = structStat.st_rdev;
+ this.st_nlink = structStat.st_nlink;
+ this.st_uid = structStat.st_uid;
+ this.st_gid = structStat.st_gid;
+ this.st_size = structStat.st_size;
+ this.st_blksize = structStat.st_blksize;
+ this.st_blocks = structStat.st_blocks;
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
+ this.st_atime_sec = structStat.st_atim.tv_sec;
+ this.st_atime_nsec = structStat.st_atim.tv_nsec;
+ this.st_mtime_sec = structStat.st_mtim.tv_sec;
+ this.st_mtime_nsec = structStat.st_mtim.tv_nsec;
+ this.st_ctime_sec = structStat.st_ctim.tv_sec;
+ this.st_ctime_nsec = structStat.st_ctim.tv_nsec;
+ } else {
+ this.st_atime_sec = structStat.st_atime;
+ this.st_atime_nsec = 0;
+ this.st_mtime_sec = structStat.st_mtime;
+ this.st_mtime_nsec = 0;
+ this.st_ctime_sec = structStat.st_ctime;
+ this.st_ctime_nsec = 0;
+ }
+ }
+
+ public String getFileString() {
+ return "File: `" + file() + "`";
+ }
+
+ public String getTypeString() {
+ return "Type: `" + FileTypes.getFileType(this).getName() + "`";
+ }
+
+ public String getSizeString() {
+ return "Size: `" + size() + "`";
+ }
+
+ public String getBlocksString() {
+ return "Blocks: `" + blocks() + "`";
+ }
+
+ public String getIOBlockString() {
+ return "IO Block: `" + blksize() + "`";
+ }
+
+ public String getDeviceString() {
+ return "Device: `" + Long.toHexString(st_dev) + "`";
+ }
+
+ public String getInodeString() {
+ return "Inode: `" + st_ino + "`";
+ }
+
+ public String getLinksString() {
+ return "Links: `" + nlink() + "`";
+ }
+
+ public String getDeviceTypeString() {
+ return "Device Type: `" + rdev() + "`";
+ }
+
+ public String getOwnerString() {
+ return "Owner: `" + owner() + "`";
+ }
+
+ public String getGroupString() {
+ return "Group: `" + group() + "`";
+ }
+
+ public String getPermissionString() {
+ return "Permissions: `" + FilePermissions.toString(permissions()) + "`";
+ }
+
+ public String getAccessTimeString() {
+ return "Access Time: `" + lastAccessTime() + "`";
+ }
+
+ public String getModifiedTimeString() {
+ return "Modified Time: `" + lastModifiedTime() + "`";
+ }
+
+ public String getChangeTimeString() {
+ return "Change Time: `" + lastChangeTime() + "`";
+ }
+
+ @NonNull
+ @Override
+ public String toString() {
+ return getFileAttributesLogString(this);
+ }
+
+ public static String getFileAttributesLogString(final FileAttributes fileAttributes) {
+ if (fileAttributes == null) return "null";
+
+ StringBuilder logString = new StringBuilder();
+
+ logString.append(fileAttributes.getFileString());
+
+ logString.append("\n").append(fileAttributes.getTypeString());
+
+ logString.append("\n").append(fileAttributes.getSizeString());
+ logString.append("\n").append(fileAttributes.getBlocksString());
+ logString.append("\n").append(fileAttributes.getIOBlockString());
+
+ logString.append("\n").append(fileAttributes.getDeviceString());
+ logString.append("\n").append(fileAttributes.getInodeString());
+ logString.append("\n").append(fileAttributes.getLinksString());
+
+ if (fileAttributes.isBlock() || fileAttributes.isCharacter())
+ logString.append("\n").append(fileAttributes.getDeviceTypeString());
+
+ logString.append("\n").append(fileAttributes.getOwnerString());
+ logString.append("\n").append(fileAttributes.getGroupString());
+ logString.append("\n").append(fileAttributes.getPermissionString());
+
+ logString.append("\n").append(fileAttributes.getAccessTimeString());
+ logString.append("\n").append(fileAttributes.getModifiedTimeString());
+ logString.append("\n").append(fileAttributes.getChangeTimeString());
+
+ return logString.toString();
+ }
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/file/filesystem/FileKey.java b/termux/termux-shared/src/main/java/com/termux/shared/file/filesystem/FileKey.java
new file mode 100644
index 000000000..bc65cf167
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/file/filesystem/FileKey.java
@@ -0,0 +1,68 @@
+/*
+ * Copyright (c) 2008, 2009, Oracle and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ *
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Oracle designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
+ * or visit www.oracle.com if you need additional information or have any
+ * questions.
+ */
+
+package com.termux.shared.file.filesystem;
+
+/**
+ * Container for device/inode to uniquely identify file.
+ * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r3:libcore/ojluni/src/main/java/sun/nio/fs/UnixFileKey.java
+ */
+
+public class FileKey {
+ private final long st_dev;
+ private final long st_ino;
+
+ FileKey(long st_dev, long st_ino) {
+ this.st_dev = st_dev;
+ this.st_ino = st_ino;
+ }
+
+ @Override
+ public int hashCode() {
+ return (int)(st_dev ^ (st_dev >>> 32)) +
+ (int)(st_ino ^ (st_ino >>> 32));
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (obj == this)
+ return true;
+ if (!(obj instanceof FileKey))
+ return false;
+ FileKey other = (FileKey)obj;
+ return (this.st_dev == other.st_dev) && (this.st_ino == other.st_ino);
+ }
+
+ @Override
+ public String toString() {
+ StringBuilder sb = new StringBuilder();
+ sb.append("(dev=")
+ .append(Long.toHexString(st_dev))
+ .append(",ino=")
+ .append(st_ino)
+ .append(')');
+ return sb.toString();
+ }
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/file/filesystem/FilePermission.java b/termux/termux-shared/src/main/java/com/termux/shared/file/filesystem/FilePermission.java
new file mode 100644
index 000000000..b9b1ed0ea
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/file/filesystem/FilePermission.java
@@ -0,0 +1,88 @@
+
+/*
+ * Copyright (c) 2007, 2011, Oracle and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ *
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Oracle designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
+ * or visit www.oracle.com if you need additional information or have any
+ * questions.
+ */
+
+package com.termux.shared.file.filesystem;
+
+/**
+ * Defines the bits for use with the {@link FileAttributes#permissions()
+ * permissions} attribute.
+ *
+ * The {@link FileAttributes} class defines methods for manipulating
+ * set of permissions.
+ *
+ * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r3:libcore/ojluni/src/main/java/java/nio/file/attribute/PosixFilePermission.java
+ *
+ * @since 1.7
+ */
+
+public enum FilePermission {
+
+ /**
+ * Read permission, owner.
+ */
+ OWNER_READ,
+
+ /**
+ * Write permission, owner.
+ */
+ OWNER_WRITE,
+
+ /**
+ * Execute/search permission, owner.
+ */
+ OWNER_EXECUTE,
+
+ /**
+ * Read permission, group.
+ */
+ GROUP_READ,
+
+ /**
+ * Write permission, group.
+ */
+ GROUP_WRITE,
+
+ /**
+ * Execute/search permission, group.
+ */
+ GROUP_EXECUTE,
+
+ /**
+ * Read permission, others.
+ */
+ OTHERS_READ,
+
+ /**
+ * Write permission, others.
+ */
+ OTHERS_WRITE,
+
+ /**
+ * Execute/search permission, others.
+ */
+ OTHERS_EXECUTE
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/file/filesystem/FilePermissions.java b/termux/termux-shared/src/main/java/com/termux/shared/file/filesystem/FilePermissions.java
new file mode 100644
index 000000000..e06916099
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/file/filesystem/FilePermissions.java
@@ -0,0 +1,145 @@
+/*
+ * Copyright (c) 2007, 2011, Oracle and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ *
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Oracle designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
+ * or visit www.oracle.com if you need additional information or have any
+ * questions.
+ */
+
+package com.termux.shared.file.filesystem;
+
+import static com.termux.shared.file.filesystem.FilePermission.*;
+
+import java.util.*;
+
+/**
+ * This class consists exclusively of static methods that operate on sets of
+ * {@link FilePermission} objects.
+ *
+ * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r3:libcore/ojluni/src/main/java/java/nio/file/attribute/PosixFilePermissions.java
+ *
+ * @since 1.7
+ */
+
+public final class FilePermissions {
+ private FilePermissions() { }
+
+ // Write string representation of permission bits to {@code sb}.
+ private static void writeBits(StringBuilder sb, boolean r, boolean w, boolean x) {
+ if (r) {
+ sb.append('r');
+ } else {
+ sb.append('-');
+ }
+ if (w) {
+ sb.append('w');
+ } else {
+ sb.append('-');
+ }
+ if (x) {
+ sb.append('x');
+ } else {
+ sb.append('-');
+ }
+ }
+
+ /**
+ * Returns the {@code String} representation of a set of permissions. It
+ * is guaranteed that the returned {@code String} can be parsed by the
+ * {@link #fromString} method.
+ *
+ *
If the set contains {@code null} or elements that are not of type
+ * {@code FilePermission} then these elements are ignored.
+ *
+ * @param perms
+ * the set of permissions
+ *
+ * @return the string representation of the permission set
+ */
+ public static String toString(Set perms) {
+ StringBuilder sb = new StringBuilder(9);
+ writeBits(sb, perms.contains(OWNER_READ), perms.contains(OWNER_WRITE),
+ perms.contains(OWNER_EXECUTE));
+ writeBits(sb, perms.contains(GROUP_READ), perms.contains(GROUP_WRITE),
+ perms.contains(GROUP_EXECUTE));
+ writeBits(sb, perms.contains(OTHERS_READ), perms.contains(OTHERS_WRITE),
+ perms.contains(OTHERS_EXECUTE));
+ return sb.toString();
+ }
+
+ private static boolean isSet(char c, char setValue) {
+ if (c == setValue)
+ return true;
+ if (c == '-')
+ return false;
+ throw new IllegalArgumentException("Invalid mode");
+ }
+ private static boolean isR(char c) { return isSet(c, 'r'); }
+ private static boolean isW(char c) { return isSet(c, 'w'); }
+ private static boolean isX(char c) { return isSet(c, 'x'); }
+
+ /**
+ * Returns the set of permissions corresponding to a given {@code String}
+ * representation.
+ *
+ * The {@code perms} parameter is a {@code String} representing the
+ * permissions. It has 9 characters that are interpreted as three sets of
+ * three. The first set refers to the owner's permissions; the next to the
+ * group permissions and the last to others. Within each set, the first
+ * character is {@code 'r'} to indicate permission to read, the second
+ * character is {@code 'w'} to indicate permission to write, and the third
+ * character is {@code 'x'} for execute permission. Where a permission is
+ * not set then the corresponding character is set to {@code '-'}.
+ *
+ *
Usage Example:
+ * Suppose we require the set of permissions that indicate the owner has read,
+ * write, and execute permissions, the group has read and execute permissions
+ * and others have none.
+ *
+ * Set<FilePermission> perms = FilePermissions.fromString("rwxr-x---");
+ *
+ *
+ * @param perms
+ * string representing a set of permissions
+ *
+ * @return the resulting set of permissions
+ *
+ * @throws IllegalArgumentException
+ * if the string cannot be converted to a set of permissions
+ *
+ * @see #toString(Set)
+ */
+ public static Set fromString(String perms) {
+ if (perms.length() != 9)
+ throw new IllegalArgumentException("Invalid mode");
+ Set result = EnumSet.noneOf(FilePermission.class);
+ if (isR(perms.charAt(0))) result.add(OWNER_READ);
+ if (isW(perms.charAt(1))) result.add(OWNER_WRITE);
+ if (isX(perms.charAt(2))) result.add(OWNER_EXECUTE);
+ if (isR(perms.charAt(3))) result.add(GROUP_READ);
+ if (isW(perms.charAt(4))) result.add(GROUP_WRITE);
+ if (isX(perms.charAt(5))) result.add(GROUP_EXECUTE);
+ if (isR(perms.charAt(6))) result.add(OTHERS_READ);
+ if (isW(perms.charAt(7))) result.add(OTHERS_WRITE);
+ if (isX(perms.charAt(8))) result.add(OTHERS_EXECUTE);
+ return result;
+ }
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/file/filesystem/FileTime.java b/termux/termux-shared/src/main/java/com/termux/shared/file/filesystem/FileTime.java
new file mode 100644
index 000000000..14c1d8b5a
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/file/filesystem/FileTime.java
@@ -0,0 +1,156 @@
+/*
+ * Copyright (c) 2009, 2013, Oracle and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ *
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Oracle designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
+ * or visit www.oracle.com if you need additional information or have any
+ * questions.
+ */
+
+package com.termux.shared.file.filesystem;
+
+import androidx.annotation.NonNull;
+
+import java.text.SimpleDateFormat;
+import java.util.Calendar;
+import java.util.Objects;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Represents the value of a file's time stamp attribute. For example, it may
+ * represent the time that the file was last
+ * {@link FileAttributes#lastModifiedTime() modified},
+ * {@link FileAttributes#lastAccessTime() accessed},
+ * or {@link FileAttributes#creationTime() created}.
+ *
+ * Instances of this class are immutable.
+ *
+ * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r3:libcore/ojluni/src/main/java/java/nio/file/attribute/FileTime.java
+ *
+ * @since 1.7
+ * @see java.nio.file.Files#setLastModifiedTime
+ * @see java.nio.file.Files#getLastModifiedTime
+ */
+
+public final class FileTime {
+ /**
+ * The unit of granularity to interpret the value. Null if
+ * this {@code FileTime} is converted from an {@code Instant},
+ * the {@code value} and {@code unit} pair will not be used
+ * in this scenario.
+ */
+ private final TimeUnit unit;
+
+ /**
+ * The value since the epoch; can be negative.
+ */
+ private final long value;
+
+
+ /**
+ * The value return by toString (created lazily)
+ */
+ private String valueAsString;
+
+ /**
+ * Initializes a new instance of this class.
+ */
+ private FileTime(long value, TimeUnit unit) {
+ this.value = value;
+ this.unit = unit;
+ }
+
+ /**
+ * Returns a {@code FileTime} representing a value at the given unit of
+ * granularity.
+ *
+ * @param value
+ * the value since the epoch (1970-01-01T00:00:00Z); can be
+ * negative
+ * @param unit
+ * the unit of granularity to interpret the value
+ *
+ * @return a {@code FileTime} representing the given value
+ */
+ public static FileTime from(long value, @NonNull TimeUnit unit) {
+ Objects.requireNonNull(unit, "unit");
+ return new FileTime(value, unit);
+ }
+
+ /**
+ * Returns a {@code FileTime} representing the given value in milliseconds.
+ *
+ * @param value
+ * the value, in milliseconds, since the epoch
+ * (1970-01-01T00:00:00Z); can be negative
+ *
+ * @return a {@code FileTime} representing the given value
+ */
+ public static FileTime fromMillis(long value) {
+ return new FileTime(value, TimeUnit.MILLISECONDS);
+ }
+
+ /**
+ * Returns the value at the given unit of granularity.
+ *
+ *
Conversion from a coarser granularity that would numerically overflow
+ * saturate to {@code Long.MIN_VALUE} if negative or {@code Long.MAX_VALUE}
+ * if positive.
+ *
+ * @param unit
+ * the unit of granularity for the return value
+ *
+ * @return value in the given unit of granularity, since the epoch
+ * since the epoch (1970-01-01T00:00:00Z); can be negative
+ */
+ public long to(TimeUnit unit) {
+ Objects.requireNonNull(unit, "unit");
+ return unit.convert(this.value, this.unit);
+ }
+
+ /**
+ * Returns the value in milliseconds.
+ *
+ *
Conversion from a coarser granularity that would numerically overflow
+ * saturate to {@code Long.MIN_VALUE} if negative or {@code Long.MAX_VALUE}
+ * if positive.
+ *
+ * @return the value in milliseconds, since the epoch (1970-01-01T00:00:00Z)
+ */
+ public long toMillis() {
+ return unit.toMillis(value);
+ }
+
+ @NonNull
+ @Override
+ public String toString() {
+ return getDate(toMillis(), "yyyy.MM.dd HH:mm:ss.SSS z");
+ }
+
+ public static String getDate(long milliSeconds, String format) {
+ try {
+ Calendar calendar = Calendar.getInstance();
+ calendar.setTimeInMillis(milliSeconds);
+ return new SimpleDateFormat(format).format(calendar.getTime());
+ } catch(Exception e) {
+ return Long.toString(milliSeconds);
+ }
+ }
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/file/filesystem/FileType.java b/termux/termux-shared/src/main/java/com/termux/shared/file/filesystem/FileType.java
new file mode 100644
index 000000000..ff9418fc4
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/file/filesystem/FileType.java
@@ -0,0 +1,32 @@
+package com.termux.shared.file.filesystem;
+
+/** The {@link Enum} that defines file types. */
+public enum FileType {
+
+ NO_EXIST("no exist", 0), // 00000000
+ REGULAR("regular", 1), // 00000001
+ DIRECTORY("directory", 2), // 00000010
+ SYMLINK("symlink", 4), // 00000100
+ SOCKET("socket", 8), // 00001000
+ CHARACTER("character", 16), // 00010000
+ FIFO("fifo", 32), // 00100000
+ BLOCK("block", 64), // 01000000
+ UNKNOWN("unknown", 128); // 10000000
+
+ private final String name;
+ private final int value;
+
+ FileType(final String name, final int value) {
+ this.name = name;
+ this.value = value;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public int getValue() {
+ return value;
+ }
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/file/filesystem/FileTypes.java b/termux/termux-shared/src/main/java/com/termux/shared/file/filesystem/FileTypes.java
new file mode 100644
index 000000000..29f2b294d
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/file/filesystem/FileTypes.java
@@ -0,0 +1,119 @@
+package com.termux.shared.file.filesystem;
+
+import android.system.Os;
+
+import androidx.annotation.NonNull;
+
+import com.termux.shared.logger.Logger;
+
+import java.io.File;
+
+public class FileTypes {
+
+ /** Flags to represent regular, directory and symlink file types defined by {@link FileType} */
+ public static final int FILE_TYPE_NORMAL_FLAGS = FileType.REGULAR.getValue() | FileType.DIRECTORY.getValue() | FileType.SYMLINK.getValue();
+
+ /** Flags to represent any file type defined by {@link FileType} */
+ public static final int FILE_TYPE_ANY_FLAGS = Integer.MAX_VALUE; // 1111111111111111111111111111111 (31 1's)
+
+ public static String convertFileTypeFlagsToNamesString(int fileTypeFlags) {
+ StringBuilder fileTypeFlagsStringBuilder = new StringBuilder();
+
+ FileType[] fileTypes = {FileType.REGULAR, FileType.DIRECTORY, FileType.SYMLINK, FileType.CHARACTER, FileType.FIFO, FileType.BLOCK, FileType.UNKNOWN};
+ for (FileType fileType : fileTypes) {
+ if ((fileTypeFlags & fileType.getValue()) > 0)
+ fileTypeFlagsStringBuilder.append(fileType.getName()).append(",");
+ }
+
+ String fileTypeFlagsString = fileTypeFlagsStringBuilder.toString();
+
+ if (fileTypeFlagsString.endsWith(","))
+ fileTypeFlagsString = fileTypeFlagsString.substring(0, fileTypeFlagsString.lastIndexOf(","));
+
+ return fileTypeFlagsString;
+ }
+
+ /**
+ * Checks the type of file that exists at {@code filePath}.
+ *
+ * Returns:
+ * - {@link FileType#NO_EXIST} if {@code filePath} is {@code null}, empty, an exception is raised
+ * or no file exists at {@code filePath}.
+ * - {@link FileType#REGULAR} if file at {@code filePath} is a regular file.
+ * - {@link FileType#DIRECTORY} if file at {@code filePath} is a directory file.
+ * - {@link FileType#SYMLINK} if file at {@code filePath} is a symlink file and {@code followLinks} is {@code false}.
+ * - {@link FileType#CHARACTER} if file at {@code filePath} is a character special file.
+ * - {@link FileType#FIFO} if file at {@code filePath} is a fifo special file.
+ * - {@link FileType#BLOCK} if file at {@code filePath} is a block special file.
+ * - {@link FileType#UNKNOWN} if file at {@code filePath} is of unknown type.
+ *
+ * The {@link File#isFile()} and {@link File#isDirectory()} uses {@link Os#stat(String)} system
+ * call (not {@link Os#lstat(String)}) to check file type and does follow symlinks.
+ *
+ * The {@link File#exists()} uses {@link Os#access(String, int)} system call to check if file is
+ * accessible and does not follow symlinks. However, it returns {@code false} for dangling symlinks,
+ * on android at least. Check https://stackoverflow.com/a/57747064/14686958
+ *
+ * Basically {@link File} API is not reliable to check for symlinks.
+ *
+ * So we get the file type directly with {@link Os#lstat(String)} if {@code followLinks} is
+ * {@code false} and {@link Os#stat(String)} if {@code followLinks} is {@code true}. All exceptions
+ * are assumed as non-existence.
+ *
+ * The {@link org.apache.commons.io.FileUtils#isSymlink(File)} can also be used for checking
+ * symlinks but {@link FileAttributes} will provide access to more attributes if necessary,
+ * including getting other special file types considering that {@link File#exists()} can't be
+ * used to reliably check for non-existence and exclude the other 3 file types. commons.io is
+ * also not compatible with android < 8 for many things.
+ *
+ * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r3:libcore/ojluni/src/main/java/java/io/File.java;l=793
+ * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r3:libcore/ojluni/src/main/java/java/io/UnixFileSystem.java;l=248
+ * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r3:libcore/ojluni/src/main/native/UnixFileSystem_md.c;l=121
+ * https://cs.android.com/android/_/android/platform/libcore/+/001ac51d61ad7443ba518bf2cf7e086efe698c6d
+ * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r3:libcore/luni/src/main/java/libcore/io/Os.java;l=51
+ * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r3:libcore/luni/src/main/java/libcore/io/Libcore.java;l=45
+ * https://cs.android.com/android/platform/superproject/+/master:frameworks/base/core/java/android/app/ActivityThread.java;l=7530
+ *
+ * @param filePath The {@code path} for file to check.
+ * @param followLinks The {@code boolean} that decides if symlinks will be followed while
+ * finding type. If set to {@code true}, then type of symlink target will
+ * be returned if file at {@code filePath} is a symlink. If set to
+ * {@code false}, then type of file at {@code filePath} itself will be
+ * returned.
+ * @return Returns the {@link FileType} of file.
+ */
+ @NonNull
+ public static FileType getFileType(final String filePath, final boolean followLinks) {
+ if (filePath == null || filePath.isEmpty()) return FileType.NO_EXIST;
+
+ try {
+ FileAttributes fileAttributes = FileAttributes.get(filePath, followLinks);
+ return getFileType(fileAttributes);
+ } catch (Exception e) {
+ // If not a ENOENT (No such file or directory) exception
+ if (e.getMessage() != null && !e.getMessage().contains("ENOENT"))
+ Logger.logError("Failed to get file type for file at path \"" + filePath + "\": " + e.getMessage());
+ return FileType.NO_EXIST;
+ }
+ }
+
+ public static FileType getFileType(@NonNull final FileAttributes fileAttributes) {
+ if (fileAttributes.isRegularFile())
+ return FileType.REGULAR;
+ else if (fileAttributes.isDirectory())
+ return FileType.DIRECTORY;
+ else if (fileAttributes.isSymbolicLink())
+ return FileType.SYMLINK;
+ else if (fileAttributes.isSocket())
+ return FileType.SOCKET;
+ else if (fileAttributes.isCharacter())
+ return FileType.CHARACTER;
+ else if (fileAttributes.isFifo())
+ return FileType.FIFO;
+ else if (fileAttributes.isBlock())
+ return FileType.BLOCK;
+ else
+ return FileType.UNKNOWN;
+ }
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/file/filesystem/NativeDispatcher.java b/termux/termux-shared/src/main/java/com/termux/shared/file/filesystem/NativeDispatcher.java
new file mode 100644
index 000000000..08ba80885
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/file/filesystem/NativeDispatcher.java
@@ -0,0 +1,58 @@
+package com.termux.shared.file.filesystem;
+
+import android.system.ErrnoException;
+import android.system.Os;
+
+import java.io.File;
+import java.io.FileDescriptor;
+import java.io.IOException;
+
+public class NativeDispatcher {
+
+ public static void stat(String filePath, FileAttributes fileAttributes) throws IOException {
+ validateFileExistence(filePath);
+
+ try {
+ fileAttributes.loadFromStructStat(Os.stat(filePath));
+ } catch (ErrnoException e) {
+ throw new IOException("Failed to run Os.stat() on file at path \"" + filePath + "\": " + e.getMessage());
+ }
+ }
+
+ public static void lstat(String filePath, FileAttributes fileAttributes) throws IOException {
+ validateFileExistence(filePath);
+
+ try {
+ fileAttributes.loadFromStructStat(Os.lstat(filePath));
+ } catch (ErrnoException e) {
+ throw new IOException("Failed to run Os.lstat() on file at path \"" + filePath + "\": " + e.getMessage());
+ }
+ }
+
+ public static void fstat(FileDescriptor fileDescriptor, FileAttributes fileAttributes) throws IOException {
+ validateFileDescriptor(fileDescriptor);
+
+ try {
+ fileAttributes.loadFromStructStat(Os.fstat(fileDescriptor));
+ } catch (ErrnoException e) {
+ throw new IOException("Failed to run Os.fstat() on file descriptor \"" + fileDescriptor.toString() + "\": " + e.getMessage());
+ }
+ }
+
+ public static void validateFileExistence(String filePath) throws IOException {
+ if (filePath == null || filePath.isEmpty()) throw new IOException("The path is null or empty");
+
+ File file = new File(filePath);
+
+ //if (!file.exists())
+ // throw new IOException("No such file or directory: \"" + filePath + "\"");
+ }
+
+ public static void validateFileDescriptor(FileDescriptor fileDescriptor) throws IOException {
+ if (fileDescriptor == null) throw new IOException("The file descriptor is null");
+
+ if (!fileDescriptor.valid())
+ throw new IOException("No such file descriptor: \"" + fileDescriptor.toString() + "\"");
+ }
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/file/filesystem/UnixConstants.java b/termux/termux-shared/src/main/java/com/termux/shared/file/filesystem/UnixConstants.java
new file mode 100644
index 000000000..9659be7e7
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/file/filesystem/UnixConstants.java
@@ -0,0 +1,158 @@
+
+/*
+ * Copyright (c) 2008, 2009, Oracle and/or its affiliates. All rights reserved.
+ *
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ *
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Oracle designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
+ * or visit www.oracle.com if you need additional information or have any
+ * questions.
+ *
+ */
+// AUTOMATICALLY GENERATED FILE - DO NOT EDIT
+package com.termux.shared.file.filesystem;
+
+// BEGIN Android-changed: Use constants from android.system.OsConstants. http://b/32203242
+// Those constants are initialized by native code to ensure correctness on different architectures.
+// AT_SYMLINK_NOFOLLOW (used by fstatat) and AT_REMOVEDIR (used by unlinkat) as of July 2018 do not
+// have equivalents in android.system.OsConstants so left unchanged.
+import android.os.Build;
+import android.system.OsConstants;
+
+import androidx.annotation.RequiresApi;
+
+/**
+ * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r3:libcore/ojluni/src/main/java/sun/nio/fs/UnixConstants.java
+ */
+public class UnixConstants {
+ private UnixConstants() { }
+
+ static final int O_RDONLY = OsConstants.O_RDONLY;
+
+ static final int O_WRONLY = OsConstants.O_WRONLY;
+
+ static final int O_RDWR = OsConstants.O_RDWR;
+
+ static final int O_APPEND = OsConstants.O_APPEND;
+
+ static final int O_CREAT = OsConstants.O_CREAT;
+
+ static final int O_EXCL = OsConstants.O_EXCL;
+
+ static final int O_TRUNC = OsConstants.O_TRUNC;
+
+ static final int O_SYNC = OsConstants.O_SYNC;
+
+ // Crash on Android 5.
+ // No static field O_DSYNC of type I in class Landroid/system/OsConstants; or its superclasses
+ // (declaration of 'android.system.OsConstants' appears in /system/framework/core-libart.jar)
+ //@RequiresApi(Build.VERSION_CODES.O_MR1)
+ //static final int O_DSYNC = OsConstants.O_DSYNC;
+
+ static final int O_NOFOLLOW = OsConstants.O_NOFOLLOW;
+
+ static final int S_IAMB = get_S_IAMB();
+
+ static final int S_IRUSR = OsConstants.S_IRUSR;
+
+ static final int S_IWUSR = OsConstants.S_IWUSR;
+
+ static final int S_IXUSR = OsConstants.S_IXUSR;
+
+ static final int S_IRGRP = OsConstants.S_IRGRP;
+
+ static final int S_IWGRP = OsConstants.S_IWGRP;
+
+ static final int S_IXGRP = OsConstants.S_IXGRP;
+
+ static final int S_IROTH = OsConstants.S_IROTH;
+
+ static final int S_IWOTH = OsConstants.S_IWOTH;
+
+ static final int S_IXOTH = OsConstants.S_IXOTH;
+
+ static final int S_IFMT = OsConstants.S_IFMT;
+
+ static final int S_IFREG = OsConstants.S_IFREG;
+
+ static final int S_IFDIR = OsConstants.S_IFDIR;
+
+ static final int S_IFLNK = OsConstants.S_IFLNK;
+
+ static final int S_IFSOCK = OsConstants.S_IFSOCK;
+
+ static final int S_IFCHR = OsConstants.S_IFCHR;
+
+ static final int S_IFBLK = OsConstants.S_IFBLK;
+
+ static final int S_IFIFO = OsConstants.S_IFIFO;
+
+ static final int R_OK = OsConstants.R_OK;
+
+ static final int W_OK = OsConstants.W_OK;
+
+ static final int X_OK = OsConstants.X_OK;
+
+ static final int F_OK = OsConstants.F_OK;
+
+ static final int ENOENT = OsConstants.ENOENT;
+
+ static final int EACCES = OsConstants.EACCES;
+
+ static final int EEXIST = OsConstants.EEXIST;
+
+ static final int ENOTDIR = OsConstants.ENOTDIR;
+
+ static final int EINVAL = OsConstants.EINVAL;
+
+ static final int EXDEV = OsConstants.EXDEV;
+
+ static final int EISDIR = OsConstants.EISDIR;
+
+ static final int ENOTEMPTY = OsConstants.ENOTEMPTY;
+
+ static final int ENOSPC = OsConstants.ENOSPC;
+
+ static final int EAGAIN = OsConstants.EAGAIN;
+
+ static final int ENOSYS = OsConstants.ENOSYS;
+
+ static final int ELOOP = OsConstants.ELOOP;
+
+ static final int EROFS = OsConstants.EROFS;
+
+ static final int ENODATA = OsConstants.ENODATA;
+
+ static final int ERANGE = OsConstants.ERANGE;
+
+ static final int EMFILE = OsConstants.EMFILE;
+
+ // S_IAMB are access mode bits, therefore, calculated by taking OR of all the read, write and
+ // execute permissions bits for owner, group and other.
+ private static int get_S_IAMB() {
+ return (OsConstants.S_IRUSR | OsConstants.S_IWUSR | OsConstants.S_IXUSR |
+ OsConstants.S_IRGRP | OsConstants.S_IWGRP | OsConstants.S_IXGRP |
+ OsConstants.S_IROTH | OsConstants.S_IWOTH | OsConstants.S_IXOTH);
+ }
+ // END Android-changed: Use constants from android.system.OsConstants. http://b/32203242
+
+
+ static final int AT_SYMLINK_NOFOLLOW = 0x100;
+ static final int AT_REMOVEDIR = 0x200;
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/file/tests/FileUtilsTests.java b/termux/termux-shared/src/main/java/com/termux/shared/file/tests/FileUtilsTests.java
new file mode 100644
index 000000000..bf1c210c0
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/file/tests/FileUtilsTests.java
@@ -0,0 +1,396 @@
+package com.termux.shared.file.tests;
+
+import android.content.Context;
+
+import androidx.annotation.NonNull;
+
+import com.termux.shared.errors.Errno;
+import com.termux.shared.file.FileUtils;
+import com.termux.shared.file.FileUtilsErrno;
+import com.termux.shared.logger.Logger;
+import com.termux.shared.errors.Error;
+
+import java.io.File;
+import java.nio.charset.Charset;
+import java.util.Arrays;
+import java.util.List;
+
+public class FileUtilsTests {
+
+ private static final String LOG_TAG = "FileUtilsTests";
+
+ /**
+ * Run basic tests for {@link FileUtils} class.
+ *
+ * Move tests need to be written, specially for failures.
+ *
+ * The log level must be set to verbose.
+ *
+ * Run at app startup like in an activity
+ * FileUtilsTests.runTests(this, TermuxConstants.TERMUX_HOME_DIR_PATH + "/FileUtilsTests");
+ *
+ * @param context The {@link Context} for operations.
+ */
+ public static void runTests(@NonNull final Context context, @NonNull final String testRootDirectoryPath) {
+ try {
+ Logger.logInfo(LOG_TAG, "Running tests");
+ Logger.logInfo(LOG_TAG, "testRootDirectoryPath: \"" + testRootDirectoryPath + "\"");
+
+ String fileUtilsTestsDirectoryCanonicalPath = FileUtils.getCanonicalPath(testRootDirectoryPath, null);
+ assertEqual("FileUtilsTests directory path is not a canonical path", testRootDirectoryPath, fileUtilsTestsDirectoryCanonicalPath);
+
+ runTestsInner(testRootDirectoryPath);
+ Logger.logInfo(LOG_TAG, "All tests successful");
+ } catch (Exception e) {
+ Logger.logErrorExtended(LOG_TAG, e.getMessage());
+ Logger.showToast(context, e.getMessage() != null ? e.getMessage().replaceAll("(?s)\nFull Error:\n.*", "") : null, true);
+ }
+ }
+
+ private static void runTestsInner(@NonNull final String testRootDirectoryPath) throws Exception {
+ Error error;
+ String label;
+ String path;
+
+ /*
+ * - dir1
+ * - sub_dir1
+ * - sub_reg1
+ * - sub_sym1 (absolute symlink to dir2)
+ * - sub_sym2 (copy of sub_sym1 for symlink to dir2)
+ * - sub_sym3 (relative symlink to dir4)
+ * - dir2
+ * - sub_reg1
+ * - sub_reg2 (copy of dir2/sub_reg1)
+ * - dir3 (copy of dir1)
+ * - dir4 (moved from dir3)
+ */
+
+ String dir1_label = "dir1";
+ String dir1_path = testRootDirectoryPath + "/dir1";
+
+ String dir1__sub_dir1_label = "dir1/sub_dir1";
+ String dir1__sub_dir1_path = dir1_path + "/sub_dir1";
+
+ String dir1__sub_dir2_label = "dir1/sub_dir2";
+ String dir1__sub_dir2_path = dir1_path + "/sub_dir2";
+
+ String dir1__sub_dir3_label = "dir1/sub_dir3";
+ String dir1__sub_dir3_path = dir1_path + "/sub_dir3";
+
+ String dir1__sub_dir3__sub_reg1_label = "dir1/sub_dir3/sub_reg1";
+ String dir1__sub_dir3__sub_reg1_path = dir1__sub_dir3_path + "/sub_reg1";
+
+ String dir1__sub_reg1_label = "dir1/sub_reg1";
+ String dir1__sub_reg1_path = dir1_path + "/sub_reg1";
+
+ String dir1__sub_sym1_label = "dir1/sub_sym1";
+ String dir1__sub_sym1_path = dir1_path + "/sub_sym1";
+
+ String dir1__sub_sym2_label = "dir1/sub_sym2";
+ String dir1__sub_sym2_path = dir1_path + "/sub_sym2";
+
+ String dir1__sub_sym3_label = "dir1/sub_sym3";
+ String dir1__sub_sym3_path = dir1_path + "/sub_sym3";
+
+
+ String dir2_label = "dir2";
+ String dir2_path = testRootDirectoryPath + "/dir2";
+
+ String dir2__sub_reg1_label = "dir2/sub_reg1";
+ String dir2__sub_reg1_path = dir2_path + "/sub_reg1";
+
+ String dir2__sub_reg2_label = "dir2/sub_reg2";
+ String dir2__sub_reg2_path = dir2_path + "/sub_reg2";
+
+
+ String dir3_label = "dir3";
+ String dir3_path = testRootDirectoryPath + "/dir3";
+
+ String dir4_label = "dir4";
+ String dir4_path = testRootDirectoryPath + "/dir4";
+
+
+
+
+
+ // Create or clear test root directory file
+ label = "testRootDirectoryPath";
+ error = FileUtils.clearDirectory(label, testRootDirectoryPath);
+ assertEqual("Failed to create " + label + " directory file", null, error);
+
+ if (!FileUtils.directoryFileExists(testRootDirectoryPath, false))
+ throwException("The " + label + " directory file does not exist as expected after creation");
+
+
+ // Create dir1 directory file
+ error = FileUtils.createDirectoryFile(dir1_label, dir1_path);
+ assertEqual("Failed to create " + dir1_label + " directory file", null, error);
+
+ // Create dir2 directory file
+ error = FileUtils.createDirectoryFile(dir2_label, dir2_path);
+ assertEqual("Failed to create " + dir2_label + " directory file", null, error);
+
+
+
+
+
+ // Create dir1/sub_dir1 directory file
+ label = dir1__sub_dir1_label; path = dir1__sub_dir1_path;
+ error = FileUtils.createDirectoryFile(label, path);
+ assertEqual("Failed to create " + label + " directory file", null, error);
+ if (!FileUtils.directoryFileExists(path, false))
+ throwException("The " + label + " directory file does not exist as expected after creation");
+
+ // Create dir1/sub_reg1 regular file
+ label = dir1__sub_reg1_label; path = dir1__sub_reg1_path;
+ error = FileUtils.createRegularFile(label, path);
+ assertEqual("Failed to create " + label + " regular file", null, error);
+ if (!FileUtils.regularFileExists(path, false))
+ throwException("The " + label + " regular file does not exist as expected after creation");
+
+ // Create dir1/sub_sym1 -> dir2 absolute symlink file
+ label = dir1__sub_sym1_label; path = dir1__sub_sym1_path;
+ error = FileUtils.createSymlinkFile(label, dir2_path, path);
+ assertEqual("Failed to create " + label + " symlink file", null, error);
+ if (!FileUtils.symlinkFileExists(path))
+ throwException("The " + label + " symlink file does not exist as expected after creation");
+
+ // Copy dir1/sub_sym1 symlink file to dir1/sub_sym2
+ label = dir1__sub_sym2_label; path = dir1__sub_sym2_path;
+ error = FileUtils.copySymlinkFile(label, dir1__sub_sym1_path, path, false);
+ assertEqual("Failed to copy " + dir1__sub_sym1_label + " symlink file to " + label, null, error);
+ if (!FileUtils.symlinkFileExists(path))
+ throwException("The " + label + " symlink file does not exist as expected after copying it from " + dir1__sub_sym1_label);
+ if (!new File(path).getCanonicalPath().equals(dir2_path))
+ throwException("The " + label + " symlink file does not point to " + dir2_label);
+
+
+
+
+
+ // Write "line1" to dir2/sub_reg1 regular file
+ label = dir2__sub_reg1_label; path = dir2__sub_reg1_path;
+ error = FileUtils.writeTextToFile(label, path, Charset.defaultCharset(), "line1", false);
+ assertEqual("Failed to write string to " + label + " file with append mode false", null, error);
+ if (!FileUtils.regularFileExists(path, false))
+ throwException("The " + label + " file does not exist as expected after writing to it with append mode false");
+
+ // Write "line2" to dir2/sub_reg1 regular file
+ error = FileUtils.writeTextToFile(label, path, Charset.defaultCharset(), "\nline2", true);
+ assertEqual("Failed to write string to " + label + " file with append mode true", null, error);
+
+ // Read dir2/sub_reg1 regular file
+ StringBuilder dataStringBuilder = new StringBuilder();
+ error = FileUtils.readTextFromFile(label, path, Charset.defaultCharset(), dataStringBuilder, false);
+ assertEqual("Failed to read from " + label + " file", null, error);
+ assertEqual("The data read from " + label + " file in not as expected", "line1\nline2", dataStringBuilder.toString());
+
+ // Copy dir2/sub_reg1 regular file to dir2/sub_reg2 file
+ label = dir2__sub_reg2_label; path = dir2__sub_reg2_path;
+ error = FileUtils.copyRegularFile(label, dir2__sub_reg1_path, path, false);
+ assertEqual("Failed to copy " + dir2__sub_reg1_label + " regular file to " + label, null, error);
+ if (!FileUtils.regularFileExists(path, false))
+ throwException("The " + label + " regular file does not exist as expected after copying it from " + dir2__sub_reg1_label);
+
+
+
+
+
+ // Copy dir1 directory file to dir3
+ label = dir3_label; path = dir3_path;
+ error = FileUtils.copyDirectoryFile(label, dir2_path, path, false);
+ assertEqual("Failed to copy " + dir2_label + " directory file to " + label, null, error);
+ if (!FileUtils.directoryFileExists(path, false))
+ throwException("The " + label + " directory file does not exist as expected after copying it from " + dir2_label);
+
+ // Copy dir1 directory file to dir3 again to test overwrite
+ label = dir3_label; path = dir3_path;
+ error = FileUtils.copyDirectoryFile(label, dir2_path, path, false);
+ assertEqual("Failed to copy " + dir2_label + " directory file to " + label, null, error);
+ if (!FileUtils.directoryFileExists(path, false))
+ throwException("The " + label + " directory file does not exist as expected after copying it from " + dir2_label);
+
+ // Move dir3 directory file to dir4
+ label = dir4_label; path = dir4_path;
+ error = FileUtils.moveDirectoryFile(label, dir3_path, path, false);
+ assertEqual("Failed to move " + dir3_label + " directory file to " + label, null, error);
+ if (!FileUtils.directoryFileExists(path, false))
+ throwException("The " + label + " directory file does not exist as expected after copying it from " + dir3_label);
+
+
+
+
+
+ // Create dir1/sub_sym3 -> dir4 relative symlink file
+ label = dir1__sub_sym3_label; path = dir1__sub_sym3_path;
+ error = FileUtils.createSymlinkFile(label, "../dir4", path);
+ assertEqual("Failed to create " + label + " symlink file", null, error);
+ if (!FileUtils.symlinkFileExists(path))
+ throwException("The " + label + " symlink file does not exist as expected after creation");
+
+ // Create dir1/sub_sym3 -> dirX relative dangling symlink file
+ // This is to ensure that symlinkFileExists returns true if a symlink file exists but is dangling
+ label = dir1__sub_sym3_label; path = dir1__sub_sym3_path;
+ error = FileUtils.createSymlinkFile(label, "../dirX", path);
+ assertEqual("Failed to create " + label + " symlink file", null, error);
+ if (!FileUtils.symlinkFileExists(path))
+ throwException("The " + label + " dangling symlink file does not exist as expected after creation");
+
+
+
+
+
+ // Delete dir1/sub_sym2 symlink file
+ label = dir1__sub_sym2_label; path = dir1__sub_sym2_path;
+ error = FileUtils.deleteSymlinkFile(label, path, false);
+ assertEqual("Failed to delete " + label + " symlink file", null, error);
+ if (FileUtils.fileExists(path, false))
+ throwException("The " + label + " symlink file still exist after deletion");
+
+ // Check if dir2 directory file still exists after deletion of dir1/sub_sym2 since it was a symlink to dir2
+ // When deleting a symlink file, its target must not be deleted
+ label = dir2_label; path = dir2_path;
+ if (!FileUtils.directoryFileExists(path, false))
+ throwException("The " + label + " directory file has unexpectedly been deleted after deletion of " + dir1__sub_sym2_label);
+
+
+
+
+
+ // Delete dir1 directory file
+ label = dir1_label; path = dir1_path;
+ error = FileUtils.deleteDirectoryFile(label, path, false);
+ assertEqual("Failed to delete " + label + " directory file", null, error);
+ if (FileUtils.fileExists(path, false))
+ throwException("The " + label + " directory file still exist after deletion");
+
+
+ // Check if dir2 directory file and dir2/sub_reg1 regular file still exist after deletion of
+ // dir1 since there was a dir1/sub_sym1 symlink to dir2 in it
+ // When deleting a directory, any targets of symlinks must not be deleted when deleting symlink files
+ label = dir2_label; path = dir2_path;
+ if (!FileUtils.directoryFileExists(path, false))
+ throwException("The " + label + " directory file has unexpectedly been deleted after deletion of " + dir1_label);
+ label = dir2__sub_reg1_label; path = dir2__sub_reg1_path;
+ if (!FileUtils.fileExists(path, false))
+ throwException("The " + label + " regular file has unexpectedly been deleted after deletion of " + dir1_label);
+
+
+
+
+
+ // Delete dir2/sub_reg1 regular file
+ label = dir2__sub_reg1_label; path = dir2__sub_reg1_path;
+ error = FileUtils.deleteRegularFile(label, path, false);
+ assertEqual("Failed to delete " + label + " regular file", null, error);
+ if (FileUtils.fileExists(path, false))
+ throwException("The " + label + " regular file still exist after deletion");
+
+
+ List ignoredSubFilePaths = Arrays.asList(dir1__sub_dir2_path, dir1__sub_dir3__sub_reg1_path);
+
+ // Create dir1 directory file
+ error = FileUtils.createDirectoryFile(dir1_label, dir1_path);
+ assertEqual("Failed to create " + dir1_label + " directory file", null, error);
+
+ // Test empty dir
+ error = FileUtils.validateDirectoryFileEmptyOrOnlyContainsSpecificFiles(dir1_label, dir1_path, ignoredSubFilePaths, false);
+ assertEqual("Failed to validate if " + dir1_label + " directory file is empty", null, error);
+
+
+ // Create dir1/sub_dir3 directory file
+ label = dir1__sub_dir3_label; path = dir1__sub_dir3_path;
+ error = FileUtils.createDirectoryFile(label, path);
+ assertEqual("Failed to create " + label + " directory file", null, error);
+ if (!FileUtils.directoryFileExists(path, false))
+ throwException("The " + label + " directory file does not exist as expected after creation");
+
+ // Test parent dir existing of non existing ignored regular file
+ error = FileUtils.validateDirectoryFileEmptyOrOnlyContainsSpecificFiles(dir1_label, dir1_path, ignoredSubFilePaths, false);
+ assertErrnoEqual("Failed to validate if " + dir1_label + " directory file is empty with parent dir existing of non existing ignored regular file", FileUtilsErrno.ERRNO_NON_EMPTY_DIRECTORY_FILE, error);
+
+
+ // Write "line1" to dir1/sub_dir3/sub_reg1 regular file
+ label = dir1__sub_dir3__sub_reg1_label; path = dir1__sub_dir3__sub_reg1_path;
+ error = FileUtils.writeTextToFile(label, path, Charset.defaultCharset(), "line1", false);
+ assertEqual("Failed to write string to " + label + " file with append mode false", null, error);
+ if (!FileUtils.regularFileExists(path, false))
+ throwException("The " + label + " file does not exist as expected after writing to it with append mode false");
+
+ // Test ignored regular file existing
+ error = FileUtils.validateDirectoryFileEmptyOrOnlyContainsSpecificFiles(dir1_label, dir1_path, ignoredSubFilePaths, false);
+ assertEqual("Failed to validate if " + dir1_label + " directory file is empty with ignored regular file existing", null, error);
+
+
+ // Create dir1/sub_dir2 directory file
+ label = dir1__sub_dir2_label; path = dir1__sub_dir2_path;
+ error = FileUtils.createDirectoryFile(label, path);
+ assertEqual("Failed to create " + label + " directory file", null, error);
+ if (!FileUtils.directoryFileExists(path, false))
+ throwException("The " + label + " directory file does not exist as expected after creation");
+
+ // Test ignored dir file existing
+ error = FileUtils.validateDirectoryFileEmptyOrOnlyContainsSpecificFiles(dir1_label, dir1_path, ignoredSubFilePaths, false);
+ assertEqual("Failed to validate if " + dir1_label + " directory file is empty with ignored dir file existing", null, error);
+
+
+ // Create dir1/sub_dir1 directory file
+ label = dir1__sub_dir1_label; path = dir1__sub_dir1_path;
+ error = FileUtils.createDirectoryFile(label, path);
+ assertEqual("Failed to create " + label + " directory file", null, error);
+ if (!FileUtils.directoryFileExists(path, false))
+ throwException("The " + label + " directory file does not exist as expected after creation");
+
+ // Test non ignored dir file existing
+ error = FileUtils.validateDirectoryFileEmptyOrOnlyContainsSpecificFiles(dir1_label, dir1_path, ignoredSubFilePaths, false);
+ assertErrnoEqual("Failed to validate if " + dir1_label + " directory file is empty with non ignored dir file existing", FileUtilsErrno.ERRNO_NON_EMPTY_DIRECTORY_FILE, error);
+
+
+ // Delete dir1 directory file
+ label = dir1_label; path = dir1_path;
+ error = FileUtils.deleteDirectoryFile(label, path, false);
+ assertEqual("Failed to delete " + label + " directory file", null, error);
+
+
+ FileUtils.getFileType("/dev/ptmx", false);
+ FileUtils.getFileType("/dev/null", false);
+ }
+
+
+
+ public static void assertEqual(@NonNull final String message, final String expected, final Error actual) throws Exception {
+ String actualString = actual != null ? actual.getMessage() : null;
+ if (!equalsRegardingNull(expected, actualString))
+ throwException(message + "\nexpected: \"" + expected + "\"\nactual: \"" + actualString + "\"\nFull Error:\n" + (actual != null ? actual.toString() : ""));
+ }
+
+ public static void assertEqual(@NonNull final String message, final String expected, final String actual) throws Exception {
+ if (!equalsRegardingNull(expected, actual))
+ throwException(message + "\nexpected: \"" + expected + "\"\nactual: \"" + actual + "\"");
+ }
+
+ private static boolean equalsRegardingNull(final String expected, final String actual) {
+ if (expected == null) {
+ return actual == null;
+ }
+
+ return isEquals(expected, actual);
+ }
+
+ public static void assertErrnoEqual(@NonNull final String message, final Errno expected, final Error actual) throws Exception {
+ if ((expected == null && actual != null) || (expected != null && !expected.equalsErrorTypeAndCode(actual)))
+ throwException(message + "\nexpected: \"" + expected + "\"\nactual: \"" + actual + "\"\nFull Error:\n" + (actual != null ? actual.toString() : ""));
+ }
+
+
+
+ private static boolean isEquals(String expected, String actual) {
+ return expected.equals(actual);
+ }
+
+ public static void throwException(@NonNull final String message) throws Exception {
+ throw new Exception(message);
+ }
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/interact/MessageDialogUtils.java b/termux/termux-shared/src/main/java/com/termux/shared/interact/MessageDialogUtils.java
new file mode 100644
index 000000000..d7543fc7c
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/interact/MessageDialogUtils.java
@@ -0,0 +1,99 @@
+package com.termux.shared.interact;
+
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.graphics.Color;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.widget.Button;
+import android.widget.TextView;
+
+import com.termux.shared.R;
+import com.termux.shared.logger.Logger;
+
+public class MessageDialogUtils {
+
+ /**
+ * Show a message in a dialog
+ *
+ * @param context The {@link Context} to use to start the dialog. An {@link Activity} {@link Context}
+ * must be passed, otherwise exceptions will be thrown.
+ * @param titleText The title text of the dialog.
+ * @param messageText The message text of the dialog.
+ * @param onDismiss The {@link DialogInterface.OnDismissListener} to run when dialog is dismissed.
+ */
+ public static void showMessage(Context context, String titleText, String messageText, final DialogInterface.OnDismissListener onDismiss) {
+ showMessage(context, titleText, messageText, null, null, null, null, onDismiss);
+ }
+
+ /**
+ * Show a message in a dialog
+ *
+ * @param context The {@link Context} to use to start the dialog. An {@link Activity} {@link Context}
+ * must be passed, otherwise exceptions will be thrown.
+ * @param titleText The title text of the dialog.
+ * @param messageText The message text of the dialog.
+ * @param positiveText The positive button text of the dialog.
+ * @param onPositiveButton The {@link DialogInterface.OnClickListener} to run when positive button
+ * is pressed.
+ * @param negativeText The negative button text of the dialog. If this is {@code null}, then
+ * negative button will not be shown.
+ * @param onNegativeButton The {@link DialogInterface.OnClickListener} to run when negative button
+ * is pressed.
+ * @param onDismiss The {@link DialogInterface.OnDismissListener} to run when dialog is dismissed.
+ */
+ public static void showMessage(Context context, String titleText, String messageText,
+ String positiveText,
+ final DialogInterface.OnClickListener onPositiveButton,
+ String negativeText,
+ final DialogInterface.OnClickListener onNegativeButton,
+ final DialogInterface.OnDismissListener onDismiss) {
+
+ AlertDialog.Builder builder = new AlertDialog.Builder(context, R.style.Theme_AppCompat_Light_Dialog);
+
+ LayoutInflater inflater = (LayoutInflater) context.getSystemService( Context.LAYOUT_INFLATER_SERVICE );
+ View view = inflater.inflate(R.layout.dialog_show_message, null);
+ if (view != null) {
+ builder.setView(view);
+
+ TextView titleView = view.findViewById(R.id.dialog_title);
+ if (titleView != null)
+ titleView.setText(titleText);
+
+ TextView messageView = view.findViewById(R.id.dialog_message);
+ if (messageView != null)
+ messageView.setText(messageText);
+ }
+
+ if (positiveText == null)
+ positiveText = context.getString(android.R.string.ok);
+ builder.setPositiveButton(positiveText, onPositiveButton);
+
+ if (negativeText != null)
+ builder.setNegativeButton(negativeText, onNegativeButton);
+
+ if (onDismiss != null)
+ builder.setOnDismissListener(onDismiss);
+
+ AlertDialog dialog = builder.create();
+
+ dialog.setOnShowListener(dialogInterface -> {
+ Logger.logError("dialog");
+ Button button = dialog.getButton(AlertDialog.BUTTON_POSITIVE);
+ if (button != null)
+ button.setTextColor(Color.BLACK);
+ button = dialog.getButton(AlertDialog.BUTTON_NEGATIVE);
+ if (button != null)
+ button.setTextColor(Color.BLACK);
+ });
+
+ dialog.show();
+ }
+
+ public static void exitAppWithErrorMessage(Context context, String titleText, String messageText) {
+ showMessage(context, titleText, messageText, dialog -> System.exit(0));
+ }
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/interact/ShareUtils.java b/termux/termux-shared/src/main/java/com/termux/shared/interact/ShareUtils.java
new file mode 100644
index 000000000..969502447
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/interact/ShareUtils.java
@@ -0,0 +1,225 @@
+package com.termux.shared.interact;
+
+import android.Manifest;
+import android.app.Activity;
+import android.content.ActivityNotFoundException;
+import android.content.ClipData;
+import android.content.ClipboardManager;
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Environment;
+
+import androidx.appcompat.app.AppCompatActivity;
+
+import com.termux.shared.R;
+import com.termux.shared.data.DataUtils;
+import com.termux.shared.data.IntentUtils;
+import com.termux.shared.file.FileUtils;
+import com.termux.shared.logger.Logger;
+import com.termux.shared.errors.Error;
+import com.termux.shared.android.PermissionUtils;
+
+import java.nio.charset.Charset;
+
+import javax.annotation.Nullable;
+
+public class ShareUtils {
+
+ private static final String LOG_TAG = "ShareUtils";
+
+ /**
+ * Open the system app chooser that allows the user to select which app to send the intent.
+ *
+ * @param context The context for operations.
+ * @param intent The intent that describes the choices that should be shown.
+ * @param title The title for choose menu.
+ */
+ public static void openSystemAppChooser(final Context context, final Intent intent, final String title) {
+ if (context == null) return;
+
+ final Intent chooserIntent = new Intent(Intent.ACTION_CHOOSER);
+ chooserIntent.putExtra(Intent.EXTRA_INTENT, intent);
+ chooserIntent.putExtra(Intent.EXTRA_TITLE, title);
+ chooserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ try {
+ context.startActivity(chooserIntent);
+ } catch (Exception e) {
+ Logger.logStackTraceWithMessage(LOG_TAG, "Failed to open system chooser for:\n" + IntentUtils.getIntentString(chooserIntent), e);
+ }
+ }
+
+ /**
+ * Share text.
+ *
+ * @param context The context for operations.
+ * @param subject The subject for sharing.
+ * @param text The text to share.
+ */
+ public static void shareText(final Context context, final String subject, final String text) {
+ shareText(context, subject, text, null);
+ }
+
+ /**
+ * Share text.
+ *
+ * @param context The context for operations.
+ * @param subject The subject for sharing.
+ * @param text The text to share.
+ * @param title The title for share menu.
+ */
+ public static void shareText(final Context context, final String subject, final String text, @Nullable final String title) {
+ if (context == null || text == null) return;
+
+ final Intent shareTextIntent = new Intent(Intent.ACTION_SEND);
+ shareTextIntent.setType("text/plain");
+ shareTextIntent.putExtra(Intent.EXTRA_SUBJECT, subject);
+ shareTextIntent.putExtra(Intent.EXTRA_TEXT, DataUtils.getTruncatedCommandOutput(text, DataUtils.TRANSACTION_SIZE_LIMIT_IN_BYTES, true, false, false));
+
+ openSystemAppChooser(context, shareTextIntent, DataUtils.isNullOrEmpty(title) ? context.getString(R.string.title_share_with) : title);
+ }
+
+
+
+ /** Wrapper for {@link #copyTextToClipboard(Context, String, String, String)} with `null` `clipDataLabel` and `toastString`. */
+ public static void copyTextToClipboard(Context context, final String text) {
+ copyTextToClipboard(context, null, text, null);
+ }
+
+ /** Wrapper for {@link #copyTextToClipboard(Context, String, String, String)} with `null` `clipDataLabel`. */
+ public static void copyTextToClipboard(Context context, final String text, final String toastString) {
+ copyTextToClipboard(context, null, text, toastString);
+ }
+
+ /**
+ * Copy the text to primary clip of the clipboard.
+ *
+ * @param context The context for operations.
+ * @param clipDataLabel The label to show to the user describing the copied text.
+ * @param text The text to copy.
+ * @param toastString If this is not {@code null} or empty, then a toast is shown if copying to
+ * clipboard is successful.
+ */
+ public static void copyTextToClipboard(Context context, @Nullable final String clipDataLabel,
+ final String text, final String toastString) {
+ if (context == null || text == null) return;
+
+ ClipboardManager clipboardManager = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE);
+ if (clipboardManager == null) return;
+
+ clipboardManager.setPrimaryClip(ClipData.newPlainText(clipDataLabel,
+ DataUtils.getTruncatedCommandOutput(text, DataUtils.TRANSACTION_SIZE_LIMIT_IN_BYTES,
+ true, false, false)));
+
+ if (toastString != null && !toastString.isEmpty())
+ Logger.showToast(context, toastString, true);
+ }
+
+
+
+ /**
+ * Wrapper for {@link #getTextFromClipboard(Context, boolean)} that returns primary text {@link String}
+ * if its set and not empty.
+ */
+ @Nullable
+ public static String getTextStringFromClipboardIfSet(Context context, boolean coerceToText) {
+ CharSequence textCharSequence = getTextFromClipboard(context, coerceToText);
+ if (textCharSequence == null) return null;
+ String textString = textCharSequence.toString();
+ return !textString.isEmpty() ? textString : null;
+ }
+
+ /**
+ * Get the text from primary clip of the clipboard.
+ *
+ * @param context The context for operations.
+ * @param coerceToText Whether to call {@link ClipData.Item#coerceToText(Context)} to coerce
+ * non-text data to text.
+ * @return Returns the {@link CharSequence} of primary text. This will be `null` if failed to get it.
+ */
+ @Nullable
+ public static CharSequence getTextFromClipboard(Context context, boolean coerceToText) {
+ if (context == null) return null;
+
+ ClipboardManager clipboardManager = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE);
+ if (clipboardManager == null) return null;
+
+ ClipData clipData = clipboardManager.getPrimaryClip();
+ if (clipData == null) return null;
+
+ ClipData.Item clipItem = clipData.getItemAt(0);
+ if (clipItem == null) return null;
+
+ return coerceToText ? clipItem.coerceToText(context) : clipItem.getText();
+ }
+
+
+
+ /**
+ * Open a url.
+ *
+ * @param context The context for operations.
+ * @param url The url to open.
+ */
+ public static void openUrl(final Context context, final String url) {
+ if (context == null || url == null || url.isEmpty()) return;
+ Uri uri = Uri.parse(url);
+ Intent intent = new Intent(Intent.ACTION_VIEW, uri);
+ try {
+ context.startActivity(intent);
+ } catch (ActivityNotFoundException e) {
+ // If no activity found to handle intent, show system chooser
+ openSystemAppChooser(context, intent, context.getString(R.string.title_open_url_with));
+ } catch (Exception e) {
+ Logger.logStackTraceWithMessage(LOG_TAG, "Failed to open url \"" + url + "\"", e);
+ }
+ }
+
+ /**
+ * Save a file at the path.
+ *
+ * If if path is under {@link Environment#getExternalStorageDirectory()}
+ * or `/sdcard` and storage permission is missing, it will be requested if {@code context} is an
+ * instance of {@link Activity} or {@link AppCompatActivity} and {@code storagePermissionRequestCode}
+ * is `>=0` and the function will automatically return. The caller should call this function again
+ * if user granted the permission.
+ *
+ * @param context The context for operations.
+ * @param label The label for file.
+ * @param filePath The path to save the file.
+ * @param text The text to write to file.
+ * @param showToast If set to {@code true}, then a toast is shown if saving to file is successful.
+ * @param storagePermissionRequestCode The request code to use while asking for permission.
+ */
+ public static void saveTextToFile(final Context context, final String label, final String filePath, final String text, final boolean showToast, final int storagePermissionRequestCode) {
+ if (context == null || filePath == null || filePath.isEmpty() || text == null) return;
+
+ // If path is under primary external storage directory, then check for missing permissions.
+ if ((FileUtils.isPathInDirPath(filePath, Environment.getExternalStorageDirectory().getAbsolutePath(), true) ||
+ FileUtils.isPathInDirPath(filePath, "/sdcard", true)) &&
+ !PermissionUtils.checkPermission(context, Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
+ Logger.logErrorAndShowToast(context, LOG_TAG, context.getString(R.string.msg_storage_permission_not_granted));
+
+ if (storagePermissionRequestCode >= 0 && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ if (context instanceof AppCompatActivity)
+ PermissionUtils.requestPermission(((AppCompatActivity) context), Manifest.permission.WRITE_EXTERNAL_STORAGE, storagePermissionRequestCode);
+ else if (context instanceof Activity)
+ PermissionUtils.requestPermission(((Activity) context), Manifest.permission.WRITE_EXTERNAL_STORAGE, storagePermissionRequestCode);
+ }
+
+ return;
+ }
+
+ Error error = FileUtils.writeTextToFile(label, filePath,
+ Charset.defaultCharset(), text, false);
+ if (error != null) {
+ Logger.logErrorExtended(LOG_TAG, error.toString());
+ Logger.showToast(context, Error.getMinimalErrorString(error), true);
+ } else {
+ if (showToast)
+ Logger.showToast(context, context.getString(R.string.msg_file_saved_successfully, label, filePath), true);
+ }
+ }
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/jni/models/JniResult.java b/termux/termux-shared/src/main/java/com/termux/shared/jni/models/JniResult.java
new file mode 100644
index 000000000..3621f9f3a
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/jni/models/JniResult.java
@@ -0,0 +1,109 @@
+package com.termux.shared.jni.models;
+
+import androidx.annotation.Keep;
+import androidx.annotation.NonNull;
+
+import com.termux.shared.logger.Logger;
+
+/**
+ * A class that can be used to return result for JNI calls with support for multiple fields to easily
+ * return success and error states.
+ *
+ * https://docs.oracle.com/javase/7/docs/technotes/guides/jni/spec/functions.html
+ * https://developer.android.com/training/articles/perf-jni
+ */
+@Keep
+public class JniResult {
+
+ /**
+ * The return value for the JNI call.
+ * This should be 0 for success.
+ */
+ public int retval;
+
+ /**
+ * The errno value for any failed native system or library calls if {@link #retval} does not equal 0.
+ * This should be 0 if no errno was set.
+ *
+ * https://manpages.debian.org/testing/manpages-dev/errno.3.en.html
+ */
+ public int errno;
+
+ /**
+ * The error message for the failure if {@link #retval} does not equal 0.
+ * The message will contain errno message returned by strerror() if errno was set.
+ *
+ * https://manpages.debian.org/testing/manpages-dev/strerror.3.en.html
+ */
+ public String errmsg;
+
+ /**
+ * Optional additional int data that needs to be returned by JNI call, like bytes read on success.
+ */
+ public int intData;
+
+ /**
+ * Create an new instance of {@link JniResult}.
+ *
+ * @param retval The {@link #retval} value.
+ * @param errno The {@link #errno} value.
+ * @param errmsg The {@link #errmsg} value.
+ */
+ public JniResult(int retval, int errno, String errmsg) {
+ this.retval = retval;
+ this.errno = errno;
+ this.errmsg = errmsg;
+ }
+
+ /**
+ * Create an new instance of {@link JniResult}.
+ *
+ * @param retval The {@link #retval} value.
+ * @param errno The {@link #errno} value.
+ * @param errmsg The {@link #errmsg} value.
+ * @param intData The {@link #intData} value.
+ */
+ public JniResult(int retval, int errno, String errmsg, int intData) {
+ this(retval, errno, errmsg);
+ this.intData = intData;
+ }
+
+ /**
+ * Create an new instance of {@link JniResult} from a {@link Throwable} with {@link #retval} -1.
+ *
+ * @param message The error message.
+ * @param throwable The {@link Throwable} value.
+ */
+ public JniResult(String message, Throwable throwable) {
+ this(-1, 0, Logger.getMessageAndStackTraceString(message, throwable));
+ }
+
+ /**
+ * Get error {@link String} for {@link JniResult}.
+ *
+ * @param result The {@link JniResult} to get error from.
+ * @return Returns the error {@link String}.
+ */
+ @NonNull
+ public static String getErrorString(final JniResult result) {
+ if (result == null) return "null";
+ return result.getErrorString();
+ }
+
+ /** Get error {@link String} for {@link JniResult}. */
+ @NonNull
+ public String getErrorString() {
+ StringBuilder logString = new StringBuilder();
+
+ logString.append(Logger.getSingleLineLogStringEntry("Retval", retval, "-"));
+
+ if (errno != 0)
+ logString.append("\n").append(Logger.getSingleLineLogStringEntry("Errno", errno, "-"));
+
+ if (errmsg != null && !errmsg.isEmpty())
+ logString.append("\n").append(Logger.getMultiLineLogStringEntry("Errmsg", errmsg, "-"));
+
+ return logString.toString();
+ }
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/logger/Logger.java b/termux/termux-shared/src/main/java/com/termux/shared/logger/Logger.java
new file mode 100644
index 000000000..e5075b1f9
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/logger/Logger.java
@@ -0,0 +1,502 @@
+package com.termux.shared.logger;
+
+import android.content.Context;
+import android.os.Handler;
+import android.os.Looper;
+import android.util.Log;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+
+import com.termux.shared.R;
+import com.termux.shared.data.DataUtils;
+
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+public class Logger {
+
+ private static String DEFAULT_LOG_TAG = "Logger";
+
+ public static final int LOG_LEVEL_OFF = 0; // log nothing
+ public static final int LOG_LEVEL_NORMAL = 1; // start logging error, warn and info messages and stacktraces
+ public static final int LOG_LEVEL_DEBUG = 2; // start logging debug messages
+ public static final int LOG_LEVEL_VERBOSE = 3; // start logging verbose messages
+
+ public static final int DEFAULT_LOG_LEVEL = LOG_LEVEL_NORMAL;
+ public static final int MAX_LOG_LEVEL = LOG_LEVEL_VERBOSE;
+ private static int CURRENT_LOG_LEVEL = DEFAULT_LOG_LEVEL;
+
+ /**
+ * The maximum size of the log entry payload that can be written to the logger. An attempt to
+ * write more than this amount will result in a truncated log entry.
+ *
+ * The limit is 4068 but this includes log tag and log level prefix "D/" before log tag and ": "
+ * suffix after it.
+ *
+ * #define LOGGER_ENTRY_MAX_PAYLOAD 4068
+ * https://cs.android.com/android/_/android/platform/system/core/+/android10-release:liblog/include/log/log_read.h;l=127
+ */
+ public static final int LOGGER_ENTRY_MAX_PAYLOAD = 4068; // 4068 bytes
+
+ /**
+ * The maximum safe size of the log entry payload that can be written to the logger, based on
+ * {@link #LOGGER_ENTRY_MAX_PAYLOAD}. Using 4000 as a safe limit to give log tag and its
+ * prefix/suffix max 68 characters for itself. Use "log*Extended()" functions to use max possible
+ * limit if tag is already known.
+ */
+ public static final int LOGGER_ENTRY_MAX_SAFE_PAYLOAD = 4000; // 4000 bytes
+
+
+
+ public static void logMessage(int logPriority, String tag, String message) {
+ if (logPriority == Log.ERROR && CURRENT_LOG_LEVEL >= LOG_LEVEL_NORMAL)
+ Log.e(getFullTag(tag), message);
+ else if (logPriority == Log.WARN && CURRENT_LOG_LEVEL >= LOG_LEVEL_NORMAL)
+ Log.w(getFullTag(tag), message);
+ else if (logPriority == Log.INFO && CURRENT_LOG_LEVEL >= LOG_LEVEL_NORMAL)
+ Log.i(getFullTag(tag), message);
+ else if (logPriority == Log.DEBUG && CURRENT_LOG_LEVEL >= LOG_LEVEL_DEBUG)
+ Log.d(getFullTag(tag), message);
+ else if (logPriority == Log.VERBOSE && CURRENT_LOG_LEVEL >= LOG_LEVEL_VERBOSE)
+ Log.v(getFullTag(tag), message);
+ }
+
+ public static void logExtendedMessage(int logLevel, String tag, String message) {
+ if (message == null) return;
+
+ int cutOffIndex;
+ int nextNewlineIndex;
+ String prefix = "";
+
+ // -8 for prefix "(xx/xx)" (max 99 sections), - log tag length, -4 for log tag prefix "D/" and suffix ": "
+ int maxEntrySize = LOGGER_ENTRY_MAX_PAYLOAD - 8 - getFullTag(tag).length() - 4;
+
+ List messagesList = new ArrayList<>();
+
+ while(!message.isEmpty()) {
+ if (message.length() > maxEntrySize) {
+ cutOffIndex = maxEntrySize;
+ nextNewlineIndex = message.lastIndexOf('\n', cutOffIndex);
+ if (nextNewlineIndex != -1) {
+ cutOffIndex = nextNewlineIndex + 1;
+ }
+ messagesList.add(message.substring(0, cutOffIndex));
+ message = message.substring(cutOffIndex);
+ } else {
+ messagesList.add(message);
+ break;
+ }
+ }
+
+ for(int i=0; i 1)
+ prefix = "(" + (i + 1) + "/" + messagesList.size() + ")\n";
+ logMessage(logLevel, tag, prefix + messagesList.get(i));
+ }
+ }
+
+
+
+ public static void logError(String tag, String message) {
+ logMessage(Log.ERROR, tag, message);
+ }
+
+ public static void logError(String message) {
+ logMessage(Log.ERROR, DEFAULT_LOG_TAG, message);
+ }
+
+ public static void logErrorExtended(String tag, String message) {
+ logExtendedMessage(Log.ERROR, tag, message);
+ }
+
+ public static void logErrorExtended(String message) {
+ logExtendedMessage(Log.ERROR, DEFAULT_LOG_TAG, message);
+ }
+
+
+
+ public static void logErrorPrivate(String tag, String message) {
+ if (CURRENT_LOG_LEVEL >= LOG_LEVEL_DEBUG)
+ logMessage(Log.ERROR, tag, message);
+ }
+
+ public static void logErrorPrivate(String message) {
+ if (CURRENT_LOG_LEVEL >= LOG_LEVEL_DEBUG)
+ logMessage(Log.ERROR, DEFAULT_LOG_TAG, message);
+ }
+
+ public static void logErrorPrivateExtended(String tag, String message) {
+ if (CURRENT_LOG_LEVEL >= LOG_LEVEL_DEBUG)
+ logExtendedMessage(Log.ERROR, tag, message);
+ }
+
+ public static void logErrorPrivateExtended(String message) {
+ if (CURRENT_LOG_LEVEL >= LOG_LEVEL_DEBUG)
+ logExtendedMessage(Log.ERROR, DEFAULT_LOG_TAG, message);
+ }
+
+
+
+ public static void logWarn(String tag, String message) {
+ logMessage(Log.WARN, tag, message);
+ }
+
+ public static void logWarn(String message) {
+ logMessage(Log.WARN, DEFAULT_LOG_TAG, message);
+ }
+
+ public static void logWarnExtended(String tag, String message) {
+ logExtendedMessage(Log.WARN, tag, message);
+ }
+
+ public static void logWarnExtended(String message) {
+ logExtendedMessage(Log.WARN, DEFAULT_LOG_TAG, message);
+ }
+
+
+
+ public static void logInfo(String tag, String message) {
+ logMessage(Log.INFO, tag, message);
+ }
+
+ public static void logInfo(String message) {
+ logMessage(Log.INFO, DEFAULT_LOG_TAG, message);
+ }
+
+ public static void logInfoExtended(String tag, String message) {
+ logExtendedMessage(Log.INFO, tag, message);
+ }
+
+ public static void logInfoExtended(String message) {
+ logExtendedMessage(Log.INFO, DEFAULT_LOG_TAG, message);
+ }
+
+
+
+ public static void logDebug(String tag, String message) {
+ logMessage(Log.DEBUG, tag, message);
+ }
+
+ public static void logDebug(String message) {
+ logMessage(Log.DEBUG, DEFAULT_LOG_TAG, message);
+ }
+
+ public static void logDebugExtended(String tag, String message) {
+ logExtendedMessage(Log.DEBUG, tag, message);
+ }
+
+ public static void logDebugExtended(String message) {
+ logExtendedMessage(Log.DEBUG, DEFAULT_LOG_TAG, message);
+ }
+
+
+
+ public static void logVerbose(String tag, String message) {
+ logMessage(Log.VERBOSE, tag, message);
+ }
+
+ public static void logVerbose(String message) {
+ logMessage(Log.VERBOSE, DEFAULT_LOG_TAG, message);
+ }
+
+ public static void logVerboseExtended(String tag, String message) {
+ logExtendedMessage(Log.VERBOSE, tag, message);
+ }
+
+ public static void logVerboseExtended(String message) {
+ logExtendedMessage(Log.VERBOSE, DEFAULT_LOG_TAG, message);
+ }
+
+ public static void logVerboseForce(String tag, String message) {
+ Log.v(tag, message);
+ }
+
+
+
+ public static void logInfoAndShowToast(Context context, String tag, String message) {
+ if (CURRENT_LOG_LEVEL >= LOG_LEVEL_NORMAL) {
+ logInfo(tag, message);
+ showToast(context, message, true);
+ }
+ }
+
+ public static void logInfoAndShowToast(Context context, String message) {
+ logInfoAndShowToast(context, DEFAULT_LOG_TAG, message);
+ }
+
+
+
+ public static void logErrorAndShowToast(Context context, String tag, String message) {
+ if (CURRENT_LOG_LEVEL >= LOG_LEVEL_NORMAL) {
+ logError(tag, message);
+ showToast(context, message, true);
+ }
+ }
+
+ public static void logErrorAndShowToast(Context context, String message) {
+ logErrorAndShowToast(context, DEFAULT_LOG_TAG, message);
+ }
+
+
+
+ public static void logDebugAndShowToast(Context context, String tag, String message) {
+ if (CURRENT_LOG_LEVEL >= LOG_LEVEL_DEBUG) {
+ logDebug(tag, message);
+ showToast(context, message, true);
+ }
+ }
+
+ public static void logDebugAndShowToast(Context context, String message) {
+ logDebugAndShowToast(context, DEFAULT_LOG_TAG, message);
+ }
+
+
+
+ public static void logStackTraceWithMessage(String tag, String message, Throwable throwable) {
+ Logger.logErrorExtended(tag, getMessageAndStackTraceString(message, throwable));
+ }
+
+ public static void logStackTraceWithMessage(String message, Throwable throwable) {
+ logStackTraceWithMessage(DEFAULT_LOG_TAG, message, throwable);
+ }
+
+ public static void logStackTrace(String tag, Throwable throwable) {
+ logStackTraceWithMessage(tag, null, throwable);
+ }
+
+ public static void logStackTrace(Throwable throwable) {
+ logStackTraceWithMessage(DEFAULT_LOG_TAG, null, throwable);
+ }
+
+
+
+ public static void logStackTracesWithMessage(String tag, String message, List throwablesList) {
+ Logger.logErrorExtended(tag, getMessageAndStackTracesString(message, throwablesList));
+ }
+
+
+
+ public static String getMessageAndStackTraceString(String message, Throwable throwable) {
+ if (message == null && throwable == null)
+ return null;
+ else if (message != null && throwable != null)
+ return message + ":\n" + getStackTraceString(throwable);
+ else if (throwable == null)
+ return message;
+ else
+ return getStackTraceString(throwable);
+ }
+
+ public static String getMessageAndStackTracesString(String message, List throwablesList) {
+ if (message == null && (throwablesList == null || throwablesList.size() == 0))
+ return null;
+ else if (message != null && (throwablesList != null && throwablesList.size() != 0))
+ return message + ":\n" + getStackTracesString(null, getStackTracesStringArray(throwablesList));
+ else if (throwablesList == null || throwablesList.size() == 0)
+ return message;
+ else
+ return getStackTracesString(null, getStackTracesStringArray(throwablesList));
+ }
+
+
+
+ public static String getStackTraceString(Throwable throwable) {
+ if (throwable == null) return null;
+
+ String stackTraceString = null;
+
+ try {
+ StringWriter errors = new StringWriter();
+ PrintWriter pw = new PrintWriter(errors);
+ throwable.printStackTrace(pw);
+ pw.close();
+ stackTraceString = errors.toString();
+ errors.close();
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+
+ return stackTraceString;
+ }
+
+
+
+ public static String[] getStackTracesStringArray(Throwable throwable) {
+ return getStackTracesStringArray(Collections.singletonList(throwable));
+ }
+
+ public static String[] getStackTracesStringArray(List throwablesList) {
+ if (throwablesList == null) return null;
+ final String[] stackTraceStringArray = new String[throwablesList.size()];
+ for (int i = 0; i < throwablesList.size(); i++) {
+ stackTraceStringArray[i] = getStackTraceString(throwablesList.get(i));
+ }
+ return stackTraceStringArray;
+ }
+
+
+
+ public static String getStackTracesString(String label, String[] stackTraceStringArray) {
+ if (label == null) label = "StackTraces:";
+ StringBuilder stackTracesString = new StringBuilder(label);
+
+ if (stackTraceStringArray == null || stackTraceStringArray.length == 0) {
+ stackTracesString.append(" -");
+ } else {
+ for (int i = 0; i != stackTraceStringArray.length; i++) {
+ if (stackTraceStringArray.length > 1)
+ stackTracesString.append("\n\nStacktrace ").append(i + 1);
+
+ stackTracesString.append("\n```\n").append(stackTraceStringArray[i]).append("\n```\n");
+ }
+ }
+
+ return stackTracesString.toString();
+ }
+
+ public static String getStackTracesMarkdownString(String label, String[] stackTraceStringArray) {
+ if (label == null) label = "StackTraces";
+ StringBuilder stackTracesString = new StringBuilder("### " + label);
+
+ if (stackTraceStringArray == null || stackTraceStringArray.length == 0) {
+ stackTracesString.append("\n\n`-`");
+ } else {
+ for (int i = 0; i != stackTraceStringArray.length; i++) {
+ if (stackTraceStringArray.length > 1)
+ stackTracesString.append("\n\n\n#### Stacktrace ").append(i + 1);
+
+ stackTracesString.append("\n\n```\n").append(stackTraceStringArray[i]).append("\n```");
+ }
+ }
+
+ stackTracesString.append("\n##\n");
+
+ return stackTracesString.toString();
+ }
+
+ public static String getSingleLineLogStringEntry(String label, Object object, String def) {
+ if (object != null)
+ return label + ": `" + object + "`";
+ else
+ return label + ": " + def;
+ }
+
+ public static String getMultiLineLogStringEntry(String label, Object object, String def) {
+ if (object != null)
+ return label + ":\n```\n" + object + "\n```\n";
+ else
+ return label + ": " + def;
+ }
+
+
+
+ public static void showToast(final Context context, final String toastText, boolean longDuration) {
+ if (context == null || DataUtils.isNullOrEmpty(toastText)) return;
+
+ new Handler(Looper.getMainLooper()).post(() -> Toast.makeText(context, toastText, longDuration ? Toast.LENGTH_LONG : Toast.LENGTH_SHORT).show());
+ }
+
+
+
+ public static CharSequence[] getLogLevelsArray() {
+ return new CharSequence[]{
+ String.valueOf(LOG_LEVEL_OFF),
+ String.valueOf(LOG_LEVEL_NORMAL),
+ String.valueOf(LOG_LEVEL_DEBUG),
+ String.valueOf(LOG_LEVEL_VERBOSE)
+ };
+ }
+
+ public static CharSequence[] getLogLevelLabelsArray(Context context, CharSequence[] logLevels, boolean addDefaultTag) {
+ if (logLevels == null) return null;
+
+ CharSequence[] logLevelLabels = new CharSequence[logLevels.length];
+
+ for(int i=0; i 23 for Nougat (7.0) and prior releases.
+ * https://developer.android.com/reference/android/util/Log#isLoggable(java.lang.String,%20int) */
+ public static void setDefaultLogTag(@NonNull String defaultLogTag) {
+ DEFAULT_LOG_TAG = defaultLogTag.length() >= 23 ? defaultLogTag.substring(0, 22) : defaultLogTag;
+ }
+
+
+
+ public static int getLogLevel() {
+ return CURRENT_LOG_LEVEL;
+ }
+
+ public static int setLogLevel(Context context, int logLevel) {
+ if (isLogLevelValid(logLevel))
+ CURRENT_LOG_LEVEL = logLevel;
+ else
+ CURRENT_LOG_LEVEL = DEFAULT_LOG_LEVEL;
+
+ if (context != null)
+ showToast(context, context.getString(R.string.log_level_value, getLogLevelLabel(context, CURRENT_LOG_LEVEL, false)),true);
+
+ return CURRENT_LOG_LEVEL;
+ }
+
+ /** The colon character ":" must not exist inside the tag, otherwise the `logcat` command
+ * filterspecs arguments `[:priority]` will not work and will throw `Invalid filter expression`
+ * error.
+ * https://cs.android.com/android/platform/superproject/+/android-12.0.0_r4:system/logging/liblog/logprint.cpp;l=363
+ * https://cs.android.com/android/platform/superproject/+/android-12.0.0_r4:system/logging/logcat/logcat.cpp;l=884
+ * */
+ public static String getFullTag(String tag) {
+ if (DEFAULT_LOG_TAG.equals(tag))
+ return tag;
+ else
+ return DEFAULT_LOG_TAG + "." + tag;
+ }
+
+ public static boolean isLogLevelValid(Integer logLevel) {
+ return (logLevel != null && logLevel >= LOG_LEVEL_OFF && logLevel <= MAX_LOG_LEVEL);
+ }
+
+ /** Check if custom log level is valid and >= {@link #CURRENT_LOG_LEVEL}. If custom log level is
+ * not valid then {@link #LOG_LEVEL_VERBOSE} must be >= {@link #CURRENT_LOG_LEVEL}. */
+ public static boolean shouldEnableLoggingForCustomLogLevel(Integer customLogLevel) {
+ if (CURRENT_LOG_LEVEL <= LOG_LEVEL_OFF) return false;
+ if (customLogLevel == null) return CURRENT_LOG_LEVEL >= LOG_LEVEL_VERBOSE; // Use default app log level
+ if (customLogLevel <= LOG_LEVEL_OFF) return false;
+ customLogLevel = Logger.isLogLevelValid(customLogLevel) ? customLogLevel: Logger.LOG_LEVEL_VERBOSE;
+ return (customLogLevel >= CURRENT_LOG_LEVEL);
+ }
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/markdown/MarkdownUtils.java b/termux/termux-shared/src/main/java/com/termux/shared/markdown/MarkdownUtils.java
new file mode 100644
index 000000000..4e1b482ec
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/markdown/MarkdownUtils.java
@@ -0,0 +1,207 @@
+package com.termux.shared.markdown;
+
+import android.content.Context;
+import android.graphics.Typeface;
+import android.text.Spanned;
+import android.text.style.AbsoluteSizeSpan;
+import android.text.style.BackgroundColorSpan;
+import android.text.style.BulletSpan;
+import android.text.style.QuoteSpan;
+import android.text.style.StrikethroughSpan;
+import android.text.style.StyleSpan;
+import android.text.style.TypefaceSpan;
+import android.text.util.Linkify;
+
+import androidx.annotation.NonNull;
+import androidx.core.content.ContextCompat;
+
+import com.google.common.base.Strings;
+import com.termux.shared.R;
+import com.termux.shared.theme.ThemeUtils;
+
+import org.commonmark.ext.gfm.strikethrough.Strikethrough;
+import org.commonmark.node.BlockQuote;
+import org.commonmark.node.Code;
+import org.commonmark.node.Emphasis;
+import org.commonmark.node.FencedCodeBlock;
+import org.commonmark.node.ListItem;
+import org.commonmark.node.StrongEmphasis;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import io.noties.markwon.AbstractMarkwonPlugin;
+import io.noties.markwon.Markwon;
+import io.noties.markwon.MarkwonSpansFactory;
+import io.noties.markwon.MarkwonVisitor;
+import io.noties.markwon.ext.strikethrough.StrikethroughPlugin;
+import io.noties.markwon.linkify.LinkifyPlugin;
+
+public class MarkdownUtils {
+
+ public static final String backtick = "`";
+ public static final Pattern backticksPattern = Pattern.compile("(" + backtick + "+)");
+
+ /**
+ * Get the markdown code {@link String} for a {@link String}. This ensures all backticks "`" are
+ * properly escaped so that markdown does not break.
+ *
+ * @param string The {@link String} to convert.
+ * @param codeBlock If the {@link String} is to be converted to a code block or inline code.
+ * @return Returns the markdown code {@link String}.
+ */
+ public static String getMarkdownCodeForString(String string, boolean codeBlock) {
+ if (string == null) return null;
+ if (string.isEmpty()) return "";
+
+ int maxConsecutiveBackTicksCount = getMaxConsecutiveBackTicksCount(string);
+
+ // markdown requires surrounding backticks count to be at least one more than the count
+ // of consecutive ticks in the string itself
+ int backticksCountToUse;
+ if (codeBlock)
+ backticksCountToUse = maxConsecutiveBackTicksCount + 3;
+ else
+ backticksCountToUse = maxConsecutiveBackTicksCount + 1;
+
+ // create a string with n backticks where n==backticksCountToUse
+ String backticksToUse = Strings.repeat(backtick, backticksCountToUse);
+
+ if (codeBlock)
+ return backticksToUse + "\n" + string + "\n" + backticksToUse;
+ else {
+ // add a space to any prefixed or suffixed backtick characters
+ if (string.startsWith(backtick))
+ string = " " + string;
+ if (string.endsWith(backtick))
+ string = string + " ";
+
+ return backticksToUse + string + backticksToUse;
+ }
+ }
+
+ /**
+ * Get the max consecutive backticks "`" in a {@link String}.
+ *
+ * @param string The {@link String} to check.
+ * @return Returns the max consecutive backticks count.
+ */
+ public static int getMaxConsecutiveBackTicksCount(String string) {
+ if (string == null || string.isEmpty()) return 0;
+
+ int maxCount = 0;
+ int matchCount;
+ String match;
+
+ Matcher matcher = backticksPattern.matcher(string);
+ while(matcher.find()) {
+ match = matcher.group(1);
+ matchCount = match != null ? match.length() : 0;
+ if (matchCount > maxCount)
+ maxCount = matchCount;
+ }
+
+ return maxCount;
+ }
+
+
+
+ public static String getLiteralSingleLineMarkdownStringEntry(String label, Object object, String def) {
+ return "**" + label + "**: " + (object != null ? object.toString() : def) + " ";
+ }
+
+ public static String getSingleLineMarkdownStringEntry(String label, Object object, String def) {
+ if (object != null)
+ return "**" + label + "**: " + getMarkdownCodeForString(object.toString(), false) + " ";
+ else
+ return "**" + label + "**: " + def + " ";
+ }
+
+ public static String getMultiLineMarkdownStringEntry(String label, Object object, String def) {
+ if (object != null)
+ return "**" + label + "**:\n" + getMarkdownCodeForString(object.toString(), true) + "\n";
+ else
+ return "**" + label + "**: " + def + "\n";
+ }
+
+ public static String getLinkMarkdownString(String label, String url) {
+ if (url != null)
+ return "[" + label.replaceAll("]", "\\\\]") + "](" + url.replaceAll("\\)", "\\\\)") + ")";
+ else
+ return label;
+ }
+
+
+ /** Check following for more info:
+ * https://github.com/noties/Markwon/tree/v4.6.2/app-sample
+ * https://noties.io/Markwon/docs/v4/recycler/
+ * https://github.com/noties/Markwon/blob/v4.6.2/app-sample/src/main/java/io/noties/markwon/app/readme/ReadMeActivity.kt
+ */
+ public static Markwon getRecyclerMarkwonBuilder(Context context) {
+ return Markwon.builder(context)
+ .usePlugin(LinkifyPlugin.create(Linkify.EMAIL_ADDRESSES | Linkify.WEB_URLS))
+ .usePlugin(new AbstractMarkwonPlugin() {
+ @Override
+ public void configureVisitor(@NonNull MarkwonVisitor.Builder builder) {
+ builder.on(FencedCodeBlock.class, (visitor, fencedCodeBlock) -> {
+ // we actually won't be applying code spans here, as our custom xml view will
+ // draw background and apply mono typeface
+ //
+ // NB the `trim` operation on literal (as code will have a new line at the end)
+ final CharSequence code = visitor.configuration()
+ .syntaxHighlight()
+ .highlight(fencedCodeBlock.getInfo(), fencedCodeBlock.getLiteral().trim());
+ visitor.builder().append(code);
+ });
+ }
+
+ @Override
+ public void configureSpansFactory(@NonNull MarkwonSpansFactory.Builder builder) {
+ // Do not change color for night themes
+ if (!ThemeUtils.isNightModeEnabled(context)) {
+ builder
+ // set color for inline code
+ .setFactory(Code.class, (configuration, props) -> new Object[]{
+ new BackgroundColorSpan(ContextCompat.getColor(context, R.color.background_markdown_code_inline)),
+ });
+ }
+ }
+ })
+ .build();
+ }
+
+ /** Check following for more info:
+ * https://github.com/noties/Markwon/tree/v4.6.2/app-sample
+ * https://github.com/noties/Markwon/blob/v4.6.2/app-sample/src/main/java/io/noties/markwon/app/samples/notification/NotificationSample.java
+ */
+ public static Markwon getSpannedMarkwonBuilder(Context context) {
+ return Markwon.builder(context)
+ .usePlugin(StrikethroughPlugin.create())
+ .usePlugin(new AbstractMarkwonPlugin() {
+ @Override
+ public void configureSpansFactory(@NonNull MarkwonSpansFactory.Builder builder) {
+ builder
+ .setFactory(Emphasis.class, (configuration, props) -> new StyleSpan(Typeface.ITALIC))
+ .setFactory(StrongEmphasis.class, (configuration, props) -> new StyleSpan(Typeface.BOLD))
+ .setFactory(BlockQuote.class, (configuration, props) -> new QuoteSpan())
+ .setFactory(Strikethrough.class, (configuration, props) -> new StrikethroughSpan())
+ // NB! notification does not handle background color
+ .setFactory(Code.class, (configuration, props) -> new Object[]{
+ new BackgroundColorSpan(ContextCompat.getColor(context, R.color.background_markdown_code_inline)),
+ new TypefaceSpan("monospace"),
+ new AbsoluteSizeSpan(48)
+ })
+ // NB! both ordered and bullet list items
+ .setFactory(ListItem.class, (configuration, props) -> new BulletSpan());
+ }
+ })
+ .build();
+ }
+
+ public static Spanned getSpannedMarkdownText(Context context, String string) {
+ if (context == null || string == null) return null;
+ final Markwon markwon = getSpannedMarkwonBuilder(context);
+ return markwon.toMarkdown(string);
+ }
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/models/ReportInfo.java b/termux/termux-shared/src/main/java/com/termux/shared/models/ReportInfo.java
new file mode 100644
index 000000000..0f70fb562
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/models/ReportInfo.java
@@ -0,0 +1,98 @@
+package com.termux.shared.models;
+
+import com.termux.shared.markdown.MarkdownUtils;
+import com.termux.shared.android.AndroidUtils;
+
+import java.io.Serializable;
+
+/**
+ * An object that stored info for {@link com.termux.shared.activities.ReportActivity}.
+ */
+public class ReportInfo implements Serializable {
+
+ /** The user action that was being processed for which the report was generated. */
+ public final String userAction;
+ /** The internal app component that sent the report. */
+ public final String sender;
+ /** The report title. */
+ public final String reportTitle;
+ /** The timestamp for the report. */
+ public final String reportTimestamp;
+
+ /** The markdown report text prefix. Will not be part of copy and share operations, etc. */
+ public String reportStringPrefix;
+ /** The markdown report text. */
+ public String reportString;
+ /** The markdown report text suffix. Will not be part of copy and share operations, etc. */
+ public String reportStringSuffix;
+
+ /** If set to {@code true}, then report header info will be added to the report when markdown is
+ * generated. */
+ public boolean addReportInfoHeaderToMarkdown = false;
+
+ /** The label for the report file to save if user selects menu_item_save_report_to_file. */
+ public String reportSaveFileLabel;
+ /** The path for the report file to save if user selects menu_item_save_report_to_file. */
+ public String reportSaveFilePath;
+
+ public ReportInfo(String userAction, String sender, String reportTitle) {
+ this.userAction = userAction;
+ this.sender = sender;
+ this.reportTitle = reportTitle;
+ this.reportTimestamp = AndroidUtils.getCurrentMilliSecondUTCTimeStamp();
+ }
+
+ public void setReportStringPrefix(String reportStringPrefix) {
+ this.reportStringPrefix = reportStringPrefix;
+ }
+
+ public void setReportString(String reportString) {
+ this.reportString = reportString;
+ }
+
+ public void setReportStringSuffix(String reportStringSuffix) {
+ this.reportStringSuffix = reportStringSuffix;
+ }
+
+ public void setAddReportInfoHeaderToMarkdown(boolean addReportInfoHeaderToMarkdown) {
+ this.addReportInfoHeaderToMarkdown = addReportInfoHeaderToMarkdown;
+ }
+
+ public void setReportSaveFileLabelAndPath(String reportSaveFileLabel, String reportSaveFilePath) {
+ setReportSaveFileLabel(reportSaveFileLabel);
+ setReportSaveFilePath(reportSaveFilePath);
+ }
+
+ public void setReportSaveFileLabel(String reportSaveFileLabel) {
+ this.reportSaveFileLabel = reportSaveFileLabel;
+ }
+
+ public void setReportSaveFilePath(String reportSaveFilePath) {
+ this.reportSaveFilePath = reportSaveFilePath;
+ }
+
+ /**
+ * Get a markdown {@link String} for {@link ReportInfo}.
+ *
+ * @param reportInfo The {@link ReportInfo} to convert.
+ * @return Returns the markdown {@link String}.
+ */
+ public static String getReportInfoMarkdownString(final ReportInfo reportInfo) {
+ if (reportInfo == null) return "null";
+
+ StringBuilder markdownString = new StringBuilder();
+
+ if (reportInfo.addReportInfoHeaderToMarkdown) {
+ markdownString.append("## Report Info\n\n");
+ markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("User Action", reportInfo.userAction, "-"));
+ markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("Sender", reportInfo.sender, "-"));
+ markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("Report Timestamp", reportInfo.reportTimestamp, "-"));
+ markdownString.append("\n##\n\n");
+ }
+
+ markdownString.append(reportInfo.reportString);
+
+ return markdownString.toString();
+ }
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/models/TextIOInfo.java b/termux/termux-shared/src/main/java/com/termux/shared/models/TextIOInfo.java
new file mode 100644
index 000000000..df9993afc
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/models/TextIOInfo.java
@@ -0,0 +1,234 @@
+package com.termux.shared.models;
+
+import android.graphics.Color;
+import android.graphics.Typeface;
+
+import androidx.annotation.NonNull;
+
+import com.termux.shared.activities.TextIOActivity;
+import com.termux.shared.data.DataUtils;
+
+import java.io.Serializable;
+
+/**
+ * An object that stored info for {@link TextIOActivity}.
+ * Max text limit is 95KB to prevent TransactionTooLargeException as per
+ * {@link DataUtils#TRANSACTION_SIZE_LIMIT_IN_BYTES}. Larger size can be supported for in-app
+ * transactions by storing {@link TextIOInfo} as a serialized object in a file like
+ * {@link com.termux.shared.activities.ReportActivity} does.
+ */
+public class TextIOInfo implements Serializable {
+
+ public static final int GENERAL_DATA_SIZE_LIMIT_IN_BYTES = 1000;
+ public static final int LABEL_SIZE_LIMIT_IN_BYTES = 4000;
+ public static final int TEXT_SIZE_LIMIT_IN_BYTES = 100000 - GENERAL_DATA_SIZE_LIMIT_IN_BYTES - LABEL_SIZE_LIMIT_IN_BYTES; // < 100KB
+
+ /** The action for which {@link TextIOActivity} will be started. */
+ private final String mAction;
+ /** The internal app component that is will start the {@link TextIOActivity}. */
+ private final String mSender;
+
+ /** The activity title. */
+ private String mTitle;
+
+ /** If back button should be shown in {@link android.app.ActionBar}. */
+ private boolean mShowBackButtonInActionBar = false;
+
+
+ /** If label is enabled. */
+ private boolean mLabelEnabled = false;
+ /**
+ * The label of text input set in {@link android.widget.TextView} that can be updated by user.
+ * Max allowed length is {@link #LABEL_SIZE_LIMIT_IN_BYTES}.
+ */
+ private String mLabel;
+ /** The text size of label. Defaults to 14sp. */
+ private int mLabelSize = 14;
+ /** The text color of label. Defaults to {@link Color#BLACK}. */
+ private int mLabelColor = Color.BLACK;
+ /** The {@link Typeface} family of label. Defaults to "sans-serif". */
+ private String mLabelTypeFaceFamily = "sans-serif";
+ /** The {@link Typeface} style of label. Defaults to {@link Typeface#BOLD}. */
+ private int mLabelTypeFaceStyle = Typeface.BOLD;
+
+
+ /**
+ * The text of text input set in {@link android.widget.EditText} that can be updated by user.
+ * Max allowed length is {@link #TEXT_SIZE_LIMIT_IN_BYTES}.
+ */
+ private String mText;
+ /** The text size for text. Defaults to 12sp. */
+ private int mTextSize = 12;
+ /** The text size for text. Defaults to {@link #TEXT_SIZE_LIMIT_IN_BYTES}. */
+ private int mTextLengthLimit = TEXT_SIZE_LIMIT_IN_BYTES;
+ /** The text color of text. Defaults to {@link Color#BLACK}. */
+ private int mTextColor = Color.BLACK;
+ /** The {@link Typeface} family for text. Defaults to "sans-serif". */
+ private String mTextTypeFaceFamily = "sans-serif";
+ /** The {@link Typeface} style for text. Defaults to {@link Typeface#NORMAL}. */
+ private int mTextTypeFaceStyle = Typeface.NORMAL;
+ /** If horizontal scrolling should be enabled for text. */
+ private boolean mTextHorizontallyScrolling = false;
+ /** If character usage should be enabled for text. */
+ private boolean mShowTextCharacterUsage = false;
+ /** If editing text should be disabled so that text acts like its in a {@link android.widget.TextView}. */
+ private boolean mEditingTextDisabled = false;
+
+
+ public TextIOInfo(@NonNull String action, @NonNull String sender) {
+ mAction = action;
+ mSender = sender;
+ }
+
+
+ public String getAction() {
+ return mAction;
+ }
+
+ public String getSender() {
+ return mSender;
+ }
+
+
+ public String getTitle() {
+ return mTitle;
+ }
+
+ public void setTitle(String title) {
+ mTitle = title;
+ }
+
+ public boolean shouldShowBackButtonInActionBar() {
+ return mShowBackButtonInActionBar;
+ }
+
+ public void setShowBackButtonInActionBar(boolean showBackButtonInActionBar) {
+ mShowBackButtonInActionBar = showBackButtonInActionBar;
+ }
+
+
+ public boolean isLabelEnabled() {
+ return mLabelEnabled;
+ }
+
+ public void setLabelEnabled(boolean labelEnabled) {
+ mLabelEnabled = labelEnabled;
+ }
+
+ public String getLabel() {
+ return mLabel;
+ }
+
+ public void setLabel(String label) {
+ mLabel = DataUtils.getTruncatedCommandOutput(label, LABEL_SIZE_LIMIT_IN_BYTES, true, false, false);
+ }
+
+ public int getLabelSize() {
+ return mLabelSize;
+ }
+
+ public void setLabelSize(int labelSize) {
+ if (labelSize > 0)
+ mLabelSize = labelSize;
+ }
+
+ public int getLabelColor() {
+ return mLabelColor;
+ }
+
+ public void setLabelColor(int labelColor) {
+ mLabelColor = labelColor;
+ }
+
+ public String getLabelTypeFaceFamily() {
+ return mLabelTypeFaceFamily;
+ }
+
+ public void setLabelTypeFaceFamily(String labelTypeFaceFamily) {
+ mLabelTypeFaceFamily = labelTypeFaceFamily;
+ }
+
+ public int getLabelTypeFaceStyle() {
+ return mLabelTypeFaceStyle;
+ }
+
+ public void setLabelTypeFaceStyle(int labelTypeFaceStyle) {
+ mLabelTypeFaceStyle = labelTypeFaceStyle;
+ }
+
+
+ public String getText() {
+ return mText;
+ }
+
+ public void setText(String text) {
+ mText = DataUtils.getTruncatedCommandOutput(text, TEXT_SIZE_LIMIT_IN_BYTES, true, false, false);
+ }
+
+ public int getTextSize() {
+ return mTextSize;
+ }
+
+ public void setTextSize(int textSize) {
+ if (textSize > 0)
+ mTextSize = textSize;
+ }
+
+ public int getTextLengthLimit() {
+ return mTextLengthLimit;
+ }
+
+ public void setTextLengthLimit(int textLengthLimit) {
+ if (textLengthLimit < TEXT_SIZE_LIMIT_IN_BYTES)
+ mTextLengthLimit = textLengthLimit;
+ }
+
+ public int getTextColor() {
+ return mTextColor;
+ }
+
+ public void setTextColor(int textColor) {
+ mTextColor = textColor;
+ }
+
+ public String getTextTypeFaceFamily() {
+ return mTextTypeFaceFamily;
+ }
+
+ public void setTextTypeFaceFamily(String textTypeFaceFamily) {
+ mTextTypeFaceFamily = textTypeFaceFamily;
+ }
+
+ public int getTextTypeFaceStyle() {
+ return mTextTypeFaceStyle;
+ }
+
+ public void setTextTypeFaceStyle(int textTypeFaceStyle) {
+ mTextTypeFaceStyle = textTypeFaceStyle;
+ }
+
+ public boolean isHorizontallyScrollable() {
+ return mTextHorizontallyScrolling;
+ }
+
+ public void setTextHorizontallyScrolling(boolean textHorizontallyScrolling) {
+ mTextHorizontallyScrolling = textHorizontallyScrolling;
+ }
+
+ public boolean shouldShowTextCharacterUsage() {
+ return mShowTextCharacterUsage;
+ }
+
+ public void setShowTextCharacterUsage(boolean showTextCharacterUsage) {
+ mShowTextCharacterUsage = showTextCharacterUsage;
+ }
+
+ public boolean isEditingTextDisabled() {
+ return mEditingTextDisabled;
+ }
+
+ public void setEditingTextDisabled(boolean editingTextDisabled) {
+ mEditingTextDisabled = editingTextDisabled;
+ }
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/net/socket/local/ILocalSocketManager.java b/termux/termux-shared/src/main/java/com/termux/shared/net/socket/local/ILocalSocketManager.java
new file mode 100644
index 000000000..a520254ed
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/net/socket/local/ILocalSocketManager.java
@@ -0,0 +1,72 @@
+package com.termux.shared.net.socket.local;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.termux.shared.errors.Error;
+
+/**
+ * The interface for the {@link LocalSocketManager} for callbacks to manager client/server starter.
+ */
+public interface ILocalSocketManager {
+
+ /**
+ * This should return the {@link Thread.UncaughtExceptionHandler} that should be used for the
+ * client socket listener and client logic runner threads started for other interface methods.
+ *
+ * @param localSocketManager The {@link LocalSocketManager} for the server.
+ * @return Should return {@link Thread.UncaughtExceptionHandler} or {@code null}, if default
+ * handler should be used which just logs the exception.
+ */
+ @Nullable
+ Thread.UncaughtExceptionHandler getLocalSocketManagerClientThreadUEH(
+ @NonNull LocalSocketManager localSocketManager);
+
+ /**
+ * This is called if any error is raised by {@link LocalSocketManager}, {@link LocalServerSocket}
+ * or {@link LocalClientSocket}. The server will automatically close the client socket
+ * with a call to {@link LocalClientSocket#closeClientSocket(boolean)} if the error occurred due
+ * to the client.
+ *
+ * The {@link LocalClientSocket#getPeerCred()} can be used to get the {@link PeerCred} object
+ * containing info for the connected client/peer.
+ *
+ * @param localSocketManager The {@link LocalSocketManager} for the server.
+ * @param clientSocket The {@link LocalClientSocket} that connected. This will be {@code null}
+ * if error is not for a {@link LocalClientSocket}.
+ * @param error The {@link Error} auto generated that can be used for logging purposes.
+ */
+ void onError(@NonNull LocalSocketManager localSocketManager,
+ @Nullable LocalClientSocket clientSocket, @NonNull Error error);
+
+ /**
+ * This is called if a {@link LocalServerSocket} connects to the server which **does not** have
+ * the server app's user id or root user id. The server will automatically close the client socket
+ * with a call to {@link LocalClientSocket#closeClientSocket(boolean)}.
+ *
+ * The {@link LocalClientSocket#getPeerCred()} can be used to get the {@link PeerCred} object
+ * containing info for the connected client/peer.
+ *
+ * @param localSocketManager The {@link LocalSocketManager} for the server.
+ * @param clientSocket The {@link LocalClientSocket} that connected.
+ * @param error The {@link Error} auto generated that can be used for logging purposes.
+ */
+ void onDisallowedClientConnected(@NonNull LocalSocketManager localSocketManager,
+ @NonNull LocalClientSocket clientSocket, @NonNull Error error);
+
+ /**
+ * This is called if a {@link LocalServerSocket} connects to the server which has the
+ * the server app's user id or root user id. It is the responsibility of the interface
+ * implementation to close the client socket with a call to
+ * {@link LocalClientSocket#closeClientSocket(boolean)} once its done processing.
+ *
+ * The {@link LocalClientSocket#getPeerCred()} can be used to get the {@link PeerCred} object
+ * containing info for the connected client/peer.
+ *
+ * @param localSocketManager The {@link LocalSocketManager} for the server.
+ * @param clientSocket The {@link LocalClientSocket} that connected.
+ */
+ void onClientAccepted(@NonNull LocalSocketManager localSocketManager,
+ @NonNull LocalClientSocket clientSocket);
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/net/socket/local/LocalClientSocket.java b/termux/termux-shared/src/main/java/com/termux/shared/net/socket/local/LocalClientSocket.java
new file mode 100644
index 000000000..75a7e6a8c
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/net/socket/local/LocalClientSocket.java
@@ -0,0 +1,483 @@
+package com.termux.shared.net.socket.local;
+
+import androidx.annotation.NonNull;
+
+import com.termux.shared.data.DataUtils;
+import com.termux.shared.errors.Error;
+import com.termux.shared.jni.models.JniResult;
+import com.termux.shared.logger.Logger;
+import com.termux.shared.markdown.MarkdownUtils;
+
+import java.io.BufferedWriter;
+import java.io.Closeable;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+
+/** The client socket for {@link LocalSocketManager}. */
+public class LocalClientSocket implements Closeable {
+
+ public static final String LOG_TAG = "LocalClientSocket";
+
+ /** The {@link LocalSocketManager} instance for the local socket. */
+ @NonNull protected final LocalSocketManager mLocalSocketManager;
+
+ /** The {@link LocalSocketRunConfig} containing run config for the {@link LocalClientSocket}. */
+ @NonNull protected final LocalSocketRunConfig mLocalSocketRunConfig;
+
+ /**
+ * The {@link LocalClientSocket} file descriptor.
+ * Value will be `>= 0` if socket has been connected and `-1` if closed.
+ */
+ protected int mFD;
+
+ /** The creation time of {@link LocalClientSocket}. This is also used for deadline. */
+ protected final long mCreationTime;
+
+ /** The {@link PeerCred} of the {@link LocalClientSocket} containing info of client/peer. */
+ @NonNull protected final PeerCred mPeerCred;
+
+ /** The {@link OutputStream} implementation for the {@link LocalClientSocket}. */
+ @NonNull protected final SocketOutputStream mOutputStream;
+
+ /** The {@link InputStream} implementation for the {@link LocalClientSocket}. */
+ @NonNull protected final SocketInputStream mInputStream;
+
+ /**
+ * Create an new instance of {@link LocalClientSocket}.
+ *
+ * @param localSocketManager The {@link #mLocalSocketManager} value.
+ * @param fd The {@link #mFD} value.
+ * @param peerCred The {@link #mPeerCred} value.
+ */
+ LocalClientSocket(@NonNull LocalSocketManager localSocketManager, int fd, @NonNull PeerCred peerCred) {
+ mLocalSocketManager = localSocketManager;
+ mLocalSocketRunConfig = localSocketManager.getLocalSocketRunConfig();
+ mCreationTime = System.currentTimeMillis();
+ mOutputStream = new SocketOutputStream();
+ mInputStream = new SocketInputStream();
+ mPeerCred = peerCred;
+
+ setFD(fd);
+ mPeerCred.fillPeerCred(localSocketManager.getContext());
+ }
+
+
+ /** Close client socket. */
+ public synchronized Error closeClientSocket(boolean logErrorMessage) {
+ try {
+ close();
+ } catch (IOException e) {
+ Error error = LocalSocketErrno.ERRNO_CLOSE_CLIENT_SOCKET_FAILED_WITH_EXCEPTION.getError(e, mLocalSocketRunConfig.getTitle(), e.getMessage());
+ if (logErrorMessage)
+ Logger.logErrorExtended(LOG_TAG, error.getErrorLogString());
+ return error;
+ }
+
+ return null;
+ }
+
+ /** Close client socket that exists at fd. */
+ public static void closeClientSocket(@NonNull LocalSocketManager localSocketManager, int fd) {
+ new LocalClientSocket(localSocketManager, fd, new PeerCred()).closeClientSocket(true);
+ }
+
+ /** Implementation for {@link Closeable#close()} to close client socket. */
+ @Override
+ public void close() throws IOException {
+ if (mFD >= 0) {
+ Logger.logVerbose(LOG_TAG, "Client socket close for \"" + mLocalSocketRunConfig.getTitle() + "\" server: " + getPeerCred().getMinimalString());
+ JniResult result = LocalSocketManager.closeSocket(mLocalSocketRunConfig.getLogTitle() + " (client)", mFD);
+ if (result == null || result.retval != 0) {
+ throw new IOException(JniResult.getErrorString(result));
+ }
+ // Update fd to signify that client socket has been closed
+ setFD(-1);
+ }
+ }
+
+
+ /**
+ * Attempts to read up to data buffer length bytes from file descriptor into the data buffer.
+ * On success, the number of bytes read is returned (zero indicates end of file) in bytesRead.
+ * It is not an error if bytesRead is smaller than the number of bytes requested; this may happen
+ * for example because fewer bytes are actually available right now (maybe because we were close
+ * to end-of-file, or because we are reading from a pipe), or because read() was interrupted by
+ * a signal.
+ *
+ * If while reading the {@link #mCreationTime} + the milliseconds returned by
+ * {@link LocalSocketRunConfig#getDeadline()} elapses but all the data has not been read, an
+ * error would be returned.
+ *
+ * This is a wrapper for {@link LocalSocketManager#read(String, int, byte[], long)}, which can
+ * be called instead if you want to get access to errno int value instead of {@link JniResult}
+ * error {@link String}.
+ *
+ * @param data The data buffer to read bytes into.
+ * @param bytesRead The actual bytes read.
+ * @return Returns the {@code error} if reading was not successful containing {@link JniResult}
+ * error {@link String}, otherwise {@code null}.
+ */
+ public Error read(@NonNull byte[] data, MutableInt bytesRead) {
+ bytesRead.value = 0;
+
+ if (mFD < 0) {
+ return LocalSocketErrno.ERRNO_USING_CLIENT_SOCKET_WITH_INVALID_FD.getError(mFD,
+ mLocalSocketRunConfig.getTitle());
+ }
+
+ JniResult result = LocalSocketManager.read(mLocalSocketRunConfig.getLogTitle() + " (client)",
+ mFD, data,
+ mLocalSocketRunConfig.getDeadline() > 0 ? mCreationTime + mLocalSocketRunConfig.getDeadline() : 0);
+ if (result == null || result.retval != 0) {
+ return LocalSocketErrno.ERRNO_READ_DATA_FROM_CLIENT_SOCKET_FAILED.getError(
+ mLocalSocketRunConfig.getTitle(), JniResult.getErrorString(result));
+ }
+
+ bytesRead.value = result.intData;
+ return null;
+ }
+
+ /**
+ * Attempts to send data buffer to the file descriptor.
+ *
+ * If while sending the {@link #mCreationTime} + the milliseconds returned by
+ * {@link LocalSocketRunConfig#getDeadline()} elapses but all the data has not been sent, an
+ * error would be returned.
+ *
+ * This is a wrapper for {@link LocalSocketManager#send(String, int, byte[], long)}, which can
+ * be called instead if you want to get access to errno int value instead of {@link JniResult}
+ * error {@link String}.
+ *
+ * @param data The data buffer containing bytes to send.
+ * @return Returns the {@code error} if sending was not successful containing {@link JniResult}
+ * error {@link String}, otherwise {@code null}.
+ */
+ public Error send(@NonNull byte[] data) {
+ if (mFD < 0) {
+ return LocalSocketErrno.ERRNO_USING_CLIENT_SOCKET_WITH_INVALID_FD.getError(mFD,
+ mLocalSocketRunConfig.getTitle());
+ }
+
+ JniResult result = LocalSocketManager.send(mLocalSocketRunConfig.getLogTitle() + " (client)",
+ mFD, data,
+ mLocalSocketRunConfig.getDeadline() > 0 ? mCreationTime + mLocalSocketRunConfig.getDeadline() : 0);
+ if (result == null || result.retval != 0) {
+ return LocalSocketErrno.ERRNO_SEND_DATA_TO_CLIENT_SOCKET_FAILED.getError(
+ mLocalSocketRunConfig.getTitle(), JniResult.getErrorString(result));
+ }
+
+ return null;
+ }
+
+ /**
+ * Attempts to read all the bytes available on {@link SocketInputStream} and appends them to
+ * {@code data} {@link StringBuilder}.
+ *
+ * This is a wrapper for {@link #read(byte[], MutableInt)} called via {@link SocketInputStream#read()}.
+ *
+ * @param data The data {@link StringBuilder} to append the bytes read into.
+ * @param closeStreamOnFinish If set to {@code true}, then underlying input stream will closed
+ * and further attempts to read from socket will fail.
+ * @return Returns the {@code error} if reading was not successful containing {@link JniResult}
+ * error {@link String}, otherwise {@code null}.
+ */
+ public Error readDataOnInputStream(@NonNull StringBuilder data, boolean closeStreamOnFinish) {
+ int c;
+ InputStreamReader inputStreamReader = getInputStreamReader();
+ try {
+ while ((c = inputStreamReader.read()) > 0) {
+ data.append((char) c);
+ }
+ } catch (IOException e) {
+ // The SocketInputStream.read() throws the Error message in an IOException,
+ // so just read the exception message and not the stack trace, otherwise it would result
+ // in a messy nested error message.
+ return LocalSocketErrno.ERRNO_READ_DATA_FROM_INPUT_STREAM_OF_CLIENT_SOCKET_FAILED_WITH_EXCEPTION.getError(
+ mLocalSocketRunConfig.getTitle(), DataUtils.getSpaceIndentedString(e.getMessage(), 1));
+ } catch (Exception e) {
+ return LocalSocketErrno.ERRNO_READ_DATA_FROM_INPUT_STREAM_OF_CLIENT_SOCKET_FAILED_WITH_EXCEPTION.getError(
+ e, mLocalSocketRunConfig.getTitle(), e.getMessage());
+ } finally {
+ if (closeStreamOnFinish) {
+ try { inputStreamReader.close();
+ } catch (IOException e) {
+ // Ignore
+ }
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Attempts to send all the bytes passed to {@link SocketOutputStream} .
+ *
+ * This is a wrapper for {@link #send(byte[])} called via {@link SocketOutputStream#write(int)}.
+ *
+ * @param data The {@link String} bytes to send.
+ * @param closeStreamOnFinish If set to {@code true}, then underlying output stream will closed
+ * and further attempts to send to socket will fail.
+ * @return Returns the {@code error} if sending was not successful containing {@link JniResult}
+ * error {@link String}, otherwise {@code null}.
+ */
+ public Error sendDataToOutputStream(@NonNull String data, boolean closeStreamOnFinish) {
+
+ OutputStreamWriter outputStreamWriter = getOutputStreamWriter();
+
+ try (BufferedWriter byteStreamWriter = new BufferedWriter(outputStreamWriter)) {
+ byteStreamWriter.write(data);
+ byteStreamWriter.flush();
+ } catch (IOException e) {
+ // The SocketOutputStream.write() throws the Error message in an IOException,
+ // so just read the exception message and not the stack trace, otherwise it would result
+ // in a messy nested error message.
+ return LocalSocketErrno.ERRNO_SEND_DATA_TO_OUTPUT_STREAM_OF_CLIENT_SOCKET_FAILED_WITH_EXCEPTION.getError(
+ mLocalSocketRunConfig.getTitle(), DataUtils.getSpaceIndentedString(e.getMessage(), 1));
+ } catch (Exception e) {
+ return LocalSocketErrno.ERRNO_SEND_DATA_TO_OUTPUT_STREAM_OF_CLIENT_SOCKET_FAILED_WITH_EXCEPTION.getError(
+ e, mLocalSocketRunConfig.getTitle(), e.getMessage());
+ } finally {
+ if (closeStreamOnFinish) {
+ try {
+ outputStreamWriter.close();
+ } catch (IOException e) {
+ // Ignore
+ }
+ }
+ }
+
+ return null;
+ }
+
+ /** Wrapper for {@link #available(MutableInt, boolean)} that checks deadline. The
+ * {@link SocketInputStream} calls this. */
+ public Error available(MutableInt available) {
+ return available(available, true);
+ }
+
+ /**
+ * Get available bytes on {@link #mInputStream} and optionally check if value returned by
+ * {@link LocalSocketRunConfig#getDeadline()} has passed.
+ */
+ public Error available(MutableInt available, boolean checkDeadline) {
+ available.value = 0;
+
+ if (mFD < 0) {
+ return LocalSocketErrno.ERRNO_USING_CLIENT_SOCKET_WITH_INVALID_FD.getError(mFD,
+ mLocalSocketRunConfig.getTitle());
+ }
+
+ if (checkDeadline && mLocalSocketRunConfig.getDeadline() > 0 && System.currentTimeMillis() > (mCreationTime + mLocalSocketRunConfig.getDeadline())) {
+ return null;
+ }
+
+ JniResult result = LocalSocketManager.available(mLocalSocketRunConfig.getLogTitle() + " (client)", mLocalSocketRunConfig.getFD());
+ if (result == null || result.retval != 0) {
+ return LocalSocketErrno.ERRNO_CHECK_AVAILABLE_DATA_ON_CLIENT_SOCKET_FAILED.getError(
+ mLocalSocketRunConfig.getTitle(), JniResult.getErrorString(result));
+ }
+
+ available.value = result.intData;
+ return null;
+ }
+
+
+
+ /** Set {@link LocalClientSocket} receiving (SO_RCVTIMEO) timeout to value returned by {@link LocalSocketRunConfig#getReceiveTimeout()}. */
+ public Error setReadTimeout() {
+ if (mFD >= 0) {
+ JniResult result = LocalSocketManager.setSocketReadTimeout(mLocalSocketRunConfig.getLogTitle() + " (client)",
+ mFD, mLocalSocketRunConfig.getReceiveTimeout());
+ if (result == null || result.retval != 0) {
+ return LocalSocketErrno.ERRNO_SET_CLIENT_SOCKET_READ_TIMEOUT_FAILED.getError(
+ mLocalSocketRunConfig.getTitle(), mLocalSocketRunConfig.getReceiveTimeout(), JniResult.getErrorString(result));
+ }
+ }
+ return null;
+ }
+
+ /** Set {@link LocalClientSocket} sending (SO_SNDTIMEO) timeout to value returned by {@link LocalSocketRunConfig#getSendTimeout()}. */
+ public Error setWriteTimeout() {
+ if (mFD >= 0) {
+ JniResult result = LocalSocketManager.setSocketSendTimeout(mLocalSocketRunConfig.getLogTitle() + " (client)",
+ mFD, mLocalSocketRunConfig.getSendTimeout());
+ if (result == null || result.retval != 0) {
+ return LocalSocketErrno.ERRNO_SET_CLIENT_SOCKET_SEND_TIMEOUT_FAILED.getError(
+ mLocalSocketRunConfig.getTitle(), mLocalSocketRunConfig.getSendTimeout(), JniResult.getErrorString(result));
+ }
+ }
+ return null;
+ }
+
+
+
+ /** Get {@link #mFD} for the client socket. */
+ public int getFD() {
+ return mFD;
+ }
+
+ /** Set {@link #mFD}. Value must be greater than 0 or -1. */
+ private void setFD(int fd) {
+ if (fd >= 0)
+ mFD = fd;
+ else
+ mFD = -1;
+ }
+
+ /** Get {@link #mPeerCred} for the client socket. */
+ public PeerCred getPeerCred() {
+ return mPeerCred;
+ }
+
+ /** Get {@link #mCreationTime} for the client socket. */
+ public long getCreationTime() {
+ return mCreationTime;
+ }
+
+ /** Get {@link #mOutputStream} for the client socket. The stream will automatically close when client socket is closed. */
+ public OutputStream getOutputStream() {
+ return mOutputStream;
+ }
+
+ /** Get {@link OutputStreamWriter} for {@link #mOutputStream} for the client socket. The stream will automatically close when client socket is closed. */
+ @NonNull
+ public OutputStreamWriter getOutputStreamWriter() {
+ return new OutputStreamWriter(getOutputStream());
+ }
+
+ /** Get {@link #mInputStream} for the client socket. The stream will automatically close when client socket is closed. */
+ public InputStream getInputStream() {
+ return mInputStream;
+ }
+
+ /** Get {@link InputStreamReader} for {@link #mInputStream} for the client socket. The stream will automatically close when client socket is closed. */
+ @NonNull
+ public InputStreamReader getInputStreamReader() {
+ return new InputStreamReader(getInputStream());
+ }
+
+
+
+ /** Get a log {@link String} for the {@link LocalClientSocket}. */
+ @NonNull
+ public String getLogString() {
+ StringBuilder logString = new StringBuilder();
+
+ logString.append("Client Socket:");
+ logString.append("\n").append(Logger.getSingleLineLogStringEntry("FD", mFD, "-"));
+ logString.append("\n").append(Logger.getSingleLineLogStringEntry("Creation Time", mCreationTime, "-"));
+ logString.append("\n\n\n");
+
+ logString.append(mPeerCred.getLogString());
+
+ return logString.toString();
+ }
+
+ /** Get a markdown {@link String} for the {@link LocalClientSocket}. */
+ @NonNull
+ public String getMarkdownString() {
+ StringBuilder markdownString = new StringBuilder();
+
+ markdownString.append("## ").append("Client Socket");
+ markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("FD", mFD, "-"));
+ markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("Creation Time", mCreationTime, "-"));
+ markdownString.append("\n\n\n");
+
+ markdownString.append(mPeerCred.getMarkdownString());
+
+ return markdownString.toString();
+ }
+
+
+
+
+
+ /** Wrapper class to allow pass by reference of int values. */
+ public static final class MutableInt {
+ public int value;
+
+ public MutableInt(int value) {
+ this.value = value;
+ }
+ }
+
+
+
+ /** The {@link InputStream} implementation for the {@link LocalClientSocket}. */
+ protected class SocketInputStream extends InputStream {
+ private final byte[] mBytes = new byte[1];
+
+ @Override
+ public int read() throws IOException {
+ MutableInt bytesRead = new MutableInt(0);
+ Error error = LocalClientSocket.this.read(mBytes, bytesRead);
+ if (error != null) {
+ throw new IOException(error.getErrorMarkdownString());
+ }
+
+ if (bytesRead.value == 0) {
+ return -1;
+ }
+
+ return mBytes[0];
+ }
+
+ @Override
+ public int read(byte[] bytes) throws IOException {
+ if (bytes == null) {
+ throw new NullPointerException("Read buffer can't be null");
+ }
+
+ MutableInt bytesRead = new MutableInt(0);
+ Error error = LocalClientSocket.this.read(bytes, bytesRead);
+ if (error != null) {
+ throw new IOException(error.getErrorMarkdownString());
+ }
+
+ if (bytesRead.value == 0) {
+ return -1;
+ }
+
+ return bytesRead.value;
+ }
+
+ @Override
+ public int available() throws IOException {
+ MutableInt available = new MutableInt(0);
+ Error error = LocalClientSocket.this.available(available);
+ if (error != null) {
+ throw new IOException(error.getErrorMarkdownString());
+ }
+ return available.value;
+ }
+ }
+
+
+
+ /** The {@link OutputStream} implementation for the {@link LocalClientSocket}. */
+ protected class SocketOutputStream extends OutputStream {
+ private final byte[] mBytes = new byte[1];
+
+ @Override
+ public void write(int b) throws IOException {
+ mBytes[0] = (byte) b;
+
+ Error error = LocalClientSocket.this.send(mBytes);
+ if (error != null) {
+ throw new IOException(error.getErrorMarkdownString());
+ }
+ }
+
+ @Override
+ public void write(byte[] bytes) throws IOException {
+ Error error = LocalClientSocket.this.send(bytes);
+ if (error != null) {
+ throw new IOException(error.getErrorMarkdownString());
+ }
+ }
+ }
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/net/socket/local/LocalServerSocket.java b/termux/termux-shared/src/main/java/com/termux/shared/net/socket/local/LocalServerSocket.java
new file mode 100644
index 000000000..385c20346
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/net/socket/local/LocalServerSocket.java
@@ -0,0 +1,303 @@
+package com.termux.shared.net.socket.local;
+
+import androidx.annotation.NonNull;
+
+import com.termux.shared.errors.Error;
+import com.termux.shared.file.FileUtils;
+import com.termux.shared.jni.models.JniResult;
+import com.termux.shared.logger.Logger;
+
+import java.io.Closeable;
+import java.io.File;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+
+/** The server socket for {@link LocalSocketManager}. */
+public class LocalServerSocket implements Closeable {
+
+ public static final String LOG_TAG = "LocalServerSocket";
+
+ /** The {@link LocalSocketManager} instance for the local socket. */
+ @NonNull protected final LocalSocketManager mLocalSocketManager;
+
+ /** The {@link LocalSocketRunConfig} containing run config for the {@link LocalServerSocket}. */
+ @NonNull protected final LocalSocketRunConfig mLocalSocketRunConfig;
+
+ /** The {@link ILocalSocketManager} client for the {@link LocalSocketManager}. */
+ @NonNull protected final ILocalSocketManager mLocalSocketManagerClient;
+
+ /** The {@link ClientSocketListener} {@link Thread} for the {@link LocalServerSocket}. */
+ @NonNull protected final Thread mClientSocketListener;
+
+ /**
+ * The required permissions for server socket file parent directory.
+ * Creation of a new socket will fail if the server starter app process does not have
+ * write and search (execute) permission on the directory in which the socket is created.
+ */
+ public static final String SERVER_SOCKET_PARENT_DIRECTORY_PERMISSIONS = "rwx"; // Default: "rwx"
+
+ /**
+ * Create an new instance of {@link LocalServerSocket}.
+ *
+ * @param localSocketManager The {@link #mLocalSocketManager} value.
+ */
+ protected LocalServerSocket(@NonNull LocalSocketManager localSocketManager) {
+ mLocalSocketManager = localSocketManager;
+ mLocalSocketRunConfig = localSocketManager.getLocalSocketRunConfig();
+ mLocalSocketManagerClient = mLocalSocketRunConfig.getLocalSocketManagerClient();
+ mClientSocketListener = new Thread(new ClientSocketListener());
+ }
+
+ /** Start server by creating server socket. */
+ public synchronized Error start() {
+ Logger.logDebug(LOG_TAG, "start");
+
+ String path = mLocalSocketRunConfig.getPath();
+ if (path == null || path.isEmpty()) {
+ return LocalSocketErrno.ERRNO_SERVER_SOCKET_PATH_NULL_OR_EMPTY.getError(mLocalSocketRunConfig.getTitle());
+ }
+ if (!mLocalSocketRunConfig.isAbstractNamespaceSocket()) {
+ path = FileUtils.getCanonicalPath(path, null);
+ }
+
+ // On Linux, sun_path is 108 bytes (UNIX_PATH_MAX) in size, so do an early check here to
+ // prevent useless parent directory creation since createServerSocket() call will fail since
+ // there is a native check as well.
+ if (path.getBytes(StandardCharsets.UTF_8).length > 108) {
+ return LocalSocketErrno.ERRNO_SERVER_SOCKET_PATH_TOO_LONG.getError(mLocalSocketRunConfig.getTitle(), path);
+ }
+
+ int backlog = mLocalSocketRunConfig.getBacklog();
+ if (backlog <= 0) {
+ return LocalSocketErrno.ERRNO_SERVER_SOCKET_BACKLOG_INVALID.getError(mLocalSocketRunConfig.getTitle(), backlog);
+ }
+
+ Error error;
+
+ // If server socket is not in abstract namespace
+ if (!mLocalSocketRunConfig.isAbstractNamespaceSocket()) {
+ if (!path.startsWith("/"))
+ return LocalSocketErrno.ERRNO_SERVER_SOCKET_PATH_NOT_ABSOLUTE.getError(mLocalSocketRunConfig.getTitle(), path);
+
+ // Create the server socket file parent directory and set SERVER_SOCKET_PARENT_DIRECTORY_PERMISSIONS if missing
+ String socketParentPath = new File(path).getParent();
+ error = FileUtils.validateDirectoryFileExistenceAndPermissions(mLocalSocketRunConfig.getTitle() + " server socket file parent",
+ socketParentPath,
+ null, true,
+ SERVER_SOCKET_PARENT_DIRECTORY_PERMISSIONS, true, true,
+ false, false);
+ if (error != null)
+ return error;
+
+
+ // Delete the server socket file to stop any existing servers and for bind() to succeed
+ error = deleteServerSocketFile();
+ if (error != null)
+ return error;
+ }
+
+ // Create the server socket
+ JniResult result = LocalSocketManager.createServerSocket(mLocalSocketRunConfig.getLogTitle() + " (server)",
+ path.getBytes(StandardCharsets.UTF_8), backlog);
+ if (result == null || result.retval != 0) {
+ return LocalSocketErrno.ERRNO_CREATE_SERVER_SOCKET_FAILED.getError(mLocalSocketRunConfig.getTitle(), JniResult.getErrorString(result));
+ }
+
+ int fd = result.intData;
+ if (fd < 0) {
+ return LocalSocketErrno.ERRNO_SERVER_SOCKET_FD_INVALID.getError(fd, mLocalSocketRunConfig.getTitle());
+ }
+
+ // Update fd to signify that server socket has been created successfully
+ mLocalSocketRunConfig.setFD(fd);
+
+ mClientSocketListener.setUncaughtExceptionHandler(mLocalSocketManager.getLocalSocketManagerClientThreadUEH());
+
+ try {
+ // Start listening to server clients
+ mClientSocketListener.start();
+ } catch (Exception e) {
+ Logger.logStackTraceWithMessage(LOG_TAG, "mClientSocketListener start failed", e);
+ }
+
+ return null;
+ }
+
+ /** Stop server. */
+ public synchronized Error stop() {
+ Logger.logDebug(LOG_TAG, "stop");
+
+ try {
+ // Stop the LocalClientSocket listener.
+ mClientSocketListener.interrupt();
+ } catch (Exception ignored) {}
+
+ Error error = closeServerSocket(false);
+ if (error != null)
+ return error;
+
+ return deleteServerSocketFile();
+ }
+
+ /** Close server socket. */
+ public synchronized Error closeServerSocket(boolean logErrorMessage) {
+ Logger.logDebug(LOG_TAG, "closeServerSocket");
+
+ try {
+ close();
+ } catch (IOException e) {
+ Error error = LocalSocketErrno.ERRNO_CLOSE_SERVER_SOCKET_FAILED_WITH_EXCEPTION.getError(e, mLocalSocketRunConfig.getTitle(), e.getMessage());
+ if (logErrorMessage)
+ Logger.logErrorExtended(LOG_TAG, error.getErrorLogString());
+ return error;
+ }
+
+ return null;
+ }
+
+ /** Implementation for {@link Closeable#close()} to close server socket. */
+ @Override
+ public synchronized void close() throws IOException {
+ Logger.logDebug(LOG_TAG, "close");
+
+ int fd = mLocalSocketRunConfig.getFD();
+
+ if (fd >= 0) {
+ JniResult result = LocalSocketManager.closeSocket(mLocalSocketRunConfig.getLogTitle() + " (server)", fd);
+ if (result == null || result.retval != 0) {
+ throw new IOException(JniResult.getErrorString(result));
+ }
+ // Update fd to signify that server socket has been closed
+ mLocalSocketRunConfig.setFD(-1);
+ }
+ }
+
+ /**
+ * Delete server socket file if not an abstract namespace socket. This will cause any existing
+ * running server to stop.
+ */
+ private Error deleteServerSocketFile() {
+ if (!mLocalSocketRunConfig.isAbstractNamespaceSocket())
+ return FileUtils.deleteSocketFile(mLocalSocketRunConfig.getTitle() + " server socket file", mLocalSocketRunConfig.getPath(), true);
+ else
+ return null;
+ }
+
+ /** Listen and accept new {@link LocalClientSocket}. */
+ public LocalClientSocket accept() {
+ Logger.logVerbose(LOG_TAG, "accept");
+
+ int clientFD;
+ while (true) {
+ // If server socket closed
+ int fd = mLocalSocketRunConfig.getFD();
+ if (fd < 0) {
+ return null;
+ }
+
+ JniResult result = LocalSocketManager.accept(mLocalSocketRunConfig.getLogTitle() + " (client)", fd);
+ if (result == null || result.retval != 0) {
+ mLocalSocketManager.onError(
+ LocalSocketErrno.ERRNO_ACCEPT_CLIENT_SOCKET_FAILED.getError(mLocalSocketRunConfig.getTitle(), JniResult.getErrorString(result)));
+ continue;
+ }
+
+ clientFD = result.intData;
+ if (clientFD < 0) {
+ mLocalSocketManager.onError(
+ LocalSocketErrno.ERRNO_CLIENT_SOCKET_FD_INVALID.getError(clientFD, mLocalSocketRunConfig.getTitle()));
+ continue;
+ }
+
+ PeerCred peerCred = new PeerCred();
+ result = LocalSocketManager.getPeerCred(mLocalSocketRunConfig.getLogTitle() + " (client)", clientFD, peerCred);
+ if (result == null || result.retval != 0) {
+ mLocalSocketManager.onError(
+ LocalSocketErrno.ERRNO_GET_CLIENT_SOCKET_PEER_UID_FAILED.getError(mLocalSocketRunConfig.getTitle(), JniResult.getErrorString(result)));
+ LocalClientSocket.closeClientSocket(mLocalSocketManager, clientFD);
+ continue;
+ }
+
+ int peerUid = peerCred.uid;
+ if (peerUid < 0) {
+ mLocalSocketManager.onError(
+ LocalSocketErrno.ERRNO_CLIENT_SOCKET_PEER_UID_INVALID.getError(peerUid, mLocalSocketRunConfig.getTitle()));
+ LocalClientSocket.closeClientSocket(mLocalSocketManager, clientFD);
+ continue;
+ }
+
+ LocalClientSocket clientSocket = new LocalClientSocket(mLocalSocketManager, clientFD, peerCred);
+ Logger.logVerbose(LOG_TAG, "Client socket accept for \"" + mLocalSocketRunConfig.getTitle() + "\" server\n" + clientSocket.getLogString());
+
+ // Only allow connection if the peer has the same uid as server app's user id or root user id
+ if (peerUid != mLocalSocketManager.getContext().getApplicationInfo().uid && peerUid != 0) {
+ mLocalSocketManager.onDisallowedClientConnected(clientSocket,
+ LocalSocketErrno.ERRNO_CLIENT_SOCKET_PEER_UID_DISALLOWED.getError(clientSocket.getPeerCred().getMinimalString(),
+ mLocalSocketManager.getLocalSocketRunConfig().getTitle()));
+ clientSocket.closeClientSocket(true);
+ continue;
+ }
+
+ return clientSocket;
+ }
+ }
+
+
+
+
+ /** The {@link LocalClientSocket} listener {@link java.lang.Runnable} for {@link LocalServerSocket}. */
+ protected class ClientSocketListener implements Runnable {
+
+ @Override
+ public void run() {
+ try {
+ Logger.logVerbose(LOG_TAG, "ClientSocketListener start");
+
+ while (!Thread.currentThread().isInterrupted()) {
+ LocalClientSocket clientSocket = null;
+ try {
+ // Listen for new client socket connections
+ clientSocket = null;
+ clientSocket = accept();
+ // If server socket is closed, then stop listener thread.
+ if (clientSocket == null)
+ break;
+
+ Error error;
+
+ error = clientSocket.setReadTimeout();
+ if (error != null) {
+ mLocalSocketManager.onError(clientSocket, error);
+ clientSocket.closeClientSocket(true);
+ continue;
+ }
+
+ error = clientSocket.setWriteTimeout();
+ if (error != null) {
+ mLocalSocketManager.onError(clientSocket, error);
+ clientSocket.closeClientSocket(true);
+ continue;
+ }
+
+ // Start new thread for client logic and pass control to ILocalSocketManager implementation
+ mLocalSocketManager.onClientAccepted(clientSocket);
+ } catch (Throwable t) {
+ mLocalSocketManager.onError(clientSocket,
+ LocalSocketErrno.ERRNO_CLIENT_SOCKET_LISTENER_FAILED_WITH_EXCEPTION.getError(t, mLocalSocketRunConfig.getTitle(), t.getMessage()));
+ if (clientSocket != null)
+ clientSocket.closeClientSocket(true);
+ }
+ }
+ } catch (Exception ignored) {
+ } finally {
+ try {
+ close();
+ } catch (Exception ignored) {}
+ }
+
+ Logger.logVerbose(LOG_TAG, "ClientSocketListener end");
+ }
+
+ }
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/net/socket/local/LocalSocketErrno.java b/termux/termux-shared/src/main/java/com/termux/shared/net/socket/local/LocalSocketErrno.java
new file mode 100644
index 000000000..251f5c67e
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/net/socket/local/LocalSocketErrno.java
@@ -0,0 +1,43 @@
+package com.termux.shared.net.socket.local;
+
+import com.termux.shared.errors.Errno;
+
+public class LocalSocketErrno extends Errno {
+
+ public static final String TYPE = "LocalSocket Error";
+
+
+ /** Errors for {@link LocalSocketManager} (100-150) */
+ public static final Errno ERRNO_START_LOCAL_SOCKET_LIB_LOAD_FAILED_WITH_EXCEPTION = new Errno(TYPE, 100, "Failed to load \"%1$s\" library.\nException: %2$s");
+
+ /** Errors for {@link LocalServerSocket} (150-200) */
+ public static final Errno ERRNO_SERVER_SOCKET_PATH_NULL_OR_EMPTY = new Errno(TYPE, 150, "The \"%1$s\" server socket path is null or empty.");
+ public static final Errno ERRNO_SERVER_SOCKET_PATH_TOO_LONG = new Errno(TYPE, 151, "The \"%1$s\" server socket path \"%2$s\" is greater than 108 bytes.");
+ public static final Errno ERRNO_SERVER_SOCKET_PATH_NOT_ABSOLUTE = new Errno(TYPE, 152, "The \"%1$s\" server socket path \"%2$s\" is not an absolute file path.");
+ public static final Errno ERRNO_SERVER_SOCKET_BACKLOG_INVALID = new Errno(TYPE, 153, "The \"%1$s\" server socket backlog \"%2$s\" is not greater than 0.");
+ public static final Errno ERRNO_CREATE_SERVER_SOCKET_FAILED = new Errno(TYPE, 154, "Create \"%1$s\" server socket failed.\n%2$s");
+ public static final Errno ERRNO_SERVER_SOCKET_FD_INVALID = new Errno(TYPE, 155, "Invalid file descriptor \"%1$s\" returned when creating \"%2$s\" server socket.");
+ public static final Errno ERRNO_ACCEPT_CLIENT_SOCKET_FAILED = new Errno(TYPE, 156, "Accepting client socket for \"%1$s\" server failed.\n%2$s");
+ public static final Errno ERRNO_CLIENT_SOCKET_FD_INVALID = new Errno(TYPE, 157, "Invalid file descriptor \"%1$s\" returned when accept new client for \"%2$s\" server.");
+ public static final Errno ERRNO_GET_CLIENT_SOCKET_PEER_UID_FAILED = new Errno(TYPE, 158, "Getting peer uid for client socket for \"%1$s\" server failed.\n%2$s");
+ public static final Errno ERRNO_CLIENT_SOCKET_PEER_UID_INVALID = new Errno(TYPE, 158, "Invalid peer uid \"%1$s\" returned for new client for \"%2$s\" server.");
+ public static final Errno ERRNO_CLIENT_SOCKET_PEER_UID_DISALLOWED = new Errno(TYPE, 160, "Disallowed peer %1$s tried to connect with \"%2$s\" server.");
+ public static final Errno ERRNO_CLOSE_SERVER_SOCKET_FAILED_WITH_EXCEPTION = new Errno(TYPE, 161, "Close \"%1$s\" server socket failed.\nException: %2$s");
+ public static final Errno ERRNO_CLIENT_SOCKET_LISTENER_FAILED_WITH_EXCEPTION = new Errno(TYPE, 162, "Exception in client socket listener for \"%1$s\" server.\nException: %2$s");
+
+ /** Errors for {@link LocalClientSocket} (200-250) */
+ public static final Errno ERRNO_SET_CLIENT_SOCKET_READ_TIMEOUT_FAILED = new Errno(TYPE, 200, "Set \"%1$s\" client socket read (SO_RCVTIMEO) timeout to \"%2$s\" failed.\n%3$s");
+ public static final Errno ERRNO_SET_CLIENT_SOCKET_SEND_TIMEOUT_FAILED = new Errno(TYPE, 201, "Set \"%1$s\" client socket send (SO_SNDTIMEO) timeout \"%2$s\" failed.\n%3$s");
+ public static final Errno ERRNO_READ_DATA_FROM_CLIENT_SOCKET_FAILED = new Errno(TYPE, 202, "Read data from \"%1$s\" client socket failed.\n%2$s");
+ public static final Errno ERRNO_READ_DATA_FROM_INPUT_STREAM_OF_CLIENT_SOCKET_FAILED_WITH_EXCEPTION = new Errno(TYPE, 203, "Read data from \"%1$s\" client socket input stream failed.\n%2$s");
+ public static final Errno ERRNO_SEND_DATA_TO_CLIENT_SOCKET_FAILED = new Errno(TYPE, 204, "Send data to \"%1$s\" client socket failed.\n%2$s");
+ public static final Errno ERRNO_SEND_DATA_TO_OUTPUT_STREAM_OF_CLIENT_SOCKET_FAILED_WITH_EXCEPTION = new Errno(TYPE, 205, "Send data to \"%1$s\" client socket output stream failed.\n%2$s");
+ public static final Errno ERRNO_CHECK_AVAILABLE_DATA_ON_CLIENT_SOCKET_FAILED = new Errno(TYPE, 206, "Check available data on \"%1$s\" client socket failed.\n%2$s");
+ public static final Errno ERRNO_CLOSE_CLIENT_SOCKET_FAILED_WITH_EXCEPTION = new Errno(TYPE, 207, "Close \"%1$s\" client socket failed.\n%2$s");
+ public static final Errno ERRNO_USING_CLIENT_SOCKET_WITH_INVALID_FD = new Errno(TYPE, 208, "Trying to use client socket with invalid file descriptor \"%1$s\" for \"%2$s\" server.");
+
+ LocalSocketErrno(final String type, final int code, final String message) {
+ super(type, code, message);
+ }
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/net/socket/local/LocalSocketManager.java b/termux/termux-shared/src/main/java/com/termux/shared/net/socket/local/LocalSocketManager.java
new file mode 100644
index 000000000..3cd959c34
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/net/socket/local/LocalSocketManager.java
@@ -0,0 +1,450 @@
+package com.termux.shared.net.socket.local;
+
+import android.content.Context;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.termux.shared.errors.Error;
+import com.termux.shared.jni.models.JniResult;
+import com.termux.shared.logger.Logger;
+
+/**
+ * Manager for an AF_UNIX/SOCK_STREAM local server.
+ *
+ * Usage:
+ * 1. Implement the {@link ILocalSocketManager} that will receive call backs from the server including
+ * when client connects via {@link ILocalSocketManager#onClientAccepted(LocalSocketManager, LocalClientSocket)}.
+ * Optionally extend the {@link LocalSocketManagerClientBase} class that provides base implementation.
+ * 2. Create a {@link LocalSocketRunConfig} instance with the run config of the server.
+ * 3. Create a {@link LocalSocketManager} instance and call {@link #start()}.
+ * 4. Stop server if needed with a call to {@link #stop()}.
+ */
+public class LocalSocketManager {
+
+ public static final String LOG_TAG = "LocalSocketManager";
+
+ /** The native JNI local socket library. */
+ protected static String LOCAL_SOCKET_LIBRARY = "local-socket";
+
+ /** Whether {@link #LOCAL_SOCKET_LIBRARY} has been loaded or not. */
+ protected static boolean localSocketLibraryLoaded;
+
+ /** The {@link Context} that may needed for various operations. */
+ @NonNull protected final Context mContext;
+
+ /** The {@link LocalSocketRunConfig} containing run config for the {@link LocalSocketManager}. */
+ @NonNull protected final LocalSocketRunConfig mLocalSocketRunConfig;
+
+ /** The {@link LocalServerSocket} for the {@link LocalSocketManager}. */
+ @NonNull protected final LocalServerSocket mServerSocket;
+
+ /** The {@link ILocalSocketManager} client for the {@link LocalSocketManager}. */
+ @NonNull protected final ILocalSocketManager mLocalSocketManagerClient;
+
+ /** The {@link Thread.UncaughtExceptionHandler} used for client thread started by {@link LocalSocketManager}. */
+ @NonNull protected final Thread.UncaughtExceptionHandler mLocalSocketManagerClientThreadUEH;
+
+ /** Whether the {@link LocalServerSocket} managed by {@link LocalSocketManager} in running or not. */
+ protected boolean mIsRunning;
+
+
+ /**
+ * Create an new instance of {@link LocalSocketManager}.
+ *
+ * @param context The {@link #mContext} value.
+ * @param localSocketRunConfig The {@link #mLocalSocketRunConfig} value.
+ */
+ public LocalSocketManager(@NonNull Context context, @NonNull LocalSocketRunConfig localSocketRunConfig) {
+ mContext = context.getApplicationContext();
+ mLocalSocketRunConfig = localSocketRunConfig;
+ mServerSocket = new LocalServerSocket(this);
+ mLocalSocketManagerClient = mLocalSocketRunConfig.getLocalSocketManagerClient();
+ mLocalSocketManagerClientThreadUEH = getLocalSocketManagerClientThreadUEHOrDefault();
+ mIsRunning = false;
+ }
+
+ /**
+ * Create the {@link LocalServerSocket} and start listening for new {@link LocalClientSocket}.
+ */
+ public synchronized Error start() {
+ Logger.logDebugExtended(LOG_TAG, "start\n" + mLocalSocketRunConfig);
+
+ if (!localSocketLibraryLoaded) {
+ try {
+ Logger.logDebug(LOG_TAG, "Loading \"" + LOCAL_SOCKET_LIBRARY + "\" library");
+ System.loadLibrary(LOCAL_SOCKET_LIBRARY);
+ localSocketLibraryLoaded = true;
+ } catch (Throwable t) {
+ Error error = LocalSocketErrno.ERRNO_START_LOCAL_SOCKET_LIB_LOAD_FAILED_WITH_EXCEPTION.getError(t, LOCAL_SOCKET_LIBRARY, t.getMessage());
+ Logger.logErrorExtended(LOG_TAG, error.getErrorLogString());
+ return error;
+ }
+ }
+
+ mIsRunning = true;
+ return mServerSocket.start();
+ }
+
+ /**
+ * Stop the {@link LocalServerSocket} and stop listening for new {@link LocalClientSocket}.
+ */
+ public synchronized Error stop() {
+ if (mIsRunning) {
+ Logger.logDebugExtended(LOG_TAG, "stop\n" + mLocalSocketRunConfig);
+ mIsRunning = false;
+ return mServerSocket.stop();
+ }
+ return null;
+ }
+
+
+
+
+ /*
+ Note: Exceptions thrown from JNI must be caught with Throwable class instead of Exception,
+ otherwise exception will be sent to UncaughtExceptionHandler of the thread.
+ */
+
+ /**
+ * Creates an AF_UNIX/SOCK_STREAM local server socket at {@code path}, with the specified backlog.
+ *
+ * @param serverTitle The server title used for logging and errors.
+ * @param path The path at which to create the socket.
+ * For a filesystem socket, this must be an absolute path to the socket file.
+ * For an abstract namespace socket, the first byte must be a null `\0` character.
+ * Max allowed length is 108 bytes as per sun_path size (UNIX_PATH_MAX) on Linux.
+ * @param backlog The maximum length to which the queue of pending connections for the socket
+ * may grow. This value may be ignored or may not have one-to-one mapping
+ * in kernel implementation. Value must be greater than 0.
+ * @return Returns the {@link JniResult}. If server creation was successful, then
+ * {@link JniResult#retval} will be 0 and {@link JniResult#intData} will contain the server socket
+ * fd.
+ */
+ @Nullable
+ public static JniResult createServerSocket(@NonNull String serverTitle, @NonNull byte[] path, int backlog) {
+ try {
+ return createServerSocketNative(serverTitle, path, backlog);
+ } catch (Throwable t) {
+ String message = "Exception in createServerSocketNative()";
+ Logger.logStackTraceWithMessage(LOG_TAG, message, t);
+ return new JniResult(message, t);
+ }
+ }
+
+ /**
+ * Closes the socket with fd.
+ *
+ * @param serverTitle The server title used for logging and errors.
+ * @param fd The socket fd.
+ * @return Returns the {@link JniResult}. If closing socket was successful, then
+ * {@link JniResult#retval} will be 0.
+ */
+ @Nullable
+ public static JniResult closeSocket(@NonNull String serverTitle, int fd) {
+ try {
+ return closeSocketNative(serverTitle, fd);
+ } catch (Throwable t) {
+ String message = "Exception in closeSocketNative()";
+ Logger.logStackTraceWithMessage(LOG_TAG, message, t);
+ return new JniResult(message, t);
+ }
+ }
+
+ /**
+ * Accepts a connection on the supplied server socket fd.
+ *
+ * @param serverTitle The server title used for logging and errors.
+ * @param fd The server socket fd.
+ * @return Returns the {@link JniResult}. If accepting socket was successful, then
+ * {@link JniResult#retval} will be 0 and {@link JniResult#intData} will contain the client socket
+ * fd.
+ */
+ @Nullable
+ public static JniResult accept(@NonNull String serverTitle, int fd) {
+ try {
+ return acceptNative(serverTitle, fd);
+ } catch (Throwable t) {
+ String message = "Exception in acceptNative()";
+ Logger.logStackTraceWithMessage(LOG_TAG, message, t);
+ return new JniResult(message, t);
+ }
+ }
+
+ /**
+ * Attempts to read up to data buffer length bytes from file descriptor fd into the data buffer.
+ * On success, the number of bytes read is returned (zero indicates end of file).
+ * It is not an error if bytes read is smaller than the number of bytes requested; this may happen
+ * for example because fewer bytes are actually available right now (maybe because we were close
+ * to end-of-file, or because we are reading from a pipe), or because read() was interrupted by
+ * a signal. On error, the {@link JniResult#errno} and {@link JniResult#errmsg} will be set.
+ *
+ * If while reading the deadline elapses but all the data has not been read, the call will fail.
+ *
+ * @param serverTitle The server title used for logging and errors.
+ * @param fd The socket fd.
+ * @param data The data buffer to read bytes into.
+ * @param deadline The deadline milliseconds since epoch.
+ * @return Returns the {@link JniResult}. If reading was successful, then {@link JniResult#retval}
+ * will be 0 and {@link JniResult#intData} will contain the bytes read.
+ */
+ @Nullable
+ public static JniResult read(@NonNull String serverTitle, int fd, @NonNull byte[] data, long deadline) {
+ try {
+ return readNative(serverTitle, fd, data, deadline);
+ } catch (Throwable t) {
+ String message = "Exception in readNative()";
+ Logger.logStackTraceWithMessage(LOG_TAG, message, t);
+ return new JniResult(message, t);
+ }
+ }
+
+ /**
+ * Attempts to send data buffer to the file descriptor. On error, the {@link JniResult#errno} and
+ * {@link JniResult#errmsg} will be set.
+ *
+ * If while sending the deadline elapses but all the data has not been sent, the call will fail.
+ *
+ * @param serverTitle The server title used for logging and errors.
+ * @param fd The socket fd.
+ * @param data The data buffer containing bytes to send.
+ * @param deadline The deadline milliseconds since epoch.
+ * @return Returns the {@link JniResult}. If sending was successful, then {@link JniResult#retval}
+ * will be 0.
+ */
+ @Nullable
+ public static JniResult send(@NonNull String serverTitle, int fd, @NonNull byte[] data, long deadline) {
+ try {
+ return sendNative(serverTitle, fd, data, deadline);
+ } catch (Throwable t) {
+ String message = "Exception in sendNative()";
+ Logger.logStackTraceWithMessage(LOG_TAG, message, t);
+ return new JniResult(message, t);
+ }
+ }
+
+ /**
+ * Gets the number of bytes available to read on the socket.
+ *
+ * @param serverTitle The server title used for logging and errors.
+ * @param fd The socket fd.
+ * @return Returns the {@link JniResult}. If checking availability was successful, then
+ * {@link JniResult#retval} will be 0 and {@link JniResult#intData} will contain the bytes available.
+ */
+ @Nullable
+ public static JniResult available(@NonNull String serverTitle, int fd) {
+ try {
+ return availableNative(serverTitle, fd);
+ } catch (Throwable t) {
+ String message = "Exception in availableNative()";
+ Logger.logStackTraceWithMessage(LOG_TAG, message, t);
+ return new JniResult(message, t);
+ }
+ }
+
+ /**
+ * Set receiving (SO_RCVTIMEO) timeout in milliseconds for socket.
+ *
+ * @param serverTitle The server title used for logging and errors.
+ * @param fd The socket fd.
+ * @param timeout The timeout value in milliseconds.
+ * @return Returns the {@link JniResult}. If setting timeout was successful, then
+ * {@link JniResult#retval} will be 0.
+ */
+ @Nullable
+ public static JniResult setSocketReadTimeout(@NonNull String serverTitle, int fd, int timeout) {
+ try {
+ return setSocketReadTimeoutNative(serverTitle, fd, timeout);
+ } catch (Throwable t) {
+ String message = "Exception in setSocketReadTimeoutNative()";
+ Logger.logStackTraceWithMessage(LOG_TAG, message, t);
+ return new JniResult(message, t);
+ }
+ }
+
+ /**
+ * Set sending (SO_SNDTIMEO) timeout in milliseconds for fd.
+ *
+ * @param serverTitle The server title used for logging and errors.
+ * @param fd The socket fd.
+ * @param timeout The timeout value in milliseconds.
+ * @return Returns the {@link JniResult}. If setting timeout was successful, then
+ * {@link JniResult#retval} will be 0.
+ */
+ @Nullable
+ public static JniResult setSocketSendTimeout(@NonNull String serverTitle, int fd, int timeout) {
+ try {
+ return setSocketSendTimeoutNative(serverTitle, fd, timeout);
+ } catch (Throwable t) {
+ String message = "Exception in setSocketSendTimeoutNative()";
+ Logger.logStackTraceWithMessage(LOG_TAG, message, t);
+ return new JniResult(message, t);
+ }
+ }
+
+ /**
+ * Get the {@link PeerCred} for the socket.
+ *
+ * @param serverTitle The server title used for logging and errors.
+ * @param fd The socket fd.
+ * @param peerCred The {@link PeerCred} object that should be filled.
+ * @return Returns the {@link JniResult}. If setting timeout was successful, then
+ * {@link JniResult#retval} will be 0.
+ */
+ @Nullable
+ public static JniResult getPeerCred(@NonNull String serverTitle, int fd, PeerCred peerCred) {
+ try {
+ return getPeerCredNative(serverTitle, fd, peerCred);
+ } catch (Throwable t) {
+ String message = "Exception in getPeerCredNative()";
+ Logger.logStackTraceWithMessage(LOG_TAG, message, t);
+ return new JniResult(message, t);
+ }
+ }
+
+
+
+ /** Wrapper for {@link #onError(LocalClientSocket, Error)} for {@code null} {@link LocalClientSocket}. */
+ public void onError(@NonNull Error error) {
+ onError(null, error);
+ }
+
+ /** Wrapper to call {@link ILocalSocketManager#onError(LocalSocketManager, LocalClientSocket, Error)} in a new thread. */
+ public void onError(@Nullable LocalClientSocket clientSocket, @NonNull Error error) {
+ startLocalSocketManagerClientThread(() ->
+ mLocalSocketManagerClient.onError(this, clientSocket, error));
+ }
+
+ /** Wrapper to call {@link ILocalSocketManager#onDisallowedClientConnected(LocalSocketManager, LocalClientSocket, Error)} in a new thread. */
+ public void onDisallowedClientConnected(@NonNull LocalClientSocket clientSocket, @NonNull Error error) {
+ startLocalSocketManagerClientThread(() ->
+ mLocalSocketManagerClient.onDisallowedClientConnected(this, clientSocket, error));
+ }
+
+ /** Wrapper to call {@link ILocalSocketManager#onClientAccepted(LocalSocketManager, LocalClientSocket)} in a new thread. */
+ public void onClientAccepted(@NonNull LocalClientSocket clientSocket) {
+ startLocalSocketManagerClientThread(() ->
+ mLocalSocketManagerClient.onClientAccepted(this, clientSocket));
+ }
+
+ /** All client accept logic must be run on separate threads so that incoming client acceptance is not blocked. */
+ public void startLocalSocketManagerClientThread(@NonNull Runnable runnable) {
+ Thread thread = new Thread(runnable);
+ thread.setUncaughtExceptionHandler(getLocalSocketManagerClientThreadUEH());
+ try {
+ thread.start();
+ } catch (Exception e) {
+ Logger.logStackTraceWithMessage(LOG_TAG, "LocalSocketManagerClientThread start failed", e);
+ }
+ }
+
+
+
+ /** Get {@link #mContext}. */
+ public Context getContext() {
+ return mContext;
+ }
+
+ /** Get {@link #mLocalSocketRunConfig}. */
+ public LocalSocketRunConfig getLocalSocketRunConfig() {
+ return mLocalSocketRunConfig;
+ }
+
+ /** Get {@link #mLocalSocketManagerClient}. */
+ public ILocalSocketManager getLocalSocketManagerClient() {
+ return mLocalSocketManagerClient;
+ }
+
+ /** Get {@link #mServerSocket}. */
+ public LocalServerSocket getServerSocket() {
+ return mServerSocket;
+ }
+
+ /** Get {@link #mLocalSocketManagerClientThreadUEH}. */
+ public Thread.UncaughtExceptionHandler getLocalSocketManagerClientThreadUEH() {
+ return mLocalSocketManagerClientThreadUEH;
+ }
+
+ /**
+ * Get {@link Thread.UncaughtExceptionHandler} returned by call to
+ * {@link ILocalSocketManager#getLocalSocketManagerClientThreadUEH(LocalSocketManager)}
+ * or the default handler that just logs the exception.
+ */
+ protected Thread.UncaughtExceptionHandler getLocalSocketManagerClientThreadUEHOrDefault() {
+ Thread.UncaughtExceptionHandler uncaughtExceptionHandler =
+ mLocalSocketManagerClient.getLocalSocketManagerClientThreadUEH(this);
+ if (uncaughtExceptionHandler == null)
+ uncaughtExceptionHandler = (t, e) ->
+ Logger.logStackTraceWithMessage(LOG_TAG, "Uncaught exception for " + t + " in " + mLocalSocketRunConfig.getTitle() + " server", e);
+ return uncaughtExceptionHandler;
+ }
+
+ /** Get {@link #mIsRunning}. */
+ public boolean isRunning() {
+ return mIsRunning;
+ }
+
+
+
+ /** Get an error log {@link String} for the {@link LocalSocketManager}. */
+ public static String getErrorLogString(@NonNull Error error,
+ @NonNull LocalSocketRunConfig localSocketRunConfig,
+ @Nullable LocalClientSocket clientSocket) {
+ StringBuilder logString = new StringBuilder();
+
+ logString.append(localSocketRunConfig.getTitle()).append(" Socket Server Error:\n");
+ logString.append(error.getErrorLogString());
+ logString.append("\n\n\n");
+
+ logString.append(localSocketRunConfig.getLogString());
+
+ if (clientSocket != null) {
+ logString.append("\n\n\n");
+ logString.append(clientSocket.getLogString());
+ }
+
+ return logString.toString();
+ }
+
+ /** Get an error markdown {@link String} for the {@link LocalSocketManager}. */
+ public static String getErrorMarkdownString(@NonNull Error error,
+ @NonNull LocalSocketRunConfig localSocketRunConfig,
+ @Nullable LocalClientSocket clientSocket) {
+ StringBuilder markdownString = new StringBuilder();
+
+ markdownString.append(error.getErrorMarkdownString());
+ markdownString.append("\n##\n\n\n");
+
+ markdownString.append(localSocketRunConfig.getMarkdownString());
+
+ if (clientSocket != null) {
+ markdownString.append("\n\n\n");
+ markdownString.append(clientSocket.getMarkdownString());
+ }
+
+ return markdownString.toString();
+ }
+
+
+
+
+
+ @Nullable private static native JniResult createServerSocketNative(@NonNull String serverTitle, @NonNull byte[] path, int backlog);
+
+ @Nullable private static native JniResult closeSocketNative(@NonNull String serverTitle, int fd);
+
+ @Nullable private static native JniResult acceptNative(@NonNull String serverTitle, int fd);
+
+ @Nullable private static native JniResult readNative(@NonNull String serverTitle, int fd, @NonNull byte[] data, long deadline);
+
+ @Nullable private static native JniResult sendNative(@NonNull String serverTitle, int fd, @NonNull byte[] data, long deadline);
+
+ @Nullable private static native JniResult availableNative(@NonNull String serverTitle, int fd);
+
+ private static native JniResult setSocketReadTimeoutNative(@NonNull String serverTitle, int fd, int timeout);
+
+ @Nullable private static native JniResult setSocketSendTimeoutNative(@NonNull String serverTitle, int fd, int timeout);
+
+ @Nullable private static native JniResult getPeerCredNative(@NonNull String serverTitle, int fd, PeerCred peerCred);
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/net/socket/local/LocalSocketManagerClientBase.java b/termux/termux-shared/src/main/java/com/termux/shared/net/socket/local/LocalSocketManagerClientBase.java
new file mode 100644
index 000000000..4d7657c45
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/net/socket/local/LocalSocketManagerClientBase.java
@@ -0,0 +1,47 @@
+package com.termux.shared.net.socket.local;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.termux.shared.errors.Error;
+import com.termux.shared.logger.Logger;
+
+/** Base helper implementation for {@link ILocalSocketManager}. */
+public abstract class LocalSocketManagerClientBase implements ILocalSocketManager {
+
+ @Nullable
+ @Override
+ public Thread.UncaughtExceptionHandler getLocalSocketManagerClientThreadUEH(
+ @NonNull LocalSocketManager localSocketManager) {
+ return null;
+ }
+
+ @Override
+ public void onError(@NonNull LocalSocketManager localSocketManager,
+ @Nullable LocalClientSocket clientSocket, @NonNull Error error) {
+ // Only log if log level is debug or higher since PeerCred.cmdline may contain private info
+ Logger.logErrorPrivate(getLogTag(), "onError");
+ Logger.logErrorPrivateExtended(getLogTag(), LocalSocketManager.getErrorLogString(error,
+ localSocketManager.getLocalSocketRunConfig(), clientSocket));
+ }
+
+ @Override
+ public void onDisallowedClientConnected(@NonNull LocalSocketManager localSocketManager,
+ @NonNull LocalClientSocket clientSocket, @NonNull Error error) {
+ Logger.logWarn(getLogTag(), "onDisallowedClientConnected");
+ Logger.logWarnExtended(getLogTag(), LocalSocketManager.getErrorLogString(error,
+ localSocketManager.getLocalSocketRunConfig(), clientSocket));
+ }
+
+ @Override
+ public void onClientAccepted(@NonNull LocalSocketManager localSocketManager,
+ @NonNull LocalClientSocket clientSocket) {
+ // Just close socket and let child class handle any required communication
+ clientSocket.closeClientSocket(true);
+ }
+
+
+
+ protected abstract String getLogTag();
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/net/socket/local/LocalSocketRunConfig.java b/termux/termux-shared/src/main/java/com/termux/shared/net/socket/local/LocalSocketRunConfig.java
new file mode 100644
index 000000000..c09f5bef3
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/net/socket/local/LocalSocketRunConfig.java
@@ -0,0 +1,265 @@
+package com.termux.shared.net.socket.local;
+
+import androidx.annotation.NonNull;
+
+import com.termux.shared.file.FileUtils;
+import com.termux.shared.logger.Logger;
+import com.termux.shared.markdown.MarkdownUtils;
+
+import java.io.Serializable;
+import java.nio.charset.StandardCharsets;
+
+
+/**
+ * Run config for {@link LocalSocketManager}.
+ */
+public class LocalSocketRunConfig implements Serializable {
+
+ /** The {@link LocalSocketManager} title. */
+ protected final String mTitle;
+
+ /**
+ * The {@link LocalServerSocket} path.
+ *
+ * For a filesystem socket, this must be an absolute path to the socket file. Creation of a new
+ * socket will fail if the server starter app process does not have write and search (execute)
+ * permission on the directory in which the socket is created. The client process must have write
+ * permission on the socket to connect to it. Other app will not be able to connect to socket
+ * if its created in private app data directory.
+ *
+ * For an abstract namespace socket, the first byte must be a null `\0` character. Note that on
+ * Android 9+, if server app is using `targetSdkVersion` `28`, then other apps will not be able
+ * to connect to it due to selinux restrictions.
+ * > Per-app SELinux domains
+ * > Apps that target Android 9 or higher cannot share data with other apps using world-accessible
+ * Unix permissions. This change improves the integrity of the Android Application Sandbox,
+ * particularly the requirement that an app's private data is accessible only by that app.
+ * https://developer.android.com/about/versions/pie/android-9.0-changes-28
+ * https://github.com/android/ndk/issues/1469
+ * https://stackoverflow.com/questions/63806516/avc-denied-connectto-when-using-uds-on-android-10
+ *
+ * Max allowed length is 108 bytes as per sun_path size (UNIX_PATH_MAX) on Linux.
+ */
+ protected final String mPath;
+
+ /** If abstract namespace {@link LocalServerSocket} instead of filesystem. */
+ protected final boolean mAbstractNamespaceSocket;
+
+ /** The {@link ILocalSocketManager} client for the {@link LocalSocketManager}. */
+ protected final ILocalSocketManager mLocalSocketManagerClient;
+
+ /**
+ * The {@link LocalServerSocket} file descriptor.
+ * Value will be `>= 0` if socket has been created successfully and `-1` if not created or closed.
+ */
+ protected int mFD = -1;
+
+ /**
+ * The {@link LocalClientSocket} receiving (SO_RCVTIMEO) timeout in milliseconds.
+ *
+ * https://manpages.debian.org/testing/manpages/socket.7.en.html
+ * https://cs.android.com/android/platform/superproject/+/android-12.0.0_r32:frameworks/base/services/core/java/com/android/server/am/NativeCrashListener.java;l=55
+ * Defaults to {@link #DEFAULT_RECEIVE_TIMEOUT}.
+ */
+ protected Integer mReceiveTimeout;
+ public static final int DEFAULT_RECEIVE_TIMEOUT = 10000;
+
+ /**
+ * The {@link LocalClientSocket} sending (SO_SNDTIMEO) timeout in milliseconds.
+ *
+ * https://manpages.debian.org/testing/manpages/socket.7.en.html
+ * https://cs.android.com/android/platform/superproject/+/android-12.0.0_r32:frameworks/base/services/core/java/com/android/server/am/NativeCrashListener.java;l=55
+ * Defaults to {@link #DEFAULT_SEND_TIMEOUT}.
+ */
+ protected Integer mSendTimeout;
+ public static final int DEFAULT_SEND_TIMEOUT = 10000;
+
+ /**
+ * The {@link LocalClientSocket} deadline in milliseconds. When the deadline has elapsed after
+ * creation time of client socket, all reads and writes will error out. Set to 0, for no
+ * deadline.
+ * Defaults to {@link #DEFAULT_DEADLINE}.
+ */
+ protected Long mDeadline;
+ public static final int DEFAULT_DEADLINE = 0;
+
+ /**
+ * The {@link LocalServerSocket} backlog for the maximum length to which the queue of pending connections
+ * for the socket may grow. This value may be ignored or may not have one-to-one mapping
+ * in kernel implementation. Value must be greater than 0.
+ *
+ * https://cs.android.com/android/platform/superproject/+/android-12.0.0_r32:frameworks/base/core/java/android/net/LocalSocketManager.java;l=31
+ * Defaults to {@link #DEFAULT_BACKLOG}.
+ */
+ protected Integer mBacklog;
+ public static final int DEFAULT_BACKLOG = 50;
+
+
+ /**
+ * Create an new instance of {@link LocalSocketRunConfig}.
+ *
+ * @param title The {@link #mTitle} value.
+ * @param path The {@link #mPath} value.
+ * @param localSocketManagerClient The {@link #mLocalSocketManagerClient} value.
+ */
+ public LocalSocketRunConfig(@NonNull String title, @NonNull String path, @NonNull ILocalSocketManager localSocketManagerClient) {
+ mTitle = title;
+ mLocalSocketManagerClient = localSocketManagerClient;
+ mAbstractNamespaceSocket = path.getBytes(StandardCharsets.UTF_8)[0] == 0;
+
+ if (mAbstractNamespaceSocket)
+ mPath = path;
+ else
+ mPath = FileUtils.getCanonicalPath(path, null);
+ }
+
+
+
+ /** Get {@link #mTitle}. */
+ public String getTitle() {
+ return mTitle;
+ }
+
+ /** Get log title that should be used for {@link LocalSocketManager}. */
+ public String getLogTitle() {
+ return Logger.getDefaultLogTag() + "." + mTitle;
+ }
+
+ /** Get {@link #mPath}. */
+ public String getPath() {
+ return mPath;
+ }
+
+ /** Get {@link #mAbstractNamespaceSocket}. */
+ public boolean isAbstractNamespaceSocket() {
+ return mAbstractNamespaceSocket;
+ }
+
+ /** Get {@link #mLocalSocketManagerClient}. */
+ public ILocalSocketManager getLocalSocketManagerClient() {
+ return mLocalSocketManagerClient;
+ }
+
+ /** Get {@link #mFD}. */
+ public Integer getFD() {
+ return mFD;
+ }
+
+ /** Set {@link #mFD}. Value must be greater than 0 or -1. */
+ public void setFD(int fd) {
+ if (fd >= 0)
+ mFD = fd;
+ else
+ mFD = -1;
+ }
+
+ /** Get {@link #mReceiveTimeout} if set, otherwise {@link #DEFAULT_RECEIVE_TIMEOUT}. */
+ public Integer getReceiveTimeout() {
+ return mReceiveTimeout != null ? mReceiveTimeout : DEFAULT_RECEIVE_TIMEOUT;
+ }
+
+ /** Set {@link #mReceiveTimeout}. */
+ public void setReceiveTimeout(Integer receiveTimeout) {
+ mReceiveTimeout = receiveTimeout;
+ }
+
+ /** Get {@link #mSendTimeout} if set, otherwise {@link #DEFAULT_SEND_TIMEOUT}. */
+ public Integer getSendTimeout() {
+ return mSendTimeout != null ? mSendTimeout : DEFAULT_SEND_TIMEOUT;
+ }
+
+ /** Set {@link #mSendTimeout}. */
+ public void setSendTimeout(Integer sendTimeout) {
+ mSendTimeout = sendTimeout;
+ }
+
+ /** Get {@link #mDeadline} if set, otherwise {@link #DEFAULT_DEADLINE}. */
+ public Long getDeadline() {
+ return mDeadline != null ? mDeadline : DEFAULT_DEADLINE;
+ }
+
+ /** Set {@link #mDeadline}. */
+ public void setDeadline(Long deadline) {
+ mDeadline = deadline;
+ }
+
+ /** Get {@link #mBacklog} if set, otherwise {@link #DEFAULT_BACKLOG}. */
+ public Integer getBacklog() {
+ return mBacklog != null ? mBacklog : DEFAULT_BACKLOG;
+ }
+
+ /** Set {@link #mBacklog}. Value must be greater than 0. */
+ public void setBacklog(Integer backlog) {
+ if (backlog > 0)
+ mBacklog = backlog;
+ }
+
+
+ /**
+ * Get a log {@link String} for {@link LocalSocketRunConfig}.
+ *
+ * @param config The {@link LocalSocketRunConfig} to get info of.
+ * @return Returns the log {@link String}.
+ */
+ @NonNull
+ public static String getRunConfigLogString(final LocalSocketRunConfig config) {
+ if (config == null) return "null";
+ return config.getLogString();
+ }
+
+ /** Get a log {@link String} for the {@link LocalSocketRunConfig}. */
+ @NonNull
+ public String getLogString() {
+ StringBuilder logString = new StringBuilder();
+
+ logString.append(mTitle).append(" Socket Server Run Config:");
+ logString.append("\n").append(Logger.getSingleLineLogStringEntry("Path", mPath, "-"));
+ logString.append("\n").append(Logger.getSingleLineLogStringEntry("AbstractNamespaceSocket", mAbstractNamespaceSocket, "-"));
+ logString.append("\n").append(Logger.getSingleLineLogStringEntry("LocalSocketManagerClient", mLocalSocketManagerClient.getClass().getName(), "-"));
+ logString.append("\n").append(Logger.getSingleLineLogStringEntry("FD", mFD, "-"));
+ logString.append("\n").append(Logger.getSingleLineLogStringEntry("ReceiveTimeout", getReceiveTimeout(), "-"));
+ logString.append("\n").append(Logger.getSingleLineLogStringEntry("SendTimeout", getSendTimeout(), "-"));
+ logString.append("\n").append(Logger.getSingleLineLogStringEntry("Deadline", getDeadline(), "-"));
+ logString.append("\n").append(Logger.getSingleLineLogStringEntry("Backlog", getBacklog(), "-"));
+
+ return logString.toString();
+ }
+
+ /**
+ * Get a markdown {@link String} for {@link LocalSocketRunConfig}.
+ *
+ * @param config The {@link LocalSocketRunConfig} to get info of.
+ * @return Returns the markdown {@link String}.
+ */
+ public static String getRunConfigMarkdownString(final LocalSocketRunConfig config) {
+ if (config == null) return "null";
+ return config.getMarkdownString();
+ }
+
+ /** Get a markdown {@link String} for the {@link LocalSocketRunConfig}. */
+ @NonNull
+ public String getMarkdownString() {
+ StringBuilder markdownString = new StringBuilder();
+
+ markdownString.append("## ").append(mTitle).append(" Socket Server Run Config");
+ markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("Path", mPath, "-"));
+ markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("AbstractNamespaceSocket", mAbstractNamespaceSocket, "-"));
+ markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("LocalSocketManagerClient", mLocalSocketManagerClient.getClass().getName(), "-"));
+ markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("FD", mFD, "-"));
+ markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("ReceiveTimeout", getReceiveTimeout(), "-"));
+ markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("SendTimeout", getSendTimeout(), "-"));
+ markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("Deadline", getDeadline(), "-"));
+ markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("Backlog", getBacklog(), "-"));
+
+ return markdownString.toString();
+ }
+
+
+
+ @NonNull
+ @Override
+ public String toString() {
+ return getLogString();
+ }
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/net/socket/local/PeerCred.java b/termux/termux-shared/src/main/java/com/termux/shared/net/socket/local/PeerCred.java
new file mode 100644
index 000000000..6674eee0f
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/net/socket/local/PeerCred.java
@@ -0,0 +1,142 @@
+package com.termux.shared.net.socket.local;
+
+import android.content.Context;
+
+import androidx.annotation.Keep;
+import androidx.annotation.NonNull;
+
+import com.termux.shared.android.ProcessUtils;
+import com.termux.shared.android.UserUtils;
+import com.termux.shared.logger.Logger;
+import com.termux.shared.markdown.MarkdownUtils;
+
+/** The {@link PeerCred} of the {@link LocalClientSocket} containing info of client/peer. */
+@Keep
+public class PeerCred {
+
+ public static final String LOG_TAG = "PeerCred";
+
+ /** Process Id. */
+ public int pid;
+ /** Process Name. */
+ public String pname;
+
+ /** User Id. */
+ public int uid;
+ /** User name. */
+ public String uname;
+
+ /** Group Id. */
+ public int gid;
+ /** Group name. */
+ public String gname;
+
+ /** Command line that started the process. */
+ public String cmdline;
+
+ PeerCred() {
+ // Initialize to -1 instead of 0 in case a failed getPeerCred()/getsockopt() call somehow doesn't report failure and returns the uid of root
+ pid = -1; uid = -1; gid = -1;
+ }
+
+ /** Set data that was not set by JNI. */
+ public void fillPeerCred(@NonNull Context context) {
+ fillUnameAndGname(context);
+ fillPname(context);
+ }
+
+ /** Set {@link #uname} and {@link #gname} if not set. */
+ public void fillUnameAndGname(@NonNull Context context) {
+ uname = UserUtils.getNameForUid(context, uid);
+
+ if (gid != uid)
+ gname = UserUtils.getNameForUid(context, gid);
+ else
+ gname = uname;
+ }
+
+ /** Set {@link #pname} if not set. */
+ public void fillPname(@NonNull Context context) {
+ // If jni did not set process name since it wouldn't be able to access /proc/ of other
+ // users/apps, then try to see if any app has that pid, but this wouldn't check child
+ // processes of the app.
+ if (pid > 0 && pname == null)
+ pname = ProcessUtils.getAppProcessNameForPid(context, pid);
+ }
+
+ /**
+ * Get a log {@link String} for {@link PeerCred}.
+ *
+ * @param peerCred The {@link PeerCred} to get info of.
+ * @return Returns the log {@link String}.
+ */
+ @NonNull
+ public static String getPeerCredLogString(final PeerCred peerCred) {
+ if (peerCred == null) return "null";
+ return peerCred.getLogString();
+ }
+
+ /** Get a log {@link String} for the {@link PeerCred}. */
+ @NonNull
+ public String getLogString() {
+ StringBuilder logString = new StringBuilder();
+
+ logString.append("Peer Cred:");
+ logString.append("\n").append(Logger.getSingleLineLogStringEntry("Process", getProcessString(), "-"));
+ logString.append("\n").append(Logger.getSingleLineLogStringEntry("User", getUserString(), "-"));
+ logString.append("\n").append(Logger.getSingleLineLogStringEntry("Group", getGroupString(), "-"));
+
+ if (cmdline != null)
+ logString.append("\n").append(Logger.getMultiLineLogStringEntry("Cmdline", cmdline, "-"));
+
+ return logString.toString();
+ }
+
+ /**
+ * Get a markdown {@link String} for {@link PeerCred}.
+ *
+ * @param peerCred The {@link PeerCred} to get info of.
+ * @return Returns the markdown {@link String}.
+ */
+ public static String getPeerCredMarkdownString(final PeerCred peerCred) {
+ if (peerCred == null) return "null";
+ return peerCred.getMarkdownString();
+ }
+
+ /** Get a markdown {@link String} for the {@link PeerCred}. */
+ @NonNull
+ public String getMarkdownString() {
+ StringBuilder markdownString = new StringBuilder();
+
+ markdownString.append("## ").append("Peer Cred");
+ markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("Process", getProcessString(), "-"));
+ markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("User", getUserString(), "-"));
+ markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("Group", getGroupString(), "-"));
+
+ if (cmdline != null)
+ markdownString.append("\n").append(MarkdownUtils.getMultiLineMarkdownStringEntry("Cmdline", cmdline, "-"));
+
+ return markdownString.toString();
+ }
+
+ @NonNull
+ public String getMinimalString() {
+ return "process=" + getProcessString() + ", user=" + getUserString() + ", group=" + getGroupString();
+ }
+
+ @NonNull
+ public String getProcessString() {
+ return pname != null && !pname.isEmpty() ? pid + " (" + pname + ")" : String.valueOf(pid);
+ }
+
+ @NonNull
+ public String getUserString() {
+ return uname != null ? uid + " (" + uname + ")" : String.valueOf(uid);
+ }
+
+ @NonNull
+ public String getGroupString() {
+ return gname != null ? gid + " (" + gname + ")" : String.valueOf(gid);
+ }
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/net/uri/UriScheme.java b/termux/termux-shared/src/main/java/com/termux/shared/net/uri/UriScheme.java
new file mode 100644
index 000000000..c32be0d5a
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/net/uri/UriScheme.java
@@ -0,0 +1,28 @@
+package com.termux.shared.net.uri;
+
+import android.net.Uri;
+
+/**
+ * The {@link Uri} schemes.
+ *
+ * https://www.iana.org/assignments/uri-schemes/uri-schemes.xhtml
+ * https://en.wikipedia.org/wiki/List_of_URI_schemes
+ */
+public class UriScheme {
+
+ /** Android app resource. */
+ public static final String SCHEME_ANDROID_RESOURCE = "android.resource";
+
+ /** Android content provider. https://www.iana.org/assignments/uri-schemes/prov/content. */
+ public static final String SCHEME_CONTENT = "content";
+
+ /** Filesystem or android app asset. https://www.rfc-editor.org/rfc/rfc8089.html. */
+ public static final String SCHEME_FILE = "file";
+
+ /* Hypertext Transfer Protocol. */
+ public static final String SCHEME_HTTP = "http";
+
+ /* Hypertext Transfer Protocol Secure. */
+ public static final String SCHEME_HTTPS = "https";
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/net/uri/UriUtils.java b/termux/termux-shared/src/main/java/com/termux/shared/net/uri/UriUtils.java
new file mode 100644
index 000000000..2254a92bd
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/net/uri/UriUtils.java
@@ -0,0 +1,102 @@
+package com.termux.shared.net.uri;
+
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.termux.shared.data.DataUtils;
+import com.termux.shared.file.FileUtils;
+
+public class UriUtils {
+
+ /**
+ * Get the full file path from a {@link Uri} including the fragment.
+ *
+ * If the {@link Uri} was created from file path with {@link Uri#parse(String)}, like "am"
+ * command "-d" option does, and the path contained a "#", then anything after it would become
+ * the fragment and {@link Uri#getPath()} will only return the path before it, which would be
+ * invalid. The fragment must be manually appended to the path to get the full path.
+ *
+ * If the {@link Uri} was created with {@link Uri.Builder} and path was set
+ * with {@link Uri.Builder#path(String)}, then "#" will automatically be encoded to "%23"
+ * and separate fragment will not exist.
+ *
+ * @param uri The {@link Uri} to get file path from.
+ * @return Returns the file path if found, otherwise {@code null}.
+ */
+ @Nullable
+ public static String getUriFilePathWithFragment(Uri uri) {
+ if (uri == null) return null;
+ String path = uri.getPath();
+ if (DataUtils.isNullOrEmpty(path)) return null;
+ String fragment = uri.getFragment();
+ return path + (DataUtils.isNullOrEmpty(fragment) ? "" : "#" + fragment);
+ }
+
+ /**
+ * Get the file basename from a {@link Uri}. The file basename is anything after last forward
+ * slash "/" in the path, or the path itself if its not found.
+ *
+ * @param uri The {@link Uri} to get basename from.
+ * @param withFragment If the {@link Uri} fragment should be included in basename.
+ * @return Returns the file basename if found, otherwise {@code null}.
+ */
+ @Nullable
+ public static String getUriFileBasename(Uri uri, boolean withFragment) {
+ if (uri == null) return null;
+
+ String path;
+ if (withFragment) {
+ path = getUriFilePathWithFragment(uri);
+ } else {
+ path = uri.getPath();
+ if (DataUtils.isNullOrEmpty(path)) return null;
+ }
+
+ return FileUtils.getFileBasename(path);
+ }
+
+ /**
+ * Get {@link UriScheme#SCHEME_FILE} {@link Uri} for path.
+ *
+ * @param path The path for the {@link Uri}.
+ * @return Returns the {@link Uri}.
+ */
+ public static Uri getFileUri(@NonNull String path) {
+ return new Uri.Builder().scheme(UriScheme.SCHEME_FILE).path(path).build();
+ }
+
+ /**
+ * Get {@link UriScheme#SCHEME_FILE} {@link Uri} for path.
+ *
+ * @param authority The authority for the {@link Uri}.
+ * @param path The path for the {@link Uri}.
+ * @return Returns the {@link Uri}.
+ */
+ public static Uri getFileUri(@NonNull String authority, @NonNull String path) {
+ return new Uri.Builder().scheme(UriScheme.SCHEME_FILE).authority(authority).path(path).build();
+ }
+
+ /**
+ * Get {@link UriScheme#SCHEME_CONTENT} {@link Uri} for path.
+ *
+ * @param path The path for the {@link Uri}.
+ * @return Returns the {@link Uri}.
+ */
+ public static Uri getContentUri(@NonNull String path) {
+ return new Uri.Builder().scheme(UriScheme.SCHEME_CONTENT).path(path).build();
+ }
+
+ /**
+ * Get {@link UriScheme#SCHEME_CONTENT} {@link Uri} for path.
+ *
+ * @param authority The authority for the {@link Uri}.
+ * @param path The path for the {@link Uri}.
+ * @return Returns the {@link Uri}.
+ */
+ public static Uri getContentUri(@NonNull String authority, @NonNull String path) {
+ return new Uri.Builder().scheme(UriScheme.SCHEME_CONTENT).authority(authority).path(path).build();
+ }
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/net/url/UrlUtils.java b/termux/termux-shared/src/main/java/com/termux/shared/net/url/UrlUtils.java
new file mode 100644
index 000000000..63ca3a051
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/net/url/UrlUtils.java
@@ -0,0 +1,113 @@
+package com.termux.shared.net.url;
+
+import androidx.annotation.Nullable;
+
+import com.termux.shared.data.DataUtils;
+import com.termux.shared.logger.Logger;
+
+import java.net.MalformedURLException;
+import java.net.URL;
+
+public class UrlUtils {
+
+ /** The parts of a {@link URL}. */
+ public enum UrlPart {
+ AUTHORITY,
+ FILE,
+ HOST,
+ REF,
+ FRAGMENT,
+ PATH,
+ PORT,
+ PROTOCOL,
+ QUERY,
+ USER_INFO
+ }
+
+ private static final String LOG_TAG = "UrlUtils";
+
+ /**
+ * Join a url base and destination.
+ *
+ * @param base The base url to open.
+ * @param destination The destination url to open.
+ * @param logError If an error message should be logged.
+ * @return Returns the joined {@link String} Url, otherwise {@code null}.
+ */
+ @Nullable
+ public static String joinUrl(final String base, String destination, boolean logError) {
+ if (DataUtils.isNullOrEmpty(base)) return null;
+ try {
+ return new URL(new URL(base), destination).toString();
+ } catch (MalformedURLException e) {
+ if (logError)
+ Logger.logError(LOG_TAG, "Failed to join url base \"" + base + "\" and destination \"" + destination + "\": " + e.getMessage());
+ return null;
+ }
+ }
+
+ /**
+ * Get {@link URL} from url string.
+ *
+ * @param urlString The urlString string.
+ * @return Returns the {@link URL} if a valid urlString, otherwise {@code null}.
+ */
+ @Nullable
+ public static URL getUrl(String urlString) {
+ if (DataUtils.isNullOrEmpty(urlString)) return null;
+ try {
+ return new URL(urlString);
+ } catch (MalformedURLException e) {
+ return null;
+ }
+ }
+
+ /**
+ * Get a {@link URL} part from url string.
+ *
+ * @param urlString The urlString string.
+ * @param urlPart The part to get.
+ * @return Returns the {@link URL} part if a valid urlString and part, otherwise {@code null}.
+ */
+ @Nullable
+ public static String getUrlPart(String urlString, UrlPart urlPart) {
+ URL url = getUrl(urlString);
+ if (url == null) return null;
+ switch (urlPart) {
+ case AUTHORITY:
+ return url.getAuthority();
+ case FILE:
+ return url.getFile();
+ case HOST:
+ return url.getHost();
+ case REF:
+ case FRAGMENT:
+ return url.getRef();
+ case PATH:
+ return url.getPath();
+ case PORT:
+ return String.valueOf(url.getPort());
+ case PROTOCOL:
+ return url.getProtocol();
+ case QUERY:
+ return url.getQuery();
+ case USER_INFO:
+ return url.getUserInfo();
+ default:
+ return null;
+ }
+ }
+
+ /** Remove "https://www.", "https://", "www.", etc */
+ public static String removeProtocol(String urlString) {
+ if (urlString == null) return null;
+ return urlString.replaceFirst("^(http[s]?://www\\.|http[s]?://|www\\.)","");
+ }
+
+ public static boolean areUrlsEqual(String url1, String url2) {
+ if (url1 == null && url2 == null) return true;
+ if (url1 == null || url2 == null) return false;
+ return UrlUtils.removeProtocol(url1).replaceAll("/+$", "").equals(UrlUtils.removeProtocol(url2).replaceAll("/+$", ""));
+ }
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/notification/NotificationUtils.java b/termux/termux-shared/src/main/java/com/termux/shared/notification/NotificationUtils.java
new file mode 100644
index 000000000..e21a83f40
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/notification/NotificationUtils.java
@@ -0,0 +1,146 @@
+package com.termux.shared.notification;
+
+import android.app.Notification;
+import android.app.NotificationChannel;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.content.Context;
+import android.os.Build;
+
+import androidx.annotation.Nullable;
+
+import com.termux.shared.logger.Logger;
+
+public class NotificationUtils {
+
+ /** Do not show notification */
+ public static final int NOTIFICATION_MODE_NONE = 0;
+ /** Show notification without sound, vibration or lights */
+ public static final int NOTIFICATION_MODE_SILENT = 1;
+ /** Show notification with sound */
+ public static final int NOTIFICATION_MODE_SOUND = 2;
+ /** Show notification with vibration */
+ public static final int NOTIFICATION_MODE_VIBRATE = 3;
+ /** Show notification with lights */
+ public static final int NOTIFICATION_MODE_LIGHTS = 4;
+ /** Show notification with sound and vibration */
+ public static final int NOTIFICATION_MODE_SOUND_AND_VIBRATE = 5;
+ /** Show notification with sound and lights */
+ public static final int NOTIFICATION_MODE_SOUND_AND_LIGHTS = 6;
+ /** Show notification with vibration and lights */
+ public static final int NOTIFICATION_MODE_VIBRATE_AND_LIGHTS = 7;
+ /** Show notification with sound, vibration and lights */
+ public static final int NOTIFICATION_MODE_ALL = 8;
+
+ private static final String LOG_TAG = "NotificationUtils";
+
+ /**
+ * Get the {@link NotificationManager}.
+ *
+ * @param context The {@link Context} for operations.
+ * @return Returns the {@link NotificationManager}.
+ */
+ @Nullable
+ public static NotificationManager getNotificationManager(final Context context) {
+ if (context == null) return null;
+ return (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
+ }
+
+ /**
+ * Get {@link Notification.Builder}.
+ *
+ * @param context The {@link Context} for operations.
+ * @param title The title for the notification.
+ * @param channelId The channel id for the notification.
+ * @param priority The priority for the notification.
+ * @param notificationText The second line text of the notification.
+ * @param notificationBigText The full text of the notification that may optionally be styled.
+ * @param contentIntent The {@link PendingIntent} which should be sent when notification is clicked.
+ * @param deleteIntent The {@link PendingIntent} which should be sent when notification is deleted.
+ * @param notificationMode The notification mode. It must be one of {@code NotificationUtils.NOTIFICATION_MODE_*}.
+ * The builder returned will be {@code null} if {@link #NOTIFICATION_MODE_NONE}
+ * is passed. That case should ideally be handled before calling this function.
+ * @return Returns the {@link Notification.Builder}.
+ */
+ @Nullable
+ public static Notification.Builder geNotificationBuilder(
+ final Context context, final String channelId, final int priority, final CharSequence title,
+ final CharSequence notificationText, final CharSequence notificationBigText,
+ final PendingIntent contentIntent, final PendingIntent deleteIntent, final int notificationMode) {
+ if (context == null) return null;
+ Notification.Builder builder = new Notification.Builder(context);
+ builder.setContentTitle(title);
+ builder.setContentText(notificationText);
+ builder.setStyle(new Notification.BigTextStyle().bigText(notificationBigText));
+ builder.setContentIntent(contentIntent);
+ builder.setDeleteIntent(deleteIntent);
+
+ builder.setPriority(priority);
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
+ builder.setChannelId(channelId);
+
+ builder = setNotificationDefaults(builder, notificationMode);
+
+ return builder;
+ }
+
+ /**
+ * Setup the notification channel if Android version is greater than or equal to
+ * {@link Build.VERSION_CODES#O}.
+ *
+ * @param context The {@link Context} for operations.
+ * @param channelId The id of the channel. Must be unique per package.
+ * @param channelName The user visible name of the channel.
+ * @param importance The importance of the channel. This controls how interruptive notifications
+ * posted to this channel are.
+ */
+ public static void setupNotificationChannel(final Context context, final String channelId, final CharSequence channelName, final int importance) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return;
+
+ NotificationChannel channel = new NotificationChannel(channelId, channelName, importance);
+
+ NotificationManager notificationManager = getNotificationManager(context);
+ if (notificationManager != null)
+ notificationManager.createNotificationChannel(channel);
+ }
+
+ public static Notification.Builder setNotificationDefaults(Notification.Builder builder, final int notificationMode) {
+
+ // TODO: setDefaults() is deprecated and should also implement setting notification mode via notification channel
+ switch (notificationMode) {
+ case NOTIFICATION_MODE_NONE:
+ Logger.logWarn(LOG_TAG, "The NOTIFICATION_MODE_NONE passed to setNotificationDefaults(), force setting builder to null.");
+ return null; // return null since notification is not supposed to be shown
+ case NOTIFICATION_MODE_SILENT:
+ break;
+ case NOTIFICATION_MODE_SOUND:
+ builder.setDefaults(Notification.DEFAULT_SOUND);
+ break;
+ case NOTIFICATION_MODE_VIBRATE:
+ builder.setDefaults(Notification.DEFAULT_VIBRATE);
+ break;
+ case NOTIFICATION_MODE_LIGHTS:
+ builder.setDefaults(Notification.DEFAULT_LIGHTS);
+ break;
+ case NOTIFICATION_MODE_SOUND_AND_VIBRATE:
+ builder.setDefaults(Notification.DEFAULT_SOUND | Notification.DEFAULT_VIBRATE);
+ break;
+ case NOTIFICATION_MODE_SOUND_AND_LIGHTS:
+ builder.setDefaults(Notification.DEFAULT_SOUND | Notification.DEFAULT_LIGHTS);
+ break;
+ case NOTIFICATION_MODE_VIBRATE_AND_LIGHTS:
+ builder.setDefaults(Notification.DEFAULT_VIBRATE | Notification.DEFAULT_LIGHTS);
+ break;
+ case NOTIFICATION_MODE_ALL:
+ builder.setDefaults(Notification.DEFAULT_ALL);
+ break;
+ default:
+ Logger.logError(LOG_TAG, "Invalid notificationMode: \"" + notificationMode + "\" passed to setNotificationDefaults()");
+ break;
+ }
+
+ return builder;
+ }
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/reflection/ReflectionUtils.java b/termux/termux-shared/src/main/java/com/termux/shared/reflection/ReflectionUtils.java
new file mode 100644
index 000000000..c57f103b4
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/reflection/ReflectionUtils.java
@@ -0,0 +1,282 @@
+package com.termux.shared.reflection;
+
+import android.os.Build;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.termux.shared.logger.Logger;
+
+import org.lsposed.hiddenapibypass.HiddenApiBypass;
+
+import java.lang.reflect.Constructor;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.util.Arrays;
+
+public class ReflectionUtils {
+
+ private static boolean HIDDEN_API_REFLECTION_RESTRICTIONS_BYPASSED = Build.VERSION.SDK_INT < Build.VERSION_CODES.P;
+
+ private static final String LOG_TAG = "ReflectionUtils";
+
+ /**
+ * Bypass android hidden API reflection restrictions.
+ * https://github.com/LSPosed/AndroidHiddenApiBypass
+ * https://developer.android.com/guide/app-compatibility/restrictions-non-sdk-interfaces
+ */
+ public static void bypassHiddenAPIReflectionRestrictions() {
+ if (!HIDDEN_API_REFLECTION_RESTRICTIONS_BYPASSED && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ Logger.logDebug(LOG_TAG, "Bypassing android hidden api reflection restrictions");
+ try {
+ HiddenApiBypass.addHiddenApiExemptions("");
+ } catch (Throwable t) {
+ Logger.logStackTraceWithMessage(LOG_TAG, "Failed to bypass hidden API reflection restrictions", t);
+ }
+
+ HIDDEN_API_REFLECTION_RESTRICTIONS_BYPASSED = true;
+ }
+ }
+
+ /** Check if android hidden API reflection restrictions are bypassed. */
+ public static boolean areHiddenAPIReflectionRestrictionsBypassed() {
+ return HIDDEN_API_REFLECTION_RESTRICTIONS_BYPASSED;
+ }
+
+
+
+
+
+ /**
+ * Get a {@link Field} for the specified class.
+ *
+ * @param clazz The {@link Class} for which to return the field.
+ * @param fieldName The name of the {@link Field}.
+ * @return Returns the {@link Field} if getting the it was successful, otherwise {@code null}.
+ */
+ @Nullable
+ public static Field getDeclaredField(@NonNull Class> clazz, @NonNull String fieldName) {
+ try {
+ Field field = clazz.getDeclaredField(fieldName);
+ field.setAccessible(true);
+ return field;
+ } catch (Exception e) {
+ Logger.logStackTraceWithMessage(LOG_TAG, "Failed to get \"" + fieldName + "\" field for \"" + clazz.getName() + "\" class", e);
+ return null;
+ }
+ }
+
+
+
+ /** Class that represents result of invoking a field. */
+ public static class FieldInvokeResult {
+ public boolean success;
+ public Object value;
+
+ FieldInvokeResult(boolean success, Object value) {
+ this.value = success;
+ this.value = value;
+ }
+ }
+
+ /**
+ * Get a value for a {@link Field} of an object for the specified class.
+ *
+ * Trying to access {@code null} fields will result in {@link NoSuchFieldException}.
+ *
+ * @param clazz The {@link Class} to which the object belongs to.
+ * @param fieldName The name of the {@link Field}.
+ * @param object The {@link Object} instance from which to get the field value.
+ * @return Returns the {@link FieldInvokeResult} of invoking the field. The
+ * {@link FieldInvokeResult#success} will be {@code true} if invoking the field was successful,
+ * otherwise {@code false}. The {@link FieldInvokeResult#value} will contain the field
+ * {@link Object} value.
+ */
+ @NonNull
+ public static FieldInvokeResult invokeField(@NonNull Class extends T> clazz, @NonNull String fieldName, T object) {
+ try {
+ Field field = getDeclaredField(clazz, fieldName);
+ if (field == null) return new FieldInvokeResult(false, null);
+ return new FieldInvokeResult(true, field.get(object));
+ } catch (Exception e) {
+ Logger.logStackTraceWithMessage(LOG_TAG, "Failed to get \"" + fieldName + "\" field value for \"" + clazz.getName() + "\" class", e);
+ return new FieldInvokeResult(false, null);
+ }
+ }
+
+
+
+
+
+ /**
+ * Wrapper for {@link #getDeclaredMethod(Class, String, Class[])} without parameters.
+ */
+ @Nullable
+ public static Method getDeclaredMethod(@NonNull Class> clazz, @NonNull String methodName) {
+ return getDeclaredMethod(clazz, methodName, new Class>[0]);
+ }
+
+ /**
+ * Get a {@link Method} for the specified class with the specified parameters.
+ *
+ * @param clazz The {@link Class} for which to return the method.
+ * @param methodName The name of the {@link Method}.
+ * @param parameterTypes The parameter types of the method.
+ * @return Returns the {@link Method} if getting the it was successful, otherwise {@code null}.
+ */
+ @Nullable
+ public static Method getDeclaredMethod(@NonNull Class> clazz, @NonNull String methodName, Class>... parameterTypes) {
+ try {
+ Method method = clazz.getDeclaredMethod(methodName, parameterTypes);
+ method.setAccessible(true);
+ return method;
+ } catch (Exception e) {
+ Logger.logStackTraceWithMessage(LOG_TAG, "Failed to get \"" + methodName + "\" method for \"" + clazz.getName() + "\" class with parameter types: " + Arrays.toString(parameterTypes), e);
+ return null;
+ }
+ }
+
+
+
+ /**
+ * Wrapper for {@link #invokeVoidMethod(Method, Object, Object...)} without arguments.
+ */
+ public static boolean invokeVoidMethod(@NonNull Method method, Object obj) {
+ return invokeVoidMethod(method, obj, new Object[0]);
+ }
+
+ /**
+ * Invoke a {@link Method} on the specified object with the specified arguments that returns
+ * {@code void}.
+ *
+ * @param method The {@link Method} to invoke.
+ * @param obj The {@link Object} the method should be invoked from.
+ * @param args The arguments to pass to the method.
+ * @return Returns {@code true} if invoking the method was successful, otherwise {@code false}.
+ */
+ public static boolean invokeVoidMethod(@NonNull Method method, Object obj, Object... args) {
+ try {
+ method.setAccessible(true);
+ method.invoke(obj, args);
+ return true;
+ } catch (Exception e) {
+ Logger.logStackTraceWithMessage(LOG_TAG, "Failed to invoke \"" + method.getName() + "\" method with object \"" + obj + "\" and args: " + Arrays.toString(args), e);
+ return false;
+ }
+ }
+
+
+
+ /** Class that represents result of invoking a method that has a non-void return type. */
+ public static class MethodInvokeResult {
+ public boolean success;
+ public Object value;
+
+ MethodInvokeResult(boolean success, Object value) {
+ this.value = success;
+ this.value = value;
+ }
+ }
+
+ /**
+ * Wrapper for {@link #invokeMethod(Method, Object, Object...)} without arguments.
+ */
+ @NonNull
+ public static MethodInvokeResult invokeMethod(@NonNull Method method, Object obj) {
+ return invokeMethod(method, obj, new Object[0]);
+ }
+
+ /**
+ * Invoke a {@link Method} on the specified object with the specified arguments.
+ *
+ * @param method The {@link Method} to invoke.
+ * @param obj The {@link Object} the method should be invoked from.
+ * @param args The arguments to pass to the method.
+ * @return Returns the {@link MethodInvokeResult} of invoking the method. The
+ * {@link MethodInvokeResult#success} will be {@code true} if invoking the method was successful,
+ * otherwise {@code false}. The {@link MethodInvokeResult#value} will contain the {@link Object}
+ * returned by the method.
+ */
+ @NonNull
+ public static MethodInvokeResult invokeMethod(@NonNull Method method, Object obj, Object... args) {
+ try {
+ method.setAccessible(true);
+ return new MethodInvokeResult(true, method.invoke(obj, args));
+ } catch (Exception e) {
+ Logger.logStackTraceWithMessage(LOG_TAG, "Failed to invoke \"" + method.getName() + "\" method with object \"" + obj + "\" and args: " + Arrays.toString(args), e);
+ return new MethodInvokeResult(false, null);
+ }
+ }
+
+
+
+ /**
+ * Wrapper for {@link #getConstructor(String, Class[])} without parameters.
+ */
+ @Nullable
+ public static Constructor> getConstructor(@NonNull String className) {
+ return getConstructor(className, new Class>[0]);
+ }
+
+ /**
+ * Wrapper for {@link #getConstructor(Class, Class[])} to get a {@link Constructor} for the
+ * {@code className}.
+ */
+ @Nullable
+ public static Constructor> getConstructor(@NonNull String className, Class>... parameterTypes) {
+ try {
+ return getConstructor(Class.forName(className), parameterTypes);
+ } catch (Exception e) {
+ Logger.logStackTraceWithMessage(LOG_TAG, "Failed to get constructor for \"" + className + "\" class with parameter types: " + Arrays.toString(parameterTypes), e);
+ return null;
+ }
+ }
+
+ /**
+ * Get a {@link Constructor} for the specified class with the specified parameters.
+ *
+ * @param clazz The {@link Class} for which to return the constructor.
+ * @param parameterTypes The parameter types of the constructor.
+ * @return Returns the {@link Constructor} if getting the it was successful, otherwise {@code null}.
+ */
+ @Nullable
+ public static Constructor> getConstructor(@NonNull Class> clazz, Class>... parameterTypes) {
+ try {
+ Constructor> constructor = clazz.getConstructor(parameterTypes);
+ constructor.setAccessible(true);
+ return constructor;
+ } catch (Exception e) {
+ Logger.logStackTraceWithMessage(LOG_TAG, "Failed to get constructor for \"" + clazz.getName() + "\" class with parameter types: " + Arrays.toString(parameterTypes), e);
+ return null;
+ }
+ }
+
+
+
+ /**
+ * Wrapper for {@link #invokeConstructor(Constructor, Object...)} without arguments.
+ */
+ @Nullable
+ public static Object invokeConstructor(@NonNull Constructor> constructor) {
+ return invokeConstructor(constructor, new Object[0]);
+ }
+
+ /**
+ * Invoke a {@link Constructor} with the specified arguments.
+ *
+ * @param constructor The {@link Constructor} to invoke.
+ * @param args The arguments to pass to the constructor.
+ * @return Returns the new instance if invoking the constructor was successful, otherwise {@code null}.
+ */
+ @Nullable
+ public static Object invokeConstructor(@NonNull Constructor> constructor, Object... args) {
+ try {
+ constructor.setAccessible(true);
+ return constructor.newInstance(args);
+ } catch (Exception e) {
+ Logger.logStackTraceWithMessage(LOG_TAG, "Failed to invoke \"" + constructor.getName() + "\" constructor with args: " + Arrays.toString(args), e);
+ return null;
+ }
+ }
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/settings/preferences/AppSharedPreferences.java b/termux/termux-shared/src/main/java/com/termux/shared/settings/preferences/AppSharedPreferences.java
new file mode 100644
index 000000000..03a75e03b
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/settings/preferences/AppSharedPreferences.java
@@ -0,0 +1,49 @@
+package com.termux.shared.settings.preferences;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+/** A class that holds {@link SharedPreferences} objects for apps. */
+public class AppSharedPreferences {
+
+ /** The {@link Context} for operations. */
+ protected final Context mContext;
+
+ /** The {@link SharedPreferences} that ideally should be created with {@link SharedPreferenceUtils#getPrivateSharedPreferences(Context, String)}. */
+ protected final SharedPreferences mSharedPreferences;
+
+ /** The {@link SharedPreferences}that ideally should be created with {@link SharedPreferenceUtils#getPrivateAndMultiProcessSharedPreferences(Context, String)}. */
+ protected final SharedPreferences mMultiProcessSharedPreferences;
+
+ protected AppSharedPreferences(@NonNull Context context, @Nullable SharedPreferences sharedPreferences) {
+ this(context, sharedPreferences, null);
+ }
+
+ protected AppSharedPreferences(@NonNull Context context, @Nullable SharedPreferences sharedPreferences,
+ @Nullable SharedPreferences multiProcessSharedPreferences) {
+ mContext = context;
+ mSharedPreferences = sharedPreferences;
+ mMultiProcessSharedPreferences = multiProcessSharedPreferences;
+ }
+
+
+
+ /** Get {@link #mContext}. */
+ public Context getContext() {
+ return mContext;
+ }
+
+ /** Get {@link #mSharedPreferences}. */
+ public SharedPreferences getSharedPreferences() {
+ return mSharedPreferences;
+ }
+
+ /** Get {@link #mMultiProcessSharedPreferences}. */
+ public SharedPreferences getMultiProcessSharedPreferences() {
+ return mMultiProcessSharedPreferences;
+ }
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/settings/preferences/SharedPreferenceUtils.java b/termux/termux-shared/src/main/java/com/termux/shared/settings/preferences/SharedPreferenceUtils.java
new file mode 100644
index 000000000..68bd7c3ee
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/settings/preferences/SharedPreferenceUtils.java
@@ -0,0 +1,432 @@
+package com.termux.shared.settings.preferences;
+
+import android.annotation.SuppressLint;
+import android.content.Context;
+import android.content.SharedPreferences;
+
+import com.termux.shared.logger.Logger;
+
+import java.util.Set;
+
+public class SharedPreferenceUtils {
+
+ private static final String LOG_TAG = "SharedPreferenceUtils";
+
+ /**
+ * Get {@link SharedPreferences} instance of the preferences file 'name' with the operating mode
+ * {@link Context#MODE_PRIVATE}. This file will be created in the app package's default
+ * shared preferences directory.
+ *
+ * @param context The {@link Context} to get the {@link SharedPreferences} instance.
+ * @param name The preferences file basename without extension.
+ * @return The single {@link SharedPreferences} instance that can be used to retrieve and
+ * modify the preference values.
+ */
+ public static SharedPreferences getPrivateSharedPreferences(Context context, String name) {
+ return context.getSharedPreferences(name, Context.MODE_PRIVATE);
+ }
+
+ /**
+ * Get {@link SharedPreferences} instance of the preferences file 'name' with the operating mode
+ * {@link Context#MODE_PRIVATE} and {@link Context#MODE_MULTI_PROCESS}. This file will be
+ * created in the app package's default shared preferences directory.
+ *
+ * @param context The {@link Context} to get the {@link SharedPreferences} instance.
+ * @param name The preferences file basename without extension.
+ * @return The single {@link SharedPreferences} instance that can be used to retrieve and
+ * modify the preference values.
+ */
+ public static SharedPreferences getPrivateAndMultiProcessSharedPreferences(Context context, String name) {
+ return context.getSharedPreferences(name, Context.MODE_PRIVATE | Context.MODE_MULTI_PROCESS);
+ }
+
+
+
+ /**
+ * Get a {@code boolean} from {@link SharedPreferences}.
+ *
+ * @param sharedPreferences The {@link SharedPreferences} to get the value from.
+ * @param key The key for the value.
+ * @param def The default value if failed to read a valid value.
+ * @return Returns the {@code boolean} value stored in {@link SharedPreferences}, otherwise returns
+ * default if failed to read a valid value, like in case of an exception.
+ */
+ public static boolean getBoolean(SharedPreferences sharedPreferences, String key, boolean def) {
+ if (sharedPreferences == null) {
+ Logger.logError(LOG_TAG, "Error getting boolean value for the \"" + key + "\" key from null shared preferences. Returning default value \"" + def + "\".");
+ return def;
+ }
+
+ try {
+ return sharedPreferences.getBoolean(key, def);
+ }
+ catch (ClassCastException e) {
+ Logger.logStackTraceWithMessage(LOG_TAG, "Error getting boolean value for the \"" + key + "\" key from shared preferences. Returning default value \"" + def + "\".", e);
+ return def;
+ }
+ }
+
+ /**
+ * Set a {@code boolean} in {@link SharedPreferences}.
+ *
+ * @param sharedPreferences The {@link SharedPreferences} to set the value in.
+ * @param key The key for the value.
+ * @param value The value to store.
+ * @param commitToFile If set to {@code true}, then value will be set to shared preferences
+ * in-memory cache and the file synchronously. Ideally, only to be used for
+ * multi-process use-cases.
+ */
+ @SuppressLint("ApplySharedPref")
+ public static void setBoolean(SharedPreferences sharedPreferences, String key, boolean value, boolean commitToFile) {
+ if (sharedPreferences == null) {
+ Logger.logError(LOG_TAG, "Ignoring setting boolean value \"" + value + "\" for the \"" + key + "\" key into null shared preferences.");
+ return;
+ }
+
+ if (commitToFile)
+ sharedPreferences.edit().putBoolean(key, value).commit();
+ else
+ sharedPreferences.edit().putBoolean(key, value).apply();
+
+ }
+
+
+
+ /**
+ * Get a {@code float} from {@link SharedPreferences}.
+ *
+ * @param sharedPreferences The {@link SharedPreferences} to get the value from.
+ * @param key The key for the value.
+ * @param def The default value if failed to read a valid value.
+ * @return Returns the {@code float} value stored in {@link SharedPreferences}, otherwise returns
+ * default if failed to read a valid value, like in case of an exception.
+ */
+ public static float getFloat(SharedPreferences sharedPreferences, String key, float def) {
+ if (sharedPreferences == null) {
+ Logger.logError(LOG_TAG, "Error getting float value for the \"" + key + "\" key from null shared preferences. Returning default value \"" + def + "\".");
+ return def;
+ }
+
+ try {
+ return sharedPreferences.getFloat(key, def);
+ }
+ catch (ClassCastException e) {
+ Logger.logStackTraceWithMessage(LOG_TAG, "Error getting float value for the \"" + key + "\" key from shared preferences. Returning default value \"" + def + "\".", e);
+ return def;
+ }
+ }
+
+ /**
+ * Set a {@code float} in {@link SharedPreferences}.
+ *
+ * @param sharedPreferences The {@link SharedPreferences} to set the value in.
+ * @param key The key for the value.
+ * @param value The value to store.
+ * @param commitToFile If set to {@code true}, then value will be set to shared preferences
+ * in-memory cache and the file synchronously. Ideally, only to be used for
+ * multi-process use-cases.
+ */
+ @SuppressLint("ApplySharedPref")
+ public static void setFloat(SharedPreferences sharedPreferences, String key, float value, boolean commitToFile) {
+ if (sharedPreferences == null) {
+ Logger.logError(LOG_TAG, "Ignoring setting float value \"" + value + "\" for the \"" + key + "\" key into null shared preferences.");
+ return;
+ }
+
+ if (commitToFile)
+ sharedPreferences.edit().putFloat(key, value).commit();
+ else
+ sharedPreferences.edit().putFloat(key, value).apply();
+ }
+
+
+
+ /**
+ * Get an {@code int} from {@link SharedPreferences}.
+ *
+ * @param sharedPreferences The {@link SharedPreferences} to get the value from.
+ * @param key The key for the value.
+ * @param def The default value if failed to read a valid value.
+ * @return Returns the {@code int} value stored in {@link SharedPreferences}, otherwise returns
+ * default if failed to read a valid value, like in case of an exception.
+ */
+ public static int getInt(SharedPreferences sharedPreferences, String key, int def) {
+ if (sharedPreferences == null) {
+ Logger.logError(LOG_TAG, "Error getting int value for the \"" + key + "\" key from null shared preferences. Returning default value \"" + def + "\".");
+ return def;
+ }
+
+ try {
+ return sharedPreferences.getInt(key, def);
+ }
+ catch (ClassCastException e) {
+ Logger.logStackTraceWithMessage(LOG_TAG, "Error getting int value for the \"" + key + "\" key from shared preferences. Returning default value \"" + def + "\".", e);
+ return def;
+ }
+ }
+
+ /**
+ * Set an {@code int} in {@link SharedPreferences}.
+ *
+ * @param sharedPreferences The {@link SharedPreferences} to set the value in.
+ * @param key The key for the value.
+ * @param value The value to store.
+ * @param commitToFile If set to {@code true}, then value will be set to shared preferences
+ * in-memory cache and the file synchronously. Ideally, only to be used for
+ * multi-process use-cases.
+ */
+ @SuppressLint("ApplySharedPref")
+ public static void setInt(SharedPreferences sharedPreferences, String key, int value, boolean commitToFile) {
+ if (sharedPreferences == null) {
+ Logger.logError(LOG_TAG, "Ignoring setting int value \"" + value + "\" for the \"" + key + "\" key into null shared preferences.");
+ return;
+ }
+
+ if (commitToFile)
+ sharedPreferences.edit().putInt(key, value).commit();
+ else
+ sharedPreferences.edit().putInt(key, value).apply();
+ }
+
+ /**
+ * Set an {@code int} in {@link SharedPreferences}.
+ *
+ * @param sharedPreferences The {@link SharedPreferences} to set the value in.
+ * @param key The key for the value.
+ * @param def The default value if failed to read a valid value.
+ * @param commitToFile If set to {@code true}, then value will be set to shared preferences
+ * in-memory cache and the file synchronously. Ideally, only to be used for
+ * multi-process use-cases.
+ * @param resetValue The value if not {@code null} that should be set if current or incremented
+ * value is less than 0.
+ * @return Returns the {@code int} value stored in {@link SharedPreferences} before increment,
+ * otherwise returns default if failed to read a valid value, like in case of an exception.
+ */
+ @SuppressLint("ApplySharedPref")
+ public static int getAndIncrementInt(SharedPreferences sharedPreferences, String key, int def,
+ boolean commitToFile, Integer resetValue) {
+ if (sharedPreferences == null) {
+ Logger.logError(LOG_TAG, "Ignoring incrementing int value for the \"" + key + "\" key into null shared preferences.");
+ return def;
+ }
+
+ int curValue = getInt(sharedPreferences, key, def);
+ if (resetValue != null && (curValue < 0)) curValue = resetValue;
+
+ int newValue = curValue + 1;
+ if (resetValue != null && newValue < 0) newValue = resetValue;
+
+ setInt(sharedPreferences, key, newValue, commitToFile);
+ return curValue;
+ }
+
+
+
+ /**
+ * Get a {@code long} from {@link SharedPreferences}.
+ *
+ * @param sharedPreferences The {@link SharedPreferences} to get the value from.
+ * @param key The key for the value.
+ * @param def The default value if failed to read a valid value.
+ * @return Returns the {@code long} value stored in {@link SharedPreferences}, otherwise returns
+ * default if failed to read a valid value, like in case of an exception.
+ */
+ public static long getLong(SharedPreferences sharedPreferences, String key, long def) {
+ if (sharedPreferences == null) {
+ Logger.logError(LOG_TAG, "Error getting long value for the \"" + key + "\" key from null shared preferences. Returning default value \"" + def + "\".");
+ return def;
+ }
+
+ try {
+ return sharedPreferences.getLong(key, def);
+ }
+ catch (ClassCastException e) {
+ Logger.logStackTraceWithMessage(LOG_TAG, "Error getting long value for the \"" + key + "\" key from shared preferences. Returning default value \"" + def + "\".", e);
+ return def;
+ }
+ }
+
+ /**
+ * Set a {@code long} in {@link SharedPreferences}.
+ *
+ * @param sharedPreferences The {@link SharedPreferences} to set the value in.
+ * @param key The key for the value.
+ * @param value The value to store.
+ * @param commitToFile If set to {@code true}, then value will be set to shared preferences
+ * in-memory cache and the file synchronously. Ideally, only to be used for
+ * multi-process use-cases.
+ */
+ @SuppressLint("ApplySharedPref")
+ public static void setLong(SharedPreferences sharedPreferences, String key, long value, boolean commitToFile) {
+ if (sharedPreferences == null) {
+ Logger.logError(LOG_TAG, "Ignoring setting long value \"" + value + "\" for the \"" + key + "\" key into null shared preferences.");
+ return;
+ }
+
+ if (commitToFile)
+ sharedPreferences.edit().putLong(key, value).commit();
+ else
+ sharedPreferences.edit().putLong(key, value).apply();
+ }
+
+
+
+ /**
+ * Get a {@code String} from {@link SharedPreferences}.
+ *
+ * @param sharedPreferences The {@link SharedPreferences} to get the value from.
+ * @param key The key for the value.
+ * @param def The default value if failed to read a valid value.
+ * @param defIfEmpty If set to {@code true}, then {@code def} will be returned if value is empty.
+ * @return Returns the {@code String} value stored in {@link SharedPreferences}, otherwise returns
+ * default if failed to read a valid value, like in case of an exception.
+ */
+ public static String getString(SharedPreferences sharedPreferences, String key, String def, boolean defIfEmpty) {
+ if (sharedPreferences == null) {
+ Logger.logError(LOG_TAG, "Error getting String value for the \"" + key + "\" key from null shared preferences. Returning default value \"" + def + "\".");
+ return def;
+ }
+
+ try {
+ String value = sharedPreferences.getString(key, def);
+ if (defIfEmpty && (value == null || value.isEmpty()))
+ return def;
+ else
+ return value;
+ }
+ catch (ClassCastException e) {
+ Logger.logStackTraceWithMessage(LOG_TAG, "Error getting String value for the \"" + key + "\" key from shared preferences. Returning default value \"" + def + "\".", e);
+ return def;
+ }
+ }
+
+ /**
+ * Set a {@code String} in {@link SharedPreferences}.
+ *
+ * @param sharedPreferences The {@link SharedPreferences} to set the value in.
+ * @param key The key for the value.
+ * @param value The value to store.
+ * @param commitToFile If set to {@code true}, then value will be set to shared preferences
+ * in-memory cache and the file synchronously. Ideally, only to be used for
+ * multi-process use-cases.
+ */
+ @SuppressLint("ApplySharedPref")
+ public static void setString(SharedPreferences sharedPreferences, String key, String value, boolean commitToFile) {
+ if (sharedPreferences == null) {
+ Logger.logError(LOG_TAG, "Ignoring setting String value \"" + value + "\" for the \"" + key + "\" key into null shared preferences.");
+ return;
+ }
+
+ if (commitToFile)
+ sharedPreferences.edit().putString(key, value).commit();
+ else
+ sharedPreferences.edit().putString(key, value).apply();
+ }
+
+
+
+ /**
+ * Get a {@code Set} from {@link SharedPreferences}.
+ *
+ * @param sharedPreferences The {@link SharedPreferences} to get the value from.
+ * @param key The key for the value.
+ * @param def The default value if failed to read a valid value.
+ * @return Returns the {@code Set} value stored in {@link SharedPreferences}, otherwise returns
+ * default if failed to read a valid value, like in case of an exception.
+ */
+ public static Set getStringSet(SharedPreferences sharedPreferences, String key, Set def) {
+ if (sharedPreferences == null) {
+ Logger.logError(LOG_TAG, "Error getting Set value for the \"" + key + "\" key from null shared preferences. Returning default value \"" + def + "\".");
+ return def;
+ }
+
+ try {
+ return sharedPreferences.getStringSet(key, def);
+ }
+ catch (ClassCastException e) {
+ Logger.logStackTraceWithMessage(LOG_TAG, "Error getting Set value for the \"" + key + "\" key from shared preferences. Returning default value \"" + def + "\".", e);
+ return def;
+ }
+ }
+
+ /**
+ * Set a {@code Set} in {@link SharedPreferences}.
+ *
+ * @param sharedPreferences The {@link SharedPreferences} to set the value in.
+ * @param key The key for the value.
+ * @param value The value to store.
+ * @param commitToFile If set to {@code true}, then value will be set to shared preferences
+ * in-memory cache and the file synchronously. Ideally, only to be used for
+ * multi-process use-cases.
+ */
+ @SuppressLint("ApplySharedPref")
+ public static void setStringSet(SharedPreferences sharedPreferences, String key, Set value, boolean commitToFile) {
+ if (sharedPreferences == null) {
+ Logger.logError(LOG_TAG, "Ignoring setting Set value \"" + value + "\" for the \"" + key + "\" key into null shared preferences.");
+ return;
+ }
+
+ if (commitToFile)
+ sharedPreferences.edit().putStringSet(key, value).commit();
+ else
+ sharedPreferences.edit().putStringSet(key, value).apply();
+ }
+
+
+
+ /**
+ * Get an {@code int} from {@link SharedPreferences} that is stored as a {@link String}.
+ *
+ * @param sharedPreferences The {@link SharedPreferences} to get the value from.
+ * @param key The key for the value.
+ * @param def The default value if failed to read a valid value.
+ * @return Returns the {@code int} value after parsing the {@link String} value stored in
+ * {@link SharedPreferences}, otherwise returns default if failed to read a valid value,
+ * like in case of an exception.
+ */
+ public static int getIntStoredAsString(SharedPreferences sharedPreferences, String key, int def) {
+ if (sharedPreferences == null) {
+ Logger.logError(LOG_TAG, "Error getting int value for the \"" + key + "\" key from null shared preferences. Returning default value \"" + def + "\".");
+ return def;
+ }
+
+ String stringValue;
+ int intValue;
+
+ try {
+ stringValue = sharedPreferences.getString(key, Integer.toString(def));
+ if (stringValue != null)
+ intValue = Integer.parseInt(stringValue);
+ else
+ intValue = def;
+ } catch (NumberFormatException | ClassCastException e) {
+ intValue = def;
+ }
+
+ return intValue;
+ }
+
+ /**
+ * Set an {@code int} into {@link SharedPreferences} that is stored as a {@link String}.
+ *
+ * @param sharedPreferences The {@link SharedPreferences} to set the value in.
+ * @param key The key for the value.
+ * @param value The value to store.
+ * @param commitToFile If set to {@code true}, then value will be set to shared preferences
+ * in-memory cache and the file synchronously. Ideally, only to be used for
+ * multi-process use-cases.
+ */
+ @SuppressLint("ApplySharedPref")
+ public static void setIntStoredAsString(SharedPreferences sharedPreferences, String key, int value, boolean commitToFile) {
+ if (sharedPreferences == null) {
+ Logger.logError(LOG_TAG, "Ignoring setting int value \"" + value + "\" for the \"" + key + "\" key into null shared preferences.");
+ return;
+ }
+
+ if (commitToFile)
+ sharedPreferences.edit().putString(key, Integer.toString(value)).commit();
+ else
+ sharedPreferences.edit().putString(key, Integer.toString(value)).apply();
+ }
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/settings/properties/SharedProperties.java b/termux/termux-shared/src/main/java/com/termux/shared/settings/properties/SharedProperties.java
new file mode 100644
index 000000000..d2efd0b4e
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/settings/properties/SharedProperties.java
@@ -0,0 +1,645 @@
+package com.termux.shared.settings.properties;
+
+import android.content.Context;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.google.common.collect.BiMap;
+import com.google.common.collect.ImmutableBiMap;
+import com.google.common.primitives.Primitives;
+import com.termux.shared.file.FileUtils;
+import com.termux.shared.file.filesystem.FileType;
+import com.termux.shared.logger.Logger;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Properties;
+import java.util.Set;
+
+/**
+ * An implementation similar to android's {@link android.content.SharedPreferences} interface for
+ * reading and writing to and from ".properties" files which also maintains an in-memory cache for
+ * the key/value pairs when an instance object is used. Operations are done under
+ * synchronization locks and should be thread safe.
+ *
+ * If {@link SharedProperties} instance object is used, then two types of in-memory cache maps are
+ * maintained, one for the literal {@link String} values found in the file for the keys and an
+ * additional one that stores (near) primitive {@link Object} values for internal use by the caller.
+ *
+ * The {@link SharedProperties} also provides static functions that can be used to read properties
+ * from files or individual key values or even their internal values. An automatic mapping to a
+ * boolean as internal value can also be done. An in-memory cache is not maintained, nor are locks used.
+ *
+ * This currently only has read support, write support can/will be added later if needed. Check android's
+ * SharedPreferencesImpl class for reference implementation.
+ *
+ * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r3:frameworks/base/core/java/android/app/SharedPreferencesImpl.java
+ */
+public class SharedProperties {
+
+ /**
+ * The {@link Properties} object that maintains an in-memory cache of values loaded from the
+ * {@link #mPropertiesFile} file. The key/value pairs are of any keys that are found in the file
+ * against their literal values in the file.
+ */
+ private Properties mProperties;
+
+ /**
+ * The {@link HashMap<>} object that maintains an in-memory cache of internal values for the values
+ * loaded from the {@link #mPropertiesFile} file. The key/value pairs are of any keys defined by
+ * {@link #mPropertiesList} that are found in the file against their internal {@link Object} values
+ * returned by the call to
+ * {@link SharedPropertiesParser#getInternalPropertyValueFromValue(Context, String, String)} interface.
+ */
+ private Map mMap;
+
+ private final Context mContext;
+ private final File mPropertiesFile;
+ private final Set mPropertiesList;
+ private final SharedPropertiesParser mSharedPropertiesParser;
+
+ private final Object mLock = new Object();
+
+ /** Defines the bidirectional map for boolean values and their internal values */
+ public static final ImmutableBiMap MAP_GENERIC_BOOLEAN =
+ new ImmutableBiMap.Builder()
+ .put("true", true)
+ .put("false", false)
+ .build();
+
+ /** Defines the bidirectional map for inverted boolean values and their internal values */
+ public static final ImmutableBiMap MAP_GENERIC_INVERTED_BOOLEAN =
+ new ImmutableBiMap.Builder()
+ .put("true", false)
+ .put("false", true)
+ .build();
+
+ private static final String LOG_TAG = "SharedProperties";
+
+ /**
+ * Constructor for the SharedProperties class.
+ *
+ * @param context The Context for operations.
+ * @param propertiesFile The {@link File} object to load properties from.
+ * @param propertiesList The {@link Set} object that defined which properties to load.
+ * If this is set to {@code null}, then all properties that exist in
+ * {@code propertiesFile} will be read by {@link #loadPropertiesFromDisk()}
+ * @param sharedPropertiesParser The implementation of the {@link SharedPropertiesParser} interface.
+ */
+ public SharedProperties(@NonNull Context context, @Nullable File propertiesFile, Set propertiesList, @NonNull SharedPropertiesParser sharedPropertiesParser) {
+ mContext = context.getApplicationContext();
+ mPropertiesFile = propertiesFile;
+ mPropertiesList = propertiesList;
+ mSharedPropertiesParser = sharedPropertiesParser;
+
+ mProperties = new Properties();
+ mMap = new HashMap<>();
+ }
+
+ /**
+ * Load the properties defined by {@link #mPropertiesList} or all properties if its {@code null}
+ * from the {@link #mPropertiesFile} file to update the {@link #mProperties} and {@link #mMap}
+ * in-memory cache.
+ * Properties are not loading automatically when constructor is called and must be manually called.
+ */
+ public void loadPropertiesFromDisk() {
+ synchronized (mLock) {
+ // Get properties from mPropertiesFile
+ Properties properties = getProperties(false);
+
+ // We still need to load default values into mMap, so we assume no properties defined if
+ // reading from mPropertiesFile failed
+ if (properties == null)
+ properties = new Properties();
+
+ HashMap map = new HashMap<>();
+ Properties newProperties = new Properties();
+
+ Set propertiesList = mPropertiesList;
+ if (propertiesList == null)
+ propertiesList = properties.stringPropertyNames();
+
+ String value;
+ Object internalValue;
+ for (String key : propertiesList) {
+ value = properties.getProperty(key); // value will be null if key does not exist in propertiesFile
+ // Logger.logVerbose(LOG_TAG, key + " : " + value);
+
+ // Call the {@link SharedPropertiesParser#getInternalPropertyValueFromValue(Context,String,String)}
+ // interface method to get the internal value to store in the {@link #mMap}.
+ internalValue = mSharedPropertiesParser.getInternalPropertyValueFromValue(mContext, key, value);
+
+ // If the internal value was successfully added to map, then also add value to newProperties
+ // We only store values in-memory defined by propertiesList
+ if (putToMap(map, key, internalValue)) { // null internalValue will be put into map
+ putToProperties(newProperties, key, value); // null value will **not** be into properties
+ }
+ }
+
+ mMap = map;
+ mProperties = newProperties;
+ }
+ }
+
+ /**
+ * Get the {@link Properties} object for the {@link #mPropertiesFile}. The {@link Properties}
+ * object will also contain properties not defined by the {@link #mPropertiesList} if cache
+ * value is {@code false}.
+ *
+ * @param cached If {@code true}, then the {@link #mProperties} in-memory cache is returned. Otherwise
+ * the {@link Properties} object is directly read from the {@link #mPropertiesFile}.
+ * @return Returns the {@link Properties} object if read from file, otherwise a copy of {@link #mProperties}.
+ */
+ public Properties getProperties(boolean cached) {
+ synchronized (mLock) {
+ if (cached) {
+ if (mProperties == null) mProperties = new Properties();
+ return getPropertiesCopy(mProperties);
+ } else {
+ return getPropertiesFromFile(mContext, mPropertiesFile, mSharedPropertiesParser);
+ }
+ }
+ }
+
+ /**
+ * Get the {@link String} value for the key passed from the {@link #mPropertiesFile}.
+ *
+ * @param key The key to read from the {@link Properties} object.
+ * @param cached If {@code true}, then the value is returned from the {@link #mProperties} in-memory cache.
+ * Otherwise the {@link Properties} object is read directly from the {@link #mPropertiesFile}
+ * and value is returned from it against the key.
+ * @return Returns the {@link String} object. This will be {@code null} if key is not found.
+ */
+ public String getProperty(String key, boolean cached) {
+ synchronized (mLock) {
+ return (String) getProperties(cached).get(key);
+ }
+ }
+
+ /**
+ * Get the {@link #mMap} object for the {@link #mPropertiesFile}. A call to
+ * {@link #loadPropertiesFromDisk()} must be made before this.
+ *
+ * @return Returns a copy of {@link #mMap} object.
+ */
+ public Map getInternalProperties() {
+ synchronized (mLock) {
+ if (mMap == null) mMap = new HashMap<>();
+ return getMapCopy(mMap);
+ }
+ }
+
+ /**
+ * Get the internal {@link Object} value for the key passed from the {@link #mPropertiesFile}.
+ * The value is returned from the {@link #mMap} in-memory cache, so a call to
+ * {@link #loadPropertiesFromDisk()} must be made before this.
+ *
+ * @param key The key to read from the {@link #mMap} object.
+ * @return Returns the {@link Object} object. This will be {@code null} if key is not found or
+ * if object was {@code null}. Use {@link HashMap#containsKey(Object)} to detect the later.
+ * situation.
+ */
+ public Object getInternalProperty(String key) {
+ synchronized (mLock) {
+ // null keys are not allowed to be stored in mMap
+ if (key != null)
+ return getInternalProperties().get(key);
+ else
+ return null;
+ }
+ }
+
+
+
+
+
+ /**
+ * A static function to get the {@link Properties} object for the propertiesFile. A lock is not
+ * taken when this function is called.
+ *
+ * @param context The {@link Context} to use to show a flash if an exception is raised while
+ * reading the file. If context is {@code null}, then flash will not be shown.
+ * @param propertiesFile The {@link File} to read the {@link Properties} from.
+ * @return Returns the {@link Properties} object. It will be {@code null} if an exception is
+ * raised while reading the file.
+ */
+ public static Properties getPropertiesFromFile(Context context, File propertiesFile, @Nullable SharedPropertiesParser sharedPropertiesParser) {
+ Properties properties = new Properties();
+
+ if (propertiesFile == null) {
+ Logger.logWarn(LOG_TAG, "Not loading properties since file is null");
+ return properties;
+ }
+
+ try {
+ try (FileInputStream in = new FileInputStream(propertiesFile)) {
+ Logger.logVerbose(LOG_TAG, "Loading properties from \"" + propertiesFile.getAbsolutePath() + "\" file");
+ properties.load(new InputStreamReader(in, StandardCharsets.UTF_8));
+ }
+ } catch (Exception e) {
+ if (context != null)
+ Toast.makeText(context, "Could not open properties file \"" + propertiesFile.getAbsolutePath() + "\": " + e.getMessage(), Toast.LENGTH_LONG).show();
+ Logger.logStackTraceWithMessage(LOG_TAG, "Error loading properties file \"" + propertiesFile.getAbsolutePath() + "\"", e);
+ return null;
+ }
+
+ if (sharedPropertiesParser != null && context != null)
+ return sharedPropertiesParser.preProcessPropertiesOnReadFromDisk(context, properties);
+ else
+ return properties;
+ }
+
+ /** Returns the first {@link File} found in
+ * {@code propertiesFilePaths} from which app properties can be loaded. If the {@link File} found
+ * is not a regular file or is not readable, then {@code null} is returned. Symlinks **will not**
+ * be followed for potential security reasons.
+ *
+ * @param propertiesFilePaths The {@link List} containing properties file paths.
+ * @param logTag If log tag to use for logging errors.
+ * @return Returns the {@link File} object for Termux:Float app properties.
+ */
+ public static File getPropertiesFileFromList(List propertiesFilePaths, @NonNull String logTag) {
+ if (propertiesFilePaths == null || propertiesFilePaths.size() == 0)
+ return null;
+
+ for(String propertiesFilePath : propertiesFilePaths) {
+ File propertiesFile = new File(propertiesFilePath);
+
+ // Symlinks **will not** be followed.
+ FileType fileType = FileUtils.getFileType(propertiesFilePath, false);
+ if (fileType == FileType.REGULAR) {
+ if (propertiesFile.canRead())
+ return propertiesFile;
+ else
+ Logger.logWarn(logTag, "Ignoring properties file at \"" + propertiesFilePath + "\" since it is not readable");
+ } else if (fileType != FileType.NO_EXIST) {
+ Logger.logWarn(logTag, "Ignoring properties file at \"" + propertiesFilePath + "\" of type: \"" + fileType.getName() + "\"");
+ }
+ }
+
+ Logger.logDebug(logTag, "No readable properties file found at: " + propertiesFilePaths);
+ return null;
+ }
+
+
+
+ public static String getProperty(Context context, File propertiesFile, String key, String def) {
+ return getProperty(context, propertiesFile, key, def, null);
+ }
+
+ /**
+ * A static function to get the {@link String} value for the {@link Properties} key read from
+ * the propertiesFile file.
+ *
+ * @param context The {@link Context} for the {@link #getPropertiesFromFile(Context,File,SharedPropertiesParser)} call.
+ * @param propertiesFile The {@link File} to read the {@link Properties} from.
+ * @param key The key to read.
+ * @param def The default value.
+ * @param sharedPropertiesParser The implementation of the {@link SharedPropertiesParser} interface.
+ * @return Returns the {@link String} object. This will be {@code null} if key is not found.
+ */
+ public static String getProperty(Context context, File propertiesFile, String key, String def, @Nullable SharedPropertiesParser sharedPropertiesParser) {
+ return (String) getDefaultIfNull(getDefaultIfNull(getPropertiesFromFile(context, propertiesFile, sharedPropertiesParser), new Properties()).get(key), def);
+ }
+
+ /**
+ * A static function to get the internal {@link Object} value for the {@link String} value for
+ * the {@link Properties} key read from the propertiesFile file.
+ *
+ * @param context The {@link Context} for the {@link #getPropertiesFromFile(Context,File,SharedPropertiesParser)} call.
+ * @param propertiesFile The {@link File} to read the {@link Properties} from.
+ * @param key The key to read.
+ * @param sharedPropertiesParser The implementation of the {@link SharedPropertiesParser} interface.
+ * @return Returns the {@link String} Object returned by the call to
+ * {@link SharedPropertiesParser#getInternalPropertyValueFromValue(Context,String,String)}.
+ */
+ public static Object getInternalProperty(Context context, File propertiesFile, String key, @NonNull SharedPropertiesParser sharedPropertiesParser) {
+ String value = (String) getDefaultIfNull(getPropertiesFromFile(context, propertiesFile, sharedPropertiesParser), new Properties()).get(key);
+
+ // Call the {@link SharedPropertiesParser#getInternalPropertyValueFromValue(Context,String,String)}
+ // interface method to get the internal value to return.
+ return sharedPropertiesParser.getInternalPropertyValueFromValue(context, key, value);
+ }
+
+
+ public static boolean isPropertyValueTrue(Context context, File propertiesFile, String key, boolean logErrorOnInvalidValue) {
+ return isPropertyValueTrue(context, propertiesFile, key, logErrorOnInvalidValue, null);
+ }
+
+ /**
+ * A static function to check if the value is {@code true} for {@link Properties} key read from
+ * the propertiesFile file.
+ *
+ * @param context The {@link Context} for the {@link #getPropertiesFromFile(Context,File,SharedPropertiesParser)} call.
+ * @param propertiesFile The {@link File} to read the {@link Properties} from.
+ * @param key The key to read.
+ * @param logErrorOnInvalidValue If {@code true}, then an error will be logged if key value
+ * was found in {@link Properties} but was invalid.
+ * @param sharedPropertiesParser The implementation of the {@link SharedPropertiesParser} interface.
+ * @return Returns the {@code true} if the {@link Properties} key {@link String} value equals "true",
+ * regardless of case. If the key does not exist in the file or does not equal "true", then
+ * {@code false} will be returned.
+ */
+ public static boolean isPropertyValueTrue(Context context, File propertiesFile, String key, boolean logErrorOnInvalidValue, @Nullable SharedPropertiesParser sharedPropertiesParser) {
+ return (boolean) getBooleanValueForStringValue(key, (String) getProperty(context, propertiesFile, key, null, sharedPropertiesParser), false, logErrorOnInvalidValue, LOG_TAG);
+ }
+
+
+ public static boolean isPropertyValueFalse(Context context, File propertiesFile, String key, boolean logErrorOnInvalidValue) {
+ return isPropertyValueFalse(context, propertiesFile, key, logErrorOnInvalidValue, null);
+ }
+
+ /**
+ * A static function to check if the value is {@code false} for {@link Properties} key read from
+ * the propertiesFile file.
+ *
+ * @param context The {@link Context} for the {@link #getPropertiesFromFile(Context,File,SharedPropertiesParser)} call.
+ * @param propertiesFile The {@link File} to read the {@link Properties} from.
+ * @param key The key to read.
+ * @param logErrorOnInvalidValue If {@code true}, then an error will be logged if key value
+ * was found in {@link Properties} but was invalid.
+ * @param sharedPropertiesParser The implementation of the {@link SharedPropertiesParser} interface.
+ * @return Returns the {@code true} if the {@link Properties} key {@link String} value equals "false",
+ * regardless of case. If the key does not exist in the file or does not equal "false", then
+ * {@code true} will be returned.
+ */
+ public static boolean isPropertyValueFalse(Context context, File propertiesFile, String key, boolean logErrorOnInvalidValue, @Nullable SharedPropertiesParser sharedPropertiesParser) {
+ return (boolean) getInvertedBooleanValueForStringValue(key, (String) getProperty(context, propertiesFile, key, null, sharedPropertiesParser), true, logErrorOnInvalidValue, LOG_TAG);
+ }
+
+
+
+
+
+ /**
+ * Put a value in a {@link #mMap}.
+ * The key cannot be {@code null}.
+ * Only {@code null}, primitive or their wrapper classes or String class objects are allowed to be added to
+ * the map, although this limitation may be changed.
+ *
+ * @param map The {@link Map} object to add value to.
+ * @param key The key for which to add the value to the map.
+ * @param value The {@link Object} to add to the map.
+ * @return Returns {@code true} if value was successfully added, otherwise {@code false}.
+ */
+ public static boolean putToMap(HashMap map, String key, Object value) {
+
+ if (map == null) {
+ Logger.logError(LOG_TAG, "Map passed to SharedProperties.putToProperties() is null");
+ return false;
+ }
+
+ // null keys are not allowed to be stored in mMap
+ if (key == null) {
+ Logger.logError(LOG_TAG, "Cannot put a null key into properties map");
+ return false;
+ }
+
+ boolean put = false;
+ if (value != null) {
+ Class> clazz = value.getClass();
+ if (clazz.isPrimitive() || Primitives.isWrapperType(clazz) || value instanceof String) {
+ put = true;
+ }
+ } else {
+ put = true;
+ }
+
+ if (put) {
+ map.put(key, value);
+ return true;
+ } else {
+ Logger.logError(LOG_TAG, "Cannot put a non-primitive value for the key \"" + key + "\" into properties map");
+ return false;
+ }
+ }
+
+ /**
+ * Put a value in a {@link Map}.
+ * The key cannot be {@code null}.
+ * Passing {@code null} as the value argument is equivalent to removing the key from the
+ * properties.
+ *
+ * @param properties The {@link Properties} object to add value to.
+ * @param key The key for which to add the value to the properties.
+ * @param value The {@link String} to add to the properties.
+ * @return Returns {@code true} if value was successfully added, otherwise {@code false}.
+ */
+ public static boolean putToProperties(Properties properties, String key, String value) {
+
+ if (properties == null) {
+ Logger.logError(LOG_TAG, "Properties passed to SharedProperties.putToProperties() is null");
+ return false;
+ }
+
+ // null keys are not allowed to be stored in mMap
+ if (key == null) {
+ Logger.logError(LOG_TAG, "Cannot put a null key into properties");
+ return false;
+ }
+
+ if (value != null) {
+ properties.put(key, value);
+ return true;
+ } else {
+ properties.remove(key);
+ }
+
+ return true;
+ }
+
+ public static Properties getPropertiesCopy(Properties inputProperties) {
+ if (inputProperties == null) return null;
+
+ Properties outputProperties = new Properties();
+ for (String key : inputProperties.stringPropertyNames()) {
+ outputProperties.put(key, inputProperties.get(key));
+ }
+
+ return outputProperties;
+ }
+
+ public static Map getMapCopy(Map map) {
+ if (map == null) return null;
+ return new HashMap<>(map);
+ }
+
+
+
+
+
+
+ /**
+ * Get the boolean value for the {@link String} value.
+ *
+ * @param value The {@link String} value to convert.
+ * @return Returns {@code true} or {@code false} if value is the literal string "true" or "false" respectively,
+ * regardless of case. Otherwise returns {@code null}.
+ */
+ public static Boolean getBooleanValueForStringValue(String value) {
+ return MAP_GENERIC_BOOLEAN.get(toLowerCase(value));
+ }
+
+ /**
+ * Get the boolean value for the {@link String} value.
+ *
+ * @param value The {@link String} value to convert.
+ * @param def The default {@link boolean} value to return.
+ * @param logErrorOnInvalidValue If {@code true}, then an error will be logged if {@code value}
+ * was not {@code null} and was invalid.
+ * @param logTag If log tag to use for logging errors.
+ * @return Returns {@code true} or {@code false} if value is the literal string "true" or "false" respectively,
+ * regardless of case. Otherwise returns default value.
+ */
+ public static boolean getBooleanValueForStringValue(String key, String value, boolean def, boolean logErrorOnInvalidValue, String logTag) {
+ return (boolean) getDefaultIfNotInMap(key, MAP_GENERIC_BOOLEAN, toLowerCase(value), def, logErrorOnInvalidValue, logTag);
+ }
+
+ /**
+ * Get the inverted boolean value for the {@link String} value.
+ *
+ * @param value The {@link String} value to convert.
+ * @param def The default {@link boolean} value to return.
+ * @param logErrorOnInvalidValue If {@code true}, then an error will be logged if {@code value}
+ * was not {@code null} and was invalid.
+ * @param logTag If log tag to use for logging errors.
+ * @return Returns {@code true} or {@code false} if value is the literal string "false" or "true" respectively,
+ * regardless of case. Otherwise returns default value.
+ */
+ public static boolean getInvertedBooleanValueForStringValue(String key, String value, boolean def, boolean logErrorOnInvalidValue, String logTag) {
+ return (boolean) getDefaultIfNotInMap(key, MAP_GENERIC_INVERTED_BOOLEAN, toLowerCase(value), def, logErrorOnInvalidValue, logTag);
+ }
+
+ /**
+ * Get the value for the {@code inputValue} {@link Object} key from a {@link BiMap<>}, otherwise
+ * default value if key not found in {@code map}.
+ *
+ * @param key The shared properties {@link String} key value for which the value is being returned.
+ * @param map The {@link BiMap<>} value to get the value from.
+ * @param inputValue The {@link Object} key value of the map.
+ * @param defaultOutputValue The default {@link boolean} value to return if {@code inputValue} not found in map.
+ * The default value must exist as a value in the {@link BiMap<>} passed.
+ * @param logErrorOnInvalidValue If {@code true}, then an error will be logged if {@code inputValue}
+ * was not {@code null} and was not found in the map.
+ * @param logTag If log tag to use for logging errors.
+ * @return Returns the value for the {@code inputValue} key from the map if it exists. Otherwise
+ * returns default value.
+ */
+ public static Object getDefaultIfNotInMap(String key, @NonNull BiMap, ?> map, Object inputValue, Object defaultOutputValue, boolean logErrorOnInvalidValue, String logTag) {
+ Object outputValue = map.get(inputValue);
+ if (outputValue == null) {
+ Object defaultInputValue = map.inverse().get(defaultOutputValue);
+ if (defaultInputValue == null)
+ Logger.logError(LOG_TAG, "The default output value \"" + defaultOutputValue + "\" for the key \"" + key + "\" does not exist as a value in the BiMap passed to getDefaultIfNotInMap(): " + map.values());
+
+ if (logErrorOnInvalidValue && inputValue != null) {
+ if (key != null)
+ Logger.logError(logTag, "The value \"" + inputValue + "\" for the key \"" + key + "\" is invalid. Using default value \"" + defaultInputValue + "\" instead.");
+ else
+ Logger.logError(logTag, "The value \"" + inputValue + "\" is invalid. Using default value \"" + defaultInputValue + "\" instead.");
+ }
+
+ return defaultOutputValue;
+ } else {
+ return outputValue;
+ }
+ }
+
+ /**
+ * Get the {@code int} {@code value} as is if between {@code min} and {@code max} (inclusive), otherwise
+ * return default value.
+ *
+ * @param key The shared properties {@link String} key value for which the value is being returned.
+ * @param value The {@code int} value to check.
+ * @param def The default {@code int} value if {@code value} not in range.
+ * @param min The min allowed {@code int} value.
+ * @param max The max allowed {@code int} value.
+ * @param logErrorOnInvalidValue If {@code true}, then an error will be logged if {@code value}
+ * not in range.
+ * @param ignoreErrorIfValueZero If logging error should be ignored if value equals 0.
+ * @param logTag If log tag to use for logging errors.
+ * @return Returns the {@code value} as is if within range. Otherwise returns default value.
+ */
+ public static int getDefaultIfNotInRange(String key, int value, int def, int min, int max, boolean logErrorOnInvalidValue, boolean ignoreErrorIfValueZero, String logTag) {
+ if (value < min || value > max) {
+ if (logErrorOnInvalidValue && (!ignoreErrorIfValueZero || value != 0)) {
+ if (key != null)
+ Logger.logError(logTag, "The value \"" + value + "\" for the key \"" + key + "\" is not within the range " + min + "-" + max + " (inclusive). Using default value \"" + def + "\" instead.");
+ else
+ Logger.logError(logTag, "The value \"" + value + "\" is not within the range " + min + "-" + max + " (inclusive). Using default value \"" + def + "\" instead.");
+ }
+ return def;
+ } else {
+ return value;
+ }
+ }
+
+ /**
+ * Get the {@code float} {@code value} as is if between {@code min} and {@code max} (inclusive), otherwise
+ * return default value.
+ *
+ * @param key The shared properties {@link String} key value for which the value is being returned.
+ * @param value The {@code float} value to check.
+ * @param def The default {@code float} value if {@code value} not in range.
+ * @param min The min allowed {@code float} value.
+ * @param max The max allowed {@code float} value.
+ * @param logErrorOnInvalidValue If {@code true}, then an error will be logged if {@code value}
+ * not in range.
+ * @param ignoreErrorIfValueZero If logging error should be ignored if value equals 0.
+ * @param logTag If log tag to use for logging errors.
+ * @return Returns the {@code value} as is if within range. Otherwise returns default value.
+ */
+ public static float getDefaultIfNotInRange(String key, float value, float def, float min, float max, boolean logErrorOnInvalidValue, boolean ignoreErrorIfValueZero, String logTag) {
+ if (value < min || value > max) {
+ if (logErrorOnInvalidValue && (!ignoreErrorIfValueZero || value != 0)) {
+ if (key != null)
+ Logger.logError(logTag, "The value \"" + value + "\" for the key \"" + key + "\" is not within the range " + min + "-" + max + " (inclusive). Using default value \"" + def + "\" instead.");
+ else
+ Logger.logError(logTag, "The value \"" + value + "\" is not within the range " + min + "-" + max + " (inclusive). Using default value \"" + def + "\" instead.");
+ }
+ return def;
+ } else {
+ return value;
+ }
+ }
+
+ /**
+ * Get the object itself if it is not {@code null}, otherwise default.
+ *
+ * @param object The {@link Object} to check.
+ * @param def The default {@link Object}.
+ * @return Returns {@code object} if it is not {@code null}, otherwise returns {@code def}.
+ */
+ public static T getDefaultIfNull(@Nullable T object, @Nullable T def) {
+ return (object == null) ? def : object;
+ }
+
+ /**
+ * Get the {@link String} object itself if it is not {@code null} or empty, otherwise default.
+ *
+ * @param object The {@link String} to check.
+ * @param def The default {@link String}.
+ * @return Returns {@code object} if it is not {@code null}, otherwise returns {@code def}.
+ */
+ public static String getDefaultIfNullOrEmpty(@Nullable String object, @Nullable String def) {
+ return (object == null || object.isEmpty()) ? def : object;
+ }
+
+ /**
+ * Covert the {@link String} value to lowercase.
+ *
+ * @param value The {@link String} value to convert.
+ * @return Returns the lowercased value.
+ */
+ public static String toLowerCase(String value) {
+ if (value == null) return null; else return value.toLowerCase();
+ }
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/settings/properties/SharedPropertiesParser.java b/termux/termux-shared/src/main/java/com/termux/shared/settings/properties/SharedPropertiesParser.java
new file mode 100644
index 000000000..1e6c67f22
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/settings/properties/SharedPropertiesParser.java
@@ -0,0 +1,37 @@
+package com.termux.shared.settings.properties;
+
+import android.content.Context;
+
+import androidx.annotation.NonNull;
+
+import java.util.HashMap;
+import java.util.Properties;
+
+/**
+ * An interface that must be defined by the caller of the {@link SharedProperties} class.
+ */
+public interface SharedPropertiesParser {
+
+ /**
+ * Called when properties are loaded from file to allow client to update the {@link Properties}
+ * loaded from properties file before key/value pairs are stored in the {@link HashMap <>} in-memory
+ * cache.
+ *
+ * @param context The context for operations.
+ * @param properties The key for which the internal object is required.
+ */
+ @NonNull
+ Properties preProcessPropertiesOnReadFromDisk(@NonNull Context context, @NonNull Properties properties);
+
+ /**
+ * A function that should return the internal {@link Object} to be stored for a key/value pair
+ * read from properties file in the {@link HashMap <>} in-memory cache.
+ *
+ * @param context The context for operations.
+ * @param key The key for which the internal object is required.
+ * @param value The literal value for the property found is the properties file.
+ * @return Returns the {@link Object} object to store in the {@link HashMap <>} in-memory cache.
+ */
+ Object getInternalPropertyValueFromValue(@NonNull Context context, String key, String value);
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/shell/ArgumentTokenizer.java b/termux/termux-shared/src/main/java/com/termux/shared/shell/ArgumentTokenizer.java
new file mode 100644
index 000000000..26af2e832
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/shell/ArgumentTokenizer.java
@@ -0,0 +1,229 @@
+/*BEGIN_COPYRIGHT_BLOCK
+ *
+ * Copyright (c) 2001-2010, JavaPLT group at Rice University (drjava@rice.edu)
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ * * Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ * * Redistributions in binary form must reproduce the above copyright
+ * notice, this list of conditions and the following disclaimer in the
+ * documentation and/or other materials provided with the distribution.
+ * * Neither the names of DrJava, the JavaPLT group, Rice University, nor the
+ * names of its contributors may be used to endorse or promote products
+ * derived from this software without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
+ * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+ * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+ * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+ * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+ * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+ * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ *
+ * This software is Open Source Initiative approved Open Source Software.
+ * Open Source Initative Approved is a trademark of the Open Source Initiative.
+ *
+ * This file is part of DrJava. Download the current version of this project
+ * from http://www.drjava.org/ or http://sourceforge.net/projects/drjava/
+ *
+ * END_COPYRIGHT_BLOCK*/
+
+package com.termux.shared.shell;
+
+import java.util.List;
+import java.util.LinkedList;
+
+/**
+ * Utility class which can tokenize a String into a list of String arguments,
+ * with behavior similar to parsing command line arguments to a program.
+ * Quoted Strings are treated as single arguments, and escaped characters
+ * are translated so that the tokenized arguments have the same meaning.
+ * Since all methods are static, the class is declared abstract to prevent
+ * instantiation.
+ * @version $Id$
+ */
+public abstract class ArgumentTokenizer {
+ private static final int NO_TOKEN_STATE = 0;
+ private static final int NORMAL_TOKEN_STATE = 1;
+ private static final int SINGLE_QUOTE_STATE = 2;
+ private static final int DOUBLE_QUOTE_STATE = 3;
+
+ /** Tokenizes the given String into String tokens
+ * @param arguments A String containing one or more command-line style arguments to be tokenized.
+ * @return A list of parsed and properly escaped arguments.
+ */
+ public static List tokenize(String arguments) {
+ return tokenize(arguments, false);
+ }
+
+ /** Tokenizes the given String into String tokens.
+ * @param arguments A String containing one or more command-line style arguments to be tokenized.
+ * @param stringify whether or not to include escape special characters
+ * @return A list of parsed and properly escaped arguments.
+ */
+ public static List tokenize(String arguments, boolean stringify) {
+
+ LinkedList argList = new LinkedList();
+ StringBuilder currArg = new StringBuilder();
+ boolean escaped = false;
+ int state = NO_TOKEN_STATE; // start in the NO_TOKEN_STATE
+ int len = arguments.length();
+
+ // Loop over each character in the string
+ for (int i = 0; i < len; i++) {
+ char c = arguments.charAt(i);
+ if (escaped) {
+ // Escaped state: just append the next character to the current arg.
+ escaped = false;
+ currArg.append(c);
+ }
+ else {
+ switch(state) {
+ case SINGLE_QUOTE_STATE:
+ if (c == '\'') {
+ // Seen the close quote; continue this arg until whitespace is seen
+ state = NORMAL_TOKEN_STATE;
+ }
+ else {
+ currArg.append(c);
+ }
+ break;
+ case DOUBLE_QUOTE_STATE:
+ if (c == '"') {
+ // Seen the close quote; continue this arg until whitespace is seen
+ state = NORMAL_TOKEN_STATE;
+ }
+ else if (c == '\\') {
+ // Look ahead, and only escape quotes or backslashes
+ i++;
+ char next = arguments.charAt(i);
+ if (next == '"' || next == '\\') {
+ currArg.append(next);
+ }
+ else {
+ currArg.append(c);
+ currArg.append(next);
+ }
+ }
+ else {
+ currArg.append(c);
+ }
+ break;
+// case NORMAL_TOKEN_STATE:
+// if (Character.isWhitespace(c)) {
+// // Whitespace ends the token; start a new one
+// argList.add(currArg.toString());
+// currArg = new StringBuffer();
+// state = NO_TOKEN_STATE;
+// }
+// else if (c == '\\') {
+// // Backslash in a normal token: escape the next character
+// escaped = true;
+// }
+// else if (c == '\'') {
+// state = SINGLE_QUOTE_STATE;
+// }
+// else if (c == '"') {
+// state = DOUBLE_QUOTE_STATE;
+// }
+// else {
+// currArg.append(c);
+// }
+// break;
+ case NO_TOKEN_STATE:
+ case NORMAL_TOKEN_STATE:
+ switch(c) {
+ case '\\':
+ escaped = true;
+ state = NORMAL_TOKEN_STATE;
+ break;
+ case '\'':
+ state = SINGLE_QUOTE_STATE;
+ break;
+ case '"':
+ state = DOUBLE_QUOTE_STATE;
+ break;
+ default:
+ if (!Character.isWhitespace(c)) {
+ currArg.append(c);
+ state = NORMAL_TOKEN_STATE;
+ }
+ else if (state == NORMAL_TOKEN_STATE) {
+ // Whitespace ends the token; start a new one
+ argList.add(currArg.toString());
+ currArg = new StringBuilder();
+ state = NO_TOKEN_STATE;
+ }
+ }
+ break;
+ default:
+ throw new IllegalStateException("ArgumentTokenizer state " + state + " is invalid!");
+ }
+ }
+ }
+
+ // If we're still escaped, put in the backslash
+ if (escaped) {
+ currArg.append('\\');
+ argList.add(currArg.toString());
+ }
+ // Close the last argument if we haven't yet
+ else if (state != NO_TOKEN_STATE) {
+ argList.add(currArg.toString());
+ }
+ // Format each argument if we've been told to stringify them
+ if (stringify) {
+ for (int i = 0; i < argList.size(); i++) {
+ argList.set(i, "\"" + _escapeQuotesAndBackslashes(argList.get(i)) + "\"");
+ }
+ }
+ return argList;
+ }
+
+ /** Inserts backslashes before any occurrences of a backslash or
+ * quote in the given string. Also converts any special characters
+ * appropriately.
+ */
+ protected static String _escapeQuotesAndBackslashes(String s) {
+ final StringBuilder buf = new StringBuilder(s);
+
+ // Walk backwards, looking for quotes or backslashes.
+ // If we see any, insert an extra backslash into the buffer at
+ // the same index. (By walking backwards, the index into the buffer
+ // will remain correct as we change the buffer.)
+ for (int i = s.length()-1; i >= 0; i--) {
+ char c = s.charAt(i);
+ if ((c == '\\') || (c == '"')) {
+ buf.insert(i, '\\');
+ }
+ // Replace any special characters with escaped versions
+ else if (c == '\n') {
+ buf.deleteCharAt(i);
+ buf.insert(i, "\\n");
+ }
+ else if (c == '\t') {
+ buf.deleteCharAt(i);
+ buf.insert(i, "\\t");
+ }
+ else if (c == '\r') {
+ buf.deleteCharAt(i);
+ buf.insert(i, "\\r");
+ }
+ else if (c == '\b') {
+ buf.deleteCharAt(i);
+ buf.insert(i, "\\b");
+ }
+ else if (c == '\f') {
+ buf.deleteCharAt(i);
+ buf.insert(i, "\\f");
+ }
+ }
+ return buf.toString();
+ }
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/shell/ShellUtils.java b/termux/termux-shared/src/main/java/com/termux/shared/shell/ShellUtils.java
new file mode 100644
index 000000000..5c3ad5760
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/shell/ShellUtils.java
@@ -0,0 +1,76 @@
+package com.termux.shared.shell;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.termux.shared.file.FileUtils;
+import com.termux.terminal.TerminalBuffer;
+import com.termux.terminal.TerminalEmulator;
+import com.termux.terminal.TerminalSession;
+
+import java.lang.reflect.Field;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+public class ShellUtils {
+
+ /** Get process id of {@link Process}. */
+ public static int getPid(Process p) {
+ try {
+ Field f = p.getClass().getDeclaredField("pid");
+ f.setAccessible(true);
+ try {
+ return f.getInt(p);
+ } finally {
+ f.setAccessible(false);
+ }
+ } catch (Throwable e) {
+ return -1;
+ }
+ }
+
+ /** Setup shell command arguments for the execute. */
+ @NonNull
+ public static String[] setupShellCommandArguments(@NonNull String executable, @Nullable String[] arguments) {
+ List result = new ArrayList<>();
+ result.add(executable);
+ if (arguments != null) Collections.addAll(result, arguments);
+ return result.toArray(new String[0]);
+ }
+
+ /** Get basename for executable. */
+ @Nullable
+ public static String getExecutableBasename(@Nullable String executable) {
+ return FileUtils.getFileBasename(executable);
+ }
+
+
+
+ /** Get transcript for {@link TerminalSession}. */
+ public static String getTerminalSessionTranscriptText(TerminalSession terminalSession, boolean linesJoined, boolean trim) {
+ if (terminalSession == null) return null;
+
+ TerminalEmulator terminalEmulator = terminalSession.getEmulator();
+ if (terminalEmulator == null) return null;
+
+ TerminalBuffer terminalBuffer = terminalEmulator.getScreen();
+ if (terminalBuffer == null) return null;
+
+ String transcriptText;
+
+ if (linesJoined)
+ transcriptText = terminalBuffer.getTranscriptTextWithFullLinesJoined();
+ else
+ transcriptText = terminalBuffer.getTranscriptTextWithoutJoinedLines();
+
+ if (transcriptText == null) return null;
+
+ if (trim)
+ transcriptText = transcriptText.trim();
+
+ return transcriptText;
+ }
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/shell/StreamGobbler.java b/termux/termux-shared/src/main/java/com/termux/shared/shell/StreamGobbler.java
new file mode 100644
index 000000000..04a5109da
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/shell/StreamGobbler.java
@@ -0,0 +1,325 @@
+/*
+ * Copyright (C) 2012-2019 Jorrit "Chainfire" Jongma
+ *
+ * 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.termux.shared.shell;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.util.List;
+import java.util.Locale;
+
+import androidx.annotation.AnyThread;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.WorkerThread;
+
+import com.termux.shared.logger.Logger;
+
+/**
+ * Thread utility class continuously reading from an InputStream
+ *
+ * https://github.com/Chainfire/libsuperuser/blob/1.1.0.201907261845/libsuperuser/src/eu/chainfire/libsuperuser/Shell.java#L141
+ * https://github.com/Chainfire/libsuperuser/blob/1.1.0.201907261845/libsuperuser/src/eu/chainfire/libsuperuser/StreamGobbler.java
+ */
+@SuppressWarnings({"WeakerAccess"})
+public class StreamGobbler extends Thread {
+ private static int threadCounter = 0;
+ private static int incThreadCounter() {
+ synchronized (StreamGobbler.class) {
+ int ret = threadCounter;
+ threadCounter++;
+ return ret;
+ }
+ }
+
+ /**
+ * Line callback interface
+ */
+ public interface OnLineListener {
+ /**
+ * Line callback
+ *
+ * This callback should process the line as quickly as possible.
+ * Delays in this callback may pause the native process or even
+ * result in a deadlock
+ *
+ * @param line String that was gobbled
+ */
+ void onLine(String line);
+ }
+
+ /**
+ * Stream closed callback interface
+ */
+ public interface OnStreamClosedListener {
+ /**
+ * Stream closed callback
+ */
+ void onStreamClosed();
+ }
+
+ @NonNull
+ private final String shell;
+ @NonNull
+ private final InputStream inputStream;
+ @NonNull
+ private final BufferedReader reader;
+ @Nullable
+ private final List listWriter;
+ @Nullable
+ private final StringBuilder stringWriter;
+ @Nullable
+ private final OnLineListener lineListener;
+ @Nullable
+ private final OnStreamClosedListener streamClosedListener;
+ @Nullable
+ private final Integer mLogLevel;
+ private volatile boolean active = true;
+ private volatile boolean calledOnClose = false;
+
+ private static final String LOG_TAG = "StreamGobbler";
+
+ /**
+ * StreamGobbler constructor
+ *
+ * We use this class because shell STDOUT and STDERR should be read as quickly as
+ * possible to prevent a deadlock from occurring, or Process.waitFor() never
+ * returning (as the buffer is full, pausing the native process)
+ *
+ * @param shell Name of the shell
+ * @param inputStream InputStream to read from
+ * @param outputList {@literal List} to write to, or null
+ * @param logLevel The custom log level to use for logging the command output. If set to
+ * {@code null}, then {@link Logger#LOG_LEVEL_VERBOSE} will be used.
+ */
+ @AnyThread
+ public StreamGobbler(@NonNull String shell, @NonNull InputStream inputStream,
+ @Nullable List outputList,
+ @Nullable Integer logLevel) {
+ super("Gobbler#" + incThreadCounter());
+ this.shell = shell;
+ this.inputStream = inputStream;
+ reader = new BufferedReader(new InputStreamReader(inputStream));
+ streamClosedListener = null;
+
+ listWriter = outputList;
+ stringWriter = null;
+ lineListener = null;
+
+ mLogLevel = logLevel;
+ }
+
+ /**
+ * StreamGobbler constructor
+ *
+ * We use this class because shell STDOUT and STDERR should be read as quickly as
+ * possible to prevent a deadlock from occurring, or Process.waitFor() never
+ * returning (as the buffer is full, pausing the native process)
+ * Do not use this for concurrent reading for STDOUT and STDERR for the same StringBuilder since
+ * its not synchronized.
+ *
+ * @param shell Name of the shell
+ * @param inputStream InputStream to read from
+ * @param outputString {@literal List} to write to, or null
+ * @param logLevel The custom log level to use for logging the command output. If set to
+ * {@code null}, then {@link Logger#LOG_LEVEL_VERBOSE} will be used.
+ */
+ @AnyThread
+ public StreamGobbler(@NonNull String shell, @NonNull InputStream inputStream,
+ @Nullable StringBuilder outputString,
+ @Nullable Integer logLevel) {
+ super("Gobbler#" + incThreadCounter());
+ this.shell = shell;
+ this.inputStream = inputStream;
+ reader = new BufferedReader(new InputStreamReader(inputStream));
+ streamClosedListener = null;
+
+ listWriter = null;
+ stringWriter = outputString;
+ lineListener = null;
+
+ mLogLevel = logLevel;
+ }
+
+ /**
+ * StreamGobbler constructor
+ *
+ * We use this class because shell STDOUT and STDERR should be read as quickly as
+ * possible to prevent a deadlock from occurring, or Process.waitFor() never
+ * returning (as the buffer is full, pausing the native process)
+ *
+ * @param shell Name of the shell
+ * @param inputStream InputStream to read from
+ * @param onLineListener OnLineListener callback
+ * @param onStreamClosedListener OnStreamClosedListener callback
+ * @param logLevel The custom log level to use for logging the command output. If set to
+ * {@code null}, then {@link Logger#LOG_LEVEL_VERBOSE} will be used.
+ */
+ @AnyThread
+ public StreamGobbler(@NonNull String shell, @NonNull InputStream inputStream,
+ @Nullable OnLineListener onLineListener,
+ @Nullable OnStreamClosedListener onStreamClosedListener,
+ @Nullable Integer logLevel) {
+ super("Gobbler#" + incThreadCounter());
+ this.shell = shell;
+ this.inputStream = inputStream;
+ reader = new BufferedReader(new InputStreamReader(inputStream));
+ streamClosedListener = onStreamClosedListener;
+
+ listWriter = null;
+ stringWriter = null;
+ lineListener = onLineListener;
+
+ mLogLevel = logLevel;
+ }
+
+ @Override
+ public void run() {
+ String defaultLogTag = Logger.getDefaultLogTag();
+ boolean loggingEnabled = Logger.shouldEnableLoggingForCustomLogLevel(mLogLevel);
+ if (loggingEnabled)
+ Logger.logVerbose(LOG_TAG, "Using custom log level: " + mLogLevel + ", current log level: " + Logger.getLogLevel());
+
+ // keep reading the InputStream until it ends (or an error occurs)
+ // optionally pausing when a command is executed that consumes the InputStream itself
+ try {
+ String line;
+ while ((line = reader.readLine()) != null) {
+ if (loggingEnabled)
+ Logger.logVerboseForce(defaultLogTag + "Command", String.format(Locale.ENGLISH, "[%s] %s", shell, line)); // This will get truncated by LOGGER_ENTRY_MAX_LEN, likely 4KB
+
+ if (stringWriter != null) stringWriter.append(line).append("\n");
+ if (listWriter != null) listWriter.add(line);
+ if (lineListener != null) lineListener.onLine(line);
+ while (!active) {
+ synchronized (this) {
+ try {
+ this.wait(128);
+ } catch (InterruptedException e) {
+ // no action
+ }
+ }
+ }
+ }
+ } catch (IOException e) {
+ // reader probably closed, expected exit condition
+ if (streamClosedListener != null) {
+ calledOnClose = true;
+ streamClosedListener.onStreamClosed();
+ }
+ }
+
+ // make sure our stream is closed and resources will be freed
+ try {
+ reader.close();
+ } catch (IOException e) {
+ // read already closed
+ }
+
+ if (!calledOnClose) {
+ if (streamClosedListener != null) {
+ calledOnClose = true;
+ streamClosedListener.onStreamClosed();
+ }
+ }
+ }
+
+ /**
+ * Resume consuming the input from the stream
+ */
+ @AnyThread
+ public void resumeGobbling() {
+ if (!active) {
+ synchronized (this) {
+ active = true;
+ this.notifyAll();
+ }
+ }
+ }
+
+ /**
+ * Suspend gobbling, so other code may read from the InputStream instead
+ *
+ * This should only be called from the OnLineListener callback!
+ */
+ @AnyThread
+ public void suspendGobbling() {
+ synchronized (this) {
+ active = false;
+ this.notifyAll();
+ }
+ }
+
+ /**
+ * Wait for gobbling to be suspended
+ *
+ * Obviously this cannot be called from the same thread as {@link #suspendGobbling()}
+ */
+ @WorkerThread
+ public void waitForSuspend() {
+ synchronized (this) {
+ while (active) {
+ try {
+ this.wait(32);
+ } catch (InterruptedException e) {
+ // no action
+ }
+ }
+ }
+ }
+
+ /**
+ * Is gobbling suspended ?
+ *
+ * @return is gobbling suspended?
+ */
+ @AnyThread
+ public boolean isSuspended() {
+ synchronized (this) {
+ return !active;
+ }
+ }
+
+ /**
+ * Get current source InputStream
+ *
+ * @return source InputStream
+ */
+ @NonNull
+ @AnyThread
+ public InputStream getInputStream() {
+ return inputStream;
+ }
+
+ /**
+ * Get current OnLineListener
+ *
+ * @return OnLineListener
+ */
+ @Nullable
+ @AnyThread
+ public OnLineListener getOnLineListener() {
+ return lineListener;
+ }
+
+ void conditionalJoin() throws InterruptedException {
+ if (calledOnClose) return; // deadlock from callback, we're inside exit procedure
+ if (Thread.currentThread() == this) return; // can't join self
+ join();
+ }
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/shell/am/AmSocketServer.java b/termux/termux-shared/src/main/java/com/termux/shared/shell/am/AmSocketServer.java
new file mode 100644
index 000000000..e316ec066
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/shell/am/AmSocketServer.java
@@ -0,0 +1,258 @@
+package com.termux.shared.shell.am;
+
+import android.Manifest;
+import android.app.Application;
+import android.content.Context;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.termux.am.Am;
+import com.termux.shared.R;
+import com.termux.shared.android.PackageUtils;
+import com.termux.shared.android.PermissionUtils;
+import com.termux.shared.errors.Error;
+import com.termux.shared.logger.Logger;
+import com.termux.shared.net.socket.local.ILocalSocketManager;
+import com.termux.shared.net.socket.local.LocalClientSocket;
+import com.termux.shared.net.socket.local.LocalServerSocket;
+import com.termux.shared.net.socket.local.LocalSocketManager;
+import com.termux.shared.net.socket.local.LocalSocketManagerClientBase;
+import com.termux.shared.net.socket.local.LocalSocketRunConfig;
+import com.termux.shared.shell.ArgumentTokenizer;
+import com.termux.shared.shell.command.ExecutionCommand;
+
+import java.io.ByteArrayOutputStream;
+import java.io.PrintStream;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * A AF_UNIX/SOCK_STREAM local server managed with {@link LocalSocketManager} whose
+ * {@link LocalServerSocket} receives android activity manager (am) commands from {@link LocalClientSocket}
+ * and runs them with termux-am-library. It would normally only allow processes belonging to the
+ * server app's user and root user to connect to it.
+ *
+ * The client must send the am command as a string without the initial "am" arg on its output stream
+ * and then wait for the result on its input stream. The result of the execution or error is sent
+ * back in the format `exit_code\0stdout\0stderr\0` where `\0` represents a null character.
+ * Check termux/termux-am-socket for implementation of a native c client.
+ *
+ * Usage:
+ * 1. Optionally extend {@link AmSocketServerClient}, the implementation for
+ * {@link ILocalSocketManager} that will receive call backs from the server including
+ * when client connects via {@link ILocalSocketManager#onClientAccepted(LocalSocketManager, LocalClientSocket)}.
+ * 2. Create a {@link AmSocketServerRunConfig} instance which extends from {@link LocalSocketRunConfig}
+ * with the run config of the am server. It would be better to use a filesystem socket instead
+ * of abstract namespace socket for security reasons.
+ * 3. Call {@link #start(Context, LocalSocketRunConfig)} to start the server and store the {@link LocalSocketManager}
+ * instance returned.
+ * 4. Stop server if needed with a call to {@link LocalSocketManager#stop()} on the
+ * {@link LocalSocketManager} instance returned by start call.
+ *
+ * https://github.com/termux/termux-am-library/blob/main/termux-am-library/src/main/java/com/termux/am/Am.java
+ * https://github.com/termux/termux-am-socket
+ * https://developer.android.com/studio/command-line/adb#am
+ * https://cs.android.com/android/platform/superproject/+/android-12.0.0_r32:frameworks/base/services/core/java/com/android/server/am/ActivityManagerShellCommand.java
+ */
+public class AmSocketServer {
+
+ public static final String LOG_TAG = "AmSocketServer";
+
+ /**
+ * Create the {@link AmSocketServer} {@link LocalServerSocket} and start listening for new {@link LocalClientSocket}.
+ *
+ * @param context The {@link Context} for {@link LocalSocketManager}.
+ * @param localSocketRunConfig The {@link LocalSocketRunConfig} for {@link LocalSocketManager}.
+ */
+ public static synchronized LocalSocketManager start(@NonNull Context context,
+ @NonNull LocalSocketRunConfig localSocketRunConfig) {
+ LocalSocketManager localSocketManager = new LocalSocketManager(context, localSocketRunConfig);
+ Error error = localSocketManager.start();
+ if (error != null) {
+ localSocketManager.onError(error);
+ return null;
+ }
+
+ return localSocketManager;
+ }
+
+ public static void processAmClient(@NonNull LocalSocketManager localSocketManager,
+ @NonNull LocalClientSocket clientSocket) {
+ Error error;
+
+ // Read amCommandString client sent and close input stream
+ StringBuilder data = new StringBuilder();
+ error = clientSocket.readDataOnInputStream(data, true);
+ if (error != null) {
+ sendResultToClient(localSocketManager, clientSocket, 1, null, error.toString());
+ return;
+ }
+
+ String amCommandString = data.toString();
+
+ Logger.logVerbose(LOG_TAG, "am command received from peer " + clientSocket.getPeerCred().getMinimalString() +
+ "\nam command: `" + amCommandString + "`");
+
+ // Parse am command string and convert it to a list of arguments
+ List amCommandList = new ArrayList<>();
+ error = parseAmCommand(amCommandString, amCommandList);
+ if (error != null) {
+ sendResultToClient(localSocketManager, clientSocket, 1, null, error.toString());
+ return;
+ }
+
+ String[] amCommandArray = amCommandList.toArray(new String[0]);
+
+ Logger.logDebug(LOG_TAG, "am command received from peer " + clientSocket.getPeerCred().getMinimalString() +
+ "\n" + ExecutionCommand.getArgumentsLogString("am command", amCommandArray));
+
+ AmSocketServerRunConfig amSocketServerRunConfig = (AmSocketServerRunConfig) localSocketManager.getLocalSocketRunConfig();
+
+ // Run am command and send its result to the client
+ StringBuilder stdout = new StringBuilder();
+ StringBuilder stderr = new StringBuilder();
+ error = runAmCommand(localSocketManager.getContext(), amCommandArray, stdout, stderr,
+ amSocketServerRunConfig.shouldCheckDisplayOverAppsPermission());
+ if (error != null) {
+ sendResultToClient(localSocketManager, clientSocket, 1, stdout.toString(),
+ !stderr.toString().isEmpty() ? stderr + "\n\n" + error : error.toString());
+ }
+
+ sendResultToClient(localSocketManager, clientSocket, 0, stdout.toString(), stderr.toString());
+ }
+
+ /**
+ * Send result to {@link LocalClientSocket} that requested the am command to be run.
+ *
+ * @param localSocketManager The {@link LocalSocketManager} instance for the local socket.
+ * @param clientSocket The {@link LocalClientSocket} to which the result is to be sent.
+ * @param exitCode The exit code value to send.
+ * @param stdout The stdout value to send.
+ * @param stderr The stderr value to send.
+ */
+ public static void sendResultToClient(@NonNull LocalSocketManager localSocketManager,
+ @NonNull LocalClientSocket clientSocket,
+ int exitCode,
+ @Nullable String stdout, @Nullable String stderr) {
+ StringBuilder result = new StringBuilder();
+ result.append(sanitizeExitCode(clientSocket, exitCode));
+ result.append('\0');
+ result.append(stdout != null ? stdout : "");
+ result.append('\0');
+ result.append(stderr != null ? stderr : "");
+
+ // Send result to client and close output stream
+ Error error = clientSocket.sendDataToOutputStream(result.toString(), true);
+ if (error != null) {
+ localSocketManager.onError(clientSocket, error);
+ }
+ }
+
+ /**
+ * Sanitize exitCode to between 0-255, otherwise it may be considered invalid.
+ * Out of bound exit codes would return with exit code `44` `Channel number out of range` in shell.
+ *
+ * @param clientSocket The {@link LocalClientSocket} to which the exit code will be sent.
+ * @param exitCode The current exit code.
+ * @return Returns the sanitized exit code.
+ */
+ public static int sanitizeExitCode(@NonNull LocalClientSocket clientSocket, int exitCode) {
+ if (exitCode < 0 || exitCode > 255) {
+ Logger.logWarn(LOG_TAG, "Ignoring invalid peer " + clientSocket.getPeerCred().getMinimalString() + " result value \"" + exitCode + "\" and force setting it to \"" + 1 + "\"");
+ exitCode = 1;
+ }
+
+ return exitCode;
+ }
+
+
+ /**
+ * Parse amCommandString into a list of arguments like normally done on shells like bourne shell.
+ * Arguments are split on whitespaces unless quoted with single or double quotes.
+ * Double quotes and backslashes can be escaped with backslashes in arguments surrounded.
+ * Double quotes and backslashes can be escaped with backslashes in arguments surrounded with
+ * double quotes.
+ *
+ * @param amCommandString The am command {@link String}.
+ * @param amCommandList The {@link List} to set list of arguments in.
+ * @return Returns the {@code error} if parsing am command failed, otherwise {@code null}.
+ */
+ public static Error parseAmCommand(String amCommandString, List amCommandList) {
+
+ if (amCommandString == null || amCommandString.isEmpty()) {
+ return null;
+ }
+
+ try {
+ amCommandList.addAll(ArgumentTokenizer.tokenize(amCommandString));
+ } catch (Exception e) {
+ return AmSocketServerErrno.ERRNO_PARSE_AM_COMMAND_FAILED_WITH_EXCEPTION.getError(e, amCommandString, e.getMessage());
+ }
+
+ return null;
+ }
+
+ /**
+ * Call termux-am-library to run the am command.
+ *
+ * @param context The {@link Context} to run am command with.
+ * @param amCommandArray The am command array.
+ * @param stdout The {@link StringBuilder} to set stdout in that is returned by the am command.
+ * @param stderr The {@link StringBuilder} to set stderr in that is returned by the am command.
+ * @param checkDisplayOverAppsPermission Check if {@link Manifest.permission#SYSTEM_ALERT_WINDOW}
+ * has been granted if running on Android `>= 10` and
+ * starting activity or service.
+ * @return Returns the {@code error} if am command failed, otherwise {@code null}.
+ */
+ public static Error runAmCommand(@NonNull Context context,
+ String[] amCommandArray,
+ @NonNull StringBuilder stdout, @NonNull StringBuilder stderr,
+ boolean checkDisplayOverAppsPermission) {
+ try (ByteArrayOutputStream stdoutByteStream = new ByteArrayOutputStream();
+ PrintStream stdoutPrintStream = new PrintStream(stdoutByteStream);
+ ByteArrayOutputStream stderrByteStream = new ByteArrayOutputStream();
+ PrintStream stderrPrintStream = new PrintStream(stderrByteStream)) {
+
+ if (checkDisplayOverAppsPermission && amCommandArray.length >= 1 &&
+ (amCommandArray[0].equals("start") || amCommandArray[0].equals("startservice")) &&
+ !PermissionUtils.validateDisplayOverOtherAppsPermissionForPostAndroid10(context, true)) {
+ throw new IllegalStateException(context.getString(R.string.error_display_over_other_apps_permission_not_granted,
+ PackageUtils.getAppNameForPackage(context)));
+ }
+
+ new Am(stdoutPrintStream, stderrPrintStream, (Application) context.getApplicationContext()).run(amCommandArray);
+
+ // Set stdout to value set by am command in stdoutPrintStream
+ stdoutPrintStream.flush();
+ stdout.append(stdoutByteStream.toString(StandardCharsets.UTF_8.name()));
+
+ // Set stderr to value set by am command in stderrPrintStream
+ stderrPrintStream.flush();
+ stderr.append(stderrByteStream.toString(StandardCharsets.UTF_8.name()));
+ } catch (Exception e) {
+ return AmSocketServerErrno.ERRNO_RUN_AM_COMMAND_FAILED_WITH_EXCEPTION.getError(e, Arrays.toString(amCommandArray), e.getMessage());
+ }
+
+ return null;
+ }
+
+
+
+
+
+ /** Implementation for {@link ILocalSocketManager} for {@link AmSocketServer}. */
+ public abstract static class AmSocketServerClient extends LocalSocketManagerClientBase {
+
+ @Override
+ public void onClientAccepted(@NonNull LocalSocketManager localSocketManager,
+ @NonNull LocalClientSocket clientSocket) {
+ AmSocketServer.processAmClient(localSocketManager, clientSocket);
+ super.onClientAccepted(localSocketManager, clientSocket);
+ }
+
+ }
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/shell/am/AmSocketServerErrno.java b/termux/termux-shared/src/main/java/com/termux/shared/shell/am/AmSocketServerErrno.java
new file mode 100644
index 000000000..74b26528d
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/shell/am/AmSocketServerErrno.java
@@ -0,0 +1,18 @@
+package com.termux.shared.shell.am;
+
+import com.termux.shared.errors.Errno;
+
+public class AmSocketServerErrno extends Errno {
+
+ public static final String TYPE = "AmSocketServer Error";
+
+
+ /** Errors for {@link AmSocketServer} (100-150) */
+ public static final Errno ERRNO_PARSE_AM_COMMAND_FAILED_WITH_EXCEPTION = new Errno(TYPE, 100, "Parse am command `%1$s` failed.\nException: %2$s");
+ public static final Errno ERRNO_RUN_AM_COMMAND_FAILED_WITH_EXCEPTION = new Errno(TYPE, 101, "Run am command `%1$s` failed.\nException: %2$s");
+
+ AmSocketServerErrno(final String type, final int code, final String message) {
+ super(type, code, message);
+ }
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/shell/am/AmSocketServerRunConfig.java b/termux/termux-shared/src/main/java/com/termux/shared/shell/am/AmSocketServerRunConfig.java
new file mode 100644
index 000000000..f36cbece5
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/shell/am/AmSocketServerRunConfig.java
@@ -0,0 +1,108 @@
+package com.termux.shared.shell.am;
+
+import android.Manifest;
+
+import androidx.annotation.NonNull;
+
+import com.termux.shared.logger.Logger;
+import com.termux.shared.markdown.MarkdownUtils;
+import com.termux.shared.net.socket.local.ILocalSocketManager;
+import com.termux.shared.net.socket.local.LocalSocketRunConfig;
+
+import java.io.Serializable;
+
+/**
+ * Run config for {@link AmSocketServer}.
+ */
+public class AmSocketServerRunConfig extends LocalSocketRunConfig implements Serializable {
+
+ /**
+ * Check if {@link Manifest.permission#SYSTEM_ALERT_WINDOW} has been granted if running on Android `>= 10`
+ * if starting activities. Will also check when starting services in case starting foreground
+ * service is not allowed.
+ *
+ * https://developer.android.com/guide/components/activities/background-starts
+ */
+ private Boolean mCheckDisplayOverAppsPermission;
+ public static final boolean DEFAULT_CHECK_DISPLAY_OVER_APPS_PERMISSION = true;
+
+ /**
+ * Create an new instance of {@link AmSocketServerRunConfig}.
+ *
+ * @param title The {@link #mTitle} value.
+ * @param path The {@link #mPath} value.
+ * @param localSocketManagerClient The {@link #mLocalSocketManagerClient} value.
+ */
+ public AmSocketServerRunConfig(@NonNull String title, @NonNull String path, @NonNull ILocalSocketManager localSocketManagerClient) {
+ super(title, path, localSocketManagerClient);
+ }
+
+
+ /** Get {@link #mCheckDisplayOverAppsPermission} if set, otherwise {@link #DEFAULT_CHECK_DISPLAY_OVER_APPS_PERMISSION}. */
+ public boolean shouldCheckDisplayOverAppsPermission() {
+ return mCheckDisplayOverAppsPermission != null ? mCheckDisplayOverAppsPermission : DEFAULT_CHECK_DISPLAY_OVER_APPS_PERMISSION;
+ }
+
+ /** Set {@link #mCheckDisplayOverAppsPermission}. */
+ public void setCheckDisplayOverAppsPermission(Boolean checkDisplayOverAppsPermission) {
+ mCheckDisplayOverAppsPermission = checkDisplayOverAppsPermission;
+ }
+
+
+
+ /**
+ * Get a log {@link String} for {@link AmSocketServerRunConfig}.
+ *
+ * @param config The {@link AmSocketServerRunConfig} to get info of.
+ * @return Returns the log {@link String}.
+ */
+ @NonNull
+ public static String getRunConfigLogString(final AmSocketServerRunConfig config) {
+ if (config == null) return "null";
+ return config.getLogString();
+ }
+
+ /** Get a log {@link String} for the {@link AmSocketServerRunConfig}. */
+ @NonNull
+ public String getLogString() {
+ StringBuilder logString = new StringBuilder();
+ logString.append(super.getLogString()).append("\n\n\n");
+
+ logString.append("Am Command:");
+ logString.append("\n").append(Logger.getSingleLineLogStringEntry("CheckDisplayOverAppsPermission", shouldCheckDisplayOverAppsPermission(), "-"));
+
+ return logString.toString();
+ }
+
+ /**
+ * Get a markdown {@link String} for {@link AmSocketServerRunConfig}.
+ *
+ * @param config The {@link AmSocketServerRunConfig} to get info of.
+ * @return Returns the markdown {@link String}.
+ */
+ public static String getRunConfigMarkdownString(final AmSocketServerRunConfig config) {
+ if (config == null) return "null";
+ return config.getMarkdownString();
+ }
+
+ /** Get a markdown {@link String} for the {@link AmSocketServerRunConfig}. */
+ @NonNull
+ public String getMarkdownString() {
+ StringBuilder markdownString = new StringBuilder();
+ markdownString.append(super.getMarkdownString()).append("\n\n\n");
+
+ markdownString.append("## ").append("Am Command");
+ markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("CheckDisplayOverAppsPermission", shouldCheckDisplayOverAppsPermission(), "-"));
+
+ return markdownString.toString();
+ }
+
+
+
+ @NonNull
+ @Override
+ public String toString() {
+ return getLogString();
+ }
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/shell/command/ExecutionCommand.java b/termux/termux-shared/src/main/java/com/termux/shared/shell/command/ExecutionCommand.java
new file mode 100644
index 000000000..78065cdad
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/shell/command/ExecutionCommand.java
@@ -0,0 +1,689 @@
+package com.termux.shared.shell.command;
+
+import android.content.Intent;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.termux.shared.data.IntentUtils;
+import com.termux.shared.shell.command.result.ResultConfig;
+import com.termux.shared.shell.command.result.ResultData;
+import com.termux.shared.errors.Error;
+import com.termux.shared.logger.Logger;
+import com.termux.shared.markdown.MarkdownUtils;
+import com.termux.shared.data.DataUtils;
+import com.termux.shared.shell.command.runner.app.AppShell;
+import com.termux.terminal.TerminalSession;
+
+import java.util.Collections;
+import java.util.List;
+
+public class ExecutionCommand {
+
+ /*
+ The {@link ExecutionState#SUCCESS} and {@link ExecutionState#FAILED} is defined based on
+ successful execution of command without any internal errors or exceptions being raised.
+ The shell command {@link #exitCode} being non-zero **does not** mean that execution command failed.
+ Only the {@link #errCode} being non-zero means that execution command failed from the Termux app
+ perspective.
+ */
+
+ /** The {@link Enum} that defines {@link ExecutionCommand} state. */
+ public enum ExecutionState {
+
+ PRE_EXECUTION("Pre-Execution", 0),
+ EXECUTING("Executing", 1),
+ EXECUTED("Executed", 2),
+ SUCCESS("Success", 3),
+ FAILED("Failed", 4);
+
+ private final String name;
+ private final int value;
+
+ ExecutionState(final String name, final int value) {
+ this.name = name;
+ this.value = value;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public int getValue() {
+ return value;
+ }
+
+
+ }
+
+ public enum Runner {
+
+ /** Run command in {@link TerminalSession}. */
+ TERMINAL_SESSION("terminal-session"),
+
+ /** Run command in {@link AppShell}. */
+ APP_SHELL("app-shell");
+
+ ///** Run command in {@link AdbShell}. */
+ //ADB_SHELL("adb-shell"),
+
+ ///** Run command in {@link RootShell}. */
+ //ROOT_SHELL("root-shell");
+
+ private final String name;
+
+ Runner(final String name) {
+ this.name = name;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public boolean equalsRunner(String runner) {
+ return runner != null && runner.equals(this.name);
+ }
+
+ /** Get {@link Runner} for {@code name} if found, otherwise {@code null}. */
+ @Nullable
+ public static Runner runnerOf(String name) {
+ for (Runner v : Runner.values()) {
+ if (v.name.equals(name)) {
+ return v;
+ }
+ }
+ return null;
+ }
+
+ /** Get {@link Runner} for {@code name} if found, otherwise {@code def}. */
+ @NonNull
+ public static Runner runnerOf(@Nullable String name, @NonNull Runner def) {
+ Runner runner = runnerOf(name);
+ return runner != null ? runner : def;
+ }
+
+ }
+
+ public enum ShellCreateMode {
+
+ /** Always create {@link TerminalSession}. */
+ ALWAYS("always"),
+
+ /** Create shell only if no shell with {@link #shellName} found. */
+ NO_SHELL_WITH_NAME("no-shell-with-name");
+
+ private final String mode;
+
+ ShellCreateMode(final String mode) {
+ this.mode = mode;
+ }
+
+ public String getMode() {
+ return mode;
+ }
+
+ public boolean equalsMode(String sessionCreateMode) {
+ return sessionCreateMode != null && sessionCreateMode.equals(this.mode);
+ }
+
+ /** Get {@link ShellCreateMode} for {@code mode} if found, otherwise {@code null}. */
+ @Nullable
+ public static ShellCreateMode modeOf(String mode) {
+ for (ShellCreateMode v : ShellCreateMode.values()) {
+ if (v.mode.equals(mode)) {
+ return v;
+ }
+ }
+ return null;
+ }
+
+ }
+
+ /** The optional unique id for the {@link ExecutionCommand}. This should equal -1 if execution
+ * command is not going to be managed by a shell manager. */
+ public Integer id;
+
+ /** The process id of command. */
+ public int mPid = -1;
+
+ /** The current state of the {@link ExecutionCommand}. */
+ private ExecutionState currentState = ExecutionState.PRE_EXECUTION;
+ /** The previous state of the {@link ExecutionCommand}. */
+ private ExecutionState previousState = ExecutionState.PRE_EXECUTION;
+
+
+ /** The executable for the {@link ExecutionCommand}. */
+ public String executable;
+ /** The executable Uri for the {@link ExecutionCommand}. */
+ public Uri executableUri;
+ /** The executable arguments array for the {@link ExecutionCommand}. */
+ public String[] arguments;
+ /** The stdin string for the {@link ExecutionCommand}. */
+ public String stdin;
+ /** The current working directory for the {@link ExecutionCommand}. */
+ public String workingDirectory;
+
+
+ /** The terminal transcript rows for the {@link ExecutionCommand}. */
+ public Integer terminalTranscriptRows;
+
+
+ /** The {@link Runner} for the {@link ExecutionCommand}. */
+ public String runner;
+
+ /** If the {@link ExecutionCommand} is meant to start a failsafe terminal session. */
+ public boolean isFailsafe;
+
+ /**
+ * The {@link ExecutionCommand} custom log level for background {@link AppShell}
+ * commands. By default, @link com.termux.shared.shell.StreamGobbler} only logs stdout and
+ * stderr if {@link Logger} `CURRENT_LOG_LEVEL` is >= {@link Logger#LOG_LEVEL_VERBOSE} and
+ * {@link AppShell} only logs stdin if `CURRENT_LOG_LEVEL` is >=
+ * {@link Logger#LOG_LEVEL_DEBUG}.
+ */
+ public Integer backgroundCustomLogLevel;
+
+
+ /** The session action of {@link Runner#TERMINAL_SESSION} commands. */
+ public String sessionAction;
+
+
+ /** The shell name of commands. */
+ public String shellName;
+
+ /** The {@link ShellCreateMode} of commands. */
+ public String shellCreateMode;
+
+ /** Whether to set {@link ExecutionCommand} shell environment. */
+ public boolean setShellCommandShellEnvironment;
+
+
+ /** The command label for the {@link ExecutionCommand}. */
+ public String commandLabel;
+ /** The markdown text for the command description for the {@link ExecutionCommand}. */
+ public String commandDescription;
+ /** The markdown text for the help of command for the {@link ExecutionCommand}. This can be used
+ * to provide useful info to the user if an internal error is raised. */
+ public String commandHelp;
+
+
+ /** Defines the markdown text for the help of the Termux plugin API that was used to start the
+ * {@link ExecutionCommand}. This can be used to provide useful info to the user if an internal
+ * error is raised. */
+ public String pluginAPIHelp;
+
+
+ /** Defines the {@link Intent} received which started the command. */
+ public Intent commandIntent;
+
+ /** Defines if {@link ExecutionCommand} was started because of an external plugin request
+ * like with an intent or from within Termux app itself. */
+ public boolean isPluginExecutionCommand;
+
+ /** Defines the {@link ResultConfig} for the {@link ExecutionCommand} containing information
+ * on how to handle the result. */
+ public final ResultConfig resultConfig = new ResultConfig();
+
+ /** Defines the {@link ResultData} for the {@link ExecutionCommand} containing information
+ * of the result. */
+ public final ResultData resultData = new ResultData();
+
+
+ /** Defines if processing results already called for this {@link ExecutionCommand}. */
+ public boolean processingResultsAlreadyCalled;
+
+ private static final String LOG_TAG = "ExecutionCommand";
+
+
+ public ExecutionCommand() {
+ }
+
+ public ExecutionCommand(Integer id) {
+ this.id = id;
+ }
+
+ public ExecutionCommand(Integer id, String executable, String[] arguments, String stdin, String workingDirectory, String runner, boolean isFailsafe) {
+ this.id = id;
+ this.executable = executable;
+ this.arguments = arguments;
+ this.stdin = stdin;
+ this.workingDirectory = workingDirectory;
+ this.runner = runner;
+ this.isFailsafe = isFailsafe;
+ }
+
+
+ public boolean isPluginExecutionCommandWithPendingResult() {
+ return isPluginExecutionCommand && resultConfig.isCommandWithPendingResult();
+ }
+
+
+ public synchronized boolean setState(ExecutionState newState) {
+ // The state transition cannot go back or change if already at {@link ExecutionState#SUCCESS}
+ if (newState.getValue() < currentState.getValue() || currentState == ExecutionState.SUCCESS) {
+ Logger.logError(LOG_TAG, "Invalid "+ getCommandIdAndLabelLogString() + " state transition from \"" + currentState.getName() + "\" to " + "\"" + newState.getName() + "\"");
+ return false;
+ }
+
+ // The {@link ExecutionState#FAILED} can be set again, like to add more errors, but we don't update
+ // {@link #previousState} with the {@link #currentState} value if its at {@link ExecutionState#FAILED} to
+ // preserve the last valid state
+ if (currentState != ExecutionState.FAILED)
+ previousState = currentState;
+
+ currentState = newState;
+ return true;
+ }
+
+ public synchronized boolean hasExecuted() {
+ return currentState.getValue() >= ExecutionState.EXECUTED.getValue();
+ }
+
+ public synchronized boolean isExecuting() {
+ return currentState == ExecutionState.EXECUTING;
+ }
+
+ public synchronized boolean isSuccessful() {
+ return currentState == ExecutionState.SUCCESS;
+ }
+
+
+ public synchronized boolean setStateFailed(@NonNull Error error) {
+ return setStateFailed(error.getType(), error.getCode(), error.getMessage(), null);
+ }
+
+ public synchronized boolean setStateFailed(@NonNull Error error, Throwable throwable) {
+ return setStateFailed(error.getType(), error.getCode(), error.getMessage(), Collections.singletonList(throwable));
+ }
+ public synchronized boolean setStateFailed(@NonNull Error error, List throwablesList) {
+ return setStateFailed(error.getType(), error.getCode(), error.getMessage(), throwablesList);
+ }
+
+ public synchronized boolean setStateFailed(int code, String message) {
+ return setStateFailed(null, code, message, null);
+ }
+
+ public synchronized boolean setStateFailed(int code, String message, Throwable throwable) {
+ return setStateFailed(null, code, message, Collections.singletonList(throwable));
+ }
+
+ public synchronized boolean setStateFailed(int code, String message, List throwablesList) {
+ return setStateFailed(null, code, message, throwablesList);
+ }
+ public synchronized boolean setStateFailed(String type, int code, String message, List throwablesList) {
+ if (!this.resultData.setStateFailed(type, code, message, throwablesList)) {
+ Logger.logWarn(LOG_TAG, "setStateFailed for " + getCommandIdAndLabelLogString() + " resultData encountered an error.");
+ }
+
+ return setState(ExecutionState.FAILED);
+ }
+
+ public synchronized boolean shouldNotProcessResults() {
+ if (processingResultsAlreadyCalled) {
+ return true;
+ } else {
+ processingResultsAlreadyCalled = true;
+ return false;
+ }
+ }
+
+ public synchronized boolean isStateFailed() {
+ if (currentState != ExecutionState.FAILED)
+ return false;
+
+ if (!resultData.isStateFailed()) {
+ Logger.logWarn(LOG_TAG, "The " + getCommandIdAndLabelLogString() + " has an invalid errCode value set in errors list while having ExecutionState.FAILED state.\n" + resultData.errorsList);
+ return false;
+ } else {
+ return true;
+ }
+ }
+
+
+ @NonNull
+ @Override
+ public String toString() {
+ if (!hasExecuted())
+ return getExecutionInputLogString(this, true, true);
+ else {
+ return getExecutionOutputLogString(this, true, true, true);
+ }
+ }
+
+ /**
+ * Get a log friendly {@link String} for {@link ExecutionCommand} execution input parameters.
+ *
+ * @param executionCommand The {@link ExecutionCommand} to convert.
+ * @param ignoreNull Set to {@code true} if non-critical {@code null} values are to be ignored.
+ * @param logStdin Set to {@code true} if {@link #stdin} should be logged.
+ * @return Returns the log friendly {@link String}.
+ */
+ public static String getExecutionInputLogString(final ExecutionCommand executionCommand, boolean ignoreNull, boolean logStdin) {
+ if (executionCommand == null) return "null";
+
+ StringBuilder logString = new StringBuilder();
+
+ logString.append(executionCommand.getCommandIdAndLabelLogString()).append(":");
+
+ if (executionCommand.mPid != -1)
+ logString.append("\n").append(executionCommand.getPidLogString());
+
+ if (executionCommand.previousState != ExecutionState.PRE_EXECUTION)
+ logString.append("\n").append(executionCommand.getPreviousStateLogString());
+ logString.append("\n").append(executionCommand.getCurrentStateLogString());
+
+ logString.append("\n").append(executionCommand.getExecutableLogString());
+ logString.append("\n").append(executionCommand.getArgumentsLogString());
+ logString.append("\n").append(executionCommand.getWorkingDirectoryLogString());
+ logString.append("\n").append(executionCommand.getRunnerLogString());
+ logString.append("\n").append(executionCommand.getIsFailsafeLogString());
+
+ if (Runner.APP_SHELL.equalsRunner(executionCommand.runner)) {
+ if (logStdin && (!ignoreNull || !DataUtils.isNullOrEmpty(executionCommand.stdin)))
+ logString.append("\n").append(executionCommand.getStdinLogString());
+
+ if (!ignoreNull || executionCommand.backgroundCustomLogLevel != null)
+ logString.append("\n").append(executionCommand.getBackgroundCustomLogLevelLogString());
+ }
+
+ if (!ignoreNull || executionCommand.sessionAction != null)
+ logString.append("\n").append(executionCommand.getSessionActionLogString());
+
+ if (!ignoreNull || executionCommand.shellName != null) {
+ logString.append("\n").append(executionCommand.getShellNameLogString());
+ }
+
+ if (!ignoreNull || executionCommand.shellCreateMode != null) {
+ logString.append("\n").append(executionCommand.getShellCreateModeLogString());
+ }
+
+ logString.append("\n").append(executionCommand.getSetRunnerShellEnvironmentLogString());
+
+ if (!ignoreNull || executionCommand.commandIntent != null)
+ logString.append("\n").append(executionCommand.getCommandIntentLogString());
+
+ logString.append("\n").append(executionCommand.getIsPluginExecutionCommandLogString());
+ if (executionCommand.isPluginExecutionCommand)
+ logString.append("\n").append(ResultConfig.getResultConfigLogString(executionCommand.resultConfig, ignoreNull));
+
+ return logString.toString();
+ }
+
+ /**
+ * Get a log friendly {@link String} for {@link ExecutionCommand} execution output parameters.
+ *
+ * @param executionCommand The {@link ExecutionCommand} to convert.
+ * @param ignoreNull Set to {@code true} if non-critical {@code null} values are to be ignored.
+ * @param logResultData Set to {@code true} if {@link #resultData} should be logged.
+ * @param logStdoutAndStderr Set to {@code true} if {@link ResultData#stdout} and {@link ResultData#stderr} should be logged.
+ * @return Returns the log friendly {@link String}.
+ */
+ public static String getExecutionOutputLogString(final ExecutionCommand executionCommand, boolean ignoreNull, boolean logResultData, boolean logStdoutAndStderr) {
+ if (executionCommand == null) return "null";
+
+ StringBuilder logString = new StringBuilder();
+
+ logString.append(executionCommand.getCommandIdAndLabelLogString()).append(":");
+
+ logString.append("\n").append(executionCommand.getPreviousStateLogString());
+ logString.append("\n").append(executionCommand.getCurrentStateLogString());
+
+ if (logResultData)
+ logString.append("\n").append(ResultData.getResultDataLogString(executionCommand.resultData, logStdoutAndStderr));
+
+ return logString.toString();
+ }
+
+ /**
+ * Get a log friendly {@link String} for {@link ExecutionCommand} with more details.
+ *
+ * @param executionCommand The {@link ExecutionCommand} to convert.
+ * @return Returns the log friendly {@link String}.
+ */
+ public static String getDetailedLogString(final ExecutionCommand executionCommand) {
+ if (executionCommand == null) return "null";
+
+ StringBuilder logString = new StringBuilder();
+
+ logString.append(getExecutionInputLogString(executionCommand, false, true));
+ logString.append(getExecutionOutputLogString(executionCommand, false, true, true));
+
+ logString.append("\n").append(executionCommand.getCommandDescriptionLogString());
+ logString.append("\n").append(executionCommand.getCommandHelpLogString());
+ logString.append("\n").append(executionCommand.getPluginAPIHelpLogString());
+
+ return logString.toString();
+ }
+
+ /**
+ * Get a markdown {@link String} for {@link ExecutionCommand}.
+ *
+ * @param executionCommand The {@link ExecutionCommand} to convert.
+ * @return Returns the markdown {@link String}.
+ */
+ public static String getExecutionCommandMarkdownString(final ExecutionCommand executionCommand) {
+ if (executionCommand == null) return "null";
+
+ if (executionCommand.commandLabel == null) executionCommand.commandLabel = "Execution Command";
+
+ StringBuilder markdownString = new StringBuilder();
+
+ markdownString.append("## ").append(executionCommand.commandLabel).append("\n");
+
+ if (executionCommand.mPid != -1)
+ markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("Pid", executionCommand.mPid, "-"));
+
+ markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("Previous State", executionCommand.previousState.getName(), "-"));
+ markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("Current State", executionCommand.currentState.getName(), "-"));
+
+ markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("Executable", executionCommand.executable, "-"));
+ markdownString.append("\n").append(getArgumentsMarkdownString("Arguments", executionCommand.arguments));
+ markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("Working Directory", executionCommand.workingDirectory, "-"));
+ markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("Runner", executionCommand.runner, "-"));
+ markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("isFailsafe", executionCommand.isFailsafe, "-"));
+
+ if (Runner.APP_SHELL.equalsRunner(executionCommand.runner)) {
+ if (!DataUtils.isNullOrEmpty(executionCommand.stdin))
+ markdownString.append("\n").append(MarkdownUtils.getMultiLineMarkdownStringEntry("Stdin", executionCommand.stdin, "-"));
+ if (executionCommand.backgroundCustomLogLevel != null)
+ markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("Background Custom Log Level", executionCommand.backgroundCustomLogLevel, "-"));
+ }
+
+ markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("Session Action", executionCommand.sessionAction, "-"));
+
+ markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("Shell Name", executionCommand.shellName, "-"));
+ markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("Shell Create Mode", executionCommand.shellCreateMode, "-"));
+ markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("Set Shell Command Shell Environment", executionCommand.setShellCommandShellEnvironment, "-"));
+
+ markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("isPluginExecutionCommand", executionCommand.isPluginExecutionCommand, "-"));
+
+ markdownString.append("\n\n").append(ResultConfig.getResultConfigMarkdownString(executionCommand.resultConfig));
+
+ markdownString.append("\n\n").append(ResultData.getResultDataMarkdownString(executionCommand.resultData));
+
+ if (executionCommand.commandDescription != null || executionCommand.commandHelp != null) {
+ if (executionCommand.commandDescription != null)
+ markdownString.append("\n\n### Command Description\n\n").append(executionCommand.commandDescription).append("\n");
+ if (executionCommand.commandHelp != null)
+ markdownString.append("\n\n### Command Help\n\n").append(executionCommand.commandHelp).append("\n");
+ markdownString.append("\n##\n");
+ }
+
+ if (executionCommand.pluginAPIHelp != null) {
+ markdownString.append("\n\n### Plugin API Help\n\n").append(executionCommand.pluginAPIHelp);
+ markdownString.append("\n##\n");
+ }
+
+ return markdownString.toString();
+ }
+
+
+ public String getIdLogString() {
+ if (id != null)
+ return "(" + id + ") ";
+ else
+ return "";
+ }
+
+ public String getPidLogString() {
+ return "Pid: `" + mPid + "`";
+ }
+
+ public String getCurrentStateLogString() {
+ return "Current State: `" + currentState.getName() + "`";
+ }
+
+ public String getPreviousStateLogString() {
+ return "Previous State: `" + previousState.getName() + "`";
+ }
+
+ public String getCommandLabelLogString() {
+ if (commandLabel != null && !commandLabel.isEmpty())
+ return commandLabel;
+ else
+ return "Execution Command";
+ }
+
+ public String getCommandIdAndLabelLogString() {
+ return getIdLogString() + getCommandLabelLogString();
+ }
+
+ public String getExecutableLogString() {
+ return "Executable: `" + executable + "`";
+ }
+
+ public String getArgumentsLogString() {
+ return getArgumentsLogString("Arguments", arguments);
+ }
+
+ public String getWorkingDirectoryLogString() {
+ return "Working Directory: `" + workingDirectory + "`";
+ }
+
+ public String getRunnerLogString() {
+ return Logger.getSingleLineLogStringEntry("Runner", runner, "-");
+ }
+
+ public String getIsFailsafeLogString() {
+ return "isFailsafe: `" + isFailsafe + "`";
+ }
+
+ public String getStdinLogString() {
+ if (DataUtils.isNullOrEmpty(stdin))
+ return "Stdin: -";
+ else
+ return Logger.getMultiLineLogStringEntry("Stdin", stdin, "-");
+ }
+
+ public String getBackgroundCustomLogLevelLogString() {
+ return "Background Custom Log Level: `" + backgroundCustomLogLevel + "`";
+ }
+
+ public String getSessionActionLogString() {
+ return Logger.getSingleLineLogStringEntry("Session Action", sessionAction, "-");
+ }
+
+ public String getShellNameLogString() {
+ return Logger.getSingleLineLogStringEntry("Shell Name", shellName, "-");
+ }
+
+ public String getShellCreateModeLogString() {
+ return Logger.getSingleLineLogStringEntry("Shell Create Mode", shellCreateMode, "-");
+ }
+
+ public String getSetRunnerShellEnvironmentLogString() {
+ return "Set Shell Command Shell Environment: `" + setShellCommandShellEnvironment + "`";
+ }
+
+ public String getCommandDescriptionLogString() {
+ return Logger.getSingleLineLogStringEntry("Command Description", commandDescription, "-");
+ }
+
+ public String getCommandHelpLogString() {
+ return Logger.getSingleLineLogStringEntry("Command Help", commandHelp, "-");
+ }
+
+ public String getPluginAPIHelpLogString() {
+ return Logger.getSingleLineLogStringEntry("Plugin API Help", pluginAPIHelp, "-");
+ }
+
+ public String getCommandIntentLogString() {
+ if (commandIntent == null)
+ return "Command Intent: -";
+ else
+ return Logger.getMultiLineLogStringEntry("Command Intent", IntentUtils.getIntentString(commandIntent), "-");
+ }
+
+ public String getIsPluginExecutionCommandLogString() {
+ return "isPluginExecutionCommand: `" + isPluginExecutionCommand + "`";
+ }
+
+
+ /**
+ * Get a log friendly {@link String} for {@link List} argumentsArray.
+ * If argumentsArray are null or of size 0, then `Arguments: -` is returned. Otherwise
+ * following format is returned:
+ *
+ * Arguments:
+ * ```
+ * Arg 1: `value`
+ * Arg 2: 'value`
+ * ```
+ *
+ * @param argumentsArray The {@link String[]} argumentsArray to convert.
+ * @return Returns the log friendly {@link String}.
+ */
+ public static String getArgumentsLogString(String label, final String[] argumentsArray) {
+ StringBuilder argumentsString = new StringBuilder(label + ":");
+
+ if (argumentsArray != null && argumentsArray.length != 0) {
+ argumentsString.append("\n```\n");
+ for (int i = 0; i != argumentsArray.length; i++) {
+ argumentsString.append(Logger.getSingleLineLogStringEntry("Arg " + (i + 1),
+ DataUtils.getTruncatedCommandOutput(argumentsArray[i], Logger.LOGGER_ENTRY_MAX_SAFE_PAYLOAD / 5, true, false, true),
+ "-")).append("\n");
+ }
+ argumentsString.append("```");
+ } else{
+ argumentsString.append(" -");
+ }
+
+ return argumentsString.toString();
+ }
+
+ /**
+ * Get a markdown {@link String} for {@link String[]} argumentsArray.
+ * If argumentsArray are null or of size 0, then `**Arguments:** -` is returned. Otherwise
+ * following format is returned:
+ *
+ * **Arguments:**
+ *
+ * **Arg 1:**
+ * ```
+ * value
+ * ```
+ * **Arg 2:**
+ * ```
+ * value
+ *```
+ *
+ * @param argumentsArray The {@link String[]} argumentsArray to convert.
+ * @return Returns the markdown {@link String}.
+ */
+ public static String getArgumentsMarkdownString(String label, final String[] argumentsArray) {
+ StringBuilder argumentsString = new StringBuilder("**" + label + ":**");
+
+ if (argumentsArray != null && argumentsArray.length != 0) {
+ argumentsString.append("\n");
+ for (int i = 0; i != argumentsArray.length; i++) {
+ argumentsString.append(MarkdownUtils.getMultiLineMarkdownStringEntry("Arg " + (i + 1), argumentsArray[i], "-")).append("\n");
+ }
+ } else{
+ argumentsString.append(" - ");
+ }
+
+ return argumentsString.toString();
+ }
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/shell/command/ShellCommandConstants.java b/termux/termux-shared/src/main/java/com/termux/shared/shell/command/ShellCommandConstants.java
new file mode 100644
index 000000000..359cb4a9c
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/shell/command/ShellCommandConstants.java
@@ -0,0 +1,75 @@
+package com.termux.shared.shell.command;
+
+import com.termux.shared.errors.Errno;
+import com.termux.shared.shell.command.result.ResultConfig;
+
+import java.util.Formatter;
+import java.util.IllegalFormatException;
+
+public class ShellCommandConstants {
+
+ /**
+ * Class to send back results of commands to their callers like plugin or 3rd party apps.
+ */
+ public static final class RESULT_SENDER {
+
+ /*
+ * The default `Formatter` format strings to use for `ResultConfig#resultFileBasename`
+ * if `ResultConfig#resultSingleFile` is `true`.
+ */
+
+ /** The {@link Formatter} format string for success if only `stdout` needs to be written to
+ * {@link ResultConfig#resultFileBasename} where `stdout` maps to `%1$s`.
+ * This is used when `err` equals {@link Errno#ERRNO_SUCCESS} (-1) and `stderr` is empty
+ * and `exit_code` equals `0` and {@link ResultConfig#resultFileOutputFormat} is not passed. */
+ public static final String FORMAT_SUCCESS_STDOUT = "%1$s%n";
+ /** The {@link Formatter} format string for success if `stdout` and `exit_code` need to be written to
+ * {@link ResultConfig#resultFileBasename} where `stdout` maps to `%1$s` and `exit_code` to `%2$s`.
+ * This is used when `err` equals {@link Errno#ERRNO_SUCCESS} (-1) and `stderr` is empty
+ * and `exit_code` does not equal `0` and {@link ResultConfig#resultFileOutputFormat} is not passed.
+ * The exit code will be placed in a markdown inline code. */
+ public static final String FORMAT_SUCCESS_STDOUT__EXIT_CODE = "%1$s%n%n%n%nexit_code=%2$s%n";
+ /** The {@link Formatter} format string for success if `stdout`, `stderr` and `exit_code` need to be
+ * written to {@link ResultConfig#resultFileBasename} where `stdout` maps to `%1$s`, `stderr`
+ * maps to `%2$s` and `exit_code` to `%3$s`.
+ * This is used when `err` equals {@link Errno#ERRNO_SUCCESS} (-1) and `stderr` is not empty
+ * and {@link ResultConfig#resultFileOutputFormat} is not passed.
+ * The stdout and stderr will be placed in a markdown code block. The exit code will be placed
+ * in a markdown inline code. The surrounding backticks will be 3 more than the consecutive
+ * backticks in any parameter itself for code blocks. */
+ public static final String FORMAT_SUCCESS_STDOUT__STDERR__EXIT_CODE = "stdout=%n%1$s%n%n%n%nstderr=%n%2$s%n%n%n%nexit_code=%3$s%n";
+ /** The {@link Formatter} format string for failure if `err`, `errmsg`(`error`), `stdout`,
+ * `stderr` and `exit_code` need to be written to {@link ResultConfig#resultFileBasename} where
+ * `err` maps to `%1$s`, `errmsg` maps to `%2$s`, `stdout` maps
+ * to `%3$s`, `stderr` to `%4$s` and `exit_code` maps to `%5$s`.
+ * Do not define an argument greater than `5`, like `%6$s` if you change this value since it will
+ * raise {@link IllegalFormatException}.
+ * This is used when `err` does not equal {@link Errno#ERRNO_SUCCESS} (-1) and
+ * {@link ResultConfig#resultFileErrorFormat} is not passed.
+ * The errmsg, stdout and stderr will be placed in a markdown code block. The err and exit code
+ * will be placed in a markdown inline code. The surrounding backticks will be 3 more than
+ * the consecutive backticks in any parameter itself for code blocks. The stdout, stderr
+ * and exit code may be empty without any surrounding backticks if not set. */
+ public static final String FORMAT_FAILED_ERR__ERRMSG__STDOUT__STDERR__EXIT_CODE = "err=%1$s%n%n%n%nerrmsg=%n%2$s%n%n%n%nstdout=%n%3$s%n%n%n%nstderr=%n%4$s%n%n%n%nexit_code=%5$s%n";
+
+
+
+ /*
+ * The default prefixes to use for result files under `ResultConfig#resultDirectoryPath`
+ * if `ResultConfig#resultSingleFile` is `false`.
+ */
+
+ /** The prefix for the err result file. */
+ public static final String RESULT_FILE_ERR_PREFIX = "err";
+ /** The prefix for the errmsg result file. */
+ public static final String RESULT_FILE_ERRMSG_PREFIX = "errmsg";
+ /** The prefix for the stdout result file. */
+ public static final String RESULT_FILE_STDOUT_PREFIX = "stdout";
+ /** The prefix for the stderr result file. */
+ public static final String RESULT_FILE_STDERR_PREFIX = "stderr";
+ /** The prefix for the exitCode result file. */
+ public static final String RESULT_FILE_EXIT_CODE_PREFIX = "exit_code";
+
+ }
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/shell/command/environment/AndroidShellEnvironment.java b/termux/termux-shared/src/main/java/com/termux/shared/shell/command/environment/AndroidShellEnvironment.java
new file mode 100644
index 000000000..8d113d015
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/shell/command/environment/AndroidShellEnvironment.java
@@ -0,0 +1,101 @@
+package com.termux.shared.shell.command.environment;
+
+import android.content.Context;
+
+import androidx.annotation.NonNull;
+
+import com.itsaky.androidide.utils.Environment;
+import com.termux.shared.shell.command.ExecutionCommand;
+
+import java.io.File;
+import java.util.HashMap;
+
+/**
+ * Environment for Android.
+ *
+ * https://cs.android.com/android/platform/superproject/+/android-12.0.0_r32:frameworks/base/core/java/android/os/Environment.java
+ * https://cs.android.com/android/platform/superproject/+/android-12.0.0_r32:system/core/rootdir/init.environ.rc.in
+ * https://cs.android.com/android/platform/superproject/+/android-5.0.0_r1.0.1:system/core/rootdir/init.environ.rc.in
+ * https://cs.android.com/android/_/android/platform/system/core/+/refs/tags/android-12.0.0_r32:rootdir/init.rc;l=910
+ * https://cs.android.com/android/platform/superproject/+/android-12.0.0_r32:packages/modules/SdkExtensions/derive_classpath/derive_classpath.cpp;l=96
+ */
+public class AndroidShellEnvironment extends UnixShellEnvironment {
+
+ protected ShellCommandShellEnvironment shellCommandShellEnvironment;
+
+ public AndroidShellEnvironment() {
+ shellCommandShellEnvironment = new ShellCommandShellEnvironment();
+ }
+
+ /** Get shell environment for Android. */
+ @NonNull
+ @Override
+ public HashMap getEnvironment(@NonNull Context currentPackageContext, boolean isFailSafe) {
+ HashMap environment = new HashMap<>(Environment.getEnvironment());
+
+ environment.put(ENV_HOME, "/");
+ environment.put(ENV_LANG, "en_US.UTF-8");
+ environment.put(ENV_PATH, System.getenv(ENV_PATH));
+ environment.put(ENV_TMPDIR, "/data/local/tmp");
+
+ environment.put(ENV_COLORTERM, "truecolor");
+ environment.put(ENV_TERM, "xterm-256color");
+
+ ShellEnvironmentUtils.putToEnvIfInSystemEnv(environment, "ANDROID_ASSETS");
+ ShellEnvironmentUtils.putToEnvIfInSystemEnv(environment, "ANDROID_DATA");
+ ShellEnvironmentUtils.putToEnvIfInSystemEnv(environment, "ANDROID_ROOT");
+ ShellEnvironmentUtils.putToEnvIfInSystemEnv(environment, "ANDROID_STORAGE");
+
+ // EXTERNAL_STORAGE is needed for /system/bin/am to work on at least
+ // Samsung S7 - see https://plus.google.com/110070148244138185604/posts/gp8Lk3aCGp3.
+ // https://cs.android.com/android/_/android/platform/system/core/+/fc000489
+ ShellEnvironmentUtils.putToEnvIfInSystemEnv(environment, "EXTERNAL_STORAGE");
+ ShellEnvironmentUtils.putToEnvIfInSystemEnv(environment, "ASEC_MOUNTPOINT");
+ ShellEnvironmentUtils.putToEnvIfInSystemEnv(environment, "LOOP_MOUNTPOINT");
+
+ ShellEnvironmentUtils.putToEnvIfInSystemEnv(environment, "ANDROID_RUNTIME_ROOT");
+ ShellEnvironmentUtils.putToEnvIfInSystemEnv(environment, "ANDROID_ART_ROOT");
+ ShellEnvironmentUtils.putToEnvIfInSystemEnv(environment, "ANDROID_I18N_ROOT");
+ ShellEnvironmentUtils.putToEnvIfInSystemEnv(environment, "ANDROID_TZDATA_ROOT");
+
+ ShellEnvironmentUtils.putToEnvIfInSystemEnv(environment, "BOOTCLASSPATH");
+ ShellEnvironmentUtils.putToEnvIfInSystemEnv(environment, "DEX2OATBOOTCLASSPATH");
+ ShellEnvironmentUtils.putToEnvIfInSystemEnv(environment, "SYSTEMSERVERCLASSPATH");
+
+ return environment;
+ }
+
+
+
+ @NonNull
+ @Override
+ public String getDefaultWorkingDirectoryPath() {
+ return "/";
+ }
+
+
+ @NonNull
+ @Override
+ public String getDefaultBinPath() {
+ return "/system/bin";
+ }
+
+ @NonNull
+ @Override
+ public HashMap setupShellCommandEnvironment(@NonNull Context currentPackageContext,
+ @NonNull ExecutionCommand executionCommand) {
+ HashMap environment = getEnvironment(currentPackageContext, executionCommand.isFailsafe);
+
+ String workingDirectory = executionCommand.workingDirectory;
+ environment.put(ENV_PWD,
+ workingDirectory != null && !workingDirectory.isEmpty() ? new File(workingDirectory).getAbsolutePath() : // PWD must be absolute path
+ getDefaultWorkingDirectoryPath());
+ ShellEnvironmentUtils.createHomeDir(environment);
+
+ if (executionCommand.setShellCommandShellEnvironment && shellCommandShellEnvironment != null)
+ environment.putAll(shellCommandShellEnvironment.getEnvironment(currentPackageContext, executionCommand));
+
+ return environment;
+ }
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/shell/command/environment/IShellEnvironment.java b/termux/termux-shared/src/main/java/com/termux/shared/shell/command/environment/IShellEnvironment.java
new file mode 100644
index 000000000..a8e75e9af
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/shell/command/environment/IShellEnvironment.java
@@ -0,0 +1,52 @@
+package com.termux.shared.shell.command.environment;
+
+import android.content.Context;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.termux.shared.shell.command.ExecutionCommand;
+
+import java.util.HashMap;
+
+public interface IShellEnvironment {
+
+ /**
+ * Get the default working directory path for the environment in case the path that was passed
+ * was {@code null} or empty.
+ *
+ * @return Should return the default working directory path.
+ */
+ @NonNull
+ String getDefaultWorkingDirectoryPath();
+
+ /**
+ * Get the default "/bin" path, like $PREFIX/bin.
+ *
+ * @return Should return the "/bin" path.
+ */
+ @NonNull
+ String getDefaultBinPath();
+
+ /**
+ * Setup shell command arguments for the file to execute, like interpreter, etc.
+ *
+ * @param fileToExecute The file to execute.
+ * @param arguments The arguments to pass to the executable.
+ * @return Should return the final process arguments.
+ */
+ @NonNull
+ String[] setupShellCommandArguments(@NonNull String fileToExecute, @Nullable String[] arguments);
+
+ /**
+ * Setup shell command environment to be used for commands.
+ *
+ * @param currentPackageContext The {@link Context} for the current package.
+ * @param executionCommand The {@link ExecutionCommand} for which to set environment.
+ * @return Should return the shell environment.
+ */
+ @NonNull
+ HashMap setupShellCommandEnvironment(@NonNull Context currentPackageContext,
+ @NonNull ExecutionCommand executionCommand);
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/shell/command/environment/ShellCommandShellEnvironment.java b/termux/termux-shared/src/main/java/com/termux/shared/shell/command/environment/ShellCommandShellEnvironment.java
new file mode 100644
index 000000000..a898dec91
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/shell/command/environment/ShellCommandShellEnvironment.java
@@ -0,0 +1,62 @@
+package com.termux.shared.shell.command.environment;
+
+import android.content.Context;
+
+import androidx.annotation.NonNull;
+
+import com.termux.shared.shell.command.ExecutionCommand;
+
+import java.util.HashMap;
+
+/**
+ * Environment for {@link ExecutionCommand}.
+ */
+public class ShellCommandShellEnvironment {
+
+ /** Environment variable prefix for the {@link ExecutionCommand}. */
+ public static final String SHELL_CMD_ENV_PREFIX = "SHELL_CMD__";
+
+ /** Environment variable for the {@link ExecutionCommand.Runner} name. */
+ public static final String ENV_SHELL_CMD__RUNNER_NAME = SHELL_CMD_ENV_PREFIX + "RUNNER_NAME";
+
+ /** Environment variable for the package name running the {@link ExecutionCommand}. */
+ public static final String ENV_SHELL_CMD__PACKAGE_NAME = SHELL_CMD_ENV_PREFIX + "PACKAGE_NAME";
+
+ /** Environment variable for the {@link ExecutionCommand#id}/TermuxShellManager.SHELL_ID name.
+ * This will be common for all runners. */
+ public static final String ENV_SHELL_CMD__SHELL_ID = SHELL_CMD_ENV_PREFIX + "SHELL_ID";
+
+ /** Environment variable for the {@link ExecutionCommand#shellName} name. */
+ public static final String ENV_SHELL_CMD__SHELL_NAME = SHELL_CMD_ENV_PREFIX + "SHELL_NAME";
+
+ /** Environment variable for the {@link ExecutionCommand.Runner#APP_SHELL} number since boot. */
+ public static final String ENV_SHELL_CMD__APP_SHELL_NUMBER_SINCE_BOOT = SHELL_CMD_ENV_PREFIX + "APP_SHELL_NUMBER_SINCE_BOOT";
+
+ /** Environment variable for the {{@link ExecutionCommand.Runner#TERMINAL_SESSION} number since boot. */
+ public static final String ENV_SHELL_CMD__TERMINAL_SESSION_NUMBER_SINCE_BOOT = SHELL_CMD_ENV_PREFIX + "TERMINAL_SESSION_NUMBER_SINCE_BOOT";
+
+ /** Environment variable for the {@link ExecutionCommand.Runner#APP_SHELL} number since app start. */
+ public static final String ENV_SHELL_CMD__APP_SHELL_NUMBER_SINCE_APP_START = SHELL_CMD_ENV_PREFIX + "APP_SHELL_NUMBER_SINCE_APP_START";
+
+ /** Environment variable for the {@link ExecutionCommand.Runner#TERMINAL_SESSION} number since app start. */
+ public static final String ENV_SHELL_CMD__TERMINAL_SESSION_NUMBER_SINCE_APP_START = SHELL_CMD_ENV_PREFIX + "TERMINAL_SESSION_NUMBER_SINCE_APP_START";
+
+
+ /** Get shell environment containing info for {@link ExecutionCommand}. */
+ @NonNull
+ public HashMap getEnvironment(@NonNull Context currentPackageContext,
+ @NonNull ExecutionCommand executionCommand) {
+ HashMap environment = new HashMap<>();
+
+ ExecutionCommand.Runner runner = ExecutionCommand.Runner.runnerOf(executionCommand.runner);
+ if (runner == null) return environment;
+
+ ShellEnvironmentUtils.putToEnvIfSet(environment, ENV_SHELL_CMD__RUNNER_NAME, runner.getName());
+ ShellEnvironmentUtils.putToEnvIfSet(environment, ENV_SHELL_CMD__PACKAGE_NAME, currentPackageContext.getPackageName());
+ ShellEnvironmentUtils.putToEnvIfSet(environment, ENV_SHELL_CMD__SHELL_ID, String.valueOf(executionCommand.id));
+ ShellEnvironmentUtils.putToEnvIfSet(environment, ENV_SHELL_CMD__SHELL_NAME, executionCommand.shellName);
+
+ return environment;
+ }
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/shell/command/environment/ShellEnvironmentUtils.java b/termux/termux-shared/src/main/java/com/termux/shared/shell/command/environment/ShellEnvironmentUtils.java
new file mode 100644
index 000000000..9b331c333
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/shell/command/environment/ShellEnvironmentUtils.java
@@ -0,0 +1,180 @@
+package com.termux.shared.shell.command.environment;
+
+import static com.termux.shared.shell.command.environment.UnixShellEnvironment.*;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.termux.shared.errors.Error;
+import com.termux.shared.file.FileUtils;
+import com.termux.shared.logger.Logger;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+public class ShellEnvironmentUtils {
+
+ private static final String LOG_TAG = "ShellEnvironmentUtils";
+
+ /**
+ * Convert environment {@link HashMap} to `environ` {@link List }.
+ *
+ * The items in the environ will have the format `name=value`.
+ *
+ * Check {@link #isValidEnvironmentVariableName(String)} and {@link #isValidEnvironmentVariableValue(String)}
+ * for valid variable names and values.
+ *
+ * https://manpages.debian.org/testing/manpages/environ.7.en.html
+ * https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap08.html
+ */
+ @NonNull
+ public static List convertEnvironmentToEnviron(@NonNull HashMap environmentMap) {
+ List environmentList = new ArrayList<>(environmentMap.size());
+ String value;
+ for (String name : environmentMap.keySet()) {
+ value = environmentMap.get(name);
+ if (isValidEnvironmentVariableNameValuePair(name, value, true))
+ environmentList.add(name + "=" + environmentMap.get(name));
+ }
+ return environmentList;
+ }
+
+ /**
+ * Convert environment {@link HashMap} to {@link String} where each item equals "key=value".
+ *
+ */
+ @NonNull
+ public static String convertEnvironmentToDotEnvFile(@NonNull HashMap environmentMap) {
+ return convertEnvironmentToDotEnvFile(convertEnvironmentMapToEnvironmentVariableList(environmentMap));
+ }
+
+ /**
+ * Convert environment {@link HashMap} to `.env` file {@link String}.
+ *
+ * The items in the `.env` file have the format `export name="value"`.
+ *
+ * If the {@link ShellEnvironmentVariable#escaped} is set to {@code true}, then
+ * {@link ShellEnvironmentVariable#value} will be considered to be a literal value that has
+ * already been escaped by the caller, otherwise all the `"`\$` in the value will be escaped
+ * with `a backslash `\`, like `\"`. Note that if `$` is escaped and if its part of variable,
+ * then variable expansion will not happen if `.env` file is sourced.
+ *
+ * The `\` at the end of a value line means line continuation. Value can contain newline characters.
+ *
+ * Check {@link #isValidEnvironmentVariableName(String)} and {@link #isValidEnvironmentVariableValue(String)}
+ * for valid variable names and values.
+ *
+ * https://github.com/ko1nksm/shdotenv#env-file-syntax
+ * https://github.com/ko1nksm/shdotenv/blob/main/docs/specification.md
+ */
+ @NonNull
+ public static String convertEnvironmentToDotEnvFile(@NonNull List environmentList) {
+ StringBuilder environment = new StringBuilder();
+ Collections.sort(environmentList);
+ for (ShellEnvironmentVariable variable : environmentList) {
+ if (isValidEnvironmentVariableNameValuePair(variable.name, variable.value, true) && variable.value != null) {
+ environment.append("export ").append(variable.name).append("=\"")
+ .append(variable.escaped ? variable.value : variable.value.replaceAll("([\"`\\\\$])", "\\\\$1"))
+ .append("\"\n");
+ }
+ }
+ return environment.toString();
+ }
+
+ /**
+ * Convert environment {@link HashMap} to {@link List< ShellEnvironmentVariable >}. Each item
+ * will have its {@link ShellEnvironmentVariable#escaped} set to {@code false}.
+ */
+ @NonNull
+ public static List convertEnvironmentMapToEnvironmentVariableList(@NonNull HashMap environmentMap) {
+ List environmentList = new ArrayList<>();
+ for (String name :environmentMap.keySet()) {
+ environmentList.add(new ShellEnvironmentVariable(name, environmentMap.get(name), false));
+ }
+ return environmentList;
+ }
+
+ /**
+ * Check if environment variable name and value pair is valid. Errors will be logged if
+ * {@code logErrors} is {@code true}.
+ *
+ * Check {@link #isValidEnvironmentVariableName(String)} and {@link #isValidEnvironmentVariableValue(String)}
+ * for valid variable names and values.
+ */
+ public static boolean isValidEnvironmentVariableNameValuePair(@Nullable String name, @Nullable String value, boolean logErrors) {
+ if (!isValidEnvironmentVariableName(name)) {
+ if (logErrors)
+ Logger.logErrorPrivate(LOG_TAG, "Invalid environment variable name. name=`" + name + "`, value=`" + value + "`");
+ return false;
+ }
+
+ if (!isValidEnvironmentVariableValue(value)) {
+ if (logErrors)
+ Logger.logErrorPrivate(LOG_TAG, "Invalid environment variable value. name=`" + name + "`, value=`" + value + "`");
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Check if environment variable name is valid. It must not be {@code null} and must not contain
+ * the null byte ('\0') and must only contain alphanumeric and underscore characters and must not
+ * start with a digit.
+ */
+ public static boolean isValidEnvironmentVariableName(@Nullable String name) {
+ return name != null && !name.contains("\0") && name.matches("[a-zA-Z_][a-zA-Z0-9_]*");
+ }
+
+ /**
+ * Check if environment variable value is valid. It must not be {@code null} and must not contain
+ * the null byte ('\0').
+ */
+ public static boolean isValidEnvironmentVariableValue(@Nullable String value) {
+ return value != null && !value.contains("\0");
+ }
+
+
+
+ /** Put value in environment if variable exists in {@link System) environment. */
+ public static void putToEnvIfInSystemEnv(@NonNull HashMap environment,
+ @NonNull String name) {
+ String value = System.getenv(name);
+ if (value != null) {
+ environment.put(name, value);
+ }
+ }
+
+ /** Put {@link String} value in environment if value set. */
+ public static void putToEnvIfSet(@NonNull HashMap environment, @NonNull String name,
+ @Nullable String value) {
+ if (value != null) {
+ environment.put(name, value);
+ }
+ }
+
+ /** Put {@link Boolean} value "true" or "false" in environment if value set. */
+ public static void putToEnvIfSet(@NonNull HashMap environment, @NonNull String name,
+ @Nullable Boolean value) {
+ if (value != null) {
+ environment.put(name, String.valueOf(value));
+ }
+ }
+
+
+
+ /** Create HOME directory in environment {@link Map} if set. */
+ public static void createHomeDir(@NonNull HashMap environment) {
+ String homeDirectory = environment.get(ENV_HOME);
+ if (homeDirectory != null && !homeDirectory.isEmpty()) {
+ Error error = FileUtils.createDirectoryFile("shell home", homeDirectory);
+ if (error != null) {
+ Logger.logErrorExtended(LOG_TAG, "Failed to create shell home directory\n" + error.toString());
+ }
+ }
+ }
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/shell/command/environment/ShellEnvironmentVariable.java b/termux/termux-shared/src/main/java/com/termux/shared/shell/command/environment/ShellEnvironmentVariable.java
new file mode 100644
index 000000000..f4abee3a2
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/shell/command/environment/ShellEnvironmentVariable.java
@@ -0,0 +1,28 @@
+package com.termux.shared.shell.command.environment;
+
+public class ShellEnvironmentVariable implements Comparable {
+
+ /** The name for environment variable */
+ public String name;
+
+ /** The value for environment variable */
+ public String value;
+
+ /** If environment variable {@link #value} is already escaped. */
+ public boolean escaped;
+
+ public ShellEnvironmentVariable(String name, String value) {
+ this(name, value, false);
+ }
+
+ public ShellEnvironmentVariable(String name, String value, boolean escaped) {
+ this.name = name;
+ this.value = value;
+ this.escaped = escaped;
+ }
+
+ @Override
+ public int compareTo(ShellEnvironmentVariable other) {
+ return this.name.compareTo(other.name);
+ }
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/shell/command/environment/UnixShellEnvironment.java b/termux/termux-shared/src/main/java/com/termux/shared/shell/command/environment/UnixShellEnvironment.java
new file mode 100644
index 000000000..7f29a6403
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/shell/command/environment/UnixShellEnvironment.java
@@ -0,0 +1,83 @@
+package com.termux.shared.shell.command.environment;
+
+import android.content.Context;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.termux.shared.shell.ShellUtils;
+import com.termux.shared.shell.command.ExecutionCommand;
+
+import java.util.HashMap;
+
+/**
+ * Environment for Unix-like systems.
+ *
+ * https://manpages.debian.org/testing/manpages/environ.7.en.html
+ * https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap08.html
+ */
+public abstract class UnixShellEnvironment implements IShellEnvironment {
+
+ /** Environment variable for the terminal's colour capabilities. */
+ public static final String ENV_COLORTERM = "COLORTERM";
+
+ /** Environment variable for the path of the user's home directory. */
+ public static final String ENV_HOME = "HOME";
+
+ /** Environment variable for the locale category for native language, local customs, and coded
+ * character set in the absence of the LC_ALL and other LC_* environment variables. */
+ public static final String ENV_LANG = "LANG";
+
+ /** Environment variable for the represent the sequence of directory paths separated with
+ * colons ":" that should be searched in for dynamic shared libraries to link programs against. */
+ public static final String ENV_LD_LIBRARY_PATH = "LD_LIBRARY_PATH";
+
+ /** Environment variable for the represent the sequence of directory path prefixes separated with
+ * colons ":" that certain functions and utilities apply in searching for an executable file
+ * known only by a filename. */
+ public static final String ENV_PATH = "PATH";
+
+ /** Environment variable for the absolute path of the current working directory. It shall not
+ * contain any components that are dot or dot-dot. The value is set by the cd utility, and by
+ * the sh utility during initialization. */
+ public static final String ENV_PWD = "PWD";
+
+ /** Environment variable for the terminal type for which output is to be prepared. This information
+ * is used by utilities and application programs wishing to exploit special capabilities specific
+ * to a terminal. The format and allowable values of this environment variable are unspecified. */
+ public static final String ENV_TERM = "TERM";
+
+ /** Environment variable for the path of a directory made available for programs that need a place
+ * to create temporary files. */
+ public static final String ENV_TMPDIR = "TMPDIR";
+
+
+ /** Names for common/supported login shell binaries. */
+ public static final String[] LOGIN_SHELL_BINARIES = new String[]{"login", "bash", "zsh", "fish", "sh"};
+
+
+
+ @NonNull
+ public abstract HashMap getEnvironment(@NonNull Context currentPackageContext,
+ boolean isFailSafe);
+
+ @NonNull
+ @Override
+ public abstract String getDefaultWorkingDirectoryPath();
+
+ @NonNull
+ @Override
+ public abstract String getDefaultBinPath();
+
+ @NonNull
+ @Override
+ public String[] setupShellCommandArguments(@NonNull String executable, @Nullable String[] arguments) {
+ return ShellUtils.setupShellCommandArguments(executable, arguments);
+ }
+
+ @NonNull
+ @Override
+ public abstract HashMap setupShellCommandEnvironment(@NonNull Context currentPackageContext,
+ @NonNull ExecutionCommand executionCommand);
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/shell/command/result/ResultConfig.java b/termux/termux-shared/src/main/java/com/termux/shared/shell/command/result/ResultConfig.java
new file mode 100644
index 000000000..e3ab5cafc
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/shell/command/result/ResultConfig.java
@@ -0,0 +1,170 @@
+package com.termux.shared.shell.command.result;
+
+import android.app.PendingIntent;
+
+import androidx.annotation.NonNull;
+
+import com.termux.shared.logger.Logger;
+import com.termux.shared.markdown.MarkdownUtils;
+
+import java.util.Formatter;
+
+public class ResultConfig {
+
+ /** Defines {@link PendingIntent} that should be sent with the result of the command. We cannot
+ * implement {@link java.io.Serializable} because {@link PendingIntent} cannot be serialized. */
+ public PendingIntent resultPendingIntent;
+ /** The key with which to send result {@link android.os.Bundle} in {@link #resultPendingIntent}. */
+ public String resultBundleKey;
+ /** The key with which to send {@link ResultData#stdout} in {@link #resultPendingIntent}. */
+ public String resultStdoutKey;
+ /** The key with which to send {@link ResultData#stderr} in {@link #resultPendingIntent}. */
+ public String resultStderrKey;
+ /** The key with which to send {@link ResultData#exitCode} in {@link #resultPendingIntent}. */
+ public String resultExitCodeKey;
+ /** The key with which to send {@link ResultData#errorsList} errCode in {@link #resultPendingIntent}. */
+ public String resultErrCodeKey;
+ /** The key with which to send {@link ResultData#errorsList} errmsg in {@link #resultPendingIntent}. */
+ public String resultErrmsgKey;
+ /** The key with which to send original length of {@link ResultData#stdout} in {@link #resultPendingIntent}. */
+ public String resultStdoutOriginalLengthKey;
+ /** The key with which to send original length of {@link ResultData#stderr} in {@link #resultPendingIntent}. */
+ public String resultStderrOriginalLengthKey;
+
+
+ /** Defines the directory path in which to write the result of the command. */
+ public String resultDirectoryPath;
+ /** Defines the directory path under which {@link #resultDirectoryPath} can exist. */
+ public String resultDirectoryAllowedParentPath;
+ /** Defines whether the result should be written to a single file or multiple files
+ * (err, error, stdout, stderr, exit_code) in {@link #resultDirectoryPath}. */
+ public boolean resultSingleFile;
+ /** Defines the basename of the result file that should be created in {@link #resultDirectoryPath}
+ * if {@link #resultSingleFile} is {@code true}. */
+ public String resultFileBasename;
+ /** Defines the output {@link Formatter} format of the {@link #resultFileBasename} result file. */
+ public String resultFileOutputFormat;
+ /** Defines the error {@link Formatter} format of the {@link #resultFileBasename} result file. */
+ public String resultFileErrorFormat;
+ /** Defines the suffix of the result files that should be created in {@link #resultDirectoryPath}
+ * if {@link #resultSingleFile} is {@code true}. */
+ public String resultFilesSuffix;
+
+
+ public ResultConfig() {
+ }
+
+
+ public boolean isCommandWithPendingResult() {
+ return resultPendingIntent != null || resultDirectoryPath != null;
+ }
+
+
+ @NonNull
+ @Override
+ public String toString() {
+ return getResultConfigLogString(this, true);
+ }
+
+ /**
+ * Get a log friendly {@link String} for {@link ResultConfig} parameters.
+ *
+ * @param resultConfig The {@link ResultConfig} to convert.
+ * @param ignoreNull Set to {@code true} if non-critical {@code null} values are to be ignored.
+ * @return Returns the log friendly {@link String}.
+ */
+ public static String getResultConfigLogString(final ResultConfig resultConfig, boolean ignoreNull) {
+ if (resultConfig == null) return "null";
+
+ StringBuilder logString = new StringBuilder();
+
+ logString.append("Result Pending: `").append(resultConfig.isCommandWithPendingResult()).append("`\n");
+
+ if (resultConfig.resultPendingIntent != null) {
+ logString.append(resultConfig.getResultPendingIntentVariablesLogString(ignoreNull));
+ if (resultConfig.resultDirectoryPath != null)
+ logString.append("\n");
+ }
+
+ if (resultConfig.resultDirectoryPath != null && !resultConfig.resultDirectoryPath.isEmpty())
+ logString.append(resultConfig.getResultDirectoryVariablesLogString(ignoreNull));
+
+ return logString.toString();
+ }
+
+ public String getResultPendingIntentVariablesLogString(boolean ignoreNull) {
+ if (resultPendingIntent == null) return "Result PendingIntent Creator: -";
+
+ StringBuilder resultPendingIntentVariablesString = new StringBuilder();
+
+ resultPendingIntentVariablesString.append("Result PendingIntent Creator: `").append(resultPendingIntent.getCreatorPackage()).append("`");
+
+ if (!ignoreNull || resultBundleKey != null)
+ resultPendingIntentVariablesString.append("\n").append(Logger.getSingleLineLogStringEntry("Result Bundle Key", resultBundleKey, "-"));
+ if (!ignoreNull || resultStdoutKey != null)
+ resultPendingIntentVariablesString.append("\n").append(Logger.getSingleLineLogStringEntry("Result Stdout Key", resultStdoutKey, "-"));
+ if (!ignoreNull || resultStderrKey != null)
+ resultPendingIntentVariablesString.append("\n").append(Logger.getSingleLineLogStringEntry("Result Stderr Key", resultStderrKey, "-"));
+ if (!ignoreNull || resultExitCodeKey != null)
+ resultPendingIntentVariablesString.append("\n").append(Logger.getSingleLineLogStringEntry("Result Exit Code Key", resultExitCodeKey, "-"));
+ if (!ignoreNull || resultErrCodeKey != null)
+ resultPendingIntentVariablesString.append("\n").append(Logger.getSingleLineLogStringEntry("Result Err Code Key", resultErrCodeKey, "-"));
+ if (!ignoreNull || resultErrmsgKey != null)
+ resultPendingIntentVariablesString.append("\n").append(Logger.getSingleLineLogStringEntry("Result Error Key", resultErrmsgKey, "-"));
+ if (!ignoreNull || resultStdoutOriginalLengthKey != null)
+ resultPendingIntentVariablesString.append("\n").append(Logger.getSingleLineLogStringEntry("Result Stdout Original Length Key", resultStdoutOriginalLengthKey, "-"));
+ if (!ignoreNull || resultStderrOriginalLengthKey != null)
+ resultPendingIntentVariablesString.append("\n").append(Logger.getSingleLineLogStringEntry("Result Stderr Original Length Key", resultStderrOriginalLengthKey, "-"));
+
+ return resultPendingIntentVariablesString.toString();
+ }
+
+ public String getResultDirectoryVariablesLogString(boolean ignoreNull) {
+ if (resultDirectoryPath == null) return "Result Directory Path: -";
+
+ StringBuilder resultDirectoryVariablesString = new StringBuilder();
+
+ resultDirectoryVariablesString.append(Logger.getSingleLineLogStringEntry("Result Directory Path", resultDirectoryPath, "-"));
+
+ resultDirectoryVariablesString.append("\n").append(Logger.getSingleLineLogStringEntry("Result Single File", resultSingleFile, "-"));
+ if (!ignoreNull || resultFileBasename != null)
+ resultDirectoryVariablesString.append("\n").append(Logger.getSingleLineLogStringEntry("Result File Basename", resultFileBasename, "-"));
+ if (!ignoreNull || resultFileOutputFormat != null)
+ resultDirectoryVariablesString.append("\n").append(Logger.getSingleLineLogStringEntry("Result File Output Format", resultFileOutputFormat, "-"));
+ if (!ignoreNull || resultFileErrorFormat != null)
+ resultDirectoryVariablesString.append("\n").append(Logger.getSingleLineLogStringEntry("Result File Error Format", resultFileErrorFormat, "-"));
+ if (!ignoreNull || resultFilesSuffix != null)
+ resultDirectoryVariablesString.append("\n").append(Logger.getSingleLineLogStringEntry("Result Files Suffix", resultFilesSuffix, "-"));
+
+ return resultDirectoryVariablesString.toString();
+ }
+
+ /**
+ * Get a markdown {@link String} for {@link ResultConfig}.
+ *
+ * @param resultConfig The {@link ResultConfig} to convert.
+ * @return Returns the markdown {@link String}.
+ */
+ public static String getResultConfigMarkdownString(final ResultConfig resultConfig) {
+ if (resultConfig == null) return "null";
+
+ StringBuilder markdownString = new StringBuilder();
+
+ if (resultConfig.resultPendingIntent != null)
+ markdownString.append(MarkdownUtils.getSingleLineMarkdownStringEntry("Result PendingIntent Creator", resultConfig.resultPendingIntent.getCreatorPackage(), "-"));
+ else
+ markdownString.append("**Result PendingIntent Creator:** - ");
+
+ if (resultConfig.resultDirectoryPath != null) {
+ markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("Result Directory Path", resultConfig.resultDirectoryPath, "-"));
+ markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("Result Single File", resultConfig.resultSingleFile, "-"));
+ markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("Result File Basename", resultConfig.resultFileBasename, "-"));
+ markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("Result File Output Format", resultConfig.resultFileOutputFormat, "-"));
+ markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("Result File Error Format", resultConfig.resultFileErrorFormat, "-"));
+ markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("Result Files Suffix", resultConfig.resultFilesSuffix, "-"));
+ }
+
+ return markdownString.toString();
+ }
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/shell/command/result/ResultData.java b/termux/termux-shared/src/main/java/com/termux/shared/shell/command/result/ResultData.java
new file mode 100644
index 000000000..3e4253208
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/shell/command/result/ResultData.java
@@ -0,0 +1,258 @@
+package com.termux.shared.shell.command.result;
+
+import androidx.annotation.NonNull;
+
+import com.termux.shared.data.DataUtils;
+import com.termux.shared.logger.Logger;
+import com.termux.shared.markdown.MarkdownUtils;
+import com.termux.shared.errors.Errno;
+import com.termux.shared.errors.Error;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+public class ResultData implements Serializable {
+
+ /** The stdout of command. */
+ public final StringBuilder stdout = new StringBuilder();
+ /** The stderr of command. */
+ public final StringBuilder stderr = new StringBuilder();
+ /** The exit code of command. */
+ public Integer exitCode;
+
+ /** The internal errors list of command. */
+ public List errorsList = new ArrayList<>();
+
+
+ public ResultData() {
+ }
+
+
+ public void clearStdout() {
+ stdout.setLength(0);
+ }
+
+ public StringBuilder prependStdout(String message) {
+ return stdout.insert(0, message);
+ }
+
+ public StringBuilder prependStdoutLn(String message) {
+ return stdout.insert(0, message + "\n");
+ }
+
+ public StringBuilder appendStdout(String message) {
+ return stdout.append(message);
+ }
+
+ public StringBuilder appendStdoutLn(String message) {
+ return stdout.append(message).append("\n");
+ }
+
+
+ public void clearStderr() {
+ stderr.setLength(0);
+ }
+
+ public StringBuilder prependStderr(String message) {
+ return stderr.insert(0, message);
+ }
+
+ public StringBuilder prependStderrLn(String message) {
+ return stderr.insert(0, message + "\n");
+ }
+
+ public StringBuilder appendStderr(String message) {
+ return stderr.append(message);
+ }
+
+ public StringBuilder appendStderrLn(String message) {
+ return stderr.append(message).append("\n");
+ }
+
+
+ public synchronized boolean setStateFailed(@NonNull Error error) {
+ return setStateFailed(error.getType(), error.getCode(), error.getMessage(), null);
+ }
+
+ public synchronized boolean setStateFailed(@NonNull Error error, Throwable throwable) {
+ return setStateFailed(error.getType(), error.getCode(), error.getMessage(), Collections.singletonList(throwable));
+ }
+ public synchronized boolean setStateFailed(@NonNull Error error, List throwablesList) {
+ return setStateFailed(error.getType(), error.getCode(), error.getMessage(), throwablesList);
+ }
+
+ public synchronized boolean setStateFailed(int code, String message) {
+ return setStateFailed(null, code, message, null);
+ }
+
+ public synchronized boolean setStateFailed(int code, String message, Throwable throwable) {
+ return setStateFailed(null, code, message, Collections.singletonList(throwable));
+ }
+
+ public synchronized boolean setStateFailed(int code, String message, List throwablesList) {
+ return setStateFailed(null, code, message, throwablesList);
+ }
+
+ public synchronized boolean setStateFailed(String type, int code, String message, List throwablesList) {
+ if (errorsList == null)
+ errorsList = new ArrayList<>();
+
+ Error error = new Error();
+ errorsList.add(error);
+
+ return error.setStateFailed(type, code, message, throwablesList);
+ }
+
+ public boolean isStateFailed() {
+ if (errorsList != null) {
+ for (Error error : errorsList)
+ if (error.isStateFailed())
+ return true;
+ }
+
+ return false;
+ }
+
+ public int getErrCode() {
+ if (errorsList != null && errorsList.size() > 0)
+ return errorsList.get(errorsList.size() - 1).getCode();
+ else
+ return Errno.ERRNO_SUCCESS.getCode();
+ }
+
+
+ @NonNull
+ @Override
+ public String toString() {
+ return getResultDataLogString(this, true);
+ }
+
+ /**
+ * Get a log friendly {@link String} for {@link ResultData} parameters.
+ *
+ * @param resultData The {@link ResultData} to convert.
+ * @param logStdoutAndStderr Set to {@code true} if {@link #stdout} and {@link #stderr} should be logged.
+ * @return Returns the log friendly {@link String}.
+ */
+ public static String getResultDataLogString(final ResultData resultData, boolean logStdoutAndStderr) {
+ if (resultData == null) return "null";
+
+ StringBuilder logString = new StringBuilder();
+
+ if (logStdoutAndStderr) {
+ logString.append("\n").append(resultData.getStdoutLogString());
+ logString.append("\n").append(resultData.getStderrLogString());
+ }
+ logString.append("\n").append(resultData.getExitCodeLogString());
+
+ logString.append("\n\n").append(getErrorsListLogString(resultData));
+
+ return logString.toString();
+ }
+
+
+
+ public String getStdoutLogString() {
+ if (stdout.toString().isEmpty())
+ return Logger.getSingleLineLogStringEntry("Stdout", null, "-");
+ else
+ return Logger.getMultiLineLogStringEntry("Stdout", DataUtils.getTruncatedCommandOutput(stdout.toString(), Logger.LOGGER_ENTRY_MAX_SAFE_PAYLOAD / 5, false, false, true), "-");
+ }
+
+ public String getStderrLogString() {
+ if (stderr.toString().isEmpty())
+ return Logger.getSingleLineLogStringEntry("Stderr", null, "-");
+ else
+ return Logger.getMultiLineLogStringEntry("Stderr", DataUtils.getTruncatedCommandOutput(stderr.toString(), Logger.LOGGER_ENTRY_MAX_SAFE_PAYLOAD / 5, false, false, true), "-");
+ }
+
+ public String getExitCodeLogString() {
+ return Logger.getSingleLineLogStringEntry("Exit Code", exitCode, "-");
+ }
+
+ public static String getErrorsListLogString(final ResultData resultData) {
+ if (resultData == null) return "null";
+
+ StringBuilder logString = new StringBuilder();
+
+ if (resultData.errorsList != null) {
+ for (Error error : resultData.errorsList) {
+ if (error.isStateFailed()) {
+ if (!logString.toString().isEmpty())
+ logString.append("\n");
+ logString.append(Error.getErrorLogString(error));
+ }
+ }
+ }
+
+ return logString.toString();
+ }
+
+ /**
+ * Get a markdown {@link String} for {@link ResultData}.
+ *
+ * @param resultData The {@link ResultData} to convert.
+ * @return Returns the markdown {@link String}.
+ */
+ public static String getResultDataMarkdownString(final ResultData resultData) {
+ if (resultData == null) return "null";
+
+ StringBuilder markdownString = new StringBuilder();
+
+ if (resultData.stdout.toString().isEmpty())
+ markdownString.append(MarkdownUtils.getSingleLineMarkdownStringEntry("Stdout", null, "-"));
+ else
+ markdownString.append(MarkdownUtils.getMultiLineMarkdownStringEntry("Stdout", resultData.stdout.toString(), "-"));
+
+ if (resultData.stderr.toString().isEmpty())
+ markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("Stderr", null, "-"));
+ else
+ markdownString.append("\n").append(MarkdownUtils.getMultiLineMarkdownStringEntry("Stderr", resultData.stderr.toString(), "-"));
+
+ markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("Exit Code", resultData.exitCode, "-"));
+
+ markdownString.append("\n\n").append(getErrorsListMarkdownString(resultData));
+
+
+ return markdownString.toString();
+ }
+
+ public static String getErrorsListMarkdownString(final ResultData resultData) {
+ if (resultData == null) return "null";
+
+ StringBuilder markdownString = new StringBuilder();
+
+ if (resultData.errorsList != null) {
+ for (Error error : resultData.errorsList) {
+ if (error.isStateFailed()) {
+ if (!markdownString.toString().isEmpty())
+ markdownString.append("\n");
+ markdownString.append(Error.getErrorMarkdownString(error));
+ }
+ }
+ }
+
+ return markdownString.toString();
+ }
+
+ public static String getErrorsListMinimalString(final ResultData resultData) {
+ if (resultData == null) return "null";
+
+ StringBuilder minimalString = new StringBuilder();
+
+ if (resultData.errorsList != null) {
+ for (Error error : resultData.errorsList) {
+ if (error.isStateFailed()) {
+ if (!minimalString.toString().isEmpty())
+ minimalString.append("\n");
+ minimalString.append(Error.getMinimalErrorString(error));
+ }
+ }
+ }
+
+ return minimalString.toString();
+ }
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/shell/command/result/ResultSender.java b/termux/termux-shared/src/main/java/com/termux/shared/shell/command/result/ResultSender.java
new file mode 100644
index 000000000..181afcaa2
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/shell/command/result/ResultSender.java
@@ -0,0 +1,349 @@
+package com.termux.shared.shell.command.result;
+
+import android.app.Activity;
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+
+import com.termux.shared.R;
+import com.termux.shared.data.DataUtils;
+import com.termux.shared.markdown.MarkdownUtils;
+import com.termux.shared.errors.Error;
+import com.termux.shared.file.FileUtils;
+import com.termux.shared.logger.Logger;
+import com.termux.shared.errors.FunctionErrno;
+import com.termux.shared.android.AndroidUtils;
+import com.termux.shared.shell.command.ShellCommandConstants.RESULT_SENDER;
+
+public class ResultSender {
+
+ private static final String LOG_TAG = "ResultSender";
+
+ /**
+ * Send result stored in {@link ResultConfig} to command caller via
+ * {@link ResultConfig#resultPendingIntent} and/or by writing it to files in
+ * {@link ResultConfig#resultDirectoryPath}. If both are not {@code null}, then result will be
+ * sent via both.
+ *
+ * @param context The {@link Context} for operations.
+ * @param logTag The log tag to use for logging.
+ * @param label The label for the command.
+ * @param resultConfig The {@link ResultConfig} object containing information on how to send the result.
+ * @param resultData The {@link ResultData} object containing result data.
+ * @param logStdoutAndStderr Set to {@code true} if {@link ResultData#stdout} and {@link ResultData#stderr}
+ * should be logged.
+ * @return Returns the {@link Error} if failed to send the result, otherwise {@code null}.
+ */
+ public static Error sendCommandResultData(Context context, String logTag, String label, ResultConfig resultConfig, ResultData resultData, boolean logStdoutAndStderr) {
+ if (context == null || resultConfig == null || resultData == null)
+ return FunctionErrno.ERRNO_NULL_OR_EMPTY_PARAMETERS.getError("context, resultConfig or resultData", "sendCommandResultData");
+
+ Error error;
+
+ if (resultConfig.resultPendingIntent != null) {
+ error = sendCommandResultDataWithPendingIntent(context, logTag, label, resultConfig, resultData, logStdoutAndStderr);
+ if (error != null || resultConfig.resultDirectoryPath == null)
+ return error;
+ }
+
+ if (resultConfig.resultDirectoryPath != null) {
+ return sendCommandResultDataToDirectory(context, logTag, label, resultConfig, resultData, logStdoutAndStderr);
+ } else {
+ return FunctionErrno.ERRNO_UNSET_PARAMETERS.getError("resultConfig.resultPendingIntent or resultConfig.resultDirectoryPath", "sendCommandResultData");
+ }
+ }
+
+ /**
+ * Send result stored in {@link ResultConfig} to command caller via {@link ResultConfig#resultPendingIntent}.
+ *
+ * @param context The {@link Context} for operations.
+ * @param logTag The log tag to use for logging.
+ * @param label The label for the command.
+ * @param resultConfig The {@link ResultConfig} object containing information on how to send the result.
+ * @param resultData The {@link ResultData} object containing result data.
+ * @param logStdoutAndStderr Set to {@code true} if {@link ResultData#stdout} and {@link ResultData#stderr}
+ * should be logged.
+ * @return Returns the {@link Error} if failed to send the result, otherwise {@code null}.
+ */
+ public static Error sendCommandResultDataWithPendingIntent(Context context, String logTag, String label, ResultConfig resultConfig, ResultData resultData, boolean logStdoutAndStderr) {
+ if (context == null || resultConfig == null || resultData == null || resultConfig.resultPendingIntent == null || resultConfig.resultBundleKey == null)
+ return FunctionErrno.ERRNO_NULL_OR_EMPTY_PARAMETER.getError("context, resultConfig, resultData, resultConfig.resultPendingIntent or resultConfig.resultBundleKey", "sendCommandResultDataWithPendingIntent");
+
+ logTag = DataUtils.getDefaultIfNull(logTag, LOG_TAG);
+
+ Logger.logDebugExtended(logTag, "Sending result for command \"" + label + "\":\n" + resultConfig.toString() + "\n" + ResultData.getResultDataLogString(resultData, logStdoutAndStderr));
+
+ String resultDataStdout = resultData.stdout.toString();
+ String resultDataStderr = resultData.stderr.toString();
+
+ String truncatedStdout = null;
+ String truncatedStderr = null;
+
+ String stdoutOriginalLength = String.valueOf(resultDataStdout.length());
+ String stderrOriginalLength = String.valueOf(resultDataStderr.length());
+
+ // Truncate stdout and stdout to max TRANSACTION_SIZE_LIMIT_IN_BYTES
+ if (resultDataStderr.isEmpty()) {
+ truncatedStdout = DataUtils.getTruncatedCommandOutput(resultDataStdout, DataUtils.TRANSACTION_SIZE_LIMIT_IN_BYTES, false, false, false);
+ } else if (resultDataStdout.isEmpty()) {
+ truncatedStderr = DataUtils.getTruncatedCommandOutput(resultDataStderr, DataUtils.TRANSACTION_SIZE_LIMIT_IN_BYTES, false, false, false);
+ } else {
+ truncatedStdout = DataUtils.getTruncatedCommandOutput(resultDataStdout, DataUtils.TRANSACTION_SIZE_LIMIT_IN_BYTES / 2, false, false, false);
+ truncatedStderr = DataUtils.getTruncatedCommandOutput(resultDataStderr, DataUtils.TRANSACTION_SIZE_LIMIT_IN_BYTES / 2, false, false, false);
+ }
+
+ if (truncatedStdout != null && truncatedStdout.length() < resultDataStdout.length()) {
+ Logger.logWarn(logTag, "The result for command \"" + label + "\" stdout length truncated from " + stdoutOriginalLength + " to " + truncatedStdout.length());
+ resultDataStdout = truncatedStdout;
+ }
+
+ if (truncatedStderr != null && truncatedStderr.length() < resultDataStderr.length()) {
+ Logger.logWarn(logTag, "The result for command \"" + label + "\" stderr length truncated from " + stderrOriginalLength + " to " + truncatedStderr.length());
+ resultDataStderr = truncatedStderr;
+ }
+
+ String resultDataErrmsg = null;
+ if (resultData.isStateFailed()) {
+ resultDataErrmsg = ResultData.getErrorsListLogString(resultData);
+ if (resultDataErrmsg.isEmpty()) resultDataErrmsg = null;
+ }
+
+ String errmsgOriginalLength = (resultDataErrmsg == null) ? null : String.valueOf(resultDataErrmsg.length());
+
+ // Truncate error to max TRANSACTION_SIZE_LIMIT_IN_BYTES / 4
+ // trim from end to preserve start of stacktraces
+ String truncatedErrmsg = DataUtils.getTruncatedCommandOutput(resultDataErrmsg, DataUtils.TRANSACTION_SIZE_LIMIT_IN_BYTES / 4, true, false, false);
+ if (truncatedErrmsg != null && truncatedErrmsg.length() < resultDataErrmsg.length()) {
+ Logger.logWarn(logTag, "The result for command \"" + label + "\" error length truncated from " + errmsgOriginalLength + " to " + truncatedErrmsg.length());
+ resultDataErrmsg = truncatedErrmsg;
+ }
+
+
+ final Bundle resultBundle = new Bundle();
+ resultBundle.putString(resultConfig.resultStdoutKey, resultDataStdout);
+ resultBundle.putString(resultConfig.resultStdoutOriginalLengthKey, stdoutOriginalLength);
+ resultBundle.putString(resultConfig.resultStderrKey, resultDataStderr);
+ resultBundle.putString(resultConfig.resultStderrOriginalLengthKey, stderrOriginalLength);
+ if (resultData.exitCode != null)
+ resultBundle.putInt(resultConfig.resultExitCodeKey, resultData.exitCode);
+ resultBundle.putInt(resultConfig.resultErrCodeKey, resultData.getErrCode());
+ resultBundle.putString(resultConfig.resultErrmsgKey, resultDataErrmsg);
+
+ Intent resultIntent = new Intent();
+ resultIntent.putExtra(resultConfig.resultBundleKey, resultBundle);
+
+ try {
+ resultConfig.resultPendingIntent.send(context, Activity.RESULT_OK, resultIntent);
+ } catch (PendingIntent.CanceledException e) {
+ // The caller doesn't want the result? That's fine, just ignore
+ Logger.logDebug(logTag, "The command \"" + label + "\" creator " + resultConfig.resultPendingIntent.getCreatorPackage() + " does not want the results anymore");
+ }
+
+ return null;
+
+ }
+
+ /**
+ * Send result stored in {@link ResultConfig} to command caller by writing it to files in
+ * {@link ResultConfig#resultDirectoryPath}.
+ *
+ * @param context The {@link Context} for operations.
+ * @param logTag The log tag to use for logging.
+ * @param label The label for the command.
+ * @param resultConfig The {@link ResultConfig} object containing information on how to send the result.
+ * @param resultData The {@link ResultData} object containing result data.
+ * @param logStdoutAndStderr Set to {@code true} if {@link ResultData#stdout} and {@link ResultData#stderr}
+ * should be logged.
+ * @return Returns the {@link Error} if failed to send the result, otherwise {@code null}.
+ */
+ public static Error sendCommandResultDataToDirectory(Context context, String logTag, String label, ResultConfig resultConfig, ResultData resultData, boolean logStdoutAndStderr) {
+ if (context == null || resultConfig == null || resultData == null || DataUtils.isNullOrEmpty(resultConfig.resultDirectoryPath))
+ return FunctionErrno.ERRNO_NULL_OR_EMPTY_PARAMETER.getError("context, resultConfig, resultData or resultConfig.resultDirectoryPath", "sendCommandResultDataToDirectory");
+
+ logTag = DataUtils.getDefaultIfNull(logTag, LOG_TAG);
+
+ Error error;
+
+ String resultDataStdout = resultData.stdout.toString();
+ String resultDataStderr = resultData.stderr.toString();
+
+ String resultDataExitCode = "";
+ if (resultData.exitCode != null)
+ resultDataExitCode = String.valueOf(resultData.exitCode);
+
+ String resultDataErrmsg = null;
+ if (resultData.isStateFailed()) {
+ resultDataErrmsg = ResultData.getErrorsListLogString(resultData);
+ }
+ resultDataErrmsg = DataUtils.getDefaultIfNull(resultDataErrmsg, "");
+
+ resultConfig.resultDirectoryPath = FileUtils.getCanonicalPath(resultConfig.resultDirectoryPath, null);
+
+ Logger.logDebugExtended(logTag, "Writing result for command \"" + label + "\":\n" + resultConfig.toString() + "\n" + ResultData.getResultDataLogString(resultData, logStdoutAndStderr));
+
+ // If resultDirectoryPath is not a directory, or is not readable or writable, then just return
+ // Creation of missing directory and setting of read, write and execute permissions are
+ // only done if resultDirectoryPath is under resultDirectoryAllowedParentPath.
+ // We try to set execute permissions, but ignore if they are missing, since only read and write
+ // permissions are required for working directories.
+ error = FileUtils.validateDirectoryFileExistenceAndPermissions("result", resultConfig.resultDirectoryPath,
+ resultConfig.resultDirectoryAllowedParentPath, true,
+ FileUtils.APP_WORKING_DIRECTORY_PERMISSIONS, true, true,
+ true, true);
+ if (error != null) {
+ error.appendMessage("\n" + context.getString(R.string.msg_directory_absolute_path, "Result", resultConfig.resultDirectoryPath));
+ return error;
+ }
+
+ if (resultConfig.resultSingleFile) {
+ // If resultFileBasename is null, empty or contains forward slashes "/"
+ if (DataUtils.isNullOrEmpty(resultConfig.resultFileBasename) ||
+ resultConfig.resultFileBasename.contains("/")) {
+ error = ResultSenderErrno.ERROR_RESULT_FILE_BASENAME_NULL_OR_INVALID.getError(resultConfig.resultFileBasename);
+ return error;
+ }
+
+ String error_or_output;
+
+ if (resultData.isStateFailed()) {
+ try {
+ if (DataUtils.isNullOrEmpty(resultConfig.resultFileErrorFormat)) {
+ error_or_output = String.format(RESULT_SENDER.FORMAT_FAILED_ERR__ERRMSG__STDOUT__STDERR__EXIT_CODE,
+ MarkdownUtils.getMarkdownCodeForString(String.valueOf(resultData.getErrCode()), false),
+ MarkdownUtils.getMarkdownCodeForString(resultDataErrmsg, true),
+ MarkdownUtils.getMarkdownCodeForString(resultDataStdout, true),
+ MarkdownUtils.getMarkdownCodeForString(resultDataStderr, true),
+ MarkdownUtils.getMarkdownCodeForString(resultDataExitCode, false));
+ } else {
+ error_or_output = String.format(resultConfig.resultFileErrorFormat,
+ resultData.getErrCode(), resultDataErrmsg, resultDataStdout, resultDataStderr, resultDataExitCode);
+ }
+ } catch (Exception e) {
+ error = ResultSenderErrno.ERROR_FORMAT_RESULT_ERROR_FAILED_WITH_EXCEPTION.getError(e.getMessage());
+ return error;
+ }
+ } else {
+ try {
+ if (DataUtils.isNullOrEmpty(resultConfig.resultFileOutputFormat)) {
+ if (resultDataStderr.isEmpty() && resultDataExitCode.equals("0"))
+ error_or_output = String.format(RESULT_SENDER.FORMAT_SUCCESS_STDOUT, resultDataStdout);
+ else if (resultDataStderr.isEmpty())
+ error_or_output = String.format(RESULT_SENDER.FORMAT_SUCCESS_STDOUT__EXIT_CODE,
+ resultDataStdout,
+ MarkdownUtils.getMarkdownCodeForString(resultDataExitCode, false));
+ else
+ error_or_output = String.format(RESULT_SENDER.FORMAT_SUCCESS_STDOUT__STDERR__EXIT_CODE,
+ MarkdownUtils.getMarkdownCodeForString(resultDataStdout, true),
+ MarkdownUtils.getMarkdownCodeForString(resultDataStderr, true),
+ MarkdownUtils.getMarkdownCodeForString(resultDataExitCode, false));
+ } else {
+ error_or_output = String.format(resultConfig.resultFileOutputFormat,
+ resultDataStdout, resultDataStderr, resultDataExitCode);
+ }
+ } catch (Exception e) {
+ error = ResultSenderErrno.ERROR_FORMAT_RESULT_OUTPUT_FAILED_WITH_EXCEPTION.getError(e.getMessage());
+ return error;
+ }
+ }
+
+ // Write error or output to temp file
+ // Check errCode file creation below for explanation for why temp file is used
+ String temp_filename = resultConfig.resultFileBasename + "-" + AndroidUtils.getCurrentMilliSecondLocalTimeStamp();
+ error = FileUtils.writeTextToFile(temp_filename, resultConfig.resultDirectoryPath + "/" + temp_filename,
+ null, error_or_output, false);
+ if (error != null) {
+ return error;
+ }
+
+ // Move error or output temp file to final destination
+ error = FileUtils.moveRegularFile("error or output temp file", resultConfig.resultDirectoryPath + "/" + temp_filename,
+ resultConfig.resultDirectoryPath + "/" + resultConfig.resultFileBasename, false);
+ if (error != null) {
+ return error;
+ }
+ } else {
+ String filename;
+
+ // Default to no suffix, useful if user expects result in an empty directory, like created with mktemp
+ if (resultConfig.resultFilesSuffix == null)
+ resultConfig.resultFilesSuffix = "";
+
+ // If resultFilesSuffix contains forward slashes "/"
+ if (resultConfig.resultFilesSuffix.contains("/")) {
+ error = ResultSenderErrno.ERROR_RESULT_FILES_SUFFIX_INVALID.getError(resultConfig.resultFilesSuffix);
+ return error;
+ }
+
+ // Write result to result files under resultDirectoryPath
+
+ // Write stdout to file
+ if (!resultDataStdout.isEmpty()) {
+ filename = RESULT_SENDER.RESULT_FILE_STDOUT_PREFIX + resultConfig.resultFilesSuffix;
+ error = FileUtils.writeTextToFile(filename, resultConfig.resultDirectoryPath + "/" + filename,
+ null, resultDataStdout, false);
+ if (error != null) {
+ return error;
+ }
+ }
+
+ // Write stderr to file
+ if (!resultDataStderr.isEmpty()) {
+ filename = RESULT_SENDER.RESULT_FILE_STDERR_PREFIX + resultConfig.resultFilesSuffix;
+ error = FileUtils.writeTextToFile(filename, resultConfig.resultDirectoryPath + "/" + filename,
+ null, resultDataStderr, false);
+ if (error != null) {
+ return error;
+ }
+ }
+
+ // Write exitCode to file
+ if (!resultDataExitCode.isEmpty()) {
+ filename = RESULT_SENDER.RESULT_FILE_EXIT_CODE_PREFIX + resultConfig.resultFilesSuffix;
+ error = FileUtils.writeTextToFile(filename, resultConfig.resultDirectoryPath + "/" + filename,
+ null, resultDataExitCode, false);
+ if (error != null) {
+ return error;
+ }
+ }
+
+ // Write errmsg to file
+ if (resultData.isStateFailed() && !resultDataErrmsg.isEmpty()) {
+ filename = RESULT_SENDER.RESULT_FILE_ERRMSG_PREFIX + resultConfig.resultFilesSuffix;
+ error = FileUtils.writeTextToFile(filename, resultConfig.resultDirectoryPath + "/" + filename,
+ null, resultDataErrmsg, false);
+ if (error != null) {
+ return error;
+ }
+ }
+
+ // Write errCode to file
+ // This must be created after writing to other result files has already finished since
+ // caller should wait for this file to be created to be notified that the command has
+ // finished and should then start reading from the rest of the result files if they exist.
+ // Since there may be a delay between creation of errCode file and writing to it or flushing
+ // to disk, we create a temp file first and then move it to the final destination, since
+ // caller may otherwise read from an empty file in some cases.
+
+ // Write errCode to temp file
+ String temp_filename = RESULT_SENDER.RESULT_FILE_ERR_PREFIX + "-" + AndroidUtils.getCurrentMilliSecondLocalTimeStamp();
+ if (!resultConfig.resultFilesSuffix.isEmpty()) temp_filename = temp_filename + "-" + resultConfig.resultFilesSuffix;
+ error = FileUtils.writeTextToFile(temp_filename, resultConfig.resultDirectoryPath + "/" + temp_filename,
+ null, String.valueOf(resultData.getErrCode()), false);
+ if (error != null) {
+ return error;
+ }
+
+ // Move errCode temp file to final destination
+ filename = RESULT_SENDER.RESULT_FILE_ERR_PREFIX + resultConfig.resultFilesSuffix;
+ error = FileUtils.moveRegularFile(RESULT_SENDER.RESULT_FILE_ERR_PREFIX + " temp file", resultConfig.resultDirectoryPath + "/" + temp_filename,
+ resultConfig.resultDirectoryPath + "/" + filename, false);
+ if (error != null) {
+ return error;
+ }
+ }
+
+ return null;
+ }
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/shell/command/result/ResultSenderErrno.java b/termux/termux-shared/src/main/java/com/termux/shared/shell/command/result/ResultSenderErrno.java
new file mode 100644
index 000000000..e21a146cd
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/shell/command/result/ResultSenderErrno.java
@@ -0,0 +1,22 @@
+package com.termux.shared.shell.command.result;
+
+import com.termux.shared.errors.Errno;
+
+/** The {@link Class} that defines ResultSender error messages and codes. */
+public class ResultSenderErrno extends Errno {
+
+ public static final String TYPE = "ResultSender Error";
+
+
+ /* Errors for null or empty parameters (100-150) */
+ public static final Errno ERROR_RESULT_FILE_BASENAME_NULL_OR_INVALID = new Errno(TYPE, 100, "The result file basename \"%1$s\" is null, empty or contains forward slashes \"/\".");
+ public static final Errno ERROR_RESULT_FILES_SUFFIX_INVALID = new Errno(TYPE, 101, "The result files suffix \"%1$s\" contains forward slashes \"/\".");
+ public static final Errno ERROR_FORMAT_RESULT_ERROR_FAILED_WITH_EXCEPTION = new Errno(TYPE, 102, "Formatting result error failed.\nException: %1$s");
+ public static final Errno ERROR_FORMAT_RESULT_OUTPUT_FAILED_WITH_EXCEPTION = new Errno(TYPE, 103, "Formatting result output failed.\nException: %1$s");
+
+
+ ResultSenderErrno(final String type, final int code, final String message) {
+ super(type, code, message);
+ }
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/shell/command/runner/app/AppShell.java b/termux/termux-shared/src/main/java/com/termux/shared/shell/command/runner/app/AppShell.java
new file mode 100644
index 000000000..3f49e5aeb
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/shell/command/runner/app/AppShell.java
@@ -0,0 +1,349 @@
+package com.termux.shared.shell.command.runner.app;
+
+import android.content.Context;
+import android.system.ErrnoException;
+import android.system.Os;
+import android.system.OsConstants;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.google.common.base.Joiner;
+import com.termux.shared.R;
+import com.termux.shared.data.DataUtils;
+import com.termux.shared.shell.command.ExecutionCommand;
+import com.termux.shared.shell.command.environment.ShellEnvironmentUtils;
+import com.termux.shared.shell.command.result.ResultData;
+import com.termux.shared.errors.Errno;
+import com.termux.shared.logger.Logger;
+import com.termux.shared.shell.command.ExecutionCommand.ExecutionState;
+import com.termux.shared.shell.command.environment.IShellEnvironment;
+import com.termux.shared.shell.ShellUtils;
+import com.termux.shared.shell.StreamGobbler;
+
+import java.io.DataOutputStream;
+import java.io.File;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+
+/**
+ * A class that maintains info for background app shells run with {@link Runtime#exec(String[], String[], File)}.
+ * It also provides a way to link each {@link Process} with the {@link ExecutionCommand}
+ * that started it. The shell is run in the app user context.
+ */
+public final class AppShell {
+
+ private final Process mProcess;
+ private final ExecutionCommand mExecutionCommand;
+ private final AppShellClient mAppShellClient;
+
+ private static final String LOG_TAG = "AppShell";
+
+ private AppShell(@NonNull final Process process, @NonNull final ExecutionCommand executionCommand,
+ final AppShellClient appShellClient) {
+ this.mProcess = process;
+ this.mExecutionCommand = executionCommand;
+ this.mAppShellClient = appShellClient;
+ }
+
+ /**
+ * Start execution of an {@link ExecutionCommand} with {@link Runtime#exec(String[], String[], File)}.
+ *
+ * The {@link ExecutionCommand#executable}, must be set.
+ * The {@link ExecutionCommand#commandLabel}, {@link ExecutionCommand#arguments} and
+ * {@link ExecutionCommand#workingDirectory} may optionally be set.
+ *
+ * @param currentPackageContext The {@link Context} for operations. This must be the context for
+ * the current package and not the context of a `sharedUserId` package,
+ * since environment setup may be dependent on current package.
+ * @param executionCommand The {@link ExecutionCommand} containing the information for execution command.
+ * @param appShellClient The {@link AppShellClient} interface implementation.
+ * The {@link AppShellClient#onAppShellExited(AppShell)} will
+ * be called regardless of {@code isSynchronous} value but not if
+ * {@code null} is returned by this method. This can
+ * optionally be {@code null}.
+ * @param shellEnvironmentClient The {@link IShellEnvironment} interface implementation.
+ * @param additionalEnvironment The additional shell environment variables to export. Existing
+ * variables will be overridden.
+ * @param isSynchronous If set to {@code true}, then the command will be executed in the
+ * caller thread and results returned synchronously in the {@link ExecutionCommand}
+ * sub object of the {@link AppShell} returned.
+ * If set to {@code false}, then a new thread is started run the commands
+ * asynchronously in the background and control is returned to the caller thread.
+ * @return Returns the {@link AppShell}. This will be {@code null} if failed to start the execution command.
+ */
+ public static AppShell execute(@NonNull final Context currentPackageContext, @NonNull ExecutionCommand executionCommand,
+ final AppShellClient appShellClient,
+ @NonNull final IShellEnvironment shellEnvironmentClient,
+ @Nullable HashMap additionalEnvironment,
+ final boolean isSynchronous) {
+ if (executionCommand.executable == null || executionCommand.executable.isEmpty()) {
+ executionCommand.setStateFailed(Errno.ERRNO_FAILED.getCode(),
+ currentPackageContext.getString(R.string.error_executable_unset, executionCommand.getCommandIdAndLabelLogString()));
+ AppShell.processAppShellResult(null, executionCommand);
+ return null;
+ }
+
+ if (executionCommand.workingDirectory == null || executionCommand.workingDirectory.isEmpty())
+ executionCommand.workingDirectory = shellEnvironmentClient.getDefaultWorkingDirectoryPath();
+ if (executionCommand.workingDirectory.isEmpty())
+ executionCommand.workingDirectory = "/";
+
+ // Transform executable path to shell/session name, e.g. "/bin/do-something.sh" => "do-something.sh".
+ String executableBasename = ShellUtils.getExecutableBasename(executionCommand.executable);
+
+ if (executionCommand.shellName == null)
+ executionCommand.shellName = executableBasename;
+
+ if (executionCommand.commandLabel == null)
+ executionCommand.commandLabel = executableBasename;
+
+ // Setup command args
+ final String[] commandArray = shellEnvironmentClient.setupShellCommandArguments(executionCommand.executable, executionCommand.arguments);
+
+ // Setup command environment
+ HashMap environment = shellEnvironmentClient.setupShellCommandEnvironment(currentPackageContext,
+ executionCommand);
+ if (additionalEnvironment != null)
+ environment.putAll(additionalEnvironment);
+ List environmentList = ShellEnvironmentUtils.convertEnvironmentToEnviron(environment);
+ Collections.sort(environmentList);
+ String[] environmentArray = environmentList.toArray(new String[0]);
+
+ if (!executionCommand.setState(ExecutionState.EXECUTING)) {
+ executionCommand.setStateFailed(Errno.ERRNO_FAILED.getCode(), currentPackageContext.getString(R.string.error_failed_to_execute_app_shell_command, executionCommand.getCommandIdAndLabelLogString()));
+ AppShell.processAppShellResult(null, executionCommand);
+ return null;
+ }
+
+ // No need to log stdin if logging is disabled, like for app internal scripts
+ Logger.logDebugExtended(LOG_TAG, ExecutionCommand.getExecutionInputLogString(executionCommand,
+ true, Logger.shouldEnableLoggingForCustomLogLevel(executionCommand.backgroundCustomLogLevel)));
+ Logger.logVerboseExtended(LOG_TAG, "\"" + executionCommand.getCommandIdAndLabelLogString() + "\" AppShell Environment:\n" +
+ Joiner.on("\n").join(environmentArray));
+
+ // Exec the process
+ final Process process;
+ try {
+ process = Runtime.getRuntime().exec(commandArray, environmentArray, new File(executionCommand.workingDirectory));
+ } catch (IOException e) {
+ executionCommand.setStateFailed(Errno.ERRNO_FAILED.getCode(), currentPackageContext.getString(R.string.error_failed_to_execute_app_shell_command, executionCommand.getCommandIdAndLabelLogString()), e);
+ AppShell.processAppShellResult(null, executionCommand);
+ return null;
+ }
+
+ final AppShell appShell = new AppShell(process, executionCommand, appShellClient);
+ if (isSynchronous) {
+ try {
+ appShell.executeInner(currentPackageContext);
+ } catch (IllegalThreadStateException | InterruptedException e) {
+ // TODO: Should either of these be handled or returned?
+ }
+ } else {
+ new Thread() {
+ @Override
+ public void run() {
+ try {
+ appShell.executeInner(currentPackageContext);
+ } catch (IllegalThreadStateException | InterruptedException e) {
+ // TODO: Should either of these be handled or returned?
+ }
+ }
+ }.start();
+ }
+
+ return appShell;
+ }
+
+ /**
+ * Sets up stdout and stderr readers for the {@link #mProcess} and waits for the process to end.
+ *
+ * If the processes finishes, then sets {@link ResultData#stdout}, {@link ResultData#stderr}
+ * and {@link ResultData#exitCode} for the {@link #mExecutionCommand} of the {@code appShell}
+ * and then calls {@link #processAppShellResult(AppShell, ExecutionCommand) to process the result}.
+ *
+ * @param context The {@link Context} for operations.
+ */
+ private void executeInner(@NonNull final Context context) throws IllegalThreadStateException, InterruptedException {
+ mExecutionCommand.mPid = ShellUtils.getPid(mProcess);
+
+ Logger.logDebug(LOG_TAG, "Running \"" + mExecutionCommand.getCommandIdAndLabelLogString() + "\" AppShell with pid " + mExecutionCommand.mPid);
+
+ mExecutionCommand.resultData.exitCode = null;
+
+ // setup stdin, and stdout and stderr gobblers
+ DataOutputStream STDIN = new DataOutputStream(mProcess.getOutputStream());
+ StreamGobbler STDOUT = new StreamGobbler(mExecutionCommand.mPid + "-stdout", mProcess.getInputStream(), mExecutionCommand.resultData.stdout, mExecutionCommand.backgroundCustomLogLevel);
+ StreamGobbler STDERR = new StreamGobbler(mExecutionCommand.mPid + "-stderr", mProcess.getErrorStream(), mExecutionCommand.resultData.stderr, mExecutionCommand.backgroundCustomLogLevel);
+
+ // start gobbling
+ STDOUT.start();
+ STDERR.start();
+
+ if (!DataUtils.isNullOrEmpty(mExecutionCommand.stdin)) {
+ try {
+ STDIN.write((mExecutionCommand.stdin + "\n").getBytes(StandardCharsets.UTF_8));
+ STDIN.flush();
+ STDIN.close();
+ //STDIN.write("exit\n".getBytes(StandardCharsets.UTF_8));
+ //STDIN.flush();
+ } catch(IOException e) {
+ if (e.getMessage() != null && (e.getMessage().contains("EPIPE") || e.getMessage().contains("Stream closed"))) {
+ // Method most horrid to catch broken pipe, in which case we
+ // do nothing. The command is not a shell, the shell closed
+ // STDIN, the script already contained the exit command, etc.
+ // these cases we want the output instead of returning null.
+ } else {
+ // other issues we don't know how to handle, leads to
+ // returning null
+ mExecutionCommand.setStateFailed(Errno.ERRNO_FAILED.getCode(), context.getString(R.string.error_exception_received_while_executing_app_shell_command, mExecutionCommand.getCommandIdAndLabelLogString(), e.getMessage()), e);
+ mExecutionCommand.resultData.exitCode = 1;
+ AppShell.processAppShellResult(this, null);
+ kill();
+ return;
+ }
+ }
+ }
+
+ // wait for our process to finish, while we gobble away in the background
+ int exitCode = mProcess.waitFor();
+
+ // make sure our threads are done gobbling
+ // and the process is destroyed - while the latter shouldn't be
+ // needed in theory, and may even produce warnings, in "normal" Java
+ // they are required for guaranteed cleanup of resources, so lets be
+ // safe and do this on Android as well
+ try {
+ STDIN.close();
+ } catch (IOException e) {
+ // might be closed already
+ }
+ STDOUT.join();
+ STDERR.join();
+ mProcess.destroy();
+
+ // Process result
+ if (exitCode == 0)
+ Logger.logDebug(LOG_TAG, "The \"" + mExecutionCommand.getCommandIdAndLabelLogString() + "\" AppShell with pid " + mExecutionCommand.mPid + " exited normally");
+ else
+ Logger.logDebug(LOG_TAG, "The \"" + mExecutionCommand.getCommandIdAndLabelLogString() + "\" AppShell with pid " + mExecutionCommand.mPid + " exited with code: " + exitCode);
+
+ // If the execution command has already failed, like SIGKILL was sent, then don't continue
+ if (mExecutionCommand.isStateFailed()) {
+ Logger.logDebug(LOG_TAG, "Ignoring setting \"" + mExecutionCommand.getCommandIdAndLabelLogString() + "\" AppShell state to ExecutionState.EXECUTED and processing results since it has already failed");
+ return;
+ }
+
+ mExecutionCommand.resultData.exitCode = exitCode;
+
+ if (!mExecutionCommand.setState(ExecutionState.EXECUTED))
+ return;
+
+ AppShell.processAppShellResult(this, null);
+ }
+
+ /**
+ * Kill this {@link AppShell} by sending a {@link OsConstants#SIGILL} to its {@link #mProcess}
+ * if its still executing.
+ *
+ * @param context The {@link Context} for operations.
+ * @param processResult If set to {@code true}, then the {@link #processAppShellResult(AppShell, ExecutionCommand)}
+ * will be called to process the failure.
+ */
+ public void killIfExecuting(@NonNull final Context context, boolean processResult) {
+ // If execution command has already finished executing, then no need to process results or send SIGKILL
+ if (mExecutionCommand.hasExecuted()) {
+ Logger.logDebug(LOG_TAG, "Ignoring sending SIGKILL to \"" + mExecutionCommand.getCommandIdAndLabelLogString() + "\" AppShell since it has already finished executing");
+ return;
+ }
+
+ Logger.logDebug(LOG_TAG, "Send SIGKILL to \"" + mExecutionCommand.getCommandIdAndLabelLogString() + "\" AppShell");
+
+ if (mExecutionCommand.setStateFailed(Errno.ERRNO_FAILED.getCode(), context.getString(R.string.error_sending_sigkill_to_process))) {
+ if (processResult) {
+ mExecutionCommand.resultData.exitCode = 137; // SIGKILL
+ AppShell.processAppShellResult(this, null);
+ }
+ }
+
+ if (mExecutionCommand.isExecuting()) {
+ kill();
+ }
+ }
+
+ /**
+ * Kill this {@link AppShell} by sending a {@link OsConstants#SIGILL} to its {@link #mProcess}.
+ */
+ public void kill() {
+ int pid = ShellUtils.getPid(mProcess);
+ try {
+ // Send SIGKILL to process
+ Os.kill(pid, OsConstants.SIGKILL);
+ } catch (ErrnoException e) {
+ Logger.logWarn(LOG_TAG, "Failed to send SIGKILL to \"" + mExecutionCommand.getCommandIdAndLabelLogString() + "\" AppShell with pid " + pid + ": " + e.getMessage());
+ }
+ }
+
+ /**
+ * Process the results of {@link AppShell} or {@link ExecutionCommand}.
+ *
+ * Only one of {@code appShell} and {@code executionCommand} must be set.
+ *
+ * If the {@code appShell} and its {@link #mAppShellClient} are not {@code null},
+ * then the {@link AppShellClient#onAppShellExited(AppShell)} callback will be called.
+ *
+ * @param appShell The {@link AppShell}, which should be set if
+ * {@link #execute(Context, ExecutionCommand, AppShellClient, IShellEnvironment, HashMap, boolean)}
+ * successfully started the process.
+ * @param executionCommand The {@link ExecutionCommand}, which should be set if
+ * {@link #execute(Context, ExecutionCommand, AppShellClient, IShellEnvironment, HashMap, boolean)}
+ * failed to start the process.
+ */
+ private static void processAppShellResult(final AppShell appShell, ExecutionCommand executionCommand) {
+ if (appShell != null)
+ executionCommand = appShell.mExecutionCommand;
+
+ if (executionCommand == null) return;
+
+ if (executionCommand.shouldNotProcessResults()) {
+ Logger.logDebug(LOG_TAG, "Ignoring duplicate call to process \"" + executionCommand.getCommandIdAndLabelLogString() + "\" AppShell result");
+ return;
+ }
+
+ Logger.logDebug(LOG_TAG, "Processing \"" + executionCommand.getCommandIdAndLabelLogString() + "\" AppShell result");
+
+ if (appShell != null && appShell.mAppShellClient != null) {
+ appShell.mAppShellClient.onAppShellExited(appShell);
+ } else {
+ // If a callback is not set and execution command didn't fail, then we set success state now
+ // Otherwise, the callback host can set it himself when its done with the appShell
+ if (!executionCommand.isStateFailed())
+ executionCommand.setState(ExecutionCommand.ExecutionState.SUCCESS);
+ }
+ }
+
+ public Process getProcess() {
+ return mProcess;
+ }
+
+ public ExecutionCommand getExecutionCommand() {
+ return mExecutionCommand;
+ }
+
+
+
+ public interface AppShellClient {
+
+ /**
+ * Callback function for when {@link AppShell} exits.
+ *
+ * @param appShell The {@link AppShell} that exited.
+ */
+ void onAppShellExited(AppShell appShell);
+
+ }
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/termux/TermuxBootstrap.java b/termux/termux-shared/src/main/java/com/termux/shared/termux/TermuxBootstrap.java
new file mode 100644
index 000000000..6635460e3
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/termux/TermuxBootstrap.java
@@ -0,0 +1,159 @@
+package com.termux.shared.termux;
+
+import androidx.annotation.Nullable;
+
+import com.termux.shared.logger.Logger;
+import com.termux.shared.termux.TermuxConstants.TERMUX_APP;
+
+public class TermuxBootstrap {
+
+ private static final String LOG_TAG = "TermuxBootstrap";
+
+ /** The field name used by Termux app to store package variant in
+ * {@link TERMUX_APP#BUILD_CONFIG_CLASS_NAME} class. */
+ public static final String BUILD_CONFIG_FIELD_TERMUX_PACKAGE_VARIANT = "TERMUX_PACKAGE_VARIANT";
+
+
+ /** The {@link PackageManager} for the bootstrap in the app APK added in app/build.gradle. */
+ public static final PackageManager TERMUX_APP_PACKAGE_MANAGER = PackageManager.APT;
+
+ /** The {@link PackageVariant} for the bootstrap in the app APK added in app/build.gradle. */
+ public static final PackageVariant TERMUX_APP_PACKAGE_VARIANT = PackageVariant.APT_ANDROID_7;
+
+
+ /** Is {@link PackageManager#APT} set as {@link #TERMUX_APP_PACKAGE_MANAGER}. */
+ public static boolean isAppPackageManagerAPT() {
+ return true;
+ }
+
+ ///** Is {@link PackageManager#TAPM} set as {@link #TERMUX_APP_PACKAGE_MANAGER}. */
+ //public static boolean isAppPackageManagerTAPM() {
+ // return PackageManager.TAPM.equals(TERMUX_APP_PACKAGE_MANAGER);
+ //}
+
+ ///** Is {@link PackageManager#PACMAN} set as {@link #TERMUX_APP_PACKAGE_MANAGER}. */
+ //public static boolean isAppPackageManagerPACMAN() {
+ // return PackageManager.PACMAN.equals(TERMUX_APP_PACKAGE_MANAGER);
+ //}
+
+
+
+ /** Is {@link PackageVariant#APT_ANDROID_7} set as {@link #TERMUX_APP_PACKAGE_VARIANT}. */
+ public static boolean isAppPackageVariantAPTAndroid7() {
+ return true;
+ }
+
+ /** Is {@link PackageVariant#APT_ANDROID_5} set as {@link #TERMUX_APP_PACKAGE_VARIANT}. */
+ public static boolean isAppPackageVariantAPTAndroid5() {
+ return false;
+ }
+
+ ///** Is {@link PackageVariant#TAPM_ANDROID_7} set as {@link #TERMUX_APP_PACKAGE_VARIANT}. */
+ //public static boolean isAppPackageVariantTAPMAndroid7() {
+ // return PackageVariant.TAPM_ANDROID_7.equals(TERMUX_APP_PACKAGE_VARIANT);
+ //}
+
+ ///** Is {@link PackageVariant#PACMAN_ANDROID_7} set as {@link #TERMUX_APP_PACKAGE_VARIANT}. */
+ //public static boolean isAppPackageVariantTPACMANAndroid7() {
+ // return PackageVariant.PACMAN_ANDROID_7.equals(TERMUX_APP_PACKAGE_VARIANT);
+ //}
+
+
+
+ /** Termux package manager. */
+ public enum PackageManager {
+
+ /**
+ * Advanced Package Tool (APT) for managing debian deb package files.
+ * https://wiki.debian.org/Apt
+ * https://wiki.debian.org/deb
+ */
+ APT("apt");
+
+ ///**
+ // * Termux Android Package Manager (TAPM) for managing termux apk package files.
+ // * https://en.wikipedia.org/wiki/Apk_(file_format)
+ // */
+ //TAPM("tapm");
+
+ ///**
+ // * Package Manager (PACMAN) for managing arch linux pkg.tar package files.
+ // * https://wiki.archlinux.org/title/pacman
+ // * https://en.wikipedia.org/wiki/Arch_Linux#Pacman
+ // */
+ //PACMAN("pacman");
+
+ private final String name;
+
+ PackageManager(final String name) {
+ this.name = name;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public boolean equalsManager(String manager) {
+ return manager != null && manager.equals(this.name);
+ }
+
+ /** Get {@link PackageManager} for {@code name} if found, otherwise {@code null}. */
+ @Nullable
+ public static PackageManager managerOf(String name) {
+ if (name == null || name.isEmpty()) return null;
+ for (PackageManager v : PackageManager.values()) {
+ if (v.name.equals(name)) {
+ return v;
+ }
+ }
+ return null;
+ }
+
+ }
+
+
+
+ /** Termux package variant. The substring before first dash "-" must match one of the {@link PackageManager}. */
+ public enum PackageVariant {
+
+ /** {@link PackageManager#APT} variant for Android 7+. */
+ APT_ANDROID_7("apt-android-7"),
+
+ /** {@link PackageManager#APT} variant for Android 5+. */
+ APT_ANDROID_5("apt-android-5");
+
+ ///** {@link PackageManager#TAPM} variant for Android 7+. */
+ //TAPM_ANDROID_7("tapm-android-7");
+
+ ///** {@link PackageManager#PACMAN} variant for Android 7+. */
+ //PACMAN_ANDROID_7("pacman-android-7");
+
+ private final String name;
+
+ PackageVariant(final String name) {
+ this.name = name;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public boolean equalsVariant(String variant) {
+ return variant != null && variant.equals(this.name);
+ }
+
+ /** Get {@link PackageVariant} for {@code name} if found, otherwise {@code null}. */
+ @Nullable
+ public static PackageVariant variantOf(String name) {
+ if (name == null || name.isEmpty()) return null;
+ for (PackageVariant v : PackageVariant.values()) {
+ if (v.name.equals(name)) {
+ return v;
+ }
+ }
+ return null;
+ }
+
+ }
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/termux/TermuxConstants.java b/termux/termux-shared/src/main/java/com/termux/shared/termux/TermuxConstants.java
new file mode 100644
index 000000000..690f63c0a
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/termux/TermuxConstants.java
@@ -0,0 +1,953 @@
+package com.termux.shared.termux;
+
+import android.annotation.SuppressLint;
+
+import com.termux.shared.shell.command.ExecutionCommand;
+import com.termux.shared.shell.command.ExecutionCommand.Runner;
+
+import java.io.File;
+import java.util.Arrays;
+import java.util.Formatter;
+import java.util.List;
+
+/*
+ * Version: v0.52.0
+ * SPDX-License-Identifier: MIT
+ *
+ * Changelog
+ *
+ * - 0.1.0 (2021-03-08)
+ * - Initial Release.
+ *
+ * - 0.2.0 (2021-03-11)
+ * - Added `_DIR` and `_FILE` substrings to paths.
+ * - Added `INTERNAL_PRIVATE_APP_DATA_DIR*`, `TERMUX_CACHE_DIR*`, `TERMUX_DATABASES_DIR*`,
+ * `TERMUX_SHARED_PREFERENCES_DIR*`, `TERMUX_BIN_PREFIX_DIR*`, `TERMUX_ETC_DIR*`,
+ * `TERMUX_INCLUDE_DIR*`, `TERMUX_LIB_DIR*`, `TERMUX_LIBEXEC_DIR*`, `TERMUX_SHARE_DIR*`,
+ * `TERMUX_TMP_DIR*`, `TERMUX_VAR_DIR*`, `TERMUX_STAGING_PREFIX_DIR*`,
+ * `TERMUX_STORAGE_HOME_DIR*`, `TERMUX_DEFAULT_PREFERENCES_FILE_BASENAME*`,
+ * `TERMUX_DEFAULT_PREFERENCES_FILE`.
+ * - Renamed `DATA_HOME_PATH` to `TERMUX_DATA_HOME_DIR_PATH`.
+ * - Renamed `CONFIG_HOME_PATH` to `TERMUX_CONFIG_HOME_DIR_PATH`.
+ * - Updated javadocs and spacing.
+ *
+ * - 0.3.0 (2021-03-12)
+ * - Remove `TERMUX_CACHE_DIR_PATH*`, `TERMUX_DATABASES_DIR_PATH*`,
+ * `TERMUX_SHARED_PREFERENCES_DIR_PATH*` since they may not be consistent on all devices.
+ * - Renamed `TERMUX_DEFAULT_PREFERENCES_FILE_BASENAME` to
+ * `TERMUX_DEFAULT_PREFERENCES_FILE_BASENAME_WITHOUT_EXTENSION`. This should be used for
+ * accessing shared preferences between Termux app and its plugins if ever needed by first
+ * getting shared package context with {@link Context.createPackageContext(String,int}).
+ *
+ * - 0.4.0 (2021-03-16)
+ * - Added `BROADCAST_TERMUX_OPENED`,
+ * `TERMUX_API_DEFAULT_PREFERENCES_FILE_BASENAME_WITHOUT_EXTENSION`
+ * `TERMUX_BOOT_DEFAULT_PREFERENCES_FILE_BASENAME_WITHOUT_EXTENSION`,
+ * `TERMUX_FLOAT_DEFAULT_PREFERENCES_FILE_BASENAME_WITHOUT_EXTENSION`,
+ * `TERMUX_STYLING_DEFAULT_PREFERENCES_FILE_BASENAME_WITHOUT_EXTENSION`,
+ * `TERMUX_TASKER_DEFAULT_PREFERENCES_FILE_BASENAME_WITHOUT_EXTENSION`,
+ * `TERMUX_WIDGET_DEFAULT_PREFERENCES_FILE_BASENAME_WITHOUT_EXTENSION`.
+ *
+ * - 0.5.0 (2021-03-16)
+ * - Renamed "Termux Plugin app" labels to "Termux:Tasker app".
+ *
+ * - 0.6.0 (2021-03-16)
+ * - Added `TERMUX_FILE_SHARE_URI_AUTHORITY`.
+ *
+ * - 0.7.0 (2021-03-17)
+ * - Fixed javadocs.
+ *
+ * - 0.8.0 (2021-03-18)
+ * - Fixed Intent extra types javadocs.
+ * - Added following to `TERMUX_SERVICE`:
+ * `EXTRA_PENDING_INTENT`, `EXTRA_RESULT_BUNDLE`,
+ * `EXTRA_STDOUT`, `EXTRA_STDERR`, `EXTRA_EXIT_CODE`,
+ * `EXTRA_ERR`, `EXTRA_ERRMSG`.
+ *
+ * - 0.9.0 (2021-03-18)
+ * - Fixed javadocs.
+ *
+ * - 0.10.0 (2021-03-19)
+ * - Added following to `TERMUX_SERVICE`:
+ * `EXTRA_SESSION_ACTION`,
+ * `VALUE_EXTRA_SESSION_ACTION_SWITCH_TO_NEW_SESSION_AND_OPEN_ACTIVITY`,
+ * `VALUE_EXTRA_SESSION_ACTION_KEEP_CURRENT_SESSION_AND_OPEN_ACTIVITY`,
+ * `VALUE_EXTRA_SESSION_ACTION_SWITCH_TO_NEW_SESSION_AND_DONT_OPEN_ACTIVITY`
+ * `VALUE_EXTRA_SESSION_ACTION_KEEP_CURRENT_SESSION_AND_DONT_OPEN_ACTIVITY`.
+ * - Added following to `RUN_COMMAND_SERVICE`:
+ * `EXTRA_SESSION_ACTION`.
+ *
+ * - 0.11.0 (2021-03-24)
+ * - Added following to `TERMUX_SERVICE`:
+ * `EXTRA_COMMAND_LABEL`, `EXTRA_COMMAND_DESCRIPTION`, `EXTRA_COMMAND_HELP`, `EXTRA_PLUGIN_API_HELP`.
+ * - Added following to `RUN_COMMAND_SERVICE`:
+ * `EXTRA_COMMAND_LABEL`, `EXTRA_COMMAND_DESCRIPTION`, `EXTRA_COMMAND_HELP`.
+ * - Updated `RESULT_BUNDLE` related extras with `PLUGIN_RESULT_BUNDLE` prefixes.
+ *
+ * - 0.12.0 (2021-03-25)
+ * - Added following to `TERMUX_SERVICE`:
+ * `EXTRA_PLUGIN_RESULT_BUNDLE_STDOUT_ORIGINAL_LENGTH`,
+ * `EXTRA_PLUGIN_RESULT_BUNDLE_STDERR_ORIGINAL_LENGTH`.
+ *
+ * - 0.13.0 (2021-03-25)
+ * - Added following to `RUN_COMMAND_SERVICE`:
+ * `EXTRA_PENDING_INTENT`.
+ *
+ * - 0.14.0 (2021-03-25)
+ * - Added `FDROID_PACKAGES_BASE_URL`,
+ * `TERMUX_GITHUB_ORGANIZATION_NAME`, `TERMUX_GITHUB_ORGANIZATION_URL`,
+ * `TERMUX_GITHUB_REPO_NAME`, `TERMUX_GITHUB_REPO_URL`, `TERMUX_FDROID_PACKAGE_URL`,
+ * `TERMUX_API_GITHUB_REPO_NAME`,`TERMUX_API_GITHUB_REPO_URL`, `TERMUX_API_FDROID_PACKAGE_URL`,
+ * `TERMUX_BOOT_GITHUB_REPO_NAME`, `TERMUX_BOOT_GITHUB_REPO_URL`, `TERMUX_BOOT_FDROID_PACKAGE_URL`,
+ * `TERMUX_FLOAT_GITHUB_REPO_NAME`, `TERMUX_FLOAT_GITHUB_REPO_URL`, `TERMUX_FLOAT_FDROID_PACKAGE_URL`,
+ * `TERMUX_STYLING_GITHUB_REPO_NAME`, `TERMUX_STYLING_GITHUB_REPO_URL`, `TERMUX_STYLING_FDROID_PACKAGE_URL`,
+ * `TERMUX_TASKER_GITHUB_REPO_NAME`, `TERMUX_TASKER_GITHUB_REPO_URL`, `TERMUX_TASKER_FDROID_PACKAGE_URL`,
+ * `TERMUX_WIDGET_GITHUB_REPO_NAME`, `TERMUX_WIDGET_GITHUB_REPO_URL` `TERMUX_WIDGET_FDROID_PACKAGE_URL`.
+ *
+ * - 0.15.0 (2021-04-06)
+ * - Fixed some variables that had `PREFIX_` substring missing in their name.
+ * - Added `TERMUX_CRASH_LOG_FILE_PATH`, `TERMUX_CRASH_LOG_BACKUP_FILE_PATH`,
+ * `TERMUX_GITHUB_ISSUES_REPO_URL`, `TERMUX_API_GITHUB_ISSUES_REPO_URL`,
+ * `TERMUX_BOOT_GITHUB_ISSUES_REPO_URL`, `TERMUX_FLOAT_GITHUB_ISSUES_REPO_URL`,
+ * `TERMUX_STYLING_GITHUB_ISSUES_REPO_URL`, `TERMUX_TASKER_GITHUB_ISSUES_REPO_URL`,
+ * `TERMUX_WIDGET_GITHUB_ISSUES_REPO_URL`,
+ * `TERMUX_GITHUB_WIKI_REPO_URL`, `TERMUX_PACKAGES_GITHUB_WIKI_REPO_URL`,
+ * `TERMUX_PACKAGES_GITHUB_REPO_NAME`, `TERMUX_PACKAGES_GITHUB_REPO_URL`, `TERMUX_PACKAGES_GITHUB_ISSUES_REPO_URL`,
+ * `TERMUX_GAME_PACKAGES_GITHUB_REPO_NAME`, `TERMUX_GAME_PACKAGES_GITHUB_REPO_URL`, `TERMUX_GAME_PACKAGES_GITHUB_ISSUES_REPO_URL`,
+ * `TERMUX_SCIENCE_PACKAGES_GITHUB_REPO_NAME`, `TERMUX_SCIENCE_PACKAGES_GITHUB_REPO_URL`, `TERMUX_SCIENCE_PACKAGES_GITHUB_ISSUES_REPO_URL`,
+ * `TERMUX_ROOT_PACKAGES_GITHUB_REPO_NAME`, `TERMUX_ROOT_PACKAGES_GITHUB_REPO_URL`, `TERMUX_ROOT_PACKAGES_GITHUB_ISSUES_REPO_URL`,
+ * `TERMUX_UNSTABLE_PACKAGES_GITHUB_REPO_NAME`, `TERMUX_UNSTABLE_PACKAGES_GITHUB_REPO_URL`, `TERMUX_UNSTABLE_PACKAGES_GITHUB_ISSUES_REPO_URL`,
+ * `TERMUX_X11_PACKAGES_GITHUB_REPO_NAME`, `TERMUX_X11_PACKAGES_GITHUB_REPO_URL`, `TERMUX_X11_PACKAGES_GITHUB_ISSUES_REPO_URL`.
+ * - Added following to `RUN_COMMAND_SERVICE`:
+ * `RUN_COMMAND_API_HELP_URL`.
+ *
+ * - 0.16.0 (2021-04-06)
+ * - Added `TERMUX_SUPPORT_EMAIL`, `TERMUX_SUPPORT_EMAIL_URL`, `TERMUX_SUPPORT_EMAIL_MAILTO_URL`,
+ * `TERMUX_REDDIT_SUBREDDIT`, `TERMUX_REDDIT_SUBREDDIT_URL`.
+ * - The `TERMUX_SUPPORT_EMAIL_URL` value must be fixed later when email has been set up.
+ *
+ * - 0.17.0 (2021-04-07)
+ * - Added `TERMUX_APP_NOTIFICATION_CHANNEL_ID`, `TERMUX_APP_NOTIFICATION_CHANNEL_NAME`, `TERMUX_APP_NOTIFICATION_ID`,
+ * `TERMUX_RUN_COMMAND_NOTIFICATION_CHANNEL_ID`, `TERMUX_RUN_COMMAND_NOTIFICATION_CHANNEL_NAME`, `TERMUX_RUN_COMMAND_NOTIFICATION_ID`,
+ * `TERMUX_PLUGIN_COMMAND_ERRORS_NOTIFICATION_CHANNEL_ID`, `TERMUX_PLUGIN_COMMAND_ERRORS_NOTIFICATION_CHANNEL_NAME`,
+ * `TERMUX_CRASH_REPORTS_NOTIFICATION_CHANNEL_ID`, `TERMUX_CRASH_REPORTS_NOTIFICATION_CHANNEL_NAME`.
+ * - Updated javadocs.
+ *
+ * - 0.18.0 (2021-04-11)
+ * - Updated `TERMUX_SUPPORT_EMAIL_URL` to a valid email.
+ * - Removed `TERMUX_SUPPORT_EMAIL`.
+ *
+ * - 0.19.0 (2021-04-12)
+ * - Added `TERMUX_ACTIVITY.ACTION_REQUEST_PERMISSIONS`.
+ * - Added `TERMUX_SERVICE.EXTRA_STDIN`.
+ * - Added `RUN_COMMAND_SERVICE.EXTRA_STDIN`.
+ * - Deprecated `TERMUX_ACTIVITY.EXTRA_RELOAD_STYLE`.
+ *
+ * - 0.20.0 (2021-05-13)
+ * - Added `TERMUX_WIKI`, `TERMUX_WIKI_URL`, `TERMUX_PLUGIN_APP_NAMES_LIST`, `TERMUX_PLUGIN_APP_PACKAGE_NAMES_LIST`.
+ * - Added `TERMUX_SETTINGS_ACTIVITY_NAME`.
+ *
+ * - 0.21.0 (2021-05-13)
+ * - Added `APK_RELEASE_FDROID`, `APK_RELEASE_FDROID_SIGNING_CERTIFICATE_SHA256_DIGEST`,
+ * `APK_RELEASE_GITHUB_DEBUG_BUILD`, `APK_RELEASE_GITHUB_DEBUG_BUILD_SIGNING_CERTIFICATE_SHA256_DIGEST`,
+ * `APK_RELEASE_GOOGLE_PLAYSTORE`, `APK_RELEASE_GOOGLE_PLAYSTORE_SIGNING_CERTIFICATE_SHA256_DIGEST`.
+ *
+ * - 0.22.0 (2021-05-13)
+ * - Added `TERMUX_DONATE_URL`.
+ *
+ * - 0.23.0 (2021-06-12)
+ * - Rename `INTERNAL_PRIVATE_APP_DATA_DIR_PATH` to `TERMUX_INTERNAL_PRIVATE_APP_DATA_DIR_PATH`.
+ *
+ * - 0.24.0 (2021-06-27)
+ * - Add `COMMA_NORMAL`, `COMMA_ALTERNATIVE`.
+ * - Added following to `TERMUX_APP.TERMUX_SERVICE`:
+ * `EXTRA_RESULT_DIRECTORY`, `EXTRA_RESULT_SINGLE_FILE`, `EXTRA_RESULT_FILE_BASENAME`,
+ * `EXTRA_RESULT_FILE_OUTPUT_FORMAT`, `EXTRA_RESULT_FILE_ERROR_FORMAT`, `EXTRA_RESULT_FILES_SUFFIX`.
+ * - Added following to `TERMUX_APP.RUN_COMMAND_SERVICE`:
+ * `EXTRA_RESULT_DIRECTORY`, `EXTRA_RESULT_SINGLE_FILE`, `EXTRA_RESULT_FILE_BASENAME`,
+ * `EXTRA_RESULT_FILE_OUTPUT_FORMAT`, `EXTRA_RESULT_FILE_ERROR_FORMAT`, `EXTRA_RESULT_FILES_SUFFIX`,
+ * `EXTRA_REPLACE_COMMA_ALTERNATIVE_CHARS_IN_ARGUMENTS`, `EXTRA_COMMA_ALTERNATIVE_CHARS_IN_ARGUMENTS`.
+ * - Added following to `RESULT_SENDER`:
+ * `FORMAT_SUCCESS_STDOUT`, `FORMAT_SUCCESS_STDOUT__EXIT_CODE`, `FORMAT_SUCCESS_STDOUT__STDERR__EXIT_CODE`
+ * `FORMAT_FAILED_ERR__ERRMSG__STDOUT__STDERR__EXIT_CODE`,
+ * `RESULT_FILE_ERR_PREFIX`, `RESULT_FILE_ERRMSG_PREFIX` `RESULT_FILE_STDOUT_PREFIX`,
+ * `RESULT_FILE_STDERR_PREFIX`, `RESULT_FILE_EXIT_CODE_PREFIX`.
+ *
+ * - 0.25.0 (2021-08-19)
+ * - Added following to `TERMUX_APP.TERMUX_SERVICE`:
+ * `EXTRA_BACKGROUND_CUSTOM_LOG_LEVEL`.
+ * - Added following to `TERMUX_APP.RUN_COMMAND_SERVICE`:
+ * `EXTRA_BACKGROUND_CUSTOM_LOG_LEVEL`.
+ *
+ * - 0.26.0 (2021-08-25)
+ * - Changed `TERMUX_ACTIVITY.ACTION_FAILSAFE_SESSION` to `TERMUX_ACTIVITY.EXTRA_FAILSAFE_SESSION`.
+ *
+ * - 0.27.0 (2021-09-02)
+ * - Added `TERMUX_FLOAT_APP_NOTIFICATION_CHANNEL_ID`, `TERMUX_FLOAT_APP_NOTIFICATION_CHANNEL_NAME`,
+ * `TERMUX_FLOAT_APP.TERMUX_FLOAT_SERVICE_NAME`.
+ * - Added following to `TERMUX_FLOAT_APP.TERMUX_FLOAT_SERVICE`:
+ * `ACTION_STOP_SERVICE`, `ACTION_SHOW`, `ACTION_HIDE`.
+ *
+ * - 0.28.0 (2021-09-02)
+ * - Added `TERMUX_FLOAT_PROPERTIES_PRIMARY_FILE*` and `TERMUX_FLOAT_PROPERTIES_SECONDARY_FILE*`.
+ *
+ * - 0.29.0 (2021-09-04)
+ * - Added `TERMUX_SHORTCUT_TASKS_SCRIPTS_DIR_BASENAME`, `TERMUX_SHORTCUT_SCRIPT_ICONS_DIR_BASENAME`,
+ * `TERMUX_SHORTCUT_SCRIPT_ICONS_DIR_PATH`, `TERMUX_SHORTCUT_SCRIPT_ICONS_DIR`.
+ * - Added following to `TERMUX_WIDGET.TERMUX_WIDGET_PROVIDER`:
+ * `ACTION_WIDGET_ITEM_CLICKED`, `ACTION_REFRESH_WIDGET`, `EXTRA_FILE_CLICKED`.
+ * - Changed naming convention of `TERMUX_FLOAT_APP.TERMUX_FLOAT_SERVICE.ACTION_*`.
+ * - Fixed wrong path set for `TERMUX_SHORTCUT_SCRIPTS_DIR_PATH`.
+ *
+ * - 0.30.0 (2021-09-08)
+ * - Changed `APK_RELEASE_GITHUB_DEBUG_BUILD`to `APK_RELEASE_GITHUB` and
+ * `APK_RELEASE_GITHUB_DEBUG_BUILD_SIGNING_CERTIFICATE_SHA256_DIGEST` to
+ * `APK_RELEASE_GITHUB_SIGNING_CERTIFICATE_SHA256_DIGEST`.
+ *
+ * - 0.31.0 (2021-09-09)
+ * - Added following to `TERMUX_APP.TERMUX_SERVICE`:
+ * `MIN_VALUE_EXTRA_SESSION_ACTION` and `MAX_VALUE_EXTRA_SESSION_ACTION`.
+ *
+ * - 0.32.0 (2021-09-23)
+ * - Added `TERMUX_API.TERMUX_API_ACTIVITY_NAME`, `TERMUX_TASKER.TERMUX_TASKER_ACTIVITY_NAME`
+ * and `TERMUX_WIDGET.TERMUX_WIDGET_ACTIVITY_NAME`.
+ *
+ * - 0.33.0 (2021-10-08)
+ * - Added `TERMUX_PROPERTIES_FILE_PATHS_LIST` and `TERMUX_FLOAT_PROPERTIES_FILE_PATHS_LIST`.
+ *
+ * - 0.34.0 (2021-10-26)
+ * - Move `RESULT_SENDER` to `com.termux.shared.shell.command.ShellCommandConstants`.
+ *
+ * - 0.35.0 (2022-01-28)
+ * - Add `TERMUX_APP.TERMUX_ACTIVITY.EXTRA_RECREATE_ACTIVITY`.
+ *
+ * - 0.36.0 (2022-03-10)
+ * - Added `TERMUX_APP.TERMUX_SERVICE.EXTRA_RUNNER` and `TERMUX_APP.RUN_COMMAND_SERVICE.EXTRA_RUNNER`
+ *
+ * - 0.37.0 (2022-03-15)
+ * - Added `TERMUX_API_APT_*`.
+ *
+ * - 0.38.0 (2022-03-16)
+ * - Added `TERMUX_APP.TERMUX_ACTIVITY.ACTION_NOTIFY_APP_CRASH`.
+ *
+ * - 0.39.0 (2022-03-18)
+ * - Added `TERMUX_APP.TERMUX_SERVICE.EXTRA_SESSION_NAME`, `TERMUX_APP.RUN_COMMAND_SERVICE.EXTRA_SESSION_NAME`,
+ * `TERMUX_APP.TERMUX_SERVICE.EXTRA_SESSION_CREATE_MODE` and `TERMUX_APP.RUN_COMMAND_SERVICE.EXTRA_SESSION_CREATE_MODE`.
+ *
+ * - 0.40.0 (2022-04-17)
+ * - Added `TERMUX_APPS_DIR_PATH` and `TERMUX_APP.APPS_DIR_PATH`.
+ *
+ * - 0.41.0 (2022-04-17)
+ * - Added `TERMUX_APP.TERMUX_AM_SOCKET_FILE_PATH`.
+ *
+ * - 0.42.0 (2022-04-29)
+ * - Added `APK_RELEASE_TERMUX_DEVS` and `APK_RELEASE_TERMUX_DEVS_SIGNING_CERTIFICATE_SHA256_DIGEST`.
+ *
+ * - 0.43.0 (2022-05-29)
+ * - Changed `TERMUX_SUPPORT_EMAIL_URL` to support@termux.dev.
+ *
+ * - 0.44.0 (2022-05-29)
+ * - Changed `TERMUX_APP.APPS_DIR_PATH` basename from `termux-app` to `com.termux`.
+ *
+ * - 0.45.0 (2022-06-01)
+ * - Added `TERMUX_APP.BUILD_CONFIG_CLASS_NAME`.
+ *
+ * - 0.46.0 (2022-06-03)
+ * - Rename `TERMUX_APP.TERMUX_SERVICE.EXTRA_SESSION_NAME` to `*.EXTRA_SHELL_NAME`,
+ * `TERMUX_APP.RUN_COMMAND_SERVICE.EXTRA_SESSION_NAME` to `*.EXTRA_SHELL_NAME`,
+ * `TERMUX_APP.TERMUX_SERVICE.EXTRA_SESSION_CREATE_MODE` to `*.EXTRA_SHELL_CREATE_MODE` and
+ * `TERMUX_APP.RUN_COMMAND_SERVICE.EXTRA_SESSION_CREATE_MODE` to `*.EXTRA_SHELL_CREATE_MODE`.
+ *
+ * - 0.47.0 (2022-06-04)
+ * - Added `TERMUX_SITE` and `TERMUX_SITE_URL`.
+ * - Changed `TERMUX_DONATE_URL`.
+ *
+ * - 0.48.0 (2022-06-04)
+ * - Removed `TERMUX_GAME_PACKAGES_GITHUB_*`, `TERMUX_SCIENCE_PACKAGES_GITHUB_*`,
+ * `TERMUX_ROOT_PACKAGES_GITHUB_*`, `TERMUX_UNSTABLE_PACKAGES_GITHUB_*`
+ *
+ * - 0.49.0 (2022-06-11)
+ * - Added `TERMUX_ENV_PREFIX_ROOT`.
+ *
+ * - 0.50.0 (2022-06-11)
+ * - Added `TERMUX_CONFIG_PREFIX_DIR_PATH`, `TERMUX_ENV_FILE_PATH` and `TERMUX_ENV_TEMP_FILE_PATH`.
+ *
+ * - 0.51.0 (2022-06-13)
+ * - Added `TERMUX_APP.FILE_SHARE_RECEIVER_ACTIVITY_CLASS_NAME` and `TERMUX_APP.FILE_VIEW_RECEIVER_ACTIVITY_CLASS_NAME`.
+ *
+ * - 0.52.0 (2022-06-18)
+ * - Added `TERMUX_PREFIX_DIR_IGNORED_SUB_FILES_PATHS_TO_CONSIDER_AS_EMPTY`.
+ */
+
+/**
+ * A class that defines shared constants of the Termux app and its plugins.
+ * This class will be hosted by termux-shared lib and should be imported by other termux plugin
+ * apps as is instead of copying constants to random classes. The 3rd party apps can also import
+ * it for interacting with termux apps. If changes are made to this file, increment the version number
+ * and add an entry in the Changelog section above.
+ *
+ * Termux app default package name is "com.termux" and is used in {@link #TERMUX_PREFIX_DIR_PATH}.
+ * The binaries compiled for termux have {@link #TERMUX_PREFIX_DIR_PATH} hardcoded in them but it
+ * can be changed during compilation.
+ *
+ * The {@link #TERMUX_PACKAGE_NAME} must be the same as the applicationId of termux-app build.gradle
+ * since its also used by {@link #TERMUX_FILES_DIR_PATH}.
+ * If {@link #TERMUX_PACKAGE_NAME} is changed, then binaries, specially used in bootstrap need to be
+ * compiled appropriately. Check https://github.com/termux/termux-packages/wiki/Building-packages
+ * for more info.
+ *
+ * Ideally the only places where changes should be required if changing package name are the following:
+ * - The {@link #TERMUX_PACKAGE_NAME} in {@link TermuxConstants}.
+ * - The "applicationId" in "build.gradle" of termux-app. This is package name that android and app
+ * stores will use and is also the final package name stored in "AndroidManifest.xml".
+ * - The "manifestPlaceholders" values for {@link #TERMUX_PACKAGE_NAME} and *_APP_NAME in
+ * "build.gradle" of termux-app.
+ * - The "ENTITY" values for {@link #TERMUX_PACKAGE_NAME} and *_APP_NAME in "strings.xml" of
+ * termux-app and of termux-shared.
+ * - The "shortcut.xml" and "*_preferences.xml" files of termux-app since dynamic variables don't
+ * work in it.
+ * - Optionally the "package" in "AndroidManifest.xml" if modifying project structure of termux-app.
+ * This is package name for java classes project structure and is prefixed if activity and service
+ * names use dot (.) notation. This is currently not advisable since this will break lot of
+ * stuff, including termux-* packages.
+ * - Optionally the *_PATH variables in {@link TermuxConstants} containing the string "termux".
+ *
+ * Check https://developer.android.com/studio/build/application-id for info on "package" in
+ * "AndroidManifest.xml" and "applicationId" in "build.gradle".
+ *
+ * The {@link #TERMUX_PACKAGE_NAME} must be used in source code of Termux app and its plugins instead
+ * of hardcoded "com.termux" paths.
+ */
+public final class TermuxConstants {
+
+
+ /*
+ * Termux organization variables.
+ */
+
+ /** Termux GitHub organization name */
+ public static final String TERMUX_GITHUB_ORGANIZATION_NAME = "AndroidIDEOfficial"; // Default: "termux"
+ /** Termux GitHub organization url */
+ public static final String TERMUX_GITHUB_ORGANIZATION_URL = "https://github.com" + "/" + TERMUX_GITHUB_ORGANIZATION_NAME; // Default: "https://github.com/termux"
+
+
+
+
+
+ /*
+ * Termux and its plugin app and package names and urls.
+ */
+
+ /** Termux app name */
+ public static final String TERMUX_APP_NAME = "AndroidIDE"; // Default: "Termux"
+ /** Termux package name */
+ public static final String TERMUX_PACKAGE_NAME = "com.itsaky.androidide"; // Default: "com.termux"
+ /** Termux GitHub repo name */
+ public static final String TERMUX_GITHUB_REPO_NAME = "AndroidIDE"; // Default: "termux-app"
+ /** Termux GitHub repo url */
+ public static final String TERMUX_GITHUB_REPO_URL = TERMUX_GITHUB_ORGANIZATION_URL + "/" + TERMUX_GITHUB_REPO_NAME; // Default: "https://github.com/termux/termux-app"
+ /** Termux GitHub issues repo url */
+ public static final String TERMUX_GITHUB_ISSUES_REPO_URL = TERMUX_GITHUB_REPO_URL + "/issues"; // Default: "https://github.com/termux/termux-app/issues"
+
+
+
+
+
+ /*
+ * Termux APK releases.
+ */
+
+ /** F-Droid APK release */
+ public static final String APK_RELEASE_FDROID = "F-Droid"; // Default: "F-Droid"
+
+ /** F-Droid APK release signing certificate SHA-256 digest */
+ public static final String APK_RELEASE_FDROID_SIGNING_CERTIFICATE_SHA256_DIGEST = "228FB2CFE90831C1499EC3CCAF61E96E8E1CE70766B9474672CE427334D41C42"; // Default: "228FB2CFE90831C1499EC3CCAF61E96E8E1CE70766B9474672CE427334D41C42"
+
+ /** GitHub APK release */
+ public static final String APK_RELEASE_GITHUB = "Github"; // Default: "Github"
+
+ /** GitHub APK release signing certificate SHA-256 digest */
+ public static final String APK_RELEASE_GITHUB_SIGNING_CERTIFICATE_SHA256_DIGEST = "B6DA01480EEFD5FBF2CD3771B8D1021EC791304BDD6C4BF41D3FAABAD48EE5E1"; // Default: "B6DA01480EEFD5FBF2CD3771B8D1021EC791304BDD6C4BF41D3FAABAD48EE5E1"
+
+ /** Google Play Store APK release */
+ public static final String APK_RELEASE_GOOGLE_PLAYSTORE = "Google Play Store"; // Default: "Google Play Store"
+
+ /** Google Play Store APK release signing certificate SHA-256 digest */
+ public static final String APK_RELEASE_GOOGLE_PLAYSTORE_SIGNING_CERTIFICATE_SHA256_DIGEST = "738F0A30A04D3C8A1BE304AF18D0779BCF3EA88FB60808F657A3521861C2EBF9"; // Default: "738F0A30A04D3C8A1BE304AF18D0779BCF3EA88FB60808F657A3521861C2EBF9"
+
+ /** Termux Devs APK release */
+ public static final String APK_RELEASE_TERMUX_DEVS = "Termux Devs"; // Default: "Termux Devs"
+
+ /** Termux Devs APK release signing certificate SHA-256 digest */
+ public static final String APK_RELEASE_TERMUX_DEVS_SIGNING_CERTIFICATE_SHA256_DIGEST = "F7A038EB551F1BE8FDF388686B784ABAB4552A5D82DF423E3D8F1B5CBE1C69AE"; // Default: "F7A038EB551F1BE8FDF388686B784ABAB4552A5D82DF423E3D8F1B5CBE1C69AE"
+
+
+
+
+
+ /*
+ * Termux packages urls.
+ */
+
+ /** Termux Packages GitHub repo name */
+ public static final String TERMUX_PACKAGES_GITHUB_REPO_NAME = "terminal-packages"; // Default: "terminal-packages"
+ /** Termux Packages GitHub repo url */
+ public static final String TERMUX_PACKAGES_GITHUB_REPO_URL = TERMUX_GITHUB_ORGANIZATION_URL + "/" + TERMUX_PACKAGES_GITHUB_REPO_NAME; // Default: "https://github.com/termux/termux-packages"
+ /** Termux Packages GitHub issues repo url */
+ public static final String TERMUX_PACKAGES_GITHUB_ISSUES_REPO_URL = TERMUX_PACKAGES_GITHUB_REPO_URL + "/issues"; // Default: "https://github.com/termux/termux-packages/issues"
+
+
+ /** Termux API apt package name */
+ public static final String TERMUX_API_APT_PACKAGE_NAME = "termux-api"; // Default: "termux-api"
+ /** Termux API apt GitHub repo name */
+ public static final String TERMUX_API_APT_GITHUB_REPO_NAME = "termux-api-package"; // Default: "termux-api-package"
+ /** Termux API apt GitHub repo url */
+ public static final String TERMUX_API_APT_GITHUB_REPO_URL = TERMUX_GITHUB_ORGANIZATION_URL + "/" + TERMUX_API_APT_GITHUB_REPO_NAME; // Default: "https://github.com/termux/termux-api-package"
+ /** Termux API apt GitHub issues repo url */
+ public static final String TERMUX_API_APT_GITHUB_ISSUES_REPO_URL = TERMUX_API_APT_GITHUB_REPO_URL + "/issues"; // Default: "https://github.com/termux/termux-api-package/issues"
+
+
+
+
+
+ /*
+ * Termux miscellaneous urls.
+ */
+
+ /** Termux Site */
+ public static final String TERMUX_SITE = TERMUX_APP_NAME + " Site"; // Default: "Termux Site"
+
+ /** Termux Wiki */
+ public static final String TERMUX_WIKI = TERMUX_APP_NAME + " Wiki"; // Default: "Termux Wiki"
+
+ /** Termux Wiki url */
+ public static final String TERMUX_WIKI_URL = "https://wiki.termux.com"; // Default: "https://wiki.termux.com"
+
+ /** Termux GitHub wiki repo url */
+ public static final String TERMUX_GITHUB_WIKI_REPO_URL = TERMUX_GITHUB_REPO_URL + "/wiki"; // Default: "https://github.com/termux/termux-app/wiki"
+
+ /** Termux Packages wiki repo url */
+ public static final String TERMUX_PACKAGES_GITHUB_WIKI_REPO_URL = TERMUX_PACKAGES_GITHUB_REPO_URL + "/wiki"; // Default: "https://github.com/termux/termux-packages/wiki"
+
+
+ /** Termux support email url */
+ public static final String TERMUX_SUPPORT_EMAIL_URL = "contact@androidide.com"; // Default: "support@termux.dev"
+
+ /** Termux support email mailto url */
+ public static final String TERMUX_SUPPORT_EMAIL_MAILTO_URL = "mailto:" + TERMUX_SUPPORT_EMAIL_URL; // Default: "mailto:support@termux.dev"
+
+
+
+
+
+ /*
+ * Termux app core directory paths.
+ */
+
+ /** Termux app internal private app data directory path */
+ @SuppressLint("SdCardPath")
+ public static final String TERMUX_INTERNAL_PRIVATE_APP_DATA_DIR_PATH = "/data/data/" + TERMUX_PACKAGE_NAME; // Default: "/data/data/com.termux"
+ /** Termux app internal private app data directory */
+ public static final File TERMUX_INTERNAL_PRIVATE_APP_DATA_DIR = new File(TERMUX_INTERNAL_PRIVATE_APP_DATA_DIR_PATH);
+
+
+
+ /** Termux app Files directory path */
+ public static final String TERMUX_FILES_DIR_PATH = TERMUX_INTERNAL_PRIVATE_APP_DATA_DIR_PATH + "/files"; // Default: "/data/data/com.termux/files"
+ /** Termux app Files directory */
+ public static final File TERMUX_FILES_DIR = new File(TERMUX_FILES_DIR_PATH);
+
+
+
+ /** Termux app $PREFIX directory path */
+ public static final String TERMUX_PREFIX_DIR_PATH = TERMUX_FILES_DIR_PATH + "/usr"; // Default: "/data/data/com.termux/files/usr"
+ /** Termux app $PREFIX directory */
+ public static final File TERMUX_PREFIX_DIR = new File(TERMUX_PREFIX_DIR_PATH);
+
+
+ /** Termux app $PREFIX/bin directory path */
+ public static final String TERMUX_BIN_PREFIX_DIR_PATH = TERMUX_PREFIX_DIR_PATH + "/bin"; // Default: "/data/data/com.termux/files/usr/bin"
+ /** Termux app $PREFIX/bin directory */
+ public static final File TERMUX_BIN_PREFIX_DIR = new File(TERMUX_BIN_PREFIX_DIR_PATH);
+
+
+ /** Termux app $PREFIX/etc directory path */
+ public static final String TERMUX_ETC_PREFIX_DIR_PATH = TERMUX_PREFIX_DIR_PATH + "/etc"; // Default: "/data/data/com.termux/files/usr/etc"
+ /** Termux app $PREFIX/etc directory */
+ public static final File TERMUX_ETC_PREFIX_DIR = new File(TERMUX_ETC_PREFIX_DIR_PATH);
+
+
+ /** Termux app $PREFIX/include directory path */
+ public static final String TERMUX_INCLUDE_PREFIX_DIR_PATH = TERMUX_PREFIX_DIR_PATH + "/include"; // Default: "/data/data/com.termux/files/usr/include"
+ /** Termux app $PREFIX/include directory */
+ public static final File TERMUX_INCLUDE_PREFIX_DIR = new File(TERMUX_INCLUDE_PREFIX_DIR_PATH);
+
+
+ /** Termux app $PREFIX/lib directory path */
+ public static final String TERMUX_LIB_PREFIX_DIR_PATH = TERMUX_PREFIX_DIR_PATH + "/lib"; // Default: "/data/data/com.termux/files/usr/lib"
+ /** Termux app $PREFIX/lib directory */
+ public static final File TERMUX_LIB_PREFIX_DIR = new File(TERMUX_LIB_PREFIX_DIR_PATH);
+
+
+ /** Termux app $PREFIX/libexec directory path */
+ public static final String TERMUX_LIBEXEC_PREFIX_DIR_PATH = TERMUX_PREFIX_DIR_PATH + "/libexec"; // Default: "/data/data/com.termux/files/usr/libexec"
+ /** Termux app $PREFIX/libexec directory */
+ public static final File TERMUX_LIBEXEC_PREFIX_DIR = new File(TERMUX_LIBEXEC_PREFIX_DIR_PATH);
+
+
+ /** Termux app $PREFIX/share directory path */
+ public static final String TERMUX_SHARE_PREFIX_DIR_PATH = TERMUX_PREFIX_DIR_PATH + "/share"; // Default: "/data/data/com.termux/files/usr/share"
+ /** Termux app $PREFIX/share directory */
+ public static final File TERMUX_SHARE_PREFIX_DIR = new File(TERMUX_SHARE_PREFIX_DIR_PATH);
+
+
+ /** Termux app $PREFIX/tmp and $TMPDIR directory path */
+ public static final String TERMUX_TMP_PREFIX_DIR_PATH = TERMUX_PREFIX_DIR_PATH + "/tmp"; // Default: "/data/data/com.termux/files/usr/tmp"
+ /** Termux app $PREFIX/tmp and $TMPDIR directory */
+ public static final File TERMUX_TMP_PREFIX_DIR = new File(TERMUX_TMP_PREFIX_DIR_PATH);
+
+
+ /** Termux app $PREFIX/var directory path */
+ public static final String TERMUX_VAR_PREFIX_DIR_PATH = TERMUX_PREFIX_DIR_PATH + "/var"; // Default: "/data/data/com.termux/files/usr/var"
+ /** Termux app $PREFIX/var directory */
+ public static final File TERMUX_VAR_PREFIX_DIR = new File(TERMUX_VAR_PREFIX_DIR_PATH);
+
+
+
+ /** Termux app usr-staging directory path */
+ public static final String TERMUX_STAGING_PREFIX_DIR_PATH = TERMUX_FILES_DIR_PATH + "/usr-staging"; // Default: "/data/data/com.termux/files/usr-staging"
+ /** Termux app usr-staging directory */
+ public static final File TERMUX_STAGING_PREFIX_DIR = new File(TERMUX_STAGING_PREFIX_DIR_PATH);
+
+
+
+ /** Termux app $HOME directory path */
+ public static final String TERMUX_HOME_DIR_PATH = TERMUX_FILES_DIR_PATH + "/home"; // Default: "/data/data/com.termux/files/home"
+ /** Termux app $HOME directory */
+ public static final File TERMUX_HOME_DIR = new File(TERMUX_HOME_DIR_PATH);
+
+
+ /** Termux app config home directory path */
+ public static final String TERMUX_CONFIG_HOME_DIR_PATH = TERMUX_HOME_DIR_PATH + "/.config/termux"; // Default: "/data/data/com.termux/files/home/.config/termux"
+ /** Termux app config home directory */
+ public static final File TERMUX_CONFIG_HOME_DIR = new File(TERMUX_CONFIG_HOME_DIR_PATH);
+
+ /** Termux app config $PREFIX directory path */
+ public static final String TERMUX_CONFIG_PREFIX_DIR_PATH = TERMUX_ETC_PREFIX_DIR_PATH + "/termux"; // Default: "/data/data/com.termux/files/usr/etc/termux"
+ /** Termux app config $PREFIX directory */
+ public static final File TERMUX_CONFIG_PREFIX_DIR = new File(TERMUX_CONFIG_PREFIX_DIR_PATH);
+
+
+ /** Termux app data home directory path */
+ public static final String TERMUX_DATA_HOME_DIR_PATH = TERMUX_HOME_DIR_PATH + "/.termux"; // Default: "/data/data/com.termux/files/home/.termux"
+ /** Termux app data home directory */
+ public static final File TERMUX_DATA_HOME_DIR = new File(TERMUX_DATA_HOME_DIR_PATH);
+
+
+ /** Termux app storage home directory path */
+ public static final String TERMUX_STORAGE_HOME_DIR_PATH = TERMUX_HOME_DIR_PATH + "/storage"; // Default: "/data/data/com.termux/files/home/storage"
+ /** Termux app storage home directory */
+ public static final File TERMUX_STORAGE_HOME_DIR = new File(TERMUX_STORAGE_HOME_DIR_PATH);
+
+
+
+ /** Termux and plugin apps directory path */
+ public static final String TERMUX_APPS_DIR_PATH = TERMUX_FILES_DIR_PATH + "/apps"; // Default: "/data/data/com.termux/files/apps"
+ /** Termux and plugin apps directory */
+ public static final File TERMUX_APPS_DIR = new File(TERMUX_APPS_DIR_PATH);
+
+
+ /** Termux app $PREFIX directory path ignored sub file paths to consider it empty */
+ public static final List TERMUX_PREFIX_DIR_IGNORED_SUB_FILES_PATHS_TO_CONSIDER_AS_EMPTY = Arrays.asList(
+ TermuxConstants.TERMUX_TMP_PREFIX_DIR_PATH, TermuxConstants.TERMUX_ENV_TEMP_FILE_PATH, TermuxConstants.TERMUX_ENV_FILE_PATH);
+
+
+
+ /*
+ * Termux app and plugin preferences and properties file paths.
+ */
+
+ /** Termux app default SharedPreferences file basename without extension */
+ public static final String TERMUX_DEFAULT_PREFERENCES_FILE_BASENAME_WITHOUT_EXTENSION = TERMUX_PACKAGE_NAME + "_termux_preferences"; // Default: "com.termux_preferences"
+
+ /** Termux app properties primary file path */
+ public static final String TERMUX_PROPERTIES_PRIMARY_FILE_PATH = TERMUX_DATA_HOME_DIR_PATH + "/termux.properties"; // Default: "/data/data/com.termux/files/home/.termux/termux.properties"
+ /** Termux app properties primary file */
+ public static final File TERMUX_PROPERTIES_PRIMARY_FILE = new File(TERMUX_PROPERTIES_PRIMARY_FILE_PATH);
+
+ /** Termux app properties secondary file path */
+ public static final String TERMUX_PROPERTIES_SECONDARY_FILE_PATH = TERMUX_CONFIG_HOME_DIR_PATH + "/termux.properties"; // Default: "/data/data/com.termux/files/home/.config/termux/termux.properties"
+ /** Termux app properties secondary file */
+ public static final File TERMUX_PROPERTIES_SECONDARY_FILE = new File(TERMUX_PROPERTIES_SECONDARY_FILE_PATH);
+
+ /** Termux app properties file paths list. **DO NOT** allow these files to be modified by
+ * {@link android.content.ContentProvider} exposed to external apps, since they may silently
+ * modify the values for security properties like {@link #PROP_ALLOW_EXTERNAL_APPS} set by users
+ * without their explicit consent. */
+ public static final List TERMUX_PROPERTIES_FILE_PATHS_LIST = Arrays.asList(
+ TERMUX_PROPERTIES_PRIMARY_FILE_PATH,
+ TERMUX_PROPERTIES_SECONDARY_FILE_PATH);
+
+
+ /** Termux app and Termux:Styling colors.properties file path */
+ public static final String TERMUX_COLOR_PROPERTIES_FILE_PATH = TERMUX_DATA_HOME_DIR_PATH + "/colors.properties"; // Default: "/data/data/com.termux/files/home/.termux/colors.properties"
+ /** Termux app and Termux:Styling colors.properties file */
+ public static final File TERMUX_COLOR_PROPERTIES_FILE = new File(TERMUX_COLOR_PROPERTIES_FILE_PATH);
+
+ /** Termux app and Termux:Styling font.ttf file path */
+ public static final String TERMUX_FONT_FILE_PATH = TERMUX_DATA_HOME_DIR_PATH + "/font.ttf"; // Default: "/data/data/com.termux/files/home/.termux/font.ttf"
+ /** Termux app and Termux:Styling font.ttf file */
+ public static final File TERMUX_FONT_FILE = new File(TERMUX_FONT_FILE_PATH);
+
+
+ /** Termux app and plugins crash log file path */
+ public static final String TERMUX_CRASH_LOG_FILE_PATH = TERMUX_HOME_DIR_PATH + "/crash_log.md"; // Default: "/data/data/com.termux/files/home/crash_log.md"
+
+ /** Termux app and plugins crash log backup file path */
+ public static final String TERMUX_CRASH_LOG_BACKUP_FILE_PATH = TERMUX_HOME_DIR_PATH + "/crash_log_backup.md"; // Default: "/data/data/com.termux/files/home/crash_log_backup.md"
+
+
+ /** Termux app environment file path */
+ public static final String TERMUX_ENV_FILE_PATH = TERMUX_CONFIG_PREFIX_DIR_PATH + "/termux.env"; // Default: "/data/data/com.termux/files/usr/etc/termux/termux.env"
+
+ /** Termux app environment temp file path */
+ public static final String TERMUX_ENV_TEMP_FILE_PATH = TERMUX_CONFIG_PREFIX_DIR_PATH + "/termux.env.tmp"; // Default: "/data/data/com.termux/files/usr/etc/termux/termux.env.tmp"
+
+ /*
+ * Termux app and plugins notification variables.
+ */
+
+ /** Termux app notification channel id used by {@link TERMUX_APP.TERMUX_SERVICE} */
+ public static final String TERMUX_APP_NOTIFICATION_CHANNEL_ID = "termux_notification_channel";
+ /** Termux app notification channel name used by {@link TERMUX_APP.TERMUX_SERVICE} */
+ public static final String TERMUX_APP_NOTIFICATION_CHANNEL_NAME = TermuxConstants.TERMUX_APP_NAME + " App";
+ /** Termux app unique notification id used by {@link TERMUX_APP.TERMUX_SERVICE} */
+ public static final int TERMUX_APP_NOTIFICATION_ID = 1337;
+
+ public static final int TERMUX_RUN_COMMAND_NOTIFICATION_ID = 1338;
+
+ /** Termux app notification channel id used for plugin command errors */
+ public static final String TERMUX_PLUGIN_COMMAND_ERRORS_NOTIFICATION_CHANNEL_ID = "termux_plugin_command_errors_notification_channel";
+ /** Termux app notification channel name used for plugin command errors */
+ public static final String TERMUX_PLUGIN_COMMAND_ERRORS_NOTIFICATION_CHANNEL_NAME = TermuxConstants.TERMUX_APP_NAME + " Plugin Commands Errors";
+
+ /** Termux app notification channel id used for crash reports */
+ public static final String TERMUX_CRASH_REPORTS_NOTIFICATION_CHANNEL_ID = "termux_crash_reports_notification_channel";
+ /** Termux app notification channel name used for crash reports */
+ public static final String TERMUX_CRASH_REPORTS_NOTIFICATION_CHANNEL_NAME = TermuxConstants.TERMUX_APP_NAME + " Crash Reports";
+
+
+
+
+ /*
+ * Termux app and plugins miscellaneous variables.
+ */
+
+ /** Termux property defined in termux.properties file as a secondary check to PERMISSION_RUN_COMMAND
+ * to allow 3rd party apps to run various commands in Termux app context */
+ public static final String PROP_ALLOW_EXTERNAL_APPS = "allow-external-apps"; // Default: "allow-external-apps"
+ /** Default value for {@link #PROP_ALLOW_EXTERNAL_APPS} */
+ public static final String PROP_DEFAULT_VALUE_ALLOW_EXTERNAL_APPS = "false"; // Default: "false"
+
+ /** The broadcast action sent when Termux App opens */
+ public static final String BROADCAST_TERMUX_OPENED = TERMUX_PACKAGE_NAME + ".app.OPENED";
+
+ /** The Uri authority for Termux app file shares */
+ public static final String TERMUX_FILE_SHARE_URI_AUTHORITY = TERMUX_PACKAGE_NAME + ".files"; // Default: "com.termux.files"
+
+ /** The normal comma character (U+002C, ,, ,, comma) */
+ public static final String COMMA_NORMAL = ","; // Default: ","
+
+ /** The alternate comma character (U+201A, ‚, ‚, single low-9 quotation mark) that
+ * may be used instead of {@link #COMMA_NORMAL} */
+ public static final String COMMA_ALTERNATIVE = "‚"; // Default: "‚"
+
+ /** Environment variable prefix root for the Termux app. */
+ public static final String TERMUX_ENV_PREFIX_ROOT = "TERMUX";
+
+
+
+
+
+
+ /**
+ * Termux app constants.
+ */
+ public static final class TERMUX_APP {
+
+ /** Termux apps directory path */
+ public static final String APPS_DIR_PATH = TERMUX_APPS_DIR_PATH + "/" + TERMUX_PACKAGE_NAME; // Default: "/data/data/com.termux/files/apps/com.termux"
+
+ /** termux-am socket file path */
+ public static final String TERMUX_AM_SOCKET_FILE_PATH = APPS_DIR_PATH + "/termux-am/am.sock"; // Default: "/data/data/com.termux/files/apps/com.termux/termux-am/am.sock"
+
+
+ /** Termux app BuildConfig class name */
+ public static final String BUILD_CONFIG_CLASS_NAME = TERMUX_PACKAGE_NAME + ".BuildConfig"; // Default: "com.termux.BuildConfig"
+
+ /** Termux app FileShareReceiverActivity class name */
+ public static final String FILE_SHARE_RECEIVER_ACTIVITY_CLASS_NAME = TERMUX_PACKAGE_NAME + ".app.api.file.FileShareReceiverActivity"; // Default: "com.termux.app.api.file.FileShareReceiverActivity"
+
+ /** Termux app FileViewReceiverActivity class name */
+ public static final String FILE_VIEW_RECEIVER_ACTIVITY_CLASS_NAME = TERMUX_PACKAGE_NAME + ".app.api.file.FileViewReceiverActivity"; // Default: "com.termux.app.api.file.FileViewReceiverActivity"
+
+
+ /** Termux app core activity name. */
+ public static final String TERMUX_ACTIVITY_NAME = TERMUX_PACKAGE_NAME + ".app.TermuxActivity"; // Default: "com.termux.app.TermuxActivity"
+
+ /**
+ * Termux app core activity.
+ */
+ public static final class TERMUX_ACTIVITY {
+
+ /** Intent extra for if termux failsafe session needs to be started and is used by {@link TERMUX_ACTIVITY} and {@link TERMUX_SERVICE#ACTION_STOP_SERVICE} */
+ public static final String EXTRA_FAILSAFE_SESSION = TermuxConstants.TERMUX_PACKAGE_NAME + ".app.failsafe_session"; // Default: "com.termux.app.failsafe_session"
+
+
+ /** Intent action to make termux app notify user that a crash happened. */
+ public static final String ACTION_NOTIFY_APP_CRASH = TermuxConstants.TERMUX_PACKAGE_NAME + ".app.notify_app_crash"; // Default: "com.termux.app.notify_app_crash"
+
+
+ /** Intent action to make termux reload its termux session styling */
+ public static final String ACTION_RELOAD_STYLE = TermuxConstants.TERMUX_PACKAGE_NAME + ".app.reload_style"; // Default: "com.termux.app.reload_style"
+ /** Intent {@code String} extra for what to reload for the TERMUX_ACTIVITY.ACTION_RELOAD_STYLE intent. This has been deperecated. */
+ @Deprecated
+ public static final String EXTRA_RELOAD_STYLE = TermuxConstants.TERMUX_PACKAGE_NAME + ".app.reload_style"; // Default: "com.termux.app.reload_style"
+
+ /** Intent {@code boolean} extra for whether to recreate activity for the TERMUX_ACTIVITY.ACTION_RELOAD_STYLE intent. */
+ public static final String EXTRA_RECREATE_ACTIVITY = TERMUX_APP.TERMUX_ACTIVITY_NAME + ".EXTRA_RECREATE_ACTIVITY"; // Default: "com.termux.app.TermuxActivity.EXTRA_RECREATE_ACTIVITY"
+
+
+ /** Intent action to make termux request storage permissions */
+ public static final String ACTION_REQUEST_PERMISSIONS = TermuxConstants.TERMUX_PACKAGE_NAME + ".app.request_storage_permissions"; // Default: "com.termux.app.request_storage_permissions"
+ }
+
+
+
+
+
+ /** Termux app settings activity name. */
+ public static final String TERMUX_SETTINGS_ACTIVITY_NAME = TERMUX_PACKAGE_NAME + ".app.activities.SettingsActivity"; // Default: "com.termux.app.activities.SettingsActivity"
+
+
+
+
+
+ /** Termux app core service name. */
+ public static final String TERMUX_SERVICE_NAME = TERMUX_PACKAGE_NAME + ".app.TermuxService"; // Default: "com.termux.app.TermuxService"
+
+ /**
+ * Termux app core service.
+ */
+ public static final class TERMUX_SERVICE {
+
+ /** Intent action to stop TERMUX_SERVICE */
+ public static final String ACTION_STOP_SERVICE = TERMUX_PACKAGE_NAME + ".service_stop"; // Default: "com.termux.service_stop"
+
+
+ /** Intent action to make TERMUX_SERVICE acquire a wakelock */
+ public static final String ACTION_WAKE_LOCK = TERMUX_PACKAGE_NAME + ".service_wake_lock"; // Default: "com.termux.service_wake_lock"
+
+
+ /** Intent action to make TERMUX_SERVICE release wakelock */
+ public static final String ACTION_WAKE_UNLOCK = TERMUX_PACKAGE_NAME + ".service_wake_unlock"; // Default: "com.termux.service_wake_unlock"
+
+
+ /** Intent action to execute command with TERMUX_SERVICE */
+ public static final String ACTION_SERVICE_EXECUTE = TERMUX_PACKAGE_NAME + ".service_execute"; // Default: "com.termux.service_execute"
+
+ /** Uri scheme for paths sent via intent to TERMUX_SERVICE */
+ public static final String URI_SCHEME_SERVICE_EXECUTE = TERMUX_PACKAGE_NAME + ".file"; // Default: "com.termux.file"
+ /** Intent {@code String[]} extra for arguments to the executable of the command for the TERMUX_SERVICE.ACTION_SERVICE_EXECUTE intent */
+ public static final String EXTRA_ARGUMENTS = TERMUX_PACKAGE_NAME + ".execute.arguments"; // Default: "com.termux.execute.arguments"
+ /** Intent {@code String} extra for stdin of the command for the TERMUX_SERVICE.ACTION_SERVICE_EXECUTE intent */
+ public static final String EXTRA_STDIN = TERMUX_PACKAGE_NAME + ".execute.stdin"; // Default: "com.termux.execute.stdin"
+ /** Intent {@code String} extra for command current working directory for the TERMUX_SERVICE.ACTION_SERVICE_EXECUTE intent */
+ public static final String EXTRA_WORKDIR = TERMUX_PACKAGE_NAME + ".execute.cwd"; // Default: "com.termux.execute.cwd"
+ /** Intent {@code boolean} extra for whether to run command in background {@link Runner#APP_SHELL} or foreground {@link Runner#TERMINAL_SESSION} for the TERMUX_SERVICE.ACTION_SERVICE_EXECUTE intent */
+ @Deprecated
+ public static final String EXTRA_BACKGROUND = TERMUX_PACKAGE_NAME + ".execute.background"; // Default: "com.termux.execute.background"
+ /** Intent {@code String} extra for command the {@link Runner} for the TERMUX_SERVICE.ACTION_SERVICE_EXECUTE intent */
+ public static final String EXTRA_RUNNER = TERMUX_PACKAGE_NAME + ".execute.runner"; // Default: "com.termux.execute.runner"
+ /** Intent {@code String} extra for custom log level for background commands defined by {@link com.termux.shared.logger.Logger} for the TERMUX_SERVICE.ACTION_SERVICE_EXECUTE intent */
+ public static final String EXTRA_BACKGROUND_CUSTOM_LOG_LEVEL = TERMUX_PACKAGE_NAME + ".execute.background_custom_log_level"; // Default: "com.termux.execute.background_custom_log_level"
+ /** Intent {@code String} extra for session action for {@link Runner#TERMINAL_SESSION} commands for the TERMUX_SERVICE.ACTION_SERVICE_EXECUTE intent */
+ public static final String EXTRA_SESSION_ACTION = TERMUX_PACKAGE_NAME + ".execute.session_action"; // Default: "com.termux.execute.session_action"
+ /** Intent {@code String} extra for shell name for commands for the TERMUX_SERVICE.ACTION_SERVICE_EXECUTE intent */
+ public static final String EXTRA_SHELL_NAME = TERMUX_PACKAGE_NAME + ".execute.shell_name"; // Default: "com.termux.execute.shell_name"
+ /** Intent {@code String} extra for the {@link ExecutionCommand.ShellCreateMode} for the TERMUX_SERVICE.ACTION_SERVICE_EXECUTE intent. */
+ public static final String EXTRA_SHELL_CREATE_MODE = TERMUX_PACKAGE_NAME + ".execute.shell_create_mode"; // Default: "com.termux.execute.shell_create_mode"
+ /** Intent {@code String} extra for label of the command for the TERMUX_SERVICE.ACTION_SERVICE_EXECUTE intent */
+ public static final String EXTRA_COMMAND_LABEL = TERMUX_PACKAGE_NAME + ".execute.command_label"; // Default: "com.termux.execute.command_label"
+ /** Intent markdown {@code String} extra for description of the command for the TERMUX_SERVICE.ACTION_SERVICE_EXECUTE intent */
+ public static final String EXTRA_COMMAND_DESCRIPTION = TERMUX_PACKAGE_NAME + ".execute.command_description"; // Default: "com.termux.execute.command_description"
+ /** Intent markdown {@code String} extra for help of the command for the TERMUX_SERVICE.ACTION_SERVICE_EXECUTE intent */
+ public static final String EXTRA_COMMAND_HELP = TERMUX_PACKAGE_NAME + ".execute.command_help"; // Default: "com.termux.execute.command_help"
+ /** Intent markdown {@code String} extra for help of the plugin API for the TERMUX_SERVICE.ACTION_SERVICE_EXECUTE intent (Internal Use Only) */
+ public static final String EXTRA_PLUGIN_API_HELP = TERMUX_PACKAGE_NAME + ".execute.plugin_api_help"; // Default: "com.termux.execute.plugin_help"
+ /** Intent {@code Parcelable} extra for the pending intent that should be sent with the
+ * result of the execution command to the execute command caller for the TERMUX_SERVICE.ACTION_SERVICE_EXECUTE intent */
+ public static final String EXTRA_PENDING_INTENT = "pendingIntent"; // Default: "pendingIntent"
+ /** Intent {@code String} extra for the directory path in which to write the result of the
+ * execution command for the execute command caller for the TERMUX_SERVICE.ACTION_SERVICE_EXECUTE intent */
+ public static final String EXTRA_RESULT_DIRECTORY = TERMUX_PACKAGE_NAME + ".execute.result_directory"; // Default: "com.termux.execute.result_directory"
+ /** Intent {@code boolean} extra for whether the result should be written to a single file
+ * or multiple files (err, errmsg, stdout, stderr, exit_code) in
+ * {@link #EXTRA_RESULT_DIRECTORY} for the TERMUX_SERVICE.ACTION_SERVICE_EXECUTE intent */
+ public static final String EXTRA_RESULT_SINGLE_FILE = TERMUX_PACKAGE_NAME + ".execute.result_single_file"; // Default: "com.termux.execute.result_single_file"
+ /** Intent {@code String} extra for the basename of the result file that should be created
+ * in {@link #EXTRA_RESULT_DIRECTORY} if {@link #EXTRA_RESULT_SINGLE_FILE} is {@code true}
+ * for the TERMUX_SERVICE.ACTION_SERVICE_EXECUTE intent */
+ public static final String EXTRA_RESULT_FILE_BASENAME = TERMUX_PACKAGE_NAME + ".execute.result_file_basename"; // Default: "com.termux.execute.result_file_basename"
+ /** Intent {@code String} extra for the output {@link Formatter} format of the
+ * {@link #EXTRA_RESULT_FILE_BASENAME} result file for the TERMUX_SERVICE.ACTION_SERVICE_EXECUTE intent */
+ public static final String EXTRA_RESULT_FILE_OUTPUT_FORMAT = TERMUX_PACKAGE_NAME + ".execute.result_file_output_format"; // Default: "com.termux.execute.result_file_output_format"
+ /** Intent {@code String} extra for the error {@link Formatter} format of the
+ * {@link #EXTRA_RESULT_FILE_BASENAME} result file for the TERMUX_SERVICE.ACTION_SERVICE_EXECUTE intent */
+ public static final String EXTRA_RESULT_FILE_ERROR_FORMAT = TERMUX_PACKAGE_NAME + ".execute.result_file_error_format"; // Default: "com.termux.execute.result_file_error_format"
+ /** Intent {@code String} extra for the optional suffix of the result files that should
+ * be created in {@link #EXTRA_RESULT_DIRECTORY} if {@link #EXTRA_RESULT_SINGLE_FILE} is
+ * {@code false} for the TERMUX_SERVICE.ACTION_SERVICE_EXECUTE intent */
+ public static final String EXTRA_RESULT_FILES_SUFFIX = TERMUX_PACKAGE_NAME + ".execute.result_files_suffix"; // Default: "com.termux.execute.result_files_suffix"
+
+
+
+ /**
+ * The value for {@link #EXTRA_SESSION_ACTION} extra that will set the new session as
+ * the current session and will start {@link TERMUX_ACTIVITY} if its not running to bring
+ * the new session to foreground.
+ */
+ public static final int VALUE_EXTRA_SESSION_ACTION_SWITCH_TO_NEW_SESSION_AND_OPEN_ACTIVITY = 0;
+
+ /**
+ * The value for {@link #EXTRA_SESSION_ACTION} extra that will keep any existing session
+ * as the current session and will start {@link TERMUX_ACTIVITY} if its not running to
+ * bring the existing session to foreground. The new session will be added to the left
+ * sidebar in the sessions list.
+ */
+ public static final int VALUE_EXTRA_SESSION_ACTION_KEEP_CURRENT_SESSION_AND_OPEN_ACTIVITY = 1;
+
+ /**
+ * The value for {@link #EXTRA_SESSION_ACTION} extra that will set the new session as
+ * the current session but will not start {@link TERMUX_ACTIVITY} if its not running
+ * and session(s) will be seen in Termux notification and can be clicked to bring new
+ * session to foreground. If the {@link TERMUX_ACTIVITY} is already running, then this
+ * will behave like {@link #VALUE_EXTRA_SESSION_ACTION_KEEP_CURRENT_SESSION_AND_OPEN_ACTIVITY}.
+ */
+ public static final int VALUE_EXTRA_SESSION_ACTION_SWITCH_TO_NEW_SESSION_AND_DONT_OPEN_ACTIVITY = 2;
+
+ /**
+ * The value for {@link #EXTRA_SESSION_ACTION} extra that will keep any existing session
+ * as the current session but will not start {@link TERMUX_ACTIVITY} if its not running
+ * and session(s) will be seen in Termux notification and can be clicked to bring
+ * existing session to foreground. If the {@link TERMUX_ACTIVITY} is already running,
+ * then this will behave like {@link #VALUE_EXTRA_SESSION_ACTION_KEEP_CURRENT_SESSION_AND_OPEN_ACTIVITY}.
+ */
+ public static final int VALUE_EXTRA_SESSION_ACTION_KEEP_CURRENT_SESSION_AND_DONT_OPEN_ACTIVITY = 3;
+
+ /** The minimum allowed value for {@link #EXTRA_SESSION_ACTION}. */
+ public static final int MIN_VALUE_EXTRA_SESSION_ACTION = VALUE_EXTRA_SESSION_ACTION_SWITCH_TO_NEW_SESSION_AND_OPEN_ACTIVITY;
+
+ /** The maximum allowed value for {@link #EXTRA_SESSION_ACTION}. */
+ public static final int MAX_VALUE_EXTRA_SESSION_ACTION = VALUE_EXTRA_SESSION_ACTION_KEEP_CURRENT_SESSION_AND_DONT_OPEN_ACTIVITY;
+
+
+ /** Intent {@code Bundle} extra to store result of execute command that is sent back for the
+ * TERMUX_SERVICE.ACTION_SERVICE_EXECUTE intent if the {@link #EXTRA_PENDING_INTENT} is not
+ * {@code null} */
+ public static final String EXTRA_PLUGIN_RESULT_BUNDLE = "result"; // Default: "result"
+ /** Intent {@code String} extra for stdout value of execute command of the {@link #EXTRA_PLUGIN_RESULT_BUNDLE} */
+ public static final String EXTRA_PLUGIN_RESULT_BUNDLE_STDOUT = "stdout"; // Default: "stdout"
+ /** Intent {@code String} extra for original length of stdout value of execute command of the {@link #EXTRA_PLUGIN_RESULT_BUNDLE} */
+ public static final String EXTRA_PLUGIN_RESULT_BUNDLE_STDOUT_ORIGINAL_LENGTH = "stdout_original_length"; // Default: "stdout_original_length"
+ /** Intent {@code String} extra for stderr value of execute command of the {@link #EXTRA_PLUGIN_RESULT_BUNDLE} */
+ public static final String EXTRA_PLUGIN_RESULT_BUNDLE_STDERR = "stderr"; // Default: "stderr"
+ /** Intent {@code String} extra for original length of stderr value of execute command of the {@link #EXTRA_PLUGIN_RESULT_BUNDLE} */
+ public static final String EXTRA_PLUGIN_RESULT_BUNDLE_STDERR_ORIGINAL_LENGTH = "stderr_original_length"; // Default: "stderr_original_length"
+ /** Intent {@code int} extra for exit code value of execute command of the {@link #EXTRA_PLUGIN_RESULT_BUNDLE} */
+ public static final String EXTRA_PLUGIN_RESULT_BUNDLE_EXIT_CODE = "exitCode"; // Default: "exitCode"
+ /** Intent {@code int} extra for err value of execute command of the {@link #EXTRA_PLUGIN_RESULT_BUNDLE} */
+ public static final String EXTRA_PLUGIN_RESULT_BUNDLE_ERR = "err"; // Default: "err"
+ /** Intent {@code String} extra for errmsg value of execute command of the {@link #EXTRA_PLUGIN_RESULT_BUNDLE} */
+ public static final String EXTRA_PLUGIN_RESULT_BUNDLE_ERRMSG = "errmsg"; // Default: "errmsg"
+
+ }
+
+
+
+
+
+ /** Termux app run command service name. */
+ public static final String RUN_COMMAND_SERVICE_NAME = TERMUX_PACKAGE_NAME + ".app.RunCommandService"; // Termux app service to receive commands from 3rd party apps "com.termux.app.RunCommandService"
+
+ /**
+ * Termux app run command service to receive commands sent by 3rd party apps.
+ */
+ public static final class RUN_COMMAND_SERVICE {
+
+ /** Termux RUN_COMMAND Intent help url */
+ public static final String RUN_COMMAND_API_HELP_URL = TERMUX_GITHUB_WIKI_REPO_URL + "/RUN_COMMAND-Intent"; // Default: "https://github.com/termux/termux-app/wiki/RUN_COMMAND-Intent"
+
+
+ /** Intent action to execute command with RUN_COMMAND_SERVICE */
+ public static final String ACTION_RUN_COMMAND = TERMUX_PACKAGE_NAME + ".RUN_COMMAND"; // Default: "com.termux.RUN_COMMAND"
+
+ /** Intent {@code String} extra for absolute path of command for the RUN_COMMAND_SERVICE.ACTION_RUN_COMMAND intent */
+ public static final String EXTRA_COMMAND_PATH = TERMUX_PACKAGE_NAME + ".RUN_COMMAND_PATH"; // Default: "com.termux.RUN_COMMAND_PATH"
+ /** Intent {@code String[]} extra for arguments to the executable of the command for the RUN_COMMAND_SERVICE.ACTION_RUN_COMMAND intent */
+ public static final String EXTRA_ARGUMENTS = TERMUX_PACKAGE_NAME + ".RUN_COMMAND_ARGUMENTS"; // Default: "com.termux.RUN_COMMAND_ARGUMENTS"
+ /** Intent {@code boolean} extra for whether to replace comma alternative characters in arguments with comma characters for the RUN_COMMAND_SERVICE.ACTION_RUN_COMMAND intent */
+ public static final String EXTRA_REPLACE_COMMA_ALTERNATIVE_CHARS_IN_ARGUMENTS = TERMUX_PACKAGE_NAME + ".RUN_COMMAND_REPLACE_COMMA_ALTERNATIVE_CHARS_IN_ARGUMENTS"; // Default: "com.termux.RUN_COMMAND_REPLACE_COMMA_ALTERNATIVE_CHARS_IN_ARGUMENTS"
+ /** Intent {@code String} extra for the comma alternative characters in arguments that should be replaced instead of the default {@link #COMMA_ALTERNATIVE} for the RUN_COMMAND_SERVICE.ACTION_RUN_COMMAND intent */
+ public static final String EXTRA_COMMA_ALTERNATIVE_CHARS_IN_ARGUMENTS = TERMUX_PACKAGE_NAME + ".RUN_COMMAND_COMMA_ALTERNATIVE_CHARS_IN_ARGUMENTS"; // Default: "com.termux.RUN_COMMAND_COMMA_ALTERNATIVE_CHARS_IN_ARGUMENTS"
+
+ /** Intent {@code String} extra for stdin of the command for the RUN_COMMAND_SERVICE.ACTION_RUN_COMMAND intent */
+ public static final String EXTRA_STDIN = TERMUX_PACKAGE_NAME + ".RUN_COMMAND_STDIN"; // Default: "com.termux.RUN_COMMAND_STDIN"
+ /** Intent {@code String} extra for current working directory of command for the RUN_COMMAND_SERVICE.ACTION_RUN_COMMAND intent */
+ public static final String EXTRA_WORKDIR = TERMUX_PACKAGE_NAME + ".RUN_COMMAND_WORKDIR"; // Default: "com.termux.RUN_COMMAND_WORKDIR"
+ /** Intent {@code boolean} extra for whether to run command in background {@link Runner#APP_SHELL} or foreground {@link Runner#TERMINAL_SESSION} for the RUN_COMMAND_SERVICE.ACTION_RUN_COMMAND intent */
+ @Deprecated
+ public static final String EXTRA_BACKGROUND = TERMUX_PACKAGE_NAME + ".RUN_COMMAND_BACKGROUND"; // Default: "com.termux.RUN_COMMAND_BACKGROUND"
+ /** Intent {@code String} extra for command the {@link Runner} for the RUN_COMMAND_SERVICE.ACTION_RUN_COMMAND intent */
+ public static final String EXTRA_RUNNER = TERMUX_PACKAGE_NAME + ".RUN_COMMAND_RUNNER"; // Default: "com.termux.RUN_COMMAND_RUNNER"
+ /** Intent {@code String} extra for custom log level for background commands defined by {@link com.termux.shared.logger.Logger} for the RUN_COMMAND_SERVICE.ACTION_RUN_COMMAND intent */
+ public static final String EXTRA_BACKGROUND_CUSTOM_LOG_LEVEL = TERMUX_PACKAGE_NAME + ".RUN_COMMAND_BACKGROUND_CUSTOM_LOG_LEVEL"; // Default: "com.termux.RUN_COMMAND_BACKGROUND_CUSTOM_LOG_LEVEL"
+ /** Intent {@code String} extra for session action of {@link Runner#TERMINAL_SESSION} commands for the RUN_COMMAND_SERVICE.ACTION_RUN_COMMAND intent */
+ public static final String EXTRA_SESSION_ACTION = TERMUX_PACKAGE_NAME + ".RUN_COMMAND_SESSION_ACTION"; // Default: "com.termux.RUN_COMMAND_SESSION_ACTION"
+ /** Intent {@code String} extra for shell name of commands for the RUN_COMMAND_SERVICE.ACTION_RUN_COMMAND intent */
+ public static final String EXTRA_SHELL_NAME = TERMUX_PACKAGE_NAME + ".RUN_COMMAND_SHELL_NAME"; // Default: "com.termux.RUN_COMMAND_SHELL_NAME"
+ /** Intent {@code String} extra for the {@link ExecutionCommand.ShellCreateMode} for the RUN_COMMAND_SERVICE.ACTION_RUN_COMMAND intent. */
+ public static final String EXTRA_SHELL_CREATE_MODE = TERMUX_PACKAGE_NAME + ".RUN_COMMAND_SHELL_CREATE_MODE"; // Default: "com.termux.RUN_COMMAND_SHELL_CREATE_MODE"
+ /** Intent {@code String} extra for label of the command for the RUN_COMMAND_SERVICE.ACTION_RUN_COMMAND intent */
+ public static final String EXTRA_COMMAND_LABEL = TERMUX_PACKAGE_NAME + ".RUN_COMMAND_COMMAND_LABEL"; // Default: "com.termux.RUN_COMMAND_COMMAND_LABEL"
+ /** Intent markdown {@code String} extra for description of the command for the RUN_COMMAND_SERVICE.ACTION_RUN_COMMAND intent */
+ public static final String EXTRA_COMMAND_DESCRIPTION = TERMUX_PACKAGE_NAME + ".RUN_COMMAND_COMMAND_DESCRIPTION"; // Default: "com.termux.RUN_COMMAND_COMMAND_DESCRIPTION"
+ /** Intent markdown {@code String} extra for help of the command for the RUN_COMMAND_SERVICE.ACTION_RUN_COMMAND intent */
+ public static final String EXTRA_COMMAND_HELP = TERMUX_PACKAGE_NAME + ".RUN_COMMAND_COMMAND_HELP"; // Default: "com.termux.RUN_COMMAND_COMMAND_HELP"
+ /** Intent {@code Parcelable} extra for the pending intent that should be sent with the result of the execution command to the execute command caller for the RUN_COMMAND_SERVICE.ACTION_RUN_COMMAND intent */
+ public static final String EXTRA_PENDING_INTENT = TERMUX_PACKAGE_NAME + ".RUN_COMMAND_PENDING_INTENT"; // Default: "com.termux.RUN_COMMAND_PENDING_INTENT"
+ /** Intent {@code String} extra for the directory path in which to write the result of
+ * the execution command for the execute command caller for the RUN_COMMAND_SERVICE.ACTION_RUN_COMMAND intent */
+ public static final String EXTRA_RESULT_DIRECTORY = TERMUX_PACKAGE_NAME + ".RUN_COMMAND_RESULT_DIRECTORY"; // Default: "com.termux.RUN_COMMAND_RESULT_DIRECTORY"
+ /** Intent {@code boolean} extra for whether the result should be written to a single file
+ * or multiple files (err, errmsg, stdout, stderr, exit_code) in
+ * {@link #EXTRA_RESULT_DIRECTORY} for the RUN_COMMAND_SERVICE.ACTION_RUN_COMMAND intent */
+ public static final String EXTRA_RESULT_SINGLE_FILE = TERMUX_PACKAGE_NAME + ".RUN_COMMAND_RESULT_SINGLE_FILE"; // Default: "com.termux.RUN_COMMAND_RESULT_SINGLE_FILE"
+ /** Intent {@code String} extra for the basename of the result file that should be created
+ * in {@link #EXTRA_RESULT_DIRECTORY} if {@link #EXTRA_RESULT_SINGLE_FILE} is {@code true}
+ * for the RUN_COMMAND_SERVICE.ACTION_RUN_COMMAND intent */
+ public static final String EXTRA_RESULT_FILE_BASENAME = TERMUX_PACKAGE_NAME + ".RUN_COMMAND_RESULT_FILE_BASENAME"; // Default: "com.termux.RUN_COMMAND_RESULT_FILE_BASENAME"
+ /** Intent {@code String} extra for the output {@link Formatter} format of the
+ * {@link #EXTRA_RESULT_FILE_BASENAME} result file for the RUN_COMMAND_SERVICE.ACTION_RUN_COMMAND intent */
+ public static final String EXTRA_RESULT_FILE_OUTPUT_FORMAT = TERMUX_PACKAGE_NAME + ".RUN_COMMAND_RESULT_FILE_OUTPUT_FORMAT"; // Default: "com.termux.RUN_COMMAND_RESULT_FILE_OUTPUT_FORMAT"
+ /** Intent {@code String} extra for the error {@link Formatter} format of the
+ * {@link #EXTRA_RESULT_FILE_BASENAME} result file for the RUN_COMMAND_SERVICE.ACTION_RUN_COMMAND intent */
+ public static final String EXTRA_RESULT_FILE_ERROR_FORMAT = TERMUX_PACKAGE_NAME + ".RUN_COMMAND_RESULT_FILE_ERROR_FORMAT"; // Default: "com.termux.RUN_COMMAND_RESULT_FILE_ERROR_FORMAT"
+ /** Intent {@code String} extra for the optional suffix of the result files that should be
+ * created in {@link #EXTRA_RESULT_DIRECTORY} if {@link #EXTRA_RESULT_SINGLE_FILE} is
+ * {@code false} for the RUN_COMMAND_SERVICE.ACTION_RUN_COMMAND intent */
+ public static final String EXTRA_RESULT_FILES_SUFFIX = TERMUX_PACKAGE_NAME + ".RUN_COMMAND_RESULT_FILES_SUFFIX"; // Default: "com.termux.RUN_COMMAND_RESULT_FILES_SUFFIX"
+
+ }
+ }
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/termux/TermuxUtils.java b/termux/termux-shared/src/main/java/com/termux/shared/termux/TermuxUtils.java
new file mode 100644
index 000000000..b300e7e0f
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/termux/TermuxUtils.java
@@ -0,0 +1,556 @@
+package com.termux.shared.termux;
+
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.content.pm.ResolveInfo;
+import android.net.Uri;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import com.termux.shared.R;
+import com.termux.shared.android.AndroidUtils;
+import com.termux.shared.android.PackageUtils;
+import com.termux.shared.errors.Error;
+import com.termux.shared.file.FileUtils;
+import com.termux.shared.logger.Logger;
+import com.termux.shared.markdown.MarkdownUtils;
+import com.termux.shared.reflection.ReflectionUtils;
+import com.termux.shared.shell.command.ExecutionCommand;
+import com.termux.shared.shell.command.runner.app.AppShell;
+import com.termux.shared.termux.TermuxConstants.TERMUX_APP;
+import com.termux.shared.termux.file.TermuxFileUtils;
+import com.termux.shared.termux.shell.command.environment.TermuxShellEnvironment;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.Charset;
+import java.util.List;
+import java.util.regex.Pattern;
+import org.apache.commons.io.IOUtils;
+
+public class TermuxUtils {
+
+ /** The modes used by {@link #getAppInfoMarkdownString(Context, AppInfoMode, String)}. */
+ public enum AppInfoMode {
+ /** Get info for Termux app only. */
+ TERMUX_PACKAGE,
+ /** Get info for Termux app and plugin app if context is of plugin app. */
+ TERMUX_AND_PLUGIN_PACKAGE,
+ TERMUX_AND_PLUGIN_PACKAGES,
+ /* Get info for Termux app and the calling package that called a Termux API. */
+ TERMUX_AND_CALLING_PACKAGE,
+ }
+
+ private static final String LOG_TAG = "TermuxUtils";
+
+ /**
+ * Get the {@link Context} for {@link TermuxConstants#TERMUX_PACKAGE_NAME} package with the
+ * {@link Context#CONTEXT_RESTRICTED} flag.
+ *
+ * @param context The {@link Context} to use to get the {@link Context} of the package.
+ * @return Returns the {@link Context}. This will {@code null} if an exception is raised.
+ */
+ public static Context getTermuxPackageContext(@NonNull Context context) {
+ return PackageUtils.getContextForPackage(context, TermuxConstants.TERMUX_PACKAGE_NAME);
+ }
+
+ /**
+ * Get the {@link Context} for {@link TermuxConstants#TERMUX_PACKAGE_NAME} package with the
+ * {@link Context#CONTEXT_INCLUDE_CODE} flag.
+ *
+ * @param context The {@link Context} to use to get the {@link Context} of the package.
+ * @return Returns the {@link Context}. This will {@code null} if an exception is raised.
+ */
+ public static Context getTermuxPackageContextWithCode(@NonNull Context context) {
+ return PackageUtils.getContextForPackage(context, TermuxConstants.TERMUX_PACKAGE_NAME, Context.CONTEXT_INCLUDE_CODE);
+ }
+
+ /** Wrapper for {@link PackageUtils#getContextForPackageOrExitApp(Context, String, boolean, String)}. */
+ public static Context getContextForPackageOrExitApp(@NonNull Context context, String packageName,
+ final boolean exitAppOnError) {
+ return PackageUtils.getContextForPackageOrExitApp(context, packageName, exitAppOnError, TermuxConstants.TERMUX_GITHUB_REPO_URL);
+ }
+
+ /**
+ * Check if Termux app is installed and enabled. This can be used by external apps that don't
+ * share `sharedUserId` with the Termux app.
+ *
+ * If your third-party app is targeting sdk `30` (android `11`), then it needs to add `com.termux`
+ * package to the `queries` element or request `QUERY_ALL_PACKAGES` permission in its
+ * `AndroidManifest.xml`. Otherwise it will get `PackageSetting{...... com.termux/......} BLOCKED`
+ * errors in `logcat` and `RUN_COMMAND` won't work.
+ * Check [package-visibility](https://developer.android.com/training/basics/intents/package-visibility#package-name),
+ * `QUERY_ALL_PACKAGES` [googleplay policy](https://support.google.com/googleplay/android-developer/answer/10158779
+ * and this [article](https://medium.com/androiddevelopers/working-with-package-visibility-dc252829de2d) for more info.
+ *
+ * {@code
+ *
+ *
+ *
+ *
+ * }
+ *
+ * @param context The context for operations.
+ * @return Returns {@code errmsg} if {@link TermuxConstants#TERMUX_PACKAGE_NAME} is not installed
+ * or disabled, otherwise {@code null}.
+ */
+ public static String isTermuxAppInstalled(@NonNull final Context context) {
+ return PackageUtils.isAppInstalled(context, TermuxConstants.TERMUX_APP_NAME, TermuxConstants.TERMUX_PACKAGE_NAME);
+ }
+
+ /**
+ * Check if Termux app is installed and accessible. This can only be used by apps that share
+ * `sharedUserId` with the Termux app.
+ *
+ * This is done by checking if first checking if app is installed and enabled and then if
+ * {@code currentPackageContext} can be used to get the {@link Context} of the app with
+ * {@link TermuxConstants#TERMUX_PACKAGE_NAME} and then if
+ * {@link TermuxConstants#TERMUX_PREFIX_DIR_PATH} exists and has
+ * {@link FileUtils#APP_WORKING_DIRECTORY_PERMISSIONS} permissions. The directory will not
+ * be automatically created and neither the missing permissions automatically set.
+ *
+ * @param currentPackageContext The context of current package.
+ * @return Returns {@code errmsg} if failed to get termux package {@link Context} or
+ * {@link TermuxConstants#TERMUX_PREFIX_DIR_PATH} is accessible, otherwise {@code null}.
+ */
+ public static String isTermuxAppAccessible(@NonNull final Context currentPackageContext) {
+ String errmsg = isTermuxAppInstalled(currentPackageContext);
+ if (errmsg == null) {
+ Context termuxPackageContext = TermuxUtils.getTermuxPackageContext(currentPackageContext);
+ // If failed to get Termux app package context
+ if (termuxPackageContext == null)
+ errmsg = currentPackageContext.getString(R.string.error_termux_app_package_context_not_accessible);
+
+ if (errmsg == null) {
+ // If TermuxConstants.TERMUX_PREFIX_DIR_PATH is not a directory or does not have required permissions
+ Error error = TermuxFileUtils.isTermuxPrefixDirectoryAccessible(false, false);
+ if (error != null)
+ errmsg = currentPackageContext.getString(R.string.error_termux_prefix_dir_path_not_accessible,
+ PackageUtils.getAppNameForPackage(currentPackageContext));
+ }
+ }
+
+ if (errmsg != null)
+ return errmsg + " " + currentPackageContext.getString(R.string.msg_termux_app_required_by_app,
+ PackageUtils.getAppNameForPackage(currentPackageContext));
+ else
+ return null;
+ }
+
+
+
+ /**
+ * Get a field value from the {@link TERMUX_APP#BUILD_CONFIG_CLASS_NAME} class of the Termux app
+ * APK installed on the device.
+ * This can only be used by apps that share `sharedUserId` with the Termux app.
+ *
+ * This is a wrapper for {@link #getTermuxAppAPKClassField(Context, String, String)}.
+ *
+ * @param currentPackageContext The context of current package.
+ * @param fieldName The name of the field to get.
+ * @return Returns the field value, otherwise {@code null} if an exception was raised or failed
+ * to get termux app package context.
+ */
+ public static Object getTermuxAppAPKBuildConfigClassField(@NonNull Context currentPackageContext,
+ @NonNull String fieldName) {
+ return getTermuxAppAPKClassField(currentPackageContext, TERMUX_APP.BUILD_CONFIG_CLASS_NAME, fieldName);
+ }
+
+ /**
+ * Get a field value from a class of the Termux app APK installed on the device.
+ * This can only be used by apps that share `sharedUserId` with the Termux app.
+ *
+ * This is done by getting first getting termux app package context and then getting in class
+ * loader (instead of current app's) that contains termux app class info, and then using that to
+ * load the required class and then getting required field from it.
+ *
+ * Note that the value returned is from the APK file and not the current value loaded in Termux
+ * app process, so only default values will be returned.
+ *
+ * Trying to access {@code null} fields will result in {@link NoSuchFieldException}.
+ *
+ * @param currentPackageContext The context of current package.
+ * @param clazzName The name of the class from which to get the field.
+ * @param fieldName The name of the field to get.
+ * @return Returns the field value, otherwise {@code null} if an exception was raised or failed
+ * to get termux app package context.
+ */
+ public static Object getTermuxAppAPKClassField(@NonNull Context currentPackageContext,
+ @NonNull String clazzName, @NonNull String fieldName) {
+ try {
+ Context termuxPackageContext = TermuxUtils.getTermuxPackageContextWithCode(currentPackageContext);
+ if (termuxPackageContext == null)
+ return null;
+
+ Class> clazz = termuxPackageContext.getClassLoader().loadClass(clazzName);
+ return ReflectionUtils.invokeField(clazz, fieldName, null).value;
+ } catch (Exception e) {
+ Logger.logStackTraceWithMessage(LOG_TAG, "Failed to get \"" + fieldName + "\" value from \"" + clazzName + "\" class", e);
+ return null;
+ }
+ }
+
+
+
+ /** Returns {@code true} if {@link Uri} has `package:` scheme for {@link TermuxConstants#TERMUX_PACKAGE_NAME} or its sub plugin package. */
+ public static boolean isUriDataForTermuxOrPluginPackage(@NonNull Uri data) {
+ return data.toString().equals("package:" + TermuxConstants.TERMUX_PACKAGE_NAME) ||
+ data.toString().startsWith("package:" + TermuxConstants.TERMUX_PACKAGE_NAME + ".");
+ }
+
+ /** Returns {@code true} if {@link Uri} has `package:` scheme for {@link TermuxConstants#TERMUX_PACKAGE_NAME} sub plugin package. */
+ public static boolean isUriDataForTermuxPluginPackage(@NonNull Uri data) {
+ return data.toString().startsWith("package:" + TermuxConstants.TERMUX_PACKAGE_NAME + ".");
+ }
+
+ /**
+ * Send the {@link TermuxConstants#BROADCAST_TERMUX_OPENED} broadcast to notify apps that Termux
+ * app has been opened.
+ *
+ * @param context The Context to send the broadcast.
+ */
+ public static void sendTermuxOpenedBroadcast(@NonNull Context context) {
+ Intent broadcast = new Intent(TermuxConstants.BROADCAST_TERMUX_OPENED);
+ List matches = context.getPackageManager().queryBroadcastReceivers(broadcast, 0);
+
+ // send broadcast to registered Termux receivers
+ // this technique is needed to work around broadcast changes that Oreo introduced
+ for (ResolveInfo info : matches) {
+ Intent explicitBroadcast = new Intent(broadcast);
+ ComponentName cname = new ComponentName(info.activityInfo.applicationInfo.packageName,
+ info.activityInfo.name);
+ explicitBroadcast.setComponent(cname);
+ context.sendBroadcast(explicitBroadcast);
+ }
+ }
+
+
+
+ /**
+ * Wrapper for {@link #getAppInfoMarkdownString(Context, AppInfoMode, String)}.
+ *
+ * @param currentPackageContext The context of current package.
+ * @param appInfoMode The {@link AppInfoMode} to decide the app info required.
+ * @return Returns the markdown {@link String}.
+ */
+ public static String getAppInfoMarkdownString(final Context currentPackageContext, final AppInfoMode appInfoMode) {
+ return getAppInfoMarkdownString(currentPackageContext, appInfoMode, null);
+ }
+
+ /**
+ * Get a markdown {@link String} for the apps info of termux app, its installed plugin apps or
+ * external apps that called a Termux API depending on {@link AppInfoMode} passed.
+ *
+ * Also check {@link PackageUtils#isAppInstalled(Context, String, String) if targetting targeting
+ * sdk `30` (android `11`) since {@link PackageManager.NameNotFoundException} may be thrown while
+ * getting info of {@code callingPackageName} app.
+ *
+ * @param currentPackageContext The context of current package.
+ * @param appInfoMode The {@link AppInfoMode} to decide the app info required.
+ * @param callingPackageName The optional package name for a plugin or external app.
+ * @return Returns the markdown {@link String}.
+ */
+ public static String getAppInfoMarkdownString(final Context currentPackageContext, final AppInfoMode appInfoMode, @Nullable String callingPackageName) {
+ if (appInfoMode == null) return null;
+
+ if (appInfoMode == AppInfoMode.TERMUX_PACKAGE) {
+ return getAppInfoMarkdownString(currentPackageContext, false);
+ }
+ return null;
+
+ }
+
+ /**
+ * Get a markdown {@link String} for the app info. If the {@code context} passed is different
+ * from the {@link TermuxConstants#TERMUX_PACKAGE_NAME} package context, then this function
+ * must have been called by a different package like a plugin, so we return info for both packages
+ * if {@code returnTermuxPackageInfoToo} is {@code true}.
+ *
+ * @param currentPackageContext The context of current package.
+ * @param returnTermuxPackageInfoToo If set to {@code true}, then will return info of the
+ * {@link TermuxConstants#TERMUX_PACKAGE_NAME} package as well if its different from current package.
+ * @return Returns the markdown {@link String}.
+ */
+ public static String getAppInfoMarkdownString(final Context currentPackageContext, final boolean returnTermuxPackageInfoToo) {
+ if (currentPackageContext == null) return "null";
+
+ StringBuilder markdownString = new StringBuilder();
+
+ Context termuxPackageContext = getTermuxPackageContext(currentPackageContext);
+
+ String termuxPackageName = null;
+ String termuxAppName = null;
+ if (termuxPackageContext != null) {
+ termuxPackageName = PackageUtils.getPackageNameForPackage(termuxPackageContext);
+ termuxAppName = PackageUtils.getAppNameForPackage(termuxPackageContext);
+ }
+
+ String currentPackageName = PackageUtils.getPackageNameForPackage(currentPackageContext);
+ String currentAppName = PackageUtils.getAppNameForPackage(currentPackageContext);
+
+ boolean isTermuxPackage = (termuxPackageName != null && termuxPackageName.equals(currentPackageName));
+
+
+ if (returnTermuxPackageInfoToo && !isTermuxPackage)
+ markdownString.append("## ").append(currentAppName).append(" App Info (Current)\n");
+ else
+ markdownString.append("## ").append(currentAppName).append(" App Info\n");
+ markdownString.append(getAppInfoMarkdownStringInner(currentPackageContext));
+ markdownString.append("\n##\n");
+
+ if (returnTermuxPackageInfoToo && termuxPackageContext != null && !isTermuxPackage) {
+ markdownString.append("\n\n## ").append(termuxAppName).append(" App Info\n");
+ markdownString.append(getAppInfoMarkdownStringInner(termuxPackageContext));
+ markdownString.append("\n##\n");
+ }
+
+
+ return markdownString.toString();
+ }
+
+ /**
+ * Get a markdown {@link String} for the app info for the package associated with the {@code context}.
+ *
+ * @param context The context for operations for the package.
+ * @return Returns the markdown {@link String}.
+ */
+ public static String getAppInfoMarkdownStringInner(@NonNull final Context context) {
+ StringBuilder markdownString = new StringBuilder();
+
+ markdownString.append((AndroidUtils.getAppInfoMarkdownString(context)));
+
+ if (context.getPackageName().equals(TermuxConstants.TERMUX_PACKAGE_NAME)) {
+ AndroidUtils.appendPropertyToMarkdown(markdownString, "TERMUX_APP_PACKAGE_MANAGER", TermuxBootstrap.TERMUX_APP_PACKAGE_MANAGER);
+ AndroidUtils.appendPropertyToMarkdown(markdownString, "TERMUX_APP_PACKAGE_VARIANT", TermuxBootstrap.TERMUX_APP_PACKAGE_VARIANT);
+ }
+
+ Error error;
+ error = TermuxFileUtils.isTermuxFilesDirectoryAccessible(context, true, true);
+ if (error != null) {
+ AndroidUtils.appendPropertyToMarkdown(markdownString, "TERMUX_FILES_DIR", TermuxConstants.TERMUX_FILES_DIR_PATH);
+ AndroidUtils.appendPropertyToMarkdown(markdownString, "IS_TERMUX_FILES_DIR_ACCESSIBLE", "false - " + Error.getMinimalErrorString(error));
+ }
+
+ String signingCertificateSHA256Digest = PackageUtils.getSigningCertificateSHA256DigestForPackage(context);
+ if (signingCertificateSHA256Digest != null) {
+ AndroidUtils.appendPropertyToMarkdown(markdownString,"APK_RELEASE", getAPKRelease(signingCertificateSHA256Digest));
+ AndroidUtils.appendPropertyToMarkdown(markdownString,"SIGNING_CERTIFICATE_SHA256_DIGEST", signingCertificateSHA256Digest);
+ }
+
+ return markdownString.toString();
+ }
+
+ /**
+ * Get a markdown {@link String} for reporting an issue.
+ *
+ * @param context The context for operations.
+ * @return Returns the markdown {@link String}.
+ */
+ public static String getReportIssueMarkdownString(@NonNull final Context context) {
+ if (context == null) return "null";
+
+ final var markdownString = new StringBuilder();
+
+ markdownString.append("## Where To Report An Issue");
+
+ markdownString.append("\n\n").append(context.getString(R.string.msg_report_issue, TermuxConstants.TERMUX_WIKI_URL)).append("\n");
+
+ markdownString.append("\n\n### Email\n");
+ markdownString.append("\n").append(MarkdownUtils.getLinkMarkdownString(TermuxConstants.TERMUX_SUPPORT_EMAIL_URL, TermuxConstants.TERMUX_SUPPORT_EMAIL_MAILTO_URL)).append(" ");
+
+ markdownString.append("\n\n### GitHub Issues for AndroidIDE apps\n");
+ markdownString.append("\n").append(MarkdownUtils.getLinkMarkdownString(TermuxConstants.TERMUX_APP_NAME, TermuxConstants.TERMUX_GITHUB_ISSUES_REPO_URL)).append(" ");
+
+ markdownString.append("\n\n### GitHub Issues for AndroidIDE Terminal packages\n");
+ markdownString.append("\n").append(MarkdownUtils.getLinkMarkdownString(TermuxConstants.TERMUX_PACKAGES_GITHUB_REPO_NAME, TermuxConstants.TERMUX_PACKAGES_GITHUB_ISSUES_REPO_URL)).append(" ");
+
+ markdownString.append("\n##\n");
+
+ return markdownString.toString();
+ }
+
+ /**
+ * Get a markdown {@link String} for important links.
+ *
+ * @param context The context for operations.
+ * @return Returns the markdown {@link String}.
+ */
+ public static String getImportantLinksMarkdownString(@NonNull final Context context) {
+ if (context == null) return "null";
+
+ StringBuilder markdownString = new StringBuilder();
+
+ markdownString.append("## Important Links");
+
+ markdownString.append("\n\n### GitHub\n");
+ markdownString.append("\n").append(MarkdownUtils.getLinkMarkdownString(TermuxConstants.TERMUX_APP_NAME, TermuxConstants.TERMUX_GITHUB_REPO_URL)).append(" ");
+ markdownString.append("\n").append(MarkdownUtils.getLinkMarkdownString(TermuxConstants.TERMUX_PACKAGES_GITHUB_REPO_NAME, TermuxConstants.TERMUX_PACKAGES_GITHUB_REPO_URL)).append(" ");
+
+ markdownString.append("\n\n### Email\n");
+ markdownString.append("\n").append(MarkdownUtils.getLinkMarkdownString(TermuxConstants.TERMUX_SUPPORT_EMAIL_URL, TermuxConstants.TERMUX_SUPPORT_EMAIL_MAILTO_URL)).append(" ");
+
+ markdownString.append("\n\n### Wiki\n");
+ markdownString.append("\n").append(MarkdownUtils.getLinkMarkdownString(TermuxConstants.TERMUX_WIKI, TermuxConstants.TERMUX_WIKI_URL)).append(" ");
+ markdownString.append("\n").append(MarkdownUtils.getLinkMarkdownString(TermuxConstants.TERMUX_APP_NAME, TermuxConstants.TERMUX_GITHUB_WIKI_REPO_URL)).append(" ");
+ markdownString.append("\n").append(MarkdownUtils.getLinkMarkdownString(TermuxConstants.TERMUX_PACKAGES_GITHUB_REPO_NAME, TermuxConstants.TERMUX_PACKAGES_GITHUB_WIKI_REPO_URL)).append(" ");
+
+ markdownString.append("\n##\n");
+
+ return markdownString.toString();
+ }
+
+
+
+ /**
+ * Get a markdown {@link String} for APT info of the app.
+ *
+ * This will take a few seconds to run due to running {@code apt update} command.
+ *
+ * @param context The context for operations.
+ * @return Returns the markdown {@link String}.
+ */
+ public static String geAPTInfoMarkdownString(@NonNull final Context context) {
+
+ String aptInfoScript;
+ InputStream inputStream = context.getResources().openRawResource(com.termux.shared.R.raw.apt_info_script);
+ try {
+ aptInfoScript = IOUtils.toString(inputStream, Charset.defaultCharset());
+ } catch (IOException e) {
+ Logger.logError(LOG_TAG, "Failed to get APT info script: " + e.getMessage());
+ return null;
+ }
+
+ IOUtils.closeQuietly(inputStream);
+
+ if (aptInfoScript == null || aptInfoScript.isEmpty()) {
+ Logger.logError(LOG_TAG, "The APT info script is null or empty");
+ return null;
+ }
+
+ aptInfoScript = aptInfoScript.replaceAll(Pattern.quote("@TERMUX_PREFIX@"), TermuxConstants.TERMUX_PREFIX_DIR_PATH);
+
+ ExecutionCommand executionCommand = new ExecutionCommand(-1,
+ TermuxConstants.TERMUX_BIN_PREFIX_DIR_PATH + "/bash", null, aptInfoScript,
+ null, ExecutionCommand.Runner.APP_SHELL.getName(), false);
+ executionCommand.commandLabel = "APT Info Command";
+ executionCommand.backgroundCustomLogLevel = Logger.LOG_LEVEL_OFF;
+ AppShell appShell = AppShell.execute(context, executionCommand, null, new TermuxShellEnvironment(), null, true);
+ if (appShell == null || !executionCommand.isSuccessful() || executionCommand.resultData.exitCode != 0) {
+ Logger.logErrorExtended(LOG_TAG, executionCommand.toString());
+ return null;
+ }
+
+ if (!executionCommand.resultData.stderr.toString().isEmpty())
+ Logger.logErrorExtended(LOG_TAG, executionCommand.toString());
+
+ StringBuilder markdownString = new StringBuilder();
+
+ markdownString.append("## ").append(TermuxConstants.TERMUX_APP_NAME).append(" APT Info\n\n");
+ markdownString.append(executionCommand.resultData.stdout.toString());
+ markdownString.append("\n##\n");
+
+ return markdownString.toString();
+ }
+
+ /**
+ * Get a markdown {@link String} for info for termux debugging.
+ *
+ * @param context The context for operations.
+ * @return Returns the markdown {@link String}.
+ */
+ public static String getTermuxDebugMarkdownString(@NonNull final Context context) {
+ String statInfo = TermuxFileUtils.getTermuxFilesStatMarkdownString(context);
+ String logcatInfo = getLogcatDumpMarkdownString(context);
+
+ if (statInfo != null && logcatInfo != null)
+ return statInfo + "\n\n" + logcatInfo;
+ else if (statInfo != null)
+ return statInfo;
+ else
+ return logcatInfo;
+
+ }
+
+ /**
+ * Get a markdown {@link String} for logcat command dump.
+ *
+ * @param context The context for operations.
+ * @return Returns the markdown {@link String}.
+ */
+ public static String getLogcatDumpMarkdownString(@NonNull final Context context) {
+ // Build script
+ // We need to prevent OutOfMemoryError since StreamGobbler StringBuilder + StringBuilder.toString()
+ // may require lot of memory if dump is too large.
+ // Putting a limit at 3000 lines. Assuming average 160 chars/line will result in 500KB usage
+ // per object.
+ // That many lines should be enough for debugging for recent issues anyways assuming termux
+ // has not been granted READ_LOGS permission s.
+ String logcatScript = "/system/bin/logcat -d -t 3000 2>&1";
+
+ // Run script
+ // Logging must be disabled for output of logcat command itself in StreamGobbler
+ ExecutionCommand executionCommand = new ExecutionCommand(-1, "/system/bin/sh",
+ null, logcatScript + "\n", "/", ExecutionCommand.Runner.APP_SHELL.getName(), true);
+ executionCommand.commandLabel = "Logcat dump command";
+ executionCommand.backgroundCustomLogLevel = Logger.LOG_LEVEL_OFF;
+ AppShell appShell = AppShell.execute(context, executionCommand, null, new TermuxShellEnvironment(), null, true);
+ if (appShell == null || !executionCommand.isSuccessful()) {
+ Logger.logErrorExtended(LOG_TAG, executionCommand.toString());
+ return null;
+ }
+
+ // Build script output
+ StringBuilder logcatOutput = new StringBuilder();
+ logcatOutput.append("$ ").append(logcatScript);
+ logcatOutput.append("\n").append(executionCommand.resultData.stdout.toString());
+
+ boolean stderrSet = !executionCommand.resultData.stderr.toString().isEmpty();
+ if (executionCommand.resultData.exitCode != 0 || stderrSet) {
+ Logger.logErrorExtended(LOG_TAG, executionCommand.toString());
+ if (stderrSet)
+ logcatOutput.append("\n").append(executionCommand.resultData.stderr.toString());
+ logcatOutput.append("\n").append("exit code: ").append(executionCommand.resultData.exitCode.toString());
+ }
+
+ // Build markdown output
+ StringBuilder markdownString = new StringBuilder();
+ markdownString.append("## Logcat Dump\n\n");
+ markdownString.append("\n\n").append(MarkdownUtils.getMarkdownCodeForString(logcatOutput.toString(), true));
+ markdownString.append("\n##\n");
+
+ return markdownString.toString();
+ }
+
+
+
+ public static String getAPKRelease(String signingCertificateSHA256Digest) {
+ if (signingCertificateSHA256Digest == null) return "null";
+
+ switch (signingCertificateSHA256Digest.toUpperCase()) {
+ case TermuxConstants.APK_RELEASE_FDROID_SIGNING_CERTIFICATE_SHA256_DIGEST:
+ return TermuxConstants.APK_RELEASE_FDROID;
+ case TermuxConstants.APK_RELEASE_GITHUB_SIGNING_CERTIFICATE_SHA256_DIGEST:
+ return TermuxConstants.APK_RELEASE_GITHUB;
+ case TermuxConstants.APK_RELEASE_GOOGLE_PLAYSTORE_SIGNING_CERTIFICATE_SHA256_DIGEST:
+ return TermuxConstants.APK_RELEASE_GOOGLE_PLAYSTORE;
+ case TermuxConstants.APK_RELEASE_TERMUX_DEVS_SIGNING_CERTIFICATE_SHA256_DIGEST:
+ return TermuxConstants.APK_RELEASE_TERMUX_DEVS;
+ default:
+ return "Unknown";
+ }
+ }
+
+
+ /**
+ * Get a process id of the main app process of the {@link TermuxConstants#TERMUX_PACKAGE_NAME}
+ * package.
+ *
+ * @param context The context for operations.
+ * @return Returns the process if found and running, otherwise {@code null}.
+ */
+ public static String getTermuxAppPID(final Context context) {
+ return PackageUtils.getPackagePID(context, TermuxConstants.TERMUX_PACKAGE_NAME);
+ }
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/termux/crash/TermuxCrashUtils.java b/termux/termux-shared/src/main/java/com/termux/shared/termux/crash/TermuxCrashUtils.java
new file mode 100644
index 000000000..508198314
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/termux/crash/TermuxCrashUtils.java
@@ -0,0 +1,410 @@
+package com.termux.shared.termux.crash;
+
+import android.app.Notification;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Build;
+import android.os.Environment;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.termux.shared.activities.ReportActivity;
+import com.termux.shared.android.AndroidUtils;
+import com.termux.shared.crash.CrashHandler;
+import com.termux.shared.data.DataUtils;
+import com.termux.shared.errors.Error;
+import com.termux.shared.file.FileUtils;
+import com.termux.shared.logger.Logger;
+import com.termux.shared.markdown.MarkdownUtils;
+import com.termux.shared.models.ReportInfo;
+import com.termux.shared.notification.NotificationUtils;
+import com.termux.shared.termux.TermuxConstants;
+import com.termux.shared.termux.TermuxConstants.TERMUX_APP;
+import com.termux.shared.termux.TermuxUtils;
+import com.termux.shared.termux.models.UserAction;
+import com.termux.shared.termux.notification.TermuxNotificationUtils;
+import com.termux.shared.termux.settings.preferences.TermuxAppSharedPreferences;
+
+import java.nio.charset.Charset;
+
+public class TermuxCrashUtils implements CrashHandler.CrashHandlerClient {
+
+ public enum TYPE {
+ UNCAUGHT_EXCEPTION,
+ CAUGHT_EXCEPTION;
+ }
+
+ private final TYPE mType;
+
+ private static final String LOG_TAG = "TermuxCrashUtils";
+
+ TermuxCrashUtils(TYPE type) {
+ mType = type;
+ }
+
+ /**
+ * Set default uncaught crash handler of the app to {@link CrashHandler} for Termux app
+ * and its plugins to log crashes at {@link TermuxConstants#TERMUX_CRASH_LOG_FILE_PATH}.
+ */
+ public static void setDefaultCrashHandler(@NonNull final Context context) {
+ CrashHandler.setDefaultCrashHandler(context, new TermuxCrashUtils(TYPE.UNCAUGHT_EXCEPTION));
+ }
+
+ /**
+ * Set uncaught crash handler of current non-main thread to {@link CrashHandler} for Termux app
+ * and its plugins to log crashes at {@link TermuxConstants#TERMUX_CRASH_LOG_FILE_PATH}.
+ */
+ public static void setCrashHandler(@NonNull final Context context) {
+ CrashHandler.setCrashHandler(context, new TermuxCrashUtils(TYPE.CAUGHT_EXCEPTION));
+ }
+
+ /**
+ * Get {@link CrashHandler} for Termux app and its plugins that can be set as the uncaught
+ * crash handler of a non-main thread to log crashes at {@link TermuxConstants#TERMUX_CRASH_LOG_FILE_PATH}.
+ */
+ public static CrashHandler getCrashHandler(@NonNull final Context context) {
+ return CrashHandler.getCrashHandler(context, new TermuxCrashUtils(TYPE.CAUGHT_EXCEPTION));
+ }
+
+ /**
+ * Log a crash to {@link TermuxConstants#TERMUX_CRASH_LOG_FILE_PATH} and notify termux app
+ * by sending it the {@link TERMUX_APP.TERMUX_ACTIVITY#ACTION_NOTIFY_APP_CRASH} broadcast.
+ *
+ * @param context The {@link Context} for operations.
+ * @param throwable The {@link Throwable} thrown for the crash.
+ */
+ public static void logCrash(@NonNull final Context context, final Throwable throwable) {
+ if (throwable == null) return;
+ CrashHandler.logCrash(context, new TermuxCrashUtils(TYPE.CAUGHT_EXCEPTION), Thread.currentThread(), throwable);
+ }
+
+ @Override
+ public boolean onPreLogCrash(Context context, Thread thread, Throwable throwable) {
+ return false;
+ }
+
+ @Override
+ public void onPostLogCrash(final Context currentPackageContext, Thread thread, Throwable throwable) {
+ if (currentPackageContext == null) return;
+ String currentPackageName = currentPackageContext.getPackageName();
+
+ // Do not notify if is a non-termux app
+ final Context context = TermuxUtils.getTermuxPackageContext(currentPackageContext);
+ if (context == null) {
+ Logger.logWarn(LOG_TAG, "Ignoring call to onPostLogCrash() since failed to get \"" + TermuxConstants.TERMUX_PACKAGE_NAME + "\" package context from \"" + currentPackageName + "\" context");
+ return;
+ }
+
+ // If an uncaught exception, then do not notify since the termux app itself would be crashing
+ if (TYPE.UNCAUGHT_EXCEPTION.equals(mType) && TermuxConstants.TERMUX_PACKAGE_NAME.equals(currentPackageName))
+ return;
+
+ String message = TERMUX_APP.TERMUX_ACTIVITY_NAME + " that \"" + currentPackageName + "\" app crashed";
+
+ try {
+ Logger.logInfo(LOG_TAG, "Sending broadcast to notify " + message);
+ Intent intent = new Intent(TERMUX_APP.TERMUX_ACTIVITY.ACTION_NOTIFY_APP_CRASH);
+ intent.setPackage(TermuxConstants.TERMUX_PACKAGE_NAME);
+ context.sendBroadcast(intent);
+ } catch (Exception e) {
+ Logger.logStackTraceWithMessage(LOG_TAG,"Failed to notify " + message, e);
+ }
+ }
+
+ @NonNull
+ @Override
+ public String getCrashLogFilePath(Context context) {
+ return TermuxConstants.TERMUX_CRASH_LOG_FILE_PATH;
+ }
+
+ @Override
+ public String getAppInfoMarkdownString(Context context) {
+ return TermuxUtils.getAppInfoMarkdownString(context, true);
+ }
+
+ /**
+ * Notify the user of an app crash by reading the crash info from the crash log file
+ * at {@link TermuxConstants#TERMUX_CRASH_LOG_FILE_PATH}. The crash log file would have been
+ * created by {@link com.termux.shared.crash.CrashHandler}.
+ *
+ * If the crash log file exists and is not empty and
+ * {@link TermuxPreferenceConstants.TERMUX_APP#KEY_CRASH_REPORT_NOTIFICATIONS_ENABLED} is
+ * enabled, then a notification will be shown for the crash on the
+ * {@link TermuxConstants#TERMUX_CRASH_REPORTS_NOTIFICATION_CHANNEL_NAME} channel, otherwise nothing will be done.
+ *
+ * After reading from the crash log file, it will be moved to {@link TermuxConstants#TERMUX_CRASH_LOG_BACKUP_FILE_PATH}.
+ *
+ * @param currentPackageContext The {@link Context} of current package.
+ * @param logTagParam The log tag to use for logging.
+ */
+ public static void notifyAppCrashFromCrashLogFile(final Context currentPackageContext, final String logTagParam) {
+ if (currentPackageContext == null) return;
+ String currentPackageName = currentPackageContext.getPackageName();
+
+ final Context context = TermuxUtils.getTermuxPackageContext(currentPackageContext);
+ if (context == null) {
+ Logger.logWarn(LOG_TAG, "Ignoring call to notifyAppCrash() since failed to get \"" + TermuxConstants.TERMUX_PACKAGE_NAME + "\" package context from \"" + currentPackageName + "\" context");
+ return;
+ }
+
+ TermuxAppSharedPreferences preferences = TermuxAppSharedPreferences.build(context);
+ if (preferences == null) return;
+
+ // If user has disabled notifications for crashes
+ if (!preferences.areCrashReportNotificationsEnabled(false))
+ return;
+
+ new Thread() {
+ @Override
+ public void run() {
+ notifyAppCrashFromCrashLogFileInner(context, logTagParam);
+ }
+ }.start();
+ }
+
+ private static synchronized void notifyAppCrashFromCrashLogFileInner(final Context context, final String logTagParam) {
+ String logTag = DataUtils.getDefaultIfNull(logTagParam, LOG_TAG);
+
+ if (!FileUtils.regularFileExists(TermuxConstants.TERMUX_CRASH_LOG_FILE_PATH, false))
+ return;
+
+ Error error;
+ StringBuilder reportStringBuilder = new StringBuilder();
+
+ // Read report string from crash log file
+ error = FileUtils.readTextFromFile("crash log", TermuxConstants.TERMUX_CRASH_LOG_FILE_PATH, Charset.defaultCharset(), reportStringBuilder, false);
+ if (error != null) {
+ Logger.logErrorExtended(logTag, error.toString());
+ return;
+ }
+
+ // Move crash log file to backup location if it exists
+ error = FileUtils.moveRegularFile("crash log", TermuxConstants.TERMUX_CRASH_LOG_FILE_PATH, TermuxConstants.TERMUX_CRASH_LOG_BACKUP_FILE_PATH, true);
+ if (error != null) {
+ Logger.logErrorExtended(logTag, error.toString());
+ }
+
+ String reportString = reportStringBuilder.toString();
+
+ if (reportString.isEmpty())
+ return;
+
+ Logger.logDebug(logTag, "A crash log file found at \"" + TermuxConstants.TERMUX_CRASH_LOG_FILE_PATH + "\".");
+
+ sendCrashReportNotification(context, logTag, null, null, reportString, false, false, null, false);
+ }
+
+
+
+
+ /**
+ * Send a crash report notification for {@link TermuxConstants#TERMUX_CRASH_REPORTS_NOTIFICATION_CHANNEL_ID}
+ * and {@link TermuxConstants#TERMUX_CRASH_REPORTS_NOTIFICATION_CHANNEL_NAME}.
+ *
+ * @param currentPackageContext The {@link Context} of current package.
+ * @param logTag The log tag to use for logging.
+ * @param title The title for the crash report and notification.
+ * @param message The message for the crash report.
+ * @param throwable The {@link Throwable} for the crash report.
+ */
+ public static void sendCrashReportNotification(final Context currentPackageContext, String logTag,
+ CharSequence title, String message, Throwable throwable) {
+ sendCrashReportNotification(currentPackageContext, logTag,
+ title, message,
+ MarkdownUtils.getMarkdownCodeForString(Logger.getMessageAndStackTraceString(message, throwable), true),
+ false, false, true);
+ }
+
+ /**
+ * Send a crash report notification for {@link TermuxConstants#TERMUX_CRASH_REPORTS_NOTIFICATION_CHANNEL_ID}
+ * and {@link TermuxConstants#TERMUX_CRASH_REPORTS_NOTIFICATION_CHANNEL_NAME}.
+ *
+ * @param currentPackageContext The {@link Context} of current package.
+ * @param logTag The log tag to use for logging.
+ * @param title The title for the crash report and notification.
+ * @param notificationTextString The text of the notification.
+ * @param message The message for the crash report.
+ */
+ public static void sendCrashReportNotification(final Context currentPackageContext, String logTag,
+ CharSequence title, String notificationTextString,
+ String message) {
+ sendCrashReportNotification(currentPackageContext, logTag,
+ title, notificationTextString, message,
+ false, false, true);
+ }
+
+ /**
+ * Send a crash report notification for {@link TermuxConstants#TERMUX_CRASH_REPORTS_NOTIFICATION_CHANNEL_ID}
+ * and {@link TermuxConstants#TERMUX_CRASH_REPORTS_NOTIFICATION_CHANNEL_NAME}.
+ *
+ * @param currentPackageContext The {@link Context} of current package.
+ * @param logTag The log tag to use for logging.
+ * @param title The title for the crash report and notification.
+ * @param notificationTextString The text of the notification.
+ * @param message The message for the crash report.
+ * @param forceNotification If set to {@code true}, then a notification will be shown
+ * regardless of if pending intent is {@code null} or
+ * {@link TermuxPreferenceConstants.TERMUX_APP#KEY_CRASH_REPORT_NOTIFICATIONS_ENABLED}
+ * is {@code false}.
+ * @param showToast If set to {@code true}, then a toast will be shown for {@code notificationTextString}.
+ * @param addDeviceInfo If set to {@code true}, then device info should be appended to the message.
+ */
+ public static void sendCrashReportNotification(final Context currentPackageContext, String logTag,
+ CharSequence title, String notificationTextString,
+ String message, boolean forceNotification,
+ boolean showToast,
+ boolean addDeviceInfo) {
+ sendCrashReportNotification(currentPackageContext, logTag,
+ title, notificationTextString, "## " + title + "\n\n" + message + "\n\n",
+ forceNotification, showToast, TermuxUtils.AppInfoMode.TERMUX_AND_PLUGIN_PACKAGE, addDeviceInfo);
+ }
+
+ /**
+ * Send a crash report notification for {@link TermuxConstants#TERMUX_CRASH_REPORTS_NOTIFICATION_CHANNEL_ID}
+ * and {@link TermuxConstants#TERMUX_CRASH_REPORTS_NOTIFICATION_CHANNEL_NAME}.
+ *
+ * @param currentPackageContext The {@link Context} of current package.
+ * @param logTag The log tag to use for logging.
+ * @param title The title for the crash report and notification.
+ * @param notificationTextString The text of the notification.
+ * @param message The message for the crash report.
+ * @param forceNotification If set to {@code true}, then a notification will be shown
+ * regardless of if pending intent is {@code null} or
+ * {@link TermuxPreferenceConstants.TERMUX_APP#KEY_CRASH_REPORT_NOTIFICATIONS_ENABLED}
+ * is {@code false}.
+ * @param showToast If set to {@code true}, then a toast will be shown for {@code notificationTextString}.
+ * @param appInfoMode The {@link TermuxUtils.AppInfoMode} to use to add app info to the message.
+ * Set to {@code null} if app info should not be appended to the message.
+ * @param addDeviceInfo If set to {@code true}, then device info should be appended to the message.
+ */
+ public static void sendCrashReportNotification(final Context currentPackageContext, String logTag,
+ CharSequence title,
+ String notificationTextString,
+ String message, boolean forceNotification,
+ boolean showToast,
+ TermuxUtils.AppInfoMode appInfoMode,
+ boolean addDeviceInfo) {
+ // Note: Do not change currentPackageContext or termuxPackageContext passed to functions or things will break
+
+ if (currentPackageContext == null) return;
+ String currentPackageName = currentPackageContext.getPackageName();
+
+ final Context termuxPackageContext = TermuxUtils.getTermuxPackageContext(currentPackageContext);
+ if (termuxPackageContext == null) {
+ Logger.logWarn(LOG_TAG, "Ignoring call to sendCrashReportNotification() since failed to get \"" + TermuxConstants.TERMUX_PACKAGE_NAME + "\" package context from \"" + currentPackageName + "\" context");
+ return;
+ }
+
+ TermuxAppSharedPreferences preferences = TermuxAppSharedPreferences.build(termuxPackageContext);
+ if (preferences == null) return;
+
+ // If user has disabled notifications for crashes
+ if (!preferences.areCrashReportNotificationsEnabled(true) && !forceNotification)
+ return;
+
+ logTag = DataUtils.getDefaultIfNull(logTag, LOG_TAG);
+
+ if (showToast)
+ Logger.showToast(currentPackageContext, notificationTextString, true);
+
+ // Send a notification to show the crash log which when clicked will open the {@link ReportActivity}
+ // to show the details of the crash
+ if (title == null || title.toString().isEmpty())
+ title = TermuxConstants.TERMUX_APP_NAME + " Crash Report";
+
+ Logger.logDebug(logTag, "Sending \"" + title + "\" notification.");
+
+ StringBuilder reportString = new StringBuilder(message);
+
+ if (appInfoMode != null)
+ reportString.append("\n\n").append(TermuxUtils.getAppInfoMarkdownString(currentPackageContext, appInfoMode, currentPackageName));
+
+ if (addDeviceInfo)
+ reportString.append("\n\n").append(AndroidUtils.getDeviceInfoMarkdownString(currentPackageContext, true));
+
+ String userActionName = UserAction.CRASH_REPORT.getName();
+
+ ReportInfo reportInfo = new ReportInfo(userActionName, logTag, title.toString());
+ reportInfo.setReportString(reportString.toString());
+ reportInfo.setReportStringSuffix("\n\n" + TermuxUtils.getReportIssueMarkdownString(currentPackageContext));
+ reportInfo.setAddReportInfoHeaderToMarkdown(true);
+ reportInfo.setReportSaveFileLabelAndPath(userActionName,
+ Environment.getExternalStorageDirectory() + "/" +
+ FileUtils.sanitizeFileName(TermuxConstants.TERMUX_APP_NAME + "-" + userActionName + ".log", true, true));
+
+ ReportActivity.NewInstanceResult result = ReportActivity.newInstance(termuxPackageContext, reportInfo);
+ if (result.contentIntent == null) return;
+
+ // Must ensure result code for PendingIntents and id for notification are unique otherwise will override previous
+ int nextNotificationId = TermuxNotificationUtils.getNextNotificationId(termuxPackageContext);
+
+ PendingIntent contentIntent = PendingIntent.getActivity(termuxPackageContext, nextNotificationId, result.contentIntent, PendingIntent.FLAG_UPDATE_CURRENT);
+
+ PendingIntent deleteIntent = null;
+ if (result.deleteIntent != null)
+ deleteIntent = PendingIntent.getBroadcast(termuxPackageContext, nextNotificationId, result.deleteIntent, PendingIntent.FLAG_UPDATE_CURRENT);
+
+ // Setup the notification channel if not already set up
+ setupCrashReportsNotificationChannel(termuxPackageContext);
+
+ // Use markdown in notification
+ CharSequence notificationTextCharSequence = MarkdownUtils.getSpannedMarkdownText(termuxPackageContext, notificationTextString);
+ //CharSequence notificationTextCharSequence = notificationTextString;
+
+ // Build the notification
+ Notification.Builder builder = getCrashReportsNotificationBuilder(currentPackageContext, termuxPackageContext,
+ title, notificationTextCharSequence, notificationTextCharSequence, contentIntent, deleteIntent,
+ NotificationUtils.NOTIFICATION_MODE_VIBRATE);
+ if (builder == null) return;
+
+ // Send the notification
+ NotificationManager notificationManager = NotificationUtils.getNotificationManager(termuxPackageContext);
+ if (notificationManager != null)
+ notificationManager.notify(nextNotificationId, builder.build());
+ }
+
+ /**
+ * Get {@link Notification.Builder} for {@link TermuxConstants#TERMUX_CRASH_REPORTS_NOTIFICATION_CHANNEL_ID}
+ * and {@link TermuxConstants#TERMUX_CRASH_REPORTS_NOTIFICATION_CHANNEL_NAME}.
+ *
+ * @param currentPackageContext The {@link Context} of current package.
+ * @param termuxPackageContext The {@link Context} of termux package.
+ * @param title The title for the notification.
+ * @param notificationText The second line text of the notification.
+ * @param notificationBigText The full text of the notification that may optionally be styled.
+ * @param contentIntent The {@link PendingIntent} which should be sent when notification is clicked.
+ * @param deleteIntent The {@link PendingIntent} which should be sent when notification is deleted.
+ * @param notificationMode The notification mode. It must be one of {@code NotificationUtils.NOTIFICATION_MODE_*}.
+ * @return Returns the {@link Notification.Builder}.
+ */
+ @Nullable
+ public static Notification.Builder getCrashReportsNotificationBuilder(final Context currentPackageContext,
+ final Context termuxPackageContext,
+ final CharSequence title,
+ final CharSequence notificationText,
+ final CharSequence notificationBigText,
+ final PendingIntent contentIntent,
+ final PendingIntent deleteIntent,
+ final int notificationMode) {
+ return TermuxNotificationUtils.getTermuxOrPluginAppNotificationBuilder(
+ currentPackageContext, termuxPackageContext,
+ TermuxConstants.TERMUX_CRASH_REPORTS_NOTIFICATION_CHANNEL_ID, Notification.PRIORITY_HIGH,
+ title, notificationText, notificationBigText, contentIntent, deleteIntent, notificationMode);
+ }
+
+ /**
+ * Setup the notification channel for {@link TermuxConstants#TERMUX_CRASH_REPORTS_NOTIFICATION_CHANNEL_ID} and
+ * {@link TermuxConstants#TERMUX_CRASH_REPORTS_NOTIFICATION_CHANNEL_NAME}.
+ *
+ * @param context The {@link Context} for operations.
+ */
+ public static void setupCrashReportsNotificationChannel(final Context context) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return;
+ NotificationUtils.setupNotificationChannel(context, TermuxConstants.TERMUX_CRASH_REPORTS_NOTIFICATION_CHANNEL_ID,
+ TermuxConstants.TERMUX_CRASH_REPORTS_NOTIFICATION_CHANNEL_NAME, NotificationManager.IMPORTANCE_HIGH);
+ }
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/termux/data/TermuxUrlUtils.java b/termux/termux-shared/src/main/java/com/termux/shared/termux/data/TermuxUrlUtils.java
new file mode 100644
index 000000000..d35d9f81d
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/termux/data/TermuxUrlUtils.java
@@ -0,0 +1,104 @@
+package com.termux.shared.termux.data;
+
+import java.util.LinkedHashSet;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+public class TermuxUrlUtils {
+
+ public static Pattern URL_MATCH_REGEX;
+
+ public static Pattern getUrlMatchRegex() {
+ if (URL_MATCH_REGEX != null) return URL_MATCH_REGEX;
+
+ StringBuilder regex_sb = new StringBuilder();
+
+ regex_sb.append("("); // Begin first matching group.
+ regex_sb.append("(?:"); // Begin scheme group.
+ regex_sb.append("dav|"); // The DAV proto.
+ regex_sb.append("dict|"); // The DICT proto.
+ regex_sb.append("dns|"); // The DNS proto.
+ regex_sb.append("file|"); // File path.
+ regex_sb.append("finger|"); // The Finger proto.
+ regex_sb.append("ftp(?:s?)|"); // The FTP proto.
+ regex_sb.append("git|"); // The Git proto.
+ regex_sb.append("gemini|"); // The Gemini proto.
+ regex_sb.append("gopher|"); // The Gopher proto.
+ regex_sb.append("http(?:s?)|"); // The HTTP proto.
+ regex_sb.append("imap(?:s?)|"); // The IMAP proto.
+ regex_sb.append("irc(?:[6s]?)|"); // The IRC proto.
+ regex_sb.append("ip[fn]s|"); // The IPFS proto.
+ regex_sb.append("ldap(?:s?)|"); // The LDAP proto.
+ regex_sb.append("pop3(?:s?)|"); // The POP3 proto.
+ regex_sb.append("redis(?:s?)|"); // The Redis proto.
+ regex_sb.append("rsync|"); // The Rsync proto.
+ regex_sb.append("rtsp(?:[su]?)|"); // The RTSP proto.
+ regex_sb.append("sftp|"); // The SFTP proto.
+ regex_sb.append("smb(?:s?)|"); // The SAMBA proto.
+ regex_sb.append("smtp(?:s?)|"); // The SMTP proto.
+ regex_sb.append("svn(?:(?:\\+ssh)?)|"); // The Subversion proto.
+ regex_sb.append("tcp|"); // The TCP proto.
+ regex_sb.append("telnet|"); // The Telnet proto.
+ regex_sb.append("tftp|"); // The TFTP proto.
+ regex_sb.append("udp|"); // The UDP proto.
+ regex_sb.append("vnc|"); // The VNC proto.
+ regex_sb.append("ws(?:s?)"); // The Websocket proto.
+ regex_sb.append(")://"); // End scheme group.
+ regex_sb.append(")"); // End first matching group.
+
+
+ // Begin second matching group.
+ regex_sb.append("(");
+
+ // User name and/or password in format 'user:pass@'.
+ regex_sb.append("(?:\\S+(?::\\S*)?@)?");
+
+ // Begin host group.
+ regex_sb.append("(?:");
+
+ // IP address (from http://www.regular-expressions.info/examples.html).
+ regex_sb.append("(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)|");
+
+ // Host name or domain.
+ regex_sb.append("(?:(?:[a-z\\u00a1-\\uffff0-9]-*)*[a-z\\u00a1-\\uffff0-9]+)(?:(?:\\.(?:[a-z\\u00a1-\\uffff0-9]-*)*[a-z\\u00a1-\\uffff0-9]+)*(?:\\.(?:[a-z\\u00a1-\\uffff0-9]-*){1,}[a-z\\u00a1-\\uffff0-9]{1,}))?|");
+
+ // Just path. Used in case of 'file://' scheme.
+ regex_sb.append("/(?:(?:[a-z\\u00a1-\\uffff0-9]-*)*[a-z\\u00a1-\\uffff0-9]+)");
+
+ // End host group.
+ regex_sb.append(")");
+
+ // Port number.
+ regex_sb.append("(?::\\d{1,5})?");
+
+ // Resource path with optional query string.
+ regex_sb.append("(?:/[a-zA-Z0-9:@%\\-._~!$&()*+,;=?/]*)?");
+
+ // Fragment.
+ regex_sb.append("(?:#[a-zA-Z0-9:@%\\-._~!$&()*+,;=?/]*)?");
+
+ // End second matching group.
+ regex_sb.append(")");
+
+ URL_MATCH_REGEX = Pattern.compile(
+ regex_sb.toString(),
+ Pattern.CASE_INSENSITIVE | Pattern.MULTILINE | Pattern.DOTALL);
+
+ return URL_MATCH_REGEX;
+ }
+
+ public static LinkedHashSet extractUrls(String text) {
+ LinkedHashSet urlSet = new LinkedHashSet<>();
+ Matcher matcher = getUrlMatchRegex().matcher(text);
+
+ while (matcher.find()) {
+ int matchStart = matcher.start(1);
+ int matchEnd = matcher.end();
+ String url = text.substring(matchStart, matchEnd);
+ urlSet.add(url);
+ }
+
+ return urlSet;
+ }
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/termux/extrakeys/ExtraKeyButton.java b/termux/termux-shared/src/main/java/com/termux/shared/termux/extrakeys/ExtraKeyButton.java
new file mode 100644
index 000000000..bcec59daa
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/termux/extrakeys/ExtraKeyButton.java
@@ -0,0 +1,151 @@
+package com.termux.shared.termux.extrakeys;
+
+import android.text.TextUtils;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.Arrays;
+import java.util.stream.Collectors;
+
+public class ExtraKeyButton {
+
+ /** The key name for the name of the extra key if using a dict to define the extra key. {key: name, ...} */
+ public static final String KEY_KEY_NAME = "key";
+
+ /** The key name for the macro value of the extra key if using a dict to define the extra key. {macro: value, ...} */
+ public static final String KEY_MACRO = "macro";
+
+ /** The key name for the alternate display name of the extra key if using a dict to define the extra key. {display: name, ...} */
+ public static final String KEY_DISPLAY_NAME = "display";
+
+ /** The key name for the nested dict to define popup extra key info if using a dict to define the extra key. {popup: {key: name, ...}, ...} */
+ public static final String KEY_POPUP = "popup";
+
+
+ /**
+ * The key that will be sent to the terminal, either a control character, like defined in
+ * {@link ExtraKeysConstants#PRIMARY_KEY_CODES_FOR_STRINGS} (LEFT, RIGHT, PGUP...) or some text.
+ */
+ private final String key;
+
+ /**
+ * If the key is a macro, i.e. a sequence of keys separated by space.
+ */
+ private final boolean macro;
+
+ /**
+ * The text that will be displayed on the button.
+ */
+ private final String display;
+
+ /**
+ * The {@link ExtraKeyButton} containing the information of the popup button (triggered by swipe up).
+ */
+ @Nullable
+ private final ExtraKeyButton popup;
+
+
+ /**
+ * Initialize a {@link ExtraKeyButton}.
+ *
+ * @param config The {@link JSONObject} containing the info to create the {@link ExtraKeyButton}.
+ * @param extraKeyDisplayMap The {@link ExtraKeysConstants.ExtraKeyDisplayMap} that defines the
+ * display text mapping for the keys if a custom value is not defined
+ * by {@link #KEY_DISPLAY_NAME}.
+ * @param extraKeyAliasMap The {@link ExtraKeysConstants.ExtraKeyDisplayMap} that defines the
+ * aliases for the actual key names.
+ */
+ public ExtraKeyButton(@NonNull JSONObject config,
+ @NonNull ExtraKeysConstants.ExtraKeyDisplayMap extraKeyDisplayMap,
+ @NonNull ExtraKeysConstants.ExtraKeyDisplayMap extraKeyAliasMap) throws JSONException {
+ this(config, null, extraKeyDisplayMap, extraKeyAliasMap);
+ }
+
+ /**
+ * Initialize a {@link ExtraKeyButton}.
+ *
+ * @param config The {@link JSONObject} containing the info to create the {@link ExtraKeyButton}.
+ * @param popup The {@link ExtraKeyButton} optional {@link #popup} button.
+ * @param extraKeyDisplayMap The {@link ExtraKeysConstants.ExtraKeyDisplayMap} that defines the
+ * display text mapping for the keys if a custom value is not defined
+ * by {@link #KEY_DISPLAY_NAME}.
+ * @param extraKeyAliasMap The {@link ExtraKeysConstants.ExtraKeyDisplayMap} that defines the
+ * aliases for the actual key names.
+ */
+ public ExtraKeyButton(@NonNull JSONObject config, @Nullable ExtraKeyButton popup,
+ @NonNull ExtraKeysConstants.ExtraKeyDisplayMap extraKeyDisplayMap,
+ @NonNull ExtraKeysConstants.ExtraKeyDisplayMap extraKeyAliasMap) throws JSONException {
+ String keyFromConfig = getStringFromJson(config, KEY_KEY_NAME);
+ String macroFromConfig = getStringFromJson(config, KEY_MACRO);
+ String[] keys;
+ if (keyFromConfig != null && macroFromConfig != null) {
+ throw new JSONException("Both key and macro can't be set for the same key. key: \"" + keyFromConfig + "\", macro: \"" + macroFromConfig + "\"");
+ } else if (keyFromConfig != null) {
+ keys = new String[]{keyFromConfig};
+ this.macro = false;
+ } else if (macroFromConfig != null) {
+ keys = macroFromConfig.split(" ");
+ this.macro = true;
+ } else {
+ throw new JSONException("All keys have to specify either key or macro");
+ }
+
+ for (int i = 0; i < keys.length; i++) {
+ keys[i] = replaceAlias(extraKeyAliasMap, keys[i]);
+ }
+
+ this.key = TextUtils.join(" ", keys);
+
+ String displayFromConfig = getStringFromJson(config, KEY_DISPLAY_NAME);
+ if (displayFromConfig != null) {
+ this.display = displayFromConfig;
+ } else {
+ this.display = Arrays.stream(keys)
+ .map(key -> extraKeyDisplayMap.get(key, key))
+ .collect(Collectors.joining(" "));
+ }
+
+ this.popup = popup;
+ }
+
+ public String getStringFromJson(@NonNull JSONObject config, @NonNull String key) {
+ try {
+ return config.getString(key);
+ } catch (JSONException e) {
+ return null;
+ }
+ }
+
+ /** Get {@link #key}. */
+ public String getKey() {
+ return key;
+ }
+
+ /** Check whether a {@link #macro} is defined or not. */
+ public boolean isMacro() {
+ return macro;
+ }
+
+ /** Get {@link #display}. */
+ public String getDisplay() {
+ return display;
+ }
+
+ /** Get {@link #popup}. */
+ @Nullable
+ public ExtraKeyButton getPopup() {
+ return popup;
+ }
+
+ /**
+ * Replace the alias with its actual key name if found in extraKeyAliasMap.
+ */
+ public static String replaceAlias(@NonNull ExtraKeysConstants.ExtraKeyDisplayMap extraKeyAliasMap, String key) {
+ return extraKeyAliasMap.get(key, key);
+ }
+
+}
diff --git a/termux/termux-shared/src/main/java/com/termux/shared/termux/extrakeys/ExtraKeysConstants.java b/termux/termux-shared/src/main/java/com/termux/shared/termux/extrakeys/ExtraKeysConstants.java
new file mode 100644
index 000000000..b747381c0
--- /dev/null
+++ b/termux/termux-shared/src/main/java/com/termux/shared/termux/extrakeys/ExtraKeysConstants.java
@@ -0,0 +1,212 @@
+package com.termux.shared.termux.extrakeys;
+
+import android.view.KeyEvent;
+
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+public class ExtraKeysConstants {
+
+ /** Defines the repetitive keys that can be passed to {@link ExtraKeysView#setRepetitiveKeys(List)}. */
+ public static List PRIMARY_REPETITIVE_KEYS = Arrays.asList(
+ "UP", "DOWN", "LEFT", "RIGHT",
+ "BKSP", "DEL",
+ "PGUP", "PGDN");
+
+
+
+ /** Defines the {@link KeyEvent} for common keys. */
+ public static Map PRIMARY_KEY_CODES_FOR_STRINGS = new HashMap