Support installing directly from Rebble appstore using internethelper

This commit is contained in:
Arjan Schrijver
2025-12-25 14:45:08 +01:00
committed by Arjan Schrijver
parent 1914162b54
commit 174f4a87e4
12 changed files with 398 additions and 36 deletions
+4 -1
View File
@@ -267,6 +267,10 @@
android:label="@string/title_activity_appmanager"
android:launchMode="singleTop"
android:parentActivityName=".activities.ControlCenterv2" />
<activity
android:name=".activities.appmanager.RebbleAppStoreActivity"
android:label="@string/activity_title_rebble_app_store"
android:parentActivityName=".activities.appmanager.AppManagerActivity" />
<activity
android:name=".activities.musicmanager.MusicManagerActivity"
android:label="@string/title_activity_musicmanager"
@@ -682,7 +686,6 @@
android:label="@string/activity_filter_filter_title"
android:parentActivityName=".activities.workouts.WorkoutListActivity"
android:excludeFromRecents="true"/>
<activity
android:name=".activities.DataManagementActivity"
android:label="@string/title_activity_db_management"
@@ -67,6 +67,7 @@ import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.activities.ExternalPebbleJSActivity;
import nodomain.freeyourgadget.gadgetbridge.adapter.GBDeviceAppAdapter;
import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.pebble.PebbleCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.qhybrid.FossilFileReader;
import nodomain.freeyourgadget.gadgetbridge.devices.qhybrid.FossilHRInstallHandler;
import nodomain.freeyourgadget.gadgetbridge.devices.qhybrid.QHybridConstants;
@@ -80,6 +81,7 @@ import nodomain.freeyourgadget.gadgetbridge.util.GB;
import nodomain.freeyourgadget.gadgetbridge.util.GridAutoFitLayoutManager;
import nodomain.freeyourgadget.gadgetbridge.util.PebbleUtils;
import nodomain.freeyourgadget.gadgetbridge.util.Version;
import nodomain.freeyourgadget.gadgetbridge.util.WebViewSingleton;
public abstract class AbstractAppManagerFragment extends Fragment {
@@ -95,6 +97,7 @@ public abstract class AbstractAppManagerFragment extends Fragment {
protected GBDevice mGBDevice = null;
protected DeviceCoordinator mCoordinator = null;
private Class<? extends Activity> watchfaceDesignerActivity;
private Class<? extends Activity> appStoreActivity;
protected abstract List<GBDeviceApp> getSystemAppsInCategory();
@@ -424,12 +427,14 @@ public abstract class AbstractAppManagerFragment extends Fragment {
mGBDevice = ((AppManagerActivity) getActivity()).getGBDevice();
mCoordinator = mGBDevice.getDeviceCoordinator();
final FloatingActionButton appListFab = ((FloatingActionButton) getActivity().findViewById(R.id.fab));
final FloatingActionButton appListFabNew = ((FloatingActionButton) getActivity().findViewById(R.id.fab_new));
final FloatingActionButton appListFab = getActivity().findViewById(R.id.fab);
final FloatingActionButton appListFabNew = getActivity().findViewById(R.id.fab_new);
final FloatingActionButton appListFabStore = getActivity().findViewById(R.id.fab_store);
watchfaceDesignerActivity = mCoordinator.getWatchfaceDesignerActivity(mGBDevice);
appStoreActivity = mCoordinator.getAppStoreActivity(mGBDevice);
View rootView = inflater.inflate(R.layout.activity_appmanager, container, false);
RecyclerView appListView = (RecyclerView) (rootView.findViewById(R.id.appListView));
RecyclerView appListView = rootView.findViewById(R.id.appListView);
appListView.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
@@ -437,6 +442,7 @@ public abstract class AbstractAppManagerFragment extends Fragment {
if (dy > 0) {
appListFab.hide();
appListFabNew.hide();
appListFabStore.hide();
} else if (dy < 0) {
if (mCoordinator.supportsFlashing(mGBDevice)) {
appListFab.show();
@@ -444,6 +450,11 @@ public abstract class AbstractAppManagerFragment extends Fragment {
if (watchfaceDesignerActivity != null) {
appListFabNew.show();
}
if (appStoreActivity != null && mGBDevice.getDeviceCoordinator() instanceof PebbleCoordinator) {
if (WebViewSingleton.getInstance().ensureInternetHelperBound()) {
appListFabStore.show();
}
}
}
}
});
@@ -462,17 +473,25 @@ public abstract class AbstractAppManagerFragment extends Fragment {
appManagementTouchHelper.attachToRecyclerView(appListView);
if ((watchfaceDesignerActivity != null) && (appListFabNew != null)) {
appListFabNew.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
Intent startIntent = new Intent(getContext(), watchfaceDesignerActivity);
startIntent.putExtra(GBDevice.EXTRA_DEVICE, mGBDevice);
getContext().startActivity(startIntent);
}
appListFabNew.setOnClickListener(v -> {
Intent startIntent = new Intent(getContext(), watchfaceDesignerActivity);
startIntent.putExtra(GBDevice.EXTRA_DEVICE, mGBDevice);
getContext().startActivity(startIntent);
});
appListFabNew.show();
}
if (appStoreActivity != null && mGBDevice.getDeviceCoordinator() instanceof PebbleCoordinator) {
if (WebViewSingleton.getInstance().ensureInternetHelperBound()) {
appListFabStore.setOnClickListener(v -> {
Intent startIntent = new Intent(getContext(), appStoreActivity);
startIntent.putExtra(GBDevice.EXTRA_DEVICE, mGBDevice);
getContext().startActivity(startIntent);
});
appListFabStore.show();
}
}
return rootView;
}
@@ -0,0 +1,297 @@
/* Copyright (C) 2022-2024 Andreas Shimokawa, Daniel Dakhno, Gordon Williams, Arjan Schrijver
This file is part of Gadgetbridge.
Gadgetbridge is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Gadgetbridge is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.activities.appmanager
import android.annotation.SuppressLint
import android.content.ComponentName
import android.content.Intent
import android.content.IntentFilter
import android.content.ServiceConnection
import android.net.Uri
import android.os.Bundle
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.os.ParcelFileDescriptor
import android.os.RemoteException
import android.webkit.PermissionRequest
import android.webkit.WebChromeClient
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.Toast
import androidx.core.net.toUri
import nodomain.freeyourgadget.gadgetbridge.GBApplication
import nodomain.freeyourgadget.gadgetbridge.R
import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBActivity
import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice
import nodomain.freeyourgadget.gadgetbridge.util.Capsule
import nodomain.freeyourgadget.gadgetbridge.util.GB
import nodomain.freeyourgadget.internethelper.aidl.http.HttpGetRequest
import nodomain.freeyourgadget.internethelper.aidl.http.HttpHeaders
import nodomain.freeyourgadget.internethelper.aidl.http.HttpResponse
import nodomain.freeyourgadget.internethelper.aidl.http.IHttpCallback
import nodomain.freeyourgadget.internethelper.aidl.http.IHttpService
import org.jsoup.Jsoup
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import java.io.File
import java.io.FileOutputStream
import java.io.OutputStream
import java.nio.charset.Charset
import java.util.concurrent.CountDownLatch
import kotlin.concurrent.Volatile
class RebbleAppStoreActivity : AbstractGBActivity() {
val LOG: Logger = LoggerFactory.getLogger(RebbleAppStoreActivity::class.java)
private var mGBDevice: GBDevice? = null
private var webView: WebView? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_banglejs_apps_management)
val intent1 = Intent("nodomain.freeyourgadget.internethelper.HttpService")
intent1.setPackage("nodomain.freeyourgadget.internethelper")
val res = applicationContext.bindService(intent1, mHttpConnection, BIND_AUTO_CREATE)
if (res) {
LOG.info("Bound to HttpService")
} else {
LOG.warn("Could not bind to HttpService")
}
val extras = intent.extras
if (extras != null) {
mGBDevice = extras.getParcelable(GBDevice.EXTRA_DEVICE)
}
requireNotNull(mGBDevice) { "Must provide a device when invoking this activity" }
initViews()
}
override fun onResume() {
super.onResume()
if (webView != null) return // already set up
val commandFilter = IntentFilter()
commandFilter.addAction(GBDevice.ACTION_DEVICE_CHANGED)
initViews()
}
override fun onDestroy() {
webView!!.destroy()
webView = null
super.onDestroy()
finish()
}
@Volatile
private var iHttpService: IHttpService? = null
private val mHttpConnection: ServiceConnection = object : ServiceConnection {
override fun onServiceConnected(className: ComponentName?, service: IBinder?) {
LOG.info("onServiceConnected: {}", className)
iHttpService = IHttpService.Stub.asInterface(service)
}
override fun onServiceDisconnected(className: ComponentName?) {
LOG.error("Service has unexpectedly disconnected: {}", className)
iHttpService = null
}
}
private fun isDownloadableWatchapp(url: String): Boolean {
val downloadExtensions = listOf(".pbw", ".zip")
return downloadExtensions.any { url.endsWith(it, ignoreCase = true) }
}
private fun downloadInstallWatchapp(url: Uri) {
val httpHeaders = HttpHeaders()
val httpGetRequest = HttpGetRequest(url.toString(), httpHeaders)
iHttpService!!.get(httpGetRequest, object : IHttpCallback.Stub() {
override fun onResponse(response: HttpResponse) {
val contentType = response.headers["content-type"]?.split(";")?.get(0)
if (!contentType.equals("application/octet-stream") && !contentType.equals("application/zip")) {
GB.toast("Download failed, wrong content-type: $contentType", Toast.LENGTH_LONG, GB.ERROR)
return
}
val inputStream = ParcelFileDescriptor.AutoCloseInputStream(response.body)
val filename = url.lastPathSegment ?: "downloaded_file.pbw"
val cacheDir = applicationContext.externalCacheDir ?: return
val cacheFile = File(cacheDir, filename)
val outputStream: OutputStream = FileOutputStream(cacheFile)
val buffer = ByteArray(1024)
var len: Int
while (inputStream.read(buffer).also { len = it } > 0) {
outputStream.write(buffer, 0, len)
}
outputStream.flush()
outputStream.close()
val installHandler: InstallHandler? = mGBDevice?.deviceCoordinator?.findInstallHandler(cacheFile.toUri(), applicationContext)
if (installHandler == null) {
GB.toast(getString(R.string.fwinstaller_file_not_compatible_to_device), Toast.LENGTH_LONG, GB.ERROR)
return
}
val startIntent = Intent(applicationContext, installHandler.getInstallActivity())
startIntent.putExtra(GBDevice.EXTRA_DEVICE, mGBDevice)
startIntent.action = Intent.ACTION_VIEW
startIntent.setDataAndType(cacheFile.toUri(), null)
startActivity(startIntent)
}
override fun onException(message: String?) {
GB.toast("Download failed: $message", Toast.LENGTH_LONG, GB.ERROR)
}
})
}
// TODO: handle links like pebble://appstore/52b231c2b70e1c159500009b#
// so we don't have to use ?dev_settings=true
@SuppressLint("SetJavaScriptEnabled")
private fun initViews() {
webView = findViewById(R.id.webview)
webView!!.webViewClient = WebViewClient()
val settings = webView!!.settings
settings.javaScriptEnabled = true
settings.loadWithOverviewMode = true
settings.useWideViewPort = true
val url = "https://apps.rebble.io/en_US/watchfaces?dev_settings=true"
webView!!.webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(
wv: WebView,
request: WebResourceRequest
): Boolean {
val requestUrl = request.url
// Check if the URL is a downloadable watchface/watchapp
if (isDownloadableWatchapp(requestUrl.toString())) {
downloadInstallWatchapp(requestUrl)
return true // Indicate that the URL loading is handled
}
val intent = Intent(Intent.ACTION_VIEW, requestUrl)
wv.context.startActivity(intent)
return true
}
override fun shouldInterceptRequest(
view: WebView?,
request: WebResourceRequest
): WebResourceResponse? {
LOG.info("shouldIntercept {} {} {}", request.method, request.url, iHttpService != null)
if (!request.method.equals("get", ignoreCase = true)) {
return super.shouldInterceptRequest(view, request)
}
if (iHttpService == null) {
return super.shouldInterceptRequest(view, request)
}
val httpHeaders = HttpHeaders()
for (header in request.requestHeaders.entries) {
httpHeaders.addHeader(header.key, header.value)
}
val httpGetRequest = HttpGetRequest(request.url.toString(), httpHeaders)
val latch = CountDownLatch(1)
val internetResponseCapsule = Capsule<WebResourceResponse?>()
try {
iHttpService!!.get(httpGetRequest, object : IHttpCallback.Stub() {
@Throws(RemoteException::class)
override fun onResponse(response: HttpResponse) {
// Extract headers
val contentType = response.headers["content-type"]?.split(";")?.get(0) ?: "text/html"
val contentEncoding = response.headers["content-encoding"] ?: "UTF-8"
// Retrieve original payload as InputStream
val inputStream = ParcelFileDescriptor.AutoCloseInputStream(response.body)
// Clean up malformed HTML from Rebble
if (contentType.equals("text/html", ignoreCase = true)) {
val rawHtml = inputStream.bufferedReader(Charset.forName(contentEncoding)).use { it.readText() }
val cleanedHtml = Jsoup.parse(rawHtml).html()
val internetResponse = WebResourceResponse(
contentType,
contentEncoding,
response.status,
"OK",
response.headers.toMap(),
cleanedHtml.byteInputStream()
)
internetResponseCapsule.set(internetResponse)
} else {
// If not text/html, return original response
val internetResponse = WebResourceResponse(
contentType,
contentEncoding,
response.status,
"OK",
response.headers.toMap(),
inputStream
)
internetResponseCapsule.set(internetResponse)
}
latch.countDown()
}
@Throws(RemoteException::class)
override fun onException(message: String?) {
throw RuntimeException(message)
}
})
} catch (e: RemoteException) {
throw RuntimeException(e)
}
try {
latch.await()
} catch (e: InterruptedException) {
throw RuntimeException(e)
}
return internetResponseCapsule.get()
}
override fun onReceivedError(
view: WebView,
errorCode: Int,
description: String?,
failingUrl: String?
) {
GB.toast(
"Error: $description",
Toast.LENGTH_SHORT,
GB.ERROR
)
view.loadUrl("about:blank")
}
}
val mainLooper = Looper.getMainLooper()
Handler(mainLooper).postDelayed({
webView!!.loadUrl(url)
}, 100)
webView!!.webChromeClient = object : WebChromeClient() {
override fun onPermissionRequest(request: PermissionRequest) {
request.grant(request.resources)
}
}
}
}
@@ -580,6 +580,12 @@ public abstract class AbstractDeviceCoordinator implements DeviceCoordinator {
return null;
}
@Nullable
@Override
public Class<? extends Activity> getAppStoreActivity(final GBDevice device) {
return null;
}
@Override
public int getBondingStyle() {
return BONDING_STYLE_ASK;
@@ -616,6 +616,13 @@ public interface DeviceCoordinator {
*/
Class<? extends Activity> getWatchfaceDesignerActivity(GBDevice device);
/**
* Returns the Activity class that will be used to download apps/watchfaces.
*
* @return
*/
Class<? extends Activity> getAppStoreActivity(GBDevice device);
/**
* Returns the device app cache directory.
*/
@@ -63,7 +63,6 @@ import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.service.devices.banglejs.BangleJSDeviceSupport;
import nodomain.freeyourgadget.gadgetbridge.util.Capsule;
import nodomain.freeyourgadget.gadgetbridge.util.GB;
import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
import nodomain.freeyourgadget.internethelper.aidl.http.HttpGetRequest;
import nodomain.freeyourgadget.internethelper.aidl.http.HttpHeaders;
@@ -115,10 +114,6 @@ public class AppsManagementActivity extends AbstractGBActivity {
mCoordinator = mGBDevice.getDeviceCoordinator();
}
private void toast(String data) {
GB.toast(data, Toast.LENGTH_LONG, GB.INFO);
}
@Override
protected void onPause() {
super.onPause();
@@ -38,6 +38,7 @@ import de.greenrobot.dao.Property;
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.activities.appmanager.AppManagerActivity;
import nodomain.freeyourgadget.gadgetbridge.activities.appmanager.RebbleAppStoreActivity;
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettings;
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer;
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsScreen;
@@ -140,6 +141,11 @@ public class PebbleCoordinator extends AbstractBLClassicDeviceCoordinator {
return AppManagerActivity.class;
}
@Override
public Class<? extends Activity> getAppStoreActivity(final GBDevice device) {
return RebbleAppStoreActivity.class;
}
@Override
public File getAppCacheDir() throws IOException {
return PebbleUtils.getPbwCacheDir();
@@ -78,8 +78,8 @@ public class GBWebClient extends WebViewClient {
}
private WebResourceResponse mimicReply(Uri requestedUri) {
if (requestedUri.getHost() != null && (StringUtils.indexOfAny(requestedUri.getHost(), AllowedDomains) != -1)) {
if (WebViewSingleton.getInstance().internetHelperBound) {
if (requestedUri.getHost() != null && !requestedUri.toString().startsWith("file://")) {// && (StringUtils.indexOfAny(requestedUri.getHost(), AllowedDomains) != -1)) {
if (WebViewSingleton.getInstance().ensureInternetHelperBound()) {
LOG.debug("WEBVIEW forwarding request to the internet helper");
try {
return WebViewSingleton.getInstance().send(requestedUri);
@@ -107,6 +107,28 @@ public class WebViewSingleton {
}
}
public boolean ensureInternetHelperBound() {
if (contextWrapper != null && !internetHelperBound) {
String internetHelperPkg = "nodomain.freeyourgadget.internethelper";
String internetHelperCls = internetHelperPkg + ".HttpService";
try {
contextWrapper.getPackageManager().getApplicationInfo(internetHelperPkg, 0);
Intent intent = new Intent();
intent.setComponent(new ComponentName(internetHelperPkg, internetHelperCls));
final Intent intent1 = new Intent("nodomain.freeyourgadget.internethelper.HttpService");
intent1.setPackage("nodomain.freeyourgadget.internethelper");
contextWrapper.getApplicationContext().bindService(intent1, internetHelperConnection, Context.BIND_AUTO_CREATE);
LOG.info("WEBVIEW: Internet helper bound successfully.");
} catch (PackageManager.NameNotFoundException e) {
LOG.info("WEBVIEW: Internet helper not installed, only mimicked HTTP requests will work.");
} catch (SecurityException e) {
LOG.info("WEBVIEW: Permission for internet helper not granted, only mimicked HTTP requests will work.");
}
}
return internetHelperBound;
}
public static WebViewSingleton getInstance() {
return instance;
}
@@ -188,24 +210,7 @@ public class WebViewSingleton {
webView.addJavascriptInterface(jsInterface, "GBjs");
webView.loadUrl("file:///android_asset/app_config/configure.html?rand=" + Math.random() * 500);
});
if (contextWrapper != null && !internetHelperBound) {
String internetHelperPkg = "nodomain.freeyourgadget.internethelper";
String internetHelperCls = internetHelperPkg + ".HttpService";
try {
contextWrapper.getPackageManager().getApplicationInfo(internetHelperPkg, 0);
Intent intent = new Intent();
intent.setComponent(new ComponentName(internetHelperPkg, internetHelperCls));
final Intent intent1 = new Intent("nodomain.freeyourgadget.internethelper.HttpService");
intent1.setPackage("nodomain.freeyourgadget.internethelper");
contextWrapper.getApplicationContext().bindService(intent1, internetHelperConnection, Context.BIND_AUTO_CREATE);
LOG.info("WEBVIEW: Internet helper bound successfully.");
} catch (PackageManager.NameNotFoundException e) {
LOG.info("WEBVIEW: Internet helper not installed, only mimicked HTTP requests will work.");
} catch (SecurityException e) {
LOG.info("WEBVIEW: Permission for internet helper not granted, only mimicked HTTP requests will work.");
}
}
ensureInternetHelperBound();
}
}
+10
View File
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M21.9,8.89l-1.05,-4.37c-0.22,-0.9 -1,-1.52 -1.91,-1.52H5.05C4.15,3 3.36,3.63 3.15,4.52L2.1,8.89c-0.24,1.02 -0.02,2.06 0.62,2.88C2.8,11.88 2.91,11.96 3,12.06V19c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2v-6.94c0.09,-0.09 0.2,-0.18 0.28,-0.28C21.92,10.96 22.15,9.91 21.9,8.89zM18.91,4.99l1.05,4.37c0.1,0.42 0.01,0.84 -0.25,1.17C19.57,10.71 19.27,11 18.77,11c-0.61,0 -1.14,-0.49 -1.21,-1.14L16.98,5L18.91,4.99zM13,5h1.96l0.54,4.52c0.05,0.39 -0.07,0.78 -0.33,1.07C14.95,10.85 14.63,11 14.22,11C13.55,11 13,10.41 13,9.69V5zM8.49,9.52L9.04,5H11v4.69C11,10.41 10.45,11 9.71,11c-0.34,0 -0.65,-0.15 -0.89,-0.41C8.57,10.3 8.45,9.91 8.49,9.52zM4.04,9.36L5.05,5h1.97L6.44,9.86C6.36,10.51 5.84,11 5.23,11c-0.49,0 -0.8,-0.29 -0.93,-0.47C4.03,10.21 3.94,9.78 4.04,9.36zM5,19v-6.03C5.08,12.98 5.15,13 5.23,13c0.87,0 1.66,-0.36 2.24,-0.95c0.6,0.6 1.4,0.95 2.31,0.95c0.87,0 1.65,-0.36 2.23,-0.93c0.59,0.57 1.39,0.93 2.29,0.93c0.84,0 1.64,-0.35 2.24,-0.95c0.58,0.59 1.37,0.95 2.24,0.95c0.08,0 0.15,-0.02 0.23,-0.03V19H5z"/>
</vector>
@@ -51,4 +51,17 @@
android:visibility="invisible"
/>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fab_store"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:layout_alignParentBottom="true"
android:layout_gravity="bottom|end"
android:layout_marginEnd="16dp"
android:layout_marginBottom="90dp"
app:srcCompat="@drawable/ic_store"
android:visibility="invisible"
/>
</android.widget.RelativeLayout>
+1
View File
@@ -4448,4 +4448,5 @@
<string name="movement_evaluation">Movement Evaluation</string>
<string name="pref_notifications_pictures_enable">Send notification pictures</string>
<string name="pref_notifications_pictures_enable_summary">Send notification pictures for current device</string>
<string name="activity_title_rebble_app_store">Rebble app store</string>
</resources>