mirror of
https://github.com/f-droid/fdroidclient.git
synced 2026-04-24 00:37:10 -04:00
Merge branch '2.0' into 'master'
Release 2.0-alpha7 See merge request fdroid/fdroidclient!1650
This commit is contained in:
@@ -18,8 +18,8 @@ android {
|
||||
applicationId = "org.fdroid"
|
||||
minSdk = 24
|
||||
targetSdk = 36
|
||||
versionCode = 2000006
|
||||
versionName = "2.0-alpha6"
|
||||
versionCode = 2000007
|
||||
versionName = "2.0-alpha7"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
@@ -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"))
|
||||
@@ -185,3 +193,14 @@ val gitHash: String
|
||||
process.waitFor() // Ensure the command completes
|
||||
return process.inputStream.use { it.readBytes().decodeToString().trim() }
|
||||
}
|
||||
|
||||
// workaround for https://issuetracker.google.com/issues/430260686
|
||||
// also https://issuetracker.google.com/issues/469819154
|
||||
// and https://issuetracker.google.com/issues/444048026
|
||||
tasks.withType<com.android.compose.screenshot.tasks.PreviewScreenshotValidationTask> {
|
||||
maxHeapSize = "4g"
|
||||
}
|
||||
|
||||
tasks.withType<com.android.compose.screenshot.tasks.PreviewScreenshotUpdateTask> {
|
||||
maxHeapSize = "4g"
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import kotlin.test.assertTrue
|
||||
import kotlin.test.fail
|
||||
import org.fdroid.BuildConfig.FLAVOR_variant
|
||||
import org.fdroid.ui.MainContent
|
||||
import org.fdroid.ui.MainModel
|
||||
import org.fdroid.ui.navigation.NavigationKey
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Before
|
||||
@@ -61,12 +62,16 @@ abstract class LocalizedScreenshotTest(val localeName: String) {
|
||||
override = DeviceConfigurationOverride.Locales(locales = localeList)
|
||||
) {
|
||||
MainContent(
|
||||
model =
|
||||
MainModel(
|
||||
dynamicColors = false,
|
||||
smallBottomBar = false,
|
||||
numUpdates = numUpdates,
|
||||
hasAppIssues = hasAppIssues,
|
||||
),
|
||||
isBigScreen = false,
|
||||
dynamicColors = false,
|
||||
showBottomBar = showBottomBar,
|
||||
currentNavKey = currentNavKey,
|
||||
numUpdates = numUpdates,
|
||||
hasAppIssues = hasAppIssues,
|
||||
onNav = {},
|
||||
content = content,
|
||||
)
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
@@ -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>
|
||||
|
||||
138
app/src/full/assets/index.template.html
Normal file
138
app/src/full/assets/index.template.html
Normal 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>
|
||||
BIN
app/src/full/assets/swap-icon.png
Normal file
BIN
app/src/full/assets/swap-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
40
app/src/full/assets/swap-icon.svg
Normal file
40
app/src/full/assets/swap-icon.svg
Normal 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 |
BIN
app/src/full/assets/swap-tick-done.png
Normal file
BIN
app/src/full/assets/swap-tick-done.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 621 B |
BIN
app/src/full/assets/swap-tick-not-done.png
Normal file
BIN
app/src/full/assets/swap-tick-not-done.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 588 B |
417
app/src/full/java/cc/mvdan/accesspoint/WifiApControl.java
Normal file
417
app/src/full/java/cc/mvdan/accesspoint/WifiApControl.java
Normal 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;
|
||||
}
|
||||
}
|
||||
113
app/src/full/java/com/google/zxing/encode/Contents.java
Executable file
113
app/src/full/java/com/google/zxing/encode/Contents.java
Executable 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,
|
||||
};
|
||||
}
|
||||
261
app/src/full/java/com/google/zxing/encode/QRCodeEncoder.java
Executable file
261
app/src/full/java/com/google/zxing/encode/QRCodeEncoder.java
Executable 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();
|
||||
}
|
||||
}
|
||||
122
app/src/full/java/javax/jmdns/impl/FDroidServiceInfo.java
Normal file
122
app/src/full/java/javax/jmdns/impl/FDroidServiceInfo.java
Normal 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];
|
||||
}
|
||||
};
|
||||
}
|
||||
94
app/src/full/java/kellinwood/logging/AbstractLogger.java
Normal file
94
app/src/full/java/kellinwood/logging/AbstractLogger.java
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
22
app/src/full/java/kellinwood/logging/LoggerFactory.java
Normal file
22
app/src/full/java/kellinwood/logging/LoggerFactory.java
Normal 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);
|
||||
}
|
||||
49
app/src/full/java/kellinwood/logging/LoggerInterface.java
Normal file
49
app/src/full/java/kellinwood/logging/LoggerInterface.java
Normal 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);
|
||||
}
|
||||
41
app/src/full/java/kellinwood/logging/LoggerManager.java
Normal file
41
app/src/full/java/kellinwood/logging/LoggerManager.java
Normal 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;
|
||||
}
|
||||
}
|
||||
69
app/src/full/java/kellinwood/logging/NullLoggerFactory.java
Normal file
69
app/src/full/java/kellinwood/logging/NullLoggerFactory.java
Normal 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;
|
||||
}
|
||||
}
|
||||
35
app/src/full/java/kellinwood/logging/StreamLogger.java
Normal file
35
app/src/full/java/kellinwood/logging/StreamLogger.java
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
95
app/src/full/java/kellinwood/security/zipsigner/KeySet.java
Normal file
95
app/src/full/java/kellinwood/security/zipsigner/KeySet.java
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
780
app/src/full/java/kellinwood/security/zipsigner/ZipSigner.java
Normal file
780
app/src/full/java/kellinwood/security/zipsigner/ZipSigner.java
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
104
app/src/full/java/kellinwood/zipio/CentralEnd.java
Normal file
104
app/src/full/java/kellinwood/zipio/CentralEnd.java
Normal 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);
|
||||
}
|
||||
}
|
||||
639
app/src/full/java/kellinwood/zipio/ZioEntry.java
Normal file
639
app/src/full/java/kellinwood/zipio/ZioEntry.java
Normal 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;
|
||||
}
|
||||
}
|
||||
141
app/src/full/java/kellinwood/zipio/ZioEntryInputStream.java
Normal file
141
app/src/full/java/kellinwood/zipio/ZioEntryInputStream.java
Normal 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;
|
||||
}
|
||||
}
|
||||
85
app/src/full/java/kellinwood/zipio/ZioEntryOutputStream.java
Normal file
85
app/src/full/java/kellinwood/zipio/ZioEntryOutputStream.java
Normal 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;
|
||||
}
|
||||
}
|
||||
234
app/src/full/java/kellinwood/zipio/ZipInput.java
Normal file
234
app/src/full/java/kellinwood/zipio/ZipInput.java
Normal 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);
|
||||
}
|
||||
}
|
||||
52
app/src/full/java/kellinwood/zipio/ZipListingHelper.java
Normal file
52
app/src/full/java/kellinwood/zipio/ZipListingHelper.java
Normal 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()));
|
||||
}
|
||||
}
|
||||
144
app/src/full/java/kellinwood/zipio/ZipOutput.java
Normal file
144
app/src/full/java/kellinwood/zipio/ZipOutput.java
Normal 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;
|
||||
}
|
||||
}
|
||||
62
app/src/full/java/org/fdroid/fdroid/FDroidApp.java
Normal file
62
app/src/full/java/org/fdroid/fdroid/FDroidApp.java
Normal 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;
|
||||
}
|
||||
}
|
||||
99
app/src/full/java/org/fdroid/fdroid/Hasher.java
Normal file
99
app/src/full/java/org/fdroid/fdroid/Hasher.java
Normal 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;
|
||||
}
|
||||
}
|
||||
87
app/src/full/java/org/fdroid/fdroid/Preferences.java
Normal file
87
app/src/full/java/org/fdroid/fdroid/Preferences.java
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
437
app/src/full/java/org/fdroid/fdroid/Utils.java
Normal file
437
app/src/full/java/org/fdroid/fdroid/Utils.java
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
78
app/src/full/java/org/fdroid/fdroid/compat/FileCompat.java
Normal file
78
app/src/full/java/org/fdroid/fdroid/compat/FileCompat.java
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
app/src/full/java/org/fdroid/fdroid/data/Apk.java
Normal file
11
app/src/full/java/org/fdroid/fdroid/data/Apk.java
Normal 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;
|
||||
}
|
||||
24
app/src/full/java/org/fdroid/fdroid/data/App.java
Normal file
24
app/src/full/java/org/fdroid/fdroid/data/App.java
Normal 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;
|
||||
}
|
||||
}
|
||||
69
app/src/full/java/org/fdroid/fdroid/data/SanitizedFile.java
Normal file
69
app/src/full/java/org/fdroid/fdroid/data/SanitizedFile.java
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
195
app/src/full/java/org/fdroid/fdroid/nearby/BluetoothManager.java
Normal file
195
app/src/full/java/org/fdroid/fdroid/nearby/BluetoothManager.java
Normal 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);
|
||||
}
|
||||
}
|
||||
350
app/src/full/java/org/fdroid/fdroid/nearby/BluetoothServer.java
Normal file
350
app/src/full/java/org/fdroid/fdroid/nearby/BluetoothServer.java
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
322
app/src/full/java/org/fdroid/fdroid/nearby/BonjourManager.java
Normal file
322
app/src/full/java/org/fdroid/fdroid/nearby/BonjourManager.java
Normal 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);
|
||||
}
|
||||
}
|
||||
504
app/src/full/java/org/fdroid/fdroid/nearby/LocalHTTPD.java
Normal file
504
app/src/full/java/org/fdroid/fdroid/nearby/LocalHTTPD.java
Normal 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(" <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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
301
app/src/full/java/org/fdroid/fdroid/nearby/LocalRepoManager.java
Normal file
301
app/src/full/java/org/fdroid/fdroid/nearby/LocalRepoManager.java
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
130
app/src/full/java/org/fdroid/fdroid/nearby/LocalRepoService.java
Normal file
130
app/src/full/java/org/fdroid/fdroid/nearby/LocalRepoService.java
Normal 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));
|
||||
}
|
||||
}
|
||||
233
app/src/full/java/org/fdroid/fdroid/nearby/NewRepoConfig.java
Normal file
233
app/src/full/java/org/fdroid/fdroid/nearby/NewRepoConfig.java
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
222
app/src/full/java/org/fdroid/fdroid/nearby/SelectAppsView.java
Normal file
222
app/src/full/java/org/fdroid/fdroid/nearby/SelectAppsView.java
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
255
app/src/full/java/org/fdroid/fdroid/nearby/StartSwapView.java
Normal file
255
app/src/full/java/org/fdroid/fdroid/nearby/StartSwapView.java
Normal 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);
|
||||
}
|
||||
}
|
||||
658
app/src/full/java/org/fdroid/fdroid/nearby/SwapService.java
Normal file
658
app/src/full/java/org/fdroid/fdroid/nearby/SwapService.java
Normal 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));
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
86
app/src/full/java/org/fdroid/fdroid/nearby/SwapView.java
Normal file
86
app/src/full/java/org/fdroid/fdroid/nearby/SwapView.java
Normal 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;
|
||||
}
|
||||
}
|
||||
1457
app/src/full/java/org/fdroid/fdroid/nearby/SwapWorkflowActivity.java
Normal file
1457
app/src/full/java/org/fdroid/fdroid/nearby/SwapWorkflowActivity.java
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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~";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
204
app/src/full/java/org/fdroid/fdroid/nearby/httpish/Request.java
Normal file
204
app/src/full/java/org/fdroid/fdroid/nearby/httpish/Request.java
Normal 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;
|
||||
}
|
||||
}
|
||||
166
app/src/full/java/org/fdroid/fdroid/nearby/httpish/Response.java
Normal file
166
app/src/full/java/org/fdroid/fdroid/nearby/httpish/Response.java
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
};
|
||||
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
};
|
||||
}
|
||||
29
app/src/full/java/org/fdroid/fdroid/nearby/peers/Peer.java
Normal file
29
app/src/full/java/org/fdroid/fdroid/nearby/peers/Peer.java
Normal 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();
|
||||
}
|
||||
109
app/src/full/java/org/fdroid/fdroid/nearby/peers/WifiPeer.java
Normal file
109
app/src/full/java/org/fdroid/fdroid/nearby/peers/WifiPeer.java
Normal 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];
|
||||
}
|
||||
};
|
||||
}
|
||||
114
app/src/full/java/org/fdroid/fdroid/net/TreeUriDownloader.java
Normal file
114
app/src/full/java/org/fdroid/fdroid/net/TreeUriDownloader.java
Normal 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() {
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 + "]";
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
}
|
||||
|
||||
}
|
||||
24
app/src/full/kotlin/org/fdroid/LegacyUtils.kt
Normal file
24
app/src/full/kotlin/org/fdroid/LegacyUtils.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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() } }
|
||||
}
|
||||
|
||||
58
app/src/full/kotlin/org/fdroid/ui/nearby/NearbyStart.kt
Normal file
58
app/src/full/kotlin/org/fdroid/ui/nearby/NearbyStart.kt
Normal 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({}) }
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user