blob: 5e638b9ad996989abeae068d5969bc7004859823 [file] [log] [blame]
/*
* Copyright (C) 2021 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.android.tv.btservices.remote;
import android.content.Context;
import android.os.AsyncTask;
import android.os.Environment;
import android.os.FileObserver;
import android.os.Handler;
import android.os.SystemProperties;
import android.text.TextUtils;
import android.util.Log;
import androidx.annotation.Nullable;
import com.google.android.tv.btservices.R;
import java.io.File;
import java.io.FileInputStream;
import java.security.DigestInputStream;
import java.security.MessageDigest;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;
import java.util.stream.Collectors;
/**
* For determining the current DFU binary.
*/
public abstract class DfuProvider {
private static final String TAG = "Atv.RemoteDfuPrvdr";
public interface Listener {
void onDfuFileAdd();
}
private class DfuFileObserver extends FileObserver {
public DfuFileObserver(String path) {
super(path, FileObserver.CLOSE_WRITE | FileObserver.DELETE);
}
@Override
public void onEvent(int event, String path) {
if (mContext != null) {
mHandler.post(DfuProvider.this::checkExternalStorage);
}
}
}
private class CheckOnDiskDfuFileTask extends AsyncTask<File[], Integer, File[]> {
@Override
protected File[] doInBackground(File[]... params) {
if (params == null || params.length == 0 || params[0] == null) {
return new File[]{};
}
return Arrays.stream(params[0])
.filter((file) -> {
return file.isFile() && file.canRead() &&
isDfuFileName(file.getName()) &&
(bypassMd5() || MD5s.contains(md5(file.getAbsolutePath())));
})
.collect(Collectors.toList())
.toArray(new File[]{});
}
@Override
protected void onPostExecute(File[] result) {
boolean changed = false;
TreeSet<DfuBinary> newDfus = new TreeSet<>();
newDfus.addAll(getPackagedBinaries());
for (File file: result) {
try {
FileInputStream fin = new FileInputStream(file.getAbsolutePath());
// New, pushed binaries have priority over the system image binaries, so we set
// 'override' to true. Note that this is for QA testing and validation only.
DfuBinary dfu = mFactory.build(fin, true /* override */);
newDfus.add(dfu);
fin.close();
Log.i(TAG, "Found dfu with version: " + dfu.getVersion());
} catch (Exception e) {
Log.e(TAG, "CheckOnDiskDfuFileTask: exception " + e);
}
}
for (DfuBinary bin : newDfus) {
if (!mDfus.contains(bin)) {
changed = true;
break;
}
}
for (DfuBinary bin : mDfus) {
if (!newDfus.contains(bin)) {
changed = true;
break;
}
}
mDfus.clear();
mDfus.addAll(newDfus);
if (changed) {
mListener.onDfuFileAdd();
}
}
}
private final TreeSet<DfuBinary> mDfus = new TreeSet<>();
private final DfuBinary.Factory mFactory;
private final Handler mHandler = new Handler();
private final Set<String> MD5s;
private final Set<Version> mManualReconnectionVersions;
private final Context mContext;
private MessageDigest mDigest;
private FileObserver mObserver;
private Listener mListener;
// This method provides the DFU binaries that are packaged with the APK.
protected abstract List<DfuBinary> getPackagedBinaries();
/**
* Returns the versions from which an upgrade will cause the connection information stored on
* the remote control being erased. After an upgrade from one of these versions the connection
* to the remote control would need to be forgotten on the host side and the user needs to
* perform pairing again.
*
* @return A set containing all versions for which the above behavior is to be expected.
*/
public Set<Version> getManualReconnectionVersions() {
return mManualReconnectionVersions;
}
private static Version convertStrToVersion(String str) {
String[] parts = str.split(" ");
int major = Integer.parseInt(parts[0], 16);
int minor = Integer.parseInt(parts[1], 16);
byte vid = (byte) (Integer.parseInt(parts[2], 16) & 0xff);
byte pid = (byte) (Integer.parseInt(parts[3], 16) & 0xff);
return new Version(major, minor, vid, pid);
}
public DfuProvider(Context context, Listener listener, DfuBinary.Factory factory) {
mContext = context;
mListener = listener;
mFactory = factory;
mDfus.addAll(getPackagedBinaries());
MD5s = Collections.unmodifiableSet(new HashSet<>(
Arrays.asList(mContext.getResources().getStringArray(R.array.dfu_binary_md5s))));
String[] versionStrs =
mContext.getResources().getStringArray(R.array.manual_reconnection_remote_versions);
mManualReconnectionVersions =
Collections.unmodifiableSet(Arrays.stream(versionStrs)
.map(DfuProvider::convertStrToVersion)
.collect(Collectors.<Version>toSet()));
try {
mDigest = MessageDigest.getInstance("MD5");
} catch (Exception e) {
Log.e(TAG, "error in opening md5 digest: " + e);
}
checkExternalStorage();
File extDir = Environment.getExternalStorageDirectory();
mObserver = new DfuFileObserver(extDir.getAbsolutePath());
mObserver.startWatching();
}
public void destroy() {
mObserver.stopWatching();
mObserver = null;
}
private static boolean bypassMd5() {
return !TextUtils.isEmpty(SystemProperties.get("btservices.dfu_bypass_md5", ""));
}
private static boolean bypassVendorIdCheck() {
return !TextUtils.isEmpty(SystemProperties.get("btservices.dfu_bypass_vendor_id", ""));
}
private static boolean bypassProductIdCheck() {
return !TextUtils.isEmpty(SystemProperties.get("btservices.dfu_bypass_product_id", ""));
}
public boolean bypassVersionCheck() {
return bypassMd5();
}
private String md5(String absPath) {
if (mDigest == null) {
return null;
}
File file = new File(absPath);
if (!file.isFile()) {
return null;
}
FileInputStream fin;
try {
fin = new FileInputStream(absPath);
} catch (Exception e) {
Log.e(TAG, "failed to open file: " + absPath);
return null;
}
mDigest.reset();
DigestInputStream dis = new DigestInputStream(fin, mDigest);
try {
while (dis.available() > 0) {
dis.read();
}
} catch(Exception e) {
Log.e(TAG, "failed to read file: " + absPath);
return null;
}
try {
fin.close();
} catch (Exception e) {
Log.e(TAG, "failed to close file " + absPath);
return null;
}
byte[] digest = mDigest.digest();
StringBuilder sb = new StringBuilder();
for(byte b: digest){
sb.append(String.format("%02x", b & 0xff));
}
return sb.toString().toLowerCase();
}
// TODO: Should be replaced with vendor implementation.
private static boolean isDfuFileName(String fname) {
if (fname == null)
return false;
fname = fname.toLowerCase();
return fname.endsWith(".bin") && fname.contains("ota");
}
private void checkExternalStorage() {
if (mContext == null) {
return;
}
File extDir = Environment.getExternalStorageDirectory();
if (!extDir.isDirectory()) {
return;
}
new CheckOnDiskDfuFileTask().execute(extDir.listFiles());
}
/**
* Given the device name and the firmware version of the current remote, this method determines
* the best possible DFU for this remote or null if there are no suitable DFU binaries.
*
* @param deviceName The name of the device.
* @param version The current version of the device.
* @return The best matching DfuBinary or null if there is no suitable one available.
*/
@Nullable
public DfuBinary getDfu(String deviceName, Version version) {
DfuBinary best = null;
for (DfuBinary bin : mDfus) {
Version binVersion = bin.getVersion();
if (!bypassVendorIdCheck() && binVersion.vid() != version.vid()) {
continue;
}
if (!bypassProductIdCheck() && binVersion.pid() != version.pid()) {
continue;
}
if (binVersion.compareTo(version) <= 0) {
continue;
}
if (best == null || (best.getVersion().compareTo(binVersion) < 0)) {
best = bin;
}
}
return best;
}
}