Skip to content

Commit 6b93cde

Browse files
Merge pull request #610 from appdevforall/fix/K2GO-439-hotspot-nearby-wifi
K2GO-439 fix(hotspot): request NEARBY_WIFI_DEVICES for LocalOnlyHotspot on targetSdk 35
2 parents dade552 + 9a81cbb commit 6b93cde

40 files changed

Lines changed: 173 additions & 65 deletions

File tree

‎controller/app/src/main/AndroidManifest.xml‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,15 @@
66
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
77
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
88
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
9-
<!-- ADFA-4520: LocalOnlyHotspot requires location at runtime on this targetSdk -->
10-
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
11-
<!-- K2GO-438: Android 12+ lets the user grant COARSE only, so declare it with FINE (CoarseFineLocation). -->
12-
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
9+
<!-- ADFA-4520 / K2GO-439: location gated LocalOnlyHotspot only on API 32 and below; API 33+ uses
10+
NEARBY_WIFI_DEVICES below, so scope FINE/COARSE (the hotspot is their only consumer) to API 32. -->
11+
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="32" />
12+
<!-- K2GO-438: Android 12 and below let the user grant COARSE only, so declare it with FINE (CoarseFineLocation). -->
13+
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" android:maxSdkVersion="32" />
14+
<!-- K2GO-439: targetSdk 33+ needs NEARBY_WIFI_DEVICES for startLocalOnlyHotspot (FINE only sufficed on 32 and below).
15+
neverForLocation: the hotspot does not derive location from Wi-Fi scans. -->
16+
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES"
17+
android:usesPermissionFlags="neverForLocation" />
1318
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
1419
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
1520

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/*
2+
* ============================================================================
3+
* Name : HotspotPermissions.java
4+
* Author : AppDevForAll
5+
* Copyright : Copyright (c) 2026 AppDevForAll
6+
* Description : Single source for the runtime permission a LocalOnlyHotspot needs
7+
* on this OS (K2GO-439). From API 33 (TIRAMISU)
8+
* WifiManager.startLocalOnlyHotspot requires NEARBY_WIFI_DEVICES;
9+
* API 32 and below need ACCESS_FINE_LOCATION. The Connect and Clone
10+
* screens read this instead of each hardcoding the location gate.
11+
* See the startLocalOnlyHotspot javadoc.
12+
* ============================================================================
13+
*/
14+
package org.appdevforall.k2go.hotspot;
15+
16+
import android.Manifest;
17+
import android.content.Context;
18+
import android.content.pm.PackageManager;
19+
import android.os.Build;
20+
21+
import androidx.core.content.ContextCompat;
22+
23+
/** Stateless permission rule for the LocalOnlyHotspot, shared by the Connect and Clone screens. */
24+
public final class HotspotPermissions {
25+
26+
private HotspotPermissions() {}
27+
28+
/** The permission(s) startLocalOnlyHotspot requires on this device's API level. */
29+
public static String[] required() {
30+
return requiredFor(Build.VERSION.SDK_INT);
31+
}
32+
33+
/**
34+
* The rule as a pure function of the API level. Package-private for unit testing.
35+
* Keyed off the device API: startLocalOnlyHotspot bases its requirement on the app
36+
* targetSdk, and keying off the device API matches only because the app targets >= 33.
37+
*/
38+
static String[] requiredFor(int sdkInt) {
39+
if (sdkInt >= Build.VERSION_CODES.TIRAMISU) {
40+
return new String[]{ Manifest.permission.NEARBY_WIFI_DEVICES };
41+
}
42+
return new String[]{ Manifest.permission.ACCESS_FINE_LOCATION };
43+
}
44+
45+
/** True when every required permission is granted. */
46+
public static boolean granted(Context ctx) {
47+
for (String p : required()) {
48+
if (ContextCompat.checkSelfPermission(ctx, p) != PackageManager.PERMISSION_GRANTED) {
49+
return false;
50+
}
51+
}
52+
return true;
53+
}
54+
}

‎controller/app/src/main/java/org/appdevforall/k2go/hotspot/LocalHotspotManager.java‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@
88
* the carrier tethering-entitlement check, so it works with no SIM and
99
* no data plan. The reservation is process-bound: it stays up while this
1010
* app process is alive and is torn down on close() or process death.
11-
* API 26+ only; callers must gate on Build.VERSION and hold
12-
* CHANGE_WIFI_STATE + ACCESS_FINE_LOCATION (with Location services on).
11+
* API 26+ only; callers must gate on Build.VERSION and hold CHANGE_WIFI_STATE
12+
* plus the hotspot permission from HotspotPermissions (NEARBY_WIFI_DEVICES on
13+
* API 33+, or ACCESS_FINE_LOCATION with Location services on 32 and below).
1314
*
1415
* V1 scope (ADFA-4520): manual opt-in from Advanced settings, plus a
1516
* contextual recommendation surfaced only when BOTH conditions hold
@@ -110,13 +111,15 @@ public boolean isOn() {
110111
}
111112

112113
/**
113-
* Starts a LocalOnlyHotspot. Caller must have already granted CHANGE_WIFI_STATE +
114-
* ACCESS_FINE_LOCATION and enabled Location services; otherwise onFailed fires.
114+
* Starts a LocalOnlyHotspot. Caller must have already granted CHANGE_WIFI_STATE and the
115+
* hotspot permission (see HotspotPermissions); on API 32 and below that also needs Location
116+
* services on. Otherwise onFailed fires.
115117
*/
116118
// NewApi is suppressed because every API-26 reference below is protected by the
117119
// explicit Build.VERSION.SDK_INT guard (lint cannot see through isSupported(), and
118120
// @RequiresApi does not cover the anonymous callback class). MissingPermission is
119-
// suppressed because CHANGE_WIFI_STATE + ACCESS_FINE_LOCATION are requested at runtime.
121+
// suppressed because CHANGE_WIFI_STATE and the hotspot permission (HotspotPermissions) are
122+
// requested at runtime.
120123
@SuppressLint({"MissingPermission", "NewApi"})
121124
public void start(Context context) {
122125
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {

‎controller/app/src/main/java/org/appdevforall/k2go/redesign/CloneFragment.java‎

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
11
package org.appdevforall.k2go.redesign;
22

3-
import android.Manifest;
43
import android.content.ClipData;
54
import android.content.ClipboardManager;
65
import android.content.Context;
76
import android.content.Intent;
8-
import android.content.pm.PackageManager;
97
import android.graphics.Color;
108
import android.graphics.drawable.GradientDrawable;
119
import android.net.Uri;
@@ -42,6 +40,7 @@
4240
import org.appdevforall.k2go.SyncHandshakeHelper;
4341
import org.appdevforall.k2go.env.EnvironmentLock;
4442
import org.appdevforall.k2go.sync.domain.ApkShareName;
43+
import org.appdevforall.k2go.hotspot.HotspotPermissions;
4544
import org.appdevforall.k2go.hotspot.LocalHotspotManager;
4645
import org.appdevforall.k2go.sync.domain.ShareConfig;
4746
import org.appdevforall.k2go.sync.presentation.SyncProgressRepository;
@@ -109,7 +108,7 @@ private enum Page { PREPARE, COPY }
109108
private ApkServer apkServer;
110109
private String apkFileName;
111110

112-
private ActivityResultLauncher<String> locationPerm;
111+
private ActivityResultLauncher<String[]> hotspotPerm;
113112
// ADFA-5146: request the location permission at most once per attempt. ensureHotspot() runs on every
114113
// render, and the permission callback re-renders; on OEMs that return a denied permission result
115114
// synchronously that becomes unbounded recursion (render -> ensureHotspot -> launch -> sync-deny ->
@@ -170,9 +169,14 @@ private enum RStage { JOIN, START }
170169
@Override
171170
public void onCreate(@Nullable Bundle s) {
172171
super.onCreate(s);
173-
locationPerm = registerForActivityResult(
174-
new ActivityResultContracts.RequestPermission(),
175-
granted -> { if (granted) hs.start(requireContext().getApplicationContext()); render(); });
172+
hotspotPerm = registerForActivityResult(
173+
new ActivityResultContracts.RequestMultiplePermissions(),
174+
result -> {
175+
if (HotspotPermissions.granted(requireContext())) {
176+
hs.start(requireContext().getApplicationContext());
177+
}
178+
render();
179+
});
176180
barcodeLauncher = registerForActivityResult(new ScanContract(), r -> onScan(r.getContents()));
177181
}
178182

@@ -445,14 +449,13 @@ private void ensureHotspot() {
445449
// (the "Caller already has an active LocalOnlyHotspot request" log spam).
446450
LocalHotspotManager.State st = hs.state().getValue();
447451
if (st != null && st.phase == LocalHotspotManager.Phase.STARTING) return;
448-
if (ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.ACCESS_FINE_LOCATION)
449-
== PackageManager.PERMISSION_GRANTED) {
452+
if (HotspotPermissions.granted(requireContext())) {
450453
hs.start(requireContext().getApplicationContext());
451454
} else if (!locationAsked) {
452455
// ADFA-5146: launch the request exactly once. Do NOT re-launch on later renders (incl. the one
453-
// the permission callback triggers) — a synchronous deny would otherwise recurse into overflow.
456+
// the permission callback triggers): a synchronous deny would otherwise recurse into overflow.
454457
locationAsked = true;
455-
locationPerm.launch(Manifest.permission.ACCESS_FINE_LOCATION);
458+
hotspotPerm.launch(HotspotPermissions.required());
456459
}
457460
}
458461

@@ -727,7 +730,10 @@ private void renderPrepare() {
727730
secJoin.setFallback(requireContext(), null);
728731
secJoin.caption.setText(getString(R.string.k2go_connect_hotspot_unsupported));
729732
secJoin.subCaption.setText(getString(R.string.k2go_connect_try_wifi));
730-
} else if (phase == LocalHotspotManager.Phase.FAILED) {
733+
} else if (phase == LocalHotspotManager.Phase.FAILED
734+
|| (phase != LocalHotspotManager.Phase.ON && !HotspotPermissions.granted(requireContext()))) {
735+
// FAILED, or the hotspot permission is not granted (denied): show the hint instead of a
736+
// perpetual "starting" placeholder (K2GO-439).
731737
secJoin.frame.setVisibility(View.GONE);
732738
secJoin.setFallback(requireContext(), null);
733739
secJoin.caption.setText(getString(R.string.k2go_connect_hotspot_failed));

‎controller/app/src/main/java/org/appdevforall/k2go/redesign/ConnectFragment.java‎

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
package org.appdevforall.k2go.redesign;
22

3-
import android.Manifest;
43
import android.content.Context;
5-
import android.content.pm.PackageManager;
64
import android.graphics.drawable.GradientDrawable;
75
import android.os.Bundle;
86
import android.view.Gravity;
@@ -19,6 +17,7 @@
1917
import androidx.core.content.ContextCompat;
2018
import androidx.fragment.app.Fragment;
2119
import org.appdevforall.k2go.R;
20+
import org.appdevforall.k2go.hotspot.HotspotPermissions;
2221
import org.appdevforall.k2go.hotspot.LocalHotspotManager;
2322
import org.appdevforall.k2go.sync.transport.NetworkInterfaces;
2423
import org.appdevforall.k2go.sync.transport.NetworkStateLiveData;
@@ -51,7 +50,7 @@ private enum Mode { HOTSPOT, WIFI }
5150
// the QR would point at a dead port. Read on onResume (a system is not gained while this is open).
5251

5352
private final LocalHotspotManager hs = LocalHotspotManager.get();
54-
private ActivityResultLauncher<String> locationPerm;
53+
private ActivityResultLauncher<String[]> hotspotPerm;
5554

5655
private TextView tabHotspot, tabWifi, finish, connFooter;
5756
// ADFA-5346: advance CTA is a MaterialButton (shape/size from the shared style); styleAdvance only
@@ -63,10 +62,12 @@ private enum Mode { HOTSPOT, WIFI }
6362
@Override
6463
public void onCreate(@Nullable Bundle s) {
6564
super.onCreate(s);
66-
locationPerm = registerForActivityResult(
67-
new ActivityResultContracts.RequestPermission(),
68-
granted -> {
69-
if (granted) hs.start(requireContext().getApplicationContext());
65+
hotspotPerm = registerForActivityResult(
66+
new ActivityResultContracts.RequestMultiplePermissions(),
67+
result -> {
68+
if (HotspotPermissions.granted(requireContext())) {
69+
hs.start(requireContext().getApplicationContext());
70+
}
7071
render();
7172
});
7273
}
@@ -126,11 +127,10 @@ private void setMode(Mode m) {
126127

127128
private void ensureHotspot() {
128129
if (!LocalHotspotManager.isSupported() || hs.isOn()) return;
129-
if (ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.ACCESS_FINE_LOCATION)
130-
== PackageManager.PERMISSION_GRANTED) {
130+
if (HotspotPermissions.granted(requireContext())) {
131131
hs.start(requireContext().getApplicationContext());
132132
} else {
133-
locationPerm.launch(Manifest.permission.ACCESS_FINE_LOCATION);
133+
hotspotPerm.launch(HotspotPermissions.required());
134134
}
135135
}
136136

@@ -141,6 +141,12 @@ public void onResume() {
141141
super.onResume();
142142
// ADFA-5150/5312: redraw on the way to the front — a system may have been recovered, or an
143143
// install may have finished, while the user was away. render() re-reads the shared verdict.
144+
// K2GO-439: if the hotspot permission was granted while away (e.g. in Settings), start it now so
145+
// the user is not stuck on "Starting...". Only when already granted: do NOT re-launch the request
146+
// here, which would re-prompt on every resume (ensureHotspot has no one-shot guard in Connect).
147+
if (mode == Mode.HOTSPOT && HotspotPermissions.granted(requireContext())) {
148+
ensureHotspot();
149+
}
144150
render();
145151
}
146152

@@ -216,7 +222,10 @@ private void renderHotspot() {
216222
singleStatus(getString(R.string.k2go_connect_hotspot_unsupported), getString(R.string.k2go_connect_try_wifi));
217223
return;
218224
}
219-
if (phase == LocalHotspotManager.Phase.FAILED) {
225+
// FAILED, or the hotspot permission is not granted (denied): show the hint instead of a
226+
// perpetual "starting" placeholder (K2GO-439).
227+
if (phase == LocalHotspotManager.Phase.FAILED
228+
|| (phase != LocalHotspotManager.Phase.ON && !HotspotPermissions.granted(requireContext()))) {
220229
singleStatus(getString(R.string.k2go_connect_hotspot_failed), getString(R.string.k2go_connect_enable_location));
221230
return;
222231
}

‎controller/app/src/main/res/values-ar/strings.xml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -799,7 +799,7 @@
799799
<string name="k2go_connect_starting_hotspot">جارٍ بدء نقطة الاتصال…</string>
800800
<string name="k2go_connect_resolving_address">جارٍ العثور على عنوان نقطة الاتصال…</string>
801801
<string name="k2go_connect_hotspot_failed">تعذّر بدء نقطة الاتصال</string>
802-
<string name="k2go_connect_enable_location">فعّل إذن الموقع، ثم أعد المحاولة.</string>
802+
<string name="k2go_connect_enable_location">فعّل Wi-Fi وامنح الإذن، ثم حاول مرة أخرى.</string>
803803
<string name="k2go_connect_no_wifi">لست على شبكة Wi-Fi</string>
804804
<string name="k2go_connect_join_wifi">انضم إلى نفس شبكة Wi-Fi أو اتصل بنقطة اتصال المكتبة.</string>
805805
<string name="k2go_fallback_wifi">Wi-Fi: %1$s</string>

‎controller/app/src/main/res/values-az/strings.xml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -819,7 +819,7 @@
819819
<string name="k2go_connect_starting_hotspot">Hotspot başladılır…</string>
820820
<string name="k2go_connect_resolving_address">Hotspot ünvanı tapılır…</string>
821821
<string name="k2go_connect_hotspot_failed">Hotspot başladıla bilmədi</string>
822-
<string name="k2go_connect_enable_location">Məkan icazəsini aktiv edin və yenidən cəhd edin.</string>
822+
<string name="k2go_connect_enable_location">Wi-Fi-ni yandırın və icazəni verin, sonra yenidən cəhd edin.</string>
823823
<string name="k2go_connect_no_wifi">Wi-Fi şəbəkəsində deyil</string>
824824
<string name="k2go_connect_join_wifi">Eyni Wi-Fi-yə və ya kitabxananın hotspotuna qoşulun.</string>
825825
<string name="k2go_fallback_wifi">Wi-Fi: %1$s</string>

‎controller/app/src/main/res/values-bg/strings.xml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -806,7 +806,7 @@
806806
<string name="k2go_connect_starting_hotspot">Стартиране на точката за достъп…</string>
807807
<string name="k2go_connect_resolving_address">Търсене на адрес на точката за достъп…</string>
808808
<string name="k2go_connect_hotspot_failed">Неуспешно стартиране на точката за достъп</string>
809-
<string name="k2go_connect_enable_location">Включете разрешението за местоположение и опитайте отново.</string>
809+
<string name="k2go_connect_enable_location">Включете Wi-Fi и дайте разрешението, след което опитайте отново.</string>
810810
<string name="k2go_connect_no_wifi">Не сте в Wi-Fi мрежа</string>
811811
<string name="k2go_connect_join_wifi">Свържете се със същата Wi-Fi или с точката за достъп на библиотеката.</string>
812812
<string name="k2go_fallback_wifi">Wi-Fi: %1$s</string>

‎controller/app/src/main/res/values-bn/strings.xml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -812,7 +812,7 @@
812812
<string name="k2go_connect_starting_hotspot">হটস্পট শুরু হচ্ছে…</string>
813813
<string name="k2go_connect_resolving_address">হটস্পট ঠিকানা খোঁজা হচ্ছে…</string>
814814
<string name="k2go_connect_hotspot_failed">হটস্পট শুরু করা যায়নি</string>
815-
<string name="k2go_connect_enable_location">অবস্থান অনুমতি চালু করুন, তারপর আবার চেষ্টা করুন।</string>
815+
<string name="k2go_connect_enable_location">Wi-Fi চালু করুন এবং অনুমতি দিন, তারপর আবার চেষ্টা করুন।</string>
816816
<string name="k2go_connect_no_wifi">কোনো Wi-Fi নেটওয়ার্কে নেই</string>
817817
<string name="k2go_connect_join_wifi">একই Wi-Fi-তে বা লাইব্রেরির হটস্পটে যুক্ত হন।</string>
818818
<string name="k2go_fallback_wifi">Wi-Fi: %1$s</string>

‎controller/app/src/main/res/values-cs/strings.xml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -806,7 +806,7 @@
806806
<string name="k2go_connect_starting_hotspot">Spouštění hotspotu…</string>
807807
<string name="k2go_connect_resolving_address">Hledání adresy hotspotu…</string>
808808
<string name="k2go_connect_hotspot_failed">Nepodařilo se spustit hotspot</string>
809-
<string name="k2go_connect_enable_location">Povol oprávnění k poloze a zkus to znovu.</string>
809+
<string name="k2go_connect_enable_location">Zapněte Wi-Fi a udělte oprávnění, poté to zkuste znovu.</string>
810810
<string name="k2go_connect_no_wifi">Nepřipojeno k síti Wi-Fi</string>
811811
<string name="k2go_connect_join_wifi">Připoj se ke stejné Wi-Fi nebo k hotspotu knihovny.</string>
812812
<string name="k2go_fallback_wifi">Wi-Fi: %1$s</string>

0 commit comments

Comments
 (0)