diff --git a/android/app/build.gradle b/android/app/build.gradle
index a694c953..8d7ebc98 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -101,8 +101,8 @@ android {
applicationId 'ca.psiphon.conduit'
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
- versionCode 76
- versionName "2.0.5"
+ versionCode 77
+ versionName "2.0.6"
}
signingConfigs {
if (hasCustomKeystoreProperties) {
diff --git a/app.json b/app.json
index c66ecc4a..a3546d13 100644
--- a/app.json
+++ b/app.json
@@ -2,7 +2,7 @@
"expo": {
"name": "conduit",
"slug": "conduit",
- "version": "2.0.5",
+ "version": "2.0.6",
"orientation": "portrait",
"scheme": "ca.psiphon.conduit",
"userInterfaceStyle": "automatic",
@@ -10,7 +10,7 @@
"ios": {
"supportsTablet": true,
"bundleIdentifier": "ca.psiphon.conduit",
- "buildNumber": "27",
+ "buildNumber": "28",
"deploymentTarget": "15.1",
"icon": "./assets/images/conduit-launcher.png",
"infoPlist": {
@@ -19,7 +19,7 @@
},
"android": {
"package": "ca.psiphon.conduit",
- "versionCode": 76,
+ "versionCode": 77,
"permissions": [
"android.permission.POST_NOTIFICATIONS",
"com.android.vending.BILLING"
diff --git a/ios/conduit/Info.plist b/ios/conduit/Info.plist
index 0e46daeb..6191333b 100644
--- a/ios/conduit/Info.plist
+++ b/ios/conduit/Info.plist
@@ -19,7 +19,7 @@
CFBundlePackageType
$(PRODUCT_BUNDLE_PACKAGE_TYPE)
CFBundleShortVersionString
- 2.0.5
+ 2.0.6
CFBundleSignature
????
CFBundleURLTypes
@@ -33,7 +33,7 @@
CFBundleVersion
- 27
+ 28
ExpoLocalization_supportsRTL
LSApplicationQueriesSchemes
diff --git a/modules/expo-psiphon-tunnel-core/android/src/main/AndroidManifest.xml b/modules/expo-psiphon-tunnel-core/android/src/main/AndroidManifest.xml
index f3f9f92e..fd3bbb62 100644
--- a/modules/expo-psiphon-tunnel-core/android/src/main/AndroidManifest.xml
+++ b/modules/expo-psiphon-tunnel-core/android/src/main/AndroidManifest.xml
@@ -2,6 +2,7 @@
+
+
+
diff --git a/modules/expo-psiphon-tunnel-core/android/src/main/java/expo/modules/psiphontunnelcore/AppLogStore.kt b/modules/expo-psiphon-tunnel-core/android/src/main/java/expo/modules/psiphontunnelcore/AppLogStore.kt
index 34ce8218..9765f38b 100644
--- a/modules/expo-psiphon-tunnel-core/android/src/main/java/expo/modules/psiphontunnelcore/AppLogStore.kt
+++ b/modules/expo-psiphon-tunnel-core/android/src/main/java/expo/modules/psiphontunnelcore/AppLogStore.kt
@@ -17,90 +17,161 @@
*/
package expo.modules.psiphontunnelcore
+import android.content.ContentValues
import android.content.Context
+import android.net.Uri
import android.util.Log
-import org.json.JSONObject
import java.io.File
-import java.io.FileOutputStream
-import java.nio.charset.StandardCharsets
-import java.text.SimpleDateFormat
-import java.util.Date
import java.util.Locale
-import java.util.TimeZone
+import java.util.concurrent.Future
+import java.util.concurrent.Executors
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.TimeoutException
object AppLogStore {
- private const val APP_LOG_FILE_NAME = "app.log"
- private const val APP_LOG_ARCHIVE_FILE_NAME = "app.log.1"
- private const val MAX_LOG_FILE_BYTES = Constants.QUARTER_MB.toLong()
- private val lock = Any()
- private val timestampFormatter = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply {
- timeZone = TimeZone.getTimeZone("UTC")
- }
+ private const val TAG = "AppLogStore"
+ private const val AUTHORITY_SUFFIX = ".log"
+ private const val PATH_INSERT_LOGS = "insert"
+ private const val LEGACY_APP_LOG_DIRECTORY = "app_logs"
+ private const val LEGACY_APP_LOG_FILE_NAME = "app.log"
+ private const val LEGACY_APP_LOG_ARCHIVE_FILE_NAME = "app.log.1"
+ private const val MAX_RETRIES = 3
+ private const val DEFAULT_FLUSH_TIMEOUT_MS = 2_000L
+ private val retryDelaysMs = longArrayOf(100L, 500L, 1_000L)
+ private val retryableLogLevels = setOf(Log.ERROR, Log.WARN, Log.INFO)
+ private val executorService = Executors.newSingleThreadExecutor()
fun info(context: Context, tag: String, message: String) {
- Log.i(tag, message)
- append(context, "Info", tag, message)
+ log(context, tag, message, Log.INFO, null)
}
fun warn(context: Context, tag: String, message: String) {
- Log.w(tag, message)
- append(context, "Warning", tag, message)
+ log(context, tag, message, Log.WARN, null)
+ }
+
+ fun warn(context: Context, tag: String, message: String, error: Throwable) {
+ log(context, tag, message, Log.WARN, error)
}
fun error(context: Context, tag: String, message: String) {
- Log.e(tag, message)
- append(context, "Error", tag, message)
+ log(context, tag, message, Log.ERROR, null)
+ }
+
+ fun error(context: Context, tag: String, message: String, error: Throwable) {
+ log(context, tag, message, Log.ERROR, error)
}
fun allLogFiles(context: Context): List {
- val logDir = logDirectory(context)
- return listOf(
- File(logDir, APP_LOG_ARCHIVE_FILE_NAME),
- File(logDir, APP_LOG_FILE_NAME),
- ).filter { it.exists() }
+ val dataRoot = Utils.dataRootDirectory(context)
+ val providerLogs = dataRoot.listFiles { _, name ->
+ name.startsWith(LoggingContentProvider.LOG_FILE_NAME) && !name.endsWith(".lck")
+ }?.toList().orEmpty()
+
+ val legacyLogDir = File(dataRoot, LEGACY_APP_LOG_DIRECTORY)
+ val legacyLogs = listOf(
+ File(legacyLogDir, LEGACY_APP_LOG_ARCHIVE_FILE_NAME),
+ File(legacyLogDir, LEGACY_APP_LOG_FILE_NAME),
+ )
+
+ return (providerLogs + legacyLogs).filter { it.exists() && it.isFile }
}
- private fun append(context: Context, level: String, tag: String, message: String) {
- synchronized(lock) {
- val logDir = logDirectory(context)
- rotateIfNeeded(logDir)
- val payload = JSONObject()
- .put("tag", tag)
- .put("message", message)
- .put("level", level)
- .put("timestamp", rfc3339Timestamp(System.currentTimeMillis()))
- .toString() + "\n"
-
- FileOutputStream(File(logDir, APP_LOG_FILE_NAME), true).use { output ->
- output.write(payload.toByteArray(StandardCharsets.UTF_8))
- }
+ fun flush(timeoutMs: Long = DEFAULT_FLUSH_TIMEOUT_MS): Boolean {
+ val barrier: Future<*> = executorService.submit {}
+ return try {
+ barrier.get(timeoutMs, TimeUnit.MILLISECONDS)
+ true
+ } catch (error: TimeoutException) {
+ barrier.cancel(false)
+ Log.w(TAG, String.format(Locale.US, "Timed out flushing logs after %dms", timeoutMs))
+ false
+ } catch (error: InterruptedException) {
+ Thread.currentThread().interrupt()
+ barrier.cancel(false)
+ Log.w(TAG, "Interrupted while flushing logs", error)
+ false
+ } catch (error: Exception) {
+ Log.w(TAG, "Failed to flush logs", error)
+ false
}
}
- private fun rotateIfNeeded(logDir: File) {
- val current = File(logDir, APP_LOG_FILE_NAME)
- if (!current.exists() || current.length() < MAX_LOG_FILE_BYTES) {
+ private fun log(context: Context, tag: String, message: String, level: Int, error: Throwable?) {
+ logcat(level, tag, message, error)
+
+ if (level == Log.DEBUG || level == Log.VERBOSE) {
return
}
- val archived = File(logDir, APP_LOG_ARCHIVE_FILE_NAME)
- if (archived.exists()) {
- archived.delete()
+ val appContext = context.applicationContext
+ val uri = Uri.parse("content://${appContext.packageName}$AUTHORITY_SUFFIX/$PATH_INSERT_LOGS")
+ val values = ContentValues().apply {
+ put("tag", tag)
+ put("message", messageForFile(message, error))
+ put("level", level)
+ put("timestamp", System.currentTimeMillis())
+ }
+
+ executorService.execute {
+ insertWithRetry(appContext, uri, values, level, 0)
+ }
+ }
+
+ private fun logcat(level: Int, tag: String, message: String, error: Throwable?) {
+ when (level) {
+ Log.ERROR -> if (error == null) Log.e(tag, message) else Log.e(tag, message, error)
+ Log.WARN -> if (error == null) Log.w(tag, message) else Log.w(tag, message, error)
+ Log.INFO -> if (error == null) Log.i(tag, message) else Log.i(tag, message, error)
+ Log.DEBUG -> if (error == null) Log.d(tag, message) else Log.d(tag, message, error)
+ Log.VERBOSE -> if (error == null) Log.v(tag, message) else Log.v(tag, message, error)
+ else -> Log.println(level, tag, messageForFile(message, error))
}
- current.renameTo(archived)
}
- private fun logDirectory(context: Context): File {
- val directory = File(Utils.dataRootDirectory(context), "app_logs")
- if (!directory.exists()) {
- directory.mkdirs()
+ private fun messageForFile(message: String, error: Throwable?): String {
+ return if (error == null) {
+ message
+ } else {
+ String.format(Locale.US, "%s: %s", message, error)
}
- return directory
}
- private fun rfc3339Timestamp(timeMillis: Long): String {
- synchronized(timestampFormatter) {
- return timestampFormatter.format(Date(timeMillis))
+ private fun insertWithRetry(
+ context: Context,
+ uri: Uri,
+ values: ContentValues,
+ level: Int,
+ attempt: Int,
+ ) {
+ var currentAttempt = attempt
+ while (true) {
+ try {
+ val result = context.contentResolver.insert(uri, values)
+ if (result != null) {
+ return
+ }
+ throw IllegalStateException("Insert returned null result")
+ } catch (error: Exception) {
+ Log.e(TAG, String.format(Locale.US, "Insert failed (attempt %d): %s", currentAttempt + 1, error.message))
+ if (currentAttempt >= MAX_RETRIES || !retryableLogLevels.contains(level)) {
+ if (level >= Log.ERROR) {
+ Log.e(values.getAsString("tag"), values.getAsString("message"))
+ }
+ return
+ }
+
+ val delay = retryDelaysMs.getOrElse(currentAttempt) { retryDelaysMs.last() }
+ currentAttempt += 1
+ try {
+ Thread.sleep(delay)
+ } catch (interrupted: InterruptedException) {
+ Thread.currentThread().interrupt()
+ if (level >= Log.ERROR) {
+ Log.e(values.getAsString("tag"), values.getAsString("message"))
+ }
+ return
+ }
+ }
}
}
}
diff --git a/modules/expo-psiphon-tunnel-core/android/src/main/java/expo/modules/psiphontunnelcore/ConduitServiceInteractor.kt b/modules/expo-psiphon-tunnel-core/android/src/main/java/expo/modules/psiphontunnelcore/ConduitServiceInteractor.kt
index 951dec74..c2def04a 100644
--- a/modules/expo-psiphon-tunnel-core/android/src/main/java/expo/modules/psiphontunnelcore/ConduitServiceInteractor.kt
+++ b/modules/expo-psiphon-tunnel-core/android/src/main/java/expo/modules/psiphontunnelcore/ConduitServiceInteractor.kt
@@ -24,7 +24,6 @@ import android.content.ServiceConnection
import android.os.Bundle
import android.os.IBinder
import android.os.RemoteException
-import android.util.Log
import ca.psiphon.conduit.nativemodule.IConduitClientCallback
import ca.psiphon.conduit.nativemodule.IConduitService
@@ -38,9 +37,21 @@ class ConduitServiceInteractor(private val context: Context) {
private var conduitService: IConduitService? = null
private var callback: ((String, Bundle) -> Unit)? = null
+ private fun logInfo(message: String) {
+ AppLogStore.info(context, TAG, message)
+ }
+
+ private fun logWarn(message: String) {
+ AppLogStore.warn(context, TAG, message)
+ }
+
+ private fun logError(message: String, error: Throwable) {
+ AppLogStore.error(context, TAG, message, error)
+ }
+
private val clientCallback = object : IConduitClientCallback.Stub() {
override fun onProxyStateUpdated(proxyStateBundle: Bundle) {
- Log.i(TAG, "Received proxy state callback: ${proxyStateBundle.getString("status")}")
+ logInfo("Received proxy state callback: ${proxyStateBundle.getString("status")}")
callback?.invoke("proxyState", proxyStateBundle)
}
@@ -55,13 +66,13 @@ class ConduitServiceInteractor(private val context: Context) {
private val serviceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
- Log.i(TAG, "Connected to InproxyForegroundService")
+ logInfo("Connected to InproxyForegroundService")
conduitService = IConduitService.Stub.asInterface(service)
registerClientIfReady("service connected")
}
override fun onServiceDisconnected(name: ComponentName?) {
- Log.i(TAG, "Disconnected from InproxyForegroundService")
+ logInfo("Disconnected from InproxyForegroundService")
conduitService = null
isServiceBound = false
@@ -76,18 +87,18 @@ class ConduitServiceInteractor(private val context: Context) {
fun onStart(callback: (String, Bundle) -> Unit) {
this.callback = callback
isStopped = false
- Log.i(TAG, "Interactor start")
+ logInfo("Interactor start")
bindService()
registerClientIfReady("observer start")
}
fun onStop() {
isStopped = true
- Log.i(TAG, "Interactor stop")
+ logInfo("Interactor stop")
try {
conduitService?.unregisterClient(clientCallback)
} catch (error: RemoteException) {
- Log.e(TAG, "Failed to unregister inproxy client", error)
+ logError("Failed to unregister inproxy client", error)
}
if (isServiceBound) {
@@ -124,10 +135,10 @@ class ConduitServiceInteractor(private val context: Context) {
}
try {
service.registerClient(clientCallback)
- Log.i(TAG, "Registered inproxy client callback: $reason")
+ logInfo("Registered inproxy client callback: $reason")
emitPendingProxyError()
} catch (error: RemoteException) {
- Log.e(TAG, "Failed to register inproxy client: $reason", error)
+ logError("Failed to register inproxy client: $reason", error)
}
}
@@ -135,12 +146,12 @@ class ConduitServiceInteractor(private val context: Context) {
if (isServiceBound) {
return
}
- Log.i(TAG, "Binding to InproxyForegroundService")
+ logInfo("Binding to InproxyForegroundService")
val intent = Intent(context, InproxyForegroundService::class.java)
val bound = context.bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE)
isServiceBound = bound
if (!bound) {
- Log.w(TAG, "bindService returned false")
+ logWarn("bindService returned false")
}
}
diff --git a/modules/expo-psiphon-tunnel-core/android/src/main/java/expo/modules/psiphontunnelcore/ConduitStateService.kt b/modules/expo-psiphon-tunnel-core/android/src/main/java/expo/modules/psiphontunnelcore/ConduitStateService.kt
index 1a02e081..bf55493d 100644
--- a/modules/expo-psiphon-tunnel-core/android/src/main/java/expo/modules/psiphontunnelcore/ConduitStateService.kt
+++ b/modules/expo-psiphon-tunnel-core/android/src/main/java/expo/modules/psiphontunnelcore/ConduitStateService.kt
@@ -27,7 +27,6 @@ import android.os.Bundle
import android.os.DeadObjectException
import android.os.IBinder
import android.os.RemoteException
-import android.util.Log
import ca.psiphon.conduit.nativemodule.IConduitClientCallback
import ca.psiphon.conduit.nativemodule.IConduitService
import ca.psiphon.conduit.state.IConduitStateCallback
@@ -74,6 +73,18 @@ class ConduitStateService : Service() {
state = ProxyState.UNKNOWN,
)
+ private fun logInfo(message: String) {
+ AppLogStore.info(applicationContext, TAG, message)
+ }
+
+ private fun logWarn(message: String) {
+ AppLogStore.warn(applicationContext, TAG, message)
+ }
+
+ private fun logError(message: String, error: Throwable) {
+ AppLogStore.error(applicationContext, TAG, message, error)
+ }
+
private val inproxyClientCallback = object : IConduitClientCallback.Stub() {
override fun onProxyStateUpdated(proxyStateBundle: Bundle) {
updateAndNotify(proxyStateFromBundle(proxyStateBundle))
@@ -90,17 +101,17 @@ class ConduitStateService : Service() {
private val inproxyServiceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
- Log.i(TAG, "Connected to InproxyForegroundService")
+ logInfo("Connected to InproxyForegroundService")
inproxyService = IConduitService.Stub.asInterface(service)
try {
inproxyService?.registerClient(inproxyClientCallback)
} catch (error: RemoteException) {
- Log.e(TAG, "Failed to register inproxy state callback", error)
+ logError("Failed to register inproxy state callback", error)
}
}
override fun onServiceDisconnected(name: ComponentName?) {
- Log.i(TAG, "Disconnected from InproxyForegroundService")
+ logInfo("Disconnected from InproxyForegroundService")
inproxyService = null
isInproxyServiceBound = false
if (!isDestroyed) {
@@ -116,10 +127,7 @@ class ConduitStateService : Service() {
}
val caller = enforceTrustedCaller("registerClient")
- Log.i(
- TAG,
- "Accepted registerClient from $caller",
- )
+ logInfo("Accepted registerClient from $caller")
var activeClientCount = 0
synchronized(clientsLock) {
@@ -129,7 +137,7 @@ class ConduitStateService : Service() {
try {
client.onStateUpdate(currentUpdate.toJson())
} catch (e: RemoteException) {
- Log.e(TAG, "Failed to deliver initial state", e)
+ logError("Failed to deliver initial state", e)
}
}
emitIpcEvent(
@@ -149,7 +157,7 @@ class ConduitStateService : Service() {
clients.remove(client.asBinder())
activeClientCount = clients.size
}
- Log.i(TAG, "Accepted unregisterClient")
+ logInfo("Accepted unregisterClient")
emitIpcEvent(
type = "unregisterClient",
status = "accepted",
@@ -159,10 +167,7 @@ class ConduitStateService : Service() {
override fun fetchConduitPrivateKey(): String {
val caller = enforceTrustedCaller("fetchConduitPrivateKey")
- Log.i(
- TAG,
- "Accepted fetchConduitPrivateKey from $caller",
- )
+ logInfo("Accepted fetchConduitPrivateKey from $caller")
val privateKey = InproxyParameters.load(applicationContext)?.privateKey.orEmpty()
if (privateKey.isBlank()) {
@@ -202,10 +207,7 @@ class ConduitStateService : Service() {
)
if (devTrustedSignatures.isNotEmpty()) {
- Log.w(
- TAG,
- "Loaded development IPC signatures for ${devTrustedSignatures.size} package(s).",
- )
+ logWarn("Loaded development IPC signatures for ${devTrustedSignatures.size} package(s).")
}
currentUpdate = StateUpdate(
@@ -231,7 +233,7 @@ class ConduitStateService : Service() {
override fun onBind(intent: Intent?): IBinder? {
if (intent?.action != BIND_ACTION) {
- Log.w(TAG, "Denying bind with invalid action: ${intent?.action}")
+ logWarn("Denying bind with invalid action: ${intent?.action}")
emitIpcEvent(
type = "bind",
status = "invalid",
@@ -242,7 +244,7 @@ class ConduitStateService : Service() {
// The framework invokes onBind during service setup, so Binder.getCallingUid()
// here does not reliably identify the eventual external client. Enforce caller
// authorization on the AIDL methods instead, where the remote UID is correct.
- Log.i(TAG, "Accepted bind for action $BIND_ACTION")
+ logInfo("Accepted bind for action $BIND_ACTION")
emitIpcEvent(
type = "bind",
status = "accepted",
@@ -267,7 +269,7 @@ class ConduitStateService : Service() {
if (error is DeadObjectException) {
toRemove.add(clientBinder)
} else {
- Log.e(TAG, "Failed to notify state client", error)
+ logError("Failed to notify state client", error)
}
}
}
@@ -298,7 +300,7 @@ class ConduitStateService : Service() {
val intent = Intent(applicationContext, InproxyForegroundService::class.java)
isInproxyServiceBound = bindService(intent, inproxyServiceConnection, Context.BIND_AUTO_CREATE)
if (!isInproxyServiceBound) {
- Log.w(TAG, "bindService returned false for InproxyForegroundService")
+ logWarn("bindService returned false for InproxyForegroundService")
}
}
@@ -306,7 +308,7 @@ class ConduitStateService : Service() {
try {
inproxyService?.unregisterClient(inproxyClientCallback)
} catch (error: RemoteException) {
- Log.e(TAG, "Failed to unregister inproxy state callback", error)
+ logError("Failed to unregister inproxy state callback", error)
}
try {
if (isInproxyServiceBound) {
@@ -333,10 +335,7 @@ class ConduitStateService : Service() {
}
private fun logDeniedCaller(operation: String, caller: String) {
- Log.w(
- TAG,
- "Denied $operation from $caller",
- )
+ logWarn("Denied $operation from $caller")
emitIpcEvent(
type = operation,
status = "denied",
@@ -378,7 +377,7 @@ class ConduitStateService : Service() {
packageInfo.versionCode
}
} catch (e: Exception) {
- Log.e(TAG, "Failed to fetch app version code", e)
+ logError("Failed to fetch app version code", e)
-1
}
}
diff --git a/modules/expo-psiphon-tunnel-core/android/src/main/java/expo/modules/psiphontunnelcore/ExpoPsiphonTunnelCoreModule.kt b/modules/expo-psiphon-tunnel-core/android/src/main/java/expo/modules/psiphontunnelcore/ExpoPsiphonTunnelCoreModule.kt
index 3673c6bb..e6d49b00 100644
--- a/modules/expo-psiphon-tunnel-core/android/src/main/java/expo/modules/psiphontunnelcore/ExpoPsiphonTunnelCoreModule.kt
+++ b/modules/expo-psiphon-tunnel-core/android/src/main/java/expo/modules/psiphontunnelcore/ExpoPsiphonTunnelCoreModule.kt
@@ -23,6 +23,7 @@ import androidx.work.Constraints
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
+import androidx.work.WorkInfo
import androidx.work.WorkManager
import expo.modules.kotlin.Promise
import expo.modules.kotlin.exception.Exceptions
@@ -49,6 +50,7 @@ class ExpoPsiphonTunnelCoreModule : Module() {
OnCreate {
conduitServiceInteractor = ConduitServiceInteractor(context.applicationContext)
+ LogsMaintenanceWorker.schedule(context.applicationContext)
}
OnDestroy {
@@ -72,18 +74,38 @@ class ExpoPsiphonTunnelCoreModule : Module() {
AsyncFunction("sendFeedback") { inproxyId: String, promise: Promise ->
try {
val appContext = context.applicationContext
+ val workManager = WorkManager.getInstance(appContext)
+ if (hasPendingFeedbackUpload(workManager)) {
+ AppLogStore.info(
+ appContext,
+ "ExpoPsiphonTunnelCoreModule",
+ "Feedback upload already pending",
+ )
+ promise.resolve(null)
+ return@AsyncFunction
+ }
+
+ val inputData = FeedbackWorker.createInputData(inproxyId)
+ val feedbackId = inputData.getString(FeedbackWorker.INPUT_FEEDBACK_ID)
+ ?: throw IllegalStateException("Missing generated feedback ID")
+ AppLogStore.info(
+ appContext,
+ "ExpoPsiphonTunnelCoreModule",
+ "Feedback upload requested: $feedbackId",
+ )
+ FeedbackWorker.createFeedbackSnapshot(appContext, feedbackId)
val request = OneTimeWorkRequestBuilder()
- .setInputData(FeedbackWorker.createInputData(inproxyId))
+ .setInputData(inputData)
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build(),
)
.build()
- WorkManager.getInstance(appContext)
+ workManager
.enqueueUniqueWork(
FeedbackWorker.UNIQUE_WORK_NAME,
- ExistingWorkPolicy.KEEP,
+ ExistingWorkPolicy.REPLACE,
request,
)
AppLogStore.info(
@@ -94,6 +116,9 @@ class ExpoPsiphonTunnelCoreModule : Module() {
promise.resolve(null)
} catch (error: Exception) {
+ if (error is InterruptedException) {
+ Thread.currentThread().interrupt()
+ }
AppLogStore.error(
context.applicationContext,
"ExpoPsiphonTunnelCoreModule",
@@ -171,6 +196,14 @@ class ExpoPsiphonTunnelCoreModule : Module() {
}
}
+ private fun hasPendingFeedbackUpload(workManager: WorkManager): Boolean {
+ return workManager.getWorkInfosForUniqueWork(FeedbackWorker.UNIQUE_WORK_NAME)
+ .get()
+ .any { workInfo ->
+ workInfo.state == WorkInfo.State.ENQUEUED || workInfo.state == WorkInfo.State.RUNNING
+ }
+ }
+
private fun emitInproxyEvent(eventType: String, eventData: Bundle) {
val data = bundleToMap(eventData)
val payload = mapOf(
diff --git a/modules/expo-psiphon-tunnel-core/android/src/main/java/expo/modules/psiphontunnelcore/FeedbackWorker.kt b/modules/expo-psiphon-tunnel-core/android/src/main/java/expo/modules/psiphontunnelcore/FeedbackWorker.kt
index 6ea55171..cf677363 100644
--- a/modules/expo-psiphon-tunnel-core/android/src/main/java/expo/modules/psiphontunnelcore/FeedbackWorker.kt
+++ b/modules/expo-psiphon-tunnel-core/android/src/main/java/expo/modules/psiphontunnelcore/FeedbackWorker.kt
@@ -22,7 +22,6 @@ import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.net.NetworkInfo
import android.os.Build
-import androidx.annotation.NonNull
import androidx.work.Data
import androidx.work.Worker
import androidx.work.WorkerParameters
@@ -33,9 +32,11 @@ import org.json.JSONObject
import psi.Psi
import java.io.BufferedReader
import java.io.File
+import java.io.FileInputStream
+import java.io.FileOutputStream
import java.io.FileReader
import java.io.IOException
-import java.nio.channels.FileChannel
+import java.nio.channels.FileLock
import java.security.SecureRandom
import java.text.SimpleDateFormat
import java.util.Date
@@ -45,7 +46,7 @@ import java.util.TreeMap
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
-class FeedbackWorker(@NonNull context: Context, @NonNull params: WorkerParameters) : Worker(context, params) {
+class FeedbackWorker(context: Context, params: WorkerParameters) : Worker(context, params) {
companion object {
const val UNIQUE_WORK_NAME = "PsiphonTunnelCoreFeedbackUpload"
const val INPUT_FEEDBACK_ID = "feedbackId"
@@ -62,11 +63,16 @@ class FeedbackWorker(@NonNull context: Context, @NonNull params: WorkerParameter
.build()
}
- fun createFeedbackSnapshot(@NonNull context: Context, @NonNull feedbackId: String) {
+ fun createFeedbackSnapshot(context: Context, feedbackId: String) {
+ AppLogStore.flush()
val dir = feedbackDirectoryForContext(context)
+ val appLogFiles = AppLogStore.allLogFiles(context)
+ if (appLogFiles.isEmpty()) {
+ AppLogStore.info(context, TAG, "No app log files found to include in feedback $feedbackId")
+ }
mergeFiles(
- File(dir, "app.$feedbackId.feedback"),
- AppLogStore.allLogFiles(context),
+ appFeedbackFile(dir, feedbackId),
+ appLogFiles,
)
val noticeFiles = mutableListOf()
@@ -79,11 +85,22 @@ class FeedbackWorker(@NonNull context: Context, @NonNull params: WorkerParameter
if (notices.exists()) {
noticeFiles.add(notices)
}
+ if (noticeFiles.isEmpty()) {
+ AppLogStore.warn(context, TAG, "No tunnel core notice files found to include in feedback $feedbackId")
+ }
- mergeFiles(File(dir, "tunnelcore.$feedbackId.feedback"), noticeFiles)
+ mergeFiles(tunnelCoreFeedbackFile(dir, feedbackId), noticeFiles)
}
- fun cleanupOldFeedbackFiles(@NonNull context: Context, olderThanMillis: Long) {
+ private fun ensureFeedbackSnapshot(context: Context, feedbackId: String) {
+ val dir = feedbackDirectoryForContext(context)
+ if (appFeedbackFile(dir, feedbackId).exists() && tunnelCoreFeedbackFile(dir, feedbackId).exists()) {
+ return
+ }
+ createFeedbackSnapshot(context, feedbackId)
+ }
+
+ fun cleanupOldFeedbackFiles(context: Context, olderThanMillis: Long) {
val dir = feedbackDirectoryForContext(context)
dir.listFiles()?.forEach { file ->
if (file.isFile && file.lastModified() < olderThanMillis) {
@@ -106,24 +123,48 @@ class FeedbackWorker(@NonNull context: Context, @NonNull params: WorkerParameter
return dir
}
+ private fun appFeedbackFile(dir: File, feedbackId: String): File {
+ return File(dir, "app.$feedbackId.feedback")
+ }
+
+ private fun tunnelCoreFeedbackFile(dir: File, feedbackId: String): File {
+ return File(dir, "tunnelcore.$feedbackId.feedback")
+ }
+
private fun mergeFiles(outputFile: File, inputFiles: List) {
if (outputFile.exists()) {
outputFile.delete()
}
- if (inputFiles.isEmpty()) {
- return
+ outputFile.parentFile?.let { parent ->
+ if (!parent.exists()) {
+ parent.mkdirs()
+ }
}
- FileChannel.open(
- outputFile.toPath(),
- java.nio.file.StandardOpenOption.CREATE,
- java.nio.file.StandardOpenOption.WRITE,
- ).use { out ->
- inputFiles.sortedBy { it.lastModified() }.forEach { file ->
- FileChannel.open(file.toPath(), java.nio.file.StandardOpenOption.READ).use { input ->
- input.transferTo(0, input.size(), out)
+ FileOutputStream(outputFile, false).use { outputStream ->
+ val outputChannel = outputStream.channel
+ inputFiles
+ .filter { it.exists() && it.isFile }
+ .sortedBy { it.lastModified() }
+ .forEach { file ->
+ FileInputStream(file).use { inputStream ->
+ val inputChannel = inputStream.channel
+ val lock: FileLock = inputChannel.lock(0L, Long.MAX_VALUE, true)
+ try {
+ var position = 0L
+ val size = inputChannel.size()
+ while (position < size) {
+ val transferred = inputChannel.transferTo(position, size - position, outputChannel)
+ if (transferred <= 0L) {
+ break
+ }
+ position += transferred
+ }
+ } finally {
+ lock.release()
+ }
+ }
}
- }
}
}
}
@@ -148,7 +189,7 @@ class FeedbackWorker(@NonNull context: Context, @NonNull params: WorkerParameter
}
return try {
- createFeedbackSnapshot(applicationContext, feedbackId)
+ ensureFeedbackSnapshot(applicationContext, feedbackId)
cleanupOldFeedbackFiles(
applicationContext,
System.currentTimeMillis() - TimeUnit.HOURS.toMillis(6),
@@ -297,22 +338,24 @@ class FeedbackWorker(@NonNull context: Context, @NonNull params: WorkerParameter
}
try {
val input = JSONObject(line)
- val timestamp = parseTimestamp(input.getString("timestamp"))
+ val inputTimestamp = input.getString("timestamp")
+ val timestamp = parseTimestamp(inputTimestamp)
+ val timestampText = normalizedTimestamp(inputTimestamp, timestamp)
val output = if (isTunnelCore) {
JSONObject()
- .put("timestamp!!timestamp", input.getString("timestamp"))
+ .put("timestamp!!timestamp", timestampText)
.put("category", "tunnel-core")
.put("data", input)
} else {
JSONObject()
- .put("timestamp!!timestamp", input.getString("timestamp"))
+ .put("timestamp!!timestamp", timestampText)
.put("category", input.optString("tag", "app"))
.put("message", input.optString("message", ""))
.put("level", input.optString("level", "Info"))
}
val list = logMap.getOrPut(timestamp) { mutableListOf() }
list.add(output)
- } catch (_: JSONException) {
+ } catch (_: Exception) {
}
}
}
@@ -352,11 +395,20 @@ class FeedbackWorker(@NonNull context: Context, @NonNull params: WorkerParameter
}
private fun parseTimestamp(value: String): Date {
+ value.toLongOrNull()?.let { return Date(it) }
synchronized(rfc3339Formatter) {
return rfc3339Formatter.parse(value) ?: Date(0)
}
}
+ private fun normalizedTimestamp(value: String, parsed: Date): String {
+ return if (value.toLongOrNull() == null) {
+ value
+ } else {
+ formatTimestamp(parsed.time)
+ }
+ }
+
private fun feedbackDirectory(): File {
val dir = File(Utils.dataRootDirectory(applicationContext), "feedback")
if (!dir.exists()) {
diff --git a/modules/expo-psiphon-tunnel-core/android/src/main/java/expo/modules/psiphontunnelcore/InproxyForegroundService.kt b/modules/expo-psiphon-tunnel-core/android/src/main/java/expo/modules/psiphontunnelcore/InproxyForegroundService.kt
index 5638b30e..878ee6b1 100644
--- a/modules/expo-psiphon-tunnel-core/android/src/main/java/expo/modules/psiphontunnelcore/InproxyForegroundService.kt
+++ b/modules/expo-psiphon-tunnel-core/android/src/main/java/expo/modules/psiphontunnelcore/InproxyForegroundService.kt
@@ -34,7 +34,6 @@ import android.os.Parcel
import android.os.RemoteException
import android.os.SystemClock
import android.text.format.Formatter
-import android.util.Log
import android.util.Base64
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
@@ -485,6 +484,26 @@ class InproxyForegroundService : Service(), PsiphonTunnel.HostService {
private var lastRegionalAccumulatorPersistMs = 0L
private var lastActivityStatsEmitMs = 0L
+ private fun logInfo(message: String) {
+ AppLogStore.info(applicationContext, tag, message)
+ }
+
+ private fun logWarn(message: String) {
+ AppLogStore.warn(applicationContext, tag, message)
+ }
+
+ private fun logWarn(message: String, error: Throwable) {
+ AppLogStore.warn(applicationContext, tag, message, error)
+ }
+
+ private fun logError(message: String) {
+ AppLogStore.error(applicationContext, tag, message)
+ }
+
+ private fun logError(message: String, error: Throwable) {
+ AppLogStore.error(applicationContext, tag, message, error)
+ }
+
private val binder = object : IConduitService.Stub() {
override fun registerClient(client: IConduitClientCallback?) {
if (client == null) {
@@ -493,16 +512,16 @@ class InproxyForegroundService : Service(), PsiphonTunnel.HostService {
synchronized(clientsLock) {
val clientBinder = client.asBinder()
clients[clientBinder] = client
- Log.i(tag, "Client registered, total=${clients.size}")
+ logInfo("Client registered, total=${clients.size}")
try {
client.onProxyStateUpdated(proxyStateBundle(state))
} catch (error: RemoteException) {
- Log.e(tag, "Failed to send proxy state update to client", error)
+ logError("Failed to send proxy state update to client", error)
}
try {
client.onProxyActivityStatsUpdated(activityStatsBundle(stats))
} catch (error: RemoteException) {
- Log.e(tag, "Failed to send proxy activity stats update to client", error)
+ logError("Failed to send proxy activity stats update to client", error)
}
}
}
@@ -513,7 +532,7 @@ class InproxyForegroundService : Service(), PsiphonTunnel.HostService {
}
synchronized(clientsLock) {
clients.remove(client.asBinder())
- Log.i(tag, "Client unregistered, total=${clients.size}")
+ logInfo("Client unregistered, total=${clients.size}")
}
}
@@ -530,18 +549,23 @@ class InproxyForegroundService : Service(), PsiphonTunnel.HostService {
override fun onCreate() {
super.onCreate()
+ logInfo("Inproxy foreground service created")
ensureNotificationChannel()
loadRegionalAccumulatorsFromDisk()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
- val action = intent?.action ?: return START_NOT_STICKY
+ val action = intent?.action
+ if (action == null) {
+ logWarn("Received start command without action")
+ return START_NOT_STICKY
+ }
when (action) {
ACTION_TOGGLE_INPROXY -> handleToggle(intent)
ACTION_PARAMS_CHANGED -> handleParamsChanged(intent)
ACTION_STOP_INPROXY -> stopInproxy("manual stop")
ACTION_START_INPROXY_WITH_LAST_PARAMS -> handleStartWithLastParams()
- else -> Log.w(tag, "Unknown action: $action")
+ else -> logWarn("Unknown action: $action")
}
if (!isRunning.get()) {
stopSelf(startId)
@@ -551,6 +575,7 @@ class InproxyForegroundService : Service(), PsiphonTunnel.HostService {
override fun onDestroy() {
super.onDestroy()
+ logInfo("Inproxy foreground service destroyed")
maybePersistRegionalAccumulators(force = true)
stopActivityEmitter()
executor.shutdownNow()
@@ -560,12 +585,16 @@ class InproxyForegroundService : Service(), PsiphonTunnel.HostService {
}
private fun handleToggle(intent: Intent) {
+ logInfo("Received toggle action")
if (isRunning.get()) {
+ logInfo("Service is running; toggling off")
stopInproxy("toggle stop")
return
}
+ logInfo("Service is not running; starting with new parameters")
val params = InproxyParameters.fromIntent(intent)
if (params == null) {
+ logError("Attempted to start inproxy with invalid parameters")
reportProxyError(
action = "inProxyStartFailed",
message = "Invalid inproxy parameters",
@@ -580,6 +609,7 @@ class InproxyForegroundService : Service(), PsiphonTunnel.HostService {
private fun handleParamsChanged(intent: Intent) {
val params = InproxyParameters.fromIntent(intent)
if (params == null) {
+ logError("Attempted to update inproxy parameters with invalid parameters")
reportProxyError(
action = "inProxyRestartFailed",
message = "Invalid inproxy parameters",
@@ -589,14 +619,17 @@ class InproxyForegroundService : Service(), PsiphonTunnel.HostService {
}
val changed = params.store(applicationContext)
if (!changed) {
+ logInfo("Parameters update called, but no changes detected")
return
}
+ logInfo("Parameters updated; changes persisted")
if (isRunning.get()) {
+ logInfo("Service is running; restarting inproxy tunnel due to parameter changes")
try {
resetStats()
psiphonTunnel.restartPsiphon()
} catch (e: Exception) {
- Log.e(tag, "Failed to restart in-proxy tunnel", e)
+ logError("Failed to restart in-proxy tunnel", e)
reportProxyError(
action = "inProxyRestartFailed",
message = e.message,
@@ -604,16 +637,20 @@ class InproxyForegroundService : Service(), PsiphonTunnel.HostService {
)
stopInproxy("restart failed")
}
+ } else {
+ logInfo("Service is stopped; updated parameters will apply on next start")
}
}
private fun handleStartWithLastParams() {
if (isRunning.get()) {
+ logInfo("Service is already running; ignoring start with last parameters action")
return
}
+ logInfo("Service is stopped; starting with last known parameters")
val params = InproxyParameters.load(applicationContext)
if (params == null) {
- Log.w(tag, "No persisted inproxy parameters available")
+ logWarn("No persisted inproxy parameters available")
return
}
startInproxy(params)
@@ -621,8 +658,10 @@ class InproxyForegroundService : Service(), PsiphonTunnel.HostService {
private fun startInproxy(params: InproxyParameters) {
if (!isRunning.compareAndSet(false, true)) {
+ logInfo("Service is not stopped; cannot start")
return
}
+ logInfo("Starting inproxy")
Utils.setServiceRunningFlag(applicationContext, true)
resetStats()
@@ -632,7 +671,7 @@ class InproxyForegroundService : Service(), PsiphonTunnel.HostService {
latestProxyState = state
if (!startForegroundSafely()) {
- Log.e(tag, "Unable to start inproxy because the foreground notification is unavailable")
+ logError("Unable to start inproxy because the foreground notification is unavailable")
isRunning.set(false)
stopActivityEmitter()
Utils.setServiceRunningFlag(applicationContext, false)
@@ -648,16 +687,19 @@ class InproxyForegroundService : Service(), PsiphonTunnel.HostService {
stopLatch = CountDownLatch(1)
executor.submit {
try {
+ logInfo("Inproxy task started")
psiphonTunnel.startTunneling(Utils.getEmbeddedServers(this))
stopLatch?.await()
+ logInfo("Inproxy task stopping")
} catch (e: PsiphonTunnel.Exception) {
- Log.e(tag, "Failed to start inproxy", e)
+ logError("Failed to start inproxy", e)
reportProxyError(
action = "inProxyStartFailed",
message = e.message,
notificationTextResId = R.string.notification_conduit_failed_to_start_text,
)
} catch (e: InterruptedException) {
+ logWarn("Inproxy task interrupted", e)
Thread.currentThread().interrupt()
} finally {
psiphonTunnel.stop()
@@ -673,6 +715,7 @@ class InproxyForegroundService : Service(), PsiphonTunnel.HostService {
@Suppress("DEPRECATION")
stopForeground(true)
}
+ logInfo("Inproxy task stopped")
stopSelf()
}
}
@@ -680,9 +723,10 @@ class InproxyForegroundService : Service(), PsiphonTunnel.HostService {
private fun stopInproxy(reason: String) {
if (!isRunning.get()) {
+ logInfo("Service is not running; cannot stop: $reason")
return
}
- Log.i(tag, "Stopping inproxy: $reason")
+ logInfo("Stopping inproxy: $reason")
synchronized(statsLock) {
latestAnnouncingWorkers = 0
latestConnectingClients = 0
@@ -963,7 +1007,7 @@ class InproxyForegroundService : Service(), PsiphonTunnel.HostService {
payloadVersion != REGIONAL_ACCUMULATOR_PERSIST_VERSION_V2 &&
payloadVersion != REGIONAL_ACCUMULATOR_PERSIST_VERSION_V1
) {
- Log.w(tag, "Ignoring regional accumulator payload version=$payloadVersion")
+ logWarn("Ignoring regional accumulator payload version=$payloadVersion")
return
}
@@ -1028,10 +1072,7 @@ class InproxyForegroundService : Service(), PsiphonTunnel.HostService {
}
}
} else {
- Log.i(
- tag,
- "Skipping proxy activity restore due to boot epoch mismatch",
- )
+ logInfo("Skipping proxy activity restore due to boot epoch mismatch")
}
}
@@ -1056,9 +1097,9 @@ class InproxyForegroundService : Service(), PsiphonTunnel.HostService {
statsPersistenceDirty = false
lastRegionalAccumulatorPersistMs = System.currentTimeMillis()
}
- Log.i(tag, "Loaded persisted regional breakdown state")
+ logInfo("Loaded persisted regional breakdown state")
} catch (e: Exception) {
- Log.w(tag, "Failed to load persisted regional breakdown state", e)
+ logWarn("Failed to load persisted regional breakdown state", e)
}
}
@@ -1115,7 +1156,7 @@ class InproxyForegroundService : Service(), PsiphonTunnel.HostService {
lastRegionalAccumulatorPersistMs = nowMs
}
} catch (e: Exception) {
- Log.w(tag, "Failed to persist regional breakdown state", e)
+ logWarn("Failed to persist regional breakdown state", e)
}
}
@@ -1130,7 +1171,7 @@ class InproxyForegroundService : Service(), PsiphonTunnel.HostService {
val bytes = parcel.marshall()
Base64.encodeToString(bytes, Base64.NO_WRAP)
} catch (e: Exception) {
- Log.w(tag, "Failed to marshal proxy activity stats", e)
+ logWarn("Failed to marshal proxy activity stats", e)
null
} finally {
parcel.recycle()
@@ -1162,7 +1203,7 @@ class InproxyForegroundService : Service(), PsiphonTunnel.HostService {
parcel.readParcelable(ProxyActivityStats::class.java.classLoader)
}
} catch (e: Exception) {
- Log.w(tag, "Failed to unmarshal proxy activity stats", e)
+ logWarn("Failed to unmarshal proxy activity stats", e)
null
} finally {
parcel.recycle()
@@ -1628,7 +1669,7 @@ class InproxyForegroundService : Service(), PsiphonTunnel.HostService {
manager.createNotificationChannel(channel)
manager.getNotificationChannel(NOTIFICATION_CHANNEL_ID) != null
} catch (error: RuntimeException) {
- Log.e(tag, "Failed to create foreground notification channel", error)
+ logError("Failed to create foreground notification channel", error)
false
}
}
@@ -1641,7 +1682,7 @@ class InproxyForegroundService : Service(), PsiphonTunnel.HostService {
startForeground(NOTIFICATION_ID, buildStartingNotification())
true
} catch (error: RuntimeException) {
- Log.e(tag, "Failed to start foreground notification", error)
+ logError("Failed to start foreground notification", error)
false
}
}
@@ -1715,7 +1756,7 @@ class InproxyForegroundService : Service(), PsiphonTunnel.HostService {
try {
manager.notify(NOTIFICATION_ID, buildNotification())
} catch (error: RuntimeException) {
- Log.e(tag, "Failed to update foreground notification", error)
+ logError("Failed to update foreground notification", error)
}
}
@@ -1772,8 +1813,7 @@ class InproxyForegroundService : Service(), PsiphonTunnel.HostService {
psiphonConfig.put("InproxyLimitUpstreamBytesPerSecond", params.limitUpstreamBytesPerSecond)
psiphonConfig.put("InproxyLimitDownstreamBytesPerSecond", params.limitDownstreamBytesPerSecond)
- Log.i(
- tag,
+ logInfo(
"Inproxy config EmitInproxyProxyActivity=true maxCommonClients=${params.maxClients} maxPersonalClients=${params.maxPersonalClients} personalCompartmentPreview=${previewCompartmentId(params.personalCompartmentId)} upLimit=${params.limitUpstreamBytesPerSecond} downLimit=${params.limitDownstreamBytesPerSecond}",
)
@@ -1809,8 +1849,7 @@ class InproxyForegroundService : Service(), PsiphonTunnel.HostService {
commonRegionActivity: Map,
) {
activityCallbackCount += 1
- Log.i(
- tag,
+ logInfo(
"onInproxyProxyActivity #$activityCallbackCount announcing=$announcing connecting=$connectingClients connected=$connectedClients up=$bytesUp down=$bytesDown",
)
@@ -1912,6 +1951,7 @@ class InproxyForegroundService : Service(), PsiphonTunnel.HostService {
}
override fun onInproxyMustUpgrade() {
+ logWarn("Inproxy must upgrade")
reportProxyError(
action = "inProxyMustUpgrade",
message = "Psiphon core requires an app upgrade",
@@ -1921,6 +1961,7 @@ class InproxyForegroundService : Service(), PsiphonTunnel.HostService {
}
override fun onStartedWaitingForNetworkConnectivity() {
+ logInfo("Started waiting for network connectivity")
state = state.copy(networkState = NetworkState.NO_INTERNET)
latestProxyState = state
publishProxyState(state)
@@ -1928,6 +1969,7 @@ class InproxyForegroundService : Service(), PsiphonTunnel.HostService {
}
override fun onStoppedWaitingForNetworkConnectivity() {
+ logInfo("Stopped waiting for network connectivity")
state = state.copy(networkState = NetworkState.HAS_INTERNET)
latestProxyState = state
publishProxyState(state)
@@ -1935,12 +1977,14 @@ class InproxyForegroundService : Service(), PsiphonTunnel.HostService {
}
override fun onConnected() {
+ logInfo("Inproxy connected")
state = ProxyState(Status.RUNNING, NetworkState.HAS_INTERNET)
latestProxyState = state
publishProxyState(state)
}
override fun onConnecting() {
+ logInfo("Inproxy connecting")
state = ProxyState(Status.RUNNING, NetworkState.HAS_INTERNET)
latestProxyState = state
publishProxyState(state)
@@ -1951,6 +1995,7 @@ class InproxyForegroundService : Service(), PsiphonTunnel.HostService {
}
override fun onSocksProxyPortInUse(port: Int) {
+ logError("SOCKS proxy port in use: $port")
reportProxyError(
action = "inProxyStartFailed",
message = "SOCKS proxy port in use: $port",
@@ -1982,7 +2027,7 @@ class InproxyForegroundService : Service(), PsiphonTunnel.HostService {
if (error is DeadObjectException) {
deadClients.add(clientBinder)
} else {
- Log.e(tag, "Failed to notify proxy state", error)
+ logError("Failed to notify proxy state", error)
}
}
}
@@ -2001,7 +2046,7 @@ class InproxyForegroundService : Service(), PsiphonTunnel.HostService {
if (error is DeadObjectException) {
deadClients.add(clientBinder)
} else {
- Log.e(tag, "Failed to notify proxy activity stats", error)
+ logError("Failed to notify proxy activity stats", error)
}
}
}
@@ -2020,7 +2065,7 @@ class InproxyForegroundService : Service(), PsiphonTunnel.HostService {
if (error is DeadObjectException) {
deadClients.add(clientBinder)
} else {
- Log.e(tag, "Failed to notify proxy error", error)
+ logError("Failed to notify proxy error", error)
}
}
}
@@ -2029,6 +2074,7 @@ class InproxyForegroundService : Service(), PsiphonTunnel.HostService {
}
private fun reportProxyError(action: String, message: String?, notificationTextResId: Int) {
+ logWarn("Reporting proxy error action=$action message=${message.orEmpty()}")
notifyClientsProxyError(action, message)
persistPendingProxyError(action, message)
deliverProxyErrorIntent(action, message, notificationTextResId)
@@ -2051,7 +2097,7 @@ class InproxyForegroundService : Service(), PsiphonTunnel.HostService {
.remove(KEY_PENDING_ERROR_MESSAGE)
.apply()
} catch (error: IOException) {
- Log.w(tag, "Failed to persist pending proxy error", error)
+ logWarn("Failed to persist pending proxy error", error)
}
}
@@ -2095,7 +2141,7 @@ class InproxyForegroundService : Service(), PsiphonTunnel.HostService {
try {
manager.notify(notificationId, notification)
} catch (error: RuntimeException) {
- Log.e(tag, "Failed to show proxy error notification", error)
+ logError("Failed to show proxy error notification", error)
}
}
@@ -2119,6 +2165,6 @@ class InproxyForegroundService : Service(), PsiphonTunnel.HostService {
val trustedSignatures = PackageHelper.parseTrustedAppsFromApplicationParameters(params)
PackageHelper.saveTrustedSignaturesToFile(applicationContext, trustedSignatures)
PackageHelper.configureRuntimeTrustedSignatures(trustedSignatures)
- Log.i(tag, "Updated runtime trusted signatures for ${trustedSignatures.size} package(s)")
+ logInfo("Updated runtime trusted signatures for ${trustedSignatures.size} package(s)")
}
}
diff --git a/modules/expo-psiphon-tunnel-core/android/src/main/java/expo/modules/psiphontunnelcore/InproxyRestartReceiver.kt b/modules/expo-psiphon-tunnel-core/android/src/main/java/expo/modules/psiphontunnelcore/InproxyRestartReceiver.kt
index 6a96e9a8..444ae9a9 100644
--- a/modules/expo-psiphon-tunnel-core/android/src/main/java/expo/modules/psiphontunnelcore/InproxyRestartReceiver.kt
+++ b/modules/expo-psiphon-tunnel-core/android/src/main/java/expo/modules/psiphontunnelcore/InproxyRestartReceiver.kt
@@ -20,7 +20,6 @@ package expo.modules.psiphontunnelcore
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
-import android.util.Log
class InproxyRestartReceiver : BroadcastReceiver() {
private val tag = "InproxyRestartReceiver"
@@ -32,7 +31,7 @@ class InproxyRestartReceiver : BroadcastReceiver() {
}
if (Utils.getServiceRunningFlag(context)) {
- Log.i(tag, "Restarting inproxy foreground service after $action")
+ AppLogStore.info(context.applicationContext, tag, "Restarting inproxy foreground service after $action")
InproxyForegroundService.startWithLastParams(context)
}
}
diff --git a/modules/expo-psiphon-tunnel-core/android/src/main/java/expo/modules/psiphontunnelcore/LoggingContentProvider.kt b/modules/expo-psiphon-tunnel-core/android/src/main/java/expo/modules/psiphontunnelcore/LoggingContentProvider.kt
new file mode 100644
index 00000000..bcd8858b
--- /dev/null
+++ b/modules/expo-psiphon-tunnel-core/android/src/main/java/expo/modules/psiphontunnelcore/LoggingContentProvider.kt
@@ -0,0 +1,224 @@
+/*
+ * Copyright (c) 2026, Psiphon Inc.
+ * All rights reserved.
+ *
+ * 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, see .
+ */
+package expo.modules.psiphontunnelcore
+
+import android.content.ContentProvider
+import android.content.ContentValues
+import android.content.UriMatcher
+import android.database.Cursor
+import android.net.Uri
+import android.util.Log
+import org.json.JSONObject
+import java.io.File
+import java.io.IOException
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+import java.util.TimeZone
+import java.util.logging.FileHandler
+import java.util.logging.Formatter
+import java.util.logging.Level
+import java.util.logging.LogRecord
+import java.util.logging.Logger
+
+class LoggingContentProvider : ContentProvider() {
+ companion object {
+ const val LOG_FILE_NAME = "conduit_log"
+
+ private const val TAG = "LoggingContentProvider"
+ private const val LOG_FILE_SIZE = Constants.QUARTER_MB
+ private const val LOG_FILE_COUNT = 2
+ private const val AUTHORITY_SUFFIX = ".log"
+ private const val PATH_INSERT_LOGS = "insert"
+ private const val MATCH_INSERT = 1
+ private val uriMatcher = UriMatcher(UriMatcher.NO_MATCH)
+ private val timestampFormatter = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply {
+ timeZone = TimeZone.getTimeZone("UTC")
+ }
+
+ private fun intToLevel(level: Int): Level {
+ return when (level) {
+ Log.VERBOSE -> Level.FINEST
+ Log.DEBUG -> Level.FINE
+ Log.INFO -> Level.INFO
+ Log.WARN -> Level.WARNING
+ Log.ERROR -> Level.SEVERE
+ else -> throw IllegalArgumentException("Invalid log level: $level")
+ }
+ }
+
+ private fun levelToString(level: Level): String {
+ return when (level) {
+ Level.FINEST -> "Verbose"
+ Level.FINE -> "Debug"
+ Level.INFO -> "Info"
+ Level.WARNING -> "Warning"
+ Level.SEVERE -> "Error"
+ else -> throw IllegalArgumentException("Invalid log level: $level")
+ }
+ }
+
+ private fun rfc3339Timestamp(timeMillis: Long): String {
+ synchronized(timestampFormatter) {
+ return timestampFormatter.format(Date(timeMillis))
+ }
+ }
+ }
+
+ private val loggerLock = Any()
+ @Volatile
+ private var logger: Logger? = null
+
+ override fun onCreate(): Boolean {
+ val providerContext = context ?: return false
+ val authority = providerContext.packageName + AUTHORITY_SUFFIX
+ uriMatcher.addURI(authority, PATH_INSERT_LOGS, MATCH_INSERT)
+ return true
+ }
+
+ override fun insert(uri: Uri, values: ContentValues?): Uri? {
+ if (uriMatcher.match(uri) != MATCH_INSERT) {
+ throw IllegalArgumentException("Unknown URI: $uri")
+ }
+ if (values == null) {
+ throw IllegalArgumentException("ContentValues cannot be null")
+ }
+
+ val tag = values.getAsString("tag")
+ val message = values.getAsString("message")
+ val level = values.getAsInteger("level")
+ val timestamp = values.getAsLong("timestamp")
+ if (tag == null || message == null || level == null || timestamp == null) {
+ throw IllegalArgumentException(
+ String.format(
+ Locale.US,
+ "Missing required fields. tag: %s, message: %s, level: %s, timestamp: %s",
+ if (tag != null) "present" else "missing",
+ if (message != null) "present" else "missing",
+ if (level != null) "present" else "missing",
+ if (timestamp != null) "present" else "missing",
+ ),
+ )
+ }
+
+ synchronized(loggerLock) {
+ val record = LogRecord(intToLevel(level), message).apply {
+ loggerName = tag
+ millis = timestamp
+ }
+ getLoggerLocked().log(record)
+ }
+ return uri
+ }
+
+ private fun getLoggerLocked(): Logger {
+ val existing = logger
+ if (existing != null) {
+ return existing
+ }
+ return initializeLoggerLocked()
+ }
+
+ private fun initializeLoggerLocked(): Logger {
+ val initialized = Logger.getLogger(LoggingContentProvider::class.java.name)
+ initialized.level = Level.ALL
+ initialized.useParentHandlers = false
+
+ initialized.handlers.forEach { handler ->
+ try {
+ handler.close()
+ initialized.removeHandler(handler)
+ } catch (error: Exception) {
+ Log.e(TAG, "Error cleaning up handler", error)
+ }
+ }
+
+ try {
+ val providerContext = context ?: throw IllegalStateException("Provider context unavailable")
+ val dataDir = Utils.dataRootDirectory(providerContext)
+ val fileHandler = FileHandler(
+ File(dataDir, LOG_FILE_NAME).absolutePath,
+ LOG_FILE_SIZE,
+ LOG_FILE_COUNT,
+ true,
+ )
+ fileHandler.formatter = JsonFormatter()
+ fileHandler.level = Level.ALL
+ initialized.addHandler(fileHandler)
+ } catch (error: IOException) {
+ Log.e(TAG, "Failed to initialize logger", error)
+ throw IllegalStateException("Logger initialization failed", error)
+ }
+
+ logger = initialized
+ return initialized
+ }
+
+ override fun query(
+ uri: Uri,
+ projection: Array?,
+ selection: String?,
+ selectionArgs: Array?,
+ sortOrder: String?,
+ ): Cursor? {
+ throw UnsupportedOperationException("Not implemented")
+ }
+
+ override fun getType(uri: Uri): String? {
+ throw UnsupportedOperationException("Not implemented")
+ }
+
+ override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int {
+ throw UnsupportedOperationException("Not implemented")
+ }
+
+ override fun update(
+ uri: Uri,
+ values: ContentValues?,
+ selection: String?,
+ selectionArgs: Array?,
+ ): Int {
+ throw UnsupportedOperationException("Not implemented")
+ }
+
+ override fun shutdown() {
+ synchronized(loggerLock) {
+ logger?.handlers?.forEach { handler ->
+ try {
+ handler.close()
+ logger?.removeHandler(handler)
+ } catch (error: Exception) {
+ Log.e(TAG, "Error closing handler during shutdown", error)
+ }
+ }
+ logger = null
+ }
+ super.shutdown()
+ }
+
+ private class JsonFormatter : Formatter() {
+ override fun format(record: LogRecord): String {
+ val payload = JSONObject()
+ .put("tag", record.loggerName)
+ .put("message", record.message)
+ .put("level", levelToString(record.level))
+ .put("timestamp", rfc3339Timestamp(record.millis))
+ return payload.toString() + "\n"
+ }
+ }
+}
diff --git a/modules/expo-psiphon-tunnel-core/android/src/main/java/expo/modules/psiphontunnelcore/LogsMaintenanceWorker.kt b/modules/expo-psiphon-tunnel-core/android/src/main/java/expo/modules/psiphontunnelcore/LogsMaintenanceWorker.kt
new file mode 100644
index 00000000..c4a3284e
--- /dev/null
+++ b/modules/expo-psiphon-tunnel-core/android/src/main/java/expo/modules/psiphontunnelcore/LogsMaintenanceWorker.kt
@@ -0,0 +1,56 @@
+/*
+ * Copyright (c) 2026, Psiphon Inc.
+ * All rights reserved.
+ *
+ * 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, see .
+ */
+package expo.modules.psiphontunnelcore
+
+import android.content.Context
+import androidx.work.ExistingPeriodicWorkPolicy
+import androidx.work.PeriodicWorkRequestBuilder
+import androidx.work.WorkManager
+import androidx.work.Worker
+import androidx.work.WorkerParameters
+import java.util.concurrent.TimeUnit
+
+class LogsMaintenanceWorker(context: Context, params: WorkerParameters) : Worker(context, params) {
+ companion object {
+ private const val TAG_WORK = "LogsMaintenanceWorker"
+ private const val REPEAT_INTERVAL_HOURS = 6L
+ private val DELETE_LOGS_AFTER_MILLIS = TimeUnit.HOURS.toMillis(REPEAT_INTERVAL_HOURS)
+
+ fun schedule(context: Context) {
+ val request = PeriodicWorkRequestBuilder(
+ REPEAT_INTERVAL_HOURS,
+ TimeUnit.HOURS,
+ ).build()
+
+ WorkManager.getInstance(context.applicationContext)
+ .enqueueUniquePeriodicWork(
+ TAG_WORK,
+ ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE,
+ request,
+ )
+ }
+ }
+
+ override fun doWork(): Result {
+ FeedbackWorker.cleanupOldFeedbackFiles(
+ applicationContext,
+ System.currentTimeMillis() - DELETE_LOGS_AFTER_MILLIS,
+ )
+ return Result.success()
+ }
+}
diff --git a/src/components/ConduitSettings.tsx b/src/components/ConduitSettings.tsx
index 42d62b70..b2e29e15 100644
--- a/src/components/ConduitSettings.tsx
+++ b/src/components/ConduitSettings.tsx
@@ -327,6 +327,7 @@ export function ConduitSettings({ inline = false }: { inline?: boolean }) {
isPersonalPairingReady,
selectInproxyParameters,
logErrorToDiagnostic,
+ sendFeedback,
} = useInproxyContext();
const { data: inproxyStatus } = useInproxyStatus();
const { data: conduitName } = useConduitName();
@@ -354,6 +355,8 @@ export function ConduitSettings({ inline = false }: { inline?: boolean }) {
const [localSettingsExpanded, setLocalSettingsExpanded] =
React.useState(false);
const [showReducedSelector, setShowReducedSelector] = React.useState(false);
+ const [showDiagnosticThanks, setShowDiagnosticThanks] =
+ React.useState(false);
const localStationIsRunning = inproxyStatus === "RUNNING";
const settingsPaddedStyle = [
ss.padded,
@@ -387,6 +390,21 @@ export function ConduitSettings({ inline = false }: { inline?: boolean }) {
const [reducedTimeError, setReducedTimeError] = React.useState<
null | "format" | "range"
>(null);
+
+ React.useEffect(() => {
+ if (!showDiagnosticThanks) {
+ return;
+ }
+
+ const timeoutId = setTimeout(() => {
+ setShowDiagnosticThanks(false);
+ }, 5000);
+
+ return () => {
+ clearTimeout(timeoutId);
+ };
+ }, [showDiagnosticThanks]);
+
const reducedTimePattern = React.useMemo(
() => /^([01]\d|2[0-3]):([0-5]\d)$/,
[],
@@ -744,6 +762,11 @@ export function ConduitSettings({ inline = false }: { inline?: boolean }) {
}
}
+ function onSendDiagnosticPress() {
+ void sendFeedback();
+ setShowDiagnosticThanks(true);
+ }
+
function renderSettingsAction({
icon,
label,
@@ -1037,6 +1060,23 @@ export function ConduitSettings({ inline = false }: { inline?: boolean }) {
onPress: () => router.push("/(app)/onboarding"),
})}
+ {Platform.OS !== "ios"
+ ? renderSettingsAction({
+ icon: (
+
+ ),
+ label: showDiagnosticThanks
+ ? t("SENT_THANK_YOU_I18N.string")
+ : t("SEND_DIAGNOSTIC_I18N.string"),
+ onPress: onSendDiagnosticPress,
+ disabled: showDiagnosticThanks,
+ })
+ : null}
+
{/* Legal */}