package org.zephyrsoft.trackworktime.location; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.net.wifi.ScanResult; import android.net.wifi.WifiManager; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import org.pmw.tinylog.Logger; import org.zephyrsoft.trackworktime.util.DateTimeUtil; import java.util.ArrayList; import java.util.List; import java.util.TimeZone; import hirondelle.date4j.DateTime; /** * Class responsible for retrieving wifi {@link ScanResult}s. * * Before use, you must register this {@link BroadcastReceiver} with {@link #register(Context)}. * * For requesting wifi {@link ScanResult}s, call {@link #requestWifiScanResults()}. * * Listen for wifi {@link ScanResult}s, by registering {@link WifiScanListener} with * {@link #setWifiScanListener(WifiScanListener)}. * * Notes: * This class receives wifi {@link ScanResult}s every time ANY APP on the phone initiates scanning. * We can take advantage of this and cache results (in {@link #latestScanResults}), to minimize * scan requests and lower battery drain. */ public class WifiScanner extends BroadcastReceiver { @NonNull private final WifiManager wifiManager; /** Max scan age [sec]. How old scan results are still considered "good enough". */ private final int maxScanAge; /** Timeout value [sec], implying when next scan request can be made. */ private final int scanRequestTimeout; /** Flag indicating if {@code this} {@link BroadcastReceiver} was already registered. * {@code true} if registered, {@code false} otherwise. */ private boolean registered = false; /** Most recently received scan results */ @NonNull private List<ScanResult> latestScanResults = new ArrayList<>(); /** Most recent update date time of {@link #latestScanResults} */ @NonNull private DateTime latestScanResultTime = DateTime.forInstant(0, TimeZone.getDefault()); /** Listener reference, for anyone who is interested in scanning results */ @Nullable private WifiScanListener wifiScanListener; /** Flag, when set to {@code true}, disables scan requests to prevent flooding. */ private boolean scanRequested = false; @NonNull private DateTime latestScanRequestTime = DateTime.forInstant(0, TimeZone.getDefault()); public enum Result { /** When {@link WifiManager#isWifiEnabled()} returns false */ FAIL_WIFI_DISABLED, /** When {@link WifiManager#startScan()} fails */ FAIL_SCAN_REQUEST_FAILED, /** When {@link WifiScanner} receives results not updated broadcast */ FAIL_RESULTS_NOT_UPDATED, /** When calling {@link #requestWifiScanResults()} too fast. * @see #scanRequestTimeout */ CANCEL_SPAMMING } /** * Callback for anyone that anyone who is interested in wifi scans. */ public interface WifiScanListener { /** * Called when wifi scan results were successfully updated * @param scanResults scan results, never null */ void onScanResultsUpdated(@NonNull List<ScanResult> scanResults); /** * Called when wifi scan fails * @see Result * @param failCode code describing what exactly went wrong */ void onScanRequestFailed(@NonNull Result failCode); } @SuppressWarnings("ConstantConditions") public WifiScanner(@NonNull WifiManager wifiManager, int maxScanAge, int scanRequestTimeout) { if(wifiManager == null) { throw new IllegalArgumentException(WifiManager.class.getSimpleName() + " should not be" + " null"); } if(maxScanAge < 0) { throw new IllegalArgumentException("Scan result age should not be negative number"); } if(scanRequestTimeout < 0) { throw new IllegalArgumentException("Scan timeout should not be negative number"); } this.wifiManager = wifiManager; this.maxScanAge = maxScanAge; this.scanRequestTimeout = scanRequestTimeout; } /** * Registers this {@link WifiScanner} * @param context any type of {@link Context}, to get application context from, * needed for registering receiver */ public void register(@NonNull Context context) { if(isRegistered()) { Logger.warn(getClass().getSimpleName() + " trying to register, but is already registered!"); return; } IntentFilter intentFilter = new IntentFilter(); intentFilter.addAction(WifiManager.SCAN_RESULTS_AVAILABLE_ACTION); // Note: Android API only allows registering broadcast receivers to application context! context.getApplicationContext().registerReceiver(this, intentFilter); setRegistered(true); } /** * Unregisters this {@link WifiScanner} * @param context any type of {@link Context}, to get application context from, * needed for un-registering receiver */ public void unregister(@NonNull Context context) { if(!isRegistered()) { Logger.warn(getClass().getSimpleName() + " trying to unregister, but is already not" + " registered!"); return; } context.getApplicationContext().unregisterReceiver(this); setRegistered(false); } /** * Check if this {@link WifiScanner} is registered * @return {@code true} if registered */ public boolean isRegistered() { return registered; } private void setRegistered(boolean registered) { this.registered = registered; Logger.debug(getClass().getSimpleName() + " changed registered state to: " + registered); } @Override public void onReceive(Context context, Intent intent) { boolean success; if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) { // EXTRA_RESULTS_UPDATED is representing if the scan was successful or not. Scans may // fail if: // - App requested too many scans in a certain period of time. This may lead to // additional scan request rejections via "scan throttling" for both foreground and // background apps. // - The device is idle and scanning is disabled. // - Wifi hardware reported a scan failure. success = intent.getBooleanExtra(WifiManager.EXTRA_RESULTS_UPDATED, false); } else { // Doze mode was implemented in API23 (M), so there shouldn't be any problems // on earlier versions. We can't really check anyways... presume it was successful. success = true; } onWifiScanFinished(success); } public void onWifiScanFinished(boolean success) { if(success) { latestScanResults.clear(); latestScanResults.addAll(wifiManager.getScanResults()); latestScanResultTime = DateTimeUtil.getCurrentDateTime(); } if(!scanRequested) { Logger.debug("Another app initiated wifi-scan, caching results."); return; } if(wifiScanListener == null) { Logger.warn("Cannot dispatch scan results! " + WifiScanListener.class.getSimpleName() + " was null!"); } else if(success) { wifiScanListener.onScanResultsUpdated(latestScanResults); } else { wifiScanListener.onScanRequestFailed(Result.FAIL_RESULTS_NOT_UPDATED); } scanRequested = false; } /** * @see WifiScanListener * @param wifiScanListener callback or {@code null} for unregistering it */ public void setWifiScanListener(@Nullable WifiScanListener wifiScanListener) { this.wifiScanListener = wifiScanListener; } /** * Make a request to scan wifi networks. * * Results will be returned with {@link WifiScanListener}. Callback can happen instant or * delayed (usually a few seconds). */ public void requestWifiScanResults() { Logger.debug("Requested wifi scan results"); if(wifiScanListener == null) { // No point in requesting scans nobody cares about... Logger.warn("Requesting wifi scan, but no " + WifiScanListener.class.getSimpleName() + " is registered!"); return; } if(!wifiManager.isWifiEnabled()) { wifiScanListener.onScanRequestFailed(Result.FAIL_WIFI_DISABLED); return; } // It's possible another application requested scan results, check last received scan results // and use them if they are not too old. if(areLastResultsOk()) { Logger.debug("Returning cached wifi scan results"); wifiScanListener.onScanResultsUpdated(latestScanResults); return; } // Note: Let's be nice, and allow returning valid cached scan results. I.e. allow returning // cached results, before checking if we can scan again. if(!canScanAgain()) { wifiScanListener.onScanRequestFailed(Result.CANCEL_SPAMMING); return; } boolean success = wifiManager.startScan(); latestScanResultTime = DateTimeUtil.getCurrentDateTime(); Logger.debug("Wifi start scan succeeded: " + success); if(!success) { wifiScanListener.onScanRequestFailed(Result.FAIL_SCAN_REQUEST_FAILED); return; } scanRequested = true; } /** * Check if current {@link #latestScanResults} are still considered to be usable, i.e. not too * old. * @return {@code true} if still ok, {@code false} if they are stale */ private boolean areLastResultsOk() { DateTime current = DateTimeUtil.getCurrentDateTime(); DateTime validUntil = latestScanResultTime.plus( 0, 0, 0, 0, 0, maxScanAge, 0, DateTime.DayOverflow.Spillover); return validUntil.gteq(current); } /** * Checks if scan request can be made with {@link #requestWifiScanResults()} * @return {@code true} if it can, {@code false otherwise} */ public boolean canScanAgain(){ DateTime current = DateTimeUtil.getCurrentDateTime(); DateTime disabledUntil = latestScanRequestTime.plus( 0, 0, 0, 0, 0, scanRequestTimeout, 0, DateTime.DayOverflow.Spillover); return disabledUntil.lteq(current); } }