Skip to content

Commit 3385ae2

Browse files
authored
Merge pull request #2444 from bugsnag/main
Main to next sync
2 parents 9ac8295 + d0cb1e0 commit 3385ae2

13 files changed

Lines changed: 285 additions & 112 deletions

File tree

.buildkite/pipeline.full.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ steps:
174174
- "ANDROID_7"
175175
- "ANDROID_8"
176176
depends_on: "fixture-r19"
177-
timeout_in_minutes: 60
177+
timeout_in_minutes: 90
178178
plugins:
179179
artifacts#v1.9.0:
180180
download:

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Changelog
22

3+
## 6.27.0 (2026-09-21)
4+
5+
### Enhancements
6+
7+
* **Remote Config Discard Rules**: Introduced support for dynamic error filtering via Remote Config. You can now define discard rules in the Bugsnag dashboard to filter events without requiring an app update. [#2441](https://github.com/bugsnag/bugsnag-android/pull/2441)
8+
39
## 6.26.1 (2026-07-18)
410

511
### Enhancements

bugsnag-android-core/src/main/java/com/bugsnag/android/Notifier.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import java.io.IOException
77
*/
88
class Notifier @JvmOverloads constructor(
99
var name: String = "Android Bugsnag Notifier",
10-
var version: String = "6.26.1",
10+
var version: String = "6.27.0",
1111
var url: String = "https://bugsnag.com"
1212
) : JsonStream.Streamable {
1313

bugsnag-android-core/src/main/java/com/bugsnag/android/internal/DeliveryPipeline.kt

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import com.bugsnag.android.CallbackState
44
import com.bugsnag.android.DeliveryStatus
55
import com.bugsnag.android.ErrorType
66
import com.bugsnag.android.EventPayload
7-
import com.bugsnag.android.Logger
87
import com.bugsnag.android.RemoteConfig
98
import com.bugsnag.android.internal.remoteconfig.RemoteConfigState
109
import java.util.concurrent.TimeUnit
@@ -14,7 +13,9 @@ internal class DeliveryPipeline(
1413
val remoteConfigState: RemoteConfigState,
1514
val config: ImmutableConfig,
1615
) {
17-
private val logger: Logger get() = config.logger
16+
companion object {
17+
private const val REMOTE_CONFIG_FETCH_TIMEOUT_SECONDS = 5L
18+
}
1819

1920
fun deliverEventPayload(payload: EventPayload): DeliveryStatus? {
2021
if (!retainPayload(payload)) {
@@ -56,14 +57,12 @@ internal class DeliveryPipeline(
5657
}
5758

5859
if (applicableDiscardRule != null) {
59-
logger.d("Discarding event due to remote discardRule: $applicableDiscardRule")
6060
// discarded events are treated as being delivered, as the server would have discarded them
6161
DeliveryStatus.DELIVERED
6262
} else {
6363
null
6464
}
65-
} catch (e: Exception) {
66-
logger.d("Error checking discard status: " + e)
65+
} catch (_: Exception) {
6766
// swallow any RemoteConfig related errors, and favour delivering the payload
6867
null
6968
}
@@ -85,6 +84,6 @@ internal class DeliveryPipeline(
8584

8685
// For non-time-sensitive events, we can wait briefly for a fresh config
8786
// if the cached one is expired or missing.
88-
return remoteConfigState.getRemoteConfig(2, TimeUnit.SECONDS)
87+
return remoteConfigState.getRemoteConfig(REMOTE_CONFIG_FETCH_TIMEOUT_SECONDS, TimeUnit.SECONDS)
8988
}
9089
}

bugsnag-android-core/src/main/java/com/bugsnag/android/internal/remoteconfig/RemoteConfigRequest.kt

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import com.bugsnag.android.internal.HEADER_BUGSNAG_API_KEY
99
import com.bugsnag.android.internal.ImmutableConfig
1010
import com.bugsnag.android.internal.JsonCollectionParser
1111
import com.bugsnag.android.internal.JsonCollectionParser.JsonParseException
12-
import com.bugsnag.android.internal.JsonHelper
1312
import java.net.HttpURLConnection
1413
import java.net.URL
1514
import java.net.URLEncoder
@@ -147,9 +146,7 @@ internal class RemoteConfigRequest(
147146
as? LinkedHashMap<String, Any?>
148147
?: return null
149148

150-
val remoteConfig = RemoteConfig.fromJsonMap(tag, expiryDate, json)
151-
logger.d("Fetched RemoteConfig JSON: ${String(JsonHelper.serialize(remoteConfig), Charsets.UTF_8)}")
152-
return remoteConfig
149+
return RemoteConfig.fromJsonMap(tag, expiryDate, json)
153150
}
154151

155152
private fun configExpiryDate(connection: HttpURLConnection): Date {

examples/sdk-app-example/gradle/libs.versions.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
activityCompose = "1.8.0"
33
agp = "8.10.0"
44
appcompat = "1.6.1"
5-
bugsnag-android = "6.26.1"
5+
bugsnag-android = "6.27.0"
66
bugsnag-gradle = "0.4.0"
77
composeBom = "2024.09.00"
88
coreKtx = "1.16.0"

features/fixtures/mazerunner/app/src/main/java/com/bugsnag/android/mazerunner/MainActivity.kt

Lines changed: 129 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ package com.bugsnag.android.mazerunner
33
import android.app.Activity
44
import android.content.Context
55
import android.content.Intent
6-
import android.content.SharedPreferences
76
import android.os.Build
87
import android.os.Bundle
98
import android.os.Handler
@@ -18,21 +17,23 @@ import java.net.URL
1817
import kotlin.concurrent.thread
1918

2019
private const val MAZE_RUNNER_COMMAND_TIMEOUT_MS = 5000
20+
private const val POLLING_INTERVAL_MS = 1000L
21+
private const val MIN_ERROR_MESSAGE_WIDTH = 1
2122
private const val LEGACY_MAZE_ADDRESS = "bs-local.com:9339"
2223

2324
class MainActivity : Activity(), CommandExecutor {
2425

2526
private companion object {
2627
var hasClearedCommandUUIDForProcess = false
28+
var activeCommandRunnerThread: Thread? = null
2729
}
2830

31+
private val activityInstanceId = Integer.toHexString(System.identityHashCode(this))
2932
private val mainHandler = Handler(Looper.getMainLooper())
3033
private val commandHandler = MazeRunnerCommandHandler(this)
31-
private var commandRunnerThread: Thread? = null
3234

3335
private val apiKeyKey = "BUGSNAG_API_KEY"
3436
private val commandUUIDKey = "MAZE_COMMAND_UUID"
35-
lateinit var prefs: SharedPreferences
3637

3738
var scenario: Scenario? = null
3839
var isActivityRecreate = false
@@ -41,13 +42,14 @@ class MainActivity : Activity(), CommandExecutor {
4142
override fun onCreate(savedInstanceState: Bundle?) {
4243
super.onCreate(savedInstanceState)
4344
this.isActivityRecreate = savedInstanceState != null
44-
log("MainActivity.onCreate called")
45+
log("MainActivity.onCreate called: activity=$activityInstanceId")
4546
requestWindowFeature(Window.FEATURE_NO_TITLE)
4647
setContentView(R.layout.activity_main)
47-
prefs = getPreferences(Context.MODE_PRIVATE)
48+
49+
val prefs = getSharedPreferences("mazerunner", Context.MODE_PRIVATE)
4850

4951
if (!hasClearedCommandUUIDForProcess) {
50-
// clearStoredCommandUUID()
52+
CiLog.info("First onCreate for this process, last command UUID: '${getStoredCommandUUID()}'")
5153
hasClearedCommandUUIDForProcess = true
5254
}
5355

@@ -81,17 +83,24 @@ class MainActivity : Activity(), CommandExecutor {
8183
super.onResume()
8284
log("MainActivity.onResume called")
8385

84-
// Don't start the command runner again if the activity is being recreated,
85-
// as it results in two threads executing commands concurrently and causing flakes.
86-
if (!this.isActivityRecreate) {
87-
startCommandRunner()
88-
}
86+
startCommandRunner()
8987
log("MainActivity.onResume complete")
9088
}
9189

90+
override fun onDestroy() {
91+
CiLog.info("MainActivity.onDestroy called: activity=$activityInstanceId")
92+
stopCommandRunner()
93+
super.onDestroy()
94+
}
95+
9296
private fun setMazeRunnerAddress() {
93-
mazeAddress = MazeRunnerAddressReader.readFromConfig(applicationContext, timeout = false)
94-
if (!mazeAddress.isNullOrBlank()) {
97+
var address = MazeRunnerAddressReader.readFromConfig(applicationContext, timeout = false)
98+
if (address == "local:9339") {
99+
address = "bs-local.com:9339"
100+
}
101+
102+
if (!address.isNullOrBlank()) {
103+
mazeAddress = address
95104
CiLog.info("Maze Runner address set from config file: $mazeAddress")
96105
return
97106
}
@@ -108,64 +117,113 @@ class MainActivity : Activity(), CommandExecutor {
108117
return
109118
}
110119

111-
val refreshedMazeAddress = MazeRunnerAddressReader.readFromConfig(applicationContext, timeout = false)
120+
var refreshedMazeAddress = MazeRunnerAddressReader.readFromConfig(applicationContext, timeout = false)
121+
if (refreshedMazeAddress == "local:9339") {
122+
refreshedMazeAddress = "bs-local.com:9339"
123+
}
124+
112125
if (!refreshedMazeAddress.isNullOrBlank()) {
113126
mazeAddress = refreshedMazeAddress
114127
CiLog.info("Maze Runner address refreshed from config file: $mazeAddress")
115128
}
116129
}
117130

118131
override fun setStoredCommandUUID(commandUUID: String) {
119-
with(prefs.edit()) {
120-
putString(commandUUIDKey, commandUUID)
121-
commit()
122-
}
123-
CiLog.info("lastCommandUUID set to: $commandUUID")
132+
getSharedPreferences("mazerunner", Context.MODE_PRIVATE).edit().putString(commandUUIDKey, commandUUID).commit()
133+
CiLog.info("lastCommandUUID set to: $commandUUID (activity=$activityInstanceId)")
124134
}
125135

126136
override fun clearStoredCommandUUID() {
127-
with(prefs.edit()) {
128-
remove(commandUUIDKey)
129-
commit()
130-
}
131-
CiLog.info("lastCommandUUID set to empty")
137+
getSharedPreferences("mazerunner", Context.MODE_PRIVATE).edit().remove(commandUUIDKey).commit()
138+
CiLog.info("lastCommandUUID cleared (activity=$activityInstanceId)")
132139
}
133140

134-
private fun getStoredCommandUUID(): String? {
135-
return prefs.getString(commandUUIDKey, "").orEmpty()
141+
private fun getStoredCommandUUID(): String {
142+
return getSharedPreferences("mazerunner", Context.MODE_PRIVATE).getString(commandUUIDKey, "").orEmpty()
136143
}
137144

138145
// Starts a thread to poll for Maze Runner actions to perform
139146
@Synchronized
140147
private fun startCommandRunner() {
141-
if (commandRunnerThread?.isAlive == true) {
142-
CiLog.info("Maze Runner command runner already active")
148+
if (activeCommandRunnerThread?.isAlive == true) {
149+
CiLog.info("Maze Runner command runner already active (current activity=$activityInstanceId)")
143150
return
144151
}
145152

146-
val runner = thread(start = false) {
153+
CiLog.info(
154+
"Starting command runner: " +
155+
"activity=$activityInstanceId, " +
156+
"thread=${Thread.currentThread().name}, " +
157+
"uuid='${getStoredCommandUUID()}'"
158+
)
159+
160+
val runner = thread(
161+
start = false,
162+
name = "maze-command-$activityInstanceId"
163+
) {
147164
try {
165+
CiLog.info(
166+
"Command runner thread started: " +
167+
"activity=$activityInstanceId, " +
168+
"thread=${Thread.currentThread().name}"
169+
)
170+
148171
if (mazeAddress == null) setMazeRunnerAddress()
149172
runCommandRunnerLoop()
150173
} finally {
151-
synchronized(this@MainActivity) {
152-
if (commandRunnerThread === Thread.currentThread()) {
153-
commandRunnerThread = null
174+
CiLog.info(
175+
"Command runner thread finished: " +
176+
"activity=$activityInstanceId, " +
177+
"thread=${Thread.currentThread().name}"
178+
)
179+
180+
synchronized(MainActivity::class.java) {
181+
if (activeCommandRunnerThread === Thread.currentThread()) {
182+
activeCommandRunnerThread = null
154183
}
155184
}
156185
}
157186
}
158187

159-
commandRunnerThread = runner
188+
activeCommandRunnerThread = runner
160189
runner.start()
161190
}
162191

192+
private fun stopCommandRunner() {
193+
val runner = synchronized(MainActivity::class.java) {
194+
val r = activeCommandRunnerThread
195+
activeCommandRunnerThread = null
196+
r
197+
}
198+
199+
if (runner?.isAlive == true) {
200+
CiLog.info(
201+
"Stopping command runner: " +
202+
"activity=$activityInstanceId, " +
203+
"thread=${runner.name}"
204+
)
205+
runner.interrupt()
206+
}
207+
}
208+
163209
private fun runCommandRunnerLoop() {
164210
var polling = true
165-
while (polling) {
166-
Thread.sleep(1000)
167-
polling = fetchAndHandleNextCommand()
211+
212+
while (polling && !Thread.currentThread().isInterrupted) {
213+
try {
214+
Thread.sleep(POLLING_INTERVAL_MS)
215+
polling = fetchAndHandleNextCommand()
216+
} catch (interrupted: InterruptedException) {
217+
CiLog.info(
218+
"Command runner interrupted: " +
219+
"activity=$activityInstanceId"
220+
)
221+
Thread.currentThread().interrupt()
222+
polling = false
223+
}
168224
}
225+
226+
CiLog.info("Maze Runner command runner stopped")
169227
}
170228

171229
private fun fetchAndHandleNextCommand(): Boolean {
@@ -209,28 +267,45 @@ class MainActivity : Activity(), CommandExecutor {
209267
}
210268

211269
private fun readCommand(): String {
212-
val commandUrl = "http://$mazeAddress/command?after=${getStoredCommandUUID().orEmpty()}"
213-
CiLog.info("Requesting Maze Runner command from: $commandUrl")
270+
val storedUUID = getStoredCommandUUID()
271+
val commandUrl = "http://$mazeAddress/command?after=$storedUUID"
272+
273+
CiLog.info(
274+
"Requesting Maze Runner command: " +
275+
"activity=$activityInstanceId, " +
276+
"thread=${Thread.currentThread().name}, " +
277+
"uuid='$storedUUID', " +
278+
"url=$commandUrl"
279+
)
280+
214281
val urlConnection = URL(commandUrl).openConnection() as HttpURLConnection
215282
urlConnection.connectTimeout = MAZE_RUNNER_COMMAND_TIMEOUT_MS
216283
urlConnection.readTimeout = MAZE_RUNNER_COMMAND_TIMEOUT_MS
217284
try {
218-
return urlConnection.inputStream.use { it.reader().readText() }
219-
} catch (ioe: IOException) {
220-
CiLog.error("Read of Maze Runner command failed", ioe)
221-
try {
222-
val errorMessage = urlConnection.errorStream.use { it.reader().readText() }
223-
CiLog.error(
224-
"Failed to GET $commandUrl (HTTP ${urlConnection.responseCode} " +
225-
"${urlConnection.responseMessage}):\n" +
226-
"${"-".repeat(errorMessage.width)}\n" +
227-
"$errorMessage\n" +
228-
"-".repeat(errorMessage.width)
229-
)
230-
} catch (e: Exception) {
231-
log("Failed to retrieve error message from connection", e)
285+
val responseCode = urlConnection.responseCode
286+
if (responseCode == HttpURLConnection.HTTP_OK) {
287+
return urlConnection.inputStream.use { it.reader().readText() }
288+
}
289+
290+
if (responseCode == HttpURLConnection.HTTP_BAD_REQUEST) {
291+
val rejectedUuid = getStoredCommandUUID()
292+
CiLog.warn("Maze Runner returned 400 Bad Request for command UUID: $rejectedUuid")
293+
294+
clearStoredCommandUUID()
295+
CiLog.info("Command UUID after clearing: '${getStoredCommandUUID()}'")
232296
}
233297

298+
val errorMessage = urlConnection.errorStream?.use { it.reader().readText() }.orEmpty()
299+
CiLog.error(
300+
"Failed to GET $commandUrl (HTTP $responseCode " +
301+
"${urlConnection.responseMessage}):\n" +
302+
"${"-".repeat(errorMessage.width.coerceAtLeast(MIN_ERROR_MESSAGE_WIDTH))}\n" +
303+
"$errorMessage\n" +
304+
"-".repeat(errorMessage.width.coerceAtLeast(MIN_ERROR_MESSAGE_WIDTH))
305+
)
306+
throw IOException("Failed to GET $commandUrl (HTTP $responseCode)")
307+
} catch (ioe: IOException) {
308+
CiLog.error("Read of Maze Runner command failed", ioe)
234309
throw ioe
235310
}
236311
}
@@ -273,6 +348,7 @@ class MainActivity : Activity(), CommandExecutor {
273348
// Clear persistent data (used to stop scenarios bleeding into each other)
274349
override fun clearPersistentData() {
275350
CiLog.info("Clearing persistent data")
351+
scenario = null
276352
PersistentData(applicationContext).clear()
277353
}
278354

@@ -291,6 +367,7 @@ class MainActivity : Activity(), CommandExecutor {
291367
else -> "a35a2a72bd230ac0aa0f52715bbdc6aa"
292368
}
293369

370+
val prefs = getSharedPreferences("mazerunner", Context.MODE_PRIVATE)
294371
if (manualMode) {
295372
log("Running in manual mode with API key: $apiKey")
296373
prefs.setStoredApiKey(apiKeyKey, apiKey)

0 commit comments

Comments
 (0)