mirror of
https://gitee.com/zhangsken/AndroidIDE.git
synced 2026-10-16 07:49:44 +08:00
feat: integrate full Termux application
This commit is contained in:
Generated
+1
@@ -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" />
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
Executable
BIN
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
}
|
||||
|
||||
+109
@@ -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
@@ -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) }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -26,10 +26,12 @@ import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.itsaky.androidide.common.R
|
||||
import com.itsaky.androidide.tasks.cancelIfActive
|
||||
import com.itsaky.androidide.ui.themes.IThemeManager
|
||||
import com.itsaky.androidide.utils.ILogger
|
||||
import com.itsaky.androidide.utils.resolveAttr
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import org.greenrobot.eventbus.EventBus
|
||||
|
||||
abstract class BaseIDEActivity : AppCompatActivity() {
|
||||
|
||||
@@ -56,6 +58,7 @@ abstract class BaseIDEActivity : AppCompatActivity() {
|
||||
navigationBarColor = this@BaseIDEActivity.navigationBarColor
|
||||
statusBarColor = this@BaseIDEActivity.statusBarColor
|
||||
}
|
||||
IThemeManager.getInstance().applyTheme(this)
|
||||
super.onCreate(savedInstanceState)
|
||||
preSetContentLayout()
|
||||
setContentView(bindLayout())
|
||||
@@ -75,6 +78,24 @@ abstract class BaseIDEActivity : AppCompatActivity() {
|
||||
activityScope.cancelIfActive("Activity is being destroyed")
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
+1
-1
@@ -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'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,6 +15,8 @@ antlr4 = "4.13.1"
|
||||
androidx-work = "2.9.0"
|
||||
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.2" }
|
||||
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.4" }
|
||||
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.2.0" }
|
||||
androidx-tracing-ktx = { module = "androidx.tracing:tracing-ktx", version = "1.2.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" }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
/build
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Vendored
+17
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+109
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
+530
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+31
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
+798
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
+117
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
+108
@@ -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.
@@ -0,0 +1 @@
|
||||
/build
|
||||
@@ -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)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user