diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 36cd717a12..9c9119cb0c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -267,6 +267,10 @@ android:label="@string/title_activity_appmanager" android:launchMode="singleTop" android:parentActivityName=".activities.ControlCenterv2" /> + - watchfaceDesignerActivity; + private Class appStoreActivity; protected abstract List 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; } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/appmanager/RebbleAppStoreActivity.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/appmanager/RebbleAppStoreActivity.kt new file mode 100644 index 0000000000..f3bbbabe7d --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/appmanager/RebbleAppStoreActivity.kt @@ -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 . */ +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() + + 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) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinator.java index 7f6590b819..50f49553a4 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinator.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinator.java @@ -580,6 +580,12 @@ public abstract class AbstractDeviceCoordinator implements DeviceCoordinator { return null; } + @Nullable + @Override + public Class getAppStoreActivity(final GBDevice device) { + return null; + } + @Override public int getBondingStyle() { return BONDING_STYLE_ASK; diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/DeviceCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/DeviceCoordinator.java index 14807a2d5a..1337e3d377 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/DeviceCoordinator.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/DeviceCoordinator.java @@ -616,6 +616,13 @@ public interface DeviceCoordinator { */ Class getWatchfaceDesignerActivity(GBDevice device); + /** + * Returns the Activity class that will be used to download apps/watchfaces. + * + * @return + */ + Class getAppStoreActivity(GBDevice device); + /** * Returns the device app cache directory. */ diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/banglejs/AppsManagementActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/banglejs/AppsManagementActivity.java index 8c8618e186..fc4cfc7546 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/banglejs/AppsManagementActivity.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/banglejs/AppsManagementActivity.java @@ -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(); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pebble/PebbleCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pebble/PebbleCoordinator.java index ee2fd10b96..b3e29f0926 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pebble/PebbleCoordinator.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pebble/PebbleCoordinator.java @@ -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 getAppStoreActivity(final GBDevice device) { + return RebbleAppStoreActivity.class; + } + @Override public File getAppCacheDir() throws IOException { return PebbleUtils.getPbwCacheDir(); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/webview/GBWebClient.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/webview/GBWebClient.java index 2ee48beaa9..dc90975304 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/webview/GBWebClient.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/pebble/webview/GBWebClient.java @@ -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); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/WebViewSingleton.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/WebViewSingleton.java index ca1ce6c165..f090a52f1b 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/WebViewSingleton.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/WebViewSingleton.java @@ -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(); } } diff --git a/app/src/main/res/drawable/ic_store.xml b/app/src/main/res/drawable/ic_store.xml new file mode 100644 index 0000000000..726c52ff8f --- /dev/null +++ b/app/src/main/res/drawable/ic_store.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/activity_fragmentappmanager.xml b/app/src/main/res/layout/activity_fragmentappmanager.xml index c9fc33928b..6e4189efe3 100644 --- a/app/src/main/res/layout/activity_fragmentappmanager.xml +++ b/app/src/main/res/layout/activity_fragmentappmanager.xml @@ -51,4 +51,17 @@ android:visibility="invisible" /> + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2d1572c5b6..0922fd6d09 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -4448,4 +4448,5 @@ Movement Evaluation Send notification pictures Send notification pictures for current device + Rebble app store