Skip to content

Commit 5c632eb

Browse files
felipeeriassvillar
authored andcommitted
Remote experiences in New Tab
Download the list of remote experiences in JSON format and display it in the New Tab view. The list is based on the content of out existing homepage, with some small changes to make it easier to process and display. The process is very similar to how props.json is managed. This change also adds a utility class to download and display remote images asynchronously.
1 parent 0710e56 commit 5c632eb

File tree

13 files changed

+381
-8
lines changed

13 files changed

+381
-8
lines changed

app/build.gradle

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ android {
125125
buildConfigField "Float", "DEFAULT_WINDOW_DISTANCE", "0.0f"
126126
buildConfigField "Boolean", "ENABLE_PAGE_ZOOM", "false"
127127
buildConfigField "Boolean", "USE_SOUNDPOOL", "true"
128+
buildConfigField 'String', 'EXPERIENCES_ENDPOINT', '"https://igalia.github.io/wolvic/experiences.json"'
128129

129130
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
130131
resValue 'string', 'app_name', 'Wolvic'
@@ -601,6 +602,7 @@ dependencies {
601602
implementation libs.androidcomponents.concept.fetch
602603
implementation libs.androidcomponents.lib.fetch
603604
implementation libs.androidcomponents.lib.dataprotect
605+
implementation libs.androidcomponents.support.images
604606
implementation libs.androidcomponents.support.rustlog
605607
implementation libs.androidcomponents.support.rusthttp
606608
implementation libs.androidcomponents.support.webextensions

app/src/common/shared/com/igalia/wolvic/browser/SettingsStore.java

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
import java.util.List;
4646
import java.util.Locale;
4747
import java.util.Map;
48+
import java.util.function.Consumer;
4849

4950
import mozilla.components.concept.fetch.Request;
5051
import mozilla.components.concept.fetch.Response;
@@ -212,19 +213,27 @@ public void initModel(@NonNull Context context) {
212213
String json = mPrefs.getString(mContext.getString(R.string.settings_key_remote_props), null);
213214
mSettingsViewModel.setProps(json);
214215

216+
String experiencesJson = mPrefs.getString(mContext.getString(R.string.settings_key_remote_experiences), null);
217+
mSettingsViewModel.setExperiences(experiencesJson);
218+
215219
mSettingsViewModel.refresh();
216220

217-
update();
221+
updateRemoteContent(BuildConfig.PROPS_ENDPOINT,
222+
R.string.settings_key_remote_props,
223+
mSettingsViewModel::setProps);
224+
updateRemoteContent(BuildConfig.EXPERIENCES_ENDPOINT,
225+
R.string.settings_key_remote_experiences,
226+
mSettingsViewModel::setExperiences);
218227
}
219228

220229
/**
221230
* Synchronizes the remote properties with the settings storage and notifies the model.
222231
* Any consumer listening to the SettingsViewModel will get notified of the properties updates.
223232
*/
224-
private void update() {
233+
private void updateRemoteContent(String endpoint, int prefsKey, Consumer<String> onContentAvailable) {
225234
((VRBrowserApplication) mContext.getApplicationContext()).getExecutors().backgroundThread().post(() -> {
226235
Request request = new Request(
227-
BuildConfig.PROPS_ENDPOINT,
236+
endpoint,
228237
Request.Method.GET,
229238
null,
230239
null,
@@ -241,14 +250,13 @@ private void update() {
241250
if (response.getStatus() == 200) {
242251
String json = response.getBody().string(StandardCharsets.UTF_8);
243252
SharedPreferences.Editor editor = mPrefs.edit();
244-
editor.putString(mContext.getString(R.string.settings_key_remote_props), json);
253+
editor.putString(mContext.getString(prefsKey), json);
245254
editor.apply();
246-
247-
mSettingsViewModel.setProps(json);
255+
// Once the JSON content has been received, execute the callback.
256+
onContentAvailable.accept(json);
248257
}
249-
250258
} catch (IOException e) {
251-
Log.d(LOGTAG, "Remote properties error: " + e.getLocalizedMessage());
259+
Log.d(LOGTAG, "Remote data fetch error for " + endpoint + ": " + e.getLocalizedMessage());
252260
}
253261
});
254262
}
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
package com.igalia.wolvic.browser.components
2+
3+
import android.content.Context
4+
import android.graphics.Bitmap
5+
import android.util.Log
6+
import android.widget.ImageView
7+
import com.igalia.wolvic.utils.BitmapCache
8+
import com.igalia.wolvic.utils.SystemUtils
9+
import kotlinx.coroutines.CoroutineScope
10+
import kotlinx.coroutines.Dispatchers
11+
import kotlinx.coroutines.launch
12+
import kotlinx.coroutines.withContext
13+
import mozilla.components.concept.fetch.Client
14+
import mozilla.components.concept.fetch.Request
15+
import mozilla.components.concept.fetch.isSuccess
16+
import mozilla.components.support.images.CancelOnDetach
17+
import mozilla.components.support.images.DesiredSize
18+
import mozilla.components.support.images.decoder.AndroidImageDecoder
19+
import java.io.IOException
20+
import java.util.concurrent.TimeUnit
21+
22+
class RemoteImageHelper(
23+
private val context: Context,
24+
private val client: Client
25+
) {
26+
private val LOGTAG = SystemUtils.createLogtag(RemoteImageHelper::class.java)
27+
private val decoder = AndroidImageDecoder()
28+
29+
fun loadIntoView(view: ImageView, url: String, private: Boolean = false) {
30+
val bitmapCache = BitmapCache.getInstance(context)
31+
32+
// Tag the view with the URL to identify the image that it should display.
33+
view.tag = url
34+
35+
// Target size is either the view dimensions or the default if the view is not measured yet.
36+
val targetSize = if (view.width > 0 && view.height > 0) {
37+
Math.max(view.width, view.height)
38+
} else {
39+
DEFAULT_TARGET_SIZE
40+
}
41+
42+
// Size guidance for the image decoder.
43+
val desiredSize = DesiredSize(
44+
targetSize = targetSize,
45+
minSize = targetSize / DEFAULT_MIN_MAX_MULTIPLIER,
46+
maxSize = targetSize * DEFAULT_MIN_MAX_MULTIPLIER,
47+
maxScaleFactor = DEFAULT_MAXIMUM_SCALE_FACTOR
48+
)
49+
50+
// ...and apply it on the main UI thread.
51+
val job = CoroutineScope(Dispatchers.IO).launch {
52+
try {
53+
val cachedBitmap = bitmapCache.getBitmap(url).get()
54+
55+
if (cachedBitmap != null) {
56+
// If the image is in cache, we can simply apply it (in the Main thread).
57+
withContext(Dispatchers.Main) {
58+
if (url == view.tag) {
59+
view.setImageBitmap(cachedBitmap)
60+
}
61+
}
62+
} else {
63+
// Otherwise, we fetch it and decode the image.
64+
val bitmap = fetchAndDecode(url, desiredSize, private)
65+
66+
if (bitmap != null) {
67+
bitmapCache.addBitmap(url, bitmap)
68+
69+
// Apply the image (in the Main thread).
70+
withContext(Dispatchers.Main) {
71+
if (url == view.tag) {
72+
view.setImageBitmap(bitmap)
73+
}
74+
}
75+
}
76+
}
77+
} catch (e: Exception) {
78+
Log.e(LOGTAG, "Error loading image: ${e.message}")
79+
}
80+
}
81+
82+
// NOTE: To support RecyclerView scrolling, image downloads will continue when views are detached.
83+
// If needed, this can be changed with view.addOnAttachStateChangeListener(CancelOnDetach(job))).
84+
}
85+
86+
private fun fetchAndDecode(
87+
url: String,
88+
desiredSize: DesiredSize,
89+
private: Boolean
90+
): Bitmap? {
91+
val request = Request(
92+
url = url.trim(),
93+
method = Request.Method.GET,
94+
cookiePolicy = if (private) Request.CookiePolicy.OMIT else Request.CookiePolicy.INCLUDE,
95+
connectTimeout = Pair(DEFAULT_CONNECT_TIMEOUT, TimeUnit.SECONDS),
96+
readTimeout = Pair(DEFAULT_READ_TIMEOUT, TimeUnit.SECONDS),
97+
redirect = Request.Redirect.FOLLOW,
98+
useCaches = true,
99+
private = private
100+
)
101+
102+
return try {
103+
val response = client.fetch(request)
104+
if (response.isSuccess) {
105+
val data = response.body.useStream { it.readBytes() }
106+
decoder.decode(data, desiredSize)
107+
} else {
108+
response.close()
109+
null
110+
}
111+
} catch (e: IOException) {
112+
Log.w(LOGTAG, "Error loading image: ${e.message}")
113+
null
114+
}
115+
}
116+
117+
companion object {
118+
private const val DEFAULT_TARGET_SIZE = 256
119+
private const val DEFAULT_MAXIMUM_SCALE_FACTOR = 2.0f
120+
private const val DEFAULT_MIN_MAX_MULTIPLIER = 4
121+
private const val DEFAULT_CONNECT_TIMEOUT = 5L
122+
private const val DEFAULT_READ_TIMEOUT = 20L
123+
}
124+
}

app/src/common/shared/com/igalia/wolvic/browser/engine/SessionStore.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import com.igalia.wolvic.browser.api.WRuntime;
2424
import com.igalia.wolvic.browser.api.WSession;
2525
import com.igalia.wolvic.browser.components.BrowserIconsHelper;
26+
import com.igalia.wolvic.browser.components.RemoteImageHelper;
2627
import com.igalia.wolvic.browser.components.WolvicWebExtensionRuntime;
2728
import com.igalia.wolvic.browser.content.TrackingProtectionStore;
2829
import com.igalia.wolvic.browser.extensions.BuiltinExtension;
@@ -44,6 +45,7 @@
4445
import mozilla.components.feature.accounts.FxaWebChannelFeature;
4546
import mozilla.components.feature.webcompat.WebCompatFeature;
4647
import mozilla.components.feature.webcompat.reporter.WebCompatReporterFeature;
48+
import mozilla.components.lib.fetch.httpurlconnection.HttpURLConnectionClient;
4749
import mozilla.components.lib.state.Store;
4850

4951
public class SessionStore implements
@@ -85,6 +87,7 @@ public static SessionStore get() {
8587
private FxaWebChannelFeature mWebChannelsFeature;
8688
private Store.Subscription mStoreSubscription;
8789
private BrowserIconsHelper mBrowserIconsHelper;
90+
private RemoteImageHelper mRemoteImageHelper;
8891
private final LinkedHashSet<SessionChangeListener> mSessionChangeListeners;
8992

9093
private SessionStore() {
@@ -136,6 +139,8 @@ public void onTrackingProtectionLevelUpdated(int level) {
136139
BUILTIN_WEB_EXTENSIONS.forEach(extension -> BuiltinExtension.install(mWebExtensionRuntime, extension.first, extension.second));
137140
mBrowserIconsHelper = new BrowserIconsHelper(context, mWebExtensionRuntime, ComponentsAdapter.get().getStore());
138141

142+
mRemoteImageHelper = new RemoteImageHelper(context, new HttpURLConnectionClient());
143+
139144
WebCompatFeature.INSTANCE.install(mWebExtensionRuntime);
140145
WebCompatReporterFeature.INSTANCE.install(mWebExtensionRuntime, context.getString(R.string.app_name));
141146
mWebChannelsFeature = new FxaWebChannelFeature(
@@ -418,6 +423,11 @@ public BrowserIconsHelper getBrowserIcons() {
418423
return mBrowserIconsHelper;
419424
}
420425

426+
@NonNull
427+
public RemoteImageHelper getRemoteImageHelper() {
428+
return mRemoteImageHelper;
429+
}
430+
421431
public void purgeSessionHistory() {
422432
for (Session session : mSessions) {
423433
session.purgeHistory();
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package com.igalia.wolvic.ui.adapters;
2+
3+
import android.content.Context;
4+
import android.view.LayoutInflater;
5+
import android.view.ViewGroup;
6+
7+
import androidx.annotation.NonNull;
8+
import androidx.databinding.DataBindingUtil;
9+
import androidx.recyclerview.widget.RecyclerView;
10+
11+
import com.igalia.wolvic.R;
12+
import com.igalia.wolvic.browser.engine.SessionStore;
13+
import com.igalia.wolvic.databinding.ExperienceItemBinding;
14+
import com.igalia.wolvic.utils.Experience;
15+
import com.igalia.wolvic.utils.SystemUtils;
16+
17+
import java.util.ArrayList;
18+
import java.util.List;
19+
20+
public class ExperiencesAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> {
21+
22+
private static final String LOGTAG = SystemUtils.createLogtag(ExperiencesAdapter.class);
23+
24+
private final Context mContext;
25+
private final List<Experience> mExperiences = new ArrayList<>();
26+
private ClickListener mListener;
27+
28+
public interface ClickListener {
29+
void onClicked(Experience experience);
30+
}
31+
32+
public ExperiencesAdapter(Context context) {
33+
mContext = context;
34+
}
35+
36+
public void updateExperiences(List<Experience> experiences) {
37+
mExperiences.clear();
38+
if (experiences != null) {
39+
mExperiences.addAll(experiences);
40+
}
41+
42+
notifyDataSetChanged();
43+
}
44+
45+
public void setClickListener(ClickListener listener) {
46+
mListener = listener;
47+
}
48+
49+
@NonNull
50+
@Override
51+
public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
52+
ExperienceItemBinding binding = DataBindingUtil.inflate(LayoutInflater.from(mContext), R.layout.experience_item, parent, false);
53+
54+
return new ExperienceViewHolder(binding);
55+
}
56+
57+
@Override
58+
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
59+
ExperienceViewHolder viewHolder = (ExperienceViewHolder) holder;
60+
Experience experience = mExperiences.get(position);
61+
62+
viewHolder.binding.setExperience(experience);
63+
viewHolder.binding.setListener(mListener);
64+
65+
SessionStore.get().getRemoteImageHelper().loadIntoView(viewHolder.binding.thumbnail, experience.getThumbnail(), false);
66+
}
67+
68+
@Override
69+
public int getItemCount() {
70+
return mExperiences.size();
71+
}
72+
73+
static class ExperienceViewHolder extends RecyclerView.ViewHolder {
74+
final ExperienceItemBinding binding;
75+
76+
ExperienceViewHolder(ExperienceItemBinding binding) {
77+
super(binding.getRoot());
78+
this.binding = binding;
79+
}
80+
}
81+
}

app/src/common/shared/com/igalia/wolvic/ui/viewmodel/SettingsViewModel.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import com.igalia.wolvic.BuildConfig;
1515
import com.igalia.wolvic.browser.SettingsStore;
1616
import com.igalia.wolvic.browser.api.WContentBlocking;
17+
import com.igalia.wolvic.utils.RemoteExperiences;
1718
import com.igalia.wolvic.utils.RemoteProperties;
1819
import com.igalia.wolvic.utils.SystemUtils;
1920

@@ -31,6 +32,7 @@ public class SettingsViewModel extends AndroidViewModel {
3132
private MutableLiveData<ObservableBoolean> isWebXREnabled;
3233
private MutableLiveData<String> propsVersionName;
3334
private MutableLiveData<Map<String, RemoteProperties>> props;
35+
private MutableLiveData<RemoteExperiences> experiences;
3436
private MutableLiveData<ObservableBoolean> isWhatsNewVisible;
3537

3638
public SettingsViewModel(@NonNull Application application) {
@@ -42,6 +44,7 @@ public SettingsViewModel(@NonNull Application application) {
4244
isWebXREnabled = new MutableLiveData<>(new ObservableBoolean(false));
4345
propsVersionName = new MutableLiveData<>();
4446
props = new MutableLiveData<>(Collections.emptyMap());
47+
experiences = new MutableLiveData<>(new RemoteExperiences());
4548
isWhatsNewVisible = new MutableLiveData<>(new ObservableBoolean(false));
4649

4750
propsVersionName.observeForever(props -> isWhatsNewVisible());
@@ -128,6 +131,21 @@ public MutableLiveData<Map<String, RemoteProperties>> getProps() {
128131
return props;
129132
}
130133

134+
public void setExperiences(String json) {
135+
try {
136+
Gson gson = new GsonBuilder().create();
137+
RemoteExperiences experiences = gson.fromJson(json, RemoteExperiences.class);
138+
this.experiences.postValue(experiences);
139+
140+
} catch (Exception e) {
141+
Log.w(LOGTAG, String.valueOf(e.getLocalizedMessage()));
142+
}
143+
}
144+
145+
public MutableLiveData<RemoteExperiences> getExperiences() {
146+
return experiences;
147+
}
148+
131149
public MutableLiveData<ObservableBoolean> getIsWhatsNewVisible() {
132150
return isWhatsNewVisible;
133151
}

0 commit comments

Comments
 (0)