Skip to content

Commit 489dadc

Browse files
committed
feat(android): add runtime credential configuration for sample app
Add ability to override Measure SDK API key and URL at runtime via a new Configure Credentials screen, and a GitHub Actions workflow to build the sample app with custom credentials. - MeasureConfigurator uses reflection to hot-swap SDK credentials - ConfigureCredentialsActivity persists overrides in SharedPreferences - SampleApp re-applies saved overrides on startup after SDK init - android-build-sample.yml workflow accepts api_key/api_url inputs, validates them, patches the manifest, and uploads the APK artifact
1 parent 219eea6 commit 489dadc

File tree

9 files changed

+423
-0
lines changed

9 files changed

+423
-0
lines changed
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
name: Build Sample App
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
api_key:
7+
description: 'Measure API Key (must start with "msrsh")'
8+
required: true
9+
type: string
10+
api_url:
11+
description: 'Measure API URL'
12+
required: true
13+
type: string
14+
15+
env:
16+
JAVA_VERSION: 21
17+
JAVA_DISTRIBUTION: 'temurin'
18+
19+
jobs:
20+
validate-inputs:
21+
name: Validate inputs
22+
runs-on: ubuntu-latest
23+
timeout-minutes: 1
24+
steps:
25+
- name: Validate API Key
26+
run: |
27+
if [[ ! "${{ inputs.api_key }}" =~ ^msrsh ]]; then
28+
echo "::error::API Key must start with 'msrsh'"
29+
exit 1
30+
fi
31+
- name: Validate API URL
32+
run: |
33+
if [[ ! "${{ inputs.api_url }}" =~ ^https?:// ]]; then
34+
echo "::error::API URL must be a valid URL starting with http:// or https://"
35+
exit 1
36+
fi
37+
38+
build-sample:
39+
name: Build sample APK
40+
needs: validate-inputs
41+
runs-on: ubuntu-latest
42+
defaults:
43+
run:
44+
working-directory: android
45+
permissions:
46+
contents: read
47+
timeout-minutes: 15
48+
steps:
49+
- uses: actions/checkout@v4
50+
- uses: actions/setup-java@v3
51+
with:
52+
distribution: ${{ env.JAVA_DISTRIBUTION }}
53+
java-version: ${{ env.JAVA_VERSION }}
54+
cache: 'gradle'
55+
- name: Set credentials in AndroidManifest.xml
56+
env:
57+
API_KEY: ${{ inputs.api_key }}
58+
API_URL: ${{ inputs.api_url }}
59+
run: |
60+
sed -i '/android:name="sh.measure.android.API_KEY"/{n;s|android:value="[^"]*"|android:value="'"$API_KEY"'"|;}' sample/src/main/AndroidManifest.xml
61+
sed -i '/android:name="sh.measure.android.API_URL"/{n;s|android:value="[^"]*"|android:value="'"$API_URL"'"|;}' sample/src/main/AndroidManifest.xml
62+
- name: Build release APK
63+
run: ./gradlew :sample:assembleRelease --no-daemon --no-parallel --no-configuration-cache --stacktrace --info
64+
- name: Upload APK
65+
uses: actions/upload-artifact@v4
66+
with:
67+
name: sample-release-apk
68+
path: android/sample/build/outputs/apk/release/*.apk
69+
retention-days: 7

android/sample/src/main/AndroidManifest.xml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,9 @@
5959
<activity
6060
android:name=".OkHttpActivity"
6161
android:exported="false" />
62+
<activity
63+
android:name=".ConfigureCredentialsActivity"
64+
android:exported="false" />
6265
<activity
6366
android:name=".ComposeActivity"
6467
android:exported="false" />
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
package sh.measure.sample
2+
3+
import android.content.Context
4+
import android.content.pm.PackageManager
5+
import android.os.Build
6+
import android.os.Bundle
7+
import android.widget.ScrollView
8+
import android.widget.Toast
9+
import androidx.appcompat.app.AppCompatActivity
10+
import androidx.core.view.ViewCompat
11+
import androidx.core.view.WindowInsetsCompat
12+
import androidx.core.view.updatePadding
13+
import com.google.android.material.button.MaterialButton
14+
import com.google.android.material.textfield.TextInputEditText
15+
16+
class ConfigureCredentialsActivity : AppCompatActivity() {
17+
18+
companion object {
19+
private const val PREFS_NAME = "measure_credential_overrides"
20+
private const val KEY_API_URL = "api_url"
21+
private const val KEY_API_KEY = "api_key"
22+
23+
fun getSavedApiUrl(context: Context): String? {
24+
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
25+
.getString(KEY_API_URL, null)
26+
}
27+
28+
fun getSavedApiKey(context: Context): String? {
29+
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
30+
.getString(KEY_API_KEY, null)
31+
}
32+
}
33+
34+
private lateinit var etApiUrl: TextInputEditText
35+
private lateinit var etApiKey: TextInputEditText
36+
37+
override fun onCreate(savedInstanceState: Bundle?) {
38+
super.onCreate(savedInstanceState)
39+
setContentView(R.layout.activity_configure_credentials)
40+
handleEdgeToEdgeDisplay()
41+
42+
etApiUrl = findViewById(R.id.et_api_url)
43+
etApiKey = findViewById(R.id.et_api_key)
44+
45+
loadCurrentValues()
46+
47+
findViewById<MaterialButton>(R.id.btn_save).setOnClickListener {
48+
save()
49+
}
50+
findViewById<MaterialButton>(R.id.btn_reset).setOnClickListener {
51+
resetToDefaults()
52+
}
53+
}
54+
55+
private fun loadCurrentValues() {
56+
val prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
57+
val savedUrl = prefs.getString(KEY_API_URL, null)
58+
val savedKey = prefs.getString(KEY_API_KEY, null)
59+
60+
if (savedUrl != null && savedKey != null) {
61+
etApiUrl.setText(savedUrl)
62+
etApiKey.setText(savedKey)
63+
} else {
64+
val (manifestUrl, manifestKey) = readManifestCredentials()
65+
etApiUrl.setText(manifestUrl)
66+
etApiKey.setText(manifestKey)
67+
}
68+
}
69+
70+
private fun save() {
71+
val apiUrl = etApiUrl.text?.toString()?.trim().orEmpty()
72+
val apiKey = etApiKey.text?.toString()?.trim().orEmpty()
73+
74+
if (apiUrl.isEmpty()) {
75+
etApiUrl.error = "API URL cannot be empty"
76+
return
77+
}
78+
if (!apiKey.startsWith("msrsh")) {
79+
etApiKey.error = "API Key must start with \"msrsh\""
80+
return
81+
}
82+
83+
getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit()
84+
.putString(KEY_API_URL, apiUrl)
85+
.putString(KEY_API_KEY, apiKey)
86+
.apply()
87+
88+
val success = MeasureConfigurator.swapCredentials(apiUrl, apiKey)
89+
if (success) {
90+
Toast.makeText(this, "Credentials saved and applied", Toast.LENGTH_SHORT).show()
91+
} else {
92+
Toast.makeText(this, "Saved, but failed to apply (SDK not initialized?)", Toast.LENGTH_SHORT).show()
93+
}
94+
}
95+
96+
private fun resetToDefaults() {
97+
getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit().clear().apply()
98+
99+
val (manifestUrl, manifestKey) = readManifestCredentials()
100+
etApiUrl.setText(manifestUrl)
101+
etApiKey.setText(manifestKey)
102+
103+
if (manifestUrl != null && manifestKey != null) {
104+
val success = MeasureConfigurator.swapCredentials(manifestUrl, manifestKey)
105+
if (success) {
106+
Toast.makeText(this, "Reset to manifest defaults", Toast.LENGTH_SHORT).show()
107+
} else {
108+
Toast.makeText(this, "Cleared overrides, but failed to apply (SDK not initialized?)", Toast.LENGTH_SHORT).show()
109+
}
110+
} else {
111+
Toast.makeText(this, "Cleared overrides", Toast.LENGTH_SHORT).show()
112+
}
113+
}
114+
115+
private fun readManifestCredentials(): Pair<String?, String?> {
116+
return try {
117+
val appInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
118+
packageManager.getApplicationInfo(
119+
packageName,
120+
PackageManager.ApplicationInfoFlags.of(PackageManager.GET_META_DATA.toLong()),
121+
)
122+
} else {
123+
@Suppress("DEPRECATION")
124+
packageManager.getApplicationInfo(packageName, PackageManager.GET_META_DATA)
125+
}
126+
val metaData = appInfo.metaData
127+
val url = metaData?.getString("sh.measure.android.API_URL")
128+
val key = metaData?.getString("sh.measure.android.API_KEY")
129+
Pair(url, key)
130+
} catch (e: PackageManager.NameNotFoundException) {
131+
Pair(null, null)
132+
}
133+
}
134+
135+
private fun handleEdgeToEdgeDisplay() {
136+
val container = findViewById<ScrollView>(R.id.sv_container)
137+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
138+
ViewCompat.setOnApplyWindowInsetsListener(container) { view, windowInsets ->
139+
val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
140+
view.updatePadding(
141+
left = insets.left,
142+
top = insets.top,
143+
right = insets.right,
144+
bottom = insets.bottom,
145+
)
146+
windowInsets
147+
}
148+
} else {
149+
ViewCompat.setOnApplyWindowInsetsListener(container) { view, windowInsets ->
150+
@Suppress("DEPRECATION") val insets = windowInsets.systemWindowInsets
151+
view.updatePadding(
152+
left = insets.left,
153+
top = insets.top,
154+
right = insets.right,
155+
bottom = insets.bottom,
156+
)
157+
windowInsets
158+
}
159+
}
160+
}
161+
}

android/sample/src/main/java/sh/measure/sample/ExceptionDemoActivity.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ class ExceptionDemoActivity : AppCompatActivity(), MsrShakeListener {
3434
val span = Measure.startSpan("activity.onCreate")
3535
setContentView(R.layout.activity_exception_demo)
3636
handleEdgeToEdgeDisplay()
37+
findViewById<MaterialTextView>(R.id.btn_configure_credentials).setOnClickListener {
38+
startActivity(Intent(this, ConfigureCredentialsActivity::class.java))
39+
}
3740
findViewById<MaterialTextView>(R.id.btn_single_exception).setOnClickListener {
3841
throw IllegalAccessException("This is a new exception")
3942
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package sh.measure.sample
2+
3+
import android.util.Log
4+
import sh.measure.android.Measure
5+
6+
/**
7+
* Uses reflection to hot-swap Measure SDK credentials at runtime.
8+
* This is a sample-app-only utility — no SDK code is modified.
9+
*
10+
* Reflection path:
11+
* Measure (object)
12+
* └─ measure: MeasureInternal (private field)
13+
* └─ measure: MeasureInitializer (private constructor param)
14+
* ├─ networkClient: NetworkClient → call init(newUrl, newApiKey)
15+
* └─ configProvider: ConfigProvider → call setMeasureUrl(newUrl)
16+
*/
17+
object MeasureConfigurator {
18+
private const val TAG = "MeasureConfigurator"
19+
20+
/**
21+
* Hot-swaps the SDK's API URL and API key using reflection.
22+
* Returns true on success, false if reflection fails.
23+
*/
24+
fun swapCredentials(apiUrl: String, apiKey: String): Boolean {
25+
return try {
26+
val initializer = getMeasureInitializer() ?: return false
27+
28+
val networkClient = initializer.javaClass.getDeclaredField("networkClient").apply {
29+
isAccessible = true
30+
}.get(initializer)!!
31+
32+
// Call networkClient.init(apiUrl, apiKey)
33+
networkClient.javaClass.getMethod("init", String::class.java, String::class.java)
34+
.invoke(networkClient, apiUrl, apiKey)
35+
36+
val configProvider = initializer.javaClass.getDeclaredField("configProvider").apply {
37+
isAccessible = true
38+
}.get(initializer)!!
39+
40+
// Call configProvider.setMeasureUrl(apiUrl)
41+
configProvider.javaClass.getMethod("setMeasureUrl", String::class.java)
42+
.invoke(configProvider, apiUrl)
43+
44+
Log.d(TAG, "Credentials swapped successfully")
45+
true
46+
} catch (e: Exception) {
47+
Log.e(TAG, "Failed to swap credentials", e)
48+
false
49+
}
50+
}
51+
52+
/**
53+
* Reads the current baseUrl and apiKey from NetworkClientImpl via reflection.
54+
* Returns (baseUrl, apiKey) or (null, null) if reflection fails.
55+
*/
56+
fun getCurrentCredentials(): Pair<String?, String?> {
57+
return try {
58+
val initializer = getMeasureInitializer() ?: return Pair(null, null)
59+
60+
val networkClient = initializer.javaClass.getDeclaredField("networkClient").apply {
61+
isAccessible = true
62+
}.get(initializer)!!
63+
64+
val baseUrl = networkClient.javaClass.getDeclaredField("baseUrl").apply {
65+
isAccessible = true
66+
}.get(networkClient)?.toString()
67+
68+
val apiKey = networkClient.javaClass.getDeclaredField("apiKey").apply {
69+
isAccessible = true
70+
}.get(networkClient) as? String
71+
72+
Pair(baseUrl, apiKey)
73+
} catch (e: Exception) {
74+
Log.e(TAG, "Failed to read credentials", e)
75+
Pair(null, null)
76+
}
77+
}
78+
79+
private fun getMeasureInitializer(): Any? {
80+
// Measure (companion object) -> measure: MeasureInternal
81+
val measureClass = Measure::class.java
82+
val measureInternalField = measureClass.getDeclaredField("measure").apply {
83+
isAccessible = true
84+
}
85+
val measureInternal = measureInternalField.get(Measure) ?: return null
86+
87+
// MeasureInternal -> measure: MeasureInitializer
88+
val initializerField = measureInternal.javaClass.getDeclaredField("measure").apply {
89+
isAccessible = true
90+
}
91+
return initializerField.get(measureInternal)
92+
}
93+
}

android/sample/src/main/java/sh/measure/sample/SampleApp.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,5 +32,12 @@ class SampleApp : Application() {
3232
.put("float", Float.MAX_VALUE).put("boolean", false)
3333
.build()
3434
Measure.trackEvent(name = "custom-app-start", attributes = attributes)
35+
36+
// Re-apply saved credential overrides (if any) after SDK init
37+
val savedUrl = ConfigureCredentialsActivity.getSavedApiUrl(this)
38+
val savedKey = ConfigureCredentialsActivity.getSavedApiKey(this)
39+
if (savedUrl != null && savedKey != null) {
40+
MeasureConfigurator.swapCredentials(savedUrl, savedKey)
41+
}
3542
}
3643
}

0 commit comments

Comments
 (0)