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 */}