diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3672628d9..909b03de1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -137,7 +137,15 @@ dependencies { debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.ui.test.manifest) + "fullImplementation"(libs.material) + "fullImplementation"(libs.androidx.documentfile) + "fullImplementation"(libs.androidx.localbroadcastmanager) "fullImplementation"(libs.guardianproject.panic) + "fullImplementation"(libs.bcpkix.jdk15to18) + "fullImplementation"(libs.jmdns) + "fullImplementation"(libs.nanohttpd) + "fullImplementation"(libs.commons.io) + "fullImplementation"(libs.commons.net) testImplementation(libs.junit) testImplementation(kotlin("test")) diff --git a/app/src/basic/kotlin/org/fdroid/ui/navigation/ExtraNavigationEntries.kt b/app/src/basic/kotlin/org/fdroid/ui/navigation/ExtraNavigationEntries.kt index eb281857b..1337b4249 100644 --- a/app/src/basic/kotlin/org/fdroid/ui/navigation/ExtraNavigationEntries.kt +++ b/app/src/basic/kotlin/org/fdroid/ui/navigation/ExtraNavigationEntries.kt @@ -1,6 +1,11 @@ package org.fdroid.ui.navigation +import android.content.Context import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavKey +import org.fdroid.R + +fun getMoreMenuItems(context: Context): List = + listOf(NavDestinations.AllApps(context.getString(R.string.app_list_all)), NavDestinations.About) fun EntryProviderScope.extraNavigationEntries(navigator: Navigator) {} diff --git a/app/src/full/AndroidManifest.xml b/app/src/full/AndroidManifest.xml index 4f8cd7354..a6b88dcc3 100644 --- a/app/src/full/AndroidManifest.xml +++ b/app/src/full/AndroidManifest.xml @@ -2,7 +2,58 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/full/assets/index.template.html b/app/src/full/assets/index.template.html new file mode 100644 index 000000000..5d8d08e11 --- /dev/null +++ b/app/src/full/assets/index.template.html @@ -0,0 +1,138 @@ + + + + + F-Droid swap + + + + + + + + + + +

You're minutes away from having swap success!

+
    +
  1. + Find a swap + Done +
  2. +
  3. + Download F-Droid + Not done +
  4. +
  5. + Install F-Droid + Not done +
  6. +
  7. + Add the swap to F-Droid + Not done +
  8. +
  9. + Install the apps you want + Not done +
  10. +
+



+
+ Available Apps +
    + {{APP_LIST}} +
+
+ + diff --git a/app/src/full/assets/swap-icon.png b/app/src/full/assets/swap-icon.png new file mode 100644 index 000000000..f9a970a8a Binary files /dev/null and b/app/src/full/assets/swap-icon.png differ diff --git a/app/src/full/assets/swap-icon.svg b/app/src/full/assets/swap-icon.svg new file mode 100644 index 000000000..43db30a87 --- /dev/null +++ b/app/src/full/assets/swap-icon.svg @@ -0,0 +1,40 @@ + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + diff --git a/app/src/full/assets/swap-tick-done.png b/app/src/full/assets/swap-tick-done.png new file mode 100644 index 000000000..81a2cb7d8 Binary files /dev/null and b/app/src/full/assets/swap-tick-done.png differ diff --git a/app/src/full/assets/swap-tick-not-done.png b/app/src/full/assets/swap-tick-not-done.png new file mode 100644 index 000000000..9046ea31f Binary files /dev/null and b/app/src/full/assets/swap-tick-not-done.png differ diff --git a/app/src/full/java/cc/mvdan/accesspoint/WifiApControl.java b/app/src/full/java/cc/mvdan/accesspoint/WifiApControl.java new file mode 100644 index 000000000..71a2cf2b4 --- /dev/null +++ b/app/src/full/java/cc/mvdan/accesspoint/WifiApControl.java @@ -0,0 +1,417 @@ +/** + * Copyright 2015 Daniel Martí + * + * 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 cc.mvdan.accesspoint; + +import android.content.Context; +import android.net.wifi.WifiConfiguration; +import android.net.wifi.WifiManager; +import android.provider.Settings; +import android.util.Log; + +import androidx.annotation.Nullable; + +import org.fdroid.BuildConfig; + +import java.io.BufferedReader; +import java.io.FileReader; +import java.io.IOException; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.net.Inet4Address; +import java.net.Inet6Address; +import java.net.InetAddress; +import java.net.NetworkInterface; +import java.util.ArrayList; +import java.util.Enumeration; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.regex.Pattern; + +/** + * WifiApControl provides control over Wi-Fi APs using the singleton pattern. + * Even though isSupported should be reliable, the underlying hidden APIs that + * are obtained via reflection to provide the main features may not work as + * expected. + *

+ * TODO Note that this project is **abandoned** since its method doesn't work on Android + * 7.1 or later. Have a look at these newer alternatives that have been tested to + * work on Android 8.0: + * + * @see shinilms/direct-net-share + * @see geekywoman/direct-net-share + * @see aegis1980/WifiHotSpot + */ +final public class WifiApControl { + + private static final String TAG = "WifiApControl"; + + private static Method getWifiApConfigurationMethod; + private static Method getWifiApStateMethod; + private static Method isWifiApEnabledMethod; + private static Method setWifiApEnabledMethod; + + public static final int WIFI_AP_STATE_DISABLING = 10; + public static final int WIFI_AP_STATE_DISABLED = 11; + public static final int WIFI_AP_STATE_ENABLING = 12; + public static final int WIFI_AP_STATE_ENABLED = 13; + public static final int WIFI_AP_STATE_FAILED = 14; + + public static final int STATE_DISABLING = WIFI_AP_STATE_DISABLING; + public static final int STATE_DISABLED = WIFI_AP_STATE_DISABLED; + public static final int STATE_ENABLING = WIFI_AP_STATE_ENABLING; + public static final int STATE_ENABLED = WIFI_AP_STATE_ENABLED; + public static final int STATE_FAILED = WIFI_AP_STATE_FAILED; + + private static boolean isSoftwareSupported() { + return (getWifiApStateMethod != null + && isWifiApEnabledMethod != null + && setWifiApEnabledMethod != null + && getWifiApConfigurationMethod != null); + } + + private static boolean isHardwareSupported() { + // TODO: implement via native code + return true; + } + + // isSupported reports whether Wi-Fi APs are supported by this device. + public static boolean isSupported() { + return isSoftwareSupported() && isHardwareSupported(); + } + + private static final String FALLBACK_DEVICE = "wlan0"; + + private final WifiManager wm; + private final String deviceName; + + private static WifiApControl instance = null; + + private WifiApControl(Context context) { + wm = (WifiManager) context.getSystemService(Context.WIFI_SERVICE); + deviceName = getDeviceName(wm); + } + + // getInstance is a standard singleton instance getter, constructing + // the actual class when first called. + @Nullable + public static WifiApControl getInstance(Context context) { + if (instance == null) { + if (!Settings.System.canWrite(context)) { + Log.e(TAG, "6.0 or later, but haven't been granted WRITE_SETTINGS!"); + return null; + } + try { + for (Method method : WifiManager.class.getDeclaredMethods()) { + switch (method.getName()) { + case "getWifiApConfiguration": + getWifiApConfigurationMethod = method; + break; + case "getWifiApState": + getWifiApStateMethod = method; + break; + case "isWifiApEnabled": + isWifiApEnabledMethod = method; + break; + case "setWifiApEnabled": + setWifiApEnabledMethod = method; + break; + } + } + instance = new WifiApControl(context); + instance.isEnabled(); // make sure this instance works + } catch (Throwable e) { + if (BuildConfig.DEBUG) { + throw e; + } + Log.e(TAG, "WifiManager failed to init", e); + return null; + } + } + return instance; + } + + private static String getDeviceName(WifiManager wifiManager) { + Log.w(TAG, "6.0 or later, unaccessible MAC - falling back to the default device name: " + FALLBACK_DEVICE); + return FALLBACK_DEVICE; + } + + private static Object invokeQuietly(Method method, Object receiver, Object... args) { + try { + return method.invoke(receiver, args); + } catch (IllegalAccessException | IllegalArgumentException | InvocationTargetException e) { + Log.e(TAG, "", e); + } + return null; + } + + // isWifiApEnabled returns whether the Wi-Fi AP is currently enabled. + // If an error occurred invoking the method via reflection, false is + // returned. + public boolean isWifiApEnabled() { + Object result = invokeQuietly(isWifiApEnabledMethod, wm); + if (result == null) { + return false; + } + return (Boolean) result; + } + + // isEnabled is a commodity function alias for isWifiApEnabled. + public boolean isEnabled() { + return isWifiApEnabled(); + } + + // newStateNumber adapts the state constants to the current values in + // the SDK. They were changed on 4.0 to have higher integer values. + public static int newStateNumber(int state) { + if (state < 10) { + return state + 10; + } + return state; + } + + // getWifiApState returns the current Wi-Fi AP state. + // If an error occurred invoking the method via reflection, -1 is + // returned. + public int getWifiApState() { + Object result = invokeQuietly(getWifiApStateMethod, wm); + if (result == null) { + return -1; + } + return newStateNumber((Integer) result); + } + + // getState is a commodity function alias for getWifiApState. + public int getState() { + return getWifiApState(); + } + + // getWifiApConfiguration returns the current Wi-Fi AP configuration. + // If an error occurred invoking the method via reflection, null is + // returned. + public WifiConfiguration getWifiApConfiguration() { + Object result = invokeQuietly(getWifiApConfigurationMethod, wm); + if (result == null) { + return null; + } + return (WifiConfiguration) result; + } + + // getConfiguration is a commodity function alias for + // getWifiApConfiguration. + public WifiConfiguration getConfiguration() { + return getWifiApConfiguration(); + } + + // setWifiApEnabled starts a Wi-Fi AP with the specified + // configuration. If one is already running, start using the new + // configuration. You should call WifiManager.setWifiEnabled(false) + // yourself before calling this method. + // If an error occurred invoking the method via reflection, false is + // returned. + public boolean setWifiApEnabled(WifiConfiguration config, boolean enabled) { + Object result = invokeQuietly(setWifiApEnabledMethod, wm, config, enabled); + if (result == null) { + return false; + } + return (Boolean) result; + } + + // setEnabled is a commodity function alias for setWifiApEnabled. + public boolean setEnabled(WifiConfiguration config, boolean enabled) { + return setWifiApEnabled(config, enabled); + } + + // enable starts the currently configured Wi-Fi AP. + public boolean enable() { + return setEnabled(getConfiguration(), true); + } + + // disable stops any currently running Wi-Fi AP. + public boolean disable() { + return setEnabled(null, false); + } + + // getInet6Address returns the IPv6 address that the device has in its + // own Wi-Fi AP local network. Will return null if no Wi-Fi AP is + // currently enabled. + public Inet6Address getInet6Address() { + if (!isEnabled()) { + return null; + } + return getInetAddress(Inet6Address.class); + } + + // getInet4Address returns the IPv4 address that the device has in its + // own Wi-Fi AP local network. Will return null if no Wi-Fi AP is + // currently enabled. + public Inet4Address getInet4Address() { + if (!isEnabled()) { + return null; + } + return getInetAddress(Inet4Address.class); + } + + + private T getInetAddress(Class addressType) { + try { + Enumeration ifaces = NetworkInterface.getNetworkInterfaces(); + while (ifaces.hasMoreElements()) { + NetworkInterface iface = ifaces.nextElement(); + + if (!iface.getName().equals(deviceName)) { + continue; + } + + Enumeration addrs = iface.getInetAddresses(); + while (addrs.hasMoreElements()) { + InetAddress addr = addrs.nextElement(); + + if (addressType.isInstance(addr)) { + return addressType.cast(addr); + } + } + } + } catch (IOException e) { + Log.e(TAG, "", e); + } + return null; + } + + // Client describes a Wi-Fi AP device connected to the network. + public static class Client { + + // ipAddr is the raw string of the IP Address client + public String ipAddr; + + // hwAddr is the raw string of the MAC of the client + public String hwAddr; + + public Client(String ipAddr, String hwAddr) { + this.ipAddr = ipAddr; + this.hwAddr = hwAddr; + } + } + + // getClients returns a list of all clients connected to the network. + // Since the information is pulled from ARP, which is cached for up to + // five minutes, this method may yield clients that disconnected + // minutes ago. + public List getClients() { + if (!isEnabled()) { + return null; + } + List result = new ArrayList<>(); + + // Basic sanity checks + Pattern macPattern = Pattern.compile("..:..:..:..:..:.."); + + BufferedReader br = null; + try { + br = new BufferedReader(new FileReader("/proc/net/arp")); + String line; + while ((line = br.readLine()) != null) { + String[] parts = line.split(" +"); + if (parts.length < 6) { + continue; + } + + String ipAddr = parts[0]; + String hwAddr = parts[3]; + String device = parts[5]; + + if (!device.equals(deviceName)) { + continue; + } + + if (!macPattern.matcher(parts[3]).find()) { + continue; + } + + result.add(new Client(ipAddr, hwAddr)); + } + } catch (IOException e) { + Log.e(TAG, "", e); + } finally { + try { + if (br != null) { + br.close(); + } + } catch (IOException e) { + Log.e(TAG, "", e); + } + } + + return result; + } + + // ReachableClientListener is an interface to collect the results + // provided by getReachableClients via callbacks. + public interface ReachableClientListener { + + // onReachableClient is called each time a reachable client is + // found. + void onReachableClient(Client c); + + // onComplete is called when we are done looking for reachable + // clients + void onComplete(); + } + + // getReachableClients fetches the clients connected to the network + // much like getClients, but only those which are reachable. Since + // checking for reachability requires network I/O, the reachable + // clients are returned via callbacks. All the clients are returned + // like in getClients so that the callback returns a subset of the + // same objects. + public List getReachableClients(final int timeout, + final ReachableClientListener listener) { + List clients = getClients(); + if (clients == null) { + return null; + } + final CountDownLatch latch = new CountDownLatch(clients.size()); + ExecutorService es = Executors.newCachedThreadPool(); + for (final Client c : clients) { + es.submit(new Runnable() { + public void run() { + try { + InetAddress ip = InetAddress.getByName(c.ipAddr); + if (ip.isReachable(timeout)) { + listener.onReachableClient(c); + } + } catch (IOException e) { + Log.e(TAG, "", e); + } + latch.countDown(); + } + }); + } + new Thread() { + public void run() { + try { + latch.await(); + } catch (InterruptedException e) { + Log.e(TAG, "", e); + } + listener.onComplete(); + } + }.start(); + return clients; + } +} diff --git a/app/src/full/java/com/google/zxing/encode/Contents.java b/app/src/full/java/com/google/zxing/encode/Contents.java new file mode 100755 index 000000000..aa5523fe9 --- /dev/null +++ b/app/src/full/java/com/google/zxing/encode/Contents.java @@ -0,0 +1,113 @@ +/* + * Copyright (C) 2008 ZXing authors + * + * 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 + * + * https://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.zxing.encode; + +import android.provider.ContactsContract; + +/** + * The set of constants to use when sending Barcode Scanner an Intent which requests a barcode + * to be encoded. + * + * @author dswitkin@google.com (Daniel Switkin) + */ +public final class Contents { + private Contents() { + } + + public static final class Type { + /** + * Plain text. Use Intent.putExtra(DATA, string). This can be used for URLs too, but string + * must include "http://" or "https://". + */ + public static final String TEXT = "TEXT_TYPE"; + + /** + * An email type. Use Intent.putExtra(DATA, string) where string is the email address. + */ + public static final String EMAIL = "EMAIL_TYPE"; + + /** + * Use Intent.putExtra(DATA, string) where string is the phone number to call. + */ + public static final String PHONE = "PHONE_TYPE"; + + /** + * An SMS type. Use Intent.putExtra(DATA, string) where string is the number to SMS. + */ + public static final String SMS = "SMS_TYPE"; + + /** + * A contact. Send a request to encode it as follows: + *

+ * import android.provider.Contacts; + *

+ * Intent intent = new Intent(Intents.Encode.ACTION); + * intent.putExtra(Intents.Encode.TYPE, CONTACT); + * Bundle bundle = new Bundle(); + * bundle.putString(Contacts.Intents.Insert.NAME, "Jenny"); + * bundle.putString(Contacts.Intents.Insert.PHONE, "8675309"); + * bundle.putString(Contacts.Intents.Insert.EMAIL, "jenny@the80s.com"); + * bundle.putString(Contacts.Intents.Insert.POSTAL, "123 Fake St. San Francisco, CA 94102"); + * intent.putExtra(Intents.Encode.DATA, bundle); + */ + public static final String CONTACT = "CONTACT_TYPE"; + + /** + * A geographic location. Use as follows: + * Bundle bundle = new Bundle(); + * bundle.putFloat("LAT", latitude); + * bundle.putFloat("LONG", longitude); + * intent.putExtra(Intents.Encode.DATA, bundle); + */ + public static final String LOCATION = "LOCATION_TYPE"; + + private Type() { + } + } + + public static final String URL_KEY = "URL_KEY"; + + public static final String NOTE_KEY = "NOTE_KEY"; + + /** + * When using Type.CONTACT, these arrays provide the keys for adding or retrieving multiple + * phone numbers and addresses. + */ + public static final String[] PHONE_KEYS = { + ContactsContract.Intents.Insert.PHONE, + ContactsContract.Intents.Insert.SECONDARY_PHONE, + ContactsContract.Intents.Insert.TERTIARY_PHONE, + }; + + public static final String[] PHONE_TYPE_KEYS = { + ContactsContract.Intents.Insert.PHONE_TYPE, + ContactsContract.Intents.Insert.SECONDARY_PHONE_TYPE, + ContactsContract.Intents.Insert.TERTIARY_PHONE_TYPE, + }; + + public static final String[] EMAIL_KEYS = { + ContactsContract.Intents.Insert.EMAIL, + ContactsContract.Intents.Insert.SECONDARY_EMAIL, + ContactsContract.Intents.Insert.TERTIARY_EMAIL, + }; + + public static final String[] EMAIL_TYPE_KEYS = { + ContactsContract.Intents.Insert.EMAIL_TYPE, + ContactsContract.Intents.Insert.SECONDARY_EMAIL_TYPE, + ContactsContract.Intents.Insert.TERTIARY_EMAIL_TYPE, + }; +} diff --git a/app/src/full/java/com/google/zxing/encode/QRCodeEncoder.java b/app/src/full/java/com/google/zxing/encode/QRCodeEncoder.java new file mode 100755 index 000000000..2acdc44fa --- /dev/null +++ b/app/src/full/java/com/google/zxing/encode/QRCodeEncoder.java @@ -0,0 +1,261 @@ +/* + * Copyright (C) 2008 ZXing authors + * + * 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 + * + * https://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. + */ +// from https://stackoverflow.com/questions/4782543/integration-zxing-library-directly-into-my-android-application + +package com.google.zxing.encode; + +import android.graphics.Bitmap; +import android.os.Bundle; +import android.provider.ContactsContract; +import android.telephony.PhoneNumberUtils; + +import com.google.zxing.BarcodeFormat; +import com.google.zxing.EncodeHintType; +import com.google.zxing.MultiFormatWriter; +import com.google.zxing.WriterException; +import com.google.zxing.common.BitMatrix; + +import java.util.Collection; +import java.util.EnumMap; +import java.util.HashSet; +import java.util.Map; + +public final class QRCodeEncoder { + private static final int WHITE = 0xFFFFFFFF; + private static final int BLACK = 0xFF000000; + + private final int dimension; + private String contents; + private String displayContents; + private String title; + private BarcodeFormat format; + private final boolean encoded; + + public QRCodeEncoder(String data, Bundle bundle, String type, String format, int dimension) { + this.dimension = dimension; + encoded = encodeContents(data, bundle, type, format); + } + + public String getContents() { + return contents; + } + + public String getDisplayContents() { + return displayContents; + } + + public String getTitle() { + return title; + } + + private boolean encodeContents(String data, Bundle bundle, String type, String formatString) { + // Default to QR_CODE if no format given. + format = null; + if (formatString != null) { + try { + format = BarcodeFormat.valueOf(formatString); + } catch (IllegalArgumentException iae) { + // Ignore it then + } + } + if (format == null || format == BarcodeFormat.QR_CODE) { + this.format = BarcodeFormat.QR_CODE; + encodeQRCodeContents(data, bundle, type); + } else if (data != null && data.length() > 0) { + contents = data; + displayContents = data; + title = "Text"; + } + return contents != null && contents.length() > 0; + } + + private void encodeQRCodeContents(String data, Bundle bundle, String type) { + switch (type) { + case Contents.Type.TEXT: + if (data != null && data.length() > 0) { + contents = data; + displayContents = data; + title = "Text"; + } + break; + case Contents.Type.EMAIL: + data = trim(data); + if (data != null) { + contents = "mailto:" + data; + displayContents = data; + title = "E-Mail"; + } + break; + case Contents.Type.PHONE: + data = trim(data); + if (data != null) { + contents = "tel:" + data; + displayContents = PhoneNumberUtils.formatNumber(data); + title = "Phone"; + } + break; + case Contents.Type.SMS: + data = trim(data); + if (data != null) { + contents = "sms:" + data; + displayContents = PhoneNumberUtils.formatNumber(data); + title = "SMS"; + } + break; + case Contents.Type.CONTACT: + if (bundle != null) { + StringBuilder newContents = new StringBuilder(100); + StringBuilder newDisplayContents = new StringBuilder(100); + + newContents.append("MECARD:"); + + String name = trim(bundle.getString(ContactsContract.Intents.Insert.NAME)); + if (name != null) { + newContents.append("N:").append(escapeMECARD(name)).append(';'); + newDisplayContents.append(name); + } + + String address = trim(bundle.getString(ContactsContract.Intents.Insert.POSTAL)); + if (address != null) { + newContents.append("ADR:").append(escapeMECARD(address)).append(';'); + newDisplayContents.append('\n').append(address); + } + + Collection uniquePhones = new HashSet<>(Contents.PHONE_KEYS.length); + for (int x = 0; x < Contents.PHONE_KEYS.length; x++) { + String phone = trim(bundle.getString(Contents.PHONE_KEYS[x])); + if (phone != null) { + uniquePhones.add(phone); + } + } + for (String phone : uniquePhones) { + newContents.append("TEL:").append(escapeMECARD(phone)).append(';'); + newDisplayContents.append('\n').append(PhoneNumberUtils.formatNumber(phone)); + } + + Collection uniqueEmails = new HashSet<>(Contents.EMAIL_KEYS.length); + for (int x = 0; x < Contents.EMAIL_KEYS.length; x++) { + String email = trim(bundle.getString(Contents.EMAIL_KEYS[x])); + if (email != null) { + uniqueEmails.add(email); + } + } + for (String email : uniqueEmails) { + newContents.append("EMAIL:").append(escapeMECARD(email)).append(';'); + newDisplayContents.append('\n').append(email); + } + + String url = trim(bundle.getString(Contents.URL_KEY)); + if (url != null) { + // escapeMECARD(url) -> wrong escape e.g. http\://zxing.google.com + newContents.append("URL:").append(url).append(';'); + newDisplayContents.append('\n').append(url); + } + + String note = trim(bundle.getString(Contents.NOTE_KEY)); + if (note != null) { + newContents.append("NOTE:").append(escapeMECARD(note)).append(';'); + newDisplayContents.append('\n').append(note); + } + + // Make sure we've encoded at least one field. + if (newDisplayContents.length() > 0) { + newContents.append(';'); + contents = newContents.toString(); + displayContents = newDisplayContents.toString(); + title = "Contact"; + } else { + contents = null; + displayContents = null; + } + } + break; + case Contents.Type.LOCATION: + if (bundle != null) { + // These must use Bundle.getFloat(), not getDouble(), it's part of the API. + float latitude = bundle.getFloat("LAT", Float.MAX_VALUE); + float longitude = bundle.getFloat("LONG", Float.MAX_VALUE); + if (latitude != Float.MAX_VALUE && longitude != Float.MAX_VALUE) { + contents = "geo:" + latitude + ',' + longitude; + displayContents = latitude + "," + longitude; + title = "Location"; + } + } + break; + } + } + + public Bitmap encodeAsBitmap() throws WriterException { + if (!encoded) return null; + + Map hints = null; + String encoding = guessAppropriateEncoding(contents); + if (encoding != null) { + hints = new EnumMap<>(EncodeHintType.class); + hints.put(EncodeHintType.CHARACTER_SET, encoding); + } + MultiFormatWriter writer = new MultiFormatWriter(); + BitMatrix result = writer.encode(contents, format, dimension, dimension, hints); + int width = result.getWidth(); + int height = result.getHeight(); + int[] pixels = new int[width * height]; + // All are 0, or black, by default + for (int y = 0; y < height; y++) { + int offset = y * width; + for (int x = 0; x < width; x++) { + pixels[offset + x] = result.get(x, y) ? BLACK : WHITE; + } + } + + Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); + bitmap.setPixels(pixels, 0, width, 0, 0, width, height); + return bitmap; + } + + private static String guessAppropriateEncoding(CharSequence contents) { + // Very crude at the moment + for (int i = 0; i < contents.length(); i++) { + if (contents.charAt(i) > 0xFF) { + return "UTF-8"; + } + } + return null; + } + + private static String trim(String s) { + if (s == null) { + return null; + } + String result = s.trim(); + return result.length() == 0 ? null : result; + } + + private static String escapeMECARD(String input) { + if (input == null || (input.indexOf(':') < 0 && input.indexOf(';') < 0)) { + return input; + } + int length = input.length(); + StringBuilder result = new StringBuilder(length); + for (int i = 0; i < length; i++) { + char c = input.charAt(i); + if (c == ':' || c == ';') { + result.append('\\'); + } + result.append(c); + } + return result.toString(); + } +} diff --git a/app/src/full/java/javax/jmdns/impl/FDroidServiceInfo.java b/app/src/full/java/javax/jmdns/impl/FDroidServiceInfo.java new file mode 100644 index 000000000..e83af0fda --- /dev/null +++ b/app/src/full/java/javax/jmdns/impl/FDroidServiceInfo.java @@ -0,0 +1,122 @@ +package javax.jmdns.impl; + +import android.os.Parcel; +import android.os.Parcelable; +import android.text.TextUtils; + +import java.net.Inet4Address; +import java.net.Inet6Address; +import java.net.UnknownHostException; + +import javax.jmdns.ServiceInfo; +import javax.jmdns.impl.util.ByteWrangler; + +/** + * The ServiceInfo class needs to be serialized in order to be sent as an Android broadcast. + * In order to make it Parcelable (or Serializable for that matter), there are some package-scope + * methods which needed to be used. Thus, this class is in the javax.jmdns.impl package so that + * it can access those methods. This is as an alternative to modifying the source code of JmDNS. + */ +public class FDroidServiceInfo extends ServiceInfoImpl implements Parcelable { + + public FDroidServiceInfo(ServiceInfo info) { + super(info); + } + + /** + * Return the fingerprint of the signing key, or {@code null} if it is not set. + */ + public String getFingerprint() { + // getPropertyString() will return "true" if the value is a zero-length byte array + // so we just do a custom version using getPropertyBytes() + byte[] data = getPropertyBytes("fingerprint"); + if (data == null || data.length == 0) { + return null; + } + String fingerprint = ByteWrangler.readUTF(data, 0, data.length); + if (TextUtils.isEmpty(fingerprint)) { + return null; + } + return fingerprint; + } + + public String getRepoAddress() { + return getURL(); // Automatically appends the "path" property if present, so no need to do it ourselves. + } + + private static byte[] readBytes(Parcel in) { + byte[] bytes = new byte[in.readInt()]; + in.readByteArray(bytes); + return bytes; + } + + public FDroidServiceInfo(Parcel in) { + super( + in.readString(), + in.readString(), + in.readString(), + in.readInt(), + in.readInt(), + in.readInt(), + in.readByte() != 0, + readBytes(in)); + + int addressCount = in.readInt(); + for (int i = 0; i < addressCount; i++) { + try { + addAddress((Inet4Address) Inet4Address.getByAddress(readBytes(in))); + } catch (UnknownHostException e) { + e.printStackTrace(); + } + } + + addressCount = in.readInt(); + for (int i = 0; i < addressCount; i++) { + try { + addAddress((Inet6Address) Inet6Address.getByAddress(readBytes(in))); + } catch (UnknownHostException e) { + e.printStackTrace(); + } + } + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(getType()); + dest.writeString(getName()); + dest.writeString(getSubtype()); + dest.writeInt(getPort()); + dest.writeInt(getWeight()); + dest.writeInt(getPriority()); + dest.writeByte(isPersistent() ? (byte) 1 : (byte) 0); + dest.writeInt(getTextBytes().length); + dest.writeByteArray(getTextBytes()); + dest.writeInt(getInet4Addresses().length); + for (int i = 0; i < getInet4Addresses().length; i++) { + Inet4Address address = getInet4Addresses()[i]; + dest.writeInt(address.getAddress().length); + dest.writeByteArray(address.getAddress()); + } + dest.writeInt(getInet6Addresses().length); + for (int i = 0; i < getInet6Addresses().length; i++) { + Inet6Address address = getInet6Addresses()[i]; + dest.writeInt(address.getAddress().length); + dest.writeByteArray(address.getAddress()); + } + } + + public static final Creator CREATOR = new Creator() { + public FDroidServiceInfo createFromParcel(Parcel source) { + return new FDroidServiceInfo(source); + } + + public FDroidServiceInfo[] newArray(int size) { + return new FDroidServiceInfo[size]; + } + }; +} diff --git a/app/src/full/java/kellinwood/logging/AbstractLogger.java b/app/src/full/java/kellinwood/logging/AbstractLogger.java new file mode 100644 index 000000000..c2c3e4e0c --- /dev/null +++ b/app/src/full/java/kellinwood/logging/AbstractLogger.java @@ -0,0 +1,94 @@ +/* + * Copyright (C) 2010 Ken Ellinwood. + * + * 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 kellinwood.logging; + +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; + +public abstract class AbstractLogger implements LoggerInterface { + + protected String category; + + SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd hh:mm:ss", Locale.ENGLISH); + + public AbstractLogger(String category) { + this.category = category; + } + + protected String format(String level, String message) { + return String.format("%s %s %s: %s\n", dateFormat.format(new Date()), level, category, message); + } + + protected abstract void write(String level, String message, Throwable t); + + protected void writeFixNullMessage(String level, String message, Throwable t) { + if (message == null) { + if (t != null) message = t.getClass().getName(); + else message = "null"; + } + write(level, message, t); + } + + public void debug(String message, Throwable t) { + writeFixNullMessage(DEBUG, message, t); + } + + public void debug(String message) { + writeFixNullMessage(DEBUG, message, null); + } + + public void error(String message, Throwable t) { + writeFixNullMessage(ERROR, message, t); + } + + public void error(String message) { + writeFixNullMessage(ERROR, message, null); + } + + public void info(String message, Throwable t) { + writeFixNullMessage(INFO, message, t); + } + + public void info(String message) { + writeFixNullMessage(INFO, message, null); + } + + public void warning(String message, Throwable t) { + writeFixNullMessage(WARNING, message, t); + } + + public void warning(String message) { + writeFixNullMessage(WARNING, message, null); + } + + public boolean isDebugEnabled() { + return true; + } + + public boolean isErrorEnabled() { + return true; + } + + public boolean isInfoEnabled() { + return true; + } + + public boolean isWarningEnabled() { + return true; + } +} diff --git a/app/src/full/java/kellinwood/logging/ConsoleLoggerFactory.java b/app/src/full/java/kellinwood/logging/ConsoleLoggerFactory.java new file mode 100644 index 000000000..1d4b31aa2 --- /dev/null +++ b/app/src/full/java/kellinwood/logging/ConsoleLoggerFactory.java @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2010 Ken Ellinwood. + * + * 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 kellinwood.logging; + +public class ConsoleLoggerFactory implements LoggerFactory { + + public LoggerInterface getLogger(String category) { + return new StreamLogger(category, System.out); + } +} diff --git a/app/src/full/java/kellinwood/logging/LoggerFactory.java b/app/src/full/java/kellinwood/logging/LoggerFactory.java new file mode 100644 index 000000000..fe15cab94 --- /dev/null +++ b/app/src/full/java/kellinwood/logging/LoggerFactory.java @@ -0,0 +1,22 @@ +/* + * Copyright (C) 2010 Ken Ellinwood. + * + * 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 kellinwood.logging; + +public interface LoggerFactory { + + public LoggerInterface getLogger(String category); +} diff --git a/app/src/full/java/kellinwood/logging/LoggerInterface.java b/app/src/full/java/kellinwood/logging/LoggerInterface.java new file mode 100644 index 000000000..0c7d86f60 --- /dev/null +++ b/app/src/full/java/kellinwood/logging/LoggerInterface.java @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2010 Ken Ellinwood. + * + * 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 kellinwood.logging; + +public interface LoggerInterface { + + public static final String ERROR = "ERROR"; + public static final String WARNING = "WARNING"; + public static final String INFO = "INFO"; + public static final String DEBUG = "DEBUG"; + + public boolean isErrorEnabled(); + + public void error(String message); + + public void error(String message, Throwable t); + + public boolean isWarningEnabled(); + + public void warning(String message); + + public void warning(String message, Throwable t); + + public boolean isInfoEnabled(); + + public void info(String message); + + public void info(String message, Throwable t); + + public boolean isDebugEnabled(); + + public void debug(String message); + + public void debug(String message, Throwable t); +} diff --git a/app/src/full/java/kellinwood/logging/LoggerManager.java b/app/src/full/java/kellinwood/logging/LoggerManager.java new file mode 100644 index 000000000..c71987cde --- /dev/null +++ b/app/src/full/java/kellinwood/logging/LoggerManager.java @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2010 Ken Ellinwood. + * + * 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 kellinwood.logging; + +import java.util.Map; +import java.util.TreeMap; + +public class LoggerManager { + + static LoggerFactory factory = new NullLoggerFactory(); + + static Map loggers = new TreeMap(); + + public static void setLoggerFactory(LoggerFactory f) { + factory = f; + } + + public static LoggerInterface getLogger(String category) { + + LoggerInterface logger = loggers.get(category); + if (logger == null) { + logger = factory.getLogger(category); + loggers.put(category, logger); + } + return logger; + } +} diff --git a/app/src/full/java/kellinwood/logging/NullLoggerFactory.java b/app/src/full/java/kellinwood/logging/NullLoggerFactory.java new file mode 100644 index 000000000..d056a402a --- /dev/null +++ b/app/src/full/java/kellinwood/logging/NullLoggerFactory.java @@ -0,0 +1,69 @@ +/* + * Copyright (C) 2010 Ken Ellinwood. + * + * 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 kellinwood.logging; + +public class NullLoggerFactory implements LoggerFactory { + + static LoggerInterface logger = new LoggerInterface() { + + public void debug(String message) { + } + + public void debug(String message, Throwable t) { + } + + public void error(String message) { + } + + public void error(String message, Throwable t) { + } + + public void info(String message) { + } + + public void info(String message, Throwable t) { + } + + public boolean isDebugEnabled() { + return false; + } + + public boolean isErrorEnabled() { + return false; + } + + public boolean isInfoEnabled() { + return false; + } + + public boolean isWarningEnabled() { + return false; + } + + public void warning(String message) { + } + + public void warning(String message, Throwable t) { + } + + }; + + + public LoggerInterface getLogger(String category) { + return logger; + } +} diff --git a/app/src/full/java/kellinwood/logging/StreamLogger.java b/app/src/full/java/kellinwood/logging/StreamLogger.java new file mode 100644 index 000000000..e5c02257f --- /dev/null +++ b/app/src/full/java/kellinwood/logging/StreamLogger.java @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2010 Ken Ellinwood. + * + * 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 kellinwood.logging; + +import java.io.PrintStream; + +public class StreamLogger extends AbstractLogger { + + PrintStream out; + + public StreamLogger(String category, PrintStream out) { + super(category); + this.out = out; + } + + @Override + protected void write(String level, String message, Throwable t) { + out.print(format(level, message)); + if (t != null) t.printStackTrace(out); + } +} diff --git a/app/src/full/java/kellinwood/security/zipsigner/AutoKeyException.java b/app/src/full/java/kellinwood/security/zipsigner/AutoKeyException.java new file mode 100644 index 000000000..b3107843e --- /dev/null +++ b/app/src/full/java/kellinwood/security/zipsigner/AutoKeyException.java @@ -0,0 +1,14 @@ +package kellinwood.security.zipsigner; + +public class AutoKeyException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + public AutoKeyException(String message) { + super(message); + } + + public AutoKeyException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/app/src/full/java/kellinwood/security/zipsigner/DefaultResourceAdapter.java b/app/src/full/java/kellinwood/security/zipsigner/DefaultResourceAdapter.java new file mode 100644 index 000000000..aa77dc6f4 --- /dev/null +++ b/app/src/full/java/kellinwood/security/zipsigner/DefaultResourceAdapter.java @@ -0,0 +1,34 @@ +package kellinwood.security.zipsigner; + +import java.util.Locale; + +/** + * Default resource adapter. + */ +public class DefaultResourceAdapter implements ResourceAdapter { + + @Override + public String getString(Item item, Object... args) { + + switch (item) { + case INPUT_SAME_AS_OUTPUT_ERROR: + return "Input and output files are the same. Specify a different name for the output."; + case AUTO_KEY_SELECTION_ERROR: + return "Unable to auto-select key for signing " + args[0]; + case LOADING_CERTIFICATE_AND_KEY: + return "Loading certificate and private key"; + case PARSING_CENTRAL_DIRECTORY: + return "Parsing the input's central directory"; + case GENERATING_MANIFEST: + return "Generating manifest"; + case GENERATING_SIGNATURE_FILE: + return "Generating signature file"; + case GENERATING_SIGNATURE_BLOCK: + return "Generating signature block file"; + case COPYING_ZIP_ENTRY: + return String.format(Locale.ENGLISH, "Copying zip entry %d of %d", args[0], args[1]); + default: + throw new IllegalArgumentException("Unknown item " + item); + } + } +} diff --git a/app/src/full/java/kellinwood/security/zipsigner/HexDumpEncoder.java b/app/src/full/java/kellinwood/security/zipsigner/HexDumpEncoder.java new file mode 100644 index 000000000..79e5495ae --- /dev/null +++ b/app/src/full/java/kellinwood/security/zipsigner/HexDumpEncoder.java @@ -0,0 +1,72 @@ +/* + * Copyright (C) 2010 Ken Ellinwood. + * + * 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 kellinwood.security.zipsigner; + +import org.bouncycastle.util.encoders.HexEncoder; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; + +/** + * Produces the classic hex dump with an address column, hex data + * section (16 bytes per row) and right-column printable character display. + */ +public class HexDumpEncoder { + + static HexEncoder encoder = new HexEncoder(); + + public static String encode(byte[] data) { + + try { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + encoder.encode(data, 0, data.length, baos); + byte[] hex = baos.toByteArray(); + + StringBuilder hexDumpOut = new StringBuilder(); + for (int i = 0; i < hex.length; i += 32) { + + int max = Math.min(i + 32, hex.length); + + StringBuilder hexOut = new StringBuilder(); + StringBuilder chrOut = new StringBuilder(); + + hexOut.append(String.format("%08x: ", (i / 2))); + + for (int j = i; j < max; j += 2) { + hexOut.append(Character.valueOf((char) hex[j])); + hexOut.append(Character.valueOf((char) hex[j + 1])); + if ((j + 2) % 4 == 0) hexOut.append(' '); + + int dataChar = data[j / 2]; + if (dataChar >= 32 && dataChar < 127) { + chrOut.append(Character.valueOf((char) dataChar)); + } else chrOut.append('.'); + } + + hexDumpOut.append(hexOut.toString()); + for (int k = hexOut.length(); k < 50; k++) hexDumpOut.append(' '); + hexDumpOut.append(" "); + hexDumpOut.append(chrOut); + hexDumpOut.append("\n"); + } + + return hexDumpOut.toString(); + } catch (IOException x) { + throw new IllegalStateException(x.getClass().getName() + ": " + x.getMessage()); + } + } +} \ No newline at end of file diff --git a/app/src/full/java/kellinwood/security/zipsigner/KeySet.java b/app/src/full/java/kellinwood/security/zipsigner/KeySet.java new file mode 100644 index 000000000..cfdeb180f --- /dev/null +++ b/app/src/full/java/kellinwood/security/zipsigner/KeySet.java @@ -0,0 +1,95 @@ +/* + * Copyright (C) 2010 Ken Ellinwood + * + * 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 kellinwood.security.zipsigner; + +import java.security.PrivateKey; +import java.security.cert.X509Certificate; + +public class KeySet { + + String name; + + // certificate + X509Certificate publicKey = null; + + // private key + PrivateKey privateKey = null; + + // signature block template + byte[] sigBlockTemplate = null; + + String signatureAlgorithm = "SHA1withRSA"; + + public KeySet() { + } + + public KeySet(String name, X509Certificate publicKey, PrivateKey privateKey, byte[] sigBlockTemplate) { + this.name = name; + this.publicKey = publicKey; + this.privateKey = privateKey; + this.sigBlockTemplate = sigBlockTemplate; + } + + public KeySet(String name, X509Certificate publicKey, PrivateKey privateKey, String signatureAlgorithm, byte[] sigBlockTemplate) { + this.name = name; + this.publicKey = publicKey; + this.privateKey = privateKey; + if (signatureAlgorithm != null) this.signatureAlgorithm = signatureAlgorithm; + this.sigBlockTemplate = sigBlockTemplate; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public X509Certificate getPublicKey() { + return publicKey; + } + + public void setPublicKey(X509Certificate publicKey) { + this.publicKey = publicKey; + } + + public PrivateKey getPrivateKey() { + return privateKey; + } + + public void setPrivateKey(PrivateKey privateKey) { + this.privateKey = privateKey; + } + + public byte[] getSigBlockTemplate() { + return sigBlockTemplate; + } + + public void setSigBlockTemplate(byte[] sigBlockTemplate) { + this.sigBlockTemplate = sigBlockTemplate; + } + + public String getSignatureAlgorithm() { + return signatureAlgorithm; + } + + public void setSignatureAlgorithm(String signatureAlgorithm) { + if (signatureAlgorithm == null) signatureAlgorithm = "SHA1withRSA"; + else this.signatureAlgorithm = signatureAlgorithm; + } +} diff --git a/app/src/full/java/kellinwood/security/zipsigner/ProgressEvent.java b/app/src/full/java/kellinwood/security/zipsigner/ProgressEvent.java new file mode 100644 index 000000000..eb29ec3e9 --- /dev/null +++ b/app/src/full/java/kellinwood/security/zipsigner/ProgressEvent.java @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2010 Ken Ellinwood. + * + * 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 kellinwood.security.zipsigner; + +public class ProgressEvent { + + public static final int PRORITY_NORMAL = 0; + public static final int PRORITY_IMPORTANT = 1; + + private String message; + private int percentDone; + private int priority; + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public int getPercentDone() { + return percentDone; + } + + public void setPercentDone(int percentDone) { + this.percentDone = percentDone; + } + + public int getPriority() { + return priority; + } + + public void setPriority(int priority) { + this.priority = priority; + } +} diff --git a/app/src/full/java/kellinwood/security/zipsigner/ProgressHelper.java b/app/src/full/java/kellinwood/security/zipsigner/ProgressHelper.java new file mode 100644 index 000000000..41b09f264 --- /dev/null +++ b/app/src/full/java/kellinwood/security/zipsigner/ProgressHelper.java @@ -0,0 +1,80 @@ +/* + * Copyright (C) 2010 Ken Ellinwood. + * + * 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 kellinwood.security.zipsigner; + +import java.util.ArrayList; + +public class ProgressHelper { + + private int progressTotalItems = 0; + private int progressCurrentItem = 0; + private ProgressEvent progressEvent = new ProgressEvent(); + + public void initProgress() { + progressTotalItems = 10000; + progressCurrentItem = 0; + } + + public int getProgressTotalItems() { + return progressTotalItems; + } + + public void setProgressTotalItems(int progressTotalItems) { + this.progressTotalItems = progressTotalItems; + } + + public int getProgressCurrentItem() { + return progressCurrentItem; + } + + public void setProgressCurrentItem(int progressCurrentItem) { + this.progressCurrentItem = progressCurrentItem; + } + + public void progress(int priority, String message) { + + progressCurrentItem += 1; + + int percentDone; + if (progressTotalItems == 0) percentDone = 0; + else percentDone = (100 * progressCurrentItem) / progressTotalItems; + + // Notify listeners here + for (ProgressListener listener : listeners) { + progressEvent.setMessage(message); + progressEvent.setPercentDone(percentDone); + progressEvent.setPriority(priority); + listener.onProgress(progressEvent); + } + } + + private ArrayList listeners = new ArrayList(); + + @SuppressWarnings("unchecked") + public synchronized void addProgressListener(ProgressListener l) { + ArrayList list = (ArrayList) listeners.clone(); + list.add(l); + listeners = list; + } + + @SuppressWarnings("unchecked") + public synchronized void removeProgressListener(ProgressListener l) { + ArrayList list = (ArrayList) listeners.clone(); + list.remove(l); + listeners = list; + } +} diff --git a/app/src/full/java/kellinwood/security/zipsigner/ProgressListener.java b/app/src/full/java/kellinwood/security/zipsigner/ProgressListener.java new file mode 100644 index 000000000..3cb62060a --- /dev/null +++ b/app/src/full/java/kellinwood/security/zipsigner/ProgressListener.java @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2010 Ken Ellinwood. + * + * 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 kellinwood.security.zipsigner; + +public interface ProgressListener { + + /** + * Called to notify the listener that progress has been made during + * the zip signing operation. + */ + public void onProgress(ProgressEvent event); +} \ No newline at end of file diff --git a/app/src/full/java/kellinwood/security/zipsigner/ResourceAdapter.java b/app/src/full/java/kellinwood/security/zipsigner/ResourceAdapter.java new file mode 100644 index 000000000..993749ed3 --- /dev/null +++ b/app/src/full/java/kellinwood/security/zipsigner/ResourceAdapter.java @@ -0,0 +1,20 @@ +package kellinwood.security.zipsigner; + +/** + * Interface to obtain internationalized strings for the progress events. + */ +public interface ResourceAdapter { + + public enum Item { + INPUT_SAME_AS_OUTPUT_ERROR, + AUTO_KEY_SELECTION_ERROR, + LOADING_CERTIFICATE_AND_KEY, + PARSING_CENTRAL_DIRECTORY, + GENERATING_MANIFEST, + GENERATING_SIGNATURE_FILE, + GENERATING_SIGNATURE_BLOCK, + COPYING_ZIP_ENTRY + } + + public String getString(Item item, Object... args); +} diff --git a/app/src/full/java/kellinwood/security/zipsigner/ZipSigner.java b/app/src/full/java/kellinwood/security/zipsigner/ZipSigner.java new file mode 100644 index 000000000..53668efd8 --- /dev/null +++ b/app/src/full/java/kellinwood/security/zipsigner/ZipSigner.java @@ -0,0 +1,780 @@ +/* + * Copyright (C) 2010 Ken Ellinwood + * Copyright (C) 2008 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. + */ + +/* This file is a heavily modified version of com.android.signapk.SignApk.java. + * The changes include: + * - addition of the signZip() convenience methods + * - addition of a progress listener interface + * - removal of main() + * - switch to a signature generation method that verifies + * in Android recovery + * - eliminated dependency on sun.security and sun.misc APIs by + * using signature block template files. + */ + +package kellinwood.security.zipsigner; + +import android.util.Base64; + +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.PrintStream; +import java.lang.reflect.Method; +import java.net.URL; +import java.security.DigestOutputStream; +import java.security.GeneralSecurityException; +import java.security.Key; +import java.security.KeyFactory; +import java.security.KeyStore; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.Provider; +import java.security.Security; +import java.security.Signature; +import java.security.cert.Certificate; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.KeySpec; +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Observable; +import java.util.Observer; +import java.util.TreeMap; +import java.util.jar.Attributes; +import java.util.jar.JarFile; +import java.util.jar.Manifest; +import java.util.regex.Pattern; + +import javax.crypto.Cipher; +import javax.crypto.EncryptedPrivateKeyInfo; +import javax.crypto.SecretKeyFactory; +import javax.crypto.spec.PBEKeySpec; + +import kellinwood.logging.LoggerInterface; +import kellinwood.logging.LoggerManager; +import kellinwood.zipio.ZioEntry; +import kellinwood.zipio.ZipInput; +import kellinwood.zipio.ZipOutput; + +/** + * This is a modified copy of com.android.signapk.SignApk.java. It provides an + * API to sign JAR files (including APKs and Zip/OTA updates) in + * a way compatible with the mincrypt verifier, using SHA1 and RSA keys. + *

+ * Please see the README.txt file in the root of this project for usage instructions. + */ +public class ZipSigner { + + private boolean canceled = false; + + private ProgressHelper progressHelper = new ProgressHelper(); + private ResourceAdapter resourceAdapter = new DefaultResourceAdapter(); + + static LoggerInterface log = null; + + private static final String CERT_SF_NAME = "META-INF/CERT.SF"; + private static final String CERT_RSA_NAME = "META-INF/CERT.RSA"; + + // Files matching this pattern are not copied to the output. + private static Pattern stripPattern = + Pattern.compile("^META-INF/(.*)[.](SF|RSA|DSA)$"); + + Map loadedKeys = new HashMap(); + KeySet keySet = null; + + public static LoggerInterface getLogger() { + if (log == null) log = LoggerManager.getLogger(ZipSigner.class.getName()); + return log; + } + + public static final String MODE_AUTO_TESTKEY = "auto-testkey"; + public static final String MODE_AUTO_NONE = "auto-none"; + public static final String MODE_AUTO = "auto"; + public static final String KEY_NONE = "none"; + public static final String KEY_TESTKEY = "testkey"; + + // Allowable key modes. + public static final String[] SUPPORTED_KEY_MODES = + new String[]{MODE_AUTO_TESTKEY, MODE_AUTO, MODE_AUTO_NONE, "media", "platform", "shared", KEY_TESTKEY, KEY_NONE}; + + String keymode = KEY_TESTKEY; // backwards compatible with versions that only signed with this key + + Map autoKeyDetect = new HashMap(); + + AutoKeyObservable autoKeyObservable = new AutoKeyObservable(); + + public ZipSigner() throws ClassNotFoundException, IllegalAccessException, InstantiationException { + // MD5 of the first 1458 bytes of the signature block generated by the key, mapped to the key name + autoKeyDetect.put("aa9852bc5a53272ac8031d49b65e4b0e", "media"); + autoKeyDetect.put("e60418c4b638f20d0721e115674ca11f", "platform"); + autoKeyDetect.put("3e24e49741b60c215c010dc6048fca7d", "shared"); + autoKeyDetect.put("dab2cead827ef5313f28e22b6fa8479f", "testkey"); + } + + public ResourceAdapter getResourceAdapter() { + return resourceAdapter; + } + + public void setResourceAdapter(ResourceAdapter resourceAdapter) { + this.resourceAdapter = resourceAdapter; + } + + // when the key mode is automatic, the observers are called when the key is determined + public void addAutoKeyObserver(Observer o) { + autoKeyObservable.addObserver(o); + } + + public String getKeymode() { + return keymode; + } + + public void setKeymode(String km) throws IOException, GeneralSecurityException { + if (getLogger().isDebugEnabled()) getLogger().debug("setKeymode: " + km); + keymode = km; + if (keymode.startsWith(MODE_AUTO)) { + keySet = null; + } else { + progressHelper.initProgress(); + loadKeys(keymode); + } + } + + public static String[] getSupportedKeyModes() { + return SUPPORTED_KEY_MODES; + } + + protected String autoDetectKey(String mode, Map zioEntries) + throws NoSuchAlgorithmException, IOException { + boolean debug = getLogger().isDebugEnabled(); + + if (!mode.startsWith(MODE_AUTO)) return mode; + + // Auto-determine which keys to use + String keyName = null; + // Start by finding the signature block file in the input. + for (Map.Entry entry : zioEntries.entrySet()) { + String entryName = entry.getKey(); + if (entryName.startsWith("META-INF/") && entryName.endsWith(".RSA")) { + + // Compute MD5 of the first 1458 bytes, which is the size of our signature block templates -- + // e.g., the portion of the sig block file that is the same for a given certificate. + MessageDigest md5 = MessageDigest.getInstance("MD5"); + byte[] entryData = entry.getValue().getData(); + if (entryData.length < 1458) break; // sig block too short to be a supported key + md5.update(entryData, 0, 1458); + byte[] rawDigest = md5.digest(); + + // Create the hex representation of the digest value + StringBuilder builder = new StringBuilder(); + for (byte b : rawDigest) { + builder.append(String.format("%02x", b)); + } + + String md5String = builder.toString(); + // Lookup the key name + keyName = autoKeyDetect.get(md5String); + + if (debug) { + if (keyName != null) { + getLogger().debug(String.format("Auto-determined key=%s using md5=%s", keyName, md5String)); + } else { + getLogger().debug(String.format("Auto key determination failed for md5=%s", md5String)); + } + } + if (keyName != null) return keyName; + } + } + + if (mode.equals(MODE_AUTO_TESTKEY)) { + // in auto-testkey mode, fallback to the testkey if it couldn't be determined + if (debug) getLogger().debug("Falling back to key=" + keyName); + return KEY_TESTKEY; + + } else if (mode.equals(MODE_AUTO_NONE)) { + // in auto-node mode, simply copy the input to the output when the key can't be determined. + if (debug) getLogger().debug("Unable to determine key, returning: " + KEY_NONE); + return KEY_NONE; + } + + return null; + } + + public void issueLoadingCertAndKeysProgressEvent() { + progressHelper.progress(ProgressEvent.PRORITY_IMPORTANT, resourceAdapter.getString(ResourceAdapter.Item.LOADING_CERTIFICATE_AND_KEY)); + } + + // Loads one of the built-in keys (media, platform, shared, testkey) + public void loadKeys(String name) + throws IOException, GeneralSecurityException { + + keySet = loadedKeys.get(name); + if (keySet != null) return; + + keySet = new KeySet(); + keySet.setName(name); + loadedKeys.put(name, keySet); + + if (KEY_NONE.equals(name)) return; + + issueLoadingCertAndKeysProgressEvent(); + + // load the private key + URL privateKeyUrl = getClass().getResource("/keys/" + name + ".pk8"); + keySet.setPrivateKey(readPrivateKey(privateKeyUrl, null)); + + // load the certificate + URL publicKeyUrl = getClass().getResource("/keys/" + name + ".x509.pem"); + keySet.setPublicKey(readPublicKey(publicKeyUrl)); + + // load the signature block template + URL sigBlockTemplateUrl = getClass().getResource("/keys/" + name + ".sbt"); + if (sigBlockTemplateUrl != null) { + keySet.setSigBlockTemplate(readContentAsBytes(sigBlockTemplateUrl)); + } + } + + public void setKeys(String name, X509Certificate publicKey, PrivateKey privateKey, byte[] signatureBlockTemplate) { + keySet = new KeySet(name, publicKey, privateKey, signatureBlockTemplate); + } + + public void setKeys(String name, X509Certificate publicKey, PrivateKey privateKey, String signatureAlgorithm, byte[] signatureBlockTemplate) { + keySet = new KeySet(name, publicKey, privateKey, signatureAlgorithm, signatureBlockTemplate); + } + + public KeySet getKeySet() { + return keySet; + } + + // Allow the operation to be canceled. + public void cancel() { + canceled = true; + } + + // Allow the instance to sign again if previously canceled. + public void resetCanceled() { + canceled = false; + } + + public boolean isCanceled() { + return canceled; + } + + @SuppressWarnings("unchecked") + public void loadProvider(String providerClassName) + throws ClassNotFoundException, IllegalAccessException, InstantiationException { + Class providerClass = Class.forName(providerClassName); + Provider provider = (Provider) providerClass.newInstance(); + Security.insertProviderAt(provider, 1); + } + + + public X509Certificate readPublicKey(URL publicKeyUrl) + throws IOException, GeneralSecurityException { + InputStream input = publicKeyUrl.openStream(); + try { + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + return (X509Certificate) cf.generateCertificate(input); + } finally { + input.close(); + } + } + + /** + * Decrypt an encrypted PKCS 8 format private key. + *

+ * Based on ghstark's post on Aug 6, 2006 at + * http://forums.sun.com/thread.jspa?threadID=758133&messageID=4330949 + * + * @param encryptedPrivateKey The raw data of the private key + * @param keyPassword the key password + */ + private KeySpec decryptPrivateKey(byte[] encryptedPrivateKey, String keyPassword) + throws GeneralSecurityException { + EncryptedPrivateKeyInfo epkInfo; + try { + epkInfo = new EncryptedPrivateKeyInfo(encryptedPrivateKey); + } catch (IOException ex) { + // Probably not an encrypted key. + return null; + } + + char[] keyPasswd = keyPassword.toCharArray(); + + SecretKeyFactory skFactory = SecretKeyFactory.getInstance(epkInfo.getAlgName()); + Key key = skFactory.generateSecret(new PBEKeySpec(keyPasswd)); + + Cipher cipher = Cipher.getInstance(epkInfo.getAlgName()); + cipher.init(Cipher.DECRYPT_MODE, key, epkInfo.getAlgParameters()); + + try { + return epkInfo.getKeySpec(cipher); + } catch (InvalidKeySpecException ex) { + getLogger().error("signapk: Password for private key may be bad."); + throw ex; + } + } + + /** + * Fetch the content at the specified URL and return it as a byte array. + */ + public byte[] readContentAsBytes(URL contentUrl) throws IOException { + return readContentAsBytes(contentUrl.openStream()); + } + + /** + * Fetch the content from the given stream and return it as a byte array. + */ + public byte[] readContentAsBytes(InputStream input) throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + + byte[] buffer = new byte[2048]; + + int numRead = input.read(buffer); + while (numRead != -1) { + baos.write(buffer, 0, numRead); + numRead = input.read(buffer); + } + + byte[] bytes = baos.toByteArray(); + return bytes; + } + + /** + * Read a PKCS 8 format private key. + */ + public PrivateKey readPrivateKey(URL privateKeyUrl, String keyPassword) + throws IOException, GeneralSecurityException { + DataInputStream input = new DataInputStream(privateKeyUrl.openStream()); + try { + byte[] bytes = readContentAsBytes(input); + + KeySpec spec = decryptPrivateKey(bytes, keyPassword); + if (spec == null) { + spec = new PKCS8EncodedKeySpec(bytes); + } + + try { + return KeyFactory.getInstance("RSA").generatePrivate(spec); + } catch (InvalidKeySpecException ex) { + return KeyFactory.getInstance("DSA").generatePrivate(spec); + } + } finally { + input.close(); + } + } + + /** + * Add the SHA1 of every file to the manifest, creating it if necessary. + */ + private Manifest addDigestsToManifest(Map entries) + throws IOException, GeneralSecurityException { + Manifest input = null; + ZioEntry manifestEntry = entries.get(JarFile.MANIFEST_NAME); + if (manifestEntry != null) { + input = new Manifest(); + input.read(manifestEntry.getInputStream()); + } + Manifest output = new Manifest(); + Attributes main = output.getMainAttributes(); + if (input != null) { + main.putAll(input.getMainAttributes()); + } else { + main.putValue("Manifest-Version", "1.0"); + main.putValue("Created-By", "1.0 (Android SignApk)"); + } + + // BASE64Encoder base64 = new BASE64Encoder(); + MessageDigest md = MessageDigest.getInstance("SHA1"); + byte[] buffer = new byte[512]; + int num; + + // We sort the input entries by name, and add them to the + // output manifest in sorted order. We expect that the output + // map will be deterministic. + + TreeMap byName = new TreeMap(); + byName.putAll(entries); + + boolean debug = getLogger().isDebugEnabled(); + if (debug) getLogger().debug("Manifest entries:"); + for (ZioEntry entry : byName.values()) { + if (canceled) break; + String name = entry.getName(); + if (debug) getLogger().debug(name); + if (!entry.isDirectory() && !name.equals(JarFile.MANIFEST_NAME) && + !name.equals(CERT_SF_NAME) && !name.equals(CERT_RSA_NAME) && + (stripPattern == null || + !stripPattern.matcher(name).matches())) { + + progressHelper.progress(ProgressEvent.PRORITY_NORMAL, resourceAdapter.getString(ResourceAdapter.Item.GENERATING_MANIFEST)); + InputStream data = entry.getInputStream(); + while ((num = data.read(buffer)) > 0) { + md.update(buffer, 0, num); + } + + Attributes attr = null; + if (input != null) { + Attributes inAttr = input.getAttributes(name); + if (inAttr != null) attr = new Attributes(inAttr); + } + if (attr == null) attr = new Attributes(); + attr.putValue("SHA1-Digest", Base64.encodeToString(md.digest(), Base64.NO_WRAP)); + output.getEntries().put(name, attr); + } + } + + return output; + } + + + /** + * Write the signature file to the given output stream. + */ + private void generateSignatureFile(Manifest manifest, OutputStream out) + throws IOException, GeneralSecurityException { + out.write(("Signature-Version: 1.0\r\n").getBytes()); + out.write(("Created-By: 1.0 (Android SignApk)\r\n").getBytes()); + + + // BASE64Encoder base64 = new BASE64Encoder(); + MessageDigest md = MessageDigest.getInstance("SHA1"); + PrintStream print = new PrintStream( + new DigestOutputStream(new ByteArrayOutputStream(), md), + true, "UTF-8"); + + // Digest of the entire manifest + manifest.write(print); + print.flush(); + + out.write(("SHA1-Digest-Manifest: " + Base64.encodeToString(md.digest(), Base64.NO_WRAP) + "\r\n\r\n").getBytes()); + + Map entries = manifest.getEntries(); + for (Map.Entry entry : entries.entrySet()) { + if (canceled) break; + progressHelper.progress(ProgressEvent.PRORITY_NORMAL, resourceAdapter.getString(ResourceAdapter.Item.GENERATING_SIGNATURE_FILE)); + // Digest of the manifest stanza for this entry. + String nameEntry = "Name: " + entry.getKey() + "\r\n"; + print.print(nameEntry); + for (Map.Entry att : entry.getValue().entrySet()) { + print.print(att.getKey() + ": " + att.getValue() + "\r\n"); + } + print.print("\r\n"); + print.flush(); + + out.write(nameEntry.getBytes()); + out.write(("SHA1-Digest: " + Base64.encodeToString(md.digest(), Base64.NO_WRAP) + "\r\n\r\n").getBytes()); + } + } + + /** + * Write a .RSA file with a digital signature. + */ + @SuppressWarnings("unchecked") + private void writeSignatureBlock(KeySet keySet, byte[] signatureFileBytes, OutputStream out) + throws IOException, GeneralSecurityException { + if (keySet.getSigBlockTemplate() != null) { + Signature signature = Signature.getInstance("SHA1withRSA"); + signature.initSign(keySet.getPrivateKey()); + signature.update(signatureFileBytes); + byte[] signatureBytes = signature.sign(); + + out.write(keySet.getSigBlockTemplate()); + out.write(signatureBytes); + + if (getLogger().isDebugEnabled()) { + + MessageDigest md = MessageDigest.getInstance("SHA1"); + md.update(signatureFileBytes); + byte[] sfDigest = md.digest(); + getLogger().debug("Sig File SHA1: \n" + HexDumpEncoder.encode(sfDigest)); + + getLogger().debug("Signature: \n" + HexDumpEncoder.encode(signatureBytes)); + + Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding"); + cipher.init(Cipher.DECRYPT_MODE, keySet.getPublicKey()); + + byte[] tmpData = cipher.doFinal(signatureBytes); + getLogger().debug("Signature Decrypted: \n" + HexDumpEncoder.encode(tmpData)); + } + } else { + try { + byte[] sigBlock = null; + // Use reflection to call the optional generator. + Class generatorClass = Class.forName("kellinwood.security.zipsigner.optional.SignatureBlockGenerator"); + Method generatorMethod = generatorClass.getMethod("generate", KeySet.class, (new byte[1]).getClass()); + sigBlock = (byte[]) generatorMethod.invoke(null, keySet, signatureFileBytes); + out.write(sigBlock); + } catch (Exception x) { + throw new RuntimeException(x.getMessage(), x); + } + } + } + + /** + * Copy all the files in a manifest from input to output. We set + * the modification times in the output to a fixed time, so as to + * reduce variation in the output file and make incremental OTAs + * more efficient. + */ + private void copyFiles(Manifest manifest, Map input, ZipOutput output, long timestamp) + throws IOException { + Map entries = manifest.getEntries(); + List names = new ArrayList(entries.keySet()); + Collections.sort(names); + int i = 1; + for (String name : names) { + if (canceled) break; + progressHelper.progress(ProgressEvent.PRORITY_NORMAL, resourceAdapter.getString(ResourceAdapter.Item.COPYING_ZIP_ENTRY, i, names.size())); + i += 1; + ZioEntry inEntry = input.get(name); + inEntry.setTime(timestamp); + output.write(inEntry); + } + } + + /** + * Copy all the files from input to output. + */ + private void copyFiles(Map input, ZipOutput output) + throws IOException { + int i = 1; + for (ZioEntry inEntry : input.values()) { + if (canceled) break; + progressHelper.progress(ProgressEvent.PRORITY_NORMAL, resourceAdapter.getString(ResourceAdapter.Item.COPYING_ZIP_ENTRY, i, input.size())); + i += 1; + output.write(inEntry); + } + } + + /** + * @deprecated - use the version that takes the passwords as char[] + */ + public void signZip(URL keystoreURL, + String keystoreType, + String keystorePw, + String certAlias, + String certPw, + String inputZipFilename, + String outputZipFilename) + throws ClassNotFoundException, IllegalAccessException, InstantiationException, + IOException, GeneralSecurityException { + signZip(keystoreURL, keystoreType, keystorePw.toCharArray(), certAlias, certPw.toCharArray(), "SHA1withRSA", inputZipFilename, outputZipFilename); + } + + public void signZip(URL keystoreURL, + String keystoreType, + char[] keystorePw, + String certAlias, + char[] certPw, + String signatureAlgorithm, + String inputZipFilename, + String outputZipFilename) + throws ClassNotFoundException, IllegalAccessException, InstantiationException, + IOException, GeneralSecurityException { + InputStream keystoreStream = null; + + try { + KeyStore keystore = null; + if (keystoreType == null) keystoreType = KeyStore.getDefaultType(); + keystore = KeyStore.getInstance(keystoreType); + + keystoreStream = keystoreURL.openStream(); + keystore.load(keystoreStream, keystorePw); + Certificate cert = keystore.getCertificate(certAlias); + X509Certificate publicKey = (X509Certificate) cert; + Key key = keystore.getKey(certAlias, certPw); + PrivateKey privateKey = (PrivateKey) key; + + setKeys("custom", publicKey, privateKey, signatureAlgorithm, null); + + signZip(inputZipFilename, outputZipFilename); + } finally { + if (keystoreStream != null) keystoreStream.close(); + } + } + + /** + * Sign the input with the default test key and certificate. + * Save result to output file. + */ + public void signZip(Map zioEntries, String outputZipFilename) + throws IOException, GeneralSecurityException { + progressHelper.initProgress(); + signZip(zioEntries, new FileOutputStream(outputZipFilename), outputZipFilename); + } + + /** + * Sign the file using the given public key cert, private key, + * and signature block template. The signature block template + * parameter may be null, but if so + * android-sun-jarsign-support.jar must be in the classpath. + */ + public void signZip(String inputZipFilename, String outputZipFilename) + throws IOException, GeneralSecurityException { + File inFile = new File(inputZipFilename).getCanonicalFile(); + File outFile = new File(outputZipFilename).getCanonicalFile(); + + if (inFile.equals(outFile)) { + throw new IllegalArgumentException(resourceAdapter.getString(ResourceAdapter.Item.INPUT_SAME_AS_OUTPUT_ERROR)); + } + + progressHelper.initProgress(); + progressHelper.progress(ProgressEvent.PRORITY_IMPORTANT, resourceAdapter.getString(ResourceAdapter.Item.PARSING_CENTRAL_DIRECTORY)); + + ZipInput input = null; + OutputStream outStream = null; + try { + input = ZipInput.read(inputZipFilename); + outStream = new FileOutputStream(outputZipFilename); + signZip(input.getEntries(), outStream, outputZipFilename); + } finally { + if (input != null) input.close(); + if (outStream != null) outStream.close(); + } + } + + /** + * Sign the + * and signature block template. The signature block template + * parameter may be null, but if so + * android-sun-jarsign-support.jar must be in the classpath. + */ + public void signZip(Map zioEntries, OutputStream outputStream, String outputZipFilename) + throws IOException, GeneralSecurityException { + boolean debug = getLogger().isDebugEnabled(); + + progressHelper.initProgress(); + if (keySet == null) { + if (!keymode.startsWith(MODE_AUTO)) + throw new IllegalStateException("No keys configured for signing the file!"); + + // Auto-determine which keys to use + String keyName = this.autoDetectKey(keymode, zioEntries); + if (keyName == null) + throw new AutoKeyException(resourceAdapter.getString(ResourceAdapter.Item.AUTO_KEY_SELECTION_ERROR, new File(outputZipFilename).getName())); + + autoKeyObservable.notifyObservers(keyName); + + loadKeys(keyName); + } + + ZipOutput zipOutput = null; + + try { + + zipOutput = new ZipOutput(outputStream); + + if (KEY_NONE.equals(keySet.getName())) { + progressHelper.setProgressTotalItems(zioEntries.size()); + progressHelper.setProgressCurrentItem(0); + copyFiles(zioEntries, zipOutput); + return; + } + + // Calculate total steps to complete for accurate progress percentages. + int progressTotalItems = 0; + for (ZioEntry entry : zioEntries.values()) { + String name = entry.getName(); + if (!entry.isDirectory() && !name.equals(JarFile.MANIFEST_NAME) && + !name.equals(CERT_SF_NAME) && !name.equals(CERT_RSA_NAME) && + (stripPattern == null || + !stripPattern.matcher(name).matches())) { + progressTotalItems += 3; // digest for manifest, digest in sig file, copy data + } + } + progressTotalItems += 1; // CERT.RSA generation + progressHelper.setProgressTotalItems(progressTotalItems); + progressHelper.setProgressCurrentItem(0); + + // Assume the certificate is valid for at least an hour. + long timestamp = keySet.getPublicKey().getNotBefore().getTime() + 3600L * 1000; + + // MANIFEST.MF + // progress(ProgressEvent.PRORITY_NORMAL, JarFile.MANIFEST_NAME); + Manifest manifest = addDigestsToManifest(zioEntries); + if (canceled) return; + ZioEntry ze = new ZioEntry(JarFile.MANIFEST_NAME); + ze.setTime(timestamp); + manifest.write(ze.getOutputStream()); + zipOutput.write(ze); + + // CERT.SF + ze = new ZioEntry(CERT_SF_NAME); + ze.setTime(timestamp); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + generateSignatureFile(manifest, out); + if (canceled) return; + byte[] sfBytes = out.toByteArray(); + if (debug) { + getLogger().debug("Signature File: \n" + new String(sfBytes) + "\n" + + HexDumpEncoder.encode(sfBytes)); + } + ze.getOutputStream().write(sfBytes); + zipOutput.write(ze); + + // CERT.RSA + progressHelper.progress(ProgressEvent.PRORITY_NORMAL, resourceAdapter.getString(ResourceAdapter.Item.GENERATING_SIGNATURE_BLOCK)); + ze = new ZioEntry(CERT_RSA_NAME); + ze.setTime(timestamp); + writeSignatureBlock(keySet, sfBytes, ze.getOutputStream()); + zipOutput.write(ze); + if (canceled) return; + + // Everything else + copyFiles(manifest, zioEntries, zipOutput, timestamp); + if (canceled) return; + + } finally { + if (zipOutput != null) zipOutput.close(); + if (canceled) { + try { + if (outputZipFilename != null) new File(outputZipFilename).delete(); + } catch (Throwable t) { + getLogger().warning(t.getClass().getName() + ":" + t.getMessage()); + } + } + } + } + + public void addProgressListener(ProgressListener l) { + progressHelper.addProgressListener(l); + } + + public synchronized void removeProgressListener(ProgressListener l) { + progressHelper.removeProgressListener(l); + } + + public static class AutoKeyObservable extends Observable { + @Override + public void notifyObservers(Object arg) { + super.setChanged(); + super.notifyObservers(arg); + } + } +} \ No newline at end of file diff --git a/app/src/full/java/kellinwood/security/zipsigner/optional/SignatureBlockGenerator.java b/app/src/full/java/kellinwood/security/zipsigner/optional/SignatureBlockGenerator.java new file mode 100644 index 000000000..63d19424d --- /dev/null +++ b/app/src/full/java/kellinwood/security/zipsigner/optional/SignatureBlockGenerator.java @@ -0,0 +1,60 @@ +package kellinwood.security.zipsigner.optional; + +import org.bouncycastle.cert.jcajce.JcaCertStore; +import org.bouncycastle.cms.CMSProcessableByteArray; +import org.bouncycastle.cms.CMSSignedData; +import org.bouncycastle.cms.CMSSignedDataGenerator; +import org.bouncycastle.cms.CMSTypedData; +import org.bouncycastle.cms.SignerInfoGenerator; +import org.bouncycastle.cms.jcajce.JcaSignerInfoGeneratorBuilder; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.DigestCalculatorProvider; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.bouncycastle.operator.jcajce.JcaDigestCalculatorProviderBuilder; +import org.bouncycastle.util.Store; + +import java.util.Collections; + +import kellinwood.security.zipsigner.KeySet; + +/** + * + */ +public class SignatureBlockGenerator { + + /** + * Sign the given content using the private and public keys from the keySet, and return the encoded CMS (PKCS#7) data. + * Use of direct signature and DER encoding produces a block that is verifiable by Android recovery programs. + */ + public static byte[] generate(KeySet keySet, byte[] content) { + try { + BouncyCastleProvider bcp = new BouncyCastleProvider(); + CMSTypedData msg = new CMSProcessableByteArray(content); + + Store certs = new JcaCertStore(Collections.singletonList(keySet.getPublicKey())); + + CMSSignedDataGenerator gen = new CMSSignedDataGenerator(); + + JcaContentSignerBuilder jcaContentSignerBuilder = new JcaContentSignerBuilder(keySet.getSignatureAlgorithm()).setProvider(bcp); + ContentSigner sha1Signer = jcaContentSignerBuilder.build(keySet.getPrivateKey()); + + JcaDigestCalculatorProviderBuilder jcaDigestCalculatorProviderBuilder = new JcaDigestCalculatorProviderBuilder().setProvider(bcp); + DigestCalculatorProvider digestCalculatorProvider = jcaDigestCalculatorProviderBuilder.build(); + + JcaSignerInfoGeneratorBuilder jcaSignerInfoGeneratorBuilder = new JcaSignerInfoGeneratorBuilder(digestCalculatorProvider); + jcaSignerInfoGeneratorBuilder.setDirectSignature(true); + SignerInfoGenerator signerInfoGenerator = jcaSignerInfoGeneratorBuilder.build(sha1Signer, keySet.getPublicKey()); + + gen.addSignerInfoGenerator(signerInfoGenerator); + + gen.addCertificates(certs); + + CMSSignedData sigData = gen.generate(msg, false); + return sigData.toASN1Structure().getEncoded("DER"); + + } catch (Exception x) { + throw new RuntimeException(x.getMessage(), x); + } + } +} diff --git a/app/src/full/java/kellinwood/zipio/CentralEnd.java b/app/src/full/java/kellinwood/zipio/CentralEnd.java new file mode 100644 index 000000000..590e0dd2b --- /dev/null +++ b/app/src/full/java/kellinwood/zipio/CentralEnd.java @@ -0,0 +1,104 @@ +/* + * Copyright (C) 2010 Ken Ellinwood + * + * 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 kellinwood.zipio; + +import java.io.IOException; + +import kellinwood.logging.LoggerInterface; +import kellinwood.logging.LoggerManager; + +public class CentralEnd { + public int signature = 0x06054b50; // end of central dir signature 4 bytes + public short numberThisDisk = 0; // number of this disk 2 bytes + public short centralStartDisk = 0; // number of the disk with the start of the central directory 2 bytes + public short numCentralEntries; // total number of entries in the central directory on this disk 2 bytes + public short totalCentralEntries; // total number of entries in the central directory 2 bytes + + public int centralDirectorySize; // size of the central directory 4 bytes + public int centralStartOffset; // offset of start of central directory with respect to the starting disk number 4 bytes + public String fileComment; // .ZIP file comment (variable size) + + private static LoggerInterface log; + + public static CentralEnd read(ZipInput input) throws IOException { + + int signature = input.readInt(); + if (signature != 0x06054b50) { + // back up to the signature + input.seek(input.getFilePointer() - 4); + return null; + } + + CentralEnd entry = new CentralEnd(); + + entry.doRead(input); + return entry; + } + + public static LoggerInterface getLogger() { + if (log == null) log = LoggerManager.getLogger(CentralEnd.class.getName()); + return log; + } + + private void doRead(ZipInput input) throws IOException { + + boolean debug = getLogger().isDebugEnabled(); + + numberThisDisk = input.readShort(); + if (debug) log.debug(String.format("This disk number: 0x%04x", numberThisDisk)); + + centralStartDisk = input.readShort(); + if (debug) { + log.debug(String.format("Central dir start disk number: 0x%04x", centralStartDisk)); + } + + numCentralEntries = input.readShort(); + if (debug) { + log.debug(String.format("Central entries on this disk: 0x%04x", numCentralEntries)); + } + + totalCentralEntries = input.readShort(); + if (debug) { + log.debug(String.format("Total number of central entries: 0x%04x", totalCentralEntries)); + } + + centralDirectorySize = input.readInt(); + if (debug) log.debug(String.format("Central directory size: 0x%08x", centralDirectorySize)); + + centralStartOffset = input.readInt(); + if (debug) log.debug(String.format("Central directory offset: 0x%08x", centralStartOffset)); + + short zipFileCommentLen = input.readShort(); + fileComment = input.readString(zipFileCommentLen); + if (debug) log.debug(".ZIP file comment: " + fileComment); + } + + public void write(ZipOutput output) throws IOException { + + boolean debug = getLogger().isDebugEnabled(); + + output.writeInt(signature); + output.writeShort(numberThisDisk); + output.writeShort(centralStartDisk); + output.writeShort(numCentralEntries); + output.writeShort(totalCentralEntries); + output.writeInt(centralDirectorySize); + output.writeInt(centralStartOffset); + output.writeShort((short) fileComment.length()); + output.writeString(fileComment); + } +} diff --git a/app/src/full/java/kellinwood/zipio/ZioEntry.java b/app/src/full/java/kellinwood/zipio/ZioEntry.java new file mode 100644 index 000000000..c3246bab3 --- /dev/null +++ b/app/src/full/java/kellinwood/zipio/ZioEntry.java @@ -0,0 +1,639 @@ +/* + * Copyright (C) 2010 Ken Ellinwood + * + * 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 kellinwood.zipio; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.SequenceInputStream; +import java.util.Date; +import java.util.Locale; +import java.util.zip.CRC32; +import java.util.zip.Inflater; +import java.util.zip.InflaterInputStream; + +import kellinwood.logging.LoggerInterface; +import kellinwood.logging.LoggerManager; + +public class ZioEntry implements Cloneable { + + private ZipInput zipInput; + + // public int signature = 0x02014b50; + private short versionMadeBy; + private short versionRequired; + private short generalPurposeBits; + private short compression; + private short modificationTime; + private short modificationDate; + private int crc32; + private int compressedSize; + private int size; + private String filename; + private byte[] extraData; + private short numAlignBytes = 0; + private String fileComment; + private short diskNumberStart; + private short internalAttributes; + private int externalAttributes; + + private int localHeaderOffset; + private long dataPosition = -1; + private byte[] data = null; + private ZioEntryOutputStream entryOut = null; + + private static byte[] alignBytes = new byte[4]; + + private static LoggerInterface log; + + public ZioEntry(ZipInput input) { + zipInput = input; + } + + public static LoggerInterface getLogger() { + if (log == null) log = LoggerManager.getLogger(ZioEntry.class.getName()); + return log; + } + + public ZioEntry(String name) { + filename = name; + fileComment = ""; + compression = 8; + extraData = new byte[0]; + setTime(System.currentTimeMillis()); + } + + public ZioEntry(String name, String sourceDataFile) + throws IOException { + zipInput = new ZipInput(sourceDataFile); + filename = name; + fileComment = ""; + this.compression = 0; + this.size = (int) zipInput.getFileLength(); + this.compressedSize = this.size; + + if (getLogger().isDebugEnabled()) + getLogger().debug(String.format(Locale.ENGLISH, "Computing CRC for %s, size=%d", sourceDataFile, size)); + + // compute CRC + CRC32 crc = new CRC32(); + + byte[] buffer = new byte[8096]; + + int numRead = 0; + while (numRead != size) { + int count = zipInput.read(buffer, 0, Math.min(buffer.length, (this.size - numRead))); + if (count > 0) { + crc.update(buffer, 0, count); + numRead += count; + } + } + + this.crc32 = (int) crc.getValue(); + + zipInput.seek(0); + this.dataPosition = 0; + extraData = new byte[0]; + setTime(new File(sourceDataFile).lastModified()); + } + + + public ZioEntry(String name, String sourceDataFile, short compression, int crc32, int compressedSize, int size) + throws IOException { + zipInput = new ZipInput(sourceDataFile); + filename = name; + fileComment = ""; + this.compression = compression; + this.crc32 = crc32; + this.compressedSize = compressedSize; + this.size = size; + this.dataPosition = 0; + extraData = new byte[0]; + setTime(new File(sourceDataFile).lastModified()); + } + + // Return a copy with a new name + public ZioEntry getClonedEntry(String newName) { + + ZioEntry clone; + try { + clone = (ZioEntry) this.clone(); + } catch (CloneNotSupportedException e) { + throw new IllegalStateException("clone() failed!"); + } + clone.setName(newName); + return clone; + } + + public void readLocalHeader() throws IOException { + ZipInput input = zipInput; + int tmp; + boolean debug = getLogger().isDebugEnabled(); + + input.seek(localHeaderOffset); + + if (debug) { + getLogger().debug(String.format("FILE POSITION: 0x%08x", input.getFilePointer())); + } + + // 0 4 Local file header signature = 0x04034b50 + int signature = input.readInt(); + if (signature != 0x04034b50) { + throw new IllegalStateException(String.format("Local header not found at pos=0x%08x, file=%s", input.getFilePointer(), filename)); + } + + // This method is usually called just before the data read, so + // its only purpose currently is to position the file pointer + // for the data read. The entry's attributes might also have + // been changed since the central dir entry was read (e.g., + // filename), so throw away the values here. + + int tmpInt; + short tmpShort; + + // 4 2 Version needed to extract (minimum) + /* versionRequired */ + tmpShort = input.readShort(); + if (debug) { + log.debug(String.format("Version required: 0x%04x", tmpShort /*versionRequired*/)); + } + + // 6 2 General purpose bit flag + /* generalPurposeBits */ + tmpShort = input.readShort(); + if (debug) { + log.debug(String.format("General purpose bits: 0x%04x", tmpShort /* generalPurposeBits */)); + } + + // 8 2 Compression method + /* compression */ + tmpShort = input.readShort(); + if (debug) log.debug(String.format("Compression: 0x%04x", tmpShort /* compression */)); + + // 10 2 File last modification time + /* modificationTime */ + tmpShort = input.readShort(); + if (debug) { + log.debug(String.format("Modification time: 0x%04x", tmpShort /* modificationTime */)); + } + + // 12 2 File last modification date + /* modificationDate */ + tmpShort = input.readShort(); + if (debug) { + log.debug(String.format("Modification date: 0x%04x", tmpShort /* modificationDate */)); + } + + // 14 4 CRC-32 + /* crc32 */ + tmpInt = input.readInt(); + if (debug) log.debug(String.format("CRC-32: 0x%04x", tmpInt /*crc32*/)); + + // 18 4 Compressed size + /* compressedSize*/ + tmpInt = input.readInt(); + if (debug) log.debug(String.format("Compressed size: 0x%04x", tmpInt /*compressedSize*/)); + + // 22 4 Uncompressed size + /* size */ + tmpInt = input.readInt(); + if (debug) log.debug(String.format("Size: 0x%04x", tmpInt /*size*/)); + + // 26 2 File name length (n) + short fileNameLen = input.readShort(); + if (debug) log.debug(String.format("File name length: 0x%04x", fileNameLen)); + + // 28 2 Extra field length (m) + short extraLen = input.readShort(); + if (debug) log.debug(String.format("Extra length: 0x%04x", extraLen)); + + // 30 n File name + String filename = input.readString(fileNameLen); + if (debug) log.debug("Filename: " + filename); + + // Extra data + byte[] extra = input.readBytes(extraLen); + + // Record the file position of this entry's data. + dataPosition = input.getFilePointer(); + if (debug) log.debug(String.format("Data position: 0x%08x", dataPosition)); + } + + public void writeLocalEntry(ZipOutput output) throws IOException { + if (data == null && dataPosition < 0 && zipInput != null) { + readLocalHeader(); + } + + localHeaderOffset = (int) output.getFilePointer(); + + boolean debug = getLogger().isDebugEnabled(); + + if (debug) { + getLogger().debug(String.format("Writing local header at 0x%08x - %s", localHeaderOffset, filename)); + } + + if (entryOut != null) { + entryOut.close(); + size = entryOut.getSize(); + data = ((ByteArrayOutputStream) entryOut.getWrappedStream()).toByteArray(); + compressedSize = data.length; + crc32 = entryOut.getCRC(); + } + + output.writeInt(0x04034b50); + output.writeShort(versionRequired); + output.writeShort(generalPurposeBits); + output.writeShort(compression); + output.writeShort(modificationTime); + output.writeShort(modificationDate); + output.writeInt(crc32); + output.writeInt(compressedSize); + output.writeInt(size); + output.writeShort((short) filename.length()); + + numAlignBytes = 0; + + // Zipalign if the file is uncompressed, i.e., "Stored", and file size is not zero. + if (compression == 0) { + + long dataPos = output.getFilePointer() + // current position + 2 + // plus size of extra data length + filename.length() + // plus filename + extraData.length; // plus extra data + + short dataPosMod4 = (short) (dataPos % 4); + + if (dataPosMod4 > 0) { + numAlignBytes = (short) (4 - dataPosMod4); + } + } + + // 28 2 Extra field length (m) + output.writeShort((short) (extraData.length + numAlignBytes)); + + // 30 n File name + output.writeString(filename); + + // Extra data + output.writeBytes(extraData); + + // Zipalign bytes + if (numAlignBytes > 0) { + output.writeBytes(alignBytes, 0, numAlignBytes); + } + + if (debug) { + getLogger().debug(String.format(Locale.ENGLISH, "Data position 0x%08x", output.getFilePointer())); + } + if (data != null) { + output.writeBytes(data); + if (debug) { + getLogger().debug(String.format(Locale.ENGLISH, "Wrote %d bytes", data.length)); + } + } else { + + if (debug) getLogger().debug(String.format("Seeking to position 0x%08x", dataPosition)); + zipInput.seek(dataPosition); + + int bufferSize = Math.min(compressedSize, 8096); + byte[] buffer = new byte[bufferSize]; + long totalCount = 0; + + while (totalCount != compressedSize) { + int numRead = zipInput.in.read(buffer, 0, (int) Math.min(compressedSize - totalCount, bufferSize)); + if (numRead > 0) { + output.writeBytes(buffer, 0, numRead); + if (debug) { + getLogger().debug(String.format(Locale.ENGLISH, "Wrote %d bytes", numRead)); + } + totalCount += numRead; + } else + throw new IllegalStateException(String.format(Locale.ENGLISH, "EOF reached while copying %s with %d bytes left to go", filename, compressedSize - totalCount)); + } + } + } + + public static ZioEntry read(ZipInput input) throws IOException { + + // 0 4 Central directory header signature = 0x02014b50 + int signature = input.readInt(); + if (signature != 0x02014b50) { + // back up to the signature + input.seek(input.getFilePointer() - 4); + return null; + } + + ZioEntry entry = new ZioEntry(input); + + entry.doRead(input); + return entry; + } + + private void doRead(ZipInput input) throws IOException { + + boolean debug = getLogger().isDebugEnabled(); + + // 4 2 Version needed to extract (minimum) + versionMadeBy = input.readShort(); + if (debug) log.debug(String.format("Version made by: 0x%04x", versionMadeBy)); + + // 4 2 Version required + versionRequired = input.readShort(); + if (debug) log.debug(String.format("Version required: 0x%04x", versionRequired)); + + // 6 2 General purpose bit flag + generalPurposeBits = input.readShort(); + if (debug) log.debug(String.format("General purpose bits: 0x%04x", generalPurposeBits)); + // Bits 1, 2, 3, and 11 are allowed to be set (first bit is bit zero). Any others are a problem. + if ((generalPurposeBits & 0xF7F1) != 0x0000) { + throw new IllegalStateException("Can't handle general purpose bits == " + String.format("0x%04x", generalPurposeBits)); + } + + // 8 2 Compression method + compression = input.readShort(); + if (debug) log.debug(String.format("Compression: 0x%04x", compression)); + + // 10 2 File last modification time + modificationTime = input.readShort(); + if (debug) log.debug(String.format("Modification time: 0x%04x", modificationTime)); + + // 12 2 File last modification date + modificationDate = input.readShort(); + if (debug) log.debug(String.format("Modification date: 0x%04x", modificationDate)); + + // 14 4 CRC-32 + crc32 = input.readInt(); + if (debug) log.debug(String.format("CRC-32: 0x%04x", crc32)); + + // 18 4 Compressed size + compressedSize = input.readInt(); + if (debug) log.debug(String.format("Compressed size: 0x%04x", compressedSize)); + + // 22 4 Uncompressed size + size = input.readInt(); + if (debug) log.debug(String.format("Size: 0x%04x", size)); + + // 26 2 File name length (n) + short fileNameLen = input.readShort(); + if (debug) log.debug(String.format("File name length: 0x%04x", fileNameLen)); + + // 28 2 Extra field length (m) + short extraLen = input.readShort(); + if (debug) log.debug(String.format("Extra length: 0x%04x", extraLen)); + + short fileCommentLen = input.readShort(); + if (debug) log.debug(String.format("File comment length: 0x%04x", fileCommentLen)); + + diskNumberStart = input.readShort(); + if (debug) log.debug(String.format("Disk number start: 0x%04x", diskNumberStart)); + + internalAttributes = input.readShort(); + if (debug) log.debug(String.format("Internal attributes: 0x%04x", internalAttributes)); + + externalAttributes = input.readInt(); + if (debug) log.debug(String.format("External attributes: 0x%08x", externalAttributes)); + + localHeaderOffset = input.readInt(); + if (debug) log.debug(String.format("Local header offset: 0x%08x", localHeaderOffset)); + + // 30 n File name + filename = input.readString(fileNameLen); + if (debug) log.debug("Filename: " + filename); + + extraData = input.readBytes(extraLen); + + fileComment = input.readString(fileCommentLen); + if (debug) log.debug("File comment: " + fileComment); + + generalPurposeBits = (short) (generalPurposeBits & 0x0800); // Don't write a data descriptor, preserve UTF-8 encoded filename bit + + // Don't write zero-length entries with compression. + if (size == 0) { + compressedSize = 0; + compression = 0; + crc32 = 0; + } + } + + /** + * Returns the entry's data. + */ + public byte[] getData() throws IOException { + if (data != null) return data; + + byte[] tmpdata = new byte[size]; + + InputStream din = getInputStream(); + int count = 0; + + while (count != size) { + int numRead = din.read(tmpdata, count, size - count); + if (numRead < 0) + throw new IllegalStateException(String.format(Locale.ENGLISH, "Read failed, expecting %d bytes, got %d instead", size, count)); + count += numRead; + } + return tmpdata; + } + + // Returns an input stream for reading the entry's data. + public InputStream getInputStream() throws IOException { + return getInputStream(null); + } + + // Returns an input stream for reading the entry's data. + public InputStream getInputStream(OutputStream monitorStream) throws IOException { + + if (entryOut != null) { + entryOut.close(); + size = entryOut.getSize(); + data = ((ByteArrayOutputStream) entryOut.getWrappedStream()).toByteArray(); + compressedSize = data.length; + crc32 = entryOut.getCRC(); + entryOut = null; + InputStream rawis = new ByteArrayInputStream(data); + if (compression == 0) return rawis; + else { + // Hacky, inflate using a sequence of input streams that returns 1 byte more than the actual length of the data. + // This extra dummy byte is required by InflaterInputStream when the data doesn't have the header and crc fields (as it is in zip files). + return new InflaterInputStream(new SequenceInputStream(rawis, new ByteArrayInputStream(new byte[1])), new Inflater(true)); + } + } + + ZioEntryInputStream dataStream; + dataStream = new ZioEntryInputStream(this); + if (monitorStream != null) dataStream.setMonitorStream(monitorStream); + if (compression != 0) { + // Note: When using nowrap=true with Inflater it is also necessary to provide + // an extra "dummy" byte as input. This is required by the ZLIB native library + // in order to support certain optimizations. + dataStream.setReturnDummyByte(true); + return new InflaterInputStream(dataStream, new Inflater(true)); + } else return dataStream; + } + + // Returns an output stream for writing an entry's data. + public OutputStream getOutputStream() { + entryOut = new ZioEntryOutputStream(compression, new ByteArrayOutputStream()); + return entryOut; + } + + public void write(ZipOutput output) throws IOException { + boolean debug = getLogger().isDebugEnabled(); + + output.writeInt(0x02014b50); + output.writeShort(versionMadeBy); + output.writeShort(versionRequired); + output.writeShort(generalPurposeBits); + output.writeShort(compression); + output.writeShort(modificationTime); + output.writeShort(modificationDate); + output.writeInt(crc32); + output.writeInt(compressedSize); + output.writeInt(size); + output.writeShort((short) filename.length()); + output.writeShort((short) (extraData.length + numAlignBytes)); + output.writeShort((short) fileComment.length()); + output.writeShort(diskNumberStart); + output.writeShort(internalAttributes); + output.writeInt(externalAttributes); + output.writeInt(localHeaderOffset); + + output.writeString(filename); + output.writeBytes(extraData); + if (numAlignBytes > 0) output.writeBytes(alignBytes, 0, numAlignBytes); + output.writeString(fileComment); + } + + /* + * Returns timestamp in Java format + */ + public long getTime() { + int year = (int) (((modificationDate >> 9) & 0x007f) + 80); + int month = (int) (((modificationDate >> 5) & 0x000f) - 1); + int day = (int) (modificationDate & 0x001f); + int hour = (int) ((modificationTime >> 11) & 0x001f); + int minute = (int) ((modificationTime >> 5) & 0x003f); + int seconds = (int) ((modificationTime << 1) & 0x003e); + Date d = new Date(year, month, day, hour, minute, seconds); + return d.getTime(); + } + + /* + * Set the file timestamp (using a Java time value). + */ + public void setTime(long time) { + Date d = new Date(time); + long dtime; + int year = d.getYear() + 1900; + if (year < 1980) { + dtime = (1 << 21) | (1 << 16); + } else { + dtime = (year - 1980) << 25 | (d.getMonth() + 1) << 21 | + d.getDate() << 16 | d.getHours() << 11 | d.getMinutes() << 5 | + d.getSeconds() >> 1; + } + + modificationDate = (short) (dtime >> 16); + modificationTime = (short) (dtime & 0xFFFF); + } + + public boolean isDirectory() { + return filename.endsWith("/"); + } + + public String getName() { + return filename; + } + + public void setName(String filename) { + this.filename = filename; + } + + /** + * Use 0 (STORED), or 8 (DEFLATE). + */ + public void setCompression(int compression) { + this.compression = (short) compression; + } + + public short getVersionMadeBy() { + return versionMadeBy; + } + + public short getVersionRequired() { + return versionRequired; + } + + public short getGeneralPurposeBits() { + return generalPurposeBits; + } + + public short getCompression() { + return compression; + } + + public int getCrc32() { + return crc32; + } + + public int getCompressedSize() { + return compressedSize; + } + + public int getSize() { + return size; + } + + public byte[] getExtraData() { + return extraData; + } + + public String getFileComment() { + return fileComment; + } + + public short getDiskNumberStart() { + return diskNumberStart; + } + + public short getInternalAttributes() { + return internalAttributes; + } + + public int getExternalAttributes() { + return externalAttributes; + } + + public int getLocalHeaderOffset() { + return localHeaderOffset; + } + + public long getDataPosition() { + return dataPosition; + } + + public ZioEntryOutputStream getEntryOut() { + return entryOut; + } + + public ZipInput getZipInput() { + return zipInput; + } +} diff --git a/app/src/full/java/kellinwood/zipio/ZioEntryInputStream.java b/app/src/full/java/kellinwood/zipio/ZioEntryInputStream.java new file mode 100644 index 000000000..e0567ad7e --- /dev/null +++ b/app/src/full/java/kellinwood/zipio/ZioEntryInputStream.java @@ -0,0 +1,141 @@ +/* + * Copyright (C) 2010 Ken Ellinwood + * + * 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 kellinwood.zipio; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.RandomAccessFile; +import java.util.Locale; + +import kellinwood.logging.LoggerInterface; +import kellinwood.logging.LoggerManager; + +/** + * Input stream used to read just the data from a zip file entry. + */ +public class ZioEntryInputStream extends InputStream { + + RandomAccessFile raf; + int size; + int offset; + LoggerInterface log; + boolean debug; + boolean returnDummyByte = false; + OutputStream monitor = null; + + public ZioEntryInputStream(ZioEntry entry) throws IOException { + + log = LoggerManager.getLogger(this.getClass().getName()); + debug = log.isDebugEnabled(); + offset = 0; + size = entry.getCompressedSize(); + raf = entry.getZipInput().in; + long dpos = entry.getDataPosition(); + if (dpos >= 0) { + if (debug) { + log.debug(String.format(Locale.ENGLISH, "Seeking to %d", entry.getDataPosition())); + } + raf.seek(entry.getDataPosition()); + } else { + // seeks to, then reads, the local header, causing the + // file pointer to be positioned at the start of the data. + entry.readLocalHeader(); + } + } + + public void setReturnDummyByte(boolean returnExtraByte) { + returnDummyByte = returnExtraByte; + } + + // For debugging, if the monitor is set we write all data read to the monitor. + public void setMonitorStream(OutputStream monitorStream) { + monitor = monitorStream; + } + + @Override + public void close() throws IOException { + } + + @Override + public boolean markSupported() { + return false; + } + + @Override + public int available() throws IOException { + int available = size - offset; + if (debug) log.debug(String.format(Locale.ENGLISH, "Available = %d", available)); + if (available == 0 && returnDummyByte) return 1; + else return available; + } + + @Override + public int read() throws IOException { + if ((size - offset) == 0) { + if (returnDummyByte) { + returnDummyByte = false; + return 0; + } else return -1; + } + int b = raf.read(); + if (b >= 0) { + if (monitor != null) monitor.write(b); + if (debug) log.debug("Read 1 byte"); + offset += 1; + } else if (debug) log.debug("Read 0 bytes"); + return b; + } + + @Override + public int read(byte[] b, int off, int len) throws IOException { + return readBytes(b, off, len); + } + + private int readBytes(byte[] b, int off, int len) throws IOException { + if ((size - offset) == 0) { + if (returnDummyByte) { + returnDummyByte = false; + b[off] = 0; + return 1; + } else return -1; + } + int numToRead = Math.min(len, available()); + int numRead = raf.read(b, off, numToRead); + if (numRead > 0) { + if (monitor != null) monitor.write(b, off, numRead); + offset += numRead; + } + if (debug) { + log.debug(String.format(Locale.ENGLISH, "Read %d bytes for read(b,%d,%d)", numRead, off, len)); + } + return numRead; + } + + @Override + public int read(byte[] b) throws IOException { + return readBytes(b, 0, b.length); + } + + @Override + public long skip(long n) throws IOException { + long numToSkip = Math.min(n, available()); + raf.seek(raf.getFilePointer() + numToSkip); + if (debug) log.debug(String.format(Locale.ENGLISH, "Skipped %d bytes", numToSkip)); + return numToSkip; + } +} diff --git a/app/src/full/java/kellinwood/zipio/ZioEntryOutputStream.java b/app/src/full/java/kellinwood/zipio/ZioEntryOutputStream.java new file mode 100644 index 000000000..e9625d730 --- /dev/null +++ b/app/src/full/java/kellinwood/zipio/ZioEntryOutputStream.java @@ -0,0 +1,85 @@ +/* + * Copyright (C) 2010 Ken Ellinwood + * + * 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 kellinwood.zipio; + +import java.io.IOException; +import java.io.OutputStream; +import java.util.zip.CRC32; +import java.util.zip.Deflater; +import java.util.zip.DeflaterOutputStream; + +public class ZioEntryOutputStream extends OutputStream { + int size = 0; // tracks uncompressed size of data + CRC32 crc = new CRC32(); + int crcValue = 0; + OutputStream wrapped; + OutputStream downstream; + Deflater deflater; + + public ZioEntryOutputStream(int compression, OutputStream wrapped) { + this.wrapped = wrapped; + if (compression != 0) { + deflater = new Deflater(Deflater.BEST_COMPRESSION, true); + downstream = new DeflaterOutputStream(wrapped, deflater); + } else { + downstream = wrapped; + } + } + + public void close() throws IOException { + downstream.flush(); + downstream.close(); + crcValue = (int) crc.getValue(); + if (deflater != null) { + deflater.end(); + } + } + + public int getCRC() { + return crcValue; + } + + public void flush() throws IOException { + downstream.flush(); + } + + public void write(byte[] b) throws IOException { + downstream.write(b); + crc.update(b); + size += b.length; + } + + public void write(byte[] b, int off, int len) throws IOException { + downstream.write(b, off, len); + crc.update(b, off, len); + size += len; + } + + public void write(int b) throws IOException { + downstream.write(b); + crc.update(b); + size += 1; + } + + public int getSize() { + return size; + } + + public OutputStream getWrappedStream() { + return wrapped; + } +} diff --git a/app/src/full/java/kellinwood/zipio/ZipInput.java b/app/src/full/java/kellinwood/zipio/ZipInput.java new file mode 100644 index 000000000..c74b6fee4 --- /dev/null +++ b/app/src/full/java/kellinwood/zipio/ZipInput.java @@ -0,0 +1,234 @@ +/* + * Copyright (C) 2010 Ken Ellinwood + * + * 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 kellinwood.zipio; + +import java.io.Closeable; +import java.io.File; +import java.io.IOException; +import java.io.RandomAccessFile; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.jar.Manifest; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import kellinwood.logging.LoggerInterface; +import kellinwood.logging.LoggerManager; + +/** + * + */ +public class ZipInput implements Closeable { + static LoggerInterface log; + + public String inputFilename; + RandomAccessFile in = null; + long fileLength; + int scanIterations = 0; + + Map zioEntries = new LinkedHashMap(); + CentralEnd centralEnd; + Manifest manifest; + + public ZipInput(String filename) throws IOException { + this.inputFilename = filename; + in = new RandomAccessFile(new File(inputFilename), "r"); + fileLength = in.length(); + } + + private static LoggerInterface getLogger() { + if (log == null) log = LoggerManager.getLogger(ZipInput.class.getName()); + return log; + } + + public String getFilename() { + return inputFilename; + } + + public long getFileLength() { + return fileLength; + } + + public static ZipInput read(String filename) throws IOException { + ZipInput zipInput = new ZipInput(filename); + zipInput.doRead(); + return zipInput; + } + + public ZioEntry getEntry(String filename) { + return zioEntries.get(filename); + } + + public Map getEntries() { + return zioEntries; + } + + /** + * Returns the names of immediate children in the directory with the given name. + * The path value must end with a "/" character. Use a value of "/" + * to get the root entries. + */ + public Collection list(String path) { + if (!path.endsWith("/")) { + throw new IllegalArgumentException("Invalid path -- does not end with '/'"); + } + + if (path.startsWith("/")) path = path.substring(1); + + Pattern p = Pattern.compile(String.format("^%s([^/]+/?).*", path)); + + Set names = new TreeSet(); + + for (String name : zioEntries.keySet()) { + Matcher m = p.matcher(name); + if (m.matches()) names.add(m.group(1)); + } + return names; + } + + public Manifest getManifest() throws IOException { + if (manifest == null) { + ZioEntry e = zioEntries.get("META-INF/MANIFEST.MF"); + if (e != null) { + manifest = new Manifest(e.getInputStream()); + } + } + return manifest; + } + + /** + * Scan the end of the file for the end of central directory record (EOCDR). + * Returns the file offset of the EOCD signature. The size parameter is an + * initial buffer size (e.g., 256). + */ + public long scanForEOCDR(int size) throws IOException { + if (size > fileLength || size > 65536) + throw new IllegalStateException("End of central directory not found in " + inputFilename); + + int scanSize = (int) Math.min(fileLength, size); + + byte[] scanBuf = new byte[scanSize]; + + in.seek(fileLength - scanSize); + + in.readFully(scanBuf); + + for (int i = scanSize - 22; i >= 0; i--) { + scanIterations += 1; + if (scanBuf[i] == 0x50 && scanBuf[i + 1] == 0x4b && scanBuf[i + 2] == 0x05 && scanBuf[i + 3] == 0x06) { + return fileLength - scanSize + i; + } + } + + return scanForEOCDR(size * 2); + } + + private void doRead() { + try { + int bufferSize = 256; + long fileLength = in.length(); + if (fileLength < bufferSize) { + bufferSize = (int) fileLength; + } + long posEOCDR = scanForEOCDR(bufferSize); + in.seek(posEOCDR); + centralEnd = CentralEnd.read(this); + + boolean debug = getLogger().isDebugEnabled(); + if (debug) { + getLogger().debug(String.format(Locale.ENGLISH, "EOCD found in %d iterations", scanIterations)); + getLogger().debug(String.format(Locale.ENGLISH, "Directory entries=%d, size=%d, offset=%d/0x%08x", centralEnd.totalCentralEntries, + centralEnd.centralDirectorySize, centralEnd.centralStartOffset, centralEnd.centralStartOffset)); + + ZipListingHelper.listHeader(getLogger()); + } + + in.seek(centralEnd.centralStartOffset); + + for (int i = 0; i < centralEnd.totalCentralEntries; i++) { + ZioEntry entry = ZioEntry.read(this); + zioEntries.put(entry.getName(), entry); + if (debug) ZipListingHelper.listEntry(getLogger(), entry); + } + + } catch (Throwable t) { + t.printStackTrace(); + } + } + + @Override + public void close() { + if (in != null) try { + in.close(); + } catch (Throwable t) { + } + } + + public long getFilePointer() throws IOException { + return in.getFilePointer(); + } + + public void seek(long position) throws IOException { + in.seek(position); + } + + public byte readByte() throws IOException { + return in.readByte(); + } + + public int readInt() throws IOException { + int result = 0; + for (int i = 0; i < 4; i++) { + result |= (in.readUnsignedByte() << (8 * i)); + } + return result; + } + + public short readShort() throws IOException { + short result = 0; + for (int i = 0; i < 2; i++) { + result |= (in.readUnsignedByte() << (8 * i)); + } + return result; + } + + public String readString(int length) throws IOException { + + byte[] buffer = new byte[length]; + for (int i = 0; i < length; i++) { + buffer[i] = in.readByte(); + } + return new String(buffer); + } + + public byte[] readBytes(int length) throws IOException { + + byte[] buffer = new byte[length]; + for (int i = 0; i < length; i++) { + buffer[i] = in.readByte(); + } + return buffer; + } + + public int read(byte[] b, int offset, int length) throws IOException { + return in.read(b, offset, length); + } +} diff --git a/app/src/full/java/kellinwood/zipio/ZipListingHelper.java b/app/src/full/java/kellinwood/zipio/ZipListingHelper.java new file mode 100644 index 000000000..2b22bf60f --- /dev/null +++ b/app/src/full/java/kellinwood/zipio/ZipListingHelper.java @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2010 Ken Ellinwood + * + * 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 kellinwood.zipio; + +import java.text.DateFormat; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; + +import kellinwood.logging.LoggerInterface; + +/** + * + */ +public class ZipListingHelper { + + static DateFormat dateFormat = new SimpleDateFormat("MM-dd-yy HH:mm", Locale.ENGLISH); + + public static void listHeader(LoggerInterface log) { + log.debug(" Length Method Size Ratio Date Time CRC-32 Name"); + log.debug("-------- ------ ------- ----- ---- ---- ------ ----"); + } + + public static void listEntry(LoggerInterface log, ZioEntry entry) { + int ratio = 0; + if (entry.getSize() > 0) { + ratio = (100 * (entry.getSize() - entry.getCompressedSize())) / entry.getSize(); + } + log.debug(String.format(Locale.ENGLISH, "%8d %6s %8d %4d%% %s %08x %s", + entry.getSize(), + entry.getCompression() == 0 ? "Stored" : "Defl:N", + entry.getCompressedSize(), + ratio, + dateFormat.format(new Date(entry.getTime())), + entry.getCrc32(), + entry.getName())); + } +} diff --git a/app/src/full/java/kellinwood/zipio/ZipOutput.java b/app/src/full/java/kellinwood/zipio/ZipOutput.java new file mode 100644 index 000000000..71dde51c4 --- /dev/null +++ b/app/src/full/java/kellinwood/zipio/ZipOutput.java @@ -0,0 +1,144 @@ +/* + * Copyright (C) 2010 Ken Ellinwood + * + * 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 kellinwood.zipio; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Set; + +import kellinwood.logging.LoggerInterface; +import kellinwood.logging.LoggerManager; + +public class ZipOutput { + + static LoggerInterface log; + + String outputFilename; + OutputStream out = null; + int filePointer = 0; + + List entriesWritten = new LinkedList(); + Set namesWritten = new HashSet(); + + public ZipOutput(String filename) throws IOException { + this.outputFilename = filename; + File ofile = new File(outputFilename); + init(ofile); + } + + public ZipOutput(File outputFile) throws IOException { + this.outputFilename = outputFile.getAbsolutePath(); + File ofile = outputFile; + init(ofile); + } + + private void init(File ofile) throws IOException { + if (ofile.exists()) ofile.delete(); + out = new FileOutputStream(ofile); + if (getLogger().isDebugEnabled()) ZipListingHelper.listHeader(getLogger()); + } + + public ZipOutput(OutputStream os) throws IOException { + out = os; + } + + private static LoggerInterface getLogger() { + if (log == null) log = LoggerManager.getLogger(ZipOutput.class.getName()); + return log; + } + + public void write(ZioEntry entry) throws IOException { + String entryName = entry.getName(); + if (namesWritten.contains(entryName)) { + getLogger().warning("Skipping duplicate file in output: " + entryName); + return; + } + entry.writeLocalEntry(this); + entriesWritten.add(entry); + namesWritten.add(entryName); + if (getLogger().isDebugEnabled()) ZipListingHelper.listEntry(getLogger(), entry); + } + + public void close() throws IOException { + CentralEnd centralEnd = new CentralEnd(); + + centralEnd.centralStartOffset = (int) getFilePointer(); + centralEnd.numCentralEntries = centralEnd.totalCentralEntries = (short) entriesWritten.size(); + + for (ZioEntry entry : entriesWritten) { + entry.write(this); + } + + centralEnd.centralDirectorySize = (int) (getFilePointer() - centralEnd.centralStartOffset); + centralEnd.fileComment = ""; + + centralEnd.write(this); + + if (out != null) try { + out.close(); + } catch (Throwable t) { + } + } + + public int getFilePointer() throws IOException { + return filePointer; + } + + public void writeInt(int value) throws IOException { + byte[] data = new byte[4]; + for (int i = 0; i < 4; i++) { + data[i] = (byte) (value & 0xFF); + value = value >> 8; + } + out.write(data); + filePointer += 4; + } + + public void writeShort(short value) throws IOException { + byte[] data = new byte[2]; + for (int i = 0; i < 2; i++) { + data[i] = (byte) (value & 0xFF); + value = (short) (value >> 8); + } + out.write(data); + filePointer += 2; + } + + public void writeString(String value) throws IOException { + + byte[] data = value.getBytes(); + out.write(data); + filePointer += data.length; + } + + public void writeBytes(byte[] value) throws IOException { + + out.write(value); + filePointer += value.length; + } + + public void writeBytes(byte[] value, int offset, int length) throws IOException { + + out.write(value, offset, length); + filePointer += length; + } +} diff --git a/app/src/full/java/org/fdroid/fdroid/FDroidApp.java b/app/src/full/java/org/fdroid/fdroid/FDroidApp.java new file mode 100644 index 000000000..70b5ce6aa --- /dev/null +++ b/app/src/full/java/org/fdroid/fdroid/FDroidApp.java @@ -0,0 +1,62 @@ +package org.fdroid.fdroid; + +import android.content.Context; + +import org.apache.commons.net.util.SubnetUtils; +import org.fdroid.database.Repository; +import org.fdroid.index.IndexFormatVersion; + +import javax.annotation.Nullable; + +/** + * Holds global state used by the swap/nearby feature. + * In the new app, org.fdroid.App is the Application class; + * this class provides only the static fields and helpers needed by swap. + */ +public class FDroidApp { + + // for the local repo on this device + public static volatile int port = 8888; + public static volatile boolean generateNewPort; + @Nullable + public static volatile String ipAddressString; + @Nullable + public static volatile SubnetUtils.SubnetInfo subnetInfo; + @Nullable + public static volatile String ssid; + @Nullable + public static volatile String bssid; + @Nullable + public static volatile Repository repo; + + @SuppressWarnings("unused") + public static volatile String queryString; + + public static final SubnetUtils.SubnetInfo UNSET_SUBNET_INFO = + new SubnetUtils("0.0.0.0/32").getInfo(); + + public static void initWifiSettings() { + port = generateNewPort ? (int) (Math.random() * 10000 + 8080) : port == 0 ? 8888 : port; + generateNewPort = false; + ipAddressString = null; + subnetInfo = UNSET_SUBNET_INFO; + ssid = null; + bssid = null; + } + + public static Repository createSwapRepo(String address, String certificate) { + long now = System.currentTimeMillis(); + if (certificate == null) certificate = "d0ef"; + return new Repository(42L, address, now, IndexFormatVersion.ONE, certificate, 20001L, 42, now); + } + + private static Context appContext; + + public static void setContext(Context context) { + appContext = context.getApplicationContext(); + } + + public static Context getInstance() { + return appContext; + } +} diff --git a/app/src/full/java/org/fdroid/fdroid/Hasher.java b/app/src/full/java/org/fdroid/fdroid/Hasher.java new file mode 100644 index 000000000..34c0519ff --- /dev/null +++ b/app/src/full/java/org/fdroid/fdroid/Hasher.java @@ -0,0 +1,99 @@ +/* + * Copyright (C) 2010-2011 Ciaran Gultnieks + * Copyright (C) 2011 Henrik Tunedal + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * This program 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 + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +package org.fdroid.fdroid; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.cert.Certificate; +import java.security.cert.CertificateEncodingException; + +public class Hasher { + + private MessageDigest digest; + private final byte[] array; + private String hashCache; + + public Hasher(String type, byte[] a) throws NoSuchAlgorithmException { + init(type); + this.array = a; + } + + private void init(String type) throws NoSuchAlgorithmException { + try { + digest = MessageDigest.getInstance(type); + } catch (Exception e) { + throw new NoSuchAlgorithmException(e); + } + } + + // Calculate hash (as lowercase hexadecimal string) for the file + // specified in the constructor. This will return a cached value + // on subsequent invocations. Returns the empty string on failure. + public String getHash() { + if (hashCache != null) { + return hashCache; + } + digest.update(array); + hashCache = hex(digest.digest()); + return hashCache; + } + + public static String hex(Certificate cert) { + byte[] encoded; + try { + encoded = cert.getEncoded(); + } catch (CertificateEncodingException e) { + encoded = new byte[0]; + } + return hex(encoded); + } + + private static String hex(byte[] sig) { + byte[] csig = new byte[sig.length * 2]; + for (int j = 0; j < sig.length; j++) { + byte v = sig[j]; + int d = (v >> 4) & 0xf; + csig[j * 2] = (byte) (d >= 10 ? ('a' + d - 10) : ('0' + d)); + d = v & 0xf; + csig[j * 2 + 1] = (byte) (d >= 10 ? ('a' + d - 10) : ('0' + d)); + } + return new String(csig); + } + + static byte[] unhex(String data) { + byte[] rawdata = new byte[data.length() / 2]; + for (int i = 0; i < data.length(); i++) { + char halfbyte = data.charAt(i); + int value; + if ('0' <= halfbyte && halfbyte <= '9') { + value = halfbyte - '0'; + } else if ('a' <= halfbyte && halfbyte <= 'f') { + value = halfbyte - 'a' + 10; + } else if ('A' <= halfbyte && halfbyte <= 'F') { + value = halfbyte - 'A' + 10; + } else { + throw new IllegalArgumentException("Bad hex digit"); + } + rawdata[i / 2] += (byte) (i % 2 == 0 ? value << 4 : value); + } + return rawdata; + } +} diff --git a/app/src/full/java/org/fdroid/fdroid/Preferences.java b/app/src/full/java/org/fdroid/fdroid/Preferences.java new file mode 100644 index 000000000..5541f73b9 --- /dev/null +++ b/app/src/full/java/org/fdroid/fdroid/Preferences.java @@ -0,0 +1,87 @@ +package org.fdroid.fdroid; + +import android.content.SharedPreferences; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * Minimal Preferences stub for the swap/nearby feature in the new app. + */ +@SuppressWarnings("unused") +public final class Preferences implements SharedPreferences.OnSharedPreferenceChangeListener { + private static final String TAG = "Preferences"; + + private static final String PREF_LOCAL_REPO_HTTPS = "localRepoHttps"; + public static final String PREF_LOCAL_REPO_NAME = "localRepoName"; + public static final String PREF_SCAN_REMOVABLE_STORAGE = "scanRemovableStorage"; + + private static Preferences instance; + private final Set localRepoHttpsListeners = new HashSet<>(); + + private Preferences() { + } + + public static Preferences get() { + if (instance == null) { + instance = new Preferences(); + } + return instance; + } + + public boolean isLocalRepoHttpsEnabled() { + return false; + } + + public String getLocalRepoName() { + return android.os.Build.MODEL; + } + + public boolean isScanRemovableStorageEnabled() { + return false; + } + + public boolean forceTouchApps() { + return false; + } + + public boolean isForceOldIndexEnabled() { + return false; + } + + public boolean isIpfsEnabled() { + return false; + } + + public List getActiveIpfsGateways() { + return new ArrayList<>(); + } + + public boolean isPureBlack() { + return false; + } + + public interface ChangeListener { + void onPreferenceChange(); + } + + public void registerLocalRepoHttpsListeners(ChangeListener listener) { + localRepoHttpsListeners.add(listener); + } + + public void unregisterLocalRepoHttpsListeners(ChangeListener listener) { + localRepoHttpsListeners.remove(listener); + } + + @Override + public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) { + if (PREF_LOCAL_REPO_HTTPS.equals(key)) { + for (ChangeListener listener : localRepoHttpsListeners) { + listener.onPreferenceChange(); + } + } + } +} + diff --git a/app/src/full/java/org/fdroid/fdroid/Utils.java b/app/src/full/java/org/fdroid/fdroid/Utils.java new file mode 100644 index 000000000..c02610e15 --- /dev/null +++ b/app/src/full/java/org/fdroid/fdroid/Utils.java @@ -0,0 +1,437 @@ +/* + * Copyright (C) 2010-12 Ciaran Gultnieks, ciaran@ciarang.com + * Copyright (C) 2019 Michael Pöhn, michael.poehn@fsfe.org + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 3 + * of the License, or (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +package org.fdroid.fdroid; + +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.ContentResolver; +import android.content.Context; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManager; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.Rect; +import android.net.Uri; +import android.os.Build; +import android.os.Handler; +import android.os.Looper; +import android.os.UserManager; +import android.text.TextUtils; +import android.util.Log; +import android.view.View; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.view.DisplayCompat; + +import com.google.zxing.BarcodeFormat; +import com.google.zxing.WriterException; +import com.google.zxing.encode.Contents; +import com.google.zxing.encode.QRCodeEncoder; + +import org.fdroid.BuildConfig; +import org.fdroid.R; +import org.fdroid.database.Repository; +import org.fdroid.download.Mirror; +import org.fdroid.fdroid.compat.FileCompat; +import org.fdroid.fdroid.data.SanitizedFile; +import org.fdroid.fdroid.net.TreeUriDownloader; +import org.fdroid.index.v2.FileV2; +import org.json.JSONArray; +import org.json.JSONException; + +import java.io.Closeable; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.Socket; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.regex.Pattern; + +import vendored.org.apache.commons.codec.digest.DigestUtils; + +public final class Utils { + + private static final String TAG = "Utils"; + + private static final int BUFFER_SIZE = 4096; + + private static final String[] FRIENDLY_SIZE_FORMAT = { + "%.0f B", "%.0f KiB", "%.1f MiB", "%.2f GiB", + }; + + private static Pattern safePackageNamePattern; + + private static Handler toastHandler; + + public static String getDisallowInstallUnknownSourcesErrorMessage(Context context) { + UserManager userManager = (UserManager) context.getSystemService(Context.USER_SERVICE); + if (Build.VERSION.SDK_INT >= 29 + && userManager.hasUserRestriction(UserManager.DISALLOW_INSTALL_UNKNOWN_SOURCES_GLOBALLY)) { + return context.getString(R.string.has_disallow_install_unknown_sources_globally); + } + return context.getString(R.string.has_disallow_install_unknown_sources); + } + + @NonNull + public static Uri getUri(String repoAddress, String... pathElements) { + /* + * Storage Access Framework URLs have this wacky URL-encoded path within the URL path. + * + * i.e. + * content://authority/tree/313E-1F1C%3A/document/313E-1F1C%3Aguardianproject.info%2Ffdroid%2Frepo + * + * Currently don't know a better way to identify these than by content:// prefix, + * seems the Android SDK expects apps to consider them as opaque identifiers. + * + * Note: This hack works for the external storage documents provider for now, + * but will most likely fail for other providers. + * Using DocumentFile off the UiThread can be used to build path Uris reliably. + */ + if (repoAddress.startsWith("content://")) { + StringBuilder result = new StringBuilder(repoAddress); + for (String element : pathElements) { + result.append(TreeUriDownloader.ESCAPED_SLASH); + result.append(element); + } + return Uri.parse(result.toString()); + } else { // Normal URL + Uri.Builder result = Uri.parse(repoAddress).buildUpon(); + for (String element : pathElements) { + result.appendPath(element); + } + return result.build(); + } + } + + /** + * Returns the repository address. Usually this is {@link Repository#getAddress()}, + * but in case of a content:// repo, we need to take its local Uri instead, + * so we can know that we need to use different downloaders for non-HTTP locations. + */ + public static String getRepoAddress(Repository repository) { + List mirrors = repository.getMirrors(); + // check if we need to account for non-HTTP mirrors + String nonHttpUri = null; + for (Mirror m : mirrors) { + if (ContentResolver.SCHEME_CONTENT.equals(m.getUrl().getProtocol().getName()) + || ContentResolver.SCHEME_FILE.equals(m.getUrl().getProtocol().getName())) { + nonHttpUri = m.getBaseUrl(); + break; + } + } + // return normal canonical URL, if this is a pure HTTP repo + if (nonHttpUri == null) { + String address = repository.getAddress(); + if (address.endsWith("/")) return address.substring(0, address.length() - 1); + return address; + } else { + return nonHttpUri; + } + } + + + public static void copy(InputStream input, OutputStream output) throws IOException { + byte[] buffer = new byte[BUFFER_SIZE]; + while (true) { + int count = input.read(buffer); + if (count == -1) { + break; + } + output.write(buffer, 0, count); + } + output.flush(); + } + + /** + * Attempt to symlink, but if that fails, it will make a copy of the file. + */ + public static boolean symlinkOrCopyFileQuietly(SanitizedFile inFile, SanitizedFile outFile) { + return FileCompat.symlink(inFile, outFile) || copyQuietly(inFile, outFile); + } + + /** + * Read the input stream until it reaches the end, ignoring any exceptions. + */ + public static void consumeStream(InputStream stream) { + final byte[] buffer = new byte[256]; + try { + int read; + do { + read = stream.read(buffer); + } while (read != -1); + } catch (IOException e) { + // Ignore... + } + } + + private static boolean copyQuietly(File inFile, File outFile) { + InputStream input = null; + OutputStream output = null; + try { + input = new FileInputStream(inFile); + output = new FileOutputStream(outFile); + Utils.copy(input, output); + return true; + } catch (IOException e) { + Log.e(TAG, "I/O error when copying a file", e); + return false; + } finally { + closeQuietly(output); + closeQuietly(input); + } + } + + public static void closeQuietly(Closeable closeable) { + if (closeable == null) { + return; + } + try { + closeable.close(); + } catch (IOException ioe) { + // ignore + } + } + + @NonNull + public static Uri getLocalRepoUri(Repository repo) { + if (TextUtils.isEmpty(repo.getAddress())) { + return Uri.parse("http://wifi-not-enabled"); + } + Uri uri = Uri.parse(repo.getAddress()); + Uri.Builder b = uri.buildUpon(); + if (!TextUtils.isEmpty(repo.getCertificate())) { + String fingerprint = DigestUtils.sha256Hex(repo.getCertificate()); + b.appendQueryParameter("fingerprint", fingerprint); + } + String scheme = Preferences.get().isLocalRepoHttpsEnabled() ? "https" : "http"; + b.scheme(scheme); + return b.build(); + } + + public static Uri getSharingUri(Repository repo) { + if (repo == null || TextUtils.isEmpty(repo.getAddress())) { + return Uri.parse("http://wifi-not-enabled"); + } + Uri localRepoUri = getLocalRepoUri(repo); + Uri.Builder b = localRepoUri.buildUpon(); + b.scheme(localRepoUri.getScheme().replaceFirst("http", "fdroidrepo")); + b.appendQueryParameter("swap", "1"); + if (!TextUtils.isEmpty(FDroidApp.bssid)) { + b.appendQueryParameter("bssid", FDroidApp.bssid); + if (!TextUtils.isEmpty(FDroidApp.ssid)) { + b.appendQueryParameter("ssid", FDroidApp.ssid); + } + } + return b.build(); + } + + public static void debugLog(String tag, String msg) { + if (BuildConfig.DEBUG) { + Log.d(tag, msg); + } + } + + public static void debugLog(String tag, String msg, Throwable tr) { + if (BuildConfig.DEBUG) { + Log.d(tag, msg, tr); + } + } + + public static String getApplicationLabel(Context context, String packageName) { + PackageManager pm = context.getPackageManager(); + ApplicationInfo appInfo; + try { + appInfo = pm.getApplicationInfo(packageName, PackageManager.GET_META_DATA); + return appInfo.loadLabel(pm).toString(); + } catch (PackageManager.NameNotFoundException | Resources.NotFoundException e) { + Utils.debugLog(TAG, "Could not get application label: " + e.getMessage()); + } + return packageName; // all else fails, return packageName + } + + @SuppressWarnings("unused") + public static class Profiler { + public final long startTime = System.currentTimeMillis(); + public final String logTag; + + public Profiler(String logTag) { + this.logTag = logTag; + } + + public void log(String message) { + long duration = System.currentTimeMillis() - startTime; + Utils.debugLog(logTag, "[" + duration + "ms] " + message); + } + } + + /** + * In order to send a {@link Toast} from a {@link android.app.Service}, we + * have to do these tricks. + */ + public static void showToastFromService(final Context context, final String msg, final int length) { + if (toastHandler == null) { + toastHandler = new Handler(Looper.getMainLooper()); + } + toastHandler.post(() -> Toast.makeText(context.getApplicationContext(), msg, length).show()); + } + + public static Bitmap generateQrBitmap(@NonNull final AppCompatActivity activity, + @NonNull final String qrData) { + final DisplayCompat.ModeCompat displayMode = DisplayCompat.getMode(activity, + activity.getWindowManager().getDefaultDisplay()); + final int qrCodeDimension = Math.min(displayMode.getPhysicalWidth(), + displayMode.getPhysicalHeight()); + debugLog(TAG, "generating QRCode Bitmap of " + qrCodeDimension + "x" + qrCodeDimension); + try { + return new QRCodeEncoder(qrData, null, Contents.Type.TEXT, + BarcodeFormat.QR_CODE.toString(), qrCodeDimension).encodeAsBitmap(); + } catch (WriterException e) { + return Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888); + } + } + + public static ArrayList toString(@Nullable List files) { + if (files == null) return new ArrayList<>(0); + ArrayList list = new ArrayList<>(files.size()); + for (FileV2 file : files) { + list.add(file.serialize()); + } + return list; + } + + public static List fileV2FromStrings(List list) { + ArrayList files = new ArrayList<>(list.size()); + for (String s : list) { + files.add(FileV2.deserialize(s)); + } + return files; + } + + /** + * Keep an instance of this class as an field in an AppCompatActivity for figuring out whether the on + * screen keyboard is currently visible or not. + */ + public static class KeyboardStateMonitor { + + private boolean visible = false; + + /** + * @param contentView this must be the top most Container of the layout used by the AppCompatActivity + */ + public KeyboardStateMonitor(final View contentView) { + contentView.getViewTreeObserver().addOnGlobalLayoutListener(() -> { + int screenHeight = contentView.getRootView().getHeight(); + Rect rect = new Rect(); + contentView.getWindowVisibleDisplayFrame(rect); + int keypadHeight = screenHeight - rect.bottom; + visible = keypadHeight >= screenHeight * 0.15; + } + ); + } + + public boolean isKeyboardVisible() { + return visible; + } + } + + public static boolean isPortInUse(String host, int port) { + boolean result = false; + + try { + (new Socket(host, port)).close(); + result = true; + } catch (IOException e) { + // Could not connect. + e.printStackTrace(); + } + return result; + } + + /** + * Copy text to the clipboard and show a toast informing the user that something has been copied. + * + * @param context the context to use + * @param label the label used in the clipboard + * @param text the text to copy + */ + public static void copyToClipboard(@NonNull Context context, @Nullable String label, + @NonNull String text) { + copyToClipboard(context, label, text, R.string.copied_to_clipboard); + } + + /** + * Copy text to the clipboard and show a toast informing the user that the text has been copied. + * + * @param context the context to use + * @param label the label used in the clipboard + * @param text the text to copy + * @param message the message to show in the toast + */ + public static void copyToClipboard(@NonNull Context context, @Nullable String label, + @NonNull String text, @StringRes int message) { + ClipboardManager clipboard = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE); + if (clipboard == null) { + // permission denied + return; + } + try { + clipboard.setPrimaryClip(ClipData.newPlainText(label, text)); + if (Build.VERSION.SDK_INT < 33) { + // Starting with Android 13 (SDK 33) there is a system dialog with more clipboard actions + // shown automatically so there is no need to inform the user about the copy action. + Toast.makeText(context, message, Toast.LENGTH_SHORT).show(); + } + } catch (Exception e) { + // should not happen, something went wrong internally + debugLog(TAG, "Could not copy to clipboard: " + e.getMessage()); + } + + } + + public static String toJsonStringArray(List list) { + JSONArray jsonArray = new JSONArray(); + for (String str : list) { + jsonArray.put(str); + } + return jsonArray.toString(); + } + + public static List parseJsonStringArray(String json) { + try { + JSONArray jsonArray = new JSONArray(json); + List l = new ArrayList<>(jsonArray.length()); + for (int i = 0; i < jsonArray.length(); i++) { + l.add(jsonArray.getString(i)); + } + return l; + } catch (JSONException e) { + return Collections.emptyList(); + } + } +} diff --git a/app/src/full/java/org/fdroid/fdroid/compat/FileCompat.java b/app/src/full/java/org/fdroid/fdroid/compat/FileCompat.java new file mode 100644 index 000000000..accb0a13a --- /dev/null +++ b/app/src/full/java/org/fdroid/fdroid/compat/FileCompat.java @@ -0,0 +1,78 @@ +package org.fdroid.fdroid.compat; + +import android.os.Environment; +import android.system.ErrnoException; +import android.util.Log; + +import org.fdroid.fdroid.Utils; +import org.fdroid.fdroid.data.SanitizedFile; + +import java.io.IOException; +import java.lang.reflect.Method; + +/** + * This class works only with {@link SanitizedFile} instances to enforce + * filtering of the file names from files downloaded from the internet. + * This helps prevent things like SQL injection, shell command injection + * and other attacks based on putting various characters into filenames. + */ +public class FileCompat { + + private static final String TAG = "FileCompat"; + public static final String SYSTEM_DIR_NAME = Environment.getRootDirectory().getAbsolutePath(); + + public static boolean symlink(SanitizedFile source, SanitizedFile dest) { + symlinkOs(source, dest); + return dest.exists(); + } + + /** + * Moved into a separate class rather than just a method, so that phones without API 21 will + * not attempt to load this class at runtime. Otherwise, using the Os.symlink method will cause + * a VerifyError to be thrown at runtime when the FileCompat class is first used. + */ + private static class Symlink21 { + + void symlink(SanitizedFile source, SanitizedFile dest) { + try { + android.system.Os.symlink(source.getAbsolutePath(), dest.getAbsolutePath()); + } catch (ErrnoException e) { + // Do nothing... + } + } + } + + static void symlinkOs(SanitizedFile source, SanitizedFile dest) { + new Symlink21().symlink(source, dest); + } + + static void symlinkRuntime(SanitizedFile source, SanitizedFile dest) { + String[] commands = { + SYSTEM_DIR_NAME + "/bin/ln", + "-s", + source.getAbsolutePath(), + dest.getAbsolutePath(), + }; + try { + Utils.debugLog(TAG, "Executing command: " + commands[0] + " " + commands[1] + + " " + commands[2] + " " + commands[3]); + Process proc = Runtime.getRuntime().exec(commands); + Utils.consumeStream(proc.getInputStream()); + Utils.consumeStream(proc.getErrorStream()); + } catch (IOException e) { + // Do nothing + } + } + + static void symlinkLibcore(SanitizedFile source, SanitizedFile dest) { + try { + Object os = Class.forName("libcore.io.Libcore").getField("os").get(null); + Method symlink = os.getClass().getMethod("symlink", String.class, String.class); + symlink.invoke(os, source.getAbsolutePath(), dest.getAbsolutePath()); + } catch (Exception e) { + // Should catch more specific exceptions than just "Exception" here, but there are + // some which come from libcore.io.Libcore, which we don't have access to at compile time. + Log.e(TAG, "Could not symlink " + source.getAbsolutePath() + " to " + dest.getAbsolutePath(), e); + } + } +} diff --git a/app/src/full/java/org/fdroid/fdroid/data/Apk.java b/app/src/full/java/org/fdroid/fdroid/data/Apk.java new file mode 100644 index 000000000..340def17e --- /dev/null +++ b/app/src/full/java/org/fdroid/fdroid/data/Apk.java @@ -0,0 +1,11 @@ +package org.fdroid.fdroid.data; + +import org.fdroid.index.v2.FileV1; + +public class Apk { + public long repoId; + public String packageName; + public long versionCode; + public String versionName; + public FileV1 apkFile; +} diff --git a/app/src/full/java/org/fdroid/fdroid/data/App.java b/app/src/full/java/org/fdroid/fdroid/data/App.java new file mode 100644 index 000000000..5b2cdc3a2 --- /dev/null +++ b/app/src/full/java/org/fdroid/fdroid/data/App.java @@ -0,0 +1,24 @@ +package org.fdroid.fdroid.data; + +import android.content.Context; + +import org.fdroid.index.v2.FileV2; + +public class App { + public String packageName; + public String name; + public FileV2 iconFile; + public Apk installedApk; + public long installedVersionCode; + public long autoInstallVersionCode; + public String installedVersionName; + public boolean compatible = false; + + public boolean hasUpdates() { + return autoInstallVersionCode > installedVersionCode; + } + + public boolean isInstalled(Context context) { + return installedVersionCode > 0; + } +} diff --git a/app/src/full/java/org/fdroid/fdroid/data/SanitizedFile.java b/app/src/full/java/org/fdroid/fdroid/data/SanitizedFile.java new file mode 100644 index 000000000..584b10424 --- /dev/null +++ b/app/src/full/java/org/fdroid/fdroid/data/SanitizedFile.java @@ -0,0 +1,69 @@ +package org.fdroid.fdroid.data; + +import java.io.File; + +/** + * File guaranteed to have a santitized name (though not a sanitized path to the parent dir). + * Useful so that we can use Java's type system to enforce that the file we are accessing + * doesn't contain illegal characters. + * Sanitized names are those which only have the following characters: [A-Za-z0-9.-_] + */ +public class SanitizedFile extends File { + + /** + * Removes anything that is not an alpha numeric character, or one of "-", ".", or "_". + */ + public static String sanitizeFileName(String name) { + return name.replaceAll("[^A-Za-z0-9-._ ]", ""); + } + + /** + * The "name" argument is assumed to be a file name, _not including any path separators_. + * If it is a relative path to be appended to "parent", such as "/blah/sneh.txt", then + * the forward slashes will be removed and it will be assumed you meant "blahsneh.txt". + */ + public SanitizedFile(File parent, String name) { + super(parent, sanitizeFileName(name)); + } + + /** + * Used by the {@link SanitizedFile#knownSanitized(File)} + * method, but intentionally kept private so people don't think that any sanitization + * will occur by passing a file in - because it won't. + */ + private SanitizedFile(File file) { + super(file.getAbsolutePath()); + } + + /** + * This is dangerous, but there will be some cases when all we have is an absolute file + * path that wasn't given to us from user input. One example would be asking Android for + * the path to an installed .apk on disk. In such situations, we can't meaningfully + * sanitize it, but will still need to pass to a function which only allows SanitizedFile's + * as arguments (because they interact with, e.g. shells). + *

+ * To illustrate, imagine perfectly valid file path: "/tmp/../secret/file.txt", + * one cannot distinguish between: + *

+ * "/tmp/" (known safe directory) + "../secret/file.txt" (suspicious looking file name) + *

+ * and + *

+ * "/tmp/../secret/" (known safe directory) + "file.txt" (known safe file name) + *

+ * I guess the best this method offers us is the ability to uniquely trace the different + * ways in which files are created and handled. It should make it easier to find and + * prevent suspect usages of methods which only expect SanitizedFile's, but are given + * a SanitizedFile returned from this method that really originated from user input. + */ + public static SanitizedFile knownSanitized(String path) { + return new SanitizedFile(new File(path)); + } + + /** + * @see SanitizedFile#knownSanitized(String) + */ + public static SanitizedFile knownSanitized(File file) { + return new SanitizedFile(file); + } +} diff --git a/app/src/full/java/org/fdroid/fdroid/nearby/BluetoothClient.java b/app/src/full/java/org/fdroid/fdroid/nearby/BluetoothClient.java new file mode 100644 index 000000000..565cbd1f7 --- /dev/null +++ b/app/src/full/java/org/fdroid/fdroid/nearby/BluetoothClient.java @@ -0,0 +1,35 @@ +package org.fdroid.fdroid.nearby; + +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.BluetoothDevice; +import android.bluetooth.BluetoothSocket; + +import androidx.annotation.RequiresPermission; + +import java.io.IOException; + +public class BluetoothClient { + private static final String TAG = "BluetoothClient"; + + private final BluetoothDevice device; + + public BluetoothClient(String macAddress) { + device = BluetoothAdapter.getDefaultAdapter().getRemoteDevice(macAddress); + } + + @RequiresPermission("android.permission.BLUETOOTH_CONNECT") + public BluetoothConnection openConnection() throws IOException { + + BluetoothConnection connection = null; + try { + BluetoothSocket socket = device.createInsecureRfcommSocketToServiceRecord(BluetoothConstants.fdroidUuid()); + connection = new BluetoothConnection(socket); + connection.open(); + return connection; + } finally { + if (connection != null) { + connection.closeQuietly(); + } + } + } +} diff --git a/app/src/full/java/org/fdroid/fdroid/nearby/BluetoothConnection.java b/app/src/full/java/org/fdroid/fdroid/nearby/BluetoothConnection.java new file mode 100644 index 000000000..bb4b7cc3f --- /dev/null +++ b/app/src/full/java/org/fdroid/fdroid/nearby/BluetoothConnection.java @@ -0,0 +1,55 @@ +package org.fdroid.fdroid.nearby; + +import android.bluetooth.BluetoothSocket; + +import androidx.annotation.RequiresPermission; + +import org.fdroid.fdroid.Utils; + +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +public class BluetoothConnection { + + private static final String TAG = "BluetoothConnection"; + + private InputStream input; + private OutputStream output; + private final BluetoothSocket socket; + + BluetoothConnection(BluetoothSocket socket) { + this.socket = socket; + } + + public InputStream getInputStream() { + return input; + } + + public OutputStream getOutputStream() { + return output; + } + + @RequiresPermission("android.permission.BLUETOOTH_CONNECT") + public void open() throws IOException { + if (!socket.isConnected()) { + socket.connect(); + } + input = new BufferedInputStream(socket.getInputStream()); + output = new BufferedOutputStream(socket.getOutputStream()); + Utils.debugLog(TAG, "Opened connection to Bluetooth device"); + } + + public void closeQuietly() { + Utils.closeQuietly(input); + Utils.closeQuietly(output); + Utils.closeQuietly(socket); + } + + public void close() { + closeQuietly(); + } +} + diff --git a/app/src/full/java/org/fdroid/fdroid/nearby/BluetoothConstants.java b/app/src/full/java/org/fdroid/fdroid/nearby/BluetoothConstants.java new file mode 100644 index 000000000..26276d5ea --- /dev/null +++ b/app/src/full/java/org/fdroid/fdroid/nearby/BluetoothConstants.java @@ -0,0 +1,11 @@ +package org.fdroid.fdroid.nearby; + +import java.util.UUID; + +class BluetoothConstants { + + static UUID fdroidUuid() { + return UUID.fromString("cd59ba31-5729-b3bb-cb29-732b59eb61aa"); + } +} + diff --git a/app/src/full/java/org/fdroid/fdroid/nearby/BluetoothManager.java b/app/src/full/java/org/fdroid/fdroid/nearby/BluetoothManager.java new file mode 100644 index 000000000..4596c98a2 --- /dev/null +++ b/app/src/full/java/org/fdroid/fdroid/nearby/BluetoothManager.java @@ -0,0 +1,195 @@ +package org.fdroid.fdroid.nearby; + +import static android.Manifest.permission.BLUETOOTH_CONNECT; +import static android.Manifest.permission.BLUETOOTH_SCAN; +import static android.content.pm.PackageManager.PERMISSION_GRANTED; +import static androidx.core.content.ContextCompat.checkSelfPermission; + +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.BluetoothDevice; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.Message; +import android.os.Process; +import android.text.TextUtils; +import android.util.Log; + +import androidx.annotation.RequiresPermission; +import androidx.localbroadcastmanager.content.LocalBroadcastManager; + +import org.fdroid.R; +import org.fdroid.fdroid.Utils; +import org.fdroid.fdroid.nearby.peers.BluetoothPeer; + +import java.lang.ref.WeakReference; + +/** + * Manage the {@link BluetoothAdapter}in a {@link HandlerThread}. + * The start process is in {@link HandlerThread#onLooperPrepared()} so that it is + * always started before any messages get delivered from the queue. + * + * @see BonjourManager + * @see LocalRepoManager + */ +public class BluetoothManager { + private static final String TAG = "BluetoothManager"; + + public static final String ACTION_FOUND = "BluetoothNewPeer"; + public static final String EXTRA_PEER = "extraBluetoothPeer"; + + public static final String ACTION_STATUS = "BluetoothStatus"; + public static final String EXTRA_STATUS = "BluetoothStatusExtra"; + public static final int STATUS_STARTING = 0; + public static final int STATUS_STARTED = 1; + public static final int STATUS_STOPPING = 2; + public static final int STATUS_STOPPED = 3; + public static final int STATUS_ERROR = 0xffff; + + private static final int STOP = 5709; + + private static WeakReference context; + private static Handler handler; + private static volatile HandlerThread handlerThread; + private static BluetoothAdapter bluetoothAdapter; + + /** + * Stops the Bluetooth adapter, triggering a status broadcast via {@link #ACTION_STATUS}. + * {@link #STATUS_STOPPED} can be broadcast multiple times for the same session, + * so make sure {@link BroadcastReceiver}s handle duplicates. + */ + public static void stop(Context context) { + BluetoothManager.context = new WeakReference<>(context); + if (handler == null || handlerThread == null || !handlerThread.isAlive()) { + Log.w(TAG, "handlerThread is already stopped, doing nothing!"); + sendBroadcast(STATUS_STOPPED, null); + return; + } + sendBroadcast(STATUS_STOPPING, null); + handler.sendEmptyMessage(STOP); + } + + /** + * Starts the service, triggering a status broadcast via {@link #ACTION_STATUS}. + * {@link #STATUS_STARTED} can be broadcast multiple times for the same session, + * so make sure {@link BroadcastReceiver}s handle duplicates. + */ + public static void start(final Context context) { + if (checkSelfPermission(context, BLUETOOTH_CONNECT) != PERMISSION_GRANTED && + checkSelfPermission(context, BLUETOOTH_SCAN) != PERMISSION_GRANTED) { + // TODO we either throw away that Bluetooth code or properly request permissions + return; + } + BluetoothManager.context = new WeakReference<>(context); + if (handlerThread != null && handlerThread.isAlive()) { + sendBroadcast(STATUS_STARTED, null); + return; + } + sendBroadcast(STATUS_STARTING, null); + + final BluetoothServer bluetoothServer = new BluetoothServer(context.getFilesDir()); + handlerThread = new HandlerThread("BluetoothManager", Process.THREAD_PRIORITY_LESS_FAVORABLE) { + @RequiresPermission(allOf = {BLUETOOTH_CONNECT, BLUETOOTH_SCAN}) + @Override + protected void onLooperPrepared() { + LocalBroadcastManager localBroadcastManager = LocalBroadcastManager.getInstance(context); + localBroadcastManager.registerReceiver(bluetoothDeviceFound, + new IntentFilter(BluetoothDevice.ACTION_FOUND)); + bluetoothAdapter = BluetoothAdapter.getDefaultAdapter(); + String name = bluetoothAdapter.getName(); + if (name != null) { + SwapService.putBluetoothNameBeforeSwap(name); + } + if (!bluetoothAdapter.enable()) { + sendBroadcast(STATUS_ERROR, context.getString(R.string.swap_error_cannot_start_bluetooth)); + return; + } + bluetoothServer.start(); + if (bluetoothAdapter.startDiscovery()) { + sendBroadcast(STATUS_STARTED, null); + } else { + sendBroadcast(STATUS_ERROR, context.getString(R.string.swap_error_cannot_start_bluetooth)); + } + for (BluetoothDevice device : bluetoothAdapter.getBondedDevices()) { + sendFoundBroadcast(context, device); + } + } + }; + handlerThread.start(); + handler = new Handler(handlerThread.getLooper()) { + @RequiresPermission(allOf = {BLUETOOTH_SCAN, BLUETOOTH_CONNECT}) + @Override + public void handleMessage(Message msg) { + LocalBroadcastManager localBroadcastManager = LocalBroadcastManager.getInstance(context); + localBroadcastManager.unregisterReceiver(bluetoothDeviceFound); + bluetoothServer.close(); + if (bluetoothAdapter != null) { + bluetoothAdapter.cancelDiscovery(); + if (!SwapService.wasBluetoothEnabledBeforeSwap()) { + bluetoothAdapter.disable(); + } + String name = SwapService.getBluetoothNameBeforeSwap(); + if (name != null) { + bluetoothAdapter.setName(name); + } + } + handlerThread.quit(); + handlerThread = null; + sendBroadcast(STATUS_STOPPED, null); + } + }; + } + + public static void restart(Context context) { + stop(context); + try { + handlerThread.join(10000); + } catch (InterruptedException | NullPointerException e) { + // ignored + } + start(context); + } + + public static void setName(Context context, String name) { + // TODO + } + + public static boolean isAlive() { + return handlerThread != null && handlerThread.isAlive(); + } + + private static void sendBroadcast(int status, String message) { + + Intent intent = new Intent(ACTION_STATUS); + intent.putExtra(EXTRA_STATUS, status); + if (!TextUtils.isEmpty(message)) { + intent.putExtra(Intent.EXTRA_TEXT, message); + } + LocalBroadcastManager.getInstance(context.get()).sendBroadcast(intent); + } + + @RequiresPermission(BLUETOOTH_CONNECT) + private static final BroadcastReceiver bluetoothDeviceFound = new BroadcastReceiver() { + @Override + @RequiresPermission(BLUETOOTH_CONNECT) + public void onReceive(Context context, Intent intent) { + sendFoundBroadcast(context, (BluetoothDevice) intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)); + } + }; + + @RequiresPermission(BLUETOOTH_CONNECT) + private static void sendFoundBroadcast(Context context, BluetoothDevice device) { + BluetoothPeer bluetoothPeer = BluetoothPeer.getInstance(device); + if (bluetoothPeer == null) { + Utils.debugLog(TAG, "IGNORING: " + device); + return; + } + Intent intent = new Intent(ACTION_FOUND); + intent.putExtra(EXTRA_PEER, bluetoothPeer); + intent.putExtra(BluetoothDevice.EXTRA_DEVICE, device); + LocalBroadcastManager.getInstance(context).sendBroadcast(intent); + } +} diff --git a/app/src/full/java/org/fdroid/fdroid/nearby/BluetoothServer.java b/app/src/full/java/org/fdroid/fdroid/nearby/BluetoothServer.java new file mode 100644 index 000000000..5c1e7da99 --- /dev/null +++ b/app/src/full/java/org/fdroid/fdroid/nearby/BluetoothServer.java @@ -0,0 +1,350 @@ +package org.fdroid.fdroid.nearby; + +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.BluetoothServerSocket; +import android.bluetooth.BluetoothSocket; +import android.util.Log; +import android.webkit.MimeTypeMap; + +import androidx.annotation.RequiresPermission; + +import org.fdroid.fdroid.Utils; +import org.fdroid.fdroid.nearby.httpish.Request; +import org.fdroid.fdroid.nearby.httpish.Response; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import fi.iki.elonen.NanoHTTPD; + +/** + * Act as a layer on top of LocalHTTPD server, by forwarding requests served + * over bluetooth to that server. + */ +@SuppressWarnings("LineLength") +public class BluetoothServer extends Thread { + + private static final String TAG = "BluetoothServer"; + + private BluetoothServerSocket serverSocket; + private final List clients = new ArrayList<>(); + + private final File webRoot; + + public BluetoothServer(File webRoot) { + this.webRoot = webRoot; + } + + public void close() { + + for (ClientConnection clientConnection : clients) { + clientConnection.interrupt(); + } + + interrupt(); + + if (serverSocket != null) { + Utils.closeQuietly(serverSocket); + } + } + + @Override + @RequiresPermission("android.permission.BLUETOOTH_CONNECT") + public void run() { + + final BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter(); + + try { + serverSocket = adapter.listenUsingInsecureRfcommWithServiceRecord("FDroid App Swap", BluetoothConstants.fdroidUuid()); + } catch (IOException e) { + Log.e(TAG, "Error starting Bluetooth server socket, will stop the server now", e); + return; + } + + while (true) { + if (isInterrupted()) { + Utils.debugLog(TAG, "Server stopped so will terminate loop looking for client connections."); + break; + } + + if (!adapter.isEnabled()) { + Utils.debugLog(TAG, "User disabled Bluetooth from outside, stopping."); + break; + } + + try { + BluetoothSocket clientSocket = serverSocket.accept(); + if (clientSocket != null) { + if (isInterrupted()) { + Utils.debugLog(TAG, "Server stopped after socket accepted from client, but before initiating connection."); + break; + } + ClientConnection client = new ClientConnection(clientSocket, webRoot); + client.start(); + clients.add(client); + } + } catch (IOException e) { + Log.e(TAG, "Error receiving client connection over Bluetooth server socket, will continue listening for other clients", e); + } + } + } + + private static class ClientConnection extends Thread { + + private final BluetoothSocket socket; + private final File webRoot; + + ClientConnection(BluetoothSocket socket, File webRoot) { + this.socket = socket; + this.webRoot = webRoot; + } + + @Override + @RequiresPermission("android.permission.BLUETOOTH_CONNECT") + public void run() { + + Utils.debugLog(TAG, "Listening for incoming Bluetooth requests from client"); + + BluetoothConnection connection; + try { + connection = new BluetoothConnection(socket); + connection.open(); + } catch (IOException e) { + Log.e(TAG, "Error listening for incoming connections over bluetooth", e); + return; + } + + while (true) { + + try { + Utils.debugLog(TAG, "Listening for new Bluetooth request from client."); + Request incomingRequest = Request.listenForRequest(connection); + handleRequest(incomingRequest).send(connection); + } catch (IOException e) { + Log.e(TAG, "Error receiving incoming connection over bluetooth", e); + break; + } + + if (isInterrupted()) { + break; + } + } + + connection.closeQuietly(); + } + + private Response handleRequest(Request request) { + + Utils.debugLog(TAG, "Received Bluetooth request from client, will process it now."); + + Response.Builder builder = null; + + try { + int statusCode = HttpURLConnection.HTTP_NOT_FOUND; + int totalSize = -1; + + if (request.getMethod().equals(Request.Methods.HEAD)) { + builder = new Response.Builder(); + } else { + HashMap headers = new HashMap<>(); + Response resp = respond(headers, "/" + request.getPath()); + + builder = new Response.Builder(resp.toContentStream()); + statusCode = resp.getStatusCode(); + totalSize = resp.getFileSize(); + } + + // TODO: At this stage, will need to download the file to get this info. + // However, should be able to make totalDownloadSize and getCacheTag work without downloading. + return builder + .setStatusCode(statusCode) + .setFileSize(totalSize) + .build(); + + } catch (Exception e) { + // throw new IOException("Error getting file " + request.getPath() + " from local repo proxy - " + e.getMessage(), e); + + Log.e(TAG, "error processing request; sending 500 response", e); + + if (builder == null) { + builder = new Response.Builder(); + } + + return builder + .setStatusCode(500) + .setFileSize(0) + .build(); + + } + + } + + private Response respond(Map headers, String uri) { + // Remove URL arguments + uri = uri.trim().replace(File.separatorChar, '/'); + if (uri.indexOf('?') >= 0) { + uri = uri.substring(0, uri.indexOf('?')); + } + + // Prohibit getting out of current directory + if (uri.contains("../")) { + return createResponse(NanoHTTPD.Response.Status.FORBIDDEN, NanoHTTPD.MIME_PLAINTEXT, + "FORBIDDEN: Won't serve ../ for security reasons."); + } + + File f = new File(webRoot, uri); + if (!f.exists()) { + return createResponse(NanoHTTPD.Response.Status.NOT_FOUND, NanoHTTPD.MIME_PLAINTEXT, + "Error 404, file not found."); + } + + // Browsers get confused without '/' after the directory, send a + // redirect. + if (f.isDirectory() && !uri.endsWith("/")) { + uri += "/"; + Response res = createResponse(NanoHTTPD.Response.Status.REDIRECT, NanoHTTPD.MIME_HTML, + "Redirected: " + uri + ""); + res.addHeader("Location", uri); + return res; + } + + if (f.isDirectory()) { + // First look for index files (index.html, index.htm, etc) and if + // none found, list the directory if readable. + String indexFile = findIndexFileInDirectory(f); + if (indexFile == null) { + if (f.canRead()) { + // No index file, list the directory if it is readable + return createResponse(NanoHTTPD.Response.Status.NOT_FOUND, NanoHTTPD.MIME_HTML, ""); + } + return createResponse(NanoHTTPD.Response.Status.FORBIDDEN, NanoHTTPD.MIME_PLAINTEXT, + "FORBIDDEN: No directory listing."); + } + return respond(headers, uri + indexFile); + } + + Response response = serveFile(uri, headers, f, getMimeTypeForFile(uri)); + return response != null ? response : + createResponse(NanoHTTPD.Response.Status.NOT_FOUND, NanoHTTPD.MIME_PLAINTEXT, + "Error 404, file not found."); + } + + /** + * Serves file from homeDir and its' subdirectories (only). Uses only URI, + * ignores all headers and HTTP parameters. + */ + Response serveFile(String uri, Map header, File file, String mime) { + Response res; + try { + // Calculate etag + String etag = Integer + .toHexString((file.getAbsolutePath() + file.lastModified() + String.valueOf(file.length())) + .hashCode()); + + // Support (simple) skipping: + long startFrom = 0; + long endAt = -1; + String range = header.get("range"); + if (range != null && range.startsWith("bytes=")) { + range = range.substring("bytes=".length()); + int minus = range.indexOf('-'); + try { + if (minus > 0) { + startFrom = Long.parseLong(range.substring(0, minus)); + endAt = Long.parseLong(range.substring(minus + 1)); + } + } catch (NumberFormatException ignored) { + } + } + + // Change return code and add Content-Range header when skipping is + // requested + long fileLen = file.length(); + if (range != null && startFrom >= 0) { + if (startFrom >= fileLen) { + res = createResponse(NanoHTTPD.Response.Status.RANGE_NOT_SATISFIABLE, + NanoHTTPD.MIME_PLAINTEXT, ""); + res.addHeader("Content-Range", "bytes 0-0/" + fileLen); + res.addHeader("ETag", etag); + } else { + if (endAt < 0) { + endAt = fileLen - 1; + } + long newLen = endAt - startFrom + 1; + if (newLen < 0) { + newLen = 0; + } + + final long dataLen = newLen; + FileInputStream fis = new FileInputStream(file) { + @Override + public int available() throws IOException { + return (int) dataLen; + } + }; + long skipped = fis.skip(startFrom); + if (skipped != startFrom) { + throw new IOException("unable to skip the required " + startFrom + " bytes."); + } + + res = createResponse(NanoHTTPD.Response.Status.PARTIAL_CONTENT, mime, fis); + res.addHeader("Content-Length", String.valueOf(dataLen)); + res.addHeader("Content-Range", "bytes " + startFrom + "-" + endAt + "/" + + fileLen); + res.addHeader("ETag", etag); + } + } else { + if (etag.equals(header.get("if-none-match"))) { + res = createResponse(NanoHTTPD.Response.Status.NOT_MODIFIED, mime, ""); + } else { + res = createResponse(NanoHTTPD.Response.Status.OK, mime, new FileInputStream(file)); + res.addHeader("Content-Length", String.valueOf(fileLen)); + res.addHeader("ETag", etag); + } + } + } catch (IOException ioe) { + res = createResponse(NanoHTTPD.Response.Status.FORBIDDEN, NanoHTTPD.MIME_PLAINTEXT, + "FORBIDDEN: Reading file failed."); + } + + return res; + } + + // Announce that the file server accepts partial content requests + private Response createResponse(NanoHTTPD.Response.Status status, String mimeType, String content) { + return new Response(status.getRequestStatus(), mimeType, content); + } + + // Announce that the file server accepts partial content requests + private Response createResponse(NanoHTTPD.Response.Status status, String mimeType, InputStream content) { + return new Response(status.getRequestStatus(), mimeType, content); + } + + public static String getMimeTypeForFile(String uri) { + String type = null; + String extension = MimeTypeMap.getFileExtensionFromUrl(uri); + if (extension != null) { + MimeTypeMap mime = MimeTypeMap.getSingleton(); + type = mime.getMimeTypeFromExtension(extension); + } + return type; + } + + private String findIndexFileInDirectory(File directory) { + String indexFileName = "index.html"; + File indexFile = new File(directory, indexFileName); + if (indexFile.exists()) { + return indexFileName; + } + return null; + } + } +} diff --git a/app/src/full/java/org/fdroid/fdroid/nearby/BonjourManager.java b/app/src/full/java/org/fdroid/fdroid/nearby/BonjourManager.java new file mode 100644 index 000000000..9c73bc04d --- /dev/null +++ b/app/src/full/java/org/fdroid/fdroid/nearby/BonjourManager.java @@ -0,0 +1,322 @@ +package org.fdroid.fdroid.nearby; + +import android.content.Context; +import android.content.Intent; +import android.net.ConnectivityManager; +import android.net.Network; +import android.net.NetworkCapabilities; +import android.net.wifi.WifiManager; +import android.os.Build; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.Message; +import android.os.Process; +import android.text.TextUtils; +import android.util.Log; + +import androidx.core.content.ContextCompat; +import androidx.localbroadcastmanager.content.LocalBroadcastManager; + +import org.fdroid.fdroid.FDroidApp; +import org.fdroid.fdroid.Preferences; +import org.fdroid.R; +import org.fdroid.fdroid.Utils; +import org.fdroid.fdroid.nearby.peers.BonjourPeer; + +import java.io.IOException; +import java.lang.ref.WeakReference; +import java.net.InetAddress; +import java.util.HashMap; + +import javax.jmdns.JmDNS; +import javax.jmdns.ServiceEvent; +import javax.jmdns.ServiceInfo; +import javax.jmdns.ServiceListener; + +/** + * Manage {@link JmDNS} in a {@link HandlerThread}. The start process is in + * {@link HandlerThread#onLooperPrepared()} so that it is always started before + * any messages get delivered from the queue. + */ +public class BonjourManager { + private static final String TAG = "BonjourManager"; + + public static final String ACTION_FOUND = "BonjourNewPeer"; + public static final String ACTION_REMOVED = "BonjourPeerRemoved"; + public static final String EXTRA_BONJOUR_PEER = "extraBonjourPeer"; + + public static final String ACTION_STATUS = "BonjourStatus"; + public static final String EXTRA_STATUS = "BonjourStatusExtra"; + public static final int STATUS_STARTING = 0; + public static final int STATUS_STARTED = 1; + public static final int STATUS_STOPPING = 2; + public static final int STATUS_STOPPED = 3; + public static final int STATUS_VISIBLE = 4; + public static final int STATUS_NOT_VISIBLE = 5; + public static final int STATUS_VPN_CONFLICT = 6; + public static final int STATUS_ERROR = 0xffff; + + public static final String HTTP_SERVICE_TYPE = "_http._tcp.local."; + public static final String HTTPS_SERVICE_TYPE = "_https._tcp.local."; + + private static final int STOP = 5709; + private static final int VISIBLE = 4151873; + private static final int NOT_VISIBLE = 144151873; + private static final int VPN_CONFLICT = 72346752; + + private static WeakReference context; + private static Handler handler; + private static volatile HandlerThread handlerThread; + private static ServiceInfo pairService; + private static JmDNS jmdns; + private static WifiManager.MulticastLock multicastLock; + + public static boolean isAlive() { + return handlerThread != null && handlerThread.isAlive(); + } + + /** + * Stops the Bonjour/mDNS, triggering a status broadcast via {@link #ACTION_STATUS}. + * {@link #STATUS_STOPPED} can be broadcast multiple times for the same session, + * so make sure {@link android.content.BroadcastReceiver}s handle duplicates. + */ + public static void stop(Context context) { + BonjourManager.context = new WeakReference<>(context); + if (handler == null || handlerThread == null || !handlerThread.isAlive()) { + sendBroadcast(STATUS_STOPPED, null); + return; + } + sendBroadcast(STATUS_STOPPING, null); + handler.sendEmptyMessage(STOP); + } + + public static void setVisible(Context context, boolean visible) { + BonjourManager.context = new WeakReference<>(context); + if (handler == null || handlerThread == null || !handlerThread.isAlive()) { + Log.e(TAG, "handlerThread is stopped, not changing visibility!"); + return; + } + if (isVpnActive(context)) { + handler.sendEmptyMessage(VPN_CONFLICT); + } else if (visible) { + handler.sendEmptyMessage(VISIBLE); + } else { + handler.sendEmptyMessage(NOT_VISIBLE); + } + } + + /** + * Starts the service, triggering a status broadcast via {@link #ACTION_STATUS}. + * {@link #STATUS_STARTED} can be broadcast multiple times for the same session, + * so make sure {@link android.content.BroadcastReceiver}s handle duplicates. + */ + public static void start(Context context) { + start(context, + Preferences.get().getLocalRepoName(), + Preferences.get().isLocalRepoHttpsEnabled(), + httpServiceListener, httpsServiceListener); + } + + /** + * Testable version, not for regular use. + * + * @see #start(Context) + */ + static void start(final Context context, + final String localRepoName, final boolean useHttps, + final ServiceListener httpServiceListener, final ServiceListener httpsServiceListener) { + BonjourManager.context = new WeakReference<>(context); + if (handlerThread != null && handlerThread.isAlive()) { + sendBroadcast(STATUS_STARTED, null); + return; + } + sendBroadcast(STATUS_STARTING, null); + + final WifiManager wifiManager = ContextCompat.getSystemService(context, WifiManager.class); + handlerThread = new HandlerThread("BonjourManager", Process.THREAD_PRIORITY_LESS_FAVORABLE) { + @Override + protected void onLooperPrepared() { + try { + InetAddress address = InetAddress.getByName(FDroidApp.ipAddressString); + jmdns = JmDNS.create(address); + jmdns.addServiceListener(HTTP_SERVICE_TYPE, httpServiceListener); + jmdns.addServiceListener(HTTPS_SERVICE_TYPE, httpsServiceListener); + + multicastLock = wifiManager.createMulticastLock(context.getPackageName()); + multicastLock.setReferenceCounted(false); + multicastLock.acquire(); + + sendBroadcast(STATUS_STARTED, null); + } catch (IOException e) { + if (handler != null) { + handler.removeMessages(VISIBLE); + handler.sendMessageAtFrontOfQueue(handler.obtainMessage(STOP)); + } + Log.e(TAG, "Error while registering jmdns service", e); + sendBroadcast(STATUS_ERROR, e.getLocalizedMessage()); + } + } + }; + handlerThread.start(); + handler = new Handler(handlerThread.getLooper()) { + + @Override + public void handleMessage(Message msg) { + switch (msg.what) { + case VISIBLE: + handleVisible(localRepoName, useHttps); + break; + case NOT_VISIBLE: + handleNotVisible(); + break; + case VPN_CONFLICT: + handleVpnConflict(); + break; + case STOP: + handleStop(); + break; + } + } + + private void handleVisible(String localRepoName, boolean useHttps) { + if (FDroidApp.repo == null) { + sendBroadcast(STATUS_ERROR, context.getString(R.string.swap_no_wifi_network)); + return; + } + HashMap values = new HashMap<>(); + values.put(BonjourPeer.PATH, "/fdroid/repo"); + values.put(BonjourPeer.NAME, localRepoName); + values.put(BonjourPeer.FINGERPRINT, FDroidApp.repo.getFingerprint()); + String type; + if (useHttps) { + values.put(BonjourPeer.TYPE, "fdroidrepos"); + type = HTTPS_SERVICE_TYPE; + } else { + values.put(BonjourPeer.TYPE, "fdroidrepo"); + type = HTTP_SERVICE_TYPE; + } + ServiceInfo newPairService = ServiceInfo.create(type, localRepoName, FDroidApp.port, 0, 0, values); + if (!newPairService.equals(pairService)) try { + if (pairService != null) { + jmdns.unregisterService(pairService); + } + jmdns.registerService(newPairService); + pairService = newPairService; + } catch (IOException e) { + e.printStackTrace(); + sendBroadcast(STATUS_ERROR, e.getLocalizedMessage()); + return; + } + sendBroadcast(STATUS_VISIBLE, null); + } + + private void handleNotVisible() { + if (pairService != null) { + jmdns.unregisterService(pairService); + pairService = null; + } + sendBroadcast(STATUS_NOT_VISIBLE, null); + } + + private void handleVpnConflict() { + sendBroadcast(STATUS_VPN_CONFLICT, null); + } + + private void handleStop() { + if (multicastLock != null) { + multicastLock.release(); + } + if (jmdns != null) { + jmdns.unregisterAllServices(); + Utils.closeQuietly(jmdns); + pairService = null; + jmdns = null; + } + handlerThread.quit(); + handlerThread = null; + sendBroadcast(STATUS_STOPPED, null); + } + + }; + } + + public static void restart(Context context) { + restart(context, + Preferences.get().getLocalRepoName(), + Preferences.get().isLocalRepoHttpsEnabled(), + httpServiceListener, httpsServiceListener); + } + + /** + * Testable version, not for regular use. + * + * @see #restart(Context) + */ + static void restart(final Context context, + final String localRepoName, final boolean useHttps, + final ServiceListener httpServiceListener, final ServiceListener httpsServiceListener) { + stop(context); + try { + handlerThread.join(10000); + } catch (InterruptedException | NullPointerException e) { + // ignored + } + start(context, localRepoName, useHttps, httpServiceListener, httpsServiceListener); + } + + private static void sendBroadcast(String action, ServiceInfo serviceInfo) { + BonjourPeer bonjourPeer = BonjourPeer.getInstance(serviceInfo); + if (bonjourPeer == null) { + Utils.debugLog(TAG, "IGNORING: " + serviceInfo); + return; + } + Intent intent = new Intent(action); + intent.putExtra(EXTRA_BONJOUR_PEER, bonjourPeer); + LocalBroadcastManager.getInstance(context.get()).sendBroadcast(intent); + } + + private static void sendBroadcast(int status, String message) { + + Intent intent = new Intent(ACTION_STATUS); + intent.putExtra(EXTRA_STATUS, status); + if (!TextUtils.isEmpty(message)) { + intent.putExtra(Intent.EXTRA_TEXT, message); + } + LocalBroadcastManager.getInstance(context.get()).sendBroadcast(intent); + } + + private static final ServiceListener httpServiceListener = new SwapServiceListener(); + private static final ServiceListener httpsServiceListener = new SwapServiceListener(); + + private static class SwapServiceListener implements ServiceListener { + @Override + public void serviceAdded(ServiceEvent serviceEvent) { + // ignored, we only need resolved info + } + + @Override + public void serviceRemoved(ServiceEvent serviceEvent) { + sendBroadcast(ACTION_REMOVED, serviceEvent.getInfo()); + } + + @Override + public void serviceResolved(ServiceEvent serviceEvent) { + sendBroadcast(ACTION_FOUND, serviceEvent.getInfo()); + } + } + + /** + * {@link ConnectivityManager#getActiveNetwork()} is only available + * starting on {@link Build.VERSION_CODES#M}, so for now, just return false + * if the device is too old. + */ + public static boolean isVpnActive(Context context) { + ConnectivityManager cm = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + Network activeNetwork = cm.getActiveNetwork(); + NetworkCapabilities caps = cm.getNetworkCapabilities(activeNetwork); + if (caps == null) { + return false; + } + return caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN); + } +} diff --git a/app/src/full/java/org/fdroid/fdroid/nearby/LocalHTTPD.java b/app/src/full/java/org/fdroid/fdroid/nearby/LocalHTTPD.java new file mode 100644 index 000000000..5974d37ba --- /dev/null +++ b/app/src/full/java/org/fdroid/fdroid/nearby/LocalHTTPD.java @@ -0,0 +1,504 @@ +package org.fdroid.fdroid.nearby; + +/* + * #%L + * NanoHttpd-Webserver + * %% + * Copyright (C) 2012 - 2015 nanohttpd + * %% + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of the nanohttpd nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. + * IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, + * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE + * OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + * OF THE POSSIBILITY OF SUCH DAMAGE. + * #L% + */ + +import android.content.Context; +import android.net.Uri; + +import org.fdroid.BuildConfig; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.io.UnsupportedEncodingException; +import java.lang.ref.WeakReference; +import java.net.URLEncoder; +import java.text.DateFormat; +import java.text.SimpleDateFormat; +import java.util.Arrays; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.StringTokenizer; +import java.util.TimeZone; + +import javax.net.ssl.SSLServerSocketFactory; + +import fi.iki.elonen.NanoHTTPD; +import fi.iki.elonen.NanoHTTPD.Response.IStatus; + +/** + * A HTTP server for serving the files that are being swapped via WiFi, etc. + * The only changes were to remove unneeded extras like {@code main()}, the + * plugin interface, and custom CORS header manipulation. + *

+ * This is mostly just synced from {@code SimpleWebServer.java} from NanoHTTPD. + * + * @see webserver/src/main/java/fi/iki/elonen/SimpleWebServer.java + */ +public class LocalHTTPD extends NanoHTTPD { + private static final String TAG = "LocalHTTPD"; + + /** + * Default Index file names. + */ + public static final String[] INDEX_FILE_NAMES = {"index.html"}; + + private final WeakReference context; + + protected List rootDirs; + + // Date format specified by RFC 7231 section 7.1.1.1. + private static final DateFormat RFC_1123 = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss 'GMT'", Locale.US); + + static { + RFC_1123.setLenient(false); + RFC_1123.setTimeZone(TimeZone.getTimeZone("GMT")); + } + + /** + * Configure and start the webserver. This also sets the MIME Types only + * for files that should be downloadable when a browser is used to display + * the swap repo, rather than the F-Droid client. The other file types + * should not be added because it could expose exploits to the browser. + */ + public LocalHTTPD(Context context, String hostname, int port, File webRoot, boolean useHttps) { + super(hostname, port); + rootDirs = Collections.singletonList(webRoot); + this.context = new WeakReference<>(context.getApplicationContext()); + if (useHttps) { + enableHTTPS(); + } + MIME_TYPES = new HashMap<>(); // ignore nanohttpd's list + MIME_TYPES.put("apk", "application/vnd.android.package-archive"); + MIME_TYPES.put("html", "text/html"); + MIME_TYPES.put("png", "image/png"); + MIME_TYPES.put("xml", "application/xml"); + } + + private boolean canServeUri(String uri, File homeDir) { + boolean canServeUri; + File f = new File(homeDir, uri); + canServeUri = f.exists(); + return canServeUri; + } + + /** + * URL-encodes everything between "/"-characters. Encodes spaces as '%20' + * instead of '+'. + */ + private String encodeUri(String uri) { + String newUri = ""; + StringTokenizer st = new StringTokenizer(uri, "/ ", true); + while (st.hasMoreTokens()) { + String tok = st.nextToken(); + if ("/".equals(tok)) { + newUri += "/"; + } else if (" ".equals(tok)) { + newUri += "%20"; + } else { + try { + newUri += URLEncoder.encode(tok, "UTF-8"); + } catch (UnsupportedEncodingException ignored) { + } + } + } + return newUri; + } + + private String findIndexFileInDirectory(File directory) { + for (String fileName : LocalHTTPD.INDEX_FILE_NAMES) { + File indexFile = new File(directory, fileName); + if (indexFile.isFile()) { + return fileName; + } + } + return null; + } + + protected Response getForbiddenResponse(String s) { + return newFixedLengthResponse(Response.Status.FORBIDDEN, NanoHTTPD.MIME_PLAINTEXT, "FORBIDDEN: " + s); + } + + protected Response getInternalErrorResponse(String s) { + return newFixedLengthResponse(Response.Status.INTERNAL_ERROR, NanoHTTPD.MIME_PLAINTEXT, + "INTERNAL ERROR: " + s); + } + + protected Response getNotFoundResponse() { + return newFixedLengthResponse(Response.Status.NOT_FOUND, NanoHTTPD.MIME_PLAINTEXT, "Error 404, file not " + + "found."); + } + + protected String listDirectory(String uri, File f) { + String heading = "Directory " + uri; + StringBuilder msg = + new StringBuilder("" + heading + "" + "

" + heading + "

"); + + String up = null; + if (uri.length() > 1) { + String u = uri.substring(0, uri.length() - 1); + int slash = u.lastIndexOf('/'); + if (slash >= 0 && slash < u.length()) { + up = uri.substring(0, slash + 1); + } + } + + List files = + Arrays.asList(Objects.requireNonNull(f.list((dir, name) -> new File(dir, name).isFile()))); + Collections.sort(files); + List directories = + Arrays.asList(Objects.requireNonNull(f.list((dir, name) -> new File(dir, name).isDirectory()))); + Collections.sort(directories); + if (up != null || directories.size() + files.size() > 0) { + msg.append("
    "); + if (up != null || directories.size() > 0) { + msg.append("
    "); + if (up != null) { + msg.append("
  • ." + + ".
  • "); + } + for (String directory : directories) { + String dir = directory + "/"; + msg.append("
  • ").append(dir).append("
  • "); + } + msg.append("
    "); + } + if (files.size() > 0) { + msg.append("
    "); + for (String file : files) { + msg.append("
  • ").append(file).append(""); + File curFile = new File(f, file); + long len = curFile.length(); + msg.append(" ("); + if (len < 1024) { + msg.append(len).append(" bytes"); + } else if (len < 1024 * 1024) { + msg.append(len / 1024).append(".").append(len % 1024 / 10 % 100).append(" KB"); + } else { + msg.append(len / (1024 * 1024)).append(".").append(len % (1024 * 1024) / 10000 % 100).append(" MB"); + } + msg.append(")
  • "); + } + msg.append("
    "); + } + msg.append("
"); + } + msg.append(""); + return msg.toString(); + } + + /** + * {@link Response#setKeepAlive(boolean)} alone does not seem to stop + * setting the {@code Connection} header to {@code keep-alive}, so also + * just directly set that header. + */ + public static Response addResponseHeaders(Response response) { + response.setKeepAlive(false); + response.setGzipEncoding(false); + response.addHeader("Connection", "close"); + response.addHeader("Content-Security-Policy", + "default-src 'none'; img-src 'self'; style-src 'self' 'unsafe-inline';"); + return response; + } + + public static Response newFixedLengthResponse(String msg) { + return addResponseHeaders(NanoHTTPD.newFixedLengthResponse(msg)); + } + + public static Response newFixedLengthResponse(IStatus status, String mimeType, + InputStream data, long totalBytes) { + return addResponseHeaders(NanoHTTPD.newFixedLengthResponse(status, mimeType, data, totalBytes)); + } + + public static Response newFixedLengthResponse(IStatus status, String mimeType, String message) { + Response response = NanoHTTPD.newFixedLengthResponse(status, mimeType, message); + addResponseHeaders(response); + response.addHeader("Accept-Ranges", "bytes"); + return response; + } + + private Response respond(Map headers, IHTTPSession session, String uri) { + return defaultRespond(headers, session, uri); + } + + private Response defaultRespond(Map headers, IHTTPSession session, String uri) { + // Remove URL arguments + uri = uri.trim().replace(File.separatorChar, '/'); + if (uri.indexOf('?') >= 0) { + uri = uri.substring(0, uri.indexOf('?')); + } + + // Prohibit getting out of current directory + if (uri.contains("../")) { + return getForbiddenResponse("Won't serve ../ for security reasons."); + } + + boolean canServeUri = false; + File homeDir = null; + for (int i = 0; !canServeUri && i < this.rootDirs.size(); i++) { + homeDir = this.rootDirs.get(i); + canServeUri = canServeUri(uri, homeDir); + } + if (!canServeUri) { + return getNotFoundResponse(); + } + + // Browsers get confused without '/' after the directory, send a + // redirect. + File f = new File(homeDir, uri); + if (f.isDirectory() && !uri.endsWith("/")) { + uri += "/"; + Response res = + newFixedLengthResponse(Response.Status.REDIRECT, NanoHTTPD.MIME_HTML, "Redirected: " + + "" + uri + ""); + res.addHeader("Location", uri); + return res; + } + + if (f.isDirectory()) { + // First look for index files (index.html, index.htm, etc) and if + // none found, list the directory if readable. + String indexFile = findIndexFileInDirectory(f); + if (indexFile == null) { + if (f.canRead()) { + // No index file, list the directory if it is readable + return newFixedLengthResponse(Response.Status.OK, NanoHTTPD.MIME_HTML, listDirectory(uri, f)); + } else { + return getForbiddenResponse("No directory listing."); + } + } else { + return respond(headers, session, uri + indexFile); + } + } + String mimeTypeForFile = getMimeTypeForFile(uri); + Response response = serveFile(uri, headers, f, mimeTypeForFile); + return response != null ? response : getNotFoundResponse(); + } + + @Override + public Response serve(IHTTPSession session) { + Map header = session.getHeaders(); + Map parms = session.getParms(); + String uri = session.getUri(); + + if (BuildConfig.DEBUG) { + System.out.println(session.getMethod() + " '" + uri + "' "); + + Iterator e = header.keySet().iterator(); + while (e.hasNext()) { + String value = e.next(); + System.out.println(" HDR: '" + value + "' = '" + header.get(value) + "'"); + } + e = parms.keySet().iterator(); + while (e.hasNext()) { + String value = e.next(); + System.out.println(" PRM: '" + value + "' = '" + parms.get(value) + "'"); + } + } + + if (session.getMethod() == Method.POST) { + try { + session.parseBody(new HashMap<>()); + } catch (IOException e) { + return newFixedLengthResponse(Response.Status.INTERNAL_ERROR, MIME_PLAINTEXT, + "Internal server error, check logcat on server for details."); + } catch (ResponseException re) { + return newFixedLengthResponse(re.getStatus(), MIME_PLAINTEXT, re.getMessage()); + } + + return handlePost(session); + } + + for (File homeDir : this.rootDirs) { + // Make sure we won't die of an exception later + if (!homeDir.isDirectory()) { + return getInternalErrorResponse("given path is not a directory (" + homeDir + ")."); + } + } + return respond(Collections.unmodifiableMap(header), session, uri); + } + + private Response handlePost(IHTTPSession session) { + Uri uri = Uri.parse(session.getUri()); + switch (uri.getPath()) { + case "/request-swap": + if (!session.getParms().containsKey("repo")) { + return newFixedLengthResponse(Response.Status.BAD_REQUEST, MIME_PLAINTEXT, + "Requires 'repo' parameter to be posted."); + } + SwapWorkflowActivity.requestSwap(context.get(), session.getParms().get("repo")); + return newFixedLengthResponse(Response.Status.OK, MIME_PLAINTEXT, "Swap request received."); + } + return newFixedLengthResponse(""); + } + + /** + * Serves file from homeDir and its' subdirectories (only). Uses only URI, + * ignores all headers and HTTP parameters. + */ + Response serveFile(String uri, Map header, File file, String mime) { + Response res; + try { + // Calculate etag + String etag = + Integer.toHexString((file.getAbsolutePath() + file.lastModified() + "" + file.length()).hashCode()); + + // Support (simple) skipping: + long startFrom = 0; + long endAt = -1; + String range = header.get("range"); + if (range != null) { + if (range.startsWith("bytes=")) { + range = range.substring("bytes=".length()); + int minus = range.indexOf('-'); + try { + if (minus > 0) { + startFrom = Long.parseLong(range.substring(0, minus)); + endAt = Long.parseLong(range.substring(minus + 1)); + } + } catch (NumberFormatException ignored) { + } + } + } + + // get if-range header. If present, it must match etag or else we + // should ignore the range request + String ifRange = header.get("if-range"); + boolean headerIfRangeMissingOrMatching = (ifRange == null || etag.equals(ifRange)); + + String ifNoneMatch = header.get("if-none-match"); + boolean headerIfNoneMatchPresentAndMatching = + ifNoneMatch != null && ("*".equals(ifNoneMatch) || ifNoneMatch.equals(etag)); + + // Change return code and add Content-Range header when skipping is + // requested + long fileLen = file.length(); + + if (headerIfRangeMissingOrMatching && range != null && startFrom >= 0 && startFrom < fileLen) { + // range request that matches current etag + // and the startFrom of the range is satisfiable + if (headerIfNoneMatchPresentAndMatching) { + // range request that matches current etag + // and the startFrom of the range is satisfiable + // would return range from file + // respond with not-modified + res = newFixedLengthResponse(Response.Status.NOT_MODIFIED, mime, ""); + res.addHeader("ETag", etag); + } else { + if (endAt < 0) { + endAt = fileLen - 1; + } + long newLen = endAt - startFrom + 1; + if (newLen < 0) { + newLen = 0; + } + + FileInputStream fis = new FileInputStream(file); + fis.skip(startFrom); + + res = newFixedLengthResponse(Response.Status.PARTIAL_CONTENT, mime, fis, newLen); + res.addHeader("Accept-Ranges", "bytes"); + res.addHeader("Content-Length", "" + newLen); + res.addHeader("Content-Range", "bytes " + startFrom + "-" + endAt + "/" + fileLen); + res.addHeader("ETag", etag); + res.addHeader("Last-Modified", RFC_1123.format(new Date(file.lastModified()))); + } + } else { + + if (headerIfRangeMissingOrMatching && range != null && startFrom >= fileLen) { + // return the size of the file + // 4xx responses are not trumped by if-none-match + res = newFixedLengthResponse(Response.Status.RANGE_NOT_SATISFIABLE, NanoHTTPD.MIME_PLAINTEXT, ""); + res.addHeader("Content-Range", "bytes */" + fileLen); + res.addHeader("ETag", etag); + } else if (range == null && headerIfNoneMatchPresentAndMatching) { + // full-file-fetch request + // would return entire file + // respond with not-modified + res = newFixedLengthResponse(Response.Status.NOT_MODIFIED, mime, ""); + res.addHeader("ETag", etag); + } else if (!headerIfRangeMissingOrMatching && headerIfNoneMatchPresentAndMatching) { + // range request that doesn't match current etag + // would return entire (different) file + // respond with not-modified + + res = newFixedLengthResponse(Response.Status.NOT_MODIFIED, mime, ""); + res.addHeader("ETag", etag); + } else { + // supply the file + res = newFixedFileResponse(file, mime); + res.addHeader("Content-Length", "" + fileLen); + res.addHeader("ETag", etag); + res.addHeader("Last-Modified", RFC_1123.format(new Date(file.lastModified()))); + } + } + } catch (IOException ioe) { + res = getForbiddenResponse("Reading file failed."); + } + + return addResponseHeaders(res); + } + + private Response newFixedFileResponse(File file, String mime) throws FileNotFoundException { + Response res; + res = newFixedLengthResponse(Response.Status.OK, mime, new FileInputStream(file), (int) file.length()); + addResponseHeaders(res); + res.addHeader("Accept-Ranges", "bytes"); + return res; + } + + private void enableHTTPS() { + try { + LocalRepoKeyStore localRepoKeyStore = LocalRepoKeyStore.get(context.get()); + SSLServerSocketFactory factory = NanoHTTPD.makeSSLSocketFactory( + localRepoKeyStore.getKeyStore(), + localRepoKeyStore.getKeyManagers()); + makeSecure(factory, null); + } catch (LocalRepoKeyStore.InitException | IOException e) { + e.printStackTrace(); + } + } +} diff --git a/app/src/full/java/org/fdroid/fdroid/nearby/LocalHTTPDManager.java b/app/src/full/java/org/fdroid/fdroid/nearby/LocalHTTPDManager.java new file mode 100644 index 000000000..96ae0d028 --- /dev/null +++ b/app/src/full/java/org/fdroid/fdroid/nearby/LocalHTTPDManager.java @@ -0,0 +1,127 @@ +package org.fdroid.fdroid.nearby; + +import android.content.Context; +import android.content.Intent; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.Message; +import android.os.Process; +import android.util.Log; + +import androidx.localbroadcastmanager.content.LocalBroadcastManager; + +import org.fdroid.fdroid.FDroidApp; +import org.fdroid.fdroid.Preferences; + +import java.io.IOException; +import java.net.BindException; + +/** + * Manage {@link LocalHTTPD} in a {@link HandlerThread}; + */ +public class LocalHTTPDManager { + private static final String TAG = "LocalHTTPDManager"; + + public static final String ACTION_STARTED = "LocalHTTPDStarted"; + public static final String ACTION_STOPPED = "LocalHTTPDStopped"; + public static final String ACTION_ERROR = "LocalHTTPDError"; + + private static final int STOP = 5709; + + private static Handler handler; + private static volatile HandlerThread handlerThread; + private static LocalHTTPD localHttpd; + + public static void start(Context context) { + start(context, Preferences.get().isLocalRepoHttpsEnabled()); + } + + /** + * Testable version, not for regular use. + * + * @see #start(Context) + */ + static void start(final Context context, final boolean useHttps) { + if (handlerThread != null && handlerThread.isAlive()) { + Log.w(TAG, "handlerThread is already running, doing nothing!"); + return; + } + + handlerThread = new HandlerThread("LocalHTTPD", Process.THREAD_PRIORITY_LESS_FAVORABLE) { + @Override + protected void onLooperPrepared() { + localHttpd = new LocalHTTPD( + context, + FDroidApp.ipAddressString, + FDroidApp.port, + context.getFilesDir(), + useHttps); + try { + localHttpd.start(); + Intent intent = new Intent(ACTION_STARTED); + LocalBroadcastManager.getInstance(context).sendBroadcast(intent); + } catch (BindException e) { + FDroidApp.generateNewPort = true; + WifiStateChangeService.start(context, null); + Intent intent = new Intent(ACTION_ERROR); + intent.putExtra(Intent.EXTRA_TEXT, + "port " + FDroidApp.port + " occupied, trying new port: (" + + e.getLocalizedMessage() + ")"); + LocalBroadcastManager.getInstance(context).sendBroadcast(intent); + } catch (IOException e) { + e.printStackTrace(); + Intent intent = new Intent(ACTION_ERROR); + intent.putExtra(Intent.EXTRA_TEXT, e.getLocalizedMessage()); + LocalBroadcastManager.getInstance(context).sendBroadcast(intent); + } + } + }; + handlerThread.start(); + handler = new Handler(handlerThread.getLooper()) { + @Override + public void handleMessage(Message msg) { + localHttpd.stop(); + handlerThread.quit(); + handlerThread = null; + } + }; + } + + public static void stop(Context context) { + if (handler == null || handlerThread == null || !handlerThread.isAlive()) { + Log.w(TAG, "handlerThread is already stopped, doing nothing!"); + handlerThread = null; + return; + } + handler.sendEmptyMessage(STOP); + Intent stoppedIntent = new Intent(ACTION_STOPPED); + LocalBroadcastManager.getInstance(context).sendBroadcast(stoppedIntent); + } + + /** + * Run {@link #stop(Context)}, wait for it to actually stop, then run + * {@link #start(Context)}. + */ + public static void restart(Context context) { + restart(context, Preferences.get().isLocalRepoHttpsEnabled()); + } + + /** + * Testable version, not for regular use. + * + * @see #restart(Context) + */ + static void restart(Context context, boolean useHttps) { + stop(context); + try { + handlerThread.join(10000); + } catch (InterruptedException | NullPointerException e) { + // ignored + } + start(context, useHttps); + } + + public static boolean isAlive() { + return handlerThread != null && handlerThread.isAlive(); + } +} diff --git a/app/src/full/java/org/fdroid/fdroid/nearby/LocalRepoKeyStore.java b/app/src/full/java/org/fdroid/fdroid/nearby/LocalRepoKeyStore.java new file mode 100644 index 000000000..9115c87c6 --- /dev/null +++ b/app/src/full/java/org/fdroid/fdroid/nearby/LocalRepoKeyStore.java @@ -0,0 +1,377 @@ +package org.fdroid.fdroid.nearby; + +import android.content.Context; +import android.util.Log; + +import org.bouncycastle.asn1.ASN1Sequence; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.GeneralName; +import org.bouncycastle.asn1.x509.GeneralNames; +import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo; +import org.bouncycastle.asn1.x509.Time; +import org.bouncycastle.asn1.x509.X509Extension; +import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.cert.X509v3CertificateBuilder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.OperatorCreationException; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.fdroid.fdroid.FDroidApp; +import org.fdroid.fdroid.Utils; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.math.BigInteger; +import java.net.Socket; +import java.security.GeneralSecurityException; +import java.security.Key; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.Principal; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.SecureRandom; +import java.security.UnrecoverableKeyException; +import java.security.cert.Certificate; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.util.Calendar; +import java.util.Date; +import java.util.GregorianCalendar; +import java.util.Locale; + +import javax.net.ssl.KeyManager; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.X509KeyManager; + +import kellinwood.security.zipsigner.ZipSigner; + +// TODO Address exception handling in a uniform way throughout + +@SuppressWarnings("LineLength") +public final class LocalRepoKeyStore { + + private static final String TAG = "LocalRepoKeyStore"; + + private static final String INDEX_CERT_ALIAS = "fdroid"; + private static final String HTTP_CERT_ALIAS = "https"; + + public static final String DEFAULT_SIG_ALG = "SHA1withRSA"; + private static final String DEFAULT_KEY_ALGO = "RSA"; + private static final int DEFAULT_KEY_BITS = 2048; + + private static final String DEFAULT_INDEX_CERT_INFO = "O=Kerplapp,OU=GuardianProject"; + + private static LocalRepoKeyStore localRepoKeyStore; + private KeyStore keyStore; + private KeyManager[] keyManagers; + private File keyStoreFile; + + public static LocalRepoKeyStore get(Context context) throws InitException { + if (localRepoKeyStore == null) { + localRepoKeyStore = new LocalRepoKeyStore(context); + } + return localRepoKeyStore; + } + + @SuppressWarnings("serial") + public static class InitException extends Exception { + public InitException(String detailMessage) { + super(detailMessage); + } + } + + private LocalRepoKeyStore(Context context) throws InitException { + try { + File appKeyStoreDir = context.getDir("keystore", Context.MODE_PRIVATE); + + Utils.debugLog(TAG, "Generating LocalRepoKeyStore instance: " + appKeyStoreDir.getAbsolutePath()); + this.keyStoreFile = new File(appKeyStoreDir, "kerplapp.bks"); + + Utils.debugLog(TAG, "Using default KeyStore type: " + KeyStore.getDefaultType()); + this.keyStore = KeyStore.getInstance(KeyStore.getDefaultType()); + + if (keyStoreFile.exists()) { + InputStream in = null; + try { + Utils.debugLog(TAG, "Keystore already exists, loading..."); + in = new FileInputStream(keyStoreFile); + keyStore.load(in, "".toCharArray()); + } catch (IOException e) { + Log.e(TAG, "Error while loading existing keystore. Will delete and create a new one."); + + // NOTE: Could opt to delete and then re-create the keystore here, but that may + // be undesirable. For example - if you were to re-connect to an existing device + // that you have swapped apps with in the past, then you would really want the + // signature to be the same as last time. + throw new InitException("Could not initialize local repo keystore: " + e); + } finally { + Utils.closeQuietly(in); + } + } + + if (!keyStoreFile.exists()) { + // If there isn't a persisted BKS keystore on disk we need to + // create a new empty keystore + // Init a new keystore with a blank passphrase + Utils.debugLog(TAG, "Keystore doesn't exist, creating..."); + keyStore.load(null, "".toCharArray()); + } + + /* + * If the keystore we loaded doesn't have an INDEX_CERT_ALIAS entry + * we need to generate a new random keypair and a self signed + * certificate for this slot. + */ + if (keyStore.getKey(INDEX_CERT_ALIAS, "".toCharArray()) == null) { + /* + * Generate a random key pair to associate with the + * INDEX_CERT_ALIAS certificate in the keystore. This keypair + * will be used for the HTTPS cert as well. + */ + KeyPair rndKeys = generateRandomKeypair(); + + /* + * Generate a self signed certificate for signing the index.jar + * We can't generate the HTTPS certificate until we know what + * the IP address will be to use for the CN field. + */ + X500Name subject = new X500Name(DEFAULT_INDEX_CERT_INFO); + Certificate indexCert = generateSelfSignedCertChain(rndKeys, subject); + + addToStore(INDEX_CERT_ALIAS, rndKeys, indexCert); + } + + /* + * Kerplapp uses its own KeyManager to to ensure the correct + * keystore alias is used for the correct purpose. With the default + * key manager it is not possible to specify that HTTP_CERT_ALIAS + * should be used for TLS and INDEX_CERT_ALIAS for signing the + * index.jar. + */ + KeyManagerFactory keyManagerFactory = KeyManagerFactory + .getInstance(KeyManagerFactory.getDefaultAlgorithm()); + + keyManagerFactory.init(keyStore, "".toCharArray()); + KeyManager defaultKeyManager = keyManagerFactory.getKeyManagers()[0]; + KeyManager wrappedKeyManager = new KerplappKeyManager( + (X509KeyManager) defaultKeyManager); + keyManagers = new KeyManager[]{ + wrappedKeyManager, + }; + } catch (UnrecoverableKeyException | KeyStoreException | NoSuchAlgorithmException | + CertificateException | OperatorCreationException | IOException e) { + Log.e(TAG, "Error loading keystore", e); + } + } + + public void setupHTTPSCertificate() { + try { + // Get the existing private/public keypair to use for the HTTPS cert + KeyPair kerplappKeypair = getKerplappKeypair(); + + /* + * Once we have an IP address, that can be used as the hostname. We + * can generate a self signed cert with a valid CN field to stash + * into the keystore in a predictable place. If the IP address + * changes we should run this method again to stomp old + * HTTPS_CERT_ALIAS entries. + */ + X500Name subject = new X500Name("CN=" + FDroidApp.ipAddressString); + Certificate indexCert = generateSelfSignedCertChain(kerplappKeypair, subject, + FDroidApp.ipAddressString); + addToStore(HTTP_CERT_ALIAS, kerplappKeypair, indexCert); + } catch (Exception e) { + Log.e(TAG, "Failed to setup HTTPS certificate", e); + } + } + + public File getKeyStoreFile() { + return keyStoreFile; + } + + public KeyStore getKeyStore() { + return keyStore; + } + + public KeyManager[] getKeyManagers() { + return keyManagers; + } + + public void signZip(File input, File output) { + try { + ZipSigner zipSigner = new ZipSigner(); + + X509Certificate cert = (X509Certificate) keyStore.getCertificate(INDEX_CERT_ALIAS); + + KeyPair kp = getKerplappKeypair(); + PrivateKey priv = kp.getPrivate(); + + zipSigner.setKeys("kerplapp", cert, priv, DEFAULT_SIG_ALG, null); + zipSigner.signZip(input.getAbsolutePath(), output.getAbsolutePath()); + + } catch (ClassNotFoundException | IllegalAccessException | InstantiationException | + GeneralSecurityException | IOException e) { + Log.e(TAG, "Unable to sign local repo index", e); + } + } + + private KeyPair getKerplappKeypair() throws KeyStoreException, UnrecoverableKeyException, + NoSuchAlgorithmException { + /* + * You can't store a keypair without an associated certificate chain so, + * we'll use the INDEX_CERT_ALIAS as the de-facto keypair/certificate + * chain. This cert/key is initialized when the KerplappKeyStore is + * constructed for the first time and should *always* be present. + */ + Key key = keyStore.getKey(INDEX_CERT_ALIAS, "".toCharArray()); + + if (key instanceof PrivateKey) { + Certificate cert = keyStore.getCertificate(INDEX_CERT_ALIAS); + PublicKey publicKey = cert.getPublicKey(); + return new KeyPair(publicKey, (PrivateKey) key); + } + + return null; + } + + public Certificate getCertificate() { + try { + Key key = keyStore.getKey(INDEX_CERT_ALIAS, "".toCharArray()); + if (key instanceof PrivateKey) { + return keyStore.getCertificate(INDEX_CERT_ALIAS); + } + } catch (GeneralSecurityException e) { + Log.e(TAG, "Unable to get certificate for local repo", e); + } + return null; + } + + private void addToStore(String alias, KeyPair kp, Certificate cert) throws KeyStoreException, + NoSuchAlgorithmException, CertificateException, IOException, UnrecoverableKeyException { + Certificate[] chain = { + cert, + }; + keyStore.setKeyEntry(alias, kp.getPrivate(), + "".toCharArray(), chain); + + keyStore.store(new FileOutputStream(keyStoreFile), "".toCharArray()); + + /* + * After adding an entry to the keystore we need to create a fresh + * KeyManager by reinitializing the KeyManagerFactory with the new key + * store content and then rewrapping the default KeyManager with our own + */ + KeyManagerFactory keyManagerFactory = KeyManagerFactory + .getInstance(KeyManagerFactory.getDefaultAlgorithm()); + + keyManagerFactory.init(keyStore, "".toCharArray()); + KeyManager defaultKeyManager = keyManagerFactory.getKeyManagers()[0]; + KeyManager wrappedKeyManager = new KerplappKeyManager((X509KeyManager) defaultKeyManager); + keyManagers = new KeyManager[]{ + wrappedKeyManager, + }; + } + + public static KeyPair generateRandomKeypair() throws NoSuchAlgorithmException { + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance(DEFAULT_KEY_ALGO); + keyPairGenerator.initialize(DEFAULT_KEY_BITS); + return keyPairGenerator.generateKeyPair(); + } + + public static Certificate generateSelfSignedCertChain(KeyPair kp, X500Name subject) + throws CertificateException, OperatorCreationException, IOException { + return generateSelfSignedCertChain(kp, subject, null); + } + + public static Certificate generateSelfSignedCertChain(KeyPair kp, X500Name subject, String hostname) + throws CertificateException, OperatorCreationException, IOException { + SecureRandom rand = new SecureRandom(); + PrivateKey privKey = kp.getPrivate(); + PublicKey pubKey = kp.getPublic(); + ContentSigner sigGen = new JcaContentSignerBuilder(DEFAULT_SIG_ALG).build(privKey); + + SubjectPublicKeyInfo subPubKeyInfo = new SubjectPublicKeyInfo( + ASN1Sequence.getInstance(pubKey.getEncoded())); + + Date now = new Date(); // now + + /* force it to use a English/Gregorian dates for the cert, hardly anyone + ever looks at the cert metadata anyway, and its very likely that they + understand English/Gregorian dates */ + Calendar c = new GregorianCalendar(Locale.ENGLISH); + c.setTime(now); + c.add(Calendar.YEAR, 1); + Time startTime = new Time(now, Locale.ENGLISH); + Time endTime = new Time(c.getTime(), Locale.ENGLISH); + + X509v3CertificateBuilder v3CertGen = new X509v3CertificateBuilder( + subject, + BigInteger.valueOf(rand.nextLong()), + startTime, + endTime, + subject, + subPubKeyInfo); + + if (hostname != null) { + GeneralNames subjectAltName = new GeneralNames( + new GeneralName(GeneralName.iPAddress, hostname)); + v3CertGen.addExtension(X509Extension.subjectAlternativeName, false, subjectAltName); + } + + X509CertificateHolder certHolder = v3CertGen.build(sigGen); + return new JcaX509CertificateConverter().getCertificate(certHolder); + } + + /* + * A X509KeyManager that always returns the KerplappKeyStore.HTTP_CERT_ALIAS + * for it's chosen server alias. All other operations are deferred to the + * wrapped X509KeyManager. + */ + private static final class KerplappKeyManager implements X509KeyManager { + private final X509KeyManager wrapped; + + private KerplappKeyManager(X509KeyManager wrapped) { + this.wrapped = wrapped; + } + + @Override + public String chooseClientAlias(String[] keyType, Principal[] issuers, Socket socket) { + return wrapped.chooseClientAlias(keyType, issuers, socket); + } + + @Override + public String chooseServerAlias(String keyType, Principal[] issuers, Socket socket) { + /* + * Always use the HTTP_CERT_ALIAS for the server alias. + */ + return LocalRepoKeyStore.HTTP_CERT_ALIAS; + } + + @Override + public X509Certificate[] getCertificateChain(String alias) { + return wrapped.getCertificateChain(alias); + } + + @Override + public String[] getClientAliases(String keyType, Principal[] issuers) { + return wrapped.getClientAliases(keyType, issuers); + } + + @Override + public PrivateKey getPrivateKey(String alias) { + return wrapped.getPrivateKey(alias); + } + + @Override + public String[] getServerAliases(String keyType, Principal[] issuers) { + return wrapped.getServerAliases(keyType, issuers); + } + } +} diff --git a/app/src/full/java/org/fdroid/fdroid/nearby/LocalRepoManager.java b/app/src/full/java/org/fdroid/fdroid/nearby/LocalRepoManager.java new file mode 100644 index 000000000..9b5afbc49 --- /dev/null +++ b/app/src/full/java/org/fdroid/fdroid/nearby/LocalRepoManager.java @@ -0,0 +1,301 @@ +package org.fdroid.fdroid.nearby; + +import android.content.Context; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManager; +import android.content.res.AssetManager; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.apache.commons.io.FileUtils; +import org.fdroid.fdroid.FDroidApp; +import org.fdroid.fdroid.Preferences; +import org.fdroid.fdroid.Utils; +import org.fdroid.fdroid.data.Apk; +import org.fdroid.fdroid.data.App; +import org.fdroid.fdroid.data.SanitizedFile; +import org.fdroid.index.v1.AppV1; +import org.fdroid.index.v1.IndexV1; +import org.fdroid.index.v1.IndexV1Creator; +import org.fdroid.index.v1.IndexV1UpdaterKt; +import org.fdroid.index.v1.IndexV1VerifierKt; +import org.fdroid.index.v1.PackageV1; +import org.fdroid.index.v1.RepoV1; + +import java.io.BufferedOutputStream; +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.jar.JarEntry; +import java.util.jar.JarOutputStream; + +/** + * The {@link SwapService} deals with managing the entire workflow from selecting apps to + * swap, to invoking this class to prepare the webroot, to enabling various communication protocols. + * This class deals specifically with the webroot side of things, ensuring we have a valid index.jar + * and the relevant .apk and icon files available. + */ +public final class LocalRepoManager { + private static final String TAG = "LocalRepoManager"; + + private final Context context; + private final PackageManager pm; + private final AssetManager assetManager; + private final String fdroidPackageName; + + public static final String[] WEB_ROOT_ASSET_FILES = { + "swap-icon.png", + "swap-tick-done.png", + "swap-tick-not-done.png", + }; + + private final List apps = new ArrayList<>(); + + private final SanitizedFile indexJar; + private final SanitizedFile indexJarUnsigned; + private final SanitizedFile webRoot; + private final SanitizedFile fdroidDir; + private final SanitizedFile fdroidDirCaps; + private final SanitizedFile repoDir; + private final SanitizedFile repoDirCaps; + + @Nullable + private static LocalRepoManager localRepoManager; + + @NonNull + public static LocalRepoManager get(Context context) { + if (localRepoManager == null) { + localRepoManager = new LocalRepoManager(context); + } + return localRepoManager; + } + + private LocalRepoManager(Context c) { + context = c.getApplicationContext(); + pm = c.getPackageManager(); + assetManager = c.getAssets(); + fdroidPackageName = c.getPackageName(); + + webRoot = SanitizedFile.knownSanitized(c.getFilesDir()); + /* /fdroid/repo is the standard path for user repos */ + fdroidDir = new SanitizedFile(webRoot, "fdroid"); + fdroidDirCaps = new SanitizedFile(webRoot, "FDROID"); + repoDir = new SanitizedFile(fdroidDir, "repo"); + repoDirCaps = new SanitizedFile(fdroidDirCaps, "REPO"); + indexJar = new SanitizedFile(repoDir, IndexV1UpdaterKt.SIGNED_FILE_NAME); + indexJarUnsigned = new SanitizedFile(repoDir, "index-v1.unsigned.jar"); + + if (!fdroidDir.exists() && !fdroidDir.mkdir()) { + Log.e(TAG, "Unable to create empty base: " + fdroidDir); + } + + if (!repoDir.exists() && !repoDir.mkdir()) { + Log.e(TAG, "Unable to create empty repo: " + repoDir); + } + + SanitizedFile iconsDir = new SanitizedFile(repoDir, "icons"); + if (!iconsDir.exists() && !iconsDir.mkdir()) { + Log.e(TAG, "Unable to create icons folder: " + iconsDir); + } + } + + private String writeFdroidApkToWebroot() { + ApplicationInfo appInfo; + String fdroidClientURL = "https://f-droid.org/F-Droid.apk"; + + try { + appInfo = pm.getApplicationInfo(fdroidPackageName, PackageManager.GET_META_DATA); + SanitizedFile apkFile = SanitizedFile.knownSanitized(appInfo.publicSourceDir); + SanitizedFile fdroidApkLink = new SanitizedFile(fdroidDir, "F-Droid.apk"); + attemptToDelete(fdroidApkLink); + if (Utils.symlinkOrCopyFileQuietly(apkFile, fdroidApkLink)) { + fdroidClientURL = "/" + fdroidDir.getName() + "/" + fdroidApkLink.getName(); + } + } catch (PackageManager.NameNotFoundException e) { + Log.e(TAG, "Could not set up F-Droid apk in the webroot", e); + } + return fdroidClientURL; + } + + void writeIndexPage(String repoAddress) { + final String fdroidClientURL = writeFdroidApkToWebroot(); + try { + File indexHtml = new File(webRoot, "index.html"); + BufferedReader in = new BufferedReader( + new InputStreamReader(assetManager.open("index.template.html"), "UTF-8")); + BufferedWriter out = new BufferedWriter(new OutputStreamWriter( + new FileOutputStream(indexHtml))); + + StringBuilder builder = new StringBuilder(); + for (App app : apps) { + builder.append("
  • ") + .append("") + .append(app.name) + .append("
  • \n"); + } + + String line; + while ((line = in.readLine()) != null) { + line = line.replaceAll("\\{\\{REPO_URL\\}\\}", repoAddress); + line = line.replaceAll("\\{\\{CLIENT_URL\\}\\}", fdroidClientURL); + line = line.replaceAll("\\{\\{APP_LIST\\}\\}", builder.toString()); + out.write(line); + } + in.close(); + out.close(); + + for (final String file : WEB_ROOT_ASSET_FILES) { + InputStream assetIn = assetManager.open(file); + OutputStream assetOut = new FileOutputStream(new File(webRoot, file)); + Utils.copy(assetIn, assetOut); + assetIn.close(); + assetOut.close(); + } + + // make symlinks/copies in each subdir of the repo to make sure that + // the user will always find the bootstrap page. + symlinkEntireWebRootElsewhere("../", fdroidDir); + symlinkEntireWebRootElsewhere("../../", repoDir); + + // add in /FDROID/REPO to support bad QR Scanner apps + attemptToMkdir(fdroidDirCaps); + attemptToMkdir(repoDirCaps); + + symlinkEntireWebRootElsewhere("../", fdroidDirCaps); + symlinkEntireWebRootElsewhere("../../", repoDirCaps); + + } catch (IOException e) { + Log.e(TAG, "Error writing local repo index", e); + } + } + + private static void attemptToMkdir(@NonNull File dir) throws IOException { + if (dir.exists()) { + if (dir.isDirectory()) { + return; + } + throw new IOException("Can't make directory " + dir + " - it is already a file."); + } + + if (!dir.mkdir()) { + throw new IOException("An error occurred trying to create directory " + dir); + } + } + + private static void attemptToDelete(@NonNull File file) { + if (!file.delete()) { + Log.i(TAG, "Could not delete \"" + file.getAbsolutePath() + "\"."); + } + } + + private void symlinkEntireWebRootElsewhere(String symlinkPrefix, File directory) { + symlinkFileElsewhere("index.html", symlinkPrefix, directory); + for (final String fileName : WEB_ROOT_ASSET_FILES) { + symlinkFileElsewhere(fileName, symlinkPrefix, directory); + } + } + + private void symlinkFileElsewhere(String fileName, String symlinkPrefix, File directory) { + SanitizedFile index = new SanitizedFile(directory, fileName); + attemptToDelete(index); + Utils.symlinkOrCopyFileQuietly(new SanitizedFile(new File(directory, symlinkPrefix), fileName), index); + } + + private void deleteContents(File path) { + if (path.exists()) { + for (File file : path.listFiles()) { + if (file.isDirectory()) { + deleteContents(file); + } else { + attemptToDelete(file); + } + } + } + } + + /** + * Get the {@code index-v1.jar} file that represents the local swap repo. + */ + public File getIndexJar() { + return indexJar; + } + + public File getWebRoot() { + return webRoot; + } + + public void deleteRepo() { + deleteContents(repoDir); + } + + void generateIndex(String repoUri, String address, String[] selectedApps) throws IOException { + String name = Preferences.get().getLocalRepoName() + " on " + FDroidApp.ipAddressString; + String description = + "A local FDroid repo generated from apps installed on " + Preferences.get().getLocalRepoName(); + RepoV1 repo = new RepoV1(System.currentTimeMillis(), 20001, 7, name, "swap-icon.png", + address, description, Collections.emptyList()); + Set apps = new HashSet<>(Arrays.asList(selectedApps)); + IndexV1Creator creator = new IndexV1Creator(context.getPackageManager(), repoDir, apps, repo); + IndexV1 indexV1 = creator.createRepo(); + cacheApps(indexV1); + writeIndexPage(repoUri); + SanitizedFile indexJson = new SanitizedFile(repoDir, IndexV1VerifierKt.DATA_FILE_NAME); + writeIndexJar(indexJson); + } + + private void cacheApps(IndexV1 indexV1) { + this.apps.clear(); + for (AppV1 a : indexV1.getApps()) { + App app = new App(); + app.packageName = a.getPackageName(); + app.name = a.getName(); + app.installedApk = new Apk(); + List packages = indexV1.getPackages().get(a.getPackageName()); + if (packages != null && packages.size() > 0) { + Long versionCode = packages.get(0).getVersionCode(); + if (versionCode != null) app.installedApk.versionCode = versionCode; + } + this.apps.add(app); + } + } + + private void writeIndexJar(SanitizedFile indexJson) throws IOException { + BufferedOutputStream bo = new BufferedOutputStream(new FileOutputStream(indexJarUnsigned)); + JarOutputStream jo = new JarOutputStream(bo); + JarEntry je = new JarEntry(indexJson.getName()); + jo.putNextEntry(je); + FileUtils.copyFile(indexJson, jo); + jo.close(); + bo.close(); + + try { + LocalRepoKeyStore.get(context).signZip(indexJarUnsigned, indexJar); + } catch (LocalRepoKeyStore.InitException e) { + throw new IOException("Could not sign index - keystore failed to initialize"); + } finally { + attemptToDelete(indexJarUnsigned); + } + } +} diff --git a/app/src/full/java/org/fdroid/fdroid/nearby/LocalRepoService.java b/app/src/full/java/org/fdroid/fdroid/nearby/LocalRepoService.java new file mode 100644 index 000000000..4e0e67c00 --- /dev/null +++ b/app/src/full/java/org/fdroid/fdroid/nearby/LocalRepoService.java @@ -0,0 +1,130 @@ +package org.fdroid.fdroid.nearby; + +import android.app.IntentService; +import android.content.Context; +import android.content.Intent; +import android.os.Process; +import android.util.Log; + +import androidx.localbroadcastmanager.content.LocalBroadcastManager; + +import org.fdroid.fdroid.FDroidApp; +import org.fdroid.R; +import org.fdroid.fdroid.Utils; + +import java.util.Arrays; +import java.util.Collections; +import java.util.Set; + +/** + * Handles setting up and generating the local repo used to swap apps, including + * the {@code index.jar}, the symlinks to the shared APKs, etc. + *

    + * The work is done in a {@link Thread} so that new incoming {@code Intents} + * are not blocked by processing. A new {@code Intent} immediately nullifies + * the current state because it means the user has chosen a different set of + * apps. That is also enforced here since new {@code Intent}s with the same + * {@link Set} of apps as the current one are ignored. Having the + * {@code Thread} also makes it easy to kill work that is in progress. + */ +public class LocalRepoService extends IntentService { + public static final String TAG = "LocalRepoService"; + + public static final String ACTION_CREATE = "org.fdroid.fdroid.nearby.action.CREATE"; + public static final String EXTRA_PACKAGE_NAMES = "org.fdroid.fdroid.nearby.extra.PACKAGE_NAMES"; + + public static final String ACTION_STATUS = "localRepoStatusAction"; + public static final String EXTRA_STATUS = "localRepoStatusExtra"; + public static final int STATUS_STARTED = 0; + public static final int STATUS_PROGRESS = 1; + public static final int STATUS_ERROR = 2; + + private String[] currentlyProcessedApps = new String[0]; + + private GenerateLocalRepoThread thread; + + public LocalRepoService() { + super("LocalRepoService"); + } + + /** + * Creates a skeleton swap repo with only F-Droid itself in it + */ + public static void create(Context context) { + create(context, Collections.singleton(context.getPackageName())); + } + + /** + * Sets up the local repo with the included {@code packageNames} + */ + public static void create(Context context, Set packageNames) { + Intent intent = new Intent(context, LocalRepoService.class); + intent.setAction(ACTION_CREATE); + intent.putExtra(EXTRA_PACKAGE_NAMES, packageNames.toArray(new String[0])); + context.startService(intent); + } + + @Override + protected void onHandleIntent(Intent intent) { + Process.setThreadPriority(Process.THREAD_PRIORITY_LOWEST); + String[] packageNames = intent.getStringArrayExtra(EXTRA_PACKAGE_NAMES); + if (packageNames == null || packageNames.length == 0) { + Utils.debugLog(TAG, "no packageNames found, quitting"); + return; + } + Arrays.sort(packageNames); + + if (Arrays.equals(currentlyProcessedApps, packageNames)) { + Utils.debugLog(TAG, "packageNames list unchanged, quitting"); + return; + } + currentlyProcessedApps = packageNames; + + if (thread != null) { + thread.interrupt(); + } + thread = new GenerateLocalRepoThread(); + thread.start(); + } + + private class GenerateLocalRepoThread extends Thread { + private static final String TAG = "GenerateLocalRepoThread"; + + @Override + public void run() { + Process.setThreadPriority(Process.THREAD_PRIORITY_LOWEST); + runProcess(LocalRepoService.this, currentlyProcessedApps); + } + } + + public static void runProcess(Context context, String[] selectedApps) { + try { + final LocalRepoManager lrm = LocalRepoManager.get(context); + broadcast(context, STATUS_PROGRESS, R.string.deleting_repo); + lrm.deleteRepo(); + broadcast(context, STATUS_PROGRESS, R.string.linking_apks); + String urlString = Utils.getSharingUri(FDroidApp.repo).toString(); + lrm.generateIndex(urlString, FDroidApp.repo.getAddress(), selectedApps); + broadcast(context, STATUS_STARTED, null); + } catch (Exception e) { + broadcast(context, STATUS_ERROR, e.getLocalizedMessage()); + Log.e(TAG, "Error creating repo", e); + } + } + + /** + * Translate Android style broadcast {@link Intent}s to {@code PrepareSwapRepo} + */ + static void broadcast(Context context, int status, String message) { + Intent intent = new Intent(ACTION_STATUS); + intent.putExtra(EXTRA_STATUS, status); + if (message != null) { + intent.putExtra(Intent.EXTRA_TEXT, message); + } + LocalBroadcastManager.getInstance(context).sendBroadcast(intent); + } + + static void broadcast(Context context, int status, int resId) { + broadcast(context, status, context.getString(resId)); + } +} diff --git a/app/src/full/java/org/fdroid/fdroid/nearby/NewRepoConfig.java b/app/src/full/java/org/fdroid/fdroid/nearby/NewRepoConfig.java new file mode 100644 index 000000000..791070f85 --- /dev/null +++ b/app/src/full/java/org/fdroid/fdroid/nearby/NewRepoConfig.java @@ -0,0 +1,233 @@ +package org.fdroid.fdroid.nearby; + +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Build; +import android.os.UserManager; +import android.text.TextUtils; +import android.util.Log; + +import org.fdroid.R; +import org.fdroid.fdroid.Utils; +import org.fdroid.fdroid.nearby.peers.WifiPeer; + +import java.util.Arrays; +import java.util.List; +import java.util.Locale; + +public class NewRepoConfig { + + private static final String TAG = "NewRepoConfig"; + + private String errorMessage; + private boolean isValidRepo; + + private String uriString; + private String host; + private String username; + private String password; + private String fingerprint; + private String bssid; + private String ssid; + private boolean fromSwap; + private boolean preventFurtherSwaps; + + public NewRepoConfig(Context context, String uri) { + init(context, uri != null ? Uri.parse(uri) : null); + } + + public NewRepoConfig(Context context, Intent intent) { + init(context, intent.getData()); + preventFurtherSwaps = intent.getBooleanExtra(SwapWorkflowActivity.EXTRA_PREVENT_FURTHER_SWAP_REQUESTS, false); + } + + private void init(Context context, Uri incomingUri) { + /* an URL from a click, NFC, QRCode scan, etc */ + Uri uri = incomingUri; + if (uri == null) { + isValidRepo = false; + return; + } + + if (hasDisallowInstallUnknownSources(context)) { + errorMessage = Utils.getDisallowInstallUnknownSourcesErrorMessage(context); + isValidRepo = false; + return; + } + + Utils.debugLog(TAG, "Parsing incoming intent looking for repo: " + incomingUri); + + // scheme and host should only ever be pure ASCII aka Locale.ENGLISH + String scheme = uri.getScheme(); + host = uri.getHost(); + if (TextUtils.isEmpty(scheme) || (TextUtils.isEmpty(host) && !"file".equals(scheme))) { + errorMessage = String.format(context.getString(R.string.malformed_repo_uri), uri); + Log.i(TAG, errorMessage); + isValidRepo = false; + return; + } + + if (Arrays.asList("FDROIDREPO", "FDROIDREPOS").contains(scheme)) { + /* + * QRCodes are more efficient in all upper case, so QR URIs are + * encoded in all upper case, then forced to lower case. Checking if + * the special F-Droid scheme being all is upper case means it + * should be downcased. + */ + uri = Uri.parse(uri.toString().toLowerCase(Locale.ENGLISH)); + } else if (uri.getPath().endsWith("/FDROID/REPO")) { + /* + * some QR scanners chop off the fdroidrepo:// and just try http://, + * then the incoming URI does not get downcased properly, and the + * query string is stripped off. So just downcase the path, and + * carry on to get something working. + */ + uri = Uri.parse(uri.toString().toLowerCase(Locale.ENGLISH)); + } + + // make scheme and host lowercase so they're readable in dialogs + scheme = scheme.toLowerCase(Locale.ENGLISH); + host = host.toLowerCase(Locale.ENGLISH); + + if (uri.getPath() == null + || !Arrays.asList("https", "http", "fdroidrepos", "fdroidrepo", "content", "file").contains(scheme)) { + isValidRepo = false; + return; + } + + String userInfo = uri.getUserInfo(); + if (userInfo != null) { + String[] userInfoTokens = userInfo.split(":"); + if (userInfoTokens.length >= 2) { + username = userInfoTokens[0]; + password = userInfoTokens[1]; + for (int i = 2; i < userInfoTokens.length; i++) { + password += ":" + userInfoTokens[i]; + } + } + } + + fingerprint = uri.getQueryParameter("fingerprint"); + bssid = uri.getQueryParameter("bssid"); + ssid = uri.getQueryParameter("ssid"); + fromSwap = uri.getQueryParameter("swap") != null; + uriString = sanitizeRepoUri(uri); + isValidRepo = true; + } + + public String getBssid() { + return bssid; + } + + public String getSsid() { + return ssid; + } + + public String getRepoUriString() { + return uriString; + } + + public Uri getRepoUri() { + if (uriString == null) { + return null; + } + return Uri.parse(uriString); + } + + public String getHost() { + return host; + } + + public String getUsername() { + return username; + } + + public String getPassword() { + return password; + } + + public String getFingerprint() { + return fingerprint; + } + + public boolean isValidRepo() { + return isValidRepo; + } + + public boolean isFromSwap() { + return fromSwap; + } + + public boolean preventFurtherSwaps() { + return preventFurtherSwaps; + } + + public String getErrorMessage() { + return errorMessage; + } + + private static final List FORCE_HTTPS_DOMAINS = Arrays.asList( + "amazonaws.com", + "github.com", + "githubusercontent.com", + "github.io", + "gitlab.com", + "gitlab.io" + ); + + /** + * Sanitize and format an incoming repo URI for function and readability. + * This also forces URLs listed in {@code app/src/main/res/xml/network_security_config.xml} + * to have "https://" as the scheme. + * + * @see Network Security Config + */ + public static String sanitizeRepoUri(Uri uri) { + String scheme = uri.getScheme(); + String newScheme = scheme.toLowerCase(Locale.ENGLISH); + String host = uri.getHost(); + String newHost = host.toLowerCase(Locale.ENGLISH); + String userInfo = uri.getUserInfo(); + if ("http".equals(newScheme)) { + for (String httpsDomain : FORCE_HTTPS_DOMAINS) { + if (newHost.endsWith(httpsDomain)) { + scheme = "https"; + break; + } + } + } + + return uri.toString() + .replaceAll("\\?.*$", "") // remove the whole query + .replaceAll("/*$", "") // remove all trailing slashes + .replace(userInfo + "@", "") // remove user authentication + .replaceFirst(host, newHost) + .replaceFirst(scheme, newScheme) + .replace("fdroidrepo", "http") // proper repo address + .replace("/FDROID/REPO", "/fdroid/repo"); // for QR FDroid path + } + + public WifiPeer toPeer() { + return new WifiPeer(this); + } + + /** + * {@link android.app.admin.DevicePolicyManager} makes it possible to set + * user- or device-wide restrictions. This changes whether installing from + * "Unknown Sources" has been disallowed by device policy. + * + * @return boolean whether installing from Unknown Sources has been disallowed + * @see UserManager#DISALLOW_INSTALL_UNKNOWN_SOURCES + * @see UserManager#DISALLOW_INSTALL_UNKNOWN_SOURCES_GLOBALLY + */ + public static boolean hasDisallowInstallUnknownSources(Context context) { + UserManager userManager = (UserManager) context.getSystemService(Context.USER_SERVICE); + if (Build.VERSION.SDK_INT < 29) { + return userManager.hasUserRestriction(UserManager.DISALLOW_INSTALL_UNKNOWN_SOURCES); + } else { + return userManager.hasUserRestriction(UserManager.DISALLOW_INSTALL_UNKNOWN_SOURCES) + || userManager.hasUserRestriction(UserManager.DISALLOW_INSTALL_UNKNOWN_SOURCES_GLOBALLY); + } + } +} diff --git a/app/src/full/java/org/fdroid/fdroid/nearby/PublicSourceDirProvider.java b/app/src/full/java/org/fdroid/fdroid/nearby/PublicSourceDirProvider.java new file mode 100644 index 000000000..2a5953679 --- /dev/null +++ b/app/src/full/java/org/fdroid/fdroid/nearby/PublicSourceDirProvider.java @@ -0,0 +1,126 @@ +package org.fdroid.fdroid.nearby; + +import android.content.ContentProvider; +import android.content.ContentValues; +import android.content.Context; +import android.content.Intent; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManager; +import android.database.Cursor; +import android.database.MatrixCursor; +import android.net.Uri; +import android.os.ParcelFileDescriptor; +import android.provider.MediaStore; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.util.Locale; + +/** + * A collection of tricks to make it possible to share the APKs of apps that + * are installed on the device. {@code file:///} are no longer a viable option. + * Also, the official MIME Type for APK files is + * {@code application/vnd.android.package-archive}. It is often blocked by + * Android, so this provides a MIME Type that is more likely to get around some + * of those blocks. + */ +public class PublicSourceDirProvider extends ContentProvider { + + public static final String TAG = "PublicSourceDirProvider"; + + public static final String SHARE_APK_MIME_TYPE = "application/zip"; // TODO maybe use intent.setType("*/*"); + + private static PackageManager pm; + + @Override + public boolean onCreate() { + return true; + } + + public static Uri getUri(Context context, String packageName) { + return Uri.parse(String.format(Locale.ENGLISH, "content://%s.nearby.%s/%s.apk", + context.getPackageName(), TAG, packageName)); + } + + public static Intent getApkShareIntent(Context context, String packageName) { + Intent intent = new Intent(Intent.ACTION_SEND); + Uri apkUri = getUri(context, packageName); + intent.setType(SHARE_APK_MIME_TYPE); + intent.putExtra(Intent.EXTRA_STREAM, apkUri); + intent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + return intent; + } + + @Nullable + @Override + public Cursor query(@NonNull Uri uri, @Nullable String[] projection, + @Nullable String selection, @Nullable String[] selectionArgs, + @Nullable String sortOrder) { + MatrixCursor metadataCursor = new MatrixCursor(new String[]{ + MediaStore.MediaColumns.DISPLAY_NAME, + MediaStore.MediaColumns.MIME_TYPE, + MediaStore.MediaColumns.DATA, + MediaStore.MediaColumns.SIZE, + }); + try { + ApplicationInfo applicationInfo = getApplicationInfo(uri); + File f = new File(applicationInfo.publicSourceDir); + metadataCursor.addRow(new Object[]{ + pm.getApplicationLabel(applicationInfo).toString().replace(" ", "") + ".apk", + SHARE_APK_MIME_TYPE, + Uri.parse("file://" + f.getCanonicalPath()), + f.length(), + }); + } catch (PackageManager.NameNotFoundException | IOException e) { + e.printStackTrace(); + } + return metadataCursor; + } + + @Nullable + @Override + public String getType(@NonNull Uri uri) { + return SHARE_APK_MIME_TYPE; + } + + @Nullable + @Override + public Uri insert(@NonNull Uri uri, @Nullable ContentValues values) { + throw new IllegalStateException("unimplemented"); + } + + @Override + public int delete(@NonNull Uri uri, @Nullable String selection, @Nullable String[] selectionArgs) { + return 0; + } + + @Override + public int update(@NonNull Uri uri, @Nullable ContentValues values, + @Nullable String selection, @Nullable String[] selectionArgs) { + return 0; + } + + @Override + public ParcelFileDescriptor openFile(Uri uri, @NonNull String mode) throws FileNotFoundException { + try { + ApplicationInfo applicationInfo = getApplicationInfo(uri); + File apkFile = new File(applicationInfo.publicSourceDir); + return ParcelFileDescriptor.open(apkFile, ParcelFileDescriptor.MODE_READ_ONLY); + } catch (IOException | PackageManager.NameNotFoundException e) { + throw new FileNotFoundException(e.getLocalizedMessage()); + } + } + + private ApplicationInfo getApplicationInfo(Uri uri) throws PackageManager.NameNotFoundException { + if (pm == null) { + pm = getContext().getPackageManager(); + } + String apkName = uri.getLastPathSegment(); + String packageName = apkName.substring(0, apkName.length() - 4); + return pm.getApplicationInfo(packageName, 0); + } +} diff --git a/app/src/full/java/org/fdroid/fdroid/nearby/SDCardScannerService.java b/app/src/full/java/org/fdroid/fdroid/nearby/SDCardScannerService.java new file mode 100644 index 000000000..49de1478d --- /dev/null +++ b/app/src/full/java/org/fdroid/fdroid/nearby/SDCardScannerService.java @@ -0,0 +1,148 @@ +/* + * Copyright (C) 2018 Hans-Christoph Steiner + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 3 + * of the License, or (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, + * MA 02110-1301, USA. + */ + +package org.fdroid.fdroid.nearby; + +import android.Manifest; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.os.Environment; +import android.os.Process; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.core.app.JobIntentService; +import androidx.core.content.ContextCompat; + +import org.fdroid.fdroid.Utils; + +import java.io.File; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; + +/** + * An {@link JobIntentService} subclass for scanning removable "external storage" + * for F-Droid package repos, e.g. SD Cards. This is intended to support + * sharable package repos, so it ignores non-removable storage, like the fake + * emulated sdcard from devices with only built-in storage. This method will + * only ever allow for reading repos, never writing. It also will not work + * for removable storage devices plugged in via USB, since do not show up as + * "External Storage" + *

    + * Scanning the removable storage requires that the user allowed it. This + * requires both the {@link org.fdroid.fdroid.Preferences#isScanRemovableStorageEnabled()} + * and the {@link Manifest.permission#READ_EXTERNAL_STORAGE} + * permission to be enabled. + * + * @see TreeUriScannerIntentService TreeUri method for writing repos to be shared + * @see Universal way to write to external SD card on Android + * @see The Storage Situation: External Storage + */ +public class SDCardScannerService extends JobIntentService { + public static final String TAG = "SDCardScannerService"; + private static final int JOB_ID = TAG.hashCode(); + + private static final String ACTION_SCAN = "org.fdroid.fdroid.nearby.SCAN"; + + private static final List SKIP_DIRS = Arrays.asList(".android_secure", "LOST.DIR"); + + public static void scan(Context context) { + Intent intent = new Intent(context, SDCardScannerService.class); + intent.setAction(ACTION_SCAN); + JobIntentService.enqueueWork(context, SDCardScannerService.class, JOB_ID, intent); + } + + @Override + protected void onHandleWork(@NonNull Intent intent) { + if (!ACTION_SCAN.equals(intent.getAction())) { + return; + } + Process.setThreadPriority(Process.THREAD_PRIORITY_LOWEST); + + HashSet files = new HashSet<>(); + for (File f : getExternalFilesDirs(null)) { + Log.i(TAG, "getExternalFilesDirs " + f); + if (f == null || !f.isDirectory()) { + continue; + } + Log.i(TAG, "getExternalFilesDirs " + f); + boolean isExternalStorageRemovable; + try { + isExternalStorageRemovable = Environment.isExternalStorageRemovable(f); + } catch (IllegalArgumentException e) { + Utils.debugLog(TAG, e.toString()); + continue; + } + if (isExternalStorageRemovable) { + String state = Environment.getExternalStorageState(f); + if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) + == PackageManager.PERMISSION_GRANTED) { + // remove Android/data/org.fdroid.fdroid/files to get root + File sdcard = f.getParentFile().getParentFile().getParentFile().getParentFile(); + Collections.addAll(files, checkExternalStorage(sdcard, state)); + } else { + Collections.addAll(files, checkExternalStorage(f, state)); + } + } + } + + Log.i(TAG, "sdcard files " + files.toString()); + ArrayList filesList = new ArrayList<>(); + for (File dir : files) { + if (!dir.isDirectory()) { + continue; + } + searchDirectory(dir); + } + } + + private File[] checkExternalStorage(File sdcard, String state) { + File[] files = null; + if (sdcard != null && + (Environment.MEDIA_MOUNTED_READ_ONLY.equals(state) || Environment.MEDIA_MOUNTED.equals(state))) { + files = sdcard.listFiles(); + } + + if (files == null) { + Utils.debugLog(TAG, "checkExternalStorage returned blank, F-Droid probably doesn't have Storage perm!"); + return new File[0]; + } else { + return files; + } + } + + private void searchDirectory(File dir) { + if (SKIP_DIRS.contains(dir.getName())) { + return; + } + File[] files = dir.listFiles(); + if (files == null) { + return; + } + for (File file : files) { + if (file.isDirectory()) { + searchDirectory(file); + } + } + } +} diff --git a/app/src/full/java/org/fdroid/fdroid/nearby/SelectAppsView.java b/app/src/full/java/org/fdroid/fdroid/nearby/SelectAppsView.java new file mode 100644 index 000000000..9d7f18d08 --- /dev/null +++ b/app/src/full/java/org/fdroid/fdroid/nearby/SelectAppsView.java @@ -0,0 +1,222 @@ +package org.fdroid.fdroid.nearby; + +import static java.util.Objects.requireNonNull; + +import android.content.Context; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.graphics.drawable.Drawable; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.view.ContextThemeWrapper; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseAdapter; +import android.widget.CheckBox; +import android.widget.ImageView; +import android.widget.ListView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; + +import org.fdroid.R; +import org.fdroid.fdroid.Utils; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +public class SelectAppsView extends SwapView { + + public SelectAppsView(Context context) { + super(context); + } + + public SelectAppsView(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public SelectAppsView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + public SelectAppsView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + + private ListView listView; + private AppListAdapter adapter; + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + listView = findViewById(R.id.list); + List packages = getContext().getPackageManager().getInstalledPackages(0); + adapter = new AppListAdapter(listView, packages); + + listView.setAdapter(adapter); + listView.setChoiceMode(ListView.CHOICE_MODE_MULTIPLE); + + listView.setOnItemClickListener((parent, v, position, id) -> toggleAppSelected(position)); + afterAppsLoaded(); + } + + @Override + public void setCurrentFilterString(String currentFilterString) { + super.setCurrentFilterString(currentFilterString); + adapter.setSearchTerm(currentFilterString); + } + + private void toggleAppSelected(int position) { + String packageName = adapter.getItem(position).packageName; + if (getActivity().getSwapService().hasSelectedPackage(packageName)) { + getActivity().getSwapService().deselectPackage(packageName); + } else { + getActivity().getSwapService().selectPackage(packageName); + } + LocalRepoService.create(getContext(), getActivity().getSwapService().getAppsToSwap()); + } + + public void afterAppsLoaded() { + for (int i = 0; i < listView.getCount(); i++) { + InstalledApp app = (InstalledApp) listView.getItemAtPosition(i); + getActivity().getSwapService().ensureFDroidSelected(); + for (String selected : getActivity().getSwapService().getAppsToSwap()) { + if (TextUtils.equals(app.packageName, selected)) { + listView.setItemChecked(i, true); + } + } + } + } + + private class AppListAdapter extends BaseAdapter { + + private final Context context = SelectAppsView.this.getContext(); + @Nullable + private LayoutInflater inflater; + + @Nullable + private Drawable defaultAppIcon; + + @NonNull + private final ListView listView; + + private final List allPackages; + private final List filteredPackages = new ArrayList<>(); + + AppListAdapter(@NonNull ListView listView, List packageInfos) { + this.listView = listView; + allPackages = new ArrayList<>(packageInfos.size()); + for (PackageInfo packageInfo : packageInfos) { + allPackages.add(new InstalledApp(context, packageInfo)); + } + filteredPackages.addAll(allPackages); + } + + void setSearchTerm(@Nullable String searchTerm) { + filteredPackages.clear(); + if (TextUtils.isEmpty(searchTerm)) { + filteredPackages.addAll(allPackages); + } else { + String query = requireNonNull(searchTerm).toLowerCase(Locale.US); + for (InstalledApp app : allPackages) { + if (app.name.toLowerCase(Locale.US).contains(query)) { + filteredPackages.add(app); + } + } + } + notifyDataSetChanged(); + } + + @NonNull + private LayoutInflater getInflater(Context context) { + if (inflater == null) { + Context themedContext = new ContextThemeWrapper(context, R.style.SwapTheme_AppList_ListItem); + inflater = ContextCompat.getSystemService(themedContext, LayoutInflater.class); + } + return inflater; + } + + private Drawable getDefaultAppIcon(Context context) { + if (defaultAppIcon == null) { + defaultAppIcon = ContextCompat.getDrawable(context, android.R.drawable.sym_def_app_icon); + } + return defaultAppIcon; + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + View view = convertView == null ? + getInflater(context).inflate(R.layout.select_local_apps_list_item, parent, false) : + convertView; + bindView(view, context, position); + return view; + } + + public void bindView(final View view, final Context context, final int position) { + InstalledApp app = getItem(position); + + TextView packageView = (TextView) view.findViewById(R.id.package_name); + TextView labelView = (TextView) view.findViewById(R.id.application_label); + ImageView iconView = (ImageView) view.findViewById(android.R.id.icon); + + Drawable icon; + try { + icon = context.getPackageManager().getApplicationIcon(app.packageName); + } catch (PackageManager.NameNotFoundException e) { + icon = getDefaultAppIcon(context); + } + + packageView.setText(app.packageName); + labelView.setText(app.name); + iconView.setImageDrawable(icon); + + // Since v11, the Android SDK provided the ability to show selected list items + // by highlighting their background. Prior to this, we need to handle this ourselves + // by adding a checkbox which can toggle selected items. + View checkBoxView = view.findViewById(R.id.checkbox); + if (checkBoxView != null) { + CheckBox checkBox = (CheckBox) checkBoxView; + checkBox.setOnCheckedChangeListener(null); + + checkBox.setChecked(listView.isItemChecked(position)); + checkBox.setOnCheckedChangeListener((buttonView, isChecked) -> { + listView.setItemChecked(position, isChecked); + toggleAppSelected(position); + }); + } + } + + @Override + public int getCount() { + return filteredPackages.size(); + } + + @Override + public InstalledApp getItem(int position) { + return filteredPackages.get(position); + } + + @Override + public long getItemId(int position) { + return getItem(position).hashCode(); + } + } + + private static class InstalledApp { + final String packageName; + final String name; + + InstalledApp(String packageName, String name) { + this.packageName = packageName; + this.name = name; + } + + InstalledApp(Context context, PackageInfo packageInfo) { + this(packageInfo.packageName, Utils.getApplicationLabel(context, packageInfo.packageName)); + } + } +} diff --git a/app/src/full/java/org/fdroid/fdroid/nearby/StartSwapView.java b/app/src/full/java/org/fdroid/fdroid/nearby/StartSwapView.java new file mode 100644 index 000000000..40360c2b7 --- /dev/null +++ b/app/src/full/java/org/fdroid/fdroid/nearby/StartSwapView.java @@ -0,0 +1,255 @@ +package org.fdroid.fdroid.nearby; + +import android.Manifest; +import android.bluetooth.BluetoothAdapter; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.pm.PackageManager; +import android.net.wifi.WifiConfiguration; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; +import android.widget.CompoundButton; +import android.widget.ImageView; +import android.widget.ListView; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; +import androidx.localbroadcastmanager.content.LocalBroadcastManager; + +import com.google.android.material.button.MaterialButton; +import com.google.android.material.materialswitch.MaterialSwitch; +import com.google.android.material.progressindicator.CircularProgressIndicator; + +import org.fdroid.fdroid.FDroidApp; +import org.fdroid.R; +import org.fdroid.fdroid.Utils; +import org.fdroid.fdroid.nearby.peers.Peer; + +import java.util.ArrayList; + +import cc.mvdan.accesspoint.WifiApControl; + +@SuppressWarnings("LineLength") +public class StartSwapView extends SwapView { + private static final String TAG = "StartSwapView"; + + public StartSwapView(Context context) { + super(context); + } + + public StartSwapView(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public StartSwapView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + public StartSwapView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + + class PeopleNearbyAdapter extends ArrayAdapter { + + PeopleNearbyAdapter(Context context) { + super(context, 0, new ArrayList()); + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + if (convertView == null) { + convertView = LayoutInflater.from(getContext()) + .inflate(R.layout.swap_peer_list_item, parent, false); + } + + Peer peer = getItem(position); + ((TextView) convertView.findViewById(R.id.peer_name)).setText(peer.getName()); + ((ImageView) convertView.findViewById(R.id.icon)) + .setImageDrawable(ContextCompat.getDrawable(getContext(), peer.getIcon())); + + return convertView; + } + } + + @Nullable /* Emulators typically don't have bluetooth adapters */ + private final BluetoothAdapter bluetooth = BluetoothAdapter.getDefaultAdapter(); + + private MaterialSwitch bluetoothSwitch; + private TextView viewBluetoothId; + private TextView textBluetoothVisible; + private TextView viewWifiId; + private TextView viewWifiNetwork; + private TextView peopleNearbyText; + private ListView peopleNearbyList; + private CircularProgressIndicator peopleNearbyProgress; + + private PeopleNearbyAdapter peopleNearbyAdapter; + + /** + * Remove relevant listeners/subscriptions/etc so that they do not receive and process events + * when this view is not in use. + *

    + */ + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + if (bluetoothSwitch != null) { + bluetoothSwitch.setOnCheckedChangeListener(null); + } + + LocalBroadcastManager.getInstance(getContext()).unregisterReceiver(onWifiNetworkChanged); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + + uiInitPeers(); + uiInitBluetooth(); + uiInitWifi(); + uiInitButtons(); + + LocalBroadcastManager.getInstance(getActivity()).registerReceiver( + onWifiNetworkChanged, new IntentFilter(WifiStateChangeService.BROADCAST)); + } + + private final BroadcastReceiver onWifiNetworkChanged = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + uiUpdateWifiNetwork(); + } + }; + + private void uiInitButtons() { + MaterialButton sendFDroidButton = findViewById(R.id.btn_send_fdroid); + sendFDroidButton.setEllipsize(TextUtils.TruncateAt.END); + findViewById(R.id.btn_send_fdroid).setOnClickListener(v -> getActivity().sendFDroid()); + } + + /** + * Setup the list of nearby peers with an adapter, and hide or show it and the associated + * message for when no peers are nearby depending on what is happening. + * + * @see SwapWorkflowActivity#bonjourFound + * @see SwapWorkflowActivity#bluetoothFound + */ + private void uiInitPeers() { + + peopleNearbyText = (TextView) findViewById(R.id.text_people_nearby); + peopleNearbyList = (ListView) findViewById(R.id.list_people_nearby); + peopleNearbyProgress = (CircularProgressIndicator) findViewById(R.id.searching_people_nearby); + + peopleNearbyAdapter = new PeopleNearbyAdapter(getContext()); + peopleNearbyList.setAdapter(peopleNearbyAdapter); + for (Peer peer : getActivity().getSwapService().getActivePeers()) { + if (peopleNearbyAdapter.getPosition(peer) == -1) { + peopleNearbyAdapter.add(peer); + } + } + + peopleNearbyList.setOnItemClickListener((parent, view, position, id) -> { + Peer peer = peopleNearbyAdapter.getItem(position); + onPeerSelected(peer); + }); + } + + private void uiInitBluetooth() { + if (bluetooth != null) { + + viewBluetoothId = (TextView) findViewById(R.id.device_id_bluetooth); + if (ContextCompat.checkSelfPermission(getContext(), Manifest.permission.BLUETOOTH_CONNECT) == + PackageManager.PERMISSION_GRANTED) { + viewBluetoothId.setText(bluetooth.getName()); + } + viewBluetoothId.setVisibility(bluetooth.isEnabled() ? View.VISIBLE : View.GONE); + + textBluetoothVisible = findViewById(R.id.bluetooth_visible); + + bluetoothSwitch = (MaterialSwitch) findViewById(R.id.switch_bluetooth); + bluetoothSwitch.setOnCheckedChangeListener(onBluetoothSwitchToggled); + bluetoothSwitch.setChecked(SwapService.getBluetoothVisibleUserPreference()); + bluetoothSwitch.setEnabled(true); + bluetoothSwitch.setOnCheckedChangeListener(onBluetoothSwitchToggled); + } else { + findViewById(R.id.bluetooth_info).setVisibility(View.GONE); + } + } + + private final CompoundButton.OnCheckedChangeListener onBluetoothSwitchToggled = new CompoundButton.OnCheckedChangeListener() { + @Override + public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { + if (isChecked) { + if (ContextCompat.checkSelfPermission(getContext(), Manifest.permission.BLUETOOTH_CONNECT) != + PackageManager.PERMISSION_GRANTED || + ContextCompat.checkSelfPermission(getContext(), Manifest.permission.BLUETOOTH_SCAN) != + PackageManager.PERMISSION_GRANTED) { + Toast.makeText(getContext(), R.string.swap_bluetooth_permissions, Toast.LENGTH_LONG).show(); + bluetoothSwitch.setChecked(false); + return; + } + Utils.debugLog(TAG, "Received onCheckChanged(true) for Bluetooth swap, prompting user as to whether they want to enable Bluetooth."); + getActivity().startBluetoothSwap(); + textBluetoothVisible.setText(R.string.swap_visible_bluetooth); + viewBluetoothId.setText(bluetooth.getName()); + viewBluetoothId.setVisibility(View.VISIBLE); + Utils.debugLog(TAG, "Received onCheckChanged(true) for Bluetooth swap (prompting user or setup Bluetooth complete)"); + // TODO: When they deny the request for enabling bluetooth, we need to disable this switch... + } else { + Utils.debugLog(TAG, "Received onCheckChanged(false) for Bluetooth swap, disabling Bluetooth swap."); + BluetoothManager.stop(getContext()); + textBluetoothVisible.setText(R.string.swap_not_visible_bluetooth); + viewBluetoothId.setVisibility(View.GONE); + Utils.debugLog(TAG, "Received onCheckChanged(false) for Bluetooth swap, Bluetooth swap disabled successfully."); + } + SwapService.putBluetoothVisibleUserPreference(isChecked); + } + }; + + private void uiInitWifi() { + + viewWifiId = (TextView) findViewById(R.id.device_id_wifi); + viewWifiNetwork = (TextView) findViewById(R.id.wifi_network); + + uiUpdateWifiNetwork(); + } + + private void uiUpdateWifiNetwork() { + + viewWifiId.setText(FDroidApp.ipAddressString); + viewWifiId.setVisibility(TextUtils.isEmpty(FDroidApp.ipAddressString) ? View.GONE : View.VISIBLE); + + WifiApControl wifiAp = WifiApControl.getInstance(getActivity()); + if (wifiAp != null && wifiAp.isWifiApEnabled()) { + WifiConfiguration config = wifiAp.getConfiguration(); + TextView textWifiVisible = findViewById(R.id.wifi_visible); + if (textWifiVisible != null) { + textWifiVisible.setText(R.string.swap_visible_hotspot); + } + Context context = getContext(); + if (config == null) { + viewWifiNetwork.setText(context.getString(R.string.swap_active_hotspot, + context.getString(R.string.swap_blank_wifi_ssid))); + } else { + viewWifiNetwork.setText(context.getString(R.string.swap_active_hotspot, config.SSID)); + } + } else if (TextUtils.isEmpty(FDroidApp.ssid)) { + // not connected to or setup with any wifi network + viewWifiNetwork.setText(R.string.swap_no_wifi_network); + } else { + // connected to a regular wifi network + viewWifiNetwork.setText(FDroidApp.ssid); + } + } + + private void onPeerSelected(Peer peer) { + getActivity().swapWith(peer); + } +} diff --git a/app/src/full/java/org/fdroid/fdroid/nearby/SwapService.java b/app/src/full/java/org/fdroid/fdroid/nearby/SwapService.java new file mode 100644 index 000000000..9541128ae --- /dev/null +++ b/app/src/full/java/org/fdroid/fdroid/nearby/SwapService.java @@ -0,0 +1,658 @@ +package org.fdroid.fdroid.nearby; + +import android.app.Notification; +import android.app.PendingIntent; +import android.app.Service; +import android.bluetooth.BluetoothAdapter; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.SharedPreferences; +import android.net.Uri; +import android.net.wifi.WifiManager; +import android.os.Build; +import android.os.IBinder; +import android.text.TextUtils; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.app.NotificationChannelCompat; +import androidx.core.app.NotificationCompat; +import androidx.core.app.NotificationManagerCompat; +import androidx.core.app.ServiceCompat; +import androidx.core.content.ContextCompat; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.localbroadcastmanager.content.LocalBroadcastManager; + +import org.fdroid.R; +import org.fdroid.database.Repository; +import org.fdroid.download.Downloader; +import org.fdroid.download.DownloaderFactory; +import org.fdroid.download.NotFoundException; +import org.fdroid.fdroid.FDroidApp; +import org.fdroid.fdroid.Preferences; +import org.fdroid.fdroid.Utils; +import org.fdroid.fdroid.nearby.peers.Peer; +import org.fdroid.index.IndexParser; +import org.fdroid.index.IndexParserKt; +import org.fdroid.index.SigningException; +import org.fdroid.index.v1.IndexV1; +import org.fdroid.index.v1.IndexV1UpdaterKt; +import org.fdroid.index.v1.IndexV1Verifier; +import org.fdroid.index.v2.FileV2; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; +import java.util.Timer; +import java.util.TimerTask; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import javax.inject.Inject; + +import cc.mvdan.accesspoint.WifiApControl; +import dagger.hilt.android.AndroidEntryPoint; + +/** + * Central service which manages all of the different moving parts of swap + * which are required to enable p2p swapping of apps. This is the background + * operations for {@link SwapWorkflowActivity}. + */ +@AndroidEntryPoint +public class SwapService extends Service { + private static final String TAG = "SwapService"; + + public static final String CHANNEL_SWAPS = "swap-channel"; + public static final String ACTION_REQUEST_SWAP = "requestSwap"; + public static final String ACTION_INTERRUPTED = "org.fdroid.fdroid.net.Downloader.action.INTERRUPTED"; + public static final String EXTRA_ERROR_MESSAGE = "org.fdroid.fdroid.net.Downloader.extra.ERROR_MESSAGE"; + private static final String SHARED_PREFERENCES = "swap-state"; + private static final String KEY_APPS_TO_SWAP = "appsToSwap"; + private static final String KEY_BLUETOOTH_ENABLED = "bluetoothEnabled"; + private static final String KEY_WIFI_ENABLED = "wifiEnabled"; + private static final String KEY_HOTSPOT_ACTIVATED = "hotspotEnabled"; + private static final String KEY_BLUETOOTH_ENABLED_BEFORE_SWAP = "bluetoothEnabledBeforeSwap"; + private static final String KEY_BLUETOOTH_NAME_BEFORE_SWAP = "bluetoothNameBeforeSwap"; + private static final String KEY_WIFI_ENABLED_BEFORE_SWAP = "wifiEnabledBeforeSwap"; + private static final String KEY_HOTSPOT_ACTIVATED_BEFORE_SWAP = "hotspotEnabledBeforeSwap"; + + @Inject + DownloaderFactory downloaderFactory; + + private final ExecutorService executor = Executors.newSingleThreadExecutor(); + + @NonNull + private final Set appsToSwap = new HashSet<>(); + private final Set activePeers = new HashSet<>(); + private final MutableLiveData index = new MutableLiveData<>(); + private final MutableLiveData indexError = new MutableLiveData<>(); + + private static LocalBroadcastManager localBroadcastManager; + private static SharedPreferences swapPreferences; + private static BluetoothAdapter bluetoothAdapter; + private static WifiManager wifiManager; + private static Timer pollConnectedSwapRepoTimer; + + public static void stop(Context context) { + Intent intent = new Intent(context, SwapService.class); + context.stopService(intent); + } + + @NonNull + public Set getAppsToSwap() { + return appsToSwap; + } + + @NonNull + public Set getActivePeers() { + return activePeers; + } + + public void connectToPeer() { + if (getPeer() == null) { + throw new IllegalStateException("Cannot connect to peer, no peer has been selected."); + } + connectTo(getPeer()); + if (LocalHTTPDManager.isAlive() && getPeer().shouldPromptForSwapBack()) { + askServerToSwapWithUs(peerRepo); + } + } + + private void connectTo(@NonNull Peer peer) { + if (peer != this.peer) { + Log.e(TAG, "Oops, got a different peer to swap with than initially planned."); + } + peerRepo = FDroidApp.createSwapRepo(peer.getRepoAddress(), null); + try { + updateRepo(peer, peerRepo); + } catch (Exception e) { + Log.e(TAG, "Error updating repo.", e); + indexError.postValue(e); + } + } + + /** + * {@code swapJarFile} is a path where the downloaded data will be written + * to, but this method will not delete it afterwards. + */ + public IndexV1 getVerifiedRepoIndex(Repository repo, String expectedSigningFingerprint, File swapJarFile) + throws SigningException, IOException, NotFoundException, InterruptedException { + Uri uri = Uri.parse(repo.getAddress()) + .buildUpon() + .appendPath(IndexV1UpdaterKt.SIGNED_FILE_NAME) + .build(); + FileV2 indexFile = FileV2.fromPath("/" + IndexV1UpdaterKt.SIGNED_FILE_NAME); + Downloader downloader = downloaderFactory.createWithTryFirstMirror(repo, uri, indexFile, swapJarFile); + downloader.download(); + IndexV1Verifier verifier = new IndexV1Verifier(swapJarFile, null, expectedSigningFingerprint); + return verifier.getStreamAndVerify(inputStream -> + IndexParserKt.parseV1(IndexParser.INSTANCE, inputStream) + ).getSecond(); + } + + /** + * Start updating the swap repo. If {@code index-v1.jar} is not found, + * then check if {@code index.jar} aka v0 is present. If so, then the + * other side is using an old F-Droid version, so tell the user. + */ + private void updateRepo(@NonNull Peer peer, Repository repo) + throws IOException, InterruptedException, SigningException, NotFoundException { + File swapJarFile = + File.createTempFile("swap", "", getApplicationContext().getCacheDir()); + File ignoredFile; + try { + index.postValue(getVerifiedRepoIndex(repo, peer.getFingerprint(), swapJarFile)); + startPollingConnectedSwapRepo(); + } catch (NotFoundException e) { + String index = "index.jar"; + Uri uri = Uri.parse(repo.getAddress()).buildUpon().appendPath(index).build(); + FileV2 indexFile = FileV2.fromPath("/" + index); + ignoredFile = File.createTempFile("ignored-", ""); + Downloader downloader = downloaderFactory.createWithTryFirstMirror(repo, uri, indexFile, ignoredFile); + downloader.download(); + String msg = getApplicationContext().getString(R.string.swap_connection_indexv0_error); + throw new FileNotFoundException(msg); + } finally { + //noinspection ResultOfMethodCallIgnored + swapJarFile.delete(); + } + } + + @Nullable + public Repository getPeerRepo() { + return peerRepo; + } + + public LiveData getIndex() { + return index; + } + + public LiveData getIndexError() { + return indexError; + } + + // ================================================= + // Have selected a specific peer to swap with + // (Rather than showing a generic QR code to scan) + // ================================================= + + @Nullable + private Peer peer; + + @Nullable + private Repository peerRepo; + + public void swapWith(Peer peer) { + this.peer = peer; + } + + public void addCurrentPeerToActive() { + activePeers.add(peer); + } + + public void removeCurrentPeerFromActive() { + activePeers.remove(peer); + } + + public boolean isConnectingWithPeer() { + return peer != null; + } + + @Nullable + public Peer getPeer() { + return peer; + } + + // ========================================== + // Remember apps user wants to swap + // ========================================== + + private void persistAppsToSwap() { + swapPreferences.edit().putString(KEY_APPS_TO_SWAP, serializePackages(appsToSwap)).apply(); + } + + /** + * Replacement for {@link SharedPreferences.Editor#putStringSet(String, Set)} + * which is only available in API >= 11. + * Package names are reverse-DNS-style, so they should only have alpha numeric values. Thus, + * this uses a comma as the separator. + * + * @see SwapService#deserializePackages(String) + */ + private static String serializePackages(Set packages) { + StringBuilder sb = new StringBuilder(); + for (String pkg : packages) { + if (sb.length() > 0) { + sb.append(','); + } + sb.append(pkg); + } + return sb.toString(); + } + + /** + * @see SwapService#deserializePackages(String) + */ + private static Set deserializePackages(String packages) { + Set set = new HashSet<>(); + if (!TextUtils.isEmpty(packages)) { + Collections.addAll(set, packages.split(",")); + } + return set; + } + + public void ensureFDroidSelected() { + String fdroid = getPackageName(); + if (!hasSelectedPackage(fdroid)) { + selectPackage(fdroid); + } + } + + public boolean hasSelectedPackage(String packageName) { + return appsToSwap.contains(packageName); + } + + public void selectPackage(String packageName) { + appsToSwap.add(packageName); + persistAppsToSwap(); + } + + public void deselectPackage(String packageName) { + if (appsToSwap.contains(packageName)) { + appsToSwap.remove(packageName); + } + persistAppsToSwap(); + } + + public static boolean getBluetoothVisibleUserPreference() { + return swapPreferences.getBoolean(SwapService.KEY_BLUETOOTH_ENABLED, false); + } + + public static void putBluetoothVisibleUserPreference(boolean visible) { + swapPreferences.edit().putBoolean(SwapService.KEY_BLUETOOTH_ENABLED, visible).apply(); + } + + public static boolean getWifiVisibleUserPreference() { + return swapPreferences.getBoolean(SwapService.KEY_WIFI_ENABLED, false); + } + + public static void putWifiVisibleUserPreference(boolean visible) { + swapPreferences.edit().putBoolean(SwapService.KEY_WIFI_ENABLED, visible).apply(); + } + + public static boolean getHotspotActivatedUserPreference() { + return swapPreferences.getBoolean(SwapService.KEY_HOTSPOT_ACTIVATED, false); + } + + public static void putHotspotActivatedUserPreference(boolean visible) { + swapPreferences.edit().putBoolean(SwapService.KEY_HOTSPOT_ACTIVATED, visible).apply(); + } + + public static boolean wasBluetoothEnabledBeforeSwap() { + return swapPreferences.getBoolean(SwapService.KEY_BLUETOOTH_ENABLED_BEFORE_SWAP, false); + } + + public static void putBluetoothEnabledBeforeSwap(boolean visible) { + swapPreferences.edit().putBoolean(SwapService.KEY_BLUETOOTH_ENABLED_BEFORE_SWAP, visible).apply(); + } + + public static String getBluetoothNameBeforeSwap() { + return swapPreferences.getString(SwapService.KEY_BLUETOOTH_NAME_BEFORE_SWAP, null); + } + + public static void putBluetoothNameBeforeSwap(String name) { + swapPreferences.edit().putString(SwapService.KEY_BLUETOOTH_NAME_BEFORE_SWAP, name).apply(); + } + + public static boolean wasWifiEnabledBeforeSwap() { + return swapPreferences.getBoolean(SwapService.KEY_WIFI_ENABLED_BEFORE_SWAP, false); + } + + public static void putWifiEnabledBeforeSwap(boolean visible) { + swapPreferences.edit().putBoolean(SwapService.KEY_WIFI_ENABLED_BEFORE_SWAP, visible).apply(); + } + + public static boolean wasHotspotEnabledBeforeSwap() { + return swapPreferences.getBoolean(SwapService.KEY_HOTSPOT_ACTIVATED_BEFORE_SWAP, false); + } + + public static void putHotspotEnabledBeforeSwap(boolean visible) { + swapPreferences.edit().putBoolean(SwapService.KEY_HOTSPOT_ACTIVATED_BEFORE_SWAP, visible).apply(); + } + + private static final int NOTIFICATION = 1; + + private final Binder binder = new Binder(); + + private static final int TIMEOUT = 15 * 60 * 1000; // 15 mins + + /** + * Used to automatically turn of swapping after a defined amount of time (15 mins). + */ + @Nullable + private Timer timer; + + private final WifiStateChangeReceiver wifiStateChangeReceiver = new WifiStateChangeReceiver(); + + public class Binder extends android.os.Binder { + public SwapService getService() { + return SwapService.this; + } + } + + @Override + public void onCreate() { + super.onCreate(); + startForeground(NOTIFICATION, createNotification()); + WifiStateChangeService.registerReceiver(this, wifiStateChangeReceiver); + localBroadcastManager = LocalBroadcastManager.getInstance(this); + swapPreferences = getSharedPreferences(SHARED_PREFERENCES, Context.MODE_PRIVATE); + + LocalHTTPDManager.start(this); + + bluetoothAdapter = BluetoothAdapter.getDefaultAdapter(); + if (bluetoothAdapter != null) { + SwapService.putBluetoothEnabledBeforeSwap(bluetoothAdapter.isEnabled()); + if (bluetoothAdapter.isEnabled()) { + BluetoothManager.start(this); + } + registerReceiver(bluetoothScanModeChanged, + new IntentFilter(BluetoothAdapter.ACTION_SCAN_MODE_CHANGED)); + } + + wifiManager = ContextCompat.getSystemService(getApplicationContext(), WifiManager.class); + if (wifiManager != null) { + SwapService.putWifiEnabledBeforeSwap(wifiManager.isWifiEnabled()); + } + + appsToSwap.addAll(deserializePackages(swapPreferences.getString(KEY_APPS_TO_SWAP, ""))); + + Preferences.get().registerLocalRepoHttpsListeners(httpsEnabledListener); + + localBroadcastManager.registerReceiver(onWifiChange, new IntentFilter(WifiStateChangeService.BROADCAST)); + localBroadcastManager.registerReceiver(bluetoothPeerFound, new IntentFilter(BluetoothManager.ACTION_FOUND)); + localBroadcastManager.registerReceiver(bonjourPeerFound, new IntentFilter(BonjourManager.ACTION_FOUND)); + localBroadcastManager.registerReceiver(bonjourPeerRemoved, new IntentFilter(BonjourManager.ACTION_REMOVED)); + + if (Build.VERSION.SDK_INT <= 28) { + if (getHotspotActivatedUserPreference()) { + WifiApControl wifiApControl = WifiApControl.getInstance(this); + if (wifiApControl != null) { + wifiApControl.enable(); + } + } else if (getWifiVisibleUserPreference()) { + if (wifiManager != null) { + if (!wifiManager.isWifiEnabled()) { + wifiManager.setWifiEnabled(true); + } + } + } + } + + BonjourManager.start(this); + BonjourManager.setVisible(this, getWifiVisibleUserPreference() || getHotspotActivatedUserPreference()); + } + + private void askServerToSwapWithUs(final Repository repo) { + executor.execute(() -> { + String swapBackUri = Utils.getLocalRepoUri(FDroidApp.repo).toString(); + HttpURLConnection conn = null; + try { + URL url = new URL(repo.getAddress().replace("/fdroid/repo", "/request-swap")); + conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("POST"); + conn.setDoInput(true); + conn.setDoOutput(true); + + try (OutputStream outputStream = conn.getOutputStream(); + OutputStreamWriter writer = new OutputStreamWriter(outputStream)) { + writer.write("repo=" + swapBackUri); + writer.flush(); + } + + int responseCode = conn.getResponseCode(); + Utils.debugLog(TAG, "Asking server at " + repo.getAddress() + " to swap with us in return (by " + + "POSTing to \"/request-swap\" with repo \"" + swapBackUri + "\"): " + responseCode); + } catch (Exception e) { + Log.e(TAG, "Error asking server to swap with us in return: ", e); + Intent intent = new Intent(ACTION_INTERRUPTED); + intent.setData(Uri.parse(repo.getAddress())); + intent.putExtra(EXTRA_ERROR_MESSAGE, e.getLocalizedMessage()); + LocalBroadcastManager.getInstance(getApplicationContext()).sendBroadcast(intent); + } finally { + if (conn != null) { + conn.disconnect(); + } + } + }); + } + + /** + * This is for setting things up for when the {@code SwapService} was + * started by the user clicking on the initial start button. The things + * that must be run always on start-up go in {@link #onCreate()}. + */ + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + Intent startUiIntent = new Intent(this, SwapWorkflowActivity.class); + if (intent.getData() != null) { + startUiIntent.setData(intent.getData()); + startUiIntent.setAction(ACTION_REQUEST_SWAP); + } + startUiIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + startActivity(startUiIntent); + return START_NOT_STICKY; + } + + @Override + public IBinder onBind(Intent intent) { + // reset the timer on each new connect, the user has come back + initTimer(); + return binder; + } + + @Override + public void onDestroy() { + Utils.debugLog(TAG, "Destroying service, will disable swapping if required, and unregister listeners."); + WifiStateChangeService.unregisterReceiver(this, wifiStateChangeReceiver); + Preferences.get().unregisterLocalRepoHttpsListeners(httpsEnabledListener); + localBroadcastManager.unregisterReceiver(onWifiChange); + localBroadcastManager.unregisterReceiver(bluetoothPeerFound); + localBroadcastManager.unregisterReceiver(bonjourPeerFound); + localBroadcastManager.unregisterReceiver(bonjourPeerRemoved); + + if (bluetoothAdapter != null) { + unregisterReceiver(bluetoothScanModeChanged); + } + + BluetoothManager.stop(this); + + BonjourManager.stop(this); + LocalHTTPDManager.stop(this); + if (Build.VERSION.SDK_INT <= 28) { + if (wifiManager != null && !wasWifiEnabledBeforeSwap()) { + wifiManager.setWifiEnabled(false); + } + WifiApControl ap = WifiApControl.getInstance(this); + if (ap != null) { + try { + if (wasHotspotEnabledBeforeSwap()) { + ap.enable(); + } else { + ap.disable(); + } + } catch (Exception e) { + Log.e(TAG, "could not access/enable/disable WiFi AP", e); + } + } + } + + stopPollingConnectedSwapRepo(); + + if (timer != null) { + timer.cancel(); + } + ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE); + + super.onDestroy(); + } + + private Notification createNotification() { + final NotificationChannelCompat swapChannel = new NotificationChannelCompat.Builder(CHANNEL_SWAPS, + NotificationManagerCompat.IMPORTANCE_LOW) + .setName(getString(R.string.notification_channel_swaps_title)) + .setDescription(getString(R.string.notification_channel_swaps_description)) + .build(); + NotificationManagerCompat notificationManager = NotificationManagerCompat.from(this); + notificationManager.createNotificationChannel(swapChannel); + + Intent intent = new Intent(this, SwapWorkflowActivity.class); + intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP); + PendingIntent contentIntent = PendingIntent.getActivity(this, 0, intent, + PendingIntent.FLAG_CANCEL_CURRENT | PendingIntent.FLAG_IMMUTABLE); + return new NotificationCompat.Builder(this, CHANNEL_SWAPS) + .setContentTitle(getText(R.string.local_repo_running)) + .setContentText(getText(R.string.touch_to_configure_local_repo)) + .setSmallIcon(R.drawable.ic_nearby) + .setContentIntent(contentIntent) + .build(); + } + + private void startPollingConnectedSwapRepo() { + stopPollingConnectedSwapRepo(); + pollConnectedSwapRepoTimer = new Timer("pollConnectedSwapRepoTimer", true); + TimerTask timerTask = new TimerTask() { + @Override + public void run() { + if (peer != null) { + connectTo(peer); + } + } + }; + pollConnectedSwapRepoTimer.schedule(timerTask, 5000); + } + + public void stopPollingConnectedSwapRepo() { + if (pollConnectedSwapRepoTimer != null) { + pollConnectedSwapRepoTimer.cancel(); + pollConnectedSwapRepoTimer = null; + } + } + + /** + * Sets or resets the idle timer for {@link #TIMEOUT}ms, once the timer + * expires, this service and all things that rely on it will be stopped. + */ + public void initTimer() { + if (timer != null) { + Utils.debugLog(TAG, "Cancelling existing timeout timer so timeout can be reset."); + timer.cancel(); + } + + Utils.debugLog(TAG, "Initializing swap timeout to " + TIMEOUT + "ms minutes"); + timer = new Timer(TAG, true); + timer.schedule(new TimerTask() { + @Override + public void run() { + Utils.debugLog(TAG, "Disabling swap because " + TIMEOUT + "ms passed."); + String msg = getString(R.string.swap_toast_closing_nearby_after_timeout); + Utils.showToastFromService(SwapService.this, msg, android.widget.Toast.LENGTH_LONG); + stop(SwapService.this); + } + }, TIMEOUT); + } + + private void restartWiFiServices() { + executor.execute(() -> { + boolean hasIp = FDroidApp.ipAddressString != null; + if (hasIp) { + LocalHTTPDManager.restart(this); + BonjourManager.restart(this); + BonjourManager.setVisible(this, getWifiVisibleUserPreference() || getHotspotActivatedUserPreference()); + } else { + BonjourManager.stop(this); + LocalHTTPDManager.stop(this); + } + }); + } + + private final Preferences.ChangeListener httpsEnabledListener = this::restartWiFiServices; + + private final BroadcastReceiver onWifiChange = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent i) { + restartWiFiServices(); + } + }; + + /** + * Handle events if the user or system changes the Bluetooth setup outside of F-Droid. + */ + private final BroadcastReceiver bluetoothScanModeChanged = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + switch (intent.getIntExtra(BluetoothAdapter.EXTRA_SCAN_MODE, -1)) { + case BluetoothAdapter.SCAN_MODE_NONE: + BluetoothManager.stop(SwapService.this); + break; + + case BluetoothAdapter.SCAN_MODE_CONNECTABLE: + case BluetoothAdapter.SCAN_MODE_CONNECTABLE_DISCOVERABLE: + BluetoothManager.start(SwapService.this); + break; + } + } + }; + + private final BroadcastReceiver bluetoothPeerFound = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + activePeers.add((Peer) intent.getParcelableExtra(BluetoothManager.EXTRA_PEER)); + } + }; + + private final BroadcastReceiver bonjourPeerFound = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + activePeers.add((Peer) intent.getParcelableExtra(BonjourManager.EXTRA_BONJOUR_PEER)); + } + }; + + private final BroadcastReceiver bonjourPeerRemoved = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + activePeers.remove((Peer) intent.getParcelableExtra(BonjourManager.EXTRA_BONJOUR_PEER)); + } + }; +} diff --git a/app/src/full/java/org/fdroid/fdroid/nearby/SwapSuccessView.java b/app/src/full/java/org/fdroid/fdroid/nearby/SwapSuccessView.java new file mode 100644 index 000000000..d054e37fa --- /dev/null +++ b/app/src/full/java/org/fdroid/fdroid/nearby/SwapSuccessView.java @@ -0,0 +1,40 @@ +package org.fdroid.fdroid.nearby; + +import android.content.Context; +import android.util.AttributeSet; + +import androidx.compose.ui.platform.ComposeView; + +import org.fdroid.R; +import org.fdroid.ui.nearby.SwapSuccessBinder; + +/** + * This is a view that shows a listing of all apps in the swap repo that this + * just connected to. + */ +public class SwapSuccessView extends SwapView { + + public SwapSuccessView(Context context) { + super(context); + } + + public SwapSuccessView(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public SwapSuccessView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + public SwapSuccessView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + + ComposeView composeView = findViewById(R.id.compose); + SwapSuccessBinder.bind(composeView, getActivity().viewModel); + } +} diff --git a/app/src/full/java/org/fdroid/fdroid/nearby/SwapView.java b/app/src/full/java/org/fdroid/fdroid/nearby/SwapView.java new file mode 100644 index 000000000..827fc6c81 --- /dev/null +++ b/app/src/full/java/org/fdroid/fdroid/nearby/SwapView.java @@ -0,0 +1,86 @@ +package org.fdroid.fdroid.nearby; + +import android.content.Context; +import android.content.res.TypedArray; +import android.util.AttributeSet; +import android.widget.RelativeLayout; + +import androidx.annotation.ColorInt; +import androidx.annotation.LayoutRes; +import androidx.core.content.ContextCompat; + +import org.fdroid.R; + +/** + * A {@link android.view.View} that registers to handle the swap events from + * {@link SwapService}. + */ +public class SwapView extends RelativeLayout { + public static final String TAG = "SwapView"; + + @ColorInt + public final int toolbarColor; + public final String toolbarTitle; + + private int layoutResId = -1; + + protected String currentFilterString; + + public SwapView(Context context) { + this(context, null); + } + + public SwapView(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + /** + * In order to support Android < 21, this calls {@code super} rather than + * {@code this}. {@link RelativeLayout}'s methods just use a 0 for the + * fourth argument, just like this used to. + */ + public SwapView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + final TypedArray a = context.obtainStyledAttributes( + attrs, R.styleable.SwapView, 0, 0); + toolbarColor = a.getColor(R.styleable.SwapView_toolbarColor, + ContextCompat.getColor(context, R.color.swap_blue)); + toolbarTitle = a.getString(R.styleable.SwapView_toolbarTitle); + a.recycle(); + } + + public SwapView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + final TypedArray a = context.obtainStyledAttributes( + attrs, R.styleable.SwapView, 0, 0); + toolbarColor = a.getColor(R.styleable.SwapView_toolbarColor, + ContextCompat.getColor(context, R.color.swap_blue)); + toolbarTitle = a.getString(R.styleable.SwapView_toolbarTitle); + a.recycle(); + } + + @LayoutRes + public int getLayoutResId() { + return layoutResId; + } + + public void setLayoutResId(@LayoutRes int layoutResId) { + this.layoutResId = layoutResId; + } + + public String getCurrentFilterString() { + return this.currentFilterString; + } + + public void setCurrentFilterString(String currentFilterString) { + this.currentFilterString = currentFilterString; + } + + public SwapWorkflowActivity getActivity() { + return (SwapWorkflowActivity) getContext(); + } + + public String getToolbarTitle() { + return toolbarTitle; + } +} diff --git a/app/src/full/java/org/fdroid/fdroid/nearby/SwapWorkflowActivity.java b/app/src/full/java/org/fdroid/fdroid/nearby/SwapWorkflowActivity.java new file mode 100644 index 000000000..66d2eccac --- /dev/null +++ b/app/src/full/java/org/fdroid/fdroid/nearby/SwapWorkflowActivity.java @@ -0,0 +1,1457 @@ +package org.fdroid.fdroid.nearby; + +import static android.view.WindowManager.LayoutParams.FLAG_SECURE; +import static org.fdroid.fdroid.nearby.SwapService.ACTION_REQUEST_SWAP; + +import android.Manifest; +import android.bluetooth.BluetoothAdapter; +import android.content.BroadcastReceiver; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.ServiceConnection; +import android.content.pm.PackageManager; +import android.graphics.Bitmap; +import android.graphics.LightingColorFilter; +import android.net.Uri; +import android.net.wifi.WifiInfo; +import android.net.wifi.WifiManager; +import android.os.Build; +import android.os.Bundle; +import android.os.IBinder; +import android.provider.Settings; +import android.text.TextUtils; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; +import android.widget.Button; +import android.widget.ImageView; +import android.widget.ListView; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; +import androidx.annotation.LayoutRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.widget.SearchView; +import androidx.core.content.ContextCompat; +import androidx.lifecycle.ViewModelProvider; +import androidx.localbroadcastmanager.content.LocalBroadcastManager; + +import com.google.android.material.appbar.MaterialToolbar; +import com.google.android.material.button.MaterialButton; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import com.google.android.material.materialswitch.MaterialSwitch; +import com.google.android.material.progressindicator.CircularProgressIndicator; +import com.google.zxing.integration.android.IntentIntegrator; +import com.google.zxing.integration.android.IntentResult; + +import org.fdroid.BuildConfig; +import org.fdroid.LegacyUtils; +import org.fdroid.R; +import org.fdroid.fdroid.FDroidApp; +import org.fdroid.fdroid.Preferences; +import org.fdroid.fdroid.Utils; +import org.fdroid.fdroid.nearby.peers.BluetoothPeer; +import org.fdroid.fdroid.nearby.peers.Peer; +import org.fdroid.fdroid.qr.CameraCharacteristicsChecker; +import org.fdroid.settings.SettingsManager; +import org.fdroid.ui.nearby.SwapSuccessViewModel; + +import java.util.Date; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.Stack; +import java.util.Timer; +import java.util.TimerTask; + +import javax.inject.Inject; + +import cc.mvdan.accesspoint.WifiApControl; +import dagger.hilt.android.AndroidEntryPoint; + +/** + * This is the core of the UI for the whole nearby swap experience. Each + * screen is implemented as a {@link View} with the related logic in this + * {@link android.app.Activity}. Long lived pieces work in {@link SwapService}. + * All these pieces of the UX are tracked here: + *

      + *
    • which WiFi network to use
    • + *
    • whether to advertise via Bluetooth or WiFi+Bonjour
    • + *
    • connect to another device's swap
    • + *
    • choose which apps to share
    • + *
    • ask if the other device would like to swap with us
    • + *
    • help connect via QR Code or NFC
    • + *
    + *

    + * There are lots of async events in this system, and the user can also change + * the views while things are working. The {@link ViewGroup} + * {@link SwapWorkflowActivity#container} can have all its widgets removed and + * replaced by a new view at any point. Therefore, any widget config that is + * based on fetching it from {@code container} must check that the result is + * not null before trying to config it. + * + * @see + */ +@SuppressWarnings("LineLength") +@AndroidEntryPoint +public class SwapWorkflowActivity extends AppCompatActivity { + private static final String TAG = "SwapWorkflowActivity"; + + /** + * When connecting to a swap, we then go and initiate a connection with that + * device and ask if it would like to swap with us. Upon receiving that request + * and agreeing, we don't then want to be asked whether we want to swap back. + * This flag protects against two devices continually going back and forth + * among each other offering swaps. + */ + public static final String EXTRA_PREVENT_FURTHER_SWAP_REQUESTS = "preventFurtherSwap"; + + private ViewGroup container; + SwapSuccessViewModel viewModel; + + private static final int REQUEST_BLUETOOTH_ENABLE_FOR_SWAP = 2; + private static final int REQUEST_BLUETOOTH_DISCOVERABLE = 3; + private static final int REQUEST_BLUETOOTH_ENABLE_FOR_SEND = 4; + private static final int REQUEST_WRITE_SETTINGS_PERMISSION = 5; + + @Inject + SettingsManager settingsManager; + + private MaterialToolbar toolbar; + private SwapView currentView; + private boolean hasPreparedLocalRepo; + private boolean newIntent; + private NewRepoConfig confirmSwapConfig; + private LocalBroadcastManager localBroadcastManager; + private WifiManager wifiManager; + private WifiApControl wifiApControl; + private BluetoothAdapter bluetoothAdapter; + + @LayoutRes + private int currentSwapViewLayoutRes = R.layout.swap_start_swap; + private final Stack backstack = new Stack<>(); + + private final ActivityResultLauncher requestPermissionLauncher = + registerForActivityResult(new ActivityResultContracts.RequestPermission(), isGranted -> { + if (isGranted) sendFDroidBluetooth(); + }); + + public static void requestSwap(Context context, String repo) { + requestSwap(context, Uri.parse(repo)); + } + + public static void requestSwap(Context context, Uri uri) { + Intent intent = new Intent(ACTION_REQUEST_SWAP, uri, context, SwapWorkflowActivity.class); + intent.putExtra(EXTRA_PREVENT_FURTHER_SWAP_REQUESTS, true); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + context.startActivity(intent); + } + + @NonNull + private final ServiceConnection serviceConnection = new ServiceConnection() { + @Override + public void onServiceConnected(ComponentName className, IBinder binder) { + service = ((SwapService.Binder) binder).getService(); + service.getIndex().observe(SwapWorkflowActivity.this, index -> + onRepoUpdateSuccess()); + service.getIndexError().observe(SwapWorkflowActivity.this, e -> + onRepoUpdateError(e)); + viewModel.onServiceConnected(service); + showRelevantView(); + } + + @Override + public void onServiceDisconnected(ComponentName className) { + finish(); + service.getIndex().removeObservers(SwapWorkflowActivity.this); + service.getIndexError().removeObservers(SwapWorkflowActivity.this); + viewModel.onServiceDisconnected(service); + service = null; + } + }; + + @Nullable + private SwapService service; + + @NonNull + public SwapService getSwapService() { + return service; + } + + /** + * Handle the back logic for the system back button. + * + * @see #inflateSwapView(int, boolean) + */ + @Override + public void onBackPressed() { + if (backstack.isEmpty()) { + super.onBackPressed(); + } else { + int resId = backstack.pop(); + inflateSwapView(resId, true); + } + } + + /** + * Handle the back logic for the upper left back button in the toolbar. + * This has a simpler, hard-coded back logic than the system back button. + * + * @see #onBackPressed() + */ + public void onToolbarBackPressed() { + int nextStep = R.layout.swap_start_swap; + if (currentView.getLayoutResId() == R.layout.swap_confirm_receive) { + nextStep = backstack.peek(); + } else if (currentView.getLayoutResId() == R.layout.swap_connecting) { + nextStep = R.layout.swap_select_apps; + } else if (currentView.getLayoutResId() == R.layout.swap_join_wifi) { + nextStep = R.layout.swap_start_swap; + } else if (currentView.getLayoutResId() == R.layout.swap_select_apps) { + if (!backstack.isEmpty() && backstack.peek() == R.layout.swap_start_swap) { + nextStep = R.layout.swap_start_swap; + } else if (getSwapService() != null && getSwapService().isConnectingWithPeer()) { + nextStep = R.layout.swap_success; + } else { + nextStep = R.layout.swap_join_wifi; + } + } else if (currentView.getLayoutResId() == R.layout.swap_send_fdroid) { + nextStep = R.layout.swap_start_swap; + } else if (currentView.getLayoutResId() == R.layout.swap_start_swap) { + if (getSwapService() != null && getSwapService().isConnectingWithPeer()) { + nextStep = R.layout.swap_success; + } else { + SwapService.stop(this); + finish(); + return; + } + } else if (currentView.getLayoutResId() == R.layout.swap_success) { + nextStep = R.layout.swap_start_swap; + } else if (currentView.getLayoutResId() == R.layout.swap_wifi_qr) { + if (!backstack.isEmpty() && backstack.peek() == R.layout.swap_start_swap) { + nextStep = R.layout.swap_start_swap; + } else { + nextStep = R.layout.swap_join_wifi; + } + } + currentSwapViewLayoutRes = nextStep; + inflateSwapView(currentSwapViewLayoutRes); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + LegacyUtils.collectInJava(settingsManager.getPreventScreenshotsFlow(), preventScreenshots -> { + if (preventScreenshots) { + getWindow().addFlags(FLAG_SECURE); + } else { + getWindow().clearFlags(FLAG_SECURE); + } + return null; + }); + viewModel = new ViewModelProvider(this).get(SwapSuccessViewModel.class); + + currentView = new SwapView(this); // dummy placeholder to avoid NullPointerExceptions; + + if (!bindService(new Intent(this, SwapService.class), serviceConnection, + BIND_ABOVE_CLIENT | BIND_IMPORTANT)) { + Toast.makeText(this, "ERROR: cannot bind to SwapService!", Toast.LENGTH_LONG).show(); + finish(); + } + + setContentView(R.layout.swap_activity); + + toolbar = findViewById(R.id.toolbar); + setSupportActionBar(toolbar); + + container = (ViewGroup) findViewById(R.id.container); + + backstack.clear(); + + localBroadcastManager = LocalBroadcastManager.getInstance(this); + + wifiManager = ContextCompat.getSystemService(getApplicationContext(), WifiManager.class); + wifiApControl = WifiApControl.getInstance(this); + + bluetoothAdapter = BluetoothAdapter.getDefaultAdapter(); + + new SwapDebug().logStatus(); + } + + @Override + protected void onDestroy() { + unbindService(serviceConnection); + super.onDestroy(); + } + + @Override + public boolean onPrepareOptionsMenu(Menu menu) { + menu.clear(); + + MenuInflater menuInflater = getMenuInflater(); + if (currentView.getLayoutResId() == R.layout.swap_select_apps) { + menuInflater.inflate(R.menu.swap_next_search, menu); + if (getSwapService().isConnectingWithPeer()) { + setUpNextButton(menu, R.string.next, R.drawable.ic_nearby); + } else { + setUpNextButton(menu, R.string.next, null); + } + setUpSearchView(menu); + return true; + } else if (currentView.getLayoutResId() == R.layout.swap_success) { + menuInflater.inflate(R.menu.swap_search, menu); + setUpSearchView(menu); + return true; + } else if (currentView.getLayoutResId() == R.layout.swap_join_wifi) { + menuInflater.inflate(R.menu.swap_next, menu); + setUpNextButton(menu, R.string.next, R.drawable.ic_arrow_forward); + return true; + } + + return super.onPrepareOptionsMenu(menu); + } + + private void setUpNextButton(Menu menu, @StringRes int titleResId, Integer drawableResId) { + MenuItem next = menu.findItem(R.id.action_next); + CharSequence title = getString(titleResId); + next.setTitle(title); + next.setTitleCondensed(title); + if (drawableResId == null) { + next.setVisible(false); + } else { + next.setVisible(true); + next.setIcon(drawableResId); + } + next.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS | MenuItem.SHOW_AS_ACTION_WITH_TEXT); + next.setOnMenuItemClickListener(item -> { + sendNext(); + return true; + }); + } + + private void sendNext() { + int currentLayoutResId = currentView.getLayoutResId(); + if (currentLayoutResId == R.layout.swap_select_apps) { + onAppsSelected(); + } else if (currentLayoutResId == R.layout.swap_join_wifi) { + inflateSwapView(R.layout.swap_select_apps); + } + } + + private void setUpSearchView(Menu menu) { + MenuItem appsMenuItem = menu.findItem(R.id.action_apps); + if (appsMenuItem != null) { + appsMenuItem.setOnMenuItemClickListener(item -> { + inflateSwapView(R.layout.swap_select_apps); + return true; + }); + } + + SearchView searchView = new SearchView(this); + MenuItem searchMenuItem = menu.findItem(R.id.action_search); + searchMenuItem.setActionView(searchView); + searchMenuItem.setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM); + + searchView.setOnQueryTextListener(new SearchView.OnQueryTextListener() { + + @Override + public boolean onQueryTextSubmit(String newText) { + String currentFilterString = currentView.getCurrentFilterString(); + String newFilter = !TextUtils.isEmpty(newText) ? newText : null; + if (currentFilterString == null && newFilter == null) { + return true; + } + if (currentFilterString != null && currentFilterString.equals(newFilter)) { + return true; + } + currentView.setCurrentFilterString(newFilter); + return true; + } + + @Override + public boolean onQueryTextChange(String s) { + return true; + } + }); + } + + @Override + protected void onResume() { + super.onResume(); + + localBroadcastManager.registerReceiver(onWifiStateChanged, + new IntentFilter(WifiStateChangeService.BROADCAST)); + localBroadcastManager.registerReceiver(localRepoStatus, new IntentFilter(LocalRepoService.ACTION_STATUS)); + localBroadcastManager.registerReceiver(bonjourFound, new IntentFilter(BonjourManager.ACTION_FOUND)); + localBroadcastManager.registerReceiver(bonjourRemoved, new IntentFilter(BonjourManager.ACTION_REMOVED)); + localBroadcastManager.registerReceiver(bonjourStatusReceiver, new IntentFilter(BonjourManager.ACTION_STATUS)); + localBroadcastManager.registerReceiver(bluetoothFound, new IntentFilter(BluetoothManager.ACTION_FOUND)); + localBroadcastManager.registerReceiver(bluetoothStatusReceiver, new IntentFilter(BluetoothManager.ACTION_STATUS)); + + registerReceiver(bluetoothScanModeChanged, + new IntentFilter(BluetoothAdapter.ACTION_SCAN_MODE_CHANGED)); + + checkIncomingIntent(); + + // could be we just started the service and it wasn't bound, yet + if (service != null && newIntent) { + showRelevantView(); + newIntent = false; + } + + if (currentSwapViewLayoutRes == R.layout.swap_start_swap) { + updateWifiBannerVisibility(); + } + } + + @Override + protected void onPause() { + super.onPause(); + + unregisterReceiver(bluetoothScanModeChanged); + + localBroadcastManager.unregisterReceiver(onWifiStateChanged); + localBroadcastManager.unregisterReceiver(localRepoStatus); + localBroadcastManager.unregisterReceiver(bonjourFound); + localBroadcastManager.unregisterReceiver(bonjourRemoved); + localBroadcastManager.unregisterReceiver(bonjourStatusReceiver); + localBroadcastManager.unregisterReceiver(bluetoothFound); + localBroadcastManager.unregisterReceiver(bluetoothStatusReceiver); + } + + @Override + protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); + setIntent(intent); + newIntent = true; + } + + /** + * Check whether incoming {@link Intent} is a swap repo, and ensure that + * it is a valid swap URL. The hostname can only be either an IP or + * Bluetooth address. + */ + private void checkIncomingIntent() { + Intent intent = getIntent(); + if (!ACTION_REQUEST_SWAP.equals(intent.getAction())) { + return; + } + Uri uri = intent.getData(); + if (uri != null && !isSwapUrl(uri)) { + String msg = getString(R.string.swap_toast_invalid_url, uri); + Toast.makeText(this, msg, Toast.LENGTH_LONG).show(); + return; + } + confirmSwapConfig = new NewRepoConfig(this, intent); + checkIfNewRepoOnSameWifi(confirmSwapConfig); + } + + private static boolean isSwapUrl(Uri uri) { + return isSwapUrl(uri.getHost(), uri.getPort()); + } + + private static boolean isSwapUrl(String host, int port) { + return port > 1023 // only root can use <= 1023, so never a swap repo + && host.matches("[0-9.]+") // host must be an IP address + && FDroidApp.subnetInfo.isInRange(host); // on the same subnet as we are + } + + private void promptToSelectWifiNetwork() { + new MaterialAlertDialogBuilder(this) + .setTitle(R.string.swap_join_same_wifi) + .setMessage(R.string.swap_join_same_wifi_desc) + .setNeutralButton(R.string.cancel, (dialog, which) -> { + // Do nothing + }) + .setPositiveButton(R.string.wifi, (dialog, which) -> { + SwapService.putWifiEnabledBeforeSwap(wifiManager.isWifiEnabled()); + if (Build.VERSION.SDK_INT <= 28) { + wifiManager.setWifiEnabled(true); + } + Intent intent = new Intent(WifiManager.ACTION_PICK_WIFI_NETWORK); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + startActivity(intent); + }) + .setNegativeButton(R.string.wifi_ap, (dialog, which) -> { + if (Build.VERSION.SDK_INT >= 26) { + showTetheringSettings(); + } else if (!Settings.System.canWrite(getBaseContext())) { + requestWriteSettingsPermission(); + } else { + setupWifiAP(); + } + }) + .create().show(); + } + + private void setupWifiAP() { + if (wifiApControl == null) { + Log.e(TAG, "WiFi AP is null"); + Toast.makeText(this, R.string.swap_toast_could_not_enable_hotspot, Toast.LENGTH_LONG).show(); + return; + } + SwapService.putHotspotEnabledBeforeSwap(wifiApControl.isEnabled()); + if (Build.VERSION.SDK_INT <= 28) { + wifiManager.setWifiEnabled(false); + } + boolean wifiEnabled = false; + try { + wifiEnabled = wifiApControl.enable(); + } catch (Exception e) { + Log.e(TAG, "Error enabling WiFi: ", e); + } + if (wifiEnabled) { + Toast.makeText(this, R.string.swap_toast_hotspot_enabled, Toast.LENGTH_SHORT).show(); + SwapService.putHotspotActivatedUserPreference(true); + } else { + Toast.makeText(this, R.string.swap_toast_could_not_enable_hotspot, Toast.LENGTH_LONG).show(); + SwapService.putHotspotActivatedUserPreference(false); + Log.e(TAG, "Could not enable WiFi AP."); + } + } + + /** + * Handle events that trigger different swap views to be shown. + */ + private void showRelevantView() { + + if (confirmSwapConfig != null) { + inflateSwapView(R.layout.swap_confirm_receive); + setUpConfirmReceive(); + confirmSwapConfig = null; + return; + } + + if (currentSwapViewLayoutRes == R.layout.swap_start_swap) { + showIntro(); + return; + } else if (currentSwapViewLayoutRes == R.layout.swap_connecting) { + // TODO: Properly decide what to do here (i.e. returning to the activity after it was connecting)... + inflateSwapView(R.layout.swap_start_swap); + return; + } + inflateSwapView(currentSwapViewLayoutRes); + } + + public void inflateSwapView(@LayoutRes int viewRes) { + inflateSwapView(viewRes, false); + + if (viewRes == R.layout.swap_start_swap) { + updateWifiBannerVisibility(); + } + } + + private void updateWifiBannerVisibility() { + final View wifiBanner = findViewById(R.id.wifi_banner); + if (wifiBanner != null) { + if (Build.VERSION.SDK_INT >= 29 && wifiManager != null && !wifiManager.isWifiEnabled()) { + Button turnOnWifi = findViewById(R.id.turn_on_wifi); + if (turnOnWifi != null) { + turnOnWifi.setOnClickListener(view -> { + wifiBanner.setVisibility(View.GONE); + startActivity(new Intent(Settings.Panel.ACTION_WIFI)); + }); + } + wifiBanner.setVisibility(View.VISIBLE); + } else { + wifiBanner.setVisibility(View.GONE); + } + } + } + + /** + * The {@link #backstack} for the global back button is managed mostly here. + * The initial screen is never added to the {@code backstack} since the + * empty state is used to detect that the system's backstack should be used. + */ + public void inflateSwapView(@LayoutRes int viewRes, boolean backPressed) { + getSwapService().initTimer(); + + if (!backPressed) { + if (currentSwapViewLayoutRes == R.layout.swap_connecting || + currentSwapViewLayoutRes == R.layout.swap_confirm_receive) { + // do not add to backstack + } else { + if (backstack.isEmpty()) { + if (viewRes != R.layout.swap_start_swap) { + backstack.push(currentSwapViewLayoutRes); + } + } else { + if (backstack.peek() != currentSwapViewLayoutRes) { + backstack.push(currentSwapViewLayoutRes); + } + } + } + } + + container.removeAllViews(); + View view = ContextCompat.getSystemService(this, LayoutInflater.class) + .inflate(viewRes, container, false); + currentView = (SwapView) view; + currentView.setLayoutResId(viewRes); + currentSwapViewLayoutRes = viewRes; + + toolbar.setTitle(currentView.getToolbarTitle()); + toolbar.setNavigationOnClickListener(v -> onToolbarBackPressed()); + toolbar.setNavigationOnClickListener(v -> { + if (currentView.getLayoutResId() == R.layout.swap_start_swap) { + SwapService.stop(this); + finish(); + return; + } else { + currentSwapViewLayoutRes = R.layout.swap_start_swap; + } + inflateSwapView(currentSwapViewLayoutRes); + }); + if (viewRes == R.layout.swap_start_swap) { + toolbar.setNavigationIcon(R.drawable.ic_close); + } else { + toolbar.setNavigationIcon(R.drawable.ic_arrow_back); + } + container.addView(view); + supportInvalidateOptionsMenu(); + + if (currentView.getLayoutResId() == R.layout.swap_send_fdroid) { + setUpFromWifi(); + setUpUseBluetoothButton(); + } else if (currentView.getLayoutResId() == R.layout.swap_wifi_qr) { + setUpFromWifi(); + setUpQrScannerButton(); + } else if (currentView.getLayoutResId() == R.layout.swap_select_apps) { + LocalRepoService.create(this, getSwapService().getAppsToSwap()); + } else if (currentView.getLayoutResId() == R.layout.swap_connecting) { + setUpConnectingView(); + } else if (currentView.getLayoutResId() == R.layout.swap_start_swap) { + setUpStartVisibility(); + } + } + + public void showIntro() { + // If we were previously swapping with a specific client, forget that we were doing that, + // as we are starting over now. + getSwapService().swapWith(null); + + LocalRepoService.create(this); + + inflateSwapView(R.layout.swap_start_swap); + } + + /** + * On {@code android-26}, only apps with privileges can access + * {@code WRITE_SETTINGS}. So this just shows the tethering settings + * for the user to do it themselves. + */ + public void showTetheringSettings() { + final Intent intent = new Intent(Intent.ACTION_MAIN, null); + intent.addCategory(Intent.CATEGORY_LAUNCHER); + final ComponentName cn = new ComponentName("com.android.settings", + "com.android.settings.TetherSettings"); + intent.setComponent(cn); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + startActivity(intent); + } + + public void requestWriteSettingsPermission() { + Intent intent = new Intent(Settings.ACTION_MANAGE_WRITE_SETTINGS, + Uri.parse("package:" + getPackageName())); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + startActivityForResult(intent, REQUEST_WRITE_SETTINGS_PERMISSION); + } + + public void sendFDroid() { + if (bluetoothAdapter == null // TODO make Bluetooth work with content:// URIs + || (!bluetoothAdapter.isEnabled() && LocalHTTPDManager.isAlive())) { + inflateSwapView(R.layout.swap_send_fdroid); + } else { + sendFDroidBluetooth(); + } + } + + /** + * Send the F-Droid APK via Bluetooth. If Bluetooth has not been + * enabled/turned on, then enabling device discoverability will + * automatically enable Bluetooth. + */ + public void sendFDroidBluetooth() { + if (ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED) { + Intent discoverBt = new Intent(BluetoothAdapter.ACTION_REQUEST_DISCOVERABLE); + discoverBt.putExtra(BluetoothAdapter.EXTRA_DISCOVERABLE_DURATION, 120); + startActivityForResult(discoverBt, REQUEST_BLUETOOTH_ENABLE_FOR_SEND); + } else { + requestPermissionLauncher.launch(Manifest.permission.BLUETOOTH_CONNECT); + } + } + + /** + * TODO: Figure out whether they have changed since last time LocalRepoService + * was run. If the local repo is running, then we can ask it what apps it is + * swapping and compare with that. Otherwise, probably will need to scan the + * file system. + */ + public void onAppsSelected() { + if (hasPreparedLocalRepo) { + onLocalRepoPrepared(); + } else { + LocalRepoService.create(this, getSwapService().getAppsToSwap()); + currentSwapViewLayoutRes = R.layout.swap_connecting; + inflateSwapView(R.layout.swap_connecting); + } + } + + /** + * Once the LocalRepoService has finished preparing our repository index, we can + * show the next screen to the user. This will be one of two things: + *

      + *
    1. If we directly selected a peer to swap with initially, we will skip straight to getting + * the list of apps from that device.
    2. + *
    3. Alternatively, if we didn't have a person to connect to, and instead clicked "Scan QR Code", + * then we want to show a QR code or NFC dialog.
    4. + *
    + */ + public void onLocalRepoPrepared() { + // TODO ditch this, use a message from LocalRepoService. Maybe? + hasPreparedLocalRepo = true; + if (getSwapService().isConnectingWithPeer()) { + startSwappingWithPeer(); + } else { + inflateSwapView(R.layout.swap_wifi_qr); + } + } + + private void startSwappingWithPeer() { + getSwapService().connectToPeer(); + inflateSwapView(R.layout.swap_connecting); + } + + public void swapWith(Peer peer) { + getSwapService().swapWith(peer); + inflateSwapView(R.layout.swap_select_apps); + } + + /** + * This is for when we initiate a swap by viewing the "Are you sure you want to swap with" view + * This can arise either: + * * As a result of scanning a QR code (in which case we likely already have a repo setup) or + * * As a result of the other device selecting our device in the "start swap" screen, in which + * case we are likely just sitting on the start swap screen also, and haven't configured + * anything yet. + */ + public void swapWith(NewRepoConfig repoConfig) { + Peer peer = repoConfig.toPeer(); + if (currentSwapViewLayoutRes == R.layout.swap_start_swap + || currentSwapViewLayoutRes == R.layout.swap_confirm_receive) { + // This will force the "Select apps to swap" workflow to begin. + swapWith(peer); + } else { + getSwapService().swapWith(peer); + startSwappingWithPeer(); + } + } + + public void denySwap() { + showIntro(); + } + + /** + * Attempts to open a QR code scanner, in the hope a user will then scan the QR code of another + * device configured to swapp apps with us. Delegates to the zxing library to do so. + */ + public void initiateQrScan() { + IntentIntegrator integrator = new IntentIntegrator(this); + integrator.initiateScan(); + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent intent) { + super.onActivityResult(requestCode, resultCode, intent); + IntentResult scanResult = IntentIntegrator.parseActivityResult(requestCode, resultCode, intent); + if (scanResult != null) { + if (scanResult.getContents() != null) { + NewRepoConfig repoConfig = new NewRepoConfig(this, scanResult.getContents()); + if (repoConfig.isValidRepo()) { + checkIfNewRepoOnSameWifi(repoConfig); + confirmSwapConfig = repoConfig; + showRelevantView(); + } else { + Toast.makeText(this, R.string.swap_qr_isnt_for_swap, Toast.LENGTH_SHORT).show(); + } + } + } else if (requestCode == REQUEST_WRITE_SETTINGS_PERMISSION) { + if (Settings.System.canWrite(this)) { + setupWifiAP(); + } + } else if (requestCode == REQUEST_BLUETOOTH_ENABLE_FOR_SWAP) { + + if (resultCode == RESULT_OK) { + Utils.debugLog(TAG, "User enabled Bluetooth, will make sure we are discoverable."); + ensureBluetoothDiscoverableThenStart(); + } else { + Utils.debugLog(TAG, "User chose not to enable Bluetooth, so doing nothing"); + SwapService.putBluetoothVisibleUserPreference(false); + } + + } else if (requestCode == REQUEST_BLUETOOTH_DISCOVERABLE) { + + if (resultCode != RESULT_CANCELED) { + Utils.debugLog(TAG, "User made Bluetooth discoverable, will proceed to start bluetooth server."); + BluetoothManager.start(this); + } else { + Utils.debugLog(TAG, "User chose not to make Bluetooth discoverable, so doing nothing"); + SwapService.putBluetoothVisibleUserPreference(false); + } + + } + } + + private void checkIfNewRepoOnSameWifi(NewRepoConfig newRepo) { + // if this is a local repo, check we're on the same wifi + if (!TextUtils.isEmpty(newRepo.getBssid())) { + WifiManager wifiManager = ContextCompat.getSystemService(getApplicationContext(), + WifiManager.class); + WifiInfo wifiInfo = wifiManager.getConnectionInfo(); + String bssid = wifiInfo.getBSSID(); + if (TextUtils.isEmpty(bssid)) { /* not all devices have wifi */ + return; + } + bssid = bssid.toLowerCase(Locale.ENGLISH); + String newRepoBssid = Uri.decode(newRepo.getBssid()).toLowerCase(Locale.ENGLISH); + if (!bssid.equals(newRepoBssid)) { + String msg = getString(R.string.not_on_same_wifi, newRepo.getSsid()); + Toast.makeText(this, msg, Toast.LENGTH_LONG).show(); + } + // TODO we should help the user to the right thing here, + // instead of just showing a message! + } + } + + /** + * The process for setting up bluetooth is as follows: + *
      + *
    • Assume we have bluetooth available (otherwise the button which allowed us to start + * the bluetooth process should not have been available)
    • + *
    • Ask user to enable (if not enabled yet)
    • + *
    • Start bluetooth server socket
    • + *
    • Enable bluetooth discoverability, so that people can connect to our server socket.
    • + *
    + * Note that this is a little different than the usual process for bluetooth _clients_, which + * involves pairing and connecting with other devices. + */ + public void startBluetoothSwap() { + if (bluetoothAdapter != null) { + if (bluetoothAdapter.isEnabled()) { + Utils.debugLog(TAG, "Bluetooth enabled, will check if device is discoverable with device."); + ensureBluetoothDiscoverableThenStart(); + } else { + Utils.debugLog(TAG, "Bluetooth disabled, asking user to enable it."); + Intent enableBtIntent = new Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE); + startActivityForResult(enableBtIntent, REQUEST_BLUETOOTH_ENABLE_FOR_SWAP); + } + } + } + + private void ensureBluetoothDiscoverableThenStart() { + Utils.debugLog(TAG, "Ensuring Bluetooth is in discoverable mode."); + if (bluetoothAdapter.getScanMode() != BluetoothAdapter.SCAN_MODE_CONNECTABLE_DISCOVERABLE) { + Utils.debugLog(TAG, "Not currently in discoverable mode, so prompting user to enable."); + Intent intent = new Intent(BluetoothAdapter.ACTION_REQUEST_DISCOVERABLE); + intent.putExtra(BluetoothAdapter.EXTRA_DISCOVERABLE_DURATION, 3600); // 1 hour + startActivityForResult(intent, REQUEST_BLUETOOTH_DISCOVERABLE); + } + BluetoothManager.start(this); + } + + private final BroadcastReceiver bluetoothScanModeChanged = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + MaterialSwitch bluetoothSwitch = container.findViewById(R.id.switch_bluetooth); + TextView textBluetoothVisible = container.findViewById(R.id.bluetooth_visible); + if (bluetoothSwitch == null || textBluetoothVisible == null + || !BluetoothManager.ACTION_STATUS.equals(intent.getAction())) { + return; + } + switch (intent.getIntExtra(BluetoothAdapter.EXTRA_SCAN_MODE, -1)) { + case BluetoothAdapter.SCAN_MODE_NONE: + textBluetoothVisible.setText(R.string.disabled); + bluetoothSwitch.setEnabled(true); + break; + + case BluetoothAdapter.SCAN_MODE_CONNECTABLE_DISCOVERABLE: + textBluetoothVisible.setText(R.string.swap_visible_bluetooth); + bluetoothSwitch.setEnabled(true); + break; + + case BluetoothAdapter.SCAN_MODE_CONNECTABLE: + textBluetoothVisible.setText(R.string.swap_not_visible_bluetooth); + bluetoothSwitch.setEnabled(true); + break; + } + } + }; + + /** + * Helper class to try and make sense of what the swap workflow is currently doing. + * The more technologies are involved in the process (e.g. Bluetooth/Wifi/NFC/etc) + * the harder it becomes to reason about and debug the whole thing. Thus,this class + * will periodically dump the state to logcat so that it is easier to see when certain + * protocols are enabled/disabled. + *

    + * To view only this output from logcat: + *

    + * adb logcat | grep 'Swap Status' + *

    + * To exclude this output from logcat (it is very noisy): + *

    + * adb logcat | grep -v 'Swap Status' + */ + class SwapDebug { + + public void logStatus() { + + if (true) return; // NOPMD + + String message = ""; + if (service == null) { + message = "No swap service"; + } else { + String bluetooth; + + bluetooth = "N/A"; + if (bluetoothAdapter != null) { + Map scanModes = new HashMap<>(3); + scanModes.put(BluetoothAdapter.SCAN_MODE_CONNECTABLE, "CON"); + scanModes.put(BluetoothAdapter.SCAN_MODE_CONNECTABLE_DISCOVERABLE, "CON_DISC"); + scanModes.put(BluetoothAdapter.SCAN_MODE_NONE, "NONE"); + bluetooth = "\"" + bluetoothAdapter.getName() + "\" - " + + scanModes.get(bluetoothAdapter.getScanMode()); + } + } + + Date now = new Date(); + Utils.debugLog("SWAP_STATUS", + now.getHours() + ":" + now.getMinutes() + ":" + now.getSeconds() + " " + message); + + new Timer().schedule(new TimerTask() { + @Override + public void run() { + new SwapDebug().logStatus(); + } + }, 1000 + ); + } + } + + private final BroadcastReceiver onWifiStateChanged = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + setUpFromWifi(); + + TextView textWifiVisible = container.findViewById(R.id.wifi_visible); + if (textWifiVisible == null) { + return; + } + switch (intent.getIntExtra(WifiStateChangeService.EXTRA_STATUS, -1)) { + case WifiManager.WIFI_STATE_ENABLING: + textWifiVisible.setText(R.string.swap_setting_up_wifi); + break; + case WifiManager.WIFI_STATE_ENABLED: + textWifiVisible.setText(R.string.swap_not_visible_wifi); + break; + case WifiManager.WIFI_STATE_DISABLING: + case WifiManager.WIFI_STATE_DISABLED: + textWifiVisible.setText(R.string.swap_stopping_wifi); + break; + case WifiManager.WIFI_STATE_UNKNOWN: + break; + } + + updateWifiBannerVisibility(); + } + }; + + private void setUpFromWifi() { + String scheme = Preferences.get().isLocalRepoHttpsEnabled() ? "https://" : "http://"; + + // the fingerprint is not useful on the button label + String buttonLabel = scheme + FDroidApp.ipAddressString + ":" + FDroidApp.port; + TextView ipAddressView = container.findViewById(R.id.device_ip_address); + if (ipAddressView != null) { + ipAddressView.setText(buttonLabel); + } + + String qrUriString = null; + if (currentView.getLayoutResId() == R.layout.swap_join_wifi) { + setUpJoinWifi(); + return; + } else if (currentView.getLayoutResId() == R.layout.swap_send_fdroid) { + qrUriString = buttonLabel; + } else if (currentView.getLayoutResId() == R.layout.swap_wifi_qr) { + Uri sharingUri = Utils.getSharingUri(FDroidApp.repo); + StringBuilder qrUrlBuilder = new StringBuilder(scheme); + qrUrlBuilder.append(sharingUri.getHost()); + if (sharingUri.getPort() != 80) { + qrUrlBuilder.append(':'); + qrUrlBuilder.append(sharingUri.getPort()); + } + qrUrlBuilder.append(sharingUri.getPath()); + boolean first = true; + + Set names = sharingUri.getQueryParameterNames(); + for (String name : names) { + if (!"ssid".equals(name)) { + if (first) { + qrUrlBuilder.append('?'); + first = false; + } else { + qrUrlBuilder.append('&'); + } + qrUrlBuilder.append(name); + qrUrlBuilder.append('='); + qrUrlBuilder.append(sharingUri.getQueryParameter(name)); + } + } + qrUriString = qrUrlBuilder.toString(); + } + + ImageView qrImage = container.findViewById(R.id.wifi_qr_code); + if (qrUriString != null && qrImage != null) { + Utils.debugLog(TAG, "Encoded swap URI in QR Code: " + qrUriString); + Bitmap qrBitmap = Utils.generateQrBitmap(this, qrUriString); + qrImage.setImageBitmap(qrBitmap); + + // Replace all blacks with the background blue. + qrImage.setColorFilter(new LightingColorFilter(0xffffffff, + ContextCompat.getColor(this, R.color.swap_blue))); + + final View qrWarningMessage = container.findViewById(R.id.warning_qr_scanner); + if (qrWarningMessage != null) { + if (CameraCharacteristicsChecker.getInstance(this).hasAutofocus()) { + qrWarningMessage.setVisibility(View.GONE); + } else { + qrWarningMessage.setVisibility(View.VISIBLE); + } + } + } + } + + // TODO: Listen for "Connecting..." state and reflect that in the view too. + private void setUpJoinWifi() { + currentView.setOnClickListener(v -> startActivity(new Intent(WifiManager.ACTION_PICK_WIFI_NETWORK))); + TextView descriptionView = container.findViewById(R.id.text_description); + ImageView wifiIcon = container.findViewById(R.id.wifi_icon); + TextView ssidView = container.findViewById(R.id.wifi_ssid); + TextView tapView = container.findViewById(R.id.wifi_available_networks_prompt); + if (descriptionView == null || wifiIcon == null || ssidView == null || tapView == null) { + return; + } + if (TextUtils.isEmpty(FDroidApp.bssid) && !TextUtils.isEmpty(FDroidApp.ipAddressString)) { + // empty bssid with an ipAddress means hotspot mode + descriptionView.setText(R.string.swap_join_this_hotspot); + wifiIcon.setImageDrawable(ContextCompat.getDrawable(this, R.drawable.ic_wifi_tethering)); + ssidView.setText(R.string.swap_active_hotspot); + tapView.setText(R.string.swap_switch_to_wifi); + } else if (TextUtils.isEmpty(FDroidApp.ssid)) { + // not connected to or setup with any wifi network + descriptionView.setText(R.string.swap_join_same_wifi); + wifiIcon.setImageDrawable(ContextCompat.getDrawable(this, R.drawable.ic_wifi)); + ssidView.setText(R.string.swap_no_wifi_network); + tapView.setText(R.string.swap_view_available_networks); + } else { + // connected to a regular wifi network + descriptionView.setText(R.string.swap_join_same_wifi); + wifiIcon.setImageDrawable(ContextCompat.getDrawable(this, R.drawable.ic_wifi)); + ssidView.setText(FDroidApp.ssid); + tapView.setText(R.string.swap_view_available_networks); + } + } + + private void setUpStartVisibility() { + bluetoothStatusReceiver.onReceive(this, new Intent(BluetoothManager.ACTION_STATUS)); + bonjourStatusReceiver.onReceive(this, new Intent(BonjourManager.ACTION_STATUS)); + + TextView viewWifiNetwork = findViewById(R.id.wifi_network); + MaterialSwitch wifiSwitch = findViewById(R.id.switch_wifi); + MaterialButton scanQrButton = findViewById(R.id.btn_scan_qr); + MaterialButton appsButton = findViewById(R.id.btn_apps); + if (viewWifiNetwork == null || wifiSwitch == null || scanQrButton == null || appsButton == null) { + return; + } + viewWifiNetwork.setOnClickListener(v -> promptToSelectWifiNetwork()); + + wifiSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> { + Context context = getApplicationContext(); + if (isChecked) { + if (wifiApControl != null && wifiApControl.isEnabled()) { + setupWifiAP(); + } else { + if (Build.VERSION.SDK_INT <= 28) { + wifiManager.setWifiEnabled(true); + } + } + BonjourManager.start(context); + } + BonjourManager.setVisible(context, isChecked); + SwapService.putWifiVisibleUserPreference(isChecked); + }); + + scanQrButton.setOnClickListener(v -> inflateSwapView(R.layout.swap_wifi_qr)); + + appsButton.setOnClickListener(v -> inflateSwapView(R.layout.swap_select_apps)); + appsButton.setEllipsize(TextUtils.TruncateAt.END); + + if (SwapService.getWifiVisibleUserPreference()) { + wifiSwitch.setChecked(true); + } else { + wifiSwitch.setChecked(false); + } + } + + private final BroadcastReceiver bonjourStatusReceiver = new BroadcastReceiver() { + + private volatile int bonjourStatus = BonjourManager.STATUS_STOPPED; + + @Override + public void onReceive(Context context, Intent intent) { + if (!BonjourManager.ACTION_STATUS.equals(intent.getAction())) { + return; + } + bonjourStatus = intent.getIntExtra(BonjourManager.EXTRA_STATUS, bonjourStatus); + TextView textWifiVisible = container.findViewById(R.id.wifi_visible); + TextView peopleNearbyText = container.findViewById(R.id.text_people_nearby); + CircularProgressIndicator peopleNearbyProgress = container.findViewById(R.id.searching_people_nearby); + if (textWifiVisible == null || peopleNearbyText == null || peopleNearbyProgress == null) { + return; + } + switch (bonjourStatus) { + case BonjourManager.STATUS_STARTING: + textWifiVisible.setText(R.string.swap_setting_up_wifi); + peopleNearbyText.setText(R.string.swap_starting); + peopleNearbyText.setVisibility(View.VISIBLE); + peopleNearbyProgress.setVisibility(View.VISIBLE); + break; + case BonjourManager.STATUS_STARTED: + textWifiVisible.setText(R.string.swap_not_visible_wifi); + peopleNearbyText.setText(R.string.swap_scanning_for_peers); + peopleNearbyText.setVisibility(View.VISIBLE); + peopleNearbyProgress.setVisibility(View.VISIBLE); + break; + case BonjourManager.STATUS_VPN_CONFLICT: + textWifiVisible.setText(R.string.swap_wifi_vpn_conflict); + break; + case BonjourManager.STATUS_NOT_VISIBLE: + textWifiVisible.setText(R.string.swap_not_visible_wifi); + peopleNearbyText.setText(R.string.swap_scanning_for_peers); + peopleNearbyText.setVisibility(View.VISIBLE); + peopleNearbyProgress.setVisibility(View.VISIBLE); + break; + case BonjourManager.STATUS_VISIBLE: + if (wifiApControl != null && wifiApControl.isEnabled()) { + textWifiVisible.setText(R.string.swap_visible_hotspot); + } else { + textWifiVisible.setText(R.string.swap_visible_wifi); + } + peopleNearbyText.setText(R.string.swap_scanning_for_peers); + peopleNearbyText.setVisibility(View.VISIBLE); + peopleNearbyProgress.setVisibility(View.VISIBLE); + break; + case BonjourManager.STATUS_STOPPING: + textWifiVisible.setText(R.string.swap_stopping_wifi); + if (!BluetoothManager.isAlive()) { + peopleNearbyText.setText(R.string.swap_stopping); + peopleNearbyText.setVisibility(View.VISIBLE); + peopleNearbyProgress.setVisibility(View.VISIBLE); + } + break; + case BonjourManager.STATUS_STOPPED: + textWifiVisible.setText(R.string.swap_not_visible_wifi); + if (!BluetoothManager.isAlive()) { + peopleNearbyText.setVisibility(View.GONE); + peopleNearbyProgress.setVisibility(View.GONE); + } + break; + case BonjourManager.STATUS_ERROR: + textWifiVisible.setText(R.string.swap_not_visible_wifi); + peopleNearbyText.setText(intent.getStringExtra(Intent.EXTRA_TEXT)); + peopleNearbyText.setVisibility(View.VISIBLE); + peopleNearbyProgress.setVisibility(View.GONE); + break; + default: + String msg = "Bad intent: " + intent + " " + bonjourStatus; + Log.i(TAG, msg); + if (BuildConfig.DEBUG) { + throw new IllegalArgumentException(msg); + } + } + } + }; + + /** + * Add any new Bonjour devices that were found, as long as they are not + * already present. + * + * @see #bluetoothFound + * @see ArrayAdapter#getPosition(Object) + * @see java.util.List#indexOf(Object) + */ + private final BroadcastReceiver bonjourFound = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + ListView peopleNearbyList = container.findViewById(R.id.list_people_nearby); + if (peopleNearbyList != null) { + ArrayAdapter peopleNearbyAdapter = (ArrayAdapter) peopleNearbyList.getAdapter(); + Peer peer = intent.getParcelableExtra(BonjourManager.EXTRA_BONJOUR_PEER); + if (peopleNearbyAdapter.getPosition(peer) == -1) { + peopleNearbyAdapter.add(peer); + } + } + } + }; + + private final BroadcastReceiver bonjourRemoved = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + ListView peopleNearbyList = container.findViewById(R.id.list_people_nearby); + if (peopleNearbyList != null) { + ArrayAdapter peopleNearbyAdapter = (ArrayAdapter) peopleNearbyList.getAdapter(); + peopleNearbyAdapter.remove((Peer) intent.getParcelableExtra(BonjourManager.EXTRA_BONJOUR_PEER)); + } + } + }; + + private final BroadcastReceiver bluetoothStatusReceiver = new BroadcastReceiver() { + + private volatile int bluetoothStatus = BluetoothManager.STATUS_STOPPED; + + @Override + public void onReceive(Context context, Intent intent) { + if (!BluetoothManager.ACTION_STATUS.equals(intent.getAction())) { + return; + } + bluetoothStatus = intent.getIntExtra(BluetoothManager.EXTRA_STATUS, bluetoothStatus); + MaterialSwitch bluetoothSwitch = container.findViewById(R.id.switch_bluetooth); + TextView textBluetoothVisible = container.findViewById(R.id.bluetooth_visible); + TextView textDeviceIdBluetooth = container.findViewById(R.id.device_id_bluetooth); + TextView peopleNearbyText = container.findViewById(R.id.text_people_nearby); + CircularProgressIndicator peopleNearbyProgress = container.findViewById(R.id.searching_people_nearby); + if (bluetoothSwitch == null || textBluetoothVisible == null || textDeviceIdBluetooth == null + || peopleNearbyText == null || peopleNearbyProgress == null) { + return; + } + switch (bluetoothStatus) { + case BluetoothManager.STATUS_STARTING: + bluetoothSwitch.setEnabled(false); + textBluetoothVisible.setText(R.string.swap_setting_up_bluetooth); + textDeviceIdBluetooth.setVisibility(View.VISIBLE); + peopleNearbyText.setText(R.string.swap_scanning_for_peers); + peopleNearbyText.setVisibility(View.VISIBLE); + peopleNearbyProgress.setVisibility(View.VISIBLE); + break; + case BluetoothManager.STATUS_STARTED: + bluetoothSwitch.setEnabled(true); + textBluetoothVisible.setText(R.string.swap_visible_bluetooth); + textDeviceIdBluetooth.setVisibility(View.VISIBLE); + peopleNearbyText.setText(R.string.swap_scanning_for_peers); + peopleNearbyText.setVisibility(View.VISIBLE); + peopleNearbyProgress.setVisibility(View.VISIBLE); + break; + case BluetoothManager.STATUS_STOPPING: + bluetoothSwitch.setEnabled(false); + textBluetoothVisible.setText(R.string.swap_stopping); + textDeviceIdBluetooth.setVisibility(View.GONE); + if (!BonjourManager.isAlive()) { + peopleNearbyText.setText(R.string.swap_stopping); + peopleNearbyText.setVisibility(View.VISIBLE); + peopleNearbyProgress.setVisibility(View.VISIBLE); + } + break; + case BluetoothManager.STATUS_STOPPED: + bluetoothSwitch.setEnabled(true); + textBluetoothVisible.setText(R.string.swap_not_visible_bluetooth); + textDeviceIdBluetooth.setVisibility(View.GONE); + if (!BonjourManager.isAlive()) { + peopleNearbyText.setVisibility(View.GONE); + peopleNearbyProgress.setVisibility(View.GONE); + } + + ListView peopleNearbyView = container.findViewById(R.id.list_people_nearby); + if (peopleNearbyView == null) { + break; + } + ArrayAdapter peopleNearbyAdapter = (ArrayAdapter) peopleNearbyView.getAdapter(); + for (int i = 0; i < peopleNearbyAdapter.getCount(); i++) { + Peer peer = (Peer) peopleNearbyAdapter.getItem(i); + if (peer.getClass().equals(BluetoothPeer.class)) { + Utils.debugLog(TAG, "Removing bluetooth peer: " + peer.getName()); + peopleNearbyAdapter.remove(peer); + } + } + break; + case BluetoothManager.STATUS_ERROR: + bluetoothSwitch.setEnabled(true); + textBluetoothVisible.setText(intent.getStringExtra(Intent.EXTRA_TEXT)); + textDeviceIdBluetooth.setVisibility(View.VISIBLE); + break; + default: + throw new IllegalArgumentException("Bad intent: " + intent); + } + } + }; + + /** + * Add any new Bluetooth devices that were found, as long as they are not + * already present. + * + * @see #bonjourFound + * @see ArrayAdapter#getPosition(Object) + * @see java.util.List#indexOf(Object) + */ + private final BroadcastReceiver bluetoothFound = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + ListView peopleNearbyList = container.findViewById(R.id.list_people_nearby); + if (peopleNearbyList != null) { + ArrayAdapter peopleNearbyAdapter = (ArrayAdapter) peopleNearbyList.getAdapter(); + Peer peer = intent.getParcelableExtra(BluetoothManager.EXTRA_PEER); + if (peopleNearbyAdapter.getPosition(peer) == -1) { + peopleNearbyAdapter.add(peer); + } + } + } + }; + + private void setUpUseBluetoothButton() { + Button useBluetooth = findViewById(R.id.btn_use_bluetooth); + if (useBluetooth != null) { + if (bluetoothAdapter == null) { + useBluetooth.setVisibility(View.GONE); + } else { + useBluetooth.setVisibility(View.VISIBLE); + } + useBluetooth.setOnClickListener(v -> { + showIntro(); + sendFDroidBluetooth(); + }); + } + } + + private void setUpQrScannerButton() { + Button openQr = findViewById(R.id.btn_qr_scanner); + if (openQr != null) { + openQr.setOnClickListener(v -> initiateQrScan()); + } + } + + private void setUpConfirmReceive() { + TextView descriptionTextView = findViewById(R.id.text_description); + if (descriptionTextView != null) { + descriptionTextView.setText(getString(R.string.swap_confirm_connect, confirmSwapConfig.getHost())); + } + + Button confirmReceiveYes = container.findViewById(R.id.confirm_receive_yes); + if (confirmReceiveYes != null) { + confirmReceiveYes.setOnClickListener(v -> denySwap()); + } + + Button confirmReceiveNo = container.findViewById(R.id.confirm_receive_no); + if (confirmReceiveNo != null) { + confirmReceiveNo.setOnClickListener(new View.OnClickListener() { + + private final NewRepoConfig config = confirmSwapConfig; + + @Override + public void onClick(View v) { + swapWith(config); + } + }); + } + } + + private void setUpConnectingProgressText(String message) { + TextView progressText = container.findViewById(R.id.progress_text); + if (progressText != null && message != null) { + progressText.setVisibility(View.VISIBLE); + progressText.setText(message); + } + } + + /** + * Listens for feedback about a local repository being prepared, like APK + * files copied to the LocalHTTPD webroot, the {@code index.html} generated, + * etc. Icons will be copied to the webroot in the background and so are + * not part of this process. + */ + private final BroadcastReceiver localRepoStatus = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + setUpConnectingProgressText(intent.getStringExtra(Intent.EXTRA_TEXT)); + + CircularProgressIndicator progressBar = container.findViewById(R.id.progress_bar); + Button tryAgainButton = container.findViewById(R.id.try_again); + if (progressBar == null || tryAgainButton == null) { + return; + } + + switch (intent.getIntExtra(LocalRepoService.EXTRA_STATUS, -1)) { + case LocalRepoService.STATUS_PROGRESS: + progressBar.show(); + tryAgainButton.setVisibility(View.GONE); + break; + case LocalRepoService.STATUS_STARTED: + progressBar.show(); + tryAgainButton.setVisibility(View.GONE); + onLocalRepoPrepared(); + break; + case LocalRepoService.STATUS_ERROR: + progressBar.hide(); + tryAgainButton.setVisibility(View.VISIBLE); + break; + default: + throw new IllegalArgumentException("Bogus intent: " + intent); + } + } + }; + + private void onRepoUpdateSuccess() { + CircularProgressIndicator progressBar = container.findViewById(R.id.progress_bar); + Button tryAgainButton = container.findViewById(R.id.try_again); + if (progressBar != null && tryAgainButton != null) { + progressBar.show(); + tryAgainButton.setVisibility(View.GONE); + } + getSwapService().addCurrentPeerToActive(); + inflateSwapView(R.layout.swap_success); + } + + private void onRepoUpdateError(Exception e) { + CircularProgressIndicator progressBar = container.findViewById(R.id.progress_bar); + Button tryAgainButton = container.findViewById(R.id.try_again); + if (progressBar != null && tryAgainButton != null) { + progressBar.hide(); + tryAgainButton.setVisibility(View.VISIBLE); + } + String msg = e.getMessage() == null ? "Error updating repo " + e : e.getMessage(); + setUpConnectingProgressText(msg); + getSwapService().removeCurrentPeerFromActive(); + } + + private void setUpConnectingView() { + TextView heading = container.findViewById(R.id.progress_text); + heading.setText(R.string.swap_connecting); + Button tryAgainButton = container.findViewById(R.id.try_again); + if (tryAgainButton != null) { + tryAgainButton.setOnClickListener(v -> onAppsSelected()); + } + } +} diff --git a/app/src/full/java/org/fdroid/fdroid/nearby/TreeUriScannerIntentService.java b/app/src/full/java/org/fdroid/fdroid/nearby/TreeUriScannerIntentService.java new file mode 100644 index 000000000..c17ea0a6a --- /dev/null +++ b/app/src/full/java/org/fdroid/fdroid/nearby/TreeUriScannerIntentService.java @@ -0,0 +1,166 @@ +/* + * Copyright (C) 2018 Hans-Christoph Steiner + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 3 + * of the License, or (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, + * MA 02110-1301, USA. + */ + +package org.fdroid.fdroid.nearby; + +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Process; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.core.app.JobIntentService; +import androidx.documentfile.provider.DocumentFile; + +import org.fdroid.R; +import org.fdroid.index.SigningException; +import org.fdroid.index.v1.IndexV1UpdaterKt; + +import java.security.CodeSigner; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.List; +import java.util.jar.JarEntry; + +/** + * An {@link JobIntentService} subclass for handling asynchronous scanning of a + * removable storage device like an SD Card or USB OTG thumb drive using the + * Storage Access Framework. Permission must first be granted by the user + * {@link Intent#ACTION_OPEN_DOCUMENT_TREE} or + * {@link android.os.storage.StorageVolume#createAccessIntent(String)}request, + * then F-Droid will have permanent access to that{@link Uri}. + *

    + * Even though the Storage Access Framework was introduced in + * {@link android.os.Build.VERSION_CODES#KITKAT android-19}, this approach is only + * workable if {@link Intent#ACTION_OPEN_DOCUMENT_TREE} is available. + * It was added in {@link android.os.Build.VERSION_CODES#LOLLIPOP android-21}. + * {@link android.os.storage.StorageVolume#createAccessIntent(String)} is also + * necessary to do this with any kind of rational UX. + * + * @see The Storage Situation: Removable Storage + * @see Be Careful with Scoped Directory Access + * @see Using Scoped Directory Access + * @see Open Files using Storage Access Framework + */ +public class TreeUriScannerIntentService extends JobIntentService { + public static final String TAG = "TreeUriScannerIntentSer"; + private static final int JOB_ID = TAG.hashCode(); + + private static final String ACTION_SCAN_TREE_URI = "org.fdroid.fdroid.nearby.action.SCAN_TREE_URI"; + + /** + * @see DocumentsContract.EXTERNAL_STORAGE_PROVIDER_AUTHORITY + * @see ExternalStorageProvider.AUTHORITY + */ + public static final String EXTERNAL_STORAGE_PROVIDER_AUTHORITY = "com.android.externalstorage.documents"; + + public static void scan(Context context, Uri data) { + Intent intent = new Intent(context, TreeUriScannerIntentService.class); + intent.setAction(ACTION_SCAN_TREE_URI); + intent.setData(data); + JobIntentService.enqueueWork(context, TreeUriScannerIntentService.class, JOB_ID, intent); + } + + /** + * Now determine if it is External Storage that must be handled by the + * {@link TreeUriScannerIntentService} or whether it is External Storage + * like an SD Card that can be directly accessed via the file system. + */ + public static void onActivityResult(Context context, Intent intent) { + if (intent == null) { + return; + } + Uri uri = intent.getData(); + if (uri != null) { + ContentResolver contentResolver = context.getContentResolver(); + int perms = Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION; + contentResolver.takePersistableUriPermission(uri, perms); + String msg = String.format(context.getString(R.string.swap_toast_using_path), uri.toString()); + Toast.makeText(context, msg, Toast.LENGTH_SHORT).show(); + scan(context, uri); + } + } + + @Override + protected void onHandleWork(@NonNull Intent intent) { + if (!ACTION_SCAN_TREE_URI.equals(intent.getAction())) { + return; + } + Uri treeUri = intent.getData(); + if (treeUri == null) { + return; + } + Process.setThreadPriority(Process.THREAD_PRIORITY_LOWEST); + DocumentFile treeFile = DocumentFile.fromTreeUri(this, treeUri); + searchDirectory(treeFile); + } + + /** + * Recursively search for {@link IndexV1UpdaterKt#SIGNED_FILE_NAME} starting + * from the given directory, looking at files first before recursing into + * directories. This is "depth last" since the index file is much more + * likely to be shallow than deep, and there can be a lot of files to + * search through starting at 4 or more levels deep, like the fdroid + * icons dirs and the per-app "external storage" dirs. + */ + private void searchDirectory(DocumentFile documentFileDir) { + DocumentFile[] documentFiles = documentFileDir.listFiles(); + if (documentFiles == null) { + return; + } + boolean foundIndex = false; + ArrayList dirs = new ArrayList<>(); + for (DocumentFile documentFile : documentFiles) { + if (documentFile.isDirectory()) { + dirs.add(documentFile); + } else if (!foundIndex) { + if (IndexV1UpdaterKt.SIGNED_FILE_NAME.equals(documentFile.getName())) { + foundIndex = true; + } + } + } + for (DocumentFile dir : dirs) { + searchDirectory(dir); + } + } + + /** + * FDroid's index.jar is signed using a particular format and does not allow lots of + * signing setups that would be valid for a regular jar. This validates those + * restrictions. + */ + static X509Certificate getSigningCertFromJar(JarEntry jarEntry) throws SigningException { + final CodeSigner[] codeSigners = jarEntry.getCodeSigners(); + if (codeSigners == null || codeSigners.length == 0) { + throw new SigningException("No signature found in index"); + } + /* we could in theory support more than 1, but as of now we do not */ + if (codeSigners.length > 1) { + throw new SigningException("index.jar must be signed by a single code signer!"); + } + List certs = codeSigners[0].getSignerCertPath().getCertificates(); + if (certs.size() != 1) { + throw new SigningException("index.jar code signers must only have a single certificate!"); + } + return (X509Certificate) certs.get(0); + } +} diff --git a/app/src/full/java/org/fdroid/fdroid/nearby/UsbDeviceAttachedReceiver.java b/app/src/full/java/org/fdroid/fdroid/nearby/UsbDeviceAttachedReceiver.java new file mode 100644 index 000000000..31d16b767 --- /dev/null +++ b/app/src/full/java/org/fdroid/fdroid/nearby/UsbDeviceAttachedReceiver.java @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2018-2019 Hans-Christoph Steiner + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 3 + * of the License, or (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, + * MA 02110-1301, USA. + */ + +package org.fdroid.fdroid.nearby; + +import android.content.BroadcastReceiver; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.content.UriPermission; +import android.database.ContentObserver; +import android.hardware.usb.UsbManager; +import android.net.Uri; +import android.os.Handler; +import android.text.TextUtils; +import android.util.Log; + +import org.fdroid.fdroid.views.main.NearbyViewBinder; + +/** + * This is just a shim to receive {@link UsbManager#ACTION_USB_ACCESSORY_ATTACHED} + * events. + */ +public class UsbDeviceAttachedReceiver extends BroadcastReceiver { + public static final String TAG = "UsbDeviceAttachedReceiv"; + + @Override + public void onReceive(final Context context, Intent intent) { + + if (intent == null || TextUtils.isEmpty(intent.getAction()) + || !UsbManager.ACTION_USB_DEVICE_ATTACHED.equals(intent.getAction())) { + Log.i(TAG, "ignoring irrelevant intent: " + intent); + return; + } + Log.i(TAG, "handling intent: " + intent); + + final ContentResolver contentResolver = context.getContentResolver(); + + for (final UriPermission uriPermission : contentResolver.getPersistedUriPermissions()) { + Uri uri = uriPermission.getUri(); + final ContentObserver contentObserver = new ContentObserver(new Handler()) { + @Override + public void onChange(boolean selfChange, Uri uri) { + NearbyViewBinder.updateUsbOtg(context); + } + }; + contentResolver.registerContentObserver(uri, true, contentObserver); + UsbDeviceDetachedReceiver.contentObservers.put(uri, contentObserver); + } + } +} diff --git a/app/src/full/java/org/fdroid/fdroid/nearby/UsbDeviceDetachedReceiver.java b/app/src/full/java/org/fdroid/fdroid/nearby/UsbDeviceDetachedReceiver.java new file mode 100644 index 000000000..8229e2d71 --- /dev/null +++ b/app/src/full/java/org/fdroid/fdroid/nearby/UsbDeviceDetachedReceiver.java @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2018-2019 Hans-Christoph Steiner + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 3 + * of the License, or (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, + * MA 02110-1301, USA. + */ + +package org.fdroid.fdroid.nearby; + +import android.content.BroadcastReceiver; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.database.ContentObserver; +import android.hardware.usb.UsbManager; +import android.net.Uri; +import android.text.TextUtils; +import android.util.Log; + +import org.fdroid.fdroid.views.main.NearbyViewBinder; + +import java.util.HashMap; + +/** + * This is just a shim to receive {@link UsbManager#ACTION_USB_DEVICE_DETACHED} + * events. + */ +public class UsbDeviceDetachedReceiver extends BroadcastReceiver { + public static final String TAG = "UsbDeviceDetachedReceiv"; + + static final HashMap contentObservers = new HashMap<>(); + + @Override + public void onReceive(Context context, Intent intent) { + if (intent == null || TextUtils.isEmpty(intent.getAction()) + || !UsbManager.ACTION_USB_DEVICE_DETACHED.equals(intent.getAction())) { + Log.i(TAG, "ignoring irrelevant intent: " + intent); + return; + } + Log.i(TAG, "handling intent: " + intent); + + final ContentResolver contentResolver = context.getContentResolver(); + NearbyViewBinder.updateUsbOtg(context); + for (ContentObserver contentObserver : contentObservers.values()) { + contentResolver.unregisterContentObserver(contentObserver); + } + } +} diff --git a/app/src/full/java/org/fdroid/fdroid/nearby/UsbDeviceMediaMountedReceiver.java b/app/src/full/java/org/fdroid/fdroid/nearby/UsbDeviceMediaMountedReceiver.java new file mode 100644 index 000000000..69bb4b102 --- /dev/null +++ b/app/src/full/java/org/fdroid/fdroid/nearby/UsbDeviceMediaMountedReceiver.java @@ -0,0 +1,24 @@ +package org.fdroid.fdroid.nearby; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.os.Environment; + +import org.fdroid.fdroid.views.main.NearbyViewBinder; + +public class UsbDeviceMediaMountedReceiver extends BroadcastReceiver { + @Override + public void onReceive(Context context, Intent intent) { + if (intent == null || intent.getAction() == null) { + return; + } + String action = intent.getAction(); + if (Environment.MEDIA_BAD_REMOVAL.equals(action) + || Environment.MEDIA_MOUNTED.equals(action) + || Environment.MEDIA_REMOVED.equals(action) + || Environment.MEDIA_EJECTING.equals(action)) { + NearbyViewBinder.updateUsbOtg(context); + } + } +} diff --git a/app/src/full/java/org/fdroid/fdroid/nearby/WifiStateChangeReceiver.java b/app/src/full/java/org/fdroid/fdroid/nearby/WifiStateChangeReceiver.java new file mode 100644 index 000000000..7061bd94f --- /dev/null +++ b/app/src/full/java/org/fdroid/fdroid/nearby/WifiStateChangeReceiver.java @@ -0,0 +1,21 @@ +package org.fdroid.fdroid.nearby; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.net.wifi.WifiManager; + +import org.fdroid.fdroid.Utils; + +public class WifiStateChangeReceiver extends BroadcastReceiver { + private static final String TAG = "WifiStateChangeReceiver"; + + @Override + public void onReceive(Context context, Intent intent) { + if (WifiManager.NETWORK_STATE_CHANGED_ACTION.equals(intent.getAction())) { + WifiStateChangeService.start(context, intent); + } else { + Utils.debugLog(TAG, "received unsupported Intent: " + intent); + } + } +} diff --git a/app/src/full/java/org/fdroid/fdroid/nearby/WifiStateChangeService.java b/app/src/full/java/org/fdroid/fdroid/nearby/WifiStateChangeService.java new file mode 100644 index 000000000..fbc169285 --- /dev/null +++ b/app/src/full/java/org/fdroid/fdroid/nearby/WifiStateChangeService.java @@ -0,0 +1,391 @@ +package org.fdroid.fdroid.nearby; + +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.net.DhcpInfo; +import android.net.NetworkInfo; +import android.net.wifi.WifiConfiguration; +import android.net.wifi.WifiInfo; +import android.net.wifi.WifiManager; +import android.text.TextUtils; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; +import androidx.localbroadcastmanager.content.LocalBroadcastManager; +import androidx.work.Constraints; +import androidx.work.Data; +import androidx.work.NetworkType; +import androidx.work.OneTimeWorkRequest; +import androidx.work.WorkManager; +import androidx.work.WorkRequest; +import androidx.work.Worker; +import androidx.work.WorkerParameters; + +import org.apache.commons.net.util.SubnetUtils; +import org.fdroid.database.Repository; +import org.fdroid.BuildConfig; +import org.fdroid.fdroid.FDroidApp; +import org.fdroid.fdroid.Hasher; +import org.fdroid.fdroid.Preferences; +import org.fdroid.R; +import org.fdroid.fdroid.Utils; + +import java.net.Inet6Address; +import java.net.InetAddress; +import java.net.InterfaceAddress; +import java.net.NetworkInterface; +import java.net.SocketException; +import java.security.cert.Certificate; +import java.util.Enumeration; +import java.util.Locale; + +import cc.mvdan.accesspoint.WifiApControl; + +/** + * Handle state changes to the device's wifi, storing the required bits. + * The {@link Intent} that starts it either has no extras included, + * which is how it can be triggered by code, or it came in from the system + * via {@link WifiStateChangeReceiver}, in + * which case an instance of {@link NetworkInfo} is included. + *

    + * The work is done in a {@link Thread} so that new incoming {@code Intents} + * are not blocked by processing. A new {@code Intent} immediately nullifies + * the current state because it means that something about the wifi has + * changed. Having the {@code Thread} also makes it easy to kill work + * that is in progress. + *

    + * This also schedules an update to encourage updates happening on + * unmetered networks like typical WiFi rather than networks that can + * cost money or have caps. + *

    + * Some devices send multiple copies of given events, like a Moto G often + * sends three {@code CONNECTED} events. So they have to be debounced to + * keep the {@link #BROADCAST} useful. + */ +@SuppressWarnings("LineLength") +public class WifiStateChangeService extends Worker { + private static final String TAG = "WifiStateChangeService"; + + public static final String BROADCAST = "org.fdroid.fdroid.action.WIFI_CHANGE"; + public static final String EXTRA_STATUS = "wifiStateChangeStatus"; + + private WifiManager wifiManager; + private static WifiInfoThread wifiInfoThread; + private static int previousWifiState = Integer.MIN_VALUE; + private volatile static int wifiState; + private static final int NETWORK_INFO_STATE_NOT_SET = -1; + + public WifiStateChangeService(@NonNull Context context, @NonNull WorkerParameters workerParams) { + super(context, workerParams); + } + + public static void registerReceiver(Context context, WifiStateChangeReceiver wifiStateChangeReceiver) { + ContextCompat.registerReceiver( + context, + wifiStateChangeReceiver, + new IntentFilter(WifiManager.NETWORK_STATE_CHANGED_ACTION), + ContextCompat.RECEIVER_NOT_EXPORTED); + } + + public static void unregisterReceiver(Context context, WifiStateChangeReceiver wifiStateChangeReceiver) { + context.unregisterReceiver(wifiStateChangeReceiver); + } + + public static void start(Context context, @Nullable Intent intent) { + int networkInfoStateInt = NETWORK_INFO_STATE_NOT_SET; + if (intent != null) { + NetworkInfo ni = intent.getParcelableExtra(WifiManager.EXTRA_NETWORK_INFO); + networkInfoStateInt = ni.getState().ordinal(); + } + + WorkRequest workRequest = new OneTimeWorkRequest.Builder(WifiStateChangeService.class) + .setConstraints(new Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build()) + .setInputData(new Data.Builder() + .putInt(WifiManager.EXTRA_NETWORK_INFO, networkInfoStateInt) + .build() + ) + .build(); + WorkManager.getInstance(context).enqueue(workRequest); + } + + @NonNull + @Override + public Result doWork() { + android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_LOWEST); + int networkInfoStateInt = getInputData().getInt(WifiManager.EXTRA_NETWORK_INFO, NETWORK_INFO_STATE_NOT_SET); + NetworkInfo.State networkInfoState = null; + if (networkInfoStateInt != NETWORK_INFO_STATE_NOT_SET) { + networkInfoState = NetworkInfo.State.values()[networkInfoStateInt]; + } + Utils.debugLog(TAG, "WiFi change service started."); + wifiManager = ContextCompat.getSystemService(getApplicationContext(), WifiManager.class); + if (wifiManager == null) { + return Result.failure(); + } + wifiState = wifiManager.getWifiState(); + Utils.debugLog(TAG, "networkInfoStateInt == " + networkInfoStateInt + + " wifiState == " + printWifiState(wifiState)); + if (networkInfoState == null + || networkInfoState == NetworkInfo.State.CONNECTED + || networkInfoState == NetworkInfo.State.DISCONNECTED) { + if (previousWifiState != wifiState && + (wifiState == WifiManager.WIFI_STATE_ENABLED + || wifiState == WifiManager.WIFI_STATE_DISABLING // might be switching to hotspot + || wifiState == WifiManager.WIFI_STATE_DISABLED // might be hotspot + || wifiState == WifiManager.WIFI_STATE_UNKNOWN)) { // might be hotspot + if (wifiInfoThread != null) { + wifiInfoThread.interrupt(); + } + wifiInfoThread = new WifiInfoThread(); + wifiInfoThread.start(); + } + } + return Result.success(); + } + + public class WifiInfoThread extends Thread { + + @Override + public void run() { + android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_LOWEST); + try { + FDroidApp.initWifiSettings(); + Utils.debugLog(TAG, "Checking wifi state (in background thread)."); + WifiInfo wifiInfo = null; + + int wifiState = wifiManager.getWifiState(); + int retryCount = 0; + while (FDroidApp.ipAddressString == null) { + if (isInterrupted()) { // can be canceled by a change via WifiStateChangeReceiver + return; + } + if (wifiState == WifiManager.WIFI_STATE_ENABLED) { + wifiInfo = wifiManager.getConnectionInfo(); + FDroidApp.ipAddressString = formatIpAddress(wifiInfo.getIpAddress()); + setSsid(wifiInfo); + DhcpInfo dhcpInfo = wifiManager.getDhcpInfo(); + if (dhcpInfo != null) { + String netmask = formatIpAddress(dhcpInfo.netmask); + if (!TextUtils.isEmpty(FDroidApp.ipAddressString) && netmask != null) { + try { + FDroidApp.subnetInfo = new SubnetUtils(FDroidApp.ipAddressString, netmask).getInfo(); + } catch (IllegalArgumentException e) { + // catch mystery: "java.lang.IllegalArgumentException: Could not parse [null/24]" + e.printStackTrace(); + } + } + } + if (FDroidApp.ipAddressString == null + || FDroidApp.subnetInfo == FDroidApp.UNSET_SUBNET_INFO) { + setIpInfoFromNetworkInterface(); + } + } else if (wifiState == WifiManager.WIFI_STATE_DISABLED + || wifiState == WifiManager.WIFI_STATE_DISABLING + || wifiState == WifiManager.WIFI_STATE_UNKNOWN) { + // try once to see if its a hotspot + setIpInfoFromNetworkInterface(); + if (FDroidApp.ipAddressString == null) { + return; + } + } + + if (retryCount > 120) { + return; + } + retryCount++; + + if (FDroidApp.ipAddressString == null) { + Thread.sleep(1000); + Utils.debugLog(TAG, "waiting for an IP address..."); + } + } + if (isInterrupted()) { // can be canceled by a change via WifiStateChangeReceiver + return; + } + + setSsid(wifiInfo); + + String scheme; + if (Preferences.get().isLocalRepoHttpsEnabled()) { + scheme = "https"; + } else { + scheme = "http"; + } + Context context = WifiStateChangeService.this.getApplicationContext(); + String address = String.format(Locale.ENGLISH, "%s://%s:%d/fdroid/repo", + scheme, FDroidApp.ipAddressString, FDroidApp.port); + // the fingerprint for the local repo's signing key + LocalRepoKeyStore localRepoKeyStore = LocalRepoKeyStore.get(context); + Certificate localCert = localRepoKeyStore.getCertificate(); + String cert = localCert == null ? + null : Hasher.hex(localCert).toLowerCase(Locale.US); + Repository repo = FDroidApp.createSwapRepo(address, cert); + + if (isInterrupted()) { // can be canceled by a change via WifiStateChangeReceiver + return; + } + + LocalRepoManager lrm = LocalRepoManager.get(context); + lrm.writeIndexPage(Utils.getSharingUri(FDroidApp.repo).toString()); + + if (isInterrupted()) { // can be canceled by a change via WifiStateChangeReceiver + return; + } + + FDroidApp.repo = repo; + + /* + * Once the IP address is known we need to generate a self + * signed certificate to use for HTTPS that has a CN field set + * to the ipAddressString. This must be run in the background + * because if this is the first time the singleton is run, it + * can take a while to instantiate. + */ + if (Preferences.get().isLocalRepoHttpsEnabled()) { + localRepoKeyStore.setupHTTPSCertificate(); + } + + } catch (LocalRepoKeyStore.InitException e) { + Log.e(TAG, "Unable to configure a fingerprint or HTTPS for the local repo", e); + } catch (InterruptedException e) { + Utils.debugLog(TAG, "interrupted"); + return; + } + Intent intent = new Intent(BROADCAST); + intent.putExtra(EXTRA_STATUS, wifiState); + LocalBroadcastManager.getInstance(getApplicationContext()).sendBroadcast(intent); + } + } + + private void setSsid(WifiInfo wifiInfo) { + Context context = getApplicationContext(); + if (wifiInfo != null && wifiInfo.getBSSID() != null) { + String ssid = wifiInfo.getSSID(); + Utils.debugLog(TAG, "Have wifi info, connected to " + ssid); + if (ssid == null) { + FDroidApp.ssid = context.getString(R.string.swap_blank_wifi_ssid); + } else { + FDroidApp.ssid = ssid.replaceAll("^\"(.*)\"$", "$1"); + } + FDroidApp.bssid = wifiInfo.getBSSID(); + } else { + WifiApControl wifiApControl = WifiApControl.getInstance(context); + Utils.debugLog(TAG, "WifiApControl: " + wifiApControl); + if (wifiApControl == null && FDroidApp.ipAddressString != null) { + wifiInfo = wifiManager.getConnectionInfo(); + if (wifiInfo != null && wifiInfo.getBSSID() != null) { + setSsid(wifiInfo); + } else { + FDroidApp.ssid = context.getString(R.string.swap_active_hotspot, ""); + } + } else if (wifiApControl != null && wifiApControl.isEnabled()) { + WifiConfiguration wifiConfiguration = wifiApControl.getConfiguration(); + Utils.debugLog(TAG, "WifiConfiguration: " + wifiConfiguration); + if (wifiConfiguration == null) { + FDroidApp.ssid = context.getString(R.string.swap_active_hotspot, ""); + FDroidApp.bssid = ""; + return; + } + + if (wifiConfiguration.hiddenSSID) { + FDroidApp.ssid = context.getString(R.string.swap_hidden_wifi_ssid); + } else { + FDroidApp.ssid = wifiConfiguration.SSID; + } + FDroidApp.bssid = wifiConfiguration.BSSID; + } + } + } + + /** + * Search for known Wi-Fi, Hotspot, and local network interfaces and get + * the IP Address info from it. This is necessary because network + * interfaces in Hotspot/AP mode do not show up in the regular + * {@link WifiManager} queries, and also on + * {@link android.os.Build.VERSION_CODES#LOLLIPOP Android 5.0} and newer, + * {@link WifiManager#getDhcpInfo()} returns an invalid netmask. + * + * @see netmask of WifiManager.getDhcpInfo() is always zero on Android 5.0 + */ + private void setIpInfoFromNetworkInterface() { + try { + Enumeration networkInterfaces = NetworkInterface.getNetworkInterfaces(); + if (networkInterfaces == null) { + return; + } + while (networkInterfaces.hasMoreElements()) { + NetworkInterface netIf = networkInterfaces.nextElement(); + + for (Enumeration inetAddresses = netIf.getInetAddresses(); inetAddresses.hasMoreElements(); ) { + InetAddress inetAddress = inetAddresses.nextElement(); + if (inetAddress.isLoopbackAddress() || inetAddress instanceof Inet6Address) { + continue; + } + if (netIf.getDisplayName().contains("wlan0") + || netIf.getDisplayName().contains("eth0") + || netIf.getDisplayName().contains("ap0")) { + FDroidApp.ipAddressString = inetAddress.getHostAddress(); + for (InterfaceAddress address : netIf.getInterfaceAddresses()) { + short networkPrefixLength = address.getNetworkPrefixLength(); + if (networkPrefixLength > 32) { + // something is giving a "/64" netmask, IPv6? + // java.lang.IllegalArgumentException: Value [64] not in range [0,32] + continue; + } + try { + String cidr = String.format(Locale.ENGLISH, "%s/%d", + FDroidApp.ipAddressString, networkPrefixLength); + FDroidApp.subnetInfo = new SubnetUtils(cidr).getInfo(); + break; + } catch (IllegalArgumentException e) { + if (BuildConfig.DEBUG) { + e.printStackTrace(); + } else { + Log.i(TAG, "Getting subnet failed: " + e.getLocalizedMessage()); + } + } + } + } + } + } + } catch (NullPointerException | SocketException e) { + // NetworkInterface.getNetworkInterfaces() can throw a NullPointerException internally + Log.e(TAG, "Could not get ip address", e); + } + } + + static String formatIpAddress(int ipAddress) { + if (ipAddress == 0) { + return null; + } + return String.format(Locale.ENGLISH, "%d.%d.%d.%d", + ipAddress & 0xff, + ipAddress >> 8 & 0xff, + ipAddress >> 16 & 0xff, + ipAddress >> 24 & 0xff); + } + + private String printWifiState(int wifiState) { + switch (wifiState) { + case WifiManager.WIFI_STATE_DISABLED: + return "WIFI_STATE_DISABLED"; + case WifiManager.WIFI_STATE_DISABLING: + return "WIFI_STATE_DISABLING"; + case WifiManager.WIFI_STATE_ENABLING: + return "WIFI_STATE_ENABLING"; + case WifiManager.WIFI_STATE_ENABLED: + return "WIFI_STATE_ENABLED"; + case WifiManager.WIFI_STATE_UNKNOWN: + return "WIFI_STATE_UNKNOWN"; + case Integer.MIN_VALUE: + return "previous value unset"; + default: + return "~not mapped~"; + } + } +} diff --git a/app/src/full/java/org/fdroid/fdroid/nearby/httpish/ContentLengthHeader.java b/app/src/full/java/org/fdroid/fdroid/nearby/httpish/ContentLengthHeader.java new file mode 100644 index 000000000..54cbb855a --- /dev/null +++ b/app/src/full/java/org/fdroid/fdroid/nearby/httpish/ContentLengthHeader.java @@ -0,0 +1,13 @@ +package org.fdroid.fdroid.nearby.httpish; + +public class ContentLengthHeader extends Header { + + @Override + public String getName() { + return "content-length"; + } + + public void handle(FileDetails details, String value) { + details.setFileSize(Integer.parseInt(value)); + } +} \ No newline at end of file diff --git a/app/src/full/java/org/fdroid/fdroid/nearby/httpish/ETagHeader.java b/app/src/full/java/org/fdroid/fdroid/nearby/httpish/ETagHeader.java new file mode 100644 index 000000000..b56c98361 --- /dev/null +++ b/app/src/full/java/org/fdroid/fdroid/nearby/httpish/ETagHeader.java @@ -0,0 +1,13 @@ +package org.fdroid.fdroid.nearby.httpish; + +public class ETagHeader extends Header { + + @Override + public String getName() { + return "etag"; + } + + public void handle(FileDetails details, String value) { + details.setCacheTag(value); + } +} diff --git a/app/src/full/java/org/fdroid/fdroid/nearby/httpish/FileDetails.java b/app/src/full/java/org/fdroid/fdroid/nearby/httpish/FileDetails.java new file mode 100644 index 000000000..a01f81ab8 --- /dev/null +++ b/app/src/full/java/org/fdroid/fdroid/nearby/httpish/FileDetails.java @@ -0,0 +1,23 @@ +package org.fdroid.fdroid.nearby.httpish; + +public class FileDetails { + + private String cacheTag; + private long fileSize; + + public String getCacheTag() { + return cacheTag; + } + + public long getFileSize() { + return fileSize; + } + + void setFileSize(int fileSize) { + this.fileSize = fileSize; + } + + void setCacheTag(String cacheTag) { + this.cacheTag = cacheTag; + } +} diff --git a/app/src/full/java/org/fdroid/fdroid/nearby/httpish/Header.java b/app/src/full/java/org/fdroid/fdroid/nearby/httpish/Header.java new file mode 100644 index 000000000..feb6bd1e4 --- /dev/null +++ b/app/src/full/java/org/fdroid/fdroid/nearby/httpish/Header.java @@ -0,0 +1,25 @@ +package org.fdroid.fdroid.nearby.httpish; + +import java.util.Locale; + +public abstract class Header { + + private static final Header[] VALID_HEADERS = { + new ContentLengthHeader(), + new ETagHeader(), + }; + + protected abstract String getName(); + + protected abstract void handle(FileDetails details, String value); + + public static void process(FileDetails details, String header, String value) { + header = header.toLowerCase(Locale.ENGLISH); + for (Header potentialHeader : VALID_HEADERS) { + if (potentialHeader.getName().equals(header)) { + potentialHeader.handle(details, value); + break; + } + } + } +} diff --git a/app/src/full/java/org/fdroid/fdroid/nearby/httpish/Request.java b/app/src/full/java/org/fdroid/fdroid/nearby/httpish/Request.java new file mode 100644 index 000000000..06cf1d4e0 --- /dev/null +++ b/app/src/full/java/org/fdroid/fdroid/nearby/httpish/Request.java @@ -0,0 +1,204 @@ +package org.fdroid.fdroid.nearby.httpish; + +import org.fdroid.fdroid.Utils; +import org.fdroid.fdroid.nearby.BluetoothConnection; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; + +public final class Request { + + private static final String TAG = "bluetooth.Request"; + + public interface Methods { + String HEAD = "HEAD"; + String GET = "GET"; + } + + private String method; + private String path; + private Map headers; + + private final BluetoothConnection connection; + private final Writer output; + private final InputStream input; + + private Request(String method, String path, BluetoothConnection connection) { + this.method = method; + this.path = path; + this.connection = connection; + + output = new OutputStreamWriter(connection.getOutputStream()); + input = connection.getInputStream(); + } + + public static Request createHEAD(String path, BluetoothConnection connection) { + return new Request(Methods.HEAD, path, connection); + } + + public static Request createGET(String path, BluetoothConnection connection) { + return new Request(Methods.GET, path, connection); + } + + public String getHeaderValue(String header) { + return headers.containsKey(header) ? headers.get(header) : null; + } + + public Response send() throws IOException { + + Utils.debugLog(TAG, "Sending request to server (" + path + ")"); + + output.write(method); + output.write(' '); + output.write(path); + + output.write("\n\n"); + + output.flush(); + + Utils.debugLog(TAG, "Finished sending request, now attempting to read response status code..."); + + int responseCode = readResponseCode(); + + Utils.debugLog(TAG, "Read response code " + responseCode + " from server, now reading headers..."); + + Map headers = readHeaders(); + + Utils.debugLog(TAG, "Read " + headers.size() + " headers"); + + if (method.equals(Methods.HEAD)) { + Utils.debugLog(TAG, "Request was a " + Methods.HEAD + + " request, not including anything other than headers and status..."); + return new Response(responseCode, headers); + } + Utils.debugLog(TAG, "Request was a " + Methods.GET + + " request, so including content stream in response..."); + return new Response(responseCode, headers, connection.getInputStream()); + } + + /** + * Helper function used by listenForRequest(). + * The reason it is here is because the listenForRequest() is a static function, which would + * need to instantiate it's own InputReaders from the bluetooth connection. However, we already + * have that happening in a Request, so it is in some ways simpler to delegate to a member + * method like this. + */ + private boolean listen() throws IOException { + + String requestLine = readLine(); + + if (requestLine == null || requestLine.trim().length() == 0) { + return false; + } + + String[] parts = requestLine.split("\\s+"); + + // First part is the method (GET/HEAD), second is the path (/fdroid/repo/index.jar) + if (parts.length < 2) { + return false; + } + + method = parts[0].toUpperCase(Locale.ENGLISH); + path = parts[1]; + headers = readHeaders(); + return true; + } + + /** + * This is a blocking method, which will wait until a full Request is received. + */ + public static Request listenForRequest(BluetoothConnection connection) throws IOException { + Request request = new Request("", "", connection); + return request.listen() ? request : null; + } + + /** + * First line of a HTTP 1.1 response is the status line: + * http://www.w3.org/Protocols/rfc2616/rfc2616-sec6.html#sec6.1 + * The first part is the HTTP version, followed by a space, then the status code, then + * a space, and then the status label (which may contain spaces). + */ + private int readResponseCode() throws IOException { + + String line = readLine(); + + // TODO: Error handling + int firstSpace = line.indexOf(' '); + int secondSpace = line.indexOf(' ', firstSpace + 1); + + String status = line.substring(firstSpace + 1, secondSpace); + return Integer.parseInt(status); + } + + private String readLine() throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + String line = null; + + while (line == null) { + + while (input.available() > 0) { + + int b = input.read(); + + if (((char) b) == '\n') { + if (baos.size() > 0) { + line = baos.toString(); + } + + return line; + } + + baos.write(b); + } + + try { + Thread.sleep(100); + } catch (Exception e) { + // ignore + } + } + + return line; + } + + /** + * Subsequent lines (after the status line) represent the headers, which are case + * insensitive and may be multi-line. We don't deal with multi-line headers in + * our HTTP-ish implementation. + */ + private Map readHeaders() throws IOException { + Map headers = new HashMap<>(); + String responseLine = readLine(); + while (responseLine != null) { + + // TODO: Error handling + String[] parts = responseLine.split(":"); + if (parts.length > 1) { + String header = parts[0].trim(); + String value = parts[1].trim(); + headers.put(header, value); + } + + if (input.available() > 0) { + responseLine = readLine(); + } else { + break; + } + } + return headers; + } + + public String getPath() { + return path; + } + + public String getMethod() { + return method; + } +} diff --git a/app/src/full/java/org/fdroid/fdroid/nearby/httpish/Response.java b/app/src/full/java/org/fdroid/fdroid/nearby/httpish/Response.java new file mode 100644 index 000000000..bf2660869 --- /dev/null +++ b/app/src/full/java/org/fdroid/fdroid/nearby/httpish/Response.java @@ -0,0 +1,166 @@ +package org.fdroid.fdroid.nearby.httpish; + +import org.fdroid.fdroid.Utils; +import org.fdroid.fdroid.nearby.BluetoothConnection; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.net.HttpURLConnection; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; + +public class Response { + + private static final String TAG = "bluetooth.Response"; + + private final int statusCode; + private final Map headers; + private final InputStream contentStream; + + public Response(int statusCode, Map headers) { + this(statusCode, headers, null); + } + + /** + * This class expects 'contentStream' to be open, and ready for use. + * It will not close it either. However it will block while doing things + * so you can call a method, wait for it to finish, and then close + * it afterwards if you like. + */ + public Response(int statusCode, Map headers, InputStream contentStream) { + this.statusCode = statusCode; + this.headers = headers; + this.contentStream = contentStream; + } + + public Response(int statusCode, String mimeType, String content) { + this.statusCode = statusCode; + this.headers = new HashMap<>(); + this.headers.put("Content-Type", mimeType); + this.contentStream = new ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8)); + } + + public Response(int statusCode, String mimeType, InputStream contentStream) { + this.statusCode = statusCode; + this.headers = new HashMap<>(); + this.headers.put("Content-Type", mimeType); + this.contentStream = contentStream; + } + + public void addHeader(String key, String value) { + headers.put(key, value); + } + + public int getStatusCode() { + return statusCode; + } + + public int getFileSize() { + if (headers != null) { + for (Map.Entry entry : headers.entrySet()) { + if ("content-length".equals(entry.getKey().toLowerCase(Locale.ENGLISH))) { + try { + return Integer.parseInt(entry.getValue()); + } catch (NumberFormatException e) { + return -1; + } + } + } + } + return -1; + } + + /** + * Extracts meaningful headers from the response into a more useful and safe + * {@link FileDetails} object. + */ + public FileDetails toFileDetails() { + FileDetails details = new FileDetails(); + for (Map.Entry entry : headers.entrySet()) { + Header.process(details, entry.getKey(), entry.getValue()); + } + return details; + } + + public InputStream toContentStream() throws UnsupportedOperationException { + if (contentStream == null) { + throw new UnsupportedOperationException("This kind of response doesn't have a content stream." + + " Did you perform a HEAD request instead of a GET request?"); + } + return contentStream; + } + + public void send(BluetoothConnection connection) throws IOException { + + Utils.debugLog(TAG, "Sending Bluetooth HTTP-ish response..."); + + Writer output = new OutputStreamWriter(connection.getOutputStream()); + output.write("HTTP(ish)/0.1 200 OK\n"); + + for (Map.Entry entry : headers.entrySet()) { + output.write(entry.getKey()); + output.write(": "); + output.write(entry.getValue()); + output.write("\n"); + } + + output.write("\n"); + output.flush(); + + if (contentStream != null) { + Utils.copy(contentStream, connection.getOutputStream()); + } + + output.flush(); + } + + public static class Builder { + + private InputStream contentStream; + private int statusCode = HttpURLConnection.HTTP_OK; + private int fileSize = -1; + private String etag; + + public Builder() { + } + + public Builder(InputStream contentStream) { + this.contentStream = contentStream; + } + + public Builder setStatusCode(int statusCode) { + this.statusCode = statusCode; + return this; + } + + public Builder setFileSize(int fileSize) { + this.fileSize = fileSize; + return this; + } + + public Builder setETag(String etag) { + this.etag = etag; + return this; + } + + public Response build() { + + Map headers = new HashMap<>(3); + + if (fileSize > 0) { + headers.put("Content-Length", Integer.toString(fileSize)); + } + + if (etag != null) { + headers.put("ETag", etag); + } + + return new Response(statusCode, headers, contentStream); + } + } +} diff --git a/app/src/full/java/org/fdroid/fdroid/nearby/peers/BluetoothPeer.java b/app/src/full/java/org/fdroid/fdroid/nearby/peers/BluetoothPeer.java new file mode 100644 index 000000000..26fa31f88 --- /dev/null +++ b/app/src/full/java/org/fdroid/fdroid/nearby/peers/BluetoothPeer.java @@ -0,0 +1,120 @@ +package org.fdroid.fdroid.nearby.peers; + +import android.bluetooth.BluetoothClass.Device; +import android.bluetooth.BluetoothDevice; +import android.os.Parcel; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresPermission; + +import org.fdroid.R; + +public class BluetoothPeer implements Peer { + + private static final String BLUETOOTH_NAME_TAG = "FDroid:"; + + private final BluetoothDevice device; + + /** + * Return a instance if the {@link BluetoothDevice} is a device that could + * host a swap repo. + */ + @Nullable + @RequiresPermission("android.permission.BLUETOOTH_CONNECT") + public static BluetoothPeer getInstance(@Nullable BluetoothDevice device) { + if (device != null && device.getName() != null && + (device.getBluetoothClass().getDeviceClass() == Device.COMPUTER_HANDHELD_PC_PDA + || device.getBluetoothClass().getDeviceClass() == Device.COMPUTER_PALM_SIZE_PC_PDA + || device.getBluetoothClass().getDeviceClass() == Device.PHONE_SMART)) { + return new BluetoothPeer(device); + } + return null; + } + + private BluetoothPeer(BluetoothDevice device) { + this.device = device; + } + + @NonNull + @Override + @RequiresPermission("android.permission.BLUETOOTH_CONNECT") + public String toString() { + String name = getName(); + if (name == null) return "null"; + return name; + } + + @Override + @Nullable + @RequiresPermission("android.permission.BLUETOOTH_CONNECT") + public String getName() { + String name = device.getName(); + if (name == null) return null; + return name.replaceAll("^" + BLUETOOTH_NAME_TAG, ""); + } + + @Override + public int getIcon() { + return R.drawable.ic_bluetooth; + } + + @Override + public boolean equals(Object peer) { + return peer instanceof BluetoothPeer + && TextUtils.equals(((BluetoothPeer) peer).device.getAddress(), device.getAddress()); + } + + @Override + public int hashCode() { + return device.getAddress().hashCode(); + } + + @Override + public String getRepoAddress() { + return "bluetooth://" + device.getAddress().replace(':', '-') + "/fdroid/repo"; + } + + /** + * Return the fingerprint of the signing key, or {@code null} if it is not set. + *

    + * This is not yet stored for Bluetooth connections. Once a device is connected to a bluetooth + * socket, if we trust it enough to accept a fingerprint from it somehow, then we may as well + * trust it enough to receive an index from it that contains a fingerprint we can use. + */ + @Override + public String getFingerprint() { + return null; + } + + @Override + public boolean shouldPromptForSwapBack() { + return false; + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeParcelable(this.device, 0); + } + + private BluetoothPeer(Parcel in) { + this.device = in.readParcelable(BluetoothDevice.class.getClassLoader()); + } + + public static final Creator CREATOR = new Creator() { + public BluetoothPeer createFromParcel(Parcel source) { + return new BluetoothPeer(source); + } + + public BluetoothPeer[] newArray(int size) { + return new BluetoothPeer[size]; + } + }; + +} diff --git a/app/src/full/java/org/fdroid/fdroid/nearby/peers/BonjourPeer.java b/app/src/full/java/org/fdroid/fdroid/nearby/peers/BonjourPeer.java new file mode 100644 index 000000000..cca554f87 --- /dev/null +++ b/app/src/full/java/org/fdroid/fdroid/nearby/peers/BonjourPeer.java @@ -0,0 +1,101 @@ +package org.fdroid.fdroid.nearby.peers; + +import android.net.Uri; +import android.os.Parcel; +import android.text.TextUtils; + +import androidx.annotation.Nullable; + +import org.fdroid.fdroid.FDroidApp; + +import javax.jmdns.ServiceInfo; +import javax.jmdns.impl.FDroidServiceInfo; + +public class BonjourPeer extends WifiPeer { + private static final String TAG = "BonjourPeer"; + + public static final String FINGERPRINT = "fingerprint"; + public static final String NAME = "name"; + public static final String PATH = "path"; + public static final String TYPE = "type"; + + private final FDroidServiceInfo serviceInfo; + + /** + * Return a instance if the {@link ServiceInfo} is fully resolved and does + * not represent this device, but something else on the network. + */ + @Nullable + public static BonjourPeer getInstance(ServiceInfo serviceInfo) { + String type = serviceInfo.getPropertyString(TYPE); + String fingerprint = serviceInfo.getPropertyString(FINGERPRINT); + if (type == null || !type.startsWith("fdroidrepo") || FDroidApp.repo == null + || TextUtils.equals(FDroidApp.repo.getFingerprint(), fingerprint)) { + return null; + } + return new BonjourPeer(serviceInfo); + } + + private BonjourPeer(ServiceInfo serviceInfo) { + this.serviceInfo = new FDroidServiceInfo(serviceInfo); + this.name = serviceInfo.getDomain(); + this.uri = Uri.parse(this.serviceInfo.getRepoAddress()); + this.shouldPromptForSwapBack = true; + } + + @Override + public String toString() { + return getName(); + } + + @Override + public String getName() { + return serviceInfo.getName(); + } + + @Override + public int hashCode() { + String fingerprint = getFingerprint(); + if (fingerprint == null) { + return 0; + } + return fingerprint.hashCode(); + } + + @Override + public String getRepoAddress() { + return serviceInfo.getRepoAddress(); + } + + /** + * Return the fingerprint of the signing key, or {@code null} if it is not set. + */ + @Override + public String getFingerprint() { + return serviceInfo.getFingerprint(); + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeParcelable(serviceInfo, flags); + } + + private BonjourPeer(Parcel in) { + this((ServiceInfo) in.readParcelable(FDroidServiceInfo.class.getClassLoader())); + } + + public static final Creator CREATOR = new Creator() { + public BonjourPeer createFromParcel(Parcel source) { + return new BonjourPeer(source); + } + + public BonjourPeer[] newArray(int size) { + return new BonjourPeer[size]; + } + }; +} diff --git a/app/src/full/java/org/fdroid/fdroid/nearby/peers/Peer.java b/app/src/full/java/org/fdroid/fdroid/nearby/peers/Peer.java new file mode 100644 index 000000000..438db6bb1 --- /dev/null +++ b/app/src/full/java/org/fdroid/fdroid/nearby/peers/Peer.java @@ -0,0 +1,29 @@ +package org.fdroid.fdroid.nearby.peers; + +import android.os.Parcelable; + +import androidx.annotation.DrawableRes; + +/** + * TODO This model assumes that "peers" from Bluetooth, Bonjour, and WiFi are + * different things. They are not different repos though, they all point to + * the same repos. This should really be combined to be a single "RemoteRepo" + * class that represents a single device's local repo, and can have zero to + * many ways to connect to it (e.g. Bluetooth, WiFi, USB Thumb Drive, SD Card, + * WiFi Direct, etc). + */ +public interface Peer extends Parcelable { + + String getName(); + + @DrawableRes + int getIcon(); + + boolean equals(Object peer); + + String getRepoAddress(); + + String getFingerprint(); + + boolean shouldPromptForSwapBack(); +} diff --git a/app/src/full/java/org/fdroid/fdroid/nearby/peers/WifiPeer.java b/app/src/full/java/org/fdroid/fdroid/nearby/peers/WifiPeer.java new file mode 100644 index 000000000..ad782be2c --- /dev/null +++ b/app/src/full/java/org/fdroid/fdroid/nearby/peers/WifiPeer.java @@ -0,0 +1,109 @@ +package org.fdroid.fdroid.nearby.peers; + +import android.net.Uri; +import android.os.Parcel; +import android.text.TextUtils; + +import org.fdroid.R; +import org.fdroid.fdroid.nearby.NewRepoConfig; + +public class WifiPeer implements Peer { + + protected String name; + protected Uri uri; + protected boolean shouldPromptForSwapBack; + + public WifiPeer() { + + } + + public WifiPeer(NewRepoConfig config) { + this(config.getRepoUri(), config.getHost(), !config.preventFurtherSwaps()); + } + + private WifiPeer(Uri uri, String name, boolean shouldPromptForSwapBack) { + this.name = name; + this.uri = uri; + this.shouldPromptForSwapBack = shouldPromptForSwapBack; + } + + /** + * Return if this instance points to the same device as that instance, even + * if some of the configuration details are not the same, like whether one + * instance supplies the fingerprint and the other does not, then use IP + * address and port number. + */ + @Override + public boolean equals(Object peer) { + if (peer instanceof BluetoothPeer) { + return false; + } + String fingerprint = getFingerprint(); + if (this instanceof BonjourPeer && peer instanceof BonjourPeer) { + BonjourPeer that = (BonjourPeer) peer; + return TextUtils.equals(this.getFingerprint(), that.getFingerprint()); + } else { + WifiPeer that = (WifiPeer) peer; + if (!TextUtils.isEmpty(fingerprint) && TextUtils.equals(this.getFingerprint(), that.getFingerprint())) { + return true; + } + return TextUtils.equals(this.getRepoAddress(), that.getRepoAddress()); + } + } + + @Override + public int hashCode() { + return (uri.getHost() + uri.getPort()).hashCode(); + } + + @Override + public String getName() { + return name; + } + + @Override + public int getIcon() { + return R.drawable.ic_wifi; + } + + @Override + public String getRepoAddress() { + return uri.toString(); + } + + @Override + public String getFingerprint() { + return uri.getQueryParameter("fingerprint"); + } + + @Override + public boolean shouldPromptForSwapBack() { + return shouldPromptForSwapBack; + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(name); + dest.writeString(uri.toString()); + dest.writeByte(shouldPromptForSwapBack ? (byte) 1 : (byte) 0); + } + + private WifiPeer(Parcel in) { + this(Uri.parse(in.readString()), in.readString(), in.readByte() == 1); + } + + public static final Creator CREATOR = new Creator() { + public WifiPeer createFromParcel(Parcel source) { + return new WifiPeer(source); + } + + public WifiPeer[] newArray(int size) { + return new WifiPeer[size]; + } + }; +} diff --git a/app/src/full/java/org/fdroid/fdroid/net/TreeUriDownloader.java b/app/src/full/java/org/fdroid/fdroid/net/TreeUriDownloader.java new file mode 100644 index 000000000..160667dc6 --- /dev/null +++ b/app/src/full/java/org/fdroid/fdroid/net/TreeUriDownloader.java @@ -0,0 +1,114 @@ +package org.fdroid.fdroid.net; + +import android.content.Context; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.documentfile.provider.DocumentFile; + +import org.fdroid.IndexFile; +import org.fdroid.download.Downloader; +import org.fdroid.download.NotFoundException; +import org.fdroid.fdroid.FDroidApp; + +import java.io.BufferedInputStream; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.net.ProtocolException; + +/** + * An {@link Downloader} subclass for downloading files from a repo on a + * removable storage device like an SD Card or USB OTG thumb drive using the + * Storage Access Framework. Permission must first be granted by the user via a + * {@link android.content.Intent#ACTION_OPEN_DOCUMENT_TREE} or + * {@link android.os.storage.StorageVolume#createAccessIntent(String)}request, + * then F-Droid will have permanent access to that{@link Uri}. + *

    + * The base repo URL of such a repo looks like: + * {@code content://com.android.externalstorage.documents/tree/1AFB-2402%3A/document/1AFB-2402%3Atesty.at.or.at%2Ffdroid%2Frepo} + * + * @see DocumentFile#fromTreeUri(Context, Uri) + * @see Open Files using Storage Access Framework + * @see Using Scoped Directory Access + */ +public class TreeUriDownloader extends Downloader { + public static final String TAG = "TreeUriDownloader"; + + /** + * Whoever designed this {@link android.provider.DocumentsContract#isTreeUri(Uri) URI system} + * was smoking crack, it escapes part of the URI path, but not all. + * So crazy tricks are required. + */ + public static final String ESCAPED_SLASH = "%2F"; + + private final Context context; + private final Uri treeUri; + private final DocumentFile documentFile; + + TreeUriDownloader(Uri uri, IndexFile indexFile, File destFile) { + super(indexFile, destFile); + context = FDroidApp.getInstance(); + String path = uri.getEncodedPath(); + int lastEscapedSlash = path.lastIndexOf(ESCAPED_SLASH); + String pathChunkToEscape = path.substring(lastEscapedSlash + ESCAPED_SLASH.length()); + String escapedPathChunk = Uri.encode(pathChunkToEscape); + treeUri = uri.buildUpon().encodedPath(path.replace(pathChunkToEscape, escapedPathChunk)).build(); + documentFile = DocumentFile.fromTreeUri(context, treeUri); + } + + /** + * This needs to convert {@link FileNotFoundException} and + * {@link IllegalArgumentException} to {@link ProtocolException} since the mirror + * failover logic expects network errors, not filesystem or other errors. + * In the downloading logic, filesystem errors are related to the file as it is + * being downloaded and written to disk. Things can fail here if the USB stick is + * not longer plugged in, the files were deleted by some other process, etc. + *

    + * Example: {@code IllegalArgumentException: Failed to determine if + * 6EED-6A10:guardianproject.info/wind-demo/fdroid/repo/index-v1.jar is child of + * 6EED-6A10:: java.io.File NotFoundException: No root for 6EED-6A10} + *

    + * Example: + */ + @NonNull + @Override + protected InputStream getInputStream(boolean resumable) throws IOException, NotFoundException { + try { + InputStream inputStream = context.getContentResolver().openInputStream(treeUri); + if (inputStream == null) { + throw new IOException("InputStream was null"); + } else { + return new BufferedInputStream(inputStream); + } + } catch (FileNotFoundException e) { + throw new NotFoundException(); + } catch (IllegalArgumentException e) { + if (e.getMessage() != null && e.getMessage().contains("FileNotFoundException")) { + // document providers have a weird way of saying 404 + throw new NotFoundException(); + } + throw new ProtocolException(e.getLocalizedMessage()); + } + } + + @Override + public boolean hasChanged() { + return true; // TODO how should this actually be implemented? + } + + @Override + protected long totalDownloadSize() { + return getIndexFile().getSize() != null ? getIndexFile().getSize() : documentFile.length(); + } + + @Override + public void download() throws IOException, InterruptedException { + downloadFromStream(false); + } + + @Override + public void close() { + } +} diff --git a/app/src/full/java/org/fdroid/fdroid/qr/CameraCharacteristicsChecker.java b/app/src/full/java/org/fdroid/fdroid/qr/CameraCharacteristicsChecker.java new file mode 100644 index 000000000..0f8d07ea7 --- /dev/null +++ b/app/src/full/java/org/fdroid/fdroid/qr/CameraCharacteristicsChecker.java @@ -0,0 +1,17 @@ +package org.fdroid.fdroid.qr; + +import android.content.Context; + +public abstract class CameraCharacteristicsChecker { + public static CameraCharacteristicsChecker getInstance(final Context context) { + return new CameraCharacteristicsMinApiLevel21(context); + } + + public abstract boolean hasAutofocus(); + + static class FDroidDeviceException extends Exception { + FDroidDeviceException(final String message, final Throwable cause) { + super(message, cause); + } + } +} diff --git a/app/src/full/java/org/fdroid/fdroid/qr/CameraCharacteristicsMinApiLevel21.java b/app/src/full/java/org/fdroid/fdroid/qr/CameraCharacteristicsMinApiLevel21.java new file mode 100644 index 000000000..c5088e7f6 --- /dev/null +++ b/app/src/full/java/org/fdroid/fdroid/qr/CameraCharacteristicsMinApiLevel21.java @@ -0,0 +1,108 @@ +package org.fdroid.fdroid.qr; + +import android.content.Context; +import android.hardware.camera2.CameraAccessException; +import android.hardware.camera2.CameraCharacteristics; +import android.hardware.camera2.CameraManager; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.core.content.ContextCompat; + +public class CameraCharacteristicsMinApiLevel21 extends CameraCharacteristicsChecker { + + private static final String TAG = "CameraCharMinApiLevel21"; + private final CameraManager cameraManager; + + CameraCharacteristicsMinApiLevel21(final Context context) { + this.cameraManager = ContextCompat.getSystemService(context, CameraManager.class); + } + + @Override + public boolean hasAutofocus() { + boolean hasAutofocus = false; + try { + hasAutofocus = hasDeviceAutofocus(); + } catch (FDroidDeviceException e) { + Log.e(TAG, e.getMessage(), e); + } + return hasAutofocus; + } + + private boolean hasDeviceAutofocus() throws FDroidDeviceException { + try { + boolean deviceHasAutofocus = false; + final String[] cameraIdList = getCameraIdList(); + + for (final String cameraId : cameraIdList) { + if (isLensFacingBack(cameraId)) { + deviceHasAutofocus = testAutofocusModeForCamera(cameraId); + break; + } + } + return deviceHasAutofocus; + } catch (Exception e) { + Log.e(TAG, e.getMessage(), e); + throw new FDroidDeviceException("Exception accessing the camera list", e); + } + } + + @NonNull + private String[] getCameraIdList() throws FDroidDeviceException { + try { + return cameraManager.getCameraIdList(); + } catch (CameraAccessException e) { + Log.e(TAG, e.getMessage(), e); + throw new FDroidDeviceException("Exception accessing the camera list", e); + } + } + + private boolean isLensFacingBack(final String cameraId) throws FDroidDeviceException { + final Integer lensFacing = getCameraCharacteristics(cameraId).get(CameraCharacteristics.LENS_FACING); + + return lensFacing != null && lensFacing == CameraCharacteristics.LENS_FACING_BACK; + } + + @NonNull + private CameraCharacteristics getCameraCharacteristics(final String cameraId) throws FDroidDeviceException { + try { + return cameraManager.getCameraCharacteristics(cameraId); + } catch (CameraAccessException e) { + Log.e(TAG, e.getMessage(), e); + throw new FDroidDeviceException("Exception accessing the camera id = " + cameraId, e); + } + + } + + private boolean testAutofocusModeForCamera(final String cameraId) throws FDroidDeviceException { + try { + boolean hasAutofocusMode = false; + final int[] autoFocusModes = getAvailableAFModes(cameraId); + if (autoFocusModes != null) { + hasAutofocusMode = testAvailableMode(autoFocusModes); + } + + return hasAutofocusMode; + } catch (FDroidDeviceException e) { + Log.e(TAG, e.getMessage(), e); + throw new FDroidDeviceException("Exception accessing the camera id = " + cameraId, e); + } + } + + private int[] getAvailableAFModes(final String cameraId) throws FDroidDeviceException { + return getCameraCharacteristics(cameraId).get(CameraCharacteristics.CONTROL_AF_AVAILABLE_MODES); + } + + private boolean testAvailableMode(final int[] autoFocusModes) { + boolean hasAutofocusMode = false; + for (final int mode : autoFocusModes) { + boolean afMode = isAutofocus(mode); + hasAutofocusMode |= afMode; + } + return hasAutofocusMode; + } + + private boolean isAutofocus(final int mode) { + return mode != android.hardware.camera2.CameraMetadata.CONTROL_AF_MODE_OFF; + } +} diff --git a/app/src/full/java/org/fdroid/fdroid/views/main/NearbyViewBinder.java b/app/src/full/java/org/fdroid/fdroid/views/main/NearbyViewBinder.java new file mode 100644 index 000000000..8bb68b1d3 --- /dev/null +++ b/app/src/full/java/org/fdroid/fdroid/views/main/NearbyViewBinder.java @@ -0,0 +1,252 @@ +package org.fdroid.fdroid.views.main; + +import android.Manifest; +import android.content.Context; +import android.content.Intent; +import android.content.UriPermission; +import android.content.pm.PackageManager; +import android.hardware.usb.UsbDevice; +import android.hardware.usb.UsbManager; +import android.net.Uri; +import android.os.Build; +import android.os.Environment; +import android.os.storage.StorageManager; +import android.os.storage.StorageVolume; +import android.provider.DocumentsContract; +import android.provider.Settings; +import android.text.TextUtils; +import android.util.Log; +import android.view.View; +import android.widget.Button; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.app.ActivityCompat; +import androidx.core.content.ContextCompat; + +import org.fdroid.R; +import org.fdroid.fdroid.Utils; +import org.fdroid.fdroid.nearby.SDCardScannerService; +import org.fdroid.fdroid.nearby.SwapService; +import org.fdroid.fdroid.nearby.TreeUriScannerIntentService; + +import java.io.File; +import java.util.List; +import java.util.concurrent.Executor; + +/** + * A splash screen encouraging people to start the swap process. The swap + * process is quite heavy duty in that it fires up Bluetooth and/or WiFi + * in order to scan for peers. As such, it is quite convenient to have a + * more lightweight view to show in the main navigation that doesn't + * automatically start doing things when the user touches the navigation + * menu in the bottom navigation. + *

    + * Lots of pieces of the nearby/swap functionality require that the user grant + * F-Droid permissions at runtime on {@code android-23} and higher. On devices + * that have a removable SD Card that is currently mounted, this will request + * permission to read it, so that F-Droid can look for repos on the SD Card. + *

    + * Once {@link Manifest.permission#READ_EXTERNAL_STORAGE} is granted to F-Droid, + * then it can read any file on an SD Card and no more prompts are needed. For + * USB OTG drives, the only way to get read permissions is to prompt the user + * via {@link Intent#ACTION_OPEN_DOCUMENT_TREE}. + *

    + * + * @see TreeUriScannerIntentService + * @see SDCardScannerService + *

    + * TODO use {@link StorageManager#registerStorageVolumeCallback(Executor, StorageManager.StorageVolumeCallback)} + */ +public class NearbyViewBinder { + public static final String TAG = "NearbyViewBinder"; + + static final int REQUEST_LOCATION_PERMISSIONS = 0xEF0F; + static final int REQUEST_STORAGE_PERMISSIONS = 0xB004; + static final int REQUEST_STORAGE_ACCESS = 0x40E5; + + private static File externalStorage = null; + private static View swapView; + + NearbyViewBinder(final AppCompatActivity activity, FrameLayout parent) { + swapView = activity.getLayoutInflater().inflate(R.layout.main_tab_nearby, parent, true); + + TextView subtext = swapView.findViewById(R.id.both_parties_need_fdroid_text); + subtext.setText(activity.getString(R.string.nearby_splash__both_parties_need_fdroid, + activity.getString(R.string.app_name))); + + Button startButton = swapView.findViewById(R.id.find_people_button); + startButton.setOnClickListener(v -> { + final String coarseLocation = Manifest.permission.ACCESS_COARSE_LOCATION; + if (Build.VERSION.SDK_INT <= 30 && PackageManager.PERMISSION_GRANTED != ContextCompat.checkSelfPermission(activity, coarseLocation)) { + ActivityCompat.requestPermissions(activity, new String[]{coarseLocation}, + REQUEST_LOCATION_PERMISSIONS); + } else { + ContextCompat.startForegroundService(activity, new Intent(activity, SwapService.class)); + } + }); + + updateExternalStorageViews(activity); + updateUsbOtg(activity); + } + + public static void updateExternalStorageViews(final AppCompatActivity activity) { + if (swapView == null || activity == null) { + return; + } + + ImageView nearbySplash = swapView.findViewById(R.id.image); + TextView explainer = swapView.findViewById(R.id.read_external_storage_text); + Button button = swapView.findViewById(R.id.request_read_external_storage_button); + + if (nearbySplash == null || explainer == null || button == null) { + return; + } + + File[] dirs = activity.getExternalFilesDirs(""); + if (dirs != null) { + for (File dir : dirs) { + if (dir != null && Environment.isExternalStorageRemovable(dir)) { + String state = Environment.getExternalStorageState(dir); + if (Environment.MEDIA_MOUNTED_READ_ONLY.equals(state) + || Environment.MEDIA_MOUNTED.equals(state)) { + // remove Android/data/org.fdroid.fdroid/files to get root + externalStorage = dir.getParentFile().getParentFile().getParentFile().getParentFile(); + break; + } + } + } + } + + final String readExternalStorage = Manifest.permission.READ_EXTERNAL_STORAGE; + + if (externalStorage != null) { + nearbySplash.setVisibility(View.GONE); + explainer.setVisibility(View.VISIBLE); + button.setVisibility(View.VISIBLE); + if (Build.VERSION.SDK_INT >= 30) { + if (!Environment.isExternalStorageManager()) { + // we don't have permission to access files yet, so ask for it + explainer.setText(R.string.nearby_splach__external_storage_permission_explainer); + button.setText(R.string.nearby_splace__external_storage_permission_button); + button.setOnClickListener(view -> activity.startActivity( + new Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, + Uri.parse(String.format("package:%s", + activity.getPackageName()))))); + } else { + explainer.setText(R.string.nearby_splash__read_external_storage); + button.setText(R.string.nearby_splash__request_permission); + button.setOnClickListener(view -> scanExternalStorageNow(activity)); + } + } else { + if ((externalStorage == null || !externalStorage.canRead()) + && PackageManager.PERMISSION_GRANTED + != ContextCompat.checkSelfPermission(activity, readExternalStorage)) { + explainer.setText(R.string.nearby_splach__external_storage_permission_explainer); + button.setText(R.string.nearby_splace__external_storage_permission_button); + button.setOnClickListener(v -> { + ActivityCompat.requestPermissions(activity, new String[]{readExternalStorage}, + REQUEST_STORAGE_PERMISSIONS); + }); + } else { + explainer.setText(R.string.nearby_splash__read_external_storage); + button.setText(R.string.nearby_splash__request_permission); + button.setOnClickListener(view -> scanExternalStorageNow(activity)); + } + } + } + } + + private static void scanExternalStorageNow(final AppCompatActivity activity) { + Toast.makeText(activity, activity.getString(R.string.scan_removable_storage_toast, externalStorage), + Toast.LENGTH_SHORT).show(); + SDCardScannerService.scan(activity); + } + + public static void updateUsbOtg(final Context context) { + if (Build.VERSION.SDK_INT < 24) { + return; + } + if (swapView == null) { + Utils.debugLog(TAG, "swapView == null"); + return; + } + TextView storageVolumeText = swapView.findViewById(R.id.storage_volume_text); + Button requestStorageVolume = swapView.findViewById(R.id.request_storage_volume_button); + storageVolumeText.setVisibility(View.GONE); + requestStorageVolume.setVisibility(View.GONE); + + final StorageManager storageManager = ContextCompat.getSystemService(context, StorageManager.class); + for (final StorageVolume storageVolume : storageManager.getStorageVolumes()) { + if (storageVolume.isRemovable() && !storageVolume.isPrimary()) { + Log.i(TAG, "StorageVolume: " + storageVolume); + Intent tmpIntent = null; + if (Build.VERSION.SDK_INT < 29) { + tmpIntent = storageVolume.createAccessIntent(null); + } else { + tmpIntent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE); + tmpIntent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, + Uri.parse("content://" + + TreeUriScannerIntentService.EXTERNAL_STORAGE_PROVIDER_AUTHORITY + + "/tree/" + + storageVolume.getUuid() + + "%3A/document/" + + storageVolume.getUuid() + + "%3A")); + } + if (tmpIntent == null) { + Utils.debugLog(TAG, "Got null Storage Volume access Intent"); + return; + } + final Intent intent = tmpIntent; + + storageVolumeText.setVisibility(View.VISIBLE); + + String text = storageVolume.getDescription(context); + if (!TextUtils.isEmpty(text)) { + requestStorageVolume.setText(text); + UsbDevice usb = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE); + if (usb != null) { + text = String.format("%s (%s %s)", text, usb.getManufacturerName(), usb.getProductName()); + Toast.makeText(context, text, Toast.LENGTH_LONG).show(); + } + } + + requestStorageVolume.setVisibility(View.VISIBLE); + requestStorageVolume.setOnClickListener(v -> { + List list = context.getContentResolver().getPersistedUriPermissions(); + if (list != null) { + for (UriPermission uriPermission : list) { + Uri uri = uriPermission.getUri(); + if (uri.getPath().equals(String.format("/tree/%s:", storageVolume.getUuid()))) { + intent.setData(uri); + TreeUriScannerIntentService.onActivityResult(context, intent); + return; + } + } + } + + AppCompatActivity activity = null; + if (context instanceof AppCompatActivity) { + activity = (AppCompatActivity) context; + } else if (swapView != null && swapView.getContext() instanceof AppCompatActivity) { + activity = (AppCompatActivity) swapView.getContext(); + } + + if (activity != null) { + activity.startActivityForResult(intent, REQUEST_STORAGE_ACCESS); + } else { + // scan in the background without requesting permissions + Toast.makeText(context.getApplicationContext(), + context.getString(R.string.scan_removable_storage_toast, externalStorage), + Toast.LENGTH_SHORT).show(); + SDCardScannerService.scan(context); + } + }); + } + } + } +} diff --git a/app/src/full/java/vendored/org/apache/commons/codec/BinaryDecoder.java b/app/src/full/java/vendored/org/apache/commons/codec/BinaryDecoder.java new file mode 100644 index 000000000..85239aa20 --- /dev/null +++ b/app/src/full/java/vendored/org/apache/commons/codec/BinaryDecoder.java @@ -0,0 +1,37 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 vendored.org.apache.commons.codec; + +/** + * Defines common decoding methods for byte array decoders. + * + */ +public interface BinaryDecoder extends Decoder { + + /** + * Decodes a byte array and returns the results as a byte array. + * + * @param source + * A byte array which has been encoded with the appropriate encoder + * @return a byte array that contains decoded content + * @throws DecoderException + * A decoder exception is thrown if a Decoder encounters a failure condition during the decode process. + */ + byte[] decode(byte[] source) throws DecoderException; +} + diff --git a/app/src/full/java/vendored/org/apache/commons/codec/BinaryEncoder.java b/app/src/full/java/vendored/org/apache/commons/codec/BinaryEncoder.java new file mode 100644 index 000000000..9bb8dc02d --- /dev/null +++ b/app/src/full/java/vendored/org/apache/commons/codec/BinaryEncoder.java @@ -0,0 +1,37 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 vendored.org.apache.commons.codec; + +/** + * Defines common encoding methods for byte array encoders. + * + */ +public interface BinaryEncoder extends Encoder { + + /** + * Encodes a byte array and return the encoded data as a byte array. + * + * @param source + * Data to be encoded + * @return A byte array containing the encoded data + * @throws EncoderException + * thrown if the Encoder encounters a failure condition during the encoding process. + */ + byte[] encode(byte[] source) throws EncoderException; +} + diff --git a/app/src/full/java/vendored/org/apache/commons/codec/CharEncoding.java b/app/src/full/java/vendored/org/apache/commons/codec/CharEncoding.java new file mode 100644 index 000000000..9e5ac8b47 --- /dev/null +++ b/app/src/full/java/vendored/org/apache/commons/codec/CharEncoding.java @@ -0,0 +1,119 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 vendored.org.apache.commons.codec; + +/** + * Character encoding names required of every implementation of the Java platform. + * + * From the Java documentation Standard charsets: + *

    + * Every implementation of the Java platform is required to support the following character encodings. Consult the + * release documentation for your implementation to see if any other encodings are supported. Consult the release + * documentation for your implementation to see if any other encodings are supported. + *

    + * + *
      + *
    • {@code US-ASCII}

      + * Seven-bit ASCII, a.k.a. ISO646-US, a.k.a. the Basic Latin block of the Unicode character set.

    • + *
    • {@code ISO-8859-1}

      + * ISO Latin Alphabet No. 1, a.k.a. ISO-LATIN-1.

    • + *
    • {@code UTF-8}

      + * Eight-bit Unicode Transformation Format.

    • + *
    • {@code UTF-16BE}

      + * Sixteen-bit Unicode Transformation Format, big-endian byte order.

    • + *
    • {@code UTF-16LE}

      + * Sixteen-bit Unicode Transformation Format, little-endian byte order.

    • + *
    • {@code UTF-16}

      + * Sixteen-bit Unicode Transformation Format, byte order specified by a mandatory initial byte-order mark (either order + * accepted on input, big-endian used on output.)

    • + *
    + * + * This perhaps would best belong in the [lang] project. Even if a similar interface is defined in [lang], it is not + * foreseen that [codec] would be made to depend on [lang]. + * + *

    + * This class is immutable and thread-safe. + *

    + * + * @see Standard charsets + * @since 1.4 + */ +public class CharEncoding { + + /** + * CharEncodingISO Latin Alphabet No. 1, a.k.a. ISO-LATIN-1. + *

    + * Every implementation of the Java platform is required to support this character encoding. + *

    + * + * @see Standard charsets + */ + public static final String ISO_8859_1 = "ISO-8859-1"; + + /** + * Seven-bit ASCII, also known as ISO646-US, also known as the Basic Latin block of the Unicode character set. + *

    + * Every implementation of the Java platform is required to support this character encoding. + *

    + * + * @see Standard charsets + */ + public static final String US_ASCII = "US-ASCII"; + + /** + * Sixteen-bit Unicode Transformation Format, The byte order specified by a mandatory initial byte-order mark + * (either order accepted on input, big-endian used on output) + *

    + * Every implementation of the Java platform is required to support this character encoding. + *

    + * + * @see Standard charsets + */ + public static final String UTF_16 = "UTF-16"; + + /** + * Sixteen-bit Unicode Transformation Format, big-endian byte order. + *

    + * Every implementation of the Java platform is required to support this character encoding. + *

    + * + * @see Standard charsets + */ + public static final String UTF_16BE = "UTF-16BE"; + + /** + * Sixteen-bit Unicode Transformation Format, little-endian byte order. + *

    + * Every implementation of the Java platform is required to support this character encoding. + *

    + * + * @see Standard charsets + */ + public static final String UTF_16LE = "UTF-16LE"; + + /** + * Eight-bit Unicode Transformation Format. + *

    + * Every implementation of the Java platform is required to support this character encoding. + *

    + * + * @see Standard charsets + */ + public static final String UTF_8 = "UTF-8"; +} diff --git a/app/src/full/java/vendored/org/apache/commons/codec/Decoder.java b/app/src/full/java/vendored/org/apache/commons/codec/Decoder.java new file mode 100644 index 000000000..79578ee7a --- /dev/null +++ b/app/src/full/java/vendored/org/apache/commons/codec/Decoder.java @@ -0,0 +1,46 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 vendored.org.apache.commons.codec; + +/** + * Provides the highest level of abstraction for Decoders. + *

    + * This is the sister interface of {@link Encoder}. All Decoders implement this common generic interface. + * Allows a user to pass a generic Object to any Decoder implementation in the codec package. + *

    + * One of the two interfaces at the center of the codec package. + * + */ +public interface Decoder { + + /** + * Decodes an "encoded" Object and returns a "decoded" Object. Note that the implementation of this interface will + * try to cast the Object parameter to the specific type expected by a particular Decoder implementation. If a + * {@link ClassCastException} occurs this decode method will throw a DecoderException. + * + * @param source + * the object to decode + * @return a 'decoded" object + * @throws DecoderException + * a decoder exception can be thrown for any number of reasons. Some good candidates are that the + * parameter passed to this method is null, a param cannot be cast to the appropriate type for a + * specific encoder. + */ + Object decode(Object source) throws DecoderException; +} + diff --git a/app/src/full/java/vendored/org/apache/commons/codec/DecoderException.java b/app/src/full/java/vendored/org/apache/commons/codec/DecoderException.java new file mode 100644 index 000000000..ee9351927 --- /dev/null +++ b/app/src/full/java/vendored/org/apache/commons/codec/DecoderException.java @@ -0,0 +1,84 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 vendored.org.apache.commons.codec; + +/** + * Thrown when there is a failure condition during the decoding process. This exception is thrown when a {@link Decoder} + * encounters a decoding specific exception such as invalid data, or characters outside of the expected range. + * + */ +public class DecoderException extends Exception { + + /** + * Declares the Serial Version Uid. + * + * @see Always Declare Serial Version Uid + */ + private static final long serialVersionUID = 1L; + + /** + * Constructs a new exception with {@code null} as its detail message. The cause is not initialized, and may + * subsequently be initialized by a call to {@link #initCause}. + * + * @since 1.4 + */ + public DecoderException() { + } + + /** + * Constructs a new exception with the specified detail message. The cause is not initialized, and may subsequently + * be initialized by a call to {@link #initCause}. + * + * @param message + * The detail message which is saved for later retrieval by the {@link #getMessage()} method. + */ + public DecoderException(final String message) { + super(message); + } + + /** + * Constructs a new exception with the specified detail message and cause. + *

    + * Note that the detail message associated with {@code cause} is not automatically incorporated into this + * exception's detail message. + * + * @param message + * The detail message which is saved for later retrieval by the {@link #getMessage()} method. + * @param cause + * The cause which is saved for later retrieval by the {@link #getCause()} method. A {@code null} + * value is permitted, and indicates that the cause is nonexistent or unknown. + * @since 1.4 + */ + public DecoderException(final String message, final Throwable cause) { + super(message, cause); + } + + /** + * Constructs a new exception with the specified cause and a detail message of (cause==null ? + * null : cause.toString()) (which typically contains the class and detail message of {@code cause}). + * This constructor is useful for exceptions that are little more than wrappers for other throwables. + * + * @param cause + * The cause which is saved for later retrieval by the {@link #getCause()} method. A {@code null} + * value is permitted, and indicates that the cause is nonexistent or unknown. + * @since 1.4 + */ + public DecoderException(final Throwable cause) { + super(cause); + } +} diff --git a/app/src/full/java/vendored/org/apache/commons/codec/Encoder.java b/app/src/full/java/vendored/org/apache/commons/codec/Encoder.java new file mode 100644 index 000000000..b8b15a153 --- /dev/null +++ b/app/src/full/java/vendored/org/apache/commons/codec/Encoder.java @@ -0,0 +1,43 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 vendored.org.apache.commons.codec; + +/** + * Provides the highest level of abstraction for Encoders. + *

    + * This is the sister interface of {@link Decoder}. Every implementation of Encoder provides this + * common generic interface which allows a user to pass a generic Object to any Encoder implementation + * in the codec package. + * + */ +public interface Encoder { + + /** + * Encodes an "Object" and returns the encoded content as an Object. The Objects here may just be + * {@code byte[]} or {@code String}s depending on the implementation used. + * + * @param source + * An object to encode + * @return An "encoded" Object + * @throws EncoderException + * An encoder exception is thrown if the encoder experiences a failure condition during the encoding + * process. + */ + Object encode(Object source) throws EncoderException; +} + diff --git a/app/src/full/java/vendored/org/apache/commons/codec/EncoderException.java b/app/src/full/java/vendored/org/apache/commons/codec/EncoderException.java new file mode 100644 index 000000000..b50c4cfb8 --- /dev/null +++ b/app/src/full/java/vendored/org/apache/commons/codec/EncoderException.java @@ -0,0 +1,87 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 vendored.org.apache.commons.codec; + +/** + * Thrown when there is a failure condition during the encoding process. This exception is thrown when an + * {@link Encoder} encounters a encoding specific exception such as invalid data, inability to calculate a checksum, + * characters outside of the expected range. + * + */ +public class EncoderException extends Exception { + + /** + * Declares the Serial Version Uid. + * + * @see Always Declare Serial Version Uid + */ + private static final long serialVersionUID = 1L; + + /** + * Constructs a new exception with {@code null} as its detail message. The cause is not initialized, and may + * subsequently be initialized by a call to {@link #initCause}. + * + * @since 1.4 + */ + public EncoderException() { + } + + /** + * Constructs a new exception with the specified detail message. The cause is not initialized, and may subsequently + * be initialized by a call to {@link #initCause}. + * + * @param message + * a useful message relating to the encoder specific error. + */ + public EncoderException(final String message) { + super(message); + } + + /** + * Constructs a new exception with the specified detail message and cause. + * + *

    + * Note that the detail message associated with {@code cause} is not automatically incorporated into this + * exception's detail message. + *

    + * + * @param message + * The detail message which is saved for later retrieval by the {@link #getMessage()} method. + * @param cause + * The cause which is saved for later retrieval by the {@link #getCause()} method. A {@code null} + * value is permitted, and indicates that the cause is nonexistent or unknown. + * @since 1.4 + */ + public EncoderException(final String message, final Throwable cause) { + super(message, cause); + } + + /** + * Constructs a new exception with the specified cause and a detail message of (cause==null ? + * null : cause.toString()) (which typically contains the class and detail message of {@code cause}). + * This constructor is useful for exceptions that are little more than wrappers for other throwables. + * + * @param cause + * The cause which is saved for later retrieval by the {@link #getCause()} method. A {@code null} + * value is permitted, and indicates that the cause is nonexistent or unknown. + * @since 1.4 + */ + public EncoderException(final Throwable cause) { + super(cause); + } +} diff --git a/app/src/full/java/vendored/org/apache/commons/codec/binary/CharSequenceUtils.java b/app/src/full/java/vendored/org/apache/commons/codec/binary/CharSequenceUtils.java new file mode 100644 index 000000000..49f867bb0 --- /dev/null +++ b/app/src/full/java/vendored/org/apache/commons/codec/binary/CharSequenceUtils.java @@ -0,0 +1,82 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 vendored.org.apache.commons.codec.binary; + +/** + *

    + * Operations on {@link CharSequence} that are {@code null} safe. + *

    + *

    + * Copied from Apache Commons Lang r1586295 on April 10, 2014 (day of 3.3.2 release). + *

    + * + * @see CharSequence + * @since 1.10 + */ +public class CharSequenceUtils { + + /** + * Green implementation of regionMatches. + * + *

    Note: This function differs from the current implementation in Apache Commons Lang + * where the input indices are not valid. It is only used within this package. + * + * @param cs + * the {@code CharSequence} to be processed + * @param ignoreCase + * whether or not to be case insensitive + * @param thisStart + * the index to start on the {@code cs} CharSequence + * @param substring + * the {@code CharSequence} to be looked for + * @param start + * the index to start on the {@code substring} CharSequence + * @param length + * character length of the region + * @return whether the region matched + */ + static boolean regionMatches(final CharSequence cs, final boolean ignoreCase, final int thisStart, + final CharSequence substring, final int start, final int length) { + if (cs instanceof String && substring instanceof String) { + return ((String) cs).regionMatches(ignoreCase, thisStart, (String) substring, start, length); + } + int index1 = thisStart; // NOPMD + int index2 = start; // NOPMD + int tmpLen = length; // NOPMD + + while (tmpLen-- > 0) { // NOPMD + final char c1 = cs.charAt(index1++); // NOPMD + final char c2 = substring.charAt(index2++); // NOPMD + + if (c1 == c2) { + continue; + } + + if (!ignoreCase) { + return false; + } + + // The same check as in String.regionMatches(): + if (Character.toUpperCase(c1) != Character.toUpperCase(c2) && + Character.toLowerCase(c1) != Character.toLowerCase(c2)) { + return false; + } + } + + return true; + } +} diff --git a/app/src/full/java/vendored/org/apache/commons/codec/binary/Hex.java b/app/src/full/java/vendored/org/apache/commons/codec/binary/Hex.java new file mode 100644 index 000000000..319bc3a6a --- /dev/null +++ b/app/src/full/java/vendored/org/apache/commons/codec/binary/Hex.java @@ -0,0 +1,567 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 vendored.org.apache.commons.codec.binary; + +import java.nio.ByteBuffer; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; + +import vendored.org.apache.commons.codec.BinaryDecoder; +import vendored.org.apache.commons.codec.BinaryEncoder; +import vendored.org.apache.commons.codec.CharEncoding; +import vendored.org.apache.commons.codec.DecoderException; +import vendored.org.apache.commons.codec.EncoderException; + +/** + * Converts hexadecimal Strings. The Charset used for certain operation can be set, the default is set in + * {@link #DEFAULT_CHARSET_NAME} + * + * This class is thread-safe. + * + * @since 1.1 + */ +public class Hex implements BinaryEncoder, BinaryDecoder { + + /** + * Default charset is {@link StandardCharsets#UTF_8}. + * + * @since 1.7 + */ + public static final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8; + + /** + * Default charset name is {@link CharEncoding#UTF_8}. + * + * @since 1.4 + */ + public static final String DEFAULT_CHARSET_NAME = CharEncoding.UTF_8; + + /** + * Used to build output as hex. + */ + private static final char[] DIGITS_LOWER = { '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', + 'e', 'f' }; + + /** + * Used to build output as hex. + */ + private static final char[] DIGITS_UPPER = { '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', + 'E', 'F' }; + + /** + * Converts an array of characters representing hexadecimal values into an array of bytes of those same values. The + * returned array will be half the length of the passed array, as it takes two characters to represent any given + * byte. An exception is thrown if the passed char array has an odd number of elements. + * + * @param data An array of characters containing hexadecimal digits + * @return A byte array containing binary data decoded from the supplied char array. + * @throws DecoderException Thrown if an odd number of characters or illegal characters are supplied + */ + public static byte[] decodeHex(final char[] data) throws DecoderException { + final byte[] out = new byte[data.length >> 1]; + decodeHex(data, out, 0); + return out; + } + + /** + * Converts an array of characters representing hexadecimal values into an array of bytes of those same values. The + * returned array will be half the length of the passed array, as it takes two characters to represent any given + * byte. An exception is thrown if the passed char array has an odd number of elements. + * + * @param data An array of characters containing hexadecimal digits + * @param out A byte array to contain the binary data decoded from the supplied char array. + * @param outOffset The position within {@code out} to start writing the decoded bytes. + * @return the number of bytes written to {@code out}. + * @throws DecoderException Thrown if an odd number of characters or illegal characters are supplied + * @since 1.15 + */ + public static int decodeHex(final char[] data, final byte[] out, final int outOffset) throws DecoderException { + final int len = data.length; + + if ((len & 0x01) != 0) { + throw new DecoderException("Odd number of characters."); + } + + final int outLen = len >> 1; + if (out.length - outOffset < outLen) { + throw new DecoderException("Output array is not large enough to accommodate decoded data."); + } + + // two characters form the hex value. + for (int i = outOffset, j = 0; j < len; i++) { // NOPMD + int f = toDigit(data[j], j) << 4; // NOPMD + j++; // NOPMD + f = f | toDigit(data[j], j); // NOPMD + j++; // NOPMD + out[i] = (byte) (f & 0xFF); + } + + return outLen; + } + + /** + * Converts a String representing hexadecimal values into an array of bytes of those same values. The returned array + * will be half the length of the passed String, as it takes two characters to represent any given byte. An + * exception is thrown if the passed String has an odd number of elements. + * + * @param data A String containing hexadecimal digits + * @return A byte array containing binary data decoded from the supplied char array. + * @throws DecoderException Thrown if an odd number of characters or illegal characters are supplied + * @since 1.11 + */ + public static byte[] decodeHex(final String data) throws DecoderException { + return decodeHex(data.toCharArray()); + } + + /** + * Converts an array of bytes into an array of characters representing the hexadecimal values of each byte in order. + * The returned array will be double the length of the passed array, as it takes two characters to represent any + * given byte. + * + * @param data a byte[] to convert to hex characters + * @return A char[] containing lower-case hexadecimal characters + */ + public static char[] encodeHex(final byte[] data) { + return encodeHex(data, true); + } + + /** + * Converts an array of bytes into an array of characters representing the hexadecimal values of each byte in order. + * The returned array will be double the length of the passed array, as it takes two characters to represent any + * given byte. + * + * @param data a byte[] to convert to Hex characters + * @param toLowerCase {@code true} converts to lowercase, {@code false} to uppercase + * @return A char[] containing hexadecimal characters in the selected case + * @since 1.4 + */ + public static char[] encodeHex(final byte[] data, final boolean toLowerCase) { + return encodeHex(data, toLowerCase ? DIGITS_LOWER : DIGITS_UPPER); + } + + /** + * Converts an array of bytes into an array of characters representing the hexadecimal values of each byte in order. + * The returned array will be double the length of the passed array, as it takes two characters to represent any + * given byte. + * + * @param data a byte[] to convert to hex characters + * @param toDigits the output alphabet (must contain at least 16 chars) + * @return A char[] containing the appropriate characters from the alphabet For best results, this should be either + * upper- or lower-case hex. + * @since 1.4 + */ + protected static char[] encodeHex(final byte[] data, final char[] toDigits) { + final int dataLength = data.length; + final char[] out = new char[dataLength << 1]; + encodeHex(data, 0, dataLength, toDigits, out, 0); + return out; + } + + /** + * Converts an array of bytes into an array of characters representing the hexadecimal values of each byte in order. + * + * @param data a byte[] to convert to hex characters + * @param dataOffset the position in {@code data} to start encoding from + * @param dataLen the number of bytes from {@code dataOffset} to encode + * @param toLowerCase {@code true} converts to lowercase, {@code false} to uppercase + * @return A char[] containing the appropriate characters from the alphabet For best results, this should be either + * upper- or lower-case hex. + * @since 1.15 + */ + public static char[] encodeHex(final byte[] data, final int dataOffset, final int dataLen, + final boolean toLowerCase) { + final char[] out = new char[dataLen << 1]; + encodeHex(data, dataOffset, dataLen, toLowerCase ? DIGITS_LOWER : DIGITS_UPPER, out, 0); + return out; + } + + /** + * Converts an array of bytes into an array of characters representing the hexadecimal values of each byte in order. + * + * @param data a byte[] to convert to hex characters + * @param dataOffset the position in {@code data} to start encoding from + * @param dataLen the number of bytes from {@code dataOffset} to encode + * @param toLowerCase {@code true} converts to lowercase, {@code false} to uppercase + * @param out a char[] which will hold the resultant appropriate characters from the alphabet. + * @param outOffset the position within {@code out} at which to start writing the encoded characters. + * @since 1.15 + */ + public static void encodeHex(final byte[] data, final int dataOffset, final int dataLen, + final boolean toLowerCase, final char[] out, final int outOffset) { + encodeHex(data, dataOffset, dataLen, toLowerCase ? DIGITS_LOWER : DIGITS_UPPER, out, outOffset); + } + + /** + * Converts an array of bytes into an array of characters representing the hexadecimal values of each byte in order. + * + * @param data a byte[] to convert to hex characters + * @param dataOffset the position in {@code data} to start encoding from + * @param dataLen the number of bytes from {@code dataOffset} to encode + * @param toDigits the output alphabet (must contain at least 16 chars) + * @param out a char[] which will hold the resultant appropriate characters from the alphabet. + * @param outOffset the position within {@code out} at which to start writing the encoded characters. + */ + private static void encodeHex(final byte[] data, final int dataOffset, final int dataLen, final char[] toDigits, + final char[] out, final int outOffset) { + // two characters form the hex value. + for (int i = dataOffset, j = outOffset; i < dataOffset + dataLen; i++) { // NOPMD + out[j++] = toDigits[(0xF0 & data[i]) >>> 4]; // NOPMD + out[j++] = toDigits[0x0F & data[i]]; // NOPMD + } + } + + /** + * Converts a byte buffer into an array of characters representing the hexadecimal values of each byte in order. The + * returned array will be double the length of the passed array, as it takes two characters to represent any given + * byte. + * + *

    All bytes identified by {@link ByteBuffer#remaining()} will be used; after this method + * the value {@link ByteBuffer#remaining() remaining()} will be zero.

    + * + * @param data a byte buffer to convert to hex characters + * @return A char[] containing lower-case hexadecimal characters + * @since 1.11 + */ + public static char[] encodeHex(final ByteBuffer data) { + return encodeHex(data, true); + } + + /** + * Converts a byte buffer into an array of characters representing the hexadecimal values of each byte in order. The + * returned array will be double the length of the passed array, as it takes two characters to represent any given + * byte. + * + *

    All bytes identified by {@link ByteBuffer#remaining()} will be used; after this method + * the value {@link ByteBuffer#remaining() remaining()} will be zero.

    + * + * @param data a byte buffer to convert to hex characters + * @param toLowerCase {@code true} converts to lowercase, {@code false} to uppercase + * @return A char[] containing hexadecimal characters in the selected case + * @since 1.11 + */ + public static char[] encodeHex(final ByteBuffer data, final boolean toLowerCase) { + return encodeHex(data, toLowerCase ? DIGITS_LOWER : DIGITS_UPPER); + } + + /** + * Converts a byte buffer into an array of characters representing the hexadecimal values of each byte in order. The + * returned array will be double the length of the passed array, as it takes two characters to represent any given + * byte. + * + *

    All bytes identified by {@link ByteBuffer#remaining()} will be used; after this method + * the value {@link ByteBuffer#remaining() remaining()} will be zero.

    + * + * @param byteBuffer a byte buffer to convert to hex characters + * @param toDigits the output alphabet (must be at least 16 characters) + * @return A char[] containing the appropriate characters from the alphabet For best results, this should be either + * upper- or lower-case hex. + * @since 1.11 + */ + protected static char[] encodeHex(final ByteBuffer byteBuffer, final char[] toDigits) { + return encodeHex(toByteArray(byteBuffer), toDigits); + } + + /** + * Converts an array of bytes into a String representing the hexadecimal values of each byte in order. The returned + * String will be double the length of the passed array, as it takes two characters to represent any given byte. + * + * @param data a byte[] to convert to hex characters + * @return A String containing lower-case hexadecimal characters + * @since 1.4 + */ + public static String encodeHexString(final byte[] data) { + return new String(encodeHex(data)); + } + + /** + * Converts an array of bytes into a String representing the hexadecimal values of each byte in order. The returned + * String will be double the length of the passed array, as it takes two characters to represent any given byte. + * + * @param data a byte[] to convert to hex characters + * @param toLowerCase {@code true} converts to lowercase, {@code false} to uppercase + * @return A String containing lower-case hexadecimal characters + * @since 1.11 + */ + public static String encodeHexString(final byte[] data, final boolean toLowerCase) { + return new String(encodeHex(data, toLowerCase)); + } + + /** + * Converts a byte buffer into a String representing the hexadecimal values of each byte in order. The returned + * String will be double the length of the passed array, as it takes two characters to represent any given byte. + * + *

    All bytes identified by {@link ByteBuffer#remaining()} will be used; after this method + * the value {@link ByteBuffer#remaining() remaining()} will be zero.

    + * + * @param data a byte buffer to convert to hex characters + * @return A String containing lower-case hexadecimal characters + * @since 1.11 + */ + public static String encodeHexString(final ByteBuffer data) { + return new String(encodeHex(data)); + } + + /** + * Converts a byte buffer into a String representing the hexadecimal values of each byte in order. The returned + * String will be double the length of the passed array, as it takes two characters to represent any given byte. + * + *

    All bytes identified by {@link ByteBuffer#remaining()} will be used; after this method + * the value {@link ByteBuffer#remaining() remaining()} will be zero.

    + * + * @param data a byte buffer to convert to hex characters + * @param toLowerCase {@code true} converts to lowercase, {@code false} to uppercase + * @return A String containing lower-case hexadecimal characters + * @since 1.11 + */ + public static String encodeHexString(final ByteBuffer data, final boolean toLowerCase) { + return new String(encodeHex(data, toLowerCase)); + } + + /** + * Convert the byte buffer to a byte array. All bytes identified by + * {@link ByteBuffer#remaining()} will be used. + * + * @param byteBuffer the byte buffer + * @return the byte[] + */ + private static byte[] toByteArray(final ByteBuffer byteBuffer) { + final int remaining = byteBuffer.remaining(); + // Use the underlying buffer if possible + if (byteBuffer.hasArray()) { + final byte[] byteArray = byteBuffer.array(); + if (remaining == byteArray.length) { + byteBuffer.position(remaining); + return byteArray; + } + } + // Copy the bytes + final byte[] byteArray = new byte[remaining]; + byteBuffer.get(byteArray); + return byteArray; + } + + /** + * Converts a hexadecimal character to an integer. + * + * @param ch A character to convert to an integer digit + * @param index The index of the character in the source + * @return An integer + * @throws DecoderException Thrown if ch is an illegal hex character + */ + protected static int toDigit(final char ch, final int index) throws DecoderException { + final int digit = Character.digit(ch, 16); + if (digit == -1) { + throw new DecoderException("Illegal hexadecimal character " + ch + " at index " + index); + } + return digit; + } + + private final Charset charset; + + /** + * Creates a new codec with the default charset name {@link #DEFAULT_CHARSET} + */ + public Hex() { + // use default encoding + this.charset = DEFAULT_CHARSET; + } + + /** + * Creates a new codec with the given Charset. + * + * @param charset the charset. + * @since 1.7 + */ + public Hex(final Charset charset) { + this.charset = charset; + } + + /** + * Creates a new codec with the given charset name. + * + * @param charsetName the charset name. + * @throws java.nio.charset.UnsupportedCharsetException If the named charset is unavailable + * @since 1.4 + * @since 1.7 throws UnsupportedCharsetException if the named charset is unavailable + */ + public Hex(final String charsetName) { + this(Charset.forName(charsetName)); + } + + /** + * Converts an array of character bytes representing hexadecimal values into an array of bytes of those same values. + * The returned array will be half the length of the passed array, as it takes two characters to represent any given + * byte. An exception is thrown if the passed char array has an odd number of elements. + * + * @param array An array of character bytes containing hexadecimal digits + * @return A byte array containing binary data decoded from the supplied byte array (representing characters). + * @throws DecoderException Thrown if an odd number of characters is supplied to this function + * @see #decodeHex(char[]) + */ + @Override + public byte[] decode(final byte[] array) throws DecoderException { + return decodeHex(new String(array, getCharset()).toCharArray()); + } + + /** + * Converts a buffer of character bytes representing hexadecimal values into an array of bytes of those same values. + * The returned array will be half the length of the passed array, as it takes two characters to represent any given + * byte. An exception is thrown if the passed char array has an odd number of elements. + * + *

    All bytes identified by {@link ByteBuffer#remaining()} will be used; after this method + * the value {@link ByteBuffer#remaining() remaining()} will be zero.

    + * + * @param buffer An array of character bytes containing hexadecimal digits + * @return A byte array containing binary data decoded from the supplied byte array (representing characters). + * @throws DecoderException Thrown if an odd number of characters is supplied to this function + * @see #decodeHex(char[]) + * @since 1.11 + */ + public byte[] decode(final ByteBuffer buffer) throws DecoderException { + return decodeHex(new String(toByteArray(buffer), getCharset()).toCharArray()); + } + + /** + * Converts a String or an array of character bytes representing hexadecimal values into an array of bytes of those + * same values. The returned array will be half the length of the passed String or array, as it takes two characters + * to represent any given byte. An exception is thrown if the passed char array has an odd number of elements. + * + * @param object A String, ByteBuffer, byte[], or an array of character bytes containing hexadecimal digits + * @return A byte array containing binary data decoded from the supplied byte array (representing characters). + * @throws DecoderException Thrown if an odd number of characters is supplied to this function or the object is not + * a String or char[] + * @see #decodeHex(char[]) + */ + @Override + public Object decode(final Object object) throws DecoderException { + if (object instanceof String) { + return decode(((String) object).toCharArray()); + } + if (object instanceof byte[]) { + return decode((byte[]) object); + } + if (object instanceof ByteBuffer) { + return decode((ByteBuffer) object); + } + try { + return decodeHex((char[]) object); + } catch (final ClassCastException e) { + throw new DecoderException(e.getMessage(), e); + } + } + + /** + * Converts an array of bytes into an array of bytes for the characters representing the hexadecimal values of each + * byte in order. The returned array will be double the length of the passed array, as it takes two characters to + * represent any given byte. + *

    + * The conversion from hexadecimal characters to the returned bytes is performed with the charset named by + * {@link #getCharset()}. + *

    + * + * @param array a byte[] to convert to hex characters + * @return A byte[] containing the bytes of the lower-case hexadecimal characters + * @since 1.7 No longer throws IllegalStateException if the charsetName is invalid. + * @see #encodeHex(byte[]) + */ + @Override + public byte[] encode(final byte[] array) { + return encodeHexString(array).getBytes(this.getCharset()); + } + + /** + * Converts byte buffer into an array of bytes for the characters representing the hexadecimal values of each byte + * in order. The returned array will be double the length of the passed array, as it takes two characters to + * represent any given byte. + * + *

    The conversion from hexadecimal characters to the returned bytes is performed with the charset named by + * {@link #getCharset()}.

    + * + *

    All bytes identified by {@link ByteBuffer#remaining()} will be used; after this method + * the value {@link ByteBuffer#remaining() remaining()} will be zero.

    + * + * @param array a byte buffer to convert to hex characters + * @return A byte[] containing the bytes of the lower-case hexadecimal characters + * @see #encodeHex(byte[]) + * @since 1.11 + */ + public byte[] encode(final ByteBuffer array) { + return encodeHexString(array).getBytes(this.getCharset()); + } + + /** + * Converts a String or an array of bytes into an array of characters representing the hexadecimal values of each + * byte in order. The returned array will be double the length of the passed String or array, as it takes two + * characters to represent any given byte. + *

    + * The conversion from hexadecimal characters to bytes to be encoded to performed with the charset named by + * {@link #getCharset()}. + *

    + * + * @param object a String, ByteBuffer, or byte[] to convert to hex characters + * @return A char[] containing lower-case hexadecimal characters + * @throws EncoderException Thrown if the given object is not a String or byte[] + * @see #encodeHex(byte[]) + */ + @Override + public Object encode(final Object object) throws EncoderException { + final byte[] byteArray; + if (object instanceof String) { + byteArray = ((String) object).getBytes(this.getCharset()); + } else if (object instanceof ByteBuffer) { + byteArray = toByteArray((ByteBuffer) object); + } else { + try { + byteArray = (byte[]) object; + } catch (final ClassCastException e) { + throw new EncoderException(e.getMessage(), e); + } + } + return encodeHex(byteArray); + } + + /** + * Gets the charset. + * + * @return the charset. + * @since 1.7 + */ + public Charset getCharset() { + return this.charset; + } + + /** + * Gets the charset name. + * + * @return the charset name. + * @since 1.4 + */ + public String getCharsetName() { + return this.charset.name(); + } + + /** + * Returns a string representation of the object, which includes the charset name. + * + * @return a string representation of the object. + */ + @Override + public String toString() { + return super.toString() + "[charsetName=" + this.charset + "]"; + } +} diff --git a/app/src/full/java/vendored/org/apache/commons/codec/binary/StringUtils.java b/app/src/full/java/vendored/org/apache/commons/codec/binary/StringUtils.java new file mode 100644 index 000000000..00c895e14 --- /dev/null +++ b/app/src/full/java/vendored/org/apache/commons/codec/binary/StringUtils.java @@ -0,0 +1,419 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 vendored.org.apache.commons.codec.binary; + +import java.io.UnsupportedEncodingException; +import java.nio.ByteBuffer; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; + +import vendored.org.apache.commons.codec.CharEncoding; + +/** + * Converts String to and from bytes using the encodings required by the Java specification. These encodings are + * specified in + * Standard charsets. + * + *

    This class is immutable and thread-safe.

    + * + * @see CharEncoding + * @see Standard charsets + * @since 1.4 + */ +public class StringUtils { + + /** + *

    + * Compares two CharSequences, returning {@code true} if they represent equal sequences of characters. + *

    + * + *

    + * {@code null}s are handled without exceptions. Two {@code null} references are considered to be equal. + * The comparison is case sensitive. + *

    + * + *
    +     * StringUtils.equals(null, null)   = true
    +     * StringUtils.equals(null, "abc")  = false
    +     * StringUtils.equals("abc", null)  = false
    +     * StringUtils.equals("abc", "abc") = true
    +     * StringUtils.equals("abc", "ABC") = false
    +     * 
    + * + *

    + * Copied from Apache Commons Lang r1583482 on April 10, 2014 (day of 3.3.2 release). + *

    + * + * @see Object#equals(Object) + * @param cs1 + * the first CharSequence, may be {@code null} + * @param cs2 + * the second CharSequence, may be {@code null} + * @return {@code true} if the CharSequences are equal (case-sensitive), or both {@code null} + * @since 1.10 + */ + public static boolean equals(final CharSequence cs1, final CharSequence cs2) { + if (cs1 == cs2) { + return true; + } + if (cs1 == null || cs2 == null) { + return false; + } + if (cs1 instanceof String && cs2 instanceof String) { + return cs1.equals(cs2); + } + return cs1.length() == cs2.length() && CharSequenceUtils.regionMatches(cs1, false, 0, cs2, 0, cs1.length()); + } + + /** + * Calls {@link String#getBytes(Charset)} + * + * @param string + * The string to encode (if null, return null). + * @param charset + * The {@link Charset} to encode the {@code String} + * @return the encoded bytes + */ + private static ByteBuffer getByteBuffer(final String string, final Charset charset) { + if (string == null) { + return null; + } + return ByteBuffer.wrap(string.getBytes(charset)); + } + + /** + * Encodes the given string into a byte buffer using the UTF-8 charset, storing the result into a new byte + * array. + * + * @param string + * the String to encode, may be {@code null} + * @return encoded bytes, or {@code null} if the input string was {@code null} + * @throws NullPointerException + * Thrown if {@link StandardCharsets#UTF_8} is not initialized, which should never happen since it is + * required by the Java platform specification. + * @see Standard charsets + * @see #getBytesUnchecked(String, String) + * @since 1.11 + */ + public static ByteBuffer getByteBufferUtf8(final String string) { + return getByteBuffer(string, StandardCharsets.UTF_8); + } + + /** + * Calls {@link String#getBytes(Charset)} + * + * @param string + * The string to encode (if null, return null). + * @param charset + * The {@link Charset} to encode the {@code String} + * @return the encoded bytes + */ + private static byte[] getBytes(final String string, final Charset charset) { + if (string == null) { + return null; + } + return string.getBytes(charset); + } + + /** + * Encodes the given string into a sequence of bytes using the ISO-8859-1 charset, storing the result into a new + * byte array. + * + * @param string + * the String to encode, may be {@code null} + * @return encoded bytes, or {@code null} if the input string was {@code null} + * @throws NullPointerException + * Thrown if {@link StandardCharsets#ISO_8859_1} is not initialized, which should never happen + * since it is required by the Java platform specification. + * @since As of 1.7, throws {@link NullPointerException} instead of UnsupportedEncodingException + * @see Standard charsets + * @see #getBytesUnchecked(String, String) + */ + public static byte[] getBytesIso8859_1(final String string) { + return getBytes(string, StandardCharsets.ISO_8859_1); + } + + + /** + * Encodes the given string into a sequence of bytes using the named charset, storing the result into a new byte + * array. + *

    + * This method catches {@link UnsupportedEncodingException} and rethrows it as {@link IllegalStateException}, which + * should never happen for a required charset name. Use this method when the encoding is required to be in the JRE. + *

    + * + * @param string + * the String to encode, may be {@code null} + * @param charsetName + * The name of a required {@link Charset} + * @return encoded bytes, or {@code null} if the input string was {@code null} + * @throws IllegalStateException + * Thrown when a {@link UnsupportedEncodingException} is caught, which should never happen for a + * required charset name. + * @see CharEncoding + * @see String#getBytes(String) + */ + public static byte[] getBytesUnchecked(final String string, final String charsetName) { + if (string == null) { + return null; + } + try { + return string.getBytes(charsetName); + } catch (final UnsupportedEncodingException e) { + throw StringUtils.newIllegalStateException(charsetName, e); + } + } + + /** + * Encodes the given string into a sequence of bytes using the US-ASCII charset, storing the result into a new byte + * array. + * + * @param string + * the String to encode, may be {@code null} + * @return encoded bytes, or {@code null} if the input string was {@code null} + * @throws NullPointerException + * Thrown if {@link StandardCharsets#US_ASCII} is not initialized, which should never happen since it is + * required by the Java platform specification. + * @since As of 1.7, throws {@link NullPointerException} instead of UnsupportedEncodingException + * @see Standard charsets + * @see #getBytesUnchecked(String, String) + */ + public static byte[] getBytesUsAscii(final String string) { + return getBytes(string, StandardCharsets.US_ASCII); + } + + /** + * Encodes the given string into a sequence of bytes using the UTF-16 charset, storing the result into a new byte + * array. + * + * @param string + * the String to encode, may be {@code null} + * @return encoded bytes, or {@code null} if the input string was {@code null} + * @throws NullPointerException + * Thrown if {@link StandardCharsets#UTF_16} is not initialized, which should never happen since it is + * required by the Java platform specification. + * @since As of 1.7, throws {@link NullPointerException} instead of UnsupportedEncodingException + * @see Standard charsets + * @see #getBytesUnchecked(String, String) + */ + public static byte[] getBytesUtf16(final String string) { + return getBytes(string, StandardCharsets.UTF_16); + } + + /** + * Encodes the given string into a sequence of bytes using the UTF-16BE charset, storing the result into a new byte + * array. + * + * @param string + * the String to encode, may be {@code null} + * @return encoded bytes, or {@code null} if the input string was {@code null} + * @throws NullPointerException + * Thrown if {@link StandardCharsets#UTF_16BE} is not initialized, which should never happen since it is + * required by the Java platform specification. + * @since As of 1.7, throws {@link NullPointerException} instead of UnsupportedEncodingException + * @see Standard charsets + * @see #getBytesUnchecked(String, String) + */ + public static byte[] getBytesUtf16Be(final String string) { + return getBytes(string, StandardCharsets.UTF_16BE); + } + + /** + * Encodes the given string into a sequence of bytes using the UTF-16LE charset, storing the result into a new byte + * array. + * + * @param string + * the String to encode, may be {@code null} + * @return encoded bytes, or {@code null} if the input string was {@code null} + * @throws NullPointerException + * Thrown if {@link StandardCharsets#UTF_16LE} is not initialized, which should never happen since it is + * required by the Java platform specification. + * @since As of 1.7, throws {@link NullPointerException} instead of UnsupportedEncodingException + * @see Standard charsets + * @see #getBytesUnchecked(String, String) + */ + public static byte[] getBytesUtf16Le(final String string) { + return getBytes(string, StandardCharsets.UTF_16LE); + } + + /** + * Encodes the given string into a sequence of bytes using the UTF-8 charset, storing the result into a new byte + * array. + * + * @param string + * the String to encode, may be {@code null} + * @return encoded bytes, or {@code null} if the input string was {@code null} + * @throws NullPointerException + * Thrown if {@link StandardCharsets#UTF_8} is not initialized, which should never happen since it is + * required by the Java platform specification. + * @since As of 1.7, throws {@link NullPointerException} instead of UnsupportedEncodingException + * @see Standard charsets + * @see #getBytesUnchecked(String, String) + */ + public static byte[] getBytesUtf8(final String string) { + return getBytes(string, StandardCharsets.UTF_8); + } + + private static IllegalStateException newIllegalStateException(final String charsetName, + final UnsupportedEncodingException e) { + return new IllegalStateException(charsetName + ": " + e); + } + + /** + * Constructs a new {@code String} by decoding the specified array of bytes using the given charset. + * + * @param bytes + * The bytes to be decoded into characters + * @param charset + * The {@link Charset} to encode the {@code String}; not {@code null} + * @return A new {@code String} decoded from the specified array of bytes using the given charset, + * or {@code null} if the input byte array was {@code null}. + * @throws NullPointerException + * Thrown if charset is {@code null} + */ + private static String newString(final byte[] bytes, final Charset charset) { + return bytes == null ? null : new String(bytes, charset); + } + + /** + * Constructs a new {@code String} by decoding the specified array of bytes using the given charset. + *

    + * This method catches {@link UnsupportedEncodingException} and re-throws it as {@link IllegalStateException}, which + * should never happen for a required charset name. Use this method when the encoding is required to be in the JRE. + *

    + * + * @param bytes + * The bytes to be decoded into characters, may be {@code null} + * @param charsetName + * The name of a required {@link Charset} + * @return A new {@code String} decoded from the specified array of bytes using the given charset, + * or {@code null} if the input byte array was {@code null}. + * @throws IllegalStateException + * Thrown when a {@link UnsupportedEncodingException} is caught, which should never happen for a + * required charset name. + * @see CharEncoding + * @see String#String(byte[], String) + */ + public static String newString(final byte[] bytes, final String charsetName) { + if (bytes == null) { + return null; + } + try { + return new String(bytes, charsetName); + } catch (final UnsupportedEncodingException e) { + throw StringUtils.newIllegalStateException(charsetName, e); + } + } + + /** + * Constructs a new {@code String} by decoding the specified array of bytes using the ISO-8859-1 charset. + * + * @param bytes + * The bytes to be decoded into characters, may be {@code null} + * @return A new {@code String} decoded from the specified array of bytes using the ISO-8859-1 charset, or + * {@code null} if the input byte array was {@code null}. + * @throws NullPointerException + * Thrown if {@link StandardCharsets#ISO_8859_1} is not initialized, which should never happen + * since it is required by the Java platform specification. + * @since As of 1.7, throws {@link NullPointerException} instead of UnsupportedEncodingException + */ + public static String newStringIso8859_1(final byte[] bytes) { + return newString(bytes, StandardCharsets.ISO_8859_1); + } + + /** + * Constructs a new {@code String} by decoding the specified array of bytes using the US-ASCII charset. + * + * @param bytes + * The bytes to be decoded into characters + * @return A new {@code String} decoded from the specified array of bytes using the US-ASCII charset, + * or {@code null} if the input byte array was {@code null}. + * @throws NullPointerException + * Thrown if {@link StandardCharsets#US_ASCII} is not initialized, which should never happen since it is + * required by the Java platform specification. + * @since As of 1.7, throws {@link NullPointerException} instead of UnsupportedEncodingException + */ + public static String newStringUsAscii(final byte[] bytes) { + return newString(bytes, StandardCharsets.US_ASCII); + } + + /** + * Constructs a new {@code String} by decoding the specified array of bytes using the UTF-16 charset. + * + * @param bytes + * The bytes to be decoded into characters + * @return A new {@code String} decoded from the specified array of bytes using the UTF-16 charset + * or {@code null} if the input byte array was {@code null}. + * @throws NullPointerException + * Thrown if {@link StandardCharsets#UTF_16} is not initialized, which should never happen since it is + * required by the Java platform specification. + * @since As of 1.7, throws {@link NullPointerException} instead of UnsupportedEncodingException + */ + public static String newStringUtf16(final byte[] bytes) { + return newString(bytes, StandardCharsets.UTF_16); + } + + /** + * Constructs a new {@code String} by decoding the specified array of bytes using the UTF-16BE charset. + * + * @param bytes + * The bytes to be decoded into characters + * @return A new {@code String} decoded from the specified array of bytes using the UTF-16BE charset, + * or {@code null} if the input byte array was {@code null}. + * @throws NullPointerException + * Thrown if {@link StandardCharsets#UTF_16BE} is not initialized, which should never happen since it is + * required by the Java platform specification. + * @since As of 1.7, throws {@link NullPointerException} instead of UnsupportedEncodingException + */ + public static String newStringUtf16Be(final byte[] bytes) { + return newString(bytes, StandardCharsets.UTF_16BE); + } + + /** + * Constructs a new {@code String} by decoding the specified array of bytes using the UTF-16LE charset. + * + * @param bytes + * The bytes to be decoded into characters + * @return A new {@code String} decoded from the specified array of bytes using the UTF-16LE charset, + * or {@code null} if the input byte array was {@code null}. + * @throws NullPointerException + * Thrown if {@link StandardCharsets#UTF_16LE} is not initialized, which should never happen since it is + * required by the Java platform specification. + * @since As of 1.7, throws {@link NullPointerException} instead of UnsupportedEncodingException + */ + public static String newStringUtf16Le(final byte[] bytes) { + return newString(bytes, StandardCharsets.UTF_16LE); + } + + /** + * Constructs a new {@code String} by decoding the specified array of bytes using the UTF-8 charset. + * + * @param bytes + * The bytes to be decoded into characters + * @return A new {@code String} decoded from the specified array of bytes using the UTF-8 charset, + * or {@code null} if the input byte array was {@code null}. + * @throws NullPointerException + * Thrown if {@link StandardCharsets#UTF_8} is not initialized, which should never happen since it is + * required by the Java platform specification. + * @since As of 1.7, throws {@link NullPointerException} instead of UnsupportedEncodingException + */ + public static String newStringUtf8(final byte[] bytes) { + return newString(bytes, StandardCharsets.UTF_8); + } + +} diff --git a/app/src/full/java/vendored/org/apache/commons/codec/digest/DigestUtils.java b/app/src/full/java/vendored/org/apache/commons/codec/digest/DigestUtils.java new file mode 100644 index 000000000..40b1d6b96 --- /dev/null +++ b/app/src/full/java/vendored/org/apache/commons/codec/digest/DigestUtils.java @@ -0,0 +1,1743 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 vendored.org.apache.commons.codec.digest; + +import androidx.annotation.RequiresApi; + +import java.io.BufferedInputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.RandomAccessFile; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.file.Files; +import java.nio.file.OpenOption; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +import vendored.org.apache.commons.codec.binary.Hex; +import vendored.org.apache.commons.codec.binary.StringUtils; + +/** + * Operations to simplify common {@link MessageDigest} tasks. + * This class is immutable and thread-safe. + * However the MessageDigest instances it creates generally won't be. + *

    + * The {@link MessageDigestAlgorithms} class provides constants for standard + * digest algorithms that can be used with the {@link #getDigest(String)} method + * and other methods that require the Digest algorithm name. + *

    + * Note: the class has short-hand methods for all the algorithms present as standard in Java 6. + * This approach requires lots of methods for each algorithm, and quickly becomes unwieldy. + * The following code works with all algorithms: + *

    + * import static org.apache.commons.codec.digest.MessageDigestAlgorithms.SHA_224;
    + * ...
    + * byte [] digest = new DigestUtils(SHA_224).digest(dataToDigest);
    + * String hdigest = new DigestUtils(SHA_224).digestAsHex(new File("pom.xml"));
    + * 
    + * @see MessageDigestAlgorithms + */ +public class DigestUtils { + + private static final int STREAM_BUFFER_LENGTH = 1024; + + /** + * Reads through a byte array and returns the digest for the data. Provided for symmetry with other methods. + * + * @param messageDigest + * The MessageDigest to use (e.g. MD5) + * @param data + * Data to digest + * @return the digest + * @since 1.11 + */ + public static byte[] digest(final MessageDigest messageDigest, final byte[] data) { + return messageDigest.digest(data); + } + + /** + * Reads through a ByteBuffer and returns the digest for the data + * + * @param messageDigest + * The MessageDigest to use (e.g. MD5) + * @param data + * Data to digest + * @return the digest + * + * @since 1.11 + */ + public static byte[] digest(final MessageDigest messageDigest, final ByteBuffer data) { + messageDigest.update(data); + return messageDigest.digest(); + } + + /** + * Reads through a File and returns the digest for the data + * + * @param messageDigest + * The MessageDigest to use (e.g. MD5) + * @param data + * Data to digest + * @return the digest + * @throws IOException + * On error reading from the stream + * @since 1.11 + */ + public static byte[] digest(final MessageDigest messageDigest, final File data) throws IOException { + return updateDigest(messageDigest, data).digest(); + } + + /** + * Reads through an InputStream and returns the digest for the data + * + * @param messageDigest + * The MessageDigest to use (e.g. MD5) + * @param data + * Data to digest + * @return the digest + * @throws IOException + * On error reading from the stream + * @since 1.11 (was private) + */ + public static byte[] digest(final MessageDigest messageDigest, final InputStream data) throws IOException { + return updateDigest(messageDigest, data).digest(); + } + + /** + * Reads through a File and returns the digest for the data + * + * @param messageDigest + * The MessageDigest to use (e.g. MD5) + * @param data + * Data to digest + * @param options + * options How to open the file + * @return the digest + * @throws IOException + * On error reading from the stream + * @since 1.14 + */ + @RequiresApi(api = 26) + public static byte[] digest(final MessageDigest messageDigest, final Path data, final OpenOption... options) + throws IOException { + return updateDigest(messageDigest, data, options).digest(); + } + + /** + * Reads through a RandomAccessFile using non-blocking-io (NIO) and returns the digest for the data + * + * @param messageDigest The MessageDigest to use (e.g. MD5) + * @param data Data to digest + * @return the digest + * @throws IOException On error reading from the stream + * @since 1.14 + */ + public static byte[] digest(final MessageDigest messageDigest, final RandomAccessFile data) throws IOException { + return updateDigest(messageDigest, data).digest(); + } + + /** + * Returns a {@code MessageDigest} for the given {@code algorithm}. + * + * @param algorithm + * the name of the algorithm requested. See Appendix A in the Java Cryptography Architecture Reference Guide for information about standard + * algorithm names. + * @return A digest instance. + * @see MessageDigest#getInstance(String) + * @throws IllegalArgumentException + * when a {@link NoSuchAlgorithmException} is caught. + */ + public static MessageDigest getDigest(final String algorithm) { + try { + return MessageDigest.getInstance(algorithm); + } catch (final NoSuchAlgorithmException e) { + throw new IllegalArgumentException(e); + } + } + + /** + * Returns a {@code MessageDigest} for the given {@code algorithm} or a default if there is a problem + * getting the algorithm. + * + * @param algorithm + * the name of the algorithm requested. See + * + * Appendix A in the Java Cryptography Architecture Reference Guide for information about standard + * algorithm names. + * @param defaultMessageDigest + * The default MessageDigest. + * @return A digest instance. + * @see MessageDigest#getInstance(String) + * @throws IllegalArgumentException + * when a {@link NoSuchAlgorithmException} is caught. + * @since 1.11 + */ + public static MessageDigest getDigest(final String algorithm, final MessageDigest defaultMessageDigest) { + try { + return MessageDigest.getInstance(algorithm); + } catch (final Exception e) { + return defaultMessageDigest; + } + } + + /** + * Returns an MD2 MessageDigest. + * + * @return An MD2 digest instance. + * @throws IllegalArgumentException + * when a {@link NoSuchAlgorithmException} is caught, which should never happen because MD2 is a + * built-in algorithm + * @see MessageDigestAlgorithms#MD2 + * @since 1.7 + */ + public static MessageDigest getMd2Digest() { + return getDigest(MessageDigestAlgorithms.MD2); + } + + /** + * Returns an MD5 MessageDigest. + * + * @return An MD5 digest instance. + * @throws IllegalArgumentException + * when a {@link NoSuchAlgorithmException} is caught, which should never happen because MD5 is a + * built-in algorithm + * @see MessageDigestAlgorithms#MD5 + */ + public static MessageDigest getMd5Digest() { + return getDigest(MessageDigestAlgorithms.MD5); + } + + /** + * Returns an SHA-1 digest. + * + * @return An SHA-1 digest instance. + * @throws IllegalArgumentException + * when a {@link NoSuchAlgorithmException} is caught, which should never happen because SHA-1 is a + * built-in algorithm + * @see MessageDigestAlgorithms#SHA_1 + * @since 1.7 + */ + public static MessageDigest getSha1Digest() { + return getDigest(MessageDigestAlgorithms.SHA_1); + } + + /** + * Returns an SHA-256 digest. + * + * @return An SHA-256 digest instance. + * @throws IllegalArgumentException + * when a {@link NoSuchAlgorithmException} is caught, which should never happen because SHA-256 is a + * built-in algorithm + * @see MessageDigestAlgorithms#SHA_256 + */ + public static MessageDigest getSha256Digest() { + return getDigest(MessageDigestAlgorithms.SHA_256); + } + + /** + * Returns an SHA3-224 digest. + * + * @return An SHA3-224 digest instance. + * @throws IllegalArgumentException + * when a {@link NoSuchAlgorithmException} is caught, which should not happen on + * Oracle Java 9 andgreater. + * @see MessageDigestAlgorithms#SHA3_224 + * @since 1.12 + */ + public static MessageDigest getSha3_224Digest() { + return getDigest(MessageDigestAlgorithms.SHA3_224); + } + + /** + * Returns an SHA3-256 digest. + * + * @return An SHA3-256 digest instance. + * @throws IllegalArgumentException + * when a {@link NoSuchAlgorithmException} is caught, which should not happen on + * Oracle Java 9 and greater. + * @see MessageDigestAlgorithms#SHA3_256 + * @since 1.12 + */ + public static MessageDigest getSha3_256Digest() { + return getDigest(MessageDigestAlgorithms.SHA3_256); + } + + /** + * Returns an SHA3-384 digest. + * + * @return An SHA3-384 digest instance. + * @throws IllegalArgumentException + * when a {@link NoSuchAlgorithmException} is caught, which should not happen on + * Oracle Java 9 and greater. + * @see MessageDigestAlgorithms#SHA3_384 + * @since 1.12 + */ + public static MessageDigest getSha3_384Digest() { + return getDigest(MessageDigestAlgorithms.SHA3_384); + } + + /** + * Returns an SHA3-512 digest. + * + * @return An SHA3-512 digest instance. + * @throws IllegalArgumentException + * when a {@link NoSuchAlgorithmException} is caught, which should not happen + * on Oracle Java 9 and greater. + * @see MessageDigestAlgorithms#SHA3_512 + * @since 1.12 + */ + public static MessageDigest getSha3_512Digest() { + return getDigest(MessageDigestAlgorithms.SHA3_512); + } + + /** + * Returns an SHA-384 digest. + * + * @return An SHA-384 digest instance. + * @throws IllegalArgumentException + * when a {@link NoSuchAlgorithmException} is caught, which should never happen + * because SHA-384 is a built-in algorithm + * @see MessageDigestAlgorithms#SHA_384 + */ + public static MessageDigest getSha384Digest() { + return getDigest(MessageDigestAlgorithms.SHA_384); + } + + /** + * Returns an SHA-512/224 digest. + * + * @return An SHA-512/224 digest instance. + * @throws IllegalArgumentException + * when a {@link NoSuchAlgorithmException} is caught. + * @see MessageDigestAlgorithms#SHA_512_224 + */ + public static MessageDigest getSha512_224Digest() { + return getDigest(MessageDigestAlgorithms.SHA_512_224); + } + + /** + * Returns an SHA-512/256 digest. + * + * @return An SHA-512/256 digest instance. + * @throws IllegalArgumentException + * when a {@link NoSuchAlgorithmException} is caught. + * @see MessageDigestAlgorithms#SHA_512_224 + */ + public static MessageDigest getSha512_256Digest() { + return getDigest(MessageDigestAlgorithms.SHA_512_256); + } + + /** + * Returns an SHA-512 digest. + * + * @return An SHA-512 digest instance. + * @throws IllegalArgumentException + * when a {@link NoSuchAlgorithmException} is caught, which should never happen + * because SHA-512 is a built-in algorithm + * @see MessageDigestAlgorithms#SHA_512 + */ + public static MessageDigest getSha512Digest() { + return getDigest(MessageDigestAlgorithms.SHA_512); + } + + /** + * Returns an SHA-1 digest. + * + * @return An SHA-1 digest instance. + * @throws IllegalArgumentException + * when a {@link NoSuchAlgorithmException} is caught + * @deprecated (1.11) Use {@link #getSha1Digest()} + */ + @Deprecated + public static MessageDigest getShaDigest() { + return getSha1Digest(); + } + + /** + * Test whether the algorithm is supported. + * @param messageDigestAlgorithm the algorithm name + * @return {@code true} if the algorithm can be found + * @since 1.11 + */ + public static boolean isAvailable(final String messageDigestAlgorithm) { + return getDigest(messageDigestAlgorithm, null) != null; + } + + /** + * Calculates the MD2 digest and returns the value as a 16 element {@code byte[]}. + * + * @param data + * Data to digest + * @return MD2 digest + * @since 1.7 + */ + public static byte[] md2(final byte[] data) { + return getMd2Digest().digest(data); + } + + /** + * Calculates the MD2 digest and returns the value as a 16 element {@code byte[]}. + * + * @param data + * Data to digest + * @return MD2 digest + * @throws IOException + * On error reading from the stream + * @since 1.7 + */ + public static byte[] md2(final InputStream data) throws IOException { + return digest(getMd2Digest(), data); + } + + /** + * Calculates the MD2 digest and returns the value as a 16 element {@code byte[]}. + * + * @param data + * Data to digest; converted to bytes using {@link StringUtils#getBytesUtf8(String)} + * @return MD2 digest + * @since 1.7 + */ + public static byte[] md2(final String data) { + return md2(StringUtils.getBytesUtf8(data)); + } + + /** + * Calculates the MD2 digest and returns the value as a 32 character hex string. + * + * @param data + * Data to digest + * @return MD2 digest as a hex string + * @since 1.7 + */ + public static String md2Hex(final byte[] data) { + return Hex.encodeHexString(md2(data)); + } + + /** + * Calculates the MD2 digest and returns the value as a 32 character hex string. + * + * @param data + * Data to digest + * @return MD2 digest as a hex string + * @throws IOException + * On error reading from the stream + * @since 1.7 + */ + public static String md2Hex(final InputStream data) throws IOException { + return Hex.encodeHexString(md2(data)); + } + + /** + * Calculates the MD2 digest and returns the value as a 32 character hex string. + * + * @param data + * Data to digest + * @return MD2 digest as a hex string + * @since 1.7 + */ + public static String md2Hex(final String data) { + return Hex.encodeHexString(md2(data)); + } + + /** + * Calculates the MD5 digest and returns the value as a 16 element {@code byte[]}. + * + * @param data + * Data to digest + * @return MD5 digest + */ + public static byte[] md5(final byte[] data) { + return getMd5Digest().digest(data); + } + + /** + * Calculates the MD5 digest and returns the value as a 16 element {@code byte[]}. + * + * @param data + * Data to digest + * @return MD5 digest + * @throws IOException + * On error reading from the stream + * @since 1.4 + */ + public static byte[] md5(final InputStream data) throws IOException { + return digest(getMd5Digest(), data); + } + + /** + * Calculates the MD5 digest and returns the value as a 16 element {@code byte[]}. + * + * @param data + * Data to digest; converted to bytes using {@link StringUtils#getBytesUtf8(String)} + * @return MD5 digest + */ + public static byte[] md5(final String data) { + return md5(StringUtils.getBytesUtf8(data)); + } + + /** + * Calculates the MD5 digest and returns the value as a 32 character hex string. + * + * @param data + * Data to digest + * @return MD5 digest as a hex string + */ + public static String md5Hex(final byte[] data) { + return Hex.encodeHexString(md5(data)); + } + + /** + * Calculates the MD5 digest and returns the value as a 32 character hex string. + * + * @param data + * Data to digest + * @return MD5 digest as a hex string + * @throws IOException + * On error reading from the stream + * @since 1.4 + */ + public static String md5Hex(final InputStream data) throws IOException { + return Hex.encodeHexString(md5(data)); + } + + /** + * Calculates the MD5 digest and returns the value as a 32 character hex string. + * + * @param data + * Data to digest + * @return MD5 digest as a hex string + */ + public static String md5Hex(final String data) { + return Hex.encodeHexString(md5(data)); + } + + /** + * Calculates the SHA-1 digest and returns the value as a {@code byte[]}. + * + * @param data + * Data to digest + * @return SHA-1 digest + * @deprecated (1.11) Use {@link #sha1(byte[])} + */ + @Deprecated + public static byte[] sha(final byte[] data) { + return sha1(data); + } + + /** + * Calculates the SHA-1 digest and returns the value as a {@code byte[]}. + * + * @param data + * Data to digest + * @return SHA-1 digest + * @throws IOException + * On error reading from the stream + * @since 1.4 + * @deprecated (1.11) Use {@link #sha1(InputStream)} + */ + @Deprecated + public static byte[] sha(final InputStream data) throws IOException { + return sha1(data); + } + + /** + * Calculates the SHA-1 digest and returns the value as a {@code byte[]}. + * + * @param data + * Data to digest + * @return SHA-1 digest + * @deprecated (1.11) Use {@link #sha1(String)} + */ + @Deprecated + public static byte[] sha(final String data) { + return sha1(data); + } + + /** + * Calculates the SHA-1 digest and returns the value as a {@code byte[]}. + * + * @param data + * Data to digest + * @return SHA-1 digest + * @since 1.7 + */ + public static byte[] sha1(final byte[] data) { + return getSha1Digest().digest(data); + } + + /** + * Calculates the SHA-1 digest and returns the value as a {@code byte[]}. + * + * @param data + * Data to digest + * @return SHA-1 digest + * @throws IOException + * On error reading from the stream + * @since 1.7 + */ + public static byte[] sha1(final InputStream data) throws IOException { + return digest(getSha1Digest(), data); + } + + /** + * Calculates the SHA-1 digest and returns the value as a {@code byte[]}. + * + * @param data + * Data to digest; converted to bytes using {@link StringUtils#getBytesUtf8(String)} + * @return SHA-1 digest + */ + public static byte[] sha1(final String data) { + return sha1(StringUtils.getBytesUtf8(data)); + } + + /** + * Calculates the SHA-1 digest and returns the value as a hex string. + * + * @param data + * Data to digest + * @return SHA-1 digest as a hex string + * @since 1.7 + */ + public static String sha1Hex(final byte[] data) { + return Hex.encodeHexString(sha1(data)); + } + + /** + * Calculates the SHA-1 digest and returns the value as a hex string. + * + * @param data + * Data to digest + * @return SHA-1 digest as a hex string + * @throws IOException + * On error reading from the stream + * @since 1.7 + */ + public static String sha1Hex(final InputStream data) throws IOException { + return Hex.encodeHexString(sha1(data)); + } + + /** + * Calculates the SHA-1 digest and returns the value as a hex string. + * + * @param data + * Data to digest + * @return SHA-1 digest as a hex string + * @since 1.7 + */ + public static String sha1Hex(final String data) { + return Hex.encodeHexString(sha1(data)); + } + + /** + * Calculates the SHA-256 digest and returns the value as a {@code byte[]}. + * + * @param data + * Data to digest + * @return SHA-256 digest + * @since 1.4 + */ + public static byte[] sha256(final byte[] data) { + return getSha256Digest().digest(data); + } + + /** + * Calculates the SHA-256 digest and returns the value as a {@code byte[]}. + * + * @param data + * Data to digest + * @return SHA-256 digest + * @throws IOException + * On error reading from the stream + * @since 1.4 + */ + public static byte[] sha256(final InputStream data) throws IOException { + return digest(getSha256Digest(), data); + } + + /** + * Calculates the SHA-256 digest and returns the value as a {@code byte[]}. + * + * @param data + * Data to digest; converted to bytes using {@link StringUtils#getBytesUtf8(String)} + * @return SHA-256 digest + * @since 1.4 + */ + public static byte[] sha256(final String data) { + return sha256(StringUtils.getBytesUtf8(data)); + } + + /** + * Calculates the SHA-256 digest and returns the value as a hex string. + * + * @param data + * Data to digest + * @return SHA-256 digest as a hex string + * @since 1.4 + */ + public static String sha256Hex(final byte[] data) { + return Hex.encodeHexString(sha256(data)); + } + + /** + * Calculates the SHA-256 digest and returns the value as a hex string. + * + * @param data + * Data to digest + * @return SHA-256 digest as a hex string + * @throws IOException + * On error reading from the stream + * @since 1.4 + */ + public static String sha256Hex(final InputStream data) throws IOException { + return Hex.encodeHexString(sha256(data)); + } + + /** + * Calculates the SHA-256 digest and returns the value as a hex string. + * + * @param data + * Data to digest + * @return SHA-256 digest as a hex string + * @since 1.4 + */ + public static String sha256Hex(final String data) { + return Hex.encodeHexString(sha256(data)); + } + + /** + * Calculates the SHA3-224 digest and returns the value as a {@code byte[]}. + * + * @param data + * Data to digest + * @return SHA3-224 digest + * @since 1.12 + */ + public static byte[] sha3_224(final byte[] data) { + return getSha3_224Digest().digest(data); + } + + /** + * Calculates the SHA3-224 digest and returns the value as a {@code byte[]}. + * + * @param data + * Data to digest + * @return SHA3-224 digest + * @throws IOException + * On error reading from the stream + * @since 1.12 + */ + public static byte[] sha3_224(final InputStream data) throws IOException { + return digest(getSha3_224Digest(), data); + } + + /** + * Calculates the SHA3-224 digest and returns the value as a {@code byte[]}. + * + * @param data + * Data to digest; converted to bytes using {@link StringUtils#getBytesUtf8(String)} + * @return SHA3-224 digest + * @since 1.12 + */ + public static byte[] sha3_224(final String data) { + return sha3_224(StringUtils.getBytesUtf8(data)); + } + + /** + * Calculates the SHA3-224 digest and returns the value as a hex string. + * + * @param data + * Data to digest + * @return SHA3-224 digest as a hex string + * @since 1.12 + */ + public static String sha3_224Hex(final byte[] data) { + return Hex.encodeHexString(sha3_224(data)); + } + + /** + * Calculates the SHA3-224 digest and returns the value as a hex string. + * + * @param data + * Data to digest + * @return SHA3-224 digest as a hex string + * @throws IOException + * On error reading from the stream + * @since 1.12 + */ + public static String sha3_224Hex(final InputStream data) throws IOException { + return Hex.encodeHexString(sha3_224(data)); + } + + /** + * Calculates the SHA3-224 digest and returns the value as a hex string. + * + * @param data + * Data to digest + * @return SHA3-224 digest as a hex string + * @since 1.12 + */ + public static String sha3_224Hex(final String data) { + return Hex.encodeHexString(sha3_224(data)); + } + + /** + * Calculates the SHA3-256 digest and returns the value as a {@code byte[]}. + * + * @param data + * Data to digest + * @return SHA3-256 digest + * @since 1.12 + */ + public static byte[] sha3_256(final byte[] data) { + return getSha3_256Digest().digest(data); + } + + /** + * Calculates the SHA3-256 digest and returns the value as a {@code byte[]}. + * + * @param data + * Data to digest + * @return SHA3-256 digest + * @throws IOException + * On error reading from the stream + * @since 1.12 + */ + public static byte[] sha3_256(final InputStream data) throws IOException { + return digest(getSha3_256Digest(), data); + } + + /** + * Calculates the SHA3-256 digest and returns the value as a {@code byte[]}. + * + * @param data + * Data to digest; converted to bytes using {@link StringUtils#getBytesUtf8(String)} + * @return SHA3-256 digest + * @since 1.12 + */ + public static byte[] sha3_256(final String data) { + return sha3_256(StringUtils.getBytesUtf8(data)); + } + + /** + * Calculates the SHA3-256 digest and returns the value as a hex string. + * + * @param data + * Data to digest + * @return SHA3-256 digest as a hex string + * @since 1.12 + */ + public static String sha3_256Hex(final byte[] data) { + return Hex.encodeHexString(sha3_256(data)); + } + + /** + * Calculates the SHA3-256 digest and returns the value as a hex string. + * + * @param data + * Data to digest + * @return SHA3-256 digest as a hex string + * @throws IOException + * On error reading from the stream + * @since 1.12 + */ + public static String sha3_256Hex(final InputStream data) throws IOException { + return Hex.encodeHexString(sha3_256(data)); + } + + /** + * Calculates the SHA3-256 digest and returns the value as a hex string. + * + * @param data + * Data to digest + * @return SHA3-256 digest as a hex string + * @since 1.12 + */ + public static String sha3_256Hex(final String data) { + return Hex.encodeHexString(sha3_256(data)); + } + + /** + * Calculates the SHA3-384 digest and returns the value as a {@code byte[]}. + * + * @param data + * Data to digest + * @return SHA3-384 digest + * @since 1.12 + */ + public static byte[] sha3_384(final byte[] data) { + return getSha3_384Digest().digest(data); + } + + /** + * Calculates the SHA3-384 digest and returns the value as a {@code byte[]}. + * + * @param data + * Data to digest + * @return SHA3-384 digest + * @throws IOException + * On error reading from the stream + * @since 1.12 + */ + public static byte[] sha3_384(final InputStream data) throws IOException { + return digest(getSha3_384Digest(), data); + } + + /** + * Calculates the SHA3-384 digest and returns the value as a {@code byte[]}. + * + * @param data + * Data to digest; converted to bytes using {@link StringUtils#getBytesUtf8(String)} + * @return SHA3-384 digest + * @since 1.12 + */ + public static byte[] sha3_384(final String data) { + return sha3_384(StringUtils.getBytesUtf8(data)); + } + + /** + * Calculates the SHA3-384 digest and returns the value as a hex string. + * + * @param data + * Data to digest + * @return SHA3-384 digest as a hex string + * @since 1.12 + */ + public static String sha3_384Hex(final byte[] data) { + return Hex.encodeHexString(sha3_384(data)); + } + + /** + * Calculates the SHA3-384 digest and returns the value as a hex string. + * + * @param data + * Data to digest + * @return SHA3-384 digest as a hex string + * @throws IOException + * On error reading from the stream + * @since 1.12 + */ + public static String sha3_384Hex(final InputStream data) throws IOException { + return Hex.encodeHexString(sha3_384(data)); + } + + /** + * Calculates the SHA3-384 digest and returns the value as a hex string. + * + * @param data + * Data to digest + * @return SHA3-384 digest as a hex string + * @since 1.12 + */ + public static String sha3_384Hex(final String data) { + return Hex.encodeHexString(sha3_384(data)); + } + + /** + * Calculates the SHA3-512 digest and returns the value as a {@code byte[]}. + * + * @param data + * Data to digest + * @return SHA3-512 digest + * @since 1.12 + */ + public static byte[] sha3_512(final byte[] data) { + return getSha3_512Digest().digest(data); + } + + /** + * Calculates the SHA3-512 digest and returns the value as a {@code byte[]}. + * + * @param data + * Data to digest + * @return SHA3-512 digest + * @throws IOException + * On error reading from the stream + * @since 1.12 + */ + public static byte[] sha3_512(final InputStream data) throws IOException { + return digest(getSha3_512Digest(), data); + } + + /** + * Calculates the SHA3-512 digest and returns the value as a {@code byte[]}. + * + * @param data + * Data to digest; converted to bytes using {@link StringUtils#getBytesUtf8(String)} + * @return SHA3-512 digest + * @since 1.12 + */ + public static byte[] sha3_512(final String data) { + return sha3_512(StringUtils.getBytesUtf8(data)); + } + + /** + * Calculates the SHA3-512 digest and returns the value as a hex string. + * + * @param data + * Data to digest + * @return SHA3-512 digest as a hex string + * @since 1.12 + */ + public static String sha3_512Hex(final byte[] data) { + return Hex.encodeHexString(sha3_512(data)); + } + + /** + * Calculates the SHA3-512 digest and returns the value as a hex string. + * + * @param data + * Data to digest + * @return SHA3-512 digest as a hex string + * @throws IOException + * On error reading from the stream + * @since 1.12 + */ + public static String sha3_512Hex(final InputStream data) throws IOException { + return Hex.encodeHexString(sha3_512(data)); + } + + /** + * Calculates the SHA3-512 digest and returns the value as a hex string. + * + * @param data + * Data to digest + * @return SHA3-512 digest as a hex string + * @since 1.12 + */ + public static String sha3_512Hex(final String data) { + return Hex.encodeHexString(sha3_512(data)); + } + + /** + * Calculates the SHA-384 digest and returns the value as a {@code byte[]}. + * + * @param data + * Data to digest + * @return SHA-384 digest + * @since 1.4 + */ + public static byte[] sha384(final byte[] data) { + return getSha384Digest().digest(data); + } + + /** + * Calculates the SHA-384 digest and returns the value as a {@code byte[]}. + * + * @param data + * Data to digest + * @return SHA-384 digest + * @throws IOException + * On error reading from the stream + * @since 1.4 + */ + public static byte[] sha384(final InputStream data) throws IOException { + return digest(getSha384Digest(), data); + } + + /** + * Calculates the SHA-384 digest and returns the value as a {@code byte[]}. + * + * @param data + * Data to digest; converted to bytes using {@link StringUtils#getBytesUtf8(String)} + * @return SHA-384 digest + * @since 1.4 + */ + public static byte[] sha384(final String data) { + return sha384(StringUtils.getBytesUtf8(data)); + } + + /** + * Calculates the SHA-384 digest and returns the value as a hex string. + * + * @param data + * Data to digest + * @return SHA-384 digest as a hex string + * @since 1.4 + */ + public static String sha384Hex(final byte[] data) { + return Hex.encodeHexString(sha384(data)); + } + + /** + * Calculates the SHA-384 digest and returns the value as a hex string. + * + * @param data + * Data to digest + * @return SHA-384 digest as a hex string + * @throws IOException + * On error reading from the stream + * @since 1.4 + */ + public static String sha384Hex(final InputStream data) throws IOException { + return Hex.encodeHexString(sha384(data)); + } + + /** + * Calculates the SHA-384 digest and returns the value as a hex string. + * + * @param data + * Data to digest + * @return SHA-384 digest as a hex string + * @since 1.4 + */ + public static String sha384Hex(final String data) { + return Hex.encodeHexString(sha384(data)); + } + + /** + * Calculates the SHA-512 digest and returns the value as a {@code byte[]}. + * + * @param data + * Data to digest + * @return SHA-512 digest + * @since 1.4 + */ + public static byte[] sha512(final byte[] data) { + return getSha512Digest().digest(data); + } + + /** + * Calculates the SHA-512 digest and returns the value as a {@code byte[]}. + * + * @param data + * Data to digest + * @return SHA-512 digest + * @throws IOException + * On error reading from the stream + * @since 1.4 + */ + public static byte[] sha512(final InputStream data) throws IOException { + return digest(getSha512Digest(), data); + } + + /** + * Calculates the SHA-512 digest and returns the value as a {@code byte[]}. + * + * @param data + * Data to digest; converted to bytes using {@link StringUtils#getBytesUtf8(String)} + * @return SHA-512 digest + * @since 1.4 + */ + public static byte[] sha512(final String data) { + return sha512(StringUtils.getBytesUtf8(data)); + } + + /** + * Calculates the SHA-512/224 digest and returns the value as a {@code byte[]}. + * + * @param data + * Data to digest + * @return SHA-512/224 digest + * @since 1.14 + */ + public static byte[] sha512_224(final byte[] data) { + return getSha512_224Digest().digest(data); + } + + /** + * Calculates the SHA-512/224 digest and returns the value as a {@code byte[]}. + * + * @param data + * Data to digest + * @return SHA-512/224 digest + * @throws IOException + * On error reading from the stream + * @since 1.14 + */ + public static byte[] sha512_224(final InputStream data) throws IOException { + return digest(getSha512_224Digest(), data); + } + + /** + * Calculates the SHA-512/224 digest and returns the value as a {@code byte[]}. + * + * @param data + * Data to digest; converted to bytes using {@link StringUtils#getBytesUtf8(String)} + * @return SHA-512/224 digest + * @since 1.14 + */ + public static byte[] sha512_224(final String data) { + return sha512_224(StringUtils.getBytesUtf8(data)); + } + + /** + * Calculates the SHA-512/224 digest and returns the value as a hex string. + * + * @param data + * Data to digest + * @return SHA-512/224 digest as a hex string + * @since 1.14 + */ + public static String sha512_224Hex(final byte[] data) { + return Hex.encodeHexString(sha512_224(data)); + } + + /** + * Calculates the SHA-512/224 digest and returns the value as a hex string. + * + * @param data + * Data to digest + * @return SHA-512/224 digest as a hex string + * @throws IOException + * On error reading from the stream + * @since 1.14 + */ + public static String sha512_224Hex(final InputStream data) throws IOException { + return Hex.encodeHexString(sha512_224(data)); + } + + /** + * Calculates the SHA-512/224 digest and returns the value as a hex string. + * + * @param data + * Data to digest + * @return SHA-512/224 digest as a hex string + * @since 1.14 + */ + public static String sha512_224Hex(final String data) { + return Hex.encodeHexString(sha512_224(data)); + } + + /** + * Calculates the SHA-512/256 digest and returns the value as a {@code byte[]}. + * + * @param data + * Data to digest + * @return SHA-512/256 digest + * @since 1.14 + */ + public static byte[] sha512_256(final byte[] data) { + return getSha512_256Digest().digest(data); + } + + /** + * Calculates the SHA-512/256 digest and returns the value as a {@code byte[]}. + * + * @param data + * Data to digest + * @return SHA-512/256 digest + * @throws IOException + * On error reading from the stream + * @since 1.14 + */ + public static byte[] sha512_256(final InputStream data) throws IOException { + return digest(getSha512_256Digest(), data); + } + + /** + * Calculates the SHA-512/256 digest and returns the value as a {@code byte[]}. + * + * @param data + * Data to digest; converted to bytes using {@link StringUtils#getBytesUtf8(String)} + * @return SHA-512/224 digest + * @since 1.14 + */ + public static byte[] sha512_256(final String data) { + return sha512_256(StringUtils.getBytesUtf8(data)); + } + + /** + * Calculates the SHA-512/256 digest and returns the value as a hex string. + * + * @param data + * Data to digest + * @return SHA-512/256 digest as a hex string + * @since 1.14 + */ + public static String sha512_256Hex(final byte[] data) { + return Hex.encodeHexString(sha512_256(data)); + } + + /** + * Calculates the SHA-512/256 digest and returns the value as a hex string. + * + * @param data + * Data to digest + * @return SHA-512/256 digest as a hex string + * @throws IOException + * On error reading from the stream + * @since 1.14 + */ + public static String sha512_256Hex(final InputStream data) throws IOException { + return Hex.encodeHexString(sha512_256(data)); + } + + /** + * Calculates the SHA-512/256 digest and returns the value as a hex string. + * + * @param data + * Data to digest + * @return SHA-512/256 digest as a hex string + * @since 1.14 + */ + public static String sha512_256Hex(final String data) { + return Hex.encodeHexString(sha512_256(data)); + } + + /** + * Calculates the SHA-512 digest and returns the value as a hex string. + * + * @param data + * Data to digest + * @return SHA-512 digest as a hex string + * @since 1.4 + */ + public static String sha512Hex(final byte[] data) { + return Hex.encodeHexString(sha512(data)); + } + + /** + * Calculates the SHA-512 digest and returns the value as a hex string. + * + * @param data + * Data to digest + * @return SHA-512 digest as a hex string + * @throws IOException + * On error reading from the stream + * @since 1.4 + */ + public static String sha512Hex(final InputStream data) throws IOException { + return Hex.encodeHexString(sha512(data)); + } + + /** + * Calculates the SHA-512 digest and returns the value as a hex string. + * + * @param data + * Data to digest + * @return SHA-512 digest as a hex string + * @since 1.4 + */ + public static String sha512Hex(final String data) { + return Hex.encodeHexString(sha512(data)); + } + + /** + * Calculates the SHA-1 digest and returns the value as a hex string. + * + * @param data + * Data to digest + * @return SHA-1 digest as a hex string + * @deprecated (1.11) Use {@link #sha1Hex(byte[])} + */ + @Deprecated + public static String shaHex(final byte[] data) { + return sha1Hex(data); + } + + /** + * Calculates the SHA-1 digest and returns the value as a hex string. + * + * @param data + * Data to digest + * @return SHA-1 digest as a hex string + * @throws IOException + * On error reading from the stream + * @since 1.4 + * @deprecated (1.11) Use {@link #sha1Hex(InputStream)} + */ + @Deprecated + public static String shaHex(final InputStream data) throws IOException { + return sha1Hex(data); + } + + /** + * Calculates the SHA-1 digest and returns the value as a hex string. + * + * @param data + * Data to digest + * @return SHA-1 digest as a hex string + * @deprecated (1.11) Use {@link #sha1Hex(String)} + */ + @Deprecated + public static String shaHex(final String data) { + return sha1Hex(data); + } + + /** + * Updates the given {@link MessageDigest}. + * + * @param messageDigest + * the {@link MessageDigest} to update + * @param valueToDigest + * the value to update the {@link MessageDigest} with + * @return the updated {@link MessageDigest} + * @since 1.7 + */ + public static MessageDigest updateDigest(final MessageDigest messageDigest, final byte[] valueToDigest) { + messageDigest.update(valueToDigest); + return messageDigest; + } + + /** + * Updates the given {@link MessageDigest}. + * + * @param messageDigest + * the {@link MessageDigest} to update + * @param valueToDigest + * the value to update the {@link MessageDigest} with + * @return the updated {@link MessageDigest} + * @since 1.11 + */ + public static MessageDigest updateDigest(final MessageDigest messageDigest, final ByteBuffer valueToDigest) { + messageDigest.update(valueToDigest); + return messageDigest; + } + + /** + * Reads through a File and updates the digest for the data + * + * @param digest + * The MessageDigest to use (e.g. MD5) + * @param data + * Data to digest + * @return the digest + * @throws IOException + * On error reading from the stream + * @since 1.11 + */ + public static MessageDigest updateDigest(final MessageDigest digest, final File data) throws IOException { + try (final BufferedInputStream inputStream = new BufferedInputStream(new FileInputStream(data))) { + return updateDigest(digest, inputStream); + } + } + + /** + * Reads through a RandomAccessFile and updates the digest for the data using non-blocking-io (NIO). + * + * TODO Decide if this should be public. + * + * @param digest The MessageDigest to use (e.g. MD5) + * @param data Data to digest + * @return the digest + * @throws IOException On error reading from the stream + * @since 1.14 + */ + private static MessageDigest updateDigest(final MessageDigest digest, final FileChannel data) throws IOException { + final ByteBuffer buffer = ByteBuffer.allocate(STREAM_BUFFER_LENGTH); + while (data.read(buffer) > 0) { + buffer.flip(); + digest.update(buffer); + buffer.clear(); + } + return digest; + } + + /** + * Reads through an InputStream and updates the digest for the data + * + * @param digest + * The MessageDigest to use (e.g. MD5) + * @param inputStream + * Data to digest + * @return the digest + * @throws IOException + * On error reading from the stream + * @since 1.8 + */ + public static MessageDigest updateDigest(final MessageDigest digest, final InputStream inputStream) + throws IOException { + final byte[] buffer = new byte[STREAM_BUFFER_LENGTH]; + int read = inputStream.read(buffer, 0, STREAM_BUFFER_LENGTH); + + while (read > -1) { + digest.update(buffer, 0, read); + read = inputStream.read(buffer, 0, STREAM_BUFFER_LENGTH); + } + + return digest; + } + + /** + * Reads through a Path and updates the digest for the data + * + * @param digest + * The MessageDigest to use (e.g. MD5) + * @param path + * Data to digest + * @param options + * options How to open the file + * @return the digest + * @throws IOException + * On error reading from the stream + * @since 1.14 + */ + @RequiresApi(api = 26) + public static MessageDigest updateDigest(final MessageDigest digest, final Path path, final OpenOption... options) + throws IOException { + try (final BufferedInputStream inputStream = new BufferedInputStream(Files.newInputStream(path, options))) { + return updateDigest(digest, inputStream); + } + } + + /** + * Reads through a RandomAccessFile and updates the digest for the data using non-blocking-io (NIO) + * + * @param digest The MessageDigest to use (e.g. MD5) + * @param data Data to digest + * @return the digest + * @throws IOException On error reading from the stream + * @since 1.14 + */ + public static MessageDigest updateDigest(final MessageDigest digest, final RandomAccessFile data) + throws IOException { + return updateDigest(digest, data.getChannel()); + } + + /** + * Updates the given {@link MessageDigest} from a String (converted to bytes using UTF-8). + *

    + * To update the digest using a different charset for the conversion, + * convert the String to a byte array using + * {@link String#getBytes(java.nio.charset.Charset)} and pass that + * to the {@link DigestUtils#updateDigest(MessageDigest, byte[])} method + * + * @param messageDigest + * the {@link MessageDigest} to update + * @param valueToDigest + * the value to update the {@link MessageDigest} with; + * converted to bytes using {@link StringUtils#getBytesUtf8(String)} + * @return the updated {@link MessageDigest} + * @since 1.7 + */ + public static MessageDigest updateDigest(final MessageDigest messageDigest, final String valueToDigest) { + messageDigest.update(StringUtils.getBytesUtf8(valueToDigest)); + return messageDigest; + } + + private final MessageDigest messageDigest; + + /** + * Preserves binary compatibility only. + * As for previous versions does not provide useful behavior + * @deprecated since 1.11; only useful to preserve binary compatibility + */ + @Deprecated + public DigestUtils() { + this.messageDigest = null; + } + + /** + * Creates an instance using the provided {@link MessageDigest} parameter. + * + * This can then be used to create digests using methods such as + * {@link #digest(byte[])} and {@link #digestAsHex(File)}. + * + * @param digest the {@link MessageDigest} to use + * @since 1.11 + */ + public DigestUtils(final MessageDigest digest) { + this.messageDigest = digest; + } + + /** + * Creates an instance using the provided {@link MessageDigest} parameter. + * + * This can then be used to create digests using methods such as + * {@link #digest(byte[])} and {@link #digestAsHex(File)}. + * + * @param name the name of the {@link MessageDigest} to use + * @see #getDigest(String) + * @throws IllegalArgumentException + * when a {@link NoSuchAlgorithmException} is caught. + * @since 1.11 + */ + public DigestUtils(final String name) { + this(getDigest(name)); + } + + /** + * Reads through a byte array and returns the digest for the data. + * + * @param data + * Data to digest + * @return the digest + * @since 1.11 + */ + public byte[] digest(final byte[] data) { + return updateDigest(messageDigest, data).digest(); + } + + /** + * Reads through a ByteBuffer and returns the digest for the data + * + * @param data + * Data to digest + * @return the digest + * + * @since 1.11 + */ + public byte[] digest(final ByteBuffer data) { + return updateDigest(messageDigest, data).digest(); + } + + /** + * Reads through a File and returns the digest for the data + * + * @param data + * Data to digest + * @return the digest + * @throws IOException + * On error reading from the stream + * @since 1.11 + */ + public byte[] digest(final File data) throws IOException { + return updateDigest(messageDigest, data).digest(); + } + + /** + * Reads through an InputStream and returns the digest for the data + * + * @param data + * Data to digest + * @return the digest + * @throws IOException + * On error reading from the stream + * @since 1.11 + */ + public byte[] digest(final InputStream data) throws IOException { + return updateDigest(messageDigest, data).digest(); + } + + /** + * Reads through a File and returns the digest for the data + * + * @param data + * Data to digest + * @param options + * options How to open the file + * @return the digest + * @throws IOException + * On error reading from the stream + * @since 1.14 + */ + @RequiresApi(api = 26) + public byte[] digest(final Path data, final OpenOption... options) throws IOException { + return updateDigest(messageDigest, data, options).digest(); + } + + /** + * Reads through a byte array and returns the digest for the data. + * + * @param data + * Data to digest treated as UTF-8 string + * @return the digest + * @since 1.11 + */ + public byte[] digest(final String data) { + return updateDigest(messageDigest, data).digest(); + } + + /** + * Reads through a byte array and returns the digest for the data. + * + * @param data + * Data to digest + * @return the digest as a hex string + * @since 1.11 + */ + public String digestAsHex(final byte[] data) { + return Hex.encodeHexString(digest(data)); + } + + /** + * Reads through a ByteBuffer and returns the digest for the data + * + * @param data + * Data to digest + * @return the digest as a hex string + * + * @since 1.11 + */ + public String digestAsHex(final ByteBuffer data) { + return Hex.encodeHexString(digest(data)); + } + + /** + * Reads through a File and returns the digest for the data + * + * @param data + * Data to digest + * @return the digest as a hex string + * @throws IOException + * On error reading from the stream + * @since 1.11 + */ + public String digestAsHex(final File data) throws IOException { + return Hex.encodeHexString(digest(data)); + } + + /** + * Reads through an InputStream and returns the digest for the data + * + * @param data + * Data to digest + * @return the digest as a hex string + * @throws IOException + * On error reading from the stream + * @since 1.11 + */ + public String digestAsHex(final InputStream data) throws IOException { + return Hex.encodeHexString(digest(data)); + } + + /** + * Reads through a File and returns the digest for the data + * + * @param data + * Data to digest + * @param options + * options How to open the file + * @return the digest as a hex string + * @throws IOException + * On error reading from the stream + * @since 1.11 + */ + @RequiresApi(api = 26) + public String digestAsHex(final Path data, final OpenOption... options) throws IOException { + return Hex.encodeHexString(digest(data, options)); + } + + /** + * Reads through a byte array and returns the digest for the data. + * + * @param data + * Data to digest treated as UTF-8 string + * @return the digest as a hex string + * @since 1.11 + */ + public String digestAsHex(final String data) { + return Hex.encodeHexString(digest(data)); + } + + /** + * Returns the message digest instance. + * @return the message digest instance + * @since 1.11 + */ + public MessageDigest getMessageDigest() { + return messageDigest; + } + +} diff --git a/app/src/full/java/vendored/org/apache/commons/codec/digest/MessageDigestAlgorithms.java b/app/src/full/java/vendored/org/apache/commons/codec/digest/MessageDigestAlgorithms.java new file mode 100644 index 000000000..e5401f3ee --- /dev/null +++ b/app/src/full/java/vendored/org/apache/commons/codec/digest/MessageDigestAlgorithms.java @@ -0,0 +1,174 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 vendored.org.apache.commons.codec.digest; + +import java.security.MessageDigest; + +/** + * Standard {@link MessageDigest} algorithm names from the Java Cryptography Architecture Standard Algorithm Name + * Documentation. + *

    + * This class is immutable and thread-safe. + *

    + *

    + * Java 8 and up: SHA-224. + *

    + *

    + * Java 9 and up: SHA3-224, SHA3-256, SHA3-384, SHA3-512. + *

    + * + * @see + * Java 7 Cryptography Architecture Standard Algorithm Name Documentation + * @see + * Java 8 Cryptography Architecture Standard Algorithm Name Documentation + * @see + * Java 9 Cryptography Architecture Standard Algorithm Name Documentation + * @see + * Java 10 Cryptography Architecture Standard Algorithm Name Documentation + * @see + * Java 11 Cryptography Architecture Standard Algorithm Name Documentation + * @see + * Java 12 Cryptography Architecture Standard Algorithm Name Documentation + * @see + * Java 13 Cryptography Architecture Standard Algorithm Name Documentation + * + * @see FIPS PUB 180-4 + * @see FIPS PUB 202 + * @since 1.7 + */ +public class MessageDigestAlgorithms { + + /** + * The MD2 message digest algorithm defined in RFC 1319. + */ + public static final String MD2 = "MD2"; + + /** + * The MD5 message digest algorithm defined in RFC 1321. + */ + public static final String MD5 = "MD5"; + + /** + * The SHA-1 hash algorithm defined in the FIPS PUB 180-2. + */ + public static final String SHA_1 = "SHA-1"; + + /** + * The SHA-224 hash algorithm defined in the FIPS PUB 180-3. + *

    + * Present in Oracle Java 8. + *

    + * + * @since 1.11 + */ + public static final String SHA_224 = "SHA-224"; + + /** + * The SHA-256 hash algorithm defined in the FIPS PUB 180-2. + */ + public static final String SHA_256 = "SHA-256"; + + /** + * The SHA-384 hash algorithm defined in the FIPS PUB 180-2. + */ + public static final String SHA_384 = "SHA-384"; + + /** + * The SHA-512 hash algorithm defined in the FIPS PUB 180-2. + */ + public static final String SHA_512 = "SHA-512"; + + /** + * The SHA-512 hash algorithm defined in the FIPS PUB 180-4. + *

    + * Included starting in Oracle Java 9. + *

    + * + * @since 1.14 + */ + public static final String SHA_512_224 = "SHA-512/224"; + + /** + * The SHA-512 hash algorithm defined in the FIPS PUB 180-4. + *

    + * Included starting in Oracle Java 9. + *

    + * + * @since 1.14 + */ + public static final String SHA_512_256 = "SHA-512/256"; + + /** + * The SHA3-224 hash algorithm defined in the FIPS PUB 202. + *

    + * Included starting in Oracle Java 9. + *

    + * + * @since 1.11 + */ + public static final String SHA3_224 = "SHA3-224"; + + /** + * The SHA3-256 hash algorithm defined in the FIPS PUB 202. + *

    + * Included starting in Oracle Java 9. + *

    + * + * @since 1.11 + */ + public static final String SHA3_256 = "SHA3-256"; + + /** + * The SHA3-384 hash algorithm defined in the FIPS PUB 202. + *

    + * Included starting in Oracle Java 9. + *

    + * + * @since 1.11 + */ + public static final String SHA3_384 = "SHA3-384"; + + /** + * The SHA3-512 hash algorithm defined in the FIPS PUB 202. + *

    + * Included starting in Oracle Java 9. + *

    + * + * @since 1.11 + */ + public static final String SHA3_512 = "SHA3-512"; + + /** + * Gets all constant values defined in this class. + * + * @return all constant values defined in this class. + * @since 1.11 + */ + public static String[] values() { + // N.B. do not use a constant array here as that can be changed externally by accident or design + return new String[] { + MD2, MD5, SHA_1, SHA_224, SHA_256, SHA_384, + SHA_512, SHA_512_224, SHA_512_256, SHA3_224, SHA3_256, SHA3_384, SHA3_512 + }; + } + + private MessageDigestAlgorithms() { + // cannot be instantiated. + } + +} diff --git a/app/src/full/kotlin/org/fdroid/LegacyUtils.kt b/app/src/full/kotlin/org/fdroid/LegacyUtils.kt new file mode 100644 index 000000000..e2ea38842 --- /dev/null +++ b/app/src/full/kotlin/org/fdroid/LegacyUtils.kt @@ -0,0 +1,24 @@ +package org.fdroid + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch + +object LegacyUtils { + + @JvmStatic + @JvmOverloads + fun collectInJava( + scope: CoroutineScope = CoroutineScope(context = kotlinx.coroutines.Dispatchers.Main), + flow: Flow, + action: (T) -> Any + ): Job { + return scope.launch { + flow.collect { value -> + action(value) + } + } + } + +} diff --git a/app/src/full/kotlin/org/fdroid/ui/navigation/ExtraNavigationEntries.kt b/app/src/full/kotlin/org/fdroid/ui/navigation/ExtraNavigationEntries.kt index eb281857b..fdf6a3911 100644 --- a/app/src/full/kotlin/org/fdroid/ui/navigation/ExtraNavigationEntries.kt +++ b/app/src/full/kotlin/org/fdroid/ui/navigation/ExtraNavigationEntries.kt @@ -1,6 +1,18 @@ package org.fdroid.ui.navigation +import android.content.Context import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavKey +import org.fdroid.R +import org.fdroid.ui.nearby.NearbyStart -fun EntryProviderScope.extraNavigationEntries(navigator: Navigator) {} +fun getMoreMenuItems(context: Context): List = + listOf( + NavDestinations.AllApps(context.getString(R.string.app_list_all)), + NavDestinations.Nearby, + NavDestinations.About, + ) + +fun EntryProviderScope.extraNavigationEntries(navigator: Navigator) { + entry(NavigationKey.Nearby) { NearbyStart { navigator.goBack() } } +} diff --git a/app/src/full/kotlin/org/fdroid/ui/nearby/NearbyStart.kt b/app/src/full/kotlin/org/fdroid/ui/nearby/NearbyStart.kt new file mode 100644 index 000000000..44af09179 --- /dev/null +++ b/app/src/full/kotlin/org/fdroid/ui/nearby/NearbyStart.kt @@ -0,0 +1,58 @@ +package org.fdroid.ui.nearby + +import android.content.Intent +import androidx.compose.foundation.layout.Arrangement.spacedBy +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import org.fdroid.R +import org.fdroid.fdroid.nearby.SwapService +import org.fdroid.ui.FDroidContent +import org.fdroid.ui.utils.BackButton + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +fun NearbyStart(onBackClicked: () -> Unit) { + val context = LocalContext.current + Scaffold( + topBar = { TopAppBar(navigationIcon = { BackButton(onClick = onBackClicked) }, title = {}) } + ) { innerPadding -> + Column( + modifier = + Modifier.fillMaxWidth() + .padding(innerPadding) + .padding(top = 16.dp, start = 16.dp, end = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = spacedBy(16.dp), + ) { + Text(stringResource(R.string.nearby_splash__download_apps_from_people_nearby)) + Button( + onClick = { + val i = Intent(context, SwapService::class.java) + ContextCompat.startForegroundService(context, i) + } + ) { + Text(stringResource(R.string.nearby_splash__find_people_button)) + } + } + } +} + +@Preview +@Composable +private fun Preview() { + FDroidContent { NearbyStart({}) } +} diff --git a/app/src/full/kotlin/org/fdroid/ui/nearby/SwapSuccess.kt b/app/src/full/kotlin/org/fdroid/ui/nearby/SwapSuccess.kt new file mode 100644 index 000000000..4c0df78aa --- /dev/null +++ b/app/src/full/kotlin/org/fdroid/ui/nearby/SwapSuccess.kt @@ -0,0 +1,117 @@ +package org.fdroid.ui.nearby + +import android.util.Log +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.fdroid.R +import org.fdroid.install.InstallConfirmationState +import org.fdroid.install.InstallState +import org.fdroid.ui.FDroidContent +import org.fdroid.ui.utils.BigLoadingIndicator + +object SwapSuccessBinder { + @JvmStatic + fun bind(composeView: ComposeView, viewModel: SwapSuccessViewModel) { + composeView.setViewCompositionStrategy( + ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed, + ) + composeView.setContent { + FDroidContent { + val model by viewModel.model.collectAsStateWithLifecycle() + val appToConfirm = model.appToConfirm + LaunchedEffect(appToConfirm?.packageName, appToConfirm?.installState) { + val state = appToConfirm?.installState as? InstallConfirmationState ?: return@LaunchedEffect + viewModel.confirmAppInstall(appToConfirm.packageName, state) + } + SwapSuccess( + model = model, + onInstall = viewModel::install, + onCancel = viewModel::cancelInstall, + onCheckUserConfirmation = viewModel::checkUserConfirmation, + ) + } + } + } +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun SwapSuccess( + model: SwapSuccessModel, + onInstall: (String) -> Unit, + onCancel: (String) -> Unit, + onCheckUserConfirmation: (String, InstallState.UserConfirmationNeeded) -> Unit, +) { + val lifecycleOwner = LocalLifecycleOwner.current + val currentAppToConfirm = model.appToConfirm + var numChecks by remember { mutableIntStateOf(0) } + DisposableEffect( + lifecycleOwner, + currentAppToConfirm?.packageName, + currentAppToConfirm?.installState + ) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + when (val state = currentAppToConfirm?.installState) { + is InstallState.UserConfirmationNeeded if numChecks < 3 -> { + Log.i("SwapSuccessScreen", "Resumed ($numChecks). Checking user confirmation... $state") + numChecks += 1 + onCheckUserConfirmation(currentAppToConfirm.packageName, state) + } + is InstallState.UserConfirmationNeeded -> { + Log.i("SwapSuccessScreen", "Cancel installation after repeated confirmation checks") + onCancel(currentAppToConfirm.packageName) + } + else -> { + numChecks = 0 + } + } + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } + + when { + model.loading -> BigLoadingIndicator() + model.apps.isEmpty() -> { + Column( + modifier = Modifier.fillMaxSize().padding(16.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(stringResource(R.string.swap_connecting)) + } + } + else -> { + LazyColumn { + items(model.apps, key = { it.packageName }) { app -> + SwapSuccessAppRow(app = app, onInstall = onInstall, onCancel = onCancel) + } + } + } + } +} diff --git a/app/src/full/kotlin/org/fdroid/ui/nearby/SwapSuccessAppRow.kt b/app/src/full/kotlin/org/fdroid/ui/nearby/SwapSuccessAppRow.kt new file mode 100644 index 000000000..52f663118 --- /dev/null +++ b/app/src/full/kotlin/org/fdroid/ui/nearby/SwapSuccessAppRow.kt @@ -0,0 +1,290 @@ +package org.fdroid.ui.nearby + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.fdroid.R +import org.fdroid.install.InstallState +import org.fdroid.install.InstallStateWithInfo +import org.fdroid.ui.FDroidContent +import org.fdroid.ui.apps.VersionLine +import org.fdroid.ui.utils.AsyncShimmerImage + +@Composable +fun SwapSuccessAppRow( + app: SwapSuccessItem, + onInstall: (String) -> Unit, + onCancel: (String) -> Unit, +) { + ListItem( + leadingContent = { + AsyncShimmerImage( + model = app.iconModel, + error = painterResource(R.drawable.ic_repo_app_default), + contentDescription = null, + modifier = Modifier.size(48.dp), + ) + }, + headlineContent = { Text(app.name) }, + supportingContent = { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + if (app.hasUpdate) { + VersionLine(app.installedVersionName, app.versionName) + } else { + Text(app.versionName) + } + val errorState = app.installState as? InstallState.Error + if (errorState?.msg != null) { + Text(errorState.msg, color = MaterialTheme.colorScheme.error) + } + } + }, + trailingContent = { + Column( + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + when (val state = app.installState) { + is InstallState.Installed -> { + Text(stringResource(R.string.app_installed)) + } + is InstallState.Error -> {} + is InstallState.Downloading -> { + Box(contentAlignment = Alignment.Center) { + IconButton(onClick = { onCancel(app.packageName) }) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.cancel), + ) + } + CircularProgressIndicator(progress = { state.progress }) + } + } + is InstallStateWithInfo -> { + Box(contentAlignment = Alignment.Center) { + IconButton(onClick = { onCancel(app.packageName) }) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.cancel), + ) + } + CircularProgressIndicator() + } + } + else -> { + if (app.isInstalled && !app.hasUpdate) { + Text(stringResource(R.string.app_installed)) + } else { + Button(onClick = { onInstall(app.packageName) }) { + Text( + stringResource( + if (app.hasUpdate) R.string.menu_upgrade else R.string.menu_install + ) + ) + } + } + } + } + } + }, + ) +} + +@Preview +@Composable +private fun SwapSuccessAppRowPreview() { + val now = System.currentTimeMillis() + FDroidContent { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + SwapSuccessAppRow( + app = + previewSwapSuccessUiItem( + packageName = "org.example.install", + name = "Fresh Install", + versionName = "1.2.0", + versionCode = 120L, + installedVersionName = null, + installedVersionCode = null, + installState = InstallState.Unknown, + ), + onInstall = {}, + onCancel = {}, + ) + SwapSuccessAppRow( + app = + previewSwapSuccessUiItem( + packageName = "org.example.update", + name = "Update Available", + versionName = "2.0.0", + versionCode = 200L, + installedVersionName = "1.9.0", + installedVersionCode = 190L, + installState = InstallState.Unknown, + ), + onInstall = {}, + onCancel = {}, + ) + SwapSuccessAppRow( + app = + previewSwapSuccessUiItem( + packageName = "org.example.installed", + name = "Already Installed", + versionName = "3.1.0", + versionCode = 310L, + installedVersionName = "3.1.0", + installedVersionCode = 310L, + installState = InstallState.Unknown, + ), + onInstall = {}, + onCancel = {}, + ) + SwapSuccessAppRow( + app = + previewSwapSuccessUiItem( + packageName = "org.example.downloading", + name = "Downloading", + versionName = "4.0.0", + versionCode = 400L, + installedVersionName = "3.5.0", + installedVersionCode = 350L, + installState = + InstallState.Downloading( + name = "Downloading", + versionName = "4.0.0", + currentVersionName = "3.5.0", + lastUpdated = now, + iconModel = null, + downloadedBytes = 40, + totalBytes = 100, + startMillis = now, + ), + ), + onInstall = {}, + onCancel = {}, + ) + SwapSuccessAppRow( + app = + previewSwapSuccessUiItem( + packageName = "org.example.installing", + name = "Installing", + versionName = "5.0.0", + versionCode = 500L, + installedVersionName = null, + installedVersionCode = null, + installState = + InstallState.Installing( + name = "Installing", + versionName = "5.0.0", + currentVersionName = null, + lastUpdated = now, + iconModel = null, + ), + ), + onInstall = {}, + onCancel = {}, + ) + SwapSuccessAppRow( + app = + previewSwapSuccessUiItem( + packageName = "org.example.error", + name = "Failed Install", + versionName = "6.0.0", + versionCode = 600L, + installedVersionName = "5.8.0", + installedVersionCode = 580L, + installState = + InstallState.Error( + msg = "Signature mismatch", + name = "Failed Install", + versionName = "6.0.0", + currentVersionName = "5.8.0", + lastUpdated = now, + iconModel = null, + ), + ), + onInstall = {}, + onCancel = {}, + ) + SwapSuccessAppRow( + app = + previewSwapSuccessUiItem( + packageName = "org.example.installedstate", + name = "Installed State", + versionName = "7.0.0", + versionCode = 700L, + installedVersionName = "7.0.0", + installedVersionCode = 700L, + installState = + InstallState.Installed( + name = "Installed State", + versionName = "7.0.0", + currentVersionName = "6.9.0", + lastUpdated = now, + iconModel = null, + ), + ), + onInstall = {}, + onCancel = {}, + ) + } + } +} + +@Preview(locale = "fa") +@Composable +private fun SwapSuccessAppRowRtlPreview() { + FDroidContent { + SwapSuccessAppRow( + app = + previewSwapSuccessUiItem( + packageName = "org.example.rtl", + name = "برنامه نمونه", + versionName = "2.0.0-beta", + versionCode = 200L, + installedVersionName = "1.9.0-alpha", + installedVersionCode = 190L, + installState = InstallState.Unknown, + ), + onInstall = {}, + onCancel = {}, + ) + } +} + +private fun previewSwapSuccessUiItem( + packageName: String, + name: String, + versionName: String, + versionCode: Long, + installedVersionName: String?, + installedVersionCode: Long?, + installState: InstallState, +): SwapSuccessItem = + SwapSuccessItem( + packageName = packageName, + name = name, + versionName = versionName, + versionCode = versionCode, + installedVersionName = installedVersionName, + installedVersionCode = installedVersionCode, + iconModel = null, + installState = installState, + ) + diff --git a/app/src/full/kotlin/org/fdroid/ui/nearby/SwapSuccessModel.kt b/app/src/full/kotlin/org/fdroid/ui/nearby/SwapSuccessModel.kt new file mode 100644 index 000000000..1696437fd --- /dev/null +++ b/app/src/full/kotlin/org/fdroid/ui/nearby/SwapSuccessModel.kt @@ -0,0 +1,26 @@ +package org.fdroid.ui.nearby + +import org.fdroid.install.InstallState + +data class SwapSuccessModel( + val apps: List = emptyList(), + val loading: Boolean = true, + val appToConfirm: SwapSuccessItem? = null, +) + +data class SwapSuccessItem( + val packageName: String, + val name: String, + val versionName: String, + val versionCode: Long, + val installedVersionName: String?, + val installedVersionCode: Long?, + val iconModel: Any?, + val installState: InstallState = InstallState.Unknown, +) { + val isInstalled: Boolean + get() = installedVersionCode != null && installedVersionCode >= versionCode + + val hasUpdate: Boolean + get() = installedVersionCode != null && installedVersionCode < versionCode +} diff --git a/app/src/full/kotlin/org/fdroid/ui/nearby/SwapSuccessViewModel.kt b/app/src/full/kotlin/org/fdroid/ui/nearby/SwapSuccessViewModel.kt new file mode 100644 index 000000000..cf699c696 --- /dev/null +++ b/app/src/full/kotlin/org/fdroid/ui/nearby/SwapSuccessViewModel.kt @@ -0,0 +1,241 @@ +package org.fdroid.ui.nearby + +import android.annotation.SuppressLint +import android.app.Application +import androidx.core.content.pm.PackageInfoCompat +import androidx.core.os.LocaleListCompat +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import mu.KotlinLogging +import org.fdroid.LocaleChooser.getBestLocale +import org.fdroid.database.AppMetadata +import org.fdroid.database.Repository +import org.fdroid.download.DownloadRequest +import org.fdroid.download.PackageName +import org.fdroid.download.getImageModel +import org.fdroid.fdroid.nearby.SwapService +import org.fdroid.index.v1.AppV1 +import org.fdroid.index.v1.IndexV1 +import org.fdroid.index.v1.PackageV1 +import org.fdroid.index.v2.MetadataV2 +import org.fdroid.index.v2.PackageVersionV2 +import org.fdroid.install.AppInstallManager +import org.fdroid.install.InstallConfirmationState +import org.fdroid.install.InstallState +import org.fdroid.install.InstalledAppsCache +import org.fdroid.settings.SettingsManager + +@HiltViewModel +class SwapSuccessViewModel +@Inject +constructor( + app: Application, + private val appInstallManager: AppInstallManager, + private val installedAppsCache: InstalledAppsCache, + private val settingsManager: SettingsManager, +) : AndroidViewModel(app) { + + private val log = KotlinLogging.logger {} + + // the service should be running for the lifetime of the view model + @SuppressLint("StaticFieldLeak") private var service: SwapService? = null + + private val installedApps + get() = installedAppsCache.installedApps.value + + private val localeList = LocaleListCompat.getDefault() + private val peerRepo = MutableStateFlow(null) + private val repoApps = MutableStateFlow?>(null) + + val model: StateFlow = + combine(repoApps, appInstallManager.appInstallStates) { repoApps, installStates -> + val apps = repoApps?.map { repoApp -> + val installedPackage = installedApps[repoApp.packageName] + val iconModel = + if (installedPackage != null) { + PackageName(repoApp.packageName, repoApp.iconRequest) + } else { + repoApp.iconRequest + } + SwapSuccessItem( + packageName = repoApp.packageName, + name = repoApp.name, + versionName = repoApp.versionName, + versionCode = repoApp.versionCode, + installedVersionName = installedPackage?.versionName, + installedVersionCode = installedPackage?.let(PackageInfoCompat::getLongVersionCode), + iconModel = iconModel, + installState = installStates[repoApp.packageName] ?: InstallState.Unknown, + ) + } + SwapSuccessModel( + apps = apps ?: emptyList(), + loading = apps == null, + appToConfirm = + apps + ?.filter { it.installState is InstallConfirmationState } + ?.minByOrNull { (it.installState as InstallConfirmationState).creationTimeMillis }, + ) + } + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5_000), + SwapSuccessModel(loading = true), + ) + + fun onServiceConnected(service: SwapService) { + log.info { "Connected to service: $service" } + service.index.observeForever(this::onIndexChanged) + this.service = service + } + + fun onServiceDisconnected(service: SwapService) { + log.info { "Disconnected from service: $service" } + service.index.removeObserver(this::onIndexChanged) + this.service = null + } + + fun onIndexChanged(indexV1: IndexV1) { + val repo = service?.peerRepo + peerRepo.value = repo + repoApps.value = + repo?.let { repo -> + indexV1.apps.mapNotNull { app -> + app.toSwapRepoApp(indexV1.packages[app.packageName], repo) + } + } ?: emptyList() + } + + fun install(packageName: String) { + val repoApp = repoApps.value?.firstOrNull { it.packageName == packageName } ?: return + val repo = peerRepo.value ?: return + val currentVersionName = + model.value.apps.firstOrNull { it.packageName == packageName }?.installedVersionName + viewModelScope.launch(Dispatchers.Main) { + appInstallManager.install( + packageName = packageName, + appMetadata = repoApp.appMetadata, + version = repoApp.packageVersion, + currentVersionName = currentVersionName, + repo = repo, + iconModel = repoApp.iconRequest, + canAskPreApprovalNow = true, + ) + } + } + + fun confirmAppInstall(packageName: String, state: InstallConfirmationState) { + viewModelScope.launch(Dispatchers.Main) { + when (state) { + is InstallState.PreApprovalConfirmationNeeded -> { + appInstallManager.requestPreApprovalConfirmation(packageName, state) + } + is InstallState.UserConfirmationNeeded -> { + appInstallManager.requestUserConfirmation(packageName, state) + } + } + } + } + + fun checkUserConfirmation(packageName: String, state: InstallState.UserConfirmationNeeded) { + viewModelScope.launch(Dispatchers.Main) { + delay(500) + appInstallManager.checkUserConfirmation(packageName, state) + } + } + + fun cancelInstall(packageName: String) { + appInstallManager.cancel(packageName) + } + + override fun onCleared() { + repoApps.value?.forEach { appInstallManager.cleanUp(it.packageName) } + super.onCleared() + } + + private data class SwapRepoApp( + val packageName: String, + val name: String, + val versionName: String, + val versionCode: Long, + val iconRequest: DownloadRequest?, + val appMetadata: AppMetadata, + val packageVersion: PackageVersionV2, + ) + + private fun AppV1.toSwapRepoApp( + packages: List?, + repository: Repository, + ): SwapRepoApp? { + val packageV1 = packages?.firstOrNull() ?: return null + val packageVersion = + packageV1.toPackageVersionV2( + releaseChannels = emptyList(), + appAntiFeatures = emptyMap(), + whatsNew = emptyMap(), + ) + val metadataV2 = toMetadataV2(packageVersion.signer?.sha256?.firstOrNull()) + val metadata = metadataV2.toAppMetadata(repository.repoId, packageName, localeList) + val iconRequest = + metadataV2.icon + ?.getBestLocale(localeList) + ?.getImageModel(repository, settingsManager.proxyConfig) as? DownloadRequest + return SwapRepoApp( + packageName = packageName, + name = metadata.localizedName ?: name ?: packageName, + versionName = packageVersion.versionName, + versionCode = packageVersion.versionCode, + iconRequest = iconRequest, + appMetadata = metadata, + packageVersion = packageVersion, + ) + } +} + +private fun MetadataV2.toAppMetadata( + repoId: Long, + packageName: String, + localeList: LocaleListCompat, +): AppMetadata = + AppMetadata( + repoId = repoId, + packageName = packageName, + added = added, + lastUpdated = lastUpdated, + name = name, + summary = summary, + description = description, + localizedName = name.getBestLocale(localeList), + localizedSummary = summary.getBestLocale(localeList), + webSite = webSite, + changelog = changelog, + license = license, + sourceCode = sourceCode, + issueTracker = issueTracker, + translation = translation, + preferredSigner = preferredSigner, + video = video, + authorName = authorName, + authorEmail = authorEmail, + authorWebSite = authorWebSite, + authorPhone = authorPhone, + donate = donate, + liberapayID = liberapayID, + liberapay = liberapay, + openCollective = openCollective, + bitcoin = bitcoin, + litecoin = litecoin, + flattrID = flattrID, + categories = categories, + isCompatible = true, + ) diff --git a/app/src/full/res/drawable-hdpi/circle.png b/app/src/full/res/drawable-hdpi/circle.png new file mode 100644 index 000000000..9de5d38fa Binary files /dev/null and b/app/src/full/res/drawable-hdpi/circle.png differ diff --git a/app/src/full/res/drawable-hdpi/ic_fdroid_grey.png b/app/src/full/res/drawable-hdpi/ic_fdroid_grey.png new file mode 100644 index 000000000..098e521de Binary files /dev/null and b/app/src/full/res/drawable-hdpi/ic_fdroid_grey.png differ diff --git a/app/src/full/res/drawable-hdpi/swap_start_header.png b/app/src/full/res/drawable-hdpi/swap_start_header.png new file mode 100644 index 000000000..9f45bc1b7 Binary files /dev/null and b/app/src/full/res/drawable-hdpi/swap_start_header.png differ diff --git a/app/src/full/res/drawable-ldpi/circle.png b/app/src/full/res/drawable-ldpi/circle.png new file mode 100644 index 000000000..e72332bdb Binary files /dev/null and b/app/src/full/res/drawable-ldpi/circle.png differ diff --git a/app/src/full/res/drawable-ldpi/ic_fdroid_grey.png b/app/src/full/res/drawable-ldpi/ic_fdroid_grey.png new file mode 100644 index 000000000..f0512812a Binary files /dev/null and b/app/src/full/res/drawable-ldpi/ic_fdroid_grey.png differ diff --git a/app/src/full/res/drawable-ldpi/swap_start_header.png b/app/src/full/res/drawable-ldpi/swap_start_header.png new file mode 100644 index 000000000..8de95d285 Binary files /dev/null and b/app/src/full/res/drawable-ldpi/swap_start_header.png differ diff --git a/app/src/full/res/drawable-mdpi/circle.png b/app/src/full/res/drawable-mdpi/circle.png new file mode 100644 index 000000000..a5e6e2a8d Binary files /dev/null and b/app/src/full/res/drawable-mdpi/circle.png differ diff --git a/app/src/full/res/drawable-mdpi/ic_fdroid_grey.png b/app/src/full/res/drawable-mdpi/ic_fdroid_grey.png new file mode 100644 index 000000000..44c0e61f8 Binary files /dev/null and b/app/src/full/res/drawable-mdpi/ic_fdroid_grey.png differ diff --git a/app/src/full/res/drawable-mdpi/swap_start_header.png b/app/src/full/res/drawable-mdpi/swap_start_header.png new file mode 100644 index 000000000..fd1dc2af8 Binary files /dev/null and b/app/src/full/res/drawable-mdpi/swap_start_header.png differ diff --git a/app/src/full/res/drawable-xhdpi/circle.png b/app/src/full/res/drawable-xhdpi/circle.png new file mode 100644 index 000000000..74e972ea4 Binary files /dev/null and b/app/src/full/res/drawable-xhdpi/circle.png differ diff --git a/app/src/full/res/drawable-xhdpi/ic_fdroid_grey.png b/app/src/full/res/drawable-xhdpi/ic_fdroid_grey.png new file mode 100644 index 000000000..4ed540141 Binary files /dev/null and b/app/src/full/res/drawable-xhdpi/ic_fdroid_grey.png differ diff --git a/app/src/full/res/drawable-xhdpi/swap_start_header.png b/app/src/full/res/drawable-xhdpi/swap_start_header.png new file mode 100644 index 000000000..255a890b0 Binary files /dev/null and b/app/src/full/res/drawable-xhdpi/swap_start_header.png differ diff --git a/app/src/full/res/drawable-xxhdpi/circle.png b/app/src/full/res/drawable-xxhdpi/circle.png new file mode 100644 index 000000000..9b8cc04f6 Binary files /dev/null and b/app/src/full/res/drawable-xxhdpi/circle.png differ diff --git a/app/src/full/res/drawable-xxhdpi/ic_fdroid_grey.png b/app/src/full/res/drawable-xxhdpi/ic_fdroid_grey.png new file mode 100644 index 000000000..248050d7f Binary files /dev/null and b/app/src/full/res/drawable-xxhdpi/ic_fdroid_grey.png differ diff --git a/app/src/full/res/drawable-xxhdpi/swap_start_header.png b/app/src/full/res/drawable-xxhdpi/swap_start_header.png new file mode 100644 index 000000000..3c06713ff Binary files /dev/null and b/app/src/full/res/drawable-xxhdpi/swap_start_header.png differ diff --git a/app/src/full/res/drawable-xxxhdpi/circle.png b/app/src/full/res/drawable-xxxhdpi/circle.png new file mode 100644 index 000000000..0a0668914 Binary files /dev/null and b/app/src/full/res/drawable-xxxhdpi/circle.png differ diff --git a/app/src/full/res/drawable-xxxhdpi/ic_fdroid_grey.png b/app/src/full/res/drawable-xxxhdpi/ic_fdroid_grey.png new file mode 100644 index 000000000..8284efcb6 Binary files /dev/null and b/app/src/full/res/drawable-xxxhdpi/ic_fdroid_grey.png differ diff --git a/app/src/full/res/drawable-xxxhdpi/swap_start_header.png b/app/src/full/res/drawable-xxxhdpi/swap_start_header.png new file mode 100644 index 000000000..9e33249c8 Binary files /dev/null and b/app/src/full/res/drawable-xxxhdpi/swap_start_header.png differ diff --git a/app/src/full/res/drawable/check.xml b/app/src/full/res/drawable/check.xml new file mode 100644 index 000000000..3f26c70ce --- /dev/null +++ b/app/src/full/res/drawable/check.xml @@ -0,0 +1,11 @@ + + + + \ No newline at end of file diff --git a/app/src/full/res/drawable/ic_add_circle_outline.xml b/app/src/full/res/drawable/ic_add_circle_outline.xml new file mode 100644 index 000000000..c6c1d9a0a --- /dev/null +++ b/app/src/full/res/drawable/ic_add_circle_outline.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/full/res/drawable/ic_apps.xml b/app/src/full/res/drawable/ic_apps.xml new file mode 100644 index 000000000..af3bfb8ef --- /dev/null +++ b/app/src/full/res/drawable/ic_apps.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/full/res/drawable/ic_arrow_back.xml b/app/src/full/res/drawable/ic_arrow_back.xml new file mode 100644 index 000000000..89d18543f --- /dev/null +++ b/app/src/full/res/drawable/ic_arrow_back.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/full/res/drawable/ic_arrow_forward.xml b/app/src/full/res/drawable/ic_arrow_forward.xml new file mode 100644 index 000000000..8083d5920 --- /dev/null +++ b/app/src/full/res/drawable/ic_arrow_forward.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/full/res/drawable/ic_bluetooth.xml b/app/src/full/res/drawable/ic_bluetooth.xml new file mode 100644 index 000000000..287c8fb08 --- /dev/null +++ b/app/src/full/res/drawable/ic_bluetooth.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/full/res/drawable/ic_bluetooth_searching.xml b/app/src/full/res/drawable/ic_bluetooth_searching.xml new file mode 100644 index 000000000..c4b89a589 --- /dev/null +++ b/app/src/full/res/drawable/ic_bluetooth_searching.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/full/res/drawable/ic_close.xml b/app/src/full/res/drawable/ic_close.xml new file mode 100644 index 000000000..ef2477f09 --- /dev/null +++ b/app/src/full/res/drawable/ic_close.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/full/res/drawable/ic_nearby.xml b/app/src/full/res/drawable/ic_nearby.xml new file mode 100644 index 000000000..59566d6b2 --- /dev/null +++ b/app/src/full/res/drawable/ic_nearby.xml @@ -0,0 +1,33 @@ + + + + + + + diff --git a/app/src/full/res/drawable/ic_qr_code.xml b/app/src/full/res/drawable/ic_qr_code.xml new file mode 100644 index 000000000..a2ef5ec78 --- /dev/null +++ b/app/src/full/res/drawable/ic_qr_code.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + diff --git a/app/src/full/res/drawable/ic_search.xml b/app/src/full/res/drawable/ic_search.xml new file mode 100644 index 000000000..a0d1f7626 --- /dev/null +++ b/app/src/full/res/drawable/ic_search.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/full/res/drawable/ic_wifi.xml b/app/src/full/res/drawable/ic_wifi.xml new file mode 100644 index 000000000..709530074 --- /dev/null +++ b/app/src/full/res/drawable/ic_wifi.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/full/res/drawable/ic_wifi_off.xml b/app/src/full/res/drawable/ic_wifi_off.xml new file mode 100644 index 000000000..1e60ce147 --- /dev/null +++ b/app/src/full/res/drawable/ic_wifi_off.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/full/res/drawable/ic_wifi_tethering.xml b/app/src/full/res/drawable/ic_wifi_tethering.xml new file mode 100644 index 000000000..c5db5777c --- /dev/null +++ b/app/src/full/res/drawable/ic_wifi_tethering.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/full/res/drawable/nearby_splash.xml b/app/src/full/res/drawable/nearby_splash.xml new file mode 100644 index 000000000..c118830a3 --- /dev/null +++ b/app/src/full/res/drawable/nearby_splash.xml @@ -0,0 +1,12 @@ + + + diff --git a/app/src/full/res/layout-sw480dp/start_swap_header.xml b/app/src/full/res/layout-sw480dp/start_swap_header.xml new file mode 100644 index 000000000..dae55f41a --- /dev/null +++ b/app/src/full/res/layout-sw480dp/start_swap_header.xml @@ -0,0 +1,33 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/full/res/layout/main_tab_nearby.xml b/app/src/full/res/layout/main_tab_nearby.xml new file mode 100644 index 000000000..8f9cec118 --- /dev/null +++ b/app/src/full/res/layout/main_tab_nearby.xml @@ -0,0 +1,141 @@ + + + + + + + + + +