diff --git a/README.md b/README.md index 4d64946..5e55294 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,9 @@ The server project is expected to already be running on port 3000. This app does not host or load the existing web UI. It calls the existing HTTP and WebSocket APIs directly: -- server URL management for `http://127.0.0.1:3000` and Tailscale `100.x.y.z:3000` +- server URL management for Tailscale `100.x.y.z:3000` APIs +- quick server selectors for `100.89.0.2`, `100.89.0.4`, `100.89.0.9`, + `100.89.0.11`, and `100.89.0.116` - native tmux session list - create, rename, send command, split pane, select pane, kill pane, pin, mute, and kill session through HTTP API @@ -24,14 +26,18 @@ APIs directly: Default: ```text -http://127.0.0.1:3000 +http://100.89.0.116:3000 ``` -On a physical Android device, `127.0.0.1` means the phone itself. For remote -testing, install Tailscale on the phone and use: +On a physical Android device, `127.0.0.1` means the phone itself. The app +therefore defaults to Tailscale and includes quick selectors for: ```text -http://100.x.y.z:3000 +http://100.89.0.2:3000 +http://100.89.0.4:3000 +http://100.89.0.9:3000 +http://100.89.0.11:3000 +http://100.89.0.116:3000 ``` The upstream server should stay bound to localhost or a private Tailscale IP. @@ -65,8 +71,15 @@ Create the base64 value from your release keystore: base64 -w 0 tmux-android-release.jks ``` -Publish a test build by running the `Android APK` workflow manually with -`publish_release=true`, or by pushing a `v*` tag. +Publish a test build by pushing a `v*` tag. That creates a GitHub Release with: + +```text +https://github.com/neatstudio/tmux-browser-android/releases/latest/download/tmux-android.apk +https://github.com/neatstudio/tmux-browser-android/releases/latest/download/latest.json +``` + +Plain branch builds only create Actions artifacts; they are useful for CI +verification, but releases are the stable download/update channel. Unsigned/debug workflow artifacts are useful only for smoke testing install and launch. Automatic in-place updates require release APKs signed with the same @@ -87,6 +100,16 @@ kanban projects, preferences, timeline events, group messages, and image metadat currently use native forms plus native JSON detail dialogs; image preview uses a native `ImageView`. The app does not load the browser UI. +## Permissions + +The app needs network access and package install handoff for updates. Android +does not ask at runtime for normal internet access. Android 8+ requires the user +to allow this app to install unknown apps before automatic update installation +can continue. Android 13+ may ask for notification permission. + +SMS permission is intentionally not requested because tmux-browser-android does +not read or send SMS. + ## Auto Update Normal Android apps cannot silently replace themselves. This app checks the @@ -97,7 +120,7 @@ install, and Android 8+ may require allowing this app to install unknown apps. The default update manifest is: ```text -https://github.com///releases/latest/download/latest.json +https://github.com/neatstudio/tmux-browser-android/releases/latest/download/latest.json ``` The workflow uploads both the APK and `latest.json` to each release. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ab131cf..646021e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -7,7 +7,7 @@ plugins { val repoSlug = providers.gradleProperty("repoSlug") .orElse("neatstudio/tmux-android") val defaultServerUrl = providers.gradleProperty("defaultServerUrl") - .orElse("http://127.0.0.1:3000") + .orElse("http://100.89.0.116:3000") val defaultUpdateUrl = providers.gradleProperty("defaultUpdateUrl") .orElse("https://github.com/${repoSlug.get()}/releases/latest/download/latest.json") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c17f127..ad250ef 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,6 +3,7 @@ + = Build.VERSION_CODES.LOLLIPOP) { getWindow().setStatusBarColor(Color.rgb(17, 20, 24)); @@ -121,6 +141,10 @@ public final class MainActivity extends Activity { activeSessionName = null; root.removeAllViews(); root.addView(createServerBar(), matchWrap()); + root.addView(createServerProfileBar(), new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + dp(46) + )); root.addView(progressBar, new LinearLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, dp(3) @@ -174,6 +198,47 @@ public final class MainActivity extends Activity { return toolbar; } + private HorizontalScrollView createServerProfileBar() { + HorizontalScrollView scroller = new HorizontalScrollView(this); + scroller.setHorizontalScrollBarEnabled(false); + scroller.setBackgroundColor(Color.rgb(22, 27, 34)); + + LinearLayout row = new LinearLayout(this); + row.setOrientation(LinearLayout.HORIZONTAL); + row.setGravity(Gravity.CENTER_VERTICAL); + row.setPadding(dp(6), dp(4), dp(6), dp(4)); + + for (String url : SERVER_PROFILES) { + String label = url.replace("http://", "").replace(":3000", ""); + Button button = toolbarButton(label, view -> selectServer(url)); + button.setMinWidth(dp(92)); + button.setMinimumWidth(dp(92)); + row.addView(button, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.MATCH_PARENT + )); + } + row.addView(toolbarButton("Custom", view -> { + urlField.requestFocus(); + urlField.selectAll(); + })); + + scroller.addView(row, new HorizontalScrollView.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.MATCH_PARENT + )); + return scroller; + } + + private void selectServer(String url) { + prefs.edit().putString("server_url", url).apply(); + api = new SessionApiClient(url); + urlField.setText(url); + setStatus("Selected " + url); + connectAppEvents(); + refreshSessions(); + } + private void refreshSessions() { setStatus("Loading sessions from " + api.getBaseUrl()); progressBar.setVisibility(View.VISIBLE); @@ -369,7 +434,8 @@ public final class MainActivity extends Activity { "Upload image file", "Upload image URL", "Image preview info", - "Open image preview" + "Open image preview", + "Permissions / update status" }; new AlertDialog.Builder(this) .setTitle("Native API actions") @@ -429,6 +495,9 @@ public final class MainActivity extends Activity { case 17: promptOpenImagePreview(); break; + case 18: + showPermissionsAndUpdateStatus(); + break; default: break; } @@ -811,6 +880,58 @@ public final class MainActivity extends Activity { .show(); } + private void showPermissionsAndUpdateStatus() { + StringBuilder text = new StringBuilder(); + text.append("Server: ").append(getServerUrl()).append('\n'); + text.append("Update manifest: ") + .append(prefs.getString("update_url", BuildConfig.DEFAULT_UPDATE_URL)) + .append('\n'); + text.append("Network: manifest permission, no runtime grant required\n"); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + text.append("Install unknown apps: ") + .append(getPackageManager().canRequestPackageInstalls() ? "allowed" : "not allowed") + .append('\n'); + } else { + text.append("Install unknown apps: allowed by Android version\n"); + } + if (Build.VERSION.SDK_INT >= 33) { + text.append("Notifications: ") + .append(checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED ? "allowed" : "not allowed") + .append('\n'); + } else { + text.append("Notifications: no runtime permission required\n"); + } + text.append("SMS: not requested; this tmux client does not need SMS permission.\n"); + + new AlertDialog.Builder(this) + .setTitle("Permissions / update") + .setMessage(text.toString()) + .setNegativeButton("Close", null) + .setNeutralButton("Install permission", (dialog, which) -> openInstallPermissionSettings()) + .setPositiveButton("Notify permission", (dialog, which) -> requestNotificationPermission()) + .show(); + } + + private void openInstallPermissionSettings() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + showMessage("Install permission is allowed on this Android version"); + return; + } + Intent intent = new Intent( + Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, + Uri.parse("package:" + getPackageName()) + ); + startActivity(intent); + } + + private void requestNotificationPermission() { + if (Build.VERSION.SDK_INT < 33) { + showMessage("Notification permission is not required on this Android version"); + return; + } + requestPermissions(new String[]{Manifest.permission.POST_NOTIFICATIONS}, 3001); + } + private void showImagePreview(String path, String basePath) { progressBar.setVisibility(View.VISIBLE); executor.execute(() -> { diff --git a/app/src/main/java/com/neatstudio/tmuxandroid/UpdateManager.java b/app/src/main/java/com/neatstudio/tmuxandroid/UpdateManager.java index 7788eda..c6e0dba 100644 --- a/app/src/main/java/com/neatstudio/tmuxandroid/UpdateManager.java +++ b/app/src/main/java/com/neatstudio/tmuxandroid/UpdateManager.java @@ -43,13 +43,15 @@ final class UpdateManager { void check(boolean userInitiated) { String manifestUrl = prefs.getString("update_url", BuildConfig.DEFAULT_UPDATE_URL); callback.onChecking(true); + postMessage("Checking update..."); executor.execute(() -> { try { ReleaseInfo info = fetchReleaseInfo(manifestUrl); if (info.versionCode <= BuildConfig.VERSION_CODE) { - postMessage(userInitiated ? "Already up to date" : null); + postMessage("Already up to date: " + BuildConfig.VERSION_NAME); return; } + postMessage("Update found: " + info.versionName); activity.runOnUiThread(() -> showUpdateDialog(info)); } catch (Exception error) { postMessage(userInitiated ? "Update check failed: " + error.getMessage() : null); @@ -95,10 +97,12 @@ final class UpdateManager { private void downloadAndInstall(ReleaseInfo info) { callback.onChecking(true); + postMessage("Downloading " + info.versionName + "..."); executor.execute(() -> { try { File apk = downloadApk(info); if (!info.sha256.isEmpty()) { + postMessage("Verifying APK..."); String actual = sha256(apk); if (!actual.equalsIgnoreCase(info.sha256)) { throw new IllegalStateException("APK SHA-256 mismatch"); @@ -145,6 +149,7 @@ final class UpdateManager { ); activity.startActivity(settingsIntent); Toast.makeText(activity, "Allow installs, then run update again", Toast.LENGTH_LONG).show(); + postMessage("Install permission required"); return; } @@ -159,6 +164,7 @@ final class UpdateManager { .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); try { activity.startActivity(intent); + postMessage("Opened Android package installer"); } catch (ActivityNotFoundException error) { postMessage("No package installer found"); }