Port old nearby feature to new full flavor

We had worked on a new nearby feature which would also employ BLE to find contacts when they are not on the same Wi-Fi and then auto connect via Wifi Direct. This had a prototype app and a UI prototype. The new nearby was ~80% done, but then scraped. So now unfortuntately, F-Droid 2.0 will need to ship with the old nearby code which is barely held together by duct tape.

This commit mostly copies the old files as-is and does minimal changes so they can keep working in the new environment. An exception is the "success view" which was woven so deep into the old job intent services and state handling that it couldn't be re-used. Instead, a quick compose re-implementation with a viewmodel was made which hooks into the modern infrastructure, so app installs and presentation with icons is working.
This commit is contained in:
Torsten Grote
2026-03-30 11:15:53 -03:00
parent 54e69a49fa
commit 0f73f9ca0e
165 changed files with 18913 additions and 6 deletions

View File

@@ -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"))

View File

@@ -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<NavDestinations> =
listOf(NavDestinations.AllApps(context.getString(R.string.app_list_all)), NavDestinations.About)
fun EntryProviderScope<NavKey>.extraNavigationEntries(navigator: Navigator) {}

View File

@@ -2,7 +2,58 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application tools:ignore="MissingApplicationIcon">
<uses-feature
android:name="android.hardware.usb.host"
android:required="false" />
<uses-feature
android:name="android.hardware.bluetooth"
android:required="false" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<!-- Some Android ROMs still require Bluetooth permission above SDK 30 -->
<uses-permission
android:name="android.permission.BLUETOOTH"
android:maxSdkVersion="32" />
<uses-permission
android:name="android.permission.BLUETOOTH_ADMIN"
android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- used only for finding other devices to swap apps with -->
<uses-permission
android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation"
tools:targetApi="31" />
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission
android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
tools:ignore="AllFilesAccessPolicy,ScopedStorage" />
<uses-permission android:name="android.permission.WRITE_SETTINGS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission-sdk-23
android:name="android.permission.ACCESS_COARSE_LOCATION"
android:maxSdkVersion="30" />
<application
android:usesCleartextTraffic="true"
tools:ignore="MissingApplicationIcon">
<activity
android:name=".fdroid.nearby.SwapWorkflowActivity"
android:configChanges="orientation|keyboardHidden"
android:exported="false"
android:label="@string/swap"
android:launchMode="singleTask"
android:parentActivityName=".MainActivity"
android:theme="@style/Theme.App" />
<activity
android:name=".ui.panic.PanicActivity"
android:exported="true"
@@ -41,6 +92,58 @@
android:exported="false"
android:label="@string/ipfsgw_title"
android:launchMode="singleTask" />
<!--https://developer.android.com/guide/components/broadcast-exceptions -->
<receiver
android:name=".fdroid.nearby.UsbDeviceAttachedReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
</intent-filter>
<meta-data
android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED"
android:resource="@xml/device_filter" />
</receiver>
<receiver
android:name=".fdroid.nearby.UsbDeviceDetachedReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.hardware.usb.action.USB_DEVICE_DETACHED" />
</intent-filter>
<meta-data
android:name="android.hardware.usb.action.USB_DEVICE_DETACHED"
android:resource="@xml/device_filter" />
</receiver>
<receiver
android:name=".fdroid.nearby.UsbDeviceMediaMountedReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.MEDIA_EJECT" />
<action android:name="android.intent.action.MEDIA_REMOVED" />
<action android:name="android.intent.action.MEDIA_MOUNTED" />
<action android:name="android.intent.action.MEDIA_BAD_REMOVAL" />
<data android:scheme="content" />
<data android:scheme="file" />
</intent-filter>
</receiver>
<service
android:name=".fdroid.nearby.SwapService"
android:exported="false"
android:foregroundServiceType="connectedDevice" />
<service
android:name=".fdroid.nearby.LocalRepoService"
android:exported="false" />
<service
android:name=".fdroid.nearby.TreeUriScannerIntentService"
android:exported="false"
android:permission="android.permission.BIND_JOB_SERVICE" />
<service
android:name=".fdroid.nearby.SDCardScannerService"
android:exported="false"
android:permission="android.permission.BIND_JOB_SERVICE" />
</application>
</manifest>

View File

@@ -0,0 +1,138 @@
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN"
"http://www.w3.org/TR/html4/strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<title>F-Droid swap</title>
<meta name="description" content="">
<meta name="viewport" content="width=device-width">
<meta http-equiv="content-type" content="text/html;charset=utf-8">
<style type="text/css">
body {
padding: 0;
margin: 0;
font-family: "Roboto Light", "Roboto Lt", Helvetica, sans-serif;
color: #fff;
background-color: #1c6bbc;
}
#swap-icon {
display: block;
margin: 1em auto 0 auto;
width: 25%;
max-width: 400px;
height: auto;
}
.tick {
display: block;
float: right;
margin-right: 1em;
width: 30px;
height: 30px;
}
h1 {
padding: 0 1.5em;
text-align: center;
font-size: 1.2em;
font-weight: normal;
}
ol {
counter-reset: li;
margin-left: 0;
margin-bottom: 0;
padding-left: 0;
}
ol > li, #download-from-web {
padding: 1em 0;
border-bottom: solid 1px rgba(245, 245, 245, 0.2);
box-sizing: border-box;
}
ol > li:first-child {
border-top: solid 1px rgba(245, 245, 245, 0.2);
}
ol > li:before {
content: counter(li);
counter-increment: li;
font: bold 1.4em Sans-Serif;
margin-left: 1em;
margin-right: 0.5em;
}
ol > li a {
font-family: "Roboto", Helvetica, sans-serif;
font-weight: bold;
color: #fff;
}
details {
margin-left: 1em;
}
ul {
list-style-type: none;
}
ul > li {
padding: 1em 0;
}
ul > li a {
font-size: xx-large;
text-decoration: none;
color: #fff;
}
ul > li a img {
padding-right: 0.5em;
}
#download-from-web {
padding-left: 2em;
padding-right: 2em;
}
</style>
</head>
<body>
<img id="swap-icon" src="swap-icon.png" />
<h1>You're minutes away from having swap success!</h1>
<ol>
<li>
Find a swap
<img src="swap-tick-done.png" class="tick done" alt="Done"/>
</li>
<li>
<a href="{{CLIENT_URL}}">Download F-Droid</a>
<img src="swap-tick-not-done.png" class="tick not-done" alt="Not done" />
</li>
<li>
Install F-Droid
<img src="swap-tick-not-done.png" class="tick not-done" alt="Not done" />
</li>
<li>
<a href="{{REPO_URL}}">Add the swap to F-Droid</a>
<img src="swap-tick-not-done.png" class="tick not-done" alt="Not done" />
</li>
<li>
Install the apps you want
<img src="swap-tick-not-done.png" class="tick not-done" alt="Not done" />
</li>
</ol>
<br/><br/><br/><br/>
<details>
<summary>Available Apps</summary>
<ul>
{{APP_LIST}}
</ul>
</details>
</body>
</html>

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:cc="http://creativecommons.org/ns#" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns="http://www.w3.org/2000/svg" version="1.1" width="210.73421" height="146.02573" id="svg4443">
<defs
id="defs4445" />
<metadata
id="metadata4448">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
transform="translate(-323.20433,-293.63503)"
id="layer1">
<g
transform="matrix(1.0670994,0,0,-1.0670994,446.72215,312.16094)"
id="g3936-3">
<path
d="M 0,0 C 21.057,0 41.224,-7.789 56.795,-21.933 L 68.468,-9.08 C 49.692,7.971 25.381,17.361 0,17.361 c -51.395,0 -93.991,-38.272 -100.862,-87.807 l -14.889,7.251 c 7.94,-11.334 14.688,-28.971 18.26,-42.987 3.422,6.887 7.947,14.579 12.955,21.692 l 0.045,0 c 0,0.022 0.006,0.045 0.006,0.07 4.396,6.236 9.159,12.027 13.896,16.413 l -12.839,-3.155 C -77.015,-30.885 -42.056,0 0,0"
id="path3938-7"
style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none" />
</g>
<g
transform="matrix(1.0670994,0,0,-1.0670994,406.74135,394.21193)"
id="g3940-3">
<path
d="m 0,0 c 0.406,1.264 0.864,2.497 1.38,3.706 6.838,16.143 22.842,27.496 41.442,27.496 22.372,0 40.921,-16.423 44.364,-37.831 l -13.394,4.844 c 9.088,-10.425 17.673,-27.247 22.707,-40.806 5.021,13.559 13.605,30.381 22.7,40.809 L 104.8,-6.993 c -3.397,31.195 -29.887,55.558 -61.978,55.558 -26.999,0 -50.053,-17.26 -58.697,-41.334"
id="path3942-2"
style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 621 B

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 588 B

View File

@@ -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.
* <p>
* 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 <a href="https://github.com/shinilms/direct-net-share">shinilms/direct-net-share</a>
* @see <a href="https://github.com/geekywoman/direct-net-share">geekywoman/direct-net-share</a>
* @see <a href="https://github.com/aegis1980/WifiHotSpot">aegis1980/WifiHotSpot</a>
*/
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 extends InetAddress> T getInetAddress(Class<T> addressType) {
try {
Enumeration<NetworkInterface> ifaces = NetworkInterface.getNetworkInterfaces();
while (ifaces.hasMoreElements()) {
NetworkInterface iface = ifaces.nextElement();
if (!iface.getName().equals(deviceName)) {
continue;
}
Enumeration<InetAddress> 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<Client> getClients() {
if (!isEnabled()) {
return null;
}
List<Client> 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<Client> getReachableClients(final int timeout,
final ReachableClientListener listener) {
List<Client> 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;
}
}

View File

@@ -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:
* <p/>
* import android.provider.Contacts;
* <p/>
* 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,
};
}

View File

@@ -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<String> 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<String> 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<EncodeHintType, Object> 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();
}
}

View File

@@ -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<FDroidServiceInfo> CREATOR = new Creator<FDroidServiceInfo>() {
public FDroidServiceInfo createFromParcel(Parcel source) {
return new FDroidServiceInfo(source);
}
public FDroidServiceInfo[] newArray(int size) {
return new FDroidServiceInfo[size];
}
};
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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<String, LoggerInterface> loggers = new TreeMap<String, LoggerInterface>();
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;
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}
}

View File

@@ -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());
}
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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<ProgressListener> listeners = new ArrayList<ProgressListener>();
@SuppressWarnings("unchecked")
public synchronized void addProgressListener(ProgressListener l) {
ArrayList<ProgressListener> list = (ArrayList<ProgressListener>) listeners.clone();
list.add(l);
listeners = list;
}
@SuppressWarnings("unchecked")
public synchronized void removeProgressListener(ProgressListener l) {
ArrayList<ProgressListener> list = (ArrayList<ProgressListener>) listeners.clone();
list.remove(l);
listeners = list;
}
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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.
* <p>
* 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<String, KeySet> loadedKeys = new HashMap<String, KeySet>();
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<String, String> autoKeyDetect = new HashMap<String, String>();
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<String, ZioEntry> 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<String, ZioEntry> 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.
* <p>
* 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<String, ZioEntry> 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<String, ZioEntry> byName = new TreeMap<String, ZioEntry>();
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<String, Attributes> entries = manifest.getEntries();
for (Map.Entry<String, Attributes> 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<Object, Object> 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<String, ZioEntry> input, ZipOutput output, long timestamp)
throws IOException {
Map<String, Attributes> entries = manifest.getEntries();
List<String> names = new ArrayList<String>(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<String, ZioEntry> 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<String, ZioEntry> 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<String, ZioEntry> 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);
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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<String, ZioEntry> zioEntries = new LinkedHashMap<String, ZioEntry>();
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<String, ZioEntry> 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<String> 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<String> names = new TreeSet<String>();
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);
}
}

View File

@@ -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()));
}
}

View File

@@ -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<ZioEntry> entriesWritten = new LinkedList<ZioEntry>();
Set<String> namesWritten = new HashSet<String>();
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;
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,99 @@
/*
* Copyright (C) 2010-2011 Ciaran Gultnieks <ciaran@ciarang.com>
* Copyright (C) 2011 Henrik Tunedal <tunedal@gmail.com>
*
* 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;
}
}

View File

@@ -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<ChangeListener> 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<String> 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();
}
}
}
}

View File

@@ -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<Mirror> 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<String> toString(@Nullable List<FileV2> files) {
if (files == null) return new ArrayList<>(0);
ArrayList<String> list = new ArrayList<>(files.size());
for (FileV2 file : files) {
list.add(file.serialize());
}
return list;
}
public static List<FileV2> fileV2FromStrings(List<String> list) {
ArrayList<FileV2> 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<String> list) {
JSONArray jsonArray = new JSONArray();
for (String str : list) {
jsonArray.put(str);
}
return jsonArray.toString();
}
public static List<String> parseJsonStringArray(String json) {
try {
JSONArray jsonArray = new JSONArray(json);
List<String> 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();
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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).
* <p>
* To illustrate, imagine perfectly valid file path: "/tmp/../secret/file.txt",
* one cannot distinguish between:
* <p>
* "/tmp/" (known safe directory) + "../secret/file.txt" (suspicious looking file name)
* <p>
* and
* <p>
* "/tmp/../secret/" (known safe directory) + "file.txt" (known safe file name)
* <p>
* 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);
}
}

View File

@@ -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();
}
}
}
}

View File

@@ -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();
}
}

View File

@@ -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");
}
}

View File

@@ -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> 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);
}
}

View File

@@ -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<ClientConnection> 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<String, String> 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<String, String> 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,
"<html><body>Redirected: <a href=\"" +
uri + "\">" + uri + "</a></body></html>");
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<String, String> 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;
}
}
}

View File

@@ -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> 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<String, String> 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);
}
}

View File

@@ -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.
* <p>
* This is mostly just synced from {@code SimpleWebServer.java} from NanoHTTPD.
*
* @see <a href="https://github.com/NanoHttpd/nanohttpd/blob/nanohttpd-project-2.3.1/webserver/src/main/java/fi/iki/elonen/SimpleWebServer.java">webserver/src/main/java/fi/iki/elonen/SimpleWebServer.java</a>
*/
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> context;
protected List<File> 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("<html><head><title>" + heading + "</title><style><!--\n" + "span.dirname { " +
"font-weight: bold; }\n" + "span.filesize { font-size: 75%; }\n"
+ "// -->\n" + "</style>" + "</head><body><h1>" + heading + "</h1>");
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<String> files =
Arrays.asList(Objects.requireNonNull(f.list((dir, name) -> new File(dir, name).isFile())));
Collections.sort(files);
List<String> 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("<ul>");
if (up != null || directories.size() > 0) {
msg.append("<section class=\"directories\">");
if (up != null) {
msg.append("<li><a rel=\"directory\" href=\"").append(up).append("\"><span class=\"dirname\">." +
".</span></a></li>");
}
for (String directory : directories) {
String dir = directory + "/";
msg.append("<li><a rel=\"directory\" href=\"").append(encodeUri(uri + dir)).append("\"><span " +
"class=\"dirname\">").append(dir).append("</span></a></li>");
}
msg.append("</section>");
}
if (files.size() > 0) {
msg.append("<section class=\"files\">");
for (String file : files) {
msg.append("<li><a href=\"").append(encodeUri(uri + file)).append("\"><span class=\"filename" +
"\">").append(file).append("</span></a>");
File curFile = new File(f, file);
long len = curFile.length();
msg.append("&nbsp;<span class=\"filesize\">(");
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(")</span></li>");
}
msg.append("</section>");
}
msg.append("</ul>");
}
msg.append("</body></html>");
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<String, String> headers, IHTTPSession session, String uri) {
return defaultRespond(headers, session, uri);
}
private Response defaultRespond(Map<String, String> 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, "<html><body>Redirected: " +
"<a href=\"" + uri + "\">" + uri + "</a></body></html>");
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<String, String> header = session.getHeaders();
Map<String, String> parms = session.getParms();
String uri = session.getUri();
if (BuildConfig.DEBUG) {
System.out.println(session.getMethod() + " '" + uri + "' ");
Iterator<String> 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<String, String> 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();
}
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}
}

View File

@@ -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<App> 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("<li><a href=\"/fdroid/repo/")
.append(app.packageName)
.append("_")
.append(app.installedApk.versionCode)
.append(".apk\">")
.append("<img width=\"32\" height=\"32\" src=\"/fdroid/repo/icons/")
.append(app.packageName)
.append("_")
.append(app.installedApk.versionCode)
.append(".png\">")
.append(app.name)
.append("</a></li>\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<String> 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<PackageV1> 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);
}
}
}

View File

@@ -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.
* <p/>
* 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<String> 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));
}
}

View File

@@ -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<String> 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 <a href="https://developer.android.com/training/articles/security-config">Network Security Config</a>
*/
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);
}
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,148 @@
/*
* Copyright (C) 2018 Hans-Christoph Steiner <hans@eds.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.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"
* <p>
* 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 <a href="https://stackoverflow.com/a/40201333">Universal way to write to external SD card on Android</a>
* @see <a href="https://commonsware.com/blog/2017/11/14/storage-situation-external-storage.html"> The Storage Situation: External Storage </a>
*/
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<String> 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<File> 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<String> 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);
}
}
}
}

View File

@@ -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<PackageInfo> 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<InstalledApp> allPackages;
private final List<InstalledApp> filteredPackages = new ArrayList<>();
AppListAdapter(@NonNull ListView listView, List<PackageInfo> 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));
}
}
}

View File

@@ -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<Peer> {
PeopleNearbyAdapter(Context context) {
super(context, 0, new ArrayList<Peer>());
}
@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.
* <p>
*/
@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);
}
}

View File

@@ -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<String> appsToSwap = new HashSet<>();
private final Set<Peer> activePeers = new HashSet<>();
private final MutableLiveData<IndexV1> index = new MutableLiveData<>();
private final MutableLiveData<Exception> 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<String> getAppsToSwap() {
return appsToSwap;
}
@NonNull
public Set<Peer> 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<IndexV1> getIndex() {
return index;
}
public LiveData<Exception> 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<String> 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<String> deserializePackages(String packages) {
Set<String> 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));
}
};
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,166 @@
/*
* Copyright (C) 2018 Hans-Christoph Steiner <hans@eds.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.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}.
* <p>
* 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 <a href="https://commonsware.com/blog/2017/11/15/storage-situation-removable-storage.html">The Storage Situation: Removable Storage </a>
* @see <a href="https://commonsware.com/blog/2016/11/18/be-careful-scoped-directory-access.html">Be Careful with Scoped Directory Access</a>
* @see <a href="https://developer.android.com/training/articles/scoped-directory-access.html">Using Scoped Directory Access</a>
* @see <a href="https://developer.android.com/guide/topics/providers/document-provider.html">Open Files using Storage Access Framework</a>
*/
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 <a href="https://android.googlesource.com/platform/frameworks/base/+/android-10.0.0_r38/core/java/android/provider/DocumentsContract.java#238">DocumentsContract.EXTERNAL_STORAGE_PROVIDER_AUTHORITY</a>
* @see <a href="https://android.googlesource.com/platform/frameworks/base/+/android-10.0.0_r38/packages/ExternalStorageProvider/src/com/android/externalstorage/ExternalStorageProvider.java#70">ExternalStorageProvider.AUTHORITY</a>
*/
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<DocumentFile> 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<? extends Certificate> 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);
}
}

View File

@@ -0,0 +1,67 @@
/*
* Copyright (C) 2018-2019 Hans-Christoph Steiner <hans@eds.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.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);
}
}
}

View File

@@ -0,0 +1,60 @@
/*
* Copyright (C) 2018-2019 Hans-Christoph Steiner <hans@eds.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.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<Uri, ContentObserver> 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);
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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.
* <p>
* 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.
* <p>
* 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.
* <p>
* 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 <a href="https://issuetracker.google.com/issues/37015180">netmask of WifiManager.getDhcpInfo() is always zero on Android 5.0</a>
*/
private void setIpInfoFromNetworkInterface() {
try {
Enumeration<NetworkInterface> networkInterfaces = NetworkInterface.getNetworkInterfaces();
if (networkInterfaces == null) {
return;
}
while (networkInterfaces.hasMoreElements()) {
NetworkInterface netIf = networkInterfaces.nextElement();
for (Enumeration<InetAddress> 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~";
}
}
}

View File

@@ -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));
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}
}
}

View File

@@ -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<String, String> 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<String, String> 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<String, String> readHeaders() throws IOException {
Map<String, String> 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;
}
}

View File

@@ -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<String, String> headers;
private final InputStream contentStream;
public Response(int statusCode, Map<String, String> 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<String, String> 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<String, String> 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<String, String> 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<String, String> 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<String, String> 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);
}
}
}

View File

@@ -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.
* <p>
* 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<BluetoothPeer> CREATOR = new Creator<BluetoothPeer>() {
public BluetoothPeer createFromParcel(Parcel source) {
return new BluetoothPeer(source);
}
public BluetoothPeer[] newArray(int size) {
return new BluetoothPeer[size];
}
};
}

View File

@@ -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<BonjourPeer> CREATOR = new Creator<BonjourPeer>() {
public BonjourPeer createFromParcel(Parcel source) {
return new BonjourPeer(source);
}
public BonjourPeer[] newArray(int size) {
return new BonjourPeer[size];
}
};
}

View File

@@ -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();
}

View File

@@ -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<WifiPeer> CREATOR = new Creator<WifiPeer>() {
public WifiPeer createFromParcel(Parcel source) {
return new WifiPeer(source);
}
public WifiPeer[] newArray(int size) {
return new WifiPeer[size];
}
};
}

View File

@@ -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}.
* <p>
* 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 <a href="https://developer.android.com/guide/topics/providers/document-provider.html">Open Files using Storage Access Framework</a>
* @see <a href="https://developer.android.com/training/articles/scoped-directory-access.html">Using Scoped Directory Access</a>
*/
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 <b>part</b> 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.
* <p>
* 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}
* <p>
* 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() {
}
}

View File

@@ -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);
}
}
}

View File

@@ -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;
}
}

View File

@@ -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.
* <p>
* 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.
* <p>
* 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}.
* <p>
*
* @see TreeUriScannerIntentService
* @see SDCardScannerService
* <p>
* 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<UriPermission> 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);
}
});
}
}
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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 <a
* href="http://download.oracle.com/javase/7/docs/api/java/nio/charset/Charset.html">Standard charsets</a>:
* <p>
* <cite>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.</cite>
* </p>
*
* <ul>
* <li>{@code US-ASCII}<p>
* Seven-bit ASCII, a.k.a. ISO646-US, a.k.a. the Basic Latin block of the Unicode character set.</p></li>
* <li>{@code ISO-8859-1}<p>
* ISO Latin Alphabet No. 1, a.k.a. ISO-LATIN-1.</p></li>
* <li>{@code UTF-8}<p>
* Eight-bit Unicode Transformation Format.</p></li>
* <li>{@code UTF-16BE}<p>
* Sixteen-bit Unicode Transformation Format, big-endian byte order.</p></li>
* <li>{@code UTF-16LE}<p>
* Sixteen-bit Unicode Transformation Format, little-endian byte order.</p></li>
* <li>{@code UTF-16}<p>
* 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.)</p></li>
* </ul>
*
* 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].
*
* <p>
* This class is immutable and thread-safe.
* </p>
*
* @see <a href="http://download.oracle.com/javase/7/docs/api/java/nio/charset/Charset.html">Standard charsets</a>
* @since 1.4
*/
public class CharEncoding {
/**
* CharEncodingISO Latin Alphabet No. 1, a.k.a. ISO-LATIN-1.
* <p>
* Every implementation of the Java platform is required to support this character encoding.
* </p>
*
* @see <a href="http://download.oracle.com/javase/7/docs/api/java/nio/charset/Charset.html">Standard charsets</a>
*/
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.
* <p>
* Every implementation of the Java platform is required to support this character encoding.
* </p>
*
* @see <a href="http://download.oracle.com/javase/7/docs/api/java/nio/charset/Charset.html">Standard charsets</a>
*/
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)
* <p>
* Every implementation of the Java platform is required to support this character encoding.
* </p>
*
* @see <a href="http://download.oracle.com/javase/7/docs/api/java/nio/charset/Charset.html">Standard charsets</a>
*/
public static final String UTF_16 = "UTF-16";
/**
* Sixteen-bit Unicode Transformation Format, big-endian byte order.
* <p>
* Every implementation of the Java platform is required to support this character encoding.
* </p>
*
* @see <a href="http://download.oracle.com/javase/7/docs/api/java/nio/charset/Charset.html">Standard charsets</a>
*/
public static final String UTF_16BE = "UTF-16BE";
/**
* Sixteen-bit Unicode Transformation Format, little-endian byte order.
* <p>
* Every implementation of the Java platform is required to support this character encoding.
* </p>
*
* @see <a href="http://download.oracle.com/javase/7/docs/api/java/nio/charset/Charset.html">Standard charsets</a>
*/
public static final String UTF_16LE = "UTF-16LE";
/**
* Eight-bit Unicode Transformation Format.
* <p>
* Every implementation of the Java platform is required to support this character encoding.
* </p>
*
* @see <a href="http://download.oracle.com/javase/7/docs/api/java/nio/charset/Charset.html">Standard charsets</a>
*/
public static final String UTF_8 = "UTF-8";
}

View File

@@ -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.
* <p>
* 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.
* <p>
* 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;
}

View File

@@ -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 <a href="http://c2.com/cgi/wiki?AlwaysDeclareSerialVersionUid">Always Declare Serial Version Uid</a>
*/
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.
* <p>
* 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 <code>(cause==null ?
* null : cause.toString())</code> (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);
}
}

View File

@@ -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.
* <p>
* 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;
}

View File

@@ -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 <a href="http://c2.com/cgi/wiki?AlwaysDeclareSerialVersionUid">Always Declare Serial Version Uid</a>
*/
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.
*
* <p>
* Note that the detail message associated with {@code cause} is not automatically incorporated into this
* exception's detail message.
* </p>
*
* @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 <code>(cause==null ?
* null : cause.toString())</code> (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);
}
}

View File

@@ -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;
/**
* <p>
* Operations on {@link CharSequence} that are {@code null} safe.
* </p>
* <p>
* Copied from Apache Commons Lang r1586295 on April 10, 2014 (day of 3.3.2 release).
* </p>
*
* @see CharSequence
* @since 1.10
*/
public class CharSequenceUtils {
/**
* Green implementation of regionMatches.
*
* <p>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;
}
}

View File

@@ -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.
*
* <p>All bytes identified by {@link ByteBuffer#remaining()} will be used; after this method
* the value {@link ByteBuffer#remaining() remaining()} will be zero.</p>
*
* @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.
*
* <p>All bytes identified by {@link ByteBuffer#remaining()} will be used; after this method
* the value {@link ByteBuffer#remaining() remaining()} will be zero.</p>
*
* @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.
*
* <p>All bytes identified by {@link ByteBuffer#remaining()} will be used; after this method
* the value {@link ByteBuffer#remaining() remaining()} will be zero.</p>
*
* @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.
*
* <p>All bytes identified by {@link ByteBuffer#remaining()} will be used; after this method
* the value {@link ByteBuffer#remaining() remaining()} will be zero.</p>
*
* @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.
*
* <p>All bytes identified by {@link ByteBuffer#remaining()} will be used; after this method
* the value {@link ByteBuffer#remaining() remaining()} will be zero.</p>
*
* @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.
*
* <p>All bytes identified by {@link ByteBuffer#remaining()} will be used; after this method
* the value {@link ByteBuffer#remaining() remaining()} will be zero.</p>
*
* @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.
* <p>
* The conversion from hexadecimal characters to the returned bytes is performed with the charset named by
* {@link #getCharset()}.
* </p>
*
* @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.
*
* <p>The conversion from hexadecimal characters to the returned bytes is performed with the charset named by
* {@link #getCharset()}.</p>
*
* <p>All bytes identified by {@link ByteBuffer#remaining()} will be used; after this method
* the value {@link ByteBuffer#remaining() remaining()} will be zero.</p>
*
* @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.
* <p>
* The conversion from hexadecimal characters to bytes to be encoded to performed with the charset named by
* {@link #getCharset()}.
* </p>
*
* @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 + "]";
}
}

View File

@@ -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 <a href="http://download.oracle.com/javase/7/docs/api/java/nio/charset/Charset.html">
* Standard charsets</a>.
*
* <p>This class is immutable and thread-safe.</p>
*
* @see CharEncoding
* @see <a href="http://download.oracle.com/javase/7/docs/api/java/nio/charset/Charset.html">Standard charsets</a>
* @since 1.4
*/
public class StringUtils {
/**
* <p>
* Compares two CharSequences, returning {@code true} if they represent equal sequences of characters.
* </p>
*
* <p>
* {@code null}s are handled without exceptions. Two {@code null} references are considered to be equal.
* The comparison is case sensitive.
* </p>
*
* <pre>
* 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
* </pre>
*
* <p>
* Copied from Apache Commons Lang r1583482 on April 10, 2014 (day of 3.3.2 release).
* </p>
*
* @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 <a href="http://download.oracle.com/javase/7/docs/api/java/nio/charset/Charset.html">Standard charsets</a>
* @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 <a href="http://download.oracle.com/javase/7/docs/api/java/nio/charset/Charset.html">Standard charsets</a>
* @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.
* <p>
* 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.
* </p>
*
* @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 <a href="http://download.oracle.com/javase/7/docs/api/java/nio/charset/Charset.html">Standard charsets</a>
* @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 <a href="http://download.oracle.com/javase/7/docs/api/java/nio/charset/Charset.html">Standard charsets</a>
* @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 <a href="http://download.oracle.com/javase/7/docs/api/java/nio/charset/Charset.html">Standard charsets</a>
* @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 <a href="http://download.oracle.com/javase/7/docs/api/java/nio/charset/Charset.html">Standard charsets</a>
* @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 <a href="http://download.oracle.com/javase/7/docs/api/java/nio/charset/Charset.html">Standard charsets</a>
* @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.
* <p>
* 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.
* </p>
*
* @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);
}
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -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 <cite>Java Cryptography Architecture Standard Algorithm Name
* Documentation</cite>.
* <p>
* This class is immutable and thread-safe.
* </p>
* <p>
* Java 8 and up: SHA-224.
* </p>
* <p>
* Java 9 and up: SHA3-224, SHA3-256, SHA3-384, SHA3-512.
* </p>
*
* @see <a href="http://docs.oracle.com/javase/7/docs/technotes/guides/security/StandardNames.html#MessageDigest">
* Java 7 Cryptography Architecture Standard Algorithm Name Documentation</a>
* @see <a href="http://docs.oracle.com/javase/8/docs/technotes/guides/security/StandardNames.html#MessageDigest">
* Java 8 Cryptography Architecture Standard Algorithm Name Documentation</a>
* @see <a href="https://docs.oracle.com/javase/9/docs/specs/security/standard-names.html#messagedigest-algorithms">
* Java 9 Cryptography Architecture Standard Algorithm Name Documentation</a>
* @see <a href="https://docs.oracle.com/javase/10/docs/specs/security/standard-names.html#messagedigest-algorithms">
* Java 10 Cryptography Architecture Standard Algorithm Name Documentation</a>
* @see <a href="https://docs.oracle.com/en/java/javase/11/docs/specs/security/standard-names.html#messagedigest-algorithms">
* Java 11 Cryptography Architecture Standard Algorithm Name Documentation</a>
* @see <a href="https://docs.oracle.com/en/java/javase/12/docs/specs/security/standard-names.html#messagedigest-algorithms">
* Java 12 Cryptography Architecture Standard Algorithm Name Documentation</a>
* @see <a href="https://docs.oracle.com/en/java/javase/13/docs/specs/security/standard-names.html#messagedigest-algorithms">
* Java 13 Cryptography Architecture Standard Algorithm Name Documentation</a>
*
* @see <a href="http://dx.doi.org/10.6028/NIST.FIPS.180-4">FIPS PUB 180-4</a>
* @see <a href="http://dx.doi.org/10.6028/NIST.FIPS.202">FIPS PUB 202</a>
* @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.
* <p>
* Present in Oracle Java 8.
* </p>
*
* @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.
* <p>
* Included starting in Oracle Java 9.
* </p>
*
* @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.
* <p>
* Included starting in Oracle Java 9.
* </p>
*
* @since 1.14
*/
public static final String SHA_512_256 = "SHA-512/256";
/**
* The SHA3-224 hash algorithm defined in the FIPS PUB 202.
* <p>
* Included starting in Oracle Java 9.
* </p>
*
* @since 1.11
*/
public static final String SHA3_224 = "SHA3-224";
/**
* The SHA3-256 hash algorithm defined in the FIPS PUB 202.
* <p>
* Included starting in Oracle Java 9.
* </p>
*
* @since 1.11
*/
public static final String SHA3_256 = "SHA3-256";
/**
* The SHA3-384 hash algorithm defined in the FIPS PUB 202.
* <p>
* Included starting in Oracle Java 9.
* </p>
*
* @since 1.11
*/
public static final String SHA3_384 = "SHA3-384";
/**
* The SHA3-512 hash algorithm defined in the FIPS PUB 202.
* <p>
* Included starting in Oracle Java 9.
* </p>
*
* @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.
}
}

View File

@@ -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 <T> collectInJava(
scope: CoroutineScope = CoroutineScope(context = kotlinx.coroutines.Dispatchers.Main),
flow: Flow<T>,
action: (T) -> Any
): Job {
return scope.launch {
flow.collect { value ->
action(value)
}
}
}
}

View File

@@ -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<NavKey>.extraNavigationEntries(navigator: Navigator) {}
fun getMoreMenuItems(context: Context): List<NavDestinations> =
listOf(
NavDestinations.AllApps(context.getString(R.string.app_list_all)),
NavDestinations.Nearby,
NavDestinations.About,
)
fun EntryProviderScope<NavKey>.extraNavigationEntries(navigator: Navigator) {
entry(NavigationKey.Nearby) { NearbyStart { navigator.goBack() } }
}

View File

@@ -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({}) }
}

View File

@@ -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)
}
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More