Initial native Android tmux client
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
package com.neatstudio.tmuxandroid;
|
||||
|
||||
import android.util.Base64;
|
||||
|
||||
import java.io.BufferedInputStream;
|
||||
import java.io.BufferedOutputStream;
|
||||
import java.net.Socket;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Arrays;
|
||||
|
||||
import javax.net.ssl.SSLSocketFactory;
|
||||
|
||||
final class AppEventSocketClient {
|
||||
interface Listener {
|
||||
void onMessage(String text);
|
||||
void onClosed();
|
||||
}
|
||||
|
||||
private final Listener listener;
|
||||
private Socket socket;
|
||||
private BufferedInputStream input;
|
||||
private BufferedOutputStream output;
|
||||
private volatile boolean closed;
|
||||
|
||||
AppEventSocketClient(Listener listener) {
|
||||
this.listener = listener;
|
||||
}
|
||||
|
||||
void connect(String baseUrl) {
|
||||
closed = false;
|
||||
new Thread(() -> run(baseUrl), "app-events-ws").start();
|
||||
}
|
||||
|
||||
void close() {
|
||||
closed = true;
|
||||
try {
|
||||
sendFrame(8, new byte[0]);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
try {
|
||||
if (socket != null) {
|
||||
socket.close();
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
private void run(String baseUrl) {
|
||||
try {
|
||||
URI uri = buildWsUri(baseUrl);
|
||||
socket = openSocket(uri);
|
||||
input = new BufferedInputStream(socket.getInputStream());
|
||||
output = new BufferedOutputStream(socket.getOutputStream());
|
||||
handshake(uri);
|
||||
while (!closed) {
|
||||
Frame frame = readFrame();
|
||||
if (frame.opcode == 1) {
|
||||
listener.onMessage(new String(frame.payload, StandardCharsets.UTF_8));
|
||||
} else if (frame.opcode == 8) {
|
||||
return;
|
||||
} else if (frame.opcode == 9) {
|
||||
sendFrame(10, frame.payload);
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
} finally {
|
||||
closed = true;
|
||||
listener.onClosed();
|
||||
try {
|
||||
if (socket != null) {
|
||||
socket.close();
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private URI buildWsUri(String baseUrl) throws Exception {
|
||||
URI base = new URI(baseUrl);
|
||||
String scheme = "https".equalsIgnoreCase(base.getScheme()) ? "wss" : "ws";
|
||||
int port = base.getPort();
|
||||
String authority = port == -1 ? base.getHost() : base.getHost() + ":" + port;
|
||||
return new URI(scheme + "://" + authority + "/ws/events");
|
||||
}
|
||||
|
||||
private Socket openSocket(URI uri) throws Exception {
|
||||
int port = uri.getPort();
|
||||
if (port == -1) {
|
||||
port = "wss".equalsIgnoreCase(uri.getScheme()) ? 443 : 80;
|
||||
}
|
||||
if ("wss".equalsIgnoreCase(uri.getScheme())) {
|
||||
return SSLSocketFactory.getDefault().createSocket(uri.getHost(), port);
|
||||
}
|
||||
return new Socket(uri.getHost(), port);
|
||||
}
|
||||
|
||||
private void handshake(URI uri) throws Exception {
|
||||
byte[] nonce = new byte[16];
|
||||
new SecureRandom().nextBytes(nonce);
|
||||
String key = Base64.encodeToString(nonce, Base64.NO_WRAP);
|
||||
String host = uri.getPort() == -1 ? uri.getHost() : uri.getHost() + ":" + uri.getPort();
|
||||
String request = "GET " + uri.getRawPath() + " HTTP/1.1\r\n"
|
||||
+ "Host: " + host + "\r\n"
|
||||
+ "Upgrade: websocket\r\n"
|
||||
+ "Connection: Upgrade\r\n"
|
||||
+ "Sec-WebSocket-Key: " + key + "\r\n"
|
||||
+ "Sec-WebSocket-Version: 13\r\n"
|
||||
+ "\r\n";
|
||||
output.write(request.getBytes(StandardCharsets.US_ASCII));
|
||||
output.flush();
|
||||
String response = readHttpHeaders();
|
||||
if (!response.startsWith("HTTP/1.1 101") && !response.startsWith("HTTP/1.0 101")) {
|
||||
throw new IllegalStateException("WebSocket handshake failed");
|
||||
}
|
||||
String expected = websocketAccept(key).toLowerCase(java.util.Locale.US);
|
||||
if (!response.toLowerCase(java.util.Locale.US).contains("sec-websocket-accept: " + expected)) {
|
||||
throw new IllegalStateException("WebSocket accept header mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
private String readHttpHeaders() throws Exception {
|
||||
java.io.ByteArrayOutputStream buffer = new java.io.ByteArrayOutputStream();
|
||||
int a = -1;
|
||||
int b = -1;
|
||||
int c = -1;
|
||||
int d;
|
||||
while ((d = input.read()) != -1) {
|
||||
buffer.write(d);
|
||||
if (a == '\r' && b == '\n' && c == '\r' && d == '\n') {
|
||||
break;
|
||||
}
|
||||
a = b;
|
||||
b = c;
|
||||
c = d;
|
||||
}
|
||||
return buffer.toString("US-ASCII");
|
||||
}
|
||||
|
||||
private static String websocketAccept(String key) throws Exception {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-1");
|
||||
return Base64.encodeToString(
|
||||
digest.digest((key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").getBytes(StandardCharsets.US_ASCII)),
|
||||
Base64.NO_WRAP
|
||||
);
|
||||
}
|
||||
|
||||
private Frame readFrame() throws Exception {
|
||||
int first = input.read();
|
||||
int second = input.read();
|
||||
if (first == -1 || second == -1) {
|
||||
throw new IllegalStateException("WebSocket closed");
|
||||
}
|
||||
int opcode = first & 0x0f;
|
||||
long length = second & 0x7f;
|
||||
if (length == 126) {
|
||||
length = ((long) input.read() << 8) | input.read();
|
||||
} else if (length == 127) {
|
||||
length = 0;
|
||||
for (int i = 0; i < 8; i++) {
|
||||
length = (length << 8) | input.read();
|
||||
}
|
||||
}
|
||||
byte[] payload = readExactly((int) length);
|
||||
return new Frame(opcode, payload);
|
||||
}
|
||||
|
||||
private byte[] readExactly(int length) throws Exception {
|
||||
byte[] data = new byte[length];
|
||||
int offset = 0;
|
||||
while (offset < length) {
|
||||
int read = input.read(data, offset, length - offset);
|
||||
if (read == -1) {
|
||||
throw new IllegalStateException("WebSocket closed");
|
||||
}
|
||||
offset += read;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
private void sendFrame(int opcode, byte[] payload) throws Exception {
|
||||
if (output == null) {
|
||||
return;
|
||||
}
|
||||
output.write(0x80 | opcode);
|
||||
byte[] mask = new byte[4];
|
||||
new SecureRandom().nextBytes(mask);
|
||||
int length = payload.length;
|
||||
if (length < 126) {
|
||||
output.write(0x80 | length);
|
||||
} else if (length <= 0xffff) {
|
||||
output.write(0x80 | 126);
|
||||
output.write((length >>> 8) & 0xff);
|
||||
output.write(length & 0xff);
|
||||
} else {
|
||||
output.write(0x80 | 127);
|
||||
for (int i = 7; i >= 0; i--) {
|
||||
output.write((length >>> (8 * i)) & 0xff);
|
||||
}
|
||||
}
|
||||
output.write(mask);
|
||||
byte[] masked = Arrays.copyOf(payload, payload.length);
|
||||
for (int i = 0; i < masked.length; i++) {
|
||||
masked[i] = (byte) (masked[i] ^ mask[i % 4]);
|
||||
}
|
||||
output.write(masked);
|
||||
output.flush();
|
||||
}
|
||||
|
||||
private static final class Frame {
|
||||
final int opcode;
|
||||
final byte[] payload;
|
||||
|
||||
Frame(int opcode, byte[] payload) {
|
||||
this.opcode = opcode;
|
||||
this.payload = payload;
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
||||
package com.neatstudio.tmuxandroid;
|
||||
|
||||
final class ReleaseInfo {
|
||||
final int versionCode;
|
||||
final String versionName;
|
||||
final String apkUrl;
|
||||
final String sha256;
|
||||
final String releasePageUrl;
|
||||
|
||||
ReleaseInfo(int versionCode, String versionName, String apkUrl, String sha256, String releasePageUrl) {
|
||||
this.versionCode = versionCode;
|
||||
this.versionName = versionName;
|
||||
this.apkUrl = apkUrl;
|
||||
this.sha256 = sha256;
|
||||
this.releasePageUrl = releasePageUrl;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
package com.neatstudio.tmuxandroid;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.io.BufferedInputStream;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
final class SessionApiClient {
|
||||
private final String baseUrl;
|
||||
|
||||
SessionApiClient(String baseUrl) {
|
||||
this.baseUrl = trimTrailingSlash(baseUrl);
|
||||
}
|
||||
|
||||
List<SessionSummary> getSessions() throws Exception {
|
||||
String text = request("GET", "/api/sessions", null);
|
||||
JSONArray array = new JSONArray(text);
|
||||
List<SessionSummary> sessions = new ArrayList<>();
|
||||
for (int i = 0; i < array.length(); i++) {
|
||||
JSONObject item = array.getJSONObject(i);
|
||||
sessions.add(new SessionSummary(
|
||||
item.getString("name"),
|
||||
item.optString("status", ""),
|
||||
nullableString(item, "currentCommand"),
|
||||
nullableString(item, "currentPath"),
|
||||
item.optInt("windows", 0),
|
||||
item.optInt("paneCount", 0)
|
||||
));
|
||||
}
|
||||
return sessions;
|
||||
}
|
||||
|
||||
void createSession(String name) throws Exception {
|
||||
JSONObject body = new JSONObject().put("name", name);
|
||||
request("POST", "/api/sessions", body.toString());
|
||||
}
|
||||
|
||||
void killSession(String name) throws Exception {
|
||||
request("DELETE", "/api/sessions/" + encodePath(name), null);
|
||||
}
|
||||
|
||||
void renameSession(String fromName, String toName) throws Exception {
|
||||
JSONObject body = new JSONObject().put("name", toName);
|
||||
request("PATCH", "/api/sessions/" + encodePath(fromName), body.toString());
|
||||
}
|
||||
|
||||
void sendCommand(String sessionName, String command) throws Exception {
|
||||
JSONObject body = new JSONObject().put("command", command);
|
||||
request("POST", "/api/sessions/" + encodePath(sessionName) + "/send", body.toString());
|
||||
}
|
||||
|
||||
void sendInput(String sessionName, String input) throws Exception {
|
||||
JSONObject body = new JSONObject().put("input", input);
|
||||
request("POST", "/api/sessions/" + encodePath(sessionName) + "/input", body.toString());
|
||||
}
|
||||
|
||||
void splitPane(String sessionName, String direction) throws Exception {
|
||||
JSONObject body = new JSONObject().put("direction", direction);
|
||||
request("POST", "/api/sessions/" + encodePath(sessionName) + "/split", body.toString());
|
||||
}
|
||||
|
||||
void selectPane(String sessionName, String paneId) throws Exception {
|
||||
JSONObject body = new JSONObject().put("paneId", paneId);
|
||||
request("POST", "/api/sessions/" + encodePath(sessionName) + "/select-pane", body.toString());
|
||||
}
|
||||
|
||||
void killPane(String sessionName, String paneId) throws Exception {
|
||||
request("DELETE", "/api/sessions/" + encodePath(sessionName) + "/panes/" + encodePath(paneId), null);
|
||||
}
|
||||
|
||||
void setPinned(String sessionName, boolean pinned) throws Exception {
|
||||
JSONObject body = new JSONObject().put("pinned", pinned);
|
||||
request("PATCH", "/api/preferences/pinned-sessions/" + encodePath(sessionName), body.toString());
|
||||
}
|
||||
|
||||
void setMuted(String sessionName, boolean muted) throws Exception {
|
||||
JSONObject body = new JSONObject().put("muted", muted);
|
||||
request("PATCH", "/api/preferences/muted-sessions/" + encodePath(sessionName), body.toString());
|
||||
}
|
||||
|
||||
void updateSessionSettings(String sessionName, int fontSize, String fontFamily, double lineHeight, String themeId) throws Exception {
|
||||
JSONObject settings = new JSONObject()
|
||||
.put("fontSize", fontSize)
|
||||
.put("fontFamily", fontFamily)
|
||||
.put("lineHeight", lineHeight)
|
||||
.put("themeId", themeId);
|
||||
JSONObject body = new JSONObject().put("settings", settings);
|
||||
request("PATCH", "/api/preferences/session-settings/" + encodePath(sessionName), body.toString());
|
||||
}
|
||||
|
||||
void createKanbanProject(String name, String path, String server) throws Exception {
|
||||
JSONObject body = new JSONObject()
|
||||
.put("name", name)
|
||||
.put("path", path)
|
||||
.put("server", server == null || server.isEmpty() ? JSONObject.NULL : server);
|
||||
request("POST", "/api/kanban/projects", body.toString());
|
||||
}
|
||||
|
||||
void deleteKanbanProject(String name) throws Exception {
|
||||
request("DELETE", "/api/kanban/projects/" + encodePath(name), null);
|
||||
}
|
||||
|
||||
void addKanbanSession(String projectName, String sessionName) throws Exception {
|
||||
JSONObject body = new JSONObject().put("sessionName", sessionName);
|
||||
request("POST", "/api/kanban/projects/" + encodePath(projectName) + "/sessions", body.toString());
|
||||
}
|
||||
|
||||
void removeKanbanSession(String projectName, String agentName, boolean kill) throws Exception {
|
||||
request(
|
||||
"DELETE",
|
||||
"/api/kanban/projects/" + encodePath(projectName) + "/sessions/" + encodePath(agentName) + "?kill=" + kill,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
void sendGroupMessage(String projectName, String fromSession, String kind, String targetType, String targetValue, String bodyText) throws Exception {
|
||||
JSONObject target = new JSONObject().put("type", targetType);
|
||||
if ("session".equals(targetType)) {
|
||||
target.put("sessionName", targetValue);
|
||||
} else if ("role".equals(targetType)) {
|
||||
target.put("role", targetValue);
|
||||
}
|
||||
JSONObject body = new JSONObject()
|
||||
.put("fromSession", fromSession)
|
||||
.put("kind", kind)
|
||||
.put("target", target)
|
||||
.put("body", bodyText);
|
||||
request("POST", "/api/kanban/projects/" + encodePath(projectName) + "/messages", body.toString());
|
||||
}
|
||||
|
||||
void scanGroupMessage(String projectName, String messageId) throws Exception {
|
||||
request("POST", "/api/kanban/projects/" + encodePath(projectName) + "/messages/" + encodePath(messageId) + "/scan", "{}");
|
||||
}
|
||||
|
||||
void postHookEvent(String sessionName, String title, String status, String bodyText) throws Exception {
|
||||
JSONObject body = new JSONObject()
|
||||
.put("source", "android")
|
||||
.put("sessionName", sessionName)
|
||||
.put("eventType", "mobile-event")
|
||||
.put("status", status)
|
||||
.put("title", title)
|
||||
.put("body", bodyText);
|
||||
request("POST", "/api/hooks/events", body.toString());
|
||||
}
|
||||
|
||||
String uploadImageUrl(String sessionName, String imageUrl) throws Exception {
|
||||
JSONObject body = new JSONObject().put("url", imageUrl);
|
||||
return request("POST", "/api/uploads/image-url", body.toString(), sessionName);
|
||||
}
|
||||
|
||||
String uploadImage(String sessionName, byte[] bytes) throws Exception {
|
||||
return prettyJson(requestBytes("POST", "/api/uploads/image", bytes, sessionName));
|
||||
}
|
||||
|
||||
byte[] imagePreview(String path, String basePath) throws Exception {
|
||||
String query = "?path=" + encodePath(path);
|
||||
if (basePath != null && !basePath.isEmpty()) {
|
||||
query += "&basePath=" + encodePath(basePath);
|
||||
}
|
||||
return requestBinary("GET", "/api/image-preview" + query, null, null);
|
||||
}
|
||||
|
||||
String imagePreviewInfo(String path, String basePath) throws Exception {
|
||||
String query = "?path=" + encodePath(path);
|
||||
if (basePath != null && !basePath.isEmpty()) {
|
||||
query += "&basePath=" + encodePath(basePath);
|
||||
}
|
||||
return prettyJson(request("GET", "/api/image-preview-info" + query, null));
|
||||
}
|
||||
|
||||
String health() throws Exception {
|
||||
return prettyJson(request("GET", "/api/health", null));
|
||||
}
|
||||
|
||||
String serverStatus() throws Exception {
|
||||
return prettyJson(request("GET", "/api/server-status", null));
|
||||
}
|
||||
|
||||
String timeline(int limit) throws Exception {
|
||||
return prettyJson(request("GET", "/api/timeline?limit=" + limit, null));
|
||||
}
|
||||
|
||||
String preferences() throws Exception {
|
||||
return prettyJson(request("GET", "/api/preferences", null));
|
||||
}
|
||||
|
||||
String sessionsAll() throws Exception {
|
||||
return prettyJson(request("GET", "/api/sessions-all", null));
|
||||
}
|
||||
|
||||
String sessionsPanes() throws Exception {
|
||||
return prettyJson(request("GET", "/api/sessions-panes", null));
|
||||
}
|
||||
|
||||
String sessionStatus(String sessionName) throws Exception {
|
||||
return prettyJson(request("GET", "/api/sessions/" + encodePath(sessionName) + "/status", null));
|
||||
}
|
||||
|
||||
String kanbanProjects() throws Exception {
|
||||
return prettyJson(request("GET", "/api/kanban/projects", null));
|
||||
}
|
||||
|
||||
String groupMessages(String projectName) throws Exception {
|
||||
return prettyJson(request("GET", "/api/kanban/projects/" + encodePath(projectName) + "/messages", null));
|
||||
}
|
||||
|
||||
String getBaseUrl() {
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
private String request(String method, String path, String body) throws Exception {
|
||||
return request(method, path, body, null);
|
||||
}
|
||||
|
||||
private String request(String method, String path, String body, String sessionNameHeader) throws Exception {
|
||||
HttpURLConnection connection = (HttpURLConnection) new URL(baseUrl + path).openConnection();
|
||||
connection.setRequestMethod(method);
|
||||
connection.setConnectTimeout(12000);
|
||||
connection.setReadTimeout(20000);
|
||||
connection.setRequestProperty("Accept", "application/json");
|
||||
if (sessionNameHeader != null && !sessionNameHeader.isEmpty()) {
|
||||
connection.setRequestProperty("X-Tmux-Session", sessionNameHeader);
|
||||
}
|
||||
if (body != null) {
|
||||
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
|
||||
connection.setDoOutput(true);
|
||||
connection.setRequestProperty("Content-Type", "application/json");
|
||||
connection.setRequestProperty("Content-Length", String.valueOf(bytes.length));
|
||||
try (OutputStream output = connection.getOutputStream()) {
|
||||
output.write(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
int code = connection.getResponseCode();
|
||||
InputStream rawInput = code >= 200 && code < 300 ? connection.getInputStream() : connection.getErrorStream();
|
||||
String text = rawInput == null ? "" : new String(readAllBytes(new BufferedInputStream(rawInput)), StandardCharsets.UTF_8);
|
||||
connection.disconnect();
|
||||
if (code < 200 || code >= 300) {
|
||||
String message = text;
|
||||
try {
|
||||
message = new JSONObject(text).optString("error", text);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
throw new IllegalStateException(method + " " + path + " failed: " + message);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
private String requestBytes(String method, String path, byte[] body, String sessionNameHeader) throws Exception {
|
||||
return new String(requestBinary(method, path, body, sessionNameHeader), StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private byte[] requestBinary(String method, String path, byte[] body, String sessionNameHeader) throws Exception {
|
||||
HttpURLConnection connection = (HttpURLConnection) new URL(baseUrl + path).openConnection();
|
||||
connection.setRequestMethod(method);
|
||||
connection.setConnectTimeout(12000);
|
||||
connection.setReadTimeout(30000);
|
||||
if (sessionNameHeader != null && !sessionNameHeader.isEmpty()) {
|
||||
connection.setRequestProperty("X-Tmux-Session", sessionNameHeader);
|
||||
}
|
||||
if (body != null) {
|
||||
connection.setDoOutput(true);
|
||||
connection.setRequestProperty("Content-Type", "application/octet-stream");
|
||||
connection.setRequestProperty("Content-Length", String.valueOf(body.length));
|
||||
try (OutputStream output = connection.getOutputStream()) {
|
||||
output.write(body);
|
||||
}
|
||||
}
|
||||
int code = connection.getResponseCode();
|
||||
InputStream rawInput = code >= 200 && code < 300 ? connection.getInputStream() : connection.getErrorStream();
|
||||
byte[] bytes = rawInput == null ? new byte[0] : readAllBytes(new BufferedInputStream(rawInput));
|
||||
connection.disconnect();
|
||||
if (code < 200 || code >= 300) {
|
||||
String message = new String(bytes, StandardCharsets.UTF_8);
|
||||
try {
|
||||
message = new JSONObject(message).optString("error", message);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
throw new IllegalStateException(method + " " + path + " failed: " + message);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private static String prettyJson(String text) {
|
||||
try {
|
||||
String trimmed = text.trim();
|
||||
if (trimmed.startsWith("[")) {
|
||||
return new JSONArray(trimmed).toString(2);
|
||||
}
|
||||
if (trimmed.startsWith("{")) {
|
||||
return new JSONObject(trimmed).toString(2);
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
private static byte[] readAllBytes(BufferedInputStream input) throws Exception {
|
||||
java.io.ByteArrayOutputStream output = new java.io.ByteArrayOutputStream();
|
||||
byte[] buffer = new byte[16 * 1024];
|
||||
int read;
|
||||
while ((read = input.read(buffer)) != -1) {
|
||||
output.write(buffer, 0, read);
|
||||
}
|
||||
return output.toByteArray();
|
||||
}
|
||||
|
||||
private static String nullableString(JSONObject item, String key) {
|
||||
return item.isNull(key) ? null : item.optString(key, null);
|
||||
}
|
||||
|
||||
private static String encodePath(String value) throws Exception {
|
||||
return java.net.URLEncoder.encode(value, "UTF-8").replace("+", "%20");
|
||||
}
|
||||
|
||||
private static String trimTrailingSlash(String value) {
|
||||
String result = value;
|
||||
while (result.endsWith("/") && result.length() > "http://x".length()) {
|
||||
result = result.substring(0, result.length() - 1);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.neatstudio.tmuxandroid;
|
||||
|
||||
final class SessionSummary {
|
||||
final String name;
|
||||
final String status;
|
||||
final String currentCommand;
|
||||
final String currentPath;
|
||||
final int windows;
|
||||
final int paneCount;
|
||||
|
||||
SessionSummary(String name, String status, String currentCommand, String currentPath, int windows, int paneCount) {
|
||||
this.name = name;
|
||||
this.status = status;
|
||||
this.currentCommand = currentCommand;
|
||||
this.currentPath = currentPath;
|
||||
this.windows = windows;
|
||||
this.paneCount = paneCount;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
package com.neatstudio.tmuxandroid;
|
||||
|
||||
import android.util.Base64;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.io.BufferedInputStream;
|
||||
import java.io.BufferedOutputStream;
|
||||
import java.net.Socket;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Arrays;
|
||||
|
||||
import javax.net.ssl.SSLSocketFactory;
|
||||
|
||||
final class TerminalSocketClient {
|
||||
interface Listener {
|
||||
void onConnected();
|
||||
void onOutput(String data);
|
||||
void onError(String message);
|
||||
void onClosed();
|
||||
}
|
||||
|
||||
private final Object writeLock = new Object();
|
||||
private final Listener listener;
|
||||
private Socket socket;
|
||||
private BufferedInputStream input;
|
||||
private BufferedOutputStream output;
|
||||
private volatile boolean closed;
|
||||
private Thread thread;
|
||||
|
||||
TerminalSocketClient(Listener listener) {
|
||||
this.listener = listener;
|
||||
}
|
||||
|
||||
void connect(String baseUrl, String sessionName, int cols, int rows) {
|
||||
closed = false;
|
||||
thread = new Thread(() -> run(baseUrl, sessionName, cols, rows), "terminal-ws");
|
||||
thread.start();
|
||||
}
|
||||
|
||||
void sendInput(String data) {
|
||||
sendMessage("input", "data", data);
|
||||
}
|
||||
|
||||
void resize(int cols, int rows) {
|
||||
sendMessage("resize", "cols", cols, "rows", rows);
|
||||
}
|
||||
|
||||
void scroll(int lines) {
|
||||
sendMessage("scroll", "lines", lines);
|
||||
}
|
||||
|
||||
void clearHistory() {
|
||||
sendMessage("clear-history");
|
||||
}
|
||||
|
||||
void close() {
|
||||
closed = true;
|
||||
try {
|
||||
sendFrame(8, new byte[0]);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
try {
|
||||
if (socket != null) {
|
||||
socket.close();
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
private void run(String baseUrl, String sessionName, int cols, int rows) {
|
||||
try {
|
||||
URI uri = buildWsUri(baseUrl);
|
||||
socket = openSocket(uri);
|
||||
input = new BufferedInputStream(socket.getInputStream());
|
||||
output = new BufferedOutputStream(socket.getOutputStream());
|
||||
handshake(uri);
|
||||
listener.onConnected();
|
||||
sendMessage(
|
||||
"attach",
|
||||
"tabId", "android-" + System.currentTimeMillis(),
|
||||
"sessionName", sessionName,
|
||||
"cols", cols,
|
||||
"rows", rows
|
||||
);
|
||||
readLoop();
|
||||
} catch (Exception error) {
|
||||
if (!closed) {
|
||||
listener.onError(error.getMessage() == null ? error.toString() : error.getMessage());
|
||||
}
|
||||
} finally {
|
||||
closed = true;
|
||||
listener.onClosed();
|
||||
try {
|
||||
if (socket != null) {
|
||||
socket.close();
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private URI buildWsUri(String baseUrl) throws Exception {
|
||||
URI base = new URI(baseUrl);
|
||||
String scheme = "https".equalsIgnoreCase(base.getScheme()) ? "wss" : "ws";
|
||||
int port = base.getPort();
|
||||
String authority = port == -1 ? base.getHost() : base.getHost() + ":" + port;
|
||||
return new URI(scheme + "://" + authority + "/ws/terminal");
|
||||
}
|
||||
|
||||
private Socket openSocket(URI uri) throws Exception {
|
||||
int port = uri.getPort();
|
||||
if (port == -1) {
|
||||
port = "wss".equalsIgnoreCase(uri.getScheme()) ? 443 : 80;
|
||||
}
|
||||
if ("wss".equalsIgnoreCase(uri.getScheme())) {
|
||||
return SSLSocketFactory.getDefault().createSocket(uri.getHost(), port);
|
||||
}
|
||||
return new Socket(uri.getHost(), port);
|
||||
}
|
||||
|
||||
private void handshake(URI uri) throws Exception {
|
||||
byte[] nonce = new byte[16];
|
||||
new SecureRandom().nextBytes(nonce);
|
||||
String key = Base64.encodeToString(nonce, Base64.NO_WRAP);
|
||||
String host = uri.getPort() == -1 ? uri.getHost() : uri.getHost() + ":" + uri.getPort();
|
||||
String request = "GET " + uri.getRawPath() + " HTTP/1.1\r\n"
|
||||
+ "Host: " + host + "\r\n"
|
||||
+ "Upgrade: websocket\r\n"
|
||||
+ "Connection: Upgrade\r\n"
|
||||
+ "Sec-WebSocket-Key: " + key + "\r\n"
|
||||
+ "Sec-WebSocket-Version: 13\r\n"
|
||||
+ "\r\n";
|
||||
output.write(request.getBytes(StandardCharsets.US_ASCII));
|
||||
output.flush();
|
||||
|
||||
String response = readHttpHeaders();
|
||||
if (!response.startsWith("HTTP/1.1 101") && !response.startsWith("HTTP/1.0 101")) {
|
||||
throw new IllegalStateException("WebSocket handshake failed: " + response.split("\r\n")[0]);
|
||||
}
|
||||
String expected = websocketAccept(key);
|
||||
if (!response.toLowerCase(java.util.Locale.US).contains("sec-websocket-accept: " + expected.toLowerCase(java.util.Locale.US))) {
|
||||
throw new IllegalStateException("WebSocket accept header mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
private String readHttpHeaders() throws Exception {
|
||||
java.io.ByteArrayOutputStream buffer = new java.io.ByteArrayOutputStream();
|
||||
int previous3 = -1;
|
||||
int previous2 = -1;
|
||||
int previous1 = -1;
|
||||
int current;
|
||||
while ((current = input.read()) != -1) {
|
||||
buffer.write(current);
|
||||
if (previous3 == '\r' && previous2 == '\n' && previous1 == '\r' && current == '\n') {
|
||||
break;
|
||||
}
|
||||
previous3 = previous2;
|
||||
previous2 = previous1;
|
||||
previous1 = current;
|
||||
}
|
||||
return buffer.toString("US-ASCII");
|
||||
}
|
||||
|
||||
private static String websocketAccept(String key) throws Exception {
|
||||
String source = key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-1");
|
||||
return Base64.encodeToString(digest.digest(source.getBytes(StandardCharsets.US_ASCII)), Base64.NO_WRAP);
|
||||
}
|
||||
|
||||
private void readLoop() throws Exception {
|
||||
while (!closed) {
|
||||
Frame frame = readFrame();
|
||||
if (frame.opcode == 1) {
|
||||
handleText(new String(frame.payload, StandardCharsets.UTF_8));
|
||||
} else if (frame.opcode == 8) {
|
||||
return;
|
||||
} else if (frame.opcode == 9) {
|
||||
sendFrame(10, frame.payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Frame readFrame() throws Exception {
|
||||
int first = input.read();
|
||||
int second = input.read();
|
||||
if (first == -1 || second == -1) {
|
||||
throw new IllegalStateException("WebSocket closed");
|
||||
}
|
||||
int opcode = first & 0x0f;
|
||||
boolean masked = (second & 0x80) != 0;
|
||||
long length = second & 0x7f;
|
||||
if (length == 126) {
|
||||
length = ((long) input.read() << 8) | input.read();
|
||||
} else if (length == 127) {
|
||||
length = 0;
|
||||
for (int i = 0; i < 8; i++) {
|
||||
length = (length << 8) | input.read();
|
||||
}
|
||||
}
|
||||
byte[] mask = null;
|
||||
if (masked) {
|
||||
mask = readExactly(4);
|
||||
}
|
||||
byte[] payload = readExactly((int) length);
|
||||
if (masked && mask != null) {
|
||||
for (int i = 0; i < payload.length; i++) {
|
||||
payload[i] = (byte) (payload[i] ^ mask[i % 4]);
|
||||
}
|
||||
}
|
||||
return new Frame(opcode, payload);
|
||||
}
|
||||
|
||||
private byte[] readExactly(int length) throws Exception {
|
||||
byte[] data = new byte[length];
|
||||
int offset = 0;
|
||||
while (offset < length) {
|
||||
int read = input.read(data, offset, length - offset);
|
||||
if (read == -1) {
|
||||
throw new IllegalStateException("WebSocket closed");
|
||||
}
|
||||
offset += read;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
private void handleText(String text) throws Exception {
|
||||
JSONObject message = new JSONObject(text);
|
||||
String type = message.optString("type", "");
|
||||
if ("output".equals(type)) {
|
||||
listener.onOutput(message.optString("data", ""));
|
||||
} else if ("error".equals(type)) {
|
||||
listener.onError(message.optString("message", "Terminal error"));
|
||||
} else if ("session-exit".equals(type)) {
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
private void sendJson(JSONObject object) {
|
||||
try {
|
||||
sendFrame(1, object.toString().getBytes(StandardCharsets.UTF_8));
|
||||
} catch (Exception error) {
|
||||
if (!closed) {
|
||||
listener.onError(error.getMessage() == null ? error.toString() : error.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void sendMessage(String type, Object... keyValues) {
|
||||
try {
|
||||
JSONObject object = new JSONObject();
|
||||
object.put("type", type);
|
||||
for (int i = 0; i + 1 < keyValues.length; i += 2) {
|
||||
object.put(String.valueOf(keyValues[i]), keyValues[i + 1]);
|
||||
}
|
||||
sendJson(object);
|
||||
} catch (Exception error) {
|
||||
if (!closed) {
|
||||
listener.onError(error.getMessage() == null ? error.toString() : error.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void sendFrame(int opcode, byte[] payload) throws Exception {
|
||||
synchronized (writeLock) {
|
||||
if (output == null) {
|
||||
return;
|
||||
}
|
||||
output.write(0x80 | opcode);
|
||||
byte[] mask = new byte[4];
|
||||
new SecureRandom().nextBytes(mask);
|
||||
int length = payload.length;
|
||||
if (length < 126) {
|
||||
output.write(0x80 | length);
|
||||
} else if (length <= 0xffff) {
|
||||
output.write(0x80 | 126);
|
||||
output.write((length >>> 8) & 0xff);
|
||||
output.write(length & 0xff);
|
||||
} else {
|
||||
output.write(0x80 | 127);
|
||||
for (int i = 7; i >= 0; i--) {
|
||||
output.write((length >>> (8 * i)) & 0xff);
|
||||
}
|
||||
}
|
||||
output.write(mask);
|
||||
byte[] masked = Arrays.copyOf(payload, payload.length);
|
||||
for (int i = 0; i < masked.length; i++) {
|
||||
masked[i] = (byte) (masked[i] ^ mask[i % 4]);
|
||||
}
|
||||
output.write(masked);
|
||||
output.flush();
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Frame {
|
||||
final int opcode;
|
||||
final byte[] payload;
|
||||
|
||||
Frame(int opcode, byte[] payload) {
|
||||
this.opcode = opcode;
|
||||
this.payload = payload;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package com.neatstudio.tmuxandroid;
|
||||
|
||||
import android.content.ContentProvider;
|
||||
import android.content.ContentValues;
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import android.database.MatrixCursor;
|
||||
import android.net.Uri;
|
||||
import android.os.ParcelFileDescriptor;
|
||||
import android.provider.OpenableColumns;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.IOException;
|
||||
|
||||
public final class UpdateFileProvider extends ContentProvider {
|
||||
static Uri getUriForFile(Context context, String authority, File file) {
|
||||
try {
|
||||
File root = new File(context.getCacheDir(), "updates").getCanonicalFile();
|
||||
File target = file.getCanonicalFile();
|
||||
if (!target.getPath().startsWith(root.getPath() + File.separator)) {
|
||||
throw new IllegalArgumentException("File is outside update cache");
|
||||
}
|
||||
return new Uri.Builder()
|
||||
.scheme("content")
|
||||
.authority(authority)
|
||||
.appendPath(target.getName())
|
||||
.build();
|
||||
} catch (IOException error) {
|
||||
throw new IllegalArgumentException("Invalid update file", error);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onCreate() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getType(Uri uri) {
|
||||
return "application/vnd.android.package-archive";
|
||||
}
|
||||
|
||||
@Override
|
||||
public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException {
|
||||
if (!"r".equals(mode)) {
|
||||
throw new FileNotFoundException("Read-only provider");
|
||||
}
|
||||
File file = resolveFile(uri);
|
||||
return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
|
||||
File file;
|
||||
try {
|
||||
file = resolveFile(uri);
|
||||
} catch (FileNotFoundException error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String[] columns = projection == null
|
||||
? new String[]{OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE}
|
||||
: projection;
|
||||
MatrixCursor cursor = new MatrixCursor(columns, 1);
|
||||
MatrixCursor.RowBuilder row = cursor.newRow();
|
||||
for (String column : columns) {
|
||||
if (OpenableColumns.DISPLAY_NAME.equals(column)) {
|
||||
row.add(file.getName());
|
||||
} else if (OpenableColumns.SIZE.equals(column)) {
|
||||
row.add(file.length());
|
||||
} else {
|
||||
row.add(null);
|
||||
}
|
||||
}
|
||||
return cursor;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Uri insert(Uri uri, ContentValues values) {
|
||||
throw new UnsupportedOperationException("insert");
|
||||
}
|
||||
|
||||
@Override
|
||||
public int delete(Uri uri, String selection, String[] selectionArgs) {
|
||||
throw new UnsupportedOperationException("delete");
|
||||
}
|
||||
|
||||
@Override
|
||||
public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
|
||||
throw new UnsupportedOperationException("update");
|
||||
}
|
||||
|
||||
private File resolveFile(Uri uri) throws FileNotFoundException {
|
||||
Context context = getContext();
|
||||
if (context == null) {
|
||||
throw new FileNotFoundException("No context");
|
||||
}
|
||||
String name = uri.getLastPathSegment();
|
||||
if (name == null || name.contains("/") || name.contains("..")) {
|
||||
throw new FileNotFoundException("Invalid file name");
|
||||
}
|
||||
try {
|
||||
File root = new File(context.getCacheDir(), "updates").getCanonicalFile();
|
||||
File file = new File(root, name).getCanonicalFile();
|
||||
if (!file.getPath().startsWith(root.getPath() + File.separator) || !file.isFile()) {
|
||||
throw new FileNotFoundException("File not found");
|
||||
}
|
||||
return file;
|
||||
} catch (IOException error) {
|
||||
FileNotFoundException wrapped = new FileNotFoundException("Invalid file");
|
||||
wrapped.initCause(error);
|
||||
throw wrapped;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package com.neatstudio.tmuxandroid;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.app.AlertDialog;
|
||||
import android.content.ActivityNotFoundException;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.provider.Settings;
|
||||
import android.widget.Toast;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.io.BufferedInputStream;
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
final class UpdateManager {
|
||||
interface Callback {
|
||||
void onChecking(boolean checking);
|
||||
void onMessage(String message);
|
||||
}
|
||||
|
||||
private final Activity activity;
|
||||
private final SharedPreferences prefs;
|
||||
private final Callback callback;
|
||||
private final ExecutorService executor = Executors.newSingleThreadExecutor();
|
||||
|
||||
UpdateManager(Activity activity, SharedPreferences prefs, Callback callback) {
|
||||
this.activity = activity;
|
||||
this.prefs = prefs;
|
||||
this.callback = callback;
|
||||
}
|
||||
|
||||
void check(boolean userInitiated) {
|
||||
String manifestUrl = prefs.getString("update_url", BuildConfig.DEFAULT_UPDATE_URL);
|
||||
callback.onChecking(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
ReleaseInfo info = fetchReleaseInfo(manifestUrl);
|
||||
if (info.versionCode <= BuildConfig.VERSION_CODE) {
|
||||
postMessage(userInitiated ? "Already up to date" : null);
|
||||
return;
|
||||
}
|
||||
activity.runOnUiThread(() -> showUpdateDialog(info));
|
||||
} catch (Exception error) {
|
||||
postMessage(userInitiated ? "Update check failed: " + error.getMessage() : null);
|
||||
} finally {
|
||||
activity.runOnUiThread(() -> callback.onChecking(false));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private ReleaseInfo fetchReleaseInfo(String manifestUrl) throws Exception {
|
||||
String json = readText(manifestUrl);
|
||||
JSONObject root = new JSONObject(json);
|
||||
return new ReleaseInfo(
|
||||
root.getInt("versionCode"),
|
||||
root.optString("versionName", ""),
|
||||
root.getString("apkUrl"),
|
||||
root.optString("sha256", ""),
|
||||
root.optString("releasePageUrl", "")
|
||||
);
|
||||
}
|
||||
|
||||
private String readText(String url) throws Exception {
|
||||
HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection();
|
||||
connection.setConnectTimeout(12000);
|
||||
connection.setReadTimeout(12000);
|
||||
connection.setRequestProperty("Accept", "application/json");
|
||||
try (InputStream input = new BufferedInputStream(connection.getInputStream())) {
|
||||
byte[] bytes = readAllBytes(input);
|
||||
return new String(bytes, java.nio.charset.StandardCharsets.UTF_8);
|
||||
} finally {
|
||||
connection.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private void showUpdateDialog(ReleaseInfo info) {
|
||||
new AlertDialog.Builder(activity)
|
||||
.setTitle("Update available")
|
||||
.setMessage("Install " + info.versionName + " now?")
|
||||
.setNegativeButton("Later", null)
|
||||
.setPositiveButton("Install", (dialog, which) -> downloadAndInstall(info))
|
||||
.show();
|
||||
}
|
||||
|
||||
private void downloadAndInstall(ReleaseInfo info) {
|
||||
callback.onChecking(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
File apk = downloadApk(info);
|
||||
if (!info.sha256.isEmpty()) {
|
||||
String actual = sha256(apk);
|
||||
if (!actual.equalsIgnoreCase(info.sha256)) {
|
||||
throw new IllegalStateException("APK SHA-256 mismatch");
|
||||
}
|
||||
}
|
||||
activity.runOnUiThread(() -> installApk(apk));
|
||||
} catch (Exception error) {
|
||||
postMessage("Update download failed: " + error.getMessage());
|
||||
} finally {
|
||||
activity.runOnUiThread(() -> callback.onChecking(false));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private File downloadApk(ReleaseInfo info) throws Exception {
|
||||
File dir = new File(activity.getCacheDir(), "updates");
|
||||
if (!dir.exists() && !dir.mkdirs()) {
|
||||
throw new IllegalStateException("Cannot create update cache");
|
||||
}
|
||||
File apk = new File(dir, "tmux-android-" + info.versionCode + ".apk");
|
||||
|
||||
HttpURLConnection connection = (HttpURLConnection) new URL(info.apkUrl).openConnection();
|
||||
connection.setConnectTimeout(12000);
|
||||
connection.setReadTimeout(60000);
|
||||
try (InputStream input = new BufferedInputStream(connection.getInputStream());
|
||||
FileOutputStream output = new FileOutputStream(apk)) {
|
||||
byte[] buffer = new byte[64 * 1024];
|
||||
int read;
|
||||
while ((read = input.read(buffer)) != -1) {
|
||||
output.write(buffer, 0, read);
|
||||
}
|
||||
} finally {
|
||||
connection.disconnect();
|
||||
}
|
||||
return apk;
|
||||
}
|
||||
|
||||
private void installApk(File apk) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
|
||||
&& !activity.getPackageManager().canRequestPackageInstalls()) {
|
||||
Intent settingsIntent = new Intent(
|
||||
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
|
||||
Uri.parse("package:" + activity.getPackageName())
|
||||
);
|
||||
activity.startActivity(settingsIntent);
|
||||
Toast.makeText(activity, "Allow installs, then run update again", Toast.LENGTH_LONG).show();
|
||||
return;
|
||||
}
|
||||
|
||||
Uri apkUri = UpdateFileProvider.getUriForFile(
|
||||
activity,
|
||||
activity.getPackageName() + ".fileprovider",
|
||||
apk
|
||||
);
|
||||
Intent intent = new Intent(Intent.ACTION_VIEW)
|
||||
.setDataAndType(apkUri, "application/vnd.android.package-archive")
|
||||
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
try {
|
||||
activity.startActivity(intent);
|
||||
} catch (ActivityNotFoundException error) {
|
||||
postMessage("No package installer found");
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] readAllBytes(InputStream input) throws Exception {
|
||||
java.io.ByteArrayOutputStream output = new java.io.ByteArrayOutputStream();
|
||||
byte[] buffer = new byte[16 * 1024];
|
||||
int read;
|
||||
while ((read = input.read(buffer)) != -1) {
|
||||
output.write(buffer, 0, read);
|
||||
}
|
||||
return output.toByteArray();
|
||||
}
|
||||
|
||||
private static String sha256(File file) throws Exception {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
try (InputStream input = new BufferedInputStream(new java.io.FileInputStream(file))) {
|
||||
byte[] buffer = new byte[64 * 1024];
|
||||
int read;
|
||||
while ((read = input.read(buffer)) != -1) {
|
||||
digest.update(buffer, 0, read);
|
||||
}
|
||||
}
|
||||
byte[] bytes = digest.digest();
|
||||
StringBuilder builder = new StringBuilder(bytes.length * 2);
|
||||
for (byte item : bytes) {
|
||||
builder.append(String.format(Locale.US, "%02x", item));
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private void postMessage(String message) {
|
||||
if (message == null) {
|
||||
return;
|
||||
}
|
||||
activity.runOnUiThread(() -> callback.onMessage(message));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user