From 708b08f6eb76572d1c084aa0e9b27c7506a54c75 Mon Sep 17 00:00:00 2001 From: fknives Date: Sat, 12 Sep 2020 03:22:09 +0300 Subject: [PATCH] initial --- .gitignore | 87 +++++++++ .idea/codeStyles/Project.xml | 138 ++++++++++++++ .idea/codeStyles/codeStyleConfig.xml | 5 + .idea/jarRepositories.xml | 25 +++ .idea/misc.xml | 9 + .idea/runConfigurations.xml | 12 ++ README.md | 26 ++- app/.gitignore | 1 + app/build.gradle | 62 +++++++ app/proguard-rules.pro | 21 +++ app/src/main/AndroidManifest.xml | 46 +++++ .../fknives/rstocklist/BindingViewHolder.kt | 16 ++ .../org/fknives/rstocklist/FileManager.kt | 49 +++++ .../org/fknives/rstocklist/MainActivity.kt | 69 +++++++ .../fknives/rstocklist/NotificationService.kt | 84 +++++++++ .../org/fknives/rstocklist/TickerAdapter.kt | 32 ++++ .../rstocklist/TickersWithLastLoadedTime.kt | 3 + .../fknives/rstocklist/appsync/ParseTicker.kt | 34 ++++ .../fknives/rstocklist/appsync/SyncService.kt | 85 +++++++++ .../appsync/TraverseRecyclerView.kt | 98 ++++++++++ .../drawable-v24/ic_launcher_foreground.xml | 31 ++++ .../res/drawable/ic_launcher_background.xml | 171 +++++++++++++++++ app/src/main/res/layout/activity_main.xml | 60 ++++++ app/src/main/res/layout/item_ticker.xml | 5 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 6 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 6 + app/src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 3593 bytes .../res/mipmap-hdpi/ic_launcher_round.png | Bin 0 -> 5339 bytes app/src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 2636 bytes .../res/mipmap-mdpi/ic_launcher_round.png | Bin 0 -> 3388 bytes app/src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 4926 bytes .../res/mipmap-xhdpi/ic_launcher_round.png | Bin 0 -> 7472 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 7909 bytes .../res/mipmap-xxhdpi/ic_launcher_round.png | Bin 0 -> 11873 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 10652 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.png | Bin 0 -> 16570 bytes app/src/main/res/values/colors.xml | 6 + app/src/main/res/values/dimens.xml | 5 + app/src/main/res/values/strings.xml | 18 ++ app/src/main/res/values/styles.xml | 10 + app/src/main/res/xml/provider_paths.xml | 3 + app/src/main/res/xml/serviceconfig.xml | 7 + build.gradle | 26 +++ gradle.properties | 21 +++ gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 54329 bytes gradle/wrapper/gradle-wrapper.properties | 6 + gradlew | 172 ++++++++++++++++++ gradlew.bat | 84 +++++++++ settings.gradle | 2 + stock_list_1599869417144.csv | 1 + 50 files changed, 1540 insertions(+), 2 deletions(-) create mode 100644 .gitignore create mode 100644 .idea/codeStyles/Project.xml create mode 100644 .idea/codeStyles/codeStyleConfig.xml create mode 100644 .idea/jarRepositories.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/runConfigurations.xml create mode 100644 app/.gitignore create mode 100644 app/build.gradle create mode 100644 app/proguard-rules.pro create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/java/org/fknives/rstocklist/BindingViewHolder.kt create mode 100644 app/src/main/java/org/fknives/rstocklist/FileManager.kt create mode 100644 app/src/main/java/org/fknives/rstocklist/MainActivity.kt create mode 100644 app/src/main/java/org/fknives/rstocklist/NotificationService.kt create mode 100644 app/src/main/java/org/fknives/rstocklist/TickerAdapter.kt create mode 100644 app/src/main/java/org/fknives/rstocklist/TickersWithLastLoadedTime.kt create mode 100644 app/src/main/java/org/fknives/rstocklist/appsync/ParseTicker.kt create mode 100644 app/src/main/java/org/fknives/rstocklist/appsync/SyncService.kt create mode 100644 app/src/main/java/org/fknives/rstocklist/appsync/TraverseRecyclerView.kt create mode 100644 app/src/main/res/drawable-v24/ic_launcher_foreground.xml create mode 100644 app/src/main/res/drawable/ic_launcher_background.xml create mode 100644 app/src/main/res/layout/activity_main.xml create mode 100644 app/src/main/res/layout/item_ticker.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png create mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/main/res/values/dimens.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/styles.xml create mode 100644 app/src/main/res/xml/provider_paths.xml create mode 100644 app/src/main/res/xml/serviceconfig.xml create mode 100644 build.gradle create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle create mode 100644 stock_list_1599869417144.csv diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fbcf181 --- /dev/null +++ b/.gitignore @@ -0,0 +1,87 @@ +# Built application files +*.aar +*.ap_ +*.aab + +# Files for the ART/Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +bin/ +gen/ +out/ +# Uncomment the following line in case you need and you don't have the release build type files in your app +# release/ + +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Proguard folder generated by Eclipse +proguard/ + +# Log Files +*.log + +# Android Studio Navigation editor temp files +.navigation/ + +# Android Studio captures folder +captures/ + +# IntelliJ +*.iml +.idea/workspace.xml +.idea/tasks.xml +.idea/gradle.xml +.idea/assetWizardSettings.xml +.idea/dictionaries +.idea/libraries +# Android Studio 3 in .gitignore file. +.idea/caches +.idea/modules.xml +# Comment next line if keeping position of elements in Navigation Editor is relevant for you +.idea/navEditor.xml + +# Keystore files +# Uncomment the following lines if you do not want to check your keystore files in. +#*.jks +#*.keystore + +# External native build folder generated in Android Studio 2.2 and later +.externalNativeBuild +.cxx/ + +# Google Services (e.g. APIs or Firebase) +# google-services.json + +# Freeline +freeline.py +freeline/ +freeline_project_description.json + +# fastlane +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots +fastlane/test_output +fastlane/readme.md + +# Version control +vcs.xml + +# lint +lint/intermediates/ +lint/generated/ +lint/outputs/ +lint/tmp/ +# lint/reports/ + +# Android Profiling +*.hprof diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 0000000..3cc336b --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,138 @@ + + + + + + + + + + + +
+ + + + xmlns:android + + ^$ + + + +
+
+ + + + xmlns:.* + + ^$ + + + BY_NAME + +
+
+ + + + .*:id + + http://schemas.android.com/apk/res/android + + + +
+
+ + + + .*:name + + http://schemas.android.com/apk/res/android + + + +
+
+ + + + name + + ^$ + + + +
+
+ + + + style + + ^$ + + + +
+
+ + + + .* + + ^$ + + + BY_NAME + +
+
+ + + + .* + + http://schemas.android.com/apk/res/android + + + ANDROID_ATTRIBUTE_ORDER + +
+
+ + + + .* + + .* + + + BY_NAME + +
+
+
+
+ + +
+
\ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000..79ee123 --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/jarRepositories.xml b/.idea/jarRepositories.xml new file mode 100644 index 0000000..a5f05cd --- /dev/null +++ b/.idea/jarRepositories.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..7bfef59 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml new file mode 100644 index 0000000..7f68460 --- /dev/null +++ b/.idea/runConfigurations.xml @@ -0,0 +1,12 @@ + + + + + + \ No newline at end of file diff --git a/README.md b/README.md index c9085f4..54f0909 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,24 @@ -# Revolut-Stock-List-Extract-Android -Simple App to extract the list of ticker symbols from Revolut +# Stock-List-Extract-Android +Simple App to extract the list of ticker symbols from R + +## IF you need only the file: last updated at 2020.09.11 +file: [stock_list_1599869417144.csv](https://github.com/fknives/Stock-List-Extract-Android/blob/dev/stock_list_1599869417144.csv) + +## Usage: +- install the application on your phone +- Start the application +- Click on Start Service +- Enable Accessibility Service +- Click on Start Service again +- Notice a notification is shown +- Navigate to R All Stocks List +- Tap on the Notification +- Notice the app starts to load from the screen (the notification is updated) +- Wait until the notification disappears and the screen is scrolled until the end +- Go back to this app +- Notice it now shows all the tickers at the bottom and you can send the file to somewhere else + +## Notes: +- the app does not have internet permission, so you don't have to worry that something is leaving your device that you do not want to +- after finishing the sync the AccessibilityService is disabled (at least above api 24, if you have a solution below please let me know) +- the file format is CSV meaning the tickers are separated by comma, easy to include into google sheets or other programs. \ No newline at end of file diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..fce1a0d --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,62 @@ +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply plugin: 'kotlin-android-extensions' +apply from: "./config.gradle" + +android { + compileSdkVersion 29 + + defaultConfig { + buildConfigField "String", "CONFIG_COMPANY_IMG_ID", "\"$COMPANY_IMG_ID\"" + buildConfigField "String", "CONFIG_COMPANY_NAME_ID", "\"$COMPANY_NAME_ID\"" + buildConfigField "String", "CONFIG_COMPANY_TICKER_ID", "\"$COMPANY_TICKER_ID\"" + buildConfigField "String", "CONFIG_COMPANY_SHARE_PRICE_ID", "\"$COMPANY_SHARE_PRICE_ID\"" + buildConfigField "String", "CONFIG_COMPANY_CHANGE_PERCENT_ID", "\"$COMPANY_CHANGE_PERCENT_ID\"" + buildConfigField "String", "CONFIG_RECYCLER_ID1", "\"$RECYCLER_ID1\"" + buildConfigField "String", "CONFIG_RECYCLER_ID2", "\"$RECYCLER_ID2\"" + applicationId "org.fknives.rstocklist" + minSdkVersion 21 + targetSdkVersion 29 + versionCode 1 + versionName "1.0" + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_1_8 + } + + buildFeatures { + viewBinding true + } +} + +dependencies { + implementation fileTree(dir: "libs", include: ["*.jar"]) + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.9" + implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.2.0" + implementation "androidx.core:core-ktx:1.3.1" + implementation "androidx.appcompat:appcompat:1.2.0" + implementation "androidx.constraintlayout:constraintlayout:2.0.1" + implementation "androidx.recyclerview:recyclerview:1.1.0" + implementation "com.google.android.material:material:1.2.1" + + testImplementation "junit:junit:4.13" + androidTestImplementation "androidx.test.ext:junit:1.1.2" + androidTestImplementation "androidx.test.espresso:espresso-core:3.3.0" + +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# 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 \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..3dc7cc9 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/org/fknives/rstocklist/BindingViewHolder.kt b/app/src/main/java/org/fknives/rstocklist/BindingViewHolder.kt new file mode 100644 index 0000000..5dbfc2a --- /dev/null +++ b/app/src/main/java/org/fknives/rstocklist/BindingViewHolder.kt @@ -0,0 +1,16 @@ +package org.fknives.rstocklist + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import androidx.viewbinding.ViewBinding + +class BindingViewHolder( + val binding: Binding +) : RecyclerView.ViewHolder(binding.root) { + + constructor( + parent: ViewGroup, + howToBind: (LayoutInflater, ViewGroup, Boolean) -> Binding + ) : this(howToBind(LayoutInflater.from(parent.context), parent, false)) +} \ No newline at end of file diff --git a/app/src/main/java/org/fknives/rstocklist/FileManager.kt b/app/src/main/java/org/fknives/rstocklist/FileManager.kt new file mode 100644 index 0000000..7747f4e --- /dev/null +++ b/app/src/main/java/org/fknives/rstocklist/FileManager.kt @@ -0,0 +1,49 @@ +package org.fknives.rstocklist + +import android.content.Context +import androidx.annotation.MainThread +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map +import java.io.File + +class FileManager private constructor(private val context: Context) { + + private val fileDir get() = context.cacheDir + private var last = 0 + private val stateFlow = MutableStateFlow(last) + val tickersWithLastLoadedAtFlow = stateFlow.map { loadTickersAndLastUpdatedAt() } + + fun saveTickers(tickers: List) { + context.cacheDir.listFiles()?.forEach { it.delete() } + val fileIntoSave = File(context.cacheDir, "$STATIC_PART_OF_FILENAME${System.currentTimeMillis()}$FILE_EXTENSION") + fileIntoSave.writeText(tickers.firstOrNull().orEmpty()) + tickers.drop(1).forEach { + fileIntoSave.appendText(",$it") + } + last = (last + 1) % 2 + stateFlow.value = last + } + + private fun loadTickersAndLastUpdatedAt(): Pair>? = + lastFile()?.let { + it.getTimestampFromFile() to it.readText().split(",") + } + + private fun File.getTimestampFromFile(): Long = + name.drop(STATIC_PART_OF_FILENAME.length).dropLast(FILE_EXTENSION.length).toLongOrNull() ?: 0L + + fun lastFile(): File? = + fileDir.listFiles() + ?.filter { it.name.contains(STATIC_PART_OF_FILENAME) } + ?.maxByOrNull { it.getTimestampFromFile() } + + companion object { + private const val STATIC_PART_OF_FILENAME = "stock_list_" + private const val FILE_EXTENSION = ".csv" + private var fileManager: FileManager? = null + + @MainThread + operator fun invoke(context: Context): FileManager = + fileManager ?: FileManager(context.applicationContext).also { fileManager = it } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/fknives/rstocklist/MainActivity.kt b/app/src/main/java/org/fknives/rstocklist/MainActivity.kt new file mode 100644 index 0000000..a0dd35a --- /dev/null +++ b/app/src/main/java/org/fknives/rstocklist/MainActivity.kt @@ -0,0 +1,69 @@ +package org.fknives.rstocklist + +import android.content.Intent +import android.os.Bundle +import android.provider.Settings +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.FileProvider +import androidx.core.view.isVisible +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.flow.collect +import org.fknives.rstocklist.appsync.SyncService +import org.fknives.rstocklist.databinding.ActivityMainBinding +import java.text.SimpleDateFormat + + +class MainActivity : AppCompatActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val binding = ActivityMainBinding.inflate(layoutInflater) + setContentView(binding.root) + val fileManager = FileManager(this) + + binding.startServiceCta.setOnClickListener { + if (SyncService.canStart()) { + startService(NotificationService.getStartIntent(this)) + } else { + startActivity( + Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + .addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY) + ) + } + } + + binding.shareFileCta.setOnClickListener { + val file = fileManager.lastFile() ?: return@setOnClickListener + val uri = FileProvider.getUriForFile( + this, + "org.fknives.rstocklist.fileprovider", + file + ) + val sharingIntent = Intent(Intent.ACTION_SEND) + sharingIntent.type = "text/*" + sharingIntent.putExtra(Intent.EXTRA_STREAM, uri) + startActivity(Intent.createChooser(sharingIntent, resources.getText(R.string.send_to))) + } + + val adapter = TickerAdapter() + binding.recycler.adapter = adapter + + lifecycleScope.launchWhenCreated { + fileManager.tickersWithLastLoadedAtFlow.collect { + binding.lastUpdatedAt.isVisible = it != null + binding.shareFileCta.isVisible = it != null + it?.first?.let(SimpleDateFormat("YYYY-MM-dd hh:mm")::format) + ?.let { date -> getString(R.string.file_last_updated_at, date) } + ?.let(binding.lastUpdatedAt::setText) + adapter.submitList(it?.second.orEmpty()) + } + } + } + + override fun onDestroy() { + super.onDestroy() + stopService(NotificationService.getStartIntent(this)) + SyncService.stop() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/fknives/rstocklist/NotificationService.kt b/app/src/main/java/org/fknives/rstocklist/NotificationService.kt new file mode 100644 index 0000000..02b9194 --- /dev/null +++ b/app/src/main/java/org/fknives/rstocklist/NotificationService.kt @@ -0,0 +1,84 @@ +package org.fknives.rstocklist + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.IBinder +import androidx.core.app.NotificationCompat +import org.fknives.rstocklist.appsync.SyncService + +class NotificationService : Service(), SyncService.EventListener { + + override fun onCreate() { + super.onCreate() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val name = getString(R.string.start_service_channel_title) + val importance = NotificationManager.IMPORTANCE_HIGH + val mChannel = NotificationChannel(NOTIFICATION_CHANNEL_ID, name, importance) + mChannel.description = name + mChannel.enableLights(true) + val manager = (getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager) + manager.createNotificationChannel(mChannel) + } + + updateNotification("Click Me When on Ticker List Screen!") + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + intent?.getBooleanExtra(SYNC_STARTED, false)?.takeIf { it }?.let { + updateNotification("Syncing ...") + SyncService.listener = this + SyncService.start() + } + intent?.getIntExtra(PROGRESS, -1)?.takeIf { it >= 0 }?.let { + updateNotification("Processed: $it, syncing ...") + } + return START_NOT_STICKY + } + + private fun updateNotification(text: String) { + val intent = Intent(this, NotificationService::class.java) + .putExtra(SYNC_STARTED, true) + val notification = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID) + .setSmallIcon(R.drawable.ic_launcher_foreground) + .setContentTitle("Start syncing on Stock List") + .setContentText(text) + .setContentIntent(PendingIntent.getService(this, 0, intent, 0)) + .build() + + startForeground(1, notification) + } + + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onItemProcessed(index: Int) { + updateNotification("Processed: ${index + 1}, syncing ...") + } + + override fun onItemProcessingFinished(items: List) { + FileManager(this).saveTickers(items) + updateNotification("Processed: ${items.size}.") + + stopSelf() + } + + override fun onDestroy() { + super.onDestroy() + SyncService.stop() + } + + companion object { + fun getStartIntent(context: Context): Intent = + Intent(context, NotificationService::class.java) + + const val NOTIFICATION_CHANNEL_ID = "START_SERVICE_ID" + const val SYNC_STARTED = "SYNC_STARTED" + const val PROGRESS = "PROGRESS" + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/fknives/rstocklist/TickerAdapter.kt b/app/src/main/java/org/fknives/rstocklist/TickerAdapter.kt new file mode 100644 index 0000000..26c3d6f --- /dev/null +++ b/app/src/main/java/org/fknives/rstocklist/TickerAdapter.kt @@ -0,0 +1,32 @@ +package org.fknives.rstocklist + +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import org.fknives.rstocklist.databinding.ItemTickerBinding + +class TickerAdapter : + ListAdapter>(StringDiffUtilItem()) { + + class StringDiffUtilItem : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: String, newItem: String): Boolean = + oldItem == newItem + + override fun areContentsTheSame(oldItem: String, newItem: String): Boolean = + true + + override fun getChangePayload(oldItem: String, newItem: String): Any? = + this + + } + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): BindingViewHolder = + BindingViewHolder(parent, ItemTickerBinding::inflate) + + override fun onBindViewHolder(holder: BindingViewHolder, position: Int) { + holder.binding.ticker.text = getItem(position) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/fknives/rstocklist/TickersWithLastLoadedTime.kt b/app/src/main/java/org/fknives/rstocklist/TickersWithLastLoadedTime.kt new file mode 100644 index 0000000..06930c6 --- /dev/null +++ b/app/src/main/java/org/fknives/rstocklist/TickersWithLastLoadedTime.kt @@ -0,0 +1,3 @@ +package org.fknives.rstocklist + +data class TickersWithLastLoadedTime(val tickers: List, val lastLoadedAt: Long) \ No newline at end of file diff --git a/app/src/main/java/org/fknives/rstocklist/appsync/ParseTicker.kt b/app/src/main/java/org/fknives/rstocklist/appsync/ParseTicker.kt new file mode 100644 index 0000000..394523e --- /dev/null +++ b/app/src/main/java/org/fknives/rstocklist/appsync/ParseTicker.kt @@ -0,0 +1,34 @@ +package org.fknives.rstocklist.appsync + +import android.view.accessibility.AccessibilityNodeInfo +import org.fknives.rstocklist.BuildConfig + +class ParseTicker { + + operator fun invoke(accessibilityNodeInfo: AccessibilityNodeInfo): String? = + if (accessibilityNodeInfo.isStockViewGroup()) { + accessibilityNodeInfo.findAccessibilityNodeInfosByViewId(BuildConfig.CONFIG_COMPANY_TICKER_ID) + .firstOrNull() + ?.text + ?.toString() + } else { + null + } + + private fun AccessibilityNodeInfo.isStockViewGroup(): Boolean = + companyIds.all { hasChildWithId(it) } + + companion object { + private val companyIds = listOf( + BuildConfig.CONFIG_COMPANY_IMG_ID, + BuildConfig.CONFIG_COMPANY_NAME_ID, + BuildConfig.CONFIG_COMPANY_TICKER_ID, + BuildConfig.CONFIG_COMPANY_SHARE_PRICE_ID, + BuildConfig.CONFIG_COMPANY_CHANGE_PERCENT_ID + ) + + + private fun AccessibilityNodeInfo.hasChildWithId(id: String): Boolean = + findAccessibilityNodeInfosByViewId(id)?.isNotEmpty() == true + } +} \ No newline at end of file diff --git a/app/src/main/java/org/fknives/rstocklist/appsync/SyncService.kt b/app/src/main/java/org/fknives/rstocklist/appsync/SyncService.kt new file mode 100644 index 0000000..240d54d --- /dev/null +++ b/app/src/main/java/org/fknives/rstocklist/appsync/SyncService.kt @@ -0,0 +1,85 @@ +package org.fknives.rstocklist.appsync + +import android.accessibilityservice.AccessibilityService +import android.view.accessibility.AccessibilityEvent +import android.view.accessibility.AccessibilityNodeInfo +import org.fknives.rstocklist.BuildConfig + +class SyncService : AccessibilityService() { + + private var traverseRecyclerView: TraverseRecyclerView? = null + private val parseTicker = ParseTicker() + + override fun onAccessibilityEvent(event: AccessibilityEvent?) { + if (syncState == SyncState.STOP){ + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) { + disableSelf() + } + return + } + synchronized(this) { + isStarted = true + if (syncState == SyncState.NOT_STARTED) return + if (syncState == SyncState.RESET) { + syncState = SyncState.WORKING + traverseRecyclerView = object : TraverseRecyclerView(0) { + val tickers = mutableListOf() + + override fun found(accessibilityNodeInfo: AccessibilityNodeInfo) { + parseTicker(accessibilityNodeInfo)?.let(tickers::add) + listener?.onItemProcessed(tickers.size - 1) + } + + override fun finished() { + syncState = SyncState.NOT_STARTED + listener?.onItemProcessingFinished(tickers) + } + + } + } + val recycler = findRecycler() ?: return + traverseRecyclerView?.next(recycler) + } + } + + override fun onInterrupt() { + isStarted = false + } + + private fun findRecycler() = + RECYCLER_VIEW_IDS.asSequence().mapNotNull { + rootInActiveWindow.findAccessibilityNodeInfosByViewId(it)?.firstOrNull() + }.firstOrNull() + + enum class SyncState { + RESET, WORKING, NOT_STARTED, STOP + } + + interface EventListener { + fun onItemProcessed(index: Int) + + fun onItemProcessingFinished(items: List) + } + + companion object { + + private val RECYCLER_VIEW_IDS = listOf( + BuildConfig.CONFIG_RECYCLER_ID1, + BuildConfig.CONFIG_RECYCLER_ID2 + ) + + private var isStarted: Boolean = false + private var syncState: SyncState = SyncState.NOT_STARTED + var listener : EventListener? = null + + fun start() { + syncState = SyncState.RESET + } + + fun stop() { + syncState = SyncState.STOP + } + + fun canStart(): Boolean = isStarted + } +} \ No newline at end of file diff --git a/app/src/main/java/org/fknives/rstocklist/appsync/TraverseRecyclerView.kt b/app/src/main/java/org/fknives/rstocklist/appsync/TraverseRecyclerView.kt new file mode 100644 index 0000000..47cb2bb --- /dev/null +++ b/app/src/main/java/org/fknives/rstocklist/appsync/TraverseRecyclerView.kt @@ -0,0 +1,98 @@ +package org.fknives.rstocklist.appsync + +import android.view.accessibility.AccessibilityNodeInfo + +abstract class TraverseRecyclerView(private var index: Int) { + + fun next(recyclerView: AccessibilityNodeInfo) { + val collectionRowCount = recyclerView.collectionInfo?.rowCount ?: return + if (collectionRowCount <= index) { + return finished() + } + + val found = recyclerView.children + .firstOrNull { it.getRefreshedFlattenIndex() == index } + when { + found == null + && recyclerView.getSmallestFlatIndexOfChildren(index) > index + && recyclerView.canScrollBackward() -> { + recyclerView.scrollBackward() + } + found == null && recyclerView.canScrollForward() -> { + recyclerView.scrollForward() + } + found == null && index == collectionRowCount - 1 -> { + finished() + } + found == null -> { + if (recyclerView.canScrollBackward()) { + recyclerView.scrollBackward() + } else { + finished() + } + } + else -> { + index++ + found(found) + } + } + } + + protected abstract fun found(accessibilityNodeInfo: AccessibilityNodeInfo) + + protected abstract fun finished() + + companion object { + + private fun AccessibilityNodeInfo.getRefreshedFlattenIndex(): Int? { + val maxColumnCount = parent.collectionInfo?.columnCount ?: return null + return apply { refresh() } + .collectionItemInfo + ?.getFlattenIndex(maxColumnCount) + } + + private fun AccessibilityNodeInfo.getSmallestFlatIndexOfChildren(default: Int): Int = + children.mapNotNull { it.getFlattenIndex() } + .minOrDefault(default) + + private val AccessibilityNodeInfo.children + get() = (0 until childCount).mapNotNull(::getChild) + + private fun AccessibilityNodeInfo.getFlattenIndex(): Int? { + val maxColumnCount = parent.collectionInfo?.columnCount ?: return null + return collectionItemInfo?.getFlattenIndex(maxColumnCount) + } + + private fun AccessibilityNodeInfo.CollectionItemInfo.getFlattenIndex(maxColumnCount: Int) = + rowIndex + + private fun > List.minOrDefault(default: T) = minOrNull() ?: default + + private fun AccessibilityNodeInfo.scrollBackward(delay: Long = 500): Boolean = + performActionThenDelayIfOk(delay) { + performAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_BACKWARD.id) + } + + private fun AccessibilityNodeInfo.scrollForward(delay: Long = 500): Boolean = + performActionThenDelayIfOk(delay) { + performAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_FORWARD.id) + } + + private inline fun performActionThenDelayIfOk(delay: Long, action: () -> Boolean): Boolean { + val result = action() + if (result) { + Thread.sleep(delay) + } + return result + } + + private fun AccessibilityNodeInfo.canScrollBackward(): Boolean = + canDo(AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_BACKWARD.id) + + private fun AccessibilityNodeInfo.canScrollForward(): Boolean = + canDo(AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_FORWARD.id) + + private fun AccessibilityNodeInfo.canDo(id: Int): Boolean = + actionList?.any { it.id == id } == true + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..a85b186 --- /dev/null +++ b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..3a13f53 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,171 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..1696255 --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,60 @@ + + + + + +