Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,11 @@
android:name=".MyDevicesListActivity"
android:exported="false"
android:theme="@style/Theme.OpenTagViewer.GenericGreyishActivity" />
<activity
android:name=".NearbyDevicesActivity"
android:exported="false"
android:label="@string/nearby_devices_title"
android:theme="@style/Theme.OpenTagViewer.GenericGreyishActivity" />
<activity
android:name=".FetchFromICloudActivity"
android:exported="false"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -673,6 +673,10 @@ private void showPageMenu() {
this.openHistoryZipLauncher.launch(new String[]{"application/zip"});
return true;
}
if (id == R.id.action_scan_nearby) {
this.startActivity(new Intent(this, NearbyDevicesActivity.class));
return true;
}
return false;
});

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
package dev.wander.android.opentagviewer;

import static android.view.View.inflate;
import static android.view.View.GONE;
import static android.view.View.VISIBLE;

import static dev.wander.android.opentagviewer.util.android.TextChangedWatcherFactory.justWatchOnChanged;

import android.os.Bundle;
import android.util.Log;
import android.view.View;
import android.widget.Button;
import android.widget.TextView;

import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import com.google.android.material.textfield.TextInputEditText;

import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.app.ActivityCompat;
import androidx.databinding.DataBindingUtil;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;

import java.util.List;
import java.util.Objects;
import java.util.concurrent.TimeUnit;

import dev.wander.android.opentagviewer.ble.BlePermissions;
import dev.wander.android.opentagviewer.ble.ForeignTagNames;
import dev.wander.android.opentagviewer.ble.NearbyDeviceBook;
import dev.wander.android.opentagviewer.ble.NearbyDeviceScanner;
import dev.wander.android.opentagviewer.ble.NearbyDeviceSighting;
import dev.wander.android.opentagviewer.databinding.ActivityNearbyDevicesBinding;
import dev.wander.android.opentagviewer.ui.compat.WindowPaddingUtil;
import dev.wander.android.opentagviewer.ui.nearbydevices.NearbyDeviceListAdaptor;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Observable;
import io.reactivex.rxjava3.disposables.Disposable;
import io.reactivex.rxjava3.schedulers.Schedulers;

/**
* Every Find My device the radio can hear, not only the user's own tags.
*
* <p><b>The screen {@link NearbyDeviceScanner} exists for.</b> It subscribes while it is on top
* - the radio listens only while somebody is looking, which is what keeps a display feature
* from being a recording - and shows one row per key prefix through {@link NearbyDeviceBook},
* named via {@link ForeignTagNames} when the user has renamed it.
*
* <p>The list is refreshed on two triggers, and needs both. New sightings arrive from the scan
* subscription, but the other half of a live list is *departures*: a tag that leaves range
* sends nothing, so a periodic pass rebuilds the snapshot for the pruning and the "last seen"
* age even while the air is quiet.
*/
public class NearbyDevicesActivity extends AppCompatActivity {
private static final String TAG = NearbyDevicesActivity.class.getSimpleName();

private static final int NEARBY_PERMISSION_REQUEST_CODE = 0x4E42;

/**
* How often the list is rebuilt while open, independent of arriving frames. One second is
* what makes the "last seen" column read like a clock rather than a frozen snapshot, and it
* costs a sort over a handful of rows.
*/
private static final long REFRESH_INTERVAL_MS = TimeUnit.SECONDS.toMillis(1);

/** Resubscribe interval after the scan ended mid-session; mirrors MapsActivity's choice. */
private static final long SCAN_RETRY_DELAY_MS = TimeUnit.SECONDS.toMillis(30);

private final NearbyDeviceScanner scanner = new NearbyDeviceScanner();
private final NearbyDeviceBook book = new NearbyDeviceBook();
private ForeignTagNames names;

private ActivityNearbyDevicesBinding binding;
private NearbyDeviceListAdaptor adaptor;

private Disposable scanSubscription;
private Disposable refreshSubscription;

/**
* The pending resubscribe after the scan ended mid-session - Bluetooth toggled off or a
* failed scan both arrive as a quiet {@code onComplete}, so without this the screen keeps
* claiming "nothing in range" forever while showing a radio nobody listens on.
*/
private Disposable scanRetrySubscription;

/**
* True between the scan completing mid-session and a resubscribe that stayed alive. The
* empty state reads from it: a dead radio needs "turn Bluetooth on", not "no devices".
*/
private boolean scanUnavailable;

/** Asked at most once per screen, for the same reason MapsActivity bounds its own request. */
private boolean blePermissionRequested = false;

@Override
protected void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);

this.names = new ForeignTagNames(this);

this.binding = DataBindingUtil.setContentView(this, R.layout.activity_nearby_devices);
WindowPaddingUtil.insetForSystemBars(this.binding.getRoot());
this.binding.setHandleClickBack(this::finish);

if (this.getSupportActionBar() != null) {
this.getSupportActionBar().hide();
}

this.adaptor = new NearbyDeviceListAdaptor(
this.getResources(),
this.names::nameFor,
this::onDeviceClicked,
this::onDeviceLongPressed);

final RecyclerView list = findViewById(R.id.nearby_devices_list);
list.setLayoutManager(new LinearLayoutManager(this));
list.setAdapter(this.adaptor);
}

@Override
protected void onResume() {
super.onResume();

if (!BlePermissions.granted(this)) {
if (!this.blePermissionRequested) {
this.blePermissionRequested = true;
Log.d(TAG, "Requesting BLE permission(s) to scan nearby devices");
ActivityCompat.requestPermissions(
this, BlePermissions.required(), NEARBY_PERMISSION_REQUEST_CODE);
}
this.showEmptyState(R.string.nearby_devices_needs_permission);
return;
}

this.startScanning();

this.refreshSubscription = Observable
.interval(REFRESH_INTERVAL_MS, TimeUnit.MILLISECONDS, Schedulers.computation())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(tick -> this.rebuildList(),
error -> Log.e(TAG, "Nearby device refresh failed", error));
}

@Override
protected void onPause() {
if (this.scanSubscription != null) {
this.scanSubscription.dispose();
this.scanSubscription = null;
}
if (this.refreshSubscription != null) {
this.refreshSubscription.dispose();
this.refreshSubscription = null;
}
if (this.scanRetrySubscription != null) {
this.scanRetrySubscription.dispose();
this.scanRetrySubscription = null;
}
super.onPause();
}

@Override
public void onRequestPermissionsResult(
final int requestCode,
@NonNull final String[] permissions,
@NonNull final int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
if (requestCode != NEARBY_PERMISSION_REQUEST_CODE) {
return;
}
// The permission dialog pauses the activity, so the framework's own onResume after it
// starts the scan. Calling onResume here would subscribe twice - onPause then disposes
// only the second pair and leaves a scan running on a closed screen.
if (!BlePermissions.granted(this)) {
this.showEmptyState(R.string.nearby_devices_needs_permission);
}
}

private void startScanning() {
this.scanUnavailable = false;
this.scanSubscription = this.scanner.scan(this.getApplicationContext())
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(this::onSighting,
error -> Log.e(TAG, "Nearby device scan failed", error),
this::onScanEnded);
}

/**
* The scan completed while the screen was open: Bluetooth went off, or the platform
* refused the scan. Retry like {@code MapsActivity#onNearbyWatchEnded} - one attempt per
* 30 seconds stays far under the scan-start budget and self-heals when the radio returns.
*/
private void onScanEnded() {
this.scanSubscription = null;
this.scanUnavailable = true;
this.rebuildList();

Log.i(TAG, "The nearby device scan ended mid-session; retrying in 30s");
this.scanRetrySubscription = Observable
.timer(SCAN_RETRY_DELAY_MS, TimeUnit.MILLISECONDS,
AndroidSchedulers.mainThread())
.subscribe(tick -> {
if (BlePermissions.granted(this)) {
this.startScanning();
}
});
}

private void onSighting(final NearbyDeviceSighting sighting) {
this.book.hear(sighting);
this.rebuildList();
}

private void rebuildList() {
final List<NearbyDeviceSighting> rows =
this.book.snapshot(System.currentTimeMillis());

if (rows.isEmpty()) {
this.showEmptyState(this.scanUnavailable
? R.string.nearby_devices_scan_unavailable
: R.string.nearby_devices_empty);
return;
}

this.binding.nearbyDevicesList.setVisibility(VISIBLE);
this.binding.nearbyDevicesEmptyState.setVisibility(GONE);
this.adaptor.setRows(rows);
this.adaptor.notifyDataSetChanged();
}

private void showEmptyState(final int textRes) {
this.binding.nearbyDevicesList.setVisibility(GONE);
this.binding.nearbyDevicesEmptyState.setVisibility(VISIBLE);
this.binding.nearbyDevicesEmptyText.setText(textRes);
}

/** Reserved for the radar branch: clicking a row will open a pointing view for this key. */
private void onDeviceClicked(final NearbyDeviceSighting sighting) {
Log.d(TAG, "Clicked nearby device " + sighting.getKeyPrefix());
}

private void onDeviceLongPressed(final View anchor, final NearbyDeviceSighting sighting) {
this.showRenameDialog(sighting);
}

/**
* The same name dialog the tag page uses, pointed at a prefix instead of a naming record:
* a name for a foreign tag lives only in this app, on the only identity it broadcasts.
*/
private void showRenameDialog(final NearbyDeviceSighting sighting) {
final View view = inflate(this, R.layout.edit_device_name_dialog, null);
final TextInputEditText textInput = view.findViewById(R.id.device_name_input);
final String existing = this.names.nameFor(sighting.getKeyPrefix());
if (existing != null) {
textInput.setText(existing);
}

final AlertDialog dialog = new MaterialAlertDialogBuilder(this)
.setTitle(R.string.device_name)
.setIcon(R.drawable.edit_24px)
.setView(view)
.setPositiveButton(R.string.confirm, (d, which) -> {
this.names.rename(sighting.getKeyPrefix(),
Objects.requireNonNull(textInput.getText()).toString());
this.rebuildList();
})
.setNegativeButton(R.string.cancel, null)
.show();

// No empty-field guard here, unlike the tag page's copy: a foreign tag has no upstream
// name to overwrite, so a blank field is the only "unnamed" there is - confirm clears.
}
}
Loading
Loading