feat: integrate full Termux application

This commit is contained in:
Akash Yadav
2023-11-12 17:18:10 +05:30
parent ed52590ae3
commit 7788fe5d5a
286 changed files with 44628 additions and 1027 deletions
+1
View File
@@ -102,6 +102,7 @@
<module name="AndroidIDE.colorschemes" target="11" />
<module name="AndroidIDE.colorschemes.main" target="11" />
<module name="AndroidIDE.editor-languages" target="11" />
<module name="AndroidIDE.emulatorview" target="17" />
<module name="AndroidIDE.google-java-format" target="1.8" />
<module name="AndroidIDE.gradle-tooling" target="1.8" />
<module name="AndroidIDE.gradle-tooling.plugin" target="1.8" />
+1 -1
View File
@@ -22,6 +22,6 @@ dependencies {
implementation(libs.common.utilcode)
implementation(libs.google.auto.service.annotations)
implementation(libs.androidx.ktx)
implementation(libs.androidx.core.ktx)
implementation(libs.google.material)
}
+5 -8
View File
@@ -27,10 +27,6 @@ android {
generateLocaleConfig = true
}
compileOptions {
isCoreLibraryDesugaringEnabled = true
}
buildTypes { release { isShrinkResources = true } }
packaging {
@@ -104,7 +100,7 @@ dependencies {
implementation(libs.google.oss.licenses)
// Kotlin
implementation(libs.androidx.ktx)
implementation(libs.androidx.core.ktx)
implementation(libs.common.kotlin)
// Local projects here
@@ -112,7 +108,10 @@ dependencies {
implementation(projects.buildInfo)
implementation(projects.common)
implementation(projects.editor)
implementation(projects.emulatorview)
implementation(projects.termux.termuxApp)
implementation(projects.termux.termuxView)
implementation(projects.termux.termuxEmulator)
implementation(projects.termux.termuxShared)
implementation(projects.eventbus)
implementation(projects.eventbusAndroid)
implementation(projects.eventbusEvents)
@@ -138,8 +137,6 @@ dependencies {
implementation(projects.uidesigner)
implementation(projects.xmlInflater)
coreLibraryDesugaring(libs.androidx.lib.desugaring)
// This is to build the tooling-api-impl project before the app is built
// So we always copy the latest JAR file to assets
compileOnly(projects.subprojects.toolingApiImpl)
+112 -111
View File
@@ -1,16 +1,125 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:installLocation="internalOnly"
tools:ignore="Instantiatable">
<application
android:allowBackup="false"
android:allowNativeHeapPointerTagging="false"
android:enableOnBackInvokedCallback="true"
android:hardwareAccelerated="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:largeHeap="true"
android:name=".app.IDEApplication"
android:preserveLegacyExternalStorage="true"
android:requestLegacyExternalStorage="true"
android:resizeableActivity="false"
android:supportsRtl="true"
android:theme="@style/Theme.AndroidIDE"
tools:ignore="UnusedAttribute,MediaCapabilities">
<activity
android:exported="true"
android:name=".activities.CrashHandlerActivity"
android:windowSoftInputMode="stateAlwaysHidden">
<intent-filter>
<action android:name="com.itsaky.androidide.REPORT_CRASH" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<activity
android:exported="true"
android:name=".activities.SplashActivity"
android:theme="@style/AppTheme.Splash">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:exported="false"
android:name=".activities.MainActivity"
android:theme="@style/Theme.AndroidIDE" />
<activity
android:configChanges="orientation|screenSize"
android:launchMode="singleTop"
android:name=".activities.editor.EditorActivityKt"
android:windowSoftInputMode="adjustResize" />
<activity android:name=".activities.PreferencesActivity" />
<activity
android:configChanges="orientation|screenSize"
android:name=".activities.AboutActivity" />
<activity
android:configChanges="orientation|screenSize"
android:name=".activities.TerminalActivity"
android:windowSoftInputMode="adjustResize" />
<activity
android:name="com.google.android.gms.oss.licenses.OssLicensesMenuActivity"
android:theme="@style/Theme.AndroidIDE" />
<activity
android:name="com.google.android.gms.oss.licenses.OssLicensesActivity"
android:theme="@style/Theme.AndroidIDE" />
<provider
android:authorities="com.itsaky.androidide.documents"
android:exported="true"
android:grantUriPermissions="true"
android:name=".provider.IDEDocumentsProvider"
android:permission="android.permission.MANAGE_DOCUMENTS">
<intent-filter>
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
</intent-filter>
</provider>
<provider
android:authorities="${applicationId}.providers.fileprovider"
android:exported="false"
android:grantUriPermissions="true"
android:name=".provider.IDEFileProvider">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/ide_file_provider_paths" />
</provider>
<receiver
android:enabled="true"
android:exported="true"
android:name=".services.InstallationResultReceiver"
tools:ignore="ExportedReceiver">
<intent-filter>
<action android:name="com.itsaky.androidide.installer.INSTALL_PACKAGE" />
</intent-filter>
</receiver>
<service
android:exported="false"
android:name=".services.builder.GradleBuildService" />
<service
android:exported="true"
android:name=".services.log.LogReceiverService"
android:permission="com.itsaky.androidide.permission.BIND_LOG_SERVICE">
<intent-filter>
<action android:name="com.itsaky.androidide.LOG_SERVICE_ACTION" />
</intent-filter>
</service>
<service
android:enabled="false"
android:exported="false"
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService">
<meta-data
android:name="autoStoreLocales"
android:value="true" />
</service>
</application>
<permission
android:name="com.itsaky.androidide.permission.BIND_LOG_SERVICE"
android:description="@string/permdesc_bind_logger_service"
android:label="@string/permlab_bind_logger_service"
android:name="com.itsaky.androidide.permission.BIND_LOG_SERVICE"
android:protectionLevel="normal" />
<uses-permission android:name="com.itsaky.androidide.permission.BIND_LOG_SERVICE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
@@ -19,114 +128,6 @@
tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:name=".app.IDEApplication"
android:allowBackup="false"
android:allowNativeHeapPointerTagging="false"
android:enableOnBackInvokedCallback="true"
android:hardwareAccelerated="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:largeHeap="true"
android:preserveLegacyExternalStorage="true"
android:requestLegacyExternalStorage="true"
android:resizeableActivity="false"
android:supportsRtl="true"
android:theme="@style/Theme.AndroidIDE"
tools:ignore="UnusedAttribute,MediaCapabilities">
<activity
android:name=".activities.CrashHandlerActivity"
android:exported="true"
android:windowSoftInputMode="stateAlwaysHidden">
<intent-filter>
<action android:name="com.itsaky.androidide.REPORT_CRASH" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<activity
android:name=".activities.SplashActivity"
android:exported="true"
android:theme="@style/AppTheme.Splash">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".activities.MainActivity"
android:exported="false"
android:theme="@style/Theme.AndroidIDE"/>
<activity
android:name=".activities.editor.EditorActivityKt"
android:configChanges="orientation|screenSize"
android:launchMode="singleTop"
android:windowSoftInputMode="adjustResize" />
<activity android:name=".activities.PreferencesActivity" />
<activity
android:name=".activities.AboutActivity"
android:configChanges="orientation|screenSize" />
<activity
android:name=".activities.TerminalActivity"
android:configChanges="orientation|screenSize"
android:windowSoftInputMode="adjustResize" />
<service
android:name=".services.builder.GradleBuildService"
android:exported="false" />
<service
android:name=".services.log.LogReceiverService"
android:exported="true"
android:permission="com.itsaky.androidide.permission.BIND_LOG_SERVICE">
<intent-filter>
<action android:name="com.itsaky.androidide.LOG_SERVICE_ACTION" />
</intent-filter>
</service>
<service
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
android:enabled="false"
android:exported="false">
<meta-data
android:name="autoStoreLocales"
android:value="true" />
</service>
<provider
android:name=".provider.IDEDocumentsProvider"
android:authorities="com.itsaky.androidide.documents"
android:exported="true"
android:grantUriPermissions="true"
android:permission="android.permission.MANAGE_DOCUMENTS">
<intent-filter>
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
</intent-filter>
</provider>
<provider
android:name=".provider.IDEFileProvider"
android:authorities="${applicationId}.providers.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/ide_file_provider_paths" />
</provider>
<receiver
android:name=".services.InstallationResultReceiver"
android:enabled="true"
android:exported="true"
tools:ignore="ExportedReceiver">
<intent-filter>
<action android:name="com.itsaky.androidide.installer.INSTALL_PACKAGE" />
</intent-filter>
</receiver>
<activity
android:name="com.google.android.gms.oss.licenses.OssLicensesMenuActivity"
android:theme="@style/Theme.AndroidIDE" />
<activity
android:name="com.google.android.gms.oss.licenses.OssLicensesActivity"
android:theme="@style/Theme.AndroidIDE" />
</application>
</manifest>
@@ -1,626 +0,0 @@
/*
* This file is part of AndroidIDE.
*
* AndroidIDE is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* AndroidIDE 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 for more details.
*
* You should have received a copy of the GNU General Public License
* along with AndroidIDE. If not, see <https://www.gnu.org/licenses/>.
*/
package com.itsaky.androidide.activities;
import static androidx.core.view.WindowCompat.getInsetsController;
import static com.itsaky.androidide.preferences.internal.GeneralPreferencesKt.getUseSystemShell;
import static com.itsaky.androidide.utils.Environment.BIN_DIR;
import static com.itsaky.androidide.utils.Environment.HOME;
import static com.itsaky.androidide.utils.Environment.LOGIN_SHELL;
import static com.itsaky.androidide.utils.Environment.PREFIX;
import static com.itsaky.androidide.utils.Environment.getEnvironment;
import android.os.Bundle;
import android.util.TypedValue;
import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.View;
import android.widget.Button;
import android.widget.LinearLayout;
import androidx.annotation.NonNull;
import androidx.core.content.ContextCompat;
import com.blankj.utilcode.util.ClipboardUtils;
import com.blankj.utilcode.util.KeyboardUtils;
import com.blankj.utilcode.util.ThrowableUtils;
import com.itsaky.androidide.R;
import com.itsaky.androidide.app.IDEActivity;
import com.itsaky.androidide.databinding.ActivityTerminalBinding;
import com.itsaky.androidide.fragments.CrashReportFragment;
import com.itsaky.androidide.fragments.sheets.ProgressSheet;
import com.itsaky.androidide.models.Constants;
import com.itsaky.androidide.preferences.internal.GeneralPreferencesKt;
import com.itsaky.androidide.ui.virtualkeys.SpecialButton;
import com.itsaky.androidide.ui.virtualkeys.VirtualKeyButton;
import com.itsaky.androidide.ui.virtualkeys.VirtualKeysConstants;
import com.itsaky.androidide.ui.virtualkeys.VirtualKeysInfo;
import com.itsaky.androidide.ui.virtualkeys.VirtualKeysView;
import com.itsaky.androidide.utils.BootstrapInstaller;
import com.itsaky.androidide.utils.ILogger;
import com.itsaky.androidide.utils.TypefaceUtilsKt;
import com.itsaky.terminal.TerminalEmulator;
import com.itsaky.terminal.TerminalSession;
import com.itsaky.terminal.TerminalSessionClient;
import com.itsaky.terminal.TextStyle;
import com.itsaky.terminal.view.TerminalView;
import com.itsaky.terminal.view.TerminalViewClient;
import org.json.JSONException;
import java.io.File;
import java.io.FileOutputStream;
import java.util.Map;
public class TerminalActivity extends IDEActivity
implements TerminalViewClient, TerminalSessionClient {
public static final String KEY_WORKING_DIRECTORY = "terminal_workingDirectory";
private static final ILogger LOG = ILogger.newInstance("TerminalActivity");
private static final byte[] SOURCES_LIST_CONTENT =
"deb https://packages.androidide.com/apt/termux-main/ stable main".getBytes();
private static final String KEY_FONT_SIZE = "terminal_fontSize";
private ActivityTerminalBinding binding;
private TerminalView terminal;
private TerminalSession session;
private boolean isVisible = false;
private KeyListener listener;
private int MIN_FONT_SIZE;
private int MAX_FONT_SIZE;
private int DEFAULT_FONT_SIZE;
public boolean isVisible() {
return isVisible;
}
@Override
public boolean onKeyUp(int keyCode, KeyEvent e) {
return false;
}
@Override
public void onTextChanged(TerminalSession changedSession) {
terminal.onScreenUpdated();
}
@Override
public void onTitleChanged(TerminalSession changedSession) {}
@Override
public void onSessionFinished(TerminalSession finishedSession) {
finish();
}
@Override
public void onCopyTextToClipboard(TerminalSession session, String text) {
ClipboardUtils.copyText("AndroidIDE Terminal", text);
}
@Override
public void onPasteTextFromClipboard(TerminalSession session) {
String clip = ClipboardUtils.getText().toString();
if (clip.trim().length() > 0 && terminal != null && terminal.mEmulator != null) {
terminal.mEmulator.paste(clip);
}
}
@Override
public void onBell(TerminalSession session) {}
@Override
public void onColorsChanged(TerminalSession session) {}
@Override
public void onTerminalCursorStateChange(boolean state) {}
@Override
public Integer getTerminalCursorStyle() {
return TerminalEmulator.DEFAULT_TERMINAL_CURSOR_STYLE;
}
@Override
public float onScale(float scale) {
if (scale < 0.9f || scale > 1.1f) {
boolean increase = scale > 1.f;
changeFontSize(increase);
return 1.0f;
}
return scale;
}
private void changeFontSize(final boolean increase) {
int fontSize = getFontSize();
fontSize += (increase ? 1 : -1) * 2;
fontSize = Math.max(MIN_FONT_SIZE, Math.min(fontSize, MAX_FONT_SIZE));
setFontSize(fontSize, true);
}
public void setFontSize(int value, boolean apply) {
getApp().getPrefManager().putString(KEY_FONT_SIZE, String.valueOf(value));
if (apply) {
terminal.setTextSize(getFontSize());
}
}
public int getFontSize() {
int fontSize;
try {
fontSize =
Integer.parseInt(
getApp()
.getPrefManager()
.getString(KEY_FONT_SIZE, String.valueOf(DEFAULT_FONT_SIZE)));
} catch (NumberFormatException | ClassCastException e) {
fontSize = DEFAULT_FONT_SIZE;
}
return Math.min(Math.max(fontSize, MIN_FONT_SIZE), MAX_FONT_SIZE);
}
@Override
public void onSingleTapUp(MotionEvent e) {
showSoftInput();
}
@Override
public boolean shouldBackButtonBeMappedToEscape() {
return false;
}
@Override
public boolean shouldEnforceCharBasedInput() {
return true;
}
@Override
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) {
if (keyCode == KeyEvent.KEYCODE_ENTER && !session.isRunning()) {
finish();
return true;
}
return false;
}
@Override
public boolean onLongPress(MotionEvent event) {
return false;
}
@Override
public boolean readControlKey() {
Boolean state = binding.virtualKeyTable.readSpecialButton(SpecialButton.CTRL, true);
return state != null && state;
}
@Override
public boolean readAltKey() {
Boolean state = binding.virtualKeyTable.readSpecialButton(SpecialButton.ALT, true);
return state != null && state;
}
@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() {
setTerminalCursorBlinkingState(true);
if (session != null) {
binding
.getRoot()
.setBackgroundColor(
session.getEmulator().mColors.mCurrentColors[TextStyle.COLOR_INDEX_BACKGROUND]);
}
}
@Override
public void logError(String tag, String message) {
LOG.error(tag + ":", message);
}
@Override
public void logWarn(String tag, String message) {
LOG.warn(tag + ":", message);
}
@Override
public void logInfo(String tag, String message) {
LOG.info(tag + ":", message);
}
@Override
public void logDebug(String tag, String message) {
LOG.debug(tag + ":", message);
}
@Override
public void logVerbose(String tag, String message) {
LOG.verbose(tag + ":", message);
}
@Override
public void logStackTraceWithMessage(String tag, String message, Exception e) {
LOG.error(tag + ":", message, e);
}
@Override
public void logStackTrace(String tag, Exception e) {
LOG.error(tag + ":", e);
}
private void setTerminalCursorBlinkingState(boolean start) {
if (terminal != null && terminal.mEmulator != null) {
terminal.setTerminalCursorBlinkerState(start, true);
}
}
@Override
public int getNavigationBarColor() {
return ContextCompat.getColor(this, android.R.color.black);
}
@Override
public int getStatusBarColor() {
return ContextCompat.getColor(this, android.R.color.black);
}
@Override
protected void onCreate(Bundle savedInstanceState) {
final var controller = getInsetsController(getWindow(), getWindow().getDecorView());
controller.setAppearanceLightNavigationBars(false);
controller.setAppearanceLightStatusBars(false);
super.onCreate(savedInstanceState);
final var bash = new File(BIN_DIR, "bash");
final var useSystemShell = getUseSystemShell();
if ((PREFIX.exists()
&& PREFIX.isDirectory()
&& bash.exists()
&& bash.isFile()
&& bash.canExecute())
|| useSystemShell) {
setupTerminalView();
} else {
LOG.info("Bootstrap is not installed.");
// Show the progress sheet
final var progress = new ProgressSheet();
progress.setSubMessageEnabled(true);
progress.setMessage(getString(R.string.please_wait));
progress.setSubMessage(getString(R.string.msg_reading_bootstrap));
progress.setCancelable(false);
progress.show(getSupportFragmentManager(), "extract_bootstrap_progress");
LOG.debug("Starting installation...");
// Install bootstrap asynchronously
final var future =
BootstrapInstaller.doInstall(
this, message -> runOnUiThread(() -> progress.setSubMessage(message)));
future.whenComplete(
(voidResult, throwable) -> {
LOG.debug("Completable future has been complete.", throwable);
runOnUiThread(
() -> {
progress.dismissAllowingStateLoss();
if (future.isCompletedExceptionally() || throwable != null) {
LOG.error("Future has been completed exceptionally.");
showInstallationError(throwable);
return;
}
setupTerminalView();
});
});
}
}
@Override
protected View bindLayout() {
binding = ActivityTerminalBinding.inflate(getLayoutInflater());
return binding.getRoot();
}
private void showInstallationError(Throwable throwable) {
binding.virtualKeyTable.setVisibility(View.GONE);
final var frag =
CrashReportFragment.newInstance(
getString(R.string.title_installation_failed),
getString(R.string.msg_bootstrap_installation_error),
ThrowableUtils.getFullStackTrace(throwable),
false);
getSupportFragmentManager()
.beginTransaction()
.replace(binding.root.getId(), frag, "bootstrap_install_error_fragment")
.addToBackStack(null)
.commit();
}
private void setupTerminalView() {
setFontVariables();
terminal = new TerminalView(this, null);
terminal.setTerminalViewClient(this);
terminal.attachSession(createSession(getWorkingDirectory()));
terminal.setKeepScreenOn(true);
terminal.setTextSize(getFontSize());
terminal.setTypeface(TypefaceUtilsKt.customOrJBMono(true));
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(-1, 0);
params.weight = 1f;
binding.getRoot().addView(terminal, 0, params);
try {
binding.virtualKeyTable.setVirtualKeysViewClient(getKeyListener());
binding.virtualKeyTable.reload(
new VirtualKeysInfo(
Constants.VIRTUAL_KEYS, "", VirtualKeysConstants.CONTROL_CHARS_ALIASES));
} catch (JSONException e) {
LOG.error("Unable to parse terminal virtual keys json data", e);
}
}
@NonNull
private String getWorkingDirectory() {
final Bundle extras = getIntent().getExtras();
if (extras != null && extras.containsKey(KEY_WORKING_DIRECTORY)) {
String directory = extras.getString(KEY_WORKING_DIRECTORY, null);
if (directory == null || directory.trim().length() <= 0) {
directory = HOME.getAbsolutePath();
}
return directory;
}
return HOME.getAbsolutePath();
}
private KeyListener getKeyListener() {
return listener == null ? listener = new KeyListener(terminal) : listener;
}
private TerminalSession createSession(final String workingDirectory) {
final Map<String, String> environment = getEnvironment();
final String[] env = new String[environment.size()];
int i = 0;
for (Map.Entry<String, String> entry : environment.entrySet()) {
env[i] = entry.getKey() + "=" + entry.getValue();
i++;
}
session =
new TerminalSession(
getShellPath(), // Shell command
workingDirectory, // Working directory
new String[] {}, // Arguments
env, // Environment variables
TerminalEmulator.DEFAULT_TERMINAL_TRANSCRIPT_ROWS, // Transcript rows
this // TerminalSessionClient
);
try {
final var file = new File(PREFIX, "etc/apt/sources.list");
final var out = new FileOutputStream(file);
out.write(SOURCES_LIST_CONTENT);
out.flush();
out.close();
} catch (Throwable th) {
LOG.error("Unable to update sources.list", th);
}
return session;
}
@NonNull
private String getShellPath() {
final var useSystemShell = GeneralPreferencesKt.getUseSystemShell();
if (!useSystemShell && LOGIN_SHELL.exists() && LOGIN_SHELL.isFile()) {
return LOGIN_SHELL.getAbsolutePath();
}
if (!useSystemShell) {
LOG.error(
"Default shell does not exist. Falling back to '/system/bin/sh'.",
"This should not happen in normal circumstances.");
}
return "/system/bin/sh";
}
public void setFontVariables() {
int[] sizes = getDefaultFontSizes();
DEFAULT_FONT_SIZE = sizes[0];
MIN_FONT_SIZE = sizes[1];
MAX_FONT_SIZE = sizes[2];
}
// https://github.com/termux/termux-app/blob/82b15803126138eef8899e0c7b582713f872cd09/termux-shared/src/main/java/com/termux/shared/termux/settings/preferences/TermuxAppSharedPreferences.java
private int[] getDefaultFontSizes() {
float dipInPixels =
TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP, 1, 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(9 * 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
if (getApp()
.getPrefManager()
.getString(KEY_FONT_SIZE, "<not_available>")
.equals("<not_available>")) {
setFontSize(defaultFontSize, false);
}
return sizes;
}
@Override
protected void onResume() {
super.onResume();
showSoftInput();
setTerminalCursorBlinkingState(true);
}
@Override
protected void onStart() {
super.onStart();
isVisible = true;
}
@Override
protected void onStop() {
super.onStop();
isVisible = false;
setTerminalCursorBlinkingState(false);
}
@Override
protected void onDestroy() {
super.onDestroy();
binding = null;
}
private void showSoftInput() {
if (terminal != null) {
terminal.requestFocus();
KeyboardUtils.showSoftInput(terminal);
}
}
private static final class KeyListener implements VirtualKeysView.IVirtualKeysView {
private final TerminalView terminal;
public KeyListener(TerminalView terminal) {
this.terminal = terminal;
}
@Override
public void onVirtualKeyButtonClick(View view, VirtualKeyButton buttonInfo, Button button) {
if (terminal == null) {
return;
}
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(key, ctrlDown, altDown, shiftDown, fnDown);
ctrlDown = false;
altDown = false;
shiftDown = false;
fnDown = false;
}
}
} else {
onTerminalExtraKeyButtonClick(buttonInfo.getKey(), false, false, false, false);
}
}
private void onTerminalExtraKeyButtonClick(
String key, boolean ctrlDown, boolean altDown, boolean shiftDown, boolean fnDown) {
if (VirtualKeysConstants.PRIMARY_KEY_CODES_FOR_STRINGS.containsKey(key)) {
Integer keyCode = VirtualKeysConstants.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);
terminal.onKeyDown(keyCode, keyEvent);
} else {
// not a control char
for (int off = 0; off < key.length(); ) {
int codePoint = key.codePointAt(off);
terminal.inputCodePoint(codePoint, ctrlDown, altDown);
off += Character.charCount(codePoint);
}
}
}
@Override
public boolean performVirtualKeyButtonHapticFeedback(
View view, VirtualKeyButton buttonInfo, Button button) {
// No need to handle this
// VirtualKeysView will take care of performing haptic feedback
return false;
}
}
}
@@ -0,0 +1,69 @@
/*
* This file is part of AndroidIDE.
*
* AndroidIDE is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* AndroidIDE 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 for more details.
*
* You should have received a copy of the GNU General Public License
* along with AndroidIDE. If not, see <https://www.gnu.org/licenses/>.
*/
package com.itsaky.androidide.activities
import android.os.Bundle
import androidx.core.content.ContextCompat
import androidx.core.view.WindowCompat
import com.itsaky.androidide.utils.Environment
import com.itsaky.androidide.utils.ILogger
import com.termux.app.TermuxActivity
import java.io.File
class TerminalActivity : TermuxActivity() {
companion object {
const val KEY_WORKING_DIRECTORY = "terminal_workingDirectory"
private val LOG = ILogger.newInstance("TerminalActivity")
private val SOURCES_LIST_CONTENT = "deb https://packages.androidide.com/apt/termux-main/ stable main".toByteArray()
}
override val navigationBarColor: Int
get() = ContextCompat.getColor(this, android.R.color.black)
override val statusBarColor: Int
get() = ContextCompat.getColor(this, android.R.color.black)
override fun onCreate(savedInstanceState: Bundle?) {
val controller = WindowCompat.getInsetsController(
window, window.decorView)
controller.isAppearanceLightNavigationBars = false
controller.isAppearanceLightStatusBars = false
super.onCreate(savedInstanceState)
}
private val workingDirectory: String
get() {
val extras = intent.extras
if (extras != null && extras.containsKey(KEY_WORKING_DIRECTORY)) {
var directory = extras.getString(KEY_WORKING_DIRECTORY, null)
if (directory.isNullOrBlank()) {
directory = Environment.HOME.absolutePath
}
return directory
}
return Environment.HOME.absolutePath
}
private fun writeSourcesList() {
try {
File(Environment.PREFIX, "etc/apt/sources.list").writeBytes(SOURCES_LIST_CONTENT)
} catch (th: Throwable) {
LOG.error("Unable to update sources.list", th)
}
}
}
@@ -17,54 +17,8 @@
package com.itsaky.androidide.app
import android.os.Bundle
import androidx.appcompat.app.AppCompatDelegate
import androidx.appcompat.app.AppCompatDelegate.getDefaultNightMode
import androidx.appcompat.app.AppCompatDelegate.setDefaultNightMode
import androidx.core.os.LocaleListCompat
import com.itsaky.androidide.eventbus.events.preferences.PreferenceChangeEvent
import com.itsaky.androidide.preferences.internal.SELECTED_LOCALE
import com.itsaky.androidide.preferences.internal.UI_MODE
import com.itsaky.androidide.preferences.internal.uiMode
import com.itsaky.androidide.ui.themes.ThemeManager
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode.MAIN
import java.util.Locale
abstract class IDEActivity : BaseIDEActivity() {
val app: IDEApplication
get() = application as IDEApplication
override fun onStart() {
super.onStart()
EventBus.getDefault().register(this)
}
override fun onStop() {
super.onStop()
EventBus.getDefault().unregister(this)
}
override fun onCreate(savedInstanceState: Bundle?) {
ThemeManager.applyTheme(this)
super.onCreate(savedInstanceState)
}
@Subscribe(threadMode = MAIN)
fun onPrefChanged(event: PreferenceChangeEvent) {
if (event.key == UI_MODE && uiMode != getDefaultNightMode()) {
setDefaultNightMode(uiMode)
} else if (event.key == SELECTED_LOCALE) {
// Use empty locale list if the locale has been reset to 'System Default'
val selectedLocale = com.itsaky.androidide.preferences.internal.selectedLocale
val localeListCompat = selectedLocale?.let {
LocaleListCompat.create(Locale.forLanguageTag(selectedLocale))
} ?: LocaleListCompat.getEmptyLocaleList()
AppCompatDelegate.setApplicationLocales(localeListCompat)
}
}
}
@@ -0,0 +1,28 @@
/*
* This file is part of AndroidIDE.
*
* AndroidIDE is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* AndroidIDE 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 for more details.
*
* You should have received a copy of the GNU General Public License
* along with AndroidIDE. If not, see <https://www.gnu.org/licenses/>.
*/
package com.itsaky.androidide.app
import androidx.lifecycle.DefaultLifecycleObserver
/**
* Delegate for [IDEActivity].
*
* @author Akash Yadav
*/
interface IDEActivityDelegate : DefaultLifecycleObserver {
}
@@ -22,6 +22,7 @@ import android.content.Intent
import android.net.Uri
import android.os.StrictMode
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.os.LocaleListCompat
import androidx.lifecycle.Observer
import androidx.work.Constraints
import androidx.work.ExistingPeriodicWorkPolicy
@@ -44,7 +45,9 @@ import com.itsaky.androidide.events.LspJavaEventsIndex
import com.itsaky.androidide.events.ProjectsApiEventsIndex
import com.itsaky.androidide.preferences.KEY_DEVOPTS_DEBUGGING_DUMPLOGS
import com.itsaky.androidide.preferences.dumpLogs
import com.itsaky.androidide.preferences.internal.SELECTED_LOCALE
import com.itsaky.androidide.preferences.internal.STAT_OPT_IN
import com.itsaky.androidide.preferences.internal.UI_MODE
import com.itsaky.androidide.preferences.internal.statOptIn
import com.itsaky.androidide.preferences.internal.uiMode
import com.itsaky.androidide.stats.AndroidIDEStats
@@ -53,20 +56,23 @@ import com.itsaky.androidide.syntax.colorschemes.SchemeAndroidIDE
import com.itsaky.androidide.tasks.executeAsync
import com.itsaky.androidide.treesitter.TreeSitter
import com.itsaky.androidide.ui.themes.IDETheme
import com.itsaky.androidide.ui.themes.IThemeManager
import com.itsaky.androidide.ui.themes.ThemeManager
import com.itsaky.androidide.utils.ILogger
import com.itsaky.androidide.utils.RecyclableObjectPool
import com.itsaky.androidide.utils.VMUtils
import com.itsaky.androidide.utils.flashError
import com.termux.app.TermuxApplication
import io.github.rosemoe.sora.widget.schemes.EditorColorScheme
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import java.lang.Thread.UncaughtExceptionHandler
import java.time.Duration
import java.util.Locale
import kotlin.system.exitProcess
class IDEApplication : BaseApplication() {
class IDEApplication : TermuxApplication() {
private var uncaughtExceptionHandler: UncaughtExceptionHandler? = null
private var ideLogcatReader: IDELogcatReader? = null
@@ -109,7 +115,7 @@ class IDEApplication : BaseApplication() {
AppCompatDelegate.setDefaultNightMode(uiMode)
if (ThemeManager.getCurrentTheme() == IDETheme.MATERIAL_YOU) {
if (IThemeManager.getInstance().getCurrentTheme() == IDETheme.MATERIAL_YOU) {
DynamicColors.applyToActivitiesIfAvailable(this)
}
@@ -214,6 +220,17 @@ class IDEApplication : BaseApplication() {
} else {
stopLogcatReader()
}
} else if (event.key == UI_MODE && uiMode != AppCompatDelegate.getDefaultNightMode()) {
AppCompatDelegate.setDefaultNightMode(uiMode)
} else if (event.key == SELECTED_LOCALE) {
// Use empty locale list if the locale has been reset to 'System Default'
val selectedLocale = com.itsaky.androidide.preferences.internal.selectedLocale
val localeListCompat = selectedLocale?.let {
LocaleListCompat.create(Locale.forLanguageTag(selectedLocale))
} ?: LocaleListCompat.getEmptyLocaleList()
AppCompatDelegate.setApplicationLocales(localeListCompat)
}
}
@@ -37,6 +37,7 @@ import com.itsaky.androidide.resources.R.drawable
import com.itsaky.androidide.resources.R.string
import com.itsaky.androidide.resources.localization.LocaleProvider
import com.itsaky.androidide.ui.themes.IDETheme
import com.itsaky.androidide.ui.themes.IThemeManager
import com.itsaky.androidide.ui.themes.ThemeManager
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
@@ -148,7 +149,7 @@ class ThemeSelector(
}
override fun getInitiallySelectionItemPosition(context: Context): Int {
return themes.indexOf(ThemeManager.getCurrentTheme())
return themes.indexOf(IThemeManager.getInstance().getCurrentTheme())
}
override fun onChoiceConfirmed(position: Int) {
@@ -18,6 +18,7 @@
package com.itsaky.androidide.ui.themes
import android.app.Activity
import com.google.auto.service.AutoService
import com.itsaky.androidide.preferences.internal.selectedTheme
import com.itsaky.androidide.utils.isSystemInDarkMode
@@ -26,12 +27,14 @@ import com.itsaky.androidide.utils.isSystemInDarkMode
*
* @author Akash Yadav
*/
object ThemeManager {
@Suppress("unused")
@AutoService(IThemeManager::class)
class ThemeManager : IThemeManager {
/**
* Apply the current theme to the given activity. Does nothing if theme is set to [Material You][IDETheme.MATERIAL_YOU].
*/
fun applyTheme(activity: Activity) {
override fun applyTheme(activity: Activity) {
val theme = getCurrentTheme()
if (theme == IDETheme.MATERIAL_YOU) {
@@ -51,7 +54,7 @@ object ThemeManager {
/**
* Get the currently selected theme.
*/
fun getCurrentTheme(): IDETheme {
override fun getCurrentTheme(): IDETheme {
return selectedTheme?.let { IDETheme.valueOf(it) } ?: IDETheme.DEFAULT
}
}
@@ -1,17 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/root"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<com.itsaky.androidide.ui.virtualkeys.VirtualKeysView
android:id="@+id/virtual_key_table"
android:layout_width="match_parent"
android:layout_height="75dp"
android:background="@android:color/black"
tools:ignore="MissingClass" />
</LinearLayout>
+1
View File
@@ -40,6 +40,7 @@ dependencies {
implementation(gradleApi())
implementation("com.android.tools.build:gradle:${libs.versions.agp.asProvider().get()}")
implementation(libs.maven.publish)
implementation(libs.common.jkotlin)
implementation(libs.common.antlr4)
@@ -36,6 +36,8 @@ object BuildConfig {
/** The target SDK version. */
const val targetSdk = 28
const val ndkVersion = "26.1.10909125"
/** The source and target Java compatibility. */
val javaVersion = JavaVersion.VERSION_11
}
@@ -0,0 +1,109 @@
/*
* This file is part of AndroidIDE.
*
* AndroidIDE is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* AndroidIDE 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 for more details.
*
* You should have received a copy of the GNU General Public License
* along with AndroidIDE. If not, see <https://www.gnu.org/licenses/>.
*/
package com.itsaky.androidide.plugins.conf
import BuildConfig
import com.android.build.gradle.BaseExtension
import org.gradle.api.Project
import org.gradle.api.artifacts.Dependency
import org.gradle.api.artifacts.VersionCatalogsExtension
import projectVersionCode
private val flavorsAbis = arrayOf("arm64-v8a", "armeabi-v7a")
fun Project.configureAndroidModule(
coreLibDesugDep: Dependency
) {
assert(
plugins.hasPlugin("com.android.application") || plugins.hasPlugin("com.android.library")) {
"${javaClass.simpleName} can only be applied to Android projects"
}
extensions.getByType(BaseExtension::class.java).run {
compileSdkVersion(BuildConfig.compileSdk)
defaultConfig {
minSdk = BuildConfig.minSdk
targetSdk = BuildConfig.targetSdk
versionCode = projectVersionCode
versionName = rootProject.version.toString()
// required
multiDexEnabled = true
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
compileOptions {
sourceCompatibility = BuildConfig.javaVersion
targetCompatibility = BuildConfig.javaVersion
isCoreLibraryDesugaringEnabled = true
}
if (":app" == project.path) {
flavorDimensions("default")
productFlavors {
flavorsAbis.forEach(this::create)
forEach {
val name = it.name
defaultConfig.buildConfigField("String",
"FLAVOR_${name.replace('-', '_').uppercase()}",
"\"${name}\"")
}
}
}
// configure split APKs for ':app' module only
if (this@configureAndroidModule == rootProject.findProject(":app")) {
splits {
abi {
reset()
isEnable = true
isUniversalApk = false
// TODO: Find a way to enable split APKs in product flavors. If this is possible, we can configure
// each flavor to include only a single ABI. For example, for the 'arm64-v8a' flavor,
// we can configure it to generate APK only for 'arm64-v8a'.
//
// See the contribution guidelines for more information.
@Suppress("ChromeOsAbiSupport")
include(*flavorsAbis)
}
}
}
buildTypes.getByName("debug") { isMinifyEnabled = false }
buildTypes.getByName("release") {
isMinifyEnabled = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
testOptions { unitTests.isIncludeAndroidResources = true }
buildFeatures.viewBinding = true
buildFeatures.buildConfig = true
configurations.getByName("coreLibraryDesugaring").apply {
dependencies.add(coreLibDesugDep)
}
}
}
@@ -0,0 +1,34 @@
/*
* This file is part of AndroidIDE.
*
* AndroidIDE is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* AndroidIDE 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 for more details.
*
* You should have received a copy of the GNU General Public License
* along with AndroidIDE. If not, see <https://www.gnu.org/licenses/>.
*/
package com.itsaky.androidide.plugins.conf
import BuildConfig
import org.gradle.api.Project
import org.gradle.api.plugins.JavaPluginExtension
import org.gradle.kotlin.dsl.configure
fun Project.configureJavaModule() {
assert(plugins.hasPlugin("java-library")) {
"${javaClass.simpleName} can only be applied to Java library projects."
}
configure<JavaPluginExtension> {
sourceCompatibility = BuildConfig.javaVersion
targetCompatibility = BuildConfig.javaVersion
}
}
@@ -0,0 +1,144 @@
/*
* This file is part of AndroidIDE.
*
* AndroidIDE is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* AndroidIDE 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 for more details.
*
* You should have received a copy of the GNU General Public License
* along with AndroidIDE. If not, see <https://www.gnu.org/licenses/>.
*/
package com.itsaky.androidide.plugins.conf
import ProjectConfig
import com.vanniktech.maven.publish.AndroidMultiVariantLibrary
import com.vanniktech.maven.publish.GradlePlugin
import com.vanniktech.maven.publish.JavaLibrary
import com.vanniktech.maven.publish.JavadocJar
import com.vanniktech.maven.publish.MavenPublishBaseExtension
import com.vanniktech.maven.publish.SonatypeHost.Companion.S01
import org.gradle.api.GradleException
import org.gradle.api.Project
import org.gradle.api.publish.PublishingExtension
import org.gradle.api.tasks.Delete
import org.gradle.api.tasks.testing.Test
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.create
import org.gradle.kotlin.dsl.withType
import publishingVersion
import java.io.File
private val projectsRequiringMavenLocalForTests = arrayOf(":gradle-plugin")
private val mavenLocalRepos = hashMapOf<String, String>()
@Suppress("UnstableApiUsage")
fun Project.configureMavenPublish() {
assert(plugins.hasPlugin("com.vanniktech.maven.publish.base")) {
"${javaClass.simpleName} can only be applied to maven publish projects."
}
gradle.projectsEvaluated {
rootProject.subprojects {
if (project.path in projectsRequiringMavenLocalForTests) {
tasks.withType<Test> {
for ((project, _) in mavenLocalRepos) {
dependsOn(
project(project).tasks.getByName("publishAllPublicationsToBuildMavenLocalRepository"))
}
}
}
}
}
afterEvaluate {
if (project.description.isNullOrBlank()) {
throw GradleException("Project ${project.path} must have a description")
}
}
configure<MavenPublishBaseExtension> {
project.configureMavenLocal()
pom {
name.set(project.name)
description.set(project.description)
inceptionYear.set("2021")
url.set(ProjectConfig.REPO_URL)
licenses {
license {
name.set("The GNU General Public License, v3.0")
url.set("https://www.gnu.org/licenses/gpl-3.0.en.html")
distribution.set("https://www.gnu.org/licenses/gpl-3.0.en.html")
}
}
developers {
developer {
id.set("androidide")
name.set("AndroidIDE")
url.set(ProjectConfig.PROJECT_SITE)
}
}
scm {
url.set(ProjectConfig.REPO_URL)
connection.set(ProjectConfig.SCM_GIT)
developerConnection.set(ProjectConfig.SCM_SSH)
}
}
coordinates(project.group.toString(), project.name, project.publishingVersion)
publishToMavenCentral(host = S01)
signAllPublications()
if (plugins.hasPlugin("com.android.library")) {
configure(AndroidMultiVariantLibrary())
} else if (plugins.hasPlugin("java-gradle-plugin")) {
configure(GradlePlugin(javadocJar = JavadocJar.Javadoc()))
} else if (plugins.hasPlugin("java-library")) {
configure(JavaLibrary(javadocJar = JavadocJar.Javadoc()))
}
}
}
private fun Project.configureMavenLocal() {
val mavenLocalPath = layout.buildDirectory.dir("maven-local")
mavenLocalRepos[project.path] = mavenLocalPath.get().asFile.absolutePath
extensions.findByType(PublishingExtension::class.java)?.run {
repositories {
maven {
name = "buildMavenLocal"
url = uri(mavenLocalPath)
}
}
}
tasks.create<Delete>("deleteBuildMavenLocal") {
delete(mavenLocalPath)
}
if (project.path in projectsRequiringMavenLocalForTests) {
tasks.withType<Test> {
dependsOn(tasks.getByName("publishAllPublicationsToBuildMavenLocalRepository"))
doFirst {
val file = mavenLocalPath.get().file("repos.txt").asFile
file.writeText(mavenLocalRepos.values.joinToString(separator = File.pathSeparator))
}
}
}
afterEvaluate {
tasks.getByName("publishAllPublicationsToBuildMavenLocalRepository") {
dependsOn(tasks.getByName("deleteBuildMavenLocal"))
}
}
}
+7 -187
View File
@@ -17,15 +17,11 @@
@file:Suppress("UnstableApiUsage")
import com.android.build.gradle.BaseExtension
import com.itsaky.androidide.plugins.AndroidIDEPlugin
import com.vanniktech.maven.publish.AndroidMultiVariantLibrary
import com.vanniktech.maven.publish.GradlePlugin
import com.vanniktech.maven.publish.JavaLibrary
import com.vanniktech.maven.publish.JavadocJar
import com.vanniktech.maven.publish.MavenPublishBaseExtension
import com.vanniktech.maven.publish.SonatypeHost.Companion.S01
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
import com.itsaky.androidide.plugins.conf.configureAndroidModule
import com.itsaky.androidide.plugins.conf.configureJavaModule
import com.itsaky.androidide.plugins.conf.configureMavenPublish
@Suppress("DSL_SCOPE_VIOLATION")
plugins {
@@ -45,74 +41,6 @@ buildscript {
}
}
val flavorsAbis = arrayOf("arm64-v8a", "armeabi-v7a")
fun Project.configureBaseExtension() {
extensions.findByType(BaseExtension::class)?.run {
compileSdkVersion(BuildConfig.compileSdk)
defaultConfig {
minSdk = BuildConfig.minSdk
targetSdk = BuildConfig.targetSdk
versionCode = projectVersionCode
versionName = rootProject.version.toString()
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
compileOptions {
sourceCompatibility = BuildConfig.javaVersion
targetCompatibility = BuildConfig.javaVersion
}
if (":app" == project.path) {
flavorDimensions("default")
productFlavors {
flavorsAbis.forEach(this::create)
forEach {
val name = it.name
defaultConfig.buildConfigField("String",
"FLAVOR_${name.replace('-', '_').uppercase()}",
"\"${name}\"")
}
}
}
// configure split APKs for ':app' module only
if (this@configureBaseExtension == rootProject.findProject(":app")) {
splits {
abi {
reset()
isEnable = true
isUniversalApk = false
// TODO: Find a way to enable split APKs in product flavors. If this is possible, we can configure
// each flavor to include only a single ABI. For example, for the 'arm64-v8a' flavor,
// we can configure it to generate APK only for 'arm64-v8a'.
//
// See the contribution guidelines for more information.
@Suppress("ChromeOsAbiSupport")
include(*flavorsAbis)
}
}
}
buildTypes.getByName("debug") { isMinifyEnabled = false }
buildTypes.getByName("release") {
isMinifyEnabled = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
testOptions { unitTests.isIncludeAndroidResources = true }
buildFeatures.viewBinding = true
buildFeatures.buildConfig = true
}
}
subprojects {
afterEvaluate {
apply { plugin(AndroidIDEPlugin::class.java) }
@@ -120,70 +48,11 @@ subprojects {
project.group = BuildConfig.packageName
project.version = rootProject.version
plugins.withId("com.android.application") { configureBaseExtension() }
plugins.withId("com.android.library") { configureBaseExtension() }
plugins.withId("java-library") {
configure<JavaPluginExtension> {
sourceCompatibility = BuildConfig.javaVersion
targetCompatibility = BuildConfig.javaVersion
}
}
project.afterEvaluate {
if (project.plugins.hasPlugin(
"com.vanniktech.maven.publish.base") && project.description.isNullOrBlank()
) {
throw GradleException("Project ${project.path} must have a description")
}
}
plugins.withId("com.vanniktech.maven.publish.base") {
configure<MavenPublishBaseExtension> {
project.configureMavenLocal()
pom {
name.set(project.name)
description.set(project.description)
inceptionYear.set("2021")
url.set(ProjectConfig.REPO_URL)
licenses {
license {
name.set("The GNU General Public License, v3.0")
url.set("https://www.gnu.org/licenses/gpl-3.0.en.html")
distribution.set("https://www.gnu.org/licenses/gpl-3.0.en.html")
}
}
developers {
developer {
id.set("androidide")
name.set("AndroidIDE")
url.set(ProjectConfig.PROJECT_SITE)
}
}
scm {
url.set(ProjectConfig.REPO_URL)
connection.set(ProjectConfig.SCM_GIT)
developerConnection.set(ProjectConfig.SCM_SSH)
}
}
coordinates(project.group.toString(), project.name, project.publishingVersion)
publishToMavenCentral(host = S01)
signAllPublications()
if (plugins.hasPlugin("com.android.library")) {
configure(AndroidMultiVariantLibrary())
} else if (plugins.hasPlugin("java-gradle-plugin")) {
configure(GradlePlugin(javadocJar = JavadocJar.Javadoc()))
} else if (plugins.hasPlugin("java-library")) {
configure(JavaLibrary(javadocJar = JavadocJar.Javadoc()))
}
}
}
plugins.withId("com.android.application") { configureAndroidModule(libs.androidx.lib.desugaring.get()) }
plugins.withId("com.android.library") { configureAndroidModule(libs.androidx.lib.desugaring.get()) }
plugins.withId("java-library") { configureJavaModule() }
plugins.withId("com.vanniktech.maven.publish.base") { configureMavenPublish() }
plugins.withId("com.gradle.plugin-publish") {
configure<GradlePluginDevelopmentExtension> {
@@ -194,55 +63,6 @@ subprojects {
tasks.withType<KotlinCompile>().configureEach {
kotlinOptions.jvmTarget = BuildConfig.javaVersion.toString()
}
gradle.projectsEvaluated {
rootProject.subprojects {
if (project.path in projectsRequiringMavenLocalForTests) {
tasks.withType<Test> {
for ((project, _) in mavenLocalRepos) {
dependsOn(project(project).tasks.getByName("publishAllPublicationsToBuildMavenLocalRepository"))
}
}
}
}
}
}
val projectsRequiringMavenLocalForTests = arrayOf(":gradle-plugin")
val mavenLocalRepos = hashMapOf<String, String>()
fun Project.configureMavenLocal() {
val mavenLocalPath = layout.buildDirectory.dir("maven-local")
mavenLocalRepos[project.path] = mavenLocalPath.get().asFile.absolutePath
extensions.findByType(PublishingExtension::class.java)?.run {
repositories {
maven {
name = "buildMavenLocal"
url = uri(mavenLocalPath)
}
}
}
tasks.create<Delete>("deleteBuildMavenLocal") {
delete(mavenLocalPath)
}
if (project.path in projectsRequiringMavenLocalForTests) {
tasks.withType<Test> {
dependsOn(tasks.getByName("publishAllPublicationsToBuildMavenLocalRepository"))
doFirst {
val file = mavenLocalPath.get().file("repos.txt").asFile
file.writeText(mavenLocalRepos.values.joinToString(separator = File.pathSeparator))
}
}
}
afterEvaluate {
tasks.getByName("publishAllPublicationsToBuildMavenLocalRepository") {
dependsOn(tasks.getByName("deleteBuildMavenLocal"))
}
}
}
tasks.register<Delete>("clean") { delete(rootProject.layout.buildDirectory) }
+4 -2
View File
@@ -20,18 +20,20 @@ dependencies {
api(libs.androidx.vectors)
api(libs.androidx.animated.vectors)
api(libs.androidx.ktx)
api(libs.androidx.core.ktx)
api(libs.common.kotlin)
api(projects.buildInfo)
api(projects.eventbusAndroid)
api(projects.eventbusEvents)
api(projects.lexers)
api(projects.resources)
api(projects.shared)
api(projects.logger)
api(projects.resources)
api(projects.subprojects.flashbar)
testImplementation(libs.tests.junit)
testImplementation(libs.tests.google.truth)
testImplementation(libs.tests.robolectric)
@@ -21,12 +21,20 @@ import android.content.pm.PackageManager
import android.os.Bundle
import android.view.View
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.os.LocaleListCompat
import androidx.fragment.app.Fragment
import com.itsaky.androidide.common.R
import com.itsaky.androidide.eventbus.events.preferences.PreferenceChangeEvent
import com.itsaky.androidide.ui.themes.IThemeManager
import com.itsaky.androidide.utils.ILogger
import com.itsaky.androidide.utils.resolveAttr
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import java.util.Locale
abstract class BaseIDEActivity : AppCompatActivity() {
@@ -36,17 +44,19 @@ abstract class BaseIDEActivity : AppCompatActivity() {
open val statusBarColor: Int
get() = resolveAttr(R.attr.colorSurface)
fun loadFragment(fragment: Fragment, id: Int) {
val transaction = supportFragmentManager.beginTransaction()
transaction.replace(id, fragment)
transaction.commit()
}
val isStoragePermissionGranted: Boolean
get() =
(ContextCompat.checkSelfPermission(this, permission.READ_EXTERNAL_STORAGE) ==
PackageManager.PERMISSION_GRANTED &&
ContextCompat.checkSelfPermission(this, permission.WRITE_EXTERNAL_STORAGE) ==
PackageManager.PERMISSION_GRANTED)
override fun onCreate(savedInstanceState: Bundle?) {
window?.apply {
navigationBarColor = this@BaseIDEActivity.navigationBarColor
statusBarColor = this@BaseIDEActivity.statusBarColor
}
IThemeManager.getInstance().applyTheme(this)
super.onCreate(savedInstanceState)
preSetContentLayout()
setContentView(bindLayout())
@@ -61,12 +71,29 @@ abstract class BaseIDEActivity : AppCompatActivity() {
}
}
val isStoragePermissionGranted: Boolean
get() =
(ContextCompat.checkSelfPermission(this, permission.READ_EXTERNAL_STORAGE) ==
PackageManager.PERMISSION_GRANTED &&
ContextCompat.checkSelfPermission(this, permission.WRITE_EXTERNAL_STORAGE) ==
PackageManager.PERMISSION_GRANTED)
override fun onStart() {
super.onStart()
EventBus.getDefault().apply {
if (!isRegistered(this@BaseIDEActivity)) {
register(this)
}
}
}
override fun onStop() {
super.onStop()
EventBus.getDefault().apply {
if (isRegistered(this@BaseIDEActivity)) {
unregister(this)
}
}
}
fun loadFragment(fragment: Fragment, id: Int) {
val transaction = supportFragmentManager.beginTransaction()
transaction.replace(id, fragment)
transaction.commit()
}
protected fun requestStorage() {
if (isStoragePermissionGranted) {
@@ -19,7 +19,7 @@ package com.itsaky.androidide.ui.themes
import androidx.annotation.StringRes
import androidx.annotation.StyleRes
import com.itsaky.androidide.R
import com.itsaky.androidide.resources.R
/**
* Themes in AndroidIDE.
@@ -0,0 +1,50 @@
/*
* This file is part of AndroidIDE.
*
* AndroidIDE is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* AndroidIDE 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 for more details.
*
* You should have received a copy of the GNU General Public License
* along with AndroidIDE. If not, see <https://www.gnu.org/licenses/>.
*/
package com.itsaky.androidide.ui.themes
import android.app.Activity
import com.itsaky.androidide.utils.ServiceLoader
/**
* Theme manager for AndroidIDE.
*
* @author Akash Yadav
*/
interface IThemeManager {
companion object {
private object Provider {
val instance by lazy { ServiceLoader.load(IThemeManager::class.java).findFirstOrThrow() }
}
/**
* Get the [IThemeManager] instance.
*/
fun getInstance() : IThemeManager = Provider.instance
}
/**
* Apply current theme to [activity].
*/
fun applyTheme(activity: Activity)
/**
* Get the current IDE theme.
*/
fun getCurrentTheme(): IDETheme
}
@@ -0,0 +1,44 @@
/*
* Copyright 2021 The Android Open Source Project
*
* 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.itsaky.androidide.window
import android.annotation.SuppressLint
import android.app.Activity
import android.graphics.Rect
import android.os.Build
import androidx.annotation.RequiresApi
@SuppressLint("ObsoleteSdkInt")
@RequiresApi(Build.VERSION_CODES.N)
internal object ActivityCompatHelperApi24 {
fun isInMultiWindowMode(activity: Activity): Boolean {
return activity.isInMultiWindowMode
}
}
@RequiresApi(Build.VERSION_CODES.R)
internal object ActivityCompatHelperApi30 {
fun currentWindowBounds(activity: Activity): Rect {
return activity.windowManager.currentWindowMetrics.bounds
}
fun maximumWindowBounds(activity: Activity): Rect {
return activity.windowManager.maximumWindowMetrics.bounds
}
}
@@ -0,0 +1,87 @@
/*
* Copyright 2021 The Android Open Source Project
*
* 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.itsaky.androidide.window
import android.graphics.Rect
/**
* A wrapper for [Rect] to handle compatibility issues with API 15. In API 15, equals and
* hashCode operate on the reference as opposed to the attributes. This leads to test failures
* because the data matches but the equals check fails.
*
* Also useful in unit tests since you can instantiate [Bounds] in a JVM test but when you
* instantiate [Rect] you are using the class from the mockable jar file. The mockable jar does
* not contain any behavior or calculations.
*/
internal class Bounds(
val left: Int,
val top: Int,
val right: Int,
val bottom: Int
) {
constructor(rect: Rect) : this(rect.left, rect.top, rect.right, rect.bottom)
/**
* Return the [Rect] representation of the bounds
*/
fun toRect(): Rect = Rect(left, top, right, bottom)
/**
* The width of the bounds, may be negative.
*/
val width: Int
get() = right - left
/**
* The height of the bounds, may be negative.
*/
val height: Int
get() = bottom - top
/**
* Determines if the bounds has empty area.
*/
val isEmpty: Boolean
get() = height == 0 || width == 0
override fun toString(): String {
return "${Bounds::class.java.simpleName} { [$left,$top,$right,$bottom] }"
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Bounds
if (left != other.left) return false
if (top != other.top) return false
if (right != other.right) return false
if (bottom != other.bottom) return false
return true
}
override fun hashCode(): Int {
var result = left
result = 31 * result + top
result = 31 * result + right
result = 31 * result + bottom
return result
}
}
@@ -0,0 +1,54 @@
/*
* Copyright 2021 The Android Open Source Project
*
* 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.itsaky.androidide.window
import android.annotation.SuppressLint
import android.graphics.Point
import android.os.Build
import android.view.Display
import android.view.DisplayCutout
import androidx.annotation.RequiresApi
@SuppressLint("ObsoleteSdkInt")
@RequiresApi(Build.VERSION_CODES.JELLY_BEAN_MR1)
internal object DisplayCompatHelperApi17 {
@Suppress("DEPRECATION")
fun getRealSize(display: Display, point: Point) {
display.getRealSize(point)
}
}
@RequiresApi(Build.VERSION_CODES.P)
internal object DisplayCompatHelperApi28 {
fun safeInsetLeft(displayCutout: DisplayCutout): Int {
return displayCutout.safeInsetLeft
}
fun safeInsetTop(displayCutout: DisplayCutout): Int {
return displayCutout.safeInsetTop
}
fun safeInsetRight(displayCutout: DisplayCutout): Int {
return displayCutout.safeInsetRight
}
fun safeInsetBottom(displayCutout: DisplayCutout): Int {
return displayCutout.safeInsetBottom
}
}
@@ -0,0 +1,124 @@
/*
* Copyright 2021 The Android Open Source Project
*
* 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.itsaky.androidide.window
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
/**
* Main interaction point with the WindowManager library. An instance of this class allows
* polling the current state of the device and display, and registering callbacks for changes in
* the corresponding states.
*
* All methods of this class will return information that is associated with this visual
* context.
*
*/
class WindowManager(
/**
* A visual [Context], such as an [Activity] or a [ContextWrapper]
*/
context: Context
) {
/**
* A class to calculate the bounds of a [android.view.Window] across different versions of
* Android.
*/
internal var windowMetricsCalculator: WindowMetricsCalculator = WindowMetricsCalculatorCompat
/**
* Activity that was registered with this instance of [WindowManager] at creation.
* This is used to find the token identifier of the window when requesting layout information
* from the [com.itsaky.androidide.window.sidecar.SidecarInterface] or is passed directly to the
* [com.itsaky.androidide.window.extensions.ExtensionInterface].
*/
private val activity: Activity = getActivityFromContext(context)
?: throw IllegalArgumentException(
"Used non-visual Context to obtain an instance of WindowManager. Please use an " +
"Activity or a ContextWrapper around one instead."
)
/**
* Returns the [WindowMetrics] according to the current system state.
*
*
* The metrics describe the size of the area the window would occupy with
* [MATCH_PARENT][android.view.WindowManager.LayoutParams.MATCH_PARENT] width and height
* and any combination of flags that would allow the window to extend behind display cutouts.
*
*
* The value of this is based on the **current** windowing state of the system. For
* example, for activities in multi-window mode, the metrics returned are based on the
* current bounds that the user has selected for the [Activity][android.app.Activity]'s
* window.
*
* @see getMaximumWindowMetrics
* @see android.view.WindowManager.getCurrentWindowMetrics
*/
fun getCurrentWindowMetrics(): WindowMetrics {
return windowMetricsCalculator.computeCurrentWindowMetrics(activity)
}
/**
* Returns the largest [WindowMetrics] an app may expect in the current system state.
*
*
* The metrics describe the size of the largest potential area the window might occupy with
* [MATCH_PARENT][android.view.WindowManager.LayoutParams.MATCH_PARENT] width and height
* and any combination of flags that would allow the window to extend behind display cutouts.
*
*
* The value of this is based on the largest **potential** windowing state of the system.
* For example, for activities in multi-window mode the metrics returned are based on what the
* bounds would be if the user expanded the window to cover the entire screen.
*
*
* Note that this might still be smaller than the size of the physical display if certain
* areas of the display are not available to windows created for the associated [Context].
* For example, devices with foldable displays that wrap around the enclosure may split the
* physical display into different regions, one for the front and one for the back, each acting
* as different logical displays. In this case [.getMaximumWindowMetrics] would return
* the region describing the side of the device the associated [context&#39;s][Context]
* window is placed.
*
* @see getCurrentWindowMetrics
* @see android.view.WindowManager.getMaximumWindowMetrics
*/
fun getMaximumWindowMetrics(): WindowMetrics {
return windowMetricsCalculator.computeMaximumWindowMetrics(activity)
}
internal companion object {
/**
* Unwraps the hierarchy of [ContextWrapper]-s until [Activity] is reached.
*
* @return Base [Activity] context or `null` if not available.
*/
fun getActivityFromContext(initialContext: Context): Activity? {
var context: Context? = initialContext
while (context is ContextWrapper) {
if (context is Activity) {
return context
}
context = context.baseContext
}
return null
}
}
}
@@ -0,0 +1,58 @@
/*
* Copyright 2021 The Android Open Source Project
*
* 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.itsaky.androidide.window
import android.graphics.Rect
/**
* Metrics about a [android.view.Window], consisting of its bounds.
*
*
* This is usually obtained from [WindowManager#getCurrentWindowMetrics] or
* [WindowManager#getMaximumWindowMetrics].
*
* @see WindowManager#getCurrentWindowMetrics
* @see WindowManager#getMaximumWindowMetrics
*/
class WindowMetrics internal constructor(private val _bounds: Bounds) {
constructor(bounds: Rect) : this(Bounds(bounds))
/**
* Returns a new [Rect] describing the bounds of the area the window occupies.
*
*
* **Note that the size of the reported bounds can have different size than
* [Display#getSize].** This method reports the window size including all system
* decorations, while [Display#getSize] reports the area excluding navigation bars
* and display cutout areas.
*
* @return window bounds in pixels.
*/
val bounds: Rect
get() = _bounds.toRect()
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null || javaClass != other.javaClass) return false
val that = other as WindowMetrics
return _bounds == that._bounds
}
override fun hashCode(): Int {
return _bounds.hashCode()
}
}
@@ -0,0 +1,76 @@
/*
* Copyright 2021 The Android Open Source Project
*
* 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.itsaky.androidide.window
import android.app.Activity
import android.os.Build
import android.view.Display
/**
* An interface to calculate the [WindowMetrics] for an [Activity].
*/
internal interface WindowMetricsCalculator {
/**
* Computes the size and position of the area the window would occupy with
* [MATCH_PARENT][android.view.WindowManager.LayoutParams.MATCH_PARENT] width and height
* and any combination of flags that would allow the window to extend behind display cutouts.
*
* For example, [android.view.WindowManager.LayoutParams.layoutInDisplayCutoutMode] set to
* [android.view.WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS] or the
* [android.view.WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS] flag set.
*
* The value returned from this method may be different from platform API(s) used to determine
* the size and position of the visible area a given context occupies. For example:
*
* * [Display.getSize] can be used to determine the size of the visible area
* a window occupies, but may be subtracted to exclude certain system decorations that
* always appear on screen, notably the navigation bar.
* * The decor view's [android.view.View#getWidth] and [android.view.View@getHeight] can be
* used to determine the size of the top level view in the view hierarchy, but this size is
* determined through a combination of [android.view.WindowManager.LayoutParams]
* flags and may not represent the true window size. For example, a window that does not
* indicate it can be displayed behind a display cutout will have the size of the decor
* view offset to exclude this region unless this region overlaps with the status bar, while
* the value returned from this method will include this region.
*
* The value returned from this method is guaranteed to be correct on platforms
* [Q][Build.VERSION_CODES.Q] and above. For older platforms the value may be invalid if
* the activity is in multi-window mode or if the navigation bar offset can not be accounted
* for, though a best effort is made to ensure the returned value is as close as possible to
* the true value. See [.computeWindowBoundsP] and
* [.computeWindowBoundsN].
*
* Note: The value of this is based on the last windowing state reported to the client.
*
* @see android.view.WindowManager.getCurrentWindowMetrics
* @see android.view.WindowMetrics.getBounds
*/
fun computeCurrentWindowMetrics(activity: Activity): WindowMetrics
/**
* Computes the maximum size and position of the area the window can expect with
* [MATCH_PARENT][android.view.WindowManager.LayoutParams.MATCH_PARENT] width and height
* and any combination of flags that would allow the window to extend behind display cutouts.
*
* The value returned from this method will always match [Display.getRealSize] on
* [Android 10][Build.VERSION_CODES.Q] and below.
*
* @see android.view.WindowManager.getMaximumWindowMetrics
*/
fun computeMaximumWindowMetrics(activity: Activity): WindowMetrics
}
@@ -0,0 +1,332 @@
/*
* Copyright 2021 The Android Open Source Project
*
* 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.itsaky.androidide.window
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.content.res.Configuration
import android.graphics.Point
import android.graphics.Rect
import android.os.Build
import android.os.Build.VERSION_CODES
import android.util.Log
import android.view.Display
import android.view.DisplayCutout
import androidx.annotation.RequiresApi
import androidx.annotation.VisibleForTesting
import com.itsaky.androidide.window.ActivityCompatHelperApi24.isInMultiWindowMode
import com.itsaky.androidide.window.ActivityCompatHelperApi30.currentWindowBounds
import com.itsaky.androidide.window.ActivityCompatHelperApi30.maximumWindowBounds
import com.itsaky.androidide.window.DisplayCompatHelperApi17.getRealSize
import com.itsaky.androidide.window.DisplayCompatHelperApi28.safeInsetBottom
import com.itsaky.androidide.window.DisplayCompatHelperApi28.safeInsetLeft
import com.itsaky.androidide.window.DisplayCompatHelperApi28.safeInsetRight
import com.itsaky.androidide.window.DisplayCompatHelperApi28.safeInsetTop
import java.lang.reflect.InvocationTargetException
/**
* Helper class used to compute window metrics across Android versions.
*/
internal object WindowMetricsCalculatorCompat : WindowMetricsCalculator {
private val TAG: String = WindowMetricsCalculatorCompat::class.java.simpleName
/**
* Computes the current [WindowMetrics] for a given [Activity]
* @see WindowMetricsCalculator.computeCurrentWindowMetrics
*/
override fun computeCurrentWindowMetrics(activity: Activity): WindowMetrics {
val bounds = if (Build.VERSION.SDK_INT >= VERSION_CODES.R) {
currentWindowBounds(activity)
} else if (Build.VERSION.SDK_INT >= VERSION_CODES.Q) {
computeWindowBoundsQ(activity)
} else if (Build.VERSION.SDK_INT >= VERSION_CODES.P) {
computeWindowBoundsP(activity)
} else
computeWindowBoundsN(activity)
return WindowMetrics(bounds)
}
/**
* Computes the maximum [WindowMetrics] for a given [Activity]
* @see WindowMetricsCalculator.computeMaximumWindowMetrics
*/
override fun computeMaximumWindowMetrics(activity: Activity): WindowMetrics {
val bounds = if (Build.VERSION.SDK_INT >= VERSION_CODES.R) {
maximumWindowBounds(activity)
} else {
// [WindowManager#getDefaultDisplay] is deprecated but we have this for
// compatibility with older versions
@Suppress("DEPRECATION")
val display = activity.windowManager.defaultDisplay
val displaySize = getRealSizeForDisplay(display)
Rect(0, 0, displaySize.x, displaySize.y)
}
return WindowMetrics(bounds)
}
/** Computes the window bounds for [Build.VERSION_CODES.Q]. */
@SuppressLint("BanUncheckedReflection", "BlockedPrivateApi")
@RequiresApi(VERSION_CODES.Q)
internal fun computeWindowBoundsQ(activity: Activity): Rect {
var bounds: Rect
val config = activity.resources.configuration
try {
val windowConfigField =
Configuration::class.java.getDeclaredField("windowConfiguration")
windowConfigField.isAccessible = true
val windowConfig = windowConfigField[config]
val getBoundsMethod = windowConfig.javaClass.getDeclaredMethod("getBounds")
bounds = Rect(getBoundsMethod.invoke(windowConfig) as Rect)
} catch (e: NoSuchFieldException) {
Log.w(TAG, e)
// If reflection fails for some reason default to the P implementation which still
// has the ability to account for display cutouts.
bounds = computeWindowBoundsP(activity)
} catch (e: NoSuchMethodException) {
Log.w(TAG, e)
bounds = computeWindowBoundsP(activity)
} catch (e: IllegalAccessException) {
Log.w(TAG, e)
bounds = computeWindowBoundsP(activity)
} catch (e: InvocationTargetException) {
Log.w(TAG, e)
bounds = computeWindowBoundsP(activity)
}
return bounds
}
/**
* Computes the window bounds for [Build.VERSION_CODES.P].
*
*
* NOTE: This method may result in incorrect values if the [android.content.res.Resources]
* value stored at 'navigation_bar_height' does not match the true navigation bar inset on
* the window.
*
*/
@SuppressLint("BanUncheckedReflection", "BlockedPrivateApi")
@RequiresApi(VERSION_CODES.P)
internal fun computeWindowBoundsP(activity: Activity): Rect {
val bounds = Rect()
val config = activity.resources.configuration
try {
val windowConfigField =
Configuration::class.java.getDeclaredField("windowConfiguration")
windowConfigField.isAccessible = true
val windowConfig = windowConfigField[config]
// In multi-window mode we'll use the WindowConfiguration#mBounds property which
// should match the window size. Otherwise we'll use the mAppBounds property and
// will adjust it below.
if (isInMultiWindowMode(activity)) {
val getAppBounds = windowConfig.javaClass.getDeclaredMethod("getBounds")
bounds.set((getAppBounds.invoke(windowConfig) as Rect))
} else {
val getAppBounds = windowConfig.javaClass.getDeclaredMethod("getAppBounds")
bounds.set((getAppBounds.invoke(windowConfig) as Rect))
}
} catch (e: NoSuchFieldException) {
Log.w(TAG, e)
getRectSizeFromDisplay(activity, bounds)
} catch (e: NoSuchMethodException) {
Log.w(TAG, e)
getRectSizeFromDisplay(activity, bounds)
} catch (e: IllegalAccessException) {
Log.w(TAG, e)
getRectSizeFromDisplay(activity, bounds)
} catch (e: InvocationTargetException) {
Log.w(TAG, e)
getRectSizeFromDisplay(activity, bounds)
}
val platformWindowManager = activity.windowManager
// [WindowManager#getDefaultDisplay] is deprecated but we have this for
// compatibility with older versions
@Suppress("DEPRECATION")
val currentDisplay = platformWindowManager.defaultDisplay
val realDisplaySize = Point()
// [Display#getRealSize] is deprecated but we have this for
// compatibility with older versions
getRealSize(display = currentDisplay, point = realDisplaySize)
if (!isInMultiWindowMode(activity)) {
// The activity is not in multi-window mode. Check if the addition of the
// navigation bar size to mAppBounds results in the real display size and if so
// assume the nav bar height should be added to the result.
val navigationBarHeight = getNavigationBarHeight(activity)
if (bounds.bottom + navigationBarHeight == realDisplaySize.y) {
bounds.bottom += navigationBarHeight
} else if (bounds.right + navigationBarHeight == realDisplaySize.x) {
bounds.right += navigationBarHeight
} else if (bounds.left == navigationBarHeight) {
bounds.left = 0
}
}
if ((bounds.width() < realDisplaySize.x || bounds.height() < realDisplaySize.y) &&
!isInMultiWindowMode(activity)
) {
// If the corrected bounds are not the same as the display size and the activity is
// not in multi-window mode it is possible there are unreported cutouts inset-ing
// the window depending on the layoutInCutoutMode. Check for them here by getting
// the cutout from the display itself.
val displayCutout = getCutoutForDisplay(currentDisplay)
if (displayCutout != null) {
if (bounds.left == safeInsetLeft(displayCutout)) {
bounds.left = 0
}
if (realDisplaySize.x - bounds.right == safeInsetRight(displayCutout)) {
bounds.right += safeInsetRight(displayCutout)
}
if (bounds.top == safeInsetTop(displayCutout)) {
bounds.top = 0
}
if (realDisplaySize.y - bounds.bottom == safeInsetBottom(displayCutout)) {
bounds.bottom += safeInsetBottom(displayCutout)
}
}
}
return bounds
}
private fun getRectSizeFromDisplay(activity: Activity, bounds: Rect) {
// [WindowManager#getDefaultDisplay] is deprecated but we have this for
// compatibility with older versions
@Suppress("DEPRECATION")
val defaultDisplay = activity.windowManager.defaultDisplay
// [Display#getRectSize] is deprecated but we have this for
// compatibility with older versions
@Suppress("DEPRECATION")
defaultDisplay.getRectSize(bounds)
}
/**
* Computes the window bounds for platforms between [Build.VERSION_CODES.N]
* and [Build.VERSION_CODES.O_MR1], inclusive.
*
*
* NOTE: This method may result in incorrect values under the following conditions:
*
* * If the activity is in multi-window mode the origin of the returned bounds will
* always be anchored at (0, 0).
* * If the [android.content.res.Resources] value stored at 'navigation_bar_height' does
* not match the true navigation bar size the returned bounds will not take into account
* the navigation
* bar.
*
*/
private fun computeWindowBoundsN(activity: Activity): Rect {
val bounds = Rect()
// [WindowManager#getDefaultDisplay] is deprecated but we have this for
// compatibility with older versions
@Suppress("DEPRECATION")
val defaultDisplay = activity.windowManager.defaultDisplay
// [Display#getRectSize] is deprecated but we have this for
// compatibility with older versions
@Suppress("DEPRECATION")
defaultDisplay.getRectSize(bounds)
if (!isInMultiWindowMode(activity)) {
// The activity is not in multi-window mode. Check if the addition of the
// navigation bar size to Display#getSize() results in the real display size and
// if so return this value. If not, return the result of Display#getSize().
val realDisplaySize = getRealSizeForDisplay(defaultDisplay)
val navigationBarHeight = getNavigationBarHeight(activity)
if (bounds.bottom + navigationBarHeight == realDisplaySize.y) {
bounds.bottom += navigationBarHeight
} else if (bounds.right + navigationBarHeight == realDisplaySize.x) {
bounds.right += navigationBarHeight
}
}
return bounds
}
/**
* Returns the full (real) size of the display, in pixels, without subtracting any window
* decor or applying any compatibility scale factors.
*
*
* The size is adjusted based on the current rotation of the display.
*
* @return a point representing the real display size in pixels.
*
* @see Display.getRealSize
*/
@VisibleForTesting
internal fun getRealSizeForDisplay(display: Display): Point {
val size = Point()
getRealSize(display, size)
return size
}
/**
* Returns the [android.content.res.Resources] value stored as 'navigation_bar_height'.
*
*
* Note: This is error-prone and is **not** the recommended way to determine the size
* of the overlapping region between the navigation bar and a given window. The best
* approach is to acquire the [android.view.WindowInsets].
*/
@SuppressLint("DiscouragedApi", "InternalInsetResource")
private fun getNavigationBarHeight(context: Context): Int {
val resources = context.resources
val resourceId = resources.getIdentifier("navigation_bar_height", "dimen", "android")
return if (resourceId > 0) {
resources.getDimensionPixelSize(resourceId)
} else 0
}
/**
* Returns the [DisplayCutout] for the given display. Note that display cutout returned
* here is for the display and the insets provided are in the display coordinate system.
*
* @return the display cutout for the given display.
*/
@SuppressLint("BanUncheckedReflection", "PrivateApi")
@RequiresApi(VERSION_CODES.P)
private fun getCutoutForDisplay(display: Display): DisplayCutout? {
var displayCutout: DisplayCutout? = null
try {
val displayInfoClass = Class.forName("android.view.DisplayInfo")
val displayInfoConstructor = displayInfoClass.getConstructor()
displayInfoConstructor.isAccessible = true
val displayInfo = displayInfoConstructor.newInstance()
val getDisplayInfoMethod = display.javaClass.getDeclaredMethod(
"getDisplayInfo", displayInfo.javaClass
)
getDisplayInfoMethod.isAccessible = true
getDisplayInfoMethod.invoke(display, displayInfo)
val displayCutoutField = displayInfo.javaClass.getDeclaredField("displayCutout")
displayCutoutField.isAccessible = true
val cutout = displayCutoutField[displayInfo]
if (cutout is DisplayCutout) {
displayCutout = cutout
}
} catch (e: ClassNotFoundException) {
Log.w(TAG, e)
} catch (e: NoSuchMethodException) {
Log.w(TAG, e)
} catch (e: NoSuchFieldException) {
Log.w(TAG, e)
} catch (e: IllegalAccessException) {
Log.w(TAG, e)
} catch (e: InvocationTargetException) {
Log.w(TAG, e)
} catch (e: InstantiationException) {
Log.w(TAG, e)
}
return displayCutout
}
}
+15 -2
View File
@@ -15,6 +15,8 @@ antlr4 = "4.13.1"
androidx-work = "2.8.1"
androidx-espresso = "3.5.1"
retrofit = "2.9.0"
markwon = "4.6.2"
maven-publish-plugin = "0.25.3"
[libraries]
@@ -48,6 +50,12 @@ common-jsonrpc = { module = "org.eclipse.lsp4j:org.eclipse.lsp4j.jsonrpc", versi
common-leakcanary = { module = "com.squareup.leakcanary:leakcanary-android", version = "2.12" }
common-retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
common-retrofit-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "retrofit" }
common-markwon-core = { module = "io.noties.markwon:core", version.ref = "markwon" }
common-markwon-extStrikethrough = { module = "io.noties.markwon:ext-strikethrough", version.ref = "markwon" }
common-markwon-linkify = { module = "io.noties.markwon:linkify", version.ref = "markwon" }
common-markwon-recycler = { module = "io.noties.markwon:recycler", version.ref = "markwon" }
common-hiddenApiBypass = { module = "org.lsposed.hiddenapibypass:hiddenapibypass", version = "2.0" }
common-termuxAmLib = { module = "com.termux:termux-am-library", version = "v2.0.0" }
# AndroidX
androidx-annotation = { module = "androidx.annotation:annotation", version = "1.7.0" }
@@ -62,7 +70,8 @@ androidx-preference = { module = "androidx.preference:preference", version = "1.
androidx-recyclerview = { module = "androidx.recyclerview:recyclerview", version = "1.3.1" }
androidx-vectors = { module = "androidx.vectordrawable:vectordrawable", version.ref = "androidx-vectordrawable" }
androidx-animated_vectors = { module = "androidx.vectordrawable:vectordrawable-animated", version.ref = "androidx-vectordrawable" }
androidx-ktx = { module = "androidx.core:core-ktx", version = "1.12.0" }
androidx-core = { module = "androidx.core:core", version = "1.12.0" }
androidx-core-ktx = { module = "androidx.core:core-ktx", version = "1.12.0" }
androidx-fragment_ktx = { module = "androidx.fragment:fragment-ktx", version = "1.5.7" }
androidx-lib_desugaring = { module = "com.android.tools:desugar_jdk_libs", version = "2.0.3" }
androidx-splashscreen = { module = "androidx.core:core-splashscreen", version = "1.0.1" }
@@ -72,6 +81,9 @@ androidx-nav-ui = { module = "androidx.navigation:navigation-ui-ktx", version.re
androidx-nav-dynamic_features = { module = "androidx.navigation:navigation-dynamic-features-fragment", version.ref = "androidx-navigation" }
androidx-tracing = { module = "androidx.tracing:tracing", version = "1.1.0" }
androidx-tracing-ktx = { module = "androidx.tracing:tracing-ktx", version = "1.1.0" }
androidx-viewpager = { module = "androidx.viewpager:viewpager", version = "1.0.0" }
#noinspection GradleDependency
androidx-window-v1alpha9 = { module = "androidx.window:window", version="1.0.0-alpha09" }
androidx-work = { module = "androidx.work:work-runtime", version.ref = "androidx-work" }
androidx-work-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "androidx-work" }
@@ -130,10 +142,11 @@ tooling-slf4j = { module = "org.slf4j:slf4j-api", version = "2.0.9" }
kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
oss-licenses-plugin = { module = "com.google.android.gms:oss-licenses-plugin", version = "0.10.6" }
nav-safe-args-gradle-plugin = { module = "androidx.navigation:navigation-safe-args-gradle-plugin", version.ref = "androidx-navigation" }
maven-publish = { module = "com.vanniktech:gradle-maven-publish-plugin", version.ref = "maven-publish-plugin" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
android-library = { id = "com.android.library", version.ref = "agp" }
kotlin = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
maven-publish = { id = "com.vanniktech.maven.publish.base", version = "0.25.3" }
maven-publish = { id = "com.vanniktech.maven.publish.base", version.ref = "maven-publish-plugin" }
gradle-publish = { id = "com.gradle.plugin-publish", version = "1.2.1" }
+1 -1
View File
@@ -40,7 +40,7 @@ dependencies {
implementation(projects.eventbusEvents)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.ktx)
implementation(libs.androidx.core.ktx)
implementation(libs.common.kotlin)
implementation(libs.common.utilcode)
implementation(libs.google.material)
+1 -1
View File
@@ -56,7 +56,7 @@ dependencies {
implementation(projects.subprojects.javapoet)
implementation(projects.subprojects.googleJavaFormat)
implementation(libs.androidx.ktx)
implementation(libs.androidx.core.ktx)
implementation(libs.common.kotlin)
testImplementation(projects.testing.lsp)
+1 -1
View File
@@ -13,7 +13,7 @@ dependencies {
implementation(projects.subprojects.fuzzysearch)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.ktx)
implementation(libs.androidx.core.ktx)
implementation(libs.common.kotlin)
implementation(libs.common.utilcode)
}
+1 -1
View File
@@ -46,7 +46,7 @@ dependencies {
implementation(projects.subprojects.xmlDom)
implementation(projects.subprojects.xmlUtils)
implementation(libs.androidx.ktx)
implementation(libs.androidx.core.ktx)
implementation(libs.common.kotlin)
implementation(libs.google.material)
@@ -22,6 +22,17 @@
<style name="Theme.AndroidIDE" parent="Theme.AndroidIDE.Styled">
<item name="android:windowLightStatusBar">false</item>
<item name="android:windowLightNavigationBar" tools:targetApi="27">false</item>
<!-- Left drawer. -->
<item name="buttonBarButtonStyle">@style/TermuxActivity.Drawer.ButtonBarStyle.Dark</item>
<item name="termuxActivityDrawerBackground">@color/black</item>
<item name="termuxActivityDrawerImageTint">@color/white</item>
<!-- Extra keys colors. -->
<item name="extraKeysButtonTextColor">@color/white</item>
<item name="extraKeysButtonActiveTextColor">@color/red_400</item>
<item name="extraKeysButtonBackgroundColor">@color/black</item>
<item name="extraKeysButtonActiveBackgroundColor">@color/grey_500</item>
</style>
</resources>
@@ -3,6 +3,7 @@
<!-- AndroidIDE -->
<string name="copy" tools:override="true">Copy</string>
<string name="terminal_more">More…</string>
<string name="text_to_search">Text to search</string>
<string name="app_name" translatable="false">AndroidIDE</string>
<string name="title_open_source_licenses">Open source licenses</string>
+11
View File
@@ -39,6 +39,17 @@
<style name="Theme.AndroidIDE" parent="Theme.AndroidIDE.Styled">
<item name="android:windowLightStatusBar">true</item>
<item name="android:windowLightNavigationBar" tools:targetApi="27">true</item>
<!-- Left drawer. -->
<item name="buttonBarButtonStyle">@style/TermuxActivity.Drawer.ButtonBarStyle.Light</item>
<item name="termuxActivityDrawerBackground">@android:color/white</item>
<item name="termuxActivityDrawerImageTint">@android:color/black</item>
<!-- Extra keys colors. -->
<item name="extraKeysButtonTextColor">@android:color/white</item>
<item name="extraKeysButtonActiveTextColor">@color/red_400</item>
<item name="extraKeysButtonBackgroundColor">@android:color/black</item>
<item name="extraKeysButtonActiveBackgroundColor">@color/grey_500</item>
</style>
<style
@@ -0,0 +1,220 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ This file is part of AndroidIDE.
~
~ AndroidIDE is free software: you can redistribute it and/or modify
~ it under the terms of the GNU General Public License as published by
~ the Free Software Foundation, either version 3 of the License, or
~ (at your option) any later version.
~
~ AndroidIDE 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 for more details.
~
~ You should have received a copy of the GNU General Public License
~ along with AndroidIDE. If not, see <https://www.gnu.org/licenses/>.
-->
<!DOCTYPE resources [
<!ENTITY TERMUX_PACKAGE_NAME "com.itsaky.androidide">
<!ENTITY TERMUX_APP_NAME "AndroidIDE">
<!ENTITY TERMUX_PREFIX_DIR_PATH "/data/data/com.itsaky.androidide/files/usr">
]>
<resources>
<string name="application_name">&TERMUX_APP_NAME;</string>
<string name="shared_user_label">&TERMUX_APP_NAME; user</string>
<!-- Termux RUN_COMMAND permission -->
<string name="permission_run_command_label">Run commands in &TERMUX_APP_NAME; environment</string>
<string name="permission_run_command_description">execute arbitrary commands within &TERMUX_APP_NAME;
environment and access files</string>
<!-- Termux Bootstrap Packages Installation -->
<string name="bootstrap_installer_body">Installing bootstrap packages…</string>
<string name="bootstrap_error_title">Unable to install bootstrap</string>
<string name="bootstrap_error_body">&TERMUX_APP_NAME; was unable to install the bootstrap packages.</string>
<string name="bootstrap_error_abort">Abort</string>
<string name="bootstrap_error_try_again">Try again</string>
<string name="bootstrap_error_not_primary_user_message">&TERMUX_APP_NAME; can only be run as the primary user.
\nBootstrap binaries compiled for &TERMUX_APP_NAME; have hardcoded $PREFIX path and cannot be installed
under any path other than:\n%1$s.</string>
<string name="bootstrap_error_installed_on_portable_sd">&TERMUX_APP_NAME; cannot be installed on
portable/external/removable sd card on your device.
\nBootstrap binaries compiled for &TERMUX_APP_NAME; have hardcoded $PREFIX path and cannot be installed
under any path other than:\n%1$s.</string>
<!-- Terminal Sidebar and Shortcuts -->
<string name="action_new_session">New session</string>
<string name="action_new_session_failsafe">Failsafe</string>
<string name="title_max_terminals_reached">Max terminals reached</string>
<string name="msg_max_terminals_reached">Close down existing ones before creating new.</string>
<string name="title_rename_session">Set session name</string>
<string name="action_rename_session_confirm">Set</string>
<string name="title_create_named_session">New named session</string>
<string name="action_create_named_session_confirm">Create</string>
<string name="action_toggle_soft_keyboard">Keyboard</string>
<string name="msg_enabling_terminal_toolbar">Enabling Terminal Toolbar</string>
<string name="msg_disabling_terminal_toolbar">Disabling Terminal Toolbar</string>
<!-- Terminal Popup -->
<string name="action_select_url">Select URL</string>
<string name="title_select_url_dialog">Click URL to copy or long press to open</string>
<string name="title_select_url_none_found">No URL found in the terminal.</string>
<string name="msg_select_url_copied_to_clipboard">URL copied to clipboard</string>
<string name="action_share_transcript">Share transcript</string>
<string name="title_share_transcript">Terminal transcript</string>
<string name="title_share_transcript_with">Send transcript to:</string>
<string name="action_share_selected_text">Share selected text</string>
<string name="title_share_selected_text">Terminal Text</string>
<string name="title_share_selected_text_with">Send selected text to:</string>
<string name="action_autofill_password">Autofill password</string>
<string name="action_reset_terminal">Reset</string>
<string name="msg_terminal_reset">Terminal reset</string>
<string name="action_kill_process">Kill process (%d)</string>
<string name="title_confirm_kill_process">Really kill this session?</string>
<string name="action_style_terminal">Style</string>
<string name="action_toggle_keep_screen_on">Keep screen on</string>
<string name="action_open_help">Help</string>
<string name="action_open_settings">Settings</string>
<string name="action_report_issue">Report Issue</string>
<string name="msg_generating_report">Generating Report</string>
<string name="msg_add_termux_debug_info">Add termux debug info to report?</string>
<!-- Termux Notifications -->
<string name="notification_action_exit">Exit</string>
<string name="notification_action_wake_lock">Acquire wakelock</string>
<string name="notification_action_wake_unlock">Release wakelock</string>
<!-- TermuxService -->
<string name="error_display_over_other_apps_permission_not_granted_to_start_terminal">&TERMUX_APP_NAME; requires
\"Display over other apps\" permission to start terminal sessions from background on Android >= 10.
Grants it from Settings -> Apps -> &TERMUX_APP_NAME; -> Advanced</string>
<string name="error_termux_service_invalid_execution_command_runner">Invalid execution command runner to TermuxService: `%1$s`</string>
<string name="error_termux_service_unsupported_execution_command_runner">Unsupported execution command runner to TermuxService: `%1$s`</string>
<string name="error_termux_service_unsupported_execution_command_shell_create_mode">Unsupported execution command shell create mode to TermuxService: `%1$s`</string>
<string name="error_termux_service_execution_command_shell_name_unset">Shell name not set but `%1$s` shell create mode passed</string>
<!-- Termux RunCommandService -->
<string name="error_run_command_service_invalid_intent_action">Invalid intent action to RunCommandService: `%1$s`</string>
<string name="error_run_command_service_invalid_execution_command_runner">Invalid execution command runner to RunCommandService: `%1$s`</string>
<string name="error_run_command_service_mandatory_extra_missing">Mandatory extra missing to RunCommandService: \"%1$s\"</string>
<string name="error_run_command_service_api_help">Visit %1$s for more info on RUN_COMMAND Intent usage.</string>
<!-- Termux File Receiver -->
<string name="title_file_received">Save file in ~/downloads/</string>
<string name="action_file_received_edit">Edit</string>
<string name="action_file_received_open_directory">Open directory</string>
<!-- Miscellaneous -->
<string name="error_termux_service_start_failed_general">Failed to start TermuxService. Check logcat for exception message.</string>
<string name="error_termux_service_start_failed_bg">Failed to start TermuxService while app is in background due to android bg restrictions.</string>
<!-- Termux Settings -->
<string name="title_activity_termux_settings">&TERMUX_APP_NAME; Settings</string>
<!-- Termux App Preferences -->
<string name="termux_preferences_title">&TERMUX_APP_NAME;</string>
<string name="termux_preferences_summary">Preferences for &TERMUX_APP_NAME; app</string>
<!-- Debugging Preferences -->
<string name="termux_debugging_preferences_title">Debugging</string>
<string name="termux_debugging_preferences_summary">Preferences for debugging</string>
<!-- Logging Category -->
<string name="termux_logging_header">Logging</string>
<!-- Log Level -->
<string name="termux_log_level_title">Log Level</string>
<!-- Terminal View Key Logging -->
<string name="termux_terminal_view_key_logging_enabled_title">Terminal View Key Logging</string>
<string name="termux_terminal_view_key_logging_enabled_off">Logs will not have entries for terminal view keys. (Default)</string>
<string name="termux_terminal_view_key_logging_enabled_on">Logcat logs will have entries for terminal view keys.
These are very verbose and should be disabled under normal circumstances or will cause performance issues.</string>
<!-- Plugin Error Notifications -->
<string name="termux_plugin_error_notifications_enabled_title">Plugin Error Notifications</string>
<string name="termux_plugin_error_notifications_enabled_off">Disable flashes and notifications for plugin errors.</string>
<string name="termux_plugin_error_notifications_enabled_on">Show flashes and notifications for plugin errors. (Default)</string>
<!-- Crash Report Notifications -->
<string name="termux_crash_report_notifications_enabled_title">Crash Report Notifications</string>
<string name="termux_crash_report_notifications_enabled_off">Disable notifications for crash reports.</string>
<string name="termux_crash_report_notifications_enabled_on">Show notifications for crash reports. (Default)</string>
<!-- Terminal IO Preferences -->
<string name="termux_terminal_io_preferences_title">Terminal I/O</string>
<string name="termux_terminal_io_preferences_summary">Preferences for terminal I/O</string>
<!-- Keyboard Category -->
<string name="termux_keyboard_header">Keyboard</string>
<!-- Soft Keyboard -->
<string name="termux_soft_keyboard_enabled_title">Soft Keyboard Enabled</string>
<string name="termux_soft_keyboard_enabled_off">Soft keyboard will be disabled.</string>
<string name="termux_soft_keyboard_enabled_on">Soft keyboard will be enabled. (Default)</string>
<!-- Soft Keyboard Only If No Hardware-->
<string name="termux_soft_keyboard_enabled_only_if_no_hardware_title">Soft Keyboard Only If No Hardware</string>
<string name="termux_soft_keyboard_enabled_only_if_no_hardware_off">Soft keyboard will be enabled even if
hardware keyboard is connected. (Default)</string>
<string name="termux_soft_keyboard_enabled_only_if_no_hardware_on">Soft keyboard will be enabled only if
no hardware keyboard is connected.</string>
<!-- Terminal View Preferences -->
<string name="termux_terminal_view_preferences_title">Terminal View</string>
<string name="termux_terminal_view_preferences_summary">Preferences for terminal view</string>
<!-- View Category -->
<string name="termux_terminal_view_view_header">View</string>
<!-- Terminal View Margin Adjustment -->
<string name="termux_terminal_view_terminal_margin_adjustment_title">Terminal Margin Adjustment</string>
<string name="termux_terminal_view_terminal_margin_adjustment_off">Terminal margin adjustment will be disabled.</string>
<string name="termux_terminal_view_terminal_margin_adjustment_on">Terminal margin adjustment will be enabled.
It should be enabled to try to fix the issue where soft keyboard covers part of extra keys/terminal view.
If it causes screen flickering on your devices, then disable it. (Default)</string>
<!-- About Preference -->
<string name="about_preference_title">About</string>
<!-- Donate Preference -->
<string name="donate_preference_title">Donate</string>
</resources>
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ This file is part of AndroidIDE.
~
~ AndroidIDE is free software: you can redistribute it and/or modify
~ it under the terms of the GNU General Public License as published by
~ the Free Software Foundation, either version 3 of the License, or
~ (at your option) any later version.
~
~ AndroidIDE 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 for more details.
~
~ You should have received a copy of the GNU General Public License
~ along with AndroidIDE. If not, see <https://www.gnu.org/licenses/>.
-->
<resources>
<attr format="reference" name="termuxActivityDrawerBackground" />
<attr format="reference" name="termuxActivityDrawerImageTint" />
<attr format="reference" name="extraKeysButtonTextColor" />
<attr format="reference" name="extraKeysButtonActiveTextColor" />
<attr format="reference" name="extraKeysButtonBackgroundColor" />
<attr format="reference" name="extraKeysButtonActiveBackgroundColor" />
</resources>
@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ This file is part of AndroidIDE.
~
~ AndroidIDE is free software: you can redistribute it and/or modify
~ it under the terms of the GNU General Public License as published by
~ the Free Software Foundation, either version 3 of the License, or
~ (at your option) any later version.
~
~ AndroidIDE 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 for more details.
~
~ You should have received a copy of the GNU General Public License
~ along with AndroidIDE. If not, see <https://www.gnu.org/licenses/>.
-->
<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_400">#BDBDBD</color>
<color name="grey_500">#9E9E9E</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>
<color name="blue_link_light">#0969DA</color>
<color name="blue_link_dark">#58A6FF</color>
</resources>
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ This file is part of AndroidIDE.
~
~ AndroidIDE is free software: you can redistribute it and/or modify
~ it under the terms of the GNU General Public License as published by
~ the Free Software Foundation, either version 3 of the License, or
~ (at your option) any later version.
~
~ AndroidIDE 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 for more details.
~
~ You should have received a copy of the GNU General Public License
~ along with AndroidIDE. If not, see <https://www.gnu.org/licenses/>.
-->
<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_half">4dip</dimen>
<dimen name="content_padding">8dip</dimen>
<dimen name="content_padding_double">16dip</dimen>
<dimen name="content_padding_triple">32dip</dimen>
</resources>
@@ -0,0 +1,147 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ This file is part of AndroidIDE.
~
~ AndroidIDE is free software: you can redistribute it and/or modify
~ it under the terms of the GNU General Public License as published by
~ the Free Software Foundation, either version 3 of the License, or
~ (at your option) any later version.
~
~ AndroidIDE 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 for more details.
~
~ You should have received a copy of the GNU General Public License
~ along with AndroidIDE. If not, see <https://www.gnu.org/licenses/>.
-->
<!DOCTYPE resources [
<!ENTITY TERMUX_PACKAGE_NAME "com.itsaky.androidide">
<!ENTITY TERMUX_APP_NAME "AndroidIDE">
<!ENTITY TERMUX_PREFIX_DIR_PATH "/data/data/com.itsaky.androidide/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.</string>
<string name="error_get_package_context_failed_help_url_message">Check %1$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 requested permission(s)</string>
<string name="error_failed_to_request_permissions">Failed to request permissions with request code %1$d: %2$s</string>
<string name="error_attempted_to_check_for_permissions_not_requested">Attempted to check for permissions that have not been requested in app manifest: %1$s</string>
<string name="error_attempted_to_ask_for_permissions_not_requested">Attempted to ask for permissions that have not been requested in app manifest: %1$s</string>
<string name="error_has_not_requested_legacy_external_storage">The \"%1$s\" package is targeting targetSdkVersion %2$d and is running on android sdk %3$d but has not set requestLegacyExternalStorage to true in app manifest</string>
<string name="msg_requires_dump_and_package_usage_stats_permissions">Requires `DUMP` and `PACKAGE_USAGE_STATS` permission</string>
<string name="error_display_over_other_apps_permission_not_granted">%1$s requires
\"Display over other apps\" permission to start activities and services from background on Android >= 10.
Grants it from Android Settings -> Apps -> %1$s -> Advanced -> Draw over other apps.
The permission name may be different on different devices, like on Xiaomi, its called
\"Display pop-up windows while running in the background\", check https://dontkillmyapp.com
for device specific issues.</string>
<!-- TermuxPluginUtils -->
<string name="error_allow_external_apps_ungranted">%1$s requires `allow-external-apps`
property to be set to `true` in `%2$s` file.</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>
<!-- Shell -->
<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_executable_unset">Executable not set \"%1$s\" for shell command</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_app_shell_command">Failed to execute \"%1$s\" app shell 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_app_shell_command">Exception received while to executing \"%1$s\" app shell 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>
<string name="msg_storage_permission_granted_on_request">The storage permission granted by user on request</string>
<string name="msg_storage_permission_not_granted_on_request">The storage permission not granted by user on request</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>
@@ -0,0 +1,89 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ This file is part of AndroidIDE.
~
~ AndroidIDE is free software: you can redistribute it and/or modify
~ it under the terms of the GNU General Public License as published by
~ the Free Software Foundation, either version 3 of the License, or
~ (at your option) any later version.
~
~ AndroidIDE 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 for more details.
~
~ You should have received a copy of the GNU General Public License
~ along with AndroidIDE. If not, see <https://www.gnu.org/licenses/>.
-->
<resources>
<style name="TermuxAlertDialogStyle" parent="@android:style/Theme.Material.Light.Dialog.Alert">
<!-- Seen in buttons on alert dialog: -->
<item name="android:colorAccent">#212121</item>
</style>
<style name="TermuxActivity.Drawer.ButtonBarStyle.Light" parent="@style/Widget.MaterialComponents.Button.TextButton">
<item name="android:textColor">@android:color/black</item>
</style>
<style name="TermuxActivity.Drawer.ButtonBarStyle.Dark" parent="@style/Widget.MaterialComponents.Button.TextButton">
<item name="android:textColor">@android:color/white</item>
</style>
<!-- BaseToolbar Title text appearance. -->
<style name="TextAppearance.Widget.BaseToolbar.Title" parent="TextAppearance.Widget.AppCompat.Toolbar.Title">
<item name="android:textSize">14sp</item>
</style>
<!-- BaseToolbar Subtitle text appearance. -->
<style name="TextAppearance.Widget.BaseToolbar.Subtitle" parent="TextAppearance.Widget.AppCompat.Toolbar.Subtitle">
<item name="android:textSize">12sp</item>
</style>
<!--
BaseDialog Title Panel materialAlertDialogTitlePanelStyle.
https://github.com/material-components/material-components-android/blob/1.4.0/lib/java/com/google/android/material/dialog/res/layout/mtrl_alert_dialog_title.xml#L29
https://github.com/material-components/material-components-android/blob/1.4.0/lib/java/com/google/android/material/dialog/res/values/themes.xml#L71
-->
<style name="BaseDialog.Title.Panel" parent="@style/MaterialAlertDialog.MaterialComponents.Title.Panel">
<!-- Title background. -->
<item name="android:background">@color/red_400</item>
<!-- Center Title. -->
<item name="android:paddingTop">14dp</item>
<item name="android:paddingBottom">14dp</item>
</style>
<!--
BaseDialog Message Text Light materialAlertDialogBodyTextStyle.
https://github.com/material-components/material-components-android/blob/1.4.0/lib/java/com/google/android/material/dialog/res/layout/mtrl_alert_dialog.xml#L59
https://github.com/material-components/material-components-android/blob/1.4.0/lib/java/com/google/android/material/dialog/res/values/themes_base.xml#L133
-->
<style name="BaseDialog.Message.Text.Light" parent="@style/MaterialAlertDialog.MaterialComponents.Body.Text">
<!-- Text color. -->
<item name="android:textColorPrimary">@color/black</item>
<item name="android:textColorLink">@color/blue_link_light</item>
</style>
<!--
BaseDialog Message Text Dark materialAlertDialogBodyTextStyle.
https://github.com/material-components/material-components-android/blob/1.4.0/lib/java/com/google/android/material/dialog/res/layout/mtrl_alert_dialog.xml#L59
https://github.com/material-components/material-components-android/blob/1.4.0/lib/java/com/google/android/material/dialog/res/values/themes_base.xml#L133
-->
<style name="BaseDialog.Message.Text.Dark" parent="@style/MaterialAlertDialog.MaterialComponents.Body.Text">
<!-- Text color. -->
<item name="android:textColorLink">@color/blue_link_light</item>
</style>
<!-- ViewDivider 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>
+4 -1
View File
@@ -50,7 +50,6 @@ include(
":editor",
":editor-api",
":editor-treesitter",
":emulatorview",
":eventbus",
":eventbus-android",
":eventbus-events",
@@ -96,6 +95,10 @@ include(
":subprojects:tooling-api-model",
":subprojects:xml-dom",
":subprojects:xml-utils",
":termux:termux-app",
":termux:termux-emulator",
":termux:termux-shared",
":termux:termux-view",
":testing:android",
":testing:lsp",
":testing:tooling",
+1 -1
View File
@@ -27,7 +27,7 @@ android {
dependencies {
implementation(libs.androidx.appcompat)
implementation(libs.androidx.annotation)
implementation(libs.androidx.ktx)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.constraintlayout)
implementation(libs.google.material)
}
+1 -1
View File
@@ -35,7 +35,7 @@ dependencies {
implementation(projects.preferences)
implementation(projects.subprojects.projects)
implementation(libs.androidx.annotation)
implementation(libs.androidx.ktx)
implementation(libs.androidx.core.ktx)
implementation(libs.google.auto.service.annotations)
testImplementation(projects.templatesApi)
+1
View File
@@ -0,0 +1 @@
/build
+71
View File
@@ -0,0 +1,71 @@
@file:Suppress("UnstableApiUsage")
plugins {
id("com.android.library")
id("kotlin-android")
}
val packageVariant = System.getenv("TERMUX_PACKAGE_VARIANT") ?: "apt-android-7" // Default: "apt-android-7"
android {
namespace = "com.termux"
ndkVersion = BuildConfig.ndkVersion
defaultConfig {
buildConfigField("String", "TERMUX_PACKAGE_VARIANT", "\"" + packageVariant + "\"") // Used by TermuxApplication class
manifestPlaceholders["TERMUX_PACKAGE_NAME"] = BuildConfig.packageName
manifestPlaceholders["TERMUX_APP_NAME"] = "AndroidIDE"
externalNativeBuild {
ndkBuild {
cFlags("-std=c11", "-Wall", "-Wextra", "-Werror", "-Os", "-fno-stack-protector", "-Wl,--gc-sections")
}
}
}
externalNativeBuild {
ndkBuild {
path = file("src/main/cpp/Android.mk")
}
}
lint.disable += "ProtectedPermissions"
testOptions {
unitTests {
isIncludeAndroidResources = true
}
}
packaging.jniLibs.useLegacyPackaging = true
}
dependencies {
implementation(libs.androidx.annotation)
implementation(libs.androidx.core)
implementation(libs.androidx.drawer)
implementation(libs.androidx.preference)
implementation(libs.androidx.viewpager)
implementation(libs.google.material)
implementation(libs.google.guava)
implementation(libs.common.markwon.core)
implementation(libs.common.markwon.extStrikethrough)
implementation(libs.common.markwon.linkify)
implementation(libs.common.markwon.recycler)
implementation(projects.common)
implementation(projects.preferences)
implementation(projects.resources)
implementation(projects.termux.termuxView)
implementation(projects.termux.termuxShared)
testImplementation(projects.testing.unit)
}
tasks.register("versionName") {
doLast {
print(project.rootProject.version)
}
}
+17
View File
@@ -0,0 +1,17 @@
# Add project specific ProGuard rules here.
# By default, the flags in this file are appended to flags specified
# in android-sdk/tools/proguard/proguard-android.txt
# You can edit the include path and order by changing the proguardFiles
# directive in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
-dontobfuscate
#-renamesourcefileattribute SourceFile
#-keepattributes SourceFile,LineNumberTable
# Temp fix for androidx.window:window:1.0.0-alpha09 imported by termux-shared
# https://issuetracker.google.com/issues/189001730
# https://android-review.googlesource.com/c/platform/frameworks/support/+/1757630
-keep class androidx.window.** { *; }
@@ -0,0 +1,84 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:sharedUserLabel="@string/shared_user_label">
<uses-feature
android:name="android.hardware.touchscreen"
android:required="false" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission
android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<uses-permission android:name="android.permission.READ_LOGS" />
<uses-permission android:name="android.permission.DUMP" />
<uses-permission android:name="android.permission.WRITE_SECURE_SETTINGS" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission
android:name="android.permission.PACKAGE_USAGE_STATS"
tools:ignore="ProtectedPermissions" />
<application>
<activity
android:configChanges="orientation|screenSize|smallestScreenSize|density|screenLayout|keyboard|keyboardHidden|navigation"
android:exported="true"
android:launchMode="singleTask"
android:name="com.termux.app.TermuxActivity"
android:resizeableActivity="true" />
<activity
android:documentLaunchMode="intoExisting"
android:name=".shared.activities.ReportActivity" />
<activity
android:excludeFromRecents="true"
android:exported="false"
android:name=".app.api.file.FileReceiverActivity"
android:noHistory="true"
android:resizeableActivity="true"
android:taskAffinity="${TERMUX_PACKAGE_NAME}.filereceiver" />
<meta-data
android:name="android.max_aspect"
android:value="10.0" />
<meta-data
android:name="com.samsung.android.keepalive.density"
android:value="true" />
<meta-data
android:name="com.samsung.android.multidisplay.keep_process_alive"
android:value="true" />
<meta-data
android:name="com.sec.android.support.multiwindow"
android:value="true" />
<!-- https://developer.samsung.com/samsung-dex/modify-optimizing.html -->
<!-- Version < 3.0. DeX Mode and Screen Mirroring support -->
<receiver
android:exported="false"
android:name=".shared.activities.ReportActivity$ReportActivityBroadcastReceiver" />
<!-- Version >= 3.0. DeX Dual Mode support -->
<service
android:exported="false"
android:name=".app.TermuxService" />
</application>
</manifest>
@@ -0,0 +1,5 @@
LOCAL_PATH:= $(call my-dir)
include $(CLEAR_VARS)
LOCAL_MODULE := libtermux-bootstrap
LOCAL_SRC_FILES := termux-bootstrap-zip.S termux-bootstrap.c
include $(BUILD_SHARED_LIBRARY)
@@ -0,0 +1,14 @@
.global blob
.global blob_size
.section .rodata
blob:
#if defined __aarch64__
.incbin "bootstrap-aarch64.zip"
#elif defined __arm__
.incbin "bootstrap-arm.zip"
#else
# error Unsupported arch
#endif
1:
blob_size:
.int 1b - blob
@@ -0,0 +1,11 @@
#include <jni.h>
extern jbyte blob[];
extern int blob_size;
JNIEXPORT jbyteArray JNICALL Java_com_termux_app_TermuxInstaller_getZip(JNIEnv *env, __attribute__((__unused__)) jobject This)
{
jbyteArray ret = (*env)->NewByteArray(env, blob_size);
(*env)->SetByteArrayRegion(env, ret, 0, blob_size, blob);
return ret;
}
@@ -0,0 +1,968 @@
/*
* This file is part of AndroidIDE.
*
* AndroidIDE is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* AndroidIDE 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 for more details.
*
* You should have received a copy of the GNU General Public License
* along with AndroidIDE. If not, see <https://www.gnu.org/licenses/>.
*/
package com.termux.app;
import android.annotation.SuppressLint;
import android.app.AlertDialog;
import android.content.BroadcastReceiver;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.ServiceConnection;
import android.os.Bundle;
import android.os.IBinder;
import android.view.ContextMenu;
import android.view.ContextMenu.ContextMenuInfo;
import android.view.Gravity;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowManager;
import android.view.autofill.AutofillManager;
import android.widget.EditText;
import android.widget.ListView;
import android.widget.RelativeLayout;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.drawerlayout.widget.DrawerLayout;
import androidx.viewpager.widget.ViewPager;
import com.itsaky.androidide.app.BaseIDEActivity;
import com.termux.R;
import com.termux.app.api.file.FileReceiverActivity;
import com.termux.app.terminal.TermuxActivityRootView;
import com.termux.app.terminal.TermuxSessionsListViewController;
import com.termux.app.terminal.TermuxTerminalSessionActivityClient;
import com.termux.app.terminal.TermuxTerminalViewClient;
import com.termux.app.terminal.io.TerminalToolbarViewPager;
import com.termux.app.terminal.io.TermuxTerminalExtraKeys;
import com.termux.databinding.ActivityTermuxBinding;
import com.termux.shared.activities.ReportActivity;
import com.termux.shared.activity.ActivityUtils;
import com.termux.shared.android.PermissionUtils;
import com.termux.shared.data.DataUtils;
import com.termux.shared.data.IntentUtils;
import com.termux.shared.logger.Logger;
import com.termux.shared.termux.TermuxConstants.TERMUX_APP.TERMUX_ACTIVITY;
import com.termux.shared.termux.crash.TermuxCrashUtils;
import com.termux.shared.termux.extrakeys.ExtraKeysView;
import com.termux.shared.termux.interact.TextInputDialogUtils;
import com.termux.shared.termux.settings.preferences.TermuxAppSharedPreferences;
import com.termux.shared.termux.settings.properties.TermuxAppSharedProperties;
import com.termux.shared.termux.theme.TermuxThemeUtils;
import com.termux.shared.view.KeyboardUtils;
import com.termux.shared.view.ViewUtils;
import com.termux.terminal.TerminalSession;
import com.termux.terminal.TerminalSessionClient;
import com.termux.view.TerminalView;
import com.termux.view.TerminalViewClient;
import java.util.Arrays;
/**
* A terminal emulator activity.
* <p/>
* See
* <ul>
* <li>http://www.mongrel-phones.com.au/default/how_to_make_a_local_service_and_bind_to_it_in_android</li>
* <li>https://code.google.com/p/android/issues/detail?id=6426</li>
* </ul>
* about memory leaks.
*/
public class TermuxActivity extends BaseIDEActivity implements ServiceConnection {
protected ActivityTermuxBinding binding;
/**
* The connection to the {@link TermuxService}. Requested in {@link #onCreate(Bundle)} with a call to
* {@link #bindService(Intent, ServiceConnection, int)}, and obtained and stored in
* {@link #onServiceConnected(ComponentName, IBinder)}.
*/
TermuxService mTermuxService;
/**
* The {@link TerminalViewClient} interface implementation to allow for communication between
* {@link TerminalView} and {@link TermuxActivity}.
*/
TermuxTerminalViewClient mTermuxTerminalViewClient;
/**
* The {@link TerminalSessionClient} interface implementation to allow for communication between
* {@link TerminalSession} and {@link TermuxActivity}.
*/
TermuxTerminalSessionActivityClient mTermuxTerminalSessionActivityClient;
/**
* Termux app shared preferences manager.
*/
private TermuxAppSharedPreferences mPreferences;
/**
* Termux app SharedProperties loaded from termux.properties
*/
private TermuxAppSharedProperties mProperties;
/**
* The terminal extra keys view.
*/
ExtraKeysView mExtraKeysView;
/**
* The client for the {@link #mExtraKeysView}.
*/
TermuxTerminalExtraKeys mTermuxTerminalExtraKeys;
/**
* The termux sessions list controller.
*/
TermuxSessionsListViewController mTermuxSessionListViewController;
/**
* The {@link TermuxActivity} broadcast receiver for various things like terminal style configuration changes.
*/
private final BroadcastReceiver mTermuxActivityBroadcastReceiver = new TermuxActivityBroadcastReceiver();
/**
* The last toast shown, used cancel current toast before showing new in {@link #showToast(String, boolean)}.
*/
Toast mLastToast;
/**
* If between onResume() and onStop(). Note that only one session is in the foreground of the terminal view at the
* time, so if the session causing a change is not in the foreground it should probably be treated as background.
*/
private boolean mIsVisible;
/**
* If onResume() was called after onCreate().
*/
private boolean mIsOnResumeAfterOnCreate = false;
/**
* If activity was restarted like due to call to {@link #recreate()} after receiving
* {@link TERMUX_ACTIVITY#ACTION_RELOAD_STYLE}, system dark night mode was changed or activity
* was killed by android.
*/
private boolean mIsActivityRecreated = false;
/**
* The {@link TermuxActivity} is in an invalid state and must not be run.
*/
private boolean mIsInvalidState;
private int mNavBarHeight;
private float mTerminalToolbarDefaultHeight;
private static final int CONTEXT_MENU_SELECT_URL_ID = 0;
private static final int CONTEXT_MENU_SHARE_TRANSCRIPT_ID = 1;
private static final int CONTEXT_MENU_SHARE_SELECTED_TEXT = 10;
private static final int CONTEXT_MENU_AUTOFILL_ID = 2;
private static final int CONTEXT_MENU_RESET_TERMINAL_ID = 3;
private static final int CONTEXT_MENU_KILL_PROCESS_ID = 4;
private static final int CONTEXT_MENU_STYLING_ID = 5;
private static final int CONTEXT_MENU_TOGGLE_KEEP_SCREEN_ON = 6;
private static final int CONTEXT_MENU_HELP_ID = 7;
private static final int CONTEXT_MENU_SETTINGS_ID = 8;
private static final int CONTEXT_MENU_REPORT_ID = 9;
private static final String ARG_TERMINAL_TOOLBAR_TEXT_INPUT = "terminal_toolbar_text_input";
private static final String ARG_ACTIVITY_RECREATED = "activity_recreated";
private static final String LOG_TAG = "TermuxActivity";
@NonNull
@Override
protected View bindLayout() {
this.binding = ActivityTermuxBinding.inflate(getLayoutInflater());
return binding.getRoot();
}
@Override
public void onCreate(Bundle savedInstanceState) {
Logger.logDebug(LOG_TAG, "onCreate");
mIsOnResumeAfterOnCreate = true;
if (savedInstanceState != null)
mIsActivityRecreated = savedInstanceState.getBoolean(ARG_ACTIVITY_RECREATED, false);
// Delete ReportInfo serialized object files from cache older than 14 days
ReportActivity.deleteReportInfoFilesOlderThanXDays(this, 14, false);
// Load Termux app SharedProperties from disk
mProperties = TermuxAppSharedProperties.getProperties();
reloadProperties();
setActivityTheme();
super.onCreate(savedInstanceState);
// Load termux shared preferences
// This will also fail if TermuxConstants.TERMUX_PACKAGE_NAME does not equal applicationId
mPreferences = TermuxAppSharedPreferences.build(this, true);
if (mPreferences == null) {
// An AlertDialog should have shown to kill the app, so we don't continue running activity code
mIsInvalidState = true;
return;
}
setMargins();
binding.activityTermuxRootView.setActivity(this);
binding.activityTermuxRootView.setOnApplyWindowInsetsListener(new TermuxActivityRootView.WindowInsetsListener());
View content = findViewById(android.R.id.content);
content.setOnApplyWindowInsetsListener((v, insets) -> {
mNavBarHeight = insets.getSystemWindowInsetBottom();
return insets;
});
if (mProperties.isUsingFullScreen()) {
getWindow().addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN);
}
setTermuxTerminalViewAndClients();
setTerminalToolbarView(savedInstanceState);
setNewSessionButtonView();
setToggleKeyboardView();
registerForContextMenu(binding.terminalView);
FileReceiverActivity.updateFileReceiverActivityComponentsState(this);
try {
// Start the {@link TermuxService} and make it run regardless of who is bound to it
Intent serviceIntent = new Intent(this, TermuxService.class);
startService(serviceIntent);
// Attempt to bind to the service, this will call the {@link #onServiceConnected(ComponentName, IBinder)}
// callback if it succeeds.
if (!bindService(serviceIntent, this, 0))
throw new RuntimeException("bindService() failed");
} catch (Exception e) {
Logger.logStackTraceWithMessage(LOG_TAG,"TermuxActivity failed to start TermuxService", e);
Logger.showToast(this,
getString(e.getMessage() != null && e.getMessage().contains("app is in background") ?
R.string.error_termux_service_start_failed_bg : R.string.error_termux_service_start_failed_general),
true);
mIsInvalidState = true;
return;
}
}
@Override
public void onStart() {
super.onStart();
Logger.logDebug(LOG_TAG, "onStart");
if (mIsInvalidState) return;
mIsVisible = true;
if (mTermuxTerminalSessionActivityClient != null)
mTermuxTerminalSessionActivityClient.onStart();
if (mTermuxTerminalViewClient != null)
mTermuxTerminalViewClient.onStart();
if (mPreferences.isTerminalMarginAdjustmentEnabled())
addTermuxActivityRootViewGlobalLayoutListener();
registerTermuxActivityBroadcastReceiver();
}
@Override
public void onResume() {
super.onResume();
Logger.logVerbose(LOG_TAG, "onResume");
if (mIsInvalidState) return;
if (mTermuxTerminalSessionActivityClient != null)
mTermuxTerminalSessionActivityClient.onResume();
if (mTermuxTerminalViewClient != null)
mTermuxTerminalViewClient.onResume();
// Check if a crash happened on last run of the app or if a plugin crashed and show a
// notification with the crash details if it did
TermuxCrashUtils.notifyAppCrashFromCrashLogFile(this, LOG_TAG);
mIsOnResumeAfterOnCreate = false;
}
@Override
protected void onStop() {
super.onStop();
Logger.logDebug(LOG_TAG, "onStop");
if (mIsInvalidState) return;
mIsVisible = false;
if (mTermuxTerminalSessionActivityClient != null)
mTermuxTerminalSessionActivityClient.onStop();
if (mTermuxTerminalViewClient != null)
mTermuxTerminalViewClient.onStop();
removeTermuxActivityRootViewGlobalLayoutListener();
unregisterTermuxActivityBroadcastReceiver();
getDrawer().closeDrawers();
}
@Override
public void onDestroy() {
super.onDestroy();
Logger.logDebug(LOG_TAG, "onDestroy");
if (mIsInvalidState) return;
this.binding = null;
this.mLastToast = null;
if (mTermuxService != null) {
// Do not leave service and session clients with references to activity.
mTermuxService.unsetTermuxTerminalSessionClient();
mTermuxService = null;
}
try {
unbindService(this);
} catch (Exception e) {
// ignore.
}
}
@Override
public void onSaveInstanceState(@NonNull Bundle savedInstanceState) {
Logger.logVerbose(LOG_TAG, "onSaveInstanceState");
super.onSaveInstanceState(savedInstanceState);
saveTerminalToolbarTextInput(savedInstanceState);
savedInstanceState.putBoolean(ARG_ACTIVITY_RECREATED, true);
}
/**
* Part of the {@link ServiceConnection} interface. The service is bound with
* {@link #bindService(Intent, ServiceConnection, int)} in {@link #onCreate(Bundle)} which will cause a call to this
* callback method.
*/
@Override
public void onServiceConnected(ComponentName componentName, IBinder service) {
Logger.logDebug(LOG_TAG, "onServiceConnected");
mTermuxService = ((TermuxService.LocalBinder) service).service;
setTermuxSessionsListView();
final Intent intent = getIntent();
setIntent(null);
if (mTermuxService.isTermuxSessionsEmpty()) {
if (mIsVisible) {
TermuxInstaller.setupBootstrapIfNeeded(TermuxActivity.this, () -> {
if (mTermuxService == null) return; // Activity might have been destroyed.
try {
boolean launchFailsafe = false;
if (intent != null && intent.getExtras() != null) {
launchFailsafe = intent.getExtras().getBoolean(TERMUX_ACTIVITY.EXTRA_FAILSAFE_SESSION, false);
}
mTermuxTerminalSessionActivityClient.addNewSession(launchFailsafe, null);
} catch (WindowManager.BadTokenException e) {
// Activity finished - ignore.
}
});
} else {
// The service connected while not in foreground - just bail out.
finishActivityIfNotFinishing();
}
} else {
// If termux was started from launcher "New session" shortcut and activity is recreated,
// then the original intent will be re-delivered, resulting in a new session being re-added
// each time.
if (!mIsActivityRecreated && intent != null && Intent.ACTION_RUN.equals(intent.getAction())) {
// Android 7.1 app shortcut from res/xml/shortcuts.xml.
boolean isFailSafe = intent.getBooleanExtra(TERMUX_ACTIVITY.EXTRA_FAILSAFE_SESSION, false);
mTermuxTerminalSessionActivityClient.addNewSession(isFailSafe, null);
} else {
mTermuxTerminalSessionActivityClient.setCurrentSession(mTermuxTerminalSessionActivityClient.getCurrentStoredSessionOrLast());
}
}
// Update the {@link TerminalSession} and {@link TerminalEmulator} clients.
mTermuxService.setTermuxTerminalSessionClient(mTermuxTerminalSessionActivityClient);
}
@Override
public void onServiceDisconnected(ComponentName name) {
Logger.logDebug(LOG_TAG, "onServiceDisconnected");
// Respect being stopped from the {@link TermuxService} notification action.
finishActivityIfNotFinishing();
}
private void reloadProperties() {
mProperties.loadTermuxPropertiesFromDisk();
if (mTermuxTerminalViewClient != null)
mTermuxTerminalViewClient.onReloadProperties();
}
private void setActivityTheme() {
// Update NightMode.APP_NIGHT_MODE
TermuxThemeUtils.setAppNightMode(mProperties.getNightMode());
}
private void setMargins() {
RelativeLayout relativeLayout = binding.activityTermuxRootRelativeLayout;
int marginHorizontal = mProperties.getTerminalMarginHorizontal();
int marginVertical = mProperties.getTerminalMarginVertical();
ViewUtils.setLayoutMarginsInDp(relativeLayout, marginHorizontal, marginVertical, marginHorizontal, marginVertical);
}
public void addTermuxActivityRootViewGlobalLayoutListener() {
getTermuxActivityRootView().getViewTreeObserver().addOnGlobalLayoutListener(getTermuxActivityRootView());
}
public void removeTermuxActivityRootViewGlobalLayoutListener() {
if (getTermuxActivityRootView() != null)
getTermuxActivityRootView().getViewTreeObserver().removeOnGlobalLayoutListener(getTermuxActivityRootView());
}
private void setTermuxTerminalViewAndClients() {
// Set termux terminal view and session clients
mTermuxTerminalSessionActivityClient = new TermuxTerminalSessionActivityClient(this);
mTermuxTerminalViewClient = new TermuxTerminalViewClient(this, mTermuxTerminalSessionActivityClient);
// Set termux terminal view
binding.terminalView.setTerminalViewClient(mTermuxTerminalViewClient);
if (mTermuxTerminalViewClient != null)
mTermuxTerminalViewClient.onCreate();
if (mTermuxTerminalSessionActivityClient != null)
mTermuxTerminalSessionActivityClient.onCreate();
}
private void setTermuxSessionsListView() {
ListView termuxSessionsListView = binding.terminalSessionsList;
mTermuxSessionListViewController = new TermuxSessionsListViewController(this, mTermuxService.getTermuxSessions());
termuxSessionsListView.setAdapter(mTermuxSessionListViewController);
termuxSessionsListView.setOnItemClickListener(mTermuxSessionListViewController);
termuxSessionsListView.setOnItemLongClickListener(mTermuxSessionListViewController);
}
private void setTerminalToolbarView(Bundle savedInstanceState) {
mTermuxTerminalExtraKeys = new TermuxTerminalExtraKeys(this, binding.terminalView,
mTermuxTerminalViewClient, mTermuxTerminalSessionActivityClient);
final ViewPager terminalToolbarViewPager = getTerminalToolbarViewPager();
if (mPreferences.shouldShowTerminalToolbar()) terminalToolbarViewPager.setVisibility(View.VISIBLE);
ViewGroup.LayoutParams layoutParams = terminalToolbarViewPager.getLayoutParams();
mTerminalToolbarDefaultHeight = layoutParams.height;
setTerminalToolbarHeight();
String savedTextInput = null;
if (savedInstanceState != null)
savedTextInput = savedInstanceState.getString(ARG_TERMINAL_TOOLBAR_TEXT_INPUT);
terminalToolbarViewPager.setAdapter(new TerminalToolbarViewPager.PageAdapter(this, savedTextInput));
terminalToolbarViewPager.addOnPageChangeListener(new TerminalToolbarViewPager.OnPageChangeListener(this, terminalToolbarViewPager));
}
private void setTerminalToolbarHeight() {
final ViewPager terminalToolbarViewPager = getTerminalToolbarViewPager();
if (terminalToolbarViewPager == null) return;
ViewGroup.LayoutParams layoutParams = terminalToolbarViewPager.getLayoutParams();
layoutParams.height = Math.round(mTerminalToolbarDefaultHeight *
(mTermuxTerminalExtraKeys.getExtraKeysInfo() == null ? 0 : mTermuxTerminalExtraKeys.getExtraKeysInfo().getMatrix().length) *
mProperties.getTerminalToolbarHeightScaleFactor());
terminalToolbarViewPager.setLayoutParams(layoutParams);
}
public void toggleTerminalToolbar() {
final ViewPager terminalToolbarViewPager = getTerminalToolbarViewPager();
if (terminalToolbarViewPager == null) return;
final boolean showNow = mPreferences.toogleShowTerminalToolbar();
Logger.showToast(this, (showNow ? getString(R.string.msg_enabling_terminal_toolbar) : getString(R.string.msg_disabling_terminal_toolbar)), true);
terminalToolbarViewPager.setVisibility(showNow ? View.VISIBLE : View.GONE);
if (showNow && isTerminalToolbarTextInputViewSelected()) {
// Focus the text input view if just revealed.
findViewById(R.id.terminal_toolbar_text_input).requestFocus();
}
}
private void saveTerminalToolbarTextInput(Bundle savedInstanceState) {
if (savedInstanceState == null) return;
final EditText textInputView = findViewById(R.id.terminal_toolbar_text_input);
if (textInputView != null) {
String textInput = textInputView.getText().toString();
if (!textInput.isEmpty()) savedInstanceState.putString(ARG_TERMINAL_TOOLBAR_TEXT_INPUT, textInput);
}
}
private void setNewSessionButtonView() {
binding.newSessionButton.setOnClickListener(v -> mTermuxTerminalSessionActivityClient.addNewSession(false, null));
binding.newSessionButton.setOnLongClickListener(v -> {
TextInputDialogUtils.textInput(TermuxActivity.this, R.string.title_create_named_session, null,
R.string.action_create_named_session_confirm, text -> mTermuxTerminalSessionActivityClient.addNewSession(false, text),
R.string.action_new_session_failsafe, text -> mTermuxTerminalSessionActivityClient.addNewSession(true, text),
-1, null, null);
return true;
});
}
private void setToggleKeyboardView() {
binding.toggleKeyboardButton.setOnClickListener(v -> {
mTermuxTerminalViewClient.onToggleSoftKeyboardRequest();
getDrawer().closeDrawers();
});
binding.toggleKeyboardButton.setOnLongClickListener(v -> {
toggleTerminalToolbar();
return true;
});
}
@SuppressLint("RtlHardcoded")
@Override
public void onBackPressed() {
if (KeyboardUtils.isSoftKeyboardVisible(this)) {
KeyboardUtils.showSoftKeyboard(this, getTerminalView());
} else if (getDrawer().isDrawerOpen(Gravity.LEFT)) {
getDrawer().closeDrawers();
} else {
finishActivityIfNotFinishing();
}
}
public void finishActivityIfNotFinishing() {
// prevent duplicate calls to finish() if called from multiple places
if (!TermuxActivity.this.isFinishing()) {
finish();
}
}
/** Show a toast and dismiss the last one if still visible. */
public void showToast(String text, boolean longDuration) {
if (text == null || text.isEmpty()) return;
if (mLastToast != null) mLastToast.cancel();
mLastToast = Toast.makeText(TermuxActivity.this, text, longDuration ? Toast.LENGTH_LONG : Toast.LENGTH_SHORT);
mLastToast.setGravity(Gravity.TOP, 0, 0);
mLastToast.show();
}
@Override
public void onCreateContextMenu(ContextMenu menu, View v, ContextMenuInfo menuInfo) {
TerminalSession currentSession = getCurrentSession();
if (currentSession == null) return;
boolean addAutoFillMenu = false;
AutofillManager autofillManager = getSystemService(AutofillManager.class);
if (autofillManager != null && autofillManager.isEnabled()) {
addAutoFillMenu = true;
}
menu.add(Menu.NONE, CONTEXT_MENU_SELECT_URL_ID, Menu.NONE, R.string.action_select_url);
menu.add(Menu.NONE, CONTEXT_MENU_SHARE_TRANSCRIPT_ID, Menu.NONE, R.string.action_share_transcript);
if (!DataUtils.isNullOrEmpty(binding.terminalView.getStoredSelectedText()))
menu.add(Menu.NONE, CONTEXT_MENU_SHARE_SELECTED_TEXT, Menu.NONE, R.string.action_share_selected_text);
if (addAutoFillMenu)
menu.add(Menu.NONE, CONTEXT_MENU_AUTOFILL_ID, Menu.NONE, R.string.action_autofill_password);
menu.add(Menu.NONE, CONTEXT_MENU_RESET_TERMINAL_ID, Menu.NONE, R.string.action_reset_terminal);
menu.add(Menu.NONE, CONTEXT_MENU_KILL_PROCESS_ID, Menu.NONE, getResources().getString(R.string.action_kill_process, getCurrentSession().getPid())).setEnabled(currentSession.isRunning());
menu.add(Menu.NONE, CONTEXT_MENU_TOGGLE_KEEP_SCREEN_ON, Menu.NONE, R.string.action_toggle_keep_screen_on).setCheckable(true).setChecked(mPreferences.shouldKeepScreenOn());
menu.add(Menu.NONE, CONTEXT_MENU_SETTINGS_ID, Menu.NONE, R.string.action_open_settings);
menu.add(Menu.NONE, CONTEXT_MENU_REPORT_ID, Menu.NONE, R.string.action_report_issue);
}
/** Hook system menu to show context menu instead. */
@Override
public boolean onCreateOptionsMenu(Menu menu) {
binding.terminalView.showContextMenu();
return false;
}
@Override
public boolean onContextItemSelected(MenuItem item) {
TerminalSession session = getCurrentSession();
switch (item.getItemId()) {
case CONTEXT_MENU_SELECT_URL_ID:
mTermuxTerminalViewClient.showUrlSelection();
return true;
case CONTEXT_MENU_SHARE_TRANSCRIPT_ID:
mTermuxTerminalViewClient.shareSessionTranscript();
return true;
case CONTEXT_MENU_SHARE_SELECTED_TEXT:
mTermuxTerminalViewClient.shareSelectedText();
return true;
case CONTEXT_MENU_AUTOFILL_ID:
requestAutoFill();
return true;
case CONTEXT_MENU_RESET_TERMINAL_ID:
onResetTerminalSession(session);
return true;
case CONTEXT_MENU_KILL_PROCESS_ID:
showKillSessionDialog(session);
return true;
case CONTEXT_MENU_TOGGLE_KEEP_SCREEN_ON:
toggleKeepScreenOn();
return true;
case CONTEXT_MENU_REPORT_ID:
mTermuxTerminalViewClient.reportIssueFromTranscript();
return true;
default:
return super.onContextItemSelected(item);
}
}
@Override
public void onContextMenuClosed(Menu menu) {
super.onContextMenuClosed(menu);
// onContextMenuClosed() is triggered twice if back button is pressed to dismiss instead of tap for some reason
binding.terminalView.onContextMenuClosed(menu);
}
private void showKillSessionDialog(TerminalSession session) {
if (session == null) return;
final AlertDialog.Builder b = new AlertDialog.Builder(this);
b.setIcon(android.R.drawable.ic_dialog_alert);
b.setMessage(R.string.title_confirm_kill_process);
b.setPositiveButton(android.R.string.yes, (dialog, id) -> {
dialog.dismiss();
session.finishIfRunning();
});
b.setNegativeButton(android.R.string.no, null);
b.show();
}
private void onResetTerminalSession(TerminalSession session) {
if (session != null) {
session.reset();
showToast(getResources().getString(R.string.msg_terminal_reset), true);
if (mTermuxTerminalSessionActivityClient != null)
mTermuxTerminalSessionActivityClient.onResetTerminalSession();
}
}
private void toggleKeepScreenOn() {
if (binding.terminalView.getKeepScreenOn()) {
binding.terminalView.setKeepScreenOn(false);
mPreferences.setKeepScreenOn(false);
} else {
binding.terminalView.setKeepScreenOn(true);
mPreferences.setKeepScreenOn(true);
}
}
private void requestAutoFill() {
AutofillManager autofillManager = getSystemService(AutofillManager.class);
if (autofillManager != null && autofillManager.isEnabled()) {
autofillManager.requestAutofill(binding.terminalView);
}
}
/**
* For processes to access primary external storage (/sdcard, /storage/emulated/0, ~/storage/shared),
* termux needs to be granted legacy WRITE_EXTERNAL_STORAGE or MANAGE_EXTERNAL_STORAGE permissions
* if targeting targetSdkVersion 30 (android 11) and running on sdk 30 (android 11) and higher.
*/
public void requestStoragePermission(boolean isPermissionCallback) {
new Thread(() -> {
// Do not ask for permission again
int requestCode = isPermissionCallback ? -1 : PermissionUtils.REQUEST_GRANT_STORAGE_PERMISSION;
// If permission is granted, then also setup storage symlinks.
if(PermissionUtils.checkAndRequestLegacyOrManageExternalStoragePermission(
TermuxActivity.this, requestCode, !isPermissionCallback)) {
if (isPermissionCallback)
Logger.logInfoAndShowToast(TermuxActivity.this, LOG_TAG,
getString(com.termux.shared.R.string.msg_storage_permission_granted_on_request));
TermuxInstaller.setupStorageSymlinks(TermuxActivity.this);
} else {
if (isPermissionCallback)
Logger.logInfoAndShowToast(TermuxActivity.this, LOG_TAG,
getString(com.termux.shared.R.string.msg_storage_permission_not_granted_on_request));
}
}).start();
}
@Override
protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
super.onActivityResult(requestCode, resultCode, data);
Logger.logVerbose(LOG_TAG, "onActivityResult: requestCode: " + requestCode + ", resultCode: " + resultCode + ", data: " + IntentUtils.getIntentString(data));
if (requestCode == PermissionUtils.REQUEST_GRANT_STORAGE_PERMISSION) {
requestStoragePermission(true);
}
}
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
Logger.logVerbose(LOG_TAG, "onRequestPermissionsResult: requestCode: " + requestCode + ", permissions: " + Arrays.toString(permissions) + ", grantResults: " + Arrays.toString(grantResults));
if (requestCode == PermissionUtils.REQUEST_GRANT_STORAGE_PERMISSION) {
requestStoragePermission(true);
}
}
public int getNavBarHeight() {
return mNavBarHeight;
}
public TermuxActivityRootView getTermuxActivityRootView() {
return binding.activityTermuxRootView;
}
public View getTermuxActivityBottomSpaceView() {
return binding.activityTermuxBottomSpaceView;
}
public ExtraKeysView getExtraKeysView() {
return mExtraKeysView;
}
public TermuxTerminalExtraKeys getTermuxTerminalExtraKeys() {
return mTermuxTerminalExtraKeys;
}
public void setExtraKeysView(ExtraKeysView extraKeysView) {
mExtraKeysView = extraKeysView;
}
public DrawerLayout getDrawer() {
return (DrawerLayout) findViewById(R.id.drawer_layout);
}
public ViewPager getTerminalToolbarViewPager() {
return binding.terminalToolbarViewPager;
}
public float getTerminalToolbarDefaultHeight() {
return mTerminalToolbarDefaultHeight;
}
public boolean isTerminalViewSelected() {
return getTerminalToolbarViewPager().getCurrentItem() == 0;
}
public boolean isTerminalToolbarTextInputViewSelected() {
return getTerminalToolbarViewPager().getCurrentItem() == 1;
}
public void termuxSessionListNotifyUpdated() {
mTermuxSessionListViewController.notifyDataSetChanged();
}
public boolean isVisible() {
return mIsVisible;
}
public boolean isOnResumeAfterOnCreate() {
return mIsOnResumeAfterOnCreate;
}
public boolean isActivityRecreated() {
return mIsActivityRecreated;
}
public TermuxService getTermuxService() {
return mTermuxService;
}
public TerminalView getTerminalView() {
return binding.terminalView;
}
public TermuxTerminalViewClient getTermuxTerminalViewClient() {
return mTermuxTerminalViewClient;
}
public TermuxTerminalSessionActivityClient getTermuxTerminalSessionClient() {
return mTermuxTerminalSessionActivityClient;
}
@Nullable
public TerminalSession getCurrentSession() {
if (binding != null)
return binding.terminalView.getCurrentSession();
else
return null;
}
public TermuxAppSharedPreferences getPreferences() {
return mPreferences;
}
public TermuxAppSharedProperties getProperties() {
return mProperties;
}
public static void updateTermuxActivityStyling(Context context, boolean recreateActivity) {
// Make sure that terminal styling is always applied.
Intent stylingIntent = new Intent(TERMUX_ACTIVITY.ACTION_RELOAD_STYLE);
stylingIntent.putExtra(TERMUX_ACTIVITY.EXTRA_RECREATE_ACTIVITY, recreateActivity);
context.sendBroadcast(stylingIntent);
}
private void registerTermuxActivityBroadcastReceiver() {
IntentFilter intentFilter = new IntentFilter();
intentFilter.addAction(TERMUX_ACTIVITY.ACTION_NOTIFY_APP_CRASH);
intentFilter.addAction(TERMUX_ACTIVITY.ACTION_RELOAD_STYLE);
intentFilter.addAction(TERMUX_ACTIVITY.ACTION_REQUEST_PERMISSIONS);
registerReceiver(mTermuxActivityBroadcastReceiver, intentFilter);
}
private void unregisterTermuxActivityBroadcastReceiver() {
unregisterReceiver(mTermuxActivityBroadcastReceiver);
}
private void fixTermuxActivityBroadcastReceiverIntent(Intent intent) {
if (intent == null) return;
String extraReloadStyle = intent.getStringExtra(TERMUX_ACTIVITY.EXTRA_RELOAD_STYLE);
if ("storage".equals(extraReloadStyle)) {
intent.removeExtra(TERMUX_ACTIVITY.EXTRA_RELOAD_STYLE);
intent.setAction(TERMUX_ACTIVITY.ACTION_REQUEST_PERMISSIONS);
}
}
class TermuxActivityBroadcastReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
if (intent == null) return;
if (mIsVisible) {
fixTermuxActivityBroadcastReceiverIntent(intent);
switch (intent.getAction()) {
case TERMUX_ACTIVITY.ACTION_NOTIFY_APP_CRASH:
Logger.logDebug(LOG_TAG, "Received intent to notify app crash");
TermuxCrashUtils.notifyAppCrashFromCrashLogFile(context, LOG_TAG);
return;
case TERMUX_ACTIVITY.ACTION_RELOAD_STYLE:
Logger.logDebug(LOG_TAG, "Received intent to reload styling");
reloadActivityStyling(intent.getBooleanExtra(TERMUX_ACTIVITY.EXTRA_RECREATE_ACTIVITY, true));
return;
case TERMUX_ACTIVITY.ACTION_REQUEST_PERMISSIONS:
Logger.logDebug(LOG_TAG, "Received intent to request storage permissions");
requestStoragePermission(false);
return;
default:
}
}
}
}
private void reloadActivityStyling(boolean recreateActivity) {
if (mProperties != null) {
reloadProperties();
if (mExtraKeysView != null) {
mExtraKeysView.setButtonTextAllCaps(mProperties.shouldExtraKeysTextBeAllCaps());
mExtraKeysView.reload(mTermuxTerminalExtraKeys.getExtraKeysInfo(), mTerminalToolbarDefaultHeight);
}
// Update NightMode.APP_NIGHT_MODE
TermuxThemeUtils.setAppNightMode(mProperties.getNightMode());
}
setMargins();
setTerminalToolbarHeight();
FileReceiverActivity.updateFileReceiverActivityComponentsState(this);
if (mTermuxTerminalSessionActivityClient != null)
mTermuxTerminalSessionActivityClient.onReloadActivityStyling();
if (mTermuxTerminalViewClient != null)
mTermuxTerminalViewClient.onReloadActivityStyling();
// To change the activity and drawer theme, activity needs to be recreated.
// It will destroy the activity, including all stored variables and views, and onCreate()
// will be called again. Extra keys input text, terminal sessions and transcripts will be preserved.
if (recreateActivity) {
Logger.logDebug(LOG_TAG, "Recreating activity");
TermuxActivity.this.recreate();
}
}
public static void startTermuxActivity(@NonNull final Context context) {
ActivityUtils.startActivity(context, newInstance(context));
}
public static Intent newInstance(@NonNull final Context context) {
Intent intent = new Intent(context, TermuxActivity.class);
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
return intent;
}
}
@@ -0,0 +1,79 @@
package com.termux.app;
import android.content.Context;
import com.itsaky.androidide.app.BaseApplication;
import com.termux.shared.errors.Error;
import com.termux.shared.logger.Logger;
import com.termux.shared.termux.TermuxConstants;
import com.termux.shared.termux.crash.TermuxCrashUtils;
import com.termux.shared.termux.file.TermuxFileUtils;
import com.termux.shared.termux.settings.preferences.TermuxAppSharedPreferences;
import com.termux.shared.termux.settings.properties.TermuxAppSharedProperties;
import com.termux.shared.termux.shell.TermuxShellManager;
import com.termux.shared.termux.shell.am.TermuxAmSocketServer;
import com.termux.shared.termux.shell.command.environment.TermuxShellEnvironment;
import com.termux.shared.termux.theme.TermuxThemeUtils;
public class TermuxApplication extends BaseApplication {
private static final String LOG_TAG = "TermuxApplication";
public void onCreate() {
super.onCreate();
Context context = getApplicationContext();
// Set crash handler for the app
TermuxCrashUtils.setDefaultCrashHandler(this);
// Set log config for the app
setLogConfig(context);
Logger.logDebug("Starting Application");
// Init app wide SharedProperties loaded from termux.properties
TermuxAppSharedProperties properties = TermuxAppSharedProperties.init(context);
// Init app wide shell manager
TermuxShellManager shellManager = TermuxShellManager.init(context);
// Set NightMode.APP_NIGHT_MODE
TermuxThemeUtils.setAppNightMode(properties.getNightMode());
// Check and create termux files directory. If failed to access it like in case of secondary
// user or external sd card installation, then don't run files directory related code
Error error = TermuxFileUtils.isTermuxFilesDirectoryAccessible(this, true, true);
boolean isTermuxFilesDirectoryAccessible = error == null;
if (isTermuxFilesDirectoryAccessible) {
Logger.logInfo(LOG_TAG, "Termux files directory is accessible");
error = TermuxFileUtils.isAppsTermuxAppDirectoryAccessible(true, true);
if (error != null) {
Logger.logErrorExtended(LOG_TAG, "Create apps/termux-app directory failed\n" + error);
return;
}
// Setup termux-am-socket server
TermuxAmSocketServer.setupTermuxAmSocketServer(context);
} else {
Logger.logErrorExtended(LOG_TAG, "Termux files directory is not accessible\n" + error);
}
// Init TermuxShellEnvironment constants and caches after everything has been setup including termux-am-socket server
TermuxShellEnvironment.init(this);
if (isTermuxFilesDirectoryAccessible) {
TermuxShellEnvironment.writeEnvironmentToFile(this);
}
}
public static void setLogConfig(Context context) {
Logger.setDefaultLogTag(TermuxConstants.TERMUX_APP_NAME);
// Load the log level from shared preferences and set it to the {@link Logger.CURRENT_LOG_LEVEL}
TermuxAppSharedPreferences preferences = TermuxAppSharedPreferences.build(context);
if (preferences == null) return;
preferences.setLogLevel(null, preferences.getLogLevel());
}
}
@@ -0,0 +1,386 @@
package com.termux.app;
import android.app.Activity;
import android.app.AlertDialog;
import android.app.ProgressDialog;
import android.content.Context;
import android.os.Build;
import android.os.Environment;
import android.system.Os;
import android.util.Pair;
import android.view.WindowManager;
import com.termux.R;
import com.termux.shared.file.FileUtils;
import com.termux.shared.termux.crash.TermuxCrashUtils;
import com.termux.shared.termux.file.TermuxFileUtils;
import com.termux.shared.interact.MessageDialogUtils;
import com.termux.shared.logger.Logger;
import com.termux.shared.markdown.MarkdownUtils;
import com.termux.shared.errors.Error;
import com.termux.shared.android.PackageUtils;
import com.termux.shared.termux.TermuxConstants;
import com.termux.shared.termux.TermuxUtils;
import com.termux.shared.termux.shell.command.environment.TermuxShellEnvironment;
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.InputStreamReader;
import java.util.ArrayList;
import java.util.List;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import static com.termux.shared.termux.TermuxConstants.TERMUX_PREFIX_DIR;
import static com.termux.shared.termux.TermuxConstants.TERMUX_PREFIX_DIR_PATH;
import static com.termux.shared.termux.TermuxConstants.TERMUX_STAGING_PREFIX_DIR;
import static com.termux.shared.termux.TermuxConstants.TERMUX_STAGING_PREFIX_DIR_PATH;
/**
* Install the Termux bootstrap packages if necessary by following the below steps:
* <p/>
* (1) If $PREFIX already exist, assume that it is correct and be done. Note that this relies on that we do not create a
* broken $PREFIX directory below.
* <p/>
* (2) A progress dialog is shown with "Installing..." message and a spinner.
* <p/>
* (3) A staging directory, $STAGING_PREFIX, is cleared if left over from broken installation below.
* <p/>
* (4) The zip file is loaded from a shared library.
* <p/>
* (5) The zip, containing entries relative to the $PREFIX, is is downloaded and extracted by a zip input stream
* continuously encountering zip file entries:
* <p/>
* (5.1) If the zip entry encountered is SYMLINKS.txt, go through it and remember all symlinks to setup.
* <p/>
* (5.2) For every other zip entry, extract it into $STAGING_PREFIX and set execute permissions if necessary.
*/
public final class TermuxInstaller {
private static final String LOG_TAG = "TermuxInstaller";
/** Performs bootstrap setup if necessary. */
static void setupBootstrapIfNeeded(final Activity activity, final Runnable whenDone) {
String bootstrapErrorMessage;
Error filesDirectoryAccessibleError;
// This will also call Context.getFilesDir(), which should ensure that termux files directory
// is created if it does not already exist
filesDirectoryAccessibleError = TermuxFileUtils.isTermuxFilesDirectoryAccessible(activity, true, true);
boolean isFilesDirectoryAccessible = filesDirectoryAccessibleError == null;
// Termux can only be run as the primary user (device owner) since only that
// account has the expected file system paths. Verify that:
if (!PackageUtils.isCurrentUserThePrimaryUser(activity)) {
bootstrapErrorMessage = activity.getString(R.string.bootstrap_error_not_primary_user_message,
MarkdownUtils.getMarkdownCodeForString(TERMUX_PREFIX_DIR_PATH, false));
Logger.logError(LOG_TAG, "isFilesDirectoryAccessible: " + isFilesDirectoryAccessible);
Logger.logError(LOG_TAG, bootstrapErrorMessage);
sendBootstrapCrashReportNotification(activity, bootstrapErrorMessage);
MessageDialogUtils.exitAppWithErrorMessage(activity,
activity.getString(R.string.bootstrap_error_title),
bootstrapErrorMessage);
return;
}
if (!isFilesDirectoryAccessible) {
bootstrapErrorMessage = Error.getMinimalErrorString(filesDirectoryAccessibleError);
//noinspection SdCardPath
if (PackageUtils.isAppInstalledOnExternalStorage(activity) &&
!TermuxConstants.TERMUX_FILES_DIR_PATH.equals(activity.getFilesDir().getAbsolutePath().replaceAll("^/data/user/0/", "/data/data/"))) {
bootstrapErrorMessage += "\n\n" + activity.getString(R.string.bootstrap_error_installed_on_portable_sd,
MarkdownUtils.getMarkdownCodeForString(TERMUX_PREFIX_DIR_PATH, false));
}
Logger.logError(LOG_TAG, bootstrapErrorMessage);
sendBootstrapCrashReportNotification(activity, bootstrapErrorMessage);
MessageDialogUtils.showMessage(activity,
activity.getString(R.string.bootstrap_error_title),
bootstrapErrorMessage, null);
return;
}
// If prefix directory exists, even if its a symlink to a valid directory and symlink is not broken/dangling
if (FileUtils.directoryFileExists(TERMUX_PREFIX_DIR_PATH, true)) {
if (TermuxFileUtils.isTermuxPrefixDirectoryEmpty()) {
Logger.logInfo(LOG_TAG, "The termux prefix directory \"" + TERMUX_PREFIX_DIR_PATH + "\" exists but is empty or only contains specific unimportant files.");
} else {
whenDone.run();
return;
}
} else if (FileUtils.fileExists(TERMUX_PREFIX_DIR_PATH, false)) {
Logger.logInfo(LOG_TAG, "The termux prefix directory \"" + TERMUX_PREFIX_DIR_PATH + "\" does not exist but another file exists at its destination.");
}
final ProgressDialog progress = ProgressDialog.show(activity, null, activity.getString(R.string.bootstrap_installer_body), true, false);
new Thread() {
@Override
public void run() {
try {
Logger.logInfo(LOG_TAG, "Installing " + TermuxConstants.TERMUX_APP_NAME + " bootstrap packages.");
Error error;
// Delete prefix staging directory or any file at its destination
error = FileUtils.deleteFile("termux prefix staging directory", TERMUX_STAGING_PREFIX_DIR_PATH, true);
if (error != null) {
showBootstrapErrorDialog(activity, whenDone, Error.getErrorMarkdownString(error));
return;
}
// Delete prefix directory or any file at its destination
error = FileUtils.deleteFile("termux prefix directory", TERMUX_PREFIX_DIR_PATH, true);
if (error != null) {
showBootstrapErrorDialog(activity, whenDone, Error.getErrorMarkdownString(error));
return;
}
// Create prefix staging directory if it does not already exist and set required permissions
error = TermuxFileUtils.isTermuxPrefixStagingDirectoryAccessible(true, true);
if (error != null) {
showBootstrapErrorDialog(activity, whenDone, Error.getErrorMarkdownString(error));
return;
}
// Create prefix directory if it does not already exist and set required permissions
error = TermuxFileUtils.isTermuxPrefixDirectoryAccessible(true, true);
if (error != null) {
showBootstrapErrorDialog(activity, whenDone, Error.getErrorMarkdownString(error));
return;
}
Logger.logInfo(LOG_TAG, "Extracting bootstrap zip to prefix staging directory \"" + TERMUX_STAGING_PREFIX_DIR_PATH + "\".");
final byte[] buffer = new byte[8096];
final List<Pair<String, String>> symlinks = new ArrayList<>(50);
final byte[] zipBytes = loadZipBytes();
try (ZipInputStream zipInput = new ZipInputStream(new ByteArrayInputStream(zipBytes))) {
ZipEntry zipEntry;
while ((zipEntry = zipInput.getNextEntry()) != null) {
if (zipEntry.getName().equals("SYMLINKS.txt")) {
BufferedReader symlinksReader = new BufferedReader(new InputStreamReader(zipInput));
String line;
while ((line = symlinksReader.readLine()) != null) {
String[] parts = line.split("←");
if (parts.length != 2)
throw new RuntimeException("Malformed symlink line: " + line);
String oldPath = parts[0];
String newPath = TERMUX_STAGING_PREFIX_DIR_PATH + "/" + parts[1];
symlinks.add(Pair.create(oldPath, newPath));
error = ensureDirectoryExists(new File(newPath).getParentFile());
if (error != null) {
showBootstrapErrorDialog(activity, whenDone, Error.getErrorMarkdownString(error));
return;
}
}
} else {
String zipEntryName = zipEntry.getName();
File targetFile = new File(TERMUX_STAGING_PREFIX_DIR_PATH, zipEntryName);
boolean isDirectory = zipEntry.isDirectory();
error = ensureDirectoryExists(isDirectory ? targetFile : targetFile.getParentFile());
if (error != null) {
showBootstrapErrorDialog(activity, whenDone, Error.getErrorMarkdownString(error));
return;
}
if (!isDirectory) {
try (FileOutputStream outStream = new FileOutputStream(targetFile)) {
int readBytes;
while ((readBytes = zipInput.read(buffer)) != -1)
outStream.write(buffer, 0, readBytes);
}
if (zipEntryName.startsWith("bin/") || zipEntryName.startsWith("libexec") ||
zipEntryName.startsWith("lib/apt/apt-helper") || zipEntryName.startsWith("lib/apt/methods")) {
//noinspection OctalInteger
Os.chmod(targetFile.getAbsolutePath(), 0700);
}
}
}
}
}
if (symlinks.isEmpty())
throw new RuntimeException("No SYMLINKS.txt encountered");
for (Pair<String, String> symlink : symlinks) {
Os.symlink(symlink.first, symlink.second);
}
Logger.logInfo(LOG_TAG, "Moving termux prefix staging to prefix directory.");
if (!TERMUX_STAGING_PREFIX_DIR.renameTo(TERMUX_PREFIX_DIR)) {
throw new RuntimeException("Moving termux prefix staging to prefix directory failed");
}
Logger.logInfo(LOG_TAG, "Bootstrap packages installed successfully.");
// Recreate env file since termux prefix was wiped earlier
TermuxShellEnvironment.writeEnvironmentToFile(activity);
activity.runOnUiThread(whenDone);
} catch (final Exception e) {
showBootstrapErrorDialog(activity, whenDone, Logger.getStackTracesMarkdownString(null, Logger.getStackTracesStringArray(e)));
} finally {
activity.runOnUiThread(() -> {
try {
progress.dismiss();
} catch (RuntimeException e) {
// Activity already dismissed - ignore.
}
});
}
}
}.start();
}
public static void showBootstrapErrorDialog(Activity activity, Runnable whenDone, String message) {
Logger.logErrorExtended(LOG_TAG, "Bootstrap Error:\n" + message);
// Send a notification with the exception so that the user knows why bootstrap setup failed
sendBootstrapCrashReportNotification(activity, message);
activity.runOnUiThread(() -> {
try {
new AlertDialog.Builder(activity).setTitle(R.string.bootstrap_error_title).setMessage(R.string.bootstrap_error_body)
.setNegativeButton(R.string.bootstrap_error_abort, (dialog, which) -> {
dialog.dismiss();
activity.finish();
})
.setPositiveButton(R.string.bootstrap_error_try_again, (dialog, which) -> {
dialog.dismiss();
FileUtils.deleteFile("termux prefix directory", TERMUX_PREFIX_DIR_PATH, true);
TermuxInstaller.setupBootstrapIfNeeded(activity, whenDone);
}).show();
} catch (WindowManager.BadTokenException e1) {
// Activity already dismissed - ignore.
}
});
}
private static void sendBootstrapCrashReportNotification(Activity activity, String message) {
final String title = TermuxConstants.TERMUX_APP_NAME + " Bootstrap Error";
// Add info of all install Termux plugin apps as well since their target sdk or installation
// on external/portable sd card can affect Termux app files directory access or exec.
TermuxCrashUtils.sendCrashReportNotification(activity, LOG_TAG,
title, null, "## " + title + "\n\n" + message + "\n\n" +
TermuxUtils.getTermuxDebugMarkdownString(activity),
true, false, TermuxUtils.AppInfoMode.TERMUX_AND_PLUGIN_PACKAGES, true);
}
static void setupStorageSymlinks(final Context context) {
final String LOG_TAG = "termux-storage";
final String title = TermuxConstants.TERMUX_APP_NAME + " Setup Storage Error";
Logger.logInfo(LOG_TAG, "Setting up storage symlinks.");
new Thread() {
public void run() {
try {
Error error;
File storageDir = TermuxConstants.TERMUX_STORAGE_HOME_DIR;
error = FileUtils.clearDirectory("~/storage", storageDir.getAbsolutePath());
if (error != null) {
Logger.logErrorAndShowToast(context, LOG_TAG, error.getMessage());
Logger.logErrorExtended(LOG_TAG, "Setup Storage Error\n" + error.toString());
TermuxCrashUtils.sendCrashReportNotification(context, LOG_TAG, title, null,
"## " + title + "\n\n" + Error.getErrorMarkdownString(error),
true, false, TermuxUtils.AppInfoMode.TERMUX_PACKAGE, true);
return;
}
Logger.logInfo(LOG_TAG, "Setting up storage symlinks at ~/storage/shared, ~/storage/downloads, ~/storage/dcim, ~/storage/pictures, ~/storage/music and ~/storage/movies for directories in \"" + Environment.getExternalStorageDirectory().getAbsolutePath() + "\".");
// Get primary storage root "/storage/emulated/0" symlink
File sharedDir = Environment.getExternalStorageDirectory();
Os.symlink(sharedDir.getAbsolutePath(), new File(storageDir, "shared").getAbsolutePath());
File documentsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS);
Os.symlink(documentsDir.getAbsolutePath(), new File(storageDir, "documents").getAbsolutePath());
File downloadsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
Os.symlink(downloadsDir.getAbsolutePath(), new File(storageDir, "downloads").getAbsolutePath());
File dcimDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM);
Os.symlink(dcimDir.getAbsolutePath(), new File(storageDir, "dcim").getAbsolutePath());
File picturesDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES);
Os.symlink(picturesDir.getAbsolutePath(), new File(storageDir, "pictures").getAbsolutePath());
File musicDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC);
Os.symlink(musicDir.getAbsolutePath(), new File(storageDir, "music").getAbsolutePath());
File moviesDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES);
Os.symlink(moviesDir.getAbsolutePath(), new File(storageDir, "movies").getAbsolutePath());
File podcastsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PODCASTS);
Os.symlink(podcastsDir.getAbsolutePath(), new File(storageDir, "podcasts").getAbsolutePath());
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
File audiobooksDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_AUDIOBOOKS);
Os.symlink(audiobooksDir.getAbsolutePath(), new File(storageDir, "audiobooks").getAbsolutePath());
}
// Dir 0 should ideally be for primary storage
// https://cs.android.com/android/platform/superproject/+/android-12.0.0_r32:frameworks/base/core/java/android/app/ContextImpl.java;l=818
// https://cs.android.com/android/platform/superproject/+/android-12.0.0_r32:frameworks/base/core/java/android/os/Environment.java;l=219
// https://cs.android.com/android/platform/superproject/+/android-12.0.0_r32:frameworks/base/core/java/android/os/Environment.java;l=181
// https://cs.android.com/android/platform/superproject/+/android-12.0.0_r32:frameworks/base/services/core/java/com/android/server/StorageManagerService.java;l=3796
// https://cs.android.com/android/platform/superproject/+/android-7.0.0_r36:frameworks/base/services/core/java/com/android/server/MountService.java;l=3053
// Create "Android/data/com.termux" symlinks
File[] dirs = context.getExternalFilesDirs(null);
if (dirs != null && dirs.length > 0) {
for (int i = 0; i < dirs.length; i++) {
File dir = dirs[i];
if (dir == null) continue;
String symlinkName = "external-" + i;
Logger.logInfo(LOG_TAG, "Setting up storage symlinks at ~/storage/" + symlinkName + " for \"" + dir.getAbsolutePath() + "\".");
Os.symlink(dir.getAbsolutePath(), new File(storageDir, symlinkName).getAbsolutePath());
}
}
// Create "Android/media/com.termux" symlinks
dirs = context.getExternalMediaDirs();
if (dirs != null && dirs.length > 0) {
for (int i = 0; i < dirs.length; i++) {
File dir = dirs[i];
if (dir == null) continue;
String symlinkName = "media-" + i;
Logger.logInfo(LOG_TAG, "Setting up storage symlinks at ~/storage/" + symlinkName + " for \"" + dir.getAbsolutePath() + "\".");
Os.symlink(dir.getAbsolutePath(), new File(storageDir, symlinkName).getAbsolutePath());
}
}
Logger.logInfo(LOG_TAG, "Storage symlinks created successfully.");
} catch (Exception e) {
Logger.logErrorAndShowToast(context, LOG_TAG, e.getMessage());
Logger.logStackTraceWithMessage(LOG_TAG, "Setup Storage Error: Error setting up link", e);
TermuxCrashUtils.sendCrashReportNotification(context, LOG_TAG, title, null,
"## " + title + "\n\n" + Logger.getStackTracesMarkdownString(null, Logger.getStackTracesStringArray(e)),
true, false, TermuxUtils.AppInfoMode.TERMUX_PACKAGE, true);
}
}
}.start();
}
private static Error ensureDirectoryExists(File directory) {
return FileUtils.createDirectoryFile(directory.getAbsolutePath());
}
public static byte[] loadZipBytes() {
// Only load the shared library when necessary to save memory usage.
System.loadLibrary("termux-bootstrap");
return getZip();
}
public static native byte[] getZip();
}
@@ -0,0 +1,705 @@
package com.termux.app;
import android.annotation.SuppressLint;
import android.app.Notification;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.content.res.Resources;
import android.net.wifi.WifiManager;
import android.os.Binder;
import android.os.Build;
import android.os.Handler;
import android.os.IBinder;
import android.os.PowerManager;
import androidx.annotation.Nullable;
import com.termux.R;
import com.termux.app.terminal.TermuxTerminalSessionActivityClient;
import com.termux.app.terminal.TermuxTerminalSessionServiceClient;
import com.termux.shared.android.PermissionUtils;
import com.termux.shared.data.DataUtils;
import com.termux.shared.data.IntentUtils;
import com.termux.shared.logger.Logger;
import com.termux.shared.notification.NotificationUtils;
import com.termux.shared.shell.command.ExecutionCommand;
import com.termux.shared.shell.command.ExecutionCommand.Runner;
import com.termux.shared.shell.command.runner.app.AppShell;
import com.termux.shared.termux.TermuxConstants;
import com.termux.shared.termux.TermuxConstants.TERMUX_APP.TERMUX_SERVICE;
import com.termux.shared.termux.plugins.TermuxPluginUtils;
import com.termux.shared.termux.settings.properties.TermuxAppSharedProperties;
import com.termux.shared.termux.shell.TermuxShellManager;
import com.termux.shared.termux.shell.TermuxShellUtils;
import com.termux.shared.termux.shell.command.environment.TermuxShellEnvironment;
import com.termux.shared.termux.shell.command.runner.terminal.TermuxSession;
import com.termux.shared.termux.terminal.TermuxTerminalSessionClientBase;
import com.termux.terminal.TerminalEmulator;
import com.termux.terminal.TerminalSession;
import com.termux.terminal.TerminalSessionClient;
import java.util.ArrayList;
import java.util.List;
/**
* A service holding a list of {@link TermuxSession} in {@link TermuxShellManager#mTermuxSessions} and background {@link AppShell}
* in {@link TermuxShellManager#mTermuxTasks}, showing a foreground notification while running so that it is not terminated.
* The user interacts with the session through {@link TermuxActivity}, but this service may outlive
* the activity when the user or the system disposes of the activity. In that case the user may
* restart {@link TermuxActivity} later to yet again access the sessions.
* <p/>
* In order to keep both terminal sessions and spawned processes (who may outlive the terminal sessions) alive as long
* as wanted by the user this service is a foreground service, {@link Service#startForeground(int, Notification)}.
* <p/>
* Optionally may hold a wake and a wifi lock, in which case that is shown in the notification - see
* {@link #buildNotification()}.
*/
public final class TermuxService extends Service implements AppShell.AppShellClient, TermuxSession.TermuxSessionClient {
/** This service is only bound from inside the same process and never uses IPC. */
class LocalBinder extends Binder {
public final TermuxService service = TermuxService.this;
}
private final IBinder mBinder = new LocalBinder();
private final Handler mHandler = new Handler();
/** The full implementation of the {@link TerminalSessionClient} interface to be used by {@link TerminalSession}
* that holds activity references for activity related functions.
* Note that the service may often outlive the activity, so need to clear this reference.
*/
private TermuxTerminalSessionActivityClient mTermuxTerminalSessionActivityClient;
/** The basic implementation of the {@link TerminalSessionClient} interface to be used by {@link TerminalSession}
* that does not hold activity references and only a service reference.
*/
private final TermuxTerminalSessionServiceClient mTermuxTerminalSessionServiceClient = new TermuxTerminalSessionServiceClient(this);
/**
* Termux app shared properties manager, loaded from termux.properties
*/
private TermuxAppSharedProperties mProperties;
/**
* Termux app shell manager
*/
private TermuxShellManager mShellManager;
/** The wake lock and wifi lock are always acquired and released together. */
private PowerManager.WakeLock mWakeLock;
private WifiManager.WifiLock mWifiLock;
/** If the user has executed the {@link TERMUX_SERVICE#ACTION_STOP_SERVICE} intent. */
boolean mWantsToStop = false;
private static final String LOG_TAG = "TermuxService";
@Override
public void onCreate() {
Logger.logVerbose(LOG_TAG, "onCreate");
// Get Termux app SharedProperties without loading from disk since TermuxApplication handles
// load and TermuxActivity handles reloads
mProperties = TermuxAppSharedProperties.getProperties();
mShellManager = TermuxShellManager.getShellManager();
runStartForeground();
}
@SuppressLint("Wakelock")
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
Logger.logDebug(LOG_TAG, "onStartCommand");
// Run again in case service is already started and onCreate() is not called
runStartForeground();
String action = null;
if (intent != null) {
Logger.logVerboseExtended(LOG_TAG, "Intent Received:\n" + IntentUtils.getIntentString(intent));
action = intent.getAction();
}
if (action != null) {
switch (action) {
case TERMUX_SERVICE.ACTION_STOP_SERVICE:
Logger.logDebug(LOG_TAG, "ACTION_STOP_SERVICE intent received");
actionStopService();
break;
case TERMUX_SERVICE.ACTION_WAKE_LOCK:
Logger.logDebug(LOG_TAG, "ACTION_WAKE_LOCK intent received");
actionAcquireWakeLock();
break;
case TERMUX_SERVICE.ACTION_WAKE_UNLOCK:
Logger.logDebug(LOG_TAG, "ACTION_WAKE_UNLOCK intent received");
actionReleaseWakeLock(true);
break;
default:
Logger.logError(LOG_TAG, "Invalid action: \"" + action + "\"");
break;
}
}
// If this service really do get killed, there is no point restarting it automatically - let the user do on next
// start of {@link Term):
return Service.START_NOT_STICKY;
}
@Override
public void onDestroy() {
Logger.logVerbose(LOG_TAG, "onDestroy");
TermuxShellUtils.clearTermuxTMPDIR(true);
actionReleaseWakeLock(false);
if (!mWantsToStop)
killAllTermuxExecutionCommands();
TermuxShellManager.onAppExit(this);
runStopForeground();
}
@Override
public IBinder onBind(Intent intent) {
Logger.logVerbose(LOG_TAG, "onBind");
return mBinder;
}
@Override
public boolean onUnbind(Intent intent) {
Logger.logVerbose(LOG_TAG, "onUnbind");
// Since we cannot rely on {@link TermuxActivity.onDestroy()} to always complete,
// we unset clients here as well if it failed, so that we do not leave service and session
// clients with references to the activity.
if (mTermuxTerminalSessionActivityClient != null)
unsetTermuxTerminalSessionClient();
return false;
}
/** Make service run in foreground mode. */
private void runStartForeground() {
setupNotificationChannel();
startForeground(TermuxConstants.TERMUX_APP_NOTIFICATION_ID, buildNotification());
}
/** Make service leave foreground mode. */
private void runStopForeground() {
stopForeground(true);
}
/** Request to stop service. */
private void requestStopService() {
Logger.logDebug(LOG_TAG, "Requesting to stop service");
runStopForeground();
stopSelf();
}
/** Process action to stop service. */
private void actionStopService() {
mWantsToStop = true;
killAllTermuxExecutionCommands();
requestStopService();
}
/** Kill all TermuxSessions and TermuxTasks by sending SIGKILL to their processes.
*
* For TermuxSessions, all sessions will be killed, whether user manually exited Termux or if
* onDestroy() was directly called because of unintended shutdown. The processing of results
* will only be done if user manually exited termux or if the session was started by a plugin
* which **expects** the result back via a pending intent.
*
* For TermuxTasks, only tasks that were started by a plugin which **expects** the result
* back via a pending intent will be killed, whether user manually exited Termux or if
* onDestroy() was directly called because of unintended shutdown. The processing of results
* will always be done for the tasks that are killed. The remaining processes will keep on
* running until the termux app process is killed by android, like by OOM, so we let them run
* as long as they can.
*
* Some plugin execution commands may not have been processed and added to mTermuxSessions and
* mTermuxTasks lists before the service is killed, so we maintain a separate
* mPendingPluginExecutionCommands list for those, so that we can notify the pending intent
* creators that execution was cancelled.
*
* Note that if user didn't manually exit Termux and if onDestroy() was directly called because
* of unintended shutdown, like android deciding to kill the service, then there will be no
* guarantee that onDestroy() will be allowed to finish and termux app process may be killed before
* it has finished. This means that in those cases some results may not be sent back to their
* creators for plugin commands but we still try to process whatever results can be processed
* despite the unreliable behaviour of onDestroy().
*
* Note that if don't kill the processes started by plugins which **expect** the result back
* and notify their creators that they have been killed, then they may get stuck waiting for
* the results forever like in case of commands started by Termux:Tasker or RUN_COMMAND intent,
* since once TermuxService has been killed, no result will be sent back. They may still get
* stuck if termux app process gets killed, so for this case reasonable timeout values should
* be used, like in Tasker for the Termux:Tasker actions.
*
* We make copies of each list since items are removed inside the loop.
*/
private synchronized void killAllTermuxExecutionCommands() {
boolean processResult;
Logger.logDebug(LOG_TAG, "Killing TermuxSessions=" + mShellManager.mTermuxSessions.size() +
", TermuxTasks=" + mShellManager.mTermuxTasks.size());
List<TermuxSession> termuxSessions = new ArrayList<>(mShellManager.mTermuxSessions);
List<AppShell> termuxTasks = new ArrayList<>(mShellManager.mTermuxTasks);
for (int i = 0; i < termuxSessions.size(); i++) {
ExecutionCommand executionCommand = termuxSessions.get(i).getExecutionCommand();
processResult = mWantsToStop || executionCommand.isPluginExecutionCommandWithPendingResult();
termuxSessions.get(i).killIfExecuting(this, processResult);
if (!processResult)
mShellManager.mTermuxSessions.remove(termuxSessions.get(i));
}
for (int i = 0; i < termuxTasks.size(); i++) {
ExecutionCommand executionCommand = termuxTasks.get(i).getExecutionCommand();
if (executionCommand.isPluginExecutionCommandWithPendingResult())
termuxTasks.get(i).killIfExecuting(this, true);
else
mShellManager.mTermuxTasks.remove(termuxTasks.get(i));
}
}
/** Process action to acquire Power and Wi-Fi WakeLocks. */
@SuppressLint({"WakelockTimeout", "BatteryLife"})
private void actionAcquireWakeLock() {
if (mWakeLock != null) {
Logger.logDebug(LOG_TAG, "Ignoring acquiring WakeLocks since they are already held");
return;
}
Logger.logDebug(LOG_TAG, "Acquiring WakeLocks");
PowerManager pm = (PowerManager) getSystemService(Context.POWER_SERVICE);
mWakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, TermuxConstants.TERMUX_APP_NAME.toLowerCase() + ":service-wakelock");
mWakeLock.acquire();
// http://tools.android.com/tech-docs/lint-in-studio-2-3#TOC-WifiManager-Leak
WifiManager wm = (WifiManager) getApplicationContext().getSystemService(Context.WIFI_SERVICE);
mWifiLock = wm.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, TermuxConstants.TERMUX_APP_NAME.toLowerCase());
mWifiLock.acquire();
if (!PermissionUtils.checkIfBatteryOptimizationsDisabled(this)) {
PermissionUtils.requestDisableBatteryOptimizations(this);
}
updateNotification();
Logger.logDebug(LOG_TAG, "WakeLocks acquired successfully");
}
/** Process action to release Power and Wi-Fi WakeLocks. */
private void actionReleaseWakeLock(boolean updateNotification) {
if (mWakeLock == null && mWifiLock == null) {
Logger.logDebug(LOG_TAG, "Ignoring releasing WakeLocks since none are already held");
return;
}
Logger.logDebug(LOG_TAG, "Releasing WakeLocks");
if (mWakeLock != null) {
mWakeLock.release();
mWakeLock = null;
}
if (mWifiLock != null) {
mWifiLock.release();
mWifiLock = null;
}
if (updateNotification)
updateNotification();
Logger.logDebug(LOG_TAG, "WakeLocks released successfully");
}
/** Create a TermuxTask. */
@Nullable
public AppShell createTermuxTask(String executablePath, String[] arguments, String stdin, String workingDirectory) {
return createTermuxTask(new ExecutionCommand(TermuxShellManager.getNextShellId(), executablePath,
arguments, stdin, workingDirectory, Runner.APP_SHELL.getName(), false));
}
/** Create a TermuxTask. */
@Nullable
public synchronized AppShell createTermuxTask(ExecutionCommand executionCommand) {
if (executionCommand == null) return null;
Logger.logDebug(LOG_TAG, "Creating \"" + executionCommand.getCommandIdAndLabelLogString() + "\" TermuxTask");
if (!Runner.APP_SHELL.equalsRunner(executionCommand.runner)) {
Logger.logDebug(LOG_TAG, "Ignoring wrong runner \"" + executionCommand.runner + "\" command passed to createTermuxTask()");
return null;
}
executionCommand.setShellCommandShellEnvironment = true;
if (Logger.getLogLevel() >= Logger.LOG_LEVEL_VERBOSE)
Logger.logVerboseExtended(LOG_TAG, executionCommand.toString());
AppShell newTermuxTask = AppShell.execute(this, executionCommand, this,
new TermuxShellEnvironment(), null,false);
if (newTermuxTask == null) {
Logger.logError(LOG_TAG, "Failed to execute new TermuxTask command for:\n" + executionCommand.getCommandIdAndLabelLogString());
// If the execution command was started for a plugin, then process the error
if (executionCommand.isPluginExecutionCommand)
TermuxPluginUtils.processPluginExecutionCommandError(this, LOG_TAG, executionCommand, false);
else {
Logger.logError(LOG_TAG, "Set log level to debug or higher to see error in logs");
Logger.logErrorPrivateExtended(LOG_TAG, executionCommand.toString());
}
return null;
}
mShellManager.mTermuxTasks.add(newTermuxTask);
updateNotification();
return newTermuxTask;
}
/** Callback received when a TermuxTask finishes. */
@Override
public void onAppShellExited(final AppShell termuxTask) {
mHandler.post(() -> {
if (termuxTask != null) {
ExecutionCommand executionCommand = termuxTask.getExecutionCommand();
Logger.logVerbose(LOG_TAG, "The onTermuxTaskExited() callback called for \"" + executionCommand.getCommandIdAndLabelLogString() + "\" TermuxTask command");
// If the execution command was started for a plugin, then process the results
if (executionCommand != null && executionCommand.isPluginExecutionCommand)
TermuxPluginUtils.processPluginExecutionCommandResult(this, LOG_TAG, executionCommand);
mShellManager.mTermuxTasks.remove(termuxTask);
}
updateNotification();
});
}
/**
* Create a {@link TermuxSession}.
* Currently called by {@link TermuxTerminalSessionActivityClient#addNewSession(boolean, String)} to add a new {@link TermuxSession}.
*/
@Nullable
public TermuxSession createTermuxSession(String executablePath, String[] arguments, String stdin,
String workingDirectory, boolean isFailSafe, String sessionName) {
ExecutionCommand executionCommand = new ExecutionCommand(TermuxShellManager.getNextShellId(),
executablePath, arguments, stdin, workingDirectory, Runner.TERMINAL_SESSION.getName(), isFailSafe);
executionCommand.shellName = sessionName;
return createTermuxSession(executionCommand);
}
/** Create a {@link TermuxSession}. */
@Nullable
public synchronized TermuxSession createTermuxSession(ExecutionCommand executionCommand) {
if (executionCommand == null) return null;
Logger.logDebug(LOG_TAG, "Creating \"" + executionCommand.getCommandIdAndLabelLogString() + "\" TermuxSession");
if (!Runner.TERMINAL_SESSION.equalsRunner(executionCommand.runner)) {
Logger.logDebug(LOG_TAG, "Ignoring wrong runner \"" + executionCommand.runner + "\" command passed to createTermuxSession()");
return null;
}
executionCommand.setShellCommandShellEnvironment = true;
executionCommand.terminalTranscriptRows = mProperties.getTerminalTranscriptRows();
if (Logger.getLogLevel() >= Logger.LOG_LEVEL_VERBOSE)
Logger.logVerboseExtended(LOG_TAG, executionCommand.toString());
// If the execution command was started for a plugin, only then will the stdout be set
// Otherwise if command was manually started by the user like by adding a new terminal session,
// then no need to set stdout
TermuxSession newTermuxSession = TermuxSession.execute(this, executionCommand, getTermuxTerminalSessionClient(),
this, new TermuxShellEnvironment(), null, executionCommand.isPluginExecutionCommand);
if (newTermuxSession == null) {
Logger.logError(LOG_TAG, "Failed to execute new TermuxSession command for:\n" + executionCommand.getCommandIdAndLabelLogString());
// If the execution command was started for a plugin, then process the error
if (executionCommand.isPluginExecutionCommand)
TermuxPluginUtils.processPluginExecutionCommandError(this, LOG_TAG, executionCommand, false);
else {
Logger.logError(LOG_TAG, "Set log level to debug or higher to see error in logs");
Logger.logErrorPrivateExtended(LOG_TAG, executionCommand.toString());
}
return null;
}
mShellManager.mTermuxSessions.add(newTermuxSession);
// Notify {@link TermuxSessionsListViewController} that sessions list has been updated if
// activity in is foreground
if (mTermuxTerminalSessionActivityClient != null)
mTermuxTerminalSessionActivityClient.termuxSessionListNotifyUpdated();
updateNotification();
// No need to recreate the activity since it likely just started and theme should already have applied
TermuxActivity.updateTermuxActivityStyling(this, false);
return newTermuxSession;
}
/** Remove a TermuxSession. */
public synchronized int removeTermuxSession(TerminalSession sessionToRemove) {
int index = getIndexOfSession(sessionToRemove);
if (index >= 0)
mShellManager.mTermuxSessions.get(index).finish();
return index;
}
/** Callback received when a {@link TermuxSession} finishes. */
@Override
public void onTermuxSessionExited(final TermuxSession termuxSession) {
if (termuxSession != null) {
ExecutionCommand executionCommand = termuxSession.getExecutionCommand();
Logger.logVerbose(LOG_TAG, "The onTermuxSessionExited() callback called for \"" + executionCommand.getCommandIdAndLabelLogString() + "\" TermuxSession command");
// If the execution command was started for a plugin, then process the results
if (executionCommand != null && executionCommand.isPluginExecutionCommand)
TermuxPluginUtils.processPluginExecutionCommandResult(this, LOG_TAG, executionCommand);
mShellManager.mTermuxSessions.remove(termuxSession);
// Notify {@link TermuxSessionsListViewController} that sessions list has been updated if
// activity in is foreground
if (mTermuxTerminalSessionActivityClient != null)
mTermuxTerminalSessionActivityClient.termuxSessionListNotifyUpdated();
}
updateNotification();
}
/** If {@link TermuxActivity} has not bound to the {@link TermuxService} yet or is destroyed, then
* interface functions requiring the activity should not be available to the terminal sessions,
* so we just return the {@link #mTermuxTerminalSessionServiceClient}. Once {@link TermuxActivity} bind
* callback is received, it should call {@link #setTermuxTerminalSessionClient} to set the
* {@link TermuxService#mTermuxTerminalSessionActivityClient} so that further terminal sessions are directly
* passed the {@link TermuxTerminalSessionActivityClient} object which fully implements the
* {@link TerminalSessionClient} interface.
*
* @return Returns the {@link TermuxTerminalSessionActivityClient} if {@link TermuxActivity} has bound with
* {@link TermuxService}, otherwise {@link TermuxTerminalSessionServiceClient}.
*/
public synchronized TermuxTerminalSessionClientBase getTermuxTerminalSessionClient() {
if (mTermuxTerminalSessionActivityClient != null)
return mTermuxTerminalSessionActivityClient;
else
return mTermuxTerminalSessionServiceClient;
}
/** This should be called when {@link TermuxActivity#onServiceConnected} is called to set the
* {@link TermuxService#mTermuxTerminalSessionActivityClient} variable and update the {@link TerminalSession}
* and {@link TerminalEmulator} clients in case they were passed {@link TermuxTerminalSessionServiceClient}
* earlier.
*
* @param termuxTerminalSessionActivityClient The {@link TermuxTerminalSessionActivityClient} object that fully
* implements the {@link TerminalSessionClient} interface.
*/
public synchronized void setTermuxTerminalSessionClient(TermuxTerminalSessionActivityClient termuxTerminalSessionActivityClient) {
mTermuxTerminalSessionActivityClient = termuxTerminalSessionActivityClient;
for (int i = 0; i < mShellManager.mTermuxSessions.size(); i++)
mShellManager.mTermuxSessions.get(i).getTerminalSession().updateTerminalSessionClient(mTermuxTerminalSessionActivityClient);
}
/** This should be called when {@link TermuxActivity} has been destroyed and in {@link #onUnbind(Intent)}
* so that the {@link TermuxService} and {@link TerminalSession} and {@link TerminalEmulator}
* clients do not hold an activity references.
*/
public synchronized void unsetTermuxTerminalSessionClient() {
for (int i = 0; i < mShellManager.mTermuxSessions.size(); i++)
mShellManager.mTermuxSessions.get(i).getTerminalSession().updateTerminalSessionClient(mTermuxTerminalSessionServiceClient);
mTermuxTerminalSessionActivityClient = null;
}
private Notification buildNotification() {
Resources res = getResources();
// Set pending intent to be launched when notification is clicked
Intent notificationIntent = TermuxActivity.newInstance(this);
PendingIntent contentIntent = PendingIntent.getActivity(this, 0, notificationIntent, 0);
// Set notification text
int sessionCount = getTermuxSessionsSize();
int taskCount = mShellManager.mTermuxTasks.size();
String notificationText = sessionCount + " session" + (sessionCount == 1 ? "" : "s");
if (taskCount > 0) {
notificationText += ", " + taskCount + " task" + (taskCount == 1 ? "" : "s");
}
final boolean wakeLockHeld = mWakeLock != null;
if (wakeLockHeld) notificationText += " (wake lock held)";
// Set notification priority
// If holding a wake or wifi lock consider the notification of high priority since it's using power,
// otherwise use a low priority
int priority = (wakeLockHeld) ? Notification.PRIORITY_HIGH : Notification.PRIORITY_LOW;
// Build the notification
Notification.Builder builder = NotificationUtils.geNotificationBuilder(this,
TermuxConstants.TERMUX_APP_NOTIFICATION_CHANNEL_ID, priority,
TermuxConstants.TERMUX_APP_NAME, notificationText, null,
contentIntent, null, NotificationUtils.NOTIFICATION_MODE_SILENT);
if (builder == null) return null;
// No need to show a timestamp:
builder.setShowWhen(false);
// Set notification icon
builder.setSmallIcon(R.drawable.ic_service_notification);
// Set background color for small notification icon
builder.setColor(0xFF607D8B);
// TermuxSessions are always ongoing
builder.setOngoing(true);
// Set Exit button action
Intent exitIntent = new Intent(this, TermuxService.class).setAction(TERMUX_SERVICE.ACTION_STOP_SERVICE);
builder.addAction(android.R.drawable.ic_delete, res.getString(R.string.notification_action_exit), PendingIntent.getService(this, 0, exitIntent, 0));
// Set Wakelock button actions
String newWakeAction = wakeLockHeld ? TERMUX_SERVICE.ACTION_WAKE_UNLOCK : TERMUX_SERVICE.ACTION_WAKE_LOCK;
Intent toggleWakeLockIntent = new Intent(this, TermuxService.class).setAction(newWakeAction);
String actionTitle = res.getString(wakeLockHeld ? R.string.notification_action_wake_unlock : R.string.notification_action_wake_lock);
int actionIcon = wakeLockHeld ? android.R.drawable.ic_lock_idle_lock : android.R.drawable.ic_lock_lock;
builder.addAction(actionIcon, actionTitle, PendingIntent.getService(this, 0, toggleWakeLockIntent, 0));
return builder.build();
}
private void setupNotificationChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return;
NotificationUtils.setupNotificationChannel(this, TermuxConstants.TERMUX_APP_NOTIFICATION_CHANNEL_ID,
TermuxConstants.TERMUX_APP_NOTIFICATION_CHANNEL_NAME, NotificationManager.IMPORTANCE_LOW);
}
/** Update the shown foreground service notification after making any changes that affect it. */
private synchronized void updateNotification() {
if (mWakeLock == null && mShellManager.mTermuxSessions.isEmpty() && mShellManager.mTermuxTasks.isEmpty()) {
// Exit if we are updating after the user disabled all locks with no sessions or tasks running.
requestStopService();
} else {
((NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE)).notify(TermuxConstants.TERMUX_APP_NOTIFICATION_ID, buildNotification());
}
}
public synchronized boolean isTermuxSessionsEmpty() {
return mShellManager.mTermuxSessions.isEmpty();
}
public synchronized int getTermuxSessionsSize() {
return mShellManager.mTermuxSessions.size();
}
public synchronized List<TermuxSession> getTermuxSessions() {
return mShellManager.mTermuxSessions;
}
@Nullable
public synchronized TermuxSession getTermuxSession(int index) {
if (index >= 0 && index < mShellManager.mTermuxSessions.size())
return mShellManager.mTermuxSessions.get(index);
else
return null;
}
@Nullable
public synchronized TermuxSession getTermuxSessionForTerminalSession(TerminalSession terminalSession) {
if (terminalSession == null) return null;
for (int i = 0; i < mShellManager.mTermuxSessions.size(); i++) {
if (mShellManager.mTermuxSessions.get(i).getTerminalSession().equals(terminalSession))
return mShellManager.mTermuxSessions.get(i);
}
return null;
}
public synchronized TermuxSession getLastTermuxSession() {
return mShellManager.mTermuxSessions.isEmpty() ? null : mShellManager.mTermuxSessions.get(mShellManager.mTermuxSessions.size() - 1);
}
public synchronized int getIndexOfSession(TerminalSession terminalSession) {
if (terminalSession == null) return -1;
for (int i = 0; i < mShellManager.mTermuxSessions.size(); i++) {
if (mShellManager.mTermuxSessions.get(i).getTerminalSession().equals(terminalSession))
return i;
}
return -1;
}
public synchronized TerminalSession getTerminalSessionForHandle(String sessionHandle) {
TerminalSession terminalSession;
for (int i = 0, len = mShellManager.mTermuxSessions.size(); i < len; i++) {
terminalSession = mShellManager.mTermuxSessions.get(i).getTerminalSession();
if (terminalSession.mHandle.equals(sessionHandle))
return terminalSession;
}
return null;
}
public synchronized AppShell getTermuxTaskForShellName(String name) {
if (DataUtils.isNullOrEmpty(name)) return null;
AppShell appShell;
for (int i = 0, len = mShellManager.mTermuxTasks.size(); i < len; i++) {
appShell = mShellManager.mTermuxTasks.get(i);
String shellName = appShell.getExecutionCommand().shellName;
if (shellName != null && shellName.equals(name))
return appShell;
}
return null;
}
public synchronized TermuxSession getTermuxSessionForShellName(String name) {
if (DataUtils.isNullOrEmpty(name)) return null;
TermuxSession termuxSession;
for (int i = 0, len = mShellManager.mTermuxSessions.size(); i < len; i++) {
termuxSession = mShellManager.mTermuxSessions.get(i);
String shellName = termuxSession.getExecutionCommand().shellName;
if (shellName != null && shellName.equals(name))
return termuxSession;
}
return null;
}
public boolean wantsToStop() {
return mWantsToStop;
}
}
@@ -0,0 +1,285 @@
package com.termux.app.api.file;
import android.content.Context;
import android.content.Intent;
import android.database.Cursor;
import android.net.Uri;
import android.provider.OpenableColumns;
import android.util.Patterns;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import com.termux.R;
import com.termux.shared.android.PackageUtils;
import com.termux.shared.data.DataUtils;
import com.termux.shared.data.IntentUtils;
import com.termux.shared.net.uri.UriUtils;
import com.termux.shared.interact.MessageDialogUtils;
import com.termux.shared.net.uri.UriScheme;
import com.termux.shared.termux.interact.TextInputDialogUtils;
import com.termux.shared.termux.TermuxConstants;
import com.termux.shared.termux.TermuxConstants.TERMUX_APP;
import com.termux.shared.termux.TermuxConstants.TERMUX_APP.TERMUX_SERVICE;
import com.termux.app.TermuxService;
import com.termux.shared.logger.Logger;
import com.termux.shared.termux.settings.properties.TermuxAppSharedProperties;
import com.termux.shared.termux.settings.properties.TermuxPropertyConstants;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.regex.Pattern;
public class FileReceiverActivity extends AppCompatActivity {
static final String TERMUX_RECEIVEDIR = TermuxConstants.TERMUX_FILES_DIR_PATH + "/home/downloads";
static final String EDITOR_PROGRAM = TermuxConstants.TERMUX_HOME_DIR_PATH + "/bin/termux-file-editor";
static final String URL_OPENER_PROGRAM = TermuxConstants.TERMUX_HOME_DIR_PATH + "/bin/termux-url-opener";
/**
* If the activity should be finished when the name input dialog is dismissed. This is disabled
* before showing an error dialog, since the act of showing the error dialog will cause the
* name input dialog to be implicitly dismissed, and we do not want to finish the activity directly
* when showing the error dialog.
*/
boolean mFinishOnDismissNameDialog = true;
private static final String API_TAG = TermuxConstants.TERMUX_APP_NAME + "FileReceiver";
private static final String LOG_TAG = "FileReceiverActivity";
static boolean isSharedTextAnUrl(String sharedText) {
return Patterns.WEB_URL.matcher(sharedText).matches()
|| Pattern.matches("magnet:\\?xt=urn:btih:.*?", sharedText);
}
@Override
protected void onResume() {
super.onResume();
final Intent intent = getIntent();
final String action = intent.getAction();
final String type = intent.getType();
final String scheme = intent.getScheme();
Logger.logVerbose(LOG_TAG, "Intent Received:\n" + IntentUtils.getIntentString(intent));
final String sharedTitle = IntentUtils.getStringExtraIfSet(intent, Intent.EXTRA_TITLE, null);
if (Intent.ACTION_SEND.equals(action) && type != null) {
final String sharedText = intent.getStringExtra(Intent.EXTRA_TEXT);
final Uri sharedUri = intent.getParcelableExtra(Intent.EXTRA_STREAM);
if (sharedUri != null) {
handleContentUri(sharedUri, sharedTitle);
} else if (sharedText != null) {
if (isSharedTextAnUrl(sharedText)) {
handleUrlAndFinish(sharedText);
} else {
String subject = IntentUtils.getStringExtraIfSet(intent, Intent.EXTRA_SUBJECT, null);
if (subject == null) subject = sharedTitle;
if (subject != null) subject += ".txt";
promptNameAndSave(new ByteArrayInputStream(sharedText.getBytes(StandardCharsets.UTF_8)), subject);
}
} else {
showErrorDialogAndQuit("Send action without content - nothing to save.");
}
} else {
Uri dataUri = intent.getData();
if (dataUri == null) {
showErrorDialogAndQuit("Data uri not passed.");
return;
}
if (UriScheme.SCHEME_CONTENT.equals(scheme)) {
handleContentUri(dataUri, sharedTitle);
} else if (UriScheme.SCHEME_FILE.equals(scheme)) {
Logger.logVerbose(LOG_TAG, "uri: \"" + dataUri + "\", path: \"" + dataUri.getPath() + "\", fragment: \"" + dataUri.getFragment() + "\"");
// Get full path including fragment (anything after last "#")
String path = UriUtils.getUriFilePathWithFragment(dataUri);
if (DataUtils.isNullOrEmpty(path)) {
showErrorDialogAndQuit("File path from data uri is null, empty or invalid.");
return;
}
File file = new File(path);
try {
FileInputStream in = new FileInputStream(file);
promptNameAndSave(in, file.getName());
} catch (FileNotFoundException e) {
showErrorDialogAndQuit("Cannot open file: " + e.getMessage() + ".");
}
} else {
showErrorDialogAndQuit("Unable to receive any file or URL.");
}
}
}
void showErrorDialogAndQuit(String message) {
mFinishOnDismissNameDialog = false;
MessageDialogUtils.showMessage(this,
API_TAG, message,
null, (dialog, which) -> finish(),
null, null,
dialog -> finish());
}
void handleContentUri(@NonNull final Uri uri, String subjectFromIntent) {
try {
Logger.logVerbose(LOG_TAG, "uri: \"" + uri + "\", path: \"" + uri.getPath() + "\", fragment: \"" + uri.getFragment() + "\"");
String attachmentFileName = null;
String[] projection = new String[]{OpenableColumns.DISPLAY_NAME};
try (Cursor c = getContentResolver().query(uri, projection, null, null, null)) {
if (c != null && c.moveToFirst()) {
final int fileNameColumnId = c.getColumnIndex(OpenableColumns.DISPLAY_NAME);
if (fileNameColumnId >= 0) attachmentFileName = c.getString(fileNameColumnId);
}
}
if (attachmentFileName == null) attachmentFileName = subjectFromIntent;
if (attachmentFileName == null) attachmentFileName = UriUtils.getUriFileBasename(uri, true);
InputStream in = getContentResolver().openInputStream(uri);
promptNameAndSave(in, attachmentFileName);
} catch (Exception e) {
showErrorDialogAndQuit("Unable to handle shared content:\n\n" + e.getMessage());
Logger.logStackTraceWithMessage(LOG_TAG, "handleContentUri(uri=" + uri + ") failed", e);
}
}
void promptNameAndSave(final InputStream in, final String attachmentFileName) {
TextInputDialogUtils.textInput(this, R.string.title_file_received, attachmentFileName,
R.string.action_file_received_edit, text -> {
File outFile = saveStreamWithName(in, text);
if (outFile == null) return;
final File editorProgramFile = new File(EDITOR_PROGRAM);
if (!editorProgramFile.isFile()) {
showErrorDialogAndQuit("The following file does not exist:\n$HOME/bin/termux-file-editor\n\n"
+ "Create this file as a script or a symlink - it will be called with the received file as only argument.");
return;
}
// Do this for the user if necessary:
//noinspection ResultOfMethodCallIgnored
editorProgramFile.setExecutable(true);
final Uri scriptUri = UriUtils.getFileUri(EDITOR_PROGRAM);
Intent executeIntent = new Intent(TERMUX_SERVICE.ACTION_SERVICE_EXECUTE, scriptUri);
executeIntent.setClass(FileReceiverActivity.this, TermuxService.class);
executeIntent.putExtra(TERMUX_SERVICE.EXTRA_ARGUMENTS, new String[]{outFile.getAbsolutePath()});
startService(executeIntent);
finish();
},
R.string.action_file_received_open_directory, text -> {
if (saveStreamWithName(in, text) == null) return;
Intent executeIntent = new Intent(TERMUX_SERVICE.ACTION_SERVICE_EXECUTE);
executeIntent.putExtra(TERMUX_SERVICE.EXTRA_WORKDIR, TERMUX_RECEIVEDIR);
executeIntent.setClass(FileReceiverActivity.this, TermuxService.class);
startService(executeIntent);
finish();
},
android.R.string.cancel, text -> finish(), dialog -> {
if (mFinishOnDismissNameDialog) finish();
});
}
public File saveStreamWithName(InputStream in, String attachmentFileName) {
File receiveDir = new File(TERMUX_RECEIVEDIR);
if (DataUtils.isNullOrEmpty(attachmentFileName)) {
showErrorDialogAndQuit("File name cannot be null or empty");
return null;
}
if (!receiveDir.isDirectory() && !receiveDir.mkdirs()) {
showErrorDialogAndQuit("Cannot create directory: " + receiveDir.getAbsolutePath());
return null;
}
try {
final File outFile = new File(receiveDir, attachmentFileName);
try (FileOutputStream f = new FileOutputStream(outFile)) {
byte[] buffer = new byte[4096];
int readBytes;
while ((readBytes = in.read(buffer)) > 0) {
f.write(buffer, 0, readBytes);
}
}
return outFile;
} catch (IOException e) {
showErrorDialogAndQuit("Error saving file:\n\n" + e);
Logger.logStackTraceWithMessage(LOG_TAG, "Error saving file", e);
return null;
}
}
void handleUrlAndFinish(final String url) {
final File urlOpenerProgramFile = new File(URL_OPENER_PROGRAM);
if (!urlOpenerProgramFile.isFile()) {
showErrorDialogAndQuit("The following file does not exist:\n$HOME/bin/termux-url-opener\n\n"
+ "Create this file as a script or a symlink - it will be called with the shared URL as the first argument.");
return;
}
// Do this for the user if necessary:
//noinspection ResultOfMethodCallIgnored
urlOpenerProgramFile.setExecutable(true);
final Uri urlOpenerProgramUri = UriUtils.getFileUri(URL_OPENER_PROGRAM);
Intent executeIntent = new Intent(TERMUX_SERVICE.ACTION_SERVICE_EXECUTE, urlOpenerProgramUri);
executeIntent.setClass(FileReceiverActivity.this, TermuxService.class);
executeIntent.putExtra(TERMUX_SERVICE.EXTRA_ARGUMENTS, new String[]{url});
startService(executeIntent);
finish();
}
/**
* Update {@link TERMUX_APP#FILE_SHARE_RECEIVER_ACTIVITY_CLASS_NAME} component state depending on
* {@link TermuxPropertyConstants#KEY_DISABLE_FILE_SHARE_RECEIVER} value and
* {@link TERMUX_APP#FILE_VIEW_RECEIVER_ACTIVITY_CLASS_NAME} component state depending on
* {@link TermuxPropertyConstants#KEY_DISABLE_FILE_VIEW_RECEIVER} value.
*/
public static void updateFileReceiverActivityComponentsState(@NonNull Context context) {
new Thread() {
@Override
public void run() {
TermuxAppSharedProperties properties = TermuxAppSharedProperties.getProperties();
String errmsg;
boolean state;
state = !properties.isFileShareReceiverDisabled();
Logger.logVerbose(LOG_TAG, "Setting " + TERMUX_APP.FILE_SHARE_RECEIVER_ACTIVITY_CLASS_NAME + " component state to " + state);
errmsg = PackageUtils.setComponentState(context,TermuxConstants.TERMUX_PACKAGE_NAME,
TERMUX_APP.FILE_SHARE_RECEIVER_ACTIVITY_CLASS_NAME,
state, null, false, false);
if (errmsg != null)
Logger.logError(LOG_TAG, errmsg);
state = !properties.isFileViewReceiverDisabled();
Logger.logVerbose(LOG_TAG, "Setting " + TERMUX_APP.FILE_VIEW_RECEIVER_ACTIVITY_CLASS_NAME + " component state to " + state);
errmsg = PackageUtils.setComponentState(context,TermuxConstants.TERMUX_PACKAGE_NAME,
TERMUX_APP.FILE_VIEW_RECEIVER_ACTIVITY_CLASS_NAME,
state, null, false, false);
if (errmsg != null)
Logger.logError(LOG_TAG, errmsg);
}
}.start();
}
}
@@ -0,0 +1,16 @@
package com.termux.app.models;
public enum UserAction {
REPORT_ISSUE_FROM_TRANSCRIPT("report issue from transcript");
private final String name;
UserAction(final String name) {
this.name = name;
}
public String getName() {
return name;
}
}
@@ -0,0 +1,284 @@
package com.termux.app.terminal;
import android.content.Context;
import android.graphics.Rect;
import android.inputmethodservice.InputMethodService;
import android.util.AttributeSet;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewTreeObserver;
import android.view.WindowInsets;
import android.view.inputmethod.EditorInfo;
import android.widget.FrameLayout;
import android.widget.LinearLayout;
import androidx.annotation.Nullable;
import androidx.core.view.WindowInsetsCompat;
import com.termux.app.TermuxActivity;
import com.termux.shared.logger.Logger;
import com.termux.shared.view.ViewUtils;
/**
* The {@link TermuxActivity} relies on {@link android.view.WindowManager.LayoutParams#SOFT_INPUT_ADJUST_RESIZE)}
* set by {@link TermuxTerminalViewClient#setSoftKeyboardState(boolean, boolean)} to automatically
* resize the view and push the terminal up when soft keyboard is opened. However, this does not
* always work properly. When `enforce-char-based-input=true` is set in `termux.properties`
* and {@link com.termux.view.TerminalView#onCreateInputConnection(EditorInfo)} sets the inputType
* to `InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS`
* instead of the default `InputType.TYPE_NULL` for termux, some keyboards may still show suggestions.
* Gboard does too, but only when text is copied and clipboard suggestions **and** number keys row
* toggles are enabled in its settings. When number keys row toggle is not enabled, Gboard will still
* show the row but will switch it with suggestions if needed. If its enabled, then number keys row
* is always shown and suggestions are shown in an additional row on top of it. This additional row is likely
* part of the candidates view returned by the keyboard app in {@link InputMethodService#onCreateCandidatesView()}.
*
* With the above configuration, the additional clipboard suggestions row partially covers the
* extra keys/terminal. Reopening the keyboard/activity does not fix the issue. This is either a bug
* in the Android OS where it does not consider the candidate's view height in its calculation to push
* up the view or because Gboard does not include the candidate's view height in the height reported
* to android that should be used, hence causing an overlap.
*
* Gboard logs the following entry to `logcat` when its opened with or without the suggestions bar showing:
* I/KeyboardViewUtil: KeyboardViewUtil.calculateMaxKeyboardBodyHeight():62 leave 500 height for app when screen height:2392, header height:176 and isFullscreenMode:false, so the max keyboard body height is:1716
* where `keyboard_height = screen_height - height_for_app - header_height` (62 is a hardcoded value in Gboard source code and may be a version number)
* So this may in fact be due to Gboard but https://stackoverflow.com/questions/57567272 suggests
* otherwise. Another similar report https://stackoverflow.com/questions/66761661.
* Also check https://github.com/termux/termux-app/issues/1539.
*
* This overlap may happen even without `enforce-char-based-input=true` for keyboards with extended layouts
* like number row, etc.
*
* To fix these issues, `activity_termux.xml` has the constant 1sp transparent
* `activity_termux_bottom_space_view` View at the bottom. This will appear as a line matching the
* activity theme. When {@link TermuxActivity} {@link ViewTreeObserver.OnGlobalLayoutListener} is
* called when any of the sub view layouts change, like keyboard opening/closing keyboard,
* extra keys/input view switched, etc, we check if the bottom space view is visible or not.
* If its not, then we add a margin to the bottom of the root view, so that the keyboard does not
* overlap the extra keys/terminal, since the margin will push up the view. By default the margin
* added is equal to the height of the hidden part of extra keys/terminal. For Gboard's case, the
* hidden part equals the `header_height`. The updates to margins may cause a jitter in some cases
* when the view is redrawn if the margin is incorrect, but logic has been implemented to avoid that.
*/
public class TermuxActivityRootView extends LinearLayout implements ViewTreeObserver.OnGlobalLayoutListener {
public TermuxActivity mActivity;
public Integer marginBottom;
public Integer lastMarginBottom;
public long lastMarginBottomTime;
public long lastMarginBottomExtraTime;
/** Log root view events. */
private boolean ROOT_VIEW_LOGGING_ENABLED = false;
private static final String LOG_TAG = "TermuxActivityRootView";
private static int mStatusBarHeight;
public TermuxActivityRootView(Context context) {
super(context);
}
public TermuxActivityRootView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
}
public TermuxActivityRootView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
public void setActivity(TermuxActivity activity) {
mActivity = activity;
}
/**
* Sets whether root view logging is enabled or not.
*
* @param value The boolean value that defines the state.
*/
public void setIsRootViewLoggingEnabled(boolean value) {
ROOT_VIEW_LOGGING_ENABLED = value;
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
if (marginBottom != null) {
if (ROOT_VIEW_LOGGING_ENABLED)
Logger.logVerbose(LOG_TAG, "onMeasure: Setting bottom margin to " + marginBottom);
ViewGroup.MarginLayoutParams params = (ViewGroup.MarginLayoutParams) getLayoutParams();
params.setMargins(0, 0, 0, marginBottom);
setLayoutParams(params);
marginBottom = null;
requestLayout();
}
}
@Override
public void onGlobalLayout() {
if (mActivity == null || !mActivity.isVisible()) return;
View bottomSpaceView = mActivity.getTermuxActivityBottomSpaceView();
if (bottomSpaceView == null) return;
boolean root_view_logging_enabled = ROOT_VIEW_LOGGING_ENABLED;
if (root_view_logging_enabled)
Logger.logVerbose(LOG_TAG, ":\nonGlobalLayout:");
FrameLayout.LayoutParams params = (FrameLayout.LayoutParams) getLayoutParams();
// Get the position Rects of the bottom space view and the main window holding it
Rect[] windowAndViewRects = ViewUtils.getWindowAndViewRects(bottomSpaceView, mStatusBarHeight);
if (windowAndViewRects == null)
return;
Rect windowAvailableRect = windowAndViewRects[0];
Rect bottomSpaceViewRect = windowAndViewRects[1];
// If the bottomSpaceViewRect is inside the windowAvailableRect, then it must be completely visible
//boolean isVisible = windowAvailableRect.contains(bottomSpaceViewRect); // rect.right comparison often fails in landscape
boolean isVisible = ViewUtils.isRectAbove(windowAvailableRect, bottomSpaceViewRect);
boolean isVisibleBecauseMargin = (windowAvailableRect.bottom == bottomSpaceViewRect.bottom) && params.bottomMargin > 0;
boolean isVisibleBecauseExtraMargin = ((bottomSpaceViewRect.bottom - windowAvailableRect.bottom) < 0);
if (root_view_logging_enabled) {
Logger.logVerbose(LOG_TAG, "windowAvailableRect " + ViewUtils.toRectString(windowAvailableRect) + ", bottomSpaceViewRect " + ViewUtils.toRectString(bottomSpaceViewRect));
Logger.logVerbose(LOG_TAG, "windowAvailableRect.bottom " + windowAvailableRect.bottom +
", bottomSpaceViewRect.bottom " +bottomSpaceViewRect.bottom +
", diff " + (bottomSpaceViewRect.bottom - windowAvailableRect.bottom) + ", bottom " + params.bottomMargin +
", isVisible " + windowAvailableRect.contains(bottomSpaceViewRect) + ", isRectAbove " + ViewUtils.isRectAbove(windowAvailableRect, bottomSpaceViewRect) +
", isVisibleBecauseMargin " + isVisibleBecauseMargin + ", isVisibleBecauseExtraMargin " + isVisibleBecauseExtraMargin);
}
// If the bottomSpaceViewRect is visible, then remove the margin if needed
if (isVisible) {
// If visible because of margin, i.e the bottom of bottomSpaceViewRect equals that of windowAvailableRect
// and a margin has been added
// Necessary so that we don't get stuck in an infinite loop since setting margin
// will call OnGlobalLayoutListener again and next time bottom space view
// will be visible and margin will be set to 0, which again will call
// OnGlobalLayoutListener...
// Calling addTermuxActivityRootViewGlobalLayoutListener with a delay fails to
// set appropriate margins when views are changed quickly since some changes
// may be missed.
if (isVisibleBecauseMargin) {
if (root_view_logging_enabled)
Logger.logVerbose(LOG_TAG, "Visible due to margin");
// Once the view has been redrawn with new margin, we set margin back to 0 so that
// when next time onMeasure() is called, margin 0 is used. This is necessary for
// cases when view has been redrawn with new margin because bottom space view was
// hidden by keyboard and then view was redrawn again due to layout change (like
// keyboard symbol view is switched to), android will add margin below its new position
// if its greater than 0, which was already above the keyboard creating x2x margin.
// Adding time check since moving split screen divider in landscape causes jitter
// and prevents some infinite loops
if ((System.currentTimeMillis() - lastMarginBottomTime) > 40) {
lastMarginBottomTime = System.currentTimeMillis();
marginBottom = 0;
} else {
if (root_view_logging_enabled)
Logger.logVerbose(LOG_TAG, "Ignoring restoring marginBottom to 0 since called to quickly");
}
return;
}
boolean setMargin = params.bottomMargin != 0;
// If visible because of extra margin, i.e the bottom of bottomSpaceViewRect is above that of windowAvailableRect
// onGlobalLayout: windowAvailableRect 1408, bottomSpaceViewRect 1232, diff -176, bottom 0, isVisible true, isVisibleBecauseMargin false, isVisibleBecauseExtraMargin false
// onGlobalLayout: Bottom margin already equals 0
if (isVisibleBecauseExtraMargin) {
// Adding time check since prevents infinite loops, like in landscape mode in freeform mode in Taskbar
if ((System.currentTimeMillis() - lastMarginBottomExtraTime) > 40) {
if (root_view_logging_enabled)
Logger.logVerbose(LOG_TAG, "Resetting margin since visible due to extra margin");
lastMarginBottomExtraTime = System.currentTimeMillis();
// lastMarginBottom must be invalid. May also happen when keyboards are changed.
lastMarginBottom = null;
setMargin = true;
} else {
if (root_view_logging_enabled)
Logger.logVerbose(LOG_TAG, "Ignoring resetting margin since visible due to extra margin since called to quickly");
}
}
if (setMargin) {
if (root_view_logging_enabled)
Logger.logVerbose(LOG_TAG, "Setting bottom margin to 0");
params.setMargins(0, 0, 0, 0);
setLayoutParams(params);
} else {
if (root_view_logging_enabled)
Logger.logVerbose(LOG_TAG, "Bottom margin already equals 0");
// This is done so that when next time onMeasure() is called, lastMarginBottom is used.
// This is done since we **expect** the keyboard to have same dimensions next time layout
// changes, so best set margin while view is drawn the first time, otherwise it will
// cause a jitter when OnGlobalLayoutListener is called with margin 0 and it sets the
// likely same lastMarginBottom again and requesting a redraw. Hopefully, this logic
// works fine for all cases.
marginBottom = lastMarginBottom;
}
}
// ELse find the part of the extra keys/terminal that is hidden and add a margin accordingly
else {
int pxHidden = bottomSpaceViewRect.bottom - windowAvailableRect.bottom;
if (root_view_logging_enabled)
Logger.logVerbose(LOG_TAG, "pxHidden " + pxHidden + ", bottom " + params.bottomMargin);
boolean setMargin = params.bottomMargin != pxHidden;
// If invisible despite margin, i.e a margin was added, but the bottom of bottomSpaceViewRect
// is still below that of windowAvailableRect, this will trigger OnGlobalLayoutListener
// again, so that margins are set properly. May happen when toolbar/extra keys is disabled
// and enabled from left drawer, just like case for isVisibleBecauseExtraMargin.
// onMeasure: Setting bottom margin to 176
// onGlobalLayout: windowAvailableRect 1232, bottomSpaceViewRect 1408, diff 176, bottom 176, isVisible false, isVisibleBecauseMargin false, isVisibleBecauseExtraMargin false
// onGlobalLayout: Bottom margin already equals 176
if (pxHidden > 0 && params.bottomMargin > 0) {
if (pxHidden != params.bottomMargin) {
if (root_view_logging_enabled)
Logger.logVerbose(LOG_TAG, "Force setting margin to 0 since not visible due to wrong margin");
pxHidden = 0;
} else {
if (root_view_logging_enabled)
Logger.logVerbose(LOG_TAG, "Force setting margin since not visible despite required margin");
}
setMargin = true;
}
if (pxHidden < 0) {
if (root_view_logging_enabled)
Logger.logVerbose(LOG_TAG, "Force setting margin to 0 since new margin is negative");
pxHidden = 0;
}
if (setMargin) {
if (root_view_logging_enabled)
Logger.logVerbose(LOG_TAG, "Setting bottom margin to " + pxHidden);
params.setMargins(0, 0, 0, pxHidden);
setLayoutParams(params);
lastMarginBottom = pxHidden;
} else {
if (root_view_logging_enabled)
Logger.logVerbose(LOG_TAG, "Bottom margin already equals " + pxHidden);
}
}
}
public static class WindowInsetsListener implements View.OnApplyWindowInsetsListener {
@Override
public WindowInsets onApplyWindowInsets(View v, WindowInsets insets) {
mStatusBarHeight = WindowInsetsCompat.toWindowInsetsCompat(insets).getInsets(WindowInsetsCompat.Type.statusBars()).top;
// Let view window handle insets however it wants
return v.onApplyWindowInsets(insets);
}
}
}
@@ -0,0 +1,109 @@
package com.termux.app.terminal;
import android.annotation.SuppressLint;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Typeface;
import android.text.SpannableString;
import android.text.Spanned;
import android.text.TextUtils;
import android.text.style.StyleSpan;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.core.content.ContextCompat;
import com.termux.R;
import com.termux.app.TermuxActivity;
import com.termux.shared.termux.shell.command.runner.terminal.TermuxSession;
import com.termux.shared.theme.NightMode;
import com.termux.shared.theme.ThemeUtils;
import com.termux.terminal.TerminalSession;
import java.util.List;
public class TermuxSessionsListViewController extends ArrayAdapter<TermuxSession> implements AdapterView.OnItemClickListener, AdapterView.OnItemLongClickListener {
final TermuxActivity mActivity;
final StyleSpan boldSpan = new StyleSpan(Typeface.BOLD);
final StyleSpan italicSpan = new StyleSpan(Typeface.ITALIC);
public TermuxSessionsListViewController(TermuxActivity activity, List<TermuxSession> sessionList) {
super(activity.getApplicationContext(), R.layout.item_terminal_sessions_list, sessionList);
this.mActivity = activity;
}
@SuppressLint("SetTextI18n")
@NonNull
@Override
public View getView(int position, View convertView, @NonNull ViewGroup parent) {
View sessionRowView = convertView;
if (sessionRowView == null) {
LayoutInflater inflater = mActivity.getLayoutInflater();
sessionRowView = inflater.inflate(R.layout.item_terminal_sessions_list, parent, false);
}
TextView sessionTitleView = sessionRowView.findViewById(R.id.session_title);
TerminalSession sessionAtRow = getItem(position).getTerminalSession();
if (sessionAtRow == null) {
sessionTitleView.setText("null session");
return sessionRowView;
}
boolean shouldEnableDarkTheme = ThemeUtils.shouldEnableDarkTheme(mActivity, NightMode.getAppNightMode().getName());
if (shouldEnableDarkTheme) {
sessionTitleView.setBackground(
ContextCompat.getDrawable(mActivity, R.drawable.session_background_black_selected)
);
}
String name = sessionAtRow.mSessionName;
String sessionTitle = sessionAtRow.getTitle();
String numberPart = "[" + (position + 1) + "] ";
String sessionNamePart = (TextUtils.isEmpty(name) ? "" : name);
String sessionTitlePart = (TextUtils.isEmpty(sessionTitle) ? "" : ((sessionNamePart.isEmpty() ? "" : "\n") + sessionTitle));
String fullSessionTitle = numberPart + sessionNamePart + sessionTitlePart;
SpannableString fullSessionTitleStyled = new SpannableString(fullSessionTitle);
fullSessionTitleStyled.setSpan(boldSpan, 0, numberPart.length() + sessionNamePart.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
fullSessionTitleStyled.setSpan(italicSpan, numberPart.length() + sessionNamePart.length(), fullSessionTitle.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
sessionTitleView.setText(fullSessionTitleStyled);
boolean sessionRunning = sessionAtRow.isRunning();
if (sessionRunning) {
sessionTitleView.setPaintFlags(sessionTitleView.getPaintFlags() & ~Paint.STRIKE_THRU_TEXT_FLAG);
} else {
sessionTitleView.setPaintFlags(sessionTitleView.getPaintFlags() | Paint.STRIKE_THRU_TEXT_FLAG);
}
int defaultColor = shouldEnableDarkTheme ? Color.WHITE : Color.BLACK;
int color = sessionRunning || sessionAtRow.getExitStatus() == 0 ? defaultColor : Color.RED;
sessionTitleView.setTextColor(color);
return sessionRowView;
}
@Override
public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
TermuxSession clickedSession = getItem(position);
mActivity.getTermuxTerminalSessionClient().setCurrentSession(clickedSession.getTerminalSession());
mActivity.getDrawer().closeDrawers();
}
@Override
public boolean onItemLongClick(AdapterView<?> parent, View view, int position, long id) {
final TermuxSession selectedSession = getItem(position);
mActivity.getTermuxTerminalSessionClient().renameSession(selectedSession.getTerminalSession());
return true;
}
}
@@ -0,0 +1,530 @@
package com.termux.app.terminal;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.app.AlertDialog;
import android.content.pm.PackageManager;
import android.graphics.Typeface;
import android.media.AudioAttributes;
import android.media.SoundPool;
import android.text.TextUtils;
import android.widget.ListView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.termux.R;
import com.termux.shared.interact.ShareUtils;
import com.termux.shared.termux.shell.command.runner.terminal.TermuxSession;
import com.termux.shared.termux.interact.TextInputDialogUtils;
import com.termux.app.TermuxActivity;
import com.termux.shared.termux.terminal.TermuxTerminalSessionClientBase;
import com.termux.shared.termux.TermuxConstants;
import com.termux.app.TermuxService;
import com.termux.shared.termux.settings.properties.TermuxPropertyConstants;
import com.termux.shared.termux.terminal.io.BellHandler;
import com.termux.shared.logger.Logger;
import com.termux.terminal.TerminalColors;
import com.termux.terminal.TerminalSession;
import com.termux.terminal.TerminalSessionClient;
import com.termux.terminal.TextStyle;
import java.io.File;
import java.io.FileInputStream;
import java.io.InputStream;
import java.util.Properties;
/** The {@link TerminalSessionClient} implementation that may require an {@link Activity} for its interface methods. */
public class TermuxTerminalSessionActivityClient extends TermuxTerminalSessionClientBase {
private final TermuxActivity mActivity;
private static final int MAX_SESSIONS = 8;
private SoundPool mBellSoundPool;
private int mBellSoundId;
private static final String LOG_TAG = "TermuxTerminalSessionActivityClient";
public TermuxTerminalSessionActivityClient(TermuxActivity activity) {
this.mActivity = activity;
}
/**
* Should be called when mActivity.onCreate() is called
*/
public void onCreate() {
// Set terminal fonts and colors
checkForFontAndColors();
}
/**
* Should be called when mActivity.onStart() is called
*/
public void onStart() {
// The service has connected, but data may have changed since we were last in the foreground.
// Get the session stored in shared preferences stored by {@link #onStop} if its valid,
// otherwise get the last session currently running.
if (mActivity.getTermuxService() != null) {
setCurrentSession(getCurrentStoredSessionOrLast());
termuxSessionListNotifyUpdated();
}
// The current terminal session may have changed while being away, force
// a refresh of the displayed terminal.
mActivity.getTerminalView().onScreenUpdated();
}
/**
* Should be called when mActivity.onResume() is called
*/
public void onResume() {
// Just initialize the mBellSoundPool and load the sound, otherwise bell might not run
// the first time bell key is pressed and play() is called, since sound may not be loaded
// quickly enough before the call to play(). https://stackoverflow.com/questions/35435625
loadBellSoundPool();
}
/**
* Should be called when mActivity.onStop() is called
*/
public void onStop() {
// Store current session in shared preferences so that it can be restored later in
// {@link #onStart} if needed.
setCurrentStoredSession();
// Release mBellSoundPool resources, specially to prevent exceptions like the following to be thrown
// java.util.concurrent.TimeoutException: android.media.SoundPool.finalize() timed out after 10 seconds
// Bell is not played in background anyways
// Related: https://stackoverflow.com/a/28708351/14686958
releaseBellSoundPool();
}
/**
* Should be called when mActivity.reloadActivityStyling() is called
*/
public void onReloadActivityStyling() {
// Set terminal fonts and colors
checkForFontAndColors();
}
@Override
public void onTextChanged(@NonNull TerminalSession changedSession) {
if (!mActivity.isVisible()) return;
if (mActivity.getCurrentSession() == changedSession) mActivity.getTerminalView().onScreenUpdated();
}
@Override
public void onTitleChanged(@NonNull TerminalSession updatedSession) {
if (!mActivity.isVisible()) return;
if (updatedSession != mActivity.getCurrentSession()) {
// Only show toast for other sessions than the current one, since the user
// probably consciously caused the title change to change in the current session
// and don't want an annoying toast for that.
mActivity.showToast(toToastTitle(updatedSession), true);
}
termuxSessionListNotifyUpdated();
}
@Override
public void onSessionFinished(@NonNull TerminalSession finishedSession) {
TermuxService service = mActivity.getTermuxService();
if (service == null || service.wantsToStop()) {
// The service wants to stop as soon as possible.
mActivity.finishActivityIfNotFinishing();
return;
}
int index = service.getIndexOfSession(finishedSession);
// For plugin commands that expect the result back, we should immediately close the session
// and send the result back instead of waiting fo the user to press enter.
// The plugin can handle/show errors itself.
boolean isPluginExecutionCommandWithPendingResult = false;
TermuxSession termuxSession = service.getTermuxSession(index);
if (termuxSession != null) {
isPluginExecutionCommandWithPendingResult = termuxSession.getExecutionCommand().isPluginExecutionCommandWithPendingResult();
if (isPluginExecutionCommandWithPendingResult)
Logger.logVerbose(LOG_TAG, "The \"" + finishedSession.mSessionName + "\" session will be force finished automatically since result in pending.");
}
if (mActivity.isVisible() && finishedSession != mActivity.getCurrentSession()) {
// Show toast for non-current sessions that exit.
// Verify that session was not removed before we got told about it finishing:
if (index >= 0)
mActivity.showToast(toToastTitle(finishedSession) + " - exited", true);
}
if (mActivity.getPackageManager().hasSystemFeature(PackageManager.FEATURE_LEANBACK)) {
// On Android TV devices we need to use older behaviour because we may
// not be able to have multiple launcher icons.
if (service.getTermuxSessionsSize() > 1 || isPluginExecutionCommandWithPendingResult) {
removeFinishedSession(finishedSession);
}
} else {
// Once we have a separate launcher icon for the failsafe session, it
// should be safe to auto-close session on exit code '0' or '130'.
if (finishedSession.getExitStatus() == 0 || finishedSession.getExitStatus() == 130 || isPluginExecutionCommandWithPendingResult) {
removeFinishedSession(finishedSession);
}
}
}
@Override
public void onCopyTextToClipboard(@NonNull TerminalSession session, String text) {
if (!mActivity.isVisible()) return;
ShareUtils.copyTextToClipboard(mActivity, text);
}
@Override
public void onPasteTextFromClipboard(@Nullable TerminalSession session) {
if (!mActivity.isVisible()) return;
String text = ShareUtils.getTextStringFromClipboardIfSet(mActivity, true);
if (text != null)
mActivity.getTerminalView().mEmulator.paste(text);
}
@Override
public void onBell(@NonNull TerminalSession session) {
if (!mActivity.isVisible()) return;
switch (mActivity.getProperties().getBellBehaviour()) {
case TermuxPropertyConstants.IVALUE_BELL_BEHAVIOUR_VIBRATE:
BellHandler.getInstance(mActivity).doBell();
break;
case TermuxPropertyConstants.IVALUE_BELL_BEHAVIOUR_BEEP:
loadBellSoundPool();
if (mBellSoundPool != null)
mBellSoundPool.play(mBellSoundId, 1.f, 1.f, 1, 0, 1.f);
break;
case TermuxPropertyConstants.IVALUE_BELL_BEHAVIOUR_IGNORE:
// Ignore the bell character.
break;
}
}
@Override
public void onColorsChanged(@NonNull TerminalSession changedSession) {
if (mActivity.getCurrentSession() == changedSession)
updateBackgroundColor();
}
@Override
public void onTerminalCursorStateChange(boolean enabled) {
// Do not start cursor blinking thread if activity is not visible
if (enabled && !mActivity.isVisible()) {
Logger.logVerbose(LOG_TAG, "Ignoring call to start cursor blinking since activity is not visible");
return;
}
// If cursor is to enabled now, then start cursor blinking if blinking is enabled
// otherwise stop cursor blinking
mActivity.getTerminalView().setTerminalCursorBlinkerState(enabled, false);
}
@Override
public void setTerminalShellPid(@NonNull TerminalSession terminalSession, int pid) {
TermuxService service = mActivity.getTermuxService();
if (service == null) return;
TermuxSession termuxSession = service.getTermuxSessionForTerminalSession(terminalSession);
if (termuxSession != null)
termuxSession.getExecutionCommand().mPid = pid;
}
/**
* Should be called when mActivity.onResetTerminalSession() is called
*/
public void onResetTerminalSession() {
// Ensure blinker starts again after reset if cursor blinking was disabled before reset like
// with "tput civis" which would have called onTerminalCursorStateChange()
mActivity.getTerminalView().setTerminalCursorBlinkerState(true, true);
}
@Override
public Integer getTerminalCursorStyle() {
return mActivity.getProperties().getTerminalCursorStyle();
}
/** Load mBellSoundPool */
private synchronized void loadBellSoundPool() {
if (mBellSoundPool == null) {
mBellSoundPool = new SoundPool.Builder().setMaxStreams(1).setAudioAttributes(
new AudioAttributes.Builder().setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.setUsage(AudioAttributes.USAGE_ASSISTANCE_SONIFICATION).build()).build();
try {
mBellSoundId = mBellSoundPool.load(mActivity, R.raw.bell, 1);
} catch (Exception e){
// Catch java.lang.RuntimeException: Unable to resume activity {com.termux/com.itsaky.androidide.activities.TermuxActivity}: android.content.res.Resources$NotFoundException: File res/raw/bell.ogg from drawable resource ID
Logger.logStackTraceWithMessage(LOG_TAG, "Failed to load bell sound pool", e);
}
}
}
/** Release mBellSoundPool resources */
private synchronized void releaseBellSoundPool() {
if (mBellSoundPool != null) {
mBellSoundPool.release();
mBellSoundPool = null;
}
}
/** Try switching to session. */
public void setCurrentSession(TerminalSession session) {
if (session == null) return;
if (mActivity.getTerminalView().attachSession(session)) {
// notify about switched session if not already displaying the session
notifyOfSessionChange();
}
// We call the following even when the session is already being displayed since config may
// be stale, like current session not selected or scrolled to.
checkAndScrollToSession(session);
updateBackgroundColor();
}
void notifyOfSessionChange() {
if (!mActivity.isVisible()) return;
if (!mActivity.getProperties().areTerminalSessionChangeToastsDisabled()) {
TerminalSession session = mActivity.getCurrentSession();
mActivity.showToast(toToastTitle(session), false);
}
}
public void switchToSession(boolean forward) {
TermuxService service = mActivity.getTermuxService();
if (service == null) return;
TerminalSession currentTerminalSession = mActivity.getCurrentSession();
int index = service.getIndexOfSession(currentTerminalSession);
int size = service.getTermuxSessionsSize();
if (forward) {
if (++index >= size) index = 0;
} else {
if (--index < 0) index = size - 1;
}
TermuxSession termuxSession = service.getTermuxSession(index);
if (termuxSession != null)
setCurrentSession(termuxSession.getTerminalSession());
}
public void switchToSession(int index) {
TermuxService service = mActivity.getTermuxService();
if (service == null) return;
TermuxSession termuxSession = service.getTermuxSession(index);
if (termuxSession != null)
setCurrentSession(termuxSession.getTerminalSession());
}
@SuppressLint("InflateParams")
public void renameSession(final TerminalSession sessionToRename) {
if (sessionToRename == null) return;
TextInputDialogUtils.textInput(mActivity, R.string.title_rename_session, sessionToRename.mSessionName, R.string.action_rename_session_confirm, text -> {
renameSession(sessionToRename, text);
termuxSessionListNotifyUpdated();
}, -1, null, -1, null, null);
}
private void renameSession(TerminalSession sessionToRename, String text) {
if (sessionToRename == null) return;
sessionToRename.mSessionName = text;
TermuxService service = mActivity.getTermuxService();
if (service != null) {
TermuxSession termuxSession = service.getTermuxSessionForTerminalSession(sessionToRename);
if (termuxSession != null)
termuxSession.getExecutionCommand().shellName = text;
}
}
public void addNewSession(boolean isFailSafe, String sessioName) {
addNewSession(isFailSafe, sessioName, null);
}
public void addNewSession(boolean isFailSafe, String sessionName, String workingDirectory) {
TermuxService service = mActivity.getTermuxService();
if (service == null) return;
if (service.getTermuxSessionsSize() >= MAX_SESSIONS) {
new AlertDialog.Builder(mActivity).setTitle(R.string.title_max_terminals_reached).setMessage(R.string.msg_max_terminals_reached)
.setPositiveButton(android.R.string.ok, null).show();
} else {
TerminalSession currentSession = mActivity.getCurrentSession();
if (workingDirectory == null) {
if (currentSession == null) {
workingDirectory = mActivity.getProperties().getDefaultWorkingDirectory();
} else {
workingDirectory = currentSession.getCwd();
}
}
TermuxSession newTermuxSession = service.createTermuxSession(null, null, null, workingDirectory, isFailSafe, sessionName);
if (newTermuxSession == null) return;
TerminalSession newTerminalSession = newTermuxSession.getTerminalSession();
setCurrentSession(newTerminalSession);
mActivity.getDrawer().closeDrawers();
}
}
public void setCurrentStoredSession() {
TerminalSession currentSession = mActivity.getCurrentSession();
if (currentSession != null)
mActivity.getPreferences().setCurrentSession(currentSession.mHandle);
else
mActivity.getPreferences().setCurrentSession(null);
}
/** The current session as stored or the last one if that does not exist. */
public TerminalSession getCurrentStoredSessionOrLast() {
TerminalSession stored = getCurrentStoredSession();
if (stored != null) {
// If a stored session is in the list of currently running sessions, then return it
return stored;
} else {
// Else return the last session currently running
TermuxService service = mActivity.getTermuxService();
if (service == null) return null;
TermuxSession termuxSession = service.getLastTermuxSession();
if (termuxSession != null)
return termuxSession.getTerminalSession();
else
return null;
}
}
private TerminalSession getCurrentStoredSession() {
String sessionHandle = mActivity.getPreferences().getCurrentSession();
// If no session is stored in shared preferences
if (sessionHandle == null)
return null;
// Check if the session handle found matches one of the currently running sessions
TermuxService service = mActivity.getTermuxService();
if (service == null) return null;
return service.getTerminalSessionForHandle(sessionHandle);
}
public void removeFinishedSession(TerminalSession finishedSession) {
// Return pressed with finished session - remove it.
TermuxService service = mActivity.getTermuxService();
if (service == null) return;
int index = service.removeTermuxSession(finishedSession);
int size = service.getTermuxSessionsSize();
if (size == 0) {
// There are no sessions to show, so finish the activity.
mActivity.finishActivityIfNotFinishing();
} else {
if (index >= size) {
index = size - 1;
}
TermuxSession termuxSession = service.getTermuxSession(index);
if (termuxSession != null)
setCurrentSession(termuxSession.getTerminalSession());
}
}
public void termuxSessionListNotifyUpdated() {
mActivity.termuxSessionListNotifyUpdated();
}
public void checkAndScrollToSession(TerminalSession session) {
if (!mActivity.isVisible()) return;
TermuxService service = mActivity.getTermuxService();
if (service == null) return;
final int indexOfSession = service.getIndexOfSession(session);
if (indexOfSession < 0) return;
final ListView termuxSessionsListView = mActivity.findViewById(R.id.terminal_sessions_list);
if (termuxSessionsListView == null) return;
termuxSessionsListView.setItemChecked(indexOfSession, true);
// Delay is necessary otherwise sometimes scroll to newly added session does not happen
termuxSessionsListView.postDelayed(() -> termuxSessionsListView.smoothScrollToPosition(indexOfSession), 1000);
}
String toToastTitle(TerminalSession session) {
TermuxService service = mActivity.getTermuxService();
if (service == null) return null;
final int indexOfSession = service.getIndexOfSession(session);
if (indexOfSession < 0) return null;
StringBuilder toastTitle = new StringBuilder("[" + (indexOfSession + 1) + "]");
if (!TextUtils.isEmpty(session.mSessionName)) {
toastTitle.append(" ").append(session.mSessionName);
}
String title = session.getTitle();
if (!TextUtils.isEmpty(title)) {
// Space to "[${NR}] or newline after session name:
toastTitle.append(session.mSessionName == null ? " " : "\n");
toastTitle.append(title);
}
return toastTitle.toString();
}
public void checkForFontAndColors() {
try {
File colorsFile = TermuxConstants.TERMUX_COLOR_PROPERTIES_FILE;
File fontFile = TermuxConstants.TERMUX_FONT_FILE;
final Properties props = new Properties();
if (colorsFile.isFile()) {
try (InputStream in = new FileInputStream(colorsFile)) {
props.load(in);
}
}
TerminalColors.COLOR_SCHEME.updateWith(props);
TerminalSession session = mActivity.getCurrentSession();
if (session != null && session.getEmulator() != null) {
session.getEmulator().mColors.reset();
}
updateBackgroundColor();
final Typeface newTypeface = (fontFile.exists() && fontFile.length() > 0) ? Typeface.createFromFile(fontFile) : Typeface.MONOSPACE;
mActivity.getTerminalView().setTypeface(newTypeface);
} catch (Exception e) {
Logger.logStackTraceWithMessage(LOG_TAG, "Error in checkForFontAndColors()", e);
}
}
public void updateBackgroundColor() {
if (!mActivity.isVisible()) return;
TerminalSession session = mActivity.getCurrentSession();
if (session != null && session.getEmulator() != null) {
mActivity.getWindow().getDecorView().setBackgroundColor(session.getEmulator().mColors.mCurrentColors[TextStyle.COLOR_INDEX_BACKGROUND]);
}
}
}
@@ -0,0 +1,31 @@
package com.termux.app.terminal;
import android.app.Service;
import androidx.annotation.NonNull;
import com.termux.app.TermuxService;
import com.termux.shared.termux.shell.command.runner.terminal.TermuxSession;
import com.termux.shared.termux.terminal.TermuxTerminalSessionClientBase;
import com.termux.terminal.TerminalSession;
import com.termux.terminal.TerminalSessionClient;
/** The {@link TerminalSessionClient} implementation that may require a {@link Service} for its interface methods. */
public class TermuxTerminalSessionServiceClient extends TermuxTerminalSessionClientBase {
private static final String LOG_TAG = "TermuxTerminalSessionServiceClient";
private final TermuxService mService;
public TermuxTerminalSessionServiceClient(TermuxService service) {
this.mService = service;
}
@Override
public void setTerminalShellPid(@NonNull TerminalSession terminalSession, int pid) {
TermuxSession termuxSession = mService.getTermuxSessionForTerminalSession(terminalSession);
if (termuxSession != null)
termuxSession.getExecutionCommand().mPid = pid;
}
}
@@ -0,0 +1,798 @@
package com.termux.app.terminal;
import android.annotation.SuppressLint;
import android.app.AlertDialog;
import android.content.Context;
import android.media.AudioManager;
import android.os.Environment;
import android.view.Gravity;
import android.view.InputDevice;
import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.View;
import android.widget.EditText;
import android.widget.ListView;
import com.termux.R;
import com.termux.app.TermuxActivity;
import com.termux.shared.file.FileUtils;
import com.termux.shared.interact.MessageDialogUtils;
import com.termux.shared.interact.ShareUtils;
import com.termux.shared.shell.ShellUtils;
import com.termux.shared.termux.TermuxBootstrap;
import com.termux.shared.termux.terminal.TermuxTerminalViewClientBase;
import com.termux.shared.termux.extrakeys.SpecialButton;
import com.termux.shared.android.AndroidUtils;
import com.termux.shared.termux.TermuxConstants;
import com.termux.shared.activities.ReportActivity;
import com.termux.shared.models.ReportInfo;
import com.termux.app.models.UserAction;
import com.termux.app.terminal.io.KeyboardShortcut;
import com.termux.shared.termux.settings.properties.TermuxPropertyConstants;
import com.termux.shared.data.DataUtils;
import com.termux.shared.logger.Logger;
import com.termux.shared.markdown.MarkdownUtils;
import com.termux.shared.termux.TermuxUtils;
import com.termux.shared.termux.data.TermuxUrlUtils;
import com.termux.shared.view.KeyboardUtils;
import com.termux.shared.view.ViewUtils;
import com.termux.terminal.KeyHandler;
import com.termux.terminal.TerminalEmulator;
import com.termux.terminal.TerminalSession;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import androidx.drawerlayout.widget.DrawerLayout;
public class TermuxTerminalViewClient extends TermuxTerminalViewClientBase {
final TermuxActivity mActivity;
final TermuxTerminalSessionActivityClient mTermuxTerminalSessionActivityClient;
/** Keeping track of the special keys acting as Ctrl and Fn for the soft keyboard and other hardware keys. */
boolean mVirtualControlKeyDown, mVirtualFnKeyDown;
private Runnable mShowSoftKeyboardRunnable;
private boolean mShowSoftKeyboardIgnoreOnce;
private boolean mShowSoftKeyboardWithDelayOnce;
private boolean mTerminalCursorBlinkerStateAlreadySet;
private List<KeyboardShortcut> mSessionShortcuts;
private static final String LOG_TAG = "TermuxTerminalViewClient";
public TermuxTerminalViewClient(TermuxActivity activity, TermuxTerminalSessionActivityClient termuxTerminalSessionActivityClient) {
this.mActivity = activity;
this.mTermuxTerminalSessionActivityClient = termuxTerminalSessionActivityClient;
}
public TermuxActivity getActivity() {
return mActivity;
}
/**
* Should be called when mActivity.onCreate() is called
*/
public void onCreate() {
onReloadProperties();
mActivity.getTerminalView().setTextSize(mActivity.getPreferences().getFontSize());
mActivity.getTerminalView().setKeepScreenOn(mActivity.getPreferences().shouldKeepScreenOn());
}
/**
* Should be called when mActivity.onStart() is called
*/
public void onStart() {
// Set {@link TerminalView#TERMINAL_VIEW_KEY_LOGGING_ENABLED} value
// Also required if user changed the preference from {@link TermuxSettings} activity and returns
boolean isTerminalViewKeyLoggingEnabled = mActivity.getPreferences().isTerminalViewKeyLoggingEnabled();
mActivity.getTerminalView().setIsTerminalViewKeyLoggingEnabled(isTerminalViewKeyLoggingEnabled);
// Piggyback on the terminal view key logging toggle for now, should add a separate toggle in future
mActivity.getTermuxActivityRootView().setIsRootViewLoggingEnabled(isTerminalViewKeyLoggingEnabled);
ViewUtils.setIsViewUtilsLoggingEnabled(isTerminalViewKeyLoggingEnabled);
}
/**
* Should be called when mActivity.onResume() is called
*/
public void onResume() {
// Show the soft keyboard if required
setSoftKeyboardState(true, mActivity.isActivityRecreated());
mTerminalCursorBlinkerStateAlreadySet = false;
if (mActivity.getTerminalView().mEmulator != null) {
// Start terminal cursor blinking if enabled
// If emulator is already set, then start blinker now, otherwise wait for onEmulatorSet()
// event to start it. This is needed since onEmulatorSet() may not be called after
// TermuxActivity is started after device display timeout with double tap and not power button.
setTerminalCursorBlinkerState(true);
mTerminalCursorBlinkerStateAlreadySet = true;
}
}
/**
* Should be called when mActivity.onStop() is called
*/
public void onStop() {
// Stop terminal cursor blinking if enabled
setTerminalCursorBlinkerState(false);
}
/**
* Should be called when mActivity.reloadProperties() is called
*/
public void onReloadProperties() {
setSessionShortcuts();
}
/**
* Should be called when mActivity.reloadActivityStyling() is called
*/
public void onReloadActivityStyling() {
// Show the soft keyboard if required
setSoftKeyboardState(false, true);
// Start terminal cursor blinking if enabled
setTerminalCursorBlinkerState(true);
}
/**
* Should be called when {@link com.termux.view.TerminalView#mEmulator} is set
*/
@Override
public void onEmulatorSet() {
if (!mTerminalCursorBlinkerStateAlreadySet) {
// Start terminal cursor blinking if enabled
// We need to wait for the first session to be attached that's set in
// TermuxActivity.onServiceConnected() and then the multiple calls to TerminalView.updateSize()
// where the final one eventually sets the mEmulator when width/height is not 0. Otherwise
// blinker will not start again if TermuxActivity is started again after exiting it with
// double back press. Check TerminalView.setTerminalCursorBlinkerState().
setTerminalCursorBlinkerState(true);
mTerminalCursorBlinkerStateAlreadySet = true;
}
}
@Override
public float onScale(float scale) {
if (scale < 0.9f || scale > 1.1f) {
boolean increase = scale > 1.f;
changeFontSize(increase);
return 1.0f;
}
return scale;
}
@Override
public void onSingleTapUp(MotionEvent e) {
TerminalEmulator term = mActivity.getCurrentSession().getEmulator();
if (mActivity.getProperties().shouldOpenTerminalTranscriptURLOnClick()) {
int[] columnAndRow = mActivity.getTerminalView().getColumnAndRow(e, true);
String wordAtTap = term.getScreen().getWordAtLocation(columnAndRow[0], columnAndRow[1]);
LinkedHashSet<CharSequence> urlSet = TermuxUrlUtils.extractUrls(wordAtTap);
if (!urlSet.isEmpty()) {
String url = (String) urlSet.iterator().next();
ShareUtils.openUrl(mActivity, url);
return;
}
}
if (!term.isMouseTrackingActive() && !e.isFromSource(InputDevice.SOURCE_MOUSE)) {
if (!KeyboardUtils.areDisableSoftKeyboardFlagsSet(mActivity))
KeyboardUtils.showSoftKeyboard(mActivity, mActivity.getTerminalView());
else
Logger.logVerbose(LOG_TAG, "Not showing soft keyboard onSingleTapUp since its disabled");
}
}
@Override
public boolean shouldBackButtonBeMappedToEscape() {
return mActivity.getProperties().isBackKeyTheEscapeKey();
}
@Override
public boolean shouldEnforceCharBasedInput() {
return mActivity.getProperties().isEnforcingCharBasedInput();
}
@Override
public boolean shouldUseCtrlSpaceWorkaround() {
return mActivity.getProperties().isUsingCtrlSpaceWorkaround();
}
@Override
public boolean isTerminalViewSelected() {
return mActivity.getTerminalToolbarViewPager() == null || mActivity.isTerminalViewSelected() || mActivity.getTerminalView().hasFocus();
}
@Override
public void copyModeChanged(boolean copyMode) {
// Disable drawer while copying.
mActivity.getDrawer().setDrawerLockMode(copyMode ? DrawerLayout.LOCK_MODE_LOCKED_CLOSED : DrawerLayout.LOCK_MODE_UNLOCKED);
}
@SuppressLint("RtlHardcoded")
@Override
public boolean onKeyDown(int keyCode, KeyEvent e, TerminalSession currentSession) {
if (handleVirtualKeys(keyCode, e, true)) return true;
if (keyCode == KeyEvent.KEYCODE_ENTER && !currentSession.isRunning()) {
mTermuxTerminalSessionActivityClient.removeFinishedSession(currentSession);
return true;
} else if (!mActivity.getProperties().areHardwareKeyboardShortcutsDisabled() &&
e.isCtrlPressed() && e.isAltPressed()) {
// Get the unmodified code point:
int unicodeChar = e.getUnicodeChar(0);
if (keyCode == KeyEvent.KEYCODE_DPAD_DOWN || unicodeChar == 'n'/* next */) {
mTermuxTerminalSessionActivityClient.switchToSession(true);
} else if (keyCode == KeyEvent.KEYCODE_DPAD_UP || unicodeChar == 'p' /* previous */) {
mTermuxTerminalSessionActivityClient.switchToSession(false);
} else if (keyCode == KeyEvent.KEYCODE_DPAD_RIGHT) {
mActivity.getDrawer().openDrawer(Gravity.LEFT);
} else if (keyCode == KeyEvent.KEYCODE_DPAD_LEFT) {
mActivity.getDrawer().closeDrawers();
} else if (unicodeChar == 'k'/* keyboard */) {
onToggleSoftKeyboardRequest();
} else if (unicodeChar == 'm'/* menu */) {
mActivity.getTerminalView().showContextMenu();
} else if (unicodeChar == 'r'/* rename */) {
mTermuxTerminalSessionActivityClient.renameSession(currentSession);
} else if (unicodeChar == 'c'/* create */) {
mTermuxTerminalSessionActivityClient.addNewSession(false, null);
} else if (unicodeChar == 'u' /* urls */) {
showUrlSelection();
} else if (unicodeChar == 'v') {
doPaste();
} else if (unicodeChar == '+' || e.getUnicodeChar(KeyEvent.META_SHIFT_ON) == '+') {
// We also check for the shifted char here since shift may be required to produce '+',
// see https://github.com/termux/termux-api/issues/2
changeFontSize(true);
} else if (unicodeChar == '-') {
changeFontSize(false);
} else if (unicodeChar >= '1' && unicodeChar <= '9') {
int index = unicodeChar - '1';
mTermuxTerminalSessionActivityClient.switchToSession(index);
}
return true;
}
return false;
}
@Override
public boolean onKeyUp(int keyCode, KeyEvent e) {
// If emulator is not set, like if bootstrap installation failed and user dismissed the error
// dialog, then just exit the activity, otherwise they will be stuck in a broken state.
if (keyCode == KeyEvent.KEYCODE_BACK && mActivity.getTerminalView().mEmulator == null) {
mActivity.finishActivityIfNotFinishing();
return true;
}
return handleVirtualKeys(keyCode, e, false);
}
/** Handle dedicated volume buttons as virtual keys if applicable. */
private boolean handleVirtualKeys(int keyCode, KeyEvent event, boolean down) {
InputDevice inputDevice = event.getDevice();
if (mActivity.getProperties().areVirtualVolumeKeysDisabled()) {
return false;
} else if (inputDevice != null && inputDevice.getKeyboardType() == InputDevice.KEYBOARD_TYPE_ALPHABETIC) {
// Do not steal dedicated buttons from a full external keyboard.
return false;
} else if (keyCode == KeyEvent.KEYCODE_VOLUME_DOWN) {
mVirtualControlKeyDown = down;
return true;
} else if (keyCode == KeyEvent.KEYCODE_VOLUME_UP) {
mVirtualFnKeyDown = down;
return true;
}
return false;
}
@Override
public boolean readControlKey() {
return readExtraKeysSpecialButton(SpecialButton.CTRL) || mVirtualControlKeyDown;
}
@Override
public boolean readAltKey() {
return readExtraKeysSpecialButton(SpecialButton.ALT);
}
@Override
public boolean readShiftKey() {
return readExtraKeysSpecialButton(SpecialButton.SHIFT);
}
@Override
public boolean readFnKey() {
return readExtraKeysSpecialButton(SpecialButton.FN);
}
public boolean readExtraKeysSpecialButton(SpecialButton specialButton) {
if (mActivity.getExtraKeysView() == null) return false;
Boolean state = mActivity.getExtraKeysView().readSpecialButton(specialButton, true);
if (state == null) {
Logger.logError(LOG_TAG,"Failed to read an unregistered " + specialButton + " special button value from extra keys.");
return false;
}
return state;
}
@Override
public boolean onLongPress(MotionEvent event) {
return false;
}
@Override
public boolean onCodePoint(final int codePoint, boolean ctrlDown, TerminalSession session) {
if (mVirtualFnKeyDown) {
int resultingKeyCode = -1;
int resultingCodePoint = -1;
boolean altDown = false;
int lowerCase = Character.toLowerCase(codePoint);
switch (lowerCase) {
// Arrow keys.
case 'w':
resultingKeyCode = KeyEvent.KEYCODE_DPAD_UP;
break;
case 'a':
resultingKeyCode = KeyEvent.KEYCODE_DPAD_LEFT;
break;
case 's':
resultingKeyCode = KeyEvent.KEYCODE_DPAD_DOWN;
break;
case 'd':
resultingKeyCode = KeyEvent.KEYCODE_DPAD_RIGHT;
break;
// Page up and down.
case 'p':
resultingKeyCode = KeyEvent.KEYCODE_PAGE_UP;
break;
case 'n':
resultingKeyCode = KeyEvent.KEYCODE_PAGE_DOWN;
break;
// Some special keys:
case 't':
resultingKeyCode = KeyEvent.KEYCODE_TAB;
break;
case 'i':
resultingKeyCode = KeyEvent.KEYCODE_INSERT;
break;
case 'h':
resultingCodePoint = '~';
break;
// Special characters to input.
case 'u':
resultingCodePoint = '_';
break;
case 'l':
resultingCodePoint = '|';
break;
// Function keys.
case '1':
case '2':
case '3':
case '4':
case '5':
case '6':
case '7':
case '8':
case '9':
resultingKeyCode = (codePoint - '1') + KeyEvent.KEYCODE_F1;
break;
case '0':
resultingKeyCode = KeyEvent.KEYCODE_F10;
break;
// Other special keys.
case 'e':
resultingCodePoint = /*Escape*/ 27;
break;
case '.':
resultingCodePoint = /*^.*/ 28;
break;
case 'b': // alt+b, jumping backward in readline.
case 'f': // alf+f, jumping forward in readline.
case 'x': // alt+x, common in emacs.
resultingCodePoint = lowerCase;
altDown = true;
break;
// Volume control.
case 'v':
resultingCodePoint = -1;
AudioManager audio = (AudioManager) mActivity.getSystemService(Context.AUDIO_SERVICE);
audio.adjustSuggestedStreamVolume(AudioManager.ADJUST_SAME, AudioManager.USE_DEFAULT_STREAM_TYPE, AudioManager.FLAG_SHOW_UI);
break;
// Writing mode:
case 'q':
case 'k':
mActivity.toggleTerminalToolbar();
mVirtualFnKeyDown=false; // force disable fn key down to restore keyboard input into terminal view, fixes termux/termux-app#1420
break;
}
if (resultingKeyCode != -1) {
TerminalEmulator term = session.getEmulator();
session.write(KeyHandler.getCode(resultingKeyCode, 0, term.isCursorKeysApplicationMode(), term.isKeypadApplicationMode()));
} else if (resultingCodePoint != -1) {
session.writeCodePoint(altDown, resultingCodePoint);
}
return true;
} else if (ctrlDown) {
if (codePoint == 106 /* Ctrl+j or \n */ && !session.isRunning()) {
mTermuxTerminalSessionActivityClient.removeFinishedSession(session);
return true;
}
List<KeyboardShortcut> shortcuts = mSessionShortcuts;
if (shortcuts != null && !shortcuts.isEmpty()) {
int codePointLowerCase = Character.toLowerCase(codePoint);
for (int i = shortcuts.size() - 1; i >= 0; i--) {
KeyboardShortcut shortcut = shortcuts.get(i);
if (codePointLowerCase == shortcut.codePoint) {
switch (shortcut.shortcutAction) {
case TermuxPropertyConstants.ACTION_SHORTCUT_CREATE_SESSION:
mTermuxTerminalSessionActivityClient.addNewSession(false, null);
return true;
case TermuxPropertyConstants.ACTION_SHORTCUT_NEXT_SESSION:
mTermuxTerminalSessionActivityClient.switchToSession(true);
return true;
case TermuxPropertyConstants.ACTION_SHORTCUT_PREVIOUS_SESSION:
mTermuxTerminalSessionActivityClient.switchToSession(false);
return true;
case TermuxPropertyConstants.ACTION_SHORTCUT_RENAME_SESSION:
mTermuxTerminalSessionActivityClient.renameSession(mActivity.getCurrentSession());
return true;
}
}
}
}
}
return false;
}
/**
* Set the terminal sessions shortcuts.
*/
private void setSessionShortcuts() {
mSessionShortcuts = new ArrayList<>();
// The {@link TermuxPropertyConstants#MAP_SESSION_SHORTCUTS} stores the session shortcut key and action pair
for (Map.Entry<String, Integer> entry : TermuxPropertyConstants.MAP_SESSION_SHORTCUTS.entrySet()) {
// The mMap stores the code points for the session shortcuts while loading properties
Integer codePoint = (Integer) mActivity.getProperties().getInternalPropertyValue(entry.getKey(), true);
// If codePoint is null, then session shortcut did not exist in properties or was invalid
// as parsed by {@link #getCodePointForSessionShortcuts(String,String)}
// If codePoint is not null, then get the action for the MAP_SESSION_SHORTCUTS key and
// add the code point to sessionShortcuts
if (codePoint != null)
mSessionShortcuts.add(new KeyboardShortcut(codePoint, entry.getValue()));
}
}
public void changeFontSize(boolean increase) {
mActivity.getPreferences().changeFontSize(increase);
mActivity.getTerminalView().setTextSize(mActivity.getPreferences().getFontSize());
}
/**
* Called when user requests the soft keyboard to be toggled via "KEYBOARD" toggle button in
* drawer or extra keys, or with ctrl+alt+k hardware keyboard shortcut.
*/
public void onToggleSoftKeyboardRequest() {
// If soft keyboard toggle behaviour is enable/disabled
if (mActivity.getProperties().shouldEnableDisableSoftKeyboardOnToggle()) {
// If soft keyboard is visible
if (!KeyboardUtils.areDisableSoftKeyboardFlagsSet(mActivity)) {
Logger.logVerbose(LOG_TAG, "Disabling soft keyboard on toggle");
mActivity.getPreferences().setSoftKeyboardEnabled(false);
KeyboardUtils.disableSoftKeyboard(mActivity, mActivity.getTerminalView());
} else {
// Show with a delay, otherwise pressing keyboard toggle won't show the keyboard after
// switching back from another app if keyboard was previously disabled by user.
// Also request focus, since it wouldn't have been requested at startup by
// setSoftKeyboardState if keyboard was disabled. #2112
Logger.logVerbose(LOG_TAG, "Enabling soft keyboard on toggle");
mActivity.getPreferences().setSoftKeyboardEnabled(true);
KeyboardUtils.clearDisableSoftKeyboardFlags(mActivity);
if(mShowSoftKeyboardWithDelayOnce) {
mShowSoftKeyboardWithDelayOnce = false;
mActivity.getTerminalView().postDelayed(getShowSoftKeyboardRunnable(), 500);
mActivity.getTerminalView().requestFocus();
} else
KeyboardUtils.showSoftKeyboard(mActivity, mActivity.getTerminalView());
}
}
// If soft keyboard toggle behaviour is show/hide
else {
// If soft keyboard is disabled by user for Termux
if (!mActivity.getPreferences().isSoftKeyboardEnabled()) {
Logger.logVerbose(LOG_TAG, "Maintaining disabled soft keyboard on toggle");
KeyboardUtils.disableSoftKeyboard(mActivity, mActivity.getTerminalView());
} else {
Logger.logVerbose(LOG_TAG, "Showing/Hiding soft keyboard on toggle");
KeyboardUtils.clearDisableSoftKeyboardFlags(mActivity);
KeyboardUtils.toggleSoftKeyboard(mActivity);
}
}
}
public void setSoftKeyboardState(boolean isStartup, boolean isReloadTermuxProperties) {
boolean noShowKeyboard = false;
// Requesting terminal view focus is necessary regardless of if soft keyboard is to be
// disabled or hidden at startup, otherwise if hardware keyboard is attached and user
// starts typing on hardware keyboard without tapping on the terminal first, then a colour
// tint will be added to the terminal as highlight for the focussed view. Test with a light
// theme. For android 8.+, the "defaultFocusHighlightEnabled" attribute is also set to false
// in TerminalView layout to fix the issue.
// If soft keyboard is disabled by user for Termux (check function docs for Termux behaviour info)
if (KeyboardUtils.shouldSoftKeyboardBeDisabled(mActivity,
mActivity.getPreferences().isSoftKeyboardEnabled(),
mActivity.getPreferences().isSoftKeyboardEnabledOnlyIfNoHardware())) {
Logger.logVerbose(LOG_TAG, "Maintaining disabled soft keyboard");
KeyboardUtils.disableSoftKeyboard(mActivity, mActivity.getTerminalView());
mActivity.getTerminalView().requestFocus();
noShowKeyboard = true;
// Delay is only required if onCreate() is called like when Termux app is exited with
// double back press, not when Termux app is switched back from another app and keyboard
// toggle is pressed to enable keyboard
if (isStartup && mActivity.isOnResumeAfterOnCreate())
mShowSoftKeyboardWithDelayOnce = true;
} else {
// Set flag to automatically push up TerminalView when keyboard is opened instead of showing over it
KeyboardUtils.setSoftInputModeAdjustResize(mActivity);
// Clear any previous flags to disable soft keyboard in case setting updated
KeyboardUtils.clearDisableSoftKeyboardFlags(mActivity);
// If soft keyboard is to be hidden on startup
if (isStartup && mActivity.getProperties().shouldSoftKeyboardBeHiddenOnStartup()) {
Logger.logVerbose(LOG_TAG, "Hiding soft keyboard on startup");
// Required to keep keyboard hidden when Termux app is switched back from another app
KeyboardUtils.setSoftKeyboardAlwaysHiddenFlags(mActivity);
KeyboardUtils.hideSoftKeyboard(mActivity, mActivity.getTerminalView());
mActivity.getTerminalView().requestFocus();
noShowKeyboard = true;
// Required to keep keyboard hidden on app startup
mShowSoftKeyboardIgnoreOnce = true;
}
}
mActivity.getTerminalView().setOnFocusChangeListener(new View.OnFocusChangeListener() {
@Override
public void onFocusChange(View view, boolean hasFocus) {
// Force show soft keyboard if TerminalView or toolbar text input view has
// focus and close it if they don't
boolean textInputViewHasFocus = false;
final EditText textInputView = mActivity.findViewById(R.id.terminal_toolbar_text_input);
if (textInputView != null) textInputViewHasFocus = textInputView.hasFocus();
if (hasFocus || textInputViewHasFocus) {
if (mShowSoftKeyboardIgnoreOnce) {
mShowSoftKeyboardIgnoreOnce = false; return;
}
Logger.logVerbose(LOG_TAG, "Showing soft keyboard on focus change");
} else {
Logger.logVerbose(LOG_TAG, "Hiding soft keyboard on focus change");
}
KeyboardUtils.setSoftKeyboardVisibility(getShowSoftKeyboardRunnable(), mActivity, mActivity.getTerminalView(), hasFocus || textInputViewHasFocus);
}
});
// Do not force show soft keyboard if termux-reload-settings command was run with hardware keyboard
// or soft keyboard is to be hidden or is disabled
if (!isReloadTermuxProperties && !noShowKeyboard) {
// Request focus for TerminalView
// Also show the keyboard, since onFocusChange will not be called if TerminalView already
// had focus on startup to show the keyboard, like when opening url with context menu
// "Select URL" long press and returning to Termux app with back button. This
// will also show keyboard even if it was closed before opening url. #2111
Logger.logVerbose(LOG_TAG, "Requesting TerminalView focus and showing soft keyboard");
mActivity.getTerminalView().requestFocus();
mActivity.getTerminalView().postDelayed(getShowSoftKeyboardRunnable(), 300);
}
}
private Runnable getShowSoftKeyboardRunnable() {
if (mShowSoftKeyboardRunnable == null) {
mShowSoftKeyboardRunnable = () -> {
KeyboardUtils.showSoftKeyboard(mActivity, mActivity.getTerminalView());
};
}
return mShowSoftKeyboardRunnable;
}
public void setTerminalCursorBlinkerState(boolean start) {
if (start) {
// If set/update the cursor blinking rate is successful, then enable cursor blinker
if (mActivity.getTerminalView().setTerminalCursorBlinkerRate(mActivity.getProperties().getTerminalCursorBlinkRate()))
mActivity.getTerminalView().setTerminalCursorBlinkerState(true, true);
else
Logger.logError(LOG_TAG,"Failed to start cursor blinker");
} else {
// Disable cursor blinker
mActivity.getTerminalView().setTerminalCursorBlinkerState(false, true);
}
}
public void shareSessionTranscript() {
TerminalSession session = mActivity.getCurrentSession();
if (session == null) return;
String transcriptText = ShellUtils.getTerminalSessionTranscriptText(session, false, true);
if (transcriptText == null) return;
// See https://github.com/termux/termux-app/issues/1166.
transcriptText = DataUtils.getTruncatedCommandOutput(transcriptText, DataUtils.TRANSACTION_SIZE_LIMIT_IN_BYTES, false, true, false).trim();
ShareUtils.shareText(mActivity, mActivity.getString(R.string.title_share_transcript),
transcriptText, mActivity.getString(R.string.title_share_transcript_with));
}
public void shareSelectedText() {
String selectedText = mActivity.getTerminalView().getStoredSelectedText();
if (DataUtils.isNullOrEmpty(selectedText)) return;
ShareUtils.shareText(mActivity, mActivity.getString(R.string.title_share_selected_text),
selectedText, mActivity.getString(R.string.title_share_selected_text_with));
}
public void showUrlSelection() {
TerminalSession session = mActivity.getCurrentSession();
if (session == null) return;
String text = ShellUtils.getTerminalSessionTranscriptText(session, true, true);
LinkedHashSet<CharSequence> urlSet = TermuxUrlUtils.extractUrls(text);
if (urlSet.isEmpty()) {
new AlertDialog.Builder(mActivity).setMessage(R.string.title_select_url_none_found).show();
return;
}
final CharSequence[] urls = urlSet.toArray(new CharSequence[0]);
Collections.reverse(Arrays.asList(urls)); // Latest first.
// Click to copy url to clipboard:
final AlertDialog dialog = new AlertDialog.Builder(mActivity).setItems(urls, (di, which) -> {
String url = (String) urls[which];
ShareUtils.copyTextToClipboard(mActivity, url, mActivity.getString(R.string.msg_select_url_copied_to_clipboard));
}).setTitle(R.string.title_select_url_dialog).create();
// Long press to open URL:
dialog.setOnShowListener(di -> {
ListView lv = dialog.getListView(); // this is a ListView with your "buds" in it
lv.setOnItemLongClickListener((parent, view, position, id) -> {
dialog.dismiss();
String url = (String) urls[position];
ShareUtils.openUrl(mActivity, url);
return true;
});
});
dialog.show();
}
public void reportIssueFromTranscript() {
TerminalSession session = mActivity.getCurrentSession();
if (session == null) return;
final String transcriptText = ShellUtils.getTerminalSessionTranscriptText(session, false, true);
if (transcriptText == null) return;
MessageDialogUtils.showMessage(mActivity, TermuxConstants.TERMUX_APP_NAME + " Report Issue",
mActivity.getString(R.string.msg_add_termux_debug_info),
mActivity.getString(R.string.action_yes), (dialog, which) -> reportIssueFromTranscript(transcriptText, true),
mActivity.getString(R.string.action_no), (dialog, which) -> reportIssueFromTranscript(transcriptText, false),
null);
}
private void reportIssueFromTranscript(String transcriptText, boolean addTermuxDebugInfo) {
Logger.showToast(mActivity, mActivity.getString(R.string.msg_generating_report), true);
new Thread() {
@Override
public void run() {
StringBuilder reportString = new StringBuilder();
String title = TermuxConstants.TERMUX_APP_NAME + " Report Issue";
reportString.append("## Transcript\n");
reportString.append("\n").append(MarkdownUtils.getMarkdownCodeForString(transcriptText, true));
reportString.append("\n##\n");
if (addTermuxDebugInfo) {
reportString.append("\n\n").append(TermuxUtils.getAppInfoMarkdownString(mActivity, TermuxUtils.AppInfoMode.TERMUX_AND_PLUGIN_PACKAGES));
} else {
reportString.append("\n\n").append(TermuxUtils.getAppInfoMarkdownString(mActivity, TermuxUtils.AppInfoMode.TERMUX_PACKAGE));
}
reportString.append("\n\n").append(AndroidUtils.getDeviceInfoMarkdownString(mActivity, true));
if (TermuxBootstrap.isAppPackageManagerAPT()) {
String termuxAptInfo = TermuxUtils.geAPTInfoMarkdownString(mActivity);
if (termuxAptInfo != null)
reportString.append("\n\n").append(termuxAptInfo);
}
if (addTermuxDebugInfo) {
String termuxDebugInfo = TermuxUtils.getTermuxDebugMarkdownString(mActivity);
if (termuxDebugInfo != null)
reportString.append("\n\n").append(termuxDebugInfo);
}
String userActionName = UserAction.REPORT_ISSUE_FROM_TRANSCRIPT.getName();
ReportInfo reportInfo = new ReportInfo(userActionName,
TermuxConstants.TERMUX_APP.TERMUX_ACTIVITY_NAME, title);
reportInfo.setReportString(reportString.toString());
reportInfo.setReportStringSuffix("\n\n" + TermuxUtils.getReportIssueMarkdownString(mActivity));
reportInfo.setReportSaveFileLabelAndPath(userActionName,
Environment.getExternalStorageDirectory() + "/" +
FileUtils.sanitizeFileName(TermuxConstants.TERMUX_APP_NAME + "-" + userActionName + ".log", true, true));
ReportActivity.startReportActivity(mActivity, reportInfo);
}
}.start();
}
public void doPaste() {
TerminalSession session = mActivity.getCurrentSession();
if (session == null) return;
if (!session.isRunning()) return;
String text = ShareUtils.getTextStringFromClipboardIfSet(mActivity, true);
if (text != null)
session.getEmulator().paste(text);
}
}
@@ -0,0 +1,68 @@
package com.termux.app.terminal.io;
import android.graphics.Rect;
import android.view.View;
import android.view.ViewGroup;
import com.termux.app.TermuxActivity;
/**
* Work around for fullscreen mode in Termux to fix ExtraKeysView not being visible.
* This class is derived from:
* https://stackoverflow.com/questions/7417123/android-how-to-adjust-layout-in-full-screen-mode-when-softkeyboard-is-visible
* and has some additional tweaks
* ---
* For more information, see https://issuetracker.google.com/issues/36911528
*/
public class FullScreenWorkAround {
private final View mChildOfContent;
private int mUsableHeightPrevious;
private final ViewGroup.LayoutParams mViewGroupLayoutParams;
private final int mNavBarHeight;
public static void apply(TermuxActivity activity) {
new FullScreenWorkAround(activity);
}
private FullScreenWorkAround(TermuxActivity activity) {
ViewGroup content = activity.findViewById(android.R.id.content);
mChildOfContent = content.getChildAt(0);
mViewGroupLayoutParams = mChildOfContent.getLayoutParams();
mNavBarHeight = activity.getNavBarHeight();
mChildOfContent.getViewTreeObserver().addOnGlobalLayoutListener(this::possiblyResizeChildOfContent);
}
private void possiblyResizeChildOfContent() {
int usableHeightNow = computeUsableHeight();
if (usableHeightNow != mUsableHeightPrevious) {
int usableHeightSansKeyboard = mChildOfContent.getRootView().getHeight();
int heightDifference = usableHeightSansKeyboard - usableHeightNow;
if (heightDifference > (usableHeightSansKeyboard / 4)) {
// keyboard probably just became visible
// ensures that usable layout space does not extend behind the
// soft keyboard, causing the extra keys to not be visible
mViewGroupLayoutParams.height = (usableHeightSansKeyboard - heightDifference) + getNavBarHeight();
} else {
// keyboard probably just became hidden
mViewGroupLayoutParams.height = usableHeightSansKeyboard;
}
mChildOfContent.requestLayout();
mUsableHeightPrevious = usableHeightNow;
}
}
private int getNavBarHeight() {
return mNavBarHeight;
}
private int computeUsableHeight() {
Rect r = new Rect();
mChildOfContent.getWindowVisibleDisplayFrame(r);
return (r.bottom - r.top);
}
}
@@ -0,0 +1,13 @@
package com.termux.app.terminal.io;
public class KeyboardShortcut {
public final int codePoint;
public final int shortcutAction;
public KeyboardShortcut(int codePoint, int shortcutAction) {
this.codePoint = codePoint;
this.shortcutAction = shortcutAction;
}
}
@@ -0,0 +1,117 @@
package com.termux.app.terminal.io;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.EditText;
import androidx.annotation.NonNull;
import androidx.viewpager.widget.PagerAdapter;
import androidx.viewpager.widget.ViewPager;
import com.termux.R;
import com.termux.app.TermuxActivity;
import com.termux.shared.termux.extrakeys.ExtraKeysView;
import com.termux.terminal.TerminalSession;
public class TerminalToolbarViewPager {
public static class PageAdapter extends PagerAdapter {
final TermuxActivity mActivity;
String mSavedTextInput;
public PageAdapter(TermuxActivity activity, String savedTextInput) {
this.mActivity = activity;
this.mSavedTextInput = savedTextInput;
}
@Override
public int getCount() {
return 2;
}
@Override
public boolean isViewFromObject(@NonNull View view, @NonNull Object object) {
return view == object;
}
@NonNull
@Override
public Object instantiateItem(@NonNull ViewGroup collection, int position) {
LayoutInflater inflater = LayoutInflater.from(mActivity);
View layout;
if (position == 0) {
layout = inflater.inflate(R.layout.view_terminal_toolbar_extra_keys, collection, false);
ExtraKeysView extraKeysView = (ExtraKeysView) layout;
extraKeysView.setExtraKeysViewClient(mActivity.getTermuxTerminalExtraKeys());
extraKeysView.setButtonTextAllCaps(mActivity.getProperties().shouldExtraKeysTextBeAllCaps());
mActivity.setExtraKeysView(extraKeysView);
extraKeysView.reload(mActivity.getTermuxTerminalExtraKeys().getExtraKeysInfo(),
mActivity.getTerminalToolbarDefaultHeight());
// apply extra keys fix if enabled in prefs
if (mActivity.getProperties().isUsingFullScreen() && mActivity.getProperties().isUsingFullScreenWorkAround()) {
FullScreenWorkAround.apply(mActivity);
}
} else {
layout = inflater.inflate(R.layout.view_terminal_toolbar_text_input, collection, false);
final EditText editText = layout.findViewById(R.id.terminal_toolbar_text_input);
if (mSavedTextInput != null) {
editText.setText(mSavedTextInput);
mSavedTextInput = null;
}
editText.setOnEditorActionListener((v, actionId, event) -> {
TerminalSession session = mActivity.getCurrentSession();
if (session != null) {
if (session.isRunning()) {
String textToSend = editText.getText().toString();
if (textToSend.length() == 0) textToSend = "\r";
session.write(textToSend);
} else {
mActivity.getTermuxTerminalSessionClient().removeFinishedSession(session);
}
editText.setText("");
}
return true;
});
}
collection.addView(layout);
return layout;
}
@Override
public void destroyItem(@NonNull ViewGroup collection, int position, @NonNull Object view) {
collection.removeView((View) view);
}
}
public static class OnPageChangeListener extends ViewPager.SimpleOnPageChangeListener {
final TermuxActivity mActivity;
final ViewPager mTerminalToolbarViewPager;
public OnPageChangeListener(TermuxActivity activity, ViewPager viewPager) {
this.mActivity = activity;
this.mTerminalToolbarViewPager = viewPager;
}
@Override
public void onPageSelected(int position) {
if (position == 0) {
mActivity.getTerminalView().requestFocus();
} else {
final EditText editText = mTerminalToolbarViewPager.findViewById(R.id.terminal_toolbar_text_input);
if (editText != null) editText.requestFocus();
}
}
}
}
@@ -0,0 +1,108 @@
package com.termux.app.terminal.io;
import android.annotation.SuppressLint;
import android.view.Gravity;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.drawerlayout.widget.DrawerLayout;
import com.termux.app.TermuxActivity;
import com.termux.app.terminal.TermuxTerminalSessionActivityClient;
import com.termux.app.terminal.TermuxTerminalViewClient;
import com.termux.shared.logger.Logger;
import com.termux.shared.termux.extrakeys.ExtraKeysConstants;
import com.termux.shared.termux.extrakeys.ExtraKeysInfo;
import com.termux.shared.termux.settings.properties.TermuxPropertyConstants;
import com.termux.shared.termux.settings.properties.TermuxSharedProperties;
import com.termux.shared.termux.terminal.io.TerminalExtraKeys;
import com.termux.view.TerminalView;
import org.json.JSONException;
public class TermuxTerminalExtraKeys extends TerminalExtraKeys {
private ExtraKeysInfo mExtraKeysInfo;
final TermuxActivity mActivity;
final TermuxTerminalViewClient mTermuxTerminalViewClient;
final TermuxTerminalSessionActivityClient mTermuxTerminalSessionActivityClient;
private static final String LOG_TAG = "TermuxTerminalExtraKeys";
public TermuxTerminalExtraKeys(TermuxActivity activity, @NonNull TerminalView terminalView,
TermuxTerminalViewClient termuxTerminalViewClient,
TermuxTerminalSessionActivityClient termuxTerminalSessionActivityClient) {
super(terminalView);
mActivity = activity;
mTermuxTerminalViewClient = termuxTerminalViewClient;
mTermuxTerminalSessionActivityClient = termuxTerminalSessionActivityClient;
setExtraKeys();
}
/**
* Set the terminal extra keys and style.
*/
private void setExtraKeys() {
mExtraKeysInfo = null;
try {
// The mMap stores the extra key and style string values while loading properties
// Check {@link #getExtraKeysInternalPropertyValueFromValue(String)} and
// {@link #getExtraKeysStyleInternalPropertyValueFromValue(String)}
String extrakeys = (String) mActivity.getProperties().getInternalPropertyValue(TermuxPropertyConstants.KEY_EXTRA_KEYS, true);
String extraKeysStyle = (String) mActivity.getProperties().getInternalPropertyValue(TermuxPropertyConstants.KEY_EXTRA_KEYS_STYLE, true);
ExtraKeysConstants.ExtraKeyDisplayMap extraKeyDisplayMap = ExtraKeysInfo.getCharDisplayMapForStyle(extraKeysStyle);
if (ExtraKeysConstants.EXTRA_KEY_DISPLAY_MAPS.DEFAULT_CHAR_DISPLAY.equals(extraKeyDisplayMap) && !TermuxPropertyConstants.DEFAULT_IVALUE_EXTRA_KEYS_STYLE.equals(extraKeysStyle)) {
Logger.logError(TermuxSharedProperties.LOG_TAG, "The style \"" + extraKeysStyle + "\" for the key \"" + TermuxPropertyConstants.KEY_EXTRA_KEYS_STYLE + "\" is invalid. Using default style instead.");
extraKeysStyle = TermuxPropertyConstants.DEFAULT_IVALUE_EXTRA_KEYS_STYLE;
}
mExtraKeysInfo = new ExtraKeysInfo(extrakeys, extraKeysStyle, ExtraKeysConstants.CONTROL_CHARS_ALIASES);
} catch (JSONException e) {
Logger.showToast(mActivity, "Could not load and set the \"" + TermuxPropertyConstants.KEY_EXTRA_KEYS + "\" property from the properties file: " + e.toString(), true);
Logger.logStackTraceWithMessage(LOG_TAG, "Could not load and set the \"" + TermuxPropertyConstants.KEY_EXTRA_KEYS + "\" property from the properties file: ", e);
try {
mExtraKeysInfo = new ExtraKeysInfo(TermuxPropertyConstants.DEFAULT_IVALUE_EXTRA_KEYS, TermuxPropertyConstants.DEFAULT_IVALUE_EXTRA_KEYS_STYLE, ExtraKeysConstants.CONTROL_CHARS_ALIASES);
} catch (JSONException e2) {
Logger.showToast(mActivity, "Can't create default extra keys",true);
Logger.logStackTraceWithMessage(LOG_TAG, "Could create default extra keys: ", e);
mExtraKeysInfo = null;
}
}
}
public ExtraKeysInfo getExtraKeysInfo() {
return mExtraKeysInfo;
}
@SuppressLint("RtlHardcoded")
@Override
public void onTerminalExtraKeyButtonClick(View view, String key, boolean ctrlDown, boolean altDown, boolean shiftDown, boolean fnDown) {
if ("KEYBOARD".equals(key)) {
if(mTermuxTerminalViewClient != null)
mTermuxTerminalViewClient.onToggleSoftKeyboardRequest();
} else if ("DRAWER".equals(key)) {
DrawerLayout drawerLayout = mTermuxTerminalViewClient.getActivity().getDrawer();
if (drawerLayout.isDrawerOpen(Gravity.LEFT))
drawerLayout.closeDrawer(Gravity.LEFT);
else
drawerLayout.openDrawer(Gravity.LEFT);
} else if ("PASTE".equals(key)) {
if(mTermuxTerminalSessionActivityClient != null)
mTermuxTerminalSessionActivityClient.onPasteTextFromClipboard(null);
} else if ("SCROLL".equals(key)) {
TerminalView terminalView = mTermuxTerminalViewClient.getActivity().getTerminalView();
if (terminalView != null && terminalView.mEmulator != null)
terminalView.mEmulator.toggleAutoScrollDisabled();
} else {
super.onTerminalExtraKeyButtonClick(view, key, ctrlDown, altDown, shiftDown, fnDown);
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#E0E0E0" />
</shape>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#212325" />
</shape>
@@ -0,0 +1,28 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:height="108dp"
android:width="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Keep in sync with non-adaptive ic_launcher.xml -->
<path
android:fillColor="#FFFFFF"
android:pathData="M34,38
h6
l12,16
l-12,16
h-6
l12,-16
"
/>
<path
android:fillColor="#FFFFFF"
android:pathData="M56,66
h18
v4
h-18
"
/>
</vector>
@@ -0,0 +1,17 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FFF"
android:pathData="M 12, 12
m -10.5, 0
a 10.5,10.5 0 1,0 21,0
a 10.5,10.5 0 1,0 -21,0"/>
<path
android:fillColor="#FF000000"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM17,13h-4v4h-2v-4L7,13v-2h4L11,7h2v4h4v2z"/>
</vector>
@@ -0,0 +1,24 @@
<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="M13,18H22V20H13V18Z"
android:fillColor="#ffffff"/>
</group>
</vector>
@@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#FF000000"
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="M19.14,12.94c0.04,-0.3 0.06,-0.61 0.06,-0.94c0,-0.32 -0.02,-0.64 -0.07,-0.94l2.03,-1.58c0.18,-0.14 0.23,-0.41 0.12,-0.61l-1.92,-3.32c-0.12,-0.22 -0.37,-0.29 -0.59,-0.22l-2.39,0.96c-0.5,-0.38 -1.03,-0.7 -1.62,-0.94L14.4,2.81c-0.04,-0.24 -0.24,-0.41 -0.48,-0.41h-3.84c-0.24,0 -0.43,0.17 -0.47,0.41L9.25,5.35C8.66,5.59 8.12,5.92 7.63,6.29L5.24,5.33c-0.22,-0.08 -0.47,0 -0.59,0.22L2.74,8.87C2.62,9.08 2.66,9.34 2.86,9.48l2.03,1.58C4.84,11.36 4.8,11.69 4.8,12s0.02,0.64 0.07,0.94l-2.03,1.58c-0.18,0.14 -0.23,0.41 -0.12,0.61l1.92,3.32c0.12,0.22 0.37,0.29 0.59,0.22l2.39,-0.96c0.5,0.38 1.03,0.7 1.62,0.94l0.36,2.54c0.05,0.24 0.24,0.41 0.48,0.41h3.84c0.24,0 0.44,-0.17 0.47,-0.41l0.36,-2.54c0.59,-0.24 1.13,-0.56 1.62,-0.94l2.39,0.96c0.22,0.08 0.47,0 0.59,-0.22l1.92,-3.32c0.12,-0.22 0.07,-0.47 -0.12,-0.61L19.14,12.94zM12,15.6c-1.98,0 -3.6,-1.62 -3.6,-3.6s1.62,-3.6 3.6,-3.6s3.6,1.62 3.6,3.6S13.98,15.6 12,15.6z"/>
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8" ?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_activated="true" android:drawable="@drawable/current_session_black"/>
<item android:state_activated="false" android:drawable="@drawable/session_ripple_black"/>
</selector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8" ?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_activated="true" android:drawable="@drawable/current_session"/>
<item android:state_activated="false" android:drawable="@drawable/session_ripple"/>
</selector>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="@android:color/darker_gray" >
<item>
<color android:color="@android:color/white" />
</item>
</ripple>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="@android:color/darker_gray" >
<item>
<color android:color="@android:color/background_dark" />
</item>
</ripple>
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" >
<solid android:color="#66FFFFFF" />
<size android:width="4dp" />
<!--
<gradient
android:angle="45"
android:centerColor="#66ff5c33"
android:endColor="#66FF3401"
android:startColor="#66FF3401" />
<corners android:radius="8dp" />
<padding
android:left="0.5dp"
android:right="0.5dp" />
-->
</shape>
@@ -0,0 +1,16 @@
<?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_primary_toolbar"
android:id="@+id/partial_primary_toolbar"/>
<FrameLayout
android:id="@+id/settings"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</LinearLayout>
@@ -0,0 +1,101 @@
<com.termux.app.terminal.TermuxActivityRootView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/activity_termux_root_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:fitsSystemWindows="true">
<RelativeLayout
android:id="@+id/activity_termux_root_relative_layout"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:layout_marginHorizontal="3dp"
android:layout_marginVertical="0dp"
android:orientation="vertical">
<androidx.drawerlayout.widget.DrawerLayout
android:id="@+id/drawer_layout"
android:layout_width="match_parent"
android:layout_alignParentTop="true"
android:layout_above="@+id/terminal_toolbar_view_pager"
android:layout_height="match_parent">
<com.termux.view.TerminalView
android:id="@+id/terminal_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:defaultFocusHighlightEnabled="false"
android:focusableInTouchMode="true"
android:scrollbarThumbVertical="@drawable/terminal_scroll_shape"
android:scrollbars="vertical"
android:importantForAutofill="no"
android:autofillHints="password"
tools:ignore="UnusedAttribute" />
<LinearLayout
android:id="@+id/left_drawer"
android:layout_width="240dp"
android:layout_height="match_parent"
android:layout_gravity="start"
android:choiceMode="singleChoice"
android:divider="@android:color/transparent"
android:dividerHeight="0dp"
android:descendantFocusability="blocksDescendants"
android:orientation="vertical"
android:background="?attr/termuxActivityDrawerBackground">
<ListView
android:id="@+id/terminal_sessions_list"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_gravity="top"
android:layout_weight="1"
android:choiceMode="singleChoice"
android:longClickable="true" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<com.google.android.material.button.MaterialButton
android:id="@+id/toggle_keyboard_button"
style="@style/Widget.Material3.Button.TextButton"
android:textAllCaps="true"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/action_toggle_soft_keyboard" />
<com.google.android.material.button.MaterialButton
android:id="@+id/new_session_button"
style="@style/Widget.Material3.Button.TextButton"
android:textAllCaps="true"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/action_new_session" />
</LinearLayout>
</LinearLayout>
</androidx.drawerlayout.widget.DrawerLayout>
<androidx.viewpager.widget.ViewPager
android:id="@+id/terminal_toolbar_view_pager"
android:visibility="gone"
android:layout_width="match_parent"
android:layout_height="37.5dp"
android:background="@color/black"
android:layout_alignParentBottom="true" />
</RelativeLayout>
<View
android:id="@+id/activity_termux_bottom_space_view"
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="@android:color/transparent" />
</com.termux.app.terminal.TermuxActivityRootView>
@@ -0,0 +1,9 @@
<com.google.android.material.textview.MaterialTextView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/session_title"
android:layout_width="fill_parent"
android:layout_height="?android:attr/listPreferredItemHeight"
android:background="@drawable/session_background_selected"
android:ellipsize="marquee"
android:gravity="start|center_vertical"
android:padding="6dip"
android:textSize="14sp" />
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ https://android.googlesource.com/platform/frameworks/support/+/refs/heads/androidx-appcompat-release/preference/preference/res/layout/preference.xml
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="vertical">
<TextView android:id="@android:id/title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:singleLine="true"
android:textAppearance="?android:attr/textAppearanceLarge"
android:textColor="?android:attr/textColorPrimary" />
<include android:id="@android:id/summary" layout="@layout/markdown_adapter_node_default" />
</LinearLayout>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<com.termux.shared.termux.extrakeys.ExtraKeysView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/terminal_toolbar_extra_keys"
style="?android:attr/buttonBarStyle"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_alignParentBottom="true"
android:orientation="horizontal" />
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<EditText xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/terminal_toolbar_text_input"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:imeOptions="actionSend|flagNoFullscreen"
android:maxLines="1"
android:inputType="text"
android:importantForAutofill="no"
android:textColor="@android:color/white"
android:textColorHighlight="@android:color/darker_gray"
android:paddingTop="0dp"
android:textCursorDrawable="@null"
android:paddingBottom="0dp"
tools:ignore="LabelFor" />
@@ -0,0 +1,21 @@
<PreferenceScreen xmlns:app="http://schemas.android.com/apk/res-auto">
<Preference
app:key="termux"
app:title="@string/termux_preferences_title"
app:summary="@string/termux_preferences_summary"
app:fragment="com.termux.app.fragments.settings.TermuxPreferencesFragment"/>
<Preference
app:key="about"
app:title="@string/about_preference_title"
app:persistent="false"/>
<!-- app:layout="@layout/preference_markdown_text" -->
<Preference
app:key="donate"
app:title="@string/donate_preference_title"
app:persistent="false"
app:isPreferenceVisible="false"/>
</PreferenceScreen>
@@ -0,0 +1,33 @@
<PreferenceScreen xmlns:app="http://schemas.android.com/apk/res-auto">
<PreferenceCategory
app:key="logging"
app:title="@string/termux_logging_header">
<ListPreference
app:defaultValue="1"
app:key="log_level"
app:title="@string/termux_log_level_title"
app:useSimpleSummaryProvider="true" />
<SwitchPreferenceCompat
app:key="terminal_view_key_logging_enabled"
app:summaryOff="@string/termux_terminal_view_key_logging_enabled_off"
app:summaryOn="@string/termux_terminal_view_key_logging_enabled_on"
app:title="@string/termux_terminal_view_key_logging_enabled_title" />
<SwitchPreferenceCompat
app:key="plugin_error_notifications_enabled"
app:summaryOff="@string/termux_plugin_error_notifications_enabled_off"
app:summaryOn="@string/termux_plugin_error_notifications_enabled_on"
app:title="@string/termux_plugin_error_notifications_enabled_title" />
<SwitchPreferenceCompat
app:key="crash_report_notifications_enabled"
app:summaryOff="@string/termux_crash_report_notifications_enabled_off"
app:summaryOn="@string/termux_crash_report_notifications_enabled_on"
app:title="@string/termux_crash_report_notifications_enabled_title" />
</PreferenceCategory>
</PreferenceScreen>
@@ -0,0 +1,18 @@
<PreferenceScreen xmlns:app="http://schemas.android.com/apk/res-auto">
<Preference
app:title="@string/termux_debugging_preferences_title"
app:summary="@string/termux_debugging_preferences_summary"
app:fragment="com.termux.app.fragments.settings.termux.DebuggingPreferencesFragment"/>
<Preference
app:title="@string/termux_terminal_io_preferences_title"
app:summary="@string/termux_terminal_io_preferences_summary"
app:fragment="com.termux.app.fragments.settings.termux.TerminalIOPreferencesFragment"/>
<Preference
app:title="@string/termux_terminal_view_preferences_title"
app:summary="@string/termux_terminal_view_preferences_summary"
app:fragment="com.termux.app.fragments.settings.termux.TerminalViewPreferencesFragment"/>
</PreferenceScreen>
@@ -0,0 +1,21 @@
<PreferenceScreen xmlns:app="http://schemas.android.com/apk/res-auto">
<PreferenceCategory
app:key="keyboard"
app:title="@string/termux_keyboard_header">
<SwitchPreferenceCompat
app:key="soft_keyboard_enabled"
app:summaryOff="@string/termux_soft_keyboard_enabled_off"
app:summaryOn="@string/termux_soft_keyboard_enabled_on"
app:title="@string/termux_soft_keyboard_enabled_title" />
<SwitchPreferenceCompat
app:key="soft_keyboard_enabled_only_if_no_hardware"
app:summaryOff="@string/termux_soft_keyboard_enabled_only_if_no_hardware_off"
app:summaryOn="@string/termux_soft_keyboard_enabled_only_if_no_hardware_on"
app:title="@string/termux_soft_keyboard_enabled_only_if_no_hardware_title" />
</PreferenceCategory>
</PreferenceScreen>
@@ -0,0 +1,15 @@
<PreferenceScreen xmlns:app="http://schemas.android.com/apk/res-auto">
<PreferenceCategory
app:key="view"
app:title="@string/termux_terminal_view_view_header">
<SwitchPreferenceCompat
app:key="terminal_margin_adjustment"
app:summaryOff="@string/termux_terminal_view_terminal_margin_adjustment_off"
app:summaryOn="@string/termux_terminal_view_terminal_margin_adjustment_on"
app:title="@string/termux_terminal_view_terminal_margin_adjustment_title" />
</PreferenceCategory>
</PreferenceScreen>
@@ -0,0 +1,32 @@
package com.termux.app;
import com.termux.shared.termux.data.TermuxUrlUtils;
import org.junit.Assert;
import org.junit.Test;
import java.util.Collections;
import java.util.LinkedHashSet;
public class TermuxActivityTest {
private void assertUrlsAre(String text, String... urls) {
LinkedHashSet<String> expected = new LinkedHashSet<>();
Collections.addAll(expected, urls);
Assert.assertEquals(expected, TermuxUrlUtils.extractUrls(text));
}
@Test
public void testExtractUrls() {
assertUrlsAre("hello http://example.com world", "http://example.com");
assertUrlsAre("http://example.com\nhttp://another.com", "http://example.com", "http://another.com");
assertUrlsAre("hello http://example.com world and http://more.example.com with secure https://more.example.com",
"http://example.com", "http://more.example.com", "https://more.example.com");
assertUrlsAre("hello https://example.com/#bar https://example.com/foo#bar",
"https://example.com/#bar", "https://example.com/foo#bar");
}
}
@@ -0,0 +1,34 @@
package com.termux.app.api.file;
import com.termux.app.api.file.FileReceiverActivity;
import org.junit.Assert;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
import java.util.ArrayList;
import java.util.List;
@RunWith(RobolectricTestRunner.class)
public class FileReceiverActivityTest {
@Test
public void testIsSharedTextAnUrl() {
List<String> validUrls = new ArrayList<>();
validUrls.add("http://example.com");
validUrls.add("https://example.com");
validUrls.add("https://example.com/path/parameter=foo");
validUrls.add("magnet:?xt=urn:btih:d540fc48eb12f2833163eed6421d449dd8f1ce1f&dn=Ubuntu+desktop+19.04+%2864bit%29&tr=udp%3A%2F%2Ftracker.openbittorrent.com%3A80&tr=udp%3A%2F%2Ftracker.publicbt.com%3A80&tr=udp%3A%2F%2Ftracker.ccc.de%3A80");
for (String url : validUrls) {
Assert.assertTrue(FileReceiverActivity.isSharedTextAnUrl(url));
}
List<String> invalidUrls = new ArrayList<>();
invalidUrls.add("a test with example.com");
for (String url : invalidUrls) {
Assert.assertFalse(FileReceiverActivity.isSharedTextAnUrl(url));
}
}
}
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
/build
+42
View File
@@ -0,0 +1,42 @@
plugins {
id("com.android.library")
id("kotlin-android")
}
android {
namespace = "com.termux.emulator"
ndkVersion = BuildConfig.ndkVersion
defaultConfig {
externalNativeBuild {
ndkBuild {
cFlags += arrayOf("-std=c11", "-Wall", "-Wextra", "-Werror", "-Os", "-fno-stack-protector", "-Wl,--gc-sections")
}
}
ndk {
abiFilters += arrayOf("x86", "x86_64", "armeabi-v7a", "arm64-v8a")
}
}
externalNativeBuild {
ndkBuild {
path = file("src/main/jni/Android.mk")
}
}
testOptions {
unitTests.isReturnDefaultValues = true
}
}
tasks.withType(Test::class.java) {
testLogging {
events("started", "passed", "skipped", "failed")
}
}
dependencies {
implementation(libs.androidx.annotation)
testImplementation(projects.testing.unit)
}
+25
View File
@@ -0,0 +1,25 @@
# Add project specific ProGuard rules here.
# By default, the flags in this file are appended to flags specified
# in /Users/fornwall/lib/android-sdk/tools/proguard/proguard-android.txt
# You can edit the include path and order by changing the proguardFiles
# directive in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# Add any project specific keep options here:
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -0,0 +1 @@
<manifest />

Some files were not shown because too many files have changed in this diff Show More