Repository: LawnchairLauncher/lawnfeed Branch: master Commit: 39ab76bad5d7 Files: 34 Total size: 57.5 KB Directory structure: gitextract_4wvmvqnb/ ├── .github/ │ └── workflows/ │ └── main.yml ├── .gitignore ├── .gitmodules ├── app/ │ ├── .gitignore │ ├── build.gradle │ ├── proguard-rules.pro │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ ├── aidl/ │ │ └── amirz/ │ │ └── aidlbridge/ │ │ ├── IBridge.aidl │ │ └── IBridgeCallback.aidl │ ├── java/ │ │ ├── amirz/ │ │ │ └── aidlbridge/ │ │ │ ├── BridgeImpl.java │ │ │ ├── BridgeService.java │ │ │ └── TransactProxy.java │ │ └── app/ │ │ └── lawnchair/ │ │ └── lawnfeed/ │ │ ├── LauncherClientProxyService.kt │ │ ├── PermissionActivity.java │ │ ├── ProxyImpl.kt │ │ ├── bridge/ │ │ │ └── TransactProxy.kt │ │ ├── receivers/ │ │ │ ├── DownloadReceiver.java │ │ │ └── UpdateReceiver.java │ │ └── updater/ │ │ ├── Updater.java │ │ └── UpdaterTask.java │ └── res/ │ ├── drawable/ │ │ └── ic_lawnchair.xml │ ├── mipmap-anydpi-v26/ │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ ├── values/ │ │ ├── colors.xml │ │ ├── strings.xml │ │ └── styles.xml │ └── xml/ │ └── file_paths.xml ├── build.gradle ├── gradle/ │ └── wrapper/ │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradle.properties ├── gradlew ├── gradlew.bat └── settings.gradle ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/main.yml ================================================ name: Build signed debug APK on: workflow_dispatch: push: branches: - master jobs: build-signed-debug-apk: runs-on: ubuntu-latest continue-on-error: true steps: - name: Check out repository uses: actions/checkout@v2.3.4 with: submodules: true - name: Restore Gradle cache uses: actions/cache@v4 with: path: | ~/.gradle/caches ~/.gradle/wrapper key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }} restore-keys: ${{ runner.os }}-gradle- - name: Set up Java 8 uses: actions/setup-java@v1.4.3 with: java-version: 8 - name: Grant execution permission to Gradle Wrapper run: chmod +x gradlew - name: Build debug APK run: ./gradlew assembleDebug - name: Setup build tool version variable shell: bash run: | BUILD_TOOL_VERSION=$(ls /usr/local/lib/android/sdk/build-tools/ | tail -n 1) echo "BUILD_TOOL_VERSION=$BUILD_TOOL_VERSION" >> $GITHUB_ENV echo Last build tool version is: $BUILD_TOOL_VERSION - name: Sign debug APK uses: r0adkll/sign-android-release@v1 id: sign-debug-apk with: releaseDirectory: app/build/outputs/apk/debug signingKeyBase64: ${{ secrets.KEYSTORE }} alias: ${{ secrets.KEY_ALIAS }} keyStorePassword: ${{ secrets.KEYSTORE_PASSWORD }} keyPassword: ${{ secrets.KEY_PASSWORD }} env: BUILD_TOOLS_VERSION: ${{ env.BUILD_TOOL_VERSION }} - name: Upload artifact uses: actions/upload-artifact@v4 with: name: Signed Debug APK path: ${{ steps.sign-debug-apk.outputs.signedReleaseFile }} ================================================ FILE: .gitignore ================================================ *.iml .gradle /local.properties /.idea/workspace.xml /.idea/libraries .DS_Store /build /captures .externalNativeBuild .idea ================================================ FILE: .gitmodules ================================================ [submodule "launcherclient"] path = launcherclient url = https://github.com/LawnchairLauncher/launcherclient.git ================================================ FILE: app/.gitignore ================================================ /build ================================================ FILE: app/build.gradle ================================================ apply plugin: 'com.android.application' final def commitHash = { -> final def stdout = new ByteArrayOutputStream() exec { commandLine 'git', 'rev-parse', '--short=7', 'HEAD' standardOutput = stdout } stdout.toString().trim() } android { compileSdkVersion 29 buildToolsVersion "29.0.2" defaultConfig { applicationId "app.lawnchair.lawnfeed" minSdkVersion 21 targetSdkVersion 29 versionCode 1 versionName "4.0+${commitHash()}" testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" } applicationVariants.all { variant -> variant.outputs.all { outputFileName = "Lawnfeed ${variant.versionName}.apk" } } buildTypes { debug { minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' } release { minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' } } } dependencies { implementation fileTree(include: ['*.jar'], dir: 'libs') implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" implementation "androidx.core:core:1.0.2" implementation('com.googlecode.json-simple:json-simple:1.1.1') { exclude group: 'org.hamcrest', module: 'hamcrest-core' } implementation project(':launcherclient') } apply plugin: 'kotlin-android' apply plugin: 'kotlin-android-extensions' ================================================ FILE: app/proguard-rules.pro ================================================ # Add project specific ProGuard rules here. # By default, the flags in this file are appended to flags specified # in C:\Users\papho\AppData\Local\Android\Sdk/tools/proguard/proguard-android.txt # You can edit the include path and order by changing the proguardFiles # directive in build.gradle. # # For more details, see # http://developer.android.com/guide/developing/tools/proguard.html # Add any project specific keep options here: # If your project uses WebView with JS, uncomment the following # and specify the fully qualified class name to the JavaScript interface # class: #-keepclassmembers class fqcn.of.javascript.interface.for.webview { # public *; #} # Uncomment this to preserve the line number information for # debugging stack traces. #-keepattributes SourceFile,LineNumberTable # If you keep the line number information, uncomment this to # hide the original source file name. #-renamesourcefileattribute SourceFile ================================================ FILE: app/src/main/AndroidManifest.xml ================================================ ================================================ FILE: app/src/main/aidl/amirz/aidlbridge/IBridge.aidl ================================================ package amirz.aidlbridge; import amirz.aidlbridge.IBridgeCallback; interface IBridge { oneway void bindService(in IBridgeCallback cb, in int flags); } ================================================ FILE: app/src/main/aidl/amirz/aidlbridge/IBridgeCallback.aidl ================================================ package amirz.aidlbridge; interface IBridgeCallback { oneway void onServiceConnected(in ComponentName name, in IBinder service); oneway void onServiceDisconnected(in ComponentName name); } ================================================ FILE: app/src/main/java/amirz/aidlbridge/BridgeImpl.java ================================================ package amirz.aidlbridge; import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.ServiceConnection; import android.net.Uri; import android.os.IBinder; import android.os.Process; import android.os.RemoteException; import android.text.TextUtils; import android.util.Log; import java.util.HashSet; import java.util.Set; import app.lawnchair.lawnfeed.bridge.TransactProxy; class BridgeImpl extends IBridge.Stub { private static final String TAG = "BridgeImpl"; private final Context mContext; private final String mPackage; private final Intent mIntent; private final Set mConnections = new HashSet<>(); BridgeImpl(Context context, Intent intent) { mContext = context; Uri caller = intent.getData(); String authority = caller.getEncodedAuthority(); mPackage = TextUtils.isEmpty(authority) ? "" : authority.split(":")[0]; mIntent = intent.cloneFilter(); mIntent.setPackage("com.google.android.googlequicksearchbox"); String auth = context.getPackageName() + ":" + Process.myUid(); mIntent.setData(caller.buildUpon().encodedAuthority(auth).build()); } String getPackage() { return mPackage; } @Override public void bindService(final IBridgeCallback cb, int flags) { Log.e(TAG, "Connect request from " + getPackage()); ServiceConnection connection = new ServiceConnection() { private boolean mConnected; @Override public void onServiceConnected(ComponentName name, IBinder service) { if (!mConnected) { mConnected = true; Log.e(TAG, "Connected for " + mPackage); try { cb.onServiceConnected(name, new TransactProxy(service, mContext)); } catch (RemoteException e) { e.printStackTrace(); } } } @Override public void onServiceDisconnected(ComponentName name) { if (mConnected) { mConnected = false; Log.e(TAG, "Disconnected for " + mPackage); try { cb.onServiceDisconnected(name); } catch (RemoteException e) { e.printStackTrace(); } } } }; if (mContext.bindService(mIntent, connection, flags)) { mConnections.add(connection); } } void disconnect() { Log.e(TAG, "Disconnect " + mPackage + " with " + mConnections.size() + " connections"); for (ServiceConnection connection : mConnections) { mContext.unbindService(connection); connection.onServiceDisconnected(null); } mConnections.clear(); } } ================================================ FILE: app/src/main/java/amirz/aidlbridge/BridgeService.java ================================================ package amirz.aidlbridge; import android.app.Service; import android.content.Intent; import android.net.Uri; import android.os.IBinder; import android.util.Log; import java.util.HashMap; import java.util.Map; public class BridgeService extends Service { private static final String TAG = "BridgeService"; private static String sLastConnection; private final Map mBridges = new HashMap<>(); @Override public IBinder onBind(Intent intent) { Uri caller = intent.getData(); if (caller != null) { Log.e(TAG, "Bind from " + caller.toString()); if (!mBridges.containsKey(intent)) { mBridges.put(intent, new BridgeImpl(getApplicationContext(), intent) { @Override public void bindService(final IBridgeCallback cb, int flags) { sLastConnection = getPackage(); for (BridgeImpl bridge : mBridges.values()) { if (bridge != this) { bridge.disconnect(); } } super.bindService(cb, flags); } }); } return mBridges.get(intent); } return null; } @Override public boolean onUnbind(Intent intent) { Uri caller = intent.getData(); if (caller != null) { Log.e(TAG, "Unbind from " + caller.toString()); if (mBridges.containsKey(intent)) { mBridges.remove(intent).disconnect(); } } return super.onUnbind(intent); } public static String getLastConnection() { return sLastConnection; } } ================================================ FILE: app/src/main/java/amirz/aidlbridge/TransactProxy.java ================================================ package amirz.aidlbridge; import android.os.Binder; import android.os.IBinder; import android.os.Parcel; import android.os.RemoteException; class TransactProxy extends Binder { private final IBinder mTarget; TransactProxy(IBinder target) { mTarget = target; } @Override protected boolean onTransact(int code, Parcel data, Parcel reply, int flags) throws RemoteException { return mTarget.transact(code, data, reply, flags); } } ================================================ FILE: app/src/main/java/app/lawnchair/lawnfeed/LauncherClientProxyService.kt ================================================ package app.lawnchair.lawnfeed import android.app.Service import android.content.Intent import android.os.IBinder class LauncherClientProxyService : Service() { override fun onBind(intent: Intent): IBinder? { return getBinder() } override fun onUnbind(intent: Intent?): Boolean { binder?.onUnbind() binder = null stopSelf() return super.onUnbind(intent) } private fun getBinder(): ProxyImpl { if (binder == null) { binder = ProxyImpl(applicationContext) } return binder!! } private var binder: ProxyImpl? = null } ================================================ FILE: app/src/main/java/app/lawnchair/lawnfeed/PermissionActivity.java ================================================ package app.lawnchair.lawnfeed; import android.app.Activity; import android.content.Context; import android.content.Intent; import android.content.pm.PackageManager; import android.os.Bundle; import android.os.Handler; import android.os.Looper; import android.os.ResultReceiver; import androidx.core.app.ActivityCompat; import androidx.core.content.ContextCompat; public class PermissionActivity extends Activity { public static final int REQUEST_CODE = 1337; private ResultReceiver resultReceiver; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); // Don't continue if the activity doesn't have an intent if (getIntent() == null) { finish(); return; } // Get permissions array which we want to request resultReceiver = getIntent().getParcelableExtra("resultReceiver"); String[] permissionsArray = getIntent().getStringArrayExtra("permissions"); int requestCode = getIntent().getIntExtra("requestCode", REQUEST_CODE); // Check if those permissions are already granted if (PermissionResponse.hasPermissions(this, permissionsArray)) { // Proceed like those permissions have been now granted onComplete(requestCode, permissionsArray, new int[]{ PackageManager.PERMISSION_GRANTED }); } else { // Otherwise request those permissions and wait for users response ActivityCompat.requestPermissions(this, permissionsArray, requestCode); } } private void onComplete(int requestCode, String[] permissions, int[] grantResult) { Bundle bundle = new Bundle(); bundle.putStringArray("permissions", permissions); bundle.putIntArray("grantResult", grantResult); bundle.putInt("requestCode", requestCode); // Send our callback to the result receiver resultReceiver.send(requestCode, bundle); finish(); } @Override public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResult) { super.onRequestPermissionsResult(requestCode, permissions, grantResult); onComplete(requestCode, permissions, grantResult); } public static void callAsync(Context context, String[] permissions, int requestCode, final PermissionResultCallback callback) { // Proceed to the callback if permissions were already granted if (PermissionResponse.hasPermissions(context, permissions)) { callback.onComplete(new PermissionResponse(permissions, new int[]{PackageManager.PERMISSION_GRANTED}, requestCode)); return; } // Our result receiver to get the response asynchronously ResultReceiver receiver = new ResultReceiver(new Handler(Looper.getMainLooper())) { @Override protected void onReceiveResult(int resultCode, Bundle resultData) { super.onReceiveResult(resultCode, resultData); int[] grantResult = resultData.getIntArray("grantResult"); String[] permissions = resultData.getStringArray("permissions"); // Call the callback with the result callback.onComplete(new PermissionResponse(permissions, grantResult, resultCode)); } }; // Build our intent to launch Intent intent = new Intent(context, PermissionActivity.class); intent.putExtra("requestCode", requestCode); intent.putExtra("permissions", permissions); intent.putExtra("resultReceiver", receiver); intent.addFlags(Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS); // Start our activity and wait context.startActivity(intent); } // Contains results from requesting the permissions public static class PermissionResponse { private String[] permissions; private int [] grantResult; private int requestCode; public PermissionResponse(String[] permissions, int[] grantResult, int requestCode) { this.permissions = permissions; this.grantResult = grantResult; this.requestCode = requestCode; } public boolean isGranted() { return (grantResult != null && grantResult.length > 0 && grantResult[0] == PackageManager.PERMISSION_GRANTED); } public String[] getPermissions() { return permissions; } public int[] getGrantResult() { return grantResult; } public int getRequestCode() { return requestCode; } public static boolean hasPermissions(Context context, String[] permissionsArray) { // If a permission isn't granted => return false for (String permission : permissionsArray) { if (ContextCompat.checkSelfPermission(context, permission) != PackageManager.PERMISSION_GRANTED){ return false; } } // Return true only when all permissions are granted return true; } } // Simple interface to handle our async callbacks public interface PermissionResultCallback { void onComplete(PermissionResponse response); } } ================================================ FILE: app/src/main/java/app/lawnchair/lawnfeed/ProxyImpl.kt ================================================ package app.lawnchair.lawnfeed import android.content.* import android.content.pm.PackageManager import android.net.Uri import android.os.Binder import android.os.Bundle import android.os.IBinder import android.os.Process import android.util.Log import app.lawnchair.launcherclient.ILauncherClientProxy import app.lawnchair.launcherclient.ILauncherClientProxyCallback import app.lawnchair.launcherclient.LauncherClientProxyConnection import app.lawnchair.launcherclient.WindowLayoutParams import app.lawnchair.lawnfeed.bridge.TransactProxy import app.lawnchair.lawnfeed.updater.Updater import com.google.android.libraries.launcherclient.ILauncherOverlay import com.google.android.libraries.launcherclient.ILauncherOverlayCallback class ProxyImpl(val context: Context) : ILauncherClientProxy.Stub() { private lateinit var proxyCallback: ILauncherClientProxyCallback private var overlayCallbacks = OverlayCallbacks() private var destroyed = false private var serviceConnected: Boolean = false private var overlay: ILauncherOverlay? = null private var allowed = false private val serviceConnection = OverlayServiceConnection() private val serviceIntent = ProxyImpl.getServiceIntent(context) private var serviceStatus: Int = 0 override fun reconnect(): Int { enforcePermission() try { if (destroyed || serviceStatus != 0) return serviceStatus if (sApplicationConnection != null && sApplicationConnection?.packageName != serviceIntent.`package`) context.unbindService(sApplicationConnection) if (sApplicationConnection == null) { sApplicationConnection = AppServiceConnection(serviceIntent.`package`) if (!connectSafely(context, sApplicationConnection!!, Context.BIND_WAIVE_PRIORITY)) { sApplicationConnection = null } } if (sApplicationConnection != null) { serviceStatus = LauncherClientProxyConnection.SERVICE_CONNECTING if (!connectSafely(context, serviceConnection, Context.BIND_ADJUST_WITH_ACTIVITY)) { serviceStatus = LauncherClientProxyConnection.SERVICE_DISCONNECTED } else { serviceConnected = true } } if (serviceStatus == 0) { proxyCallback.overlayStatusChanged(0) } } catch (e: Exception) { Log.d(TAG, "error reconnecting", e) } return serviceStatus } private fun connectSafely(context: Context, conn: ServiceConnection, flags: Int): Boolean { try { return context.bindService(serviceIntent, conn, flags or Context.BIND_AUTO_CREATE) } catch (e: SecurityException) { Log.e("DrawerOverlayClient", "Unable to connect to overlay service") return false } } override fun closeOverlay(options: Int) { enforcePermission() overlay?.closeOverlay(options) } override fun endScroll() { enforcePermission() overlay?.endScroll() } override fun onPause() { enforcePermission() overlay?.onPause() } override fun onResume() { enforcePermission() overlay?.onResume() } override fun onScroll(progress: Float) { enforcePermission() overlay?.onScroll(progress) } override fun openOverlay(options: Int) { enforcePermission() overlay?.openOverlay(options) } override fun startScroll() { enforcePermission() overlay?.startScroll() } override fun windowAttached(attrs: WindowLayoutParams, options: Int) { enforcePermission() overlay?.windowAttached(attrs.layoutParams, overlayCallbacks, options) } override fun windowAttached2(bundle: Bundle) { enforcePermission() overlay?.windowAttached2(bundle, overlayCallbacks) } override fun setActivityState(activityState: Int) { enforcePermission() overlay?.setActivityState(activityState) } override fun windowDetached(isChangingConfigurations: Boolean) { enforcePermission() overlay?.windowDetached(isChangingConfigurations) } override fun onQsbClick(intent: Intent?) { enforcePermission() context.sendOrderedBroadcast(intent, null, object : BroadcastReceiver() { @Suppress("NAME_SHADOWING") override fun onReceive(context: Context?, intent: Intent?) { proxyCallback.onQsbResult(resultCode) } }, null, 0, null, null) } override fun requestVoiceDetection(start: Boolean) { enforcePermission() overlay?.requestVoiceDetection(start) } override fun hasOverlayContent(): Boolean { enforcePermission() return overlay?.hasOverlayContent() ?: false } override fun startSearch(data: ByteArray?, bundle: Bundle?): Boolean { enforcePermission() return overlay?.startSearch(data, bundle) ?: false } override fun isVoiceDetectionRunning(): Boolean { enforcePermission() return overlay?.isVoiceDetectionRunning ?: false } override fun getVoiceSearchLanguage(): String { enforcePermission() return overlay?.voiceSearchLanguage ?: "" } override fun init(callback: ILauncherClientProxyCallback): Int { allowed = callingPackage in TransactProxy.allowedPackages enforcePermission() proxyCallback = callback Updater.checkUpdate(context) ProxyImpl.getVersion(context) return version } private val callingPackage get() = context.packageManager.getNameForUid(Binder.getCallingUid()) fun enforcePermission() { if (!allowed) throw SecurityException("$callingPackage is not allowed to call this service") } inner class OverlayCallbacks : ILauncherOverlayCallback.Stub() { override fun overlayScrollChanged(progress: Float) { if (!destroyed) proxyCallback.overlayScrollChanged(progress) } override fun overlayStatusChanged(status: Int) { if (!destroyed) proxyCallback.overlayStatusChanged(status) } } internal inner class AppServiceConnection(val packageName: String) : ServiceConnection { override fun onServiceConnected(name: ComponentName, service: IBinder) { } override fun onServiceDisconnected(name: ComponentName) { if (name.packageName == packageName) { sApplicationConnection = null } } } private inner class OverlayServiceConnection : ServiceConnection { override fun onServiceConnected(name: ComponentName, service: IBinder) { overlay = ILauncherOverlay.Stub.asInterface(service) serviceStatus = LauncherClientProxyConnection.SERVICE_CONNECTED proxyCallback.onServiceConnected() } override fun onServiceDisconnected(name: ComponentName) { overlay = null serviceStatus = LauncherClientProxyConnection.SERVICE_DISCONNECTED proxyCallback.onServiceDisconnected() } } companion object { const val TAG = "ProxyImpl" private var sApplicationConnection: AppServiceConnection? = null private var version = -1 internal fun getServiceIntent(context: Context): Intent { val uri = Uri.parse("app://${context.packageName}:${Process.myUid()}").buildUpon() .appendQueryParameter("v", Integer.toString(5)) .build() return Intent("com.android.launcher3.WINDOW_OVERLAY") .setPackage("com.google.android.googlequicksearchbox") .setData(uri) } private fun getVersion(context: Context) { val resolveService = context.packageManager.resolveService(getServiceIntent(context), PackageManager.GET_META_DATA) version = resolveService?.serviceInfo?.metaData?.getInt("service.api.version", 1) ?: 1 Log.v("LauncherClient", "version: $version") } } fun onUnbind() { Log.d(TAG, "onUnbind") destroyed = true if (serviceConnected) context.unbindService(serviceConnection) if (sApplicationConnection != null) context.unbindService(sApplicationConnection) sApplicationConnection = null } } ================================================ FILE: app/src/main/java/app/lawnchair/lawnfeed/bridge/TransactProxy.kt ================================================ package app.lawnchair.lawnfeed.bridge import android.content.Context import android.content.pm.PackageManager.PERMISSION_GRANTED import android.os.Binder import android.os.IBinder import android.os.Parcel import app.lawnchair.lawnfeed.Manifest class TransactProxy(private val target: IBinder, private val context: Context) : Binder() { private val permissionGranted by lazy { context.checkCallingPermission(Manifest.permission.CONNECT_SERVICE) == PERMISSION_GRANTED } private val allowed by lazy { permissionGranted || callingPackage in allowedPackages } private val callingPackage get() = context.packageManager.getNameForUid(getCallingUid()) override fun onTransact(code: Int, data: Parcel, reply: Parcel?, flags: Int): Boolean { enforcePermission() return target.transact(code, data, reply, flags) } private fun enforcePermission() { if (!allowed) throw SecurityException("$callingPackage is not allowed to call this service") } companion object { @JvmStatic val allowedPackages = setOf( "app.lawnchair", "app.lawnchair.play", "app.lawnchair.nightly", "ch.deletescape.lawnchair.plah", "ch.deletescape.lawnchair" ) } } ================================================ FILE: app/src/main/java/app/lawnchair/lawnfeed/receivers/DownloadReceiver.java ================================================ package app.lawnchair.lawnfeed.receivers; import android.app.DownloadManager; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.database.Cursor; import android.net.Uri; import android.widget.Toast; import app.lawnchair.lawnfeed.R; public abstract class DownloadReceiver extends BroadcastReceiver { public String mFilename; private long downloadId; @Override public void onReceive(Context context, Intent intent) { DownloadManager downloadManager = (DownloadManager) context.getSystemService(Context.DOWNLOAD_SERVICE); String action = intent.getAction(); // We only want to check if the download has completed if (DownloadManager.ACTION_DOWNLOAD_COMPLETE.equals(action)) { DownloadManager.Query query = new DownloadManager.Query(); query.setFilterById(downloadId); // Check if file has been successfully downloaded Cursor c = downloadManager.query(query); if (c.moveToFirst()) { int columnIndex = c.getColumnIndex(DownloadManager.COLUMN_STATUS); int status = c.getInt(columnIndex); switch (status) { // If everything is fine, call the abstract method and proceed case DownloadManager.STATUS_SUCCESSFUL: Uri uri = Uri.parse(c.getString(c.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI))); onDownloadDone(uri); break; // Otherwise tell the user that the download failed default: Toast.makeText(context, R.string.download_file_error, Toast.LENGTH_LONG); break; } context.unregisterReceiver(this); } // Close any open resources c.close(); } } public void setDownloadId(long id) { this.downloadId = id; } public void setFilename(String filename) { this.mFilename = filename; } public abstract void onDownloadDone(Uri uri); } ================================================ FILE: app/src/main/java/app/lawnchair/lawnfeed/receivers/UpdateReceiver.java ================================================ package app.lawnchair.lawnfeed.receivers; import android.Manifest; import android.app.DownloadManager; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.net.Uri; import android.os.Build; import android.os.Environment; import android.util.Log; import android.widget.Toast; import androidx.core.content.FileProvider; import java.io.File; import app.lawnchair.lawnfeed.PermissionActivity; import app.lawnchair.lawnfeed.PermissionActivity.*; import app.lawnchair.lawnfeed.R; public class UpdateReceiver extends BroadcastReceiver { @Override public void onReceive(final Context context, final Intent intent) { // Get our download link and setup receiver to install apk after download final String link = intent.getStringExtra("downloadLink"); final DownloadReceiver receiver = new DownloadReceiver() { @Override public void onDownloadDone(Uri uri) { // Seems like Android has changed the way to open package manager on Nougat and higher if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { // Open package installer and install downloaded apk file Intent install = new Intent(Intent.ACTION_INSTALL_PACKAGE); install.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); // Pass downloaded file uri to our install intent Uri content = FileProvider.getUriForFile(context, context.getPackageName() + ".provider", new File(uri.getPath())); install.setData(content); context.startActivity(install); } else { // The old way before Nougat Intent install = new Intent(Intent.ACTION_VIEW); install.setDataAndType(uri, "application/vnd.android.package-archive"); install.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); context.startActivity(install); } } }; // Request permissions and run asynchronously PermissionActivity.callAsync(context, new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, PermissionActivity.REQUEST_CODE, new PermissionResultCallback() { @Override public void onComplete(PermissionResponse response) { // Don't continue if permissing aren't granted if (!response.isGranted()) { Log.e("Updater", "No permissions granted!"); return; } String filename = intent.getStringExtra("filename"); // Check if our dir exists (theoretically it should, but you never know) File outputDir = new File(Environment.getExternalStorageDirectory(), "Download"); if (!outputDir.exists()) { outputDir.mkdir(); } File file = new File(outputDir, filename); // Call onDownloadDone if file already exists (not installed due to security settings, etc.) if (file.exists()) { receiver.onDownloadDone(Uri.parse(file.getAbsolutePath())); return; } // Start downloading Toast.makeText(context, R.string.downloading_toast, Toast.LENGTH_LONG).show(); DownloadManager downloadManager = (DownloadManager) context.getSystemService(Context.DOWNLOAD_SERVICE); if (link != null) { DownloadManager.Request request = new DownloadManager.Request(Uri.parse(link)); request.setDestinationUri(Uri.fromFile(file)); receiver.setDownloadId(downloadManager.enqueue(request)); } // Register our download receiver receiver.setFilename(filename); context.getApplicationContext().registerReceiver(receiver, new IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)); } } ); } } ================================================ FILE: app/src/main/java/app/lawnchair/lawnfeed/updater/Updater.java ================================================ package app.lawnchair.lawnfeed.updater; import android.app.Activity; import android.app.NotificationChannel; import android.app.NotificationManager; import android.app.PendingIntent; import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.content.pm.PackageManager; import android.media.RingtoneManager; import android.net.ConnectivityManager; import android.net.NetworkInfo; import android.os.Build; import android.util.Log; import androidx.core.app.NotificationCompat; import org.json.simple.JSONObject; import org.json.simple.parser.JSONParser; import org.json.simple.parser.ParseException; import java.io.IOException; import java.net.MalformedURLException; import java.net.URL; import app.lawnchair.lawnfeed.receivers.UpdateReceiver; import app.lawnchair.lawnfeed.R; public class Updater { public static final String VERSION_URL = "https://storage.codebucket.de/lawnchair/version.json"; public static final String DOWNLOAD_URL = "https://storage.codebucket.de/lawnchair/%1$s/Lawnfeed-%1$s.apk"; private static final String PREFERENCES_NAME = "updater"; public static final String PREFERENCES_LAST_CHECKED = "last_checked"; public static final String PREFERENCES_CACHED_UPDATE = "cached_update"; private static final long TWELVE_HOURS = 43200000; private static final String TAG = "Updater"; public static final String CHANNEL_ID = "lawnfeed_updater"; public static void checkUpdate(final Context context) { final SharedPreferences prefs = context.getSharedPreferences(PREFERENCES_NAME, Activity.MODE_PRIVATE); // Create notification channel on Android O if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { NotificationChannel channel = new NotificationChannel(CHANNEL_ID, context.getResources().getString(R.string.lawnfeed_updates), NotificationManager.IMPORTANCE_DEFAULT); context.getSystemService(NotificationManager.class).createNotificationChannel(channel); } UpdaterTask task = new UpdaterTask(context, VERSION_URL, new UpdateListener() { @Override public void onSuccess(Update update) { // Don't notify the user if he is running newer version than the latest if (getBuildNumber(context) >= update.getBuildNumber()) { Log.e(TAG, update.getBuildNumber() + " is lower than " + getBuildNumber(context) + "?"); return; } // We need our url as String for the Intent String url = update.getDownloadUrl().toString(); // Intent for download task Intent intentAction = new Intent(context, UpdateReceiver.class); intentAction.putExtra("downloadLink", url); intentAction.putExtra("filename", url.substring(url.lastIndexOf('/') + 1, url.length())); // Build notification PendingIntent pendingIntent = PendingIntent.getBroadcast(context,0, intentAction, PendingIntent.FLAG_UPDATE_CURRENT); NotificationCompat.Builder builder = new NotificationCompat.Builder(context, CHANNEL_ID) .setContentTitle(context.getResources().getString(R.string.update_available_title)) .setContentText(context.getResources().getString(R.string.update_available)) .setSmallIcon(R.drawable.ic_lawnchair) .setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)) .setVibrate(new long[]{0, 100, 100, 100}) .setAutoCancel(true) .setContentIntent(pendingIntent); NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); notificationManager.notify(0, builder.build()); // Cache update if (!update.isCached()) { prefs.edit() .putLong(PREFERENCES_LAST_CHECKED, System.currentTimeMillis()) .putString(PREFERENCES_CACHED_UPDATE, update.toString()) .apply(); } } @Override public void onError(UpdateError error) { Log.e(TAG, error.toString()); } }); // Don't check for updates if last update check was not longer than 6 hours ago long lastChecked = prefs.getLong(PREFERENCES_LAST_CHECKED, 0); if (lastChecked + TWELVE_HOURS >= System.currentTimeMillis()) { Log.i(TAG, "Last update check was earlier than 12 hours ago, using cached info"); task.onPostExecute(Update.fromString(prefs.getString(PREFERENCES_CACHED_UPDATE, ""))); return; } Log.i(TAG, "Checking for new updates"); // Run updater task in background task.execute(); } // Get current app build number from versionCode public static int getBuildNumber(Context context) { try { return context.getPackageManager().getPackageInfo(context.getPackageName(), 0).versionCode; } catch (PackageManager.NameNotFoundException ex) {} return 0; } // Check if String is a valid URL public static boolean isValidUrl(String url) { try { new URL(url); return true; } catch (MalformedURLException ignored) {} return false; } // Check if device have a network connection public static boolean isNetworkConnectivity(Context context) { ConnectivityManager cm = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); if (cm != null) { NetworkInfo networkInfo = cm.getActiveNetworkInfo(); if (networkInfo != null) { return networkInfo.isConnected(); } } return false; } public static class Update { private int buildNumber; private URL download; private boolean cached; public Update(int buildNumber, URL download) { this(buildNumber, download, false); } public Update(int buildNumber, URL download, boolean cached) { this.buildNumber = buildNumber; this.download = download; this.cached = cached; } public Integer getBuildNumber() { return buildNumber; } public URL getDownloadUrl() { return download; } public boolean isCached() { return cached; } @Override public String toString() { // Create a new json object JSONObject obj = new JSONObject(); obj.put("buildNumber", buildNumber); obj.put("download", download.toString()); // Return parsed json to string return obj.toJSONString(); } public static Update fromString(String json) { try { // Read json string JSONObject obj = (JSONObject) new JSONParser().parse(json); int buildNumber = ((Long) obj.get("buildNumber")).intValue(); URL download = new URL((String) obj.get("download")); // Return cached update from json return new Update(buildNumber, download, true); } catch (IOException | ParseException ex) { Log.e(TAG, "Invalid JSON object: " + json); } // Shouldn't be returned, but it may happen return new Update(0, null, true); } } public enum UpdateError { // No internet connection available NETWORK_NOT_AVAILABLE, // URL for version info is not valid VERSION_URL_MALFORMED, // Version info is invalid or unreachable VERSION_ERROR, // Download URL for update is not valid INVALID_DOWNLOAD_URL } public interface UpdateListener { void onSuccess(Update update); void onError(UpdateError error); } } ================================================ FILE: app/src/main/java/app/lawnchair/lawnfeed/updater/UpdaterTask.java ================================================ package app.lawnchair.lawnfeed.updater; import android.content.Context; import android.os.AsyncTask; import org.json.simple.JSONObject; import org.json.simple.parser.JSONParser; import org.json.simple.parser.ParseException; import java.io.IOException; import java.io.InputStreamReader; import java.net.HttpURLConnection; import java.net.MalformedURLException; import java.net.URL; public class UpdaterTask extends AsyncTask { private Context context; private String update; private Updater.UpdateListener listener; public UpdaterTask(Context context, String update, Updater.UpdateListener listener) { this.context = context; this.update = update; this.listener = listener; } @Override protected void onPreExecute() { super.onPreExecute(); // No listener = no actions if (listener == null) { cancel(true); return; } // Check if device is connected to the Internet if (!Updater.isNetworkConnectivity(context)) { listener.onError(Updater.UpdateError.NETWORK_NOT_AVAILABLE); cancel(true); return; } // Check if URL is valid if (!Updater.isValidUrl(update)) { listener.onError(Updater.UpdateError.VERSION_URL_MALFORMED); cancel(true); return; } } @Override protected Updater.Update doInBackground(Void... voids) { // Our version.json from the storage server JSONObject json = null; try { // Retrieve json object from URL URL url = new URL(update); HttpURLConnection connection = (HttpURLConnection) url.openConnection(); json = (JSONObject) new JSONParser().parse(new InputStreamReader(connection.getInputStream())); } catch (IOException | ParseException ex) { // Throw error if listener is not null if (listener != null) { listener.onError(Updater.UpdateError.VERSION_ERROR); } // Of course cancel the task cancel(true); return null; } // Don't continue if the json doesn't contain the last build number if (!json.containsKey("travis_build_number")) { return null; } int buildNumber = Integer.valueOf((String) json.get("travis_build_number")); String version = (String) json.get("app_version"); String downloadUrl = String.format(Updater.DOWNLOAD_URL, version); URL download = null; try { download = new URL(downloadUrl); } catch (MalformedURLException ex) { // Throw error if listener is not null if (listener != null) { listener.onError(Updater.UpdateError.INVALID_DOWNLOAD_URL); } // Of course cancel the task cancel(true); return null; } return new Updater.Update(buildNumber, download); } @Override protected void onPostExecute(Updater.Update update) { super.onPostExecute(update); // Return fetched update to listener if (listener != null) { listener.onSuccess(update); } } } ================================================ FILE: app/src/main/res/drawable/ic_lawnchair.xml ================================================ ================================================ FILE: app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml ================================================ ================================================ FILE: app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml ================================================ ================================================ FILE: app/src/main/res/values/colors.xml ================================================ #3F51B5 #303F9F #FF4081 ================================================ FILE: app/src/main/res/values/strings.xml ================================================ Lawnfeed Update available A new version of Lawnfeed is available to download! Downloading... Error downloading file Lawnfeed Updates ================================================ FILE: app/src/main/res/values/styles.xml ================================================ ================================================ FILE: app/src/main/res/xml/file_paths.xml ================================================ ================================================ FILE: build.gradle ================================================ // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { ext.kotlin_version = '1.2.30' repositories { google() jcenter() } dependencies { classpath 'com.android.tools.build:gradle:3.1.3' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } allprojects { repositories { google() jcenter() maven { url 'https://jitpack.io' } } } task clean(type: Delete) { delete rootProject.buildDir } ================================================ FILE: gradle/wrapper/gradle-wrapper.properties ================================================ #Sat Mar 27 07:42:52 CET 2021 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-4.8-all.zip ================================================ FILE: gradle.properties ================================================ # Project-wide Gradle settings. # IDE (e.g. Android Studio) users: # Gradle settings configured through the IDE *will override* # any settings specified in this file. # For more details on how to configure your build environment visit # http://www.gradle.org/docs/current/userguide/build_environment.html # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. org.gradle.jvmargs=-Xmx1536m # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects # org.gradle.parallel=true ================================================ FILE: gradlew ================================================ #!/usr/bin/env sh ############################################################################## ## ## Gradle start up script for UN*X ## ############################################################################## # Attempt to set APP_HOME # Resolve links: $0 may be a link PRG="$0" # Need this for relative symlinks. while [ -h "$PRG" ] ; do ls=`ls -ld "$PRG"` link=`expr "$ls" : '.*-> \(.*\)$'` if expr "$link" : '/.*' > /dev/null; then PRG="$link" else PRG=`dirname "$PRG"`"/$link" fi done SAVED="`pwd`" cd "`dirname \"$PRG\"`/" >/dev/null APP_HOME="`pwd -P`" cd "$SAVED" >/dev/null APP_NAME="Gradle" APP_BASE_NAME=`basename "$0"` # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS="" # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD="maximum" warn () { echo "$*" } die () { echo echo "$*" echo exit 1 } # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false case "`uname`" in CYGWIN* ) cygwin=true ;; Darwin* ) darwin=true ;; MINGW* ) msys=true ;; NONSTOP* ) nonstop=true ;; esac CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables JAVACMD="$JAVA_HOME/jre/sh/java" else JAVACMD="$JAVA_HOME/bin/java" fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else JAVACMD="java" which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi # Increase the maximum file descriptors if we can. if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then MAX_FD_LIMIT=`ulimit -H -n` if [ $? -eq 0 ] ; then if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then MAX_FD="$MAX_FD_LIMIT" fi ulimit -n $MAX_FD if [ $? -ne 0 ] ; then warn "Could not set maximum file descriptor limit: $MAX_FD" fi else warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" fi fi # For Darwin, add options to specify how the application appears in the dock if $darwin; then GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" fi # For Cygwin, switch paths to Windows format before running java if $cygwin ; then APP_HOME=`cygpath --path --mixed "$APP_HOME"` CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` JAVACMD=`cygpath --unix "$JAVACMD"` # We build the pattern for arguments to be converted via cygpath ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` SEP="" for dir in $ROOTDIRSRAW ; do ROOTDIRS="$ROOTDIRS$SEP$dir" SEP="|" done OURCYGPATTERN="(^($ROOTDIRS))" # Add a user-defined pattern to the cygpath arguments if [ "$GRADLE_CYGPATTERN" != "" ] ; then OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" fi # Now convert the arguments - kludge to limit ourselves to /bin/sh i=0 for arg in "$@" ; do CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` else eval `echo args$i`="\"$arg\"" fi i=$((i+1)) done case $i in (0) set -- ;; (1) set -- "$args0" ;; (2) set -- "$args0" "$args1" ;; (3) set -- "$args0" "$args1" "$args2" ;; (4) set -- "$args0" "$args1" "$args2" "$args3" ;; (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; esac fi # Escape application args save () { for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done echo " " } APP_ARGS=$(save "$@") # Collect all arguments for the java command, following the shell quoting and substitution rules eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then cd "$(dirname "$0")" fi exec "$JAVACMD" "$@" ================================================ FILE: gradlew.bat ================================================ @if "%DEBUG%" == "" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @rem @rem ########################################################################## @rem Set local scope for the variables with windows NT shell if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 if "%DIRNAME%" == "" set DIRNAME=. set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. set DEFAULT_JVM_OPTS= @rem Find java.exe if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if "%ERRORLEVEL%" == "0" goto init echo. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. echo. echo Please set the JAVA_HOME variable in your environment to match the echo location of your Java installation. goto fail :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto init echo. echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% echo. echo Please set the JAVA_HOME variable in your environment to match the echo location of your Java installation. goto fail :init @rem Get command-line arguments, handling Windows variants if not "%OS%" == "Windows_NT" goto win9xME_args :win9xME_args @rem Slurp the command line arguments. set CMD_LINE_ARGS= set _SKIP=2 :win9xME_args_slurp if "x%~1" == "x" goto execute set CMD_LINE_ARGS=%* :execute @rem Setup the command line set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar @rem Execute Gradle "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% :end @rem End local scope for the variables with windows NT shell if "%ERRORLEVEL%"=="0" goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 exit /b 1 :mainEnd if "%OS%"=="Windows_NT" endlocal :omega ================================================ FILE: settings.gradle ================================================ include ':app', ':launcherclient'