Improve Tailscale defaults and update UX
All checks were successful
Gitea Smoke / smoke (push) Successful in 2s
All checks were successful
Gitea Smoke / smoke (push) Successful in 2s
This commit is contained in:
parent
a4b8f457d2
commit
8709c7bae5
39
README.md
39
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/<owner>/<repo>/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.
|
||||
|
||||
@ -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")
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
package com.neatstudio.tmuxandroid;
|
||||
|
||||
import android.Manifest;
|
||||
import android.app.Activity;
|
||||
import android.app.AlertDialog;
|
||||
import android.content.ClipData;
|
||||
@ -7,6 +8,7 @@ import android.content.ClipboardManager;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.BitmapFactory;
|
||||
import android.graphics.Color;
|
||||
@ -14,6 +16,7 @@ import android.graphics.Typeface;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.provider.Settings;
|
||||
import android.text.InputType;
|
||||
import android.view.Gravity;
|
||||
import android.view.View;
|
||||
@ -45,6 +48,15 @@ public final class MainActivity extends Activity {
|
||||
private static final int TERMINAL_COLS = 96;
|
||||
private static final int TERMINAL_ROWS = 32;
|
||||
private static final int MAX_TERMINAL_CHARS = 120_000;
|
||||
private static final String OLD_LOCAL_DEFAULT_URL = "http://127.0.0.1:3000";
|
||||
private static final String DEFAULT_TAILSCALE_URL = "http://100.89.0.116:3000";
|
||||
private static final String[] SERVER_PROFILES = {
|
||||
"http://100.89.0.116: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"
|
||||
};
|
||||
|
||||
private final ExecutorService executor = Executors.newSingleThreadExecutor();
|
||||
private SharedPreferences prefs;
|
||||
@ -72,6 +84,14 @@ public final class MainActivity extends Activity {
|
||||
.putString("server_url", BuildConfig.DEFAULT_SERVER_URL)
|
||||
.putString("update_url", BuildConfig.DEFAULT_UPDATE_URL)
|
||||
.apply();
|
||||
} else if (
|
||||
OLD_LOCAL_DEFAULT_URL.equals(prefs.getString("server_url", ""))
|
||||
&& !prefs.getBoolean("tailscale_defaults_applied_v1", false)
|
||||
) {
|
||||
prefs.edit()
|
||||
.putString("server_url", DEFAULT_TAILSCALE_URL)
|
||||
.putBoolean("tailscale_defaults_applied_v1", true)
|
||||
.apply();
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= 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(() -> {
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user