diff --git a/controller/app/src/main/AndroidManifest.xml b/controller/app/src/main/AndroidManifest.xml index c8ca87b85..c87743171 100644 --- a/controller/app/src/main/AndroidManifest.xml +++ b/controller/app/src/main/AndroidManifest.xml @@ -248,6 +248,15 @@ android:value="Removes selected library content from the co-located server to reclaim device storage." /> + + + + diff --git a/controller/app/src/main/java/org/appdevforall/k2go/contentexchange/data/CxCoreDataSource.java b/controller/app/src/main/java/org/appdevforall/k2go/contentexchange/data/CxCoreDataSource.java index 6b50b6dab..38318bc7e 100644 --- a/controller/app/src/main/java/org/appdevforall/k2go/contentexchange/data/CxCoreDataSource.java +++ b/controller/app/src/main/java/org/appdevforall/k2go/contentexchange/data/CxCoreDataSource.java @@ -13,6 +13,8 @@ package org.appdevforall.k2go.contentexchange.data; import org.appdevforall.k2go.config.BoxEndpoints; +import org.appdevforall.k2go.contentexchange.domain.ItemRef; +import org.json.JSONArray; import org.json.JSONObject; import java.io.ByteArrayOutputStream; @@ -22,6 +24,7 @@ import java.net.HttpURLConnection; import java.net.URL; import java.nio.charset.StandardCharsets; +import java.util.List; /** * The one place in this feature that touches {@link HttpURLConnection}. It issues @@ -138,7 +141,66 @@ public JSONObject deleteExportOp(String opId) throws Exception { return deleteJson(V1 + "/export/" + encodeSegment(opId)); } + // ---- share gate (K2GO-376) ----------------------------------------------------------------- + // These hit the sender's OWN cx-core over the loopback relay. The receiver reaches the same + // session over the hotspot (a separate off-loopback exposure); the app never copies bytes. The + // token minted here is the session's only auth and travels in the X-Share-Token header. + + /** + * {@code POST /v1/share} with {@code {items:[{app,localRef}]}} -> {@code {sessionId, token, + * manifestUrl}}. Opens a scoped share session on the local cx-core and returns the token the + * sender puts in the QR; no token header (this call mints it). K2GO-376. + */ + public ShareCreated postShareCreate(List items) throws Exception { + JSONArray arr = new JSONArray(); + for (ItemRef ref : items) { + arr.put(new JSONObject().put("app", ref.app()).put("localRef", ref.localRef())); + } + JSONObject body = new JSONObject().put("items", arr); + JSONObject res = postJson(V1 + "/share", body, null); + return new ShareCreated( + res.optString("sessionId", ""), + res.optString("token", ""), + res.optString("manifestUrl", "")); + } + + /** + * {@code POST /v1/share/{sessionId}/complete} (X-Share-Token) -> {@code {ok}}. The clean-success + * close; the receiver drives it. A 404 means already closed (harmless). K2GO-376. + */ + public JSONObject postShareComplete(String sessionId, String token) throws Exception { + return postJson(V1 + "/share/" + encodeSegment(sessionId) + "/complete", new JSONObject(), token); + } + + /** + * {@code DELETE /v1/share/{sessionId}} (X-Share-Token) -> {@code {ok}}. Cancel / abort, by host + * or receiver; the sender's foreground service calls it on teardown (the app-side closer). A 404 + * means already closed, so a double close is harmless. K2GO-376. + */ + public JSONObject deleteShare(String sessionId, String token) throws Exception { + return deleteJson(V1 + "/share/" + encodeSegment(sessionId), token); + } + + /** The result of opening a share session: the id, the auth token, and the manifest path. */ + public static final class ShareCreated { + public final String sessionId; + public final String token; + public final String manifestUrl; + + ShareCreated(String sessionId, String token, String manifestUrl) { + this.sessionId = sessionId; + this.token = token; + this.manifestUrl = manifestUrl; + } + } + private static JSONObject postJson(String urlStr, JSONObject body) throws Exception { + return postJson(urlStr, body, null); + } + + /** POST with an optional {@code X-Share-Token} header (K2GO-376: the token is the gate's only + * auth, so it rides a header, never the URL query, to stay out of relay / nginx access logs). */ + private static JSONObject postJson(String urlStr, JSONObject body, String shareToken) throws Exception { HttpURLConnection c = (HttpURLConnection) new URL(urlStr).openConnection(); try { c.setUseCaches(false); @@ -148,6 +210,9 @@ private static JSONObject postJson(String urlStr, JSONObject body) throws Except c.setDoOutput(true); c.setRequestProperty("Content-Type", "application/json"); c.setRequestProperty("Accept", "application/json"); + if (shareToken != null) { + c.setRequestProperty("X-Share-Token", shareToken); + } byte[] payload = body.toString().getBytes(StandardCharsets.UTF_8); try (OutputStream os = c.getOutputStream()) { os.write(payload); @@ -165,6 +230,11 @@ private static JSONObject postJson(String urlStr, JSONObject body) throws Except } private static JSONObject deleteJson(String urlStr) throws Exception { + return deleteJson(urlStr, null); + } + + /** DELETE with an optional {@code X-Share-Token} header (K2GO-376; see {@link #postJson}). */ + private static JSONObject deleteJson(String urlStr, String shareToken) throws Exception { HttpURLConnection c = (HttpURLConnection) new URL(urlStr).openConnection(); try { c.setUseCaches(false); @@ -172,6 +242,9 @@ private static JSONObject deleteJson(String urlStr) throws Exception { c.setReadTimeout(DELETE_READ_TIMEOUT_MS); c.setRequestMethod("DELETE"); c.setRequestProperty("Accept", "application/json"); + if (shareToken != null) { + c.setRequestProperty("X-Share-Token", shareToken); + } int code = c.getResponseCode(); boolean ok = code >= 200 && code < 400; String text = readAll(ok ? c.getInputStream() : c.getErrorStream()); diff --git a/controller/app/src/main/java/org/appdevforall/k2go/contentexchange/data/ShareHandshake.java b/controller/app/src/main/java/org/appdevforall/k2go/contentexchange/data/ShareHandshake.java new file mode 100644 index 000000000..433db7ef0 --- /dev/null +++ b/controller/app/src/main/java/org/appdevforall/k2go/contentexchange/data/ShareHandshake.java @@ -0,0 +1,118 @@ +/* + * ============================================================================ + * Name : ShareHandshake.java + * Author : AppDevForAll + * Copyright : Copyright (c) 2026 AppDevForAll + * Description : K2GO-376. The Share QR payload schema (encode + parse), a variant of the sync + * handshake (SyncHandshakeHelper) for the content-exchange feature. Encode is the + * sender side (build the invite to render); parse is the receiver side; both go + * through ShareInviteValidator, so one class owns the invite contract for both forks. + * ============================================================================ + */ +package org.appdevforall.k2go.contentexchange.data; + +import org.appdevforall.k2go.contentexchange.domain.ShareInviteValidator; +import org.appdevforall.k2go.contentexchange.domain.ShareTransport; +import org.appdevforall.k2go.sync.domain.SyncCredentialValidator; +import org.json.JSONObject; + +/** + * Encodes and parses the Share invite QR (ADR "Pillar Share"). The payload is small JSON: + * + *
{"app":"iiab_share","addr":"<host>:<port>","token":"<t>","manifestUrl":"/v1/share/<id>/manifest"}
+ * + *

{@code addr} is the sender's hotspot IP and the share port joined with a colon (the one field + * the ADR calls the address). The receiver composes {@code http://addr + manifestUrl} for the + * manifest and {@code http://addr + /v1/share//fetch/} for each item, with the token in the + * {@code X-Share-Token} header (never the URL). Parsing is fail-closed: an invite that does not + * validate returns {@code null}, mirroring {@code SyncHandshakeHelper.parsePayload}. + */ +public final class ShareHandshake { + + private ShareHandshake() { + } + + /** A parsed, validated Share invite: the host and port split out of {@code addr}, plus the token + * and manifest path. {@link #baseUrl()} is the origin the receiver prefixes to share paths. */ + public static final class ShareInvite { + public final String host; + public final int port; + public final String token; + public final String manifestUrl; + + public ShareInvite(String host, int port, String token, String manifestUrl) { + this.host = host; + this.port = port; + this.token = token; + this.manifestUrl = manifestUrl; + } + + /** {@code http://host:port} (no trailing slash): the receiver appends the share paths. */ + public String baseUrl() { + return ShareTransport.SCHEME + "://" + host + ":" + port; + } + } + + /** + * Build the QR payload for a validated invite, or {@code null} when the inputs do not validate + * (so a malformed address or token is never rendered into a scannable code). {@code host} is the + * sender's hotspot IPv4; {@code port} is {@link ShareTransport#SHARE_PORT}. + */ + public static String encode(String host, int port, String token, String manifestUrl) { + SyncCredentialValidator.Result check = + ShareInviteValidator.validate(host, port, token, manifestUrl); + if (!check.valid) { + return null; + } + try { + JSONObject json = new JSONObject(); + json.put("app", ShareTransport.QR_APP_TAG); + json.put("addr", host + ":" + port); + json.put("token", token); + json.put("manifestUrl", manifestUrl); + return json.toString(); + } catch (Exception e) { + return null; + } + } + + /** + * Parse a scanned payload into a validated invite, or {@code null} when it is not a Share QR or + * fails validation. The receiver fork calls this at the scan boundary; the scanned text is + * untrusted input, so every field passes {@link ShareInviteValidator} before it is returned. + */ + public static ShareInvite decode(String scannedJson) { + try { + JSONObject json = new JSONObject(scannedJson); + if (!ShareTransport.QR_APP_TAG.equals(json.optString("app"))) { + return null; // not a Share invite + } + String addr = json.optString("addr", ""); + String token = json.optString("token", ""); + String manifestUrl = json.optString("manifestUrl", ""); + int colon = addr.lastIndexOf(':'); // IPv4 has no colons, so the last ':' splits host:port + if (colon <= 0 || colon == addr.length() - 1) { + return null; + } + String host = addr.substring(0, colon); + int port = parsePort(addr.substring(colon + 1)); + SyncCredentialValidator.Result check = + ShareInviteValidator.validate(host, port, token, manifestUrl); + if (!check.valid) { + return null; + } + return new ShareInvite(host, port, token, manifestUrl); + } catch (Exception e) { + return null; + } + } + + /** Parse the port digits, returning -1 (an invalid port) on any non-numeric or out-of-range text. */ + private static int parsePort(String s) { + try { + return Integer.parseInt(s); + } catch (NumberFormatException e) { + return -1; + } + } +} diff --git a/controller/app/src/main/java/org/appdevforall/k2go/contentexchange/domain/ShareInviteValidator.java b/controller/app/src/main/java/org/appdevforall/k2go/contentexchange/domain/ShareInviteValidator.java new file mode 100644 index 000000000..8e1ae6231 --- /dev/null +++ b/controller/app/src/main/java/org/appdevforall/k2go/contentexchange/domain/ShareInviteValidator.java @@ -0,0 +1,101 @@ +/* + * ============================================================================ + * Name : ShareInviteValidator.java + * Author : AppDevForAll + * Copyright : Copyright (c) 2026 AppDevForAll + * Description : K2GO-376. Domain rule: what counts as a *safe* Share invite (the QR payload the + * sender shows and the receiver scans). Pure (no android.*, no JSON); the single + * source for the invite shape, so the sender and the receiver fork validate the + * same thing. Reuses the host/port rule from the sync security slice (S1). + * ============================================================================ + */ +package org.appdevforall.k2go.contentexchange.domain; + +import org.appdevforall.k2go.sync.domain.SyncCredentialValidator; + +/** + * Pure validation of a Share invite (ADR "Pillar Share"). The invite carries the sender's reachable + * address and port, a session token, and the manifest path. On the sender these come from trusted + * local sources (the hotspot owner and the sender's own cx-core), but the RECEIVER scans the same + * QR as untrusted input, and both forks build against this one rule so the contract cannot drift. + * + *

The receiver later composes URLs from these fields ({@code http://host:port} + a share path), + * so the host/port rule is reused from {@link SyncCredentialValidator} (the hardened S1 rule) and + * the token/path are held to conservative, injection-free character sets. + */ +public final class ShareInviteValidator { + + /** The cx-core token is {@code secrets.token_urlsafe(32)}: url-safe base64, ~43 chars. */ + private static final int MAX_TOKEN_LEN = 256; + /** The manifest path is short ({@code /v1/share//manifest}); cap it well above that. */ + private static final int MAX_PATH_LEN = 512; + + private ShareInviteValidator() { + } + + /** Validate every field of an invite. Call this at the build boundary (sender) and the scan + * boundary (receiver); reject the whole invite when it returns invalid. */ + public static SyncCredentialValidator.Result validate(String host, int port, String token, + String manifestUrl) { + if (!SyncCredentialValidator.isValidHost(host)) { + return SyncCredentialValidator.Result.fail("invalid host"); + } + if (!SyncCredentialValidator.isValidPort(port)) { + return SyncCredentialValidator.Result.fail("invalid port"); + } + if (!isValidToken(token)) { + return SyncCredentialValidator.Result.fail("invalid token"); + } + if (!isValidManifestPath(manifestUrl)) { + return SyncCredentialValidator.Result.fail("invalid manifest path"); + } + return SyncCredentialValidator.Result.ok(); + } + + /** + * A token safe to place in an HTTP header and compare: a non-empty run of url-safe base64 + * characters ({@code [A-Za-z0-9-_]}), which is exactly what {@code token_urlsafe} produces. No + * whitespace or control characters, so it cannot smuggle a second header line. + */ + public static boolean isValidToken(String token) { + if (token == null || token.isEmpty() || token.length() > MAX_TOKEN_LEN) { + return false; + } + for (int i = 0; i < token.length(); i++) { + char c = token.charAt(i); + boolean ok = (c >= 'A' && c <= 'Z') + || (c >= 'a' && c <= 'z') + || (c >= '0' && c <= '9') + || c == '-' + || c == '_'; + if (!ok) { + return false; + } + } + return true; + } + + /** + * A server-relative path safe to append to {@code http://host:port}: it must start with a single + * {@code /}, carry no whitespace or control characters, no {@code ..} segment, and no scheme or + * authority ({@code ://}), so it cannot redirect the receiver to another host. + */ + public static boolean isValidManifestPath(String path) { + if (path == null || path.length() < 2 || path.length() > MAX_PATH_LEN) { + return false; + } + if (path.charAt(0) != '/' || path.charAt(1) == '/') { + return false; // must be a single-slash absolute path, not "//host" (an authority) + } + if (path.contains("..") || path.contains("://")) { + return false; + } + for (int i = 0; i < path.length(); i++) { + char c = path.charAt(i); + if (c <= 0x20 || c >= 0x7f) { + return false; // no control characters, space or DEL + } + } + return true; + } +} diff --git a/controller/app/src/main/java/org/appdevforall/k2go/contentexchange/domain/ShareTransport.java b/controller/app/src/main/java/org/appdevforall/k2go/contentexchange/domain/ShareTransport.java new file mode 100644 index 000000000..a090fd628 --- /dev/null +++ b/controller/app/src/main/java/org/appdevforall/k2go/contentexchange/domain/ShareTransport.java @@ -0,0 +1,40 @@ +/* + * ============================================================================ + * Name : ShareTransport.java + * Author : AppDevForAll + * Copyright : Copyright (c) 2026 AppDevForAll + * Description : K2GO-376. One source for the content-exchange wireless-share transport + * constants: the off-loopback port the receiver reaches the sender on, the + * scheme, and the QR application tag. Pure (no android.*); the sender and the + * later receiver fork both read it so the two forks cannot drift apart. + * ============================================================================ + */ +package org.appdevforall.k2go.contentexchange.domain; + +/** + * Transport settings for granular wireless Share (K2GO-376). + * + *

Share is the network twin of Export: the receiver PULLS from the sender's live cx-core + * (ADR "Pillar Share"). On the sender the share session is created over the loopback relay, but + * the RECEIVER reaches the gate over the hotspot at {@code http://:SHARE_PORT}. + * That off-loopback exposure is a separate server-side slice (plain HTTP, v1 only); this is the + * single constant the app points at it, so it stays in step with the exposure port. + */ +public final class ShareTransport { + + private ShareTransport() { + } + + /** + * TCP port the sender's cx-core is exposed on, off loopback, for the paired receiver. Must + * match the scoped-exposure slice (candidate 8095, plain HTTP for v1). The loopback relay the + * sender uses to CREATE the session is unrelated to this port. + */ + public static final int SHARE_PORT = 8095; + + /** The receiver reaches the gate over plain HTTP on the hotspot (no TLS for v1; see the ADR). */ + public static final String SCHEME = "http"; + + /** QR payload {@code app} tag, so a scanned code is recognised as a Share invite (not a Sync QR). */ + public static final String QR_APP_TAG = "iiab_share"; +} diff --git a/controller/app/src/main/java/org/appdevforall/k2go/contentexchange/presentation/ContentManagerFragment.java b/controller/app/src/main/java/org/appdevforall/k2go/contentexchange/presentation/ContentManagerFragment.java index 08e243223..84e4b0b1e 100644 --- a/controller/app/src/main/java/org/appdevforall/k2go/contentexchange/presentation/ContentManagerFragment.java +++ b/controller/app/src/main/java/org/appdevforall/k2go/contentexchange/presentation/ContentManagerFragment.java @@ -9,7 +9,8 @@ * (pick a drive via SAF, then write the open-format bundle through the * foreground service) and the DELETE action is wired (confirm, then remove each * selected item on the box through the foreground service, refreshing the list and - * reclaimed space on settle); Share is a later slice and stays disabled. + * reclaimed space on settle). SHARE is wired too (K2GO-376): over the selection it + * opens a wireless share session and shows the invite QR through the share service. * ============================================================================ */ package org.appdevforall.k2go.contentexchange.presentation; @@ -38,9 +39,14 @@ import org.appdevforall.k2go.contentexchange.domain.ItemRef; import org.appdevforall.k2go.contentexchange.domain.ManagedContent; import org.appdevforall.k2go.contentexchange.domain.StorageStatus; +import org.appdevforall.k2go.hotspot.HotspotPermissions; +import org.appdevforall.k2go.hotspot.LocalHotspotManager; import org.appdevforall.k2go.storage.StorageProbe; import org.appdevforall.k2go.ui.dialog.BrandDialog; import org.appdevforall.k2go.util.ByteFormatter; +import org.appdevforall.k2go.util.SnackbarDuration; + +import com.google.android.material.snackbar.Snackbar; import java.util.ArrayList; import java.util.List; @@ -63,6 +69,13 @@ public class ContentManagerFragment extends Fragment { private Button exportButton; private Button importButton; private Button deleteButton; + private Button shareButton; + + /** The selection captured when Share was tapped, held across the hotspot-permission round-trip. */ + private List pendingShare; + /** Runtime hotspot permission (NEARBY_WIFI_DEVICES / ACCESS_FINE_LOCATION), requested before the + * share foreground service brings the hotspot up (it cannot prompt from a service). */ + private ActivityResultLauncher hotspotPerm; /** Set when a delete run is launched, so the manager reloads the list (and reclaimed space) when * it next resumes. Cleared once the reload runs and the run has settled. */ @@ -84,15 +97,20 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c exportButton = root.findViewById(R.id.k2go_cx_export); importButton = root.findViewById(R.id.k2go_cx_import); deleteButton = root.findViewById(R.id.k2go_cx_delete); + shareButton = root.findViewById(R.id.k2go_cx_share); openTree = registerForActivityResult(new ActivityResultContracts.OpenDocumentTree(), this::onTreePicked); openImportTree = registerForActivityResult( new ActivityResultContracts.OpenDocumentTree(), this::onImportTreePicked); + hotspotPerm = registerForActivityResult( + new ActivityResultContracts.RequestMultiplePermissions(), result -> onHotspotPermissionResult()); exportButton.setEnabled(true); exportButton.setOnClickListener(v -> onExportClicked()); importButton.setOnClickListener(v -> onImportClicked()); deleteButton.setEnabled(true); deleteButton.setOnClickListener(v -> onDeleteClicked()); + shareButton.setEnabled(true); + shareButton.setOnClickListener(v -> onShareClicked()); TextView back = root.findViewById(R.id.k2go_cx_back); back.setText(getString(R.string.k2go_back_link)); @@ -225,7 +243,7 @@ private String freeLine() { : getString(R.string.k2go_cx_free_unavailable); } - /** Updates the bottom action bar from the selection. Export and Delete act; Share is pending. */ + /** Updates the bottom action bar from the selection. Export, Share and Delete all act on it. */ private void renderSelection(SelectionSummary sel) { // Rebind rows so group headers (tri-state) and item checkboxes match the new selection. if (adapter != null) { @@ -379,6 +397,64 @@ private void showProgress() { .commit(); } + // ---- share --------------------------------------------------------------------------------- + + /** Share: if a run is still live, just show it; otherwise capture the selection and make sure the + * hotspot permission is granted (the service cannot prompt) before starting. */ + private void onShareClicked() { + if (ContentShareService.session().hasSession()) { + showShareProgress(); + return; + } + List selected = viewModel.selectedItems(); + if (selected.isEmpty()) { + return; + } + pendingShare = selected; + if (!LocalHotspotManager.isSupported()) { + String msg = getString(R.string.k2go_cx_share_hotspot_unsupported); + Snackbar.make(requireView(), msg, SnackbarDuration.millisForText(msg)).show(); + return; + } + if (HotspotPermissions.granted(requireContext())) { + startShare(pendingShare); + } else { + hotspotPerm.launch(HotspotPermissions.required()); + } + } + + /** The hotspot-permission request returned: start when granted, otherwise say why we cannot. */ + private void onHotspotPermissionResult() { + if (!isAdded()) { + return; + } + if (pendingShare != null && HotspotPermissions.granted(requireContext())) { + startShare(pendingShare); + } else { + pendingShare = null; // the request was declined; drop the held selection + String msg = getString(R.string.k2go_cx_share_need_permission); + Snackbar.make(requireView(), msg, SnackbarDuration.millisForText(msg)).show(); + } + } + + private void startShare(List selected) { + ItemRef[] refs = new ItemRef[selected.size()]; + for (int i = 0; i < selected.size(); i++) { + refs[i] = new ItemRef(selected.get(i).app(), selected.get(i).localRef()); + } + ContentShareService.start(requireContext(), refs); + pendingShare = null; + viewModel.clearSelection(); + showShareProgress(); + } + + private void showShareProgress() { + requireActivity().getSupportFragmentManager().beginTransaction() + .replace(R.id.k2go_setup_host, new ContentShareFragment()) + .addToBackStack(null) + .commit(); + } + // ---- delete -------------------------------------------------------------------------------- /** Delete: if a run is still active, just show it; otherwise confirm and start. diff --git a/controller/app/src/main/java/org/appdevforall/k2go/contentexchange/presentation/ContentShareFragment.java b/controller/app/src/main/java/org/appdevforall/k2go/contentexchange/presentation/ContentShareFragment.java new file mode 100644 index 000000000..3e4141f81 --- /dev/null +++ b/controller/app/src/main/java/org/appdevforall/k2go/contentexchange/presentation/ContentShareFragment.java @@ -0,0 +1,235 @@ +/* + * ============================================================================ + * Name : ContentShareFragment.java + * Author : AppDevForAll + * Copyright : Copyright (c) 2026 AppDevForAll + * Description : K2GO-376. Shows the Share invite: two stacked QR sections (join the hotspot, then + * scan to receive) once the sender foreground service has opened the share session and + * the hotspot is up. Reads state only (the ShareSession + the shared LocalHotspotManager + * state); all work is the service's. Done closes the gate + the hotspot via the service. + * ============================================================================ + */ +package org.appdevforall.k2go.contentexchange.presentation; + +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; + +import org.appdevforall.k2go.R; +import org.appdevforall.k2go.contentexchange.data.ShareHandshake; +import org.appdevforall.k2go.contentexchange.domain.ShareTransport; +import org.appdevforall.k2go.hotspot.HotspotPermissions; +import org.appdevforall.k2go.hotspot.LocalHotspotManager; +import org.appdevforall.k2go.redesign.QrSection; + +public class ContentShareFragment extends Fragment { + + private TextView subtitle; + private TextView status; + private TextView waiting; + private View joinRoot; + private View receiveRoot; + private QrSection secJoin; + private QrSection secReceive; + private Button done; + + private final ShareSession session = ContentShareService.session(); + private final LocalHotspotManager hs = LocalHotspotManager.get(); + + /** One-shot guard so a re-entrant render() does not schedule the back-pop twice (K2GO-427). */ + private boolean leaving; + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle s) { + View root = inflater.inflate(R.layout.fragment_k2go_content_share, container, false); + subtitle = root.findViewById(R.id.k2go_cxs_subtitle); + status = root.findViewById(R.id.k2go_cxs_status); + waiting = root.findViewById(R.id.k2go_cxs_waiting); + done = root.findViewById(R.id.k2go_cxs_done); + + joinRoot = root.findViewById(R.id.k2go_cxs_join); + receiveRoot = root.findViewById(R.id.k2go_cxs_receive); + secJoin = new QrSection(joinRoot, R.id.sec_qr_frame, R.id.sec_qr, R.id.sec_qr_ph, + R.id.sec_caption, R.id.sec_subcaption, R.id.sec_fallback_toggle, R.id.sec_fallback, R.id.sec_fallback_values); + secReceive = new QrSection(receiveRoot, R.id.sec_qr_frame, R.id.sec_qr, R.id.sec_qr_ph, + R.id.sec_caption, R.id.sec_subcaption, R.id.sec_fallback_toggle, R.id.sec_fallback, R.id.sec_fallback_values); + ((TextView) joinRoot.findViewById(R.id.sec_badge)).setText("1"); + ((TextView) joinRoot.findViewById(R.id.sec_title)).setText(R.string.k2go_cx_share_step_join); + ((TextView) receiveRoot.findViewById(R.id.sec_badge)).setText("2"); + ((TextView) receiveRoot.findViewById(R.id.sec_title)).setText(R.string.k2go_cx_share_step_receive); + + done.setOnClickListener(v -> { + ContentShareService.cancel(requireContext()); + goBack(); + }); + // The AP IPv4 lands a beat after the hotspot reports ON; re-render when it (or the phase) changes + // so the receive QR appears the moment every piece is ready. Observed once, tied to the view. + hs.state().observe(getViewLifecycleOwner(), st -> render()); + return root; + } + + @Override + public void onResume() { + super.onResume(); + // The token/manifest arrive from the service worker; re-render when the session state changes. + session.setListener(() -> { + if (isAdded()) { + render(); + } + }); + render(); + } + + @Override + public void onPause() { + super.onPause(); + session.setListener(null); + } + + @Override + public void onDestroyView() { + // Stop any "resolving..." ellipsis so its Handler cannot tick a destroyed view (K2GO-375). + if (secJoin != null) { + secJoin.stopPending(); + } + if (secReceive != null) { + secReceive.stopPending(); + } + leaving = false; + super.onDestroyView(); + } + + private void render() { + if (!isAdded() || secReceive == null) { + return; + } + // A cleared / absent session means the share was stopped: leave this screen. + if (!session.hasSession()) { + goBack(); + return; + } + subtitle.setText(getString(R.string.k2go_cx_share_subtitle_fmt, session.itemCount())); + + if (session.hasError()) { + showStatus(session.errorMessage() != null + ? session.errorMessage() : getString(R.string.k2go_cx_share_error)); + return; + } + if (session.isPreparing()) { + showStatus(getString(R.string.k2go_cx_share_preparing)); + return; + } + // READY: the gate exists; the QR needs the hotspot up and its address resolved. + renderInvite(); + } + + private void renderInvite() { + LocalHotspotManager.State st = hs.state().getValue(); + LocalHotspotManager.Phase phase = st != null ? st.phase : LocalHotspotManager.Phase.OFF; + + if (!LocalHotspotManager.isSupported()) { + showStatus(getString(R.string.k2go_cx_share_hotspot_unsupported)); + return; + } + if (phase == LocalHotspotManager.Phase.FAILED + || (phase != LocalHotspotManager.Phase.ON && !HotspotPermissions.granted(requireContext()))) { + showStatus(getString(R.string.k2go_cx_share_hotspot_failed)); + return; + } + + // OFF / STARTING / ON: show the two sections; only ON has scannable codes. + showSections(); + boolean on = phase == LocalHotspotManager.Phase.ON; + if (!on) { + String starting = getString(R.string.k2go_cx_share_starting_hotspot); + for (QrSection sec : new QrSection[]{secJoin, secReceive}) { + sec.setQrPending(requireContext(), starting); + sec.caption.setText(""); + sec.subCaption.setText(""); + sec.setFallback(requireContext(), null); + } + waiting.setVisibility(View.GONE); + return; + } + + String ssid = st != null && st.ssid != null ? st.ssid : ""; + String pass = st != null && st.passphrase != null ? st.passphrase : ""; + String ip = st != null ? st.hotspotIp : null; + + // Section 1: join the hotspot (standard Wi-Fi auto-join QR + the credentials as fallback). + secJoin.setQr(requireContext(), "WIFI:S:" + ssid + ";T:WPA;P:" + pass + ";;", null); + secJoin.caption.setText(R.string.k2go_cx_share_join_caption); + secJoin.subCaption.setText(""); + secJoin.setFallback(requireContext(), new String[]{ + getString(R.string.k2go_fallback_wifi, ssid), getString(R.string.k2go_fallback_pass, pass)}); + + // Section 2: the share invite QR. Needs the AP IPv4, which resolves a beat after ON. + secReceive.caption.setText(R.string.k2go_cx_share_receive_caption); + secReceive.subCaption.setText(""); + String invite = ip != null + ? ShareHandshake.encode(ip, ShareTransport.SHARE_PORT, session.token(), session.manifestUrl()) + : null; + if (invite == null) { + // No dead QR at a guessed address; hold the slot until the real AP IP arrives (render reruns). + secReceive.setQrPending(requireContext(), getString(R.string.k2go_cx_share_resolving_address)); + secReceive.setFallback(requireContext(), null); + waiting.setVisibility(View.GONE); + } else { + secReceive.setQr(requireContext(), invite, null); + secReceive.setFallback(requireContext(), null); + waiting.setVisibility(View.VISIBLE); + } + } + + /** Show a single status line (preparing / hotspot / error) instead of the two QR cards. */ + private void showStatus(String text) { + status.setText(text); + status.setVisibility(View.VISIBLE); + secJoin.stopPending(); + secReceive.stopPending(); + joinRoot.setVisibility(View.GONE); + receiveRoot.setVisibility(View.GONE); + waiting.setVisibility(View.GONE); + } + + private void showSections() { + status.setVisibility(View.GONE); + joinRoot.setVisibility(View.VISIBLE); + receiveRoot.setVisibility(View.VISIBLE); + } + + /** + * Leave this screen. Mirrors ExportProgressFragment: the pop is deferred to the next main-loop tick + * and re-checked there, so a render() driven from inside a FragmentManager transaction (the listener, + * or the service beginning the session on a later message) never calls popBackStack mid-transaction + * ("FragmentManager is already executing transactions", K2GO-427). + */ + private void goBack() { + if (leaving) { + return; + } + View v = getView(); + if (v == null) { + return; + } + leaving = true; + v.post(() -> { + if (!isAdded()) { + return; + } + if (session.hasSession()) { + leaving = false; // a run exists now: stay, and let a later leave (Done) reschedule + return; + } + getParentFragmentManager().popBackStack(); + }); + } +} diff --git a/controller/app/src/main/java/org/appdevforall/k2go/contentexchange/presentation/ContentShareService.java b/controller/app/src/main/java/org/appdevforall/k2go/contentexchange/presentation/ContentShareService.java new file mode 100644 index 000000000..11baee4d7 --- /dev/null +++ b/controller/app/src/main/java/org/appdevforall/k2go/contentexchange/presentation/ContentShareService.java @@ -0,0 +1,292 @@ +/* + * ============================================================================ + * Name : ContentShareService.java + * Author : AppDevForAll + * Copyright : Copyright (c) 2026 AppDevForAll + * Description : K2GO-376. Sender foreground service for granular wireless Share. Over the current + * content-manager selection it opens a scoped share session on the LOCAL cx-core + * (loopback), brings the hotspot up (only if it was off), holds both the hotspot and + * the gate open with CPU + Wi-Fi locks, and on teardown closes the gate (DELETE) and + * stops the hotspot only if it started it. It copies NO bytes: cx-core streams to the + * receiver that pulls over the hotspot. The invite/QR fragment observes SESSION. + * ============================================================================ + */ +package org.appdevforall.k2go.contentexchange.presentation; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.net.wifi.WifiManager; +import android.os.Build; +import android.os.IBinder; +import android.os.PowerManager; + +import androidx.annotation.Nullable; +import androidx.core.app.NotificationCompat; +import androidx.core.content.ContextCompat; + +import org.appdevforall.k2go.R; +import org.appdevforall.k2go.contentexchange.data.CxCoreDataSource; +import org.appdevforall.k2go.contentexchange.domain.ItemRef; +import org.appdevforall.k2go.hotspot.HotspotPermissions; +import org.appdevforall.k2go.hotspot.LocalHotspotManager; +import org.appdevforall.k2go.notifications.NotificationIds; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +public final class ContentShareService extends Service { + + public static final String ACTION_START = "org.appdevforall.k2go.CONTENT_SHARE_START"; + public static final String ACTION_CANCEL = "org.appdevforall.k2go.CONTENT_SHARE_CANCEL"; + + public static final String EXTRA_APPS = "apps"; + public static final String EXTRA_LOCAL_REFS = "localRefs"; + + private static final String CHANNEL_ID = "content_share_channel"; + private static final int NOTIFICATION_ID = NotificationIds.CONTENT_SHARE; + + /** The single run state the invite/QR fragment observes. */ + private static final ShareSession SESSION = new ShareSession(); + + static ShareSession session() { + return SESSION; + } + + private final ExecutorService worker = Executors.newSingleThreadExecutor(); + + // The open session on the local cx-core, held so onDestroy can close the gate. Set by the worker. + private volatile String sessionId; + private volatile String token; + // True only when THIS service started the hotspot, so teardown never yanks a hotspot that Connect + // or Clone brought up (LocalHotspotManager is a shared, process-bound singleton). + private volatile boolean ownedHotspot; + + private PowerManager.WakeLock wakeLock; + private WifiManager.WifiLock wifiLock; + + // ---- static control surface (the fragment calls these) ------------------------------------- + + public static void start(Context ctx, ItemRef[] refs) { + String[] apps = new String[refs.length]; + String[] locals = new String[refs.length]; + for (int i = 0; i < refs.length; i++) { + apps[i] = refs[i].app(); + locals[i] = refs[i].localRef(); + } + Intent i = new Intent(ctx, ContentShareService.class).setAction(ACTION_START) + .putExtra(EXTRA_APPS, apps) + .putExtra(EXTRA_LOCAL_REFS, locals); + ContextCompat.startForegroundService(ctx, i); + } + + /** Stop sharing (the user's "Done"/"Stop", and the notification action): closes the gate and the + * hotspot in onDestroy. */ + public static void cancel(Context ctx) { + ContextCompat.startForegroundService(ctx, + new Intent(ctx, ContentShareService.class).setAction(ACTION_CANCEL)); + } + + @Override + public void onCreate() { + super.onCreate(); + createChannel(); + } + + @Nullable + @Override + public IBinder onBind(Intent intent) { + return null; + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + String action = intent != null ? intent.getAction() : null; + + if (ACTION_CANCEL.equals(action)) { + // Satisfy the FGS start-foreground obligation (a fresh instance may handle this), then tear + // down. onDestroy closes the gate and stops the hotspot. + startForeground(NOTIFICATION_ID, buildNotification()); + SESSION.clear(); + stopForeground(true); + stopSelf(); + return START_NOT_STICKY; + } + + // A sticky restart after a process kill delivers a null intent. The hotspot is process-bound, so + // it died with the process and there is nothing to resume; do not re-hold locks for a dead share. + if (intent == null && !SESSION.hasSession()) { + stopSelf(); + return START_NOT_STICKY; + } + + if (SESSION.hasSession()) { + // A duplicate start while a share is live. This onStartCommand may come from + // startForegroundService, so honor the FGS start obligation before ignoring it; the + // fragment shows the running share. + startForeground(NOTIFICATION_ID, buildNotification()); + return START_STICKY; + } + + // START: a fresh share over the intent's selection. + final ItemRef[] refs = readRefs(intent); + if (refs.length == 0) { + stopSelf(); + return START_NOT_STICKY; + } + SESSION.beginPreparing(refs.length); + startForeground(NOTIFICATION_ID, buildNotification()); + acquireHardwareLocks(); + ensureHotspot(); + worker.execute(() -> openSession(refs)); + // START_STICKY (like CloneShareService): keep the process at foreground priority so the + // process-bound hotspot and the live stream survive backgrounding and Doze. + return START_STICKY; + } + + // ---- the run ------------------------------------------------------------------------------- + + /** Open the scoped share session on the local cx-core and publish the invite. Copies no bytes. */ + private void openSession(ItemRef[] refs) { + List items = new ArrayList<>(); + for (ItemRef ref : refs) { + items.add(ref); + } + try { + CxCoreDataSource.ShareCreated created = new CxCoreDataSource().postShareCreate(items); + if (created.sessionId.isEmpty() || created.token.isEmpty() || created.manifestUrl.isEmpty()) { + SESSION.fail(getString(R.string.k2go_cx_share_error)); + return; + } + // Store before publishing, so a teardown that races the publish still has the id to close. + this.sessionId = created.sessionId; + this.token = created.token; + SESSION.setReady(created.sessionId, created.token, created.manifestUrl); + } catch (Exception e) { + SESSION.fail(getString(R.string.k2go_cx_share_error)); + } + } + + /** + * Bring the hotspot up only if it is currently off. If Connect or Clone already has it up (ON or + * STARTING), this service does not own it and must not stop it on teardown. The runtime hotspot + * permission is gated by the fragment before start; without it the hotspot stays off and the + * fragment surfaces the failure from LocalHotspotManager's state. + */ + private void ensureHotspot() { + if (!LocalHotspotManager.isSupported()) { + return; + } + LocalHotspotManager hs = LocalHotspotManager.get(); + LocalHotspotManager.State st = hs.state().getValue(); + LocalHotspotManager.Phase phase = st != null ? st.phase : LocalHotspotManager.Phase.OFF; + boolean up = phase == LocalHotspotManager.Phase.ON || phase == LocalHotspotManager.Phase.STARTING; + if (!up && HotspotPermissions.granted(this)) { + ownedHotspot = true; + hs.start(getApplicationContext()); + } + } + + private void acquireHardwareLocks() { + PowerManager pm = (PowerManager) getSystemService(Context.POWER_SERVICE); + if (pm != null && wakeLock == null) { + wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "IIAB:ContentShareWakeLock"); + wakeLock.acquire(); + } + WifiManager wm = (WifiManager) getApplicationContext().getSystemService(Context.WIFI_SERVICE); + if (wm != null && wifiLock == null) { + wifiLock = wm.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "IIAB:ContentShareWifiLock"); + wifiLock.acquire(); + } + } + + private void releaseHardwareLocks() { + if (wakeLock != null && wakeLock.isHeld()) { + wakeLock.release(); + } + wakeLock = null; + if (wifiLock != null && wifiLock.isHeld()) { + wifiLock.release(); + } + wifiLock = null; + } + + @Override + public void onDestroy() { + releaseHardwareLocks(); + // Stop the hotspot only if this service started it (shared singleton: never yank Connect's). + if (ownedHotspot) { + LocalHotspotManager.get().stop(); + ownedHotspot = false; + } + // Close the gate (the app-side closer, ADR "Pillar Share"). Off the main thread; the process + // usually outlives this service (the app is still open). A rare race where the id is not stored + // yet, or a process kill, is covered by cx-core's safety cap and the receiver's complete. + final String id = sessionId; + final String tok = token; + if (id != null && tok != null) { + new Thread(() -> { + try { + new CxCoreDataSource().deleteShare(id, tok); + } catch (Exception ignored) { + // best-effort close; cx-core's safety cap reclaims an abandoned session + } + }, "content-share-close").start(); + } + worker.shutdownNow(); + super.onDestroy(); + } + + // ---- helpers ------------------------------------------------------------------------------- + + private static ItemRef[] readRefs(Intent intent) { + if (intent == null) { + return new ItemRef[0]; + } + String[] apps = intent.getStringArrayExtra(EXTRA_APPS); + String[] locals = intent.getStringArrayExtra(EXTRA_LOCAL_REFS); + if (apps == null || locals == null || apps.length != locals.length) { + return new ItemRef[0]; + } + ItemRef[] refs = new ItemRef[apps.length]; + for (int i = 0; i < apps.length; i++) { + refs[i] = new ItemRef(apps[i], locals[i]); + } + return refs; + } + + // ---- notification -------------------------------------------------------------------------- + + private Notification buildNotification() { + Intent cancel = new Intent(this, ContentShareService.class).setAction(ACTION_CANCEL); + PendingIntent cancelIntent = PendingIntent.getService(this, 1, cancel, + PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT); + return new NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle(getString(R.string.k2go_cx_share_notif_title)) + .setContentText(getString(R.string.k2go_cx_share_notif_text)) + .setSmallIcon(android.R.drawable.stat_sys_upload) + .setOngoing(true) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setOnlyAlertOnce(true) + .addAction(0, getString(R.string.k2go_cx_share_stop), cancelIntent) + .build(); + } + + private void createChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + NotificationChannel channel = new NotificationChannel( + CHANNEL_ID, getString(R.string.k2go_cx_share_channel_name), + NotificationManager.IMPORTANCE_LOW); + NotificationManager manager = getSystemService(NotificationManager.class); + if (manager != null) { + manager.createNotificationChannel(channel); + } + } + } +} diff --git a/controller/app/src/main/java/org/appdevforall/k2go/contentexchange/presentation/ShareSession.java b/controller/app/src/main/java/org/appdevforall/k2go/contentexchange/presentation/ShareSession.java new file mode 100644 index 000000000..d4b62ad43 --- /dev/null +++ b/controller/app/src/main/java/org/appdevforall/k2go/contentexchange/presentation/ShareSession.java @@ -0,0 +1,128 @@ +/* + * ============================================================================ + * Name : ShareSession.java + * Author : AppDevForAll + * Copyright : Copyright (c) 2026 AppDevForAll + * Description : K2GO-376. The one share-run state the invite/QR fragment observes and the sender + * foreground service mutates. Encapsulated (no public mutable fields), mirroring + * ExportSession: the service owns the single instance and the fragment reads it + * through getters. Holds the invite (session id + token + manifest path) the QR needs. + * ============================================================================ + */ +package org.appdevforall.k2go.contentexchange.presentation; + +import android.os.Handler; +import android.os.Looper; + +final class ShareSession { + + /** Pinged on the main thread whenever the run state changes. */ + interface Listener { + void onUpdate(); + } + + /** PREPARING: creating the session on the local cx-core. READY: the invite exists; show the QR. */ + enum Phase { PREPARING, READY, ERROR } + + private final Handler main = new Handler(Looper.getMainLooper()); + + private Listener listener; + + private volatile boolean active = false; // a share run exists (set until clear) + private volatile Phase phase = Phase.PREPARING; + private int itemCount; + private String sessionId; + private String token; + private String manifestUrl; + private String errorMessage; + + void setListener(Listener l) { + this.listener = l; + } + + // ---- service-side mutation ----------------------------------------------------------------- + + /** Start a run: the item count is fixed now; the invite arrives once cx-core opens the session. */ + void beginPreparing(int itemCount) { + this.itemCount = itemCount; + this.sessionId = null; + this.token = null; + this.manifestUrl = null; + this.errorMessage = null; + this.phase = Phase.PREPARING; + this.active = true; + publish(); + } + + /** The local cx-core opened the session: the invite is ready to render. */ + void setReady(String sessionId, String token, String manifestUrl) { + this.sessionId = sessionId; + this.token = token; + this.manifestUrl = manifestUrl; + this.phase = Phase.READY; + publish(); + } + + void fail(String message) { + this.errorMessage = message; + this.phase = Phase.ERROR; + publish(); + } + + void clear() { + active = false; + phase = Phase.PREPARING; + itemCount = 0; + sessionId = null; + token = null; + manifestUrl = null; + errorMessage = null; + publish(); + } + + void publish() { + main.post(() -> { + if (listener != null) { + listener.onUpdate(); + } + }); + } + + // ---- UI-side reads ------------------------------------------------------------------------- + + boolean hasSession() { + return active; + } + + boolean isPreparing() { + return active && phase == Phase.PREPARING; + } + + boolean isReady() { + return active && phase == Phase.READY; + } + + boolean hasError() { + return active && phase == Phase.ERROR; + } + + int itemCount() { + return itemCount; + } + + String sessionId() { + return sessionId; + } + + String token() { + return token; + } + + String manifestUrl() { + return manifestUrl; + } + + String errorMessage() { + return errorMessage; + } +} diff --git a/controller/app/src/main/java/org/appdevforall/k2go/notifications/NotificationIds.java b/controller/app/src/main/java/org/appdevforall/k2go/notifications/NotificationIds.java index a2fadb7b1..0acede28f 100644 --- a/controller/app/src/main/java/org/appdevforall/k2go/notifications/NotificationIds.java +++ b/controller/app/src/main/java/org/appdevforall/k2go/notifications/NotificationIds.java @@ -40,6 +40,7 @@ private NotificationIds() {} public static final int CONTENT_EXPORT = 15; // ContentExportService (K2GO-427) public static final int CONTENT_IMPORT = 16; // ContentImportService (K2GO-427) public static final int CONTENT_DELETE = 17; // ContentDeleteService (K2GO-427) + public static final int CONTENT_SHARE = 18; // ContentShareService (K2GO-376) // High range: single-purpose notifications that re-alert in place (they replace, never stack), // kept well away from the low range. Listed here too so the one source stays complete. diff --git a/controller/app/src/main/res/layout/fragment_k2go_content_manager.xml b/controller/app/src/main/res/layout/fragment_k2go_content_manager.xml index 7ee1174d8..54464ac3e 100644 --- a/controller/app/src/main/res/layout/fragment_k2go_content_manager.xml +++ b/controller/app/src/main/res/layout/fragment_k2go_content_manager.xml @@ -2,8 +2,8 @@ + the same list. The bottom action bar (Export / Share / Delete) acts on the selection; the + buttons are enabled by the fragment. --> - + - - diff --git a/controller/app/src/main/res/layout/fragment_k2go_content_share.xml b/controller/app/src/main/res/layout/fragment_k2go_content_share.xml new file mode 100644 index 000000000..f6b144dd5 --- /dev/null +++ b/controller/app/src/main/res/layout/fragment_k2go_content_share.xml @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + +