Skip to content

Commit 367ec0c

Browse files
ADFA-2787 Handle aborted onboarding flow correctly without raising an error (#941)
* Handle aborted onboarding flow correctly without raising an error * Stop and restart web server cleanly so we can re-use the ip address and port * Separate handling for socket exceptions based on being either client-driven or server-driven
1 parent 69a1d78 commit 367ec0c

File tree

6 files changed

+81
-16
lines changed

6 files changed

+81
-16
lines changed

app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ class MainActivity : EdgeToEdgeIDEActivity() {
7272
private var _binding: ActivityMainBinding? = null
7373
private val analyticsManager: IAnalyticsManager by inject()
7474
private var feedbackButtonManager: FeedbackButtonManager? = null
75+
private var webServer: WebServer? = null
7576

7677
private val onBackPressedCallback =
7778
object : OnBackPressedCallback(true) {
@@ -328,15 +329,19 @@ class MainActivity : EdgeToEdgeIDEActivity() {
328329
try {
329330
val dbFile = Environment.DOC_DB
330331
log.info("Starting WebServer - using database file from: {}", dbFile.absolutePath)
331-
val webServer = WebServer(ServerConfig(databasePath = dbFile.absolutePath))
332-
webServer.start()
332+
val server = WebServer(ServerConfig(databasePath = dbFile.absolutePath))
333+
webServer = server
334+
server.start()
333335
} catch (e: Exception) {
334336
log.error("Failed to start WebServer", e)
337+
} finally {
338+
webServer = null
335339
}
336340
}
337341
}
338342

339343
override fun onDestroy() {
344+
webServer?.stop()
340345
ITemplateProvider.getInstance().release()
341346
super.onDestroy()
342347
_binding = null

app/src/main/java/com/itsaky/androidide/assets/AssetsInstallationHelper.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import java.util.UUID
3333
import java.util.concurrent.ConcurrentHashMap
3434
import java.util.zip.ZipException
3535
import java.util.zip.ZipInputStream
36+
import kotlin.coroutines.cancellation.CancellationException
3637
import kotlin.io.path.ExperimentalPathApi
3738
import kotlin.io.path.deleteRecursively
3839
import kotlin.math.pow
@@ -77,6 +78,9 @@ object AssetsInstallationHelper {
7778

7879
if (result.isFailure) {
7980
val e = result.exceptionOrNull()
81+
if (e is CancellationException) {
82+
throw e
83+
}
8084
val msg = e?.message ?: "Failed to install assets"
8185
logger.error("Failed to install assets", e)
8286
onProgress(Progress(msg))

app/src/main/java/com/itsaky/androidide/assets/BundledAssetsInstaller.kt

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ import java.io.IOException
2727
import java.nio.file.Files
2828
import java.nio.file.Path
2929
import java.util.zip.ZipInputStream
30+
import kotlin.io.path.ExperimentalPathApi
31+
import kotlin.io.path.deleteRecursively
3032

3133
data object BundledAssetsInstaller : BaseAssetsInstaller() {
3234
private val logger = LoggerFactory.getLogger(BundledAssetsInstaller::class.java)
@@ -38,6 +40,7 @@ data object BundledAssetsInstaller : BaseAssetsInstaller() {
3840
stagingDir: Path,
3941
): Unit = Unit
4042

43+
@OptIn(ExperimentalPathApi::class)
4144
@WorkerThread
4245
override suspend fun doInstall(
4346
context: Context,
@@ -52,10 +55,14 @@ data object BundledAssetsInstaller : BaseAssetsInstaller() {
5255
ANDROID_SDK_ZIP,
5356
LOCAL_MAVEN_REPO_ARCHIVE_ZIP_NAME,
5457
-> {
58+
val destDir = destinationDirForArchiveEntry(entryName).toPath()
59+
if (Files.exists(destDir)) {
60+
destDir.deleteRecursively()
61+
}
62+
Files.createDirectories(destDir)
5563
val assetPath = ToolsManager.getCommonAsset("$entryName.br")
5664
assets.open(assetPath).use { assetStream ->
5765
BrotliInputStream(assetStream).use { srcStream ->
58-
val destDir = destinationDirForArchiveEntry(entryName).toPath()
5966
AssetsInstallationHelper.extractZipToDir(srcStream, destDir)
6067
}
6168
}
@@ -164,8 +171,11 @@ data object BundledAssetsInstaller : BaseAssetsInstaller() {
164171
logger.debug("Extracting plugin artifacts from '{}'", entryName)
165172
val pluginDir = Environment.PLUGIN_API_JAR.parentFile
166173
?: throw IllegalStateException("Plugin API parent directory is null")
167-
pluginDir.mkdirs()
168174
val pluginDirPath = pluginDir.toPath().toAbsolutePath().normalize()
175+
if (Files.exists(pluginDirPath)) {
176+
pluginDirPath.deleteRecursively()
177+
}
178+
Files.createDirectories(pluginDirPath)
169179

170180
val assetPath = ToolsManager.getCommonAsset("$entryName.br")
171181
assets.open(assetPath).use { assetStream ->

app/src/main/java/com/itsaky/androidide/assets/SplitAssetsInstaller.kt

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import java.io.FileNotFoundException
2121
import java.nio.file.Files
2222
import java.nio.file.Path
2323
import java.util.zip.ZipFile
24+
import kotlin.io.path.ExperimentalPathApi
25+
import kotlin.io.path.deleteRecursively
2426
import java.util.zip.ZipInputStream
2527
import kotlin.system.measureTimeMillis
2628

@@ -40,6 +42,7 @@ data object SplitAssetsInstaller : BaseAssetsInstaller() {
4042
zipFile = ZipFile(Environment.SPLIT_ASSETS_ZIP)
4143
}
4244

45+
@OptIn(ExperimentalPathApi::class)
4346
@WorkerThread
4447
override suspend fun doInstall(
4548
context: Context,
@@ -60,6 +63,10 @@ data object SplitAssetsInstaller : BaseAssetsInstaller() {
6063
GRADLE_API_NAME_JAR_ZIP,
6164
-> {
6265
val destDir = destinationDirForArchiveEntry(entry.name).toPath()
66+
if (Files.exists(destDir)) {
67+
destDir.deleteRecursively()
68+
}
69+
Files.createDirectories(destDir)
6370
logger.debug("Extracting '{}' to dir: {}", entry.name, destDir)
6471
AssetsInstallationHelper.extractZipToDir(zipInput, destDir)
6572
logger.debug("Completed extracting '{}' to dir: {}", entry.name, destDir)
@@ -128,8 +135,11 @@ data object SplitAssetsInstaller : BaseAssetsInstaller() {
128135
logger.debug("Extracting plugin artifacts from '{}'", entry.name)
129136
val pluginDir = Environment.PLUGIN_API_JAR.parentFile
130137
?: throw IllegalStateException("Plugin API parent directory is null")
131-
pluginDir.mkdirs()
132138
val pluginDirPath = pluginDir.toPath().toAbsolutePath().normalize()
139+
if (Files.exists(pluginDirPath)) {
140+
pluginDirPath.deleteRecursively()
141+
}
142+
Files.createDirectories(pluginDirPath)
133143

134144
ZipInputStream(zipInput).use { pluginZip ->
135145
var pluginEntry = pluginZip.nextEntry

app/src/main/java/com/itsaky/androidide/localWebServer/WebServer.kt

Lines changed: 42 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import java.io.ByteArrayInputStream
88
import java.io.File
99
import java.io.InputStreamReader
1010
import java.io.PrintWriter
11+
import java.net.InetSocketAddress
1112
import java.net.ServerSocket
1213
import java.net.Socket
1314
import java.nio.file.Files
@@ -74,8 +75,20 @@ FROM LastChange
7475
}
7576
}
7677

78+
/**
79+
* Stops the server by closing the listening socket. Safe to call from any thread.
80+
* Causes [start]'s accept loop to exit. No-op if not started or already stopped.
81+
*/
82+
fun stop() {
83+
if (!::serverSocket.isInitialized) return
84+
try {
85+
serverSocket.close()
86+
} catch (e: Exception) {
87+
log.debug("Error closing server socket: {}", e.message)
88+
}
89+
}
90+
7791
fun start() {
78-
lateinit var clientSocket: Socket
7992
try {
8093
log.debug("Starting WebServer on {}, port {}, debugEnabled={}, debugEnablePath='{}', debugDatabasePath='{}'.",
8194
config.bindName, config.port, debugEnabled, config.debugEnablePath, config.debugDatabasePath)
@@ -92,24 +105,42 @@ FROM LastChange
92105
// NEW FEATURE: Log database metadata when debug is enabled
93106
if (debugEnabled) logDatabaseLastChanged()
94107

95-
serverSocket = ServerSocket(config.port, 0, java.net.InetAddress.getByName(config.bindName))
108+
serverSocket = ServerSocket().apply { setReuseAddress(true) }
109+
serverSocket.bind(InetSocketAddress(config.bindName, config.port))
96110
log.info("WebServer started successfully.")
97111

98112
while (true) {
113+
var clientSocket: Socket? = null
99114
try {
100-
clientSocket = serverSocket.accept()
101-
if (debugEnabled) log.debug("Returned from socket accept().")
102-
handleClient(clientSocket)
103-
} catch (e: Exception) {
104-
log.error("Error handling client: {}", e.message)
105115
try {
106-
val writer = PrintWriter(clientSocket.getOutputStream(), true)
107-
sendError(writer, 500, "Internal Server Error 1")
116+
clientSocket = serverSocket.accept()
117+
if (debugEnabled) log.debug("Returned from socket accept().")
118+
} catch (e: java.net.SocketException) {
119+
if (e.message?.contains("Closed", ignoreCase = true) == true) {
120+
if (debugEnabled) log.debug("WebServer socket closed, shutting down.")
121+
break
122+
}
123+
log.error("Accept failed: {}", e.message)
124+
continue
125+
}
126+
try {
127+
clientSocket?.let { handleClient(it) }
108128
} catch (e: Exception) {
109-
log.error("Error sending error response: {}", e.message)
129+
if (e is java.net.SocketException && e.message?.contains("Closed", ignoreCase = true) == true) {
130+
if (debugEnabled) log.debug("Client disconnected: {}", e.message)
131+
} else {
132+
log.error("Error handling client: {}", e.message)
133+
clientSocket?.let { socket ->
134+
try {
135+
sendError(PrintWriter(socket.getOutputStream(), true), 500, "Internal Server Error 1")
136+
} catch (e2: Exception) {
137+
log.error("Error sending error response: {}", e2.message)
138+
}
139+
}
140+
}
110141
}
111142
} finally {
112-
clientSocket.close() // TODO: What if the client socket isn't open? How to check? --DS, 22-Jul-2025
143+
clientSocket?.close()
113144
}
114145
}
115146
} catch (e: Exception) {

app/src/main/java/com/itsaky/androidide/viewmodel/InstallationViewModel.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import com.itsaky.androidide.viewmodel.InstallationState.InstallationGranted
2020
import com.itsaky.androidide.viewmodel.InstallationState.InstallationPending
2121
import com.itsaky.androidide.viewmodel.InstallationState.Installing
2222
import io.sentry.Sentry
23+
import kotlin.coroutines.cancellation.CancellationException
2324
import kotlinx.coroutines.Dispatchers
2425
import kotlinx.coroutines.flow.MutableSharedFlow
2526
import kotlinx.coroutines.flow.MutableStateFlow
@@ -103,6 +104,10 @@ class InstallationViewModel : ViewModel() {
103104
}
104105
}
105106
} catch (e: Exception) {
107+
if (e is CancellationException) {
108+
_state.update { InstallationPending }
109+
throw e
110+
}
106111
Sentry.captureException(e)
107112
log.error("IDE setup installation failed", e)
108113
val errorMsg = e.message ?: context.getString(R.string.unknown_error)

0 commit comments

Comments
 (0)