Repo created

This commit is contained in:
Fr4nz D13trich 2025-11-22 16:41:32 +01:00
parent d22b8dc57b
commit 24b567c524
271 changed files with 39630 additions and 2 deletions

1
termux-shared/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

65
termux-shared/LICENSE.md Normal file
View file

@ -0,0 +1,65 @@
The `termux-shared` library is released under [GPLv3 only](https://www.gnu.org/licenses/gpl-3.0.html) license.
### Exceptions
#### [MIT License](https://opensource.org/licenses/MIT)
- [`src/main/java/com/termux/shared/termux/TermuxConstants.java`](src/main/java/com/termux/shared/termux/TermuxConstants.java).
- [`src/main/java/com/termux/shared/settings/properties/TermuxPropertyConstants.java`](src/main/java/com/termux/shared/settings/properties/TermuxPropertyConstants.java).
- [`src/main/java/com/termux/shared/activities/*`](src/main/java/com/termux/shared/activities).
- [`src/main/java/com/termux/shared/crash/CrashHandler.java`](src/main/java/com/termux/shared/crash/CrashHandler.java).
- [`src/main/java/com/termux/shared/data/DataUtils.java`](src/main/java/com/termux/shared/data/DataUtils.java).
- [`src/main/java/com/termux/shared/data/IntentUtils.java`](src/main/java/com/termux/shared/data/IntentUtils.java).
- [`src/main/java/com/termux/shared/file/filesystem/FileType.java`](src/main/java/com/termux/shared/file/filesystem/FileType.java).
- [`src/main/java/com/termux/shared/file/filesystem/FileTypes.java`](src/main/java/com/termux/shared/file/filesystem/FileTypes.java).
- [`src/main/java/com/termux/shared/file/filesystem/NativeDispatcher.java`](src/main/java/com/termux/shared/file/filesystem/NativeDispatcher.java).
- [`src/main/java/com/termux/shared/file/tests/FileUtilsTests.java`](src/main/java/com/termux/shared/file/tests/FileUtilsTests.java).
- [`src/main/java/com/termux/shared/file/FileUtils.java`](src/main/java/com/termux/shared/file/FileUtils.java).
- [`src/main/java/com/termux/shared/interact/ShareUtils.java`](src/main/java/com/termux/shared/interact/ShareUtils.java).
- [`src/main/java/com/termux/shared/interact/MessageDialogUtils.java`](src/main/java/com/termux/shared/interact/MessageDialogUtils.java).
- [`src/main/java/com/termux/shared/logger/Logger.java`](src/main/java/com/termux/shared/logger/Logger.java).
- [`src/main/java/com/termux/shared/markdown/MarkdownUtils.java`](src/main/java/com/termux/shared/markdown/MarkdownUtils.java).
- [`src/main/java/com/termux/shared/models/*`](src/main/java/com/termux/shared/models).
- [`src/main/java/com/termux/shared/notification/NotificationUtils.java`](src/main/java/com/termux/shared/notification/NotificationUtils.java).
- [`src/main/java/com/termux/shared/settings/preferences/SharedPreferenceUtils.java`](src/main/java/com/termux/shared/settings/preferences/SharedPreferenceUtils.java).
- [`src/main/java/com/termux/shared/settings/properties/SharedPropertiesParser.java`](src/main/java/com/termux/shared/settings/properties/SharedPropertiesParser.java).
- [`src/main/java/com/termux/shared/settings/properties/SharedProperties.java`](src/main/java/com/termux/shared/settings/properties/SharedProperties.java).
- [`src/main/java/com/termux/shared/shell/ResultSender.java`](src/main/java/com/termux/shared/shell/ResultSender.java).
- [`src/main/java/com/termux/shared/shell/ShellEnvironmentClient.java`](src/main/java/com/termux/shared/shell/ShellEnvironmentClient.java).
- [`src/main/java/com/termux/shared/shell/ShellUtils.java`](src/main/java/com/termux/shared/shell/ShellUtils.java).
- [`src/main/java/com/termux/shared/shell/TermuxTask.java`](src/main/java/com/termux/shared/shell/TermuxTask.java).
- [`src/main/java/com/termux/shared/termux/AndroidUtils.java`](src/main/java/com/termux/shared/termux/AndroidUtils.java).
- [`src/main/java/com/termux/shared/view/KeyboardUtils.java`](src/main/java/com/termux/shared/view/KeyboardUtils.java).
- [`src/main/java/com/termux/shared/view/ViewUtils.java`](src/main/java/com/termux/shared/view/ViewUtils.java).
- [`src/main/res/drawable/*`](src/main/res/drawable).
- [`src/main/res/layout/*`](src/main/res/layout).
- [`src/main/res/menu/*`](src/main/res/menu).
- [`src/main/res/values/*`](src/main/res/values).
##
#### [GPLv2 only with "Classpath" exception](https://openjdk.java.net/legal/gplv2+ce.html)
- [`src/main/java/com/termux/shared/file/filesystem/*`](src/main/java/com/termux/shared/file/filesystem) files that use code from [libcore/ojluni](https://cs.android.com/android/platform/superproject/+/android-11.0.0_r3:libcore/ojluni/).
##
#### [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0)
- [`src/main/java/com/termux/shared/shell/StreamGobbler.java`](src/main/java/com/termux/shared/shell/StreamGobbler.java) uses code from [libsuperuser ](https://github.com/Chainfire/libsuperuser).
##

View file

@ -0,0 +1,74 @@
apply plugin: 'com.android.library'
apply plugin: 'maven-publish'
android {
compileSdkVersion project.properties.compileSdkVersion.toInteger()
dependencies {
implementation 'androidx.appcompat:appcompat:1.3.1'
implementation "androidx.annotation:annotation:1.3.0"
implementation "androidx.core:core:1.6.0"
implementation 'com.google.android.material:material:1.4.0'
implementation "com.google.guava:guava:24.1-jre"
implementation "io.noties.markwon:core:$markwonVersion"
implementation "io.noties.markwon:ext-strikethrough:$markwonVersion"
implementation "io.noties.markwon:linkify:$markwonVersion"
implementation "io.noties.markwon:recycler:$markwonVersion"
implementation "org.lsposed.hiddenapibypass:hiddenapibypass:6.1"
// Do not increment version higher than 1.0.0-alpha09 since it will break ViewUtils and needs to be looked into
// noinspection GradleDependency
implementation "androidx.window:window:1.0.0-alpha09"
// Do not increment version higher than 2.5 or there
// will be runtime exceptions on android < 8
// due to missing classes like java.nio.file.Path.
implementation "commons-io:commons-io:2.5"
implementation project(":terminal-view")
}
defaultConfig {
minSdkVersion project.properties.minSdkVersion.toInteger()
targetSdkVersion project.properties.targetSdkVersion.toInteger()
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
}
dependencies {
testImplementation "junit:junit:4.13.2"
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
}
task sourceJar(type: Jar) {
from android.sourceSets.main.java.srcDirs
classifier "sources"
}
afterEvaluate {
publishing {
publications {
// Creates a Maven publication called "release".
release(MavenPublication) {
from components.release
groupId = 'com.termux'
artifactId = 'termux-shared'
version = '0.118.0'
artifact(sourceJar)
}
}
}
}

10
termux-shared/proguard-rules.pro vendored Normal file
View file

@ -0,0 +1,10 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
-dontobfuscate
#-renamesourcefileattribute SourceFile
#-keepattributes SourceFile,LineNumberTable

View file

@ -0,0 +1,26 @@
package com.termux.shared;
import android.content.Context;
import androidx.test.platform.app.InstrumentationRegistry;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import org.junit.Test;
import org.junit.runner.RunWith;
import static org.junit.Assert.*;
/**
* Instrumented test, which will execute on an Android device.
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
@RunWith(AndroidJUnit4.class)
public class ExampleInstrumentedTest {
@Test
public void useAppContext() {
// Context of the app under test.
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
assertEquals("com.termux.shared.test", appContext.getPackageName());
}
}

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.termux.shared">
<uses-permission android:name="android.permission.VIBRATE" />
</manifest>

View file

@ -0,0 +1,472 @@
package com.termux.shared.activities;
import androidx.annotation.NonNull;
import androidx.appcompat.app.ActionBar;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.widget.Toolbar;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
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 com.termux.shared.R;
import com.termux.shared.data.DataUtils;
import com.termux.shared.file.FileUtils;
import com.termux.shared.file.filesystem.FileType;
import com.termux.shared.logger.Logger;
import com.termux.shared.models.errors.Error;
import com.termux.shared.termux.TermuxConstants;
import com.termux.shared.markdown.MarkdownUtils;
import com.termux.shared.interact.ShareUtils;
import com.termux.shared.models.ReportInfo;
import org.commonmark.node.FencedCodeBlock;
import org.jetbrains.annotations.NotNull;
import io.noties.markwon.Markwon;
import io.noties.markwon.recycler.MarkwonAdapter;
import io.noties.markwon.recycler.SimpleEntry;
/**
* 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 `<activity android:name="com.termux.shared.activities.ReportActivity" android:theme="@style/Theme.AppCompat.TermuxReportActivity" android:documentLaunchMode="intoExisting" />` }
* and
* {@code `<receiver android:name="com.termux.shared.activities.ReportActivity$ReportActivityBroadcastReceiver" android:exported="false" />` }
* 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 AppCompatActivity {
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";
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
Logger.logVerbose(LOG_TAG, "onCreate");
setContentView(R.layout.activity_report);
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");
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));
}
}
}
}
}

View file

@ -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 ` <activity android:name="com.termux.shared.activities.TextIOActivity" android:theme="@style/Theme.AppCompat.TermuxTextIOActivity" />` }
*/
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;
}
}

View file

@ -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;
}
}
}

View file

@ -0,0 +1,101 @@
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.models.errors.Error;
import com.termux.shared.termux.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 defaultUEH;
private static final String LOG_TAG = "CrashUtils";
private CrashHandler(@NonNull final Context context, @NonNull final CrashHandlerClient crashHandlerClient) {
this.mContext = context;
this.mCrashHandlerClient = crashHandlerClient;
this.defaultUEH = Thread.getDefaultUncaughtExceptionHandler();
}
public void uncaughtException(@NonNull Thread thread, @NonNull Throwable throwable) {
logCrash(mContext, mCrashHandlerClient, thread, throwable);
defaultUEH.uncaughtException(thread, throwable);
}
/**
* Set default uncaught crash handler of current thread to {@link CrashHandler}.
*/
public static void setCrashHandler(@NonNull final Context context, @NonNull final CrashHandlerClient crashHandlerClient) {
if (!(Thread.getDefaultUncaughtExceptionHandler() instanceof CrashHandler)) {
Thread.setDefaultUncaughtExceptionHandler(new CrashHandler(context, crashHandlerClient));
}
}
/**
* Log a crash in the crash log file at {@code crashlogFilePath}.
*
* @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 final Context context, @NonNull final CrashHandlerClient crashHandlerClient, final Thread thread, final 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.writeStringToFile("crash log", crashHandlerClient.getCrashLogFilePath(context),
Charset.defaultCharset(), reportString.toString(), false);
if (error != null) {
Logger.logErrorExtended(LOG_TAG, error.toString());
}
}
public interface CrashHandlerClient {
/**
* Get crash log file path.
*
* @param context The {@link Context} passed to {@link CrashHandler#CrashHandler(Context, CrashHandlerClient)}.
* @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)}.
* @return Should return app info markdown string.
*/
String getAppInfoMarkdownString(Context context);
}
}

View file

@ -0,0 +1,31 @@
package com.termux.shared.crash;
import android.content.Context;
import androidx.annotation.NonNull;
import com.termux.shared.termux.TermuxConstants;
import com.termux.shared.termux.TermuxUtils;
public class TermuxCrashUtils implements CrashHandler.CrashHandlerClient {
/**
* Set default uncaught crash handler of current thread to {@link CrashHandler} for Termux app
* and its plugin to log crashes at {@link TermuxConstants#TERMUX_CRASH_LOG_FILE_PATH}.
*/
public static void setCrashHandler(@NonNull final Context context) {
CrashHandler.setCrashHandler(context, new TermuxCrashUtils());
}
@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);
}
}

View file

@ -0,0 +1,209 @@
package com.termux.shared.data;
import android.os.Bundle;
import androidx.annotation.Nullable;
import java.io.ByteArrayOutputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;
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;
}
/**
* 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> 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;
}
}
}

View file

@ -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();
}
}

View file

@ -0,0 +1,104 @@
package com.termux.shared.data;
import java.util.LinkedHashSet;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class UrlUtils {
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-\\uffff]{2,})))?|");
// 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<CharSequence> extractUrls(String text) {
LinkedHashSet<CharSequence> 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;
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,377 @@
package com.termux.shared.file;
import android.content.Context;
import android.os.Environment;
import androidx.annotation.NonNull;
import com.termux.shared.logger.Logger;
import com.termux.shared.markdown.MarkdownUtils;
import com.termux.shared.models.ExecutionCommand;
import com.termux.shared.models.errors.Error;
import com.termux.shared.models.errors.FileUtilsErrno;
import com.termux.shared.shell.TermuxShellEnvironmentClient;
import com.termux.shared.shell.TermuxTask;
import com.termux.shared.termux.AndroidUtils;
import com.termux.shared.termux.TermuxConstants;
import com.termux.shared.termux.TermuxUtils;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Pattern;
public class TermuxFileUtils {
private static final String LOG_TAG = "TermuxFileUtils";
/**
* Replace "$PREFIX/" or "~/" prefix with termux absolute paths.
*
* @param paths The {@code paths} to expand.
* @return Returns the {@code expand paths}.
*/
public static List<String> getExpandedTermuxPaths(List<String> paths) {
if (paths == null) return null;
List<String> expandedPaths = new ArrayList<>();
for (int i = 0; i < paths.size(); i++) {
expandedPaths.add(getExpandedTermuxPath(paths.get(i)));
}
return expandedPaths;
}
/**
* Replace "$PREFIX/" or "~/" prefix with termux absolute paths.
*
* @param path The {@code path} to expand.
* @return Returns the {@code expand path}.
*/
public static String getExpandedTermuxPath(String path) {
if (path != null && !path.isEmpty()) {
path = path.replaceAll("^\\$PREFIX$", TermuxConstants.TERMUX_PREFIX_DIR_PATH);
path = path.replaceAll("^\\$PREFIX/", TermuxConstants.TERMUX_PREFIX_DIR_PATH + "/");
path = path.replaceAll("^~/$", TermuxConstants.TERMUX_HOME_DIR_PATH);
path = path.replaceAll("^~/", TermuxConstants.TERMUX_HOME_DIR_PATH + "/");
}
return path;
}
/**
* Replace termux absolute paths with "$PREFIX/" or "~/" prefix.
*
* @param paths The {@code paths} to unexpand.
* @return Returns the {@code unexpand paths}.
*/
public static List<String> getUnExpandedTermuxPaths(List<String> paths) {
if (paths == null) return null;
List<String> unExpandedPaths = new ArrayList<>();
for (int i = 0; i < paths.size(); i++) {
unExpandedPaths.add(getUnExpandedTermuxPath(paths.get(i)));
}
return unExpandedPaths;
}
/**
* Replace termux absolute paths with "$PREFIX/" or "~/" prefix.
*
* @param path The {@code path} to unexpand.
* @return Returns the {@code unexpand path}.
*/
public static String getUnExpandedTermuxPath(String path) {
if (path != null && !path.isEmpty()) {
path = path.replaceAll("^" + Pattern.quote(TermuxConstants.TERMUX_PREFIX_DIR_PATH) + "/", "\\$PREFIX/");
path = path.replaceAll("^" + Pattern.quote(TermuxConstants.TERMUX_HOME_DIR_PATH) + "/", "~/");
}
return path;
}
/**
* Get canonical path.
*
* @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.
* @param expandPath The {@code boolean} that decides if input path is first attempted to be expanded by calling
* {@link TermuxFileUtils#getExpandedTermuxPath(String)} before its passed to
* {@link FileUtils#getCanonicalPath(String, String)}.
* @return Returns the {@code canonical path}.
*/
public static String getCanonicalPath(String path, final String prefixForNonAbsolutePath, final boolean expandPath) {
if (path == null) path = "";
if (expandPath)
path = getExpandedTermuxPath(path);
return FileUtils.getCanonicalPath(path, prefixForNonAbsolutePath);
}
/**
* Check if {@code path} is under the allowed termux working directory paths. If it is, then
* allowed parent path is returned.
*
* @param path The {@code path} to check.
* @return Returns the allowed path if it {@code path} is under it, otherwise {@link TermuxConstants#TERMUX_FILES_DIR_PATH}.
*/
public static String getMatchedAllowedTermuxWorkingDirectoryParentPathForPath(String path) {
if (path == null || path.isEmpty()) return TermuxConstants.TERMUX_FILES_DIR_PATH;
if (path.startsWith(TermuxConstants.TERMUX_STORAGE_HOME_DIR_PATH + "/")) {
return TermuxConstants.TERMUX_STORAGE_HOME_DIR_PATH;
} if (path.startsWith(Environment.getExternalStorageDirectory().getAbsolutePath() + "/")) {
return Environment.getExternalStorageDirectory().getAbsolutePath();
} else if (path.startsWith("/sdcard/")) {
return "/sdcard";
} else {
return TermuxConstants.TERMUX_FILES_DIR_PATH;
}
}
/**
* Validate the existence and permissions of directory file at path as a working directory for
* termux app.
*
* The creation of missing directory and setting of missing permissions will only be done if
* {@code path} is under paths returned by {@link #getMatchedAllowedTermuxWorkingDirectoryParentPathForPath(String)}.
*
* The permissions set to directory will be {@link FileUtils#APP_WORKING_DIRECTORY_PERMISSIONS}.
*
* @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 createDirectoryIfMissing The {@code boolean} that decides if directory file
* should be created if its missing.
* @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 boolean createDirectoryIfMissing,
final boolean setPermissions, final boolean setMissingPermissionsOnly,
final boolean ignoreErrorsIfPathIsInParentDirPath, final boolean ignoreIfNotExecutable) {
return FileUtils.validateDirectoryFileExistenceAndPermissions(label, filePath,
TermuxFileUtils.getMatchedAllowedTermuxWorkingDirectoryParentPathForPath(filePath), createDirectoryIfMissing,
FileUtils.APP_WORKING_DIRECTORY_PERMISSIONS, setPermissions, setMissingPermissionsOnly,
ignoreErrorsIfPathIsInParentDirPath, ignoreIfNotExecutable);
}
/**
* Validate if {@link TermuxConstants#TERMUX_FILES_DIR_PATH} exists and has
* {@link FileUtils#APP_WORKING_DIRECTORY_PERMISSIONS} permissions.
*
* This is required because binaries compiled for termux are hard coded with
* {@link TermuxConstants#TERMUX_PREFIX_DIR_PATH} and the path must be accessible.
*
* The permissions set to directory will be {@link FileUtils#APP_WORKING_DIRECTORY_PERMISSIONS}.
*
* This function does not create the directory manually but by calling {@link Context#getFilesDir()}
* so that android itself creates it. However, the call will not create its parent package
* data directory `/data/user/0/[package_name]` if it does not already exist and a `logcat`
* error will be logged by android.
* {@code Failed to ensure /data/user/0/<package_name>/files: mkdir failed: ENOENT (No such file or directory)}
* An android app normally can't create the package data directory since its parent `/data/user/0`
* is owned by `system` user and is normally created at app install or update time and not at app startup.
*
* Note that the path returned by {@link Context#getFilesDir()} may
* be under `/data/user/[id]/[package_name]` instead of `/data/data/[package_name]`
* defined by default by {@link TermuxConstants#TERMUX_FILES_DIR_PATH} where id will be 0 for
* primary user and a higher number for other users/profiles. If app is running under work profile
* or secondary user, then {@link TermuxConstants#TERMUX_FILES_DIR_PATH} will not be accessible
* and will not be automatically created, unless there is a bind mount from `/data/data` to
* `/data/user/[id]`, ideally in the right namespace.
* https://source.android.com/devices/tech/admin/multi-user
*
*
* On Android version `<=10`, the `/data/user/0` is a symlink to `/data/data` directory.
* https://cs.android.com/android/platform/superproject/+/android-10.0.0_r47:system/core/rootdir/init.rc;l=589
* {@code
* symlink /data/data /data/user/0
* }
*
* {@code
* /system/bin/ls -lhd /data/data /data/user/0
* drwxrwx--x 179 system system 8.0K 2021-xx-xx xx:xx /data/data
* lrwxrwxrwx 1 root root 10 2021-xx-xx xx:xx /data/user/0 -> /data/data
* }
*
* On Android version `>=11`, the `/data/data` directory is bind mounted at `/data/user/0`.
* https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:system/core/rootdir/init.rc;l=705
* https://cs.android.com/android/_/android/platform/system/core/+/3cca270e95ca8d8bc8b800e2b5d7da1825fd7100
* {@code
* # Unlink /data/user/0 if we previously symlink it to /data/data
* rm /data/user/0
*
* # Bind mount /data/user/0 to /data/data
* mkdir /data/user/0 0700 system system encryption=None
* mount none /data/data /data/user/0 bind rec
* }
*
* {@code
* /system/bin/grep -E '( /data )|( /data/data )|( /data/user/[0-9]+ )' /proc/self/mountinfo 2>&1 | /system/bin/grep -v '/data_mirror' 2>&1
* 87 32 253:5 / /data rw,nosuid,nodev,noatime shared:27 - ext4 /dev/block/dm-5 rw,seclabel,resgid=1065,errors=panic
* 91 87 253:5 /data /data/user/0 rw,nosuid,nodev,noatime shared:27 - ext4 /dev/block/dm-5 rw,seclabel,resgid=1065,errors=panic
* }
*
* The column 4 defines the root of the mount within the filesystem.
* Basically, `/dev/block/dm-5/` is mounted at `/data` and `/dev/block/dm-5/data` is mounted at
* `/data/user/0`.
* https://www.kernel.org/doc/Documentation/filesystems/proc.txt (section 3.5)
* https://www.kernel.org/doc/Documentation/filesystems/sharedsubtree.txt
* https://unix.stackexchange.com/a/571959
*
*
* Also note that running `/system/bin/ls -lhd /data/user/0/com.termux` as secondary user will result
* in `ls: /data/user/0/com.termux: Permission denied` where `0` is primary user id but running
* `/system/bin/ls -lhd /data/user/10/com.termux` will result in
* `drwx------ 6 u10_a149 u10_a149 4.0K 2021-xx-xx xx:xx /data/user/10/com.termux` where `10` is
* secondary user id. So can't stat directory (not contents) of primary user from secondary user
* but can the other way around. However, this is happening on android 10 avd, but not on android
* 11 avd.
*
* @param context The {@link Context} for operations.
* @param createDirectoryIfMissing The {@code boolean} that decides if directory file
* should be created if its missing.
* @param setMissingPermissions The {@code boolean} that decides if permissions are to be
* automatically set.
* @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 isTermuxFilesDirectoryAccessible(@NonNull final Context context, boolean createDirectoryIfMissing, boolean setMissingPermissions) {
if (createDirectoryIfMissing)
context.getFilesDir();
if (!FileUtils.directoryFileExists(TermuxConstants.TERMUX_FILES_DIR_PATH, true))
return FileUtilsErrno.ERRNO_FILE_NOT_FOUND_AT_PATH.getError("termux files directory", TermuxConstants.TERMUX_FILES_DIR_PATH);
if (setMissingPermissions)
FileUtils.setMissingFilePermissions("termux files directory", TermuxConstants.TERMUX_FILES_DIR_PATH,
FileUtils.APP_WORKING_DIRECTORY_PERMISSIONS);
return FileUtils.checkMissingFilePermissions("termux files directory", TermuxConstants.TERMUX_FILES_DIR_PATH,
FileUtils.APP_WORKING_DIRECTORY_PERMISSIONS, false);
}
/**
* Validate if {@link TermuxConstants#TERMUX_PREFIX_DIR_PATH} exists and has
* {@link FileUtils#APP_WORKING_DIRECTORY_PERMISSIONS} permissions.
* .
*
* The {@link TermuxConstants#TERMUX_PREFIX_DIR_PATH} directory would not exist if termux has
* not been installed or the bootstrap setup has not been run or if it was deleted by the user.
*
* @param createDirectoryIfMissing The {@code boolean} that decides if directory file
* should be created if its missing.
* @param setMissingPermissions The {@code boolean} that decides if permissions are to be
* automatically set.
* @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 isTermuxPrefixDirectoryAccessible(boolean createDirectoryIfMissing, boolean setMissingPermissions) {
return FileUtils.validateDirectoryFileExistenceAndPermissions("termux prefix directory", TermuxConstants.TERMUX_PREFIX_DIR_PATH,
null, createDirectoryIfMissing,
FileUtils.APP_WORKING_DIRECTORY_PERMISSIONS, setMissingPermissions, true,
false, false);
}
/**
* Validate if {@link TermuxConstants#TERMUX_STAGING_PREFIX_DIR_PATH} exists and has
* {@link FileUtils#APP_WORKING_DIRECTORY_PERMISSIONS} permissions.
*
* @param createDirectoryIfMissing The {@code boolean} that decides if directory file
* should be created if its missing.
* @param setMissingPermissions The {@code boolean} that decides if permissions are to be
* automatically set.
* @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 isTermuxPrefixStagingDirectoryAccessible(boolean createDirectoryIfMissing, boolean setMissingPermissions) {
return FileUtils.validateDirectoryFileExistenceAndPermissions("termux prefix staging directory", TermuxConstants.TERMUX_STAGING_PREFIX_DIR_PATH,
null, createDirectoryIfMissing,
FileUtils.APP_WORKING_DIRECTORY_PERMISSIONS, setMissingPermissions, true,
false, false);
}
/**
* Get a markdown {@link String} for stat output for various Termux app files paths.
*
* @param context The context for operations.
* @return Returns the markdown {@link String}.
*/
public static String getTermuxFilesStatMarkdownString(@NonNull final Context context) {
Context termuxPackageContext = TermuxUtils.getTermuxPackageContext(context);
if (termuxPackageContext == null) return null;
// Also ensures that termux files directory is created if it does not already exist
String filesDir = termuxPackageContext.getFilesDir().getAbsolutePath();
// Build script
StringBuilder statScript = new StringBuilder();
statScript
.append("echo 'ls info:'\n")
.append("/system/bin/ls -lhdZ")
.append(" '/data/data'")
.append(" '/data/user/0'")
.append(" '" + TermuxConstants.TERMUX_INTERNAL_PRIVATE_APP_DATA_DIR_PATH + "'")
.append(" '/data/user/0/" + TermuxConstants.TERMUX_PACKAGE_NAME + "'")
.append(" '" + TermuxConstants.TERMUX_FILES_DIR_PATH + "'")
.append(" '" + filesDir + "'")
.append(" '/data/user/0/" + TermuxConstants.TERMUX_PACKAGE_NAME + "/files'")
.append(" '/data/user/" + TermuxConstants.TERMUX_PACKAGE_NAME + "/files'")
.append(" '" + TermuxConstants.TERMUX_STAGING_PREFIX_DIR_PATH + "'")
.append(" '" + TermuxConstants.TERMUX_PREFIX_DIR_PATH + "'")
.append(" '" + TermuxConstants.TERMUX_HOME_DIR_PATH + "'")
.append(" '" + TermuxConstants.TERMUX_BIN_PREFIX_DIR_PATH + "/login'")
.append(" 2>&1")
.append("\necho; echo 'mount info:'\n")
.append("/system/bin/grep -E '( /data )|( /data/data )|( /data/user/[0-9]+ )' /proc/self/mountinfo 2>&1 | /system/bin/grep -v '/data_mirror' 2>&1");
// Run script
ExecutionCommand executionCommand = new ExecutionCommand(1, "/system/bin/sh", null, statScript.toString() + "\n", "/", true, true);
executionCommand.commandLabel = TermuxConstants.TERMUX_APP_NAME + " Files Stat Command";
executionCommand.backgroundCustomLogLevel = Logger.LOG_LEVEL_OFF;
TermuxTask termuxTask = TermuxTask.execute(context, executionCommand, null, new TermuxShellEnvironmentClient(), true);
if (termuxTask == null || !executionCommand.isSuccessful()) {
Logger.logErrorExtended(LOG_TAG, executionCommand.toString());
return null;
}
// Build script output
StringBuilder statOutput = new StringBuilder();
statOutput.append("$ ").append(statScript.toString());
statOutput.append("\n\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)
statOutput.append("\n").append(executionCommand.resultData.stderr.toString());
statOutput.append("\n").append("exit code: ").append(executionCommand.resultData.exitCode.toString());
}
// Build markdown output
StringBuilder markdownString = new StringBuilder();
markdownString.append("## ").append(TermuxConstants.TERMUX_APP_NAME).append(" Files Info\n\n");
AndroidUtils.appendPropertyToMarkdown(markdownString,"TERMUX_REQUIRED_FILES_DIR_PATH ($PREFIX)", TermuxConstants.TERMUX_FILES_DIR_PATH);
AndroidUtils.appendPropertyToMarkdown(markdownString,"ANDROID_ASSIGNED_FILES_DIR_PATH", filesDir);
markdownString.append("\n\n").append(MarkdownUtils.getMarkdownCodeForString(statOutput.toString(), true));
markdownString.append("\n##\n");
return markdownString.toString();
}
}

View file

@ -0,0 +1,414 @@
/*
* 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 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<FilePermission> permissions() {
int bits = (st_mode & UnixConstants.S_IAMB);
HashSet<FilePermission> 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();
}
}

View file

@ -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();
}
}

View file

@ -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.
*
* <p> 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
}

View file

@ -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.
*
* <p> 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<FilePermission> 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.
*
* <p> 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 '-'}.
*
* <p> <b>Usage Example:</b>
* 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.
* <pre>
* Set&lt;FilePermission&gt; perms = FilePermissions.fromString("rwxr-x---");
* </pre>
*
* @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<FilePermission> fromString(String perms) {
if (perms.length() != 9)
throw new IllegalArgumentException("Invalid mode");
Set<FilePermission> 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;
}
}

View file

@ -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}.
*
* <p> 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.
*
* <p> 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.
*
* <p> 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);
}
}
}

View file

@ -0,0 +1,31 @@
package com.termux.shared.file.filesystem;
/** The {@link Enum} that defines file types. */
public enum FileType {
NO_EXIST("no exist", 0), // 0000000
REGULAR("regular", 1), // 0000001
DIRECTORY("directory", 2), // 0000010
SYMLINK("symlink", 4), // 0000100
CHARACTER("character", 8), // 0001000
FIFO("fifo", 16), // 0010000
BLOCK("block", 32), // 0100000
UNKNOWN("unknown", 64); // 1000000
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;
}
}

View file

@ -0,0 +1,116 @@
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.
*/
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.isCharacter())
return FileType.CHARACTER;
else if (fileAttributes.isFifo())
return FileType.FIFO;
else if (fileAttributes.isBlock())
return FileType.BLOCK;
else
return FileType.UNKNOWN;
}
}

View file

@ -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() + "\"");
}
}

View file

@ -0,0 +1,149 @@
/*
* 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.system.OsConstants;
/**
* 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;
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_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;
}

View file

@ -0,0 +1,310 @@
package com.termux.shared.file.tests;
import android.content.Context;
import androidx.annotation.NonNull;
import com.termux.shared.file.FileUtils;
import com.termux.shared.logger.Logger;
import com.termux.shared.models.errors.Error;
import java.io.File;
import java.nio.charset.Charset;
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_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.writeStringToFile(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.writeStringToFile(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.readStringFromFile(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");
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);
}
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);
}
}

View file

@ -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));
}
}

View file

@ -0,0 +1,224 @@
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.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.models.errors.Error;
import com.termux.shared.packages.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.
*/
private 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) {
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.writeStringToFile(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);
}
}
}

View file

@ -0,0 +1,78 @@
package com.termux.shared.interact;
import android.app.Activity;
import android.app.AlertDialog;
import android.content.Context;
import android.content.DialogInterface;
import android.text.Selection;
import android.util.TypedValue;
import android.view.KeyEvent;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup.LayoutParams;
import android.widget.EditText;
import android.widget.LinearLayout;
import android.widget.TextView;
import com.termux.shared.R;
public final class TextInputDialogUtils {
public interface TextSetListener {
void onTextSet(String text);
}
public static void textInput(Activity activity, int titleText, String initialText,
int positiveButtonText, final TextSetListener onPositive,
int neutralButtonText, final TextSetListener onNeutral,
int negativeButtonText, final TextSetListener onNegative,
final DialogInterface.OnDismissListener onDismiss) {
final EditText input = new EditText(activity);
input.setSingleLine();
if (initialText != null) {
input.setText(initialText);
Selection.setSelection(input.getText(), initialText.length());
}
final AlertDialog[] dialogHolder = new AlertDialog[1];
input.setImeActionLabel(activity.getResources().getString(positiveButtonText), KeyEvent.KEYCODE_ENTER);
input.setOnEditorActionListener((v, actionId, event) -> {
onPositive.onTextSet(input.getText().toString());
dialogHolder[0].dismiss();
return true;
});
float dipInPixels = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 1, activity.getResources().getDisplayMetrics());
// https://www.google.com/design/spec/components/dialogs.html#dialogs-specs
int paddingTopAndSides = Math.round(16 * dipInPixels);
int paddingBottom = Math.round(24 * dipInPixels);
LinearLayout layout = new LinearLayout(activity);
layout.setOrientation(LinearLayout.VERTICAL);
layout.setLayoutParams(new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT));
layout.setPadding(paddingTopAndSides, paddingTopAndSides, paddingTopAndSides, paddingBottom);
layout.addView(input);
AlertDialog.Builder builder = new AlertDialog.Builder(activity)
.setTitle(titleText).setView(layout)
.setPositiveButton(positiveButtonText, (d, whichButton) -> onPositive.onTextSet(input.getText().toString()));
if (onNeutral != null) {
builder.setNeutralButton(neutralButtonText, (dialog, which) -> onNeutral.onTextSet(input.getText().toString()));
}
if (onNegative == null) {
builder.setNegativeButton(android.R.string.cancel, null);
} else {
builder.setNegativeButton(negativeButtonText, (dialog, which) -> onNegative.onTextSet(input.getText().toString()));
}
if (onDismiss != null)
builder.setOnDismissListener(onDismiss);
dialogHolder[0] = builder.create();
dialogHolder[0].setCanceledOnTouchOutside(false);
dialogHolder[0].show();
}
}

View file

@ -0,0 +1,448 @@
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 com.termux.shared.R;
import com.termux.shared.data.DataUtils;
import com.termux.shared.termux.TermuxConstants;
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 {
public static final String DEFAULT_LOG_TAG = TermuxConstants.TERMUX_APP_NAME;
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<String> 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<messagesList.size(); i++) {
if (messagesList.size() > 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 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 logErrorAndShowToast(Context context, String tag, String message) {
if (context == null) return;
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 (context == null) return;
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<Throwable> 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<Throwable> 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<Throwable> 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<logLevels.length; i++) {
logLevelLabels[i] = getLogLevelLabel(context, Integer.parseInt(logLevels[i].toString()), addDefaultTag);
}
return logLevelLabels;
}
public static String getLogLevelLabel(final Context context, final int logLevel, final boolean addDefaultTag) {
String logLabel;
switch (logLevel) {
case LOG_LEVEL_OFF: logLabel = context.getString(R.string.log_level_off); break;
case LOG_LEVEL_NORMAL: logLabel = context.getString(R.string.log_level_normal); break;
case LOG_LEVEL_DEBUG: logLabel = context.getString(R.string.log_level_debug); break;
case LOG_LEVEL_VERBOSE: logLabel = context.getString(R.string.log_level_verbose); break;
default: logLabel = context.getString(R.string.log_level_unknown); break;
}
if (addDefaultTag && logLevel == DEFAULT_LOG_LEVEL)
return logLabel + " (default)";
else
return logLabel;
}
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;
}
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 (customLogLevel == null || CURRENT_LOG_LEVEL <= LOG_LEVEL_OFF || customLogLevel <= LOG_LEVEL_OFF) return false;
customLogLevel = Logger.isLogLevelValid(customLogLevel) ? customLogLevel: Logger.LOG_LEVEL_VERBOSE;
return (customLogLevel >= CURRENT_LOG_LEVEL);
}
}

View file

@ -0,0 +1,199 @@
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 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 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) {
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);
}
}

View file

@ -0,0 +1,552 @@
package com.termux.shared.models;
import android.content.Intent;
import android.net.Uri;
import androidx.annotation.NonNull;
import com.termux.shared.data.IntentUtils;
import com.termux.shared.models.errors.Error;
import com.termux.shared.logger.Logger;
import com.termux.shared.markdown.MarkdownUtils;
import com.termux.shared.data.DataUtils;
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;
}
}
/** The optional unique id for the {@link ExecutionCommand}. */
public Integer id;
/** 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;
/** If the {@link ExecutionCommand} is a background or a foreground terminal session command. */
public boolean inBackground;
/** If the {@link ExecutionCommand} is meant to start a failsafe terminal session. */
public boolean isFailsafe;
/**
* The {@link ExecutionCommand} custom log level for background {@link com.termux.shared.shell.TermuxTask}
* 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 com.termux.shared.shell.TermuxTask} only logs stdin if `CURRENT_LOG_LEVEL` is >=
* {@link Logger#LOG_LEVEL_DEBUG}.
*/
public Integer backgroundCustomLogLevel;
/** The session action of foreground commands. */
public String sessionAction;
/** 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, boolean inBackground, boolean isFailsafe) {
this.id = id;
this.executable = executable;
this.arguments = arguments;
this.stdin = stdin;
this.workingDirectory = workingDirectory;
this.inBackground = inBackground;
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<Throwable> 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<Throwable> throwablesList) {
return setStateFailed(null, code, message, throwablesList);
}
public synchronized boolean setStateFailed(String type, int code, String message, List<Throwable> 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.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.getInBackgroundLogString());
logString.append("\n").append(executionCommand.getIsFailsafeLogString());
if (executionCommand.inBackground) {
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.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");
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(executionCommand.arguments));
markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("Working Directory", executionCommand.workingDirectory, "-"));
markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("inBackground", executionCommand.inBackground, "-"));
markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("isFailsafe", executionCommand.isFailsafe, "-"));
if (executionCommand.inBackground) {
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("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 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);
}
public String getWorkingDirectoryLogString() {
return "Working Directory: `" + workingDirectory + "`";
}
public String getInBackgroundLogString() {
return "inBackground: `" + inBackground + "`";
}
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 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<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 log friendly {@link String}.
*/
public static String getArgumentsLogString(final String[] argumentsArray) {
StringBuilder argumentsString = new StringBuilder("Arguments:");
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(final String[] argumentsArray) {
StringBuilder argumentsString = new StringBuilder("**Arguments:**");
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();
}
}

View file

@ -0,0 +1,97 @@
package com.termux.shared.models;
import androidx.annotation.Keep;
import com.termux.shared.markdown.MarkdownUtils;
import com.termux.shared.termux.AndroidUtils;
import java.io.Serializable;
/**
* An object that stored info for {@link com.termux.shared.activities.ReportActivity}.
*/
public class ReportInfo implements Serializable {
/**
* Explicitly define `serialVersionUID` to prevent exceptions on deserialization.
*
* Like when calling `Bundle.getSerializable()` on Android.
* `android.os.BadParcelableException: Parcelable encountered IOException reading a Serializable object` (name = <class_name>)
* `java.io.InvalidClassException: <class_name>; local class incompatible`
*
* The `@Keep` annotation is necessary to prevent the field from being removed by proguard when
* app is compiled, even if its kept during library compilation.
*
* **See Also:**
* - https://docs.oracle.com/javase/8/docs/platform/serialization/spec/version.html#a6678
* - https://docs.oracle.com/javase/8/docs/platform/serialization/spec/class.html#a4100
*/
@Keep
private static final long serialVersionUID = 1L;
/** 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 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, app and device info will be added to the report when
* markdown is generated.
*/
public final boolean addReportInfoHeaderToMarkdown;
/** The timestamp for the report. */
public final String reportTimestamp;
/** The label for the report file to save if user selects menu_item_save_report_to_file. */
public final String reportSaveFileLabel;
/** The path for the report file to save if user selects menu_item_save_report_to_file. */
public final String reportSaveFilePath;
public ReportInfo(String userAction, String sender, String reportTitle, String reportStringPrefix,
String reportString, String reportStringSuffix, boolean addReportInfoHeaderToMarkdown,
String reportSaveFileLabel, String reportSaveFilePath) {
this.userAction = userAction;
this.sender = sender;
this.reportTitle = reportTitle;
this.reportStringPrefix = reportStringPrefix;
this.reportString = reportString;
this.reportStringSuffix = reportStringSuffix;
this.addReportInfoHeaderToMarkdown = addReportInfoHeaderToMarkdown;
this.reportSaveFileLabel = reportSaveFileLabel;
this.reportSaveFilePath = reportSaveFilePath;
this.reportTimestamp = AndroidUtils.getCurrentMilliSecondUTCTimeStamp();
}
/**
* 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();
}
}

View file

@ -0,0 +1,170 @@
package com.termux.shared.models;
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();
}
}

View file

@ -0,0 +1,258 @@
package com.termux.shared.models;
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.models.errors.Errno;
import com.termux.shared.models.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<Error> 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<Throwable> 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<Throwable> throwablesList) {
return setStateFailed(null, code, message, throwablesList);
}
public synchronized boolean setStateFailed(String type, int code, String message, List<Throwable> 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();
}
}

View file

@ -0,0 +1,254 @@
package com.termux.shared.models;
import android.graphics.Color;
import android.graphics.Typeface;
import androidx.annotation.Keep;
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 {
/**
* Explicitly define `serialVersionUID` to prevent exceptions on deserialization.
*
* Like when calling `Bundle.getSerializable()` on Android.
* `android.os.BadParcelableException: Parcelable encountered IOException reading a Serializable object` (name = <class_name>)
* `java.io.InvalidClassException: <class_name>; local class incompatible`
*
* The `@Keep` annotation is necessary to prevent the field from being removed by proguard when
* app is compiled, even if its kept during library compilation.
*
* **See Also:**
* - https://docs.oracle.com/javase/8/docs/platform/serialization/spec/version.html#a6678
* - https://docs.oracle.com/javase/8/docs/platform/serialization/spec/class.html#a4100
*/
@Keep
private static final long serialVersionUID = 1L;
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;
}
}

View file

@ -0,0 +1,110 @@
package com.termux.shared.models.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<String, Errno> 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 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(final String type, final int code, 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 + "\"";
}
public String getType() {
return type;
}
public String getMessage() {
return message;
}
public int getCode() {
return code;
}
/**
* 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<Throwable> 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);
}
}
}

View file

@ -0,0 +1,262 @@
package com.termux.shared.models.errors;
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<Throwable> 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<Throwable> 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<Throwable> 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<Throwable> 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<Throwable> 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<Throwable> 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<Throwable> 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<Throwable> throwablesList) {
return setStateFailed(this.type, code, message, throwablesList);
}
public synchronized boolean setStateFailed(String type, int code, String message, List<Throwable> 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);
}
/**
* 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";
StringBuilder logString = new StringBuilder();
logString.append(error.getCodeString());
logString.append("\n").append(error.getTypeAndMessageLogString());
if (error.throwablesList != null)
logString.append("\n").append(error.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";
StringBuilder logString = new StringBuilder();
logString.append(error.getCodeString());
logString.append(error.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";
StringBuilder logString = new StringBuilder();
logString.append("(").append(error.getCode()).append(") ");
logString.append(error.getType()).append(": ").append(error.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";
StringBuilder markdownString = new StringBuilder();
markdownString.append(MarkdownUtils.getSingleLineMarkdownStringEntry("Error Code", error.getCode(), "-"));
markdownString.append("\n").append(MarkdownUtils.getMultiLineMarkdownStringEntry((Errno.TYPE.equals(error.getType()) ? "Error Message" : "Error Message (" + error.getType() + ")"), error.message, "-"));
markdownString.append("\n\n").append(error.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));
}
}

View file

@ -0,0 +1,106 @@
package com.termux.shared.models.errors;
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\" is not one of allowed file types \"%3$s\".");
public static final Errno ERRNO_VALIDATE_FILE_EXISTENCE_AND_PERMISSIONS_FAILED_WITH_EXCEPTION = new Errno(TYPE, 159, "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, 160, "Validating directory existence and permissions 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_STRING_TO_FILE_FAILED_WITH_EXCEPTION = new Errno(TYPE, 350, "Reading string from %1$s at path \"%2$s\" failed.\nException: %3$s");
public static final Errno ERRNO_WRITING_STRING_TO_FILE_FAILED_WITH_EXCEPTION = new Errno(TYPE, 351, "Writing string 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_READING_SERIALIZABLE_OBJECT_TO_FILE_FAILED_WITH_EXCEPTION = new Errno(TYPE, 354, "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, 355, "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 Map<Errno, Errno> ERRNO_SHORT_MAPPING = new HashMap<Errno, Errno>() {{
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);
}};
}

View file

@ -0,0 +1,21 @@
package com.termux.shared.models.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\"");
FunctionErrno(final String type, final int code, final String message) {
super(type, code, message);
}
}

View file

@ -0,0 +1,20 @@
package com.termux.shared.models.errors;
/** 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);
}
}

View file

@ -0,0 +1,144 @@
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 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;
}
}

View file

@ -0,0 +1,38 @@
package com.termux.shared.notification;
import android.content.Context;
import com.termux.shared.settings.preferences.TermuxAppSharedPreferences;
import com.termux.shared.settings.preferences.TermuxPreferenceConstants;
import com.termux.shared.termux.TermuxConstants;
public class TermuxNotificationUtils {
/**
* Try to get the next unique notification id that isn't already being used by the app.
*
* Termux app and its plugin must use unique notification ids from the same pool due to usage of android:sharedUserId.
* https://commonsware.com/blog/2017/06/07/jobscheduler-job-ids-libraries.html
*
* @param context The {@link Context} for operations.
* @return Returns the notification id that should be safe to use.
*/
public synchronized static int getNextNotificationId(final Context context) {
if (context == null) return TermuxPreferenceConstants.TERMUX_APP.DEFAULT_VALUE_KEY_LAST_NOTIFICATION_ID;
TermuxAppSharedPreferences preferences = TermuxAppSharedPreferences.build(context);
if (preferences == null) return TermuxPreferenceConstants.TERMUX_APP.DEFAULT_VALUE_KEY_LAST_NOTIFICATION_ID;
int lastNotificationId = preferences.getLastNotificationId();
int nextNotificationId = lastNotificationId + 1;
while(nextNotificationId == TermuxConstants.TERMUX_APP_NOTIFICATION_ID || nextNotificationId == TermuxConstants.TERMUX_RUN_COMMAND_NOTIFICATION_ID) {
nextNotificationId++;
}
if (nextNotificationId == Integer.MAX_VALUE || nextNotificationId < 0)
nextNotificationId = TermuxPreferenceConstants.TERMUX_APP.DEFAULT_VALUE_KEY_LAST_NOTIFICATION_ID;
preferences.setLastNotificationId(nextNotificationId);
return nextNotificationId;
}
}

View file

@ -0,0 +1,471 @@
package com.termux.shared.packages;
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.UserManager;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
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 com.termux.shared.termux.TermuxConstants;
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.
*
* @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) {
try {
return context.createPackageContext(packageName, Context.CONTEXT_RESTRICTED);
} catch (Exception e) {
Logger.logVerbose(LOG_TAG, "Failed to get \"" + packageName + "\" package context: " + 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.
* @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) {
Context packageContext = getContextForPackage(context, packageName);
if (packageContext == null && exitAppOnError) {
String errorMessage = context.getString(R.string.error_get_package_context_failed_message,
packageName, TermuxConstants.TERMUX_GITHUB_REPO_URL);
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, 0);
}
/**
* 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) {
try {
return context.getPackageManager().getPackageInfo(context.getPackageName(), flags);
} catch (final Exception 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 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 context.getApplicationInfo().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 context.getApplicationInfo().packageName;
}
/**
* 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 context.getApplicationInfo().targetSdkVersion;
}
/**
* 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 the {@code versionName}. This will be {@code null} if an exception is raised.
*/
public static Boolean isAppForPackageADebuggableBuild(@NonNull final Context context) {
return ( 0 != ( context.getApplicationInfo().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 the {@code versionName}. This will be {@code null} if an exception is raised.
*/
public static Boolean isAppInstalledOnExternalStorage(@NonNull final Context context) {
return ( 0 != ( context.getApplicationInfo().flags & ApplicationInfo.FLAG_EXTERNAL_STORAGE ) );
}
/**
* 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) {
try {
return getPackageInfoForPackage(context).versionCode;
} catch (final Exception e) {
return 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) {
try {
return getPackageInfoForPackage(context).versionName;
} catch (final Exception e) {
return 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) {
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, 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 current user.
*
* @param context The {@link Context} for operations.
* @return Returns the serial number. This will be {@code null} if failed to get it.
*/
@Nullable
public static Long getSerialNumberForCurrentUser(@NonNull Context context) {
UserManager userManager = (UserManager) context.getSystemService(Context.USER_SERVICE);
if (userManager == null) return null;
return userManager.getSerialNumberForUser(android.os.Process.myUserHandle());
}
/**
* 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}.
*/
public static boolean isCurrentUserThePrimaryUser(@NonNull Context context) {
Long userId = getSerialNumberForCurrentUser(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<ComponentName> 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<ActivityManager.RunningAppProcessInfo> 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 `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
* <manifest
* <queries>
* <package android:name="package_name" />
* </queries>
* </manifest>
* }
*
* @param context The context for operations.
* @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;
PackageManager packageManager = context.getPackageManager();
ApplicationInfo applicationInfo;
try {
applicationInfo = packageManager.getApplicationInfo(packageName, 0);
} catch (final PackageManager.NameNotFoundException e) {
applicationInfo = null;
}
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;
}
/**
* 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 state 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.
* @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 state, String toastString,
boolean showErrorMessage) {
try {
PackageManager packageManager = context.getPackageManager();
if (packageManager != null) {
ComponentName componentName = new ComponentName(packageName, className);
if (toastString != null) Logger.showToast(context, toastString, true);
packageManager.setComponentEnabledSetting(componentName,
state ? PackageManager.COMPONENT_ENABLED_STATE_ENABLED : PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
PackageManager.DONT_KILL_APP);
}
return null;
} catch (final Exception e) {
String errmsg = context.getString(
state ? 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;
}
}

View file

@ -0,0 +1,114 @@
package com.termux.shared.packages;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.net.Uri;
import android.os.Build;
import android.os.PowerManager;
import android.provider.Settings;
import androidx.core.content.ContextCompat;
import com.termux.shared.R;
import com.termux.shared.logger.Logger;
import java.util.Arrays;
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";
public static boolean checkPermission(Context context, String permission) {
if (permission == null) return false;
return checkPermissions(context, new String[]{permission});
}
public static boolean checkPermissions(Context context, String[] permissions) {
if (permissions == null) return false;
int result;
for (String p:permissions) {
result = ContextCompat.checkSelfPermission(context,p);
if (result != PackageManager.PERMISSION_GRANTED) {
return false;
}
}
return true;
}
public static void requestPermission(Activity activity, String permission, int requestCode) {
if (permission == null) return;
requestPermissions(activity, new String[]{permission}, requestCode);
}
public static void requestPermissions(Activity activity, String[] permissions, int requestCode) {
if (activity == null || permissions == null) return;
int result;
Logger.showToast(activity, activity.getString(R.string.message_sudo_please_grant_permissions), true);
try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}
for (String permission:permissions) {
result = ContextCompat.checkSelfPermission(activity, permission);
if (result != PackageManager.PERMISSION_GRANTED) {
Logger.logDebug(LOG_TAG, "Requesting Permissions: " + Arrays.toString(permissions));
try {
activity.requestPermissions(new String[]{permission}, requestCode);
} catch (Exception e) {
Logger.logStackTraceWithMessage(LOG_TAG, "Failed to request permissions with request code " + requestCode + ": " + Arrays.toString(permissions), e);
}
}
}
}
public static boolean checkDisplayOverOtherAppsPermission(Context context) {
return Settings.canDrawOverlays(context);
}
public static void requestDisplayOverOtherAppsPermission(Activity context, int requestCode) {
Intent intent = new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:" + context.getPackageName()));
context.startActivityForResult(intent, requestCode);
}
public static boolean validateDisplayOverOtherAppsPermissionForPostAndroid10(Context context, boolean logResults) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return true;
if (!PermissionUtils.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;
}
}
public static boolean checkIfBatteryOptimizationsDisabled(Context context) {
PowerManager powerManager = (PowerManager) context.getSystemService(Context.POWER_SERVICE);
return powerManager.isIgnoringBatteryOptimizations(context.getPackageName());
}
@SuppressLint("BatteryLife")
public static void requestDisableBatteryOptimizations(Activity activity, int requestCode) {
Intent intent = new Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS);
intent.setData(Uri.parse("package:" + activity.getPackageName()));
activity.startActivityForResult(intent, requestCode);
}
}

View file

@ -0,0 +1,275 @@
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");
HiddenApiBypass.addHiddenApiExemptions("");
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.
*
* @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 <T> FieldInvokeResult invokeField(@NonNull Class<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;
}
}
}

View file

@ -0,0 +1,400 @@
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();
}
/**
* 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<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.
* @return Returns the {@code Set<String>} value stored in {@link SharedPreferences}, otherwise returns
* default if failed to read a valid value, like in case of an exception.
*/
public static Set<String> getStringSet(SharedPreferences sharedPreferences, String key, Set<String> def) {
if (sharedPreferences == null) {
Logger.logError(LOG_TAG, "Error getting Set<String> 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<String> value for the \"" + key + "\" key from shared preferences. Returning default value \"" + def + "\".", e);
return def;
}
}
/**
* Set a {@code Set<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 setStringSet(SharedPreferences sharedPreferences, String key, Set<String> value, boolean commitToFile) {
if (sharedPreferences == null) {
Logger.logError(LOG_TAG, "Ignoring setting Set<String> 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();
}
}

View file

@ -0,0 +1,87 @@
package com.termux.shared.settings.preferences;
import android.app.Activity;
import android.content.Context;
import android.content.SharedPreferences;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.termux.shared.logger.Logger;
import com.termux.shared.packages.PackageUtils;
import com.termux.shared.settings.preferences.TermuxPreferenceConstants.TERMUX_API_APP;
import com.termux.shared.termux.TermuxConstants;
public class TermuxAPIAppSharedPreferences {
private final Context mContext;
private final SharedPreferences mSharedPreferences;
private final SharedPreferences mMultiProcessSharedPreferences;
private static final String LOG_TAG = "TermuxAPIAppSharedPreferences";
private TermuxAPIAppSharedPreferences(@NonNull Context context) {
mContext = context;
mSharedPreferences = getPrivateSharedPreferences(mContext);
mMultiProcessSharedPreferences = getPrivateAndMultiProcessSharedPreferences(mContext);
}
/**
* Get the {@link Context} for a package name.
*
* @param context The {@link Context} to use to get the {@link Context} of the
* {@link TermuxConstants#TERMUX_API_PACKAGE_NAME}.
* @return Returns the {@link TermuxAPIAppSharedPreferences}. This will {@code null} if an exception is raised.
*/
@Nullable
public static TermuxAPIAppSharedPreferences build(@NonNull final Context context) {
Context termuxTaskerPackageContext = PackageUtils.getContextForPackage(context, TermuxConstants.TERMUX_API_PACKAGE_NAME);
if (termuxTaskerPackageContext == null)
return null;
else
return new TermuxAPIAppSharedPreferences(termuxTaskerPackageContext);
}
/**
* Get the {@link Context} for a package name.
*
* @param context The {@link Activity} to use to get the {@link Context} of the
* {@link TermuxConstants#TERMUX_API_PACKAGE_NAME}.
* @param exitAppOnError If {@code true} and failed to get package context, then a dialog will
* be shown which when dismissed will exit the app.
* @return Returns the {@link TermuxAPIAppSharedPreferences}. This will {@code null} if an exception is raised.
*/
public static TermuxAPIAppSharedPreferences build(@NonNull final Context context, final boolean exitAppOnError) {
Context termuxTaskerPackageContext = PackageUtils.getContextForPackageOrExitApp(context, TermuxConstants.TERMUX_API_PACKAGE_NAME, exitAppOnError);
if (termuxTaskerPackageContext == null)
return null;
else
return new TermuxAPIAppSharedPreferences(termuxTaskerPackageContext);
}
private static SharedPreferences getPrivateSharedPreferences(Context context) {
if (context == null) return null;
return SharedPreferenceUtils.getPrivateSharedPreferences(context, TermuxConstants.TERMUX_API_DEFAULT_PREFERENCES_FILE_BASENAME_WITHOUT_EXTENSION);
}
private static SharedPreferences getPrivateAndMultiProcessSharedPreferences(Context context) {
if (context == null) return null;
return SharedPreferenceUtils.getPrivateAndMultiProcessSharedPreferences(context, TermuxConstants.TERMUX_API_DEFAULT_PREFERENCES_FILE_BASENAME_WITHOUT_EXTENSION);
}
public int getLogLevel(boolean readFromFile) {
if (readFromFile)
return SharedPreferenceUtils.getInt(mMultiProcessSharedPreferences, TERMUX_API_APP.KEY_LOG_LEVEL, Logger.DEFAULT_LOG_LEVEL);
else
return SharedPreferenceUtils.getInt(mSharedPreferences, TERMUX_API_APP.KEY_LOG_LEVEL, Logger.DEFAULT_LOG_LEVEL);
}
public void setLogLevel(Context context, int logLevel, boolean commitToFile) {
logLevel = Logger.setLogLevel(context, logLevel);
SharedPreferenceUtils.setInt(mSharedPreferences, TERMUX_API_APP.KEY_LOG_LEVEL, logLevel, commitToFile);
}
}

View file

@ -0,0 +1,237 @@
package com.termux.shared.settings.preferences;
import android.app.Activity;
import android.content.Context;
import android.content.SharedPreferences;
import android.util.TypedValue;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.termux.shared.packages.PackageUtils;
import com.termux.shared.termux.TermuxConstants;
import com.termux.shared.logger.Logger;
import com.termux.shared.data.DataUtils;
import com.termux.shared.settings.preferences.TermuxPreferenceConstants.TERMUX_APP;
public class TermuxAppSharedPreferences {
private final Context mContext;
private final SharedPreferences mSharedPreferences;
private int MIN_FONTSIZE;
private int MAX_FONTSIZE;
private int DEFAULT_FONTSIZE;
private static final String LOG_TAG = "TermuxAppSharedPreferences";
private TermuxAppSharedPreferences(@NonNull Context context) {
mContext = context;
mSharedPreferences = getPrivateSharedPreferences(mContext);
setFontVariables(context);
}
/**
* Get the {@link Context} for a package name.
*
* @param context The {@link Context} to use to get the {@link Context} of the
* {@link TermuxConstants#TERMUX_PACKAGE_NAME}.
* @return Returns the {@link TermuxAppSharedPreferences}. This will {@code null} if an exception is raised.
*/
@Nullable
public static TermuxAppSharedPreferences build(@NonNull final Context context) {
Context termuxPackageContext = PackageUtils.getContextForPackage(context, TermuxConstants.TERMUX_PACKAGE_NAME);
if (termuxPackageContext == null)
return null;
else
return new TermuxAppSharedPreferences(termuxPackageContext);
}
/**
* Get the {@link Context} for a package name.
*
* @param context The {@link Activity} to use to get the {@link Context} of the
* {@link TermuxConstants#TERMUX_PACKAGE_NAME}.
* @param exitAppOnError If {@code true} and failed to get package context, then a dialog will
* be shown which when dismissed will exit the app.
* @return Returns the {@link TermuxAppSharedPreferences}. This will {@code null} if an exception is raised.
*/
public static TermuxAppSharedPreferences build(@NonNull final Context context, final boolean exitAppOnError) {
Context termuxPackageContext = PackageUtils.getContextForPackageOrExitApp(context, TermuxConstants.TERMUX_PACKAGE_NAME, exitAppOnError);
if (termuxPackageContext == null)
return null;
else
return new TermuxAppSharedPreferences(termuxPackageContext);
}
private static SharedPreferences getPrivateSharedPreferences(Context context) {
if (context == null) return null;
return SharedPreferenceUtils.getPrivateSharedPreferences(context, TermuxConstants.TERMUX_DEFAULT_PREFERENCES_FILE_BASENAME_WITHOUT_EXTENSION);
}
public boolean shouldShowTerminalToolbar() {
return SharedPreferenceUtils.getBoolean(mSharedPreferences, TERMUX_APP.KEY_SHOW_TERMINAL_TOOLBAR, TERMUX_APP.DEFAULT_VALUE_SHOW_TERMINAL_TOOLBAR);
}
public void setShowTerminalToolbar(boolean value) {
SharedPreferenceUtils.setBoolean(mSharedPreferences, TERMUX_APP.KEY_SHOW_TERMINAL_TOOLBAR, value, false);
}
public boolean toogleShowTerminalToolbar() {
boolean currentValue = shouldShowTerminalToolbar();
setShowTerminalToolbar(!currentValue);
return !currentValue;
}
public boolean isTerminalMarginAdjustmentEnabled() {
return SharedPreferenceUtils.getBoolean(mSharedPreferences, TERMUX_APP.KEY_TERMINAL_MARGIN_ADJUSTMENT, TERMUX_APP.DEFAULT_TERMINAL_MARGIN_ADJUSTMENT);
}
public void setTerminalMarginAdjustment(boolean value) {
SharedPreferenceUtils.setBoolean(mSharedPreferences, TERMUX_APP.KEY_TERMINAL_MARGIN_ADJUSTMENT, value, false);
}
public boolean isSoftKeyboardEnabled() {
return SharedPreferenceUtils.getBoolean(mSharedPreferences, TERMUX_APP.KEY_SOFT_KEYBOARD_ENABLED, TERMUX_APP.DEFAULT_VALUE_KEY_SOFT_KEYBOARD_ENABLED);
}
public void setSoftKeyboardEnabled(boolean value) {
SharedPreferenceUtils.setBoolean(mSharedPreferences, TERMUX_APP.KEY_SOFT_KEYBOARD_ENABLED, value, false);
}
public boolean isSoftKeyboardEnabledOnlyIfNoHardware() {
return SharedPreferenceUtils.getBoolean(mSharedPreferences, TERMUX_APP.KEY_SOFT_KEYBOARD_ENABLED_ONLY_IF_NO_HARDWARE, TERMUX_APP.DEFAULT_VALUE_KEY_SOFT_KEYBOARD_ENABLED_ONLY_IF_NO_HARDWARE);
}
public void setSoftKeyboardEnabledOnlyIfNoHardware(boolean value) {
SharedPreferenceUtils.setBoolean(mSharedPreferences, TERMUX_APP.KEY_SOFT_KEYBOARD_ENABLED_ONLY_IF_NO_HARDWARE, value, false);
}
public boolean shouldKeepScreenOn() {
return SharedPreferenceUtils.getBoolean(mSharedPreferences, TERMUX_APP.KEY_KEEP_SCREEN_ON, TERMUX_APP.DEFAULT_VALUE_KEEP_SCREEN_ON);
}
public void setKeepScreenOn(boolean value) {
SharedPreferenceUtils.setBoolean(mSharedPreferences, TERMUX_APP.KEY_KEEP_SCREEN_ON, value, false);
}
public static int[] getDefaultFontSizes(Context context) {
float dipInPixels = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 1, context.getResources().getDisplayMetrics());
int[] sizes = new int[3];
// This is a bit arbitrary and sub-optimal. We want to give a sensible default for minimum font size
// to prevent invisible text due to zoom be mistake:
sizes[1] = (int) (4f * dipInPixels); // min
// http://www.google.com/design/spec/style/typography.html#typography-line-height
int defaultFontSize = Math.round(12 * dipInPixels);
// Make it divisible by 2 since that is the minimal adjustment step:
if (defaultFontSize % 2 == 1) defaultFontSize--;
sizes[0] = defaultFontSize; // default
sizes[2] = 256; // max
return sizes;
}
public void setFontVariables(Context context) {
int[] sizes = getDefaultFontSizes(context);
DEFAULT_FONTSIZE = sizes[0];
MIN_FONTSIZE = sizes[1];
MAX_FONTSIZE = sizes[2];
}
public int getFontSize() {
int fontSize = SharedPreferenceUtils.getIntStoredAsString(mSharedPreferences, TERMUX_APP.KEY_FONTSIZE, DEFAULT_FONTSIZE);
return DataUtils.clamp(fontSize, MIN_FONTSIZE, MAX_FONTSIZE);
}
public void setFontSize(int value) {
SharedPreferenceUtils.setIntStoredAsString(mSharedPreferences, TERMUX_APP.KEY_FONTSIZE, value, false);
}
public void changeFontSize(boolean increase) {
int fontSize = getFontSize();
fontSize += (increase ? 1 : -1) * 2;
fontSize = Math.max(MIN_FONTSIZE, Math.min(fontSize, MAX_FONTSIZE));
setFontSize(fontSize);
}
public String getCurrentSession() {
return SharedPreferenceUtils.getString(mSharedPreferences, TERMUX_APP.KEY_CURRENT_SESSION, null, true);
}
public void setCurrentSession(String value) {
SharedPreferenceUtils.setString(mSharedPreferences, TERMUX_APP.KEY_CURRENT_SESSION, value, false);
}
public int getLogLevel() {
return SharedPreferenceUtils.getInt(mSharedPreferences, TERMUX_APP.KEY_LOG_LEVEL, Logger.DEFAULT_LOG_LEVEL);
}
public void setLogLevel(Context context, int logLevel) {
logLevel = Logger.setLogLevel(context, logLevel);
SharedPreferenceUtils.setInt(mSharedPreferences, TERMUX_APP.KEY_LOG_LEVEL, logLevel, false);
}
public int getLastNotificationId() {
return SharedPreferenceUtils.getInt(mSharedPreferences, TERMUX_APP.KEY_LAST_NOTIFICATION_ID, TERMUX_APP.DEFAULT_VALUE_KEY_LAST_NOTIFICATION_ID);
}
public void setLastNotificationId(int notificationId) {
SharedPreferenceUtils.setInt(mSharedPreferences, TERMUX_APP.KEY_LAST_NOTIFICATION_ID, notificationId, false);
}
public boolean isTerminalViewKeyLoggingEnabled() {
return SharedPreferenceUtils.getBoolean(mSharedPreferences, TERMUX_APP.KEY_TERMINAL_VIEW_KEY_LOGGING_ENABLED, TERMUX_APP.DEFAULT_VALUE_TERMINAL_VIEW_KEY_LOGGING_ENABLED);
}
public void setTerminalViewKeyLoggingEnabled(boolean value) {
SharedPreferenceUtils.setBoolean(mSharedPreferences, TERMUX_APP.KEY_TERMINAL_VIEW_KEY_LOGGING_ENABLED, value, false);
}
public boolean arePluginErrorNotificationsEnabled() {
return SharedPreferenceUtils.getBoolean(mSharedPreferences, TERMUX_APP.KEY_PLUGIN_ERROR_NOTIFICATIONS_ENABLED, TERMUX_APP.DEFAULT_VALUE_PLUGIN_ERROR_NOTIFICATIONS_ENABLED);
}
public void setPluginErrorNotificationsEnabled(boolean value) {
SharedPreferenceUtils.setBoolean(mSharedPreferences, TERMUX_APP.KEY_PLUGIN_ERROR_NOTIFICATIONS_ENABLED, value, false);
}
public boolean areCrashReportNotificationsEnabled() {
return SharedPreferenceUtils.getBoolean(mSharedPreferences, TERMUX_APP.KEY_CRASH_REPORT_NOTIFICATIONS_ENABLED, TERMUX_APP.DEFAULT_VALUE_CRASH_REPORT_NOTIFICATIONS_ENABLED);
}
public void setCrashReportNotificationsEnabled(boolean value) {
SharedPreferenceUtils.setBoolean(mSharedPreferences, TERMUX_APP.KEY_CRASH_REPORT_NOTIFICATIONS_ENABLED, value, false);
}
}

View file

@ -0,0 +1,87 @@
package com.termux.shared.settings.preferences;
import android.app.Activity;
import android.content.Context;
import android.content.SharedPreferences;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.termux.shared.logger.Logger;
import com.termux.shared.packages.PackageUtils;
import com.termux.shared.settings.preferences.TermuxPreferenceConstants.TERMUX_BOOT_APP;
import com.termux.shared.termux.TermuxConstants;
public class TermuxBootAppSharedPreferences {
private final Context mContext;
private final SharedPreferences mSharedPreferences;
private final SharedPreferences mMultiProcessSharedPreferences;
private static final String LOG_TAG = "TermuxBootAppSharedPreferences";
private TermuxBootAppSharedPreferences(@NonNull Context context) {
mContext = context;
mSharedPreferences = getPrivateSharedPreferences(mContext);
mMultiProcessSharedPreferences = getPrivateAndMultiProcessSharedPreferences(mContext);
}
/**
* Get the {@link Context} for a package name.
*
* @param context The {@link Context} to use to get the {@link Context} of the
* {@link TermuxConstants#TERMUX_BOOT_PACKAGE_NAME}.
* @return Returns the {@link TermuxBootAppSharedPreferences}. This will {@code null} if an exception is raised.
*/
@Nullable
public static TermuxBootAppSharedPreferences build(@NonNull final Context context) {
Context termuxTaskerPackageContext = PackageUtils.getContextForPackage(context, TermuxConstants.TERMUX_BOOT_PACKAGE_NAME);
if (termuxTaskerPackageContext == null)
return null;
else
return new TermuxBootAppSharedPreferences(termuxTaskerPackageContext);
}
/**
* Get the {@link Context} for a package name.
*
* @param context The {@link Activity} to use to get the {@link Context} of the
* {@link TermuxConstants#TERMUX_BOOT_PACKAGE_NAME}.
* @param exitAppOnError If {@code true} and failed to get package context, then a dialog will
* be shown which when dismissed will exit the app.
* @return Returns the {@link TermuxBootAppSharedPreferences}. This will {@code null} if an exception is raised.
*/
public static TermuxBootAppSharedPreferences build(@NonNull final Context context, final boolean exitAppOnError) {
Context termuxTaskerPackageContext = PackageUtils.getContextForPackageOrExitApp(context, TermuxConstants.TERMUX_BOOT_PACKAGE_NAME, exitAppOnError);
if (termuxTaskerPackageContext == null)
return null;
else
return new TermuxBootAppSharedPreferences(termuxTaskerPackageContext);
}
private static SharedPreferences getPrivateSharedPreferences(Context context) {
if (context == null) return null;
return SharedPreferenceUtils.getPrivateSharedPreferences(context, TermuxConstants.TERMUX_BOOT_DEFAULT_PREFERENCES_FILE_BASENAME_WITHOUT_EXTENSION);
}
private static SharedPreferences getPrivateAndMultiProcessSharedPreferences(Context context) {
if (context == null) return null;
return SharedPreferenceUtils.getPrivateAndMultiProcessSharedPreferences(context, TermuxConstants.TERMUX_BOOT_DEFAULT_PREFERENCES_FILE_BASENAME_WITHOUT_EXTENSION);
}
public int getLogLevel(boolean readFromFile) {
if (readFromFile)
return SharedPreferenceUtils.getInt(mMultiProcessSharedPreferences, TERMUX_BOOT_APP.KEY_LOG_LEVEL, Logger.DEFAULT_LOG_LEVEL);
else
return SharedPreferenceUtils.getInt(mSharedPreferences, TERMUX_BOOT_APP.KEY_LOG_LEVEL, Logger.DEFAULT_LOG_LEVEL);
}
public void setLogLevel(Context context, int logLevel, boolean commitToFile) {
logLevel = Logger.setLogLevel(context, logLevel);
SharedPreferenceUtils.setInt(mSharedPreferences, TERMUX_BOOT_APP.KEY_LOG_LEVEL, logLevel, commitToFile);
}
}

View file

@ -0,0 +1,172 @@
package com.termux.shared.settings.preferences;
import android.app.Activity;
import android.content.Context;
import android.content.SharedPreferences;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.termux.shared.data.DataUtils;
import com.termux.shared.logger.Logger;
import com.termux.shared.packages.PackageUtils;
import com.termux.shared.settings.preferences.TermuxPreferenceConstants.TERMUX_FLOAT_APP;
import com.termux.shared.termux.TermuxConstants;
public class TermuxFloatAppSharedPreferences {
private final Context mContext;
private final SharedPreferences mSharedPreferences;
private final SharedPreferences mMultiProcessSharedPreferences;
private int MIN_FONTSIZE;
private int MAX_FONTSIZE;
private int DEFAULT_FONTSIZE;
private static final String LOG_TAG = "TermuxFloatAppSharedPreferences";
private TermuxFloatAppSharedPreferences(@NonNull Context context) {
mContext = context;
mSharedPreferences = getPrivateSharedPreferences(mContext);
mMultiProcessSharedPreferences = getPrivateAndMultiProcessSharedPreferences(mContext);
setFontVariables(context);
}
/**
* Get the {@link Context} for a package name.
*
* @param context The {@link Context} to use to get the {@link Context} of the
* {@link TermuxConstants#TERMUX_FLOAT_PACKAGE_NAME}.
* @return Returns the {@link TermuxFloatAppSharedPreferences}. This will {@code null} if an exception is raised.
*/
@Nullable
public static TermuxFloatAppSharedPreferences build(@NonNull final Context context) {
Context termuxFloatPackageContext = PackageUtils.getContextForPackage(context, TermuxConstants.TERMUX_FLOAT_PACKAGE_NAME);
if (termuxFloatPackageContext == null)
return null;
else
return new TermuxFloatAppSharedPreferences(termuxFloatPackageContext);
}
/**
* Get the {@link Context} for a package name.
*
* @param context The {@link Activity} to use to get the {@link Context} of the
* {@link TermuxConstants#TERMUX_FLOAT_PACKAGE_NAME}.
* @param exitAppOnError If {@code true} and failed to get package context, then a dialog will
* be shown which when dismissed will exit the app.
* @return Returns the {@link TermuxFloatAppSharedPreferences}. This will {@code null} if an exception is raised.
*/
public static TermuxFloatAppSharedPreferences build(@NonNull final Context context, final boolean exitAppOnError) {
Context termuxFloatPackageContext = PackageUtils.getContextForPackageOrExitApp(context, TermuxConstants.TERMUX_FLOAT_PACKAGE_NAME, exitAppOnError);
if (termuxFloatPackageContext == null)
return null;
else
return new TermuxFloatAppSharedPreferences(termuxFloatPackageContext);
}
private static SharedPreferences getPrivateSharedPreferences(Context context) {
if (context == null) return null;
return SharedPreferenceUtils.getPrivateSharedPreferences(context, TermuxConstants.TERMUX_FLOAT_DEFAULT_PREFERENCES_FILE_BASENAME_WITHOUT_EXTENSION);
}
private static SharedPreferences getPrivateAndMultiProcessSharedPreferences(Context context) {
if (context == null) return null;
return SharedPreferenceUtils.getPrivateAndMultiProcessSharedPreferences(context, TermuxConstants.TERMUX_FLOAT_DEFAULT_PREFERENCES_FILE_BASENAME_WITHOUT_EXTENSION);
}
public int getWindowX() {
return SharedPreferenceUtils.getInt(mSharedPreferences, TERMUX_FLOAT_APP.KEY_WINDOW_X, 200);
}
public void setWindowX(int value) {
SharedPreferenceUtils.setInt(mSharedPreferences, TERMUX_FLOAT_APP.KEY_WINDOW_X, value, false);
}
public int getWindowY() {
return SharedPreferenceUtils.getInt(mSharedPreferences, TERMUX_FLOAT_APP.KEY_WINDOW_Y, 200);
}
public void setWindowY(int value) {
SharedPreferenceUtils.setInt(mSharedPreferences, TERMUX_FLOAT_APP.KEY_WINDOW_Y, value, false);
}
public int getWindowWidth() {
return SharedPreferenceUtils.getInt(mSharedPreferences, TERMUX_FLOAT_APP.KEY_WINDOW_WIDTH, 500);
}
public void setWindowWidth(int value) {
SharedPreferenceUtils.setInt(mSharedPreferences, TERMUX_FLOAT_APP.KEY_WINDOW_WIDTH, value, false);
}
public int getWindowHeight() {
return SharedPreferenceUtils.getInt(mSharedPreferences, TERMUX_FLOAT_APP.KEY_WINDOW_HEIGHT, 500);
}
public void setWindowHeight(int value) {
SharedPreferenceUtils.setInt(mSharedPreferences, TERMUX_FLOAT_APP.KEY_WINDOW_HEIGHT, value, false);
}
public void setFontVariables(Context context) {
int[] sizes = TermuxAppSharedPreferences.getDefaultFontSizes(context);
DEFAULT_FONTSIZE = sizes[0];
MIN_FONTSIZE = sizes[1];
MAX_FONTSIZE = sizes[2];
}
public int getFontSize() {
int fontSize = SharedPreferenceUtils.getIntStoredAsString(mSharedPreferences, TERMUX_FLOAT_APP.KEY_FONTSIZE, DEFAULT_FONTSIZE);
return DataUtils.clamp(fontSize, MIN_FONTSIZE, MAX_FONTSIZE);
}
public void setFontSize(int value) {
SharedPreferenceUtils.setIntStoredAsString(mSharedPreferences, TERMUX_FLOAT_APP.KEY_FONTSIZE, value, false);
}
public void changeFontSize(boolean increase) {
int fontSize = getFontSize();
fontSize += (increase ? 1 : -1) * 2;
fontSize = Math.max(MIN_FONTSIZE, Math.min(fontSize, MAX_FONTSIZE));
setFontSize(fontSize);
}
public int getLogLevel(boolean readFromFile) {
if (readFromFile)
return SharedPreferenceUtils.getInt(mMultiProcessSharedPreferences, TERMUX_FLOAT_APP.KEY_LOG_LEVEL, Logger.DEFAULT_LOG_LEVEL);
else
return SharedPreferenceUtils.getInt(mSharedPreferences, TERMUX_FLOAT_APP.KEY_LOG_LEVEL, Logger.DEFAULT_LOG_LEVEL);
}
public void setLogLevel(Context context, int logLevel, boolean commitToFile) {
logLevel = Logger.setLogLevel(context, logLevel);
SharedPreferenceUtils.setInt(mSharedPreferences, TERMUX_FLOAT_APP.KEY_LOG_LEVEL, logLevel, commitToFile);
}
public boolean isTerminalViewKeyLoggingEnabled(boolean readFromFile) {
if (readFromFile)
return SharedPreferenceUtils.getBoolean(mMultiProcessSharedPreferences, TERMUX_FLOAT_APP.KEY_TERMINAL_VIEW_KEY_LOGGING_ENABLED, TERMUX_FLOAT_APP.DEFAULT_VALUE_TERMINAL_VIEW_KEY_LOGGING_ENABLED);
else
return SharedPreferenceUtils.getBoolean(mSharedPreferences, TERMUX_FLOAT_APP.KEY_TERMINAL_VIEW_KEY_LOGGING_ENABLED, TERMUX_FLOAT_APP.DEFAULT_VALUE_TERMINAL_VIEW_KEY_LOGGING_ENABLED);
}
public void setTerminalViewKeyLoggingEnabled(boolean value, boolean commitToFile) {
SharedPreferenceUtils.setBoolean(mSharedPreferences, TERMUX_FLOAT_APP.KEY_TERMINAL_VIEW_KEY_LOGGING_ENABLED, value, commitToFile);
}
}

View file

@ -0,0 +1,294 @@
package com.termux.shared.settings.preferences;
/*
* Version: v0.15.0
*
* Changelog
*
* - 0.1.0 (2021-03-12)
* - Initial Release.
*
* - 0.2.0 (2021-03-13)
* - Added `KEY_LOG_LEVEL` and `KEY_TERMINAL_VIEW_LOGGING_ENABLED`.
*
* - 0.3.0 (2021-03-16)
* - Changed to per app scoping of variables so that the same file can store all constants of
* Termux app and its plugins. This will allow {@link com.termux.app.TermuxSettings} to
* manage preferences of plugins as well if they don't have launcher activity themselves
* and also allow plugin apps to make changes to preferences from background.
* - Added following to `TERMUX_TASKER_APP`:
* `KEY_LOG_LEVEL`.
*
* - 0.4.0 (2021-03-13)
* - Added following to `TERMUX_APP`:
* `KEY_PLUGIN_ERROR_NOTIFICATIONS_ENABLED` and `DEFAULT_VALUE_PLUGIN_ERROR_NOTIFICATIONS_ENABLED`.
*
* - 0.5.0 (2021-03-24)
* - Added following to `TERMUX_APP`:
* `KEY_LAST_NOTIFICATION_ID` and `DEFAULT_VALUE_KEY_LAST_NOTIFICATION_ID`.
*
* - 0.6.0 (2021-03-24)
* - Change `DEFAULT_VALUE_KEEP_SCREEN_ON` value to `false` in `TERMUX_APP`.
*
* - 0.7.0 (2021-03-27)
* - Added following to `TERMUX_APP`:
* `KEY_SOFT_KEYBOARD_ENABLED` and `DEFAULT_VALUE_KEY_SOFT_KEYBOARD_ENABLED`.
*
* - 0.8.0 (2021-04-06)
* - Added following to `TERMUX_APP`:
* `KEY_CRASH_REPORT_NOTIFICATIONS_ENABLED` and `DEFAULT_VALUE_CRASH_REPORT_NOTIFICATIONS_ENABLED`.
*
* - 0.9.0 (2021-04-07)
* - Updated javadocs.
*
* - 0.10.0 (2021-05-12)
* - Added following to `TERMUX_APP`:
* `KEY_SOFT_KEYBOARD_ENABLED_ONLY_IF_NO_HARDWARE` and `DEFAULT_VALUE_KEY_SOFT_KEYBOARD_ENABLED_ONLY_IF_NO_HARDWARE`.
*
* - 0.11.0 (2021-07-08)
* - Added following to `TERMUX_APP`:
* `KEY_DISABLE_TERMINAL_MARGIN_ADJUSTMENT`.
*
* - 0.12.0 (2021-08-27)
* - Added `TERMUX_API_APP.KEY_LOG_LEVEL`, `TERMUX_BOOT_APP.KEY_LOG_LEVEL`,
* `TERMUX_FLOAT_APP.KEY_LOG_LEVEL`, `TERMUX_STYLING_APP.KEY_LOG_LEVEL`,
* `TERMUX_Widget_APP.KEY_LOG_LEVEL`.
*
* - 0.13.0 (2021-09-02)
* - Added following to `TERMUX_FLOAT_APP`:
* `KEY_WINDOW_X`, `KEY_WINDOW_Y`, `KEY_WINDOW_WIDTH`, `KEY_WINDOW_HEIGHT`, `KEY_FONTSIZE`,
* `KEY_TERMINAL_VIEW_KEY_LOGGING_ENABLED`.
*
* - 0.14.0 (2021-09-04)
* - Added `TERMUX_WIDGET_APP.KEY_TOKEN`.
*
* - 0.15.0 (2021-09-05)
* - Added following to `TERMUX_TASKER_APP`:
* `KEY_LAST_PENDING_INTENT_REQUEST_CODE` and `DEFAULT_VALUE_KEY_LAST_PENDING_INTENT_REQUEST_CODE`.
*/
/**
* A class that defines shared constants of the SharedPreferences used by 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.
*/
public final class TermuxPreferenceConstants {
/**
* Termux app constants.
*/
public static final class TERMUX_APP {
/**
* Defines the key for whether terminal view margin adjustment that is done to prevent soft
* keyboard from covering bottom part of terminal view on some devices is enabled or not.
* Margin adjustment may cause screen flickering on some devices and so should be disabled.
*/
public static final String KEY_TERMINAL_MARGIN_ADJUSTMENT = "terminal_margin_adjustment";
public static final boolean DEFAULT_TERMINAL_MARGIN_ADJUSTMENT = true;
/**
* Defines the key for whether to show terminal toolbar containing extra keys and text input field.
*/
public static final String KEY_SHOW_TERMINAL_TOOLBAR = "show_extra_keys";
public static final boolean DEFAULT_VALUE_SHOW_TERMINAL_TOOLBAR = true;
/**
* Defines the key for whether the soft keyboard will be enabled, for cases where users want
* to use a hardware keyboard instead.
*/
public static final String KEY_SOFT_KEYBOARD_ENABLED = "soft_keyboard_enabled";
public static final boolean DEFAULT_VALUE_KEY_SOFT_KEYBOARD_ENABLED = true;
/**
* Defines the key for whether the soft keyboard will be enabled only if no hardware keyboard
* attached, for cases where users want to use a hardware keyboard instead.
*/
public static final String KEY_SOFT_KEYBOARD_ENABLED_ONLY_IF_NO_HARDWARE = "soft_keyboard_enabled_only_if_no_hardware";
public static final boolean DEFAULT_VALUE_KEY_SOFT_KEYBOARD_ENABLED_ONLY_IF_NO_HARDWARE = false;
/**
* Defines the key for whether to always keep screen on.
*/
public static final String KEY_KEEP_SCREEN_ON = "screen_always_on";
public static final boolean DEFAULT_VALUE_KEEP_SCREEN_ON = false;
/**
* Defines the key for font size of termux terminal view.
*/
public static final String KEY_FONTSIZE = "fontsize";
/**
* Defines the key for current termux terminal session.
*/
public static final String KEY_CURRENT_SESSION = "current_session";
/**
* Defines the key for current log level.
*/
public static final String KEY_LOG_LEVEL = "log_level";
/**
* Defines the key for last used notification id.
*/
public static final String KEY_LAST_NOTIFICATION_ID = "last_notification_id";
public static final int DEFAULT_VALUE_KEY_LAST_NOTIFICATION_ID = 0;
/**
* Defines the key for whether termux terminal view key logging is enabled or not
*/
public static final String KEY_TERMINAL_VIEW_KEY_LOGGING_ENABLED = "terminal_view_key_logging_enabled";
public static final boolean DEFAULT_VALUE_TERMINAL_VIEW_KEY_LOGGING_ENABLED = false;
/**
* Defines the key for whether flashes and notifications for plugin errors are enabled or not.
*/
public static final String KEY_PLUGIN_ERROR_NOTIFICATIONS_ENABLED = "plugin_error_notifications_enabled";
public static final boolean DEFAULT_VALUE_PLUGIN_ERROR_NOTIFICATIONS_ENABLED = true;
/**
* Defines the key for whether notifications for crash reports are enabled or not.
*/
public static final String KEY_CRASH_REPORT_NOTIFICATIONS_ENABLED = "crash_report_notifications_enabled";
public static final boolean DEFAULT_VALUE_CRASH_REPORT_NOTIFICATIONS_ENABLED = true;
}
/**
* Termux:API app constants.
*/
public static final class TERMUX_API_APP {
/**
* Defines the key for current log level.
*/
public static final String KEY_LOG_LEVEL = "log_level";
}
/**
* Termux:Boot app constants.
*/
public static final class TERMUX_BOOT_APP {
/**
* Defines the key for current log level.
*/
public static final String KEY_LOG_LEVEL = "log_level";
}
/**
* Termux:Float app constants.
*/
public static final class TERMUX_FLOAT_APP {
/**
* The float window x coordinate.
*/
public static final String KEY_WINDOW_X = "window_x";
/**
* The float window y coordinate.
*/
public static final String KEY_WINDOW_Y = "window_y";
/**
* The float window width.
*/
public static final String KEY_WINDOW_WIDTH = "window_width";
/**
* The float window height.
*/
public static final String KEY_WINDOW_HEIGHT = "window_height";
/**
* Defines the key for font size of termux terminal view.
*/
public static final String KEY_FONTSIZE = "fontsize";
/**
* Defines the key for current log level.
*/
public static final String KEY_LOG_LEVEL = "log_level";
/**
* Defines the key for whether termux terminal view key logging is enabled or not
*/
public static final String KEY_TERMINAL_VIEW_KEY_LOGGING_ENABLED = "terminal_view_key_logging_enabled";
public static final boolean DEFAULT_VALUE_TERMINAL_VIEW_KEY_LOGGING_ENABLED = false;
}
/**
* Termux:Styling app constants.
*/
public static final class TERMUX_STYLING_APP {
/**
* Defines the key for current log level.
*/
public static final String KEY_LOG_LEVEL = "log_level";
}
/**
* Termux:Tasker app constants.
*/
public static final class TERMUX_TASKER_APP {
/**
* Defines the key for current log level.
*/
public static final String KEY_LOG_LEVEL = "log_level";
/**
* Defines the key for last used PendingIntent request code.
*/
public static final String KEY_LAST_PENDING_INTENT_REQUEST_CODE = "last_pending_intent_request_code";
public static final int DEFAULT_VALUE_KEY_LAST_PENDING_INTENT_REQUEST_CODE = 0;
}
/**
* Termux:Widget app constants.
*/
public static final class TERMUX_WIDGET_APP {
/**
* Defines the key for current log level.
*/
public static final String KEY_LOG_LEVEL = "log_level";
/**
* Defines the key for current token for shortcuts.
*/
public static final String KEY_TOKEN = "token";
}
}

View file

@ -0,0 +1,87 @@
package com.termux.shared.settings.preferences;
import android.app.Activity;
import android.content.Context;
import android.content.SharedPreferences;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.termux.shared.logger.Logger;
import com.termux.shared.packages.PackageUtils;
import com.termux.shared.settings.preferences.TermuxPreferenceConstants.TERMUX_STYLING_APP;
import com.termux.shared.termux.TermuxConstants;
public class TermuxStylingAppSharedPreferences {
private final Context mContext;
private final SharedPreferences mSharedPreferences;
private final SharedPreferences mMultiProcessSharedPreferences;
private static final String LOG_TAG = "TermuxStylingAppSharedPreferences";
private TermuxStylingAppSharedPreferences(@NonNull Context context) {
mContext = context;
mSharedPreferences = getPrivateSharedPreferences(mContext);
mMultiProcessSharedPreferences = getPrivateAndMultiProcessSharedPreferences(mContext);
}
/**
* Get the {@link Context} for a package name.
*
* @param context The {@link Context} to use to get the {@link Context} of the
* {@link TermuxConstants#TERMUX_STYLING_PACKAGE_NAME}.
* @return Returns the {@link TermuxStylingAppSharedPreferences}. This will {@code null} if an exception is raised.
*/
@Nullable
public static TermuxStylingAppSharedPreferences build(@NonNull final Context context) {
Context termuxTaskerPackageContext = PackageUtils.getContextForPackage(context, TermuxConstants.TERMUX_STYLING_PACKAGE_NAME);
if (termuxTaskerPackageContext == null)
return null;
else
return new TermuxStylingAppSharedPreferences(termuxTaskerPackageContext);
}
/**
* Get the {@link Context} for a package name.
*
* @param context The {@link Activity} to use to get the {@link Context} of the
* {@link TermuxConstants#TERMUX_STYLING_PACKAGE_NAME}.
* @param exitAppOnError If {@code true} and failed to get package context, then a dialog will
* be shown which when dismissed will exit the app.
* @return Returns the {@link TermuxStylingAppSharedPreferences}. This will {@code null} if an exception is raised.
*/
public static TermuxStylingAppSharedPreferences build(@NonNull final Context context, final boolean exitAppOnError) {
Context termuxTaskerPackageContext = PackageUtils.getContextForPackageOrExitApp(context, TermuxConstants.TERMUX_STYLING_PACKAGE_NAME, exitAppOnError);
if (termuxTaskerPackageContext == null)
return null;
else
return new TermuxStylingAppSharedPreferences(termuxTaskerPackageContext);
}
private static SharedPreferences getPrivateSharedPreferences(Context context) {
if (context == null) return null;
return SharedPreferenceUtils.getPrivateSharedPreferences(context, TermuxConstants.TERMUX_STYLING_DEFAULT_PREFERENCES_FILE_BASENAME_WITHOUT_EXTENSION);
}
private static SharedPreferences getPrivateAndMultiProcessSharedPreferences(Context context) {
if (context == null) return null;
return SharedPreferenceUtils.getPrivateAndMultiProcessSharedPreferences(context, TermuxConstants.TERMUX_STYLING_DEFAULT_PREFERENCES_FILE_BASENAME_WITHOUT_EXTENSION);
}
public int getLogLevel(boolean readFromFile) {
if (readFromFile)
return SharedPreferenceUtils.getInt(mMultiProcessSharedPreferences, TERMUX_STYLING_APP.KEY_LOG_LEVEL, Logger.DEFAULT_LOG_LEVEL);
else
return SharedPreferenceUtils.getInt(mSharedPreferences, TERMUX_STYLING_APP.KEY_LOG_LEVEL, Logger.DEFAULT_LOG_LEVEL);
}
public void setLogLevel(Context context, int logLevel, boolean commitToFile) {
logLevel = Logger.setLogLevel(context, logLevel);
SharedPreferenceUtils.setInt(mSharedPreferences, TERMUX_STYLING_APP.KEY_LOG_LEVEL, logLevel, commitToFile);
}
}

View file

@ -0,0 +1,97 @@
package com.termux.shared.settings.preferences;
import android.app.Activity;
import android.content.Context;
import android.content.SharedPreferences;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.termux.shared.packages.PackageUtils;
import com.termux.shared.termux.TermuxConstants;
import com.termux.shared.settings.preferences.TermuxPreferenceConstants.TERMUX_TASKER_APP;
import com.termux.shared.logger.Logger;
public class TermuxTaskerAppSharedPreferences {
private final Context mContext;
private final SharedPreferences mSharedPreferences;
private final SharedPreferences mMultiProcessSharedPreferences;
private static final String LOG_TAG = "TermuxTaskerAppSharedPreferences";
private TermuxTaskerAppSharedPreferences(@NonNull Context context) {
mContext = context;
mSharedPreferences = getPrivateSharedPreferences(mContext);
mMultiProcessSharedPreferences = getPrivateAndMultiProcessSharedPreferences(mContext);
}
/**
* Get the {@link Context} for a package name.
*
* @param context The {@link Context} to use to get the {@link Context} of the
* {@link TermuxConstants#TERMUX_TASKER_PACKAGE_NAME}.
* @return Returns the {@link TermuxTaskerAppSharedPreferences}. This will {@code null} if an exception is raised.
*/
@Nullable
public static TermuxTaskerAppSharedPreferences build(@NonNull final Context context) {
Context termuxTaskerPackageContext = PackageUtils.getContextForPackage(context, TermuxConstants.TERMUX_TASKER_PACKAGE_NAME);
if (termuxTaskerPackageContext == null)
return null;
else
return new TermuxTaskerAppSharedPreferences(termuxTaskerPackageContext);
}
/**
* Get the {@link Context} for a package name.
*
* @param context The {@link Activity} to use to get the {@link Context} of the
* {@link TermuxConstants#TERMUX_TASKER_PACKAGE_NAME}.
* @param exitAppOnError If {@code true} and failed to get package context, then a dialog will
* be shown which when dismissed will exit the app.
* @return Returns the {@link TermuxTaskerAppSharedPreferences}. This will {@code null} if an exception is raised.
*/
public static TermuxTaskerAppSharedPreferences build(@NonNull final Context context, final boolean exitAppOnError) {
Context termuxTaskerPackageContext = PackageUtils.getContextForPackageOrExitApp(context, TermuxConstants.TERMUX_TASKER_PACKAGE_NAME, exitAppOnError);
if (termuxTaskerPackageContext == null)
return null;
else
return new TermuxTaskerAppSharedPreferences(termuxTaskerPackageContext);
}
private static SharedPreferences getPrivateSharedPreferences(Context context) {
if (context == null) return null;
return SharedPreferenceUtils.getPrivateSharedPreferences(context, TermuxConstants.TERMUX_TASKER_DEFAULT_PREFERENCES_FILE_BASENAME_WITHOUT_EXTENSION);
}
private static SharedPreferences getPrivateAndMultiProcessSharedPreferences(Context context) {
if (context == null) return null;
return SharedPreferenceUtils.getPrivateAndMultiProcessSharedPreferences(context, TermuxConstants.TERMUX_TASKER_DEFAULT_PREFERENCES_FILE_BASENAME_WITHOUT_EXTENSION);
}
public int getLogLevel(boolean readFromFile) {
if (readFromFile)
return SharedPreferenceUtils.getInt(mMultiProcessSharedPreferences, TERMUX_TASKER_APP.KEY_LOG_LEVEL, Logger.DEFAULT_LOG_LEVEL);
else
return SharedPreferenceUtils.getInt(mSharedPreferences, TERMUX_TASKER_APP.KEY_LOG_LEVEL, Logger.DEFAULT_LOG_LEVEL);
}
public void setLogLevel(Context context, int logLevel, boolean commitToFile) {
logLevel = Logger.setLogLevel(context, logLevel);
SharedPreferenceUtils.setInt(mSharedPreferences, TERMUX_TASKER_APP.KEY_LOG_LEVEL, logLevel, commitToFile);
}
public int getLastPendingIntentRequestCode() {
return SharedPreferenceUtils.getInt(mSharedPreferences, TERMUX_TASKER_APP.KEY_LAST_PENDING_INTENT_REQUEST_CODE, TERMUX_TASKER_APP.DEFAULT_VALUE_KEY_LAST_PENDING_INTENT_REQUEST_CODE);
}
public void setLastPendingIntentRequestCode(int lastPendingIntentRequestCode) {
SharedPreferenceUtils.setInt(mSharedPreferences, TERMUX_TASKER_APP.KEY_LAST_PENDING_INTENT_REQUEST_CODE, lastPendingIntentRequestCode, false);
}
}

View file

@ -0,0 +1,106 @@
package com.termux.shared.settings.preferences;
import android.app.Activity;
import android.content.Context;
import android.content.SharedPreferences;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.termux.shared.logger.Logger;
import com.termux.shared.packages.PackageUtils;
import com.termux.shared.settings.preferences.TermuxPreferenceConstants.TERMUX_WIDGET_APP;
import com.termux.shared.termux.TermuxConstants;
import java.util.UUID;
public class TermuxWidgetAppSharedPreferences {
private final Context mContext;
private final SharedPreferences mSharedPreferences;
private final SharedPreferences mMultiProcessSharedPreferences;
private static final String LOG_TAG = "TermuxWidgetAppSharedPreferences";
private TermuxWidgetAppSharedPreferences(@NonNull Context context) {
mContext = context;
mSharedPreferences = getPrivateSharedPreferences(mContext);
mMultiProcessSharedPreferences = getPrivateAndMultiProcessSharedPreferences(mContext);
}
/**
* Get the {@link Context} for a package name.
*
* @param context The {@link Context} to use to get the {@link Context} of the
* {@link TermuxConstants#TERMUX_WIDGET_PACKAGE_NAME}.
* @return Returns the {@link TermuxWidgetAppSharedPreferences}. This will {@code null} if an exception is raised.
*/
@Nullable
public static TermuxWidgetAppSharedPreferences build(@NonNull final Context context) {
Context termuxTaskerPackageContext = PackageUtils.getContextForPackage(context, TermuxConstants.TERMUX_WIDGET_PACKAGE_NAME);
if (termuxTaskerPackageContext == null)
return null;
else
return new TermuxWidgetAppSharedPreferences(termuxTaskerPackageContext);
}
/**
* Get the {@link Context} for a package name.
*
* @param context The {@link Activity} to use to get the {@link Context} of the
* {@link TermuxConstants#TERMUX_WIDGET_PACKAGE_NAME}.
* @param exitAppOnError If {@code true} and failed to get package context, then a dialog will
* be shown which when dismissed will exit the app.
* @return Returns the {@link TermuxWidgetAppSharedPreferences}. This will {@code null} if an exception is raised.
*/
public static TermuxWidgetAppSharedPreferences build(@NonNull final Context context, final boolean exitAppOnError) {
Context termuxTaskerPackageContext = PackageUtils.getContextForPackageOrExitApp(context, TermuxConstants.TERMUX_WIDGET_PACKAGE_NAME, exitAppOnError);
if (termuxTaskerPackageContext == null)
return null;
else
return new TermuxWidgetAppSharedPreferences(termuxTaskerPackageContext);
}
private static SharedPreferences getPrivateSharedPreferences(Context context) {
if (context == null) return null;
return SharedPreferenceUtils.getPrivateSharedPreferences(context, TermuxConstants.TERMUX_WIDGET_DEFAULT_PREFERENCES_FILE_BASENAME_WITHOUT_EXTENSION);
}
private static SharedPreferences getPrivateAndMultiProcessSharedPreferences(Context context) {
if (context == null) return null;
return SharedPreferenceUtils.getPrivateAndMultiProcessSharedPreferences(context, TermuxConstants.TERMUX_WIDGET_DEFAULT_PREFERENCES_FILE_BASENAME_WITHOUT_EXTENSION);
}
public static String getGeneratedToken(@NonNull Context context) {
TermuxWidgetAppSharedPreferences preferences = TermuxWidgetAppSharedPreferences.build(context, true);
if (preferences == null) return null;
return preferences.getGeneratedToken();
}
public String getGeneratedToken() {
String token = SharedPreferenceUtils.getString(mSharedPreferences, TERMUX_WIDGET_APP.KEY_TOKEN, null, true);
if (token == null) {
token = UUID.randomUUID().toString();
SharedPreferenceUtils.setString(mSharedPreferences, TERMUX_WIDGET_APP.KEY_TOKEN, token, true);
}
return token;
}
public int getLogLevel(boolean readFromFile) {
if (readFromFile)
return SharedPreferenceUtils.getInt(mMultiProcessSharedPreferences, TERMUX_WIDGET_APP.KEY_LOG_LEVEL, Logger.DEFAULT_LOG_LEVEL);
else
return SharedPreferenceUtils.getInt(mSharedPreferences, TERMUX_WIDGET_APP.KEY_LOG_LEVEL, Logger.DEFAULT_LOG_LEVEL);
}
public void setLogLevel(Context context, int logLevel, boolean commitToFile) {
logLevel = Logger.setLogLevel(context, logLevel);
SharedPreferenceUtils.setInt(mSharedPreferences, TERMUX_WIDGET_APP.KEY_LOG_LEVEL, logLevel, commitToFile);
}
}

View file

@ -0,0 +1,577 @@
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.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.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<String, Object> mMap;
private final Context mContext;
private final File mPropertiesFile;
private final Set<String> 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<String, Boolean> MAP_GENERIC_BOOLEAN =
new ImmutableBiMap.Builder<String, Boolean>()
.put("true", true)
.put("false", false)
.build();
/** Defines the bidirectional map for inverted boolean values and their internal values */
public static final ImmutableBiMap<String, Boolean> MAP_GENERIC_INVERTED_BOOLEAN =
new ImmutableBiMap.Builder<String, Boolean>()
.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<String>} 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<String> propertiesList, @NonNull SharedPropertiesParser sharedPropertiesParser) {
mContext = context;
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<String, Object> map = new HashMap<>();
Properties newProperties = new Properties();
Set<String> 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);
}
}
}
/**
* 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<String, Object> 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) {
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;
}
return properties;
}
/**
* 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)} call.
* @param propertiesFile The {@link File} to read the {@link Properties} from.
* @param key The key to read.
* @param def The default value.
* @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) {
return (String) getDefaultIfNull(getDefaultIfNull(getPropertiesFromFile(context, propertiesFile), 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)} 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), 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);
}
/**
* 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)}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.
* @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) {
return (boolean) getBooleanValueForStringValue(key, (String) getProperty(context, propertiesFile, key, null), false, logErrorOnInvalidValue, LOG_TAG);
}
/**
* 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)} 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.
* @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) {
return (boolean) getInvertedBooleanValueForStringValue(key, (String) getProperty(context, propertiesFile, key, null), 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<String, Object> 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<String, Object> getMapCopy(Map<String, Object> 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.
* @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> 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();
}
}

View file

@ -0,0 +1,23 @@
package com.termux.shared.settings.properties;
import android.content.Context;
import java.util.HashMap;
/**
* An interface that must be defined by the caller of the {@link SharedProperties} class.
*/
public interface SharedPropertiesParser {
/**
* 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(Context context, String key, String value);
}

View file

@ -0,0 +1,462 @@
package com.termux.shared.settings.properties;
import androidx.annotation.NonNull;
import com.google.common.collect.ImmutableBiMap;
import com.termux.shared.termux.TermuxConstants;
import com.termux.shared.logger.Logger;
import com.termux.terminal.TerminalEmulator;
import com.termux.view.TerminalView;
import java.io.File;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Set;
/*
* Version: v0.15.0
*
* Changelog
*
* - 0.1.0 (2021-03-11)
* - Initial Release.
*
* - 0.2.0 (2021-03-11)
* - Renamed `HOME_PATH` to `TERMUX_HOME_DIR_PATH`.
* - Renamed `TERMUX_PROPERTIES_PRIMARY_PATH` to `TERMUX_PROPERTIES_PRIMARY_FILE_PATH`.
* - Renamed `TERMUX_PROPERTIES_SECONDARY_FILE_PATH` to `TERMUX_PROPERTIES_SECONDARY_FILE_PATH`.
*
* - 0.3.0 (2021-03-16)
* - Add `*TERMINAL_TOOLBAR_HEIGHT_SCALE_FACTOR*`.
*
* - 0.4.0 (2021-03-16)
* - Removed `MAP_GENERIC_BOOLEAN` and `MAP_GENERIC_INVERTED_BOOLEAN`.
*
* - 0.5.0 (2021-03-25)
* - Add `KEY_HIDE_SOFT_KEYBOARD_ON_STARTUP`.
*
* - 0.6.0 (2021-04-07)
* - Updated javadocs.
*
* - 0.7.0 (2021-05-09)
* - Add `*SOFT_KEYBOARD_TOGGLE_BEHAVIOUR*`.
*
* - 0.8.0 (2021-05-10)
* - Change the `KEY_USE_BACK_KEY_AS_ESCAPE_KEY` and `KEY_VIRTUAL_VOLUME_KEYS_DISABLED` booleans
* to `KEY_BACK_KEY_BEHAVIOUR` and `KEY_VOLUME_KEYS_BEHAVIOUR` String internal values.
* - Renamed `SOFT_KEYBOARD_TOGGLE_BEHAVIOUR` to `KEY_SOFT_KEYBOARD_TOGGLE_BEHAVIOUR`.
*
* - 0.9.0 (2021-05-14)
* - Add `*KEY_TERMINAL_CURSOR_BLINK_RATE*`.
*
* - 0.10.0 (2021-05-15)
* - Add `MAP_BACK_KEY_BEHAVIOUR`, `MAP_SOFT_KEYBOARD_TOGGLE_BEHAVIOUR`, `MAP_VOLUME_KEYS_BEHAVIOUR`.
*
* - 0.11.0 (2021-06-10)
* - Add `*KEY_TERMINAL_TRANSCRIPT_ROWS*`.
*
* - 0.12.0 (2021-06-10)
* - Add `*KEY_TERMINAL_CURSOR_STYLE*`.
*
* - 0.13.0 (2021-08-25)
* - Add `*KEY_TERMINAL_MARGIN_HORIZONTAL*` and `*KEY_TERMINAL_MARGIN_VERTICAL*`.
*
* - 0.14.0 (2021-09-02)
* - Add `getTermuxFloatPropertiesFile()`.
*
* - 0.15.0 (2021-09-05)
* - Add `KEY_EXTRA_KEYS_TEXT_ALL_CAPS`.
*/
/**
* A class that defines shared constants of the SharedProperties used by 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.
*
* The properties are loaded from the first file found at
* {@link TermuxConstants#TERMUX_PROPERTIES_PRIMARY_FILE_PATH} or
* {@link TermuxConstants#TERMUX_PROPERTIES_SECONDARY_FILE_PATH}
*/
public final class TermuxPropertyConstants {
/* boolean */
/** Defines the key for whether hardware keyboard shortcuts are enabled. */
public static final String KEY_DISABLE_HARDWARE_KEYBOARD_SHORTCUTS = "disable-hardware-keyboard-shortcuts"; // Default: "disable-hardware-keyboard-shortcuts"
/** Defines the key for whether a toast will be shown when user changes the terminal session */
public static final String KEY_DISABLE_TERMINAL_SESSION_CHANGE_TOAST = "disable-terminal-session-change-toast"; // Default: "disable-terminal-session-change-toast"
/** Defines the key for whether to enforce character based input to fix the issue where for some devices like Samsung, the letters might not appear until enter is pressed */
public static final String KEY_ENFORCE_CHAR_BASED_INPUT = "enforce-char-based-input"; // Default: "enforce-char-based-input"
/** Defines the key for whether text for the extra keys buttons should be all capitalized automatically */
public static final String KEY_EXTRA_KEYS_TEXT_ALL_CAPS = "extra-keys-text-all-caps"; // Default: "extra-keys-text-all-caps"
/** Defines the key for whether to hide soft keyboard when termux app is started */
public static final String KEY_HIDE_SOFT_KEYBOARD_ON_STARTUP = "hide-soft-keyboard-on-startup"; // Default: "hide-soft-keyboard-on-startup"
/** Defines the key for whether url links in terminal transcript will automatically open on click or on tap */
public static final String KEY_TERMINAL_ONCLICK_URL_OPEN = "terminal-onclick-url-open"; // Default: "terminal-onclick-url-open"
/** Defines the key for whether to use black UI */
public static final String KEY_USE_BLACK_UI = "use-black-ui"; // Default: "use-black-ui"
/** Defines the key for whether to use ctrl space workaround to fix the issue where ctrl+space does not work on some ROMs */
public static final String KEY_USE_CTRL_SPACE_WORKAROUND = "ctrl-space-workaround"; // Default: "ctrl-space-workaround"
/** Defines the key for whether to use fullscreen */
public static final String KEY_USE_FULLSCREEN = "fullscreen"; // Default: "fullscreen"
/** Defines the key for whether to use fullscreen workaround */
public static final String KEY_USE_FULLSCREEN_WORKAROUND = "use-fullscreen-workaround"; // Default: "use-fullscreen-workaround"
/* int */
/** Defines the key for the bell behaviour */
public static final String KEY_BELL_BEHAVIOUR = "bell-character"; // Default: "bell-character"
public static final String VALUE_BELL_BEHAVIOUR_VIBRATE = "vibrate";
public static final String VALUE_BELL_BEHAVIOUR_BEEP = "beep";
public static final String VALUE_BELL_BEHAVIOUR_IGNORE = "ignore";
public static final String DEFAULT_VALUE_BELL_BEHAVIOUR = VALUE_BELL_BEHAVIOUR_VIBRATE;
public static final int IVALUE_BELL_BEHAVIOUR_VIBRATE = 1;
public static final int IVALUE_BELL_BEHAVIOUR_BEEP = 2;
public static final int IVALUE_BELL_BEHAVIOUR_IGNORE = 3;
public static final int DEFAULT_IVALUE_BELL_BEHAVIOUR = IVALUE_BELL_BEHAVIOUR_VIBRATE;
/** Defines the bidirectional map for bell behaviour values and their internal values */
public static final ImmutableBiMap<String, Integer> MAP_BELL_BEHAVIOUR =
new ImmutableBiMap.Builder<String, Integer>()
.put(VALUE_BELL_BEHAVIOUR_VIBRATE, IVALUE_BELL_BEHAVIOUR_VIBRATE)
.put(VALUE_BELL_BEHAVIOUR_BEEP, IVALUE_BELL_BEHAVIOUR_BEEP)
.put(VALUE_BELL_BEHAVIOUR_IGNORE, IVALUE_BELL_BEHAVIOUR_IGNORE)
.build();
/** Defines the key for the terminal cursor blink rate */
public static final String KEY_TERMINAL_CURSOR_BLINK_RATE = "terminal-cursor-blink-rate"; // Default: "terminal-cursor-blink-rate"
public static final int IVALUE_TERMINAL_CURSOR_BLINK_RATE_MIN = TerminalView.TERMINAL_CURSOR_BLINK_RATE_MIN;
public static final int IVALUE_TERMINAL_CURSOR_BLINK_RATE_MAX = TerminalView.TERMINAL_CURSOR_BLINK_RATE_MAX;
public static final int DEFAULT_IVALUE_TERMINAL_CURSOR_BLINK_RATE = 0;
/** Defines the key for the terminal cursor style */
public static final String KEY_TERMINAL_CURSOR_STYLE = "terminal-cursor-style"; // Default: "terminal-cursor-style"
public static final String VALUE_TERMINAL_CURSOR_STYLE_BLOCK = "block";
public static final String VALUE_TERMINAL_CURSOR_STYLE_UNDERLINE = "underline";
public static final String VALUE_TERMINAL_CURSOR_STYLE_BAR = "bar";
public static final int IVALUE_TERMINAL_CURSOR_STYLE_BLOCK = TerminalEmulator.TERMINAL_CURSOR_STYLE_BLOCK;
public static final int IVALUE_TERMINAL_CURSOR_STYLE_UNDERLINE = TerminalEmulator.TERMINAL_CURSOR_STYLE_UNDERLINE;
public static final int IVALUE_TERMINAL_CURSOR_STYLE_BAR = TerminalEmulator.TERMINAL_CURSOR_STYLE_BAR;
public static final int DEFAULT_IVALUE_TERMINAL_CURSOR_STYLE = TerminalEmulator.DEFAULT_TERMINAL_CURSOR_STYLE;
/** Defines the bidirectional map for terminal cursor styles and their internal values */
public static final ImmutableBiMap<String, Integer> MAP_TERMINAL_CURSOR_STYLE =
new ImmutableBiMap.Builder<String, Integer>()
.put(VALUE_TERMINAL_CURSOR_STYLE_BLOCK, IVALUE_TERMINAL_CURSOR_STYLE_BLOCK)
.put(VALUE_TERMINAL_CURSOR_STYLE_UNDERLINE, IVALUE_TERMINAL_CURSOR_STYLE_UNDERLINE)
.put(VALUE_TERMINAL_CURSOR_STYLE_BAR, IVALUE_TERMINAL_CURSOR_STYLE_BAR)
.build();
/** Defines the key for the terminal margin on left and right in dp units */
public static final String KEY_TERMINAL_MARGIN_HORIZONTAL = "terminal-margin-horizontal"; // Default: "terminal-margin-horizontal"
public static final int IVALUE_TERMINAL_MARGIN_HORIZONTAL_MIN = 0;
public static final int IVALUE_TERMINAL_MARGIN_HORIZONTAL_MAX = 100;
public static final int DEFAULT_IVALUE_TERMINAL_HORIZONTAL_MARGIN = 3;
/** Defines the key for the terminal margin on top and bottom in dp units */
public static final String KEY_TERMINAL_MARGIN_VERTICAL = "terminal-margin-vertical"; // Default: "terminal-margin-vertical"
public static final int IVALUE_TERMINAL_MARGIN_VERTICAL_MIN = 0;
public static final int IVALUE_TERMINAL_MARGIN_VERTICAL_MAX = 100;
public static final int DEFAULT_IVALUE_TERMINAL_VERTICAL_MARGIN = 0;
/** Defines the key for the terminal transcript rows */
public static final String KEY_TERMINAL_TRANSCRIPT_ROWS = "terminal-transcript-rows"; // Default: "terminal-transcript-rows"
public static final int IVALUE_TERMINAL_TRANSCRIPT_ROWS_MIN = TerminalEmulator.TERMINAL_TRANSCRIPT_ROWS_MIN;
public static final int IVALUE_TERMINAL_TRANSCRIPT_ROWS_MAX = TerminalEmulator.TERMINAL_TRANSCRIPT_ROWS_MAX;
public static final int DEFAULT_IVALUE_TERMINAL_TRANSCRIPT_ROWS = TerminalEmulator.DEFAULT_TERMINAL_TRANSCRIPT_ROWS;
/* float */
/** Defines the key for the terminal toolbar height */
public static final String KEY_TERMINAL_TOOLBAR_HEIGHT_SCALE_FACTOR = "terminal-toolbar-height"; // Default: "terminal-toolbar-height"
public static final float IVALUE_TERMINAL_TOOLBAR_HEIGHT_SCALE_FACTOR_MIN = 0.4f;
public static final float IVALUE_TERMINAL_TOOLBAR_HEIGHT_SCALE_FACTOR_MAX = 3;
public static final float DEFAULT_IVALUE_TERMINAL_TOOLBAR_HEIGHT_SCALE_FACTOR = 1;
/* Integer */
/** Defines the key for create session shortcut */
public static final String KEY_SHORTCUT_CREATE_SESSION = "shortcut.create-session"; // Default: "shortcut.create-session"
/** Defines the key for next session shortcut */
public static final String KEY_SHORTCUT_NEXT_SESSION = "shortcut.next-session"; // Default: "shortcut.next-session"
/** Defines the key for previous session shortcut */
public static final String KEY_SHORTCUT_PREVIOUS_SESSION = "shortcut.previous-session"; // Default: "shortcut.previous-session"
/** Defines the key for rename session shortcut */
public static final String KEY_SHORTCUT_RENAME_SESSION = "shortcut.rename-session"; // Default: "shortcut.rename-session"
public static final int ACTION_SHORTCUT_CREATE_SESSION = 1;
public static final int ACTION_SHORTCUT_NEXT_SESSION = 2;
public static final int ACTION_SHORTCUT_PREVIOUS_SESSION = 3;
public static final int ACTION_SHORTCUT_RENAME_SESSION = 4;
/** Defines the bidirectional map for session shortcut values and their internal actions */
public static final ImmutableBiMap<String, Integer> MAP_SESSION_SHORTCUTS =
new ImmutableBiMap.Builder<String, Integer>()
.put(KEY_SHORTCUT_CREATE_SESSION, ACTION_SHORTCUT_CREATE_SESSION)
.put(KEY_SHORTCUT_NEXT_SESSION, ACTION_SHORTCUT_NEXT_SESSION)
.put(KEY_SHORTCUT_PREVIOUS_SESSION, ACTION_SHORTCUT_PREVIOUS_SESSION)
.put(KEY_SHORTCUT_RENAME_SESSION, ACTION_SHORTCUT_RENAME_SESSION)
.build();
/* String */
/** Defines the key for whether back key will behave as escape key or literal back key */
public static final String KEY_BACK_KEY_BEHAVIOUR = "back-key"; // Default: "back-key"
public static final String IVALUE_BACK_KEY_BEHAVIOUR_BACK = "back";
public static final String IVALUE_BACK_KEY_BEHAVIOUR_ESCAPE = "escape";
public static final String DEFAULT_IVALUE_BACK_KEY_BEHAVIOUR = IVALUE_BACK_KEY_BEHAVIOUR_BACK;
/** Defines the bidirectional map for back key behaviour values and their internal values */
public static final ImmutableBiMap<String, String> MAP_BACK_KEY_BEHAVIOUR =
new ImmutableBiMap.Builder<String, String>()
.put(IVALUE_BACK_KEY_BEHAVIOUR_BACK, IVALUE_BACK_KEY_BEHAVIOUR_BACK)
.put(IVALUE_BACK_KEY_BEHAVIOUR_ESCAPE, IVALUE_BACK_KEY_BEHAVIOUR_ESCAPE)
.build();
/** Defines the key for the default working directory */
public static final String KEY_DEFAULT_WORKING_DIRECTORY = "default-working-directory"; // Default: "default-working-directory"
/** Defines the default working directory */
public static final String DEFAULT_IVALUE_DEFAULT_WORKING_DIRECTORY = TermuxConstants.TERMUX_HOME_DIR_PATH;
/** Defines the key for extra keys */
public static final String KEY_EXTRA_KEYS = "extra-keys"; // Default: "extra-keys"
//public static final String DEFAULT_IVALUE_EXTRA_KEYS = "[[ESC, TAB, CTRL, ALT, {key: '-', popup: '|'}, DOWN, UP]]"; // Single row
public static final String DEFAULT_IVALUE_EXTRA_KEYS = "[['ESC','/',{key: '-', popup: '|'},'HOME','UP','END','PGUP'], ['TAB','CTRL','ALT','LEFT','DOWN','RIGHT','PGDN']]"; // Double row
/** Defines the key for extra keys style */
public static final String KEY_EXTRA_KEYS_STYLE = "extra-keys-style"; // Default: "extra-keys-style"
public static final String DEFAULT_IVALUE_EXTRA_KEYS_STYLE = "default";
/** Defines the key for whether toggle soft keyboard request will show/hide or enable/disable keyboard */
public static final String KEY_SOFT_KEYBOARD_TOGGLE_BEHAVIOUR = "soft-keyboard-toggle-behaviour"; // Default: "soft-keyboard-toggle-behaviour"
public static final String IVALUE_SOFT_KEYBOARD_TOGGLE_BEHAVIOUR_SHOW_HIDE = "show/hide";
public static final String IVALUE_SOFT_KEYBOARD_TOGGLE_BEHAVIOUR_ENABLE_DISABLE = "enable/disable";
public static final String DEFAULT_IVALUE_SOFT_KEYBOARD_TOGGLE_BEHAVIOUR = IVALUE_SOFT_KEYBOARD_TOGGLE_BEHAVIOUR_SHOW_HIDE;
/** Defines the bidirectional map for toggle soft keyboard behaviour values and their internal values */
public static final ImmutableBiMap<String, String> MAP_SOFT_KEYBOARD_TOGGLE_BEHAVIOUR =
new ImmutableBiMap.Builder<String, String>()
.put(IVALUE_SOFT_KEYBOARD_TOGGLE_BEHAVIOUR_SHOW_HIDE, IVALUE_SOFT_KEYBOARD_TOGGLE_BEHAVIOUR_SHOW_HIDE)
.put(IVALUE_SOFT_KEYBOARD_TOGGLE_BEHAVIOUR_ENABLE_DISABLE, IVALUE_SOFT_KEYBOARD_TOGGLE_BEHAVIOUR_ENABLE_DISABLE)
.build();
/** Defines the key for whether volume keys will behave as virtual or literal volume keys */
public static final String KEY_VOLUME_KEYS_BEHAVIOUR = "volume-keys"; // Default: "volume-keys"
public static final String IVALUE_VOLUME_KEY_BEHAVIOUR_VIRTUAL = "virtual";
public static final String IVALUE_VOLUME_KEY_BEHAVIOUR_VOLUME = "volume";
public static final String DEFAULT_IVALUE_VOLUME_KEYS_BEHAVIOUR = IVALUE_VOLUME_KEY_BEHAVIOUR_VIRTUAL;
/** Defines the bidirectional map for volume keys behaviour values and their internal values */
public static final ImmutableBiMap<String, String> MAP_VOLUME_KEYS_BEHAVIOUR =
new ImmutableBiMap.Builder<String, String>()
.put(IVALUE_VOLUME_KEY_BEHAVIOUR_VIRTUAL, IVALUE_VOLUME_KEY_BEHAVIOUR_VIRTUAL)
.put(IVALUE_VOLUME_KEY_BEHAVIOUR_VOLUME, IVALUE_VOLUME_KEY_BEHAVIOUR_VOLUME)
.build();
/** Defines the set for keys loaded by termux
* Setting this to {@code null} will make {@link SharedProperties} throw an exception.
* */
public static final Set<String> TERMUX_PROPERTIES_LIST = new HashSet<>(Arrays.asList(
/* boolean */
KEY_DISABLE_HARDWARE_KEYBOARD_SHORTCUTS,
KEY_DISABLE_TERMINAL_SESSION_CHANGE_TOAST,
KEY_ENFORCE_CHAR_BASED_INPUT,
KEY_EXTRA_KEYS_TEXT_ALL_CAPS,
KEY_HIDE_SOFT_KEYBOARD_ON_STARTUP,
KEY_TERMINAL_ONCLICK_URL_OPEN,
KEY_USE_BLACK_UI,
KEY_USE_CTRL_SPACE_WORKAROUND,
KEY_USE_FULLSCREEN,
KEY_USE_FULLSCREEN_WORKAROUND,
TermuxConstants.PROP_ALLOW_EXTERNAL_APPS,
/* int */
KEY_BELL_BEHAVIOUR,
KEY_TERMINAL_CURSOR_BLINK_RATE,
KEY_TERMINAL_CURSOR_STYLE,
KEY_TERMINAL_MARGIN_HORIZONTAL,
KEY_TERMINAL_MARGIN_VERTICAL,
KEY_TERMINAL_TRANSCRIPT_ROWS,
/* float */
KEY_TERMINAL_TOOLBAR_HEIGHT_SCALE_FACTOR,
/* Integer */
KEY_SHORTCUT_CREATE_SESSION,
KEY_SHORTCUT_NEXT_SESSION,
KEY_SHORTCUT_PREVIOUS_SESSION,
KEY_SHORTCUT_RENAME_SESSION,
/* String */
KEY_BACK_KEY_BEHAVIOUR,
KEY_DEFAULT_WORKING_DIRECTORY,
KEY_EXTRA_KEYS,
KEY_EXTRA_KEYS_STYLE,
KEY_SOFT_KEYBOARD_TOGGLE_BEHAVIOUR,
KEY_VOLUME_KEYS_BEHAVIOUR
));
/** Defines the set for keys loaded by termux that have default boolean behaviour with false as default.
* "true" -> true
* "false" -> false
* default: false
*/
public static final Set<String> TERMUX_DEFAULT_FALSE_BOOLEAN_BEHAVIOUR_PROPERTIES_LIST = new HashSet<>(Arrays.asList(
KEY_DISABLE_HARDWARE_KEYBOARD_SHORTCUTS,
KEY_DISABLE_TERMINAL_SESSION_CHANGE_TOAST,
KEY_ENFORCE_CHAR_BASED_INPUT,
KEY_HIDE_SOFT_KEYBOARD_ON_STARTUP,
KEY_TERMINAL_ONCLICK_URL_OPEN,
KEY_USE_CTRL_SPACE_WORKAROUND,
KEY_USE_FULLSCREEN,
KEY_USE_FULLSCREEN_WORKAROUND,
TermuxConstants.PROP_ALLOW_EXTERNAL_APPS
));
/** Defines the set for keys loaded by termux that have default boolean behaviour with true as default.
* "true" -> true
* "false" -> false
* default: true
*/
public static final Set<String> TERMUX_DEFAULT_TRUE_BOOLEAN_BEHAVIOUR_PROPERTIES_LIST = new HashSet<>(Arrays.asList(
KEY_EXTRA_KEYS_TEXT_ALL_CAPS
));
/** Defines the set for keys loaded by termux that have default inverted boolean behaviour with false as default.
* "false" -> true
* "true" -> false
* default: false
*/
public static final Set<String> TERMUX_DEFAULT_INVERETED_FALSE_BOOLEAN_BEHAVIOUR_PROPERTIES_LIST = new HashSet<>(Arrays.asList(
));
/** Defines the set for keys loaded by termux that have default inverted boolean behaviour with true as default.
* "false" -> true
* "true" -> false
* default: true
*/
public static final Set<String> TERMUX_DEFAULT_INVERETED_TRUE_BOOLEAN_BEHAVIOUR_PROPERTIES_LIST = new HashSet<>(Arrays.asList(
));
/** Returns the first {@link File} found at
* {@link TermuxConstants#TERMUX_PROPERTIES_PRIMARY_FILE_PATH} or
* {@link TermuxConstants#TERMUX_PROPERTIES_SECONDARY_FILE_PATH}
* from which termux properties can be loaded.
* If the {@link File} found is not a regular file or is not readable then null is returned.
*
* @return Returns the {@link File} object for termux properties.
*/
public static File getTermuxPropertiesFile() {
return getPropertiesFile(new String[]{
TermuxConstants.TERMUX_PROPERTIES_PRIMARY_FILE_PATH,
TermuxConstants.TERMUX_PROPERTIES_SECONDARY_FILE_PATH
});
}
/** Returns the first {@link File} found at
* {@link TermuxConstants#TERMUX_FLOAT_PROPERTIES_PRIMARY_FILE_PATH} or
* {@link TermuxConstants#TERMUX_FLOAT_PROPERTIES_SECONDARY_FILE_PATH}
* from which termux properties can be loaded.
* If the {@link File} found is not a regular file or is not readable then null is returned.
*
* @return Returns the {@link File} object for termux properties.
*/
public static File getTermuxFloatPropertiesFile() {
return getPropertiesFile(new String[]{
TermuxConstants.TERMUX_FLOAT_PROPERTIES_PRIMARY_FILE_PATH,
TermuxConstants.TERMUX_FLOAT_PROPERTIES_SECONDARY_FILE_PATH
});
}
public static File getPropertiesFile(@NonNull String[] possiblePropertiesFileLocations) {
File propertiesFile = new File(possiblePropertiesFileLocations[0]);
int i = 0;
while (!propertiesFile.exists() && i < possiblePropertiesFileLocations.length) {
propertiesFile = new File(possiblePropertiesFileLocations[i]);
i += 1;
}
if (propertiesFile.isFile() && propertiesFile.canRead()) {
return propertiesFile;
} else {
Logger.logDebug("No readable properties file found at: " + Arrays.toString(possiblePropertiesFileLocations));
return null;
}
}
}

View file

@ -0,0 +1,632 @@
package com.termux.shared.settings.properties;
import android.content.Context;
import android.content.res.Configuration;
import androidx.annotation.NonNull;
import com.termux.shared.logger.Logger;
import com.termux.shared.data.DataUtils;
import java.io.File;
import java.util.HashMap;
import java.util.Map;
import java.util.Properties;
import java.util.Set;
public abstract class TermuxSharedProperties {
protected final Context mContext;
protected final String mLabel;
protected final File mPropertiesFile;
protected final SharedProperties mSharedProperties;
public static final String LOG_TAG = "TermuxSharedProperties";
public TermuxSharedProperties(@NonNull Context context, @NonNull String label, File propertiesFile,
@NonNull Set<String> propertiesList, @NonNull SharedPropertiesParser sharedPropertiesParser) {
mContext = context;
mLabel = label;
mPropertiesFile = propertiesFile;
mSharedProperties = new SharedProperties(context, mPropertiesFile, propertiesList, sharedPropertiesParser);
loadTermuxPropertiesFromDisk();
}
/**
* Reload the termux properties from disk into an in-memory cache.
*/
public void loadTermuxPropertiesFromDisk() {
mSharedProperties.loadPropertiesFromDisk();
dumpPropertiesToLog();
dumpInternalPropertiesToLog();
}
/**
* Get the {@link Properties} from the {@link #mPropertiesFile} file.
*
* @param cached If {@code true}, then the {@link Properties} in-memory cache is returned.
* Otherwise the {@link Properties} object is read directly from the
* {@link #mPropertiesFile} file.
* @return Returns the {@link Properties} object. It will be {@code null} if an exception is
* raised while reading the file.
*/
public Properties getProperties(boolean cached) {
return mSharedProperties.getProperties(cached);
}
/**
* Get the {@link String} value for the key passed from the {@link #mPropertiesFile} file.
*
* @param key The key to read.
* @param def The default value.
* @param cached If {@code true}, then the value is returned from the the {@link Properties} in-memory cache.
* Otherwise the {@link Properties} object is read directly from the file
* 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 getPropertyValue(String key, String def, boolean cached) {
return SharedProperties.getDefaultIfNull(mSharedProperties.getProperty(key, cached), def);
}
/**
* A function to check if the value is {@code true} for {@link Properties} key read from
* the {@link #mPropertiesFile} file.
*
* @param key The key to read.
* @param cached If {@code true}, then the value is checked from the the {@link Properties} in-memory cache.
* Otherwise the {@link Properties} object is read directly from the file
* and value is checked from it.
* @param logErrorOnInvalidValue If {@code true}, then an error will be logged if key value
* was found in {@link Properties} but was invalid.
* @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 boolean isPropertyValueTrue(String key, boolean cached, boolean logErrorOnInvalidValue) {
return (boolean) SharedProperties.getBooleanValueForStringValue(key, (String) getPropertyValue(key, null, cached), false, logErrorOnInvalidValue, LOG_TAG);
}
/**
* A function to check if the value is {@code false} for {@link Properties} key read from
* the {@link #mPropertiesFile} file.
*
* @param key The key to read.
* @param cached If {@code true}, then the value is checked from the the {@link Properties} in-memory cache.
* Otherwise the {@link Properties} object is read directly from the file
* and value is checked from it.
* @param logErrorOnInvalidValue If {@code true}, then an error will be logged if key value
* was found in {@link Properties} but was invalid.
* @return Returns {@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 boolean isPropertyValueFalse(String key, boolean cached, boolean logErrorOnInvalidValue) {
return (boolean) SharedProperties.getInvertedBooleanValueForStringValue(key, (String) getPropertyValue(key, null, cached), true, logErrorOnInvalidValue, LOG_TAG);
}
/**
* Get the internal value {@link Object} {@link HashMap <>} in-memory cache for the
* {@link #mPropertiesFile} file. A call to {@link #loadTermuxPropertiesFromDisk()} must be made
* before this.
*
* @return Returns a copy of {@link Map} object.
*/
public Map<String, Object> getInternalProperties() {
return mSharedProperties.getInternalProperties();
}
/**
* Get the internal {@link Object} value for the key passed from the {@link #mPropertiesFile} file.
* If cache is {@code true}, then value is returned from the {@link HashMap <>} in-memory cache,
* so a call to {@link #loadTermuxPropertiesFromDisk()} must be made before this.
*
* @param key The key to read from the {@link HashMap<>} in-memory cache.
* @param cached If {@code true}, then the value is returned from the the {@link HashMap <>} in-memory cache,
* but if the value is null, then an attempt is made to return the default value.
* If {@code false}, then the {@link Properties} object is read directly from the file
* and internal value is returned for the property value against the key.
* @return Returns the {@link Object} object. This will be {@code null} if key is not found or
* the object stored against the key is {@code null}.
*/
public Object getInternalPropertyValue(String key, boolean cached) {
Object value;
if (cached) {
value = mSharedProperties.getInternalProperty(key);
// If the value is not null since key was found or if the value was null since the
// object stored for the key was itself null, we detect the later by checking if the key
// exists in the map.
if (value != null || mSharedProperties.getInternalProperties().containsKey(key)) {
return value;
} else {
// This should not happen normally unless mMap was modified after the
// {@link #loadTermuxPropertiesFromDisk()} call
// A null value can still be returned by
// {@link #getInternalPropertyValueFromValue(Context,String,String)} for some keys
value = getInternalTermuxPropertyValueFromValue(mContext, key, null);
Logger.logWarn(LOG_TAG, "The value for \"" + key + "\" not found in SharedProperties cache, force returning default value: `" + value + "`");
return value;
}
} else {
// We get the property value directly from file and return its internal value
return getInternalTermuxPropertyValueFromValue(mContext, key, mSharedProperties.getProperty(key, false));
}
}
/**
* Get the internal {@link Object} value for the key passed from the file returned by
* {@code propertiesFile}. The {@link Properties} object is
* read directly from the file and internal value is returned for the property value against the key.
*
* @param context The context for operations.
* @param key The key for which the internal object is required.
* @return Returns the {@link Object} object. This will be {@code null} if key is not found or
* the object stored against the key is {@code null}.
*/
public static Object getInternalPropertyValue(Context context, File propertiesFile, String key,
@NonNull SharedPropertiesParser sharedPropertiesParser) {
return SharedProperties.getInternalProperty(context, propertiesFile, key, sharedPropertiesParser);
}
/**
* The class that implements the {@link SharedPropertiesParser} interface.
*/
public static class SharedPropertiesParserClient implements SharedPropertiesParser {
/**
* Override the
* {@link SharedPropertiesParser#getInternalPropertyValueFromValue(Context,String,String)}
* interface function.
*/
@Override
public Object getInternalPropertyValueFromValue(Context context, String key, String value) {
return getInternalTermuxPropertyValueFromValue(context, key, value);
}
}
/**
* A static function that should return the internal termux {@link Object} for a key/value pair
* read from properties file.
*
* @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 internal termux {@link Object} object.
*/
public static Object getInternalTermuxPropertyValueFromValue(Context context, String key, String value) {
if (key == null) return null;
/*
For keys where a MAP_* is checked by respective functions. Note that value to this function
would actually be the key for the MAP_*:
- If the value is currently null, then searching MAP_* should also return null and internal default value will be used.
- If the value is not null and does not exist in MAP_*, then internal default value will be used.
- If the value is not null and does exist in MAP_*, then internal value returned by map will be used.
*/
switch (key) {
/* boolean */
case TermuxPropertyConstants.KEY_USE_BLACK_UI:
return (boolean) getUseBlackUIInternalPropertyValueFromValue(context, value);
/* int */
case TermuxPropertyConstants.KEY_BELL_BEHAVIOUR:
return (int) getBellBehaviourInternalPropertyValueFromValue(value);
case TermuxPropertyConstants.KEY_TERMINAL_CURSOR_BLINK_RATE:
return (int) getTerminalCursorBlinkRateInternalPropertyValueFromValue(value);
case TermuxPropertyConstants.KEY_TERMINAL_CURSOR_STYLE:
return (int) getTerminalCursorStyleInternalPropertyValueFromValue(value);
case TermuxPropertyConstants.KEY_TERMINAL_MARGIN_HORIZONTAL:
return (int) getTerminalMarginHorizontalInternalPropertyValueFromValue(value);
case TermuxPropertyConstants.KEY_TERMINAL_MARGIN_VERTICAL:
return (int) getTerminalMarginVerticalInternalPropertyValueFromValue(value);
case TermuxPropertyConstants.KEY_TERMINAL_TRANSCRIPT_ROWS:
return (int) getTerminalTranscriptRowsInternalPropertyValueFromValue(value);
/* float */
case TermuxPropertyConstants.KEY_TERMINAL_TOOLBAR_HEIGHT_SCALE_FACTOR:
return (float) getTerminalToolbarHeightScaleFactorInternalPropertyValueFromValue(value);
/* Integer (may be null) */
case TermuxPropertyConstants.KEY_SHORTCUT_CREATE_SESSION:
case TermuxPropertyConstants.KEY_SHORTCUT_NEXT_SESSION:
case TermuxPropertyConstants.KEY_SHORTCUT_PREVIOUS_SESSION:
case TermuxPropertyConstants.KEY_SHORTCUT_RENAME_SESSION:
return (Integer) getCodePointForSessionShortcuts(key, value);
/* String (may be null) */
case TermuxPropertyConstants.KEY_BACK_KEY_BEHAVIOUR:
return (String) getBackKeyBehaviourInternalPropertyValueFromValue(value);
case TermuxPropertyConstants.KEY_DEFAULT_WORKING_DIRECTORY:
return (String) getDefaultWorkingDirectoryInternalPropertyValueFromValue(value);
case TermuxPropertyConstants.KEY_EXTRA_KEYS:
return (String) getExtraKeysInternalPropertyValueFromValue(value);
case TermuxPropertyConstants.KEY_EXTRA_KEYS_STYLE:
return (String) getExtraKeysStyleInternalPropertyValueFromValue(value);
case TermuxPropertyConstants.KEY_SOFT_KEYBOARD_TOGGLE_BEHAVIOUR:
return (String) getSoftKeyboardToggleBehaviourInternalPropertyValueFromValue(value);
case TermuxPropertyConstants.KEY_VOLUME_KEYS_BEHAVIOUR:
return (String) getVolumeKeysBehaviourInternalPropertyValueFromValue(value);
default:
// default false boolean behaviour
if (TermuxPropertyConstants.TERMUX_DEFAULT_FALSE_BOOLEAN_BEHAVIOUR_PROPERTIES_LIST.contains(key))
return (boolean) SharedProperties.getBooleanValueForStringValue(key, value, false, true, LOG_TAG);
// default true boolean behaviour
if (TermuxPropertyConstants.TERMUX_DEFAULT_TRUE_BOOLEAN_BEHAVIOUR_PROPERTIES_LIST.contains(key))
return (boolean) SharedProperties.getBooleanValueForStringValue(key, value, true, true, LOG_TAG);
// default inverted false boolean behaviour
//else if (TermuxPropertyConstants.TERMUX_DEFAULT_INVERETED_FALSE_BOOLEAN_BEHAVIOUR_PROPERTIES_LIST.contains(key))
// return (boolean) SharedProperties.getInvertedBooleanValueForStringValue(key, value, false, true, LOG_TAG);
// default inverted true boolean behaviour
// else if (TermuxPropertyConstants.TERMUX_DEFAULT_INVERETED_TRUE_BOOLEAN_BEHAVIOUR_PROPERTIES_LIST.contains(key))
// return (boolean) SharedProperties.getInvertedBooleanValueForStringValue(key, value, true, true, LOG_TAG);
// just use String object as is (may be null)
else
return value;
}
}
/**
* Returns {@code true} or {@code false} if value is the literal string "true" or "false" respectively regardless of case.
* Otherwise returns {@code true} if the night mode is currently enabled in the system.
*
* @param value The {@link String} value to convert.
* @return Returns the internal value for value.
*/
public static boolean getUseBlackUIInternalPropertyValueFromValue(Context context, String value) {
int nightMode = context.getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK;
return SharedProperties.getBooleanValueForStringValue(TermuxPropertyConstants.KEY_USE_BLACK_UI, value, nightMode == Configuration.UI_MODE_NIGHT_YES, true, LOG_TAG);
}
/**
* Returns the internal value after mapping it based on
* {@code TermuxPropertyConstants#MAP_BELL_BEHAVIOUR} if the value is not {@code null}
* and is valid, otherwise returns {@link TermuxPropertyConstants#DEFAULT_IVALUE_BELL_BEHAVIOUR}.
*
* @param value The {@link String} value to convert.
* @return Returns the internal value for value.
*/
public static int getBellBehaviourInternalPropertyValueFromValue(String value) {
return (int) SharedProperties.getDefaultIfNotInMap(TermuxPropertyConstants.KEY_BELL_BEHAVIOUR, TermuxPropertyConstants.MAP_BELL_BEHAVIOUR, SharedProperties.toLowerCase(value), TermuxPropertyConstants.DEFAULT_IVALUE_BELL_BEHAVIOUR, true, LOG_TAG);
}
/**
* Returns the int for the value if its not null and is between
* {@link TermuxPropertyConstants#IVALUE_TERMINAL_CURSOR_BLINK_RATE_MIN} and
* {@link TermuxPropertyConstants#IVALUE_TERMINAL_CURSOR_BLINK_RATE_MAX},
* otherwise returns {@link TermuxPropertyConstants#DEFAULT_IVALUE_TERMINAL_CURSOR_BLINK_RATE}.
*
* @param value The {@link String} value to convert.
* @return Returns the internal value for value.
*/
public static int getTerminalCursorBlinkRateInternalPropertyValueFromValue(String value) {
return SharedProperties.getDefaultIfNotInRange(TermuxPropertyConstants.KEY_TERMINAL_CURSOR_BLINK_RATE,
DataUtils.getIntFromString(value, TermuxPropertyConstants.DEFAULT_IVALUE_TERMINAL_CURSOR_BLINK_RATE),
TermuxPropertyConstants.DEFAULT_IVALUE_TERMINAL_CURSOR_BLINK_RATE,
TermuxPropertyConstants.IVALUE_TERMINAL_CURSOR_BLINK_RATE_MIN,
TermuxPropertyConstants.IVALUE_TERMINAL_CURSOR_BLINK_RATE_MAX,
true, true, LOG_TAG);
}
/**
* Returns the internal value after mapping it based on
* {@link TermuxPropertyConstants#MAP_TERMINAL_CURSOR_STYLE} if the value is not {@code null}
* and is valid, otherwise returns {@link TermuxPropertyConstants#DEFAULT_IVALUE_TERMINAL_CURSOR_STYLE}.
*
* @param value The {@link String} value to convert.
* @return Returns the internal value for value.
*/
public static int getTerminalCursorStyleInternalPropertyValueFromValue(String value) {
return (int) SharedProperties.getDefaultIfNotInMap(TermuxPropertyConstants.KEY_TERMINAL_CURSOR_STYLE, TermuxPropertyConstants.MAP_TERMINAL_CURSOR_STYLE, SharedProperties.toLowerCase(value), TermuxPropertyConstants.DEFAULT_IVALUE_TERMINAL_CURSOR_STYLE, true, LOG_TAG);
}
/**
* Returns the int for the value if its not null and is between
* {@link TermuxPropertyConstants#IVALUE_TERMINAL_MARGIN_HORIZONTAL_MIN} and
* {@link TermuxPropertyConstants#IVALUE_TERMINAL_MARGIN_HORIZONTAL_MAX},
* otherwise returns {@link TermuxPropertyConstants#DEFAULT_IVALUE_TERMINAL_HORIZONTAL_MARGIN}.
*
* @param value The {@link String} value to convert.
* @return Returns the internal value for value.
*/
public static int getTerminalMarginHorizontalInternalPropertyValueFromValue(String value) {
return SharedProperties.getDefaultIfNotInRange(TermuxPropertyConstants.KEY_TERMINAL_MARGIN_HORIZONTAL,
DataUtils.getIntFromString(value, TermuxPropertyConstants.DEFAULT_IVALUE_TERMINAL_HORIZONTAL_MARGIN),
TermuxPropertyConstants.DEFAULT_IVALUE_TERMINAL_HORIZONTAL_MARGIN,
TermuxPropertyConstants.IVALUE_TERMINAL_MARGIN_HORIZONTAL_MIN,
TermuxPropertyConstants.IVALUE_TERMINAL_MARGIN_HORIZONTAL_MAX,
true, true, LOG_TAG);
}
/**
* Returns the int for the value if its not null and is between
* {@link TermuxPropertyConstants#IVALUE_TERMINAL_MARGIN_VERTICAL_MIN} and
* {@link TermuxPropertyConstants#IVALUE_TERMINAL_MARGIN_VERTICAL_MAX},
* otherwise returns {@link TermuxPropertyConstants#DEFAULT_IVALUE_TERMINAL_VERTICAL_MARGIN}.
*
* @param value The {@link String} value to convert.
* @return Returns the internal value for value.
*/
public static int getTerminalMarginVerticalInternalPropertyValueFromValue(String value) {
return SharedProperties.getDefaultIfNotInRange(TermuxPropertyConstants.KEY_TERMINAL_MARGIN_VERTICAL,
DataUtils.getIntFromString(value, TermuxPropertyConstants.DEFAULT_IVALUE_TERMINAL_VERTICAL_MARGIN),
TermuxPropertyConstants.DEFAULT_IVALUE_TERMINAL_VERTICAL_MARGIN,
TermuxPropertyConstants.IVALUE_TERMINAL_MARGIN_VERTICAL_MIN,
TermuxPropertyConstants.IVALUE_TERMINAL_MARGIN_VERTICAL_MAX,
true, true, LOG_TAG);
}
/**
* Returns the int for the value if its not null and is between
* {@link TermuxPropertyConstants#IVALUE_TERMINAL_TRANSCRIPT_ROWS_MIN} and
* {@link TermuxPropertyConstants#IVALUE_TERMINAL_TRANSCRIPT_ROWS_MAX},
* otherwise returns {@link TermuxPropertyConstants#DEFAULT_IVALUE_TERMINAL_TRANSCRIPT_ROWS}.
*
* @param value The {@link String} value to convert.
* @return Returns the internal value for value.
*/
public static int getTerminalTranscriptRowsInternalPropertyValueFromValue(String value) {
return SharedProperties.getDefaultIfNotInRange(TermuxPropertyConstants.KEY_TERMINAL_TRANSCRIPT_ROWS,
DataUtils.getIntFromString(value, TermuxPropertyConstants.DEFAULT_IVALUE_TERMINAL_TRANSCRIPT_ROWS),
TermuxPropertyConstants.DEFAULT_IVALUE_TERMINAL_TRANSCRIPT_ROWS,
TermuxPropertyConstants.IVALUE_TERMINAL_TRANSCRIPT_ROWS_MIN,
TermuxPropertyConstants.IVALUE_TERMINAL_TRANSCRIPT_ROWS_MAX,
true, true, LOG_TAG);
}
/**
* Returns the int for the value if its not null and is between
* {@link TermuxPropertyConstants#IVALUE_TERMINAL_TOOLBAR_HEIGHT_SCALE_FACTOR_MIN} and
* {@link TermuxPropertyConstants#IVALUE_TERMINAL_TOOLBAR_HEIGHT_SCALE_FACTOR_MAX},
* otherwise returns {@link TermuxPropertyConstants#DEFAULT_IVALUE_TERMINAL_TOOLBAR_HEIGHT_SCALE_FACTOR}.
*
* @param value The {@link String} value to convert.
* @return Returns the internal value for value.
*/
public static float getTerminalToolbarHeightScaleFactorInternalPropertyValueFromValue(String value) {
return SharedProperties.getDefaultIfNotInRange(TermuxPropertyConstants.KEY_TERMINAL_TOOLBAR_HEIGHT_SCALE_FACTOR,
DataUtils.getFloatFromString(value, TermuxPropertyConstants.DEFAULT_IVALUE_TERMINAL_TOOLBAR_HEIGHT_SCALE_FACTOR),
TermuxPropertyConstants.DEFAULT_IVALUE_TERMINAL_TOOLBAR_HEIGHT_SCALE_FACTOR,
TermuxPropertyConstants.IVALUE_TERMINAL_TOOLBAR_HEIGHT_SCALE_FACTOR_MIN,
TermuxPropertyConstants.IVALUE_TERMINAL_TOOLBAR_HEIGHT_SCALE_FACTOR_MAX,
true, true, LOG_TAG);
}
/**
* Returns the code point for the value if key is not {@code null} and value is not {@code null} and is valid,
* otherwise returns {@code null}.
*
* @param key The key for session shortcut.
* @param value The {@link String} value to convert.
* @return Returns the internal value for value.
*/
public static Integer getCodePointForSessionShortcuts(String key, String value) {
if (key == null) return null;
if (value == null) return null;
String[] parts = value.toLowerCase().trim().split("\\+");
String input = parts.length == 2 ? parts[1].trim() : null;
if (!(parts.length == 2 && parts[0].trim().equals("ctrl")) || input.isEmpty() || input.length() > 2) {
Logger.logError(LOG_TAG, "Keyboard shortcut '" + key + "' is not Ctrl+<something>");
return null;
}
char c = input.charAt(0);
int codePoint = c;
if (Character.isLowSurrogate(c)) {
if (input.length() != 2 || Character.isHighSurrogate(input.charAt(1))) {
Logger.logError(LOG_TAG, "Keyboard shortcut '" + key + "' is not Ctrl+<something>");
return null;
} else {
codePoint = Character.toCodePoint(input.charAt(1), c);
}
}
return codePoint;
}
/**
* Returns the value itself if it is not {@code null}, otherwise returns {@link TermuxPropertyConstants#DEFAULT_IVALUE_BACK_KEY_BEHAVIOUR}.
*
* @param value {@link String} value to convert.
* @return Returns the internal value for value.
*/
public static String getBackKeyBehaviourInternalPropertyValueFromValue(String value) {
return (String) SharedProperties.getDefaultIfNotInMap(TermuxPropertyConstants.KEY_BACK_KEY_BEHAVIOUR, TermuxPropertyConstants.MAP_BACK_KEY_BEHAVIOUR, SharedProperties.toLowerCase(value), TermuxPropertyConstants.DEFAULT_IVALUE_BACK_KEY_BEHAVIOUR, true, LOG_TAG);
}
/**
* Returns the path itself if a directory exists at it and is readable, otherwise returns
* {@link TermuxPropertyConstants#DEFAULT_IVALUE_DEFAULT_WORKING_DIRECTORY}.
*
* @param path The {@link String} path to check.
* @return Returns the internal value for value.
*/
public static String getDefaultWorkingDirectoryInternalPropertyValueFromValue(String path) {
if (path == null || path.isEmpty()) return TermuxPropertyConstants.DEFAULT_IVALUE_DEFAULT_WORKING_DIRECTORY;
File workDir = new File(path);
if (!workDir.exists() || !workDir.isDirectory() || !workDir.canRead()) {
// Fallback to default directory if user configured working directory does not exist,
// is not a directory or is not readable.
Logger.logError(LOG_TAG, "The path \"" + path + "\" for the key \"" + TermuxPropertyConstants.KEY_DEFAULT_WORKING_DIRECTORY + "\" does not exist, is not a directory or is not readable. Using default value \"" + TermuxPropertyConstants.DEFAULT_IVALUE_DEFAULT_WORKING_DIRECTORY + "\" instead.");
return TermuxPropertyConstants.DEFAULT_IVALUE_DEFAULT_WORKING_DIRECTORY;
} else {
return path;
}
}
/**
* Returns the value itself if it is not {@code null}, otherwise returns {@link TermuxPropertyConstants#DEFAULT_IVALUE_EXTRA_KEYS}.
*
* @param value The {@link String} value to convert.
* @return Returns the internal value for value.
*/
public static String getExtraKeysInternalPropertyValueFromValue(String value) {
return SharedProperties.getDefaultIfNullOrEmpty(value, TermuxPropertyConstants.DEFAULT_IVALUE_EXTRA_KEYS);
}
/**
* Returns the value itself if it is not {@code null}, otherwise returns {@link TermuxPropertyConstants#DEFAULT_IVALUE_EXTRA_KEYS_STYLE}.
*
* @param value {@link String} value to convert.
* @return Returns the internal value for value.
*/
public static String getExtraKeysStyleInternalPropertyValueFromValue(String value) {
return SharedProperties.getDefaultIfNullOrEmpty(value, TermuxPropertyConstants.DEFAULT_IVALUE_EXTRA_KEYS_STYLE);
}
/**
* Returns the value itself if it is not {@code null}, otherwise returns {@link TermuxPropertyConstants#DEFAULT_IVALUE_SOFT_KEYBOARD_TOGGLE_BEHAVIOUR}.
*
* @param value {@link String} value to convert.
* @return Returns the internal value for value.
*/
public static String getSoftKeyboardToggleBehaviourInternalPropertyValueFromValue(String value) {
return (String) SharedProperties.getDefaultIfNotInMap(TermuxPropertyConstants.KEY_SOFT_KEYBOARD_TOGGLE_BEHAVIOUR, TermuxPropertyConstants.MAP_SOFT_KEYBOARD_TOGGLE_BEHAVIOUR, SharedProperties.toLowerCase(value), TermuxPropertyConstants.DEFAULT_IVALUE_SOFT_KEYBOARD_TOGGLE_BEHAVIOUR, true, LOG_TAG);
}
/**
* Returns the value itself if it is not {@code null}, otherwise returns {@link TermuxPropertyConstants#DEFAULT_IVALUE_VOLUME_KEYS_BEHAVIOUR}.
*
* @param value {@link String} value to convert.
* @return Returns the internal value for value.
*/
public static String getVolumeKeysBehaviourInternalPropertyValueFromValue(String value) {
return (String) SharedProperties.getDefaultIfNotInMap(TermuxPropertyConstants.KEY_VOLUME_KEYS_BEHAVIOUR, TermuxPropertyConstants.MAP_VOLUME_KEYS_BEHAVIOUR, SharedProperties.toLowerCase(value), TermuxPropertyConstants.DEFAULT_IVALUE_VOLUME_KEYS_BEHAVIOUR, true, LOG_TAG);
}
public boolean areHardwareKeyboardShortcutsDisabled() {
return (boolean) getInternalPropertyValue(TermuxPropertyConstants.KEY_DISABLE_HARDWARE_KEYBOARD_SHORTCUTS, true);
}
public boolean areTerminalSessionChangeToastsDisabled() {
return (boolean) getInternalPropertyValue(TermuxPropertyConstants.KEY_DISABLE_TERMINAL_SESSION_CHANGE_TOAST, true);
}
public boolean isEnforcingCharBasedInput() {
return (boolean) getInternalPropertyValue(TermuxPropertyConstants.KEY_ENFORCE_CHAR_BASED_INPUT, true);
}
public boolean shouldExtraKeysTextBeAllCaps() {
return (boolean) getInternalPropertyValue(TermuxPropertyConstants.KEY_EXTRA_KEYS_TEXT_ALL_CAPS, true);
}
public boolean shouldSoftKeyboardBeHiddenOnStartup() {
return (boolean) getInternalPropertyValue(TermuxPropertyConstants.KEY_HIDE_SOFT_KEYBOARD_ON_STARTUP, true);
}
public boolean shouldOpenTerminalTranscriptURLOnClick() {
return (boolean) getInternalPropertyValue(TermuxPropertyConstants.KEY_TERMINAL_ONCLICK_URL_OPEN, true);
}
public boolean isUsingBlackUI() {
return (boolean) getInternalPropertyValue(TermuxPropertyConstants.KEY_USE_BLACK_UI, true);
}
public boolean isUsingCtrlSpaceWorkaround() {
return (boolean) getInternalPropertyValue(TermuxPropertyConstants.KEY_USE_CTRL_SPACE_WORKAROUND, true);
}
public boolean isUsingFullScreen() {
return (boolean) getInternalPropertyValue(TermuxPropertyConstants.KEY_USE_FULLSCREEN, true);
}
public boolean isUsingFullScreenWorkAround() {
return (boolean) getInternalPropertyValue(TermuxPropertyConstants.KEY_USE_FULLSCREEN_WORKAROUND, true);
}
public int getBellBehaviour() {
return (int) getInternalPropertyValue(TermuxPropertyConstants.KEY_BELL_BEHAVIOUR, true);
}
public int getTerminalCursorBlinkRate() {
return (int) getInternalPropertyValue(TermuxPropertyConstants.KEY_TERMINAL_CURSOR_BLINK_RATE, true);
}
public int getTerminalCursorStyle() {
return (int) getInternalPropertyValue(TermuxPropertyConstants.KEY_TERMINAL_CURSOR_STYLE, true);
}
public int getTerminalMarginHorizontal() {
return (int) getInternalPropertyValue(TermuxPropertyConstants.KEY_TERMINAL_MARGIN_HORIZONTAL, true);
}
public int getTerminalMarginVertical() {
return (int) getInternalPropertyValue(TermuxPropertyConstants.KEY_TERMINAL_MARGIN_VERTICAL, true);
}
public int getTerminalTranscriptRows() {
return (int) getInternalPropertyValue(TermuxPropertyConstants.KEY_TERMINAL_TRANSCRIPT_ROWS, true);
}
public float getTerminalToolbarHeightScaleFactor() {
return (float) getInternalPropertyValue(TermuxPropertyConstants.KEY_TERMINAL_TOOLBAR_HEIGHT_SCALE_FACTOR, true);
}
public boolean isBackKeyTheEscapeKey() {
return (boolean) TermuxPropertyConstants.IVALUE_BACK_KEY_BEHAVIOUR_ESCAPE.equals(getInternalPropertyValue(TermuxPropertyConstants.KEY_BACK_KEY_BEHAVIOUR, true));
}
public String getDefaultWorkingDirectory() {
return (String) getInternalPropertyValue(TermuxPropertyConstants.KEY_DEFAULT_WORKING_DIRECTORY, true);
}
public boolean shouldEnableDisableSoftKeyboardOnToggle() {
return (boolean) TermuxPropertyConstants.IVALUE_SOFT_KEYBOARD_TOGGLE_BEHAVIOUR_ENABLE_DISABLE.equals(getInternalPropertyValue(TermuxPropertyConstants.KEY_SOFT_KEYBOARD_TOGGLE_BEHAVIOUR, true));
}
public boolean areVirtualVolumeKeysDisabled() {
return (boolean) TermuxPropertyConstants.IVALUE_VOLUME_KEY_BEHAVIOUR_VOLUME.equals(getInternalPropertyValue(TermuxPropertyConstants.KEY_VOLUME_KEYS_BEHAVIOUR, true));
}
public void dumpPropertiesToLog() {
Properties properties = getProperties(true);
StringBuilder propertiesDump = new StringBuilder();
propertiesDump.append(mLabel).append(" Termux Properties:");
if (properties != null) {
for (String key : properties.stringPropertyNames()) {
propertiesDump.append("\n").append(key).append(": `").append(properties.get(key)).append("`");
}
} else {
propertiesDump.append(" null");
}
Logger.logVerbose(LOG_TAG, propertiesDump.toString());
}
public void dumpInternalPropertiesToLog() {
HashMap<String, Object> internalProperties = (HashMap<String, Object>) getInternalProperties();
StringBuilder internalPropertiesDump = new StringBuilder();
internalPropertiesDump.append(mLabel).append(" Internal Properties:");
if (internalProperties != null) {
for (String key : internalProperties.keySet()) {
internalPropertiesDump.append("\n").append(key).append(": `").append(internalProperties.get(key)).append("`");
}
}
Logger.logVerbose(LOG_TAG, internalPropertiesDump.toString());
}
}

View file

@ -0,0 +1,352 @@
package com.termux.shared.shell;
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.models.errors.Error;
import com.termux.shared.file.FileUtils;
import com.termux.shared.logger.Logger;
import com.termux.shared.models.ResultConfig;
import com.termux.shared.models.ResultData;
import com.termux.shared.models.errors.FunctionErrno;
import com.termux.shared.models.errors.ResultSenderErrno;
import com.termux.shared.termux.AndroidUtils;
import com.termux.shared.termux.TermuxConstants.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.writeStringToFile(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.writeStringToFile(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.writeStringToFile(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.writeStringToFile(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.writeStringToFile(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.writeStringToFile(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;
}
}

View file

@ -0,0 +1,47 @@
package com.termux.shared.shell;
import android.content.Context;
import androidx.annotation.NonNull;
public interface ShellEnvironmentClient {
/**
* 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, likely $PREFIX/bin.
*
* @return Should return the "/bin" path.
*/
@NonNull
String getDefaultBinPath();
/**
* Build the shell environment to be used for commands.
*
* @param currentPackageContext The {@link Context} for the current package.
* @param isFailSafe If running a failsafe session.
* @param workingDirectory The working directory for the environment.
* @return Should return the build environment.
*/
@NonNull
String[] buildEnvironment(Context currentPackageContext, boolean isFailSafe, String workingDirectory);
/**
* Setup process 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[] setupProcessArgs(@NonNull String fileToExecute, String[] arguments);
}

View file

@ -0,0 +1,55 @@
package com.termux.shared.shell;
import com.termux.terminal.TerminalBuffer;
import com.termux.terminal.TerminalEmulator;
import com.termux.terminal.TerminalSession;
import java.lang.reflect.Field;
public class ShellUtils {
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;
}
}
public static String getExecutableBasename(String executable) {
if (executable == null) return null;
int lastSlash = executable.lastIndexOf('/');
return (lastSlash == -1) ? executable : executable.substring(lastSlash + 1);
}
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;
}
}

View file

@ -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 {
/**
* <p>Line callback</p>
*
* <p>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</p>
*
* @param line String that was gobbled
*/
void onLine(String line);
}
/**
* Stream closed callback interface
*/
public interface OnStreamClosedListener {
/**
* <p>Stream closed callback</p>
*/
void onStreamClosed();
}
@NonNull
private final String shell;
@NonNull
private final InputStream inputStream;
@NonNull
private final BufferedReader reader;
@Nullable
private final List<String> 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";
/**
* <p>StreamGobbler constructor</p>
*
* <p>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)</p>
*
* @param shell Name of the shell
* @param inputStream InputStream to read from
* @param outputList {@literal List<String>} 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<String> 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;
}
/**
* <p>StreamGobbler constructor</p>
*
* <p>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)</p>
* 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<String>} 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;
}
/**
* <p>StreamGobbler constructor</p>
*
* <p>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)</p>
*
* @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.DEFAULT_LOG_TAG;
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();
}
}
}
/**
* <p>Resume consuming the input from the stream</p>
*/
@AnyThread
public void resumeGobbling() {
if (!active) {
synchronized (this) {
active = true;
this.notifyAll();
}
}
}
/**
* <p>Suspend gobbling, so other code may read from the InputStream instead</p>
*
* <p>This should <i>only</i> be called from the OnLineListener callback!</p>
*/
@AnyThread
public void suspendGobbling() {
synchronized (this) {
active = false;
this.notifyAll();
}
}
/**
* <p>Wait for gobbling to be suspended</p>
*
* <p>Obviously this cannot be called from the same thread as {@link #suspendGobbling()}</p>
*/
@WorkerThread
public void waitForSuspend() {
synchronized (this) {
while (active) {
try {
this.wait(32);
} catch (InterruptedException e) {
// no action
}
}
}
}
/**
* <p>Is gobbling suspended ?</p>
*
* @return is gobbling suspended?
*/
@AnyThread
public boolean isSuspended() {
synchronized (this) {
return !active;
}
}
/**
* <p>Get current source InputStream</p>
*
* @return source InputStream
*/
@NonNull
@AnyThread
public InputStream getInputStream() {
return inputStream;
}
/**
* <p>Get current OnLineListener</p>
*
* @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();
}
}

View file

@ -0,0 +1,269 @@
package com.termux.shared.shell;
import android.content.Context;
import android.system.OsConstants;
import androidx.annotation.NonNull;
import com.termux.shared.R;
import com.termux.shared.models.ExecutionCommand;
import com.termux.shared.models.ResultData;
import com.termux.shared.models.errors.Errno;
import com.termux.shared.logger.Logger;
import com.termux.terminal.TerminalSession;
import com.termux.terminal.TerminalSessionClient;
import java.io.File;
/**
* A class that maintains info for foreground Termux sessions.
* It also provides a way to link each {@link TerminalSession} with the {@link ExecutionCommand}
* that started it.
*/
public class TermuxSession {
private final TerminalSession mTerminalSession;
private final ExecutionCommand mExecutionCommand;
private final TermuxSessionClient mTermuxSessionClient;
private final boolean mSetStdoutOnExit;
private static final String LOG_TAG = "TermuxSession";
private TermuxSession(@NonNull final TerminalSession terminalSession, @NonNull final ExecutionCommand executionCommand,
final TermuxSessionClient termuxSessionClient, final boolean setStdoutOnExit) {
this.mTerminalSession = terminalSession;
this.mExecutionCommand = executionCommand;
this.mTermuxSessionClient = termuxSessionClient;
this.mSetStdoutOnExit = setStdoutOnExit;
}
/**
* Start execution of an {@link ExecutionCommand} with {@link Runtime#exec(String[], String[], File)}.
*
* The {@link ExecutionCommand#executable}, must be set, {@link ExecutionCommand#commandLabel},
* {@link ExecutionCommand#arguments} and {@link ExecutionCommand#workingDirectory} may optionally
* be set.
*
* If {@link ExecutionCommand#executable} is {@code null}, then a default shell is automatically
* chosen.
*
* @param context The {@link Context} for operations.
* @param executionCommand The {@link ExecutionCommand} containing the information for execution command.
* @param terminalSessionClient The {@link TerminalSessionClient} interface implementation.
* @param termuxSessionClient The {@link TermuxSessionClient} interface implementation.
* @param shellEnvironmentClient The {@link ShellEnvironmentClient} interface implementation.
* @param sessionName The optional {@link TerminalSession} name.
* @param setStdoutOnExit If set to {@code true}, then the {@link ResultData#stdout}
* available in the {@link TermuxSessionClient#onTermuxSessionExited(TermuxSession)}
* callback will be set to the {@link TerminalSession} transcript. The session
* transcript will contain both stdout and stderr combined, basically
* anything sent to the the pseudo terminal /dev/pts, including PS1 prefixes.
* Set this to {@code true} only if the session transcript is required,
* since this requires extra processing to get it.
* @return Returns the {@link TermuxSession}. This will be {@code null} if failed to start the execution command.
*/
public static TermuxSession execute(@NonNull final Context context, @NonNull ExecutionCommand executionCommand,
@NonNull final TerminalSessionClient terminalSessionClient, final TermuxSessionClient termuxSessionClient,
@NonNull final ShellEnvironmentClient shellEnvironmentClient,
final String sessionName, final boolean setStdoutOnExit) {
if (executionCommand.workingDirectory == null || executionCommand.workingDirectory.isEmpty())
executionCommand.workingDirectory = shellEnvironmentClient.getDefaultWorkingDirectoryPath();
if (executionCommand.workingDirectory.isEmpty())
executionCommand.workingDirectory = "/";
String[] environment = shellEnvironmentClient.buildEnvironment(context, executionCommand.isFailsafe, executionCommand.workingDirectory);
String defaultBinPath = shellEnvironmentClient.getDefaultBinPath();
if (defaultBinPath.isEmpty())
defaultBinPath = "/system/bin";
boolean isLoginShell = false;
if (executionCommand.executable == null) {
if (!executionCommand.isFailsafe) {
for (String shellBinary : new String[]{"login", "bash", "zsh"}) {
File shellFile = new File(defaultBinPath, shellBinary);
if (shellFile.canExecute()) {
executionCommand.executable = shellFile.getAbsolutePath();
break;
}
}
}
if (executionCommand.executable == null) {
// Fall back to system shell as last resort:
// Do not start a login shell since ~/.profile may cause startup failure if its invalid.
// /system/bin/sh is provided by mksh (not toybox) and does load .mkshrc but for android its set
// to /system/etc/mkshrc even though its default is ~/.mkshrc.
// So /system/etc/mkshrc must still be valid for failsafe session to start properly.
// https://cs.android.com/android/platform/superproject/+/android-11.0.0_r3:external/mksh/src/main.c;l=663
// https://cs.android.com/android/platform/superproject/+/android-11.0.0_r3:external/mksh/src/main.c;l=41
// https://cs.android.com/android/platform/superproject/+/android-11.0.0_r3:external/mksh/Android.bp;l=114
executionCommand.executable = "/system/bin/sh";
} else {
isLoginShell = true;
}
}
String[] processArgs = shellEnvironmentClient.setupProcessArgs(executionCommand.executable, executionCommand.arguments);
executionCommand.executable = processArgs[0];
String processName = (isLoginShell ? "-" : "") + ShellUtils.getExecutableBasename(executionCommand.executable);
String[] arguments = new String[processArgs.length];
arguments[0] = processName;
if (processArgs.length > 1) System.arraycopy(processArgs, 1, arguments, 1, processArgs.length - 1);
executionCommand.arguments = arguments;
if (executionCommand.commandLabel == null)
executionCommand.commandLabel = processName;
if (!executionCommand.setState(ExecutionCommand.ExecutionState.EXECUTING)) {
executionCommand.setStateFailed(Errno.ERRNO_FAILED.getCode(), context.getString(R.string.error_failed_to_execute_termux_session_command, executionCommand.getCommandIdAndLabelLogString()));
TermuxSession.processTermuxSessionResult(null, executionCommand);
return null;
}
Logger.logDebugExtended(LOG_TAG, executionCommand.toString());
Logger.logDebug(LOG_TAG, "Running \"" + executionCommand.getCommandIdAndLabelLogString() + "\" TermuxSession");
TerminalSession terminalSession = new TerminalSession(executionCommand.executable, executionCommand.workingDirectory, executionCommand.arguments, environment, executionCommand.terminalTranscriptRows, terminalSessionClient);
if (sessionName != null) {
terminalSession.mSessionName = sessionName;
}
return new TermuxSession(terminalSession, executionCommand, termuxSessionClient, setStdoutOnExit);
}
/**
* Signal that this {@link TermuxSession} has finished. This should be called when
* {@link TerminalSessionClient#onSessionFinished(TerminalSession)} callback is received by the caller.
*
* If the processes has finished, then sets {@link ResultData#stdout}, {@link ResultData#stderr}
* and {@link ResultData#exitCode} for the {@link #mExecutionCommand} of the {@code termuxTask}
* and then calls {@link #processTermuxSessionResult(TermuxSession, ExecutionCommand)} to process the result}.
*
*/
public void finish() {
// If process is still running, then ignore the call
if (mTerminalSession.isRunning()) return;
int exitCode = mTerminalSession.getExitStatus();
if (exitCode == 0)
Logger.logDebug(LOG_TAG, "The \"" + mExecutionCommand.getCommandIdAndLabelLogString() + "\" TermuxSession exited normally");
else
Logger.logDebug(LOG_TAG, "The \"" + mExecutionCommand.getCommandIdAndLabelLogString() + "\" TermuxSession 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() + "\" TermuxSession state to ExecutionState.EXECUTED and processing results since it has already failed");
return;
}
mExecutionCommand.resultData.exitCode = exitCode;
if (this.mSetStdoutOnExit)
mExecutionCommand.resultData.stdout.append(ShellUtils.getTerminalSessionTranscriptText(mTerminalSession, true, false));
if (!mExecutionCommand.setState(ExecutionCommand.ExecutionState.EXECUTED))
return;
TermuxSession.processTermuxSessionResult(this, null);
}
/**
* Kill this {@link TermuxSession} by sending a {@link OsConstants#SIGILL} to its {@link #mTerminalSession}
* if its still executing.
*
* @param context The {@link Context} for operations.
* @param processResult If set to {@code true}, then the {@link #processTermuxSessionResult(TermuxSession, 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() + "\" TermuxSession since it has already finished executing");
return;
}
Logger.logDebug(LOG_TAG, "Send SIGKILL to \"" + mExecutionCommand.getCommandIdAndLabelLogString() + "\" TermuxSession");
if (mExecutionCommand.setStateFailed(Errno.ERRNO_FAILED.getCode(), context.getString(R.string.error_sending_sigkill_to_process))) {
if (processResult) {
mExecutionCommand.resultData.exitCode = 137; // SIGKILL
// Get whatever output has been set till now in case its needed
if (this.mSetStdoutOnExit)
mExecutionCommand.resultData.stdout.append(ShellUtils.getTerminalSessionTranscriptText(mTerminalSession, true, false));
TermuxSession.processTermuxSessionResult(this, null);
}
}
// Send SIGKILL to process
mTerminalSession.finishIfRunning();
}
/**
* Process the results of {@link TermuxSession} or {@link ExecutionCommand}.
*
* Only one of {@code termuxSession} and {@code executionCommand} must be set.
*
* If the {@code termuxSession} and its {@link #mTermuxSessionClient} are not {@code null},
* then the {@link TermuxSession.TermuxSessionClient#onTermuxSessionExited(TermuxSession)}
* callback will be called.
*
* @param termuxSession The {@link TermuxSession}, which should be set if
* {@link #execute(Context, ExecutionCommand, TerminalSessionClient, TermuxSessionClient, ShellEnvironmentClient, String, boolean)}
* successfully started the process.
* @param executionCommand The {@link ExecutionCommand}, which should be set if
* {@link #execute(Context, ExecutionCommand, TerminalSessionClient, TermuxSessionClient, ShellEnvironmentClient, String, boolean)}
* failed to start the process.
*/
private static void processTermuxSessionResult(final TermuxSession termuxSession, ExecutionCommand executionCommand) {
if (termuxSession != null)
executionCommand = termuxSession.mExecutionCommand;
if (executionCommand == null) return;
if (executionCommand.shouldNotProcessResults()) {
Logger.logDebug(LOG_TAG, "Ignoring duplicate call to process \"" + executionCommand.getCommandIdAndLabelLogString() + "\" TermuxSession result");
return;
}
Logger.logDebug(LOG_TAG, "Processing \"" + executionCommand.getCommandIdAndLabelLogString() + "\" TermuxSession result");
if (termuxSession != null && termuxSession.mTermuxSessionClient != null) {
termuxSession.mTermuxSessionClient.onTermuxSessionExited(termuxSession);
} 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 termuxSession
if (!executionCommand.isStateFailed())
executionCommand.setState(ExecutionCommand.ExecutionState.SUCCESS);
}
}
public TerminalSession getTerminalSession() {
return mTerminalSession;
}
public ExecutionCommand getExecutionCommand() {
return mExecutionCommand;
}
public interface TermuxSessionClient {
/**
* Callback function for when {@link TermuxSession} exits.
*
* @param termuxSession The {@link TermuxSession} that exited.
*/
void onTermuxSessionExited(TermuxSession termuxSession);
}
}

View file

@ -0,0 +1,33 @@
package com.termux.shared.shell;
import android.content.Context;
import androidx.annotation.NonNull;
public class TermuxShellEnvironmentClient implements ShellEnvironmentClient {
@NonNull
@Override
public String getDefaultWorkingDirectoryPath() {
return TermuxShellUtils.getDefaultWorkingDirectoryPath();
}
@NonNull
@Override
public String getDefaultBinPath() {
return TermuxShellUtils.getDefaultBinPath();
}
@NonNull
@Override
public String[] buildEnvironment(Context currentPackageContext, boolean isFailSafe, String workingDirectory) {
return TermuxShellUtils.buildEnvironment(currentPackageContext, isFailSafe, workingDirectory);
}
@NonNull
@Override
public String[] setupProcessArgs(@NonNull String fileToExecute, String[] arguments) {
return TermuxShellUtils.setupProcessArgs(fileToExecute, arguments);
}
}

View file

@ -0,0 +1,237 @@
package com.termux.shared.shell;
import android.content.Context;
import android.content.pm.ApplicationInfo;
import android.os.Build;
import androidx.annotation.NonNull;
import com.termux.shared.android.SELinuxUtils;
import com.termux.shared.data.DataUtils;
import com.termux.shared.models.errors.Error;
import com.termux.shared.termux.TermuxConstants;
import com.termux.shared.file.FileUtils;
import com.termux.shared.logger.Logger;
import com.termux.shared.packages.PackageUtils;
import com.termux.shared.termux.TermuxUtils;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public class TermuxShellUtils {
public static String TERMUX_VERSION_NAME;
public static String TERMUX_IS_DEBUGGABLE_BUILD;
public static String TERMUX_APP_PID;
public static String TERMUX_APK_RELEASE;
public static String TERMUX_API_VERSION_NAME;
public static String getDefaultWorkingDirectoryPath() {
return TermuxConstants.TERMUX_HOME_DIR_PATH;
}
public static String getDefaultBinPath() {
return TermuxConstants.TERMUX_BIN_PREFIX_DIR_PATH;
}
public static String[] buildEnvironment(Context currentPackageContext, boolean isFailSafe, String workingDirectory) {
TermuxConstants.TERMUX_HOME_DIR.mkdirs();
if (workingDirectory == null || workingDirectory.isEmpty())
workingDirectory = getDefaultWorkingDirectoryPath();
List<String> environment = new ArrayList<>();
loadTermuxEnvVariables(currentPackageContext);
if (TERMUX_VERSION_NAME != null)
environment.add("TERMUX_VERSION=" + TERMUX_VERSION_NAME);
if (TERMUX_IS_DEBUGGABLE_BUILD != null)
environment.add("TERMUX_IS_DEBUGGABLE_BUILD=" + TERMUX_IS_DEBUGGABLE_BUILD);
if (TERMUX_APP_PID != null)
environment.add("TERMUX_APP_PID=" + TERMUX_APP_PID);
if (TERMUX_APK_RELEASE != null)
environment.add("TERMUX_APK_RELEASE=" + TERMUX_APK_RELEASE);
if (TERMUX_API_VERSION_NAME != null)
environment.add("TERMUX_API_VERSION=" + TERMUX_API_VERSION_NAME);
environment.add("TERM=xterm-256color");
environment.add("COLORTERM=truecolor");
try {
ApplicationInfo applicationInfo = currentPackageContext.getPackageManager().getApplicationInfo(
TermuxConstants.TERMUX_PACKAGE_NAME, 0);
if (applicationInfo != null && !applicationInfo.enabled) {
applicationInfo = null;
}
if (applicationInfo != null) {
environment.add("TERMUX_APP__DATA_DIR=" + applicationInfo.dataDir);
environment.add("TERMUX_APP__LEGACY_DATA_DIR=" + "/data/data/" + applicationInfo.packageName);
environment.add("TERMUX_APP__SE_FILE_CONTEXT=" + SELinuxUtils.getFileContext(applicationInfo.dataDir));
String seInfoUser = PackageUtils.getApplicationInfoSeInfoUserForPackage(applicationInfo);
environment.add("TERMUX_APP__SE_INFO=" + PackageUtils.getApplicationInfoSeInfoForPackage(applicationInfo) +
(DataUtils.isNullOrEmpty(seInfoUser) ? "" : seInfoUser));
}
} catch (final Exception e) {
// Ignore
}
environment.add("TERMUX__ROOTFS_DIR=" + TermuxConstants.TERMUX_FILES_DIR_PATH);
environment.add("HOME=" + TermuxConstants.TERMUX_HOME_DIR_PATH);
environment.add("TERMUX__HOME=" + TermuxConstants.TERMUX_HOME_DIR_PATH);
environment.add("PREFIX=" + TermuxConstants.TERMUX_PREFIX_DIR_PATH);
environment.add("TERMUX__PREFIX=" + TermuxConstants.TERMUX_PREFIX_DIR_PATH);
environment.add("TERMUX__SE_PROCESS_CONTEXT=" + SELinuxUtils.getContext());
environment.add("BOOTCLASSPATH=" + System.getenv("BOOTCLASSPATH"));
environment.add("ANDROID_ROOT=" + System.getenv("ANDROID_ROOT"));
environment.add("ANDROID_DATA=" + System.getenv("ANDROID_DATA"));
// EXTERNAL_STORAGE is needed for /system/bin/am to work on at least
// Samsung S7 - see https://plus.google.com/110070148244138185604/posts/gp8Lk3aCGp3.
environment.add("EXTERNAL_STORAGE=" + System.getenv("EXTERNAL_STORAGE"));
// These variables are needed if running on Android 10 and higher.
addToEnvIfPresent(environment, "ANDROID_ART_ROOT");
addToEnvIfPresent(environment, "DEX2OATBOOTCLASSPATH");
addToEnvIfPresent(environment, "ANDROID_I18N_ROOT");
addToEnvIfPresent(environment, "ANDROID_RUNTIME_ROOT");
addToEnvIfPresent(environment, "ANDROID_TZDATA_ROOT");
environment.add("ANDROID__BUILD_VERSION_SDK=" + Build.VERSION.SDK_INT);
if (isFailSafe) {
// Keep the default path so that system binaries can be used in the failsafe session.
environment.add("PATH= " + System.getenv("PATH"));
} else {
environment.add("LANG=en_US.UTF-8");
environment.add("PATH=" + TermuxConstants.TERMUX_BIN_PREFIX_DIR_PATH);
environment.add("PWD=" + workingDirectory);
environment.add("TMPDIR=" + TermuxConstants.TERMUX_TMP_PREFIX_DIR_PATH);
}
return environment.toArray(new String[0]);
}
public static void addToEnvIfPresent(List<String> environment, String name) {
String value = System.getenv(name);
if (value != null) {
environment.add(name + "=" + value);
}
}
public static String[] setupProcessArgs(@NonNull String fileToExecute, String[] arguments) {
// The file to execute may either be:
// - An elf file, in which we execute it directly.
// - A script file without shebang, which we execute with our standard shell $PREFIX/bin/sh instead of the
// system /system/bin/sh. The system shell may vary and may not work at all due to LD_LIBRARY_PATH.
// - A file with shebang, which we try to handle with e.g. /bin/foo -> $PREFIX/bin/foo.
String interpreter = null;
try {
File file = new File(fileToExecute);
try (FileInputStream in = new FileInputStream(file)) {
byte[] buffer = new byte[256];
int bytesRead = in.read(buffer);
if (bytesRead > 4) {
if (buffer[0] == 0x7F && buffer[1] == 'E' && buffer[2] == 'L' && buffer[3] == 'F') {
// Elf file, do nothing.
} else if (buffer[0] == '#' && buffer[1] == '!') {
// Try to parse shebang.
StringBuilder builder = new StringBuilder();
for (int i = 2; i < bytesRead; i++) {
char c = (char) buffer[i];
if (c == ' ' || c == '\n') {
if (builder.length() == 0) {
// Skip whitespace after shebang.
} else {
// End of shebang.
String executable = builder.toString();
if (executable.startsWith("/usr") || executable.startsWith("/bin")) {
String[] parts = executable.split("/");
String binary = parts[parts.length - 1];
interpreter = TermuxConstants.TERMUX_BIN_PREFIX_DIR_PATH + "/" + binary;
}
break;
}
} else {
builder.append(c);
}
}
} else {
// No shebang and no ELF, use standard shell.
interpreter = TermuxConstants.TERMUX_BIN_PREFIX_DIR_PATH + "/sh";
}
}
}
} catch (IOException e) {
// Ignore.
}
List<String> result = new ArrayList<>();
if (interpreter != null) result.add(interpreter);
result.add(fileToExecute);
if (arguments != null) Collections.addAll(result, arguments);
return result.toArray(new String[0]);
}
public static void clearTermuxTMPDIR(boolean onlyIfExists) {
if(onlyIfExists && !FileUtils.directoryFileExists(TermuxConstants.TERMUX_TMP_PREFIX_DIR_PATH, false))
return;
Error error;
error = FileUtils.clearDirectory("$TMPDIR", FileUtils.getCanonicalPath(TermuxConstants.TERMUX_TMP_PREFIX_DIR_PATH, null));
if (error != null) {
Logger.logErrorExtended(error.toString());
}
}
public static void loadTermuxEnvVariables(Context currentPackageContext) {
String termuxAPKReleaseOld = TERMUX_APK_RELEASE;
TERMUX_VERSION_NAME = TERMUX_IS_DEBUGGABLE_BUILD = TERMUX_APP_PID = TERMUX_APK_RELEASE = null;
// Check if Termux app is installed and not disabled
if (TermuxUtils.isTermuxAppInstalled(currentPackageContext) == null) {
// This function may be called by a different package like a plugin, so we get version for Termux package via its context
Context termuxPackageContext = TermuxUtils.getTermuxPackageContext(currentPackageContext);
if (termuxPackageContext != null) {
TERMUX_VERSION_NAME = PackageUtils.getVersionNameForPackage(termuxPackageContext);
TERMUX_IS_DEBUGGABLE_BUILD = PackageUtils.isAppForPackageADebuggableBuild(termuxPackageContext) ? "1" : "0";
TERMUX_APP_PID = TermuxUtils.getTermuxAppPID(currentPackageContext);
// Getting APK signature is a slightly expensive operation, so do it only when needed
if (termuxAPKReleaseOld == null) {
String signingCertificateSHA256Digest = PackageUtils.getSigningCertificateSHA256DigestForPackage(termuxPackageContext);
if (signingCertificateSHA256Digest != null)
TERMUX_APK_RELEASE = TermuxUtils.getAPKRelease(signingCertificateSHA256Digest).replaceAll("[^a-zA-Z]", "_").toUpperCase();
} else {
TERMUX_APK_RELEASE = termuxAPKReleaseOld;
}
}
}
TERMUX_API_VERSION_NAME = null;
// Check if Termux:API app is installed and not disabled
if (TermuxUtils.isTermuxAPIAppInstalled(currentPackageContext) == null) {
// This function may be called by a different package like a plugin, so we get version for Termux:API package via its context
Context termuxAPIPackageContext = TermuxUtils.getTermuxAPIPackageContext(currentPackageContext);
if (termuxAPIPackageContext != null)
TERMUX_API_VERSION_NAME = PackageUtils.getVersionNameForPackage(termuxAPIPackageContext);
}
}
}

View file

@ -0,0 +1,315 @@
package com.termux.shared.shell;
import android.content.Context;
import android.system.ErrnoException;
import android.system.Os;
import android.system.OsConstants;
import androidx.annotation.NonNull;
import com.termux.shared.R;
import com.termux.shared.data.DataUtils;
import com.termux.shared.models.ExecutionCommand;
import com.termux.shared.models.ResultData;
import com.termux.shared.models.errors.Errno;
import com.termux.shared.logger.Logger;
import com.termux.shared.models.ExecutionCommand.ExecutionState;
import java.io.DataOutputStream;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
/**
* A class that maintains info for background Termux tasks 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.
*/
public final class TermuxTask {
private final Process mProcess;
private final ExecutionCommand mExecutionCommand;
private final TermuxTaskClient mTermuxTaskClient;
private static final String LOG_TAG = "TermuxTask";
private TermuxTask(@NonNull final Process process, @NonNull final ExecutionCommand executionCommand,
final TermuxTaskClient termuxTaskClient) {
this.mProcess = process;
this.mExecutionCommand = executionCommand;
this.mTermuxTaskClient = termuxTaskClient;
}
/**
* 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 context The {@link Context} for operations.
* @param executionCommand The {@link ExecutionCommand} containing the information for execution command.
* @param termuxTaskClient The {@link TermuxTaskClient} interface implementation.
* The {@link TermuxTaskClient#onTermuxTaskExited(TermuxTask)} 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 ShellEnvironmentClient} interface implementation.
* @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 TermuxTask} 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 TermuxTask}. This will be {@code null} if failed to start the execution command.
*/
public static TermuxTask execute(@NonNull final Context context, @NonNull ExecutionCommand executionCommand,
final TermuxTaskClient termuxTaskClient,
@NonNull final ShellEnvironmentClient shellEnvironmentClient,
final boolean isSynchronous) {
if (executionCommand.workingDirectory == null || executionCommand.workingDirectory.isEmpty())
executionCommand.workingDirectory = shellEnvironmentClient.getDefaultWorkingDirectoryPath();
if (executionCommand.workingDirectory.isEmpty())
executionCommand.workingDirectory = "/";
String[] env = shellEnvironmentClient.buildEnvironment(context, false, executionCommand.workingDirectory);
final String[] commandArray = shellEnvironmentClient.setupProcessArgs(executionCommand.executable, executionCommand.arguments);
if (!executionCommand.setState(ExecutionState.EXECUTING)) {
executionCommand.setStateFailed(Errno.ERRNO_FAILED.getCode(), context.getString(R.string.error_failed_to_execute_termux_task_command, executionCommand.getCommandIdAndLabelLogString()));
TermuxTask.processTermuxTaskResult(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)));
String taskName = ShellUtils.getExecutableBasename(executionCommand.executable);
if (executionCommand.commandLabel == null)
executionCommand.commandLabel = taskName;
// Exec the process
final Process process;
try {
process = Runtime.getRuntime().exec(commandArray, env, new File(executionCommand.workingDirectory));
} catch (IOException e) {
executionCommand.setStateFailed(Errno.ERRNO_FAILED.getCode(), context.getString(R.string.error_failed_to_execute_termux_task_command, executionCommand.getCommandIdAndLabelLogString()), e);
TermuxTask.processTermuxTaskResult(null, executionCommand);
return null;
}
final TermuxTask termuxTask = new TermuxTask(process, executionCommand, termuxTaskClient);
if (isSynchronous) {
try {
termuxTask.executeInner(context);
} catch (IllegalThreadStateException | InterruptedException e) {
// TODO: Should either of these be handled or returned?
}
} else {
new Thread() {
@Override
public void run() {
try {
termuxTask.executeInner(context);
} catch (IllegalThreadStateException | InterruptedException e) {
// TODO: Should either of these be handled or returned?
}
}
}.start();
}
return termuxTask;
}
/**
* 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 termuxTask}
* and then calls {@link #processTermuxTaskResult(TermuxTask, ExecutionCommand) to process the result}.
*
* @param context The {@link Context} for operations.
*/
private void executeInner(@NonNull final Context context) throws IllegalThreadStateException, InterruptedException {
final int pid = ShellUtils.getPid(mProcess);
Logger.logDebug(LOG_TAG, "Running \"" + mExecutionCommand.getCommandIdAndLabelLogString() + "\" TermuxTask with pid " + pid);
mExecutionCommand.resultData.exitCode = null;
// setup stdin, and stdout and stderr gobblers
DataOutputStream STDIN = new DataOutputStream(mProcess.getOutputStream());
StreamGobbler STDOUT = new StreamGobbler(pid + "-stdout", mProcess.getInputStream(), mExecutionCommand.resultData.stdout, mExecutionCommand.backgroundCustomLogLevel);
StreamGobbler STDERR = new StreamGobbler(pid + "-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_termux_task_command, mExecutionCommand.getCommandIdAndLabelLogString(), e.getMessage()), e);
mExecutionCommand.resultData.exitCode = 1;
TermuxTask.processTermuxTaskResult(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() + "\" TermuxTask with pid " + pid + " exited normally");
else
Logger.logDebug(LOG_TAG, "The \"" + mExecutionCommand.getCommandIdAndLabelLogString() + "\" TermuxTask with pid " + pid + " 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() + "\" TermuxTask state to ExecutionState.EXECUTED and processing results since it has already failed");
return;
}
mExecutionCommand.resultData.exitCode = exitCode;
if (!mExecutionCommand.setState(ExecutionState.EXECUTED))
return;
TermuxTask.processTermuxTaskResult(this, null);
}
/**
* Kill this {@link TermuxTask} 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 #processTermuxTaskResult(TermuxTask, 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() + "\" TermuxTask since it has already finished executing");
return;
}
Logger.logDebug(LOG_TAG, "Send SIGKILL to \"" + mExecutionCommand.getCommandIdAndLabelLogString() + "\" TermuxTask");
if (mExecutionCommand.setStateFailed(Errno.ERRNO_FAILED.getCode(), context.getString(R.string.error_sending_sigkill_to_process))) {
if (processResult) {
mExecutionCommand.resultData.exitCode = 137; // SIGKILL
TermuxTask.processTermuxTaskResult(this, null);
}
}
if (mExecutionCommand.isExecuting()) {
kill();
}
}
/**
* Kill this {@link TermuxTask} 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() + "\" TermuxTask with pid " + pid + ": " + e.getMessage());
}
}
/**
* Process the results of {@link TermuxTask} or {@link ExecutionCommand}.
*
* Only one of {@code termuxTask} and {@code executionCommand} must be set.
*
* If the {@code termuxTask} and its {@link #mTermuxTaskClient} are not {@code null},
* then the {@link TermuxTaskClient#onTermuxTaskExited(TermuxTask)} callback will be called.
*
* @param termuxTask The {@link TermuxTask}, which should be set if
* {@link #execute(Context, ExecutionCommand, TermuxTaskClient, ShellEnvironmentClient, boolean)}
* successfully started the process.
* @param executionCommand The {@link ExecutionCommand}, which should be set if
* {@link #execute(Context, ExecutionCommand, TermuxTaskClient, ShellEnvironmentClient, boolean)}
* failed to start the process.
*/
private static void processTermuxTaskResult(final TermuxTask termuxTask, ExecutionCommand executionCommand) {
if (termuxTask != null)
executionCommand = termuxTask.mExecutionCommand;
if (executionCommand == null) return;
if (executionCommand.shouldNotProcessResults()) {
Logger.logDebug(LOG_TAG, "Ignoring duplicate call to process \"" + executionCommand.getCommandIdAndLabelLogString() + "\" TermuxTask result");
return;
}
Logger.logDebug(LOG_TAG, "Processing \"" + executionCommand.getCommandIdAndLabelLogString() + "\" TermuxTask result");
if (termuxTask != null && termuxTask.mTermuxTaskClient != null) {
termuxTask.mTermuxTaskClient.onTermuxTaskExited(termuxTask);
} 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 termuxTask
if (!executionCommand.isStateFailed())
executionCommand.setState(ExecutionCommand.ExecutionState.SUCCESS);
}
}
public Process getProcess() {
return mProcess;
}
public ExecutionCommand getExecutionCommand() {
return mExecutionCommand;
}
public interface TermuxTaskClient {
/**
* Callback function for when {@link TermuxTask} exits.
*
* @param termuxTask The {@link TermuxTask} that exited.
*/
void onTermuxTaskExited(TermuxTask termuxTask);
}
}

View file

@ -0,0 +1,88 @@
package com.termux.shared.terminal;
import com.termux.shared.logger.Logger;
import com.termux.terminal.TerminalSession;
import com.termux.terminal.TerminalSessionClient;
public class TermuxTerminalSessionClientBase implements TerminalSessionClient {
public TermuxTerminalSessionClientBase() {
}
@Override
public void onTextChanged(TerminalSession changedSession) {
}
@Override
public void onTitleChanged(TerminalSession updatedSession) {
}
@Override
public void onSessionFinished(final TerminalSession finishedSession) {
}
@Override
public void onCopyTextToClipboard(TerminalSession session, String text) {
}
@Override
public void onPasteTextFromClipboard(TerminalSession session) {
}
@Override
public void onBell(TerminalSession session) {
}
@Override
public void onColorsChanged(TerminalSession changedSession) {
}
@Override
public void onTerminalCursorStateChange(boolean state) {
}
@Override
public Integer getTerminalCursorStyle() {
return null;
}
@Override
public void logError(String tag, String message) {
Logger.logError(tag, message);
}
@Override
public void logWarn(String tag, String message) {
Logger.logWarn(tag, message);
}
@Override
public void logInfo(String tag, String message) {
Logger.logInfo(tag, message);
}
@Override
public void logDebug(String tag, String message) {
Logger.logDebug(tag, message);
}
@Override
public void logVerbose(String tag, String message) {
Logger.logVerbose(tag, message);
}
@Override
public void logStackTraceWithMessage(String tag, String message, Exception e) {
Logger.logStackTraceWithMessage(tag, message, e);
}
@Override
public void logStackTrace(String tag, Exception e) {
Logger.logStackTrace(tag, e);
}
}

View file

@ -0,0 +1,127 @@
package com.termux.shared.terminal;
import android.view.KeyEvent;
import android.view.MotionEvent;
import com.termux.shared.logger.Logger;
import com.termux.terminal.TerminalSession;
import com.termux.view.TerminalViewClient;
public class TermuxTerminalViewClientBase implements TerminalViewClient {
public TermuxTerminalViewClientBase() {
}
@Override
public float onScale(float scale) {
return 1.0f;
}
@Override
public void onSingleTapUp(MotionEvent e) {
}
public boolean shouldBackButtonBeMappedToEscape() {
return false;
}
public boolean shouldEnforceCharBasedInput() {
return false;
}
public boolean shouldUseCtrlSpaceWorkaround() {
return false;
}
@Override
public boolean isTerminalViewSelected() {
return true;
}
@Override
public void copyModeChanged(boolean copyMode) {
}
@Override
public boolean onKeyDown(int keyCode, KeyEvent e, TerminalSession session) {
return false;
}
@Override
public boolean onKeyUp(int keyCode, KeyEvent e) {
return false;
}
@Override
public boolean onLongPress(MotionEvent event) {
return false;
}
@Override
public boolean readControlKey() {
return false;
}
@Override
public boolean readAltKey() {
return false;
}
@Override
public boolean readShiftKey() {
return false;
}
@Override
public boolean readFnKey() {
return false;
}
@Override
public boolean onCodePoint(int codePoint, boolean ctrlDown, TerminalSession session) {
return false;
}
@Override
public void onEmulatorSet() {
}
@Override
public void logError(String tag, String message) {
Logger.logError(tag, message);
}
@Override
public void logWarn(String tag, String message) {
Logger.logWarn(tag, message);
}
@Override
public void logInfo(String tag, String message) {
Logger.logInfo(tag, message);
}
@Override
public void logDebug(String tag, String message) {
Logger.logDebug(tag, message);
}
@Override
public void logVerbose(String tag, String message) {
Logger.logVerbose(tag, message);
}
@Override
public void logStackTraceWithMessage(String tag, String message, Exception e) {
Logger.logStackTraceWithMessage(tag, message, e);
}
@Override
public void logStackTrace(String tag, Exception e) {
Logger.logStackTrace(tag, e);
}
}

View file

@ -0,0 +1,79 @@
package com.termux.shared.terminal.io;
import android.content.Context;
import android.os.Build;
import android.os.Handler;
import android.os.Looper;
import android.os.SystemClock;
import android.os.VibrationEffect;
import android.os.Vibrator;
import com.termux.shared.logger.Logger;
public class BellHandler {
private static BellHandler instance = null;
private static final Object lock = new Object();
private static final String LOG_TAG = "BellHandler";
public static BellHandler getInstance(Context context) {
if (instance == null) {
synchronized (lock) {
if (instance == null) {
instance = new BellHandler((Vibrator) context.getApplicationContext().getSystemService(Context.VIBRATOR_SERVICE));
}
}
}
return instance;
}
private static final long DURATION = 50;
private static final long MIN_PAUSE = 3 * DURATION;
private final Handler handler = new Handler(Looper.getMainLooper());
private long lastBell = 0;
private final Runnable bellRunnable;
private BellHandler(final Vibrator vibrator) {
bellRunnable = new Runnable() {
@Override
public void run() {
if (vibrator != null) {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
vibrator.vibrate(VibrationEffect.createOneShot(DURATION, VibrationEffect.DEFAULT_AMPLITUDE));
} else {
vibrator.vibrate(DURATION);
}
} catch (Exception e) {
// Issue on samsung devices on android 8
// java.lang.NullPointerException: Attempt to read from field 'android.os.VibrationEffect com.android.server.VibratorService$Vibration.mEffect' on a null object reference
Logger.logStackTraceWithMessage(LOG_TAG, "Failed to run vibrator", e);
}
}
}
};
}
public synchronized void doBell() {
long now = now();
long timeSinceLastBell = now - lastBell;
if (timeSinceLastBell < 0) {
// there is a next bell pending; don't schedule another one
} else if (timeSinceLastBell < MIN_PAUSE) {
// there was a bell recently, schedule the next one
handler.postDelayed(bellRunnable, MIN_PAUSE - timeSinceLastBell);
lastBell = lastBell + MIN_PAUSE;
} else {
// the last bell was long ago, do it now
bellRunnable.run();
lastBell = now;
}
}
private long now() {
return SystemClock.uptimeMillis();
}
}

View file

@ -0,0 +1,77 @@
package com.termux.shared.terminal.io;
import android.view.KeyEvent;
import android.view.View;
import android.widget.Button;
import androidx.annotation.NonNull;
import com.termux.shared.terminal.io.extrakeys.ExtraKeyButton;
import com.termux.shared.terminal.io.extrakeys.ExtraKeysView;
import com.termux.shared.terminal.io.extrakeys.SpecialButton;
import com.termux.view.TerminalView;
import static com.termux.shared.terminal.io.extrakeys.ExtraKeysConstants.PRIMARY_KEY_CODES_FOR_STRINGS;
public class TerminalExtraKeys implements ExtraKeysView.IExtraKeysView {
private final TerminalView mTerminalView;
public TerminalExtraKeys(@NonNull TerminalView terminalView) {
mTerminalView = terminalView;
}
@Override
public void onExtraKeyButtonClick(View view, ExtraKeyButton buttonInfo, Button button) {
if (buttonInfo.isMacro()) {
String[] keys = buttonInfo.getKey().split(" ");
boolean ctrlDown = false;
boolean altDown = false;
boolean shiftDown = false;
boolean fnDown = false;
for (String key : keys) {
if (SpecialButton.CTRL.getKey().equals(key)) {
ctrlDown = true;
} else if (SpecialButton.ALT.getKey().equals(key)) {
altDown = true;
} else if (SpecialButton.SHIFT.getKey().equals(key)) {
shiftDown = true;
} else if (SpecialButton.FN.getKey().equals(key)) {
fnDown = true;
} else {
onTerminalExtraKeyButtonClick(view, key, ctrlDown, altDown, shiftDown, fnDown);
ctrlDown = false; altDown = false; shiftDown = false; fnDown = false;
}
}
} else {
onTerminalExtraKeyButtonClick(view, buttonInfo.getKey(), false, false, false, false);
}
}
protected void onTerminalExtraKeyButtonClick(View view, String key, boolean ctrlDown, boolean altDown, boolean shiftDown, boolean fnDown) {
if (PRIMARY_KEY_CODES_FOR_STRINGS.containsKey(key)) {
Integer keyCode = PRIMARY_KEY_CODES_FOR_STRINGS.get(key);
if (keyCode == null) return;
int metaState = 0;
if (ctrlDown) metaState |= KeyEvent.META_CTRL_ON | KeyEvent.META_CTRL_LEFT_ON;
if (altDown) metaState |= KeyEvent.META_ALT_ON | KeyEvent.META_ALT_LEFT_ON;
if (shiftDown) metaState |= KeyEvent.META_SHIFT_ON | KeyEvent.META_SHIFT_LEFT_ON;
if (fnDown) metaState |= KeyEvent.META_FUNCTION_ON;
KeyEvent keyEvent = new KeyEvent(0, 0, KeyEvent.ACTION_UP, keyCode, 0, metaState);
mTerminalView.onKeyDown(keyCode, keyEvent);
} else {
// not a control char
key.codePoints().forEach(codePoint -> {
mTerminalView.inputCodePoint(codePoint, ctrlDown, altDown);
});
}
}
@Override
public boolean performExtraKeyButtonHapticFeedback(View view, ExtraKeyButton buttonInfo, Button button) {
return false;
}
}

View file

@ -0,0 +1,151 @@
package com.termux.shared.terminal.io.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);
}
}

View file

@ -0,0 +1,211 @@
package com.termux.shared.terminal.io.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<String> PRIMARY_REPETITIVE_KEYS = Arrays.asList(
"UP", "DOWN", "LEFT", "RIGHT",
"BKSP", "DEL",
"PGUP", "PGDN");
/** Defines the {@link KeyEvent} for common keys. */
public static Map<String, Integer> PRIMARY_KEY_CODES_FOR_STRINGS = new HashMap<String, Integer>() {{
put("SPACE", KeyEvent.KEYCODE_SPACE);
put("ESC", KeyEvent.KEYCODE_ESCAPE);
put("TAB", KeyEvent.KEYCODE_TAB);
put("HOME", KeyEvent.KEYCODE_MOVE_HOME);
put("END", KeyEvent.KEYCODE_MOVE_END);
put("PGUP", KeyEvent.KEYCODE_PAGE_UP);
put("PGDN", KeyEvent.KEYCODE_PAGE_DOWN);
put("INS", KeyEvent.KEYCODE_INSERT);
put("DEL", KeyEvent.KEYCODE_FORWARD_DEL);
put("BKSP", KeyEvent.KEYCODE_DEL);
put("UP", KeyEvent.KEYCODE_DPAD_UP);
put("LEFT", KeyEvent.KEYCODE_DPAD_LEFT);
put("RIGHT", KeyEvent.KEYCODE_DPAD_RIGHT);
put("DOWN", KeyEvent.KEYCODE_DPAD_DOWN);
put("ENTER", KeyEvent.KEYCODE_ENTER);
put("F1", KeyEvent.KEYCODE_F1);
put("F2", KeyEvent.KEYCODE_F2);
put("F3", KeyEvent.KEYCODE_F3);
put("F4", KeyEvent.KEYCODE_F4);
put("F5", KeyEvent.KEYCODE_F5);
put("F6", KeyEvent.KEYCODE_F6);
put("F7", KeyEvent.KEYCODE_F7);
put("F8", KeyEvent.KEYCODE_F8);
put("F9", KeyEvent.KEYCODE_F9);
put("F10", KeyEvent.KEYCODE_F10);
put("F11", KeyEvent.KEYCODE_F11);
put("F12", KeyEvent.KEYCODE_F12);
}};
/**
* HashMap that implements Python dict.get(key, default) function.
* Default java.util .get(key) is then the same as .get(key, null);
*/
static class CleverMap<K,V> extends HashMap<K,V> {
V get(K key, V defaultValue) {
if (containsKey(key))
return get(key);
else
return defaultValue;
}
}
public static class ExtraKeyDisplayMap extends CleverMap<String, String> {}
/*
* Multiple maps are available to quickly change
* the style of the keys.
*/
public static class EXTRA_KEY_DISPLAY_MAPS {
/**
* Keys are displayed in a natural looking way, like "" for "RIGHT"
*/
public static final ExtraKeyDisplayMap CLASSIC_ARROWS_DISPLAY = new ExtraKeyDisplayMap() {{
// classic arrow keys (for @see arrowVariationDisplay)
put("LEFT", ""); // U+2190 LEFTWARDS ARROW
put("RIGHT", ""); // U+2192 RIGHTWARDS ARROW
put("UP", ""); // U+2191 UPWARDS ARROW
put("DOWN", ""); // U+2193 DOWNWARDS ARROW
}};
public static final ExtraKeyDisplayMap WELL_KNOWN_CHARACTERS_DISPLAY = new ExtraKeyDisplayMap() {{
// well known characters // https://en.wikipedia.org/wiki/{Enter_key, Tab_key, Delete_key}
put("ENTER", ""); // U+21B2 DOWNWARDS ARROW WITH TIP LEFTWARDS
put("TAB", ""); // U+21B9 LEFTWARDS ARROW TO BAR OVER RIGHTWARDS ARROW TO BAR
put("BKSP", ""); // U+232B ERASE TO THE LEFT sometimes seen and easy to understand
put("DEL", ""); // U+2326 ERASE TO THE RIGHT not well known but easy to understand
put("DRAWER", ""); // U+2630 TRIGRAM FOR HEAVEN not well known but easy to understand
put("KEYBOARD", ""); // U+2328 KEYBOARD not well known but easy to understand
put("PASTE", ""); // U+2398
}};
public static final ExtraKeyDisplayMap LESS_KNOWN_CHARACTERS_DISPLAY = new ExtraKeyDisplayMap() {{
// https://en.wikipedia.org/wiki/{Home_key, End_key, Page_Up_and_Page_Down_keys}
// home key can mean "goto the beginning of line" or "goto first page" depending on context, hence the diagonal
put("HOME", ""); // from IEC 9995 // U+21F1 NORTH WEST ARROW TO CORNER
put("END", ""); // from IEC 9995 // // U+21F2 SOUTH EAST ARROW TO CORNER
put("PGUP", ""); // no ISO character exists, U+21D1 UPWARDS DOUBLE ARROW will do the trick
put("PGDN", ""); // no ISO character exists, U+21D3 DOWNWARDS DOUBLE ARROW will do the trick
}};
public static final ExtraKeyDisplayMap ARROW_TRIANGLE_VARIATION_DISPLAY = new ExtraKeyDisplayMap() {{
// alternative to classic arrow keys
put("LEFT", ""); // U+25C0 BLACK LEFT-POINTING TRIANGLE
put("RIGHT", ""); // U+25B6 BLACK RIGHT-POINTING TRIANGLE
put("UP", ""); // U+25B2 BLACK UP-POINTING TRIANGLE
put("DOWN", ""); // U+25BC BLACK DOWN-POINTING TRIANGLE
}};
public static final ExtraKeyDisplayMap NOT_KNOWN_ISO_CHARACTERS = new ExtraKeyDisplayMap() {{
// Control chars that are more clear as text // https://en.wikipedia.org/wiki/{Function_key, Alt_key, Control_key, Esc_key}
// put("FN", "FN"); // no ISO character exists
put("CTRL", ""); // ISO character "U+2388 ⎈ HELM SYMBOL" is unknown to people and never printed on computers, however "U+25C7 ◇ WHITE DIAMOND" is a nice presentation, and "^" for terminal app and mac is often used
put("ALT", ""); // ISO character "U+2387 ⎇ ALTERNATIVE KEY SYMBOL'" is unknown to people and only printed as the Option key "" on Mac computer
put("ESC", ""); // ISO character "U+238B ⎋ BROKEN CIRCLE WITH NORTHWEST ARROW" is unknown to people and not often printed on computers
}};
public static final ExtraKeyDisplayMap NICER_LOOKING_DISPLAY = new ExtraKeyDisplayMap() {{
// nicer looking for most cases
put("-", ""); // U+2015 HORIZONTAL BAR
}};
/**
* Full Iso
*/
public static final ExtraKeyDisplayMap FULL_ISO_CHAR_DISPLAY = new ExtraKeyDisplayMap() {{
putAll(CLASSIC_ARROWS_DISPLAY);
putAll(WELL_KNOWN_CHARACTERS_DISPLAY);
putAll(LESS_KNOWN_CHARACTERS_DISPLAY); // NEW
putAll(NICER_LOOKING_DISPLAY);
putAll(NOT_KNOWN_ISO_CHARACTERS); // NEW
}};
/**
* Only arrows
*/
public static final ExtraKeyDisplayMap ARROWS_ONLY_CHAR_DISPLAY = new ExtraKeyDisplayMap() {{
putAll(CLASSIC_ARROWS_DISPLAY);
// putAll(wellKnownCharactersDisplay); // REMOVED
// putAll(lessKnownCharactersDisplay); // REMOVED
putAll(NICER_LOOKING_DISPLAY);
}};
/**
* Classic symbols and less known symbols
*/
public static final ExtraKeyDisplayMap LOTS_OF_ARROWS_CHAR_DISPLAY = new ExtraKeyDisplayMap() {{
putAll(CLASSIC_ARROWS_DISPLAY);
putAll(WELL_KNOWN_CHARACTERS_DISPLAY);
putAll(LESS_KNOWN_CHARACTERS_DISPLAY); // NEW
putAll(NICER_LOOKING_DISPLAY);
}};
/**
* Some classic symbols everybody knows
*/
public static final ExtraKeyDisplayMap DEFAULT_CHAR_DISPLAY = new ExtraKeyDisplayMap() {{
putAll(CLASSIC_ARROWS_DISPLAY);
putAll(WELL_KNOWN_CHARACTERS_DISPLAY);
putAll(NICER_LOOKING_DISPLAY);
// all other characters are displayed as themselves
}};
}
/**
* Aliases for the keys
*/
public static final ExtraKeyDisplayMap CONTROL_CHARS_ALIASES = new ExtraKeyDisplayMap() {{
put("ESCAPE", "ESC");
put("CONTROL", "CTRL");
put("SHFT", "SHIFT");
put("RETURN", "ENTER"); // Technically different keys, but most applications won't see the difference
put("FUNCTION", "FN");
// no alias for ALT
// Directions are sometimes written as first and last letter for brevety
put("LT", "LEFT");
put("RT", "RIGHT");
put("DN", "DOWN");
// put("UP", "UP"); well, "UP" is already two letters
put("PAGEUP", "PGUP");
put("PAGE_UP", "PGUP");
put("PAGE UP", "PGUP");
put("PAGE-UP", "PGUP");
// no alias for HOME
// no alias for END
put("PAGEDOWN", "PGDN");
put("PAGE_DOWN", "PGDN");
put("PAGE-DOWN", "PGDN");
put("DELETE", "DEL");
put("BACKSPACE", "BKSP");
// easier for writing in termux.properties
put("BACKSLASH", "\\");
put("QUOTE", "\"");
put("APOSTROPHE", "'");
}};
}

View file

@ -0,0 +1,211 @@
package com.termux.shared.terminal.io.extrakeys;
import android.view.View;
import android.widget.Button;
import androidx.annotation.NonNull;
import com.termux.shared.terminal.io.extrakeys.ExtraKeysConstants.EXTRA_KEY_DISPLAY_MAPS;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
/**
* A {@link Class} that defines the info needed by {@link ExtraKeysView} to display the extra key
* views.
*
* The {@code propertiesInfo} passed to the constructors of this class must be json array of arrays.
* Each array element of the json array will be considered a separate row of keys.
* Each key can either be simple string that defines the name of the key or a json dict that defines
* advance info for the key. The syntax can be `'KEY'` or `{key: 'KEY'}`.
* For example `HOME` or `{key: 'HOME', ...}.
*
* In advance json dict mode, the key can also be a sequence of space separated keys instead of one
* key. This can be done by replacing `key` key/value pair of the dict with a `macro` key/value pair.
* The syntax is `{macro: 'KEY COMBINATION'}`. For example {macro: 'HOME RIGHT', ...}.
*
* In advance json dict mode, you can define a nested json dict with the `popup` key which will be
* used as the popup key and will be triggered on swipe up. The syntax can be
* `{key: 'KEY', popup: 'POPUP_KEY'}` or `{key: 'KEY', popup: {macro: 'KEY COMBINATION', display: 'Key combo'}}`.
* For example `{key: 'HOME', popup: {KEY: 'END', ...}, ...}`.
*
* In advance json dict mode, the key can also have a custom display name that can be used as the
* text to display on the button by defining the `display` key. The syntax is `{display: 'DISPLAY'}`.
* For example {display: 'Custom name', ...}.
*
* Examples:
* {@code
* # Empty:
* []
*
* # Single row:
* [[ESC, TAB, CTRL, ALT, {key: '-', popup: '|'}, DOWN, UP]]
*
* # 2 row:
* [['ESC','/',{key: '-', popup: '|'},'HOME','UP','END','PGUP'],
* ['TAB','CTRL','ALT','LEFT','DOWN','RIGHT','PGDN']]
*
* # Advance:
* [[
* {key: ESC, popup: {macro: "CTRL f d", display: "tmux exit"}},
* {key: CTRL, popup: {macro: "CTRL f BKSP", display: "tmux ←"}},
* {key: ALT, popup: {macro: "CTRL f TAB", display: "tmux →"}},
* {key: TAB, popup: {macro: "ALT a", display: A-a}},
* {key: LEFT, popup: HOME},
* {key: DOWN, popup: PGDN},
* {key: UP, popup: PGUP},
* {key: RIGHT, popup: END},
* {macro: "ALT j", display: A-j, popup: {macro: "ALT g", display: A-g}},
* {key: KEYBOARD, popup: {macro: "CTRL d", display: exit}}
* ]]
*
* }
*
* Aliases are also allowed for the keys that you can pass as {@code extraKeyAliasMap}. Check
* {@link ExtraKeysConstants#CONTROL_CHARS_ALIASES}.
*
* Its up to the {@link ExtraKeysView.IExtraKeysView} client on how to handle individual key values
* of an {@link ExtraKeyButton}. They are sent as is via
* {@link ExtraKeysView.IExtraKeysView#onExtraKeyButtonClick(View, ExtraKeyButton, Button)}. The
* {@link com.termux.shared.terminal.io.TerminalExtraKeys} which is an implementation of the interface,
* checks if the key is one of {@link ExtraKeysConstants#PRIMARY_KEY_CODES_FOR_STRINGS} and generates
* a {@link android.view.KeyEvent} for it, and if its not, then converts the key to code points by
* calling {@link CharSequence#codePoints()} and passes them to the terminal as literal strings.
*
* Examples:
* {@code
* "ENTER" will trigger the ENTER keycode
* "LEFT" will trigger the LEFT keycode and be displayed as ""
* "" will input a "" character
* "" will input a "" character
* "-_-" will input the string "-_-"
* }
*
* For more info, check https://wiki.termux.com/wiki/Touch_Keyboard.
*/
public class ExtraKeysInfo {
/**
* Matrix of buttons to be displayed in {@link ExtraKeysView}.
*/
private final ExtraKeyButton[][] mButtons;
/**
* Initialize {@link ExtraKeysInfo}.
*
* @param propertiesInfo The {@link String} containing the info to create the {@link ExtraKeysInfo}.
* Check the class javadoc for details.
* @param style The style to pass to {@link #getCharDisplayMapForStyle(String)} to get the
* {@link ExtraKeysConstants.ExtraKeyDisplayMap} that defines the display text
* mapping for the keys if a custom value is not defined by
* {@link ExtraKeyButton#KEY_DISPLAY_NAME} for a key.
* @param extraKeyAliasMap The {@link ExtraKeysConstants.ExtraKeyDisplayMap} that defines the
* aliases for the actual key names. You can create your own or
* optionally pass {@link ExtraKeysConstants#CONTROL_CHARS_ALIASES}.
*/
public ExtraKeysInfo(@NonNull String propertiesInfo, String style,
@NonNull ExtraKeysConstants.ExtraKeyDisplayMap extraKeyAliasMap) throws JSONException {
mButtons = initExtraKeysInfo(propertiesInfo, getCharDisplayMapForStyle(style), extraKeyAliasMap);
}
/**
* Initialize {@link ExtraKeysInfo}.
*
* @param propertiesInfo The {@link String} containing the info to create the {@link ExtraKeysInfo}.
* Check the class javadoc for details.
* @param extraKeyDisplayMap The {@link ExtraKeysConstants.ExtraKeyDisplayMap} that defines the
* display text mapping for the keys if a custom value is not defined
* by {@link ExtraKeyButton#KEY_DISPLAY_NAME} for a key. You can create
* your own or optionally pass one of the values defined in
* {@link #getCharDisplayMapForStyle(String)}.
* @param extraKeyAliasMap The {@link ExtraKeysConstants.ExtraKeyDisplayMap} that defines the
* aliases for the actual key names. You can create your own or
* optionally pass {@link ExtraKeysConstants#CONTROL_CHARS_ALIASES}.
*/
public ExtraKeysInfo(@NonNull String propertiesInfo,
@NonNull ExtraKeysConstants.ExtraKeyDisplayMap extraKeyDisplayMap,
@NonNull ExtraKeysConstants.ExtraKeyDisplayMap extraKeyAliasMap) throws JSONException {
mButtons = initExtraKeysInfo(propertiesInfo, extraKeyDisplayMap, extraKeyAliasMap);
}
private ExtraKeyButton[][] initExtraKeysInfo(@NonNull String propertiesInfo,
@NonNull ExtraKeysConstants.ExtraKeyDisplayMap extraKeyDisplayMap,
@NonNull ExtraKeysConstants.ExtraKeyDisplayMap extraKeyAliasMap) throws JSONException {
// Convert String propertiesInfo to Array of Arrays
JSONArray arr = new JSONArray(propertiesInfo);
Object[][] matrix = new Object[arr.length()][];
for (int i = 0; i < arr.length(); i++) {
JSONArray line = arr.getJSONArray(i);
matrix[i] = new Object[line.length()];
for (int j = 0; j < line.length(); j++) {
matrix[i][j] = line.get(j);
}
}
// convert matrix to buttons
ExtraKeyButton[][] buttons = new ExtraKeyButton[matrix.length][];
for (int i = 0; i < matrix.length; i++) {
buttons[i] = new ExtraKeyButton[matrix[i].length];
for (int j = 0; j < matrix[i].length; j++) {
Object key = matrix[i][j];
JSONObject jobject = normalizeKeyConfig(key);
ExtraKeyButton button;
if (!jobject.has(ExtraKeyButton.KEY_POPUP)) {
// no popup
button = new ExtraKeyButton(jobject, extraKeyDisplayMap, extraKeyAliasMap);
} else {
// a popup
JSONObject popupJobject = normalizeKeyConfig(jobject.get(ExtraKeyButton.KEY_POPUP));
ExtraKeyButton popup = new ExtraKeyButton(popupJobject, extraKeyDisplayMap, extraKeyAliasMap);
button = new ExtraKeyButton(jobject, popup, extraKeyDisplayMap, extraKeyAliasMap);
}
buttons[i][j] = button;
}
}
return buttons;
}
/**
* Convert "value" -> {"key": "value"}. Required by
* {@link ExtraKeyButton#ExtraKeyButton(JSONObject, ExtraKeyButton, ExtraKeysConstants.ExtraKeyDisplayMap, ExtraKeysConstants.ExtraKeyDisplayMap)}.
*/
private static JSONObject normalizeKeyConfig(Object key) throws JSONException {
JSONObject jobject;
if (key instanceof String) {
jobject = new JSONObject();
jobject.put(ExtraKeyButton.KEY_KEY_NAME, key);
} else if (key instanceof JSONObject) {
jobject = (JSONObject) key;
} else {
throw new JSONException("An key in the extra-key matrix must be a string or an object");
}
return jobject;
}
public ExtraKeyButton[][] getMatrix() {
return mButtons;
}
@NonNull
public static ExtraKeysConstants.ExtraKeyDisplayMap getCharDisplayMapForStyle(String style) {
switch (style) {
case "arrows-only":
return EXTRA_KEY_DISPLAY_MAPS.ARROWS_ONLY_CHAR_DISPLAY;
case "arrows-all":
return EXTRA_KEY_DISPLAY_MAPS.LOTS_OF_ARROWS_CHAR_DISPLAY;
case "all":
return EXTRA_KEY_DISPLAY_MAPS.FULL_ISO_CHAR_DISPLAY;
case "none":
return new ExtraKeysConstants.ExtraKeyDisplayMap();
default:
return EXTRA_KEY_DISPLAY_MAPS.DEFAULT_CHAR_DISPLAY;
}
}
}

View file

@ -0,0 +1,656 @@
package com.termux.shared.terminal.io.extrakeys;
import android.annotation.SuppressLint;
import android.content.Context;
import android.os.Build;
import android.os.Handler;
import android.os.Looper;
import android.provider.Settings;
import android.util.AttributeSet;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.ScheduledExecutorService;
import java.util.Map;
import java.util.HashMap;
import java.util.stream.Collectors;
import android.view.HapticFeedbackConstants;
import android.view.LayoutInflater;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewConfiguration;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.GridLayout;
import android.widget.PopupWindow;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
/**
* A {@link View} showing extra keys (such as Escape, Ctrl, Alt) not normally available on an Android soft
* keyboards.
*
* To use it, add following to a layout file and import it in your activity layout file or inflate
* it with a {@link androidx.viewpager.widget.ViewPager}.:
* {@code
* <?xml version="1.0" encoding="utf-8"?>
* <com.termux.shared.terminal.io.extrakeys.ExtraKeysView xmlns:android="http://schemas.android.com/apk/res/android"
* android:id="@+id/extra_keys"
* style="?android:attr/buttonBarStyle"
* android:layout_width="match_parent"
* android:layout_height="match_parent"
* android:layout_alignParentBottom="true"
* android:orientation="horizontal" />
* }
*
* Then in your activity, get its reference by a call to {@link android.app.Activity#findViewById(int)}
* or {@link LayoutInflater#inflate(int, ViewGroup)} if using {@link androidx.viewpager.widget.ViewPager}.
* Then call {@link #setExtraKeysViewClient(IExtraKeysView)} and pass it the implementation of
* {@link IExtraKeysView} so that you can receive callbacks. You can also override other values set
* in {@link ExtraKeysView#ExtraKeysView(Context, AttributeSet)} by calling the respective functions.
* If you extend {@link ExtraKeysView}, you can also set them in the constructor, but do call super().
*
* After this you will have to make a call to {@link ExtraKeysView#reload(ExtraKeysInfo) and pass
* it the {@link ExtraKeysInfo} to load and display the extra keys. Read its class javadocs for more
* info on how to create it.
*
* Termux app defines the view in res/layout/view_terminal_toolbar_extra_keys and
* inflates it in TerminalToolbarViewPager.instantiateItem() and sets the {@link ExtraKeysView} client
* and calls {@link ExtraKeysView#reload(ExtraKeysInfo).
* The {@link ExtraKeysInfo} is created by TermuxAppSharedProperties.setExtraKeys().
* Then its got and the view height is adjusted in TermuxActivity.setTerminalToolbarHeight().
* The client used is TermuxTerminalExtraKeys, which extends
* {@link com.termux.shared.terminal.io.TerminalExtraKeys} to handle Termux app specific logic and
* leave the rest to the super class.
*/
public final class ExtraKeysView extends GridLayout {
/** The client for the {@link ExtraKeysView}. */
public interface IExtraKeysView {
/**
* This is called by {@link ExtraKeysView} when a button is clicked. This is also called
* for {@link #mRepetitiveKeys} and {@link ExtraKeyButton} that have a popup set.
* However, this is not called for {@link #mSpecialButtons}, whose state can instead be read
* via a call to {@link #readSpecialButton(SpecialButton, boolean)}.
*
* @param view The view that was clicked.
* @param buttonInfo The {@link ExtraKeyButton} for the button that was clicked.
* The button may be a {@link ExtraKeyButton#KEY_MACRO} set which can be
* checked with a call to {@link ExtraKeyButton#isMacro()}.
* @param button The {@link Button} that was clicked.
*/
void onExtraKeyButtonClick(View view, ExtraKeyButton buttonInfo, Button button);
/**
* This is called by {@link ExtraKeysView} when a button is clicked so that the client
* can perform any hepatic feedback. This is only called in the {@link Button.OnClickListener}
* and not for every repeat. Its also called for {@link #mSpecialButtons}.
*
* @param view The view that was clicked.
* @param buttonInfo The {@link ExtraKeyButton} for the button that was clicked.
* @param button The {@link Button} that was clicked.
* @return Return {@code true} if the client handled the feedback, otherwise {@code false}
* so that {@link ExtraKeysView#performExtraKeyButtonHapticFeedback(View, ExtraKeyButton, Button)}
* can handle it depending on system settings.
*/
boolean performExtraKeyButtonHapticFeedback(View view, ExtraKeyButton buttonInfo, Button button);
}
/** Defines the default value for {@link #mButtonTextColor}. */
public static final int DEFAULT_BUTTON_TEXT_COLOR = 0xFFFFFFFF;
/** Defines the default value for {@link #mButtonActiveTextColor}. */
public static final int DEFAULT_BUTTON_ACTIVE_TEXT_COLOR = 0xFF80DEEA;
/** Defines the default value for {@link #mButtonBackgroundColor}. */
public static final int DEFAULT_BUTTON_BACKGROUND_COLOR = 0x00000000;
/** Defines the default value for {@link #mButtonActiveBackgroundColor}. */
public static final int DEFAULT_BUTTON_ACTIVE_BACKGROUND_COLOR = 0xFF7F7F7F;
/** Defines the minimum allowed duration in milliseconds for {@link #mLongPressTimeout}. */
public static final int MIN_LONG_PRESS_DURATION = 200;
/** Defines the maximum allowed duration in milliseconds for {@link #mLongPressTimeout}. */
public static final int MAX_LONG_PRESS_DURATION = 3000;
/** Defines the fallback duration in milliseconds for {@link #mLongPressTimeout}. */
public static final int FALLBACK_LONG_PRESS_DURATION = 400;
/** Defines the minimum allowed duration in milliseconds for {@link #mLongPressRepeatDelay}. */
public static final int MIN_LONG_PRESS__REPEAT_DELAY = 5;
/** Defines the maximum allowed duration in milliseconds for {@link #mLongPressRepeatDelay}. */
public static final int MAX_LONG_PRESS__REPEAT_DELAY = 2000;
/** Defines the default duration in milliseconds for {@link #mLongPressRepeatDelay}. */
public static final int DEFAULT_LONG_PRESS_REPEAT_DELAY = 80;
/** The implementation of the {@link IExtraKeysView} that acts as a client for the {@link ExtraKeysView}. */
private IExtraKeysView mExtraKeysViewClient;
/** The map for the {@link SpecialButton} and their {@link SpecialButtonState}. Defaults to
* the one returned by {@link #getDefaultSpecialButtons(ExtraKeysView)}. */
private Map<SpecialButton, SpecialButtonState> mSpecialButtons;
/** The keys for the {@link SpecialButton} added to {@link #mSpecialButtons}. This is automatically
* set when the call to {@link #setSpecialButtons(Map)} is made. */
private Set<String> mSpecialButtonsKeys;
/**
* The list of keys for which auto repeat of key should be triggered if its extra keys button
* is long pressed. This is done by calling {@link IExtraKeysView#onExtraKeyButtonClick(View, ExtraKeyButton, Button)}
* every {@link #mLongPressRepeatDelay} seconds after {@link #mLongPressTimeout} has passed.
* The default keys are defined by {@link ExtraKeysConstants#PRIMARY_REPETITIVE_KEYS}.
*/
private List<String> mRepetitiveKeys;
/** The text color for the extra keys button. Defaults to {@link #DEFAULT_BUTTON_TEXT_COLOR}. */
private int mButtonTextColor;
/** The text color for the extra keys button when its active.
* Defaults to {@link #DEFAULT_BUTTON_ACTIVE_TEXT_COLOR}. */
private int mButtonActiveTextColor;
/** The background color for the extra keys button. Defaults to {@link #DEFAULT_BUTTON_BACKGROUND_COLOR}. */
private int mButtonBackgroundColor;
/** The background color for the extra keys button when its active. Defaults to
* {@link #DEFAULT_BUTTON_ACTIVE_BACKGROUND_COLOR}. */
private int mButtonActiveBackgroundColor;
/** Defines whether text for the extra keys button should be all capitalized automatically. */
private boolean mButtonTextAllCaps = true;
/**
* Defines the duration in milliseconds before a press turns into a long press. The default
* duration used is the one returned by a call to {@link ViewConfiguration#getLongPressTimeout()}
* which will return the system defined duration which can be changed in accessibility settings.
* The duration must be in between {@link #MIN_LONG_PRESS_DURATION} and {@link #MAX_LONG_PRESS_DURATION},
* otherwise {@link #FALLBACK_LONG_PRESS_DURATION} is used.
*/
private int mLongPressTimeout;
/**
* Defines the duration in milliseconds for the delay between trigger of each repeat of
* {@link #mRepetitiveKeys}. The default value is defined by {@link #DEFAULT_LONG_PRESS_REPEAT_DELAY}.
* The duration must be in between {@link #MIN_LONG_PRESS__REPEAT_DELAY} and
* {@link #MAX_LONG_PRESS__REPEAT_DELAY}, otherwise {@link #DEFAULT_LONG_PRESS_REPEAT_DELAY} is used.
*/
private int mLongPressRepeatDelay;
/** The popup window shown if {@link ExtraKeyButton#getPopup()} returns a {@code non-null} value
* and a swipe up action is done on an extra key. */
private PopupWindow mPopupWindow;
private ScheduledExecutorService mScheduledExecutor;
private Handler mHandler;
private SpecialButtonsLongHoldRunnable mSpecialButtonsLongHoldRunnable;
private int mLongPressCount;
public ExtraKeysView(Context context, AttributeSet attrs) {
super(context, attrs);
setRepetitiveKeys(ExtraKeysConstants.PRIMARY_REPETITIVE_KEYS);
setSpecialButtons(getDefaultSpecialButtons(this));
setButtonColors(DEFAULT_BUTTON_TEXT_COLOR, DEFAULT_BUTTON_ACTIVE_TEXT_COLOR,
DEFAULT_BUTTON_BACKGROUND_COLOR, DEFAULT_BUTTON_ACTIVE_BACKGROUND_COLOR);
setLongPressTimeout(ViewConfiguration.getLongPressTimeout());
setLongPressRepeatDelay(DEFAULT_LONG_PRESS_REPEAT_DELAY);
}
/** Get {@link #mExtraKeysViewClient}. */
public IExtraKeysView getExtraKeysViewClient() {
return mExtraKeysViewClient;
}
/** Set {@link #mExtraKeysViewClient}. */
public void setExtraKeysViewClient(IExtraKeysView extraKeysViewClient) {
mExtraKeysViewClient = extraKeysViewClient;
}
/** Get {@link #mRepetitiveKeys}. */
public List<String> getRepetitiveKeys() {
if (mRepetitiveKeys == null) return null;
return mRepetitiveKeys.stream().map(String::new).collect(Collectors.toList());
}
/** Set {@link #mRepetitiveKeys}. Must not be {@code null}. */
public void setRepetitiveKeys(@NonNull List<String> repetitiveKeys) {
mRepetitiveKeys = repetitiveKeys;
}
/** Get {@link #mSpecialButtons}. */
public Map<SpecialButton, SpecialButtonState> getSpecialButtons() {
if (mSpecialButtons == null) return null;
return mSpecialButtons.entrySet().stream().collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
}
/** Get {@link #mSpecialButtonsKeys}. */
public Set<String> getSpecialButtonsKeys() {
if (mSpecialButtonsKeys == null) return null;
return mSpecialButtonsKeys.stream().map(String::new).collect(Collectors.toSet());
}
/** Set {@link #mSpecialButtonsKeys}. Must not be {@code null}. */
public void setSpecialButtons(@NonNull Map<SpecialButton, SpecialButtonState> specialButtons) {
mSpecialButtons = specialButtons;
mSpecialButtonsKeys = this.mSpecialButtons.keySet().stream().map(SpecialButton::getKey).collect(Collectors.toSet());
}
/**
* Set the {@link ExtraKeysView} button colors.
*
* @param buttonTextColor The value for {@link #mButtonTextColor}.
* @param buttonActiveTextColor The value for {@link #mButtonActiveTextColor}.
* @param buttonBackgroundColor The value for {@link #mButtonBackgroundColor}.
* @param buttonActiveBackgroundColor The value for {@link #mButtonActiveBackgroundColor}.
*/
public void setButtonColors(int buttonTextColor, int buttonActiveTextColor, int buttonBackgroundColor, int buttonActiveBackgroundColor) {
mButtonTextColor = buttonTextColor;
mButtonActiveTextColor = buttonActiveTextColor;
mButtonBackgroundColor = buttonBackgroundColor;
mButtonActiveBackgroundColor = buttonActiveBackgroundColor;
}
/** Get {@link #mButtonTextColor}. */
public int getButtonTextColor() {
return mButtonTextColor;
}
/** Set {@link #mButtonTextColor}. */
public void setButtonTextColor(int buttonTextColor) {
mButtonTextColor = buttonTextColor;
}
/** Get {@link #mButtonActiveTextColor}. */
public int getButtonActiveTextColor() {
return mButtonActiveTextColor;
}
/** Set {@link #mButtonActiveTextColor}. */
public void setButtonActiveTextColor(int buttonActiveTextColor) {
mButtonActiveTextColor = buttonActiveTextColor;
}
/** Get {@link #mButtonBackgroundColor}. */
public int getButtonBackgroundColor() {
return mButtonBackgroundColor;
}
/** Set {@link #mButtonBackgroundColor}. */
public void setButtonBackgroundColor(int buttonBackgroundColor) {
mButtonBackgroundColor = buttonBackgroundColor;
}
/** Get {@link #mButtonActiveBackgroundColor}. */
public int getButtonActiveBackgroundColor() {
return mButtonActiveBackgroundColor;
}
/** Set {@link #mButtonActiveBackgroundColor}. */
public void setButtonActiveBackgroundColor(int buttonActiveBackgroundColor) {
mButtonActiveBackgroundColor = buttonActiveBackgroundColor;
}
/** Set {@link #mButtonTextAllCaps}. */
public void setButtonTextAllCaps(boolean buttonTextAllCaps) {
mButtonTextAllCaps = buttonTextAllCaps;
}
/** Get {@link #mLongPressTimeout}. */
public int getLongPressTimeout() {
return mLongPressTimeout;
}
/** Set {@link #mLongPressTimeout}. */
public void setLongPressTimeout(int longPressDuration) {
if (longPressDuration >= MIN_LONG_PRESS_DURATION && longPressDuration <= MAX_LONG_PRESS_DURATION) {
mLongPressTimeout = longPressDuration;
} else {
mLongPressTimeout = FALLBACK_LONG_PRESS_DURATION;
}
}
/** Get {@link #mLongPressRepeatDelay}. */
public int getLongPressRepeatDelay() {
return mLongPressRepeatDelay;
}
/** Set {@link #mLongPressRepeatDelay}. */
public void setLongPressRepeatDelay(int longPressRepeatDelay) {
if (mLongPressRepeatDelay >= MIN_LONG_PRESS__REPEAT_DELAY && mLongPressRepeatDelay <= MAX_LONG_PRESS__REPEAT_DELAY) {
mLongPressRepeatDelay = longPressRepeatDelay;
} else {
mLongPressRepeatDelay = DEFAULT_LONG_PRESS_REPEAT_DELAY;
}
}
/** Get the default map that can be used for {@link #mSpecialButtons}. */
@NonNull
public Map<SpecialButton, SpecialButtonState> getDefaultSpecialButtons(ExtraKeysView extraKeysView) {
return new HashMap<SpecialButton, SpecialButtonState>() {{
put(SpecialButton.CTRL, new SpecialButtonState(extraKeysView));
put(SpecialButton.ALT, new SpecialButtonState(extraKeysView));
put(SpecialButton.SHIFT, new SpecialButtonState(extraKeysView));
put(SpecialButton.FN, new SpecialButtonState(extraKeysView));
}};
}
/**
* Reload this instance of {@link ExtraKeysView} with the info passed in {@code extraKeysInfo}.
*
* @param extraKeysInfo The {@link ExtraKeysInfo} that defines the necessary info for the extra keys.
*/
@SuppressLint("ClickableViewAccessibility")
public void reload(ExtraKeysInfo extraKeysInfo) {
if (extraKeysInfo == null)
return;
for(SpecialButtonState state : mSpecialButtons.values())
state.buttons = new ArrayList<>();
removeAllViews();
ExtraKeyButton[][] buttons = extraKeysInfo.getMatrix();
setRowCount(buttons.length);
setColumnCount(maximumLength(buttons));
for (int row = 0; row < buttons.length; row++) {
for (int col = 0; col < buttons[row].length; col++) {
final ExtraKeyButton buttonInfo = buttons[row][col];
Button button;
if (isSpecialButton(buttonInfo)) {
button = createSpecialButton(buttonInfo.getKey(), true);
if (button == null) return;
} else {
button = new Button(getContext(), null, android.R.attr.buttonBarButtonStyle);
}
button.setText(buttonInfo.getDisplay());
button.setTextColor(mButtonTextColor);
button.setAllCaps(mButtonTextAllCaps);
button.setPadding(0, 0, 0, 0);
button.setOnClickListener(view -> {
performExtraKeyButtonHapticFeedback(view, buttonInfo, button);
onAnyExtraKeyButtonClick(view, buttonInfo, button);
});
button.setOnTouchListener((view, event) -> {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
view.setBackgroundColor(mButtonActiveBackgroundColor);
// Start long press scheduled executors which will be stopped in next MotionEvent
startScheduledExecutors(view, buttonInfo, button);
return true;
case MotionEvent.ACTION_MOVE:
if (buttonInfo.getPopup() != null) {
// Show popup on swipe up
if (mPopupWindow == null && event.getY() < 0) {
stopScheduledExecutors();
view.setBackgroundColor(mButtonBackgroundColor);
showPopup(view, buttonInfo.getPopup());
}
if (mPopupWindow != null && event.getY() > 0) {
view.setBackgroundColor(mButtonActiveBackgroundColor);
dismissPopup();
}
}
return true;
case MotionEvent.ACTION_CANCEL:
view.setBackgroundColor(mButtonBackgroundColor);
stopScheduledExecutors();
return true;
case MotionEvent.ACTION_UP:
view.setBackgroundColor(mButtonBackgroundColor);
stopScheduledExecutors();
// If ACTION_UP up was not from a repetitive key or was with a key with a popup button
if (mLongPressCount == 0 || mPopupWindow != null) {
// Trigger popup button click if swipe up complete
if (mPopupWindow != null) {
dismissPopup();
if (buttonInfo.getPopup() != null) {
onAnyExtraKeyButtonClick(view, buttonInfo.getPopup(), button);
}
} else {
view.performClick();
}
}
return true;
default:
return true;
}
});
LayoutParams param = new GridLayout.LayoutParams();
param.width = 0;
param.height = 0;
param.setMargins(0, 0, 0, 0);
param.columnSpec = GridLayout.spec(col, GridLayout.FILL, 1.f);
param.rowSpec = GridLayout.spec(row, GridLayout.FILL, 1.f);
button.setLayoutParams(param);
addView(button);
}
}
}
private void onExtraKeyButtonClick(View view, ExtraKeyButton buttonInfo, Button button) {
if (mExtraKeysViewClient != null)
mExtraKeysViewClient.onExtraKeyButtonClick(view, buttonInfo, button);
}
private void performExtraKeyButtonHapticFeedback(View view, ExtraKeyButton buttonInfo, Button button) {
if (mExtraKeysViewClient != null) {
// If client handled the feedback, then just return
if (mExtraKeysViewClient.performExtraKeyButtonHapticFeedback(view, buttonInfo, button))
return;
}
if (Settings.System.getInt(getContext().getContentResolver(),
Settings.System.HAPTIC_FEEDBACK_ENABLED, 0) != 0) {
if (Build.VERSION.SDK_INT >= 28) {
button.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP);
} else {
// Perform haptic feedback only if no total silence mode enabled.
if (Settings.Global.getInt(getContext().getContentResolver(), "zen_mode", 0) != 2) {
button.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP);
}
}
}
}
private void onAnyExtraKeyButtonClick(View view, @NonNull ExtraKeyButton buttonInfo, Button button) {
if (isSpecialButton(buttonInfo)) {
if (mLongPressCount > 0) return;
SpecialButtonState state = mSpecialButtons.get(SpecialButton.valueOf(buttonInfo.getKey()));
if (state == null) return;
// Toggle active state and disable lock state if new state is not active
state.setIsActive(!state.isActive);
if (!state.isActive)
state.setIsLocked(false);
} else {
onExtraKeyButtonClick(view, buttonInfo, button);
}
}
private void startScheduledExecutors(View view, ExtraKeyButton buttonInfo, Button button) {
stopScheduledExecutors();
mLongPressCount = 0;
if (mRepetitiveKeys.contains(buttonInfo.getKey())) {
// Auto repeat key if long pressed until ACTION_UP stops it by calling stopScheduledExecutors.
// Currently, only one (last) repeat key can run at a time. Old ones are stopped.
mScheduledExecutor = Executors.newSingleThreadScheduledExecutor();
mScheduledExecutor.scheduleWithFixedDelay(() -> {
mLongPressCount++;
onExtraKeyButtonClick(view, buttonInfo, button);
}, mLongPressTimeout, mLongPressRepeatDelay, TimeUnit.MILLISECONDS);
} else if (isSpecialButton(buttonInfo)) {
// Lock the key if long pressed by running mSpecialButtonsLongHoldRunnable after
// waiting for mLongPressTimeout milliseconds. If user does not long press, then the
// ACTION_UP triggered will cancel the runnable by calling stopScheduledExecutors before
// it has a chance to run.
SpecialButtonState state = mSpecialButtons.get(SpecialButton.valueOf(buttonInfo.getKey()));
if (state == null) return;
if (mHandler == null)
mHandler = new Handler(Looper.getMainLooper());
mSpecialButtonsLongHoldRunnable = new SpecialButtonsLongHoldRunnable(state);
mHandler.postDelayed(mSpecialButtonsLongHoldRunnable, mLongPressTimeout);
}
}
private void stopScheduledExecutors() {
if (mScheduledExecutor != null) {
mScheduledExecutor.shutdownNow();
mScheduledExecutor = null;
}
if (mSpecialButtonsLongHoldRunnable != null && mHandler != null) {
mHandler.removeCallbacks(mSpecialButtonsLongHoldRunnable);
mSpecialButtonsLongHoldRunnable = null;
}
}
private class SpecialButtonsLongHoldRunnable implements Runnable {
private final SpecialButtonState mState;
public SpecialButtonsLongHoldRunnable(SpecialButtonState state) {
mState = state;
}
public void run() {
// Toggle active and lock state
mState.setIsLocked(!mState.isActive);
mState.setIsActive(!mState.isActive);
mLongPressCount++;
}
}
void showPopup(View view, ExtraKeyButton extraButton) {
int width = view.getMeasuredWidth();
int height = view.getMeasuredHeight();
Button button;
if (isSpecialButton(extraButton)) {
button = createSpecialButton(extraButton.getKey(), false);
if (button == null) return;
} else {
button = new Button(getContext(), null, android.R.attr.buttonBarButtonStyle);
button.setTextColor(mButtonTextColor);
}
button.setText(extraButton.getDisplay());
button.setAllCaps(mButtonTextAllCaps);
button.setPadding(0, 0, 0, 0);
button.setMinHeight(0);
button.setMinWidth(0);
button.setMinimumWidth(0);
button.setMinimumHeight(0);
button.setWidth(width);
button.setHeight(height);
button.setBackgroundColor(mButtonActiveBackgroundColor);
mPopupWindow = new PopupWindow(this);
mPopupWindow.setWidth(LayoutParams.WRAP_CONTENT);
mPopupWindow.setHeight(LayoutParams.WRAP_CONTENT);
mPopupWindow.setContentView(button);
mPopupWindow.setOutsideTouchable(true);
mPopupWindow.setFocusable(false);
mPopupWindow.showAsDropDown(view, 0, -2 * height);
}
private void dismissPopup() {
mPopupWindow.setContentView(null);
mPopupWindow.dismiss();
mPopupWindow = null;
}
/** Check whether a {@link ExtraKeyButton} is a {@link SpecialButton}. */
public boolean isSpecialButton(ExtraKeyButton button) {
return mSpecialButtonsKeys.contains(button.getKey());
}
/**
* Read whether {@link SpecialButton} registered in {@link #mSpecialButtons} is active or not.
*
* @param specialButton The {@link SpecialButton} to read.
* @param autoSetInActive Set to {@code true} if {@link SpecialButtonState#isActive} should be
* set {@code false} if button is not locked.
* @return Returns {@code null} if button does not exist in {@link #mSpecialButtons}. If button
* exists, then returns {@code true} if the button is created in {@link ExtraKeysView}
* and is active, otherwise {@code false}.
*/
@Nullable
public Boolean readSpecialButton(SpecialButton specialButton, boolean autoSetInActive) {
SpecialButtonState state = mSpecialButtons.get(specialButton);
if (state == null) return null;
if (!state.isCreated || !state.isActive)
return false;
// Disable active state only if not locked
if (autoSetInActive && !state.isLocked)
state.setIsActive(false);
return true;
}
private Button createSpecialButton(String buttonKey, boolean needUpdate) {
SpecialButtonState state = mSpecialButtons.get(SpecialButton.valueOf(buttonKey));
if (state == null) return null;
state.setIsCreated(true);
Button button = new Button(getContext(), null, android.R.attr.buttonBarButtonStyle);
button.setTextColor(state.isActive ? mButtonActiveTextColor : mButtonTextColor);
if (needUpdate) {
state.buttons.add(button);
}
return button;
}
/**
* General util function to compute the longest column length in a matrix.
*/
static int maximumLength(Object[][] matrix) {
int m = 0;
for (Object[] row : matrix)
m = Math.max(m, row.length);
return m;
}
}

View file

@ -0,0 +1,52 @@
package com.termux.shared.terminal.io.extrakeys;
import androidx.annotation.NonNull;
import java.util.HashMap;
/** The {@link Class} that implements special buttons for {@link ExtraKeysView}. */
public class SpecialButton {
private static final HashMap<String, SpecialButton> map = new HashMap<>();
public static final SpecialButton CTRL = new SpecialButton("CTRL");
public static final SpecialButton ALT = new SpecialButton("ALT");
public static final SpecialButton SHIFT = new SpecialButton("SHIFT");
public static final SpecialButton FN = new SpecialButton("FN");
/** The special button key. */
private final String key;
/**
* Initialize a {@link SpecialButton}.
*
* @param key The unique key name for the special button. The key is registered in {@link #map}
* with which the {@link SpecialButton} can be retrieved via a call to
* {@link #valueOf(String)}.
*/
public SpecialButton(@NonNull final String key) {
this.key = key;
map.put(key, this);
}
/** Get {@link #key} for this {@link SpecialButton}. */
public String getKey() {
return key;
}
/**
* Get the {@link SpecialButton} for {@code key}.
*
* @param key The unique key name for the special button.
*/
public static SpecialButton valueOf(String key) {
return map.get(key);
}
@NonNull
@Override
public String toString() {
return key;
}
}

View file

@ -0,0 +1,49 @@
package com.termux.shared.terminal.io.extrakeys;
import android.widget.Button;
import java.util.ArrayList;
import java.util.List;
/** The {@link Class} that maintains a state of a {@link SpecialButton} */
public class SpecialButtonState {
/** If special button has been created for the {@link ExtraKeysView}. */
boolean isCreated = false;
/** If special button is active. */
boolean isActive = false;
/** If special button is locked due to long hold on it and should not be deactivated if its
* state is read. */
boolean isLocked = false;
List<Button> buttons = new ArrayList<>();
ExtraKeysView mExtraKeysView;
/**
* Initialize a {@link SpecialButtonState} to maintain state of a {@link SpecialButton}.
*
* @param extraKeysView The {@link ExtraKeysView} instance in which the {@link SpecialButton}
* is to be registered.
*/
public SpecialButtonState(ExtraKeysView extraKeysView) {
mExtraKeysView = extraKeysView;
}
/** Set {@link #isCreated}. */
public void setIsCreated(boolean value) {
isCreated = value;
}
/** Set {@link #isActive}. */
public void setIsActive(boolean value) {
isActive = value;
buttons.forEach(button -> button.setTextColor(value ? mExtraKeysView.getButtonActiveTextColor() : mExtraKeysView.getButtonTextColor()));
}
/** Set {@link #isLocked}. */
public void setIsLocked(boolean value) {
isLocked = value;
}
}

View file

@ -0,0 +1,229 @@
package com.termux.shared.termux;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.pm.ApplicationInfo;
import android.os.Build;
import android.system.Os;
import android.system.OsConstants;
import androidx.annotation.NonNull;
import com.google.common.base.Joiner;
import com.termux.shared.android.SELinuxUtils;
import com.termux.shared.data.DataUtils;
import com.termux.shared.logger.Logger;
import com.termux.shared.markdown.MarkdownUtils;
import com.termux.shared.packages.PackageUtils;
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}.
*
* @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();
ApplicationInfo applicationInfo = context.getApplicationInfo();
if (applicationInfo == null) return null;
AndroidUtils.appendPropertyToMarkdown(markdownString,"APP_NAME", PackageUtils.getAppNameForPackage(context));
AndroidUtils.appendPropertyToMarkdown(markdownString,"PACKAGE_NAME", PackageUtils.getPackageNameForPackage(context));
AndroidUtils.appendPropertyToMarkdown(markdownString,"VERSION_NAME", PackageUtils.getVersionNameForPackage(context));
AndroidUtils.appendPropertyToMarkdown(markdownString,"VERSION_CODE", PackageUtils.getVersionCodeForPackage(context));
AndroidUtils.appendPropertyToMarkdown(markdownString,"TARGET_SDK", PackageUtils.getTargetSDKForPackage(context));
AndroidUtils.appendPropertyToMarkdown(markdownString,"IS_DEBUGGABLE_BUILD", PackageUtils.isAppForPackageADebuggableBuild(context));
if (PackageUtils.isAppInstalledOnExternalStorage(context)) {
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));
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);
Long userId = PackageUtils.getSerialNumberForCurrentUser(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 device info.
*
* @param context The context for operations.
* @return Returns the markdown {@link String}.
*/
public static String getDeviceInfoMarkdownString(@NonNull final Context context) {
// 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);
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));
appendPropertyToMarkdown(markdownString, "SUPPORTED_32_BIT_ABIS", Joiner.on(", ").skipNulls().join(Build.SUPPORTED_32_BIT_ABIS));
appendPropertyToMarkdown(markdownString, "SUPPORTED_64_BIT_ABIS", Joiner.on(", ").skipNulls().join(Build.SUPPORTED_64_BIT_ABIS));
// If on Android >= 15
if (Build.VERSION.SDK_INT >= 35) {
try {
appendPropertyToMarkdownIfSet(markdownString, "PAGE_SIZE", Os.sysconf(OsConstants._SC_PAGESIZE));
} catch (Throwable t) {
// Ignore
}
}
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 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());
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,545 @@
package com.termux.shared.termux;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.pm.ApplicationInfo;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import androidx.annotation.NonNull;
import com.termux.shared.R;
import com.termux.shared.file.FileUtils;
import com.termux.shared.file.TermuxFileUtils;
import com.termux.shared.logger.Logger;
import com.termux.shared.markdown.MarkdownUtils;
import com.termux.shared.models.ExecutionCommand;
import com.termux.shared.models.errors.Error;
import com.termux.shared.packages.PackageUtils;
import com.termux.shared.shell.TermuxShellEnvironmentClient;
import com.termux.shared.shell.TermuxTask;
import org.apache.commons.io.IOUtils;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.Charset;
import java.util.List;
import java.util.regex.Pattern;
public class TermuxUtils {
private static final String LOG_TAG = "TermuxUtils";
/**
* Get the {@link Context} for {@link TermuxConstants#TERMUX_PACKAGE_NAME} package.
*
* @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_API_PACKAGE_NAME} package.
*
* @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 getTermuxAPIPackageContext(@NonNull Context context) {
return PackageUtils.getContextForPackage(context, TermuxConstants.TERMUX_API_PACKAGE_NAME);
}
/**
* Get the {@link Context} for {@link TermuxConstants#TERMUX_BOOT_PACKAGE_NAME} package.
*
* @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 getTermuxBootPackageContext(@NonNull Context context) {
return PackageUtils.getContextForPackage(context, TermuxConstants.TERMUX_BOOT_PACKAGE_NAME);
}
/**
* Get the {@link Context} for {@link TermuxConstants#TERMUX_FLOAT_PACKAGE_NAME} package.
*
* @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 getTermuxFloatPackageContext(@NonNull Context context) {
return PackageUtils.getContextForPackage(context, TermuxConstants.TERMUX_FLOAT_PACKAGE_NAME);
}
/**
* Get the {@link Context} for {@link TermuxConstants#TERMUX_STYLING_PACKAGE_NAME} package.
*
* @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 getTermuxStylingPackageContext(@NonNull Context context) {
return PackageUtils.getContextForPackage(context, TermuxConstants.TERMUX_STYLING_PACKAGE_NAME);
}
/**
* Get the {@link Context} for {@link TermuxConstants#TERMUX_TASKER_PACKAGE_NAME} package.
*
* @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 getTermuxTaskerPackageContext(@NonNull Context context) {
return PackageUtils.getContextForPackage(context, TermuxConstants.TERMUX_TASKER_PACKAGE_NAME);
}
/**
* Get the {@link Context} for {@link TermuxConstants#TERMUX_WIDGET_PACKAGE_NAME} package.
*
* @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 getTermuxWidgetPackageContext(@NonNull Context context) {
return PackageUtils.getContextForPackage(context, TermuxConstants.TERMUX_WIDGET_PACKAGE_NAME);
}
/**
* 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
* <manifest
* <queries>
* <package android:name="com.termux" />
* </queries>
* </manifest>
* }
*
* @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:API app is installed and enabled. This can be used by external apps that don't
* share `sharedUserId` with the Termux:API app.
*
* @param context The context for operations.
* @return Returns {@code errmsg} if {@link TermuxConstants#TERMUX_API_PACKAGE_NAME} is not installed
* or disabled, otherwise {@code null}.
*/
public static String isTermuxAPIAppInstalled(@NonNull final Context context) {
return PackageUtils.isAppInstalled(context, TermuxConstants.TERMUX_API_APP_NAME, TermuxConstants.TERMUX_API_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;
}
/**
* 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<ResolveInfo> 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);
}
}
/**
* Get a markdown {@link String} for the apps info of all/any termux plugin apps installed.
*
* @param currentPackageContext The context of current package.
* @return Returns the markdown {@link String}.
*/
public static String getTermuxPluginAppsInfoMarkdownString(final Context currentPackageContext) {
if (currentPackageContext == null) return "null";
StringBuilder markdownString = new StringBuilder();
List<String> termuxPluginAppPackageNamesList = TermuxConstants.TERMUX_PLUGIN_APP_PACKAGE_NAMES_LIST;
if (termuxPluginAppPackageNamesList != null) {
for (int i = 0; i < termuxPluginAppPackageNamesList.size(); i++) {
String termuxPluginAppPackageName = termuxPluginAppPackageNamesList.get(i);
Context termuxPluginAppContext = PackageUtils.getContextForPackage(currentPackageContext, termuxPluginAppPackageName);
// If the package context for the plugin app is not null, then assume its installed and get its info
if (termuxPluginAppContext != null) {
if (i != 0)
markdownString.append("\n\n");
markdownString.append(getAppInfoMarkdownString(termuxPluginAppContext, false));
}
}
}
if (markdownString.toString().isEmpty())
return null;
return markdownString.toString();
}
/**
* 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));
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)));
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";
StringBuilder 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### Reddit\n");
markdownString.append("\n").append(MarkdownUtils.getLinkMarkdownString(TermuxConstants.TERMUX_REDDIT_SUBREDDIT, TermuxConstants.TERMUX_REDDIT_SUBREDDIT_URL)).append(" ");
markdownString.append("\n\n### Github Issues for Termux apps\n");
markdownString.append("\n").append(MarkdownUtils.getLinkMarkdownString(TermuxConstants.TERMUX_APP_NAME, TermuxConstants.TERMUX_GITHUB_ISSUES_REPO_URL)).append(" ");
markdownString.append("\n").append(MarkdownUtils.getLinkMarkdownString(TermuxConstants.TERMUX_API_APP_NAME, TermuxConstants.TERMUX_API_GITHUB_ISSUES_REPO_URL)).append(" ");
markdownString.append("\n").append(MarkdownUtils.getLinkMarkdownString(TermuxConstants.TERMUX_BOOT_APP_NAME, TermuxConstants.TERMUX_BOOT_GITHUB_ISSUES_REPO_URL)).append(" ");
markdownString.append("\n").append(MarkdownUtils.getLinkMarkdownString(TermuxConstants.TERMUX_FLOAT_APP_NAME, TermuxConstants.TERMUX_FLOAT_GITHUB_ISSUES_REPO_URL)).append(" ");
markdownString.append("\n").append(MarkdownUtils.getLinkMarkdownString(TermuxConstants.TERMUX_STYLING_APP_NAME, TermuxConstants.TERMUX_STYLING_GITHUB_ISSUES_REPO_URL)).append(" ");
markdownString.append("\n").append(MarkdownUtils.getLinkMarkdownString(TermuxConstants.TERMUX_TASKER_APP_NAME, TermuxConstants.TERMUX_TASKER_GITHUB_ISSUES_REPO_URL)).append(" ");
markdownString.append("\n").append(MarkdownUtils.getLinkMarkdownString(TermuxConstants.TERMUX_WIDGET_APP_NAME, TermuxConstants.TERMUX_WIDGET_GITHUB_ISSUES_REPO_URL)).append(" ");
markdownString.append("\n\n### Github Issues for Termux 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").append(MarkdownUtils.getLinkMarkdownString(TermuxConstants.TERMUX_GAME_PACKAGES_GITHUB_REPO_NAME, TermuxConstants.TERMUX_GAME_PACKAGES_GITHUB_ISSUES_REPO_URL)).append(" ");
markdownString.append("\n").append(MarkdownUtils.getLinkMarkdownString(TermuxConstants.TERMUX_SCIENCE_PACKAGES_GITHUB_REPO_NAME, TermuxConstants.TERMUX_SCIENCE_PACKAGES_GITHUB_ISSUES_REPO_URL)).append(" ");
markdownString.append("\n").append(MarkdownUtils.getLinkMarkdownString(TermuxConstants.TERMUX_ROOT_PACKAGES_GITHUB_REPO_NAME, TermuxConstants.TERMUX_ROOT_PACKAGES_GITHUB_ISSUES_REPO_URL)).append(" ");
markdownString.append("\n").append(MarkdownUtils.getLinkMarkdownString(TermuxConstants.TERMUX_UNSTABLE_PACKAGES_GITHUB_REPO_NAME, TermuxConstants.TERMUX_UNSTABLE_PACKAGES_GITHUB_ISSUES_REPO_URL)).append(" ");
markdownString.append("\n").append(MarkdownUtils.getLinkMarkdownString(TermuxConstants.TERMUX_X11_PACKAGES_GITHUB_REPO_NAME, TermuxConstants.TERMUX_X11_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_API_APP_NAME, TermuxConstants.TERMUX_API_GITHUB_REPO_URL)).append(" ");
markdownString.append("\n").append(MarkdownUtils.getLinkMarkdownString(TermuxConstants.TERMUX_BOOT_APP_NAME, TermuxConstants.TERMUX_BOOT_GITHUB_REPO_URL)).append(" ");
markdownString.append("\n").append(MarkdownUtils.getLinkMarkdownString(TermuxConstants.TERMUX_FLOAT_APP_NAME, TermuxConstants.TERMUX_FLOAT_GITHUB_REPO_URL)).append(" ");
markdownString.append("\n").append(MarkdownUtils.getLinkMarkdownString(TermuxConstants.TERMUX_STYLING_APP_NAME, TermuxConstants.TERMUX_STYLING_GITHUB_REPO_URL)).append(" ");
markdownString.append("\n").append(MarkdownUtils.getLinkMarkdownString(TermuxConstants.TERMUX_TASKER_APP_NAME, TermuxConstants.TERMUX_TASKER_GITHUB_REPO_URL)).append(" ");
markdownString.append("\n").append(MarkdownUtils.getLinkMarkdownString(TermuxConstants.TERMUX_WIDGET_APP_NAME, TermuxConstants.TERMUX_WIDGET_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### Reddit\n");
markdownString.append("\n").append(MarkdownUtils.getLinkMarkdownString(TermuxConstants.TERMUX_REDDIT_SUBREDDIT, TermuxConstants.TERMUX_REDDIT_SUBREDDIT_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, true, false);
executionCommand.commandLabel = "APT Info Command";
executionCommand.backgroundCustomLogLevel = Logger.LOG_LEVEL_OFF;
TermuxTask termuxTask = TermuxTask.execute(context, executionCommand, null, new TermuxShellEnvironmentClient(), true);
if (termuxTask == 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", "/", true, true);
executionCommand.commandLabel = "Logcat dump command";
executionCommand.backgroundCustomLogLevel = Logger.LOG_LEVEL_OFF;
TermuxTask termuxTask = TermuxTask.execute(context, executionCommand, null, new TermuxShellEnvironmentClient(), true);
if (termuxTask == 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;
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);
}
}

View file

@ -0,0 +1,195 @@
package com.termux.shared.view;
import android.app.Activity;
import android.content.Context;
import android.content.res.Configuration;
import android.inputmethodservice.InputMethodService;
import android.view.View;
import android.view.WindowInsets;
import android.view.WindowManager;
import android.view.inputmethod.InputMethodManager;
import androidx.annotation.NonNull;
import androidx.core.view.WindowInsetsCompat;
import com.termux.shared.logger.Logger;
public class KeyboardUtils {
private static final String LOG_TAG = "KeyboardUtils";
public static void setSoftKeyboardVisibility(@NonNull final Runnable showSoftKeyboardRunnable, final Activity activity, final View view, final boolean visible) {
if (visible) {
// A Runnable with a delay is used, otherwise soft keyboard may not automatically open
// on some devices, but still may fail
view.postDelayed(showSoftKeyboardRunnable, 500);
} else {
view.removeCallbacks(showSoftKeyboardRunnable);
hideSoftKeyboard(activity, view);
}
}
/**
* Toggle the soft keyboard. The {@link InputMethodManager#SHOW_FORCED} is passed as
* {@code showFlags} so that keyboard is forcefully shown if it needs to be enabled.
*
* This is also important for soft keyboard to be shown when a hardware keyboard is connected, and
* user has disabled the {@code Show on-screen keyboard while hardware keyboard is connected} toggle
* in Android "Language and Input" settings but the current soft keyboard app overrides the
* default implementation of {@link InputMethodService#onEvaluateInputViewShown()} and returns
* {@code true}.
*/
public static void toggleSoftKeyboard(final Context context) {
if (context == null) return;
InputMethodManager inputMethodManager = (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE);
if (inputMethodManager != null)
inputMethodManager.toggleSoftInput(InputMethodManager.SHOW_FORCED, 0);
}
/**
* Show the soft keyboard. The {@code 0} value is passed as {@code flags} so that keyboard is
* forcefully shown.
*
* This is also important for soft keyboard to be shown on app startup when a hardware keyboard
* is connected, and user has disabled the {@code Show on-screen keyboard while hardware keyboard
* is connected} toggle in Android "Language and Input" settings but the current soft keyboard app
* overrides the default implementation of {@link InputMethodService#onEvaluateInputViewShown()}
* and returns {@code true}.
* https://cs.android.com/android/platform/superproject/+/android-11.0.0_r3:frameworks/base/core/java/android/inputmethodservice/InputMethodService.java;l=1751
*
* Also check {@link InputMethodService#onShowInputRequested(int, boolean)} which must return
* {@code true}, which can be done by failing its {@code ((flags&InputMethod.SHOW_EXPLICIT) == 0)}
* check by passing {@code 0} as {@code flags}.
* https://cs.android.com/android/platform/superproject/+/android-11.0.0_r3:frameworks/base/core/java/android/inputmethodservice/InputMethodService.java;l=2022
*/
public static void showSoftKeyboard(final Context context, final View view) {
if (context == null || view == null) return;
InputMethodManager inputMethodManager = (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE);
if (inputMethodManager != null)
inputMethodManager.showSoftInput(view, 0);
}
public static void hideSoftKeyboard(final Context context, final View view) {
if (context == null || view == null) return;
InputMethodManager inputMethodManager = (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE);
if (inputMethodManager != null)
inputMethodManager.hideSoftInputFromWindow(view.getWindowToken(), 0);
}
public static void disableSoftKeyboard(final Activity activity, final View view) {
if (activity == null || view == null) return;
hideSoftKeyboard(activity, view);
setDisableSoftKeyboardFlags(activity);
}
public static void setDisableSoftKeyboardFlags(final Activity activity) {
if (activity != null && activity.getWindow() != null)
activity.getWindow().setFlags(WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM, WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM);
}
public static void clearDisableSoftKeyboardFlags(final Activity activity) {
if (activity != null && activity.getWindow() != null)
activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM);
}
public static boolean areDisableSoftKeyboardFlagsSet(final Activity activity) {
if (activity == null || activity.getWindow() == null) return false;
return (activity.getWindow().getAttributes().flags & WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM) != 0;
}
public static void setSoftKeyboardAlwaysHiddenFlags(final Activity activity) {
if (activity != null && activity.getWindow() != null)
activity.getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN);
}
public static void setSoftInputModeAdjustResize(final Activity activity) {
// TODO: The flag is deprecated for API 30 and WindowInset API should be used
// https://developer.android.com/reference/android/view/WindowManager.LayoutParams#SOFT_INPUT_ADJUST_RESIZE
// https://medium.com/androiddevelopers/animating-your-keyboard-fb776a8fb66d
// https://stackoverflow.com/a/65194077/14686958
if (activity != null && activity.getWindow() != null)
activity.getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE);
}
/**
* Check if soft keyboard is visible.
* Does not work on android 7 but does on android 11 avd.
*
* @param activity The Activity of the root view for which the visibility should be checked.
* @return Returns {@code true} if soft keyboard is visible, otherwise {@code false}.
*/
public static boolean isSoftKeyboardVisible(final Activity activity) {
if (activity != null && activity.getWindow() != null) {
WindowInsets insets = activity.getWindow().getDecorView().getRootWindowInsets();
if (insets != null) {
WindowInsetsCompat insetsCompat = WindowInsetsCompat.toWindowInsetsCompat(insets);
if (insetsCompat.isVisible(WindowInsetsCompat.Type.ime())) {
Logger.logVerbose(LOG_TAG, "Soft keyboard visible");
return true;
}
}
}
Logger.logVerbose(LOG_TAG, "Soft keyboard not visible");
return false;
}
/**
* Check if hardware keyboard is connected.
* Based on default implementation of {@link InputMethodService#onEvaluateInputViewShown()}.
*
* https://developer.android.com/guide/topics/resources/providing-resources#ImeQualifier
*
* @param context The Context for operations.
* @return Returns {@code true} if device has hardware keys for text input or an external hardware
* keyboard is connected, otherwise {@code false}.
*/
public static boolean isHardKeyboardConnected(final Context context) {
if (context == null) return false;
Configuration config = context.getResources().getConfiguration();
return config.keyboard != Configuration.KEYBOARD_NOKEYS
|| config.hardKeyboardHidden == Configuration.HARDKEYBOARDHIDDEN_NO;
}
/**
* Check if soft keyboard should be disabled based on user configuration.
*
* @param context The Context for operations.
* @return Returns {@code true} if device has soft keyboard should be disabled, otherwise {@code false}.
*/
public static boolean shouldSoftKeyboardBeDisabled(final Context context, final boolean isSoftKeyboardEnabled, final boolean isSoftKeyboardEnabledOnlyIfNoHardware) {
// If soft keyboard is disabled by user regardless of hardware keyboard
if (!isSoftKeyboardEnabled) {
return true;
} else {
/*
* Currently, for this case, soft keyboard will be disabled on Termux app startup and
* when switching back from another app. Soft keyboard can be temporarily enabled in
* show/hide soft keyboard toggle behaviour with keyboard toggle buttons and will continue
* to work when tapping on terminal view for opening and back button for closing, until
* Termux app is switched to another app. After returning back, keyboard will be disabled
* until toggle is pressed again.
* This may also be helpful for the Lineage OS bug where if "Show soft keyboard" toggle
* in "Language and Input" is disabled and Termux is started without a hardware keyboard
* in landscape mode, and then the keyboard is connected and phone is rotated to portrait
* mode and then keyboard is toggled with Termux keyboard toggle buttons, then a blank
* space is shown in-place of the soft keyboard. Its likely related to
* WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE which pushes up the view when
* keyboard is opened instead of the keyboard opening on top of the view (hiding stuff).
* If the "Show soft keyboard" toggle was disabled, then this resizing shouldn't happen.
* But it seems resizing does happen, but keyboard is never opened since its not supposed to.
* https://github.com/termux/termux-app/issues/1995#issuecomment-837080079
*/
// If soft keyboard is disabled by user only if hardware keyboard is connected
if(isSoftKeyboardEnabledOnlyIfNoHardware) {
boolean isHardKeyboardConnected = KeyboardUtils.isHardKeyboardConnected(context);
Logger.logVerbose(LOG_TAG, "Hardware keyboard connected=" + isHardKeyboardConnected);
return isHardKeyboardConnected;
} else {
return false;
}
}
}
}

View file

@ -0,0 +1,238 @@
package com.termux.shared.view;
import android.app.Activity;
import android.content.Context;
import android.content.ContextWrapper;
import android.content.res.Configuration;
import android.graphics.Point;
import android.graphics.Rect;
import android.util.TypedValue;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import com.termux.shared.logger.Logger;
public class ViewUtils {
/** Log root view events. */
public static boolean VIEW_UTILS_LOGGING_ENABLED = false;
private static final String LOG_TAG = "ViewUtils";
/**
* Sets whether view utils logging is enabled or not.
*
* @param value The boolean value that defines the state.
*/
public static void setIsViewUtilsLoggingEnabled(boolean value) {
VIEW_UTILS_LOGGING_ENABLED = value;
}
/**
* Check if a {@link View} is fully visible and not hidden or partially covered by another view.
*
* https://stackoverflow.com/a/51078418/14686958
*
* @param view The {@link View} to check.
* @param statusBarHeight The status bar height received by {@link View.OnApplyWindowInsetsListener}.
* @return Returns {@code true} if view is fully visible.
*/
public static boolean isViewFullyVisible(View view, int statusBarHeight) {
Rect[] windowAndViewRects = getWindowAndViewRects(view, statusBarHeight);
if (windowAndViewRects == null)
return false;
return windowAndViewRects[0].contains(windowAndViewRects[1]);
}
/**
* Get the {@link Rect} of a {@link View} and the {@link Rect} of the window inside which it
* exists.
*
* https://stackoverflow.com/a/51078418/14686958
*
* @param view The {@link View} inside the window whose {@link Rect} to get.
* @param statusBarHeight The status bar height received by {@link View.OnApplyWindowInsetsListener}.
* @return Returns {@link Rect[]} if view is visible where Rect[0] will contain window
* {@link Rect} and Rect[1] will contain view {@link Rect}. This will be {@code null}
* if view is not visible.
*/
@Nullable
public static Rect[] getWindowAndViewRects(View view, int statusBarHeight) {
if (view == null || !view.isShown())
return null;
boolean view_utils_logging_enabled = VIEW_UTILS_LOGGING_ENABLED;
// windowRect - will hold available area where content remain visible to users
// Takes into account screen decorations (e.g. statusbar)
Rect windowRect = new Rect();
view.getWindowVisibleDisplayFrame(windowRect);
// If there is actionbar, get his height
int actionBarHeight = 0;
boolean isInMultiWindowMode = false;
Context context = view.getContext();
if (context instanceof AppCompatActivity) {
androidx.appcompat.app.ActionBar actionBar = ((AppCompatActivity) context).getSupportActionBar();
if (actionBar != null) actionBarHeight = actionBar.getHeight();
isInMultiWindowMode = ((AppCompatActivity) context).isInMultiWindowMode();
} else if (context instanceof Activity) {
android.app.ActionBar actionBar = ((Activity) context).getActionBar();
if (actionBar != null) actionBarHeight = actionBar.getHeight();
isInMultiWindowMode = ((Activity) context).isInMultiWindowMode();
}
int displayOrientation = getDisplayOrientation(context);
// windowAvailableRect - takes into account actionbar and statusbar height
Rect windowAvailableRect;
windowAvailableRect = new Rect(windowRect.left, windowRect.top + actionBarHeight, windowRect.right, windowRect.bottom);
// viewRect - holds position of the view in window
// (methods as getGlobalVisibleRect, getHitRect, getDrawingRect can return different result,
// when partialy visible)
Rect viewRect;
final int[] viewsLocationInWindow = new int[2];
view.getLocationInWindow(viewsLocationInWindow);
int viewLeft = viewsLocationInWindow[0];
int viewTop = viewsLocationInWindow[1];
if (view_utils_logging_enabled) {
Logger.logVerbose(LOG_TAG, "getWindowAndViewRects:");
Logger.logVerbose(LOG_TAG, "windowRect: " + toRectString(windowRect) + ", windowAvailableRect: " + toRectString(windowAvailableRect));
Logger.logVerbose(LOG_TAG, "viewsLocationInWindow: " + toPointString(new Point(viewLeft, viewTop)));
Logger.logVerbose(LOG_TAG, "activitySize: " + toPointString(getDisplaySize(context, true)) +
", displaySize: " + toPointString(getDisplaySize(context, false)) +
", displayOrientation=" + displayOrientation);
}
if (isInMultiWindowMode) {
if (displayOrientation == Configuration.ORIENTATION_PORTRAIT) {
// The windowRect.top of the window at the of split screen mode should start right
// below the status bar
if (statusBarHeight != windowRect.top) {
if (view_utils_logging_enabled)
Logger.logVerbose(LOG_TAG, "Window top does not equal statusBarHeight " + statusBarHeight + " in multi-window portrait mode. Window is possibly bottom app in split screen mode. Adding windowRect.top " + windowRect.top + " to viewTop.");
viewTop += windowRect.top;
} else {
if (view_utils_logging_enabled)
Logger.logVerbose(LOG_TAG, "windowRect.top equals statusBarHeight " + statusBarHeight + " in multi-window portrait mode. Window is possibly top app in split screen mode.");
}
} else if (displayOrientation == Configuration.ORIENTATION_LANDSCAPE) {
// If window is on the right in landscape mode of split screen, the viewLeft actually
// starts at windowRect.left instead of 0 returned by getLocationInWindow
viewLeft += windowRect.left;
}
}
int viewRight = viewLeft + view.getWidth();
int viewBottom = viewTop + view.getHeight();
viewRect = new Rect(viewLeft, viewTop, viewRight, viewBottom);
if (displayOrientation == Configuration.ORIENTATION_LANDSCAPE && viewRight > windowAvailableRect.right) {
if (view_utils_logging_enabled)
Logger.logVerbose(LOG_TAG, "viewRight " + viewRight + " is greater than windowAvailableRect.right " + windowAvailableRect.right + " in landscape mode. Setting windowAvailableRect.right to viewRight since it may not include navbar height.");
windowAvailableRect.right = viewRight;
}
return new Rect[]{windowAvailableRect, viewRect};
}
/**
* Check if {@link Rect} r2 is above r2. An empty rectangle never contains another rectangle.
*
* @param r1 The base rectangle.
* @param r2 The rectangle being tested that should be above.
* @return Returns {@code true} if r2 is above r1.
*/
public static boolean isRectAbove(@NonNull Rect r1, @NonNull Rect r2) {
// check for empty first
return r1.left < r1.right && r1.top < r1.bottom
// now check if above
&& r1.left <= r2.left && r1.bottom >= r2.bottom;
}
/**
* Get device orientation.
*
* Related: https://stackoverflow.com/a/29392593/14686958
*
* @param context The {@link Context} to check with.
* @return {@link Configuration#ORIENTATION_PORTRAIT} or {@link Configuration#ORIENTATION_LANDSCAPE}.
*/
public static int getDisplayOrientation(@NonNull Context context) {
Point size = getDisplaySize(context, false);
return (size.x < size.y) ? Configuration.ORIENTATION_PORTRAIT : Configuration.ORIENTATION_LANDSCAPE;
}
/**
* Get device display size.
*
* @param context The {@link Context} to check with. It must be {@link Activity} context, otherwise
* android will throw:
* `java.lang.IllegalArgumentException: Used non-visual Context to obtain an instance of WindowManager. Please use an Activity or a ContextWrapper around one instead.`
* @param activitySize The set to {@link true}, then size returned will be that of the activity
* and can be smaller than physical display size in multi-window mode.
* @return Returns the display size as {@link Point}.
*/
public static Point getDisplaySize( @NonNull Context context, boolean activitySize) {
// android.view.WindowManager.getDefaultDisplay() and Display.getSize() are deprecated in
// API 30 and give wrong values in API 30 for activitySize=false in multi-window
androidx.window.WindowManager windowManager = new androidx.window.WindowManager(context);
androidx.window.WindowMetrics windowMetrics;
if (activitySize)
windowMetrics = windowManager.getCurrentWindowMetrics();
else
windowMetrics = windowManager.getMaximumWindowMetrics();
return new Point(windowMetrics.getBounds().width(), windowMetrics.getBounds().height());
}
/** Convert {@link Rect} to {@link String}. */
public static String toRectString(Rect rect) {
if (rect == null) return "null";
return "(" + rect.left + "," + rect.top + "), (" + rect.right + "," + rect.bottom + ")";
}
/** Convert {@link Point} to {@link String}. */
public static String toPointString(Point point) {
if (point == null) return "null";
return "(" + point.x + "," + point.y + ")";
}
/** Get the {@link Activity} associated with the {@link Context} if available. */
@Nullable
public static Activity getActivity(Context context) {
while (context instanceof ContextWrapper) {
if (context instanceof Activity) {
return (Activity)context;
}
context = ((ContextWrapper)context).getBaseContext();
}
return null;
}
/** Convert value in device independent pixels (dp) to pixels (px) units. */
public static int dpToPx(Context context, int dp) {
return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, context.getResources().getDisplayMetrics());
}
public static void setLayoutMarginsInDp(@NonNull View view, int left, int top, int right, int bottom) {
Context context = view.getContext();
setLayoutMarginsInPixels(view, dpToPx(context, left), dpToPx(context, top), dpToPx(context, right), dpToPx(context, bottom));
}
public static void setLayoutMarginsInPixels(@NonNull View view, int left, int top, int right, int bottom) {
if (view.getLayoutParams() instanceof ViewGroup.MarginLayoutParams) {
ViewGroup.MarginLayoutParams params = (ViewGroup.MarginLayoutParams) view.getLayoutParams();
params.setMargins(left, top, right, bottom);
view.setLayoutParams(params);
}
}
}

View file

@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#FFFFFF"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M16,1L4,1c-1.1,0 -2,0.9 -2,2v14h2L4,3h12L16,1zM19,5L8,5c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h11c1.1,0 2,-0.9 2,-2L21,7c0,-1.1 -0.9,-2 -2,-2zM19,21L8,21L8,7h11v14z"/>
</vector>

View file

@ -0,0 +1,37 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<!--
Updated notification icon compliant with system icons guidelines
https://material.io/design/iconography/system-icons.html
-->
<group>
<clip-path
android:pathData="M0,0h24v24h-24z"/>
<path
android:pathData="M5,4H2L8,12L2,20H5L11,12L5,4Z"
android:fillColor="#ffffff"/>
<path
android:pathData="M19.59,14
l-2.09,2.09
L15.41,14
L14,15.41
l2.09,2.09
L14,19.59
L15.41,21
l2.09,-2.08
L19.59,21
L21,19.59
l-2.08,-2.09
L21,15.41
L19.59,14
z"
android:fillColor="#ffffff"/>
</group>
</vector>

View file

@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#FFFFFF"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M18,16.08c-0.76,0 -1.44,0.3 -1.96,0.77L8.91,12.7c0.05,-0.23 0.09,-0.46 0.09,-0.7s-0.04,-0.47 -0.09,-0.7l7.05,-4.11c0.54,0.5 1.25,0.81 2.04,0.81 1.66,0 3,-1.34 3,-3s-1.34,-3 -3,-3 -3,1.34 -3,3c0,0.24 0.04,0.47 0.09,0.7L8.04,9.81C7.5,9.31 6.79,9 6,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3c0.79,0 1.5,-0.31 2.04,-0.81l7.12,4.16c-0.05,0.21 -0.08,0.43 -0.08,0.65 0,1.61 1.31,2.92 2.92,2.92 1.61,0 2.92,-1.31 2.92,-2.92s-1.31,-2.92 -2.92,-2.92z"/>
</vector>

View file

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<include
layout="@layout/partial_toolbar"
android:id="@+id/partial_toolbar"/>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clipChildren="false"
android:clipToPadding="false"
android:overScrollMode="never"
android:paddingTop="@dimen/content_padding"
android:paddingBottom="36dip" />
</LinearLayout>

View file

@ -0,0 +1,93 @@
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<include
layout="@layout/partial_toolbar"
android:id="@+id/partial_toolbar"/>
<TextView
android:id="@+id/text_io_label"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="@dimen/content_padding"
android:gravity="start|center_vertical"
android:textSize="14sp"
android:textStyle="bold"
android:textColor="@android:color/black"
android:visibility="invisible" />
<View
android:id="@+id/text_io_label_separator"
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginHorizontal="@dimen/content_padding"
android:background="@android:color/darker_gray"
android:visibility="invisible" />
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<!-- id must be assigned to scroll views to restore scroll position automatically on activity resume -->
<androidx.core.widget.NestedScrollView
android:id="@+id/text_io_nested_scroll_view"
android:layout_width="0dp"
android:layout_height="0dp"
android:padding="@dimen/content_padding_half"
android:fillViewport="true"
app:layout_constraintBottom_toTopOf="@+id/text_io_text_character_usage"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<HorizontalScrollView
android:id="@+id/text_io_horizontal_scroll_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true">
<LinearLayout
android:id="@+id/text_io_text_linear_layout"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:orientation="vertical">
<EditText
android:id="@+id/text_io_text"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="start|top"
android:inputType="textMultiLine"
android:importantForAutofill="no"
tools:ignore="LabelFor" />
</LinearLayout>
</HorizontalScrollView>
</LinearLayout>
</androidx.core.widget.NestedScrollView>
<TextView
android:id="@+id/text_io_text_character_usage"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingEnd="@dimen/content_padding_half"
app:layout_constraintBottom_toBottomOf="parent"
android:gravity="end|center_vertical"
android:textSize="12sp"
android:textColor="@android:color/black"
android:visibility="invisible" />
</androidx.constraintlayout.widget.ConstraintLayout>
</LinearLayout>

View file

@ -0,0 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/red_400"
android:paddingStart="24dp"
android:paddingTop="16dp"
android:paddingEnd="24dp"
android:paddingBottom="16dp">
<TextView
android:id="@+id/dialog_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="@style/TextAppearance.AppCompat.Title"
android:textColor="@android:color/white"/>
</LinearLayout>
<ScrollView
android:layout_width="wrap_content"
android:layout_height="wrap_content" >
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="24dp"
android:paddingTop="16dp"
android:paddingEnd="24dp"
android:paddingBottom="16dp"
android:scrollbars="vertical">
<TextView
android:id="@+id/dialog_message"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:autoLink="web|email"
android:textSize="14sp"
android:textColor="@android:color/tab_indicator_text"
android:textColorLink="@android:color/black"/>
</LinearLayout>
</ScrollView>
</LinearLayout>

View file

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<HorizontalScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clipChildren="false"
android:clipToPadding="false"
android:fillViewport="true"
android:paddingLeft="16dip"
android:paddingRight="16dip"
android:scrollbarStyle="outsideInset">
<TextView
android:id="@+id/code_text_view"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@color/background_markdown_code_block"
android:fontFamily="monospace"
android:lineSpacingExtra="2dip"
android:paddingLeft="16dip"
android:paddingTop="8dip"
android:paddingRight="16dip"
android:paddingBottom="8dip"
android:textAppearance="?android:attr/textAppearanceMedium"
android:textSize="12sp" />
</HorizontalScrollView>

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/default_text_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginLeft="16dip"
android:layout_marginRight="16dip"
android:breakStrategy="simple"
android:hyphenationFrequency="none"
android:lineSpacingExtra="2dip"
android:paddingTop="8dip"
android:paddingBottom="8dip"
android:textAppearance="?android:attr/textAppearanceMedium"
android:textColor="#000"
android:textSize="12sp" />

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/toolbar_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="?attr/colorPrimaryDark"
android:gravity="center_vertical"
android:minHeight="?attr/actionBarSize"
android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
app:popupTheme="@style/ThemeOverlay.AppCompat.Light"
app:titleTextAppearance="@style/Toolbar.Title">
</androidx.appcompat.widget.Toolbar>
</LinearLayout>

View file

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/menu_item_share_report"
android:icon="@drawable/ic_share"
android:title="@string/action_share"
app:showAsAction="never" />
<item
android:id="@+id/menu_item_copy_report"
android:icon="@drawable/ic_copy"
android:title="@string/action_copy"
app:showAsAction="never" />
<item
android:id="@+id/menu_item_save_report_to_file"
android:title="@string/action_save_to_file"
app:showAsAction="never" />
</menu>

View file

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/menu_item_cancel"
android:title="@string/action_cancel"
app:showAsAction="never" />
<item
android:id="@+id/menu_item_share_text"
android:icon="@drawable/ic_share"
android:title="@string/action_share"
app:showAsAction="never" />
<item
android:id="@+id/menu_item_copy_text"
android:icon="@drawable/ic_copy"
android:title="@string/action_copy"
app:showAsAction="never" />
</menu>

View file

@ -0,0 +1,59 @@
#!/bin/bash
subscribed_repositories() {
local main_sources
main_sources=$(grep -P '^\s*deb\s' "@TERMUX_PREFIX@/etc/apt/sources.list")
if [ -n "$main_sources" ]; then
echo "#### sources.list"
echo "\`$main_sources\`"
fi
local filename repo_package supl_sources
while read -r filename; do
repo_package=$(dpkg -S "$filename" 2>/dev/null | cut -d : -f 1)
supl_sources=$(grep -P '^\s*deb\s' "$filename")
if [ -n "$supl_sources" ]; then
if [ -n "$repo_package" ]; then
echo "#### $repo_package (sources.list.d/$(basename "$filename"))"
else
echo "#### sources.list.d/$(basename "$filename")"
fi
echo "\`$supl_sources\` "
fi
done < <(find "@TERMUX_PREFIX@/etc/apt/sources.list.d" -maxdepth 1 ! -type d)
}
updatable_packages() {
local updatable
if [ "$(id -u)" = "0" ]; then
echo "Running as root. Cannot check updatable packages."
else
apt update >/dev/null 2>&1
updatable=$(apt list --upgradable 2>/dev/null | tail -n +2)
if [ -z "$updatable" ];then
echo "All packages up to date"
else
echo $'```\n'"$updatable"$'\n```\n'
fi
fi
}
output="
### Subscribed Repositories
$(subscribed_repositories)
##
### Updatable Packages
$(updatable_packages)
##
"
echo "$output"

Binary file not shown.

View file

@ -0,0 +1,19 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="Theme.MaterialComponents.DayNight.TermuxPrimaryActivity" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">@color/red_400</item>
<item name="colorPrimaryVariant">@color/red_800</item>
<item name="colorOnPrimary">@color/black</item>
<!-- Secondary brand color. -->
<item name="colorSecondary">@color/grey_900</item>
<item name="colorSecondaryVariant">@color/black</item>
<item name="colorOnSecondary">@color/white</item>
<!-- Status bar color. -->
<item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item>
<!-- Text color. -->
<item name="android:textColorLink">@color/grey_200</item>
</style>
</resources>

View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="background_markdown_code_inline">#1F000000</color>
<color name="background_markdown_code_block">#0F000000</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
<color name="red_400">#FF0000</color>
<color name="red_800">#C4001D</color>
<color name="grey_200">#EEEEEE</color>
<color name="grey_800">#424242</color>
<color name="grey_900">#212121</color>
<color name="red_error">#DC143C</color>
<color name="red_error_link">#FC143C</color>
</resources>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Default screen margins, per the Android Design guidelines. -->
<dimen name="activity_horizontal_margin">16dp</dimen>
<dimen name="activity_vertical_margin">16dp</dimen>
<dimen name="content_padding">8dip</dimen>
<dimen name="content_padding_double">16dip</dimen>
<dimen name="content_padding_half">4dip</dimen>
</resources>

View file

@ -0,0 +1,118 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE resources [
<!ENTITY TERMUX_PACKAGE_NAME "com.termux">
<!ENTITY TERMUX_APP_NAME "Termux">
<!ENTITY TERMUX_API_APP_NAME "Termux:API">
<!ENTITY TERMUX_BOOT_APP_NAME "Termux:Boot">
<!ENTITY TERMUX_FLOAT_APP_NAME "Termux:Float">
<!ENTITY TERMUX_STYLING_APP_NAME "Termux:Styling">
<!ENTITY TERMUX_TASKER_APP_NAME "Termux:Tasker">
<!ENTITY TERMUX_WIDGET_APP_NAME "Termux:Widget">
<!ENTITY TERMUX_PREFIX_DIR_PATH "/data/data/com.termux/files/usr">
]>
<resources>
<string name="msg_directory_absolute_path">%1$s Directory Absolute Path: \"%2$s\"</string>
<!-- PackageUtils -->
<string name="error_app_not_installed_or_disabled_warning">The %1$s (%2$s) app is not installed or is disabled."</string>
<string name="error_get_package_context_failed_title">Failed To Get Package Context</string>
<string name="error_get_package_context_failed_message">Failed to get package context for the \"%1$s\" package.
This may be because the app package is not installed or it has different APK signature from the current app.
Check install instruction at %2$s for more details.</string>
<string name="error_get_component_state_failed">Failed to get %1$s/%2$s component state"</string>
<string name="error_enable_component_failed">Failed to enable %1$s/%2$s component"</string>
<string name="error_disable_component_failed">Failed to enable %1$s/%2$s component"</string>
<!-- PermissionUtils -->
<string name="message_sudo_please_grant_permissions">Please grant permissions on next screen</string>
<!-- ReportActivity -->
<string name="title_report_text">Report Text</string>
<string name="msg_report_truncated">**Report Truncated**\n\nReport is too large to view here.
Use `Save To File` option from options menu (3-dots on top right) and view it in an external text editor app.\n\n##\n\n</string>
<!-- ShareUtils -->
<string name="title_share_with">Share With</string>
<string name="title_open_url_with">Open URL With</string>
<string name="msg_storage_permission_not_granted">The storage permission not granted."</string>
<string name="msg_file_saved_successfully">The %1$s file saved successfully at \"%2$s\""</string>
<!-- ShellUtils -->
<string name="error_sending_sigkill_to_process">Sending SIGKILL to process on user request or because android is killing the execution service</string>
<string name="error_execution_cancelled">Execution has been cancelled since execution service is being killed</string>
<string name="error_failed_to_execute_termux_session_command">"Failed to execute \"%1$s\" termux session command"</string>
<string name="error_failed_to_execute_termux_task_command">"Failed to execute \"%1$s\" termux task command"</string>
<string name="error_exception_received_while_executing_termux_session_command">Exception received while to executing \"%1$s\" termux session command.\nException: %2$s</string>
<string name="error_exception_received_while_executing_termux_task_command">Exception received while to executing \"%1$s\" termux task command.\nException: %2$s"</string>
<!-- TermuxUtils -->
<string name="msg_report_issue">
If you want to report this issue, then copy its text from the options menu (3-dots on top right) and post
an issue on one of the following links.
\n\nIf you are posting a Termux app crash report, then please provide details on what you were doing that
caused the crash and how to reproduce it, if possible.
\n\nIf you are posting an issue on Github, then post it in the repository at which the report belongs at.
Issues opened or emails sent with **(partial) screenshots** instead of copied text or a file of this report
**will likely be automatically closed/deleted**. You may optionally remove any device specific info that
you consider private or don\'t want to share or that is not relevant to the issue.
\n\nWe do not provide support for any hacking related tools/scripts. Any questions asked about them over email,
on github or other official termux community forums **will likely be automatically closed/deleted** and may
even result in **temporary or permanent** ban. Check %1$s/wiki/Hacking for details.</string>
<string name="msg_termux_app_required_by_app">The &TERMUX_APP_NAME; is required by the %1$s app to run termux commands."</string>
<string name="error_termux_app_package_context_not_accessible">The &TERMUX_APP_NAME; app (package context) is not accessible."</string>
<string name="error_termux_prefix_dir_path_not_accessible">The &TERMUX_APP_NAME; app $PREFIX directory is not accessible by the %1$s app.
This may be because you have not installed or setup &TERMUX_APP_NAME; app or
&TERMUX_APP_NAME; app and %1$s app both have different APK signatures because you have managed to install both apps from different sources.
It may also be because &TERMUX_APP_NAME; $PREFIX directory \"&TERMUX_PREFIX_DIR_PATH;\" does not exist or does not have read,
write and execute permissions."</string>
<!-- Miscellaneous -->
<string name="action_yes">Yes</string>
<string name="action_no">No</string>
<string name="action_copy">Copy</string>
<string name="action_share">Share</string>
<string name="action_cancel">Cancel</string>
<string name="action_save_to_file">Save To File</string>
<!-- Launcher Icons -->
<string name="action_enable_launcher_icon">Enable Launcher Icon</string>
<string name="action_disable_launcher_icon">Disable Launcher Icon</string>
<string name="msg_enabling_launcher_icon">Enabling %1$s app launcher icon"</string>
<string name="msg_disabling_launcher_icon">Disabling %1$s app launcher icon"</string>
<string name="setting_launcher_icon_title">Launcher Icon Enabled</string>
<string name="setting_launcher_icon_enabled_off">Launcher Icon will be disabled.</string>
<string name="setting_launcher_icon_enabled_on">Launcher Icon will be enabled. (Default)</string>
<!-- Log Level -->
<string name="log_level_title">Log Level</string>
<string name="log_level_off">"Off"</string>
<string name="log_level_normal">"Normal"</string>
<string name="log_level_debug">"Debug"</string>
<string name="log_level_verbose">"Verbose"</string>
<string name="log_level_unknown">"*Unknown*"</string>
<string name="log_level_value">Logcat log level set to \"%1$s\"</string>
</resources>

View file

@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="Theme.MaterialComponents.DayNight.TermuxPrimaryActivity" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">@color/red_400</item>
<item name="colorPrimaryVariant">@color/red_800</item>
<item name="colorOnPrimary">@color/white</item>
<!-- Secondary brand color. -->
<item name="colorSecondary">@color/grey_900</item>
<item name="colorSecondaryVariant">@color/black</item>
<item name="colorOnSecondary">@color/white</item>
<!-- Status bar color. -->
<item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item>
<!-- Text color. -->
<item name="android:textColorPrimary">@color/black</item>
<item name="android:textColorLink">@color/grey_800</item>
<item name="android:textColorSecondary">@color/white</item>
</style>
<style name="Theme.AppCompat.TermuxReportActivity" parent="Theme.AppCompat.Light.NoActionBar">
<item name="colorPrimaryDark">#FF0000</item>
</style>
<style name="Theme.AppCompat.TermuxTextIOActivity" parent="Theme.AppCompat.Light.NoActionBar">
<item name="colorPrimaryDark">#FF0000</item>
</style>
<style name="Toolbar.Title" parent="TextAppearance.Widget.AppCompat.Toolbar.Title">
<item name="android:textSize">14sp</item>
</style>
<style name="ViewDivider">
<item name="android:layout_width">match_parent</item>
<item name="android:layout_height">1dp</item>
<item name="android:layout_marginTop">@dimen/activity_vertical_margin</item>
<item name="android:layout_marginBottom">@dimen/activity_vertical_margin</item>
<item name="android:background">?android:attr/listDivider</item>
</style>
</resources>