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
|
not host or load the existing web UI. It calls the existing HTTP and WebSocket
|
||||||
APIs directly:
|
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
|
- native tmux session list
|
||||||
- create, rename, send command, split pane, select pane, kill pane, pin, mute,
|
- create, rename, send command, split pane, select pane, kill pane, pin, mute,
|
||||||
and kill session through HTTP API
|
and kill session through HTTP API
|
||||||
@ -24,14 +26,18 @@ APIs directly:
|
|||||||
Default:
|
Default:
|
||||||
|
|
||||||
```text
|
```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
|
On a physical Android device, `127.0.0.1` means the phone itself. The app
|
||||||
testing, install Tailscale on the phone and use:
|
therefore defaults to Tailscale and includes quick selectors for:
|
||||||
|
|
||||||
```text
|
```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.
|
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
|
base64 -w 0 tmux-android-release.jks
|
||||||
```
|
```
|
||||||
|
|
||||||
Publish a test build by running the `Android APK` workflow manually with
|
Publish a test build by pushing a `v*` tag. That creates a GitHub Release with:
|
||||||
`publish_release=true`, or by pushing a `v*` tag.
|
|
||||||
|
```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
|
Unsigned/debug workflow artifacts are useful only for smoke testing install and
|
||||||
launch. Automatic in-place updates require release APKs signed with the same
|
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
|
currently use native forms plus native JSON detail dialogs; image preview uses a
|
||||||
native `ImageView`. The app does not load the browser UI.
|
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
|
## Auto Update
|
||||||
|
|
||||||
Normal Android apps cannot silently replace themselves. This app checks the
|
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:
|
The default update manifest is:
|
||||||
|
|
||||||
```text
|
```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.
|
The workflow uploads both the APK and `latest.json` to each release.
|
||||||
|
|||||||
@ -7,7 +7,7 @@ plugins {
|
|||||||
val repoSlug = providers.gradleProperty("repoSlug")
|
val repoSlug = providers.gradleProperty("repoSlug")
|
||||||
.orElse("neatstudio/tmux-android")
|
.orElse("neatstudio/tmux-android")
|
||||||
val defaultServerUrl = providers.gradleProperty("defaultServerUrl")
|
val defaultServerUrl = providers.gradleProperty("defaultServerUrl")
|
||||||
.orElse("http://127.0.0.1:3000")
|
.orElse("http://100.89.0.116:3000")
|
||||||
val defaultUpdateUrl = providers.gradleProperty("defaultUpdateUrl")
|
val defaultUpdateUrl = providers.gradleProperty("defaultUpdateUrl")
|
||||||
.orElse("https://github.com/${repoSlug.get()}/releases/latest/download/latest.json")
|
.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.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
package com.neatstudio.tmuxandroid;
|
package com.neatstudio.tmuxandroid;
|
||||||
|
|
||||||
|
import android.Manifest;
|
||||||
import android.app.Activity;
|
import android.app.Activity;
|
||||||
import android.app.AlertDialog;
|
import android.app.AlertDialog;
|
||||||
import android.content.ClipData;
|
import android.content.ClipData;
|
||||||
@ -7,6 +8,7 @@ import android.content.ClipboardManager;
|
|||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
import android.content.SharedPreferences;
|
import android.content.SharedPreferences;
|
||||||
|
import android.content.pm.PackageManager;
|
||||||
import android.graphics.Bitmap;
|
import android.graphics.Bitmap;
|
||||||
import android.graphics.BitmapFactory;
|
import android.graphics.BitmapFactory;
|
||||||
import android.graphics.Color;
|
import android.graphics.Color;
|
||||||
@ -14,6 +16,7 @@ import android.graphics.Typeface;
|
|||||||
import android.net.Uri;
|
import android.net.Uri;
|
||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
|
import android.provider.Settings;
|
||||||
import android.text.InputType;
|
import android.text.InputType;
|
||||||
import android.view.Gravity;
|
import android.view.Gravity;
|
||||||
import android.view.View;
|
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_COLS = 96;
|
||||||
private static final int TERMINAL_ROWS = 32;
|
private static final int TERMINAL_ROWS = 32;
|
||||||
private static final int MAX_TERMINAL_CHARS = 120_000;
|
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 final ExecutorService executor = Executors.newSingleThreadExecutor();
|
||||||
private SharedPreferences prefs;
|
private SharedPreferences prefs;
|
||||||
@ -72,6 +84,14 @@ public final class MainActivity extends Activity {
|
|||||||
.putString("server_url", BuildConfig.DEFAULT_SERVER_URL)
|
.putString("server_url", BuildConfig.DEFAULT_SERVER_URL)
|
||||||
.putString("update_url", BuildConfig.DEFAULT_UPDATE_URL)
|
.putString("update_url", BuildConfig.DEFAULT_UPDATE_URL)
|
||||||
.apply();
|
.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) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||||
getWindow().setStatusBarColor(Color.rgb(17, 20, 24));
|
getWindow().setStatusBarColor(Color.rgb(17, 20, 24));
|
||||||
@ -121,6 +141,10 @@ public final class MainActivity extends Activity {
|
|||||||
activeSessionName = null;
|
activeSessionName = null;
|
||||||
root.removeAllViews();
|
root.removeAllViews();
|
||||||
root.addView(createServerBar(), matchWrap());
|
root.addView(createServerBar(), matchWrap());
|
||||||
|
root.addView(createServerProfileBar(), new LinearLayout.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
dp(46)
|
||||||
|
));
|
||||||
root.addView(progressBar, new LinearLayout.LayoutParams(
|
root.addView(progressBar, new LinearLayout.LayoutParams(
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
dp(3)
|
dp(3)
|
||||||
@ -174,6 +198,47 @@ public final class MainActivity extends Activity {
|
|||||||
return toolbar;
|
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() {
|
private void refreshSessions() {
|
||||||
setStatus("Loading sessions from " + api.getBaseUrl());
|
setStatus("Loading sessions from " + api.getBaseUrl());
|
||||||
progressBar.setVisibility(View.VISIBLE);
|
progressBar.setVisibility(View.VISIBLE);
|
||||||
@ -369,7 +434,8 @@ public final class MainActivity extends Activity {
|
|||||||
"Upload image file",
|
"Upload image file",
|
||||||
"Upload image URL",
|
"Upload image URL",
|
||||||
"Image preview info",
|
"Image preview info",
|
||||||
"Open image preview"
|
"Open image preview",
|
||||||
|
"Permissions / update status"
|
||||||
};
|
};
|
||||||
new AlertDialog.Builder(this)
|
new AlertDialog.Builder(this)
|
||||||
.setTitle("Native API actions")
|
.setTitle("Native API actions")
|
||||||
@ -429,6 +495,9 @@ public final class MainActivity extends Activity {
|
|||||||
case 17:
|
case 17:
|
||||||
promptOpenImagePreview();
|
promptOpenImagePreview();
|
||||||
break;
|
break;
|
||||||
|
case 18:
|
||||||
|
showPermissionsAndUpdateStatus();
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -811,6 +880,58 @@ public final class MainActivity extends Activity {
|
|||||||
.show();
|
.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) {
|
private void showImagePreview(String path, String basePath) {
|
||||||
progressBar.setVisibility(View.VISIBLE);
|
progressBar.setVisibility(View.VISIBLE);
|
||||||
executor.execute(() -> {
|
executor.execute(() -> {
|
||||||
|
|||||||
@ -43,13 +43,15 @@ final class UpdateManager {
|
|||||||
void check(boolean userInitiated) {
|
void check(boolean userInitiated) {
|
||||||
String manifestUrl = prefs.getString("update_url", BuildConfig.DEFAULT_UPDATE_URL);
|
String manifestUrl = prefs.getString("update_url", BuildConfig.DEFAULT_UPDATE_URL);
|
||||||
callback.onChecking(true);
|
callback.onChecking(true);
|
||||||
|
postMessage("Checking update...");
|
||||||
executor.execute(() -> {
|
executor.execute(() -> {
|
||||||
try {
|
try {
|
||||||
ReleaseInfo info = fetchReleaseInfo(manifestUrl);
|
ReleaseInfo info = fetchReleaseInfo(manifestUrl);
|
||||||
if (info.versionCode <= BuildConfig.VERSION_CODE) {
|
if (info.versionCode <= BuildConfig.VERSION_CODE) {
|
||||||
postMessage(userInitiated ? "Already up to date" : null);
|
postMessage("Already up to date: " + BuildConfig.VERSION_NAME);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
postMessage("Update found: " + info.versionName);
|
||||||
activity.runOnUiThread(() -> showUpdateDialog(info));
|
activity.runOnUiThread(() -> showUpdateDialog(info));
|
||||||
} catch (Exception error) {
|
} catch (Exception error) {
|
||||||
postMessage(userInitiated ? "Update check failed: " + error.getMessage() : null);
|
postMessage(userInitiated ? "Update check failed: " + error.getMessage() : null);
|
||||||
@ -95,10 +97,12 @@ final class UpdateManager {
|
|||||||
|
|
||||||
private void downloadAndInstall(ReleaseInfo info) {
|
private void downloadAndInstall(ReleaseInfo info) {
|
||||||
callback.onChecking(true);
|
callback.onChecking(true);
|
||||||
|
postMessage("Downloading " + info.versionName + "...");
|
||||||
executor.execute(() -> {
|
executor.execute(() -> {
|
||||||
try {
|
try {
|
||||||
File apk = downloadApk(info);
|
File apk = downloadApk(info);
|
||||||
if (!info.sha256.isEmpty()) {
|
if (!info.sha256.isEmpty()) {
|
||||||
|
postMessage("Verifying APK...");
|
||||||
String actual = sha256(apk);
|
String actual = sha256(apk);
|
||||||
if (!actual.equalsIgnoreCase(info.sha256)) {
|
if (!actual.equalsIgnoreCase(info.sha256)) {
|
||||||
throw new IllegalStateException("APK SHA-256 mismatch");
|
throw new IllegalStateException("APK SHA-256 mismatch");
|
||||||
@ -145,6 +149,7 @@ final class UpdateManager {
|
|||||||
);
|
);
|
||||||
activity.startActivity(settingsIntent);
|
activity.startActivity(settingsIntent);
|
||||||
Toast.makeText(activity, "Allow installs, then run update again", Toast.LENGTH_LONG).show();
|
Toast.makeText(activity, "Allow installs, then run update again", Toast.LENGTH_LONG).show();
|
||||||
|
postMessage("Install permission required");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -159,6 +164,7 @@ final class UpdateManager {
|
|||||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||||
try {
|
try {
|
||||||
activity.startActivity(intent);
|
activity.startActivity(intent);
|
||||||
|
postMessage("Opened Android package installer");
|
||||||
} catch (ActivityNotFoundException error) {
|
} catch (ActivityNotFoundException error) {
|
||||||
postMessage("No package installer found");
|
postMessage("No package installer found");
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user