/* Copyright (C) 2023-2024 Daniel Dakhno, José Rebelo This file is part of Gadgetbridge. Gadgetbridge is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Gadgetbridge is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . */ package nodomain.freeyourgadget.gadgetbridge.activities.discovery; import android.os.ParcelUuid; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.concurrent.BlockingQueue; import java.util.concurrent.LinkedBlockingQueue; import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate; import nodomain.freeyourgadget.gadgetbridge.model.DeviceType; import nodomain.freeyourgadget.gadgetbridge.util.DeviceHelper; /** * A dedicated thread to process {@link GBScanEvent}s. This class keeps a map from mac address to * GBDeviceCandidate, with the current known state of each candidate. *

* Processing works as follows: * - The processor consumes mac addresses from the eventsToProcessQueue * - Mac addresses are placed on the queue when there are one or more new GBScanEvents to process in * the eventsToProcessMap map * - The eventsToProcessMap contains a list of events per device, so that they can be processed in batch * - The GBDeviceEvent for the corresponding mac address in candidatesByAddress gets updated with the new * information, and matched against the coordinators. */ public final class GBScanEventProcessor implements Runnable { private static final Logger LOG = LoggerFactory.getLogger(GBScanEventProcessor.class); private static final ParcelUuid ZERO_UUID = ParcelUuid.fromString("00000000-0000-0000-0000-000000000000"); private final Map candidatesByAddress = new LinkedHashMap<>(); private final BlockingQueue eventsToProcessQueue = new LinkedBlockingQueue<>(); private final Map> eventsToProcessMap = new HashMap<>(); private boolean discoverUnsupported = false; private volatile boolean running = false; private Thread thread = null; private final Callback callback; public GBScanEventProcessor(final Callback callback) { this.callback = callback; } @Override public void run() { LOG.info("Device Found Processor Thread started."); while (running) { try { LOG.debug("Polling found devices queue, current size = {}", eventsToProcessQueue.size()); final String candidateAddress = eventsToProcessQueue.take(); if (candidateAddress != null) { if (processAllScanEvents(candidateAddress)) { callback.onDeviceChanged(); } } } catch (final InterruptedException e) { LOG.warn("Processing thread interrupted"); Thread.currentThread().interrupt(); break; } } } public void start() { if (running) { LOG.warn("Already running!"); return; } running = true; thread = new Thread("Gadgetbridge Device Found Processor Thread") { @Override public void run() { GBScanEventProcessor.this.run(); } }; thread.start(); } public void stop() { running = false; if (thread != null) { thread.interrupt(); thread = null; } } public void clear() { candidatesByAddress.clear(); eventsToProcessMap.clear(); eventsToProcessQueue.clear(); } public void setDiscoverUnsupported(boolean discoverUnsupported) { this.discoverUnsupported = discoverUnsupported; } /** * Returns the current list of GBDeviceCandidates. The candidates are cloned, since they can be * modified concurrently by the processor. */ public List getDevices() { final List ret = new ArrayList<>(); // candidatesByAddress keeps insertion order, so newer devices will be at the end synchronized (candidatesByAddress) { for (final Map.Entry entry : candidatesByAddress.entrySet()) { ret.add(entry.getValue().clone()); } } return ret; } /** * Schedule a {@link GBScanEvent} to be processed asynchronously. */ public void scheduleProcessing(final GBScanEvent event) { LOG.debug("Scheduling {} for processing ({})", event.getDevice().getAddress(), event.getServiceUuids()); final String address = event.getDevice().getAddress(); synchronized (eventsToProcessMap) { if (!eventsToProcessMap.containsKey(address)) { eventsToProcessMap.put(address, new LinkedList<>()); } Objects.requireNonNull(eventsToProcessMap.get(address)).add(event); } try { eventsToProcessQueue.put(address); } catch (final InterruptedException e) { LOG.error("Failed to put device on processing queue", e); } } private boolean processCandidate(final GBDeviceCandidate candidate) { LOG.debug("found device: {}, {}", candidate.getName(), candidate.getMacAddress()); if (LOG.isDebugEnabled()) { final ParcelUuid[] uuids = candidate.getServiceUuids(); if (uuids != null && uuids.length > 0) { for (ParcelUuid uuid : uuids) { LOG.debug(" supports uuid: " + uuid.toString()); } } } final DeviceType deviceType = DeviceHelper.getInstance().resolveDeviceType(candidate, false); if (deviceType.isSupported() || discoverUnsupported) { synchronized (candidatesByAddress) { candidatesByAddress.put(candidate.getMacAddress(), candidate); } } return deviceType.isSupported(); } private boolean processAllScanEvents(final String address) { final List events; synchronized (eventsToProcessMap) { events = eventsToProcessMap.remove(address); } if (events == null || events.isEmpty()) { LOG.warn("Attempted to process {}, but found no events", address); return false; } LOG.debug("Processing {} events for {}", events.size(), address); GBDeviceCandidate candidate = candidatesByAddress.get(address); String previousName = null; ParcelUuid[] previousUuids = null; boolean firstTime = false; if (candidate == null) { // First time we see this device LOG.debug("Found {} for the first time", address); firstTime = true; final GBScanEvent firstEvent = events.get(0); events.remove(0); candidate = new GBDeviceCandidate(firstEvent.getDevice(), firstEvent.getRssi(), firstEvent.getServiceUuids()); } else { previousName = candidate.getName(); previousUuids = candidate.getServiceUuids(); } // Update the device with the remaining events for (final GBScanEvent event : events) { candidate.setRssi(event.getRssi()); candidate.addUuids(event.getServiceUuids()); } candidate.refreshNameIfUnknown(); try { candidate.addUuids(candidate.getDevice().getUuids()); } catch (final SecurityException e) { LOG.error("SecurityException on candidate.getDevice().getUuids()"); } if (!firstTime) { if (Objects.equals(candidate.getName(), previousName) && Arrays.equals(candidate.getServiceUuids(), previousUuids)) { // Neither name nor uuids changed, do not reprocess LOG.trace("Not reprocessing {} due to no changes", address); return false; } } if (processCandidate(candidate)) { LOG.info( "Device {} ({}) is supported as '{}' without scanning services", candidate.getDevice(), candidate.getName(), DeviceHelper.getInstance().resolveDeviceType(candidate, false) ); return true; } if (candidate.getServiceUuids().length == 0 || (candidate.getServiceUuids().length == 1 && candidate.getServiceUuids()[0].equals(ZERO_UUID))) { LOG.debug("Fetching uuids for {} with sdp", candidate.getDevice().getAddress()); try { candidate.getDevice().fetchUuidsWithSdp(); } catch (final SecurityException e) { LOG.error("SecurityException on candidate.getDevice().fetchUuidsWithSdp()"); } } return true; } public interface Callback { void onDeviceChanged(); } }