1package com.android.hotspot2.osu;
2
3import android.util.Log;
4
5import com.android.anqp.HSIconFileElement;
6import com.android.anqp.IconInfo;
7import com.android.hotspot2.Utils;
8
9import java.net.ProtocolException;
10import java.nio.BufferUnderflowException;
11import java.nio.ByteBuffer;
12import java.nio.ByteOrder;
13import java.util.ArrayList;
14import java.util.Arrays;
15import java.util.Collections;
16import java.util.HashMap;
17import java.util.Iterator;
18import java.util.LinkedHashMap;
19import java.util.LinkedList;
20import java.util.List;
21import java.util.Map;
22
23import static com.android.anqp.Constants.ANQPElementType.HSIconFile;
24
25public class IconCache extends Thread {
26    private static final int CacheSize = 64;
27    private static final int RetryCount = 3;
28
29    private final OSUManager mOSUManager;
30    private final Map<Long, LinkedList<QuerySet>> mBssQueues = new HashMap<>();
31
32    private final Map<IconKey, HSIconFileElement> mCache =
33            new LinkedHashMap<IconKey, HSIconFileElement>() {
34                @Override
35                protected boolean removeEldestEntry(Map.Entry eldest) {
36                    return size() > CacheSize;
37                }
38            };
39
40    private static class IconKey {
41        private final long mBSSID;
42        private final long mHESSID;
43        private final String mSSID;
44        private final int mAnqpDomID;
45        private final String mFileName;
46
47        private IconKey(OSUInfo osuInfo, String fileName) {
48            mBSSID = osuInfo.getBSSID();
49            mHESSID = osuInfo.getHESSID();
50            mSSID = osuInfo.getAdvertisingSSID();
51            mAnqpDomID = osuInfo.getAnqpDomID();
52            mFileName = fileName;
53        }
54
55        public String getFileName() {
56            return mFileName;
57        }
58
59        @Override
60        public boolean equals(Object thatObject) {
61            if (this == thatObject) {
62                return true;
63            }
64            if (thatObject == null || getClass() != thatObject.getClass()) {
65                return false;
66            }
67
68            IconKey that = (IconKey) thatObject;
69
70            return mFileName.equals(that.mFileName) && ((mBSSID == that.mBSSID) ||
71                    ((mAnqpDomID == that.mAnqpDomID) && (mAnqpDomID != 0) &&
72                            (mHESSID == that.mHESSID) && ((mHESSID != 0)
73                            || mSSID.equals(that.mSSID))));
74        }
75
76        @Override
77        public int hashCode() {
78            int result = (int) (mBSSID ^ (mBSSID >>> 32));
79            result = 31 * result + (int) (mHESSID ^ (mHESSID >>> 32));
80            result = 31 * result + mSSID.hashCode();
81            result = 31 * result + mAnqpDomID;
82            result = 31 * result + mFileName.hashCode();
83            return result;
84        }
85
86        @Override
87        public String toString() {
88            return String.format("%012x:%012x '%s' [%d] + '%s'",
89                    mBSSID, mHESSID, mSSID, mAnqpDomID, mFileName);
90        }
91    }
92
93    private static class QueryEntry {
94        private final IconKey mKey;
95        private int mRetry;
96        private long mLastSent;
97
98        private QueryEntry(IconKey key) {
99            mKey = key;
100            mLastSent = System.currentTimeMillis();
101        }
102
103        private IconKey getKey() {
104            return mKey;
105        }
106
107        private int bumpRetry() {
108            mLastSent = System.currentTimeMillis();
109            return mRetry++;
110        }
111
112        private long age(long now) {
113            return now - mLastSent;
114        }
115
116        @Override
117        public String toString() {
118            return String.format("Entry %s, retry %d", mKey, mRetry);
119        }
120    }
121
122    private static class QuerySet {
123        private final OSUInfo mOsuInfo;
124        private final LinkedList<QueryEntry> mEntries;
125
126        private QuerySet(OSUInfo osuInfo, List<IconInfo> icons) {
127            mOsuInfo = osuInfo;
128            mEntries = new LinkedList<>();
129            for (IconInfo iconInfo : icons) {
130                mEntries.addLast(new QueryEntry(new IconKey(osuInfo, iconInfo.getFileName())));
131            }
132        }
133
134        private QueryEntry peek() {
135            return mEntries.getFirst();
136        }
137
138        private QueryEntry pop() {
139            mEntries.removeFirst();
140            return mEntries.isEmpty() ? null : mEntries.getFirst();
141        }
142
143        private boolean isEmpty() {
144            return mEntries.isEmpty();
145        }
146
147        private List<QueryEntry> getAllEntries() {
148            return Collections.unmodifiableList(mEntries);
149        }
150
151        private long getBssid() {
152            return mOsuInfo.getBSSID();
153        }
154
155        private OSUInfo getOsuInfo() {
156            return mOsuInfo;
157        }
158
159        private IconKey updateIcon(String fileName, HSIconFileElement iconFileElement) {
160            IconKey key = null;
161            for (QueryEntry queryEntry : mEntries) {
162                if (queryEntry.getKey().getFileName().equals(fileName)) {
163                    key = queryEntry.getKey();
164                }
165            }
166            if (key == null) {
167                return null;
168            }
169
170            if (iconFileElement != null) {
171                mOsuInfo.setIconFileElement(iconFileElement, fileName);
172            } else {
173                mOsuInfo.setIconStatus(OSUInfo.IconStatus.NotAvailable);
174            }
175            return key;
176        }
177
178        private boolean updateIcon(IconKey key, HSIconFileElement iconFileElement) {
179            boolean match = false;
180            for (QueryEntry queryEntry : mEntries) {
181                if (queryEntry.getKey().equals(key)) {
182                    match = true;
183                    break;
184                }
185            }
186            if (!match) {
187                return false;
188            }
189
190            if (iconFileElement != null) {
191                mOsuInfo.setIconFileElement(iconFileElement, key.getFileName());
192            } else {
193                mOsuInfo.setIconStatus(OSUInfo.IconStatus.NotAvailable);
194            }
195            return true;
196        }
197
198        @Override
199        public String toString() {
200            return "OSU " + mOsuInfo + ": " + mEntries;
201        }
202    }
203
204    public IconCache(OSUManager osuManager) {
205        mOSUManager = osuManager;
206    }
207
208    public void clear() {
209        mBssQueues.clear();
210        mCache.clear();
211    }
212
213    private boolean enqueue(QuerySet querySet) {
214        boolean newEntry = false;
215        LinkedList<QuerySet> queries = mBssQueues.get(querySet.getBssid());
216        if (queries == null) {
217            queries = new LinkedList<>();
218            mBssQueues.put(querySet.getBssid(), queries);
219            newEntry = true;
220        }
221        queries.addLast(querySet);
222        return newEntry;
223    }
224
225    public void startIconQuery(OSUInfo osuInfo, List<IconInfo> icons) {
226        Log.d("ZXZ", String.format("Icon query on %012x for %s", osuInfo.getBSSID(), icons));
227        if (icons == null || icons.isEmpty()) {
228            return;
229        }
230
231        QuerySet querySet = new QuerySet(osuInfo, icons);
232        for (QueryEntry entry : querySet.getAllEntries()) {
233            HSIconFileElement iconElement = mCache.get(entry.getKey());
234            if (iconElement != null) {
235                osuInfo.setIconFileElement(iconElement, entry.getKey().getFileName());
236                mOSUManager.iconResults(Arrays.asList(osuInfo));
237                return;
238            }
239        }
240        if (enqueue(querySet)) {
241            initiateQuery(querySet.getBssid());
242        }
243    }
244
245    private void initiateQuery(long bssid) {
246        LinkedList<QuerySet> queryEntries = mBssQueues.get(bssid);
247        if (queryEntries == null) {
248            return;
249        } else if (queryEntries.isEmpty()) {
250            mBssQueues.remove(bssid);
251            return;
252        }
253
254        QuerySet querySet = queryEntries.getFirst();
255        QueryEntry queryEntry = querySet.peek();
256        if (queryEntry.bumpRetry() >= RetryCount) {
257            QueryEntry newEntry = querySet.pop();
258            if (newEntry == null) {
259                // No more entries in this QuerySet, advance to the next set.
260                querySet.getOsuInfo().setIconStatus(OSUInfo.IconStatus.NotAvailable);
261                queryEntries.removeFirst();
262                if (queryEntries.isEmpty()) {
263                    // No further QuerySet on this BSSID, drop the bucket and bail.
264                    mBssQueues.remove(bssid);
265                    return;
266                } else {
267                    querySet = queryEntries.getFirst();
268                    queryEntry = querySet.peek();
269                    queryEntry.bumpRetry();
270                }
271            }
272        }
273        mOSUManager.doIconQuery(bssid, queryEntry.getKey().getFileName());
274    }
275
276    public void notifyIconReceived(long bssid, String fileName, byte[] iconData) {
277        Log.d("ZXZ", String.format("Icon '%s':%d received from %012x",
278                fileName, iconData != null ? iconData.length : -1, bssid));
279        IconKey key;
280        HSIconFileElement iconFileElement = null;
281        List<OSUInfo> updates = new ArrayList<>();
282
283        LinkedList<QuerySet> querySets = mBssQueues.get(bssid);
284        if (querySets == null || querySets.isEmpty()) {
285            Log.d(OSUManager.TAG,
286                    String.format("Spurious icon response from %012x for '%s' (%d) bytes",
287                            bssid, fileName, iconData != null ? iconData.length : -1));
288            Log.d("ZXZ", "query set: " + querySets
289                    + ", BSS queues: " + Utils.bssidsToString(mBssQueues.keySet()));
290            return;
291        } else {
292            QuerySet querySet = querySets.removeFirst();
293            if (iconData != null) {
294                try {
295                    iconFileElement = new HSIconFileElement(HSIconFile,
296                            ByteBuffer.wrap(iconData).order(ByteOrder.LITTLE_ENDIAN));
297                } catch (ProtocolException | BufferUnderflowException e) {
298                    Log.e(OSUManager.TAG, "Failed to parse ANQP icon file: " + e);
299                }
300            }
301            key = querySet.updateIcon(fileName, iconFileElement);
302            if (key == null) {
303                Log.d(OSUManager.TAG,
304                        String.format("Spurious icon response from %012x for '%s' (%d) bytes",
305                                bssid, fileName, iconData != null ? iconData.length : -1));
306                Log.d("ZXZ", "query set: " + querySets + ", BSS queues: "
307                        + Utils.bssidsToString(mBssQueues.keySet()));
308                querySets.addFirst(querySet);
309                return;
310            }
311
312            if (iconFileElement != null) {
313                mCache.put(key, iconFileElement);
314            }
315
316            if (querySet.isEmpty()) {
317                mBssQueues.remove(bssid);
318            }
319            updates.add(querySet.getOsuInfo());
320        }
321
322        // Update any other pending entries that matches the ESS of the currently resolved icon
323        Iterator<Map.Entry<Long, LinkedList<QuerySet>>> bssIterator =
324                mBssQueues.entrySet().iterator();
325        while (bssIterator.hasNext()) {
326            Map.Entry<Long, LinkedList<QuerySet>> bssEntries = bssIterator.next();
327            Iterator<QuerySet> querySetIterator = bssEntries.getValue().iterator();
328            while (querySetIterator.hasNext()) {
329                QuerySet querySet = querySetIterator.next();
330                if (querySet.updateIcon(key, iconFileElement)) {
331                    querySetIterator.remove();
332                    updates.add(querySet.getOsuInfo());
333                }
334            }
335            if (bssEntries.getValue().isEmpty()) {
336                bssIterator.remove();
337            }
338        }
339
340        initiateQuery(bssid);
341
342        mOSUManager.iconResults(updates);
343    }
344
345    private static final long RequeryTimeLow = 6000L;
346    private static final long RequeryTimeHigh = 15000L;
347
348    public void tickle(boolean wifiOff) {
349        synchronized (mCache) {
350            if (wifiOff) {
351                mBssQueues.clear();
352            } else {
353                long now = System.currentTimeMillis();
354
355                Iterator<Map.Entry<Long, LinkedList<QuerySet>>> bssIterator =
356                        mBssQueues.entrySet().iterator();
357                while (bssIterator.hasNext()) {
358                    // Get the list of entries for this BSSID
359                    Map.Entry<Long, LinkedList<QuerySet>> bssEntries = bssIterator.next();
360                    Iterator<QuerySet> querySetIterator = bssEntries.getValue().iterator();
361                    while (querySetIterator.hasNext()) {
362                        QuerySet querySet = querySetIterator.next();
363                        QueryEntry queryEntry = querySet.peek();
364                        long age = queryEntry.age(now);
365                        if (age > RequeryTimeHigh) {
366                            // Timed out entry, move on to the next.
367                            queryEntry = querySet.pop();
368                            if (queryEntry == null) {
369                                // Empty query set, update status and remove it.
370                                querySet.getOsuInfo()
371                                        .setIconStatus(OSUInfo.IconStatus.NotAvailable);
372                                querySetIterator.remove();
373                            } else {
374                                // Start a query on the next entry and bail out of the set iteration
375                                initiateQuery(querySet.getBssid());
376                                break;
377                            }
378                        } else if (age > RequeryTimeLow) {
379                            // Re-issue queries for qualified entries and bail out of set iteration
380                            initiateQuery(querySet.getBssid());
381                            break;
382                        }
383                    }
384                    if (bssEntries.getValue().isEmpty()) {
385                        // Kill the whole bucket if the set list is empty
386                        bssIterator.remove();
387                    }
388                }
389            }
390        }
391    }
392}
393