From 24b567c52404619f5ab2ee0f61c53cd5e0b5bee8 Mon Sep 17 00:00:00 2001 From: Fr4nzD13trich Date: Sat, 22 Nov 2025 16:41:32 +0100 Subject: [PATCH] Repo created --- .editorconfig | 20 + .gitattributes | 5 + .gitignore | 38 + LICENSE.md | 6 + README.md | 257 +- app/build.gradle | 210 ++ app/dev_keystore.jks | Bin 0 -> 2097 bytes app/proguard-rules.pro | 17 + app/src/main/AndroidManifest.xml | 205 ++ app/src/main/cpp/Android.mk | 5 + app/src/main/cpp/termux-bootstrap-zip.S | 18 + app/src/main/cpp/termux-bootstrap.c | 11 + .../com/termux/app/RunCommandService.java | 270 ++ .../java/com/termux/app/TermuxActivity.java | 925 ++++++ .../com/termux/app/TermuxApplication.java | 29 + .../java/com/termux/app/TermuxInstaller.java | 365 +++ .../com/termux/app/TermuxOpenReceiver.java | 224 ++ .../java/com/termux/app/TermuxService.java | 849 ++++++ .../termux/app/activities/HelpActivity.java | 77 + .../app/activities/SettingsActivity.java | 164 ++ .../TermuxAPIPreferencesFragment.java | 49 + .../TermuxFloatPreferencesFragment.java | 49 + .../settings/TermuxPreferencesFragment.java | 49 + .../TermuxTaskerPreferencesFragment.java | 49 + .../TermuxWidgetPreferencesFragment.java | 49 + .../termux/DebuggingPreferencesFragment.java | 155 + .../termux/TerminalIOPreferencesFragment.java | 82 + .../TerminalViewPreferencesFragment.java | 77 + .../DebuggingPreferencesFragment.java | 101 + .../DebuggingPreferencesFragment.java | 126 + .../DebuggingPreferencesFragment.java | 101 + .../DebuggingPreferencesFragment.java | 101 + .../com/termux/app/models/UserAction.java | 20 + .../properties/TermuxAppSharedProperties.java | 119 + .../app/terminal/TermuxActivityRootView.java | 284 ++ .../TermuxSessionsListViewController.java | 107 + .../terminal/TermuxTerminalSessionClient.java | 495 ++++ .../terminal/TermuxTerminalViewClient.java | 761 +++++ .../app/terminal/io/FullScreenWorkAround.java | 68 + .../app/terminal/io/KeyboardShortcut.java | 13 + .../terminal/io/TerminalToolbarViewPager.java | 117 + .../terminal/io/TermuxTerminalExtraKeys.java | 49 + .../java/com/termux/app/utils/CrashUtils.java | 214 ++ .../com/termux/app/utils/PluginUtils.java | 335 +++ .../filepicker/TermuxDocumentsProvider.java | 268 ++ .../TermuxFileReceiverActivity.java | 237 ++ app/src/main/res/drawable/banner.png | Bin 0 -> 3425 bytes app/src/main/res/drawable/current_session.xml | 4 + .../res/drawable/current_session_black.xml | 4 + app/src/main/res/drawable/ic_foreground.xml | 28 + app/src/main/res/drawable/ic_new_session.xml | 17 + .../res/drawable/ic_service_notification.xml | 24 + app/src/main/res/drawable/ic_settings.xml | 5 + .../session_background_black_selected.xml | 5 + .../drawable/session_background_selected.xml | 5 + app/src/main/res/drawable/session_ripple.xml | 7 + .../res/drawable/session_ripple_black.xml | 7 + .../res/drawable/terminal_scroll_shape.xml | 22 + app/src/main/res/layout/activity_settings.xml | 9 + app/src/main/res/layout/activity_termux.xml | 112 + .../layout/item_terminal_sessions_list.xml | 9 + .../res/layout/preference_markdown_text.xml | 20 + .../view_terminal_toolbar_extra_keys.xml | 8 + .../view_terminal_toolbar_text_input.xml | 16 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + app/src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 1454 bytes .../res/mipmap-hdpi/ic_launcher_round.png | Bin 0 -> 1609 bytes app/src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 1122 bytes .../res/mipmap-mdpi/ic_launcher_round.png | Bin 0 -> 1199 bytes app/src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 1847 bytes .../res/mipmap-xhdpi/ic_launcher_round.png | Bin 0 -> 2108 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 3238 bytes .../res/mipmap-xxhdpi/ic_launcher_round.png | Bin 0 -> 3748 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 3556 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.png | Bin 0 -> 4497 bytes app/src/main/res/values/colors.xml | 3 + app/src/main/res/values/strings.xml | 229 ++ app/src/main/res/values/styles.xml | 52 + app/src/main/res/xml/root_preferences.xml | 49 + app/src/main/res/xml/shortcuts.xml | 54 + .../xml/termux_api_debugging_preferences.xml | 15 + .../main/res/xml/termux_api_preferences.xml | 8 + .../res/xml/termux_debugging_preferences.xml | 33 + .../termux_float_debugging_preferences.xml | 21 + .../main/res/xml/termux_float_preferences.xml | 8 + app/src/main/res/xml/termux_preferences.xml | 18 + .../termux_tasker_debugging_preferences.xml | 15 + .../res/xml/termux_tasker_preferences.xml | 8 + .../xml/termux_terminal_io_preferences.xml | 21 + .../xml/termux_terminal_view_preferences.xml | 15 + .../termux_widget_debugging_preferences.xml | 15 + .../res/xml/termux_widget_preferences.xml | 8 + .../com/termux/app/TermuxActivityTest.java | 32 + .../TermuxFileReceiverActivityTest.java | 34 + art/copy-to-other-apps.sh | 13 + art/feature-graphic.svg | 30 + art/generate-big-icon.sh | 23 + art/generate-feature-graphic.sh | 5 + art/generate-launcher-images.sh | 20 + art/generate-tv-banner.sh | 9 + art/ic_launcher.svg | 26 + art/ic_launcher2.png | Bin 0 -> 10167 bytes art/ic_launcher2_round.png | Bin 0 -> 13356 bytes art/tv-banner.svg | 23 + build.gradle | 21 + .../android/en-US/full_description.txt | 13 + .../metadata/android/en-US/images/icon.png | Bin 0 -> 3556 bytes .../en-US/images/phoneScreenshots/1.jpg | Bin 0 -> 141057 bytes .../en-US/images/phoneScreenshots/2.jpg | Bin 0 -> 120386 bytes .../en-US/images/phoneScreenshots/3.jpg | Bin 0 -> 98625 bytes .../en-US/images/phoneScreenshots/4.jpg | Bin 0 -> 162046 bytes .../android/en-US/short_description.txt | 1 + gradle.properties | 29 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 59203 bytes gradle/wrapper/gradle-wrapper.properties | 5 + gradlew | 185 ++ gradlew.bat | 89 + jitpack.yml | 4 + settings.gradle | 1 + terminal-emulator/build.gradle | 74 + terminal-emulator/proguard-rules.pro | 25 + .../src/main/AndroidManifest.xml | 2 + .../java/com/termux/terminal/ByteQueue.java | 108 + .../main/java/com/termux/terminal/JNI.java | 41 + .../java/com/termux/terminal/KeyHandler.java | 373 +++ .../main/java/com/termux/terminal/Logger.java | 80 + .../com/termux/terminal/TerminalBuffer.java | 497 ++++ .../termux/terminal/TerminalColorScheme.java | 126 + .../com/termux/terminal/TerminalColors.java | 96 + .../com/termux/terminal/TerminalEmulator.java | 2605 +++++++++++++++++ .../com/termux/terminal/TerminalOutput.java | 32 + .../java/com/termux/terminal/TerminalRow.java | 283 ++ .../com/termux/terminal/TerminalSession.java | 372 +++ .../terminal/TerminalSessionClient.java | 46 + .../java/com/termux/terminal/TextStyle.java | 90 + .../java/com/termux/terminal/WcWidth.java | 566 ++++ terminal-emulator/src/main/jni/Android.mk | 5 + terminal-emulator/src/main/jni/termux.c | 218 ++ .../java/com/termux/terminal/ApcTest.java | 21 + .../com/termux/terminal/ByteQueueTest.java | 54 + .../ControlSequenceIntroducerTest.java | 131 + .../termux/terminal/CursorAndScreenTest.java | 266 ++ .../java/com/termux/terminal/DecSetTest.java | 78 + .../terminal/DeviceControlStringTest.java | 53 + .../java/com/termux/terminal/HistoryTest.java | 33 + .../com/termux/terminal/KeyHandlerTest.java | 203 ++ .../terminal/OperatingSystemControlTest.java | 196 ++ .../termux/terminal/RectangularAreasTest.java | 117 + .../java/com/termux/terminal/ResizeTest.java | 212 ++ .../com/termux/terminal/ScreenBufferTest.java | 65 + .../com/termux/terminal/ScrollRegionTest.java | 167 ++ .../com/termux/terminal/TerminalRowTest.java | 432 +++ .../com/termux/terminal/TerminalTest.java | 350 +++ .../com/termux/terminal/TerminalTestCase.java | 319 ++ .../com/termux/terminal/TextStyleTest.java | 65 + .../com/termux/terminal/UnicodeInputTest.java | 136 + .../java/com/termux/terminal/WcWidthTest.java | 81 + terminal-view/build.gradle | 53 + terminal-view/proguard-rules.pro | 25 + terminal-view/src/main/AndroidManifest.xml | 2 + .../view/GestureAndScaleRecognizer.java | 112 + .../com/termux/view/TerminalRenderer.java | 249 ++ .../java/com/termux/view/TerminalView.java | 1476 ++++++++++ .../com/termux/view/TerminalViewClient.java | 83 + .../view/textselection/CursorController.java | 55 + .../TextSelectionCursorController.java | 402 +++ .../TextSelectionHandleView.java | 345 +++ .../text_select_handle_left_material.xml | 10 + .../text_select_handle_right_material.xml | 10 + terminal-view/src/main/res/values/strings.xml | 5 + termux-shared/.gitignore | 1 + termux-shared/LICENSE.md | 65 + termux-shared/build.gradle | 74 + termux-shared/proguard-rules.pro | 10 + .../shared/ExampleInstrumentedTest.java | 26 + termux-shared/src/main/AndroidManifest.xml | 5 + .../shared/activities/ReportActivity.java | 472 +++ .../shared/activities/TextIOActivity.java | 278 ++ .../termux/shared/android/SELinuxUtils.java | 96 + .../com/termux/shared/crash/CrashHandler.java | 101 + .../termux/shared/crash/TermuxCrashUtils.java | 31 + .../com/termux/shared/data/DataUtils.java | 209 ++ .../com/termux/shared/data/IntentUtils.java | 166 ++ .../java/com/termux/shared/data/UrlUtils.java | 104 + .../com/termux/shared/file/FileUtils.java | 1858 ++++++++++++ .../termux/shared/file/TermuxFileUtils.java | 377 +++ .../file/filesystem/FileAttributes.java | 414 +++ .../shared/file/filesystem/FileKey.java | 68 + .../file/filesystem/FilePermission.java | 88 + .../file/filesystem/FilePermissions.java | 145 + .../shared/file/filesystem/FileTime.java | 156 + .../shared/file/filesystem/FileType.java | 31 + .../shared/file/filesystem/FileTypes.java | 116 + .../file/filesystem/NativeDispatcher.java | 58 + .../shared/file/filesystem/UnixConstants.java | 149 + .../shared/file/tests/FileUtilsTests.java | 310 ++ .../shared/interact/MessageDialogUtils.java | 99 + .../termux/shared/interact/ShareUtils.java | 224 ++ .../shared/interact/TextInputDialogUtils.java | 78 + .../java/com/termux/shared/logger/Logger.java | 448 +++ .../termux/shared/markdown/MarkdownUtils.java | 199 ++ .../shared/models/ExecutionCommand.java | 552 ++++ .../com/termux/shared/models/ReportInfo.java | 97 + .../termux/shared/models/ResultConfig.java | 170 ++ .../com/termux/shared/models/ResultData.java | 258 ++ .../com/termux/shared/models/TextIOInfo.java | 254 ++ .../termux/shared/models/errors/Errno.java | 110 + .../termux/shared/models/errors/Error.java | 262 ++ .../shared/models/errors/FileUtilsErrno.java | 106 + .../shared/models/errors/FunctionErrno.java | 21 + .../models/errors/ResultSenderErrno.java | 20 + .../notification/NotificationUtils.java | 144 + .../notification/TermuxNotificationUtils.java | 38 + .../termux/shared/packages/PackageUtils.java | 471 +++ .../shared/packages/PermissionUtils.java | 114 + .../shared/reflection/ReflectionUtils.java | 275 ++ .../preferences/SharedPreferenceUtils.java | 400 +++ .../TermuxAPIAppSharedPreferences.java | 87 + .../TermuxAppSharedPreferences.java | 237 ++ .../TermuxBootAppSharedPreferences.java | 87 + .../TermuxFloatAppSharedPreferences.java | 172 ++ .../TermuxPreferenceConstants.java | 294 ++ .../TermuxStylingAppSharedPreferences.java | 87 + .../TermuxTaskerAppSharedPreferences.java | 97 + .../TermuxWidgetAppSharedPreferences.java | 106 + .../settings/properties/SharedProperties.java | 577 ++++ .../properties/SharedPropertiesParser.java | 23 + .../properties/TermuxPropertyConstants.java | 462 +++ .../properties/TermuxSharedProperties.java | 632 ++++ .../com/termux/shared/shell/ResultSender.java | 352 +++ .../shared/shell/ShellEnvironmentClient.java | 47 + .../com/termux/shared/shell/ShellUtils.java | 55 + .../termux/shared/shell/StreamGobbler.java | 325 ++ .../termux/shared/shell/TermuxSession.java | 269 ++ .../shell/TermuxShellEnvironmentClient.java | 33 + .../termux/shared/shell/TermuxShellUtils.java | 237 ++ .../com/termux/shared/shell/TermuxTask.java | 315 ++ .../TermuxTerminalSessionClientBase.java | 88 + .../TermuxTerminalViewClientBase.java | 127 + .../shared/terminal/io/BellHandler.java | 79 + .../shared/terminal/io/TerminalExtraKeys.java | 77 + .../terminal/io/extrakeys/ExtraKeyButton.java | 151 + .../io/extrakeys/ExtraKeysConstants.java | 211 ++ .../terminal/io/extrakeys/ExtraKeysInfo.java | 211 ++ .../terminal/io/extrakeys/ExtraKeysView.java | 656 +++++ .../terminal/io/extrakeys/SpecialButton.java | 52 + .../io/extrakeys/SpecialButtonState.java | 49 + .../termux/shared/termux/AndroidUtils.java | 229 ++ .../termux/shared/termux/TermuxConstants.java | 1240 ++++++++ .../com/termux/shared/termux/TermuxUtils.java | 545 ++++ .../com/termux/shared/view/KeyboardUtils.java | 195 ++ .../com/termux/shared/view/ViewUtils.java | 238 ++ .../src/main/res/drawable/ic_copy.xml | 5 + .../res/drawable/ic_error_notification.xml | 37 + .../src/main/res/drawable/ic_share.xml | 5 + .../src/main/res/layout/activity_report.xml | 21 + .../src/main/res/layout/activity_text_io.xml | 93 + .../main/res/layout/dialog_show_message.xml | 50 + .../markdown_adapter_node_code_block.xml | 26 + .../layout/markdown_adapter_node_default.xml | 15 + .../src/main/res/layout/partial_toolbar.xml | 22 + .../src/main/res/menu/menu_report.xml | 20 + .../src/main/res/menu/menu_text_io.xml | 20 + .../src/main/res/raw/apt_info_script.sh | 59 + termux-shared/src/main/res/raw/bell.ogg | Bin 0 -> 5090 bytes .../src/main/res/values-night/themes.xml | 19 + termux-shared/src/main/res/values/colors.xml | 18 + termux-shared/src/main/res/values/dimens.xml | 10 + termux-shared/src/main/res/values/strings.xml | 118 + termux-shared/src/main/res/values/themes.xml | 42 + 271 files changed, 39630 insertions(+), 2 deletions(-) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 LICENSE.md create mode 100644 app/build.gradle create mode 100644 app/dev_keystore.jks create mode 100644 app/proguard-rules.pro create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/cpp/Android.mk create mode 100644 app/src/main/cpp/termux-bootstrap-zip.S create mode 100644 app/src/main/cpp/termux-bootstrap.c create mode 100644 app/src/main/java/com/termux/app/RunCommandService.java create mode 100644 app/src/main/java/com/termux/app/TermuxActivity.java create mode 100644 app/src/main/java/com/termux/app/TermuxApplication.java create mode 100644 app/src/main/java/com/termux/app/TermuxInstaller.java create mode 100644 app/src/main/java/com/termux/app/TermuxOpenReceiver.java create mode 100644 app/src/main/java/com/termux/app/TermuxService.java create mode 100644 app/src/main/java/com/termux/app/activities/HelpActivity.java create mode 100644 app/src/main/java/com/termux/app/activities/SettingsActivity.java create mode 100644 app/src/main/java/com/termux/app/fragments/settings/TermuxAPIPreferencesFragment.java create mode 100644 app/src/main/java/com/termux/app/fragments/settings/TermuxFloatPreferencesFragment.java create mode 100644 app/src/main/java/com/termux/app/fragments/settings/TermuxPreferencesFragment.java create mode 100644 app/src/main/java/com/termux/app/fragments/settings/TermuxTaskerPreferencesFragment.java create mode 100644 app/src/main/java/com/termux/app/fragments/settings/TermuxWidgetPreferencesFragment.java create mode 100644 app/src/main/java/com/termux/app/fragments/settings/termux/DebuggingPreferencesFragment.java create mode 100644 app/src/main/java/com/termux/app/fragments/settings/termux/TerminalIOPreferencesFragment.java create mode 100644 app/src/main/java/com/termux/app/fragments/settings/termux/TerminalViewPreferencesFragment.java create mode 100644 app/src/main/java/com/termux/app/fragments/settings/termux_api/DebuggingPreferencesFragment.java create mode 100644 app/src/main/java/com/termux/app/fragments/settings/termux_float/DebuggingPreferencesFragment.java create mode 100644 app/src/main/java/com/termux/app/fragments/settings/termux_tasker/DebuggingPreferencesFragment.java create mode 100644 app/src/main/java/com/termux/app/fragments/settings/termux_widget/DebuggingPreferencesFragment.java create mode 100644 app/src/main/java/com/termux/app/models/UserAction.java create mode 100644 app/src/main/java/com/termux/app/settings/properties/TermuxAppSharedProperties.java create mode 100644 app/src/main/java/com/termux/app/terminal/TermuxActivityRootView.java create mode 100644 app/src/main/java/com/termux/app/terminal/TermuxSessionsListViewController.java create mode 100644 app/src/main/java/com/termux/app/terminal/TermuxTerminalSessionClient.java create mode 100644 app/src/main/java/com/termux/app/terminal/TermuxTerminalViewClient.java create mode 100644 app/src/main/java/com/termux/app/terminal/io/FullScreenWorkAround.java create mode 100644 app/src/main/java/com/termux/app/terminal/io/KeyboardShortcut.java create mode 100644 app/src/main/java/com/termux/app/terminal/io/TerminalToolbarViewPager.java create mode 100644 app/src/main/java/com/termux/app/terminal/io/TermuxTerminalExtraKeys.java create mode 100644 app/src/main/java/com/termux/app/utils/CrashUtils.java create mode 100644 app/src/main/java/com/termux/app/utils/PluginUtils.java create mode 100644 app/src/main/java/com/termux/filepicker/TermuxDocumentsProvider.java create mode 100644 app/src/main/java/com/termux/filepicker/TermuxFileReceiverActivity.java create mode 100644 app/src/main/res/drawable/banner.png create mode 100644 app/src/main/res/drawable/current_session.xml create mode 100644 app/src/main/res/drawable/current_session_black.xml create mode 100644 app/src/main/res/drawable/ic_foreground.xml create mode 100644 app/src/main/res/drawable/ic_new_session.xml create mode 100644 app/src/main/res/drawable/ic_service_notification.xml create mode 100644 app/src/main/res/drawable/ic_settings.xml create mode 100644 app/src/main/res/drawable/session_background_black_selected.xml create mode 100644 app/src/main/res/drawable/session_background_selected.xml create mode 100644 app/src/main/res/drawable/session_ripple.xml create mode 100644 app/src/main/res/drawable/session_ripple_black.xml create mode 100644 app/src/main/res/drawable/terminal_scroll_shape.xml create mode 100644 app/src/main/res/layout/activity_settings.xml create mode 100644 app/src/main/res/layout/activity_termux.xml create mode 100644 app/src/main/res/layout/item_terminal_sessions_list.xml create mode 100644 app/src/main/res/layout/preference_markdown_text.xml create mode 100644 app/src/main/res/layout/view_terminal_toolbar_extra_keys.xml create mode 100644 app/src/main/res/layout/view_terminal_toolbar_text_input.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png create mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/styles.xml create mode 100644 app/src/main/res/xml/root_preferences.xml create mode 100644 app/src/main/res/xml/shortcuts.xml create mode 100644 app/src/main/res/xml/termux_api_debugging_preferences.xml create mode 100644 app/src/main/res/xml/termux_api_preferences.xml create mode 100644 app/src/main/res/xml/termux_debugging_preferences.xml create mode 100644 app/src/main/res/xml/termux_float_debugging_preferences.xml create mode 100644 app/src/main/res/xml/termux_float_preferences.xml create mode 100644 app/src/main/res/xml/termux_preferences.xml create mode 100644 app/src/main/res/xml/termux_tasker_debugging_preferences.xml create mode 100644 app/src/main/res/xml/termux_tasker_preferences.xml create mode 100644 app/src/main/res/xml/termux_terminal_io_preferences.xml create mode 100644 app/src/main/res/xml/termux_terminal_view_preferences.xml create mode 100644 app/src/main/res/xml/termux_widget_debugging_preferences.xml create mode 100644 app/src/main/res/xml/termux_widget_preferences.xml create mode 100644 app/src/test/java/com/termux/app/TermuxActivityTest.java create mode 100644 app/src/test/java/com/termux/filepicker/TermuxFileReceiverActivityTest.java create mode 100755 art/copy-to-other-apps.sh create mode 100644 art/feature-graphic.svg create mode 100755 art/generate-big-icon.sh create mode 100755 art/generate-feature-graphic.sh create mode 100755 art/generate-launcher-images.sh create mode 100755 art/generate-tv-banner.sh create mode 100644 art/ic_launcher.svg create mode 100644 art/ic_launcher2.png create mode 100644 art/ic_launcher2_round.png create mode 100644 art/tv-banner.svg create mode 100644 build.gradle create mode 100644 fastlane/metadata/android/en-US/full_description.txt create mode 100644 fastlane/metadata/android/en-US/images/icon.png create mode 100644 fastlane/metadata/android/en-US/images/phoneScreenshots/1.jpg create mode 100644 fastlane/metadata/android/en-US/images/phoneScreenshots/2.jpg create mode 100644 fastlane/metadata/android/en-US/images/phoneScreenshots/3.jpg create mode 100644 fastlane/metadata/android/en-US/images/phoneScreenshots/4.jpg create mode 100644 fastlane/metadata/android/en-US/short_description.txt create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 jitpack.yml create mode 100644 settings.gradle create mode 100644 terminal-emulator/build.gradle create mode 100644 terminal-emulator/proguard-rules.pro create mode 100644 terminal-emulator/src/main/AndroidManifest.xml create mode 100644 terminal-emulator/src/main/java/com/termux/terminal/ByteQueue.java create mode 100644 terminal-emulator/src/main/java/com/termux/terminal/JNI.java create mode 100644 terminal-emulator/src/main/java/com/termux/terminal/KeyHandler.java create mode 100644 terminal-emulator/src/main/java/com/termux/terminal/Logger.java create mode 100644 terminal-emulator/src/main/java/com/termux/terminal/TerminalBuffer.java create mode 100644 terminal-emulator/src/main/java/com/termux/terminal/TerminalColorScheme.java create mode 100644 terminal-emulator/src/main/java/com/termux/terminal/TerminalColors.java create mode 100644 terminal-emulator/src/main/java/com/termux/terminal/TerminalEmulator.java create mode 100644 terminal-emulator/src/main/java/com/termux/terminal/TerminalOutput.java create mode 100644 terminal-emulator/src/main/java/com/termux/terminal/TerminalRow.java create mode 100644 terminal-emulator/src/main/java/com/termux/terminal/TerminalSession.java create mode 100644 terminal-emulator/src/main/java/com/termux/terminal/TerminalSessionClient.java create mode 100644 terminal-emulator/src/main/java/com/termux/terminal/TextStyle.java create mode 100644 terminal-emulator/src/main/java/com/termux/terminal/WcWidth.java create mode 100644 terminal-emulator/src/main/jni/Android.mk create mode 100644 terminal-emulator/src/main/jni/termux.c create mode 100644 terminal-emulator/src/test/java/com/termux/terminal/ApcTest.java create mode 100644 terminal-emulator/src/test/java/com/termux/terminal/ByteQueueTest.java create mode 100644 terminal-emulator/src/test/java/com/termux/terminal/ControlSequenceIntroducerTest.java create mode 100644 terminal-emulator/src/test/java/com/termux/terminal/CursorAndScreenTest.java create mode 100644 terminal-emulator/src/test/java/com/termux/terminal/DecSetTest.java create mode 100644 terminal-emulator/src/test/java/com/termux/terminal/DeviceControlStringTest.java create mode 100644 terminal-emulator/src/test/java/com/termux/terminal/HistoryTest.java create mode 100644 terminal-emulator/src/test/java/com/termux/terminal/KeyHandlerTest.java create mode 100644 terminal-emulator/src/test/java/com/termux/terminal/OperatingSystemControlTest.java create mode 100644 terminal-emulator/src/test/java/com/termux/terminal/RectangularAreasTest.java create mode 100644 terminal-emulator/src/test/java/com/termux/terminal/ResizeTest.java create mode 100644 terminal-emulator/src/test/java/com/termux/terminal/ScreenBufferTest.java create mode 100644 terminal-emulator/src/test/java/com/termux/terminal/ScrollRegionTest.java create mode 100644 terminal-emulator/src/test/java/com/termux/terminal/TerminalRowTest.java create mode 100644 terminal-emulator/src/test/java/com/termux/terminal/TerminalTest.java create mode 100644 terminal-emulator/src/test/java/com/termux/terminal/TerminalTestCase.java create mode 100644 terminal-emulator/src/test/java/com/termux/terminal/TextStyleTest.java create mode 100644 terminal-emulator/src/test/java/com/termux/terminal/UnicodeInputTest.java create mode 100644 terminal-emulator/src/test/java/com/termux/terminal/WcWidthTest.java create mode 100644 terminal-view/build.gradle create mode 100644 terminal-view/proguard-rules.pro create mode 100644 terminal-view/src/main/AndroidManifest.xml create mode 100644 terminal-view/src/main/java/com/termux/view/GestureAndScaleRecognizer.java create mode 100644 terminal-view/src/main/java/com/termux/view/TerminalRenderer.java create mode 100644 terminal-view/src/main/java/com/termux/view/TerminalView.java create mode 100644 terminal-view/src/main/java/com/termux/view/TerminalViewClient.java create mode 100644 terminal-view/src/main/java/com/termux/view/textselection/CursorController.java create mode 100644 terminal-view/src/main/java/com/termux/view/textselection/TextSelectionCursorController.java create mode 100644 terminal-view/src/main/java/com/termux/view/textselection/TextSelectionHandleView.java create mode 100644 terminal-view/src/main/res/drawable/text_select_handle_left_material.xml create mode 100644 terminal-view/src/main/res/drawable/text_select_handle_right_material.xml create mode 100644 terminal-view/src/main/res/values/strings.xml create mode 100644 termux-shared/.gitignore create mode 100644 termux-shared/LICENSE.md create mode 100644 termux-shared/build.gradle create mode 100644 termux-shared/proguard-rules.pro create mode 100644 termux-shared/src/androidTest/java/com/termux/shared/ExampleInstrumentedTest.java create mode 100644 termux-shared/src/main/AndroidManifest.xml create mode 100644 termux-shared/src/main/java/com/termux/shared/activities/ReportActivity.java create mode 100644 termux-shared/src/main/java/com/termux/shared/activities/TextIOActivity.java create mode 100644 termux-shared/src/main/java/com/termux/shared/android/SELinuxUtils.java create mode 100644 termux-shared/src/main/java/com/termux/shared/crash/CrashHandler.java create mode 100644 termux-shared/src/main/java/com/termux/shared/crash/TermuxCrashUtils.java create mode 100644 termux-shared/src/main/java/com/termux/shared/data/DataUtils.java create mode 100644 termux-shared/src/main/java/com/termux/shared/data/IntentUtils.java create mode 100644 termux-shared/src/main/java/com/termux/shared/data/UrlUtils.java create mode 100644 termux-shared/src/main/java/com/termux/shared/file/FileUtils.java create mode 100644 termux-shared/src/main/java/com/termux/shared/file/TermuxFileUtils.java create mode 100644 termux-shared/src/main/java/com/termux/shared/file/filesystem/FileAttributes.java create mode 100644 termux-shared/src/main/java/com/termux/shared/file/filesystem/FileKey.java create mode 100644 termux-shared/src/main/java/com/termux/shared/file/filesystem/FilePermission.java create mode 100644 termux-shared/src/main/java/com/termux/shared/file/filesystem/FilePermissions.java create mode 100644 termux-shared/src/main/java/com/termux/shared/file/filesystem/FileTime.java create mode 100644 termux-shared/src/main/java/com/termux/shared/file/filesystem/FileType.java create mode 100644 termux-shared/src/main/java/com/termux/shared/file/filesystem/FileTypes.java create mode 100644 termux-shared/src/main/java/com/termux/shared/file/filesystem/NativeDispatcher.java create mode 100644 termux-shared/src/main/java/com/termux/shared/file/filesystem/UnixConstants.java create mode 100644 termux-shared/src/main/java/com/termux/shared/file/tests/FileUtilsTests.java create mode 100644 termux-shared/src/main/java/com/termux/shared/interact/MessageDialogUtils.java create mode 100644 termux-shared/src/main/java/com/termux/shared/interact/ShareUtils.java create mode 100644 termux-shared/src/main/java/com/termux/shared/interact/TextInputDialogUtils.java create mode 100644 termux-shared/src/main/java/com/termux/shared/logger/Logger.java create mode 100644 termux-shared/src/main/java/com/termux/shared/markdown/MarkdownUtils.java create mode 100644 termux-shared/src/main/java/com/termux/shared/models/ExecutionCommand.java create mode 100644 termux-shared/src/main/java/com/termux/shared/models/ReportInfo.java create mode 100644 termux-shared/src/main/java/com/termux/shared/models/ResultConfig.java create mode 100644 termux-shared/src/main/java/com/termux/shared/models/ResultData.java create mode 100644 termux-shared/src/main/java/com/termux/shared/models/TextIOInfo.java create mode 100644 termux-shared/src/main/java/com/termux/shared/models/errors/Errno.java create mode 100644 termux-shared/src/main/java/com/termux/shared/models/errors/Error.java create mode 100644 termux-shared/src/main/java/com/termux/shared/models/errors/FileUtilsErrno.java create mode 100644 termux-shared/src/main/java/com/termux/shared/models/errors/FunctionErrno.java create mode 100644 termux-shared/src/main/java/com/termux/shared/models/errors/ResultSenderErrno.java create mode 100644 termux-shared/src/main/java/com/termux/shared/notification/NotificationUtils.java create mode 100644 termux-shared/src/main/java/com/termux/shared/notification/TermuxNotificationUtils.java create mode 100644 termux-shared/src/main/java/com/termux/shared/packages/PackageUtils.java create mode 100644 termux-shared/src/main/java/com/termux/shared/packages/PermissionUtils.java create mode 100644 termux-shared/src/main/java/com/termux/shared/reflection/ReflectionUtils.java create mode 100644 termux-shared/src/main/java/com/termux/shared/settings/preferences/SharedPreferenceUtils.java create mode 100644 termux-shared/src/main/java/com/termux/shared/settings/preferences/TermuxAPIAppSharedPreferences.java create mode 100644 termux-shared/src/main/java/com/termux/shared/settings/preferences/TermuxAppSharedPreferences.java create mode 100644 termux-shared/src/main/java/com/termux/shared/settings/preferences/TermuxBootAppSharedPreferences.java create mode 100644 termux-shared/src/main/java/com/termux/shared/settings/preferences/TermuxFloatAppSharedPreferences.java create mode 100644 termux-shared/src/main/java/com/termux/shared/settings/preferences/TermuxPreferenceConstants.java create mode 100644 termux-shared/src/main/java/com/termux/shared/settings/preferences/TermuxStylingAppSharedPreferences.java create mode 100644 termux-shared/src/main/java/com/termux/shared/settings/preferences/TermuxTaskerAppSharedPreferences.java create mode 100644 termux-shared/src/main/java/com/termux/shared/settings/preferences/TermuxWidgetAppSharedPreferences.java create mode 100644 termux-shared/src/main/java/com/termux/shared/settings/properties/SharedProperties.java create mode 100644 termux-shared/src/main/java/com/termux/shared/settings/properties/SharedPropertiesParser.java create mode 100644 termux-shared/src/main/java/com/termux/shared/settings/properties/TermuxPropertyConstants.java create mode 100644 termux-shared/src/main/java/com/termux/shared/settings/properties/TermuxSharedProperties.java create mode 100644 termux-shared/src/main/java/com/termux/shared/shell/ResultSender.java create mode 100644 termux-shared/src/main/java/com/termux/shared/shell/ShellEnvironmentClient.java create mode 100644 termux-shared/src/main/java/com/termux/shared/shell/ShellUtils.java create mode 100644 termux-shared/src/main/java/com/termux/shared/shell/StreamGobbler.java create mode 100644 termux-shared/src/main/java/com/termux/shared/shell/TermuxSession.java create mode 100644 termux-shared/src/main/java/com/termux/shared/shell/TermuxShellEnvironmentClient.java create mode 100644 termux-shared/src/main/java/com/termux/shared/shell/TermuxShellUtils.java create mode 100644 termux-shared/src/main/java/com/termux/shared/shell/TermuxTask.java create mode 100644 termux-shared/src/main/java/com/termux/shared/terminal/TermuxTerminalSessionClientBase.java create mode 100644 termux-shared/src/main/java/com/termux/shared/terminal/TermuxTerminalViewClientBase.java create mode 100644 termux-shared/src/main/java/com/termux/shared/terminal/io/BellHandler.java create mode 100644 termux-shared/src/main/java/com/termux/shared/terminal/io/TerminalExtraKeys.java create mode 100644 termux-shared/src/main/java/com/termux/shared/terminal/io/extrakeys/ExtraKeyButton.java create mode 100644 termux-shared/src/main/java/com/termux/shared/terminal/io/extrakeys/ExtraKeysConstants.java create mode 100644 termux-shared/src/main/java/com/termux/shared/terminal/io/extrakeys/ExtraKeysInfo.java create mode 100644 termux-shared/src/main/java/com/termux/shared/terminal/io/extrakeys/ExtraKeysView.java create mode 100644 termux-shared/src/main/java/com/termux/shared/terminal/io/extrakeys/SpecialButton.java create mode 100644 termux-shared/src/main/java/com/termux/shared/terminal/io/extrakeys/SpecialButtonState.java create mode 100644 termux-shared/src/main/java/com/termux/shared/termux/AndroidUtils.java create mode 100644 termux-shared/src/main/java/com/termux/shared/termux/TermuxConstants.java create mode 100644 termux-shared/src/main/java/com/termux/shared/termux/TermuxUtils.java create mode 100644 termux-shared/src/main/java/com/termux/shared/view/KeyboardUtils.java create mode 100644 termux-shared/src/main/java/com/termux/shared/view/ViewUtils.java create mode 100644 termux-shared/src/main/res/drawable/ic_copy.xml create mode 100644 termux-shared/src/main/res/drawable/ic_error_notification.xml create mode 100644 termux-shared/src/main/res/drawable/ic_share.xml create mode 100644 termux-shared/src/main/res/layout/activity_report.xml create mode 100644 termux-shared/src/main/res/layout/activity_text_io.xml create mode 100644 termux-shared/src/main/res/layout/dialog_show_message.xml create mode 100644 termux-shared/src/main/res/layout/markdown_adapter_node_code_block.xml create mode 100644 termux-shared/src/main/res/layout/markdown_adapter_node_default.xml create mode 100644 termux-shared/src/main/res/layout/partial_toolbar.xml create mode 100644 termux-shared/src/main/res/menu/menu_report.xml create mode 100644 termux-shared/src/main/res/menu/menu_text_io.xml create mode 100644 termux-shared/src/main/res/raw/apt_info_script.sh create mode 100644 termux-shared/src/main/res/raw/bell.ogg create mode 100755 termux-shared/src/main/res/values-night/themes.xml create mode 100644 termux-shared/src/main/res/values/colors.xml create mode 100644 termux-shared/src/main/res/values/dimens.xml create mode 100644 termux-shared/src/main/res/values/strings.xml create mode 100644 termux-shared/src/main/res/values/themes.xml diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..2bbadef --- /dev/null +++ b/.editorconfig @@ -0,0 +1,20 @@ +# Copying and distribution of this file, with or without modification, +# are permitted in any medium without royalty provided this notice is +# preserved. This file is offered as-is, without any warranty. + +# EditorConfig +# http://EditorConfig.org + +# top-most EditorConfig file +root = true + +[*] +end_of_line = lf +insert_final_newline = true +charset = utf-8 +indent_style = space +indent_size = 4 + +[*.y{a,}ml] +indent_size = 2 +indent_style = space diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..2537384 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +* text=auto +*.bat text eol=crlf +*.gradle text eol=lf +*.mk text eol=lf +*.sh text eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cb4634f --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +# From https://gist.github.com/iainconnor/8605514 +# with the addition of the /captures below. +/captures + +# Built application files +build/ +release/ +*.apk +*.so +.externalNativeBuild +.cxx +*.zip + +# Crashlytics configuations +com_crashlytics_export_strings.xml + +# Local configuration file (sdk path, etc) +local.properties + +# Gradle generated files +.gradle/ + +# Signing files +.signing/ + +# Intellij +.idea/ +*.iml + +# OS-specific files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +.swp +ehthumbs.db +Thumbs.db diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..36cd48f --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,6 @@ +The `termux/termux-app` repository is released under [GPLv3 only](https://www.gnu.org/licenses/gpl-3.0.html) license. + +### Exceptions + +- [Terminal Emulator for Android](https://github.com/jackpal/Android-Terminal-Emulator) code is used which is released under [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). Check [`terminal-view`](terminal-view) and [`terminal-emulator`](terminal-emulator) libraries. +- Check [`termux-shared/LICENSE.md`](termux-shared/LICENSE.md) for `termux-shared` library related exceptions. diff --git a/README.md b/README.md index 1a8479b..3c6b2d1 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,256 @@ -# termux +# Termux application -Android Terminal App \ No newline at end of file +[![Build status](https://github.com/termux/termux-app/workflows/Build/badge.svg)](https://github.com/termux/termux-app/actions) +[![Testing status](https://github.com/termux/termux-app/workflows/Unit%20tests/badge.svg)](https://github.com/termux/termux-app/actions) +[![Join the chat at https://gitter.im/termux/termux](https://badges.gitter.im/termux/termux.svg)](https://gitter.im/termux/termux) +[![Join the Termux discord server](https://img.shields.io/discord/641256914684084234.svg?label=&logo=discord&logoColor=ffffff&color=5865F2)](https://discord.gg/HXpF69X) +[![Termux library releases at Jitpack](https://jitpack.io/v/termux/termux-app.svg)](https://jitpack.io/#termux/termux-app) + + +[Termux](https://termux.com) is an Android terminal application and Linux environment. + +Note that this repository is for the app itself (the user interface and the terminal emulation). For the packages installable inside the app, see [termux/termux-packages](https://github.com/termux/termux-packages). + +Quick how-to about Termux package management is available at [Package Management](https://github.com/termux/termux-packages/wiki/Package-Management). It also has info on how to fix **`repository is under maintenance or down`** errors when running `apt` or `pkg` commands. + +*** + +**@termux is looking for Termux Application maintainers for implementing new features, fixing bugs and reviewing pull requests since the current one (@fornwall) is inactive.** + +Issue https://github.com/termux/termux-app/issues/1072 needs extra attention. + +*** + +### Contents +- [Termux App and Plugins](#Termux-App-and-Plugins) +- [Installation](#Installation) +- [Uninstallation](#Uninstallation) +- [Important Links](#Important-Links) +- [Debugging](#Debugging) +- [For Maintainers and Contributors](#For-Maintainers-and-Contributors) +- [Forking](#Forking) +## + + + +## Termux App and Plugins + +The core [Termux](https://github.com/termux/termux-app) app comes with the following optional plugin apps. + +- [Termux:API](https://github.com/termux/termux-api) +- [Termux:Boot](https://github.com/termux/termux-boot) +- [Termux:Float](https://github.com/termux/termux-float) +- [Termux:Styling](https://github.com/termux/termux-styling) +- [Termux:Tasker](https://github.com/termux/termux-tasker) +- [Termux:Widget](https://github.com/termux/termux-widget) +## + + + +## Installation + +Latest version is `v0.118.1`. + +Termux can be obtained through various sources listed below for **only** Android `>= 7`. Support was dropped for Android `5` and `6` on [2020-01-01](https://www.reddit.com/r/termux/comments/dnzdbs/end_of_android56_support_on_20200101/) at `v0.83`, old builds are available on [archive.org](https://archive.org/details/termux-repositories-legacy). + +The APK files of different sources are signed with different signature keys. The `Termux` app and all its plugins use the same [`sharedUserId`](https://developer.android.com/guide/topics/manifest/manifest-element) `com.termux` and so all their APKs installed on a device must have been signed with the same signature key to work together and so they must all be installed from the same source. Do not attempt to mix them together, i.e do not try to install an app or plugin from `F-Droid` and another one from a different source like `Github`. Android Package Manager will also normally not allow installation of APKs with different signatures and you will get errors on installation like `App not installed`, `Failed to install due to an unknown error`, `INSTALL_FAILED_UPDATE_INCOMPATIBLE`, `INSTALL_FAILED_SHARED_USER_INCOMPATIBLE`, `signatures do not match previously installed version`, etc. This restriction can be bypassed with root or with custom roms. + +If you wish to install from a different source, then you must **uninstall any and all existing Termux or its plugin app APKs** from your device first, then install all new APKs from the same new source. Check [Uninstallation](#Uninstallation) section for details. You may also want to consider [Backing up Termux](https://wiki.termux.com/wiki/Backing_up_Termux) before the uninstallation so that you can restore it after re-installing from Termux different source. + +In the following paragraphs, *"bootstrap"* refers to the minimal packages that are shipped with the `termux-app` itself to start a working shell environment. Its zips are built and released [here](https://github.com/termux/termux-packages/releases). + +### F-Droid + +Termux application can be obtained from `F-Droid` from [here](https://f-droid.org/en/packages/com.termux/). + +You **do not** need to download the `F-Droid` app (via the `Download F-Droid` link) to install Termux. You can download the Termux APK directly from the site by clicking the `Download APK` link at the bottom of each version section. + +It usually takes a few days (or even a week or more) for updates to be available on `F-Droid` once an update has been released on `Github`. The `F-Droid` releases are built and published by `F-Droid` once they [detect](https://gitlab.com/fdroid/fdroiddata/-/blob/master/metadata/com.termux.yml) a new `Github` release. The Termux maintainers **do not** have any control over the building and publishing of the Termux apps on `F-Droid`. Moreover, the Termux maintainers also do not have access to the APK signing keys of `F-Droid` releases, so we cannot release an APK ourselves on `Github` that would be compatible with `F-Droid` releases. + +The `F-Droid` app often may not notify you of updates and you will manually have to do a pull down swipe action in the `Updates` tab of the app for it to check updates. Make sure battery optimizations are disabled for the app, check https://dontkillmyapp.com/ for details on how to do that. + +Only a universal APK is released, which will work on all supported architectures. The APK and bootstrap installation size will be `~180MB`. `F-Droid` does [not support](https://github.com/termux/termux-app/pull/1904) architecture specific APKs. + +### Github + +Termux application can be obtained on `Github` either from [`Github Releases`](https://github.com/termux/termux-app/releases) for version `>= 0.118.0` or from [`Github Build`](https://github.com/termux/termux-app/actions/workflows/debug_build.yml) action workflows. + +The APKs for `Github Releases` will be listed under `Assets` drop-down of a release. These are automatically attached when a new version is released. + +The APKs for `Github Build` action workflows will be listed under `Artifacts` section of a workflow run. These are created for each commit/push done to the repository and can be used by users who don't want to wait for releases and want to try out the latest features immediately or want to test their pull requests. Note that for action workflows, you need to be [**logged into a `Github` account**](https://github.com/login) for the `Artifacts` links to be enabled/clickable. If you are using the [`Github` app](https://github.com/mobile), then make sure to open workflow link in a browser like Chrome or Firefox that has your Github account logged in since the in-app browser may not be logged in. + +The APKs for both of these are [`debuggable`](https://developer.android.com/studio/debug) and are compatible with each other but they are not compatible with other sources. + +Both universal and architecture specific APKs are released. The APK and bootstrap installation size will be `~180MB` if using universal and `~120MB` if using architecture specific. Check [here](https://github.com/termux/termux-app/issues/2153) for details. + +### Google Play Store **(Deprecated)** + +**Termux and its plugins are no longer updated on [Google Play Store](https://play.google.com/store/apps/details?id=com.termux) due to [android 10 issues](https://github.com/termux/termux-packages/wiki/Termux-and-Android-10) and have been deprecated.** The last version released for Android `>= 7` was `v0.101`. **It is highly recommended to not install Termux apps from Play Store any more.** + +There are plans for **unpublishing** the Termux app and all its plugins on Play Store soon so that new users cannot install it and for **disabling** the Termux apps with updates so that existing users **cannot continue using outdated versions**. You are encouraged to move to `F-Droid` or `Github` builds as soon as possible. + +You **will not need to buy plugins again** if you bought them on Play Store. All plugins are free on `F-Droid` and `Github`. + +You can backup all your data under `$HOME/` and `$PREFIX/` before changing installation source, and then restore it afterwards, by following instructions at [Backing up Termux](https://wiki.termux.com/wiki/Backing_up_Termux) before the uninstallation. + +There is currently no work being done to solve android `10` issues and *working* updates will not be resumed on Google Play Store any time soon. We will continue targeting sdk `28` for now. So there is not much point in staying on Play Store builds and waiting for updates to be resumed. If for some reason you don't want to move to `F-Droid` or `Github` sources for now, then at least check [Package Management](https://github.com/termux/termux-packages/wiki/Package-Management) to **change your mirror**, otherwise, you will get **`repository is under maintenance or down`** errors when running `apt` or `pkg` commands. After that, it is also **highly advisable** to run `pkg upgrade` command to update all packages to the latest available versions, or at least update `termux-tools` package with `pkg install termux-tools` command. + +Note that by upgrading old packages to latest versions, like that of `python` may break your setups/scripts since they may not be compatible anymore. Moreover, you will not be able to downgrade the package versions since termux repos only keep the latest version and you will have to manually rebuild the old versions of the packages if required as per https://github.com/termux/termux-packages/wiki/Building-packages. + +If you plan on staying on Play Store sources in future as well, then you may want to **disable automatic updates in Play Store** for Termux apps, since if and when updates to disable Termux apps are released, then **you will not be able to downgrade** and **will be forced** to move since apps won't work anymore. Only a way to backup `termux-app` data may be provided. The `termux-tools` [version `>= 0.135`](https://github.com/termux/termux-packages/pull/7493) will also show a banner at the top of the terminal saying `You are likely using a very old version of Termux, probably installed from the Google Play Store.`, you can remove it by running `rm -f /data/data/com.termux/files/usr/etc/motd-playstore` and restarting the app. + +#### Why Disable? + +
+ + +- They should be disabled because deprecated things get removed and are not supported after some time, its the standard practice. It has been many months now since deprecation was announced and updates have not been released on Play Store since after `29 September 2020`. + +- The new versions have lots of **new features and fixes** which you can mostly check out in the Changelog of [`Github Releases`](https://github.com/termux/termux-app/releases) that you may be missing out. Extra detail is usually provided in [commit messages](https://github.com/termux/termux-app/commits/master). + +- Users on old versions are quite often reporting issues in multiple repositories and support forums that were **fixed months ago**, which we then have to deal with. The maintainers of @termux work in their free time, majorly for free, to work on development and provide support and having to re-re-deal with old issues takes away the already limited time from current work and is not possible to continue doing. Play Store page of `termux-app` has been filled with bad reviews of *"broken app"*, even though its clearly mentioned on the page that app is not being updated, yet users don't read and still install and report issues. + +- Asking people to pay for plugins when the `termux-app` at installation time is broken due to repository issues and has bugs is unethical. + +- Old versions don't have proper logging/debugging and crash report support. Reporting bugs without logs or detailed info is not helpful in solving them. + +- It's also easier for us to solve package related issues and provide custom functionality with app updates, which can't be done if users continue using old versions. For example, the [bintray shudown](https://github.com/termux/termux-packages/wiki/Package-Management) causing package install/update failures for new Play Store users is/was not an issue for F-Droid users since it is being shipped with updated bootstrap and repo info, hence no reported issues from new F-Droid users. +
+ +## + + + +## Uninstallation + +Uninstallation may be required if a user doesn't want Termux installed in their device anymore or is switching to a different [install source](#Installation). You may also want to consider [Backing up Termux](https://wiki.termux.com/wiki/Backing_up_Termux) before the uninstallation. + +To uninstall Termux completely, you must uninstall **any and all existing Termux or its plugin app APKs** listed in [Termux App and Plugins](#Termux-App-and-Plugins). + +Go to `Android Settings` -> `Applications` and then look for those apps. You can also use the search feature if it’s available on your device and search `termux` in the applications list. + +Even if you think you have not installed any of the plugins, it's strongly suggested to go through the application list in Android settings and double-check. +## + + + +## Important Links + +### Community +All community links are available [here](https://wiki.termux.com/wiki/Community). + +The main ones are the following. + +- [Termux Reddit community](https://reddit.com/r/termux) +- [Termux Matrix Channel](https://matrix.to/#termux_termux:gitter.im) +- [Termux Dev Matrix Channel](https://matrix.to/#termux_dev:gitter.im) +- [Termux Twitter](https://twitter.com/termux/) +- [Termux Reports Email](mailto:support@termux.dev) + +### Wikis + +- [Termux Wiki](https://wiki.termux.com/wiki/) +- [Termux App Wiki](https://github.com/termux/termux-app/wiki) +- [Termux Packages Wiki](https://github.com/termux/termux-packages/wiki) + +### Miscellaneous +- [FAQ](https://wiki.termux.com/wiki/FAQ) +- [Termux File System Layout](https://github.com/termux/termux-packages/wiki/Termux-file-system-layout) +- [Differences From Linux](https://wiki.termux.com/wiki/Differences_from_Linux) +- [Package Management](https://wiki.termux.com/wiki/Package_Management) +- [Remote Access](https://wiki.termux.com/wiki/Remote_Access) +- [Backing up Termux](https://wiki.termux.com/wiki/Backing_up_Termux) +- [Terminal Settings](https://wiki.termux.com/wiki/Terminal_Settings) +- [Touch Keyboard](https://wiki.termux.com/wiki/Touch_Keyboard) +- [Android Storage and Sharing Data with Other Apps](https://wiki.termux.com/wiki/Internal_and_external_storage) +- [Android APIs](https://wiki.termux.com/wiki/Termux:API) +- [Moved Termux Packages Hosting From Bintray to IPFS](https://github.com/termux/termux-packages/issues/6348) +- [Running Commands in Termux From Other Apps via `RUN_COMMAND` intent](https://github.com/termux/termux-app/wiki/RUN_COMMAND-Intent) +- [Termux and Android 10](https://github.com/termux/termux-packages/wiki/Termux-and-Android-10) + + +### Terminal + +
+ + +### Terminal resources + +- [XTerm control sequences](https://invisible-island.net/xterm/ctlseqs/ctlseqs.html) +- [vt100.net](https://vt100.net/) +- [Terminal codes (ANSI and terminfo equivalents)](https://wiki.bash-hackers.org/scripting/terminalcodes) + +### Terminal emulators + +- VTE (libvte): Terminal emulator widget for GTK+, mainly used in gnome-terminal. [Source](https://github.com/GNOME/vte), [Open Issues](https://bugzilla.gnome.org/buglist.cgi?quicksearch=product%3A%22vte%22+), and [All (including closed) issues](https://bugzilla.gnome.org/buglist.cgi?bug_status=RESOLVED&bug_status=VERIFIED&chfield=resolution&chfieldfrom=-2000d&chfieldvalue=FIXED&product=vte&resolution=FIXED). + +- iTerm 2: OS X terminal application. [Source](https://github.com/gnachman/iTerm2), [Issues](https://gitlab.com/gnachman/iterm2/issues) and [Documentation](https://iterm2.com/documentation.html) (which includes [iTerm2 proprietary escape codes](https://iterm2.com/documentation-escape-codes.html)). + +- Konsole: KDE terminal application. [Source](https://projects.kde.org/projects/kde/applications/konsole/repository), in particular [tests](https://projects.kde.org/projects/kde/applications/konsole/repository/revisions/master/show/tests), [Bugs](https://bugs.kde.org/buglist.cgi?bug_severity=critical&bug_severity=grave&bug_severity=major&bug_severity=crash&bug_severity=normal&bug_severity=minor&bug_status=UNCONFIRMED&bug_status=NEW&bug_status=ASSIGNED&bug_status=REOPENED&product=konsole) and [Wishes](https://bugs.kde.org/buglist.cgi?bug_severity=wishlist&bug_status=UNCONFIRMED&bug_status=NEW&bug_status=ASSIGNED&bug_status=REOPENED&product=konsole). + +- hterm: JavaScript terminal implementation from Chromium. [Source](https://github.com/chromium/hterm), including [tests](https://github.com/chromium/hterm/blob/master/js/hterm_vt_tests.js), and [Google group](https://groups.google.com/a/chromium.org/forum/#!forum/chromium-hterm). + +- xterm: The grandfather of terminal emulators. [Source](https://invisible-island.net/datafiles/release/xterm.tar.gz). + +- Connectbot: Android SSH client. [Source](https://github.com/connectbot/connectbot) + +- Android Terminal Emulator: Android terminal app which Termux terminal handling is based on. Inactive. [Source](https://github.com/jackpal/Android-Terminal-Emulator). +
+ +## + + + +### Debugging + +You can help debug problems of the `Termux` app and its plugins by setting appropriate `logcat` `Log Level` in `Termux` app settings -> `` -> `Debugging` -> `Log Level` (Requires `Termux` app version `>= 0.118.0`). The `Log Level` defaults to `Normal` and log level `Verbose` currently logs additional information. Its best to revert log level to `Normal` after you have finished debugging since private data may otherwise be passed to `logcat` during normal operation and moreover, additional logging increases execution time. + +The plugin apps **do not execute the commands themselves** but send execution intents to `Termux` app, which has its own log level which can be set in `Termux` app settings -> `Termux` -> `Debugging` -> `Log Level`. So you must set log level for both `Termux` and the respective plugin app settings to get all the info. + +Once log levels have been set, you can run the `logcat` command in `Termux` app terminal to view the logs in realtime (`Ctrl+c` to stop) or use `logcat -d > logcat.txt` to take a dump of the log. You can also view the logs from a PC over `ADB`. For more information, check official android `logcat` guide [here](https://developer.android.com/studio/command-line/logcat). + +Moreover, users can generate termux files `stat` info and `logcat` dump automatically too with terminal's long hold options menu `More` -> `Report Issue` option and selecting `YES` in the prompt shown to add debug info. This can be helpful for reporting and debugging other issues. If the report generated is too large, then `Save To File` option in context menu (3 dots on top right) of `ReportActivity` can be used and the file viewed/shared instead. + +Users must post complete report (optionally without sensitive info) when reporting issues. Issues opened with **(partial) screenshots of error reports** instead of text will likely be automatically closed/deleted. + +##### Log Levels + +- `Off` - Log nothing. +- `Normal` - Start logging error, warn and info messages and stacktraces. +- `Debug` - Start logging debug messages. +- `Verbose` - Start logging verbose messages. +## + + + +## For Maintainers and Contributors + +The [termux-shared](termux-shared) library was added in [`v0.109`](https://github.com/termux/termux-app/releases/tag/v0.109). It defines shared constants and utils of the Termux app and its plugins. It was created to allow for the removal of all hardcoded paths in the Termux app. Some of the termux plugins are using this as well and rest will in future. If you are contributing code that is using a constant or a util that may be shared, then define it in `termux-shared` library if it currently doesn't exist and reference it from there. Update the relevant changelogs as well. Pull requests using hardcoded values **will/should not** be accepted. Termux app and plugin specific classes must be added under `com.termux.shared.termux` package and general classes outside it. The [`termux-shared` `LICENSE`](termux-shared/LICENSE.md) must also be checked and updated if necessary when contributing code. The licenses of any external library or code must be honoured. + +The main Termux constants are defined by [`TermuxConstants`](https://github.com/termux/termux-app/blob/master/termux-shared/src/main/java/com/termux/shared/termux/TermuxConstants.java) class. It also contains information on how to fork Termux or build it with your own package name. Changing the package name will require building the bootstrap zip packages and other packages with the new `$PREFIX`, check [Building Packages](https://github.com/termux/termux-packages/wiki/Building-packages) for more info. + +Check [Termux Libraries](https://github.com/termux/termux-app/wiki/Termux-Libraries) for how to import termux libraries in plugin apps and [Forking and Local Development](https://github.com/termux/termux-app/wiki/Termux-Libraries#forking-and-local-development) for how to update termux libraries for plugins. + +Commit messages **must** use [Conventional Commits](https://www.conventionalcommits.org) specs so that chagelogs can automatically be generated by the [`create-conventional-changelog`](https://github.com/termux/create-conventional-changelog) script, check its repo for further details on the spec. Use the following `types` as `Added: Add foo`, `Added|Fixed: Add foo and fix bar`, `Changed!: Change baz as a breaking change`, etc. You can optionally add a scope as well, like `Fixed(terminal): Some bug`. The space after `:` is necessary. + +- **Added** for new features. +- **Changed** for changes in existing functionality. +- **Deprecated** for soon-to-be removed features. +- **Removed** for now removed features. +- **Fixed** for any bug fixes. +- **Security** in case of vulnerabilities. +- **Docs** for updating documentation. + +Changelogs for releases are generated based on [Keep a Changelog](https://github.com/olivierlacan/keep-a-changelog) specs. + +The `versionName` in `build.gradle` files of Termux and its plugin apps must follow the [semantic version `2.0.0` spec](https://semver.org/spec/v2.0.0.html) in the format `major.minor.patch(-prerelease)(+buildmetadata)`. When bumping `versionName` in `build.gradle` files and when creating a tag for new releases on github, make sure to include the patch number as well, like `v0.1.0` instead of just `v0.1`. The `build.gradle` files and `attach_debug_apks_to_release` workflow validates the version as well and the build/attachment will fail if `versionName` does not follow the spec. +## + + + +## Forking + +- Check [`TermuxConstants`](https://github.com/termux/termux-app/blob/master/termux-shared/src/main/java/com/termux/shared/termux/TermuxConstants.java) javadocs for instructions on what changes to make in the app to change package name. +- You also need to recompile bootstrap zip for the new package name. Check [here](https://github.com/termux/termux-app/issues/1983) and [here](https://github.com/termux/termux-app/issues/2081#issuecomment-865280111) for experimental work on it. +- Currently, not all plugins use `TermuxConstants` from `termux-shared` library and have hardcoded `com.termux` values and will need to be manually patched. +- If forking termux plugins, check [Forking and Local Development](https://github.com/termux/termux-app/wiki/Termux-Libraries#forking-and-local-development) for info on how to use termux libraries for plugins. diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..386c568 --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,210 @@ +plugins { + id "com.android.application" +} + +android { + compileSdkVersion project.properties.compileSdkVersion.toInteger() + ndkVersion = System.getenv("JITPACK_NDK_VERSION") ?: project.properties.ndkVersion + def appVersionName = System.getenv("TERMUX_APP_VERSION_NAME") ?: "" + def apkVersionTag = System.getenv("TERMUX_APK_VERSION_TAG") ?: "" + def splitAPKsForDebugBuilds = System.getenv("TERMUX_SPLIT_APKS_FOR_DEBUG_BUILDS") ?: "1" + def splitAPKsForReleaseBuilds = System.getenv("TERMUX_SPLIT_APKS_FOR_RELEASE_BUILDS") ?: "0" // F-Droid does not support split APKs #1904 + + dependencies { + implementation "androidx.annotation:annotation:1.3.0" + implementation "androidx.core:core:1.6.0" + implementation "androidx.drawerlayout:drawerlayout:1.1.1" + implementation "androidx.preference:preference:1.1.1" + implementation "androidx.viewpager:viewpager:1.0.0" + implementation "com.google.guava:guava:24.1-jre" + implementation "io.noties.markwon:core:$markwonVersion" + implementation "io.noties.markwon:ext-strikethrough:$markwonVersion" + implementation "io.noties.markwon:linkify:$markwonVersion" + implementation "io.noties.markwon:recycler:$markwonVersion" + + implementation project(":terminal-view") + implementation project(":termux-shared") + } + + defaultConfig { + applicationId "com.termux" + minSdkVersion project.properties.minSdkVersion.toInteger() + targetSdkVersion project.properties.targetSdkVersion.toInteger() + versionCode 1002 + versionName "0.118.3" + + if (appVersionName) versionName = appVersionName + validateVersionName(versionName) + + manifestPlaceholders.TERMUX_PACKAGE_NAME = "com.termux" + manifestPlaceholders.TERMUX_APP_NAME = "Termux" + manifestPlaceholders.TERMUX_API_APP_NAME = "Termux:API" + manifestPlaceholders.TERMUX_BOOT_APP_NAME = "Termux:Boot" + manifestPlaceholders.TERMUX_FLOAT_APP_NAME = "Termux:Float" + manifestPlaceholders.TERMUX_STYLING_APP_NAME = "Termux:Styling" + manifestPlaceholders.TERMUX_TASKER_APP_NAME = "Termux:Tasker" + manifestPlaceholders.TERMUX_WIDGET_APP_NAME = "Termux:Widget" + + externalNativeBuild { + ndkBuild { + cFlags "-std=c11", "-Wall", "-Wextra", "-Werror", "-Os", "-fno-stack-protector", "-Wl,--gc-sections" + } + } + + splits { + abi { + enable ((gradle.startParameter.taskNames.any { it.contains("Debug") } && splitAPKsForDebugBuilds == "1") || + (gradle.startParameter.taskNames.any { it.contains("Release") } && splitAPKsForReleaseBuilds == "1")) + reset () + include 'x86', 'x86_64', 'armeabi-v7a', 'arm64-v8a' + universalApk true + } + } + } + + signingConfigs { + debug { + storeFile file('dev_keystore.jks') + keyAlias 'alias' + storePassword 'xrj45yWGLbsO7W0v' + keyPassword 'xrj45yWGLbsO7W0v' + } + } + + buildTypes { + release { + minifyEnabled true + shrinkResources false // Reproducible builds + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + + debug { + signingConfig signingConfigs.debug + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + externalNativeBuild { + ndkBuild { + path "src/main/cpp/Android.mk" + } + } + + lintOptions { + disable 'ProtectedPermissions' + } + + testOptions { + unitTests { + includeAndroidResources = true + } + } + + packagingOptions { + jniLibs { + useLegacyPackaging true + } + } + + applicationVariants.all { variant -> + variant.outputs.all { output -> + if (variant.buildType.name == "debug") { + def abi = output.getFilter(com.android.build.OutputFile.ABI) + outputFileName = new File("termux-app_" + (apkVersionTag ? apkVersionTag : "debug") + "_" + (abi ? abi : "universal") + ".apk") + } else if (variant.buildType.name == "release") { + def abi = output.getFilter(com.android.build.OutputFile.ABI) + outputFileName = new File("termux-app_" + (apkVersionTag ? apkVersionTag : "release") + "_" + (abi ? abi : "universal") + ".apk") + } + } + } + +} + +dependencies { + testImplementation "junit:junit:4.13.2" + testImplementation "org.robolectric:robolectric:4.4" +} + +task versionName { + doLast { + print android.defaultConfig.versionName + } +} + +def validateVersionName(String versionName) { + // https://semver.org/spec/v2.0.0.html#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string + // ^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$ + if (!java.util.regex.Pattern.matches("^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?\$", versionName)) + throw new GradleException("The versionName '" + versionName + "' is not a valid version as per semantic version '2.0.0' spec in the format 'major.minor.patch(-prerelease)(+buildmetadata)'. https://semver.org/spec/v2.0.0.html.") +} + +def downloadBootstrap(String arch, String expectedChecksum, String version) { + def digest = java.security.MessageDigest.getInstance("SHA-256") + + def localUrl = "src/main/cpp/bootstrap-" + arch + ".zip" + def file = new File(projectDir, localUrl) + if (file.exists()) { + def buffer = new byte[8192] + def input = new FileInputStream(file) + while (true) { + def readBytes = input.read(buffer) + if (readBytes < 0) break + digest.update(buffer, 0, readBytes) + } + def checksum = new BigInteger(1, digest.digest()).toString(16) + while (checksum.length() < 64) { checksum = "0" + checksum } + if (checksum == expectedChecksum) { + return + } else { + logger.quiet("Deleting old local file with wrong hash: " + localUrl) + file.delete() + } + } + + def remoteUrl = "https://github.com/termux/termux-packages/releases/download/bootstrap-" + version + "/bootstrap-" + arch + ".zip" + logger.quiet("Downloading " + remoteUrl + " ...") + + file.parentFile.mkdirs() + def out = new BufferedOutputStream(new FileOutputStream(file)) + + def connection = new URL(remoteUrl).openConnection() + connection.setInstanceFollowRedirects(true) + def digestStream = new java.security.DigestInputStream(connection.inputStream, digest) + out << digestStream + out.close() + + def checksum = new BigInteger(1, digest.digest()).toString(16) + while (checksum.length() < 64) { checksum = "0" + checksum } + if (checksum != expectedChecksum) { + file.delete() + throw new GradleException("Wrong checksum for " + remoteUrl + ": expected: " + expectedChecksum + ", actual: " + checksum) + } +} + +clean { + doLast { + def tree = fileTree(new File(projectDir, 'src/main/cpp')) + tree.include 'bootstrap-*.zip' + tree.each { it.delete() } + } +} + +task downloadBootstraps() { + doLast { + def version = "2025.03.28-r1+apt-android-7" + downloadBootstrap("aarch64", "c8d702b6f742935001c37cda81b8ac69504a95d5cf28f2899532dd8cd4b057eb", version) + downloadBootstrap("arm", "f3bb9d1b32552b34fff41861dbf193ec5ba2848d67d779ac1c7256da6640f85d", version) + downloadBootstrap("i686", "36db3e1ac3547f9a174fd763bd9a484fa1a3449cdd81e1cf2408ff0454f839c6", version) + downloadBootstrap("x86_64", "1c124ec2396ee70a51b0b0a574f29aa659526aa2b9f558f993b2fb05d1e51855", version) + } +} + +afterEvaluate { + android.applicationVariants.all { variant -> + variant.javaCompileProvider.get().dependsOn(downloadBootstraps) + } +} diff --git a/app/dev_keystore.jks b/app/dev_keystore.jks new file mode 100644 index 0000000000000000000000000000000000000000..174cc95acd605bf557f9e63983f55fadc5ae44bd GIT binary patch literal 2097 zcmbW1dpOgJAIJCIMROk`WY(HSNXCZ42syb#xvfbXBITaTl-rTawPUPoP84wxvzSX) z*WsklYK7d&_2d##Bndwpzsq@^^Y`zM_aCq4^E{vDb9tWE*22~T2m}J}1o%&2J|TfV z^c@OikDxq3AQ*sWKsWI+ViG!H02I&$%K-ok1Z6;H@f7nw;XKE~r-Rc}UysG8b8`+3 zaKmS-jr5o{SCP8uu-J*^GyFYRWhu#0 z>t<$WClxHdNaB(zFReHwKYsVzqu=L%i!WGRZZ$pf#Yu6ZsC9QL5?Q;;{^9wK<&0DX zmNu_>81-1Q>Z#=y4=1WFfT+3S>UR4SO7BkZT+dH`z?dc%PuSvpeuPv6|!-(VL zQ^%PiH=6;~;fxshnQJ)l$%_Sy6P#FyTqWbyP>t=M_}R}SXYn_xQIwBH^PG6a-0GB2 zL?60;HtO2fX;T;X(87|~0+Xc%yo$kU>6XS*3{4#~(zS>~Z$$XTsXmgoP0Tx#P5nXG z*U>26+8Y5;Nd9qR!q^9%=&_|A`=*=kp6)-zCn201>!oE?Hv{DYR75qdA|-EAcLzkQ0^ z4L@?sdV*7{T#(@@M(@%cCuOf!pT8Z?9D<$ZY8ocpxYjs1q(l0QB)yp9kSA+FoYLTSkVls8Uw{^G@haj;DuNC7}h_X;^R+~$L zV-@=2`u7B2Z~CVY9^3TQ7hT)aN4*vvmDgIcSs|pKEoP&vvn&|L=w#$b1MX_?HqE$c zpW{q|bEe=un!~bliG4?rX}5khF5bBwmiCQ960)*8UBp7iU8(g03D}xKue`e}ieXW% zjm8+l`Vf^@7j}Ho$!=_%QCvTi-g^$wmc03kgLYD`npCbNwlffW*2AI8sc}_DXwCHr z-@8;~J52g?$t9uGoUAWxq&pEXaBY;HLgpE&A*Spt%{x22X}df;g)Deq>z#p0EB67j zW?-LWLJxxJ`jci9G$!c>eZd*6R15bVuqxH*&-4A|R^65U#O)S6cn{Um9v-PTj?;VH z9MZr4yLF35(5bW+{^2tD0FS<$WaUn^#8=aPC@l1Fjxd=$6VdRd0!O3adhgf{_4VW} z%dYR=5@`1;>H~_~1hS8{wqnH$C!95bZcbcYm#le-#h?H6;;BQ1c#|Dd&fyv0X*>fE z6+^%P7!1`CXvO2D#l*GK?9x`HcjO2I;SKd=@KR!slTZmM*ioOzsDQs&@^41s<^ElV zNJ&^ZJ7C-b{V$x0#G{p@^-b_5c!B|8C&bfA(nJ$|yn*5W@c(Vg0I>fYa7W(|1|SRC z*+Bx#007Xh&mlofl_28n{OOYvrmei(5x-+yIrQpFpKOC|9hel40EfK3H?pz+bGf3Q(Mm1cQ|0SMc{5q|O$1xYI6K~wZbHQ@LY(AB9xR1p zx{#m))|5T&e2aFropN%LDHU-uG^2A9Hrt7$z z$&)?RR2OfPY?S=Y=~L0zh$PV}&9$UxQHh-tOW-=1bQsdgTp~e>6!Gq)xi*X8w#=U?cKT z8*?>+z+Zj0>2M8GE4X=J=Byb-5UB%N{6dp0RQXbp<3u4JlnE5@$Y43o# z_bJkeeSx@OVPD8{OyQ(G!Ef=G0Ir^B^L7S_e^tXet#;c#NCUT@4=iL}$h0=yR-Y~c hj~#VhH9HJD9eBKXgx6a9(6#$et0J;JPJugM`WN=>nzsM| literal 0 HcmV?d00001 diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..a01c038 --- /dev/null +++ b/app/proguard-rules.pro @@ -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.** { *; } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..992de63 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,205 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/cpp/Android.mk b/app/src/main/cpp/Android.mk new file mode 100644 index 0000000..d013e2e --- /dev/null +++ b/app/src/main/cpp/Android.mk @@ -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) diff --git a/app/src/main/cpp/termux-bootstrap-zip.S b/app/src/main/cpp/termux-bootstrap-zip.S new file mode 100644 index 0000000..1cfc958 --- /dev/null +++ b/app/src/main/cpp/termux-bootstrap-zip.S @@ -0,0 +1,18 @@ + .global blob + .global blob_size + .section .rodata + blob: + #if defined __i686__ + .incbin "bootstrap-i686.zip" + #elif defined __x86_64__ + .incbin "bootstrap-x86_64.zip" + #elif defined __aarch64__ + .incbin "bootstrap-aarch64.zip" + #elif defined __arm__ + .incbin "bootstrap-arm.zip" + #else + # error Unsupported arch + #endif + 1: + blob_size: + .int 1b - blob diff --git a/app/src/main/cpp/termux-bootstrap.c b/app/src/main/cpp/termux-bootstrap.c new file mode 100644 index 0000000..8ba745f --- /dev/null +++ b/app/src/main/cpp/termux-bootstrap.c @@ -0,0 +1,11 @@ +#include + +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; +} diff --git a/app/src/main/java/com/termux/app/RunCommandService.java b/app/src/main/java/com/termux/app/RunCommandService.java new file mode 100644 index 0000000..7939d9c --- /dev/null +++ b/app/src/main/java/com/termux/app/RunCommandService.java @@ -0,0 +1,270 @@ +package com.termux.app; + +import android.app.Notification; +import android.app.NotificationManager; +import android.app.Service; +import android.content.Intent; +import android.net.Uri; +import android.os.Binder; +import android.os.Build; +import android.os.IBinder; + +import com.termux.R; +import com.termux.shared.data.DataUtils; +import com.termux.shared.data.IntentUtils; +import com.termux.shared.file.TermuxFileUtils; +import com.termux.shared.file.filesystem.FileType; +import com.termux.shared.models.errors.Errno; +import com.termux.shared.models.errors.Error; +import com.termux.shared.termux.TermuxConstants; +import com.termux.shared.termux.TermuxConstants.TERMUX_APP.RUN_COMMAND_SERVICE; +import com.termux.shared.termux.TermuxConstants.TERMUX_APP.TERMUX_SERVICE; +import com.termux.shared.file.FileUtils; +import com.termux.shared.logger.Logger; +import com.termux.shared.notification.NotificationUtils; +import com.termux.app.utils.PluginUtils; +import com.termux.shared.models.ExecutionCommand; + +/** + * A service that receives {@link RUN_COMMAND_SERVICE#ACTION_RUN_COMMAND} intent from third party apps and + * plugins that contains info on command execution and forwards the extras to {@link TermuxService} + * for the actual execution. + * + * Check https://github.com/termux/termux-app/wiki/RUN_COMMAND-Intent for more info. + */ +public class RunCommandService extends Service { + + private static final String LOG_TAG = "RunCommandService"; + + class LocalBinder extends Binder { + public final RunCommandService service = RunCommandService.this; + } + + private final IBinder mBinder = new RunCommandService.LocalBinder(); + + @Override + public IBinder onBind(Intent intent) { + return mBinder; + } + + @Override + public void onCreate() { + Logger.logVerbose(LOG_TAG, "onCreate"); + runStartForeground(); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + Logger.logDebug(LOG_TAG, "onStartCommand"); + + if (intent == null) return Service.START_NOT_STICKY; + + // Run again in case service is already started and onCreate() is not called + runStartForeground(); + + ExecutionCommand executionCommand = new ExecutionCommand(); + executionCommand.pluginAPIHelp = this.getString(R.string.error_run_command_service_api_help, RUN_COMMAND_SERVICE.RUN_COMMAND_API_HELP_URL); + + Error error; + String errmsg; + + // If invalid action passed, then just return + if (!RUN_COMMAND_SERVICE.ACTION_RUN_COMMAND.equals(intent.getAction())) { + errmsg = this.getString(R.string.error_run_command_service_invalid_intent_action, intent.getAction()); + executionCommand.setStateFailed(Errno.ERRNO_FAILED.getCode(), errmsg); + PluginUtils.processPluginExecutionCommandError(this, LOG_TAG, executionCommand, false); + return stopService(); + } + + String executableExtra = executionCommand.executable = IntentUtils.getStringExtraIfSet(intent, RUN_COMMAND_SERVICE.EXTRA_COMMAND_PATH, null); + executionCommand.arguments = IntentUtils.getStringArrayExtraIfSet(intent, RUN_COMMAND_SERVICE.EXTRA_ARGUMENTS, null); + + /* + * If intent was sent with `am` command, then normal comma characters may have been replaced + * with alternate characters if a normal comma existed in an argument itself to prevent it + * splitting into multiple arguments by `am` command. + * If `tudo` or `sudo` are used, then simply using their `-r` and `--comma-alternative` command + * options can be used without passing the below extras, but native supports is helpful if + * they are not being used. + * https://github.com/agnostic-apollo/tudo#passing-arguments-using-run_command-intent + * https://android.googlesource.com/platform/frameworks/base/+/21bdaf1/cmds/am/src/com/android/commands/am/Am.java#572 + */ + boolean replaceCommaAlternativeCharsInArguments = intent.getBooleanExtra(RUN_COMMAND_SERVICE.EXTRA_REPLACE_COMMA_ALTERNATIVE_CHARS_IN_ARGUMENTS, false); + if (replaceCommaAlternativeCharsInArguments) { + String commaAlternativeCharsInArguments = IntentUtils.getStringExtraIfSet(intent, RUN_COMMAND_SERVICE.EXTRA_COMMA_ALTERNATIVE_CHARS_IN_ARGUMENTS, null); + if (commaAlternativeCharsInArguments == null) + commaAlternativeCharsInArguments = TermuxConstants.COMMA_ALTERNATIVE; + // Replace any commaAlternativeCharsInArguments characters with normal commas + DataUtils.replaceSubStringsInStringArrayItems(executionCommand.arguments, commaAlternativeCharsInArguments, TermuxConstants.COMMA_NORMAL); + } + + executionCommand.stdin = IntentUtils.getStringExtraIfSet(intent, RUN_COMMAND_SERVICE.EXTRA_STDIN, null); + executionCommand.workingDirectory = IntentUtils.getStringExtraIfSet(intent, RUN_COMMAND_SERVICE.EXTRA_WORKDIR, null); + executionCommand.inBackground = intent.getBooleanExtra(RUN_COMMAND_SERVICE.EXTRA_BACKGROUND, false); + executionCommand.backgroundCustomLogLevel = IntentUtils.getIntegerExtraIfSet(intent, RUN_COMMAND_SERVICE.EXTRA_BACKGROUND_CUSTOM_LOG_LEVEL, null); + executionCommand.sessionAction = intent.getStringExtra(RUN_COMMAND_SERVICE.EXTRA_SESSION_ACTION); + executionCommand.commandLabel = IntentUtils.getStringExtraIfSet(intent, RUN_COMMAND_SERVICE.EXTRA_COMMAND_LABEL, "RUN_COMMAND Execution Intent Command"); + executionCommand.commandDescription = IntentUtils.getStringExtraIfSet(intent, RUN_COMMAND_SERVICE.EXTRA_COMMAND_DESCRIPTION, null); + executionCommand.commandHelp = IntentUtils.getStringExtraIfSet(intent, RUN_COMMAND_SERVICE.EXTRA_COMMAND_HELP, null); + executionCommand.isPluginExecutionCommand = true; + executionCommand.resultConfig.resultPendingIntent = intent.getParcelableExtra(RUN_COMMAND_SERVICE.EXTRA_PENDING_INTENT); + executionCommand.resultConfig.resultDirectoryPath = IntentUtils.getStringExtraIfSet(intent, RUN_COMMAND_SERVICE.EXTRA_RESULT_DIRECTORY, null); + if (executionCommand.resultConfig.resultDirectoryPath != null) { + executionCommand.resultConfig.resultSingleFile = intent.getBooleanExtra(RUN_COMMAND_SERVICE.EXTRA_RESULT_SINGLE_FILE, false); + executionCommand.resultConfig.resultFileBasename = IntentUtils.getStringExtraIfSet(intent, RUN_COMMAND_SERVICE.EXTRA_RESULT_FILE_BASENAME, null); + executionCommand.resultConfig.resultFileOutputFormat = IntentUtils.getStringExtraIfSet(intent, RUN_COMMAND_SERVICE.EXTRA_RESULT_FILE_OUTPUT_FORMAT, null); + executionCommand.resultConfig.resultFileErrorFormat = IntentUtils.getStringExtraIfSet(intent, RUN_COMMAND_SERVICE.EXTRA_RESULT_FILE_ERROR_FORMAT, null); + executionCommand.resultConfig.resultFilesSuffix = IntentUtils.getStringExtraIfSet(intent, RUN_COMMAND_SERVICE.EXTRA_RESULT_FILES_SUFFIX, null); + } + + // If "allow-external-apps" property to not set to "true", then just return + // We enable force notifications if "allow-external-apps" policy is violated so that the + // user knows someone tried to run a command in termux context, since it may be malicious + // app or imported (tasker) plugin project and not the user himself. If a pending intent is + // also sent, then its creator is also logged and shown. + errmsg = PluginUtils.checkIfAllowExternalAppsPolicyIsViolated(this, LOG_TAG); + if (errmsg != null) { + executionCommand.setStateFailed(Errno.ERRNO_FAILED.getCode(), errmsg); + PluginUtils.processPluginExecutionCommandError(this, LOG_TAG, executionCommand, true); + return stopService(); + } + + + + // If executable is null or empty, then exit here instead of getting canonical path which would expand to "/" + if (executionCommand.executable == null || executionCommand.executable.isEmpty()) { + errmsg = this.getString(R.string.error_run_command_service_mandatory_extra_missing, RUN_COMMAND_SERVICE.EXTRA_COMMAND_PATH); + executionCommand.setStateFailed(Errno.ERRNO_FAILED.getCode(), errmsg); + PluginUtils.processPluginExecutionCommandError(this, LOG_TAG, executionCommand, false); + return stopService(); + } + + // Get canonical path of executable + executionCommand.executable = TermuxFileUtils.getCanonicalPath(executionCommand.executable, null, true); + + // If executable is not a regular file, or is not readable or executable, then just return + // Setting of missing read and execute permissions is not done + error = FileUtils.validateRegularFileExistenceAndPermissions("executable", executionCommand.executable, null, + FileUtils.APP_EXECUTABLE_FILE_PERMISSIONS, true, true, + false); + if (error != null) { + executionCommand.setStateFailed(error); + PluginUtils.processPluginExecutionCommandError(this, LOG_TAG, executionCommand, false); + return stopService(); + } + + + + // If workingDirectory is not null or empty + if (executionCommand.workingDirectory != null && !executionCommand.workingDirectory.isEmpty()) { + // Get canonical path of workingDirectory + executionCommand.workingDirectory = TermuxFileUtils.getCanonicalPath(executionCommand.workingDirectory, null, true); + + // If workingDirectory is not a directory, or is not readable or writable, then just return + // Creation of missing directory and setting of read, write and execute permissions are only done if workingDirectory is + // under allowed termux working directory paths. + // We try to set execute permissions, but ignore if they are missing, since only read and write permissions are required + // for working directories. + error = TermuxFileUtils.validateDirectoryFileExistenceAndPermissions("working", executionCommand.workingDirectory, + true, true, true, + false, true); + if (error != null) { + executionCommand.setStateFailed(error); + PluginUtils.processPluginExecutionCommandError(this, LOG_TAG, executionCommand, false); + return stopService(); + } + } + + // If the executable passed as the extra was an applet for coreutils/busybox, then we must + // use it instead of the canonical path above since otherwise arguments would be passed to + // coreutils/busybox instead and command would fail. Broken symlinks would already have been + // validated so it should be fine to use it. + executableExtra = TermuxFileUtils.getExpandedTermuxPath(executableExtra); + if (FileUtils.getFileType(executableExtra, false) == FileType.SYMLINK) { + Logger.logVerbose(LOG_TAG, "The executableExtra path \"" + executableExtra + "\" is a symlink so using it instead of the canonical path \"" + executionCommand.executable + "\""); + executionCommand.executable = executableExtra; + } + + executionCommand.executableUri = new Uri.Builder().scheme(TERMUX_SERVICE.URI_SCHEME_SERVICE_EXECUTE).path(executionCommand.executable).build(); + + Logger.logVerboseExtended(LOG_TAG, executionCommand.toString()); + + // Create execution intent with the action TERMUX_SERVICE#ACTION_SERVICE_EXECUTE to be sent to the TERMUX_SERVICE + Intent execIntent = new Intent(TERMUX_SERVICE.ACTION_SERVICE_EXECUTE, executionCommand.executableUri); + execIntent.setClass(this, TermuxService.class); + execIntent.putExtra(TERMUX_SERVICE.EXTRA_ARGUMENTS, executionCommand.arguments); + execIntent.putExtra(TERMUX_SERVICE.EXTRA_STDIN, executionCommand.stdin); + if (executionCommand.workingDirectory != null && !executionCommand.workingDirectory.isEmpty()) execIntent.putExtra(TERMUX_SERVICE.EXTRA_WORKDIR, executionCommand.workingDirectory); + execIntent.putExtra(TERMUX_SERVICE.EXTRA_BACKGROUND, executionCommand.inBackground); + execIntent.putExtra(TERMUX_SERVICE.EXTRA_BACKGROUND_CUSTOM_LOG_LEVEL, DataUtils.getStringFromInteger(executionCommand.backgroundCustomLogLevel, null)); + execIntent.putExtra(TERMUX_SERVICE.EXTRA_SESSION_ACTION, executionCommand.sessionAction); + execIntent.putExtra(TERMUX_SERVICE.EXTRA_COMMAND_LABEL, executionCommand.commandLabel); + execIntent.putExtra(TERMUX_SERVICE.EXTRA_COMMAND_DESCRIPTION, executionCommand.commandDescription); + execIntent.putExtra(TERMUX_SERVICE.EXTRA_COMMAND_HELP, executionCommand.commandHelp); + execIntent.putExtra(TERMUX_SERVICE.EXTRA_PLUGIN_API_HELP, executionCommand.pluginAPIHelp); + execIntent.putExtra(TERMUX_SERVICE.EXTRA_PENDING_INTENT, executionCommand.resultConfig.resultPendingIntent); + execIntent.putExtra(TERMUX_SERVICE.EXTRA_RESULT_DIRECTORY, executionCommand.resultConfig.resultDirectoryPath); + if (executionCommand.resultConfig.resultDirectoryPath != null) { + execIntent.putExtra(TERMUX_SERVICE.EXTRA_RESULT_SINGLE_FILE, executionCommand.resultConfig.resultSingleFile); + execIntent.putExtra(TERMUX_SERVICE.EXTRA_RESULT_FILE_BASENAME, executionCommand.resultConfig.resultFileBasename); + execIntent.putExtra(TERMUX_SERVICE.EXTRA_RESULT_FILE_OUTPUT_FORMAT, executionCommand.resultConfig.resultFileOutputFormat); + execIntent.putExtra(TERMUX_SERVICE.EXTRA_RESULT_FILE_ERROR_FORMAT, executionCommand.resultConfig.resultFileErrorFormat); + execIntent.putExtra(TERMUX_SERVICE.EXTRA_RESULT_FILES_SUFFIX, executionCommand.resultConfig.resultFilesSuffix); + } + + // Start TERMUX_SERVICE and pass it execution intent + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + this.startForegroundService(execIntent); + } else { + this.startService(execIntent); + } + + return stopService(); + } + + private int stopService() { + runStopForeground(); + return Service.START_NOT_STICKY; + } + + private void runStartForeground() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + setupNotificationChannel(); + startForeground(TermuxConstants.TERMUX_RUN_COMMAND_NOTIFICATION_ID, buildNotification()); + } + } + + private void runStopForeground() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + stopForeground(true); + } + } + + private Notification buildNotification() { + // Build the notification + Notification.Builder builder = NotificationUtils.geNotificationBuilder(this, + TermuxConstants.TERMUX_RUN_COMMAND_NOTIFICATION_CHANNEL_ID, Notification.PRIORITY_LOW, + TermuxConstants.TERMUX_RUN_COMMAND_NOTIFICATION_CHANNEL_NAME, null, null, + null, 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); + + return builder.build(); + } + + private void setupNotificationChannel() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return; + + NotificationUtils.setupNotificationChannel(this, TermuxConstants.TERMUX_RUN_COMMAND_NOTIFICATION_CHANNEL_ID, + TermuxConstants.TERMUX_RUN_COMMAND_NOTIFICATION_CHANNEL_NAME, NotificationManager.IMPORTANCE_LOW); + } + +} diff --git a/app/src/main/java/com/termux/app/TermuxActivity.java b/app/src/main/java/com/termux/app/TermuxActivity.java new file mode 100644 index 0000000..243e170 --- /dev/null +++ b/app/src/main/java/com/termux/app/TermuxActivity.java @@ -0,0 +1,925 @@ +package com.termux.app; + +import android.Manifest; +import android.annotation.SuppressLint; +import android.app.Activity; +import android.app.AlertDialog; +import android.content.ActivityNotFoundException; +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.content.pm.PackageManager; +import android.graphics.Color; +import android.net.Uri; +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.widget.EditText; +import android.widget.ImageButton; +import android.widget.ListView; +import android.widget.RelativeLayout; +import android.widget.Toast; + +import com.termux.R; +import com.termux.app.terminal.TermuxActivityRootView; +import com.termux.shared.activities.ReportActivity; +import com.termux.shared.packages.PermissionUtils; +import com.termux.shared.data.DataUtils; +import com.termux.shared.termux.TermuxConstants; +import com.termux.shared.termux.TermuxConstants.TERMUX_APP.TERMUX_ACTIVITY; +import com.termux.app.activities.HelpActivity; +import com.termux.app.activities.SettingsActivity; +import com.termux.shared.settings.preferences.TermuxAppSharedPreferences; +import com.termux.app.terminal.TermuxSessionsListViewController; +import com.termux.app.terminal.io.TerminalToolbarViewPager; +import com.termux.app.terminal.TermuxTerminalSessionClient; +import com.termux.app.terminal.TermuxTerminalViewClient; +import com.termux.shared.terminal.io.extrakeys.ExtraKeysView; +import com.termux.app.settings.properties.TermuxAppSharedProperties; +import com.termux.shared.interact.TextInputDialogUtils; +import com.termux.shared.logger.Logger; +import com.termux.shared.termux.TermuxUtils; +import com.termux.shared.view.ViewUtils; +import com.termux.terminal.TerminalSession; +import com.termux.terminal.TerminalSessionClient; +import com.termux.app.utils.CrashUtils; +import com.termux.view.TerminalView; +import com.termux.view.TerminalViewClient; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; +import androidx.drawerlayout.widget.DrawerLayout; +import androidx.viewpager.widget.ViewPager; + +/** + * A terminal emulator activity. + *

+ * See + *

    + *
  • http://www.mongrel-phones.com.au/default/how_to_make_a_local_service_and_bind_to_it_in_android
  • + *
  • https://code.google.com/p/android/issues/detail?id=6426
  • + *
+ * about memory leaks. + */ +public final class TermuxActivity extends Activity implements ServiceConnection { + + /** + * 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 TerminalView} shown in {@link TermuxActivity} that displays the terminal. + */ + TerminalView mTerminalView; + + /** + * 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}. + */ + TermuxTerminalSessionClient mTermuxTerminalSessionClient; + + /** + * Termux app shared preferences manager. + */ + private TermuxAppSharedPreferences mPreferences; + + /** + * Termux app shared properties manager, loaded from termux.properties + */ + private TermuxAppSharedProperties mProperties; + + /** + * The root view of the {@link TermuxActivity}. + */ + TermuxActivityRootView mTermuxActivityRootView; + + /** + * The space at the bottom of {@link @mTermuxActivityRootView} of the {@link TermuxActivity}. + */ + View mTermuxActivityBottomSpaceView; + + /** + * The terminal extra keys view. + */ + ExtraKeysView mExtraKeysView; + + /** + * 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 isOnResumeAfterOnCreate = false; + + /** + * The {@link TermuxActivity} is in an invalid state and must not be run. + */ + private boolean mIsInvalidState; + + private int mNavBarHeight; + + private int 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_USERNAME = 11; + private static final int CONTEXT_MENU_AUTOFILL_PASSWORD = 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 LOG_TAG = "TermuxActivity"; + + @Override + public void onCreate(Bundle savedInstanceState) { + + Logger.logDebug(LOG_TAG, "onCreate"); + isOnResumeAfterOnCreate = true; + + // Check if a crash happened on last run of the app and show a + // notification with the crash details if it did + CrashUtils.notifyAppCrashOnLastRun(this, LOG_TAG); + + // Delete ReportInfo serialized object files from cache older than 14 days + ReportActivity.deleteReportInfoFilesOlderThanXDays(this, 14, false); + + // Load termux shared properties + mProperties = new TermuxAppSharedProperties(this); + + setActivityTheme(); + + super.onCreate(savedInstanceState); + + setContentView(R.layout.activity_termux); + + // 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(); + + mTermuxActivityRootView = findViewById(R.id.activity_termux_root_view); + mTermuxActivityRootView.setActivity(this); + mTermuxActivityBottomSpaceView = findViewById(R.id.activity_termux_bottom_space_view); + mTermuxActivityRootView.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); + } + + setDrawerTheme(); + + setTermuxTerminalViewAndClients(); + + setTerminalToolbarView(savedInstanceState); + + setSettingsButtonView(); + + setNewSessionButtonView(); + + setToggleKeyboardView(); + + registerForContextMenu(mTerminalView); + + // 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"); + + // Send the {@link TermuxConstants#BROADCAST_TERMUX_OPENED} broadcast to notify apps that Termux + // app has been opened. + TermuxUtils.sendTermuxOpenedBroadcast(this); + } + + @Override + public void onStart() { + super.onStart(); + + Logger.logDebug(LOG_TAG, "onStart"); + + if (mIsInvalidState) return; + + mIsVisible = true; + + if (mTermuxTerminalSessionClient != null) + mTermuxTerminalSessionClient.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 (mTermuxTerminalSessionClient != null) + mTermuxTerminalSessionClient.onResume(); + + if (mTermuxTerminalViewClient != null) + mTermuxTerminalViewClient.onResume(); + + isOnResumeAfterOnCreate = false; + } + + @Override + protected void onStop() { + super.onStop(); + + Logger.logDebug(LOG_TAG, "onStop"); + + if (mIsInvalidState) return; + + mIsVisible = false; + + if (mTermuxTerminalSessionClient != null) + mTermuxTerminalSessionClient.onStop(); + + if (mTermuxTerminalViewClient != null) + mTermuxTerminalViewClient.onStop(); + + removeTermuxActivityRootViewGlobalLayoutListener(); + + unregisterTermuxActivityBroadcastReceiever(); + getDrawer().closeDrawers(); + } + + @Override + public void onDestroy() { + super.onDestroy(); + + Logger.logDebug(LOG_TAG, "onDestroy"); + + if (mIsInvalidState) return; + + 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) { + super.onSaveInstanceState(savedInstanceState); + saveTerminalToolbarTextInput(savedInstanceState); + } + + + + + + /** + * 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(); + + if (mTermuxService.isTermuxSessionsEmpty()) { + if (mIsVisible) { + TermuxInstaller.setupBootstrapIfNeeded(TermuxActivity.this, () -> { + if (mTermuxService == null) return; // Activity might have been destroyed. + try { + Bundle bundle = getIntent().getExtras(); + boolean launchFailsafe = false; + if (bundle != null) { + launchFailsafe = bundle.getBoolean(TERMUX_ACTIVITY.EXTRA_FAILSAFE_SESSION, false); + } + mTermuxTerminalSessionClient.addNewSession(launchFailsafe, null); + } catch (WindowManager.BadTokenException e) { + // Activity finished - ignore. + } + }); + } else { + // The service connected while not in foreground - just bail out. + finishActivityIfNotFinishing(); + } + } else { + Intent i = getIntent(); + if (i != null && Intent.ACTION_RUN.equals(i.getAction())) { + // Android 7.1 app shortcut from res/xml/shortcuts.xml. + boolean isFailSafe = i.getBooleanExtra(TERMUX_ACTIVITY.EXTRA_FAILSAFE_SESSION, false); + mTermuxTerminalSessionClient.addNewSession(isFailSafe, null); + } else { + mTermuxTerminalSessionClient.setCurrentSession(mTermuxTerminalSessionClient.getCurrentStoredSessionOrLast()); + } + } + + // Update the {@link TerminalSession} and {@link TerminalEmulator} clients. + mTermuxService.setTermuxTerminalSessionClient(mTermuxTerminalSessionClient); + } + + @Override + public void onServiceDisconnected(ComponentName name) { + + Logger.logDebug(LOG_TAG, "onServiceDisconnected"); + + // Respect being stopped from the {@link TermuxService} notification action. + finishActivityIfNotFinishing(); + } + + + + + + private void setActivityTheme() { + if (mProperties.isUsingBlackUI()) { + this.setTheme(R.style.Theme_Termux_Black); + } else { + this.setTheme(R.style.Theme_Termux); + } + } + + private void setDrawerTheme() { + if (mProperties.isUsingBlackUI()) { + findViewById(R.id.left_drawer).setBackgroundColor(ContextCompat.getColor(this, + android.R.color.background_dark)); + ((ImageButton) findViewById(R.id.settings_button)).setColorFilter(Color.WHITE); + } + } + + private void setMargins() { + RelativeLayout relativeLayout = findViewById(R.id.activity_termux_root_relative_layout); + 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 + mTermuxTerminalSessionClient = new TermuxTerminalSessionClient(this); + mTermuxTerminalViewClient = new TermuxTerminalViewClient(this, mTermuxTerminalSessionClient); + + // Set termux terminal view + mTerminalView = findViewById(R.id.terminal_view); + mTerminalView.setTerminalViewClient(mTermuxTerminalViewClient); + + if (mTermuxTerminalViewClient != null) + mTermuxTerminalViewClient.onCreate(); + + if (mTermuxTerminalSessionClient != null) + mTermuxTerminalSessionClient.onCreate(); + } + + private void setTermuxSessionsListView() { + ListView termuxSessionsListView = findViewById(R.id.terminal_sessions_list); + mTermuxSessionListViewController = new TermuxSessionsListViewController(this, mTermuxService.getTermuxSessions()); + termuxSessionsListView.setAdapter(mTermuxSessionListViewController); + termuxSessionsListView.setOnItemClickListener(mTermuxSessionListViewController); + termuxSessionsListView.setOnItemLongClickListener(mTermuxSessionListViewController); + } + + + + private void setTerminalToolbarView(Bundle savedInstanceState) { + 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 = (int) Math.round(mTerminalToolbarDefaultHeight * + (mProperties.getExtraKeysInfo() == null ? 0 : mProperties.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 setSettingsButtonView() { + ImageButton settingsButton = findViewById(R.id.settings_button); + settingsButton.setOnClickListener(v -> { + startActivity(new Intent(this, SettingsActivity.class)); + }); + } + + private void setNewSessionButtonView() { + View newSessionButton = findViewById(R.id.new_session_button); + newSessionButton.setOnClickListener(v -> mTermuxTerminalSessionClient.addNewSession(false, null)); + newSessionButton.setOnLongClickListener(v -> { + TextInputDialogUtils.textInput(TermuxActivity.this, R.string.title_create_named_session, null, + R.string.action_create_named_session_confirm, text -> mTermuxTerminalSessionClient.addNewSession(false, text), + R.string.action_new_session_failsafe, text -> mTermuxTerminalSessionClient.addNewSession(true, text), + -1, null, null); + return true; + }); + } + + private void setToggleKeyboardView() { + findViewById(R.id.toggle_keyboard_button).setOnClickListener(v -> { + mTermuxTerminalViewClient.onToggleSoftKeyboardRequest(); + getDrawer().closeDrawers(); + }); + + findViewById(R.id.toggle_keyboard_button).setOnLongClickListener(v -> { + toggleTerminalToolbar(); + return true; + }); + } + + + + + + @SuppressLint("RtlHardcoded") + @Override + public void onBackPressed() { + 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 autoFillEnabled = mTerminalView.isAutoFillEnabled(); + + 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(mTerminalView.getStoredSelectedText())) + menu.add(Menu.NONE, CONTEXT_MENU_SHARE_SELECTED_TEXT, Menu.NONE, R.string.action_share_selected_text); + if (autoFillEnabled) + menu.add(Menu.NONE, CONTEXT_MENU_AUTOFILL_USERNAME, Menu.NONE, R.string.action_autofill_username); + if (autoFillEnabled) + menu.add(Menu.NONE, CONTEXT_MENU_AUTOFILL_PASSWORD, 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_STYLING_ID, Menu.NONE, R.string.action_style_terminal); + 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_HELP_ID, Menu.NONE, R.string.action_open_help); + 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) { + mTerminalView.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_USERNAME: + mTerminalView.requestAutoFillUsername(); + return true; + case CONTEXT_MENU_AUTOFILL_PASSWORD: + mTerminalView.requestAutoFillPassword(); + 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_STYLING_ID: + showStylingDialog(); + return true; + case CONTEXT_MENU_TOGGLE_KEEP_SCREEN_ON: + toggleKeepScreenOn(); + return true; + case CONTEXT_MENU_HELP_ID: + startActivity(new Intent(this, HelpActivity.class)); + return true; + case CONTEXT_MENU_SETTINGS_ID: + startActivity(new Intent(this, SettingsActivity.class)); + 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 + mTerminalView.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 (mTermuxTerminalSessionClient != null) + mTermuxTerminalSessionClient.onResetTerminalSession(); + } + } + + private void showStylingDialog() { + Intent stylingIntent = new Intent(); + stylingIntent.setClassName(TermuxConstants.TERMUX_STYLING_PACKAGE_NAME, TermuxConstants.TERMUX_STYLING.TERMUX_STYLING_ACTIVITY_NAME); + try { + startActivity(stylingIntent); + } catch (ActivityNotFoundException | IllegalArgumentException e) { + // The startActivity() call is not documented to throw IllegalArgumentException. + // However, crash reporting shows that it sometimes does, so catch it here. + new AlertDialog.Builder(this).setMessage(getString(R.string.error_styling_not_installed)) + .setPositiveButton(R.string.action_styling_install, (dialog, which) -> startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(TermuxConstants.TERMUX_STYLING_FDROID_PACKAGE_URL)))).setNegativeButton(android.R.string.cancel, null).show(); + } + } + private void toggleKeepScreenOn() { + if (mTerminalView.getKeepScreenOn()) { + mTerminalView.setKeepScreenOn(false); + mPreferences.setKeepScreenOn(false); + } else { + mTerminalView.setKeepScreenOn(true); + mPreferences.setKeepScreenOn(true); + } + } + + + + /** + * For processes to access shared internal storage (/sdcard) we need this permission. + */ + public boolean ensureStoragePermissionGranted() { + if (PermissionUtils.checkPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE)) { + return true; + } else { + Logger.logInfo(LOG_TAG, "Storage permission not granted, requesting permission."); + PermissionUtils.requestPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE, PermissionUtils.REQUEST_GRANT_STORAGE_PERMISSION); + return false; + } + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + if (requestCode == PermissionUtils.REQUEST_GRANT_STORAGE_PERMISSION && grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + Logger.logInfo(LOG_TAG, "Storage permission granted by user on request."); + TermuxInstaller.setupStorageSymlinks(this); + } else { + Logger.logInfo(LOG_TAG, "Storage permission denied by user on request."); + } + } + + + + public int getNavBarHeight() { + return mNavBarHeight; + } + + public TermuxActivityRootView getTermuxActivityRootView() { + return mTermuxActivityRootView; + } + + public View getTermuxActivityBottomSpaceView() { + return mTermuxActivityBottomSpaceView; + } + + public ExtraKeysView getExtraKeysView() { + return mExtraKeysView; + } + + public void setExtraKeysView(ExtraKeysView extraKeysView) { + mExtraKeysView = extraKeysView; + } + + public DrawerLayout getDrawer() { + return (DrawerLayout) findViewById(R.id.drawer_layout); + } + + + public ViewPager getTerminalToolbarViewPager() { + return (ViewPager) findViewById(R.id.terminal_toolbar_view_pager); + } + + 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 isOnResumeAfterOnCreate; + } + + + + public TermuxService getTermuxService() { + return mTermuxService; + } + + public TerminalView getTerminalView() { + return mTerminalView; + } + + public TermuxTerminalViewClient getTermuxTerminalViewClient() { + return mTermuxTerminalViewClient; + } + + public TermuxTerminalSessionClient getTermuxTerminalSessionClient() { + return mTermuxTerminalSessionClient; + } + + @Nullable + public TerminalSession getCurrentSession() { + if (mTerminalView != null) + return mTerminalView.getCurrentSession(); + else + return null; + } + + public TermuxAppSharedPreferences getPreferences() { + return mPreferences; + } + + public TermuxAppSharedProperties getProperties() { + return mProperties; + } + + + + + public static void updateTermuxActivityStyling(Context context) { + // Make sure that terminal styling is always applied. + Intent stylingIntent = new Intent(TERMUX_ACTIVITY.ACTION_RELOAD_STYLE); + context.sendBroadcast(stylingIntent); + } + + private void registerTermuxActivityBroadcastReceiver() { + IntentFilter intentFilter = new IntentFilter(); + intentFilter.addAction(TERMUX_ACTIVITY.ACTION_REQUEST_PERMISSIONS); + intentFilter.addAction(TERMUX_ACTIVITY.ACTION_RELOAD_STYLE); + + registerReceiver(mTermuxActivityBroadcastReceiver, intentFilter); + } + + private void unregisterTermuxActivityBroadcastReceiever() { + unregisterReceiver(mTermuxActivityBroadcastReceiver); + } + + private void fixTermuxActivityBroadcastReceieverIntent(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) { + fixTermuxActivityBroadcastReceieverIntent(intent); + + switch (intent.getAction()) { + case TERMUX_ACTIVITY.ACTION_REQUEST_PERMISSIONS: + Logger.logDebug(LOG_TAG, "Received intent to request storage permissions"); + if (ensureStoragePermissionGranted()) + TermuxInstaller.setupStorageSymlinks(TermuxActivity.this); + return; + case TERMUX_ACTIVITY.ACTION_RELOAD_STYLE: + Logger.logDebug(LOG_TAG, "Received intent to reload styling"); + reloadActivityStyling(); + return; + default: + } + } + } + } + + private void reloadActivityStyling() { + if (mProperties!= null) { + mProperties.loadTermuxPropertiesFromDisk(); + + if (mExtraKeysView != null) { + mExtraKeysView.setButtonTextAllCaps(mProperties.shouldExtraKeysTextBeAllCaps()); + mExtraKeysView.reload(mProperties.getExtraKeysInfo()); + } + } + + setMargins(); + setTerminalToolbarHeight(); + + if (mTermuxTerminalSessionClient != null) + mTermuxTerminalSessionClient.onReload(); + + if (mTermuxTerminalViewClient != null) + mTermuxTerminalViewClient.onReload(); + + if (mTermuxService != null) + mTermuxService.setTerminalTranscriptRows(); + + // To change the activity and drawer theme, activity needs to be recreated. + // But this will destroy the activity, and will call the onCreate() again. + // We need to investigate if enabling this is wise, since all stored variables and + // views will be destroyed and bindService() will be called again. Extra keys input + // text will we restored since that has already been implemented. Terminal sessions + // and transcripts are also already preserved. Theme does change properly too. + // TermuxActivity.this.recreate(); + } + + + + public static void startTermuxActivity(@NonNull final Context context) { + context.startActivity(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; + } + +} diff --git a/app/src/main/java/com/termux/app/TermuxApplication.java b/app/src/main/java/com/termux/app/TermuxApplication.java new file mode 100644 index 0000000..b7f2898 --- /dev/null +++ b/app/src/main/java/com/termux/app/TermuxApplication.java @@ -0,0 +1,29 @@ +package com.termux.app; + +import android.app.Application; + +import com.termux.shared.crash.TermuxCrashUtils; +import com.termux.shared.settings.preferences.TermuxAppSharedPreferences; +import com.termux.shared.logger.Logger; + + +public class TermuxApplication extends Application { + public void onCreate() { + super.onCreate(); + + // Set crash handler for the app + TermuxCrashUtils.setCrashHandler(this); + + // Set log level for the app + setLogLevel(); + } + + private void setLogLevel() { + // Load the log level from shared preferences and set it to the {@link Logger.CURRENT_LOG_LEVEL} + TermuxAppSharedPreferences preferences = TermuxAppSharedPreferences.build(getApplicationContext()); + if (preferences == null) return; + preferences.setLogLevel(null, preferences.getLogLevel()); + Logger.logDebug("Starting Application"); + } +} + diff --git a/app/src/main/java/com/termux/app/TermuxInstaller.java b/app/src/main/java/com/termux/app/TermuxInstaller.java new file mode 100644 index 0000000..98157a0 --- /dev/null +++ b/app/src/main/java/com/termux/app/TermuxInstaller.java @@ -0,0 +1,365 @@ +package com.termux.app; + +import android.app.Activity; +import android.app.AlertDialog; +import android.app.ProgressDialog; +import android.content.Context; +import android.os.Environment; +import android.system.Os; +import android.util.Pair; +import android.view.WindowManager; + +import com.termux.R; +import com.termux.app.utils.CrashUtils; +import com.termux.shared.file.FileUtils; +import com.termux.shared.file.TermuxFileUtils; +import com.termux.shared.interact.MessageDialogUtils; +import com.termux.shared.logger.Logger; +import com.termux.shared.markdown.MarkdownUtils; +import com.termux.shared.models.ExecutionCommand; +import com.termux.shared.models.errors.Error; +import com.termux.shared.packages.PackageUtils; +import com.termux.shared.shell.TermuxShellEnvironmentClient; +import com.termux.shared.shell.TermuxTask; +import com.termux.shared.termux.TermuxConstants; +import com.termux.shared.termux.TermuxUtils; + +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: + *

+ * (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. + *

+ * (2) A progress dialog is shown with "Installing..." message and a spinner. + *

+ * (3) A staging directory, $STAGING_PREFIX, is cleared if left over from broken installation below. + *

+ * (4) The zip file is loaded from a shared library. + *

+ * (5) The zip, containing entries relative to the $PREFIX, is is downloaded and extracted by a zip input stream + * continuously encountering zip file entries: + *

+ * (5.1) If the zip entry encountered is SYMLINKS.txt, go through it and remember all symlinks to setup. + *

+ * (5.2) For every other zip entry, extract it into $STAGING_PREFIX and set execute permissions if necessary. + */ +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) + "\nTERMUX_FILES_DIR: " + MarkdownUtils.getMarkdownCodeForString(TermuxConstants.TERMUX_FILES_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)) { + File[] PREFIX_FILE_LIST = TERMUX_PREFIX_DIR.listFiles(); + // If prefix directory is empty or only contains the tmp directory + if(PREFIX_FILE_LIST == null || PREFIX_FILE_LIST.length == 0 || (PREFIX_FILE_LIST.length == 1 && TermuxConstants.TERMUX_TMP_PREFIX_DIR_PATH.equals(PREFIX_FILE_LIST[0].getAbsolutePath()))) { + Logger.logInfo(LOG_TAG, "The termux prefix directory \"" + TERMUX_PREFIX_DIR_PATH + "\" exists but is empty or only contains the tmp directory."); + } 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> 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") || + zipEntryName.equals("etc/termux/bootstrap/termux-bootstrap-second-stage.sh")) { + //noinspection OctalInteger + Os.chmod(targetFile.getAbsolutePath(), 0700); + } + } + } + } + } + + if (symlinks.isEmpty()) + throw new RuntimeException("No SYMLINKS.txt encountered"); + for (Pair 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"); + } + + // Run Termux bootstrap second stage. + String termuxBootstrapSecondStageFile = TERMUX_PREFIX_DIR_PATH + "/etc/termux/bootstrap/termux-bootstrap-second-stage.sh"; + if (!FileUtils.fileExists(termuxBootstrapSecondStageFile, false)) { + Logger.logInfo(LOG_TAG, "Not running Termux bootstrap second stage since script not found at \"" + termuxBootstrapSecondStageFile + "\" path."); + } else { + if (!FileUtils.fileExists(TermuxConstants.TERMUX_BIN_PREFIX_DIR_PATH + "/bash", true)) { + Logger.logInfo(LOG_TAG, "Not running Termux bootstrap second stage since bash not found."); + } + Logger.logInfo(LOG_TAG, "Running Termux bootstrap second stage."); + + ExecutionCommand executionCommand = new ExecutionCommand(-1, + termuxBootstrapSecondStageFile, null, null, + null, true, false); + executionCommand.commandLabel = "Termux Bootstrap Second Stage Command"; + executionCommand.backgroundCustomLogLevel = Logger.LOG_LEVEL_NORMAL; + TermuxTask termuxTask = TermuxTask.execute(activity, executionCommand, null, new TermuxShellEnvironmentClient(), true); + if (termuxTask == null || !executionCommand.isSuccessful() || executionCommand.resultData.exitCode != 0) { + // Generate debug report before deleting broken prefix directory to get `stat` info at time of failure. + showBootstrapErrorDialog(activity, whenDone, MarkdownUtils.getMarkdownCodeForString(executionCommand.toString(), true)); + + // Delete prefix directory as otherwise when app is restarted, the broken prefix directory would be used and logged into. + error = FileUtils.deleteFile("termux prefix directory", TERMUX_PREFIX_DIR_PATH, true); + if (error != null) + Logger.logErrorExtended(LOG_TAG, error.toString()); + return; + } + } + + Logger.logInfo(LOG_TAG, "Bootstrap packages installed successfully."); + 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) { + CrashUtils.sendCrashReportNotification(activity, LOG_TAG, + "## Bootstrap Error\n\n" + message + "\n\n" + + TermuxUtils.getTermuxDebugMarkdownString(activity), + true, true); + } + + static void setupStorageSymlinks(final Context context) { + final String LOG_TAG = "termux-storage"; + + 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()); + CrashUtils.sendCrashReportNotification(context, LOG_TAG, "## Setup Storage Error\n\n" + Error.getErrorMarkdownString(error), true, 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() + "\"."); + + File sharedDir = Environment.getExternalStorageDirectory(); + Os.symlink(sharedDir.getAbsolutePath(), new File(storageDir, "shared").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()); + + final File[] dirs = context.getExternalFilesDirs(null); + if (dirs != null && dirs.length > 1) { + for (int i = 1; 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()); + } + } + + 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); + CrashUtils.sendCrashReportNotification(context, LOG_TAG, "## Setup Storage Error\n\n" + Logger.getStackTracesMarkdownString(null, Logger.getStackTracesStringArray(e)), true, 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(); + +} diff --git a/app/src/main/java/com/termux/app/TermuxOpenReceiver.java b/app/src/main/java/com/termux/app/TermuxOpenReceiver.java new file mode 100644 index 0000000..539c875 --- /dev/null +++ b/app/src/main/java/com/termux/app/TermuxOpenReceiver.java @@ -0,0 +1,224 @@ +package com.termux.app; + +import android.content.ActivityNotFoundException; +import android.content.BroadcastReceiver; +import android.content.ContentValues; +import android.content.Context; +import android.content.Intent; +import android.database.Cursor; +import android.database.MatrixCursor; +import android.net.Uri; +import android.os.Environment; +import android.os.ParcelFileDescriptor; +import android.provider.MediaStore; +import android.webkit.MimeTypeMap; + +import com.termux.app.utils.PluginUtils; +import com.termux.shared.data.IntentUtils; +import com.termux.shared.logger.Logger; +import com.termux.shared.termux.TermuxConstants; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; + +import androidx.annotation.NonNull; + +public class TermuxOpenReceiver extends BroadcastReceiver { + + private static final String LOG_TAG = "TermuxOpenReceiver"; + + @Override + public void onReceive(Context context, Intent intent) { + final Uri data = intent.getData(); + if (data == null) { + Logger.logError(LOG_TAG, "termux-open: Called without intent data"); + return; + } + + Logger.logVerbose(LOG_TAG, "Intent Received:\n" + IntentUtils.getIntentString(intent)); + + final String filePath = data.getPath(); + final String contentTypeExtra = intent.getStringExtra("content-type"); + final boolean useChooser = intent.getBooleanExtra("chooser", false); + final String intentAction = intent.getAction() == null ? Intent.ACTION_VIEW : intent.getAction(); + switch (intentAction) { + case Intent.ACTION_SEND: + case Intent.ACTION_VIEW: + // Ok. + break; + default: + Logger.logError(LOG_TAG, "Invalid action '" + intentAction + "', using 'view'"); + break; + } + + final boolean isExternalUrl = data.getScheme() != null && !data.getScheme().equals("file"); + if (isExternalUrl) { + Intent urlIntent = new Intent(intentAction, data); + if (intentAction.equals(Intent.ACTION_SEND)) { + urlIntent.putExtra(Intent.EXTRA_TEXT, data.toString()); + urlIntent.setData(null); + } else if (contentTypeExtra != null) { + urlIntent.setDataAndType(data, contentTypeExtra); + } + urlIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + try { + context.startActivity(urlIntent); + } catch (ActivityNotFoundException e) { + Logger.logError(LOG_TAG, "termux-open: No app handles the url " + data); + } + return; + } + + final File fileToShare = new File(filePath); + if (!(fileToShare.isFile() && fileToShare.canRead())) { + Logger.logError(LOG_TAG, "termux-open: Not a readable file: '" + fileToShare.getAbsolutePath() + "'"); + return; + } + + Intent sendIntent = new Intent(); + sendIntent.setAction(intentAction); + sendIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_GRANT_READ_URI_PERMISSION); + + String contentTypeToUse; + if (contentTypeExtra == null) { + String fileName = fileToShare.getName(); + int lastDotIndex = fileName.lastIndexOf('.'); + String fileExtension = fileName.substring(lastDotIndex + 1); + MimeTypeMap mimeTypes = MimeTypeMap.getSingleton(); + // Lower casing makes it work with e.g. "JPG": + contentTypeToUse = mimeTypes.getMimeTypeFromExtension(fileExtension.toLowerCase()); + if (contentTypeToUse == null) contentTypeToUse = "application/octet-stream"; + } else { + contentTypeToUse = contentTypeExtra; + } + + Uri uriToShare = Uri.parse("content://" + TermuxConstants.TERMUX_FILE_SHARE_URI_AUTHORITY + fileToShare.getAbsolutePath()); + + if (Intent.ACTION_SEND.equals(intentAction)) { + sendIntent.putExtra(Intent.EXTRA_STREAM, uriToShare); + sendIntent.setType(contentTypeToUse); + } else { + sendIntent.setDataAndType(uriToShare, contentTypeToUse); + } + + if (useChooser) { + sendIntent = Intent.createChooser(sendIntent, null).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + } + + try { + context.startActivity(sendIntent); + } catch (ActivityNotFoundException e) { + Logger.logError(LOG_TAG, "termux-open: No app handles the url " + data); + } + } + + public static class ContentProvider extends android.content.ContentProvider { + + private static final String LOG_TAG = "TermuxContentProvider"; + + @Override + public boolean onCreate() { + return true; + } + + @Override + public Cursor query(@NonNull Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) { + File file = new File(uri.getPath()); + + if (projection == null) { + projection = new String[]{ + MediaStore.MediaColumns.DISPLAY_NAME, + MediaStore.MediaColumns.SIZE, + MediaStore.MediaColumns._ID + }; + } + + Object[] row = new Object[projection.length]; + for (int i = 0; i < projection.length; i++) { + String column = projection[i]; + Object value; + switch (column) { + case MediaStore.MediaColumns.DISPLAY_NAME: + value = file.getName(); + break; + case MediaStore.MediaColumns.SIZE: + value = (int) file.length(); + break; + case MediaStore.MediaColumns._ID: + value = 1; + break; + default: + value = null; + } + row[i] = value; + } + + MatrixCursor cursor = new MatrixCursor(projection); + cursor.addRow(row); + return cursor; + } + + @Override + public String getType(@NonNull Uri uri) { + String path = uri.getLastPathSegment(); + int extIndex = path.lastIndexOf('.') + 1; + if (extIndex > 0) { + MimeTypeMap mimeMap = MimeTypeMap.getSingleton(); + String ext = path.substring(extIndex).toLowerCase(); + return mimeMap.getMimeTypeFromExtension(ext); + } + return null; + } + + @Override + public Uri insert(@NonNull Uri uri, ContentValues values) { + return null; + } + + @Override + public int delete(@NonNull Uri uri, String selection, String[] selectionArgs) { + return 0; + } + + @Override + public int update(@NonNull Uri uri, ContentValues values, String selection, String[] selectionArgs) { + return 0; + } + + @Override + public ParcelFileDescriptor openFile(@NonNull Uri uri, @NonNull String mode) throws FileNotFoundException { + File file = new File(uri.getPath()); + try { + String path = file.getCanonicalPath(); + Logger.logDebug(LOG_TAG, "Open file request received for \"" + path + "\" with mode \"" + mode + "\""); + String storagePath = Environment.getExternalStorageDirectory().getCanonicalPath(); + // See https://support.google.com/faqs/answer/7496913: + if (!(path.startsWith(TermuxConstants.TERMUX_FILES_DIR_PATH) || path.startsWith(storagePath))) { + throw new IllegalArgumentException("Invalid path: " + path); + } + + // If "allow-external-apps" property to not set to "true", then throw exception + String errmsg = PluginUtils.checkIfAllowExternalAppsPolicyIsViolated(getContext(), LOG_TAG); + if (errmsg != null) { + throw new IllegalArgumentException(errmsg); + } + + // Do not allow apps with RUN_COMMAND permission to modify termux apps properties files, + // including allow-external-apps + if (TermuxConstants.TERMUX_PROPERTIES_PRIMARY_FILE_PATH.equals(path) || + TermuxConstants.TERMUX_PROPERTIES_SECONDARY_FILE_PATH.equals(path) || + TermuxConstants.TERMUX_FLOAT_PROPERTIES_PRIMARY_FILE_PATH.equals(path) || + TermuxConstants.TERMUX_FLOAT_PROPERTIES_SECONDARY_FILE_PATH.equals(path)) { + mode = "r"; + } + + } catch (IOException e) { + throw new IllegalArgumentException(e); + } + + return ParcelFileDescriptor.open(file, ParcelFileDescriptor.parseMode(mode)); + } + } + +} diff --git a/app/src/main/java/com/termux/app/TermuxService.java b/app/src/main/java/com/termux/app/TermuxService.java new file mode 100644 index 0000000..f7034e2 --- /dev/null +++ b/app/src/main/java/com/termux/app/TermuxService.java @@ -0,0 +1,849 @@ +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.ActivityNotFoundException; +import android.content.Context; +import android.content.Intent; +import android.content.res.Resources; +import android.net.Uri; +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 android.provider.Settings; +import android.widget.ArrayAdapter; + +import androidx.annotation.Nullable; + +import com.termux.R; +import com.termux.app.settings.properties.TermuxAppSharedProperties; +import com.termux.app.terminal.TermuxTerminalSessionClient; +import com.termux.app.utils.PluginUtils; +import com.termux.shared.data.IntentUtils; +import com.termux.shared.models.errors.Errno; +import com.termux.shared.shell.ShellUtils; +import com.termux.shared.shell.TermuxShellEnvironmentClient; +import com.termux.shared.shell.TermuxShellUtils; +import com.termux.shared.termux.TermuxConstants; +import com.termux.shared.termux.TermuxConstants.TERMUX_APP.TERMUX_ACTIVITY; +import com.termux.shared.termux.TermuxConstants.TERMUX_APP.TERMUX_SERVICE; +import com.termux.shared.settings.preferences.TermuxAppSharedPreferences; +import com.termux.shared.shell.TermuxSession; +import com.termux.shared.terminal.TermuxTerminalSessionClientBase; +import com.termux.shared.logger.Logger; +import com.termux.shared.notification.NotificationUtils; +import com.termux.shared.packages.PermissionUtils; +import com.termux.shared.data.DataUtils; +import com.termux.shared.models.ExecutionCommand; +import com.termux.shared.shell.TermuxTask; +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 #mTermuxSessions} and background {@link TermuxTask} + * in {@link #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. + *

+ * 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)}. + *

+ * 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 TermuxTask.TermuxTaskClient, TermuxSession.TermuxSessionClient { + + private static int EXECUTION_ID = 1000; + + /** 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 foreground TermuxSessions which this service manages. + * Note that this list is observed by {@link TermuxActivity#mTermuxSessionListViewController}, + * so any changes must be made on the UI thread and followed by a call to + * {@link ArrayAdapter#notifyDataSetChanged()} }. + */ + final List mTermuxSessions = new ArrayList<>(); + + /** + * The background TermuxTasks which this service manages. + */ + final List mTermuxTasks = new ArrayList<>(); + + /** + * The pending plugin ExecutionCommands that have yet to be processed by this service. + */ + final List mPendingPluginExecutionCommands = new ArrayList<>(); + + /** 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. + */ + TermuxTerminalSessionClient mTermuxTerminalSessionClient; + + /** The basic implementation of the {@link TerminalSessionClient} interface to be used by {@link TerminalSession} + * that does not hold activity references. + */ + final TermuxTerminalSessionClientBase mTermuxTerminalSessionClientBase = new TermuxTerminalSessionClientBase(); + + /** 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; + + public Integer mTerminalTranscriptRows; + + private static final String LOG_TAG = "TermuxService"; + + @Override + public void onCreate() { + Logger.logVerbose(LOG_TAG, "onCreate"); + 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 = 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; + case TERMUX_SERVICE.ACTION_SERVICE_EXECUTE: + Logger.logDebug(LOG_TAG, "ACTION_SERVICE_EXECUTE intent received"); + actionServiceExecute(intent); + 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(); + 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 (mTermuxTerminalSessionClient != 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=" + mTermuxSessions.size() + ", TermuxTasks=" + mTermuxTasks.size() + ", PendingPluginExecutionCommands=" + mPendingPluginExecutionCommands.size()); + + List termuxSessions = new ArrayList<>(mTermuxSessions); + for (int i = 0; i < termuxSessions.size(); i++) { + ExecutionCommand executionCommand = termuxSessions.get(i).getExecutionCommand(); + processResult = mWantsToStop || executionCommand.isPluginExecutionCommandWithPendingResult(); + termuxSessions.get(i).killIfExecuting(this, processResult); + } + + List termuxTasks = new ArrayList<>(mTermuxTasks); + for (int i = 0; i < termuxTasks.size(); i++) { + ExecutionCommand executionCommand = termuxTasks.get(i).getExecutionCommand(); + if (executionCommand.isPluginExecutionCommandWithPendingResult()) + termuxTasks.get(i).killIfExecuting(this, true); + } + + List pendingPluginExecutionCommands = new ArrayList<>(mPendingPluginExecutionCommands); + for (int i = 0; i < pendingPluginExecutionCommands.size(); i++) { + ExecutionCommand executionCommand = pendingPluginExecutionCommands.get(i); + if (!executionCommand.shouldNotProcessResults() && executionCommand.isPluginExecutionCommandWithPendingResult()) { + if (executionCommand.setStateFailed(Errno.ERRNO_CANCELLED.getCode(), this.getString(com.termux.shared.R.string.error_execution_cancelled))) { + PluginUtils.processPluginExecutionCommandResult(this, LOG_TAG, executionCommand); + } + } + } + } + + + + /** 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(); + + String packageName = getPackageName(); + if (!pm.isIgnoringBatteryOptimizations(packageName)) { + Intent whitelist = new Intent(); + whitelist.setAction(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS); + whitelist.setData(Uri.parse("package:" + packageName)); + whitelist.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + + try { + startActivity(whitelist); + } catch (ActivityNotFoundException e) { + Logger.logStackTraceWithMessage(LOG_TAG, "Failed to call ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS", e); + } + } + + 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"); + } + + /** Process {@link TERMUX_SERVICE#ACTION_SERVICE_EXECUTE} intent to execute a shell command in + * a foreground TermuxSession or in a background TermuxTask. */ + private void actionServiceExecute(Intent intent) { + if (intent == null) { + Logger.logError(LOG_TAG, "Ignoring null intent to actionServiceExecute"); + return; + } + + ExecutionCommand executionCommand = new ExecutionCommand(getNextExecutionId()); + + executionCommand.executableUri = intent.getData(); + executionCommand.inBackground = intent.getBooleanExtra(TERMUX_SERVICE.EXTRA_BACKGROUND, false); + + if (executionCommand.executableUri != null) { + executionCommand.executable = executionCommand.executableUri.getPath(); + executionCommand.arguments = IntentUtils.getStringArrayExtraIfSet(intent, TERMUX_SERVICE.EXTRA_ARGUMENTS, null); + if (executionCommand.inBackground) + executionCommand.stdin = IntentUtils.getStringExtraIfSet(intent, TERMUX_SERVICE.EXTRA_STDIN, null); + executionCommand.backgroundCustomLogLevel = IntentUtils.getIntegerExtraIfSet(intent, TERMUX_SERVICE.EXTRA_BACKGROUND_CUSTOM_LOG_LEVEL, null); + } + + executionCommand.workingDirectory = IntentUtils.getStringExtraIfSet(intent, TERMUX_SERVICE.EXTRA_WORKDIR, null); + executionCommand.isFailsafe = intent.getBooleanExtra(TERMUX_ACTIVITY.EXTRA_FAILSAFE_SESSION, false); + executionCommand.sessionAction = intent.getStringExtra(TERMUX_SERVICE.EXTRA_SESSION_ACTION); + executionCommand.commandLabel = IntentUtils.getStringExtraIfSet(intent, TERMUX_SERVICE.EXTRA_COMMAND_LABEL, "Execution Intent Command"); + executionCommand.commandDescription = IntentUtils.getStringExtraIfSet(intent, TERMUX_SERVICE.EXTRA_COMMAND_DESCRIPTION, null); + executionCommand.commandHelp = IntentUtils.getStringExtraIfSet(intent, TERMUX_SERVICE.EXTRA_COMMAND_HELP, null); + executionCommand.pluginAPIHelp = IntentUtils.getStringExtraIfSet(intent, TERMUX_SERVICE.EXTRA_PLUGIN_API_HELP, null); + executionCommand.isPluginExecutionCommand = true; + executionCommand.resultConfig.resultPendingIntent = intent.getParcelableExtra(TERMUX_SERVICE.EXTRA_PENDING_INTENT); + executionCommand.resultConfig.resultDirectoryPath = IntentUtils.getStringExtraIfSet(intent, TERMUX_SERVICE.EXTRA_RESULT_DIRECTORY, null); + if (executionCommand.resultConfig.resultDirectoryPath != null) { + executionCommand.resultConfig.resultSingleFile = intent.getBooleanExtra(TERMUX_SERVICE.EXTRA_RESULT_SINGLE_FILE, false); + executionCommand.resultConfig.resultFileBasename = IntentUtils.getStringExtraIfSet(intent, TERMUX_SERVICE.EXTRA_RESULT_FILE_BASENAME, null); + executionCommand.resultConfig.resultFileOutputFormat = IntentUtils.getStringExtraIfSet(intent, TERMUX_SERVICE.EXTRA_RESULT_FILE_OUTPUT_FORMAT, null); + executionCommand.resultConfig.resultFileErrorFormat = IntentUtils.getStringExtraIfSet(intent, TERMUX_SERVICE.EXTRA_RESULT_FILE_ERROR_FORMAT, null); + executionCommand.resultConfig.resultFilesSuffix = IntentUtils.getStringExtraIfSet(intent, TERMUX_SERVICE.EXTRA_RESULT_FILES_SUFFIX, null); + } + + // Add the execution command to pending plugin execution commands list + mPendingPluginExecutionCommands.add(executionCommand); + + if (executionCommand.inBackground) { + executeTermuxTaskCommand(executionCommand); + } else { + executeTermuxSessionCommand(executionCommand); + } + } + + + + + + /** Execute a shell command in background {@link TermuxTask}. */ + private void executeTermuxTaskCommand(ExecutionCommand executionCommand) { + if (executionCommand == null) return; + + Logger.logDebug(LOG_TAG, "Executing background \"" + executionCommand.getCommandIdAndLabelLogString() + "\" TermuxTask command"); + + TermuxTask newTermuxTask = createTermuxTask(executionCommand); + } + + /** Create a {@link TermuxTask}. */ + @Nullable + public TermuxTask createTermuxTask(String executablePath, String[] arguments, String stdin, String workingDirectory) { + return createTermuxTask(new ExecutionCommand(getNextExecutionId(), executablePath, arguments, stdin, workingDirectory, true, false)); + } + + /** Create a {@link TermuxTask}. */ + @Nullable + public synchronized TermuxTask createTermuxTask(ExecutionCommand executionCommand) { + if (executionCommand == null) return null; + + Logger.logDebug(LOG_TAG, "Creating \"" + executionCommand.getCommandIdAndLabelLogString() + "\" TermuxTask"); + + if (!executionCommand.inBackground) { + Logger.logDebug(LOG_TAG, "Ignoring a foreground execution command passed to createTermuxTask()"); + return null; + } + + if (Logger.getLogLevel() >= Logger.LOG_LEVEL_VERBOSE) + Logger.logVerboseExtended(LOG_TAG, executionCommand.toString()); + + TermuxTask newTermuxTask = TermuxTask.execute(this, executionCommand, this, new TermuxShellEnvironmentClient(), 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) + PluginUtils.processPluginExecutionCommandError(this, LOG_TAG, executionCommand, false); + else + Logger.logErrorExtended(LOG_TAG, executionCommand.toString()); + return null; + } + + mTermuxTasks.add(newTermuxTask); + + // Remove the execution command from the pending plugin execution commands list since it has + // now been processed + if (executionCommand.isPluginExecutionCommand) + mPendingPluginExecutionCommands.remove(executionCommand); + + updateNotification(); + + return newTermuxTask; + } + + /** Callback received when a {@link TermuxTask} finishes. */ + @Override + public void onTermuxTaskExited(final TermuxTask 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) + PluginUtils.processPluginExecutionCommandResult(this, LOG_TAG, executionCommand); + + mTermuxTasks.remove(termuxTask); + } + + updateNotification(); + }); + } + + + + + + /** Execute a shell command in a foreground {@link TermuxSession}. */ + private void executeTermuxSessionCommand(ExecutionCommand executionCommand) { + if (executionCommand == null) return; + + Logger.logDebug(LOG_TAG, "Executing foreground \"" + executionCommand.getCommandIdAndLabelLogString() + "\" TermuxSession command"); + + String sessionName = null; + + // Transform executable path to session name, e.g. "/bin/do-something.sh" => "do something.sh". + if (executionCommand.executable != null) { + sessionName = ShellUtils.getExecutableBasename(executionCommand.executable).replace('-', ' '); + } + + TermuxSession newTermuxSession = createTermuxSession(executionCommand, sessionName); + if (newTermuxSession == null) return; + + handleSessionAction(DataUtils.getIntFromString(executionCommand.sessionAction, + TERMUX_SERVICE.VALUE_EXTRA_SESSION_ACTION_SWITCH_TO_NEW_SESSION_AND_OPEN_ACTIVITY), + newTermuxSession.getTerminalSession()); + } + + /** + * Create a {@link TermuxSession}. + * Currently called by {@link TermuxTerminalSessionClient#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) { + return createTermuxSession(new ExecutionCommand(getNextExecutionId(), executablePath, arguments, stdin, workingDirectory, false, isFailSafe), sessionName); + } + + /** Create a {@link TermuxSession}. */ + @Nullable + public synchronized TermuxSession createTermuxSession(ExecutionCommand executionCommand, String sessionName) { + if (executionCommand == null) return null; + + Logger.logDebug(LOG_TAG, "Creating \"" + executionCommand.getCommandIdAndLabelLogString() + "\" TermuxSession"); + + if (executionCommand.inBackground) { + Logger.logDebug(LOG_TAG, "Ignoring a background execution command passed to createTermuxSession()"); + return null; + } + + 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 + executionCommand.terminalTranscriptRows = getTerminalTranscriptRows(); + TermuxSession newTermuxSession = TermuxSession.execute(this, executionCommand, getTermuxTerminalSessionClient(), this, new TermuxShellEnvironmentClient(), sessionName, 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) + PluginUtils.processPluginExecutionCommandError(this, LOG_TAG, executionCommand, false); + else + Logger.logErrorExtended(LOG_TAG, executionCommand.toString()); + return null; + } + + mTermuxSessions.add(newTermuxSession); + + // Remove the execution command from the pending plugin execution commands list since it has + // now been processed + if (executionCommand.isPluginExecutionCommand) + mPendingPluginExecutionCommands.remove(executionCommand); + + // Notify {@link TermuxSessionsListViewController} that sessions list has been updated if + // activity in is foreground + if (mTermuxTerminalSessionClient != null) + mTermuxTerminalSessionClient.termuxSessionListNotifyUpdated(); + + updateNotification(); + TermuxActivity.updateTermuxActivityStyling(this); + + return newTermuxSession; + } + + /** Remove a TermuxSession. */ + public synchronized int removeTermuxSession(TerminalSession sessionToRemove) { + int index = getIndexOfSession(sessionToRemove); + + if (index >= 0) + 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) + PluginUtils.processPluginExecutionCommandResult(this, LOG_TAG, executionCommand); + + mTermuxSessions.remove(termuxSession); + + // Notify {@link TermuxSessionsListViewController} that sessions list has been updated if + // activity in is foreground + if (mTermuxTerminalSessionClient != null) + mTermuxTerminalSessionClient.termuxSessionListNotifyUpdated(); + } + + updateNotification(); + } + + /** Get the terminal transcript rows to be used for new {@link TermuxSession}. */ + public Integer getTerminalTranscriptRows() { + if (mTerminalTranscriptRows == null) + setTerminalTranscriptRows(); + return mTerminalTranscriptRows; + } + + public void setTerminalTranscriptRows() { + // TermuxService only uses this termux property currently, so no need to load them all into + // an internal values map like TermuxActivity does + mTerminalTranscriptRows = TermuxAppSharedProperties.getTerminalTranscriptRows(this); + } + + + + + + /** Process session action for new session. */ + private void handleSessionAction(int sessionAction, TerminalSession newTerminalSession) { + Logger.logDebug(LOG_TAG, "Processing sessionAction \"" + sessionAction + "\" for session \"" + newTerminalSession.mSessionName + "\""); + + switch (sessionAction) { + case TERMUX_SERVICE.VALUE_EXTRA_SESSION_ACTION_SWITCH_TO_NEW_SESSION_AND_OPEN_ACTIVITY: + setCurrentStoredTerminalSession(newTerminalSession); + if (mTermuxTerminalSessionClient != null) + mTermuxTerminalSessionClient.setCurrentSession(newTerminalSession); + startTermuxActivity(); + break; + case TERMUX_SERVICE.VALUE_EXTRA_SESSION_ACTION_KEEP_CURRENT_SESSION_AND_OPEN_ACTIVITY: + if (getTermuxSessionsSize() == 1) + setCurrentStoredTerminalSession(newTerminalSession); + startTermuxActivity(); + break; + case TERMUX_SERVICE.VALUE_EXTRA_SESSION_ACTION_SWITCH_TO_NEW_SESSION_AND_DONT_OPEN_ACTIVITY: + setCurrentStoredTerminalSession(newTerminalSession); + if (mTermuxTerminalSessionClient != null) + mTermuxTerminalSessionClient.setCurrentSession(newTerminalSession); + break; + case TERMUX_SERVICE.VALUE_EXTRA_SESSION_ACTION_KEEP_CURRENT_SESSION_AND_DONT_OPEN_ACTIVITY: + if (getTermuxSessionsSize() == 1) + setCurrentStoredTerminalSession(newTerminalSession); + break; + default: + Logger.logError(LOG_TAG, "Invalid sessionAction: \"" + sessionAction + "\". Force using default sessionAction."); + handleSessionAction(TERMUX_SERVICE.VALUE_EXTRA_SESSION_ACTION_SWITCH_TO_NEW_SESSION_AND_OPEN_ACTIVITY, newTerminalSession); + break; + } + } + + /** Launch the {@link }TermuxActivity} to bring it to foreground. */ + private void startTermuxActivity() { + // For android >= 10, apps require Display over other apps permission to start foreground activities + // from background (services). If it is not granted, then TermuxSessions that are started will + // show in Termux notification but will not run until user manually clicks the notification. + if (PermissionUtils.validateDisplayOverOtherAppsPermissionForPostAndroid10(this, true)) { + TermuxActivity.startTermuxActivity(this); + } else { + TermuxAppSharedPreferences preferences = TermuxAppSharedPreferences.build(this); + if (preferences == null) return; + if (preferences.arePluginErrorNotificationsEnabled()) + Logger.showToast(this, this.getString(R.string.error_display_over_other_apps_permission_not_granted), true); + } + } + + + + + + /** 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 #mTermuxTerminalSessionClientBase}. Once {@link TermuxActivity} bind + * callback is received, it should call {@link #setTermuxTerminalSessionClient} to set the + * {@link TermuxService#mTermuxTerminalSessionClient} so that further terminal sessions are directly + * passed the {@link TermuxTerminalSessionClient} object which fully implements the + * {@link TerminalSessionClient} interface. + * + * @return Returns the {@link TermuxTerminalSessionClient} if {@link TermuxActivity} has bound with + * {@link TermuxService}, otherwise {@link TermuxTerminalSessionClientBase}. + */ + public synchronized TermuxTerminalSessionClientBase getTermuxTerminalSessionClient() { + if (mTermuxTerminalSessionClient != null) + return mTermuxTerminalSessionClient; + else + return mTermuxTerminalSessionClientBase; + } + + /** This should be called when {@link TermuxActivity#onServiceConnected} is called to set the + * {@link TermuxService#mTermuxTerminalSessionClient} variable and update the {@link TerminalSession} + * and {@link TerminalEmulator} clients in case they were passed {@link TermuxTerminalSessionClientBase} + * earlier. + * + * @param termuxTerminalSessionClient The {@link TermuxTerminalSessionClient} object that fully + * implements the {@link TerminalSessionClient} interface. + */ + public synchronized void setTermuxTerminalSessionClient(TermuxTerminalSessionClient termuxTerminalSessionClient) { + mTermuxTerminalSessionClient = termuxTerminalSessionClient; + + for (int i = 0; i < mTermuxSessions.size(); i++) + mTermuxSessions.get(i).getTerminalSession().updateTerminalSessionClient(mTermuxTerminalSessionClient); + } + + /** 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 < mTermuxSessions.size(); i++) + mTermuxSessions.get(i).getTerminalSession().updateTerminalSessionClient(mTermuxTerminalSessionClientBase); + + mTermuxTerminalSessionClient = 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 = 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 && mTermuxSessions.isEmpty() && 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()); + } + } + + + + + + private void setCurrentStoredTerminalSession(TerminalSession session) { + if (session == null) return; + // Make the newly created session the current one to be displayed + TermuxAppSharedPreferences preferences = TermuxAppSharedPreferences.build(this); + if (preferences == null) return; + preferences.setCurrentSession(session.mHandle); + } + + public synchronized boolean isTermuxSessionsEmpty() { + return mTermuxSessions.isEmpty(); + } + + public synchronized int getTermuxSessionsSize() { + return mTermuxSessions.size(); + } + + public synchronized List getTermuxSessions() { + return mTermuxSessions; + } + + @Nullable + public synchronized TermuxSession getTermuxSession(int index) { + if (index >= 0 && index < mTermuxSessions.size()) + return mTermuxSessions.get(index); + else + return null; + } + + public synchronized TermuxSession getLastTermuxSession() { + return mTermuxSessions.isEmpty() ? null : mTermuxSessions.get(mTermuxSessions.size() - 1); + } + + public synchronized int getIndexOfSession(TerminalSession terminalSession) { + for (int i = 0; i < mTermuxSessions.size(); i++) { + if (mTermuxSessions.get(i).getTerminalSession().equals(terminalSession)) + return i; + } + return -1; + } + + public synchronized TerminalSession getTerminalSessionForHandle(String sessionHandle) { + TerminalSession terminalSession; + for (int i = 0, len = mTermuxSessions.size(); i < len; i++) { + terminalSession = mTermuxSessions.get(i).getTerminalSession(); + if (terminalSession.mHandle.equals(sessionHandle)) + return terminalSession; + } + return null; + } + + + + public static synchronized int getNextExecutionId() { + return EXECUTION_ID++; + } + + public boolean wantsToStop() { + return mWantsToStop; + } + +} diff --git a/app/src/main/java/com/termux/app/activities/HelpActivity.java b/app/src/main/java/com/termux/app/activities/HelpActivity.java new file mode 100644 index 0000000..facbf36 --- /dev/null +++ b/app/src/main/java/com/termux/app/activities/HelpActivity.java @@ -0,0 +1,77 @@ +package com.termux.app.activities; + +import android.app.Activity; +import android.content.ActivityNotFoundException; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.view.ViewGroup; +import android.webkit.WebSettings; +import android.webkit.WebView; +import android.webkit.WebViewClient; +import android.widget.ProgressBar; +import android.widget.RelativeLayout; + +import com.termux.shared.termux.TermuxConstants; + +/** Basic embedded browser for viewing help pages. */ +public final class HelpActivity extends Activity { + + WebView mWebView; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + final RelativeLayout progressLayout = new RelativeLayout(this); + RelativeLayout.LayoutParams lParams = new RelativeLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); + lParams.addRule(RelativeLayout.CENTER_IN_PARENT); + ProgressBar progressBar = new ProgressBar(this); + progressBar.setIndeterminate(true); + progressBar.setLayoutParams(lParams); + progressLayout.addView(progressBar); + + mWebView = new WebView(this); + WebSettings settings = mWebView.getSettings(); + settings.setCacheMode(WebSettings.LOAD_NO_CACHE); + settings.setAppCacheEnabled(false); + setContentView(progressLayout); + mWebView.clearCache(true); + + mWebView.setWebViewClient(new WebViewClient() { + @Override + public boolean shouldOverrideUrlLoading(WebView view, String url) { + if (url.equals(TermuxConstants.TERMUX_WIKI_URL) || url.startsWith(TermuxConstants.TERMUX_WIKI_URL + "/")) { + // Inline help. + setContentView(progressLayout); + return false; + } + + try { + startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(url)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)); + } catch (ActivityNotFoundException e) { + // Android TV does not have a system browser. + setContentView(progressLayout); + return false; + } + return true; + } + + @Override + public void onPageFinished(WebView view, String url) { + setContentView(mWebView); + } + }); + mWebView.loadUrl(TermuxConstants.TERMUX_WIKI_URL); + } + + @Override + public void onBackPressed() { + if (mWebView.canGoBack()) { + mWebView.goBack(); + } else { + super.onBackPressed(); + } + } + +} diff --git a/app/src/main/java/com/termux/app/activities/SettingsActivity.java b/app/src/main/java/com/termux/app/activities/SettingsActivity.java new file mode 100644 index 0000000..3ffbaad --- /dev/null +++ b/app/src/main/java/com/termux/app/activities/SettingsActivity.java @@ -0,0 +1,164 @@ +package com.termux.app.activities; + +import android.content.Context; +import android.os.Bundle; +import android.os.Environment; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.app.AppCompatActivity; +import androidx.preference.Preference; +import androidx.preference.PreferenceFragmentCompat; + +import com.termux.R; +import com.termux.shared.activities.ReportActivity; +import com.termux.shared.file.FileUtils; +import com.termux.shared.models.ReportInfo; +import com.termux.app.models.UserAction; +import com.termux.shared.interact.ShareUtils; +import com.termux.shared.packages.PackageUtils; +import com.termux.shared.settings.preferences.TermuxAPIAppSharedPreferences; +import com.termux.shared.settings.preferences.TermuxFloatAppSharedPreferences; +import com.termux.shared.settings.preferences.TermuxTaskerAppSharedPreferences; +import com.termux.shared.settings.preferences.TermuxWidgetAppSharedPreferences; +import com.termux.shared.termux.AndroidUtils; +import com.termux.shared.termux.TermuxConstants; +import com.termux.shared.termux.TermuxUtils; + +public class SettingsActivity extends AppCompatActivity { + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_settings); + if (savedInstanceState == null) { + getSupportFragmentManager() + .beginTransaction() + .replace(R.id.settings, new RootPreferencesFragment()) + .commit(); + } + ActionBar actionBar = getSupportActionBar(); + if (actionBar != null) { + actionBar.setDisplayHomeAsUpEnabled(true); + actionBar.setDisplayShowHomeEnabled(true); + } + } + + @Override + public boolean onSupportNavigateUp() { + onBackPressed(); + return true; + } + + public static class RootPreferencesFragment extends PreferenceFragmentCompat { + @Override + public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { + Context context = getContext(); + if (context == null) return; + + setPreferencesFromResource(R.xml.root_preferences, rootKey); + + configureTermuxAPIPreference(context); + configureTermuxFloatPreference(context); + configureTermuxTaskerPreference(context); + configureTermuxWidgetPreference(context); + configureAboutPreference(context); + configureDonatePreference(context); + } + + private void configureTermuxAPIPreference(@NonNull Context context) { + Preference termuxAPIPreference = findPreference("termux_api"); + if (termuxAPIPreference != null) { + TermuxAPIAppSharedPreferences preferences = TermuxAPIAppSharedPreferences.build(context, false); + // If failed to get app preferences, then likely app is not installed, so do not show its preference + termuxAPIPreference.setVisible(preferences != null); + } + } + + private void configureTermuxFloatPreference(@NonNull Context context) { + Preference termuxFloatPreference = findPreference("termux_float"); + if (termuxFloatPreference != null) { + TermuxFloatAppSharedPreferences preferences = TermuxFloatAppSharedPreferences.build(context, false); + // If failed to get app preferences, then likely app is not installed, so do not show its preference + termuxFloatPreference.setVisible(preferences != null); + } + } + + private void configureTermuxTaskerPreference(@NonNull Context context) { + Preference termuxTaskerPreference = findPreference("termux_tasker"); + if (termuxTaskerPreference != null) { + TermuxTaskerAppSharedPreferences preferences = TermuxTaskerAppSharedPreferences.build(context, false); + // If failed to get app preferences, then likely app is not installed, so do not show its preference + termuxTaskerPreference.setVisible(preferences != null); + } + } + + private void configureTermuxWidgetPreference(@NonNull Context context) { + Preference termuxWidgetPreference = findPreference("termux_widget"); + if (termuxWidgetPreference != null) { + TermuxWidgetAppSharedPreferences preferences = TermuxWidgetAppSharedPreferences.build(context, false); + // If failed to get app preferences, then likely app is not installed, so do not show its preference + termuxWidgetPreference.setVisible(preferences != null); + } + } + + private void configureAboutPreference(@NonNull Context context) { + Preference aboutPreference = findPreference("about"); + if (aboutPreference != null) { + aboutPreference.setOnPreferenceClickListener(preference -> { + new Thread() { + @Override + public void run() { + String title = "About"; + + StringBuilder aboutString = new StringBuilder(); + aboutString.append(TermuxUtils.getAppInfoMarkdownString(context, false)); + + String termuxPluginAppsInfo = TermuxUtils.getTermuxPluginAppsInfoMarkdownString(context); + if (termuxPluginAppsInfo != null) + aboutString.append("\n\n").append(termuxPluginAppsInfo); + + aboutString.append("\n\n").append(AndroidUtils.getDeviceInfoMarkdownString(context)); + aboutString.append("\n\n").append(TermuxUtils.getImportantLinksMarkdownString(context)); + + String userActionName = UserAction.ABOUT.getName(); + ReportActivity.startReportActivity(context, new ReportInfo(userActionName, + TermuxConstants.TERMUX_APP.TERMUX_SETTINGS_ACTIVITY_NAME, title, null, + aboutString.toString(), null, false, + userActionName, + Environment.getExternalStorageDirectory() + "/" + + FileUtils.sanitizeFileName(TermuxConstants.TERMUX_APP_NAME + "-" + userActionName + ".log", true, true))); + } + }.start(); + + return true; + }); + } + } + + private void configureDonatePreference(@NonNull Context context) { + Preference donatePreference = findPreference("donate"); + if (donatePreference != null) { + String signingCertificateSHA256Digest = PackageUtils.getSigningCertificateSHA256DigestForPackage(context); + if (signingCertificateSHA256Digest != null) { + // If APK is a Google Playstore release, then do not show the donation link + // since Termux isn't exempted from the playstore policy donation links restriction + // Check Fund solicitations: https://pay.google.com/intl/en_in/about/policy/ + String apkRelease = TermuxUtils.getAPKRelease(signingCertificateSHA256Digest); + if (apkRelease == null || apkRelease.equals(TermuxConstants.APK_RELEASE_GOOGLE_PLAYSTORE_SIGNING_CERTIFICATE_SHA256_DIGEST)) { + donatePreference.setVisible(false); + return; + } else { + donatePreference.setVisible(true); + } + } + + donatePreference.setOnPreferenceClickListener(preference -> { + ShareUtils.openURL(context, TermuxConstants.TERMUX_DONATE_URL); + return true; + }); + } + } + } + +} diff --git a/app/src/main/java/com/termux/app/fragments/settings/TermuxAPIPreferencesFragment.java b/app/src/main/java/com/termux/app/fragments/settings/TermuxAPIPreferencesFragment.java new file mode 100644 index 0000000..de8508e --- /dev/null +++ b/app/src/main/java/com/termux/app/fragments/settings/TermuxAPIPreferencesFragment.java @@ -0,0 +1,49 @@ +package com.termux.app.fragments.settings; + +import android.content.Context; +import android.os.Bundle; + +import androidx.annotation.Keep; +import androidx.preference.PreferenceDataStore; +import androidx.preference.PreferenceFragmentCompat; +import androidx.preference.PreferenceManager; + +import com.termux.R; +import com.termux.shared.settings.preferences.TermuxAPIAppSharedPreferences; + +@Keep +public class TermuxAPIPreferencesFragment extends PreferenceFragmentCompat { + + @Override + public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { + Context context = getContext(); + if (context == null) return; + + PreferenceManager preferenceManager = getPreferenceManager(); + preferenceManager.setPreferenceDataStore(TermuxAPIPreferencesDataStore.getInstance(context)); + + setPreferencesFromResource(R.xml.termux_api_preferences, rootKey); + } + +} + +class TermuxAPIPreferencesDataStore extends PreferenceDataStore { + + private final Context mContext; + private final TermuxAPIAppSharedPreferences mPreferences; + + private static TermuxAPIPreferencesDataStore mInstance; + + private TermuxAPIPreferencesDataStore(Context context) { + mContext = context; + mPreferences = TermuxAPIAppSharedPreferences.build(context, true); + } + + public static synchronized TermuxAPIPreferencesDataStore getInstance(Context context) { + if (mInstance == null) { + mInstance = new TermuxAPIPreferencesDataStore(context); + } + return mInstance; + } + +} diff --git a/app/src/main/java/com/termux/app/fragments/settings/TermuxFloatPreferencesFragment.java b/app/src/main/java/com/termux/app/fragments/settings/TermuxFloatPreferencesFragment.java new file mode 100644 index 0000000..ee0bacb --- /dev/null +++ b/app/src/main/java/com/termux/app/fragments/settings/TermuxFloatPreferencesFragment.java @@ -0,0 +1,49 @@ +package com.termux.app.fragments.settings; + +import android.content.Context; +import android.os.Bundle; + +import androidx.annotation.Keep; +import androidx.preference.PreferenceDataStore; +import androidx.preference.PreferenceFragmentCompat; +import androidx.preference.PreferenceManager; + +import com.termux.R; +import com.termux.shared.settings.preferences.TermuxFloatAppSharedPreferences; + +@Keep +public class TermuxFloatPreferencesFragment extends PreferenceFragmentCompat { + + @Override + public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { + Context context = getContext(); + if (context == null) return; + + PreferenceManager preferenceManager = getPreferenceManager(); + preferenceManager.setPreferenceDataStore(TermuxFloatPreferencesDataStore.getInstance(context)); + + setPreferencesFromResource(R.xml.termux_float_preferences, rootKey); + } + +} + +class TermuxFloatPreferencesDataStore extends PreferenceDataStore { + + private final Context mContext; + private final TermuxFloatAppSharedPreferences mPreferences; + + private static TermuxFloatPreferencesDataStore mInstance; + + private TermuxFloatPreferencesDataStore(Context context) { + mContext = context; + mPreferences = TermuxFloatAppSharedPreferences.build(context, true); + } + + public static synchronized TermuxFloatPreferencesDataStore getInstance(Context context) { + if (mInstance == null) { + mInstance = new TermuxFloatPreferencesDataStore(context); + } + return mInstance; + } + +} diff --git a/app/src/main/java/com/termux/app/fragments/settings/TermuxPreferencesFragment.java b/app/src/main/java/com/termux/app/fragments/settings/TermuxPreferencesFragment.java new file mode 100644 index 0000000..273fa0f --- /dev/null +++ b/app/src/main/java/com/termux/app/fragments/settings/TermuxPreferencesFragment.java @@ -0,0 +1,49 @@ +package com.termux.app.fragments.settings; + +import android.content.Context; +import android.os.Bundle; + +import androidx.annotation.Keep; +import androidx.preference.PreferenceDataStore; +import androidx.preference.PreferenceFragmentCompat; +import androidx.preference.PreferenceManager; + +import com.termux.R; +import com.termux.shared.settings.preferences.TermuxAppSharedPreferences; + +@Keep +public class TermuxPreferencesFragment extends PreferenceFragmentCompat { + + @Override + public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { + Context context = getContext(); + if (context == null) return; + + PreferenceManager preferenceManager = getPreferenceManager(); + preferenceManager.setPreferenceDataStore(TermuxPreferencesDataStore.getInstance(context)); + + setPreferencesFromResource(R.xml.termux_preferences, rootKey); + } + +} + +class TermuxPreferencesDataStore extends PreferenceDataStore { + + private final Context mContext; + private final TermuxAppSharedPreferences mPreferences; + + private static TermuxPreferencesDataStore mInstance; + + private TermuxPreferencesDataStore(Context context) { + mContext = context; + mPreferences = TermuxAppSharedPreferences.build(context, true); + } + + public static synchronized TermuxPreferencesDataStore getInstance(Context context) { + if (mInstance == null) { + mInstance = new TermuxPreferencesDataStore(context); + } + return mInstance; + } + +} diff --git a/app/src/main/java/com/termux/app/fragments/settings/TermuxTaskerPreferencesFragment.java b/app/src/main/java/com/termux/app/fragments/settings/TermuxTaskerPreferencesFragment.java new file mode 100644 index 0000000..b86685b --- /dev/null +++ b/app/src/main/java/com/termux/app/fragments/settings/TermuxTaskerPreferencesFragment.java @@ -0,0 +1,49 @@ +package com.termux.app.fragments.settings; + +import android.content.Context; +import android.os.Bundle; + +import androidx.annotation.Keep; +import androidx.preference.PreferenceDataStore; +import androidx.preference.PreferenceFragmentCompat; +import androidx.preference.PreferenceManager; + +import com.termux.R; +import com.termux.shared.settings.preferences.TermuxTaskerAppSharedPreferences; + +@Keep +public class TermuxTaskerPreferencesFragment extends PreferenceFragmentCompat { + + @Override + public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { + Context context = getContext(); + if (context == null) return; + + PreferenceManager preferenceManager = getPreferenceManager(); + preferenceManager.setPreferenceDataStore(TermuxTaskerPreferencesDataStore.getInstance(context)); + + setPreferencesFromResource(R.xml.termux_tasker_preferences, rootKey); + } + +} + +class TermuxTaskerPreferencesDataStore extends PreferenceDataStore { + + private final Context mContext; + private final TermuxTaskerAppSharedPreferences mPreferences; + + private static TermuxTaskerPreferencesDataStore mInstance; + + private TermuxTaskerPreferencesDataStore(Context context) { + mContext = context; + mPreferences = TermuxTaskerAppSharedPreferences.build(context, true); + } + + public static synchronized TermuxTaskerPreferencesDataStore getInstance(Context context) { + if (mInstance == null) { + mInstance = new TermuxTaskerPreferencesDataStore(context); + } + return mInstance; + } + +} diff --git a/app/src/main/java/com/termux/app/fragments/settings/TermuxWidgetPreferencesFragment.java b/app/src/main/java/com/termux/app/fragments/settings/TermuxWidgetPreferencesFragment.java new file mode 100644 index 0000000..b87e93e --- /dev/null +++ b/app/src/main/java/com/termux/app/fragments/settings/TermuxWidgetPreferencesFragment.java @@ -0,0 +1,49 @@ +package com.termux.app.fragments.settings; + +import android.content.Context; +import android.os.Bundle; + +import androidx.annotation.Keep; +import androidx.preference.PreferenceDataStore; +import androidx.preference.PreferenceFragmentCompat; +import androidx.preference.PreferenceManager; + +import com.termux.R; +import com.termux.shared.settings.preferences.TermuxWidgetAppSharedPreferences; + +@Keep +public class TermuxWidgetPreferencesFragment extends PreferenceFragmentCompat { + + @Override + public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { + Context context = getContext(); + if (context == null) return; + + PreferenceManager preferenceManager = getPreferenceManager(); + preferenceManager.setPreferenceDataStore(TermuxWidgetPreferencesDataStore.getInstance(context)); + + setPreferencesFromResource(R.xml.termux_widget_preferences, rootKey); + } + +} + +class TermuxWidgetPreferencesDataStore extends PreferenceDataStore { + + private final Context mContext; + private final TermuxWidgetAppSharedPreferences mPreferences; + + private static TermuxWidgetPreferencesDataStore mInstance; + + private TermuxWidgetPreferencesDataStore(Context context) { + mContext = context; + mPreferences = TermuxWidgetAppSharedPreferences.build(context, true); + } + + public static synchronized TermuxWidgetPreferencesDataStore getInstance(Context context) { + if (mInstance == null) { + mInstance = new TermuxWidgetPreferencesDataStore(context); + } + return mInstance; + } + +} diff --git a/app/src/main/java/com/termux/app/fragments/settings/termux/DebuggingPreferencesFragment.java b/app/src/main/java/com/termux/app/fragments/settings/termux/DebuggingPreferencesFragment.java new file mode 100644 index 0000000..72ced34 --- /dev/null +++ b/app/src/main/java/com/termux/app/fragments/settings/termux/DebuggingPreferencesFragment.java @@ -0,0 +1,155 @@ +package com.termux.app.fragments.settings.termux; + +import android.content.Context; +import android.os.Bundle; + +import androidx.annotation.Keep; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.preference.ListPreference; +import androidx.preference.PreferenceCategory; +import androidx.preference.PreferenceDataStore; +import androidx.preference.PreferenceFragmentCompat; +import androidx.preference.PreferenceManager; + +import com.termux.R; +import com.termux.shared.settings.preferences.TermuxAppSharedPreferences; +import com.termux.shared.logger.Logger; + +@Keep +public class DebuggingPreferencesFragment extends PreferenceFragmentCompat { + + @Override + public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { + Context context = getContext(); + if (context == null) return; + + PreferenceManager preferenceManager = getPreferenceManager(); + preferenceManager.setPreferenceDataStore(DebuggingPreferencesDataStore.getInstance(context)); + + setPreferencesFromResource(R.xml.termux_debugging_preferences, rootKey); + + configureLoggingPreferences(context); + } + + private void configureLoggingPreferences(@NonNull Context context) { + PreferenceCategory loggingCategory = findPreference("logging"); + if (loggingCategory == null) return; + + ListPreference logLevelListPreference = findPreference("log_level"); + if (logLevelListPreference != null) { + TermuxAppSharedPreferences preferences = TermuxAppSharedPreferences.build(context, true); + if (preferences == null) return; + + setLogLevelListPreferenceData(logLevelListPreference, context, preferences.getLogLevel()); + loggingCategory.addPreference(logLevelListPreference); + } + } + + public static ListPreference setLogLevelListPreferenceData(ListPreference logLevelListPreference, Context context, int logLevel) { + if (logLevelListPreference == null) + logLevelListPreference = new ListPreference(context); + + CharSequence[] logLevels = Logger.getLogLevelsArray(); + CharSequence[] logLevelLabels = Logger.getLogLevelLabelsArray(context, logLevels, true); + + logLevelListPreference.setEntryValues(logLevels); + logLevelListPreference.setEntries(logLevelLabels); + + logLevelListPreference.setValue(String.valueOf(logLevel)); + logLevelListPreference.setDefaultValue(Logger.DEFAULT_LOG_LEVEL); + + return logLevelListPreference; + } + +} + +class DebuggingPreferencesDataStore extends PreferenceDataStore { + + private final Context mContext; + private final TermuxAppSharedPreferences mPreferences; + + private static DebuggingPreferencesDataStore mInstance; + + private DebuggingPreferencesDataStore(Context context) { + mContext = context; + mPreferences = TermuxAppSharedPreferences.build(context, true); + } + + public static synchronized DebuggingPreferencesDataStore getInstance(Context context) { + if (mInstance == null) { + mInstance = new DebuggingPreferencesDataStore(context); + } + return mInstance; + } + + + + @Override + @Nullable + public String getString(String key, @Nullable String defValue) { + if (mPreferences == null) return null; + if (key == null) return null; + + switch (key) { + case "log_level": + return String.valueOf(mPreferences.getLogLevel()); + default: + return null; + } + } + + @Override + public void putString(String key, @Nullable String value) { + if (mPreferences == null) return; + if (key == null) return; + + switch (key) { + case "log_level": + if (value != null) { + mPreferences.setLogLevel(mContext, Integer.parseInt(value)); + } + break; + default: + break; + } + } + + + + @Override + public void putBoolean(String key, boolean value) { + if (mPreferences == null) return; + if (key == null) return; + + switch (key) { + case "terminal_view_key_logging_enabled": + mPreferences.setTerminalViewKeyLoggingEnabled(value); + break; + case "plugin_error_notifications_enabled": + mPreferences.setPluginErrorNotificationsEnabled(value); + break; + case "crash_report_notifications_enabled": + mPreferences.setCrashReportNotificationsEnabled(value); + break; + default: + break; + } + } + + @Override + public boolean getBoolean(String key, boolean defValue) { + if (mPreferences == null) return false; + switch (key) { + case "terminal_view_key_logging_enabled": + return mPreferences.isTerminalViewKeyLoggingEnabled(); + case "plugin_error_notifications_enabled": + return mPreferences.arePluginErrorNotificationsEnabled(); + case "crash_report_notifications_enabled": + return mPreferences.areCrashReportNotificationsEnabled(); + default: + return false; + } + } + +} diff --git a/app/src/main/java/com/termux/app/fragments/settings/termux/TerminalIOPreferencesFragment.java b/app/src/main/java/com/termux/app/fragments/settings/termux/TerminalIOPreferencesFragment.java new file mode 100644 index 0000000..46e7504 --- /dev/null +++ b/app/src/main/java/com/termux/app/fragments/settings/termux/TerminalIOPreferencesFragment.java @@ -0,0 +1,82 @@ +package com.termux.app.fragments.settings.termux; + +import android.content.Context; +import android.os.Bundle; + +import androidx.annotation.Keep; +import androidx.preference.PreferenceDataStore; +import androidx.preference.PreferenceFragmentCompat; +import androidx.preference.PreferenceManager; + +import com.termux.R; +import com.termux.shared.settings.preferences.TermuxAppSharedPreferences; + +@Keep +public class TerminalIOPreferencesFragment extends PreferenceFragmentCompat { + + @Override + public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { + Context context = getContext(); + if (context == null) return; + + PreferenceManager preferenceManager = getPreferenceManager(); + preferenceManager.setPreferenceDataStore(TerminalIOPreferencesDataStore.getInstance(context)); + + setPreferencesFromResource(R.xml.termux_terminal_io_preferences, rootKey); + } + +} + +class TerminalIOPreferencesDataStore extends PreferenceDataStore { + + private final Context mContext; + private final TermuxAppSharedPreferences mPreferences; + + private static TerminalIOPreferencesDataStore mInstance; + + private TerminalIOPreferencesDataStore(Context context) { + mContext = context; + mPreferences = TermuxAppSharedPreferences.build(context, true); + } + + public static synchronized TerminalIOPreferencesDataStore getInstance(Context context) { + if (mInstance == null) { + mInstance = new TerminalIOPreferencesDataStore(context); + } + return mInstance; + } + + + + @Override + public void putBoolean(String key, boolean value) { + if (mPreferences == null) return; + if (key == null) return; + + switch (key) { + case "soft_keyboard_enabled": + mPreferences.setSoftKeyboardEnabled(value); + break; + case "soft_keyboard_enabled_only_if_no_hardware": + mPreferences.setSoftKeyboardEnabledOnlyIfNoHardware(value); + break; + default: + break; + } + } + + @Override + public boolean getBoolean(String key, boolean defValue) { + if (mPreferences == null) return false; + + switch (key) { + case "soft_keyboard_enabled": + return mPreferences.isSoftKeyboardEnabled(); + case "soft_keyboard_enabled_only_if_no_hardware": + return mPreferences.isSoftKeyboardEnabledOnlyIfNoHardware(); + default: + return false; + } + } + +} diff --git a/app/src/main/java/com/termux/app/fragments/settings/termux/TerminalViewPreferencesFragment.java b/app/src/main/java/com/termux/app/fragments/settings/termux/TerminalViewPreferencesFragment.java new file mode 100644 index 0000000..ed10d18 --- /dev/null +++ b/app/src/main/java/com/termux/app/fragments/settings/termux/TerminalViewPreferencesFragment.java @@ -0,0 +1,77 @@ +package com.termux.app.fragments.settings.termux; + +import android.content.Context; +import android.os.Bundle; + +import androidx.annotation.Keep; +import androidx.preference.PreferenceDataStore; +import androidx.preference.PreferenceFragmentCompat; +import androidx.preference.PreferenceManager; + +import com.termux.R; +import com.termux.shared.settings.preferences.TermuxAppSharedPreferences; + +@Keep +public class TerminalViewPreferencesFragment extends PreferenceFragmentCompat { + + @Override + public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { + Context context = getContext(); + if (context == null) return; + + PreferenceManager preferenceManager = getPreferenceManager(); + preferenceManager.setPreferenceDataStore(TerminalViewPreferencesDataStore.getInstance(context)); + + setPreferencesFromResource(R.xml.termux_terminal_view_preferences, rootKey); + } + +} + +class TerminalViewPreferencesDataStore extends PreferenceDataStore { + + private final Context mContext; + private final TermuxAppSharedPreferences mPreferences; + + private static TerminalViewPreferencesDataStore mInstance; + + private TerminalViewPreferencesDataStore(Context context) { + mContext = context; + mPreferences = TermuxAppSharedPreferences.build(context, true); + } + + public static synchronized TerminalViewPreferencesDataStore getInstance(Context context) { + if (mInstance == null) { + mInstance = new TerminalViewPreferencesDataStore(context); + } + return mInstance; + } + + + + @Override + public void putBoolean(String key, boolean value) { + if (mPreferences == null) return; + if (key == null) return; + + switch (key) { + case "terminal_margin_adjustment": + mPreferences.setTerminalMarginAdjustment(value); + break; + default: + break; + } + } + + @Override + public boolean getBoolean(String key, boolean defValue) { + if (mPreferences == null) return false; + + switch (key) { + case "terminal_margin_adjustment": + return mPreferences.isTerminalMarginAdjustmentEnabled(); + default: + return false; + } + } + +} diff --git a/app/src/main/java/com/termux/app/fragments/settings/termux_api/DebuggingPreferencesFragment.java b/app/src/main/java/com/termux/app/fragments/settings/termux_api/DebuggingPreferencesFragment.java new file mode 100644 index 0000000..6d09eae --- /dev/null +++ b/app/src/main/java/com/termux/app/fragments/settings/termux_api/DebuggingPreferencesFragment.java @@ -0,0 +1,101 @@ +package com.termux.app.fragments.settings.termux_api; + +import android.content.Context; +import android.os.Bundle; + +import androidx.annotation.Keep; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.preference.ListPreference; +import androidx.preference.PreferenceCategory; +import androidx.preference.PreferenceDataStore; +import androidx.preference.PreferenceFragmentCompat; +import androidx.preference.PreferenceManager; + +import com.termux.R; +import com.termux.shared.settings.preferences.TermuxAPIAppSharedPreferences; + +@Keep +public class DebuggingPreferencesFragment extends PreferenceFragmentCompat { + + @Override + public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { + Context context = getContext(); + if (context == null) return; + + PreferenceManager preferenceManager = getPreferenceManager(); + preferenceManager.setPreferenceDataStore(DebuggingPreferencesDataStore.getInstance(context)); + + setPreferencesFromResource(R.xml.termux_api_debugging_preferences, rootKey); + + configureLoggingPreferences(context); + } + + private void configureLoggingPreferences(@NonNull Context context) { + PreferenceCategory loggingCategory = findPreference("logging"); + if (loggingCategory == null) return; + + ListPreference logLevelListPreference = findPreference("log_level"); + if (logLevelListPreference != null) { + TermuxAPIAppSharedPreferences preferences = TermuxAPIAppSharedPreferences.build(context, true); + if (preferences == null) return; + + com.termux.app.fragments.settings.termux.DebuggingPreferencesFragment. + setLogLevelListPreferenceData(logLevelListPreference, context, preferences.getLogLevel(true)); + loggingCategory.addPreference(logLevelListPreference); + } + } +} + +class DebuggingPreferencesDataStore extends PreferenceDataStore { + + private final Context mContext; + private final TermuxAPIAppSharedPreferences mPreferences; + + private static DebuggingPreferencesDataStore mInstance; + + private DebuggingPreferencesDataStore(Context context) { + mContext = context; + mPreferences = TermuxAPIAppSharedPreferences.build(context, true); + } + + public static synchronized DebuggingPreferencesDataStore getInstance(Context context) { + if (mInstance == null) { + mInstance = new DebuggingPreferencesDataStore(context); + } + return mInstance; + } + + + + @Override + @Nullable + public String getString(String key, @Nullable String defValue) { + if (mPreferences == null) return null; + if (key == null) return null; + + switch (key) { + case "log_level": + return String.valueOf(mPreferences.getLogLevel(true)); + default: + return null; + } + } + + @Override + public void putString(String key, @Nullable String value) { + if (mPreferences == null) return; + if (key == null) return; + + switch (key) { + case "log_level": + if (value != null) { + mPreferences.setLogLevel(mContext, Integer.parseInt(value), true); + } + break; + default: + break; + } + } + +} diff --git a/app/src/main/java/com/termux/app/fragments/settings/termux_float/DebuggingPreferencesFragment.java b/app/src/main/java/com/termux/app/fragments/settings/termux_float/DebuggingPreferencesFragment.java new file mode 100644 index 0000000..a52f785 --- /dev/null +++ b/app/src/main/java/com/termux/app/fragments/settings/termux_float/DebuggingPreferencesFragment.java @@ -0,0 +1,126 @@ +package com.termux.app.fragments.settings.termux_float; + +import android.content.Context; +import android.os.Bundle; + +import androidx.annotation.Keep; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.preference.ListPreference; +import androidx.preference.PreferenceCategory; +import androidx.preference.PreferenceDataStore; +import androidx.preference.PreferenceFragmentCompat; +import androidx.preference.PreferenceManager; + +import com.termux.R; +import com.termux.shared.settings.preferences.TermuxFloatAppSharedPreferences; + +@Keep +public class DebuggingPreferencesFragment extends PreferenceFragmentCompat { + + @Override + public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { + Context context = getContext(); + if (context == null) return; + + PreferenceManager preferenceManager = getPreferenceManager(); + preferenceManager.setPreferenceDataStore(DebuggingPreferencesDataStore.getInstance(context)); + + setPreferencesFromResource(R.xml.termux_float_debugging_preferences, rootKey); + + configureLoggingPreferences(context); + } + + private void configureLoggingPreferences(@NonNull Context context) { + PreferenceCategory loggingCategory = findPreference("logging"); + if (loggingCategory == null) return; + + ListPreference logLevelListPreference = findPreference("log_level"); + if (logLevelListPreference != null) { + TermuxFloatAppSharedPreferences preferences = TermuxFloatAppSharedPreferences.build(context, true); + if (preferences == null) return; + + com.termux.app.fragments.settings.termux.DebuggingPreferencesFragment. + setLogLevelListPreferenceData(logLevelListPreference, context, preferences.getLogLevel(true)); + loggingCategory.addPreference(logLevelListPreference); + } + } +} + +class DebuggingPreferencesDataStore extends PreferenceDataStore { + + private final Context mContext; + private final TermuxFloatAppSharedPreferences mPreferences; + + private static DebuggingPreferencesDataStore mInstance; + + private DebuggingPreferencesDataStore(Context context) { + mContext = context; + mPreferences = TermuxFloatAppSharedPreferences.build(context, true); + } + + public static synchronized DebuggingPreferencesDataStore getInstance(Context context) { + if (mInstance == null) { + mInstance = new DebuggingPreferencesDataStore(context); + } + return mInstance; + } + + + + @Override + @Nullable + public String getString(String key, @Nullable String defValue) { + if (mPreferences == null) return null; + if (key == null) return null; + + switch (key) { + case "log_level": + return String.valueOf(mPreferences.getLogLevel(true)); + default: + return null; + } + } + + @Override + public void putString(String key, @Nullable String value) { + if (mPreferences == null) return; + if (key == null) return; + + switch (key) { + case "log_level": + if (value != null) { + mPreferences.setLogLevel(mContext, Integer.parseInt(value), true); + } + break; + default: + break; + } + } + + @Override + public void putBoolean(String key, boolean value) { + if (mPreferences == null) return; + if (key == null) return; + + switch (key) { + case "terminal_view_key_logging_enabled": + mPreferences.setTerminalViewKeyLoggingEnabled(value, true); + break; + default: + break; + } + } + + @Override + public boolean getBoolean(String key, boolean defValue) { + if (mPreferences == null) return false; + switch (key) { + case "terminal_view_key_logging_enabled": + return mPreferences.isTerminalViewKeyLoggingEnabled(true); + default: + return false; + } + } + +} diff --git a/app/src/main/java/com/termux/app/fragments/settings/termux_tasker/DebuggingPreferencesFragment.java b/app/src/main/java/com/termux/app/fragments/settings/termux_tasker/DebuggingPreferencesFragment.java new file mode 100644 index 0000000..a667081 --- /dev/null +++ b/app/src/main/java/com/termux/app/fragments/settings/termux_tasker/DebuggingPreferencesFragment.java @@ -0,0 +1,101 @@ +package com.termux.app.fragments.settings.termux_tasker; + +import android.content.Context; +import android.os.Bundle; + +import androidx.annotation.Keep; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.preference.ListPreference; +import androidx.preference.PreferenceCategory; +import androidx.preference.PreferenceDataStore; +import androidx.preference.PreferenceFragmentCompat; +import androidx.preference.PreferenceManager; + +import com.termux.R; +import com.termux.shared.settings.preferences.TermuxTaskerAppSharedPreferences; + +@Keep +public class DebuggingPreferencesFragment extends PreferenceFragmentCompat { + + @Override + public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { + Context context = getContext(); + if (context == null) return; + + PreferenceManager preferenceManager = getPreferenceManager(); + preferenceManager.setPreferenceDataStore(DebuggingPreferencesDataStore.getInstance(context)); + + setPreferencesFromResource(R.xml.termux_tasker_debugging_preferences, rootKey); + + configureLoggingPreferences(context); + } + + private void configureLoggingPreferences(@NonNull Context context) { + PreferenceCategory loggingCategory = findPreference("logging"); + if (loggingCategory == null) return; + + ListPreference logLevelListPreference = findPreference("log_level"); + if (logLevelListPreference != null) { + TermuxTaskerAppSharedPreferences preferences = TermuxTaskerAppSharedPreferences.build(context, true); + if (preferences == null) return; + + com.termux.app.fragments.settings.termux.DebuggingPreferencesFragment. + setLogLevelListPreferenceData(logLevelListPreference, context, preferences.getLogLevel(true)); + loggingCategory.addPreference(logLevelListPreference); + } + } +} + +class DebuggingPreferencesDataStore extends PreferenceDataStore { + + private final Context mContext; + private final TermuxTaskerAppSharedPreferences mPreferences; + + private static DebuggingPreferencesDataStore mInstance; + + private DebuggingPreferencesDataStore(Context context) { + mContext = context; + mPreferences = TermuxTaskerAppSharedPreferences.build(context, true); + } + + public static synchronized DebuggingPreferencesDataStore getInstance(Context context) { + if (mInstance == null) { + mInstance = new DebuggingPreferencesDataStore(context); + } + return mInstance; + } + + + + @Override + @Nullable + public String getString(String key, @Nullable String defValue) { + if (mPreferences == null) return null; + if (key == null) return null; + + switch (key) { + case "log_level": + return String.valueOf(mPreferences.getLogLevel(true)); + default: + return null; + } + } + + @Override + public void putString(String key, @Nullable String value) { + if (mPreferences == null) return; + if (key == null) return; + + switch (key) { + case "log_level": + if (value != null) { + mPreferences.setLogLevel(mContext, Integer.parseInt(value), true); + } + break; + default: + break; + } + } + +} diff --git a/app/src/main/java/com/termux/app/fragments/settings/termux_widget/DebuggingPreferencesFragment.java b/app/src/main/java/com/termux/app/fragments/settings/termux_widget/DebuggingPreferencesFragment.java new file mode 100644 index 0000000..919f159 --- /dev/null +++ b/app/src/main/java/com/termux/app/fragments/settings/termux_widget/DebuggingPreferencesFragment.java @@ -0,0 +1,101 @@ +package com.termux.app.fragments.settings.termux_widget; + +import android.content.Context; +import android.os.Bundle; + +import androidx.annotation.Keep; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.preference.ListPreference; +import androidx.preference.PreferenceCategory; +import androidx.preference.PreferenceDataStore; +import androidx.preference.PreferenceFragmentCompat; +import androidx.preference.PreferenceManager; + +import com.termux.R; +import com.termux.shared.settings.preferences.TermuxWidgetAppSharedPreferences; + +@Keep +public class DebuggingPreferencesFragment extends PreferenceFragmentCompat { + + @Override + public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { + Context context = getContext(); + if (context == null) return; + + PreferenceManager preferenceManager = getPreferenceManager(); + preferenceManager.setPreferenceDataStore(DebuggingPreferencesDataStore.getInstance(context)); + + setPreferencesFromResource(R.xml.termux_widget_debugging_preferences, rootKey); + + configureLoggingPreferences(context); + } + + private void configureLoggingPreferences(@NonNull Context context) { + PreferenceCategory loggingCategory = findPreference("logging"); + if (loggingCategory == null) return; + + ListPreference logLevelListPreference = findPreference("log_level"); + if (logLevelListPreference != null) { + TermuxWidgetAppSharedPreferences preferences = TermuxWidgetAppSharedPreferences.build(context, true); + if (preferences == null) return; + + com.termux.app.fragments.settings.termux.DebuggingPreferencesFragment. + setLogLevelListPreferenceData(logLevelListPreference, context, preferences.getLogLevel(true)); + loggingCategory.addPreference(logLevelListPreference); + } + } +} + +class DebuggingPreferencesDataStore extends PreferenceDataStore { + + private final Context mContext; + private final TermuxWidgetAppSharedPreferences mPreferences; + + private static DebuggingPreferencesDataStore mInstance; + + private DebuggingPreferencesDataStore(Context context) { + mContext = context; + mPreferences = TermuxWidgetAppSharedPreferences.build(context, true); + } + + public static synchronized DebuggingPreferencesDataStore getInstance(Context context) { + if (mInstance == null) { + mInstance = new DebuggingPreferencesDataStore(context); + } + return mInstance; + } + + + + @Override + @Nullable + public String getString(String key, @Nullable String defValue) { + if (mPreferences == null) return null; + if (key == null) return null; + + switch (key) { + case "log_level": + return String.valueOf(mPreferences.getLogLevel(true)); + default: + return null; + } + } + + @Override + public void putString(String key, @Nullable String value) { + if (mPreferences == null) return; + if (key == null) return; + + switch (key) { + case "log_level": + if (value != null) { + mPreferences.setLogLevel(mContext, Integer.parseInt(value), true); + } + break; + default: + break; + } + } + +} diff --git a/app/src/main/java/com/termux/app/models/UserAction.java b/app/src/main/java/com/termux/app/models/UserAction.java new file mode 100644 index 0000000..ee47605 --- /dev/null +++ b/app/src/main/java/com/termux/app/models/UserAction.java @@ -0,0 +1,20 @@ +package com.termux.app.models; + +public enum UserAction { + + ABOUT("about"), + CRASH_REPORT("crash report"), + PLUGIN_EXECUTION_COMMAND("plugin execution command"), + REPORT_ISSUE_FROM_TRANSCRIPT("report issue from transcript"); + + private final String name; + + UserAction(final String name) { + this.name = name; + } + + public String getName() { + return name; + } + +} diff --git a/app/src/main/java/com/termux/app/settings/properties/TermuxAppSharedProperties.java b/app/src/main/java/com/termux/app/settings/properties/TermuxAppSharedProperties.java new file mode 100644 index 0000000..7dbf2e1 --- /dev/null +++ b/app/src/main/java/com/termux/app/settings/properties/TermuxAppSharedProperties.java @@ -0,0 +1,119 @@ +package com.termux.app.settings.properties; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import com.termux.app.terminal.io.KeyboardShortcut; +import com.termux.shared.terminal.io.extrakeys.ExtraKeysConstants; +import com.termux.shared.terminal.io.extrakeys.ExtraKeysConstants.EXTRA_KEY_DISPLAY_MAPS; +import com.termux.shared.terminal.io.extrakeys.ExtraKeysInfo; +import com.termux.shared.logger.Logger; +import com.termux.shared.settings.properties.TermuxPropertyConstants; +import com.termux.shared.settings.properties.TermuxSharedProperties; +import com.termux.shared.termux.TermuxConstants; + +import org.json.JSONException; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +public class TermuxAppSharedProperties extends TermuxSharedProperties { + + private ExtraKeysInfo mExtraKeysInfo; + private List mSessionShortcuts = new ArrayList<>(); + + private static final String LOG_TAG = "TermuxAppSharedProperties"; + + public TermuxAppSharedProperties(@NonNull Context context) { + super(context, TermuxConstants.TERMUX_APP_NAME, TermuxPropertyConstants.getTermuxPropertiesFile(), + TermuxPropertyConstants.TERMUX_PROPERTIES_LIST, new SharedPropertiesParserClient()); + } + + /** + * Reload the termux properties from disk into an in-memory cache. + */ + @Override + public void loadTermuxPropertiesFromDisk() { + super.loadTermuxPropertiesFromDisk(); + + setExtraKeys(); + setSessionShortcuts(); + } + + /** + * 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) getInternalPropertyValue(TermuxPropertyConstants.KEY_EXTRA_KEYS, true); + String extraKeysStyle = (String) getInternalPropertyValue(TermuxPropertyConstants.KEY_EXTRA_KEYS_STYLE, true); + + ExtraKeysConstants.ExtraKeyDisplayMap extraKeyDisplayMap = ExtraKeysInfo.getCharDisplayMapForStyle(extraKeysStyle); + if (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(mContext, "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(mContext, "Can't create default extra keys",true); + Logger.logStackTraceWithMessage(LOG_TAG, "Could create default extra keys: ", e); + mExtraKeysInfo = null; + } + } + } + + /** + * Set the terminal sessions shortcuts. + */ + private void setSessionShortcuts() { + if (mSessionShortcuts == null) + mSessionShortcuts = new ArrayList<>(); + else + mSessionShortcuts.clear(); + + // The {@link TermuxPropertyConstants#MAP_SESSION_SHORTCUTS} stores the session shortcut key and action pair + for (Map.Entry entry : TermuxPropertyConstants.MAP_SESSION_SHORTCUTS.entrySet()) { + // The mMap stores the code points for the session shortcuts while loading properties + Integer codePoint = (Integer) 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 List getSessionShortcuts() { + return mSessionShortcuts; + } + + public ExtraKeysInfo getExtraKeysInfo() { + return mExtraKeysInfo; + } + + + + /** + * Load the {@link TermuxPropertyConstants#KEY_TERMINAL_TRANSCRIPT_ROWS} value from termux properties file on disk. + */ + public static int getTerminalTranscriptRows(Context context) { + return (int) TermuxSharedProperties.getInternalPropertyValue(context, TermuxPropertyConstants.getTermuxPropertiesFile(), + TermuxPropertyConstants.KEY_TERMINAL_TRANSCRIPT_ROWS, new SharedPropertiesParserClient()); + } + +} diff --git a/app/src/main/java/com/termux/app/terminal/TermuxActivityRootView.java b/app/src/main/java/com/termux/app/terminal/TermuxActivityRootView.java new file mode 100644 index 0000000..7e8b0e9 --- /dev/null +++ b/app/src/main/java/com/termux/app/terminal/TermuxActivityRootView.java @@ -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); + } + } + +} diff --git a/app/src/main/java/com/termux/app/terminal/TermuxSessionsListViewController.java b/app/src/main/java/com/termux/app/terminal/TermuxSessionsListViewController.java new file mode 100644 index 0000000..76c7930 --- /dev/null +++ b/app/src/main/java/com/termux/app/terminal/TermuxSessionsListViewController.java @@ -0,0 +1,107 @@ +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.shell.TermuxSession; +import com.termux.terminal.TerminalSession; + +import java.util.List; + +public class TermuxSessionsListViewController extends ArrayAdapter 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 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 isUsingBlackUI = mActivity.getProperties().isUsingBlackUI(); + + if (isUsingBlackUI) { + 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 = isUsingBlackUI ? 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; + } + +} diff --git a/app/src/main/java/com/termux/app/terminal/TermuxTerminalSessionClient.java b/app/src/main/java/com/termux/app/terminal/TermuxTerminalSessionClient.java new file mode 100644 index 0000000..a9e6025 --- /dev/null +++ b/app/src/main/java/com/termux/app/terminal/TermuxTerminalSessionClient.java @@ -0,0 +1,495 @@ +package com.termux.app.terminal; + +import android.annotation.SuppressLint; +import android.app.AlertDialog; +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +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 com.termux.R; +import com.termux.shared.shell.TermuxSession; +import com.termux.shared.interact.TextInputDialogUtils; +import com.termux.shared.interact.ShareUtils; +import com.termux.app.TermuxActivity; +import com.termux.shared.terminal.TermuxTerminalSessionClientBase; +import com.termux.shared.termux.TermuxConstants; +import com.termux.app.TermuxService; +import com.termux.shared.settings.properties.TermuxPropertyConstants; +import com.termux.shared.terminal.io.BellHandler; +import com.termux.shared.logger.Logger; +import com.termux.terminal.TerminalColors; +import com.termux.terminal.TerminalSession; +import com.termux.terminal.TextStyle; + +import java.io.File; +import java.io.FileInputStream; +import java.io.InputStream; +import java.util.Properties; + +public class TermuxTerminalSessionClient 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 = "TermuxTerminalSessionClient"; + + public TermuxTerminalSessionClient(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 + getBellSoundPool(); + } + + /** + * 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 onReload() { + // Set terminal fonts and colors + checkForFontAndColors(); + } + + + + @Override + public void onTextChanged(TerminalSession changedSession) { + if (!mActivity.isVisible()) return; + + if (mActivity.getCurrentSession() == changedSession) mActivity.getTerminalView().onScreenUpdated(); + } + + @Override + public void onTitleChanged(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(final 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(TerminalSession session, String text) { + if (!mActivity.isVisible()) return; + + ShareUtils.copyTextToClipboard(mActivity, text); + } + + @Override + public void onPasteTextFromClipboard(TerminalSession session) { + if (!mActivity.isVisible()) return; + + String text = ShareUtils.getTextStringFromClipboardIfSet(mActivity, true); + if (text != null) + mActivity.getTerminalView().mEmulator.paste(text); + } + + @Override + public void onBell(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: + getBellSoundPool().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(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); + } + + /** + * 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(); + } + + + + /** Initialize and get mBellSoundPool */ + private synchronized SoundPool getBellSoundPool() { + 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(); + + mBellSoundId = mBellSoundPool.load(mActivity, R.raw.bell, 1); + } + + return mBellSoundPool; + } + + /** 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 -> { + sessionToRename.mSessionName = text; + termuxSessionListNotifyUpdated(); + }, -1, null, -1, null, null); + } + + public void addNewSession(boolean isFailSafe, String sessionName) { + 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(); + + String workingDirectory; + 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]); + } + } + +} diff --git a/app/src/main/java/com/termux/app/terminal/TermuxTerminalViewClient.java b/app/src/main/java/com/termux/app/terminal/TermuxTerminalViewClient.java new file mode 100644 index 0000000..279e3b0 --- /dev/null +++ b/app/src/main/java/com/termux/app/terminal/TermuxTerminalViewClient.java @@ -0,0 +1,761 @@ +package com.termux.app.terminal; + +import android.annotation.SuppressLint; +import android.app.AlertDialog; +import android.content.ActivityNotFoundException; +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +import android.media.AudioManager; +import android.net.Uri; +import android.os.Environment; +import android.text.TextUtils; +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 android.widget.Toast; + +import com.termux.R; +import com.termux.app.TermuxActivity; +import com.termux.shared.data.UrlUtils; +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.terminal.TermuxTerminalViewClientBase; +import com.termux.shared.terminal.io.extrakeys.SpecialButton; +import com.termux.shared.termux.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.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.view.KeyboardUtils; +import com.termux.shared.view.ViewUtils; +import com.termux.terminal.KeyHandler; +import com.termux.terminal.TerminalBuffer; +import com.termux.terminal.TerminalEmulator; +import com.termux.terminal.TerminalSession; + +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; + +import androidx.drawerlayout.widget.DrawerLayout; + +public class TermuxTerminalViewClient extends TermuxTerminalViewClientBase { + + final TermuxActivity mActivity; + + final TermuxTerminalSessionClient mTermuxTerminalSessionClient; + + /** 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 static final String LOG_TAG = "TermuxTerminalViewClient"; + + public TermuxTerminalViewClient(TermuxActivity activity, TermuxTerminalSessionClient termuxTerminalSessionClient) { + this.mActivity = activity; + this.mTermuxTerminalSessionClient = termuxTerminalSessionClient; + } + + public TermuxActivity getActivity() { + return mActivity; + } + + /** + * Should be called when mActivity.onCreate() is called + */ + public void onCreate() { + 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, false); + + 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.reloadActivityStyling() is called + */ + public void onReload() { + // 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 urlSet = UrlUtils.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()) { + mTermuxTerminalSessionClient.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 */) { + mTermuxTerminalSessionClient.switchToSession(true); + } else if (keyCode == KeyEvent.KEYCODE_DPAD_UP || unicodeChar == 'p' /* previous */) { + mTermuxTerminalSessionClient.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 */) { + mTermuxTerminalSessionClient.renameSession(currentSession); + } else if (unicodeChar == 'c'/* create */) { + mTermuxTerminalSessionClient.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'; + mTermuxTerminalSessionClient.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()) { + mTermuxTerminalSessionClient.removeFinishedSession(session); + return true; + } + + List shortcuts = mActivity.getProperties().getSessionShortcuts(); + 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: + mTermuxTerminalSessionClient.addNewSession(false, null); + return true; + case TermuxPropertyConstants.ACTION_SHORTCUT_NEXT_SESSION: + mTermuxTerminalSessionClient.switchToSession(true); + return true; + case TermuxPropertyConstants.ACTION_SHORTCUT_PREVIOUS_SESSION: + mTermuxTerminalSessionClient.switchToSession(false); + return true; + case TermuxPropertyConstants.ACTION_SHORTCUT_RENAME_SESSION: + mTermuxTerminalSessionClient.renameSession(mActivity.getCurrentSession()); + return true; + } + } + } + } + } + + return false; + } + + + + 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 urlSet = UrlUtils.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"); + + reportString.append("\n\n").append(TermuxUtils.getAppInfoMarkdownString(mActivity, true)); + reportString.append("\n\n").append(AndroidUtils.getDeviceInfoMarkdownString(mActivity)); + + 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(); + ReportActivity.startReportActivity(mActivity, + new ReportInfo(userActionName, + TermuxConstants.TERMUX_APP.TERMUX_ACTIVITY_NAME, title, null, + reportString.toString(), "\n\n" + TermuxUtils.getReportIssueMarkdownString(mActivity), + false, + userActionName, + Environment.getExternalStorageDirectory() + "/" + + FileUtils.sanitizeFileName(TermuxConstants.TERMUX_APP_NAME + "-" + userActionName + ".log", true, true))); + } + }.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); + } + +} diff --git a/app/src/main/java/com/termux/app/terminal/io/FullScreenWorkAround.java b/app/src/main/java/com/termux/app/terminal/io/FullScreenWorkAround.java new file mode 100644 index 0000000..c01f899 --- /dev/null +++ b/app/src/main/java/com/termux/app/terminal/io/FullScreenWorkAround.java @@ -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); + } + +} + diff --git a/app/src/main/java/com/termux/app/terminal/io/KeyboardShortcut.java b/app/src/main/java/com/termux/app/terminal/io/KeyboardShortcut.java new file mode 100644 index 0000000..00a832d --- /dev/null +++ b/app/src/main/java/com/termux/app/terminal/io/KeyboardShortcut.java @@ -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; + } + +} diff --git a/app/src/main/java/com/termux/app/terminal/io/TerminalToolbarViewPager.java b/app/src/main/java/com/termux/app/terminal/io/TerminalToolbarViewPager.java new file mode 100644 index 0000000..de49b3c --- /dev/null +++ b/app/src/main/java/com/termux/app/terminal/io/TerminalToolbarViewPager.java @@ -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.terminal.io.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(new TermuxTerminalExtraKeys(mActivity.getTerminalView(), + mActivity.getTermuxTerminalViewClient(), mActivity.getTermuxTerminalSessionClient())); + extraKeysView.setButtonTextAllCaps(mActivity.getProperties().shouldExtraKeysTextBeAllCaps()); + mActivity.setExtraKeysView(extraKeysView); + extraKeysView.reload(mActivity.getProperties().getExtraKeysInfo()); + + // 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(); + } + } + + } + +} diff --git a/app/src/main/java/com/termux/app/terminal/io/TermuxTerminalExtraKeys.java b/app/src/main/java/com/termux/app/terminal/io/TermuxTerminalExtraKeys.java new file mode 100644 index 0000000..8eed517 --- /dev/null +++ b/app/src/main/java/com/termux/app/terminal/io/TermuxTerminalExtraKeys.java @@ -0,0 +1,49 @@ +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.terminal.TermuxTerminalSessionClient; +import com.termux.app.terminal.TermuxTerminalViewClient; +import com.termux.shared.terminal.io.TerminalExtraKeys; +import com.termux.view.TerminalView; + +public class TermuxTerminalExtraKeys extends TerminalExtraKeys { + + + TermuxTerminalViewClient mTermuxTerminalViewClient; + TermuxTerminalSessionClient mTermuxTerminalSessionClient; + + public TermuxTerminalExtraKeys(@NonNull TerminalView terminalView, + TermuxTerminalViewClient termuxTerminalViewClient, + TermuxTerminalSessionClient termuxTerminalSessionClient) { + super(terminalView); + mTermuxTerminalViewClient = termuxTerminalViewClient; + mTermuxTerminalSessionClient = termuxTerminalSessionClient; + } + + @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(mTermuxTerminalSessionClient != null) + mTermuxTerminalSessionClient.onPasteTextFromClipboard(null); + } else { + super.onTerminalExtraKeyButtonClick(view, key, ctrlDown, altDown, shiftDown, fnDown); + } + } + +} diff --git a/app/src/main/java/com/termux/app/utils/CrashUtils.java b/app/src/main/java/com/termux/app/utils/CrashUtils.java new file mode 100644 index 0000000..73e7d67 --- /dev/null +++ b/app/src/main/java/com/termux/app/utils/CrashUtils.java @@ -0,0 +1,214 @@ +package com.termux.app.utils; + +import android.app.Notification; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.Context; +import android.os.Environment; + +import androidx.annotation.Nullable; + +import com.termux.R; +import com.termux.shared.activities.ReportActivity; +import com.termux.shared.models.errors.Error; +import com.termux.shared.notification.NotificationUtils; +import com.termux.shared.file.FileUtils; +import com.termux.shared.models.ReportInfo; +import com.termux.app.models.UserAction; +import com.termux.shared.notification.TermuxNotificationUtils; +import com.termux.shared.settings.preferences.TermuxAppSharedPreferences; +import com.termux.shared.settings.preferences.TermuxPreferenceConstants; +import com.termux.shared.data.DataUtils; +import com.termux.shared.logger.Logger; +import com.termux.shared.termux.AndroidUtils; +import com.termux.shared.termux.TermuxUtils; + +import com.termux.shared.termux.TermuxConstants; + +import java.nio.charset.Charset; + +public class CrashUtils { + + private static final String LOG_TAG = "CrashUtils"; + + /** + * Notify the user of an app crash at last run by reading the crash info from the crash log file + * at {@link TermuxConstants#TERMUX_CRASH_LOG_FILE_PATH}. The crash log file would have been + * created by {@link com.termux.shared.crash.CrashHandler}. + * + * If the crash log file exists and is not empty and + * {@link TermuxPreferenceConstants.TERMUX_APP#KEY_CRASH_REPORT_NOTIFICATIONS_ENABLED} is + * enabled, then a notification will be shown for the crash on the + * {@link TermuxConstants#TERMUX_CRASH_REPORTS_NOTIFICATION_CHANNEL_NAME} channel, otherwise nothing will be done. + * + * After reading from the crash log file, it will be moved to {@link TermuxConstants#TERMUX_CRASH_LOG_BACKUP_FILE_PATH}. + * + * @param context The {@link Context} for operations. + * @param logTagParam The log tag to use for logging. + */ + public static void notifyAppCrashOnLastRun(final Context context, final String logTagParam) { + if (context == null) return; + + TermuxAppSharedPreferences preferences = TermuxAppSharedPreferences.build(context); + if (preferences == null) return; + + // If user has disabled notifications for crashes + if (!preferences.areCrashReportNotificationsEnabled()) + return; + + new Thread() { + @Override + public void run() { + String logTag = DataUtils.getDefaultIfNull(logTagParam, LOG_TAG); + + if (!FileUtils.regularFileExists(TermuxConstants.TERMUX_CRASH_LOG_FILE_PATH, false)) + return; + + Error error; + StringBuilder reportStringBuilder = new StringBuilder(); + + // Read report string from crash log file + error = FileUtils.readStringFromFile("crash log", TermuxConstants.TERMUX_CRASH_LOG_FILE_PATH, Charset.defaultCharset(), reportStringBuilder, false); + if (error != null) { + Logger.logErrorExtended(logTag, error.toString()); + return; + } + + // Move crash log file to backup location if it exists + error = FileUtils.moveRegularFile("crash log", TermuxConstants.TERMUX_CRASH_LOG_FILE_PATH, TermuxConstants.TERMUX_CRASH_LOG_BACKUP_FILE_PATH, true); + if (error != null) { + Logger.logErrorExtended(logTag, error.toString()); + } + + String reportString = reportStringBuilder.toString(); + + if (reportString.isEmpty()) + return; + + Logger.logDebug(logTag, "A crash log file found at \"" + TermuxConstants.TERMUX_CRASH_LOG_FILE_PATH + "\"."); + + sendCrashReportNotification(context, logTag, reportString, false, false); + } + }.start(); + } + + /** + * Send a crash report notification for {@link TermuxConstants#TERMUX_CRASH_REPORTS_NOTIFICATION_CHANNEL_ID} + * and {@link TermuxConstants#TERMUX_CRASH_REPORTS_NOTIFICATION_CHANNEL_NAME}. + * + * @param context The {@link Context} for operations. + * @param logTag The log tag to use for logging. + * @param message The message for the crash report. + * @param forceNotification If set to {@code true}, then a notification will be shown + * regardless of if pending intent is {@code null} or + * {@link TermuxPreferenceConstants.TERMUX_APP#KEY_CRASH_REPORT_NOTIFICATIONS_ENABLED} + * is {@code false}. + * @param addAppAndDeviceInfo If set to {@code true}, then app and device info will be appended + * to the message. + */ + public static void sendCrashReportNotification(final Context context, String logTag, String message, boolean forceNotification, boolean addAppAndDeviceInfo) { + if (context == null) return; + + TermuxAppSharedPreferences preferences = TermuxAppSharedPreferences.build(context); + if (preferences == null) return; + + // If user has disabled notifications for crashes + if (!preferences.areCrashReportNotificationsEnabled() && !forceNotification) + return; + + logTag = DataUtils.getDefaultIfNull(logTag, LOG_TAG); + + // Send a notification to show the crash log which when clicked will open the {@link ReportActivity} + // to show the details of the crash + String title = TermuxConstants.TERMUX_APP_NAME + " Crash Report"; + + Logger.logDebug(logTag, "Sending \"" + title + "\" notification."); + + StringBuilder reportString = new StringBuilder(message); + + if (addAppAndDeviceInfo) { + reportString.append("\n\n").append(TermuxUtils.getAppInfoMarkdownString(context, true)); + reportString.append("\n\n").append(AndroidUtils.getDeviceInfoMarkdownString(context)); + } + + String userActionName = UserAction.CRASH_REPORT.getName(); + ReportActivity.NewInstanceResult result = ReportActivity.newInstance(context, new ReportInfo(userActionName, + logTag, title, null, reportString.toString(), + "\n\n" + TermuxUtils.getReportIssueMarkdownString(context), true, + userActionName, + Environment.getExternalStorageDirectory() + "/" + + FileUtils.sanitizeFileName(TermuxConstants.TERMUX_APP_NAME + "-" + userActionName + ".log", true, true))); + if (result.contentIntent == null) return; + + // Must ensure result code for PendingIntents and id for notification are unique otherwise will override previous + int nextNotificationId = TermuxNotificationUtils.getNextNotificationId(context); + + PendingIntent contentIntent = PendingIntent.getActivity(context, nextNotificationId, result.contentIntent, PendingIntent.FLAG_UPDATE_CURRENT); + + PendingIntent deleteIntent = null; + if (result.deleteIntent != null) + deleteIntent = PendingIntent.getBroadcast(context, nextNotificationId, result.deleteIntent, PendingIntent.FLAG_UPDATE_CURRENT); + + // Setup the notification channel if not already set up + setupCrashReportsNotificationChannel(context); + + // Build the notification + Notification.Builder builder = getCrashReportsNotificationBuilder(context, title, null, + null, contentIntent, deleteIntent, NotificationUtils.NOTIFICATION_MODE_VIBRATE); + if (builder == null) return; + + // Send the notification + NotificationManager notificationManager = NotificationUtils.getNotificationManager(context); + if (notificationManager != null) + notificationManager.notify(nextNotificationId, builder.build()); + } + + /** + * Get {@link Notification.Builder} for {@link TermuxConstants#TERMUX_CRASH_REPORTS_NOTIFICATION_CHANNEL_ID} + * and {@link TermuxConstants#TERMUX_CRASH_REPORTS_NOTIFICATION_CHANNEL_NAME}. + * + * @param context The {@link Context} for operations. + * @param title The title for the notification. + * @param notificationText The second line text of the notification. + * @param notificationBigText The full text of the notification that may optionally be styled. + * @param contentIntent The {@link PendingIntent} which should be sent when notification is clicked. + * @param deleteIntent The {@link PendingIntent} which should be sent when notification is deleted. + * @param notificationMode The notification mode. It must be one of {@code NotificationUtils.NOTIFICATION_MODE_*}. + * @return Returns the {@link Notification.Builder}. + */ + @Nullable + public static Notification.Builder getCrashReportsNotificationBuilder(final Context context, final CharSequence title, final CharSequence notificationText, final CharSequence notificationBigText, final PendingIntent contentIntent, final PendingIntent deleteIntent, final int notificationMode) { + + Notification.Builder builder = NotificationUtils.geNotificationBuilder(context, + TermuxConstants.TERMUX_CRASH_REPORTS_NOTIFICATION_CHANNEL_ID, Notification.PRIORITY_HIGH, + title, notificationText, notificationBigText, contentIntent, deleteIntent, notificationMode); + + if (builder == null) return null; + + // Enable timestamp + builder.setShowWhen(true); + + // Set notification icon + builder.setSmallIcon(R.drawable.ic_error_notification); + + // Set background color for small notification icon + builder.setColor(0xFF607D8B); + + // Dismiss on click + builder.setAutoCancel(true); + + return builder; + } + + /** + * Setup the notification channel for {@link TermuxConstants#TERMUX_CRASH_REPORTS_NOTIFICATION_CHANNEL_ID} and + * {@link TermuxConstants#TERMUX_CRASH_REPORTS_NOTIFICATION_CHANNEL_NAME}. + * + * @param context The {@link Context} for operations. + */ + public static void setupCrashReportsNotificationChannel(final Context context) { + NotificationUtils.setupNotificationChannel(context, TermuxConstants.TERMUX_CRASH_REPORTS_NOTIFICATION_CHANNEL_ID, + TermuxConstants.TERMUX_CRASH_REPORTS_NOTIFICATION_CHANNEL_NAME, NotificationManager.IMPORTANCE_HIGH); + } + +} diff --git a/app/src/main/java/com/termux/app/utils/PluginUtils.java b/app/src/main/java/com/termux/app/utils/PluginUtils.java new file mode 100644 index 0000000..adc165a --- /dev/null +++ b/app/src/main/java/com/termux/app/utils/PluginUtils.java @@ -0,0 +1,335 @@ +package com.termux.app.utils; + +import android.app.Notification; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.Context; +import android.os.Environment; + +import androidx.annotation.Nullable; + +import com.termux.R; +import com.termux.shared.activities.ReportActivity; +import com.termux.shared.file.FileUtils; +import com.termux.shared.file.TermuxFileUtils; +import com.termux.shared.models.ResultConfig; +import com.termux.shared.models.ResultData; +import com.termux.shared.models.errors.Errno; +import com.termux.shared.models.errors.Error; +import com.termux.shared.notification.NotificationUtils; +import com.termux.shared.notification.TermuxNotificationUtils; +import com.termux.shared.shell.ResultSender; +import com.termux.shared.shell.ShellUtils; +import com.termux.shared.termux.AndroidUtils; +import com.termux.shared.termux.TermuxConstants; +import com.termux.shared.termux.TermuxConstants.TERMUX_APP.TERMUX_SERVICE; +import com.termux.shared.logger.Logger; +import com.termux.shared.settings.preferences.TermuxAppSharedPreferences; +import com.termux.shared.settings.preferences.TermuxPreferenceConstants.TERMUX_APP; +import com.termux.shared.settings.properties.SharedProperties; +import com.termux.shared.settings.properties.TermuxPropertyConstants; +import com.termux.shared.models.ReportInfo; +import com.termux.shared.models.ExecutionCommand; +import com.termux.app.models.UserAction; +import com.termux.shared.data.DataUtils; +import com.termux.shared.markdown.MarkdownUtils; +import com.termux.shared.termux.TermuxUtils; + +public class PluginUtils { + + private static final String LOG_TAG = "PluginUtils"; + + /** + * Process {@link ExecutionCommand} result. + * + * The ExecutionCommand currentState must be greater or equal to + * {@link ExecutionCommand.ExecutionState#EXECUTED}. + * If the {@link ExecutionCommand#isPluginExecutionCommand} is {@code true} and + * {@link ResultConfig#resultPendingIntent} or {@link ResultConfig#resultDirectoryPath} + * is not {@code null}, then the result of commands are sent back to the command caller. + * + * @param context The {@link Context} that will be used to send result intent to the {@link PendingIntent} creator. + * @param logTag The log tag to use for logging. + * @param executionCommand The {@link ExecutionCommand} to process. + */ + public static void processPluginExecutionCommandResult(final Context context, String logTag, final ExecutionCommand executionCommand) { + if (executionCommand == null) return; + + logTag = DataUtils.getDefaultIfNull(logTag, LOG_TAG); + Error error = null; + ResultData resultData = executionCommand.resultData; + + if (!executionCommand.hasExecuted()) { + Logger.logWarn(logTag, executionCommand.getCommandIdAndLabelLogString() + ": Ignoring call to processPluginExecutionCommandResult() since the execution command state is not higher than the ExecutionState.EXECUTED"); + return; + } + + boolean isPluginExecutionCommandWithPendingResult = executionCommand.isPluginExecutionCommandWithPendingResult(); + boolean isExecutionCommandLoggingEnabled = Logger.shouldEnableLoggingForCustomLogLevel(executionCommand.backgroundCustomLogLevel); + + // Log the output. ResultData should not be logged if pending result since ResultSender will do it + // or if logging is disabled + Logger.logDebugExtended(logTag, ExecutionCommand.getExecutionOutputLogString(executionCommand, true, + !isPluginExecutionCommandWithPendingResult, isExecutionCommandLoggingEnabled)); + + // If execution command was started by a plugin which expects the result back + if (isPluginExecutionCommandWithPendingResult) { + // Set variables which will be used by sendCommandResultData to send back the result + if (executionCommand.resultConfig.resultPendingIntent != null) + setPluginResultPendingIntentVariables(executionCommand); + if (executionCommand.resultConfig.resultDirectoryPath != null) + setPluginResultDirectoryVariables(executionCommand); + + // Send result to caller + error = ResultSender.sendCommandResultData(context, logTag, executionCommand.getCommandIdAndLabelLogString(), + executionCommand.resultConfig, executionCommand.resultData, isExecutionCommandLoggingEnabled); + if (error != null) { + // error will be added to existing Errors + resultData.setStateFailed(error); + Logger.logDebugExtended(logTag, ExecutionCommand.getExecutionOutputLogString(executionCommand, true, true, isExecutionCommandLoggingEnabled)); + + // Flash and send notification for the error + Logger.showToast(context, ResultData.getErrorsListMinimalString(resultData), true); + sendPluginCommandErrorNotification(context, logTag, executionCommand, ResultData.getErrorsListMinimalString(resultData)); + } + + } + + if (!executionCommand.isStateFailed() && error == null) + executionCommand.setState(ExecutionCommand.ExecutionState.SUCCESS); + } + + /** + * Process {@link ExecutionCommand} error. + * + * The ExecutionCommand currentState must be equal to {@link ExecutionCommand.ExecutionState#FAILED}. + * The {@link ResultData#getErrCode()} must have been set to a value greater than + * {@link Errno#ERRNO_SUCCESS}. + * The {@link ResultData#errorsList} must also be set with appropriate error info. + * + * If the {@link ExecutionCommand#isPluginExecutionCommand} is {@code true} and + * {@link ResultConfig#resultPendingIntent} or {@link ResultConfig#resultDirectoryPath} + * is not {@code null}, then the errors of commands are sent back to the command caller. + * + * Otherwise if the {@link TERMUX_APP#KEY_PLUGIN_ERROR_NOTIFICATIONS_ENABLED} is + * enabled, then a flash and a notification will be shown for the error as well + * on the {@link TermuxConstants#TERMUX_PLUGIN_COMMAND_ERRORS_NOTIFICATION_CHANNEL_NAME} channel instead of just logging + * the error. + * + * @param context The {@link Context} for operations. + * @param logTag The log tag to use for logging. + * @param executionCommand The {@link ExecutionCommand} that failed. + * @param forceNotification If set to {@code true}, then a flash and notification will be shown + * regardless of if pending intent is {@code null} or + * {@link TERMUX_APP#KEY_PLUGIN_ERROR_NOTIFICATIONS_ENABLED} + * is {@code false}. + */ + public static void processPluginExecutionCommandError(final Context context, String logTag, final ExecutionCommand executionCommand, boolean forceNotification) { + if (context == null || executionCommand == null) return; + + logTag = DataUtils.getDefaultIfNull(logTag, LOG_TAG); + Error error; + ResultData resultData = executionCommand.resultData; + + if (!executionCommand.isStateFailed()) { + Logger.logWarn(logTag, executionCommand.getCommandIdAndLabelLogString() + ": Ignoring call to processPluginExecutionCommandError() since the execution command is not in ExecutionState.FAILED"); + return; + } + + boolean isPluginExecutionCommandWithPendingResult = executionCommand.isPluginExecutionCommandWithPendingResult(); + boolean isExecutionCommandLoggingEnabled = Logger.shouldEnableLoggingForCustomLogLevel(executionCommand.backgroundCustomLogLevel); + + // Log the error and any exception. ResultData should not be logged if pending result since ResultSender will do it + Logger.logErrorExtended(logTag, ExecutionCommand.getExecutionOutputLogString(executionCommand, true, + !isPluginExecutionCommandWithPendingResult, isExecutionCommandLoggingEnabled)); + + // If execution command was started by a plugin which expects the result back + if (isPluginExecutionCommandWithPendingResult) { + // Set variables which will be used by sendCommandResultData to send back the result + if (executionCommand.resultConfig.resultPendingIntent != null) + setPluginResultPendingIntentVariables(executionCommand); + if (executionCommand.resultConfig.resultDirectoryPath != null) + setPluginResultDirectoryVariables(executionCommand); + + // Send result to caller + error = ResultSender.sendCommandResultData(context, logTag, executionCommand.getCommandIdAndLabelLogString(), + executionCommand.resultConfig, executionCommand.resultData, isExecutionCommandLoggingEnabled); + if (error != null) { + // error will be added to existing Errors + resultData.setStateFailed(error); + Logger.logErrorExtended(logTag, ExecutionCommand.getExecutionOutputLogString(executionCommand, true, true, isExecutionCommandLoggingEnabled)); + forceNotification = true; + } + + // No need to show notifications if a pending intent was sent, let the caller handle the result himself + if (!forceNotification) return; + } + + TermuxAppSharedPreferences preferences = TermuxAppSharedPreferences.build(context); + if (preferences == null) return; + + // If user has disabled notifications for plugin commands, then just return + if (!preferences.arePluginErrorNotificationsEnabled() && !forceNotification) + return; + + // Flash and send notification for the error + Logger.showToast(context, ResultData.getErrorsListMinimalString(resultData), true); + sendPluginCommandErrorNotification(context, logTag, executionCommand, ResultData.getErrorsListMinimalString(resultData)); + + } + + /** Set variables which will be used by {@link ResultSender#sendCommandResultData(Context, String, String, ResultConfig, ResultData, boolean)} + * to send back the result via {@link ResultConfig#resultPendingIntent}. */ + public static void setPluginResultPendingIntentVariables(ExecutionCommand executionCommand) { + ResultConfig resultConfig = executionCommand.resultConfig; + + resultConfig.resultBundleKey = TERMUX_SERVICE.EXTRA_PLUGIN_RESULT_BUNDLE; + resultConfig.resultStdoutKey = TERMUX_SERVICE.EXTRA_PLUGIN_RESULT_BUNDLE_STDOUT; + resultConfig.resultStdoutOriginalLengthKey = TERMUX_SERVICE.EXTRA_PLUGIN_RESULT_BUNDLE_STDOUT_ORIGINAL_LENGTH; + resultConfig.resultStderrKey = TERMUX_SERVICE.EXTRA_PLUGIN_RESULT_BUNDLE_STDERR; + resultConfig.resultStderrOriginalLengthKey = TERMUX_SERVICE.EXTRA_PLUGIN_RESULT_BUNDLE_STDERR_ORIGINAL_LENGTH; + resultConfig.resultExitCodeKey = TERMUX_SERVICE.EXTRA_PLUGIN_RESULT_BUNDLE_EXIT_CODE; + resultConfig.resultErrCodeKey = TERMUX_SERVICE.EXTRA_PLUGIN_RESULT_BUNDLE_ERR; + resultConfig.resultErrmsgKey = TERMUX_SERVICE.EXTRA_PLUGIN_RESULT_BUNDLE_ERRMSG; + } + + /** Set variables which will be used by {@link ResultSender#sendCommandResultData(Context, String, String, ResultConfig, ResultData, boolean)} + * to send back the result by writing it to files in {@link ResultConfig#resultDirectoryPath}. */ + public static void setPluginResultDirectoryVariables(ExecutionCommand executionCommand) { + ResultConfig resultConfig = executionCommand.resultConfig; + + resultConfig.resultDirectoryPath = TermuxFileUtils.getCanonicalPath(resultConfig.resultDirectoryPath, null, true); + resultConfig.resultDirectoryAllowedParentPath = TermuxFileUtils.getMatchedAllowedTermuxWorkingDirectoryParentPathForPath(resultConfig.resultDirectoryPath); + + // Set default resultFileBasename if resultSingleFile is true to `-.log` + if (resultConfig.resultSingleFile && resultConfig.resultFileBasename == null) + resultConfig.resultFileBasename = ShellUtils.getExecutableBasename(executionCommand.executable) + "-" + AndroidUtils.getCurrentMilliSecondLocalTimeStamp() + ".log"; + } + + + + /** + * Send an error notification for {@link TermuxConstants#TERMUX_PLUGIN_COMMAND_ERRORS_NOTIFICATION_CHANNEL_ID} + * and {@link TermuxConstants#TERMUX_PLUGIN_COMMAND_ERRORS_NOTIFICATION_CHANNEL_NAME}. + * + * @param context The {@link Context} for operations. + * @param executionCommand The {@link ExecutionCommand} that failed. + * @param notificationTextString The text of the notification. + */ + public static void sendPluginCommandErrorNotification(Context context, String logTag, ExecutionCommand executionCommand, String notificationTextString) { + // Send a notification to show the error which when clicked will open the ReportActivity + // to show the details of the error + String title = TermuxConstants.TERMUX_APP_NAME + " Plugin Execution Command Error"; + + StringBuilder reportString = new StringBuilder(); + + reportString.append(ExecutionCommand.getExecutionCommandMarkdownString(executionCommand)); + reportString.append("\n\n").append(TermuxUtils.getAppInfoMarkdownString(context, true)); + reportString.append("\n\n").append(AndroidUtils.getDeviceInfoMarkdownString(context)); + + String userActionName = UserAction.PLUGIN_EXECUTION_COMMAND.getName(); + ReportActivity.NewInstanceResult result = ReportActivity.newInstance(context, + new ReportInfo(userActionName, logTag, title, null, + reportString.toString(), null,true, + userActionName, + Environment.getExternalStorageDirectory() + "/" + + FileUtils.sanitizeFileName(TermuxConstants.TERMUX_APP_NAME + "-" + userActionName + ".log", true, true))); + if (result.contentIntent == null) return; + + // Must ensure result code for PendingIntents and id for notification are unique otherwise will override previous + int nextNotificationId = TermuxNotificationUtils.getNextNotificationId(context); + + PendingIntent contentIntent = PendingIntent.getActivity(context, nextNotificationId, result.contentIntent, PendingIntent.FLAG_UPDATE_CURRENT); + + PendingIntent deleteIntent = null; + if (result.deleteIntent != null) + deleteIntent = PendingIntent.getBroadcast(context, nextNotificationId, result.deleteIntent, PendingIntent.FLAG_UPDATE_CURRENT); + + // Setup the notification channel if not already set up + setupPluginCommandErrorsNotificationChannel(context); + + // Use markdown in notification + CharSequence notificationTextCharSequence = MarkdownUtils.getSpannedMarkdownText(context, notificationTextString); + //CharSequence notificationTextCharSequence = notificationTextString; + + // Build the notification + Notification.Builder builder = getPluginCommandErrorsNotificationBuilder(context, title, + notificationTextCharSequence, notificationTextCharSequence, contentIntent, deleteIntent, NotificationUtils.NOTIFICATION_MODE_VIBRATE); + if (builder == null) return; + + // Send the notification + NotificationManager notificationManager = NotificationUtils.getNotificationManager(context); + if (notificationManager != null) + notificationManager.notify(nextNotificationId, builder.build()); + } + + /** + * Get {@link Notification.Builder} for {@link TermuxConstants#TERMUX_PLUGIN_COMMAND_ERRORS_NOTIFICATION_CHANNEL_ID} + * and {@link TermuxConstants#TERMUX_PLUGIN_COMMAND_ERRORS_NOTIFICATION_CHANNEL_NAME}. + * + * @param context The {@link Context} for operations. + * @param title The title for the notification. + * @param notificationText The second line text of the notification. + * @param notificationBigText The full text of the notification that may optionally be styled. + * @param contentIntent The {@link PendingIntent} which should be sent when notification is clicked. + * @param deleteIntent The {@link PendingIntent} which should be sent when notification is deleted. + * @param notificationMode The notification mode. It must be one of {@code NotificationUtils.NOTIFICATION_MODE_*}. + * @return Returns the {@link Notification.Builder}. + */ + @Nullable + public static Notification.Builder getPluginCommandErrorsNotificationBuilder( + final Context context, final CharSequence title, final CharSequence notificationText, + final CharSequence notificationBigText, final PendingIntent contentIntent, final PendingIntent deleteIntent, final int notificationMode) { + + Notification.Builder builder = NotificationUtils.geNotificationBuilder(context, + TermuxConstants.TERMUX_PLUGIN_COMMAND_ERRORS_NOTIFICATION_CHANNEL_ID, Notification.PRIORITY_HIGH, + title, notificationText, notificationBigText, contentIntent, deleteIntent, notificationMode); + + if (builder == null) return null; + + // Enable timestamp + builder.setShowWhen(true); + + // Set notification icon + builder.setSmallIcon(R.drawable.ic_error_notification); + + // Set background color for small notification icon + builder.setColor(0xFF607D8B); + + // Dismiss on click + builder.setAutoCancel(true); + + return builder; + } + + /** + * Setup the notification channel for {@link TermuxConstants#TERMUX_PLUGIN_COMMAND_ERRORS_NOTIFICATION_CHANNEL_ID} and + * {@link TermuxConstants#TERMUX_PLUGIN_COMMAND_ERRORS_NOTIFICATION_CHANNEL_NAME}. + * + * @param context The {@link Context} for operations. + */ + public static void setupPluginCommandErrorsNotificationChannel(final Context context) { + NotificationUtils.setupNotificationChannel(context, TermuxConstants.TERMUX_PLUGIN_COMMAND_ERRORS_NOTIFICATION_CHANNEL_ID, + TermuxConstants.TERMUX_PLUGIN_COMMAND_ERRORS_NOTIFICATION_CHANNEL_NAME, NotificationManager.IMPORTANCE_HIGH); + } + + + + /** + * Check if {@link TermuxConstants#PROP_ALLOW_EXTERNAL_APPS} property is not set to "true". + * + * @param context The {@link Context} to get error string. + * @return Returns the {@code error} if policy is violated, otherwise {@code null}. + */ + public static String checkIfAllowExternalAppsPolicyIsViolated(final Context context, String apiName) { + String errmsg = null; + if (!SharedProperties.isPropertyValueTrue(context, TermuxPropertyConstants.getTermuxPropertiesFile(), + TermuxConstants.PROP_ALLOW_EXTERNAL_APPS, true)) { + errmsg = context.getString(R.string.error_allow_external_apps_ungranted, apiName, + TermuxFileUtils.getUnExpandedTermuxPath(TermuxConstants.TERMUX_PROPERTIES_PRIMARY_FILE_PATH)); + } + + return errmsg; + } + +} diff --git a/app/src/main/java/com/termux/filepicker/TermuxDocumentsProvider.java b/app/src/main/java/com/termux/filepicker/TermuxDocumentsProvider.java new file mode 100644 index 0000000..7974d6d --- /dev/null +++ b/app/src/main/java/com/termux/filepicker/TermuxDocumentsProvider.java @@ -0,0 +1,268 @@ +package com.termux.filepicker; + +import android.content.res.AssetFileDescriptor; +import android.database.Cursor; +import android.database.MatrixCursor; +import android.graphics.Point; +import android.os.CancellationSignal; +import android.os.ParcelFileDescriptor; +import android.provider.DocumentsContract.Document; +import android.provider.DocumentsContract.Root; +import android.provider.DocumentsProvider; +import android.webkit.MimeTypeMap; + +import com.termux.R; +import com.termux.shared.termux.TermuxConstants; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.util.Collections; +import java.util.LinkedList; + +/** + * A document provider for the Storage Access Framework which exposes the files in the + * $HOME/ directory to other apps. + *

+ * Note that this replaces providing an activity matching the ACTION_GET_CONTENT intent: + *

+ * "A document provider and ACTION_GET_CONTENT should be considered mutually exclusive. If you + * support both of them simultaneously, your app will appear twice in the system picker UI, + * offering two different ways of accessing your stored data. This would be confusing for users." + * - http://developer.android.com/guide/topics/providers/document-provider.html#43 + */ +public class TermuxDocumentsProvider extends DocumentsProvider { + + private static final String ALL_MIME_TYPES = "*/*"; + + private static final File BASE_DIR = TermuxConstants.TERMUX_HOME_DIR; + + + // The default columns to return information about a root if no specific + // columns are requested in a query. + private static final String[] DEFAULT_ROOT_PROJECTION = new String[]{ + Root.COLUMN_ROOT_ID, + Root.COLUMN_MIME_TYPES, + Root.COLUMN_FLAGS, + Root.COLUMN_ICON, + Root.COLUMN_TITLE, + Root.COLUMN_SUMMARY, + Root.COLUMN_DOCUMENT_ID, + Root.COLUMN_AVAILABLE_BYTES + }; + + // The default columns to return information about a document if no specific + // columns are requested in a query. + private static final String[] DEFAULT_DOCUMENT_PROJECTION = new String[]{ + Document.COLUMN_DOCUMENT_ID, + Document.COLUMN_MIME_TYPE, + Document.COLUMN_DISPLAY_NAME, + Document.COLUMN_LAST_MODIFIED, + Document.COLUMN_FLAGS, + Document.COLUMN_SIZE + }; + + @Override + public Cursor queryRoots(String[] projection) { + final MatrixCursor result = new MatrixCursor(projection != null ? projection : DEFAULT_ROOT_PROJECTION); + final String applicationName = getContext().getString(R.string.application_name); + + final MatrixCursor.RowBuilder row = result.newRow(); + row.add(Root.COLUMN_ROOT_ID, getDocIdForFile(BASE_DIR)); + row.add(Root.COLUMN_DOCUMENT_ID, getDocIdForFile(BASE_DIR)); + row.add(Root.COLUMN_SUMMARY, null); + row.add(Root.COLUMN_FLAGS, Root.FLAG_SUPPORTS_CREATE | Root.FLAG_SUPPORTS_SEARCH | Root.FLAG_SUPPORTS_IS_CHILD); + row.add(Root.COLUMN_TITLE, applicationName); + row.add(Root.COLUMN_MIME_TYPES, ALL_MIME_TYPES); + row.add(Root.COLUMN_AVAILABLE_BYTES, BASE_DIR.getFreeSpace()); + row.add(Root.COLUMN_ICON, R.mipmap.ic_launcher); + return result; + } + + @Override + public Cursor queryDocument(String documentId, String[] projection) throws FileNotFoundException { + final MatrixCursor result = new MatrixCursor(projection != null ? projection : DEFAULT_DOCUMENT_PROJECTION); + includeFile(result, documentId, null); + return result; + } + + @Override + public Cursor queryChildDocuments(String parentDocumentId, String[] projection, String sortOrder) throws FileNotFoundException { + final MatrixCursor result = new MatrixCursor(projection != null ? projection : DEFAULT_DOCUMENT_PROJECTION); + final File parent = getFileForDocId(parentDocumentId); + for (File file : parent.listFiles()) { + includeFile(result, null, file); + } + return result; + } + + @Override + public ParcelFileDescriptor openDocument(final String documentId, String mode, CancellationSignal signal) throws FileNotFoundException { + final File file = getFileForDocId(documentId); + final int accessMode = ParcelFileDescriptor.parseMode(mode); + return ParcelFileDescriptor.open(file, accessMode); + } + + @Override + public AssetFileDescriptor openDocumentThumbnail(String documentId, Point sizeHint, CancellationSignal signal) throws FileNotFoundException { + final File file = getFileForDocId(documentId); + final ParcelFileDescriptor pfd = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY); + return new AssetFileDescriptor(pfd, 0, file.length()); + } + + @Override + public boolean onCreate() { + return true; + } + + @Override + public String createDocument(String parentDocumentId, String mimeType, String displayName) throws FileNotFoundException { + File newFile = new File(parentDocumentId, displayName); + int noConflictId = 2; + while (newFile.exists()) { + newFile = new File(parentDocumentId, displayName + " (" + noConflictId++ + ")"); + } + try { + boolean succeeded; + if (Document.MIME_TYPE_DIR.equals(mimeType)) { + succeeded = newFile.mkdir(); + } else { + succeeded = newFile.createNewFile(); + } + if (!succeeded) { + throw new FileNotFoundException("Failed to create document with id " + newFile.getPath()); + } + } catch (IOException e) { + throw new FileNotFoundException("Failed to create document with id " + newFile.getPath()); + } + return newFile.getPath(); + } + + @Override + public void deleteDocument(String documentId) throws FileNotFoundException { + File file = getFileForDocId(documentId); + if (!file.delete()) { + throw new FileNotFoundException("Failed to delete document with id " + documentId); + } + } + + @Override + public String getDocumentType(String documentId) throws FileNotFoundException { + File file = getFileForDocId(documentId); + return getMimeType(file); + } + + @Override + public Cursor querySearchDocuments(String rootId, String query, String[] projection) throws FileNotFoundException { + final MatrixCursor result = new MatrixCursor(projection != null ? projection : DEFAULT_DOCUMENT_PROJECTION); + final File parent = getFileForDocId(rootId); + + // This example implementation searches file names for the query and doesn't rank search + // results, so we can stop as soon as we find a sufficient number of matches. Other + // implementations might rank results and use other data about files, rather than the file + // name, to produce a match. + final LinkedList pending = new LinkedList<>(); + pending.add(parent); + + final int MAX_SEARCH_RESULTS = 50; + while (!pending.isEmpty() && result.getCount() < MAX_SEARCH_RESULTS) { + final File file = pending.removeFirst(); + // Avoid directories outside the $HOME directory linked with symlinks (to avoid e.g. search + // through the whole SD card). + boolean isInsideHome; + try { + isInsideHome = file.getCanonicalPath().startsWith(TermuxConstants.TERMUX_HOME_DIR_PATH); + } catch (IOException e) { + isInsideHome = true; + } + if (isInsideHome) { + if (file.isDirectory()) { + Collections.addAll(pending, file.listFiles()); + } else { + if (file.getName().toLowerCase().contains(query)) { + includeFile(result, null, file); + } + } + } + } + + return result; + } + + @Override + public boolean isChildDocument(String parentDocumentId, String documentId) { + return documentId.startsWith(parentDocumentId); + } + + /** + * Get the document id given a file. This document id must be consistent across time as other + * applications may save the ID and use it to reference documents later. + *

+ * The reverse of @{link #getFileForDocId}. + */ + private static String getDocIdForFile(File file) { + return file.getAbsolutePath(); + } + + /** + * Get the file given a document id (the reverse of {@link #getDocIdForFile(File)}). + */ + private static File getFileForDocId(String docId) throws FileNotFoundException { + final File f = new File(docId); + if (!f.exists()) throw new FileNotFoundException(f.getAbsolutePath() + " not found"); + return f; + } + + private static String getMimeType(File file) { + if (file.isDirectory()) { + return Document.MIME_TYPE_DIR; + } else { + final String name = file.getName(); + final int lastDot = name.lastIndexOf('.'); + if (lastDot >= 0) { + final String extension = name.substring(lastDot + 1).toLowerCase(); + final String mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension); + if (mime != null) return mime; + } + return "application/octet-stream"; + } + } + + /** + * Add a representation of a file to a cursor. + * + * @param result the cursor to modify + * @param docId the document ID representing the desired file (may be null if given file) + * @param file the File object representing the desired file (may be null if given docID) + */ + private void includeFile(MatrixCursor result, String docId, File file) + throws FileNotFoundException { + if (docId == null) { + docId = getDocIdForFile(file); + } else { + file = getFileForDocId(docId); + } + + int flags = 0; + if (file.isDirectory()) { + if (file.canWrite()) flags |= Document.FLAG_DIR_SUPPORTS_CREATE; + } else if (file.canWrite()) { + flags |= Document.FLAG_SUPPORTS_WRITE; + } + if (file.getParentFile().canWrite()) flags |= Document.FLAG_SUPPORTS_DELETE; + + final String displayName = file.getName(); + final String mimeType = getMimeType(file); + if (mimeType.startsWith("image/")) flags |= Document.FLAG_SUPPORTS_THUMBNAIL; + + final MatrixCursor.RowBuilder row = result.newRow(); + row.add(Document.COLUMN_DOCUMENT_ID, docId); + row.add(Document.COLUMN_DISPLAY_NAME, displayName); + row.add(Document.COLUMN_SIZE, file.length()); + row.add(Document.COLUMN_MIME_TYPE, mimeType); + row.add(Document.COLUMN_LAST_MODIFIED, file.lastModified()); + row.add(Document.COLUMN_FLAGS, flags); + row.add(Document.COLUMN_ICON, R.mipmap.ic_launcher); + } + +} diff --git a/app/src/main/java/com/termux/filepicker/TermuxFileReceiverActivity.java b/app/src/main/java/com/termux/filepicker/TermuxFileReceiverActivity.java new file mode 100644 index 0000000..b4807e5 --- /dev/null +++ b/app/src/main/java/com/termux/filepicker/TermuxFileReceiverActivity.java @@ -0,0 +1,237 @@ +package com.termux.filepicker; + +import android.app.Activity; +import android.content.Intent; +import android.database.Cursor; +import android.net.Uri; +import android.provider.OpenableColumns; +import android.util.Patterns; + +import com.termux.R; +import com.termux.shared.data.DataUtils; +import com.termux.shared.data.IntentUtils; +import com.termux.shared.interact.MessageDialogUtils; +import com.termux.shared.interact.TextInputDialogUtils; +import com.termux.shared.termux.TermuxConstants; +import com.termux.shared.termux.TermuxConstants.TERMUX_APP.TERMUX_SERVICE; +import com.termux.app.TermuxService; +import com.termux.shared.logger.Logger; + +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 TermuxFileReceiverActivity extends Activity { + + 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 = "TermuxFileReceiverActivity"; + + static boolean isSharedTextAnUrl(String sharedText) { + if (sharedText == null || sharedText.isEmpty()) return false; + + 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 ("content".equals(scheme)) { + handleContentUri(dataUri, sharedTitle); + } else if ("file".equals(scheme)) { + // When e.g. clicking on a downloaded apk: + String path = dataUri.getPath(); + 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(final Uri uri, String subjectFromIntent) { + try { + 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; + + 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 = new Uri.Builder().scheme("file").path(EDITOR_PROGRAM).build(); + + Intent executeIntent = new Intent(TERMUX_SERVICE.ACTION_SERVICE_EXECUTE, scriptUri); + executeIntent.setClass(TermuxFileReceiverActivity.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(TermuxFileReceiverActivity.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 = new Uri.Builder().scheme("file").path(URL_OPENER_PROGRAM).build(); + + Intent executeIntent = new Intent(TERMUX_SERVICE.ACTION_SERVICE_EXECUTE, urlOpenerProgramUri); + executeIntent.setClass(TermuxFileReceiverActivity.this, TermuxService.class); + executeIntent.putExtra(TERMUX_SERVICE.EXTRA_ARGUMENTS, new String[]{url}); + startService(executeIntent); + finish(); + } + +} diff --git a/app/src/main/res/drawable/banner.png b/app/src/main/res/drawable/banner.png new file mode 100644 index 0000000000000000000000000000000000000000..445d384acd6594be1144672576906895b3a9fdf9 GIT binary patch literal 3425 zcmb_fX*8Sb+E!IMP;GS+Y@8l*Ya5ydHTIYy5>nctrPxtZQ(_7=glhMuZOp1#<2D~- zP*XLL=vIm#VrY;ENzL<6b2#rl-&)_F@B6v$cdhq%e?04X?sZ@Hb=}wd>b4D3!av_Cq)-In}k&#B+* zg%dz3e`8Ptz*#GN&tU5`Q&DR-OmA;54u=zCT;Y2dt2A^6X$WB%)4ozm_4)X?Q2vt^ z@dASJpfkcCtxF(Hl!B&PyIQIKvy_j`%|#_8Hg>bFk?UUu3ZcqnL2g%jX#(~tms3(w z(uVeG-8(Jan35oq|2csnEMvkZhJ9#hd4K^62?;4HD}VgAU73`$^bgtLfq?+0wiE9z zWa(hUB`Rfa20m($|BPsR;LQh?`QMvsWVN-mXJ*ni)JMn061gftL7VqKcuG!0ZE!jf znx?BJNO>ox@dos_*w|R3ZH9YaUtdT_$m52V;^N}gRwH9$V`b&ph2e^D{jkIJ&KJ+0 z$CB;K?ndmaq`TT(O$U-bapHu!y1InAvtzS={vkaRf<8Pvj3uwEt|F1h4A-;g&nM#X zo7>wiHEuD*#U5}t+}GDPD(Yy06oHu_maVgJk&y+3g?YH``#L&05fKM5F)@vejpgO# zOeT{^B(AKi419KJ@?#zDZBC=CtX@y#1y2yeY;A1=Yinw1%FC_P#~6(E&d#j&yry+{ zXlSUXXUT-$pPxTFKYVy_oM%wRmZQVXeiR0a#d2(2qxOmq?HnAw`Fq^DMR_HrY=JK=E1Tb& z&C7_mt#WyEbX1Rn@$++OjokD875$sn*DUeNmlb3;x~~zuZ|nl+Ku|9e6GvFH^kTS> zpkTXc`=R#Ers?qcsMo`b4yF2YWYvs?7vS>&y(&p*4H!1(d4+e&J=klaQ#*Y+Q(;a*uiyl zd^}0@UBKe<^1Z9+07$9mnZBG)Kzj^~Vc4y#@o^{h5G+zbPW zn=`$r-i`!mX(=fshyfz&jHHRzqetGtYZiy&sR~_veJiwlQxrEbG4UeOgL9#UOm;nL zsjgPPew{uzc!x-k7mK6S0p(xLZ>zlj;k=xY0mqF{Q*)bm5kjR>)vjHGKM7f4Xf5+8 zxt}(qc6XQ8)!97(_!+*#3PsoNZT1^~(Praza;|8_d$;R&F+G;n_kU8gye#jEdPf4j}X&gfyHuN91nFM?FF&K<^hGYKimvSP1+T`d##-$ZSSo?Anjo#y-T^c*>P3>#zGdW(jp?2pW>6AvvE>1eECbe# zvBsSy`3x%6Hv5iivraa)wf(EuPPLCo{4wGrpKAjFdd1Al%$Xd;T4MkbQczG}F^(iw zR8&+}BIFx)cXtQWB_hJZZE_7%B>?he=j4cp&TQYich8sI`mplVt5=e(JS7;N^966b zJ9%;)XenF+iG*9=fNhj=C;%q;`T2phM51$cjZAO)HHVFjjqiY5a0|@w!Eyjr$;ru@ zBWthm`2IX&LSak?Bn;4&vOA^0^<@94=Z?gkiHJ@qx~~-swzu5GZqKAr5dg{H@Sc-G z=N<3g@A?t3y`&1{#aW9_fhqf2TE5hsr1thg{FzVZXjf`?w+4}metBtWn>+02Bna{O zbAFb{ja>ZD0pAQDn(dq+<+aI4Hv|HKL?$_NXJ+1JXA?bo-adQA_pz>|WQMoH&f_rR z-@pj%;Tv7bUpN5Ogbiu{yw$p!qM>(eZM8}$!ttd|O%7_0saF>jbJt4{4_n>Bi*XO}&bsjz63BV-i&ox9(##B{RaYm7=OBGkL#Jr^!w_v`$ z%et*SLp@aL<+x5yPfrfCt5YsIa(`=n{$-EK`xQwBmAce=up)+cw*sw4)g@xh|IEzH z>;UB`z*32s^jjA7gJ01ElKs7V#aampFuSm@FgrVX{cn$o5IQkGo&6mM=Nn*fkiS33 z)!~Cz{|Jjhfult@4IG&npn0UVpM34=x_AwM96;#{#EI4u97hY9?aTRhb?i5|b@O^L z@ABZm6hvxS$fUBWs-eG&)*IYUkwQ+D-7ODr4VN5XU_U2lOcO~YgwWTpOBD7lYG#fCZ(PtF-LB`0Wd;Nie~Fz zCNiEw&?5+w;K5tmR?72J=YpG>YHA#7QNK^|#u<#C4W82I8{~6M@$vC>b#*!uPfeL~ zoh%mbL%$K2=yb<||5*9TmA2%dL4pifTiyYirvxubwW{nio)4QStWmO~*$?Ma3QtJdKFJi}p6u*Lz1^ z0BIx$h<{T?gLQP+4d{%i9D)mJA0UG`n#1Kzi>8}Rrc@u{s^>n;bcC;nbhQE?t84EMxfq9-XQ z0-kO5>abrT(ZV7kq@(@CtLawN0fB+nA01nXdC>vYv9qw~Fg`w%mjLV&GSA3x?0qMj zeVk-`bo%`Hx9INf?)F~S1eU1r;Xhmi0Zx;Pip&nm!lBU4%1X5a5+)ch5J5pfX?PhK z8EGBAA@4yNtp>YjU#q01hFBGUtJD`f8xw~O4TYMSy~)Vv8B7HX1TZE5jZ05Y_v+6D zD1)1f5fcC9!PZ=1Qb$L}>#XvorY5MlIZI1|lB)2K97X!$4>wi|HZTDY$S-&aWVSBg zHU?<4$%TalE>}!kTuH;Ir@Q;CsAwM8D>2c})YQ}+g=)_XW#gKvs;obGSXfw?nY9Dx zO=oUxZE-u378wS@7EcNq({WS3OYHCOchq;jeo%G(%o)il(}Z8J!lP<-^f5aq<^ZVp z>t&!a*d}%hwt^AWUPWfISo(T;!jWQ$vWAa6Jcfsd6~B37%D8iLjFAx~Sw$NupUthU zrTO`b-=(!WmP4#yJpNc&p@NDMo8;ZNPMQx-oBSu*d@P z3*aLQ3krn8E|desH860lvd4!C)O&b(zSbAo+TN~qCA&ZmfLR+*;Fwx8IXNjJtv#2} zc_W2#TuLNHPJRBptgMX3oPRCFnn6eF{Y{5kH{6slOeK_om9H>F1`U4F>fa zN?f|cnV+9uT6*&9uL1M);{Bbpb&G zf=;(%$pwMiyz`8>A07{v127=n;`oB6q(BtJ|FP&Lt#2-cmS?zx`|w9dcN&lUjeidR d?@^RDdvZ|qe;gRMdEx)s05i8St2S|a{vS2~mX81c literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable/current_session.xml b/app/src/main/res/drawable/current_session.xml new file mode 100644 index 0000000..90dd281 --- /dev/null +++ b/app/src/main/res/drawable/current_session.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/current_session_black.xml b/app/src/main/res/drawable/current_session_black.xml new file mode 100644 index 0000000..6a92649 --- /dev/null +++ b/app/src/main/res/drawable/current_session_black.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/ic_foreground.xml b/app/src/main/res/drawable/ic_foreground.xml new file mode 100644 index 0000000..3f3e59b --- /dev/null +++ b/app/src/main/res/drawable/ic_foreground.xml @@ -0,0 +1,28 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_new_session.xml b/app/src/main/res/drawable/ic_new_session.xml new file mode 100644 index 0000000..9b5b24a --- /dev/null +++ b/app/src/main/res/drawable/ic_new_session.xml @@ -0,0 +1,17 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_service_notification.xml b/app/src/main/res/drawable/ic_service_notification.xml new file mode 100644 index 0000000..205dcfc --- /dev/null +++ b/app/src/main/res/drawable/ic_service_notification.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_settings.xml b/app/src/main/res/drawable/ic_settings.xml new file mode 100644 index 0000000..e5d1108 --- /dev/null +++ b/app/src/main/res/drawable/ic_settings.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/session_background_black_selected.xml b/app/src/main/res/drawable/session_background_black_selected.xml new file mode 100644 index 0000000..25b7506 --- /dev/null +++ b/app/src/main/res/drawable/session_background_black_selected.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/session_background_selected.xml b/app/src/main/res/drawable/session_background_selected.xml new file mode 100644 index 0000000..3db6d6e --- /dev/null +++ b/app/src/main/res/drawable/session_background_selected.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/session_ripple.xml b/app/src/main/res/drawable/session_ripple.xml new file mode 100644 index 0000000..9c4a1e7 --- /dev/null +++ b/app/src/main/res/drawable/session_ripple.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/app/src/main/res/drawable/session_ripple_black.xml b/app/src/main/res/drawable/session_ripple_black.xml new file mode 100644 index 0000000..21423eb --- /dev/null +++ b/app/src/main/res/drawable/session_ripple_black.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/app/src/main/res/drawable/terminal_scroll_shape.xml b/app/src/main/res/drawable/terminal_scroll_shape.xml new file mode 100644 index 0000000..76cb071 --- /dev/null +++ b/app/src/main/res/drawable/terminal_scroll_shape.xml @@ -0,0 +1,22 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml new file mode 100644 index 0000000..7dc1a6c --- /dev/null +++ b/app/src/main/res/layout/activity_settings.xml @@ -0,0 +1,9 @@ + + + + diff --git a/app/src/main/res/layout/activity_termux.xml b/app/src/main/res/layout/activity_termux.xml new file mode 100644 index 0000000..c30354e --- /dev/null +++ b/app/src/main/res/layout/activity_termux.xml @@ -0,0 +1,112 @@ + + + + + + + + + + + + + + + + + + +