@craft-native/android 0.0.72
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +606 -0
- package/dist/index.js +410 -0
- package/dist/index.js.map +10 -0
- package/package.json +38 -0
- package/templates/AndroidManifest.xml.template +43 -0
- package/templates/CraftBridge.kt.template +3880 -0
- package/templates/CraftBridgeExtensions.kt.template +383 -0
- package/templates/CraftHealthConnect.kt.template +200 -0
- package/templates/CraftHealthConnectStub.kt.template +28 -0
- package/templates/CraftWidgetProvider.kt.template +246 -0
- package/templates/LocationRecordingService.kt.template +160 -0
- package/templates/MainActivity.kt.template +230 -0
- package/templates/build.gradle.kts.app.template +102 -0
- package/templates/build.gradle.kts.project.template +6 -0
- package/templates/fastlane/Appfile +25 -0
- package/templates/fastlane/Fastfile +192 -0
- package/templates/fastlane/Gemfile +7 -0
- package/templates/github-workflow-android.yml +248 -0
- package/templates/github-workflow-test.yml +329 -0
- package/templates/proguard-rules.pro.template +9 -0
- package/templates/settings.gradle.kts.template +18 -0
- package/templates/test-bridges.html +1463 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
package {{PACKAGE_NAME}}
|
|
2
|
+
|
|
3
|
+
import android.app.PendingIntent
|
|
4
|
+
import android.appwidget.AppWidgetManager
|
|
5
|
+
import android.appwidget.AppWidgetProvider
|
|
6
|
+
import android.content.BroadcastReceiver
|
|
7
|
+
import android.content.ComponentName
|
|
8
|
+
import android.content.Context
|
|
9
|
+
import android.content.Intent
|
|
10
|
+
import android.content.IntentFilter
|
|
11
|
+
import android.os.Build
|
|
12
|
+
import android.widget.RemoteViews
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Craft Widget Provider
|
|
16
|
+
*
|
|
17
|
+
* A customizable home screen widget that displays data from your Craft app.
|
|
18
|
+
* The widget can show title, subtitle, value, and an icon.
|
|
19
|
+
*
|
|
20
|
+
* To use widgets:
|
|
21
|
+
* 1. Add this provider to your AndroidManifest.xml
|
|
22
|
+
* 2. Create widget layout in res/layout/craft_widget.xml
|
|
23
|
+
* 3. Create widget info in res/xml/craft_widget_info.xml
|
|
24
|
+
* 4. Call window.craft.widget.update() from your web app
|
|
25
|
+
*/
|
|
26
|
+
class CraftWidgetProvider : AppWidgetProvider() {
|
|
27
|
+
|
|
28
|
+
companion object {
|
|
29
|
+
const val ACTION_WIDGET_UPDATE = "{{PACKAGE_NAME}}.WIDGET_UPDATE"
|
|
30
|
+
const val PREFS_NAME = "craft_widget_prefs"
|
|
31
|
+
const val PREF_TITLE = "widget_title"
|
|
32
|
+
const val PREF_SUBTITLE = "widget_subtitle"
|
|
33
|
+
const val PREF_VALUE = "widget_value"
|
|
34
|
+
const val PREF_ICON = "widget_icon"
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Update all widgets programmatically
|
|
38
|
+
*/
|
|
39
|
+
fun updateAllWidgets(context: Context) {
|
|
40
|
+
val intent = Intent(context, CraftWidgetProvider::class.java).apply {
|
|
41
|
+
action = AppWidgetManager.ACTION_APPWIDGET_UPDATE
|
|
42
|
+
val appWidgetManager = AppWidgetManager.getInstance(context)
|
|
43
|
+
val widgetComponent = ComponentName(context, CraftWidgetProvider::class.java)
|
|
44
|
+
val appWidgetIds = appWidgetManager.getAppWidgetIds(widgetComponent)
|
|
45
|
+
putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, appWidgetIds)
|
|
46
|
+
}
|
|
47
|
+
context.sendBroadcast(intent)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
override fun onUpdate(
|
|
52
|
+
context: Context,
|
|
53
|
+
appWidgetManager: AppWidgetManager,
|
|
54
|
+
appWidgetIds: IntArray
|
|
55
|
+
) {
|
|
56
|
+
// Update each widget instance
|
|
57
|
+
for (appWidgetId in appWidgetIds) {
|
|
58
|
+
updateAppWidget(context, appWidgetManager, appWidgetId)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
override fun onReceive(context: Context, intent: Intent) {
|
|
63
|
+
super.onReceive(context, intent)
|
|
64
|
+
|
|
65
|
+
// Handle custom update action from the app
|
|
66
|
+
if (intent.action == ACTION_WIDGET_UPDATE) {
|
|
67
|
+
val appWidgetManager = AppWidgetManager.getInstance(context)
|
|
68
|
+
val widgetComponent = ComponentName(context, CraftWidgetProvider::class.java)
|
|
69
|
+
val appWidgetIds = appWidgetManager.getAppWidgetIds(widgetComponent)
|
|
70
|
+
|
|
71
|
+
for (appWidgetId in appWidgetIds) {
|
|
72
|
+
updateAppWidget(context, appWidgetManager, appWidgetId)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
override fun onEnabled(context: Context) {
|
|
78
|
+
// Register broadcast receiver for widget updates
|
|
79
|
+
val filter = IntentFilter(ACTION_WIDGET_UPDATE)
|
|
80
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
81
|
+
context.registerReceiver(widgetUpdateReceiver, filter, Context.RECEIVER_EXPORTED)
|
|
82
|
+
} else {
|
|
83
|
+
context.registerReceiver(widgetUpdateReceiver, filter)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
override fun onDisabled(context: Context) {
|
|
88
|
+
// Unregister broadcast receiver
|
|
89
|
+
try {
|
|
90
|
+
context.unregisterReceiver(widgetUpdateReceiver)
|
|
91
|
+
} catch (_: IllegalArgumentException) {
|
|
92
|
+
// Receiver not registered
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private val widgetUpdateReceiver = object : BroadcastReceiver() {
|
|
97
|
+
override fun onReceive(context: Context, intent: Intent) {
|
|
98
|
+
if (intent.action == ACTION_WIDGET_UPDATE) {
|
|
99
|
+
updateAllWidgets(context)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private fun updateAppWidget(
|
|
105
|
+
context: Context,
|
|
106
|
+
appWidgetManager: AppWidgetManager,
|
|
107
|
+
appWidgetId: Int
|
|
108
|
+
) {
|
|
109
|
+
// Get data from shared preferences
|
|
110
|
+
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
|
111
|
+
val title = prefs.getString(PREF_TITLE, "Craft Widget") ?: "Craft Widget"
|
|
112
|
+
val subtitle = prefs.getString(PREF_SUBTITLE, "") ?: ""
|
|
113
|
+
val value = prefs.getString(PREF_VALUE, "") ?: ""
|
|
114
|
+
val iconName = prefs.getString(PREF_ICON, null)
|
|
115
|
+
|
|
116
|
+
// Create remote views
|
|
117
|
+
val views = RemoteViews(context.packageName, R.layout.craft_widget)
|
|
118
|
+
|
|
119
|
+
// Set text values
|
|
120
|
+
views.setTextViewText(R.id.widget_title, title)
|
|
121
|
+
views.setTextViewText(R.id.widget_subtitle, subtitle)
|
|
122
|
+
views.setTextViewText(R.id.widget_value, value)
|
|
123
|
+
|
|
124
|
+
// Set visibility for subtitle
|
|
125
|
+
views.setViewVisibility(
|
|
126
|
+
R.id.widget_subtitle,
|
|
127
|
+
if (subtitle.isEmpty()) android.view.View.GONE else android.view.View.VISIBLE
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
// Set visibility for value
|
|
131
|
+
views.setViewVisibility(
|
|
132
|
+
R.id.widget_value,
|
|
133
|
+
if (value.isEmpty()) android.view.View.GONE else android.view.View.VISIBLE
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
// Set icon if provided
|
|
137
|
+
if (iconName != null) {
|
|
138
|
+
val iconResId = context.resources.getIdentifier(
|
|
139
|
+
iconName,
|
|
140
|
+
"drawable",
|
|
141
|
+
context.packageName
|
|
142
|
+
)
|
|
143
|
+
if (iconResId != 0) {
|
|
144
|
+
views.setImageViewResource(R.id.widget_icon, iconResId)
|
|
145
|
+
views.setViewVisibility(R.id.widget_icon, android.view.View.VISIBLE)
|
|
146
|
+
} else {
|
|
147
|
+
views.setViewVisibility(R.id.widget_icon, android.view.View.GONE)
|
|
148
|
+
}
|
|
149
|
+
} else {
|
|
150
|
+
views.setViewVisibility(R.id.widget_icon, android.view.View.GONE)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Create click intent to launch main app
|
|
154
|
+
val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName)
|
|
155
|
+
if (launchIntent != null) {
|
|
156
|
+
val pendingIntent = PendingIntent.getActivity(
|
|
157
|
+
context,
|
|
158
|
+
0,
|
|
159
|
+
launchIntent,
|
|
160
|
+
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
|
161
|
+
)
|
|
162
|
+
views.setOnClickPendingIntent(R.id.widget_container, pendingIntent)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Update the widget
|
|
166
|
+
appWidgetManager.updateAppWidget(appWidgetId, views)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Widget Layout (res/layout/craft_widget.xml):
|
|
172
|
+
*
|
|
173
|
+
* <?xml version="1.0" encoding="utf-8"?>
|
|
174
|
+
* <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
|
175
|
+
* android:id="@+id/widget_container"
|
|
176
|
+
* android:layout_width="match_parent"
|
|
177
|
+
* android:layout_height="match_parent"
|
|
178
|
+
* android:orientation="vertical"
|
|
179
|
+
* android:padding="16dp"
|
|
180
|
+
* android:background="@drawable/widget_background">
|
|
181
|
+
*
|
|
182
|
+
* <ImageView
|
|
183
|
+
* android:id="@+id/widget_icon"
|
|
184
|
+
* android:layout_width="32dp"
|
|
185
|
+
* android:layout_height="32dp"
|
|
186
|
+
* android:visibility="gone" />
|
|
187
|
+
*
|
|
188
|
+
* <TextView
|
|
189
|
+
* android:id="@+id/widget_title"
|
|
190
|
+
* android:layout_width="wrap_content"
|
|
191
|
+
* android:layout_height="wrap_content"
|
|
192
|
+
* android:textSize="16sp"
|
|
193
|
+
* android:textStyle="bold"
|
|
194
|
+
* android:textColor="@color/widget_text_primary" />
|
|
195
|
+
*
|
|
196
|
+
* <TextView
|
|
197
|
+
* android:id="@+id/widget_subtitle"
|
|
198
|
+
* android:layout_width="wrap_content"
|
|
199
|
+
* android:layout_height="wrap_content"
|
|
200
|
+
* android:textSize="12sp"
|
|
201
|
+
* android:textColor="@color/widget_text_secondary" />
|
|
202
|
+
*
|
|
203
|
+
* <TextView
|
|
204
|
+
* android:id="@+id/widget_value"
|
|
205
|
+
* android:layout_width="wrap_content"
|
|
206
|
+
* android:layout_height="wrap_content"
|
|
207
|
+
* android:textSize="32sp"
|
|
208
|
+
* android:textStyle="bold"
|
|
209
|
+
* android:textColor="@color/widget_accent"
|
|
210
|
+
* android:layout_marginTop="8dp" />
|
|
211
|
+
*
|
|
212
|
+
* </LinearLayout>
|
|
213
|
+
*/
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Widget Info (res/xml/craft_widget_info.xml):
|
|
217
|
+
*
|
|
218
|
+
* <?xml version="1.0" encoding="utf-8"?>
|
|
219
|
+
* <appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
|
220
|
+
* android:minWidth="110dp"
|
|
221
|
+
* android:minHeight="110dp"
|
|
222
|
+
* android:targetCellWidth="2"
|
|
223
|
+
* android:targetCellHeight="2"
|
|
224
|
+
* android:updatePeriodMillis="1800000"
|
|
225
|
+
* android:initialLayout="@layout/craft_widget"
|
|
226
|
+
* android:resizeMode="horizontal|vertical"
|
|
227
|
+
* android:widgetCategory="home_screen"
|
|
228
|
+
* android:previewImage="@drawable/widget_preview"
|
|
229
|
+
* android:description="@string/widget_description" />
|
|
230
|
+
*/
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* AndroidManifest.xml entries needed:
|
|
234
|
+
*
|
|
235
|
+
* <receiver
|
|
236
|
+
* android:name=".CraftWidgetProvider"
|
|
237
|
+
* android:exported="true">
|
|
238
|
+
* <intent-filter>
|
|
239
|
+
* <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
|
240
|
+
* <action android:name="{{PACKAGE_NAME}}.WIDGET_UPDATE" />
|
|
241
|
+
* </intent-filter>
|
|
242
|
+
* <meta-data
|
|
243
|
+
* android:name="android.appwidget.provider"
|
|
244
|
+
* android:resource="@xml/craft_widget_info" />
|
|
245
|
+
* </receiver>
|
|
246
|
+
*/
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
package {{PACKAGE_NAME}}
|
|
2
|
+
|
|
3
|
+
import android.Manifest
|
|
4
|
+
import android.app.NotificationChannel
|
|
5
|
+
import android.app.NotificationManager
|
|
6
|
+
import android.app.Service
|
|
7
|
+
import android.content.Context
|
|
8
|
+
import android.content.Intent
|
|
9
|
+
import android.content.pm.PackageManager
|
|
10
|
+
import android.os.Build
|
|
11
|
+
import android.os.IBinder
|
|
12
|
+
import android.os.Looper
|
|
13
|
+
import androidx.core.app.NotificationCompat
|
|
14
|
+
import androidx.core.content.ContextCompat
|
|
15
|
+
import com.google.android.gms.location.LocationCallback
|
|
16
|
+
import com.google.android.gms.location.LocationRequest
|
|
17
|
+
import com.google.android.gms.location.LocationResult
|
|
18
|
+
import com.google.android.gms.location.LocationServices
|
|
19
|
+
import com.google.android.gms.location.Priority
|
|
20
|
+
import org.json.JSONArray
|
|
21
|
+
import org.json.JSONObject
|
|
22
|
+
import java.io.File
|
|
23
|
+
|
|
24
|
+
object CraftLocationRecordingStore {
|
|
25
|
+
private const val PREFS = "craft_location_recording"
|
|
26
|
+
private const val FILE = "craft-location-recording.jsonl"
|
|
27
|
+
|
|
28
|
+
private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
|
29
|
+
private fun file(context: Context) = File(context.filesDir, FILE)
|
|
30
|
+
|
|
31
|
+
@Synchronized
|
|
32
|
+
fun start(context: Context, id: String, startedAt: Long) {
|
|
33
|
+
file(context).writeText("")
|
|
34
|
+
prefs(context).edit()
|
|
35
|
+
.putString("id", id)
|
|
36
|
+
.putBoolean("active", true)
|
|
37
|
+
.putBoolean("paused", false)
|
|
38
|
+
.putLong("startedAt", startedAt)
|
|
39
|
+
.apply()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
@Synchronized
|
|
43
|
+
fun setPaused(context: Context, paused: Boolean) {
|
|
44
|
+
prefs(context).edit().putBoolean("paused", paused).apply()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
@Synchronized
|
|
48
|
+
fun stop(context: Context) {
|
|
49
|
+
prefs(context).edit().putBoolean("active", false).putBoolean("paused", false).apply()
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
fun isActive(context: Context) = prefs(context).getBoolean("active", false)
|
|
53
|
+
fun isPaused(context: Context) = prefs(context).getBoolean("paused", false)
|
|
54
|
+
|
|
55
|
+
@Synchronized
|
|
56
|
+
fun append(context: Context, location: android.location.Location) {
|
|
57
|
+
if (!isActive(context) || isPaused(context)) return
|
|
58
|
+
val json = JSONObject().apply {
|
|
59
|
+
put("latitude", location.latitude)
|
|
60
|
+
put("longitude", location.longitude)
|
|
61
|
+
put("accuracy", location.accuracy)
|
|
62
|
+
put("altitude", location.altitude)
|
|
63
|
+
put("speed", location.speed)
|
|
64
|
+
put("heading", location.bearing)
|
|
65
|
+
put("timestamp", location.time)
|
|
66
|
+
}
|
|
67
|
+
file(context).appendText("$json\n")
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
@Synchronized
|
|
71
|
+
fun locations(context: Context): JSONArray {
|
|
72
|
+
val result = JSONArray()
|
|
73
|
+
val source = file(context)
|
|
74
|
+
if (!source.exists()) return result
|
|
75
|
+
source.forEachLine { line ->
|
|
76
|
+
if (line.isNotBlank()) runCatching { result.put(JSONObject(line)) }
|
|
77
|
+
}
|
|
78
|
+
return result
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
fun state(context: Context, includeLocations: Boolean = false): JSONObject {
|
|
82
|
+
val values = prefs(context)
|
|
83
|
+
val locations = locations(context)
|
|
84
|
+
return JSONObject().apply {
|
|
85
|
+
put("id", values.getString("id", null))
|
|
86
|
+
put("active", values.getBoolean("active", false))
|
|
87
|
+
put("paused", values.getBoolean("paused", false))
|
|
88
|
+
put("startedAt", values.getLong("startedAt", 0).takeIf { it > 0 } ?: JSONObject.NULL)
|
|
89
|
+
put("sampleCount", locations.length())
|
|
90
|
+
if (includeLocations) put("locations", locations)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
class LocationRecordingService : Service() {
|
|
96
|
+
private val client by lazy { LocationServices.getFusedLocationProviderClient(this) }
|
|
97
|
+
private var callback: LocationCallback? = null
|
|
98
|
+
|
|
99
|
+
override fun onCreate() {
|
|
100
|
+
super.onCreate()
|
|
101
|
+
val manager = getSystemService(NotificationManager::class.java)
|
|
102
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
103
|
+
manager.createNotificationChannel(NotificationChannel(CHANNEL, "Activity recording", NotificationManager.IMPORTANCE_LOW))
|
|
104
|
+
}
|
|
105
|
+
val notification = NotificationCompat.Builder(this, CHANNEL)
|
|
106
|
+
.setSmallIcon(android.R.drawable.ic_menu_mylocation)
|
|
107
|
+
.setContentTitle("Recording your activity")
|
|
108
|
+
.setContentText("WildLoop is securely saving your route")
|
|
109
|
+
.setOngoing(true)
|
|
110
|
+
.setSilent(true)
|
|
111
|
+
.build()
|
|
112
|
+
startForeground(NOTIFICATION_ID, notification)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
|
116
|
+
when (intent?.action) {
|
|
117
|
+
ACTION_STOP -> {
|
|
118
|
+
stopUpdates()
|
|
119
|
+
stopSelf()
|
|
120
|
+
return START_NOT_STICKY
|
|
121
|
+
}
|
|
122
|
+
else -> startUpdates()
|
|
123
|
+
}
|
|
124
|
+
return if (CraftLocationRecordingStore.isActive(this)) START_STICKY else START_NOT_STICKY
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private fun startUpdates() {
|
|
128
|
+
if (callback != null || !CraftLocationRecordingStore.isActive(this)) return
|
|
129
|
+
if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) return
|
|
130
|
+
val request = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 5_000)
|
|
131
|
+
.setMinUpdateIntervalMillis(2_000)
|
|
132
|
+
.setMaxUpdateDelayMillis(10_000)
|
|
133
|
+
.build()
|
|
134
|
+
callback = object : LocationCallback() {
|
|
135
|
+
override fun onLocationResult(result: LocationResult) {
|
|
136
|
+
for (location in result.locations) CraftLocationRecordingStore.append(this@LocationRecordingService, location)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
client.requestLocationUpdates(request, callback!!, Looper.getMainLooper())
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private fun stopUpdates() {
|
|
143
|
+
callback?.let { client.removeLocationUpdates(it) }
|
|
144
|
+
callback = null
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
override fun onDestroy() {
|
|
148
|
+
stopUpdates()
|
|
149
|
+
super.onDestroy()
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
override fun onBind(intent: Intent?): IBinder? = null
|
|
153
|
+
|
|
154
|
+
companion object {
|
|
155
|
+
const val ACTION_START = "craft.location.START"
|
|
156
|
+
const val ACTION_STOP = "craft.location.STOP"
|
|
157
|
+
private const val CHANNEL = "craft_location_recording"
|
|
158
|
+
private const val NOTIFICATION_ID = 7001
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
package {{PACKAGE_NAME}}
|
|
2
|
+
|
|
3
|
+
import android.annotation.SuppressLint
|
|
4
|
+
import android.os.Bundle
|
|
5
|
+
import android.content.Intent
|
|
6
|
+
import android.net.Uri
|
|
7
|
+
import android.webkit.WebResourceRequest
|
|
8
|
+
import android.webkit.WebResourceError
|
|
9
|
+
import android.webkit.WebResourceResponse
|
|
10
|
+
import android.webkit.WebChromeClient
|
|
11
|
+
import android.webkit.WebSettings
|
|
12
|
+
import android.webkit.WebView
|
|
13
|
+
import android.webkit.WebViewClient
|
|
14
|
+
import androidx.appcompat.app.AppCompatActivity
|
|
15
|
+
import androidx.core.view.WindowCompat
|
|
16
|
+
import androidx.webkit.WebViewAssetLoader
|
|
17
|
+
import org.json.JSONObject
|
|
18
|
+
import java.io.FileNotFoundException
|
|
19
|
+
import java.net.URLConnection
|
|
20
|
+
|
|
21
|
+
class MainActivity : AppCompatActivity() {
|
|
22
|
+
private lateinit var webView: WebView
|
|
23
|
+
private lateinit var craftBridge: CraftBridge
|
|
24
|
+
private val devServerUrl: String? by lazy { loadDevServerUrl() }
|
|
25
|
+
private val trustedOrigins: Set<String> by lazy { loadTrustedOrigins() }
|
|
26
|
+
private val hasBundledFallback: Boolean by lazy { configJson()?.optBoolean("hasBundledFallback", false) == true }
|
|
27
|
+
private val bundledAssetLoader: WebViewAssetLoader by lazy {
|
|
28
|
+
WebViewAssetLoader.Builder()
|
|
29
|
+
.setDomain(BUNDLED_APP_HOST)
|
|
30
|
+
.addPathHandler("/", RouteAwareAssetPathHandler())
|
|
31
|
+
.build()
|
|
32
|
+
}
|
|
33
|
+
private var loadedBundledFallback = false
|
|
34
|
+
|
|
35
|
+
@SuppressLint("SetJavaScriptEnabled")
|
|
36
|
+
override fun onCreate(savedInstanceState: Bundle?) {
|
|
37
|
+
super.onCreate(savedInstanceState)
|
|
38
|
+
|
|
39
|
+
// Enable edge-to-edge
|
|
40
|
+
WindowCompat.setDecorFitsSystemWindows(window, false)
|
|
41
|
+
|
|
42
|
+
setContentView(R.layout.activity_main)
|
|
43
|
+
|
|
44
|
+
webView = findViewById(R.id.webview)
|
|
45
|
+
craftBridge = CraftBridge(this, webView, CraftHealthConnect(this, webView))
|
|
46
|
+
handleIncomingDeepLink(intent, dispatch = false)
|
|
47
|
+
|
|
48
|
+
setupWebView()
|
|
49
|
+
loadContent()
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
@SuppressLint("SetJavaScriptEnabled")
|
|
53
|
+
private fun setupWebView() {
|
|
54
|
+
webView.settings.apply {
|
|
55
|
+
javaScriptEnabled = true
|
|
56
|
+
domStorageEnabled = true
|
|
57
|
+
allowFileAccess = false
|
|
58
|
+
allowContentAccess = false
|
|
59
|
+
allowFileAccessFromFileURLs = false
|
|
60
|
+
allowUniversalAccessFromFileURLs = false
|
|
61
|
+
mediaPlaybackRequiresUserGesture = false
|
|
62
|
+
mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW
|
|
63
|
+
cacheMode = WebSettings.LOAD_DEFAULT
|
|
64
|
+
setSupportZoom(false)
|
|
65
|
+
builtInZoomControls = false
|
|
66
|
+
displayZoomControls = false
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
webView.webViewClient = object : WebViewClient() {
|
|
70
|
+
override fun onPageFinished(view: WebView?, url: String?) {
|
|
71
|
+
super.onPageFinished(view, url)
|
|
72
|
+
if (url != null && isTrustedUrl(url)) craftBridge.injectBridge()
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest?): WebResourceResponse? {
|
|
76
|
+
val url = request?.url ?: return null
|
|
77
|
+
return bundledAssetLoader.shouldInterceptRequest(url)
|
|
78
|
+
?: super.shouldInterceptRequest(view, request)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
|
|
82
|
+
val url = request?.url?.toString() ?: return true
|
|
83
|
+
if (isTrustedUrl(url)) return false
|
|
84
|
+
runCatching { startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) }
|
|
85
|
+
return true
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
override fun onReceivedError(view: WebView?, request: WebResourceRequest?, error: WebResourceError?) {
|
|
89
|
+
super.onReceivedError(view, request, error)
|
|
90
|
+
if (request?.isForMainFrame == true && hasBundledFallback && !loadedBundledFallback) {
|
|
91
|
+
loadedBundledFallback = true
|
|
92
|
+
webView.loadUrl(BUNDLED_APP_URL)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
webView.webChromeClient = WebChromeClient()
|
|
98
|
+
|
|
99
|
+
// Add JavaScript interface
|
|
100
|
+
webView.addJavascriptInterface(craftBridge, "CraftAndroid")
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private fun loadContent() {
|
|
104
|
+
// Check for dev server URL in config
|
|
105
|
+
val remoteUrl = devServerUrl
|
|
106
|
+
if (remoteUrl != null) {
|
|
107
|
+
webView.loadUrl(remoteUrl)
|
|
108
|
+
} else {
|
|
109
|
+
// Load from assets
|
|
110
|
+
webView.loadUrl(BUNDLED_APP_URL)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private inner class RouteAwareAssetPathHandler : WebViewAssetLoader.PathHandler {
|
|
115
|
+
override fun handle(path: String): WebResourceResponse? {
|
|
116
|
+
val cleanPath = Uri.decode(path).substringBefore('?').trimStart('/')
|
|
117
|
+
if (cleanPath.split('/').any { it == ".." }) return null
|
|
118
|
+
val candidates = when {
|
|
119
|
+
cleanPath.isBlank() -> listOf("index.html")
|
|
120
|
+
cleanPath.endsWith('/') -> listOf("${cleanPath}index.html")
|
|
121
|
+
cleanPath.substringAfterLast('/').contains('.') -> listOf(cleanPath)
|
|
122
|
+
else -> listOf("${cleanPath}.html", "${cleanPath}/index.html")
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
for (candidate in candidates) {
|
|
126
|
+
try {
|
|
127
|
+
val stream = assets.open(candidate)
|
|
128
|
+
val mimeType = URLConnection.guessContentTypeFromName(candidate)
|
|
129
|
+
?: when (candidate.substringAfterLast('.', "").lowercase()) {
|
|
130
|
+
"js", "mjs" -> "text/javascript"
|
|
131
|
+
"css" -> "text/css"
|
|
132
|
+
"json", "webmanifest" -> "application/json"
|
|
133
|
+
"svg" -> "image/svg+xml"
|
|
134
|
+
else -> "application/octet-stream"
|
|
135
|
+
}
|
|
136
|
+
val encoding = if (mimeType.startsWith("text/") || mimeType.contains("javascript") || mimeType.contains("json")) "UTF-8" else null
|
|
137
|
+
return WebResourceResponse(mimeType, encoding, stream)
|
|
138
|
+
} catch (_: FileNotFoundException) {
|
|
139
|
+
// Try the next clean-route candidate.
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return null
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private fun handleIncomingDeepLink(source: Intent?, dispatch: Boolean) {
|
|
147
|
+
val url = source?.data?.toString() ?: return
|
|
148
|
+
craftBridge.setInitialURL(url)
|
|
149
|
+
if (dispatch) craftBridge.dispatchDeepLink(url)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private fun configJson(): JSONObject? {
|
|
153
|
+
return try {
|
|
154
|
+
assets.open("craft.config.json").bufferedReader().use { JSONObject(it.readText()) }
|
|
155
|
+
} catch (_: Exception) {
|
|
156
|
+
null
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private fun loadDevServerUrl(): String? {
|
|
161
|
+
return try {
|
|
162
|
+
configJson()?.optString("devServerURL")?.takeIf { it.isNotBlank() }
|
|
163
|
+
} catch (e: Exception) {
|
|
164
|
+
null
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private fun loadTrustedOrigins(): Set<String> {
|
|
169
|
+
val origins = mutableSetOf<String>()
|
|
170
|
+
val config = configJson()
|
|
171
|
+
val configured = config?.optJSONArray("trustedOrigins")
|
|
172
|
+
if (configured != null) {
|
|
173
|
+
for (index in 0 until configured.length()) origins.add(configured.optString(index))
|
|
174
|
+
}
|
|
175
|
+
devServerUrl?.let { url ->
|
|
176
|
+
val uri = Uri.parse(url)
|
|
177
|
+
origins.add("${uri.scheme}://${uri.authority}")
|
|
178
|
+
}
|
|
179
|
+
return origins
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
private fun isTrustedUrl(value: String): Boolean {
|
|
183
|
+
val uri = Uri.parse(value)
|
|
184
|
+
if (uri.scheme == "https" && uri.host == BUNDLED_APP_HOST) return true
|
|
185
|
+
val origin = "${uri.scheme}://${uri.authority}"
|
|
186
|
+
val localDevelopment = uri.scheme == "http" && uri.host in setOf("localhost", "127.0.0.1", "10.0.2.2")
|
|
187
|
+
return (uri.scheme == "https" || localDevelopment) && origin in trustedOrigins
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
override fun onNewIntent(intent: Intent) {
|
|
191
|
+
super.onNewIntent(intent)
|
|
192
|
+
setIntent(intent)
|
|
193
|
+
handleIncomingDeepLink(intent, dispatch = true)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
override fun onBackPressed() {
|
|
197
|
+
if (webView.canGoBack()) {
|
|
198
|
+
webView.goBack()
|
|
199
|
+
} else {
|
|
200
|
+
super.onBackPressed()
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
override fun onResume() {
|
|
205
|
+
super.onResume()
|
|
206
|
+
webView.onResume()
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
override fun onPause() {
|
|
210
|
+
webView.onPause()
|
|
211
|
+
super.onPause()
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
override fun onDestroy() {
|
|
215
|
+
craftBridge.close()
|
|
216
|
+
webView.destroy()
|
|
217
|
+
super.onDestroy()
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
@Deprecated("Delegates the Health Connect permission contract on Android 13 and earlier")
|
|
221
|
+
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
|
222
|
+
if (craftBridge.onActivityResult(requestCode, resultCode, data)) return
|
|
223
|
+
super.onActivityResult(requestCode, resultCode, data)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
companion object {
|
|
227
|
+
private const val BUNDLED_APP_HOST = "appassets.androidplatform.net"
|
|
228
|
+
private const val BUNDLED_APP_URL = "https://appassets.androidplatform.net/"
|
|
229
|
+
}
|
|
230
|
+
}
|