1 /*
2  * Copyright (C) 2021 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.google.android.tv.btservices.remote;
18 
19 import android.content.Context;
20 import android.os.AsyncTask;
21 import android.os.Environment;
22 import android.os.FileObserver;
23 import android.os.Handler;
24 import android.os.SystemProperties;
25 import android.text.TextUtils;
26 import android.util.Log;
27 import androidx.annotation.Nullable;
28 import com.google.android.tv.btservices.R;
29 import java.io.File;
30 import java.io.FileInputStream;
31 import java.security.DigestInputStream;
32 import java.security.MessageDigest;
33 import java.util.Arrays;
34 import java.util.Collections;
35 import java.util.HashSet;
36 import java.util.List;
37 import java.util.Set;
38 import java.util.TreeSet;
39 import java.util.stream.Collectors;
40 
41 /**
42  * For determining the current DFU binary.
43  */
44 public abstract class DfuProvider {
45 
46     private static final String TAG = "Atv.RemoteDfuPrvdr";
47 
48     public interface Listener {
onDfuFileAdd()49         void onDfuFileAdd();
50     }
51 
52     private class DfuFileObserver extends FileObserver {
53 
DfuFileObserver(String path)54         public DfuFileObserver(String path) {
55             super(path, FileObserver.CLOSE_WRITE | FileObserver.DELETE);
56         }
57 
58         @Override
onEvent(int event, String path)59         public void onEvent(int event, String path) {
60             if (mContext != null) {
61                 mHandler.post(DfuProvider.this::checkExternalStorage);
62             }
63         }
64     }
65 
66     private class CheckOnDiskDfuFileTask extends AsyncTask<File[], Integer, File[]> {
67         @Override
doInBackground(File[].... params)68         protected File[] doInBackground(File[]... params) {
69             if (params == null || params.length == 0 || params[0] == null) {
70                 return new File[]{};
71             }
72             return Arrays.stream(params[0])
73                 .filter((file) -> {
74                       return file.isFile() && file.canRead() &&
75                           isDfuFileName(file.getName()) &&
76                           (bypassMd5() || MD5s.contains(md5(file.getAbsolutePath())));
77                           })
78                     .collect(Collectors.toList())
79                     .toArray(new File[]{});
80         }
81 
82         @Override
onPostExecute(File[] result)83         protected void onPostExecute(File[] result) {
84             boolean changed = false;
85             TreeSet<DfuBinary> newDfus = new TreeSet<>();
86             newDfus.addAll(getPackagedBinaries());
87             for (File file: result) {
88                 try {
89                     FileInputStream fin = new FileInputStream(file.getAbsolutePath());
90                     // New, pushed binaries have priority over the system image binaries, so we set
91                     // 'override' to true. Note that this is for QA testing and validation only.
92                     DfuBinary dfu = mFactory.build(fin, true /* override */);
93                     newDfus.add(dfu);
94                     fin.close();
95                     Log.i(TAG, "Found dfu with version: " + dfu.getVersion());
96                 } catch (Exception e) {
97                     Log.e(TAG, "CheckOnDiskDfuFileTask: exception " + e);
98                 }
99             }
100             for (DfuBinary bin : newDfus) {
101                 if (!mDfus.contains(bin)) {
102                     changed = true;
103                     break;
104                 }
105             }
106             for (DfuBinary bin : mDfus) {
107                 if (!newDfus.contains(bin)) {
108                     changed = true;
109                     break;
110                 }
111             }
112             mDfus.clear();
113             mDfus.addAll(newDfus);
114             if (changed) {
115                 mListener.onDfuFileAdd();
116             }
117         }
118     }
119 
120     private final TreeSet<DfuBinary> mDfus = new TreeSet<>();
121     private final DfuBinary.Factory mFactory;
122     private final Handler mHandler = new Handler();
123     private final Set<String> MD5s;
124     private final Set<Version> mManualReconnectionVersions;
125     private final Context mContext;
126     private MessageDigest mDigest;
127     private FileObserver mObserver;
128     private Listener mListener;
129 
130     // This method provides the DFU binaries that are packaged with the APK.
getPackagedBinaries()131     protected abstract List<DfuBinary> getPackagedBinaries();
132 
133     /**
134      * Returns the versions from which an upgrade will cause the connection information stored on
135      * the remote control being erased. After an upgrade from one of these versions the connection
136      * to the remote control would need to be forgotten on the host side and the user needs to
137      * perform pairing again.
138      *
139      * @return A set containing all versions for which the above behavior is to be expected.
140      */
getManualReconnectionVersions()141     public Set<Version> getManualReconnectionVersions() {
142         return mManualReconnectionVersions;
143     }
144 
convertStrToVersion(String str)145     private static Version convertStrToVersion(String str) {
146         String[] parts = str.split(" ");
147         int major = Integer.parseInt(parts[0], 16);
148         int minor = Integer.parseInt(parts[1], 16);
149         byte vid = (byte) (Integer.parseInt(parts[2], 16) & 0xff);
150         byte pid = (byte) (Integer.parseInt(parts[3], 16) & 0xff);
151         return new Version(major, minor, vid, pid);
152     }
153 
DfuProvider(Context context, Listener listener, DfuBinary.Factory factory)154     public DfuProvider(Context context, Listener listener, DfuBinary.Factory factory) {
155         mContext = context;
156         mListener = listener;
157         mFactory = factory;
158         mDfus.addAll(getPackagedBinaries());
159         MD5s = Collections.unmodifiableSet(new HashSet<>(
160                 Arrays.asList(mContext.getResources().getStringArray(R.array.dfu_binary_md5s))));
161 
162         String[] versionStrs =
163                 mContext.getResources().getStringArray(R.array.manual_reconnection_remote_versions);
164         mManualReconnectionVersions =
165                   Collections.unmodifiableSet(Arrays.stream(versionStrs)
166                         .map(DfuProvider::convertStrToVersion)
167                         .collect(Collectors.<Version>toSet()));
168 
169         try {
170             mDigest = MessageDigest.getInstance("MD5");
171         } catch (Exception e) {
172             Log.e(TAG, "error in opening md5 digest: " + e);
173         }
174         checkExternalStorage();
175         File extDir = Environment.getExternalStorageDirectory();
176         mObserver = new DfuFileObserver(extDir.getAbsolutePath());
177         mObserver.startWatching();
178     }
179 
destroy()180     public void destroy() {
181         mObserver.stopWatching();
182         mObserver = null;
183     }
184 
bypassMd5()185     private static boolean bypassMd5() {
186         return !TextUtils.isEmpty(SystemProperties.get("btservices.dfu_bypass_md5", ""));
187     }
188 
bypassVendorIdCheck()189     private static boolean bypassVendorIdCheck() {
190         return !TextUtils.isEmpty(SystemProperties.get("btservices.dfu_bypass_vendor_id", ""));
191     }
192 
bypassProductIdCheck()193     private static boolean bypassProductIdCheck() {
194         return !TextUtils.isEmpty(SystemProperties.get("btservices.dfu_bypass_product_id", ""));
195     }
196 
bypassVersionCheck()197     public boolean bypassVersionCheck() {
198         return bypassMd5();
199     }
200 
md5(String absPath)201     private String md5(String absPath) {
202         if (mDigest == null) {
203             return null;
204         }
205 
206         File file = new File(absPath);
207         if (!file.isFile()) {
208             return null;
209         }
210 
211         FileInputStream fin;
212         try {
213             fin = new FileInputStream(absPath);
214         } catch (Exception e) {
215             Log.e(TAG, "failed to open file: " + absPath);
216             return null;
217         }
218         mDigest.reset();
219 
220         DigestInputStream dis = new DigestInputStream(fin, mDigest);
221         try {
222             while (dis.available() > 0) {
223                 dis.read();
224             }
225         } catch(Exception e) {
226             Log.e(TAG, "failed to read file: " + absPath);
227             return null;
228         }
229         try {
230             fin.close();
231         } catch (Exception e) {
232             Log.e(TAG, "failed to close file " + absPath);
233             return null;
234         }
235 
236         byte[] digest = mDigest.digest();
237         StringBuilder sb = new StringBuilder();
238         for(byte b: digest){
239             sb.append(String.format("%02x", b & 0xff));
240         }
241         return sb.toString().toLowerCase();
242     }
243 
244     // TODO: Should be replaced with vendor implementation.
isDfuFileName(String fname)245     private static boolean isDfuFileName(String fname) {
246         if (fname == null)
247             return false;
248         fname = fname.toLowerCase();
249         return fname.endsWith(".bin") && fname.contains("ota");
250     }
251 
checkExternalStorage()252     private void checkExternalStorage() {
253         if (mContext == null) {
254             return;
255         }
256         File extDir = Environment.getExternalStorageDirectory();
257         if (!extDir.isDirectory()) {
258             return;
259         }
260         new CheckOnDiskDfuFileTask().execute(extDir.listFiles());
261     }
262 
263     /**
264      * Given the device name and the firmware version of the current remote, this method determines
265      * the best possible DFU for this remote or null if there are no suitable DFU binaries.
266      *
267      * @param deviceName The name of the device.
268      * @param version The current version of the device.
269      * @return The best matching DfuBinary or null if there is no suitable one available.
270      */
271     @Nullable
getDfu(String deviceName, Version version)272     public DfuBinary getDfu(String deviceName, Version version) {
273         DfuBinary best = null;
274         for (DfuBinary bin : mDfus) {
275             Version binVersion = bin.getVersion();
276             if (!bypassVendorIdCheck() && binVersion.vid() != version.vid()) {
277                 continue;
278             }
279 
280             if (!bypassProductIdCheck() && binVersion.pid() != version.pid()) {
281                 continue;
282             }
283 
284             if (binVersion.compareTo(version) <= 0) {
285                 continue;
286             }
287 
288             if (best == null || (best.getVersion().compareTo(binVersion) < 0)) {
289                 best = bin;
290             }
291         }
292         return best;
293     }
294 }
295