Skip to content

Commit 0fda461

Browse files
committed
Improving map JS security
1 parent 9eeed38 commit 0fda461

File tree

3 files changed

+185
-194
lines changed

3 files changed

+185
-194
lines changed
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
package org.wordpress.android.ui.newstats.components
2+
3+
import android.annotation.SuppressLint
4+
import android.graphics.Color
5+
import android.net.http.SslError
6+
import android.util.Base64
7+
import android.webkit.SslErrorHandler
8+
import android.webkit.WebResourceError
9+
import android.webkit.WebResourceRequest
10+
import android.webkit.WebSettings
11+
import android.webkit.WebView
12+
import android.webkit.WebViewClient
13+
import androidx.compose.foundation.shape.RoundedCornerShape
14+
import androidx.compose.material3.MaterialTheme
15+
import androidx.compose.runtime.Composable
16+
import androidx.compose.runtime.remember
17+
import androidx.compose.ui.Modifier
18+
import androidx.compose.ui.draw.clip
19+
import androidx.compose.ui.graphics.toArgb
20+
import androidx.compose.ui.platform.LocalContext
21+
import androidx.compose.ui.res.stringResource
22+
import androidx.compose.ui.unit.dp
23+
import androidx.compose.ui.viewinterop.AndroidView
24+
import androidx.core.content.ContextCompat
25+
import org.wordpress.android.R
26+
import java.util.Locale
27+
28+
private const val RGB_MASK = 0xFFFFFF
29+
30+
/**
31+
* A WebView component for displaying Google GeoChart maps.
32+
*
33+
* Security measures implemented (following the pattern from MapViewHolder in old stats):
34+
* - Custom WebViewClient with error handlers for graceful degradation
35+
* - Handles SSL errors by hiding the view (does not proceed with insecure connections)
36+
* - Handles resource errors gracefully
37+
* - Loads HTML content as base64 data (not from external URLs)
38+
* - JavaScript is enabled only for Google Charts functionality
39+
*
40+
* @param mapData The map data string in Google GeoChart format
41+
* @param modifier Modifier for the WebView container
42+
* @param onError Optional callback when an error occurs loading the map
43+
*/
44+
@SuppressLint("SetJavaScriptEnabled")
45+
@Composable
46+
fun StatsGeoChartWebView(
47+
mapData: String,
48+
modifier: Modifier = Modifier,
49+
onError: (() -> Unit)? = null
50+
) {
51+
val context = LocalContext.current
52+
// Use the same colors as old stats implementation (MapViewHolder pattern)
53+
val colorLow = ContextCompat.getColor(context, R.color.stats_map_activity_low).toHexString()
54+
val colorHigh = ContextCompat.getColor(context, R.color.stats_map_activity_high).toHexString()
55+
val emptyColor = ContextCompat.getColor(context, R.color.stats_map_activity_empty).toHexString()
56+
val backgroundColor = MaterialTheme.colorScheme.surface.toHexString()
57+
val viewsLabel = stringResource(R.string.stats_countries_views_header)
58+
59+
val htmlPage = remember(mapData, colorLow, colorHigh, backgroundColor, emptyColor, viewsLabel) {
60+
buildGeoChartHtml(mapData, viewsLabel, colorLow, colorHigh, emptyColor, backgroundColor)
61+
}
62+
63+
AndroidView(
64+
modifier = modifier.clip(RoundedCornerShape(8.dp)),
65+
factory = { ctx ->
66+
WebView(ctx).apply {
67+
setBackgroundColor(Color.TRANSPARENT)
68+
69+
// Set up WebViewClient with error handlers (matching old stats MapViewHolder pattern)
70+
webViewClient = createWebViewClientWithErrorHandlers(onError)
71+
72+
// Settings matching the old stats implementation
73+
settings.javaScriptEnabled = true
74+
settings.cacheMode = WebSettings.LOAD_NO_CACHE
75+
}
76+
},
77+
update = { webView ->
78+
val base64Html = Base64.encodeToString(htmlPage.toByteArray(), Base64.DEFAULT)
79+
webView.loadData(base64Html, "text/html; charset=UTF-8", "base64")
80+
}
81+
)
82+
}
83+
84+
/**
85+
* Creates a WebViewClient with error handlers for graceful degradation.
86+
* This follows the same pattern as MapViewHolder in the old stats implementation.
87+
*/
88+
private fun createWebViewClientWithErrorHandlers(onError: (() -> Unit)?): WebViewClient {
89+
return object : WebViewClient() {
90+
override fun onReceivedError(
91+
view: WebView?,
92+
request: WebResourceRequest?,
93+
error: WebResourceError?
94+
) {
95+
super.onReceivedError(view, request, error)
96+
// Trigger error callback for main frame errors
97+
if (request?.isForMainFrame == true) {
98+
onError?.invoke()
99+
}
100+
}
101+
102+
override fun onReceivedSslError(
103+
view: WebView?,
104+
handler: SslErrorHandler?,
105+
error: SslError?
106+
) {
107+
// Do not proceed on SSL errors - this is the secure default behavior
108+
super.onReceivedSslError(view, handler, error)
109+
onError?.invoke()
110+
}
111+
}
112+
}
113+
114+
@Suppress("LongParameterList")
115+
private fun buildGeoChartHtml(
116+
mapData: String,
117+
viewsLabel: String,
118+
colorLow: String,
119+
colorHigh: String,
120+
emptyColor: String,
121+
backgroundColor: String
122+
): String {
123+
return """
124+
<html>
125+
<head>
126+
<script type="text/javascript" src="https://www.gstatic.com/charts/loader.js"></script>
127+
<script type="text/javascript">
128+
google.charts.load('current', {'packages':['geochart']});
129+
google.charts.setOnLoadCallback(drawRegionsMap);
130+
function drawRegionsMap() {
131+
var data = google.visualization.arrayToDataTable([
132+
['Country', '$viewsLabel'],$mapData
133+
]);
134+
var options = {
135+
keepAspectRatio: true,
136+
region: 'world',
137+
colorAxis: { colors: ['#$colorLow', '#$colorHigh'] },
138+
datalessRegionColor: '#$emptyColor',
139+
backgroundColor: '#$backgroundColor',
140+
legend: 'none',
141+
enableRegionInteractivity: false
142+
};
143+
var chart = new google.visualization.GeoChart(
144+
document.getElementById('regions_div')
145+
);
146+
chart.draw(data, options);
147+
}
148+
</script>
149+
</head>
150+
<body style="margin: 0px;">
151+
<div id="regions_div" style="width: 100%; height: 100%;"></div>
152+
</body>
153+
</html>
154+
""".trimIndent()
155+
}
156+
157+
private fun androidx.compose.ui.graphics.Color.toHexString(): String {
158+
return String.format(Locale.US, "%06X", (this.toArgb() and RGB_MASK))
159+
}
160+
161+
private fun Int.toHexString(): String {
162+
return String.format(Locale.US, "%06X", (this and RGB_MASK))
163+
}

WordPress/src/main/java/org/wordpress/android/ui/newstats/countries/CountriesCard.kt

Lines changed: 11 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,5 @@
11
package org.wordpress.android.ui.newstats.countries
22

3-
import android.annotation.SuppressLint
4-
import android.graphics.Color
5-
import android.util.Base64
6-
import android.webkit.WebSettings
7-
import android.webkit.WebView
83
import androidx.compose.animation.core.LinearEasing
94
import androidx.compose.animation.core.RepeatMode
105
import androidx.compose.animation.core.animateFloat
@@ -35,27 +30,27 @@ import androidx.compose.material3.Icon
3530
import androidx.compose.material3.MaterialTheme
3631
import androidx.compose.material3.Text
3732
import androidx.compose.runtime.Composable
38-
import androidx.compose.runtime.remember
3933
import androidx.compose.ui.Alignment
4034
import androidx.compose.ui.Modifier
4135
import androidx.compose.ui.draw.clip
4236
import androidx.compose.ui.geometry.Offset
4337
import androidx.compose.ui.graphics.Brush
44-
import androidx.compose.ui.graphics.toArgb
4538
import androidx.compose.ui.res.stringResource
4639
import androidx.compose.ui.text.font.FontWeight
4740
import androidx.compose.ui.text.style.TextOverflow
4841
import androidx.compose.ui.tooling.preview.Preview
4942
import androidx.compose.ui.unit.dp
50-
import androidx.compose.ui.viewinterop.AndroidView
5143
import coil.compose.AsyncImage
44+
import org.wordpress.android.ui.newstats.components.StatsGeoChartWebView
5245
import org.wordpress.android.R
5346
import org.wordpress.android.ui.compose.theme.AppThemeM3
47+
import androidx.compose.ui.platform.LocalContext
48+
import androidx.core.content.ContextCompat
49+
import androidx.compose.ui.graphics.Color as ComposeColor
5450
import org.wordpress.android.ui.newstats.StatsColors
5551
import org.wordpress.android.ui.newstats.util.formatStatValue
5652
import java.util.Locale
5753

58-
private const val RGB_MASK = 0xFFFFFF
5954
private val CardCornerRadius = 10.dp
6055
private val CardPadding = 16.dp
6156
private val CardMargin = 16.dp
@@ -259,88 +254,26 @@ private fun EmptyContent() {
259254
}
260255
}
261256

262-
@SuppressLint("SetJavaScriptEnabled")
263257
@Composable
264258
private fun CountryMap(
265259
mapData: String,
266260
modifier: Modifier = Modifier
267261
) {
268-
val colorLow = MaterialTheme.colorScheme.primary.blendWithWhite(0.2f).toHexString()
269-
val colorHigh = MaterialTheme.colorScheme.primary.toHexString()
270-
val backgroundColor = MaterialTheme.colorScheme.surface.toHexString()
271-
val emptyColor = MaterialTheme.colorScheme.surfaceVariant.toHexString()
272-
val viewsLabel = stringResource(R.string.stats_countries_views_header)
273-
274-
val htmlPage = remember(mapData, colorLow, colorHigh, backgroundColor, emptyColor, viewsLabel) {
275-
buildMapHtml(mapData, viewsLabel, colorLow, colorHigh, emptyColor, backgroundColor)
276-
}
277-
278-
AndroidView(
279-
modifier = modifier.clip(RoundedCornerShape(8.dp)),
280-
factory = { ctx ->
281-
WebView(ctx).apply {
282-
setBackgroundColor(Color.TRANSPARENT)
283-
settings.javaScriptEnabled = true
284-
settings.cacheMode = WebSettings.LOAD_NO_CACHE
285-
}
286-
},
287-
update = { webView ->
288-
val base64Html = Base64.encodeToString(htmlPage.toByteArray(), Base64.DEFAULT)
289-
webView.loadData(base64Html, "text/html; charset=UTF-8", "base64")
290-
}
262+
StatsGeoChartWebView(
263+
mapData = mapData,
264+
modifier = modifier
291265
)
292266
}
293267

294-
@Suppress("LongParameterList")
295-
private fun buildMapHtml(
296-
mapData: String,
297-
viewsLabel: String,
298-
colorLow: String,
299-
colorHigh: String,
300-
emptyColor: String,
301-
backgroundColor: String
302-
): String {
303-
return """
304-
<html>
305-
<head>
306-
<script type="text/javascript" src="https://www.gstatic.com/charts/loader.js"></script>
307-
<script type="text/javascript">
308-
google.charts.load('current', {'packages':['geochart']});
309-
google.charts.setOnLoadCallback(drawRegionsMap);
310-
function drawRegionsMap() {
311-
var data = google.visualization.arrayToDataTable([
312-
['Country', '$viewsLabel'],$mapData
313-
]);
314-
var options = {
315-
keepAspectRatio: true,
316-
region: 'world',
317-
colorAxis: { colors: ['#$colorLow', '#$colorHigh'] },
318-
datalessRegionColor: '#$emptyColor',
319-
backgroundColor: '#$backgroundColor',
320-
legend: 'none',
321-
enableRegionInteractivity: false
322-
};
323-
var chart = new google.visualization.GeoChart(
324-
document.getElementById('regions_div')
325-
);
326-
chart.draw(data, options);
327-
}
328-
</script>
329-
</head>
330-
<body style="margin: 0px;">
331-
<div id="regions_div" style="width: 100%; height: 100%;"></div>
332-
</body>
333-
</html>
334-
""".trimIndent()
335-
}
336-
337268
@Composable
338269
private fun MapLegend(
339270
minViews: Long,
340271
maxViews: Long
341272
) {
342-
val colorLow = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)
343-
val colorHigh = MaterialTheme.colorScheme.primary
273+
val context = LocalContext.current
274+
// Use the same colors as the map (stats color resources)
275+
val colorLow = ComposeColor(ContextCompat.getColor(context, R.color.stats_map_activity_low))
276+
val colorHigh = ComposeColor(ContextCompat.getColor(context, R.color.stats_map_activity_high))
344277

345278
Row(
346279
modifier = Modifier.fillMaxWidth(),
@@ -525,25 +458,6 @@ private fun ErrorContent(
525458
}
526459
}
527460

528-
private fun androidx.compose.ui.graphics.Color.toHexString(): String {
529-
val argb = this.toArgb()
530-
return String.format(Locale.US, "%06X", argb and RGB_MASK)
531-
}
532-
533-
/**
534-
* Blends this color with white based on the given ratio.
535-
* Ratio of 0.0 returns white, ratio of 1.0 returns the original color.
536-
*/
537-
private fun androidx.compose.ui.graphics.Color.blendWithWhite(ratio: Float): androidx.compose.ui.graphics.Color {
538-
val white = androidx.compose.ui.graphics.Color.White
539-
return androidx.compose.ui.graphics.Color(
540-
red = white.red + (this.red - white.red) * ratio,
541-
green = white.green + (this.green - white.green) * ratio,
542-
blue = white.blue + (this.blue - white.blue) * ratio,
543-
alpha = 1f
544-
)
545-
}
546-
547461
// Previews
548462
@Preview(showBackground = true)
549463
@Composable

0 commit comments

Comments
 (0)