Implement plugin interface in mobile app
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package com.github.shadowsocks.utils;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.StringTokenizer;
|
||||
|
||||
/**
|
||||
@@ -32,23 +33,21 @@ public final class Commandline {
|
||||
/**
|
||||
* Quote the parts of the given array in way that makes them
|
||||
* usable as command line arguments.
|
||||
* @param line the list of arguments to quote.
|
||||
* @param args the list of arguments to quote.
|
||||
* @return empty string for null or no command, else every argument split
|
||||
* by spaces and quoted by quoting rules.
|
||||
*/
|
||||
public static String toString(String[] line) {
|
||||
public static String toString(Iterable<String> args) {
|
||||
// empty path return empty string
|
||||
if (line == null || line.length == 0) {
|
||||
if (args == null) {
|
||||
return "";
|
||||
}
|
||||
// path containing one or more elements
|
||||
final StringBuilder result = new StringBuilder();
|
||||
for (int i = 0; i < line.length; i++) {
|
||||
if (i > 0) {
|
||||
result.append(' ');
|
||||
}
|
||||
for (int j = 0; j < line[i].length(); ++j) {
|
||||
char ch = line[i].charAt(j);
|
||||
for (String arg : args) {
|
||||
if (result.length() > 0) result.append(' ');
|
||||
for (int j = 0; j < arg.length(); ++j) {
|
||||
char ch = arg.charAt(j);
|
||||
switch (ch) {
|
||||
case ' ': case '\\': case '"': case '\'': result.append('\\'); // intentionally no break
|
||||
default: result.append(ch);
|
||||
@@ -57,6 +56,16 @@ public final class Commandline {
|
||||
}
|
||||
return result.toString();
|
||||
}
|
||||
/**
|
||||
* Quote the parts of the given array in way that makes them
|
||||
* usable as command line arguments.
|
||||
* @param args the list of arguments to quote.
|
||||
* @return empty string for null or no command, else every argument split
|
||||
* by spaces and quoted by quoting rules.
|
||||
*/
|
||||
public static String toString(String[] args) {
|
||||
return toString(Arrays.asList(args)); // thanks to Java, arrays aren't iterable
|
||||
}
|
||||
|
||||
/**
|
||||
* Crack a command line.
|
||||
|
||||
@@ -24,7 +24,7 @@ import java.io.IOException
|
||||
import java.net.InetAddress
|
||||
import java.util
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.{Timer, TimerTask}
|
||||
import java.util.{Locale, Timer, TimerTask}
|
||||
|
||||
import android.app.Service
|
||||
import android.content.{BroadcastReceiver, Context, Intent, IntentFilter}
|
||||
@@ -36,6 +36,7 @@ import com.github.shadowsocks.ShadowsocksApplication.app
|
||||
import com.github.shadowsocks.acl.Acl
|
||||
import com.github.shadowsocks.aidl.{IShadowsocksService, IShadowsocksServiceCallback}
|
||||
import com.github.shadowsocks.database.Profile
|
||||
import com.github.shadowsocks.plugin.{PluginConfiguration, PluginManager, PluginOptions}
|
||||
import com.github.shadowsocks.utils._
|
||||
import okhttp3.{Dns, FormBody, OkHttpClient, Request}
|
||||
|
||||
@@ -46,6 +47,8 @@ trait BaseService extends Service {
|
||||
|
||||
@volatile private var state = State.STOPPED
|
||||
@volatile protected var profile: Profile = _
|
||||
@volatile private var plugin: PluginOptions = _
|
||||
@volatile private var pluginPath: String = _
|
||||
|
||||
case class NameNotResolvedException() extends IOException
|
||||
case class NullConnectionException() extends NullPointerException
|
||||
@@ -149,8 +152,12 @@ trait BaseService extends Service {
|
||||
profile.password = proxy(2).trim
|
||||
profile.method = proxy(3).trim
|
||||
}
|
||||
|
||||
if (profile.route == Acl.CUSTOM_RULES) // rationalize custom rules
|
||||
Acl.save(Acl.CUSTOM_RULES, new Acl().fromId(Acl.CUSTOM_RULES))
|
||||
|
||||
plugin = new PluginConfiguration(profile.plugin).selectedOptions
|
||||
pluginPath = PluginManager.initPlugin(plugin.id)
|
||||
}
|
||||
|
||||
def startRunner(profile: Profile) {
|
||||
@@ -304,4 +311,22 @@ trait BaseService extends Service {
|
||||
case _: Exception => "exclude = " + default + ";"
|
||||
}
|
||||
}
|
||||
|
||||
protected def buildShadowsocksConfig(file: String, localPortOffset: Int = 0): String = {
|
||||
val builder = new StringBuilder(
|
||||
"""{"server": "%s", "server_port": %d, "local_port": %d, "password": "%s", "method": "%s""""
|
||||
.formatLocal(Locale.ENGLISH, profile.host, profile.remotePort, profile.localPort + localPortOffset,
|
||||
profile.password, profile.method))
|
||||
if (profile.auth) builder.append(""", "auth": true""")
|
||||
if (pluginPath != null) {
|
||||
builder.append(""", "plugin": """")
|
||||
builder.append(pluginPath)
|
||||
builder.append("""", "plugin_opts": """")
|
||||
builder.append(plugin.toString)
|
||||
builder.append('"')
|
||||
}
|
||||
builder.append('}')
|
||||
IOUtils.writeString(file, builder.toString)
|
||||
file
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import java.util.concurrent.Semaphore
|
||||
|
||||
import android.util.Log
|
||||
import com.github.shadowsocks.utils.CloseUtils._
|
||||
import com.github.shadowsocks.utils.Commandline
|
||||
|
||||
import scala.collection.JavaConversions._
|
||||
import scala.collection.immutable.Stream
|
||||
@@ -57,7 +58,7 @@ class GuardedProcess(cmd: Seq[String]) {
|
||||
try {
|
||||
var callback: () => Unit = null
|
||||
while (!isDestroyed) {
|
||||
Log.i(TAG, "start process: " + cmd)
|
||||
Log.i(TAG, "start process: " + Commandline.toString(cmd))
|
||||
val startTime = currentTimeMillis
|
||||
|
||||
process = new ProcessBuilder(cmd).redirectErrorStream(true).start
|
||||
@@ -75,7 +76,7 @@ class GuardedProcess(cmd: Seq[String]) {
|
||||
isRestart = false
|
||||
} else {
|
||||
if (currentTimeMillis - startTime < 1000) {
|
||||
Log.w(TAG, "process exit too fast, stop guard: " + cmd)
|
||||
Log.w(TAG, "process exit too fast, stop guard: " + Commandline.toString(cmd))
|
||||
isDestroyed = true
|
||||
}
|
||||
}
|
||||
@@ -83,11 +84,11 @@ class GuardedProcess(cmd: Seq[String]) {
|
||||
}
|
||||
} catch {
|
||||
case _: InterruptedException =>
|
||||
Log.i(TAG, "thread interrupt, destroy process: " + cmd)
|
||||
Log.i(TAG, "thread interrupt, destroy process: " + Commandline.toString(cmd))
|
||||
process.destroy()
|
||||
case e: IOException => ioException = e
|
||||
} finally semaphore.release()
|
||||
}, "GuardThread-" + cmd)
|
||||
}, "GuardThread-" + cmd.head)
|
||||
|
||||
guardThread.start()
|
||||
semaphore.acquire()
|
||||
|
||||
@@ -53,21 +53,11 @@ class ShadowsocksNatService extends BaseService {
|
||||
var su: Shell.Interactive = _
|
||||
|
||||
def startShadowsocksDaemon() {
|
||||
val conf =
|
||||
ConfigUtils
|
||||
.SHADOWSOCKS.formatLocal(Locale.ENGLISH, profile.host, profile.remotePort, profile.localPort,
|
||||
profile.password, profile.method, 600)
|
||||
Utils.printToFile(new File(getApplicationInfo.dataDir + "/ss-local-nat.conf"))(p => {
|
||||
p.println(conf)
|
||||
})
|
||||
|
||||
val cmd = ArrayBuffer[String](getApplicationInfo.nativeLibraryDir + "/libss-local.so"
|
||||
, "-b" , "127.0.0.1"
|
||||
, "-t" , "600"
|
||||
, "-P", getApplicationInfo.dataDir
|
||||
, "-c" , getApplicationInfo.dataDir + "/ss-local-nat.conf")
|
||||
|
||||
if (profile.auth) cmd += "-A"
|
||||
, "-c" , buildShadowsocksConfig(getApplicationInfo.dataDir + "/ss-local-nat.conf"))
|
||||
|
||||
if (TcpFastOpen.sendEnabled) cmd += "--fast-open"
|
||||
|
||||
@@ -76,55 +66,24 @@ class ShadowsocksNatService extends BaseService {
|
||||
cmd += Acl.getPath(profile.route)
|
||||
}
|
||||
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, cmd.mkString(" "))
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, Commandline.toString(cmd))
|
||||
sslocalProcess = new GuardedProcess(cmd).start()
|
||||
}
|
||||
|
||||
def startDNSTunnel() {
|
||||
if (profile.udpdns) {
|
||||
val conf = ConfigUtils
|
||||
.SHADOWSOCKS.formatLocal(Locale.ENGLISH, profile.host, profile.remotePort, profile.localPort + 53,
|
||||
profile.password, profile.method, 10)
|
||||
Utils.printToFile(new File(getApplicationInfo.dataDir + "/ss-tunnel-nat.conf"))(p => {
|
||||
p.println(conf)
|
||||
})
|
||||
val cmd = ArrayBuffer[String](getApplicationInfo.nativeLibraryDir + "/libss-tunnel.so"
|
||||
, "-u"
|
||||
, "-t" , "10"
|
||||
, "-b" , "127.0.0.1"
|
||||
, "-l" , (profile.localPort + 53).toString
|
||||
, "-L" , if (profile.remoteDns == null) "8.8.8.8:53" else profile.remoteDns + ":53"
|
||||
, "-P" , getApplicationInfo.dataDir
|
||||
, "-c" , getApplicationInfo.dataDir + "/ss-tunnel-nat.conf")
|
||||
val cmd = ArrayBuffer[String](getApplicationInfo.nativeLibraryDir + "/libss-tunnel.so"
|
||||
, "-t" , "10"
|
||||
, "-b" , "127.0.0.1"
|
||||
, "-l" , (profile.localPort + 53).toString
|
||||
, "-L" , if (profile.remoteDns == null) "8.8.8.8:53" else profile.remoteDns + ":53"
|
||||
, "-P", getApplicationInfo.dataDir
|
||||
, "-c" , buildShadowsocksConfig(getApplicationInfo.dataDir + "/ss-tunnel-nat.conf", 53))
|
||||
|
||||
if (profile.auth) cmd += "-A"
|
||||
if (profile.udpdns) cmd.append("-u")
|
||||
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, cmd.mkString(" "))
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, Commandline.toString(cmd))
|
||||
|
||||
sstunnelProcess = new GuardedProcess(cmd).start()
|
||||
|
||||
} else {
|
||||
val conf =
|
||||
ConfigUtils
|
||||
.SHADOWSOCKS.formatLocal(Locale.ENGLISH, profile.host, profile.remotePort, profile.localPort + 63,
|
||||
profile.password, profile.method, 10)
|
||||
Utils.printToFile(new File(getApplicationInfo.dataDir + "/ss-tunnel-nat.conf"))(p => {
|
||||
p.println(conf)
|
||||
})
|
||||
val cmdBuf = ArrayBuffer[String](getApplicationInfo.nativeLibraryDir + "/libss-tunnel.so"
|
||||
, "-t" , "10"
|
||||
, "-b" , "127.0.0.1"
|
||||
, "-l" , (profile.localPort + 63).toString
|
||||
, "-L" , if (profile.remoteDns == null) "8.8.8.8:53" else profile.remoteDns + ":53"
|
||||
, "-P", getApplicationInfo.dataDir
|
||||
, "-c" , getApplicationInfo.dataDir + "/ss-tunnel-nat.conf")
|
||||
|
||||
if (profile.auth) cmdBuf += "-A"
|
||||
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, cmdBuf.mkString(" "))
|
||||
|
||||
sstunnelProcess = new GuardedProcess(cmdBuf).start()
|
||||
}
|
||||
sstunnelProcess = new GuardedProcess(cmd).start()
|
||||
}
|
||||
|
||||
def startDnsDaemon() {
|
||||
@@ -150,7 +109,7 @@ class ShadowsocksNatService extends BaseService {
|
||||
})
|
||||
val cmd = Array(getApplicationInfo.nativeLibraryDir + "/libpdnsd.so", "-c", getApplicationInfo.dataDir + "/pdnsd-nat.conf")
|
||||
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, cmd.mkString(" "))
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, Commandline.toString(cmd))
|
||||
|
||||
pdnsdProcess = new GuardedProcess(cmd).start()
|
||||
}
|
||||
@@ -162,7 +121,7 @@ class ShadowsocksNatService extends BaseService {
|
||||
p.println(conf)
|
||||
})
|
||||
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, cmd.mkString(" "))
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, Commandline.toString(cmd))
|
||||
redsocksProcess = new GuardedProcess(cmd).start()
|
||||
}
|
||||
|
||||
|
||||
@@ -30,10 +30,11 @@ import android.net.VpnService
|
||||
import android.os._
|
||||
import android.util.Log
|
||||
import com.github.shadowsocks.ShadowsocksApplication.app
|
||||
import com.github.shadowsocks.acl.{AclSyncJob, Acl}
|
||||
import com.github.shadowsocks.acl.{Acl, AclSyncJob}
|
||||
import com.github.shadowsocks.database.Profile
|
||||
import com.github.shadowsocks.utils._
|
||||
|
||||
import scala.collection.JavaConversions._
|
||||
import scala.collection.mutable.ArrayBuffer
|
||||
|
||||
class ShadowsocksVpnService extends VpnService with BaseService {
|
||||
@@ -162,21 +163,11 @@ class ShadowsocksVpnService extends VpnService with BaseService {
|
||||
}
|
||||
|
||||
def startShadowsocksDaemon() {
|
||||
val conf =
|
||||
ConfigUtils
|
||||
.SHADOWSOCKS.formatLocal(Locale.ENGLISH, profile.host, profile.remotePort, profile.localPort,
|
||||
profile.password, profile.method, 600)
|
||||
Utils.printToFile(new File(getApplicationInfo.dataDir + "/ss-local-vpn.conf"))(p => {
|
||||
p.println(conf)
|
||||
})
|
||||
|
||||
val cmd = ArrayBuffer[String](getApplicationInfo.nativeLibraryDir + "/libss-local.so", "-V"
|
||||
, "-b", "127.0.0.1"
|
||||
, "-t", "600"
|
||||
, "-P", getApplicationInfo.dataDir
|
||||
, "-c", getApplicationInfo.dataDir + "/ss-local-vpn.conf")
|
||||
|
||||
if (profile.auth) cmd += "-A"
|
||||
, "-c", buildShadowsocksConfig(getApplicationInfo.dataDir + "/ss-local-vpn.conf"))
|
||||
|
||||
if (profile.udpdns) cmd += "-u"
|
||||
|
||||
@@ -187,31 +178,20 @@ class ShadowsocksVpnService extends VpnService with BaseService {
|
||||
|
||||
if (TcpFastOpen.sendEnabled) cmd += "--fast-open"
|
||||
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, cmd.mkString(" "))
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, Commandline.toString(cmd))
|
||||
|
||||
sslocalProcess = new GuardedProcess(cmd).start()
|
||||
}
|
||||
|
||||
def startDnsTunnel() {
|
||||
val conf =
|
||||
ConfigUtils
|
||||
.SHADOWSOCKS.formatLocal(Locale.ENGLISH, profile.host, profile.remotePort, profile.localPort + 63,
|
||||
profile.password, profile.method, 10)
|
||||
Utils.printToFile(new File(getApplicationInfo.dataDir + "/ss-tunnel-vpn.conf"))(p => {
|
||||
p.println(conf)
|
||||
})
|
||||
val cmd = ArrayBuffer[String](getApplicationInfo.nativeLibraryDir + "/libss-tunnel.so"
|
||||
val cmd = Array[String](getApplicationInfo.nativeLibraryDir + "/libss-tunnel.so"
|
||||
, "-V"
|
||||
, "-t", "10"
|
||||
, "-b", "127.0.0.1"
|
||||
, "-L" , if (profile.remoteDns == null) "8.8.8.8:53" else profile.remoteDns + ":53"
|
||||
, "-P", getApplicationInfo.dataDir
|
||||
, "-c", getApplicationInfo.dataDir + "/ss-tunnel-vpn.conf")
|
||||
|
||||
if (profile.auth) cmd += "-A"
|
||||
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, cmd.mkString(" "))
|
||||
|
||||
, "-c", buildShadowsocksConfig(getApplicationInfo.dataDir + "/ss-tunnel-vpn.conf", 53))
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, Commandline.toString(cmd))
|
||||
sstunnelProcess = new GuardedProcess(cmd).start()
|
||||
}
|
||||
|
||||
@@ -236,7 +216,7 @@ class ShadowsocksVpnService extends VpnService with BaseService {
|
||||
})
|
||||
val cmd = Array(getApplicationInfo.nativeLibraryDir + "/libpdnsd.so", "-c", getApplicationInfo.dataDir + "/pdnsd-vpn.conf")
|
||||
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, cmd.mkString(" "))
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, Commandline.toString(cmd))
|
||||
|
||||
pdnsdProcess = new GuardedProcess(cmd).start()
|
||||
}
|
||||
@@ -310,7 +290,7 @@ class ShadowsocksVpnService extends VpnService with BaseService {
|
||||
cmd += ("--dnsgw", "%s:%d".formatLocal(Locale.ENGLISH, PRIVATE_VLAN.formatLocal(Locale.ENGLISH, "1"),
|
||||
profile.localPort + 53))
|
||||
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, cmd.mkString(" "))
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, Commandline.toString(cmd))
|
||||
|
||||
tun2socksProcess = new GuardedProcess(cmd).start(() => sendFd(fd))
|
||||
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
package com.github.shadowsocks.plugin
|
||||
|
||||
import java.io.{File, FileNotFoundException, FileOutputStream, IOException}
|
||||
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.{BroadcastReceiver, Intent}
|
||||
import android.content.{BroadcastReceiver, ContentResolver, Intent}
|
||||
import android.net.Uri
|
||||
import com.github.shadowsocks.ShadowsocksApplication.app
|
||||
import com.github.shadowsocks.utils.CloseUtils.autoClose
|
||||
import com.github.shadowsocks.utils.IOUtils
|
||||
|
||||
import scala.collection.JavaConverters._
|
||||
|
||||
@@ -22,4 +27,53 @@ object PluginManager {
|
||||
})
|
||||
cachedPlugins
|
||||
}
|
||||
|
||||
// the following parts are meant to be used by :bg
|
||||
@throws[Throwable]
|
||||
def initPlugin(id: String): String = {
|
||||
if (id.isEmpty) return null
|
||||
var throwable: Throwable = null
|
||||
|
||||
try initNativePlugin(id) match {
|
||||
case null =>
|
||||
case path => return path
|
||||
} catch {
|
||||
case t: Throwable => if (throwable == null) throwable = t
|
||||
}
|
||||
|
||||
// add other plugin types here
|
||||
|
||||
throw if (throwable == null)
|
||||
new FileNotFoundException(app.getString(com.github.shadowsocks.R.string.plugin_unknown, id)) else throwable
|
||||
}
|
||||
|
||||
@throws[IOException]
|
||||
@throws[IndexOutOfBoundsException]
|
||||
@throws[AssertionError]
|
||||
private def initNativePlugin(id: String): String = {
|
||||
val builder = new Uri.Builder()
|
||||
.scheme(ContentResolver.SCHEME_CONTENT)
|
||||
.authority(PluginInterface.getAuthority(id))
|
||||
val cr = app.getContentResolver
|
||||
val cursor = cr.query(builder.build(), Array(PluginInterface.COLUMN_PATH), null, null, null)
|
||||
if (cursor != null) {
|
||||
var initialized = false
|
||||
val pluginDir = new File(app.getFilesDir, "plugin")
|
||||
if (cursor.moveToFirst()) {
|
||||
IOUtils.deleteRecursively(pluginDir)
|
||||
if (!pluginDir.mkdirs()) throw new FileNotFoundException("Unable to create plugin directory")
|
||||
val pluginDirPath = pluginDir.getAbsolutePath + '/'
|
||||
do {
|
||||
val path = cursor.getString(0)
|
||||
val file = new File(pluginDir, path)
|
||||
assert(file.getAbsolutePath.startsWith(pluginDirPath))
|
||||
autoClose(cr.openInputStream(builder.path(path).build()))(in =>
|
||||
autoClose(new FileOutputStream(file))(out => IOUtils.copy(in, out)))
|
||||
if (path == id) initialized = true
|
||||
} while (cursor.moveToNext())
|
||||
}
|
||||
if (!initialized) throw new IndexOutOfBoundsException("Plugin entry binary not found")
|
||||
new File(pluginDir, id).getAbsolutePath
|
||||
} else null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,6 @@ object Executable {
|
||||
}
|
||||
|
||||
object ConfigUtils {
|
||||
val SHADOWSOCKS = "{\"server\": \"%s\", \"server_port\": %d, \"local_port\": %d, \"password\": \"%s\", \"method\":\"%s\", \"timeout\": %d}"
|
||||
val REDSOCKS = "base {\n" +
|
||||
" log_debug = off;\n" +
|
||||
" log_info = off;\n" +
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
package com.github.shadowsocks.utils
|
||||
|
||||
import com.github.shadowsocks.utils.CloseUtils._
|
||||
import java.io.{FileWriter, InputStream, OutputStream}
|
||||
import java.io._
|
||||
|
||||
/**
|
||||
* @author Mygod
|
||||
@@ -39,4 +39,10 @@ object IOUtils {
|
||||
|
||||
def writeString(file: String, content: String): Unit =
|
||||
autoClose(new FileWriter(file))(writer => writer.write(content))
|
||||
|
||||
@throws[FileNotFoundException]
|
||||
def deleteRecursively(file: File) {
|
||||
if (file.isDirectory) file.listFiles().foreach(deleteRecursively)
|
||||
if (!file.delete()) throw new FileNotFoundException("Failed to delete: " + file.getAbsolutePath)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ abstract class NativePluginProvider extends ContentProvider {
|
||||
|
||||
override def query(uri: Uri, projection: Array[String], selection: String, selectionArgs: Array[String],
|
||||
sortOrder: String): Cursor = {
|
||||
if (selection != null || selectionArgs != null || sortOrder != null) ???
|
||||
assert(selection == null && selectionArgs == null && sortOrder == null)
|
||||
val result = new MatrixCursor(projection.filter(_ == PluginInterface.COLUMN_PATH))
|
||||
populateFiles(new PathProvider(uri, result))
|
||||
result
|
||||
@@ -54,7 +54,7 @@ abstract class NativePluginProvider extends ContentProvider {
|
||||
|
||||
def openFile(uri: Uri): ParcelFileDescriptor
|
||||
override def openFile(uri: Uri, mode: String): ParcelFileDescriptor = {
|
||||
if (mode != "r") ???
|
||||
assert(mode == "r")
|
||||
openFile(uri)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user